# 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

```sh
# 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:

```sh
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`.
