scribasedocs

Aegis Policy Compiler

Aegis Policy Compiler

Aegis is the Scribase policy compiler and test harness. It verifies that your row-level security policies do what you intend before they reach production.

The problem Aegis solves

Postgres RLS policies are SQL expressions evaluated at runtime. A policy bug — a typo, a wrong column reference, a missing WITH CHECK — silently passes syntax checking but causes data leaks or access denials at runtime.

Aegis turns policy correctness into a compile-time property by letting you write tests alongside your schema and run them in CI.

Policy syntax in schemas

Declare policies inline with your model:

scribase
model orders {
  id         uuid primary key default gen_random_uuid()
  user_id    uuid not null references auth.users
  status     text not null default 'pending'
  total      numeric(12,2) not null

  policy "owner can read" on select
    using (auth.uid() = user_id)

  policy "owner can insert" on insert
    with check (auth.uid() = user_id)

  policy "owner can update" on update
    using (auth.uid() = user_id)
    with check (auth.uid() = user_id and status != 'paid')

  policy "service role has full access" on all
    using (auth.role() = 'service_role')
    with check (auth.role() = 'service_role')
}

Writing policy tests

Tests declare a JWT context and assert what the authenticated user can and cannot do:

scribase
test "order owner can read their own order" {
  as user { uid: "user-alice" }
  expect select from orders where user_id = "user-alice" to succeed
}

test "order owner cannot read another user's order" {
  as user { uid: "user-alice" }
  expect select from orders where user_id = "user-bob" to return empty
}

test "unauthenticated user cannot insert" {
  as anon
  expect insert into orders { user_id: "user-alice", total: 99 } to fail
}

test "owner cannot update paid order" {
  as user { uid: "user-alice" }
  given orders { id: "ord-1", user_id: "user-alice", status: "paid", total: 99 }
  expect update orders set status = "pending" where id = "ord-1" to fail
}

test "service role can read all orders" {
  as service_role
  expect select from orders to return all
}

Running tests

Terminal
# Run all policy tests in the schema file
scribase schema test schema.scribase

# Compile first to preview the generated SQL
scribase schema compile schema.scribase

Output:

running 5 policy tests...

  order owner can read their own order      PASS
  order owner cannot read another user's    PASS
  unauthenticated user cannot insert        PASS
  owner cannot update paid order            PASS
  service role can read all orders          PASS

5 passed, 0 failed

A failing test exits with code 1, suitable for use in CI.

Mutation testing

A passing suite only proves something if it can fail. The standalone Aegis harness adds mutation testing: it weakens each compiled policy on purpose (for example, inverting an ownership check) against a real Postgres, and requires at least one isolation test to catch every mutation. A mutation nobody catches is reported as a coverage gap.

Mutation testing is not part of scribase schema test; run it with the Aegis CLI, which ships alongside the Scribase source and uses its own aegis.yaml schema and fixture format:

Terminal
aegis compile aegis.yaml   # emit the SQL Aegis will test
aegis test                 # prove user A cannot read user B
aegis test --mutate        # prove each proof can fail

Fix the gap by adding a test:

scribase
test "owner can update pending order" {
  as user { uid: "user-alice" }
  given orders { id: "ord-2", user_id: "user-alice", status: "pending", total: 50 }
  expect update orders set total = 60 where id = "ord-2" to succeed
}

Known Postgres behaviors

Aegis is aware of several Postgres RLS behaviors that are easy to get wrong:

RETURNING pulls SELECT policy

An INSERT ... RETURNING * checks both the INSERT and SELECT policies. If the row you just inserted would not be visible to the current user via SELECT, the RETURNING clause raises insufficient_privilege. Write separate policies for INSERT and SELECT, or accept that RETURNING may be empty.

current_setting returns empty string, not NULL

current_setting('request.jwt.claim.sub', true) returns '' (empty string) when the setting is unset — not NULL. A check like current_setting(...) IS NOT NULL always passes.

Use NULLIF:

SQL
NULLIF(current_setting('request.jwt.claim.sub', true), '') IS NOT NULL

FORCE RLS does not constrain superusers

A table owned by a superuser role bypasses FORCE ROW LEVEL SECURITY. Scribase applies schemas as the scribase_migrator role (which has BYPASSRLS) but tables are owned by application roles. Run schema compile to confirm ownership.

Integrating with CI

YAML
# .github/workflows/schema.yml
- name: Test RLS policies
  run: scribase schema test schema.scribase

A non-zero exit code fails the workflow. If you maintain an Aegis fixture as well, add a second step that runs aegis test --mutate.