mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
docs(database): make writing and running the tests part of the procedure (#49276)
Ref DOCS-1274 Follow-up to #49017, now merged. This is the go-to-green piece: everything aimed at the three failing eval checks, and nothing else. Technical corrections follow in the PR stacked on this one. ## Problem `build-docs-002-rls-guide` points an agent at this guide with a vibe-coder prompt that never says RLS, policy, role, or test. Grants, policies, access probes, indexes, and security-definer placement all pass. Three checks fail, and have failed on every recorded run: | Check | What it measures | Why it failed | | --- | --- | --- | | `pgTAP test file(s) written under supabase/tests/` | Any `.sql` file exists | The agent never wrote one. | | `supabase test db runs at least 8 assertions and all pass` | Suite runs, ≥8 assertions, none failing | Nothing to run. The only example was `plan(4)`, under the floor even if copied perfectly. | | `tests assert allow and deny per operation … for anon and authenticated` | LLM judge on coverage | Never reached the judge: "no test files to review". | The guide already had a `Test your policies` section, so this isn't a strength problem. Agents don't read the page. They fetch it through an LLM extraction guided by their own query, and that query asked for enabling RLS, policy syntax, `auth.uid()`, indexes, and security definer functions. It never mentioned tests. A section about testing never enters the extract, so more testing prose cannot reach the agent. There was also a plain documentation bug underneath it: `Secure a table with RLS` said a table isn't secured until the suite passes, but the procedure beneath it ran 1–3 and ended on `grant`. A reader following the numbered steps finished without ever being told to write a test. ## Solution Put the tests where the procedure and the examples already are. - **`Secure a table with RLS`** opens with the four steps that finish a table, ending on `supabase test db`. Until the suite passes, you don't know whether the policies do what you intended. - **`Enable RLS and set the grants` gains step 4** — `supabase test new <table>_rls.test`, then `supabase test db`. The procedure ends on a passing suite instead of a grant. - **The public-read example** gains its policy and `announcements_rls.test.sql`, so a test file rides along in the enable-RLS extract. - **The four policy examples** are followed immediately by `profiles_rls.test.sql`, so one rides along in the `create policy` extract too. - **`Run the test suite` shrinks** to creating and running the files. It no longer carries content that has to survive extraction. - Each file leads with its own path as a comment, so it survives if the fence metadata is dropped. ### How that maps to the three checks | Check | Addressed by | | --- | --- | | Test files written | A complete test file now sits inside both extracts an agent's own query pulls, and step 4 of the procedure names the command that creates one. | | ≥8 assertions, all passing | `announcements_rls.test.sql` is `plan(10)`, `profiles_rls.test.sql` is `plan(14)`. Either alone clears the floor; together, 24. | | Coverage judge | `profiles` asserts allow **and** deny for all four operations. Allowed writes use `returning` + `results_eq`, proving state changed rather than that nothing raised. `using`-filtered denials use `is_empty`, asserting the row is unchanged rather than that an error was raised — the case the rubric explicitly fails suites for getting wrong. Both files switch role with `set local role` and identity with `set local request.jwt.claim.sub`, and cover `anon` as well as `authenticated`. | ## Manual testing 1. Open the [Row Level Security guide](https://docs-git-docs-rls-tests-in-procedure-supabase.vercel.app/docs/guides/database/postgres/row-level-security) on the preview. `Secure a table with RLS` opens with a four-step definition of done ending on `supabase test db`. 2. Read `Enable RLS and set the grants`. The procedure runs 1–4 and ends on writing and running the test, not on the grant. 3. Scroll to `DELETE policies`. The four policies are followed immediately by `profiles_rls.test.sql`, not a pointer to a later section. 4. Open the [markdown version](https://docs-git-docs-rls-tests-in-procedure-supabase.vercel.app/docs/guides/database/postgres/row-level-security.md), which is what agents fetch. Both test files are present, each leading with its path. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Documentation - Updated database security guidance for enabling row-level security and configuring grants. - Added per-table pgTAP testing requirements and revised `supabase test db` examples. - Expanded examples for permitted and denied access across public and authenticated roles. - Added dedicated guidance for profile testing and security-definer member/non-member cases. - Documented recursive-policy `42P17` failures and the security-definer workaround. - Clarified indexing, denial diagnosis, returned-row verification, and table-hardening links. - Streamlined the general policy-testing guidance. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0243ad7cf1
commit
21265b2e59
1 file changed
+320
-103
@@ -16,7 +16,7 @@ A table in an exposed schema without RLS is readable and writable by any role wi
|
||||
Use the guide in three parts:
|
||||
|
||||
- [Understand Row Level Security](#understand-row-level-security) explains how grants and policies combine to control access.
|
||||
- [Secure a table with RLS](#secure-a-table-with-rls) is the procedure to follow for every table in an exposed schema.
|
||||
- [Secure a table with RLS](#secure-a-table-with-rls) is the procedure to follow for every table in an exposed schema: grants, policies, and `supabase test db`.
|
||||
- [RLS reference](#rls-reference) documents the helper functions and patterns you use inside a policy expression.
|
||||
|
||||
Read the first section when you're deciding how to model access. Go directly to the second section when you're ready to secure a table.
|
||||
@@ -63,7 +63,7 @@ Adding policies doesn't take those grants back. A table protected only by polici
|
||||
|
||||
Not every project grants these automatically. See [Default privileges](/docs/guides/api/securing-your-api#default-privileges). Grant each role only the operations it needs.
|
||||
|
||||
A missing grant raises a `42501` error before any policy runs. When a request fails that your policy should allow, check the grants before you change the policy. To set them, see [Lock down the table](#lock-down-the-table).
|
||||
A missing grant raises a `42501` error before any policy runs. When a request fails that your policy should allow, check the grants before you change the policy. To set them, see [Enable RLS and set the grants](#enable-rls-and-set-the-grants).
|
||||
|
||||
### Authenticated and unauthenticated roles
|
||||
|
||||
@@ -102,9 +102,16 @@ Views bypass RLS by default because they are usually created with the `postgres`
|
||||
|
||||
## Secure a table with RLS
|
||||
|
||||
Follow these steps for every table in an exposed schema.
|
||||
Follow these steps for every table in an exposed schema:
|
||||
|
||||
### Lock down the table
|
||||
1. [Enable RLS and set the grants](#enable-rls-and-set-the-grants) to match the app.
|
||||
2. [Write a policy per operation](#write-a-policy-for-each-operation).
|
||||
3. [Create a `.sql` file under `supabase/tests/`](#policy-tests) that asserts allow and deny for `select`, `insert`, `update`, and `delete`, for `anon` and `authenticated`.
|
||||
4. [Run `supabase test db`](#run-the-test-suite) and fix what it reports.
|
||||
|
||||
Until the suite passes, you don't know whether the policies do what you intended.
|
||||
|
||||
### Enable RLS and set the grants
|
||||
|
||||
Run these statements in the [SQL Editor](/dashboard/project/_/sql/new) for a one-off change, or in a [migration](/docs/guides/deployment/database-migrations) to keep the change reproducible across environments. Grants and RLS belong in the same migration.
|
||||
|
||||
@@ -131,17 +138,105 @@ Enable RLS, then set the grants to match what each role does in your app:
|
||||
grant select, insert, update, delete on table public.reports to authenticated;
|
||||
```
|
||||
|
||||
Data that clients read but never write, such as a feed a backend job populates, gets no write grant at all:
|
||||
4. Write the test file for the table, and run the suite.
|
||||
|
||||
```bash
|
||||
supabase test new reports_rls.test
|
||||
supabase test db
|
||||
```
|
||||
|
||||
Give every table you secure one. The examples below show what goes in the file.
|
||||
|
||||
Data that clients read but never write, such as a feed a backend job populates, gets select only. The policy and the test file are part of the same change:
|
||||
|
||||
```sql
|
||||
revoke all on table public.weather_readings from anon, authenticated;
|
||||
grant select on table public.weather_readings to anon, authenticated;
|
||||
alter table public.announcements enable row level security;
|
||||
revoke all on table public.announcements from anon, authenticated;
|
||||
grant select on table public.announcements to anon, authenticated;
|
||||
|
||||
create policy "Anyone can read announcements"
|
||||
on public.announcements for select
|
||||
to anon, authenticated
|
||||
using ( true );
|
||||
```
|
||||
|
||||
```sql supabase/tests/announcements_rls.test.sql
|
||||
-- File: supabase/tests/announcements_rls.test.sql
|
||||
-- Create: supabase test new announcements_rls.test
|
||||
-- Run: supabase test db
|
||||
-- Repeat for every public-read table you secured.
|
||||
begin;
|
||||
select plan(10);
|
||||
|
||||
insert into announcements (id, body)
|
||||
values ('33333333-3333-3333-3333-333333333333', 'published');
|
||||
|
||||
-- A read has to return the row. lives_ok passes on an empty result.
|
||||
select ok(
|
||||
not has_table_privilege('anon', 'public.announcements', 'insert,update,delete'),
|
||||
'anon holds no write grant on the feed'
|
||||
);
|
||||
select ok(
|
||||
not has_table_privilege('authenticated', 'public.announcements', 'insert,update,delete'),
|
||||
'authenticated holds no write grant on the feed'
|
||||
);
|
||||
|
||||
set local role anon;
|
||||
select results_eq(
|
||||
$$select body from announcements where id = '33333333-3333-3333-3333-333333333333'$$,
|
||||
array['published'],
|
||||
'anon reads the feed'
|
||||
);
|
||||
select throws_ok(
|
||||
$$insert into announcements select * from announcements$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot insert into the feed'
|
||||
);
|
||||
select throws_ok(
|
||||
$$update announcements set id = id$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot update the feed'
|
||||
);
|
||||
select throws_ok(
|
||||
$$delete from announcements$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot delete from the feed'
|
||||
);
|
||||
|
||||
set local role authenticated;
|
||||
select results_eq(
|
||||
$$select body from announcements where id = '33333333-3333-3333-3333-333333333333'$$,
|
||||
array['published'],
|
||||
'authenticated reads the feed'
|
||||
);
|
||||
select throws_ok(
|
||||
$$insert into announcements select * from announcements$$,
|
||||
'42501',
|
||||
null,
|
||||
'authenticated cannot insert into the feed'
|
||||
);
|
||||
select throws_ok(
|
||||
$$update announcements set id = id$$,
|
||||
'42501',
|
||||
null,
|
||||
'authenticated cannot update the feed'
|
||||
);
|
||||
select throws_ok(
|
||||
$$delete from announcements$$,
|
||||
'42501',
|
||||
null,
|
||||
'authenticated cannot delete from the feed'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
```
|
||||
|
||||
If new tables still receive automatic grants, see [Revoke default privileges](/docs/guides/api/securing-your-api#revoke-default-privileges). To enable RLS automatically on every new table, see [Event triggers](/docs/guides/database/postgres/event-triggers).
|
||||
|
||||
Write the tests for this table in the same change. See [Test your policies](#test-your-policies).
|
||||
|
||||
### Write a policy for each operation
|
||||
|
||||
Write a separate policy for `select`, `insert`, `update`, and `delete`. Postgres does not accept multiple operations in one `for` clause, and a `for all` policy hides which operation each rule was meant to cover.
|
||||
@@ -216,6 +311,153 @@ to authenticated
|
||||
using ( (select auth.uid()) = user_id );
|
||||
```
|
||||
|
||||
#### Policy tests
|
||||
|
||||
When you adapt those four policies to a table, add this file for that table. The path is `supabase/tests/<table>_rls.test.sql`. For a table users share, also assert that a member who is not the owner can perform the operations its policies allow, and that a non-member cannot.
|
||||
|
||||
```sql supabase/tests/profiles_rls.test.sql
|
||||
-- File: supabase/tests/profiles_rls.test.sql
|
||||
-- Create: supabase test new profiles_rls.test
|
||||
-- Run: supabase test db
|
||||
-- One file per table you enabled RLS on. Name that table in the assertions.
|
||||
-- Do not create a table only the tests use.
|
||||
begin;
|
||||
select plan(14);
|
||||
|
||||
insert into auth.users (id, email)
|
||||
values
|
||||
('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
|
||||
('22222222-2222-2222-2222-222222222222', 'other@example.com');
|
||||
|
||||
-- anon holds no grant, so the request stops before any policy runs.
|
||||
set local role anon;
|
||||
select throws_ok(
|
||||
$$select * from profiles$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot read profiles'
|
||||
);
|
||||
select throws_ok(
|
||||
$$insert into profiles (id, user_id, avatar_url)
|
||||
values (
|
||||
gen_random_uuid(),
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'anon.png'
|
||||
)$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot create a profile'
|
||||
);
|
||||
select throws_ok(
|
||||
$$update profiles set avatar_url = 'anon.png'$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot update profiles'
|
||||
);
|
||||
select throws_ok(
|
||||
$$delete from profiles$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot delete profiles'
|
||||
);
|
||||
|
||||
-- The owner writes their own row. returning proves the row changed.
|
||||
set local role authenticated;
|
||||
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
|
||||
select results_eq(
|
||||
$$insert into profiles (id, user_id, avatar_url)
|
||||
values (
|
||||
gen_random_uuid(),
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'owner.png'
|
||||
)
|
||||
returning avatar_url$$,
|
||||
array['owner.png'],
|
||||
'the owner creates their own profile'
|
||||
);
|
||||
select results_eq(
|
||||
$$select avatar_url from profiles where user_id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
array['owner.png'],
|
||||
'the owner reads their own profile'
|
||||
);
|
||||
select results_eq(
|
||||
$$update profiles set avatar_url = 'updated.png'
|
||||
where user_id = '11111111-1111-1111-1111-111111111111'
|
||||
returning avatar_url$$,
|
||||
array['updated.png'],
|
||||
'the owner updates their own profile'
|
||||
);
|
||||
|
||||
-- A signed-in stranger holds the grant, so the policy is what stops them.
|
||||
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
|
||||
select throws_ok(
|
||||
$$insert into profiles (id, user_id, avatar_url)
|
||||
values (
|
||||
gen_random_uuid(),
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'stolen.png'
|
||||
)$$,
|
||||
'42501',
|
||||
null,
|
||||
'another user cannot create a profile for the owner'
|
||||
);
|
||||
select is_empty(
|
||||
$$select * from profiles$$,
|
||||
'another user reads no profiles'
|
||||
);
|
||||
select is_empty(
|
||||
$$update profiles set avatar_url = 'stolen.png' returning avatar_url$$,
|
||||
'another user updates no profiles'
|
||||
);
|
||||
|
||||
-- Matching no rows is not proof on its own. Pair every denied write with a
|
||||
-- check that the row it targeted is intact. Scope it to that row: a suite
|
||||
-- that asserts on everything a role can see breaks once the table holds more.
|
||||
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
|
||||
select results_eq(
|
||||
$$select avatar_url from profiles where user_id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
array['updated.png'],
|
||||
'the denied update left the owner row intact'
|
||||
);
|
||||
|
||||
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
|
||||
select is_empty(
|
||||
$$delete from profiles returning avatar_url$$,
|
||||
'another user deletes no profiles'
|
||||
);
|
||||
|
||||
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
|
||||
select results_eq(
|
||||
$$select avatar_url from profiles where user_id = '11111111-1111-1111-1111-111111111111'$$,
|
||||
array['updated.png'],
|
||||
'the denied delete left the owner row intact'
|
||||
);
|
||||
|
||||
-- Owner delete last so earlier cases still have a row to assert against.
|
||||
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
|
||||
select results_eq(
|
||||
$$delete from profiles where user_id = '11111111-1111-1111-1111-111111111111'
|
||||
returning avatar_url$$,
|
||||
array['updated.png'],
|
||||
'the owner deletes their own profile'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
```
|
||||
|
||||
Match the assertion to how the request is denied. Only two of the three raise an error:
|
||||
|
||||
| Denied by | Postgres | Assert with |
|
||||
| -------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| A missing grant | raises `42501` | `throws_ok` |
|
||||
| A `with check` violation | raises `42501` | `throws_ok` |
|
||||
| A `using` clause filtering the row out | raises nothing, matches zero rows | `is_empty` over the statement with `returning`, then a read proving the target row is intact |
|
||||
|
||||
Never prove an allowed write with `lives_ok`. It passes when the write matched zero rows. Add `returning` and assert the returned value.
|
||||
|
||||
Switch role and identity with `set local role` and `set local request.jwt.claim.sub`.
|
||||
|
||||
### Specify roles in your policies
|
||||
|
||||
Always name the role a policy applies to, using the `to` clause. Instead of this:
|
||||
@@ -235,7 +477,11 @@ using ( (select auth.uid()) = user_id );
|
||||
|
||||
This prevents the policy `( (select auth.uid()) = user_id )` from running for any `anon` users, since the execution stops at the `to authenticated` step.
|
||||
|
||||
These three rules keep policies correct as a table grows. For the measured impact and for tuning beyond them, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance).
|
||||
### Run the test suite
|
||||
|
||||
The files above live under `supabase/tests/` and run through [pgTAP](/docs/guides/database/extensions/pgtap). Create them with `supabase test new <table>_rls.test`, and run them with `supabase test db`.
|
||||
|
||||
[`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main) adds `tests.create_supabase_user()`, `tests.authenticate_as()`, and `tests.rls_enabled()`. See [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended) and [Testing your database](/docs/guides/database/testing).
|
||||
|
||||
### Add indexes
|
||||
|
||||
@@ -296,6 +542,8 @@ You can only use this technique if the results of the query or function do not c
|
||||
|
||||
</Admonition>
|
||||
|
||||
Indexing the filter columns and wrapping helper calls keeps policies fast as a table grows. For the measured impact, and for tuning beyond these two rules, see [Row Level Security performance](/docs/guides/database/postgres/row-level-security-performance).
|
||||
|
||||
### Expose a view safely
|
||||
|
||||
In Postgres 15 and above, make a view obey the RLS policies of its underlying tables when invoked by `anon` and `authenticated` by setting `security_invoker = true`.
|
||||
@@ -308,99 +556,6 @@ as select <QUERY>
|
||||
|
||||
In older versions of Postgres, protect your views by revoking access from the `anon` and `authenticated` roles, or by putting them in an unexposed schema.
|
||||
|
||||
### Test your policies
|
||||
|
||||
We recommend writing tests for every policy, in the same change that sets the grants and creates the policies. Tests are a fundamental part of a secure setup, and they give you a repeatable way to prove a policy behaves the way you intended.
|
||||
|
||||
A wrong policy fails quietly. Too permissive, and a query returns rows it shouldn't. Too strict, and it returns nothing and raises no error. Neither case surfaces as an error, so tests are how you find out.
|
||||
|
||||
Supabase runs database tests with [pgTAP](/docs/guides/database/extensions/pgtap) through the CLI. Test files are `.sql` files under `supabase/tests/`.
|
||||
|
||||
#### Anatomy of a policy test
|
||||
|
||||
Each case sets an identity, runs one statement as that identity, and asserts the outcome. Three things decide whether the assertion means anything.
|
||||
|
||||
**Identity.** Switch role and identity between cases with `set local role` and `set local request.jwt.claim.sub`, so each assertion runs as the user it describes. Without the switch, every case runs as the same role and proves nothing about access.
|
||||
|
||||
**Denials.** A denied request doesn't always raise an error, so match the assertion to the way the denial happens:
|
||||
|
||||
- A missing grant raises `42501`. Assert it with `throws_ok`.
|
||||
- A `with check` violation raises `42501`. Assert it with `throws_ok`.
|
||||
- A `using` clause that filters the target row out raises nothing. The update or delete matches zero rows instead. Assert that no row changed.
|
||||
|
||||
**Allowed writes.** The absence of an error doesn't prove that anything changed. Add `returning` to the statement so one assertion covers both directions. An allowed write returns the changed row, and a write the policy filters out returns nothing.
|
||||
|
||||
#### Write and run the tests
|
||||
|
||||
1. Create a test file:
|
||||
|
||||
```bash
|
||||
supabase test new profiles_rls
|
||||
```
|
||||
|
||||
2. Write the tests. Cover `select`, `insert`, `update`, and `delete` twice each, once for a request the policy allows and once for a request it denies, for `anon` as well as `authenticated`.
|
||||
|
||||
[`supabase-test-helpers`](https://github.com/usebasejump/supabase-test-helpers/tree/main) removes most of the setup below. It adds `tests.create_supabase_user()`, `tests.authenticate_as()`, and `tests.rls_enabled()`, so you don't hand-roll user seeding or role switching. See [Advanced pgTAP testing](/docs/guides/local-development/testing/pgtap-extended) for schema-wide assertions and a worked multi-tenant example.
|
||||
|
||||
3. Run the suite:
|
||||
|
||||
```bash
|
||||
supabase test db
|
||||
```
|
||||
|
||||
This example shows each of those techniques against a `profiles` table where `authenticated` holds every privilege, `anon` holds none, and each user reads and writes only their own row. Extend it to the remaining operations:
|
||||
|
||||
```sql supabase/tests/profiles_rls.test.sql
|
||||
begin;
|
||||
select plan(4);
|
||||
|
||||
insert into auth.users (id, email)
|
||||
values
|
||||
('11111111-1111-1111-1111-111111111111', 'owner@example.com'),
|
||||
('22222222-2222-2222-2222-222222222222', 'other@example.com');
|
||||
|
||||
-- anon holds no grant, so the request stops before any policy runs.
|
||||
set local role anon;
|
||||
select throws_ok(
|
||||
$$select * from profiles$$,
|
||||
'42501',
|
||||
null,
|
||||
'anon cannot read profiles'
|
||||
);
|
||||
|
||||
-- The owner writes their own row. returning proves the row changed.
|
||||
set local role authenticated;
|
||||
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
|
||||
select results_eq(
|
||||
$$insert into profiles (id, user_id, avatar_url)
|
||||
values (
|
||||
gen_random_uuid(),
|
||||
'11111111-1111-1111-1111-111111111111',
|
||||
'owner.png'
|
||||
)
|
||||
returning avatar_url$$,
|
||||
array['owner.png'],
|
||||
'the owner creates their own profile'
|
||||
);
|
||||
|
||||
-- A signed-in stranger holds the grant, so the policy is what stops them. The
|
||||
-- using clause filters the row out, so these match nothing and raise nothing.
|
||||
set local request.jwt.claim.sub = '22222222-2222-2222-2222-222222222222';
|
||||
select is_empty(
|
||||
$$select * from profiles$$,
|
||||
'another user reads no profiles'
|
||||
);
|
||||
select is_empty(
|
||||
$$update profiles set avatar_url = 'stolen.png' returning avatar_url$$,
|
||||
'another user updates no profiles'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
rollback;
|
||||
```
|
||||
|
||||
For CLI setup and more pgTAP helpers, see [Testing your database](/docs/guides/database/testing).
|
||||
|
||||
## RLS reference
|
||||
|
||||
These are the functions and patterns available inside a policy expression.
|
||||
@@ -522,6 +677,8 @@ to authenticated
|
||||
using ( (select private.has_good_role()) );
|
||||
```
|
||||
|
||||
Add member and non-member cases to that table's file under `supabase/tests/`. A member who is not the owner must be allowed; a non-member must not.
|
||||
|
||||
Set `search_path = ''` on every `security definer` function and schema-qualify the names inside it. Without a pinned `search_path`, a caller can point an unqualified name at their own object and run it with the function owner's privileges.
|
||||
|
||||
<Admonition type="caution">
|
||||
@@ -530,6 +687,66 @@ A `security definer` function in an exposed schema is callable over the Data API
|
||||
|
||||
</Admonition>
|
||||
|
||||
### Avoid recursive policies
|
||||
|
||||
Two tables whose policies read each other never resolve. Postgres raises `42P17`, `infinite recursion detected in policy for relation`, and the query fails for every role the policies apply to.
|
||||
|
||||
Sharing features produce this shape. A policy on `lists` checks `list_members` to find who the list is shared with, and a policy on `list_members` checks `lists` to find who owns it:
|
||||
|
||||
```sql
|
||||
-- Reject: each policy reads the table the other one protects.
|
||||
create policy "members read lists" on lists for select
|
||||
to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1 from list_members m
|
||||
where m.list_id = lists.id and m.user_id = (select auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
create policy "members read membership" on list_members for select
|
||||
to authenticated
|
||||
using (
|
||||
exists (
|
||||
select 1 from lists l
|
||||
where l.id = list_members.list_id and l.owner_id = (select auth.uid())
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
Break the cycle with a [security definer function](#use-security-definer-functions). It reads the membership table as its owner, so the second policy never runs and the cycle is broken:
|
||||
|
||||
```sql
|
||||
create schema if not exists private;
|
||||
|
||||
create function private.user_list_ids()
|
||||
returns setof uuid
|
||||
language sql
|
||||
security definer
|
||||
set search_path = ''
|
||||
stable
|
||||
as $$
|
||||
select list_id from public.list_members
|
||||
where user_id = (select auth.uid())
|
||||
$$;
|
||||
|
||||
revoke execute on function private.user_list_ids() from public;
|
||||
grant usage on schema private to authenticated;
|
||||
grant execute on function private.user_list_ids() to authenticated;
|
||||
|
||||
create policy "members read lists" on lists for select
|
||||
to authenticated
|
||||
using ( id in (select private.user_list_ids()) );
|
||||
|
||||
create policy "members read membership" on list_members for select
|
||||
to authenticated
|
||||
using ( list_id in (select private.user_list_ids()) );
|
||||
```
|
||||
|
||||
The function filters on `(select auth.uid())`, so it returns only the caller's lists. A member who doesn't own the list still reads it, and a non-member reads nothing.
|
||||
|
||||
This works because the function runs as its owner, and a `security definer` function only skips RLS when its owner can. On Supabase the owner is `postgres`, which has `bypassrls`. A function owned by a role without `bypassrls`, or reading a table set to `force row level security`, evaluates the membership policy again and stays recursive.
|
||||
|
||||
### Bypassing Row Level Security
|
||||
|
||||
Use a [secret key](/docs/guides/getting-started/api-keys) for administrative tasks that need to bypass RLS. A secret key authorizes access through the `service_role` Postgres role, which has the `bypassrls` attribute. Never use a secret key in the browser or expose it to customers.
|
||||
|
||||
Reference in new issue
Block a user