AvelonDocs

Guide 06

Authorization

06. Authorization

Two questions, two directories, two enforcement points. Conflating them is why authorization bugs in most codebases are found in production.

QuestionLives inFailure looks like
PolicyMay this actor perform this actionapp/Policies/403, hidden button
WardWhich rows may this actor reach at allapp/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.