Skip to content

Authorization

Enforce resource and record abilities consistently across routes and projections.

Every ability decision in Adonia goes through one interface, resolves in one order, and is enforced at three points. There is no second path.

The vocabulary

viewList | view | create | edit | delete | restore | forceDelete
runAction:<slug> | accessLens:<slug>

The seven resource abilities are what descriptor.resource.abilities (protocol §3) and a record’s can (protocol §6) project. The parameterized two address a single action or lens; actions and lenses themselves are Phase 3, but the abilities resolve through the same chain today.

view, edit, delete, restore and forceDelete are per record; viewList and create address the resource, so passing a record to them is meaningless and the authorizer drops it.

Resolution order

For every check, in order — a step that has no opinion continues the chain, a step that answers ends it:

  1. static can on the resource — an inline closure. Always available, never touches the container.
  2. The resource’s Bouncer policystatic policy, consulted through ctx.bouncer.
  3. config.authorization.fallbackdevFallback outside production, fallback (shipped default: deny) in it.

An inline can returning false is a deny. A policy returning false is a deny. Neither is rescued by an allow fallback — only silence continues the chain.

export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  // (1) inline overrides — win over everything
  static override can = {
    delete: (ctx: HttpContextLike, record?: unknown) =>
      record instanceof Post &&
      ctx.auth.user instanceof User &&
      record.authorId === ctx.auth.user.id,
  }

  // (2) the policy — consulted for every ability `can` does not override
  static override policy = () => import('#policies/post_policy')

  schema(s: SchemaBuilder): SchemaComponent[] {
    return [s.section('Content').components([F.text('title').required()])]
  }
}

HttpContextLike is the structural context the compiler passes (§21), so auth.user arrives as unknown — narrow it before reading a column off it.

Policy method names

Method names match ability names: viewList(), view(user, post), create(user), … A parameterized ability tries the camel-cased specialization first and then the generic handler, which receives the slug:

ability tried first then
delete delete(user, post)
delete, with no record deleteAny(user)
runAction:publish runActionPublish(user, post) runAction(user, 'publish', post)
accessLens:trashed accessLensTrashed(user) accessLens(user, 'trashed')

Bouncer injects the user itself; the record (when the ability has one) is the last argument. A policy that implements neither candidate has no opinion, and the config fallback answers.

Record abilities without a record. descriptor.resource.abilities and the requiresAbility() gates that read it are compiled on pages that hold no record — index, create, store, and the two field.* callbacks. view, edit, delete, restore and forceDelete can only mean “over anything here at all” there, so the chain asks a resource-wide method instead: editAny(user), deleteAny(user), … (Laravel’s viewAny convention; viewList is this framework’s spelling of viewAny). The record-scoped edit(user, post) is deliberately not invoked with post === undefined — that is the ordinary way a policy is written, and calling it would turn every index page into a TypeError. Declare <ability>Any when the policy wants a say; leave it out and the fallback answers, with a dev diagnostic naming what was missing.

Method discovery. Both the prototype form (delete(user, post) {}) and the class-field form (delete = (user, post) => …, an own property of the instance) are found. The instance probe runs only when the prototype probe missed, and constructs the policy at most once per class; a constructor that demands container injection simply falls back to the prototype.

A declared policy that answers nothing is reported, not silent. When static policy resolves but no candidate matches, the bridge emits a dev diagnostic (module: 'adonia', deduped per policy × ability) before returning “no opinion”. §12 makes only silence continue the chain, and silence is otherwise indistinguishable from “there is no policy” — under the shipped devFallback: 'allow' a written, denying policy nobody can find reads as a grant.

@adonisjs/bouncer is optional

It is an optional peer dependency and is never imported — not statically, not dynamically. The bridge reads ctx.bouncer structurally, so an app without Bouncer installed simply skips step 2: a declared static policy is never consulted and every ability falls through to the fallback. Nothing throws.

The three enforcement points

All mandatory, all through the same Authorizer.

1. Route entry

Each §10.1 handler authorizes its page-level ability before doing work:

handler ability
index viewList
show view
create, store create
edit, update edit
destroy delete
restore restore
field.options, field.upload HMAC-bound create or record-scoped edit

Per-record abilities are checked after the record loads — a policy cannot judge a record it has not seen.

The two field.* callbacks serve create and edit forms and nothing else. Each descriptor URL signs its exact mode, route identity, and (for edit) record id with the app key. The handler verifies that envelope first; edit callbacks then load the record through the resource detail scope and evaluate edit, ability gates, and canSee against that row before invoking author code. Missing or tampered signatures are a 403, and create callbacks deliberately carry no record id.

2. Descriptor compilation

