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:
Miranda LimonczenkoandClaude Opus 5 authored and GitHub committed 2026-08-24 09:23:33 -07:00
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.