06. Authorization
Two questions, two directories, two enforcement points. Conflating them is why authorization bugs in most codebases are found in production.
| Question | Lives in | Failure looks like | |
|---|---|---|---|
| Policy | May this actor perform this action | app/Policies/ | 403, hidden button |
| Ward | Which rows may this actor reach at all | app/Wards/ | empty result, or a data leak if wrong |
Policies
Evaluated in application code. Produce 403s and drive UI affordances.
export const PostPolicy = definePolicy(Post, {
viewAny: () => true,
view: () => true,
create: (actor) => actor !== null,
update: (actor, post) => actor?.id === post.user_id,
delete: (actor, post) => actor?.id === post.user_id,
})
Registered in app/Providers/AuthServiceProvider.ts. Used from controllers and views:
await this.authorize('update', post) // throws Forbidden
if (await can('update', post)) { ... } // boolean, for rendering
Wards
Declared once in TypeScript. Enforced twice.
export const PostWard = defineWard(Post, {
read: (actor) => actor
? { or: [{ published: true }, { user_id: actor.id }] }
: { published: true },
insert: (actor) => actor ? { user_id: actor.id } : false,
update: (actor) => actor ? { user_id: actor.id } : false,
delete: (actor) => actor ? { user_id: actor.id } : false,
})
A ward returns a predicate object narrowing the row set, true to allow all rows, or false to deny everything. In the IR (D25), true compiles to const true, false to const false, and the injected predicate is ANDed with the query's own where. A const false ward short-circuits without a database round trip.
Two enforcement paths
1. reeve ward:sync compiles the ward to row-level security policies and writes them into a migration. The database enforces it even when application code is wrong, which is the only protection that survives a bug in your own framework. 2. The query builder applies the same predicate as QueryIR.ward, so Post.query() is already scoped. Tests behave identically without a live policy, and results are consistent across environments.
A driver declares which paths it supports. Supabase and Postgres do both. A driver reporting rowSecurity: false gets path 2 only, and ward:sync prints exactly what it cannot enforce at the database rather than silently doing half the job.
reeve ward:check
Fails CI when:
- A model has no ward
- A migration adds a table with no ward
- A ward and its compiled policies have drifted
- A ward's compiled policy and its client-side predicate disagree on a fixture set
This is the single most valuable command in the framework, because a missing ward is a data leak rather than a bug.
Service-role access
Deliberately ugly and deliberately narrow:
await Scrivener.unwarded(Post).query().get()
Bailiff restricts unwarded to app/Errands/ and database/seeds/, so it can never appear in a controller, a view, or an action reachable from a request.
Why the separation matters
Most stacks handle both questions in one policy class plus hand-written query scopes, and the two drift. A policy says only the author may update a post, and a listing query forgets the where, so every user sees every draft. Nothing catches it because the policy tests pass.
In Avelon the listing cannot forget, because scoping is injected into the IR, and the database refuses regardless, because the ward compiled to a policy. Two independent mechanisms from one declaration.
Correctness
The ward compiler is the only part of the framework where paranoia is proportionate. A predicate that compiles to the wrong policy is a data leak with no error message.
Required before shipping:
- Property-based tests generating random predicates, comparing client-side scoping against database
enforcement on identical fixtures. Any disagreement is a failure.
- Tests run against a live database, never a mock.
- A published list of predicate shapes the compiler refuses, with a clear error, rather than compiling
something approximate.