Skip to the lesson
CivOps AI Academy · F07Access Rules from the Matrix: Row-Level Security on Every Table, Proven by Tests
0%

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].

Where row-level security decidesA signed-in request carries a token through the API to PostgreSQL, which identifies the user, applies the policies and returns only the rows the policy allows. Phone browsersigned in as anoperator, line 2tokenPlatform APIpasses the tokento the databasePostgreSQL1. who is asking? auth.uid()2. which policies apply?3. keep rows where the policy says yes4. refuse new rows that failRowsline 2 onlyThe rule runs inside the database, on every query, whoever sends it: screen, script, connector or AI agent.
Where the decision is made. The phone, the API and any script only pass the request along. The database decides, on every query.

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.
USING and WITH CHECKLeft: USING filters existing rows, so a line 2 operator sees only line 2 rows. Right: WITH CHECK accepts a new line 2 row and refuses a line 3 row. USING: which existing rows you can touchline-2 · jam · 12 minvisibleline-3 · changeover · 30 mininvisibleline-2 · no material · 8 minvisibleline-3 · fault · 45 mininvisibleWITH CHECK: which new or changed rows are allowedinsert line-2 · jam · 5 minacceptedinsert line-3 · jam · 5 minrefusedAn operator on line 2 can neither seenor create line 3's records.
USING and WITH CHECK. One filters what you can see and touch; the other gates what you can write.
Two policies, read aloud
-- 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.

How policies combineTwo permissive approve policies combine with OR; the result must also pass a restrictive no-self-approval policy, combined with AND. Supervisor approvepermissive · own areaQuality lead approvepermissive · own siteORany oneANDmust also passRestrictive: no self-approvalapprover ≠ creatorAllowedif any permissive passesand every restrictive passesGrants add up with OR; limits that must always hold are restrictive and add with AND
OR for grants, AND for limits. A supervisor policy and a quality lead policy each grant approval; the no-self-approval rule must pass as well.

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].

Who row-level security applies toSigned-in users, anonymous visitors and machine roles are bound by the policies; the service role, table owners without FORCE and superusers skip them. Rules applyauthenticatedevery signed-in personanonvisitors, not signed inconnector rolea machine accountRules skipped: keep off phones, browsers and agentsservice role / secret keyserver code onlytable ownerunless FORCE is setsuperuser, BYPASSRLSadministration only
Who the rules apply to. Everything a phone, a browser, a connector or an agent uses sits on the left. The right-hand accounts stay in reviewed server code and administration.
AccountBound by RLS?Where it may be used
anon (publishable key, not signed in)YesBrowsers and phones: it can reach only what policies grant to anon, which in this course is nothing
authenticated (signed-in person)YesBrowsers and phones, through sign-in
Connector or agent accountYesThe connector host or MCP server, with its own narrow role
service role (secret key)NoOnly reviewed server code that must act for the platform itself; stored as a secret in Vercel, never in a browser, chat or prompt
1lock that holds whatever sends the query
0rows visible with RLS on and no policy
neverthe secret key in a browser, chat or agent

Knowledge check

An AI agent needs to read downtime summaries. Which credential should it get?

References

  1. PostgreSQL Documentation: Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
  2. Supabase Docs: Row Level Security. https://supabase.com/docs/guides/database/postgres/row-level-security
  3. PostgreSQL Documentation: CREATE POLICY. https://www.postgresql.org/docs/current/sql-createpolicy.html
  4. 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:

From matrix cell to policyEach exposure maps to one policy: read to SELECT, insert to INSERT with a check, update to UPDATE, approve to a limited UPDATE, rules to restrictive policies, and a blank cell to no policy at all. Matrix cellPolicyConditionreadFOR SELECTUSING in scopeinsertFOR INSERTWITH CHECK in scope and created_by = youupdateFOR UPDATEUSING and WITH CHECK in scopeapproveFOR UPDATEpending → approved, in scope, only status columnsruleAS RESTRICTIVEe.g. created_by ≠ you, for approve(blank)no policyRLS on: nothing is allowed
Matrix cell to policy. Six cases cover every matrix in this course.

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.

The scope helper
-- 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 people table 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.

supabase/migrations/…_rls_from_matrix.sql (one table)
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 entryGeneratedEffect
Table listed with cellsRLS on + one policy per cellExactly what the matrix says
Table listed with no cellsRLS on, no policiesNobody but server code can reach it
Table missing from the matrixGenerator stops with an errorFix the matrix before anything ships (Session 6's completeness test)
Exercise · Generate your policies35 minutes

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

  1. PostgreSQL Documentation: CREATE FUNCTION (writing SECURITY DEFINER functions safely). https://www.postgresql.org/docs/current/sql-createfunction.html
  2. Supabase Docs: Row Level Security (helper functions and performance). https://supabase.com/docs/guides/database/postgres/row-level-security
  3. PostgreSQL Documentation: Privileges (column privileges). https://www.postgresql.org/docs/current/ddl-priv.html
  4. 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].

What a refusal looks likeReads and updates outside the policy return zero rows without an error; an insert outside the policy raises error 42501. SELECT line 3 rows0 rowsno error: the rows are simply invisibleINSERT a line 3 rowerror 42501new row violates row-level security policyUPDATE a line 3 row0 rows changedno error: nothing matchedApprove own entry0 rows changedthe restrictive policy hides it from the updateTests must check for empty results as well as errors: a silent zero is how row-level security says no.
What a refusal looks like. Two silent zeros and an error. A test that only looks for errors would pass a broken rule.

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].

A test for every cellA grid of four roles by three actions on downtime_events; every cell has a test proving it is allowed or refused. downtime_eventsreadinsertapproveoperator✓ allowed, and tested✓ allowed, and tested✗ refused, and testedsupervisor✓ allowed, and tested✗ refused, and tested✓ allowed, and testedmanager✓ allowed, and tested✗ refused, and tested✗ refused, and testedconnector✗ refused, and tested✗ refused, and tested✗ refused, and testedEvery cell, both ways: what must work and what must be refused, for every role.
Both ways, every cell. Green cells prove access works; red cells prove refusal holds. A change to the matrix changes the grid and its tests together.
Five checks for one role
-- 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]:

The open-table check
-- 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');
Exercise · Prove your rules in CI30 minutes

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

  1. PostgreSQL Documentation: Row Security Policies. https://www.postgresql.org/docs/current/ddl-rowsecurity.html
  2. Supabase Docs: Testing your database (pgTAP and supabase test db). https://supabase.com/docs/guides/database/testing
  3. pgTAP: unit testing for PostgreSQL. https://pgtap.org/
  4. 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

Choose one answer for each question, then submit. You will see the right answer and why for every question.

1. Why is row-level security the strongest of the access locks?
2. Row-level security is on and a table has no policies. What can a signed-in user do with it?
3. In a policy, what does USING decide?
4. How do two permissive policies on the same table and command combine?
5. Which should be a restrictive policy?
6. Which credential skips every row-level security policy in Supabase?
7. Why is the scope helper a security definer function with an empty search_path?
8. A blank cell in the matrix generates:
9. Why does approve also need column privileges?
10. A line 2 operator selects line 3 rows. What does row-level security return?
11. What does the open-table check look for?
12. Maintenance is granted update on work orders. What should one pull request contain?