Two things happen while the descriptor is built:

  • descriptor.resource.abilities carries the real verdicts, resolved once per request. Row can projections reuse that resolution (see below).

  • Nodes gated with requiresAbility() are omitted:

    F.select('ownerId').requiresAbility('edit')

    requiresAbility is a canSee-shaped gate, which is what makes the omission total: the node disappears from the descriptor tree, from state, from the compiled validator and from the fill set, in the same pass. A gated field cannot be rendered, submitted or mass-assigned (protocol §9 invariant 1, TECH_SPEC §19).

    Gates are additive. canSee, visible and requiresAbility each ADD a gate; the node survives only when every one of them passes, in any declaration order. Nothing a later call declares can relax an earlier restriction.

    F.select('ownerId').requiresAbility('edit').canSee(isAdmin) // BOTH must pass

    A node gated only by requiresAbility stays on the §9.4 cached path (the ability hash is part of the cache key). Adding a raw canSee moves the whole node off it — the visibility scope only ever strengthens, never relaxes.

    On the index page the same gate removes the field’s derived column and its allowlist keys, so the value is neither rendered nor orderable through ?sort=/?columns= (§8.3/§19). A resource that declares its own table() owns its column set outright and gates through the column and filter DSL, which carries the same two methods:

    t.columns([C.number('salary').money('USD').sortable().requiresAbility('edit')])
     .filters([Filter.ternary('trashed').requiresAbility('restore')])

    A gated-away column leaves the wire table, the sortable/searchable/ columns allowlists and the default sort; a gated-away filter leaves the wire block and the filters[<key>] allowlist, so a hand-written query key is silently dropped rather than applied. A raw canSee on a column or filter additionally keeps the resolved table out of the shared index-plan memo, since a closure’s inputs are not in the cache key.

Navigation is gated the same way: the §9.1 share hook omits any resource the request cannot viewList.

3. The mutation guard

store/update re-authorize inside the write transaction, immediately before the write. This is the TOCTOU guard: between rendering a form and receiving its submit — or between the route check and the write within one request — an ability can be revoked by a role change or an ownership transfer. Re-checking inside the transaction means the denial and the write cannot interleave: the throw aborts the transaction, Lucid rolls it back, and the caller gets a 403 over an unchanged row.

Per-record can and the query budget

Every serialized record — index rows, edit/detail record — carries exactly the five §12 record abilities: view, edit, delete, restore, forceDelete. viewList and create address the resource, not the row, and never ride along; read them from descriptor.resource.abilities. The projection happens in one place (recordCan in table/serializer.ts), so no caller can widen it.

resolveRowAbilities() resolves record-independent abilities once for the whole page and re-evaluates only the five per-record abilities against rows already in memory. It returns a synchronous lookup, which is what lets the row serializer stay synchronous and index serialization stay O(rows) with zero per-row queries (TECH_SPEC §18).

Client-side can is never authoritative — it enables and disables row actions and links; every route re-authorizes.

Row-level scoping (static query)

Abilities answer “may this user do X”; static query() (§6.1/§8.3 base) answers “which rows exist for this user at all”. It is enforced on every row-touching path, not only the index:

export default class ScopedPostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'

  static override query(query: ModelQueryBuilderContract<LucidModel>, ctx: HttpContextLike) {
    const user = ctx.auth.user
    if (user === null || typeof user !== 'object' || !('id' in user)) {
      return query.whereRaw('1 = 0')
    }
    const authorId = user.id
    if (typeof authorId !== 'number' && typeof authorId !== 'string') {
      return query.whereRaw('1 = 0')
    }
    return query.where('authorId', authorId)
  }

  schema(s: SchemaBuilder): SchemaComponent[] {
    return [s.section('Content').components([])]
  }
}
path scoped by
index query() then indexQuery()
show, edit, update, destroy, restore query() then detailQuery()
relation option list + its exists check query()

A record outside the scope is a 404, not a 403: “exists but is not yours” and “does not exist” must be indistinguishable from outside (§17).

indexQuery() shapes the list only — a deep link to /:resource/:id still resolves through it — so row-level access belongs in query(), never in indexQuery(). detailQuery() covers all five single-record paths together, because a scope that hid a record from the detail page but not from PUT would be a hole rather than a feature.

Denials

A denied check raises AbilityDeniedException, an UnauthorizedException (E_ADONIA_UNAUTHORIZED, HTTP 403) carrying the ability and the enforcement point as fields alongside the message. Rendering follows the error model: the panel-chromed 403 page for browsers, { code, message } for JSON endpoints.

Configuration

// config/adonia.ts
import { defineConfig } from '@adonia/core'

export default defineConfig({
  authorization: {
    fallback: 'deny', // production default
    devFallback: 'allow', // shipped default outside production
  },
})

Two boot-time warnings (§20, aggregated by adonia:doctor):

  • fallback: 'allow' in production — every unresolved ability is granted.
  • devFallback: 'allow' — the shipped default. Convenient before a panel has policies, and worth naming because it makes every policy test pass. Set it to 'deny' to exercise policies the way production will.

No authorization layer installed

adonia.authorizer is bound by AdoniaProvider at boot, so a booted app always has one. Where the binding is absent — a unit test, a host route calling a controller directly — §12 defines the meaning as “no authorization layer installed”: everything is granted, requiresAbility() gates pass, and navigation stays visible. Consumers must degrade this way rather than treating an unbound authorizer as a failure.

Testing

@adonia/core/testing’s makeFakeContext({ bouncer }) accepts a structural Bouncer double, so step 2 is testable without installing the optional peer. The conformance suite is packages/core/tests/authorization.spec.ts: the authorization matrix (every ability × every enforcement point × allow/deny), the resolution-order precedences, the degradation path, per-record divergence, and the transactional rollback.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close