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:
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:
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
# Run all policy tests in the schema file
scribase schema test schema.scribase
# Compile first to preview the generated SQL
scribase schema compile schema.scribaseOutput:
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 failedA 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:
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 failFix the gap by adding a test:
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:
NULLIF(current_setting('request.jwt.claim.sub', true), '') IS NOT NULLFORCE 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
# .github/workflows/schema.yml
- name: Test RLS policies
run: scribase schema test schema.scribaseA non-zero exit code fails the workflow. If you maintain an Aegis fixture as
well, add a second step that runs aegis test --mutate.