F07 · Session 7 · Chapter 1
Rules inside the database
Screens can be bypassed and server code can be forgotten. A rule inside the database runs on every query, whoever sends it. This chapter explains PostgreSQL's row-level security: how a policy decides which rows each person can see and change, and who it does not apply to.
30 min3 chapters≈ 95 minutes2 exercises12-question assessment · 80% passes
By the end of this chapter you can
- Explain what row-level security does and why it is the last and strongest lock.
- Read a policy: the command it covers, USING and WITH CHECK.
- Explain how permissive and restrictive policies combine.
- Name the accounts that skip row-level security, and keep them away from phones, browsers and agents.
The lock that cannot be walked around
Session 6 wrote down who sees what. This session makes the database enforce it. PostgreSQL's row-level security (RLS) lets a table carry policies: rules that decide, for each row and each person, whether that row can be seen, added or changed [1]. Supabase, the database you set up in Session 1, is PostgreSQL, and its API passes each signed-in person's identity to the database so policies can use it [2].
Because the rule runs inside the database, it holds no matter how the request arrives: from your screens, from a script someone writes next year, from the connector, or from an AI agent. A screen that forgets to filter by line still gets only the rows the policy allows.
Reading a policy
A policy names a table, a command (SELECT, INSERT, UPDATE, DELETE or ALL), the database roles it applies to, and up to two conditions [3]:
- USING decides which existing rows the command can see or touch. Rows that fail it are simply invisible.
- WITH CHECK decides which new or changed rows are allowed. A row that fails it is refused with an error.
-- Operators may read downtime events on lines in their scope.
create policy downtime_events_operator_read on public.downtime_events
for select to authenticated
using (app.has_role('operator', line_id));
-- Operators may record downtime on lines in their scope, as themselves.
create policy downtime_events_operator_insert on public.downtime_events
for insert to authenticated
with check (app.has_role('operator', line_id) and created_by = (select auth.uid()));Read them aloud: signed-in people may select downtime events where they hold the operator role for that row's line. And: signed-in people may insert a downtime event only if they hold the operator role for its line and record it under their own identity. auth.uid() is Supabase's function for the signed-in person's id [2]; app.has_role is a helper chapter 2 generates from the matrix.
Knowledge check
A table has row-level security switched on and no policies. What can a signed-in operator see in it?
How policies combine
A table can have several policies for the same command. Ordinary (permissive) policies combine with OR: a row is allowed if any one of them allows it. Restrictive policies combine with AND: every one must also pass [3]. Grants from the matrix are permissive; rules that must always hold, such as no self-approval, are restrictive.
Knowledge check
A supervisor policy allows approving rows in their area. A restrictive policy says the approver must not be the creator. A supervisor tries to approve their own entry. What happens?
Who skips the rules
Some accounts are not bound by row-level security at all. Superusers and roles with the BYPASSRLS attribute skip it, and so does a table's owner unless the table is set to FORCE ROW LEVEL SECURITY [1]. In Supabase, the service role (the secret key) bypasses every policy; Supabase warns that it must never be used in a browser or shipped to users [4].
| Account | Bound by RLS? | Where it may be used |
|---|---|---|
| anon (publishable key, not signed in) | Yes | Browsers and phones: it can reach only what policies grant to anon, which in this course is nothing |
| authenticated (signed-in person) | Yes | Browsers and phones, through sign-in |
| Connector or agent account | Yes | The connector host or MCP server, with its own narrow role |
| service role (secret key) | No | Only reviewed server code that must act for the platform itself; stored as a secret in Vercel, never in a browser, chat or prompt |
Knowledge check
An AI agent needs to read downtime summaries. Which credential should it get?
References
- PostgreSQL Documentation: Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- Supabase Docs: Row Level Security. https://supabase.com/docs/guides/database/postgres/row-level-security
- PostgreSQL Documentation: CREATE POLICY. https://www.postgresql.org/docs/current/sql-createpolicy.html
- Supabase Docs: Understanding API keys. https://supabase.com/docs/guides/api/api-keys
F07 · Chapter 2 · The generator
Policies generated from the matrix
Nobody writes policies by hand. A generator reads access/matrix.json and writes one migration with row-level security on every table, one helper for scope, and one policy per filled cell. This chapter shows what it writes and how to check it.
30 min1 helper function1 policy per cellRLS on every table≈ 35 minutes hands-on
By the end of this chapter you can
- Map each exposure in the matrix to the policy it generates.
- Explain the scope helper and why it runs with its owner's rights.
- Generate the migration with your agent and check it against the matrix.
From cell to policy
Each filled cell in the matrix becomes exactly one policy; each blank cell becomes nothing, because row-level security already refuses what no policy grants. Rules such as no self-approval become restrictive policies. That is the whole mapping:
The scope helper
Every policy asks the same question: does the signed-in person hold this role, with a scope that covers this row's line? Rather than repeat the logic in every policy, the generator writes one helper function. It reads the people assignments and the lines table from Session 3's schema.
-- Generated from access/matrix.json by scripts/gen-rls.mjs. Do not edit by hand.
create schema if not exists app;
-- True when the signed-in person holds this role with a scope covering the given line.
create or replace function app.has_role(want text, row_line text)
returns boolean
language sql stable
security definer set search_path = ''
as $$
select exists (
select 1
from public.people p
join public.lines l on l.id = row_line
where p.user_id = (select auth.uid())
and p.role = want
and ( (p.scope = 'line' and p.line_id = l.id)
or (p.scope = 'area' and p.area_id = l.area_id)
or (p.scope = 'site' and p.site_id = l.site_id))
);
$$;- security definer runs the function with its owner's rights, so it can read the
peopletable even though ordinary users cannot. That is safe because the function only answers yes or no about the person asking. - set search_path = '' stops anyone substituting a look-alike table; every name is written in full. PostgreSQL's documentation recommends this for security definer functions [1].
- (select auth.uid()), wrapped in a select, lets PostgreSQL work it out once per query instead of once per row. Supabase recommends this for speed on large tables [2].
- The app schema is not exposed through the API, so nobody can call the helper directly from a browser [2].
The policies for one table
Here is everything the generator writes for downtime_events, from the matrix in Session 6. Compare it line by line with the matrix: every filled cell has a policy, every blank cell has none.
alter table public.downtime_events enable row level security;
alter table public.downtime_events force row level security;
-- operator: insert read
create policy downtime_events_operator_read on public.downtime_events
for select to authenticated using (app.has_role('operator', line_id));
create policy downtime_events_operator_insert on public.downtime_events
for insert to authenticated
with check (app.has_role('operator', line_id) and created_by = (select auth.uid()));
-- supervisor: read approve
create policy downtime_events_supervisor_read on public.downtime_events
for select to authenticated using (app.has_role('supervisor', line_id));
create policy downtime_events_supervisor_approve on public.downtime_events
for update to authenticated
using (app.has_role('supervisor', line_id) and status = 'pending')
with check (app.has_role('supervisor', line_id) and status = 'approved'
and approved_by = (select auth.uid()));
-- manager: read · maintenance: read
create policy downtime_events_manager_read on public.downtime_events
for select to authenticated using (app.has_role('manager', line_id));
create policy downtime_events_maintenance_read on public.downtime_events
for select to authenticated using (app.has_role('maintenance', line_id));
-- rule no_self_approval
create policy downtime_events_no_self_approval on public.downtime_events
as restrictive for update to authenticated
using (created_by <> (select auth.uid()));
-- approve may change only the approval columns
revoke update on public.downtime_events from authenticated;
grant update (status, approved_by, approved_at) on public.downtime_events to authenticated;Knowledge check
The matrix gives the manager read on downtime_events and nothing else. Which policies does the generator write for the manager on that table?
Every table, no exceptions
The generator switches row-level security on for every table in the schema, including tables no role can reach (such as an audit log written only by the database). A table without row-level security, in a schema the API exposes, is open to anyone holding the publishable key. Supabase's database advisors flag such tables in the dashboard [4], and chapter 3 adds a test that fails CI if one exists.
| Matrix entry | Generated | Effect |
|---|---|---|
| Table listed with cells | RLS on + one policy per cell | Exactly what the matrix says |
| Table listed with no cells | RLS on, no policies | Nobody but server code can reach it |
| Table missing from the matrix | Generator stops with an error | Fix the matrix before anything ships (Session 6's completeness test) |
You need: Your repository with access/matrix.json, your Supabase project, your AI coding agent
Your agent writes the generator and runs it; you check the output against the matrix, cell by cell.
Outcome: A generated migration that puts row-level security on every table and one policy per filled matrix cell, reviewed against the matrix.
Knowledge check
Why does the generator write force row level security as well as enable?
References
- PostgreSQL Documentation: CREATE FUNCTION (writing SECURITY DEFINER functions safely). https://www.postgresql.org/docs/current/sql-createfunction.html
- Supabase Docs: Row Level Security (helper functions and performance). https://supabase.com/docs/guides/database/postgres/row-level-security
- PostgreSQL Documentation: Privileges (column privileges). https://www.postgresql.org/docs/current/ddl-priv.html
- Supabase Docs: Database advisors. https://supabase.com/docs/guides/database/database-advisors
F07 · Chapter 3 · Tests
Proving every rule
A policy nobody has tested is a hope. This chapter writes the tests that prove each cell of the matrix both ways, recognises what a refusal looks like (often an empty result, not an error), and adds the CI check that no table is ever left open.
20 min2 tests per cell1 open-table checkruns on every pull request
By the end of this chapter you can
- Recognise how row-level security refuses: empty results for reads and updates, an error for inserts.
- Write tests that sign in as each role and check what must work and what must be refused.
- Add a CI check that fails if any table is missing row-level security.
What no looks like
Row-level security usually says no quietly. A query for rows outside your scope returns nothing, with no error. An update aimed at a row you cannot touch changes nothing, with no error. Only an insert (or an update whose result fails WITH CHECK) raises an error: new row violates row-level security policy, with the SQL error code 42501 [1].
A test for every cell
For every role and every table, the tests do what the matrix says should work and what it says should be refused, and check both. Session 4's synthetic data gives them rows on several lines and areas to work with. Supabase supports database tests written with pgTAP, run with its command-line tool [2] [3].
-- supabase/tests/downtime_events.test.sql (run with: supabase test db)
begin;
select plan(5);
-- Sign in as the line 2 operator from the seed data.
set local role authenticated;
set local request.jwt.claims = '{"sub": "00000000-0000-0000-0000-000000000002"}';
select ok((select count(*) from public.downtime_events where line_id = 'line-2') > 0,
'operator sees line 2 downtime');
select is((select count(*) from public.downtime_events where line_id = 'line-3'), 0::bigint,
'operator sees no line 3 downtime');
select lives_ok($$ insert into public.downtime_events (line_id, minutes, reason, created_by)
values ('line-2', 12, 'jam', '00000000-0000-0000-0000-000000000002') $$,
'operator records downtime on line 2');
select throws_ok($$ insert into public.downtime_events (line_id, minutes, reason, created_by)
values ('line-3', 12, 'jam', '00000000-0000-0000-0000-000000000002') $$,
'42501', null, 'operator cannot record downtime on line 3');
select is((select count(*) from public.work_orders), 0::bigint,
'operator sees no work orders');
select * from finish();
rollback;set local role authenticated and the request.jwt.claims setting make the database behave exactly as it does for that signed-in person, so auth.uid() returns their id [2]. Everything runs inside a transaction that is rolled back, so the test leaves no trace.
Let the generator write the tests
Writing every test by hand would drift from the matrix just as hand-written policies would. Ask your agent to generate the tests from the matrix too: for each role and table, one allowed and one refused check per action, using known users from the seed data. Then hand-write the few tests the generator cannot know about, such as self-approval.
Knowledge check
A test signs in as the line 2 operator, selects line 3 rows, and checks only that no error occurred. Is it a good test?
No table left open
One more check guards the whole schema. PostgreSQL's catalogue records, for each table, whether row-level security is on. A test that lists tables in the exposed schema without it should always return nothing [4]:
-- Fails if any table the API can reach has row-level security off.
select is(
(select count(*) from pg_tables where schemaname = 'public' and not rowsecurity),
0::bigint,
'every public table has row-level security');You need: Your repository with the generated migration, your AI coding agent, GitHub
Your agent writes the tests from the matrix; you read them and make sure each refused case checks for an empty result or error 42501.
Outcome: CI proves every cell of the matrix both ways, refuses self-approval, and fails if any table lacks row-level security.
Knowledge check
Someone adds a table directly in the Supabase dashboard and forgets row-level security. What catches it?
Knowledge check
The matrix changes: maintenance may now update work orders. What changes in the repository?
References
- PostgreSQL Documentation: Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
- Supabase Docs: Testing your database (pgTAP and supabase test db). https://supabase.com/docs/guides/database/testing
- pgTAP: unit testing for PostgreSQL. https://pgtap.org/
- PostgreSQL Documentation: pg_tables system view. https://www.postgresql.org/docs/current/view-pg-tables.html
Chapter 4 · 12 questions · 80% passes
Final assessment
Twelve questions across the element. Score 80% (10 of 12) to pass. Your LMS records your score and each answer; you can review the chapters and try again.
15 min12 questions≈ 15 minutesRetake allowed
Your result
CivOps AI Academy
Access Rules from the Matrix: Row-Level Security on Every Table, Proven by Tests
Element F07 complete · Learner
Your LMS records this completion. For the CivOps Foundation certificate, finish the Foundation Course at https://civops.io/learn.