[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M70KXZNX9ES38N2A8M58ZZMZ":3,"skill-same:01M70KXZNX9ES38N2A8M58ZZMZ":76},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":26,"publishedAt":47,"linkOnly":48,"markdownContent":49,"markdownBody":50,"frontMatter":51,"refName":52,"commitSha":53,"skillDirectory":54,"forksCount":55,"repositoryHtmlUrl":56,"repositoryHomepageUrl":57,"ownerHtmlUrl":58,"ownerBlogUrl":59,"ownerDescription":60,"updatedAt":61,"install":62,"resources":75},"01M70KXZNX9ES38N2A8M58ZZMZ","supabase-rls-policies",null,"Write, review, and test Supabase Row Level Security (RLS) policies so that one user cannot read or change another user's rows. Use when creating or changing tables, policies, views, or security definer functions in a Supabase project, when reviewing a migration that touches RLS, or when asked to prove user isolation with pgTAP tests that sign in as two users.","github\u002Fawesome-copilot","github","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F9919?v=4","01M6AC83DS133TPXFSP1DHNCC6","GitHub","01M6AD3PBDYPK7NVFJNJ66Y8RS",41028,"MIT","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fsupabase-rls-policies\u002FSKILL.md",[18,23],{"categoryNo":19,"categoryName":20,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000008","测试与质量","SCENARIO","使用场景",{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000023","Skill与智能体开发",[27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46],"rls","supabase rls","supabase rls policies","supabase","with","functions","users","use","prove","tests","as","tables","isolation","security","write","read","change","review","when","project","2026-11-10 10:12:30",false,"---\nname: supabase-rls-policies\ndescription: 'Write, review, and test Supabase Row Level Security (RLS) policies so that one user cannot read or change another user''s rows. Use when creating or changing tables, policies, views, or security definer functions in a Supabase project, when reviewing a migration that touches RLS, or when asked to prove user isolation with pgTAP tests that sign in as two users.'\n---\n\n# Supabase RLS Policies\n\nWrite Row Level Security (RLS) policies for Supabase tables, then prove them with pgTAP tests that act as two different users. A policy is not finished until a test shows that a second user cannot read, change, delete, or create rows that belong to the first user.\n\n## When to Use This Skill\n\n- Creating a table in an exposed schema (by default `public`)\n- Adding or changing a policy, a view, or a `security definer` function\n- Reviewing a migration or pull request that touches RLS\n- Answering whether users or tenants are isolated from each other\n\n## Workflow\n\n1. **List the access rules first.** For each table, write down which role (`anon` or `authenticated`) may run which operation (`select`, `insert`, `update`, `delete`) on which rows. Confirm the list with the user before writing SQL. Each rule gets one test.\n2. **Write the migration.** Enable RLS, add one policy per operation, and index every column the policies filter on.\n3. **Write a pgTAP test file** for the table under `supabase\u002Ftests\u002F`, using the template below.\n4. **Run `supabase test db`** and read every line of output.\n5. **Break the policy on purpose, on a local database only.** Copy the test file and add a temporary policy with `using (true)` right after `begin;`, for example `create policy \"tmp_open\" on public.notes for select to authenticated using (true);`. Run `supabase test db` against the local stack from `supabase start`, confirm at least one test fails, then delete the copy. Do the same with a temporary `for update` and a `for delete` policy. The file ends with `rollback`, so the temporary policy is undone even when the run fails or stops early. Never put a temporary policy in a migration, and never run this against a shared or production database. Do not drop the real policy to do this: with no policy every read is denied, so denial tests still pass and prove nothing.\n6. **Do not change a policy just to make a test pass.** Report which assertion failed and which policy it covers, and let the user decide which one is wrong.\n\n## Rules for Every Table\n\n### Enable RLS and name the role\n\n- Run `alter table ... enable row level security` on every table in an exposed schema. Once RLS is on and no policy exists, the API returns no rows.\n- Add a `to` clause to every policy. Without it the policy applies to `PUBLIC`, which means every role, including `anon`.\n- `auth.uid()` returns `null` when the request has no signed in user. `to authenticated` keeps anonymous requests from reaching the check at all.\n\n### One policy per operation, with the owner checked on writes\n\n```sql\nalter table public.notes enable row level security;\n\ncreate policy \"notes_select_own\" on public.notes\n  for select to authenticated\n  using ((select auth.uid()) = owner_id);\n\ncreate policy \"notes_insert_own\" on public.notes\n  for insert to authenticated\n  with check ((select auth.uid()) = owner_id);\n\ncreate policy \"notes_update_own\" on public.notes\n  for update to authenticated\n  using ((select auth.uid()) = owner_id)\n  with check ((select auth.uid()) = owner_id);\n\ncreate policy \"notes_delete_own\" on public.notes\n  for delete to authenticated\n  using ((select auth.uid()) = owner_id);\n\ncreate index notes_owner_id_idx on public.notes using btree (owner_id);\n```\n\n- `using` checks rows that already exist. `with check` checks the row being written. Rows that fail `using` are skipped with no error; a row that fails `with check` raises an error.\n- **Bind the owner column in every insert and update check.** A check such as `auth.uid() is not null` only proves the caller is signed in. Any signed in user can then insert a row with another user's id in the owner column.\n- A column default such as `default auth.uid()` does not protect the owner column. Postgres uses a default only when the insert does not supply a value, and the client can always supply one.\n- An `update` needs a matching `select` policy, or the update will not work as expected.\n- Wrap `auth.uid()` and `auth.jwt()` in `(select ...)` so Postgres runs them once per statement instead of once per row.\n- Index every column a policy filters on. A column counts as indexed only when it is the first column of a btree index.\n\n### Authorization data in the token\n\n- Never base a policy on `user_metadata` (`raw_user_meta_data`). Signed in users can change it themselves.\n- Store roles, teams, and plans in `app_metadata` (`raw_app_meta_data`), which users cannot change, and read it with `auth.jwt()`.\n- A change to `app_metadata` reaches `auth.jwt()` only after the user's token is refreshed. A user removed from a team keeps access until then.\n\n### Multi factor authentication\n\nTo require a second factor, add a restrictive policy:\n\n```sql\ncreate policy \"notes_require_aal2\" on public.notes\n  as restrictive\n  for all to authenticated\n  using ((select auth.jwt()->>'aal') = 'aal2');\n```\n\nRestrictive policies only narrow access. A table with only restrictive policies returns no rows, so keep the permissive policies above.\n\n### Views\n\nViews bypass RLS by default because they run with the rights of the role that created them. On Postgres 15 and later, create views with `security_invoker = true`:\n\n```sql\ncreate view public.my_notes\n  with (security_invoker = true)\n  as select id, body from public.notes;\n```\n\nOn older versions, remove every grant path or keep the view in a schema that is not exposed. Revoking only the direct grants is not enough: a grant to `PUBLIC`, a grant to a role the API roles are members of, or a grant on a single column still lets them read the view. Check the result with `has_any_column_privilege`, which counts all three; `relacl` lists only direct table grants.\n\n```sql\nrevoke all on public.my_notes from public, anon, authenticated;\nselect has_any_column_privilege('anon', 'public.my_notes', 'select'),\n       has_any_column_privilege('authenticated', 'public.my_notes', 'select');  -- both must be false\n```\n\n### Security definer functions\n\n- A `security definer` function runs as its owner. If the owner is `postgres`, it bypasses RLS.\n- Never create one in an exposed schema. It can be called over the Data API with the owner's rights.\n- Set `search_path = ''` and schema qualify every name inside the function.\n\n### Keys\n\n- Secret keys (and the legacy `service_role` key) bypass RLS. Use them only on a server, never in a browser or mobile app.\n- A secret key bypasses RLS only when the request has no user access token. If the request carries one, that user's policies apply.\n\n## Two User pgTAP Test Template\n\nSave as `supabase\u002Ftests\u002Fnotes_rls_test.sql` and adapt the table, columns, and ids. The file runs in one transaction and rolls back, so test data never persists.\n\n```sql\nbegin;\ncreate extension if not exists pgtap with schema extensions;\nselect plan(8);\n\n-- Two users, created before switching roles.\ninsert into auth.users (id, email) values\n  ('11111111-1111-1111-1111-111111111111', 'user1@test.com'),\n  ('22222222-2222-2222-2222-222222222222', 'user2@test.com');\n\ninsert into public.notes (id, owner_id, body) values\n  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'user 1 note');\n\n-- As user 1: the owner can read and change their own row.\nset local role authenticated;\nset local request.jwt.claims = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\"}';\n\nselect results_eq(\n  $$select body from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  $$values ('user 1 note'::text)$$,\n  'user 1 reads their own note'\n);\n\nselect results_eq(\n  $$update public.notes set body = 'edited' where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' returning body$$,\n  $$values ('edited'::text)$$,\n  'user 1 updates their own note'\n);\n\n-- As user 2: every attempt on user 1's row must fail.\n-- User 2 owns no rows yet, so the write probes need no where clause.\nset local request.jwt.claims = '{\"sub\": \"22222222-2222-2222-2222-222222222222\", \"role\": \"authenticated\"}';\n\nselect is_empty(\n  $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  'user 2 cannot read user 1 note'\n);\n\nselect is_empty(\n  $$update public.notes set body = 'changed' returning 1$$,\n  'user 2 cannot update user 1 note'\n);\n\nselect is_empty(\n  $$delete from public.notes returning 1$$,\n  'user 2 cannot delete user 1 note'\n);\n\nselect throws_ok(\n  $$insert into public.notes (owner_id, body) values ('11111111-1111-1111-1111-111111111111', 'forged')$$,\n  '42501', null,\n  'user 2 cannot create a note owned by user 1'\n);\n\nselect results_eq(\n  $$insert into public.notes (owner_id, body) values ('22222222-2222-2222-2222-222222222222', 'user 2 note') returning owner_id$$,\n  $$values ('22222222-2222-2222-2222-222222222222'::uuid)$$,\n  'user 2 creates their own note'\n);\n\n-- No where clause: the update policy alone picks user 2's row,\n-- so only its with check can stop the transfer.\nselect throws_ok(\n  $$update public.notes set owner_id = '11111111-1111-1111-1111-111111111111'$$,\n  '42501', null,\n  'user 2 cannot hand their note to user 1'\n);\n\nselect * from finish();\nrollback;\n```\n\nNotes on the template:\n\n- Use `returning` on writes. An update or delete that RLS filters out succeeds with zero rows, so only the returned rows show what happened.\n- In user 2's update and delete probes, do not filter on a column or return one. When an update or delete reads a column, Postgres applies the `select` policy as well, so a correct `select` policy can hide an `update` or `delete` policy that is too open. Return the constant `1` and rely on user 2 owning no rows yet.\n- Filter reads by the ids the test inserted. A count over the whole table breaks once other data exists.\n- Error `42501` is the code Postgres raises when a written row fails a `with check` expression.\n- Add an `anon` case for any table that anonymous visitors must not read, and add one to the plan count. Replace the claims first: changing the role does not clear them, so `auth.uid()` would still return user 2's id.\n\n  ```sql\n  set local request.jwt.claims = '{\"role\": \"anon\"}';\n  set local role anon;\n  select is_empty(\n    $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n    'an anonymous visitor cannot read user 1 note'\n  );\n  ```\n\n  If your project does not grant table privileges to `anon`, the read raises `42501` instead, so use `throws_ok`.\n\n## Testing Policies That Read Other Claims\n\nThe template sets the whole claims object in `request.jwt.claims`, the setting PostgREST fills from the token. `auth.uid()` and `auth.jwt()` both read it. If a test sets only `request.jwt.claim.sub`, `auth.uid()` still works but `auth.jwt()` returns `null`, so a policy that compares a claim such as `aal` or a value in `app_metadata` sees `null`. The `aal2` policy above denies every user in that state, so a test that expects a denial passes for the wrong reason.\n\nFor a table with the restrictive `aal2` policy, add `\"aal\": \"aal2\"` to the claims at both user switches in the template, so the owner checks still pass. Then test the other side before `select * from finish();`, and add these two assertions to the plan count:\n\n```sql\n-- If the anon case ran first, the SQL role is still anon. The role claim in the JSON does not change it.\nset local role authenticated;\nset local request.jwt.claims = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\", \"aal\": \"aal1\"}';\nselect is_empty(\n  $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  'an aal1 session cannot read user 1 note'\n);\n\nset local request.jwt.claims = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\", \"aal\": \"aal2\"}';\nselect isnt_empty(\n  $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  'an aal2 session reads user 1 note'\n);\n```\n\n## Review Checklist\n\n- [ ] RLS is enabled on every table in an exposed schema\n- [ ] Every policy names a role with `to`\n- [ ] Insert and update checks bind the owner column to `(select auth.uid())`\n- [ ] No policy reads `user_metadata`\n- [ ] Views use `security_invoker = true`, or are not exposed\n- [ ] Security definer functions live outside exposed schemas and set `search_path = ''`\n- [ ] Each policy column has an index that leads with that column\n- [ ] Each user data table has a two user test covering read, update, delete, and insert\n- [ ] The second user's update and delete probes read no column\n- [ ] Tests set the full `request.jwt.claims` object at each user switch, and replace it before the `anon` case\n- [ ] The suite fails when a temporary `using (true)` policy is added inside the test transaction on a local database\n\n## References\n\n- [Row Level Security](https:\u002F\u002Fsupabase.com\u002Fdocs\u002Fguides\u002Fdatabase\u002Fpostgres\u002Frow-level-security), Supabase docs\n- [Test Row Level Security policies with pgTAP](https:\u002F\u002Fsupabase.com\u002Fdocs\u002Fguides\u002Fdatabase\u002Fpostgres\u002Ftesting-row-level-security), Supabase docs\n- [Multi-Factor Authentication](https:\u002F\u002Fsupabase.com\u002Fdocs\u002Fguides\u002Fauth\u002Fauth-mfa), Supabase docs\n- [CREATE POLICY](https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-createpolicy.html), PostgreSQL docs\n- [Transactions](https:\u002F\u002Fdocs.postgrest.org\u002Fen\u002Fstable\u002Freferences\u002Ftransactions.html), PostgREST docs (`request.jwt.claims`)\n- [CREATE TABLE](https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-createtable.html), PostgreSQL docs (column defaults)\n- [auth.uid() source](https:\u002F\u002Fgithub.com\u002Fsupabase\u002Fauth\u002Fblob\u002F35307ce6c1ca59016fd820f1d77d9b6201dc2e15\u002Fmigrations\u002F20220224000811_update_auth_functions.up.sql) and [auth.jwt() source](https:\u002F\u002Fgithub.com\u002Fsupabase\u002Fauth\u002Fblob\u002F35307ce6c1ca59016fd820f1d77d9b6201dc2e15\u002Fmigrations\u002F20220531120530_add_auth_jwt_function.up.sql), Supabase Auth\n","# Supabase RLS Policies\n\nWrite Row Level Security (RLS) policies for Supabase tables, then prove them with pgTAP tests that act as two different users. A policy is not finished until a test shows that a second user cannot read, change, delete, or create rows that belong to the first user.\n\n## When to Use This Skill\n\n- Creating a table in an exposed schema (by default `public`)\n- Adding or changing a policy, a view, or a `security definer` function\n- Reviewing a migration or pull request that touches RLS\n- Answering whether users or tenants are isolated from each other\n\n## Workflow\n\n1. **List the access rules first.** For each table, write down which role (`anon` or `authenticated`) may run which operation (`select`, `insert`, `update`, `delete`) on which rows. Confirm the list with the user before writing SQL. Each rule gets one test.\n2. **Write the migration.** Enable RLS, add one policy per operation, and index every column the policies filter on.\n3. **Write a pgTAP test file** for the table under `supabase\u002Ftests\u002F`, using the template below.\n4. **Run `supabase test db`** and read every line of output.\n5. **Break the policy on purpose, on a local database only.** Copy the test file and add a temporary policy with `using (true)` right after `begin;`, for example `create policy \"tmp_open\" on public.notes for select to authenticated using (true);`. Run `supabase test db` against the local stack from `supabase start`, confirm at least one test fails, then delete the copy. Do the same with a temporary `for update` and a `for delete` policy. The file ends with `rollback`, so the temporary policy is undone even when the run fails or stops early. Never put a temporary policy in a migration, and never run this against a shared or production database. Do not drop the real policy to do this: with no policy every read is denied, so denial tests still pass and prove nothing.\n6. **Do not change a policy just to make a test pass.** Report which assertion failed and which policy it covers, and let the user decide which one is wrong.\n\n## Rules for Every Table\n\n### Enable RLS and name the role\n\n- Run `alter table ... enable row level security` on every table in an exposed schema. Once RLS is on and no policy exists, the API returns no rows.\n- Add a `to` clause to every policy. Without it the policy applies to `PUBLIC`, which means every role, including `anon`.\n- `auth.uid()` returns `null` when the request has no signed in user. `to authenticated` keeps anonymous requests from reaching the check at all.\n\n### One policy per operation, with the owner checked on writes\n\n```sql\nalter table public.notes enable row level security;\n\ncreate policy \"notes_select_own\" on public.notes\n  for select to authenticated\n  using ((select auth.uid()) = owner_id);\n\ncreate policy \"notes_insert_own\" on public.notes\n  for insert to authenticated\n  with check ((select auth.uid()) = owner_id);\n\ncreate policy \"notes_update_own\" on public.notes\n  for update to authenticated\n  using ((select auth.uid()) = owner_id)\n  with check ((select auth.uid()) = owner_id);\n\ncreate policy \"notes_delete_own\" on public.notes\n  for delete to authenticated\n  using ((select auth.uid()) = owner_id);\n\ncreate index notes_owner_id_idx on public.notes using btree (owner_id);\n```\n\n- `using` checks rows that already exist. `with check` checks the row being written. Rows that fail `using` are skipped with no error; a row that fails `with check` raises an error.\n- **Bind the owner column in every insert and update check.** A check such as `auth.uid() is not null` only proves the caller is signed in. Any signed in user can then insert a row with another user's id in the owner column.\n- A column default such as `default auth.uid()` does not protect the owner column. Postgres uses a default only when the insert does not supply a value, and the client can always supply one.\n- An `update` needs a matching `select` policy, or the update will not work as expected.\n- Wrap `auth.uid()` and `auth.jwt()` in `(select ...)` so Postgres runs them once per statement instead of once per row.\n- Index every column a policy filters on. A column counts as indexed only when it is the first column of a btree index.\n\n### Authorization data in the token\n\n- Never base a policy on `user_metadata` (`raw_user_meta_data`). Signed in users can change it themselves.\n- Store roles, teams, and plans in `app_metadata` (`raw_app_meta_data`), which users cannot change, and read it with `auth.jwt()`.\n- A change to `app_metadata` reaches `auth.jwt()` only after the user's token is refreshed. A user removed from a team keeps access until then.\n\n### Multi factor authentication\n\nTo require a second factor, add a restrictive policy:\n\n```sql\ncreate policy \"notes_require_aal2\" on public.notes\n  as restrictive\n  for all to authenticated\n  using ((select auth.jwt()->>'aal') = 'aal2');\n```\n\nRestrictive policies only narrow access. A table with only restrictive policies returns no rows, so keep the permissive policies above.\n\n### Views\n\nViews bypass RLS by default because they run with the rights of the role that created them. On Postgres 15 and later, create views with `security_invoker = true`:\n\n```sql\ncreate view public.my_notes\n  with (security_invoker = true)\n  as select id, body from public.notes;\n```\n\nOn older versions, remove every grant path or keep the view in a schema that is not exposed. Revoking only the direct grants is not enough: a grant to `PUBLIC`, a grant to a role the API roles are members of, or a grant on a single column still lets them read the view. Check the result with `has_any_column_privilege`, which counts all three; `relacl` lists only direct table grants.\n\n```sql\nrevoke all on public.my_notes from public, anon, authenticated;\nselect has_any_column_privilege('anon', 'public.my_notes', 'select'),\n       has_any_column_privilege('authenticated', 'public.my_notes', 'select');  -- both must be false\n```\n\n### Security definer functions\n\n- A `security definer` function runs as its owner. If the owner is `postgres`, it bypasses RLS.\n- Never create one in an exposed schema. It can be called over the Data API with the owner's rights.\n- Set `search_path = ''` and schema qualify every name inside the function.\n\n### Keys\n\n- Secret keys (and the legacy `service_role` key) bypass RLS. Use them only on a server, never in a browser or mobile app.\n- A secret key bypasses RLS only when the request has no user access token. If the request carries one, that user's policies apply.\n\n## Two User pgTAP Test Template\n\nSave as `supabase\u002Ftests\u002Fnotes_rls_test.sql` and adapt the table, columns, and ids. The file runs in one transaction and rolls back, so test data never persists.\n\n```sql\nbegin;\ncreate extension if not exists pgtap with schema extensions;\nselect plan(8);\n\n-- Two users, created before switching roles.\ninsert into auth.users (id, email) values\n  ('11111111-1111-1111-1111-111111111111', 'user1@test.com'),\n  ('22222222-2222-2222-2222-222222222222', 'user2@test.com');\n\ninsert into public.notes (id, owner_id, body) values\n  ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '11111111-1111-1111-1111-111111111111', 'user 1 note');\n\n-- As user 1: the owner can read and change their own row.\nset local role authenticated;\nset local request.jwt.claims = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\"}';\n\nselect results_eq(\n  $$select body from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  $$values ('user 1 note'::text)$$,\n  'user 1 reads their own note'\n);\n\nselect results_eq(\n  $$update public.notes set body = 'edited' where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' returning body$$,\n  $$values ('edited'::text)$$,\n  'user 1 updates their own note'\n);\n\n-- As user 2: every attempt on user 1's row must fail.\n-- User 2 owns no rows yet, so the write probes need no where clause.\nset local request.jwt.claims = '{\"sub\": \"22222222-2222-2222-2222-222222222222\", \"role\": \"authenticated\"}';\n\nselect is_empty(\n  $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  'user 2 cannot read user 1 note'\n);\n\nselect is_empty(\n  $$update public.notes set body = 'changed' returning 1$$,\n  'user 2 cannot update user 1 note'\n);\n\nselect is_empty(\n  $$delete from public.notes returning 1$$,\n  'user 2 cannot delete user 1 note'\n);\n\nselect throws_ok(\n  $$insert into public.notes (owner_id, body) values ('11111111-1111-1111-1111-111111111111', 'forged')$$,\n  '42501', null,\n  'user 2 cannot create a note owned by user 1'\n);\n\nselect results_eq(\n  $$insert into public.notes (owner_id, body) values ('22222222-2222-2222-2222-222222222222', 'user 2 note') returning owner_id$$,\n  $$values ('22222222-2222-2222-2222-222222222222'::uuid)$$,\n  'user 2 creates their own note'\n);\n\n-- No where clause: the update policy alone picks user 2's row,\n-- so only its with check can stop the transfer.\nselect throws_ok(\n  $$update public.notes set owner_id = '11111111-1111-1111-1111-111111111111'$$,\n  '42501', null,\n  'user 2 cannot hand their note to user 1'\n);\n\nselect * from finish();\nrollback;\n```\n\nNotes on the template:\n\n- Use `returning` on writes. An update or delete that RLS filters out succeeds with zero rows, so only the returned rows show what happened.\n- In user 2's update and delete probes, do not filter on a column or return one. When an update or delete reads a column, Postgres applies the `select` policy as well, so a correct `select` policy can hide an `update` or `delete` policy that is too open. Return the constant `1` and rely on user 2 owning no rows yet.\n- Filter reads by the ids the test inserted. A count over the whole table breaks once other data exists.\n- Error `42501` is the code Postgres raises when a written row fails a `with check` expression.\n- Add an `anon` case for any table that anonymous visitors must not read, and add one to the plan count. Replace the claims first: changing the role does not clear them, so `auth.uid()` would still return user 2's id.\n\n  ```sql\n  set local request.jwt.claims = '{\"role\": \"anon\"}';\n  set local role anon;\n  select is_empty(\n    $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n    'an anonymous visitor cannot read user 1 note'\n  );\n  ```\n\n  If your project does not grant table privileges to `anon`, the read raises `42501` instead, so use `throws_ok`.\n\n## Testing Policies That Read Other Claims\n\nThe template sets the whole claims object in `request.jwt.claims`, the setting PostgREST fills from the token. `auth.uid()` and `auth.jwt()` both read it. If a test sets only `request.jwt.claim.sub`, `auth.uid()` still works but `auth.jwt()` returns `null`, so a policy that compares a claim such as `aal` or a value in `app_metadata` sees `null`. The `aal2` policy above denies every user in that state, so a test that expects a denial passes for the wrong reason.\n\nFor a table with the restrictive `aal2` policy, add `\"aal\": \"aal2\"` to the claims at both user switches in the template, so the owner checks still pass. Then test the other side before `select * from finish();`, and add these two assertions to the plan count:\n\n```sql\n-- If the anon case ran first, the SQL role is still anon. The role claim in the JSON does not change it.\nset local role authenticated;\nset local request.jwt.claims = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\", \"aal\": \"aal1\"}';\nselect is_empty(\n  $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  'an aal1 session cannot read user 1 note'\n);\n\nset local request.jwt.claims = '{\"sub\": \"11111111-1111-1111-1111-111111111111\", \"role\": \"authenticated\", \"aal\": \"aal2\"}';\nselect isnt_empty(\n  $$select 1 from public.notes where id = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'$$,\n  'an aal2 session reads user 1 note'\n);\n```\n\n## Review Checklist\n\n- [ ] RLS is enabled on every table in an exposed schema\n- [ ] Every policy names a role with `to`\n- [ ] Insert and update checks bind the owner column to `(select auth.uid())`\n- [ ] No policy reads `user_metadata`\n- [ ] Views use `security_invoker = true`, or are not exposed\n- [ ] Security definer functions live outside exposed schemas and set `search_path = ''`\n- [ ] Each policy column has an index that leads with that column\n- [ ] Each user data table has a two user test covering read, update, delete, and insert\n- [ ] The second user's update and delete probes read no column\n- [ ] Tests set the full `request.jwt.claims` object at each user switch, and replace it before the `anon` case\n- [ ] The suite fails when a temporary `using (true)` policy is added inside the test transaction on a local database\n\n## References\n\n- [Row Level Security](https:\u002F\u002Fsupabase.com\u002Fdocs\u002Fguides\u002Fdatabase\u002Fpostgres\u002Frow-level-security), Supabase docs\n- [Test Row Level Security policies with pgTAP](https:\u002F\u002Fsupabase.com\u002Fdocs\u002Fguides\u002Fdatabase\u002Fpostgres\u002Ftesting-row-level-security), Supabase docs\n- [Multi-Factor Authentication](https:\u002F\u002Fsupabase.com\u002Fdocs\u002Fguides\u002Fauth\u002Fauth-mfa), Supabase docs\n- [CREATE POLICY](https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-createpolicy.html), PostgreSQL docs\n- [Transactions](https:\u002F\u002Fdocs.postgrest.org\u002Fen\u002Fstable\u002Freferences\u002Ftransactions.html), PostgREST docs (`request.jwt.claims`)\n- [CREATE TABLE](https:\u002F\u002Fwww.postgresql.org\u002Fdocs\u002Fcurrent\u002Fsql-createtable.html), PostgreSQL docs (column defaults)\n- [auth.uid() source](https:\u002F\u002Fgithub.com\u002Fsupabase\u002Fauth\u002Fblob\u002F35307ce6c1ca59016fd820f1d77d9b6201dc2e15\u002Fmigrations\u002F20220224000811_update_auth_functions.up.sql) and [auth.jwt() source](https:\u002F\u002Fgithub.com\u002Fsupabase\u002Fauth\u002Fblob\u002F35307ce6c1ca59016fd820f1d77d9b6201dc2e15\u002Fmigrations\u002F20220531120530_add_auth_jwt_function.up.sql), Supabase Auth\n",{"name":5,"description":7},"main","227949a7542a55d2155c00e7cdb998d9a0fee4f3","skills\u002Fsupabase-rls-policies",5335,"https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot","https:\u002F\u002Fawesome-copilot.github.com\u002F","https:\u002F\u002Fgithub.com\u002Fgithub","https:\u002F\u002Fgithub.com\u002Fabout","How people build software.","2026-11-11 11:59:04",{"source":8,"skillName":5,"commands":63,"prompt":73,"zipAvailable":74},[64,67,70],{"tool":65,"command":66},"npx","npx skills add github\u002Fawesome-copilot --skill supabase-rls-policies",{"tool":68,"command":69},"bunx","bunx skills add github\u002Fawesome-copilot --skill supabase-rls-policies",{"tool":71,"command":72},"pnpm","pnpm dlx skills add github\u002Fawesome-copilot --skill supabase-rls-policies","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fsupabase-rls-policies\u002FSKILL.md",true,[],[77,105,135,169,186,219],{"skillNo":78,"name":79,"nameZh":6,"description":80,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":81,"categories":82,"keywords":84,"publishedAt":47},"01M70KWA5YWXDV6V8DZ19E3T2Y","skill-distribution-audit","Audit a skill collection before release or investigate installer omissions, archived\u002Ftemplate leakage, duplicate identities, and manifest drift. Reconcile the intended public source set, distribution manifest, and actual discovery output for a specified consumer version. Includes a deterministic set-comparison helper.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fskill-distribution-audit\u002FSKILL.md",[83],{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},[85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104],"distribution","skill distribution audit","audit","SET","deterministic","installer","for","discovery","comparison","helper","manifest","version","collection","drift","output","investigate","duplicate","template","release","reconcile",{"skillNo":106,"name":107,"nameZh":6,"description":108,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":109,"sourceHtmlUrl":110,"categories":111,"keywords":117,"publishedAt":47},"01M70KCJPFVW7PPQ49CC5R8JGK","logicapps-custom-code-net10-migration","Migrate Azure Logic Apps Standard local-function custom-code projects from .NET 8 to .NET 10. Use for legacy Logic Apps custom-code workspace upgrades, project\u002Fpackage configuration, custom-worker settings, publication, and integrated VS Code debugging. Not for ordinary .NET applications, standalone Azure Functions, Consumption workflows, or cloud deployment.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Flogicapps-custom-code-net10-migration\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Flogicapps-custom-code-net10-migration\u002FSKILL.md",[112],{"categoryNo":113,"categoryName":114,"taxonomyType":115,"taxonomyTypeDesc":116},"01JBSEEDCAT000000000000030","开发工程师","PROFESSION","适用职业",[118,119,120,121,122,123,91,46,124,125,126,127,128,129,130,34,131,132,133,134],"custom","code","logicapps custom code net10 migration","migration","projects","standalone","deployment","applications","local","azure","worker","upgrades","package","apps","Legacy","debugging","workflows",{"skillNo":136,"name":137,"nameZh":6,"description":138,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":139,"sourceHtmlUrl":140,"categories":141,"keywords":146,"publishedAt":47},"01M6AEBN37RQMXJ3298PEY7PG1","winmd-api-search","Find and explore Windows desktop APIs. Use when building features that need platform capabilities — camera, file access, notifications, UI controls, AI\u002FML, sensors, networking, etc. Discovers the right API for a task and retrieves full type details (methods, properties, events, enumeration values).","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fwinmd-api-search\u002FLICENSE.txt","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fwinmd-api-search\u002FSKILL.md",[142,145],{"categoryNo":143,"categoryName":144,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000010","后端与接口",{"categoryNo":113,"categoryName":114,"taxonomyType":115,"taxonomyTypeDesc":116},[147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,45,162,163,164,165,166,91,167,168],"winmd api search","search","api","windows","desktop","file access","enumeration","ui","ml","file","networking","capabilities","notifications","explore","access","features","full","camera","events","platform","methods","controls",{"skillNo":170,"name":171,"nameZh":6,"description":172,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":173,"licenseFileUrl":174,"sourceHtmlUrl":175,"categories":176,"keywords":182,"publishedAt":47},"01M6AE571R3SC1ZGE202B3NB5S","resemble-detect","Deepfake detection and media safety — detect AI-generated audio, images, video, and text, trace synthesis sources, apply watermarks, verify speaker identity, and analyze media intelligence using Resemble AI","Apache-2.0","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fresemble-detect\u002FLICENSE","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fresemble-detect\u002FSKILL.md",[177,178,181],{"categoryNo":143,"categoryName":144,"taxonomyType":21,"taxonomyTypeDesc":22},{"categoryNo":179,"categoryName":180,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000016","图像与多媒体",{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},[183,88,156,184,185],"resemble detect","https","identity",{"skillNo":187,"name":188,"nameZh":6,"description":189,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":173,"licenseFileUrl":190,"sourceHtmlUrl":191,"categories":192,"keywords":197,"publishedAt":47},"01M6AE30BSK2HCY4TQQ22Z8JRD","quality-playbook","Run a complete quality engineering audit on any codebase. Derives behavioral requirements from the code, generates spec-traced functional tests, runs a three-pass code review with regression tests, executes a multi-model spec audit (Council of Three), and produces a consolidated bug report with TDD-verified patches. Finds the 35% of real defects that structural code review alone cannot catch. Works with any language. Trigger on 'quality playbook', 'spec audit', 'Council of Three', 'fitness-to-purpose', or 'coverage theater'.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fquality-playbook\u002FLICENSE.txt","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fquality-playbook\u002FSKILL.md",[193,196],{"categoryNo":194,"categoryName":195,"taxonomyType":21,"taxonomyTypeDesc":22},"01JBSEEDCAT000000000000007","代码审查",{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},[198,199,200,201,119,44,9,87,202,203,184,204,205,206,207,208,209,210,211,212,213,214,36,215,216,217,218],"playbook","quality playbook","quality","code review","tdd","run","report","spec","bug report","council","engineering","functional","spec audit","bug","verified","multi-model","three","coverage","model","regression","structural",{"skillNo":220,"name":221,"nameZh":6,"description":222,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":223,"sourceHtmlUrl":224,"categories":225,"keywords":227,"publishedAt":47},"01M6AD8N3ACX62R5XZC6BG14H9","azure-resource-visualizer","Analyze Azure resource groups and generate detailed Mermaid architecture diagrams showing the relationships between individual resources. Use this skill when the user asks for a diagram of their Azure resources or help in understanding how the resources relate to each other.","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fazure-resource-visualizer\u002FLICENSE.txt","https:\u002F\u002Fgithub.com\u002Fgithub\u002Fawesome-copilot\u002Fblob\u002Fmain\u002Fskills\u002Fazure-resource-visualizer\u002FSKILL.md",[226],{"categoryNo":24,"categoryName":25,"taxonomyType":21,"taxonomyTypeDesc":22},[127,228,229,230,231,232,233,234,235,236,237,45,238,239,91,34,240,241,242],"azure resource visualizer","diagram","generate","mermaid","help","architecture diagrams","microsoft","architecture","diagrams","resources","analyze","how","relationships","groups","user"]