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:
static canon the resource — an inline closure. Always available, never touches the container.- The resource’s Bouncer policy —
static policy, consulted throughctx.bouncer. config.authorization.fallback—devFallbackoutside 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.abilitiescarries the real verdicts, resolved once per request. Rowcanprojections reuse that resolution (see below). -
Nodes gated with
requiresAbility()are omitted:F.select('ownerId').requiresAbility('edit')requiresAbilityis acanSee-shaped gate, which is what makes the omission total: the node disappears from the descriptor tree, fromstate, 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,visibleandrequiresAbilityeach 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 passA node gated only by
requiresAbilitystays on the §9.4 cached path (the ability hash is part of the cache key). Adding a rawcanSeemoves 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 owntable()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/columnsallowlists and the default sort; a gated-away filter leaves the wire block and thefilters[<key>]allowlist, so a hand-written query key is silently dropped rather than applied. A rawcanSeeon 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.