11.1 Overview
ADL uses role-based access control (RBAC) to govern who can perform which operations on which resources and which fields. Permissions are declared in the permissions: block of each resource and are enforced by the middleware chain (§4.2.5) on every external HTTP request.
The permission system operates at three levels, each more granular than the last:
- Operation-level — which roles can list, get, create, update, or delete records of this resource
- Field-level — which roles can read or write specific fields within a resource
- Transition-level — which roles can execute specific state machine transitions
Permissions MAY be declared as role arrays (simple) or AEL expressions (complex). Both forms are fully supported and can coexist within the same resource.
11.2 Operation-Level Permissions
Operation-level permissions control access to the five CRUD operations:
resources:
Post:
permissions:
list: ["*"]
get: ["*"]
create: [authenticated]
update: [owner, editor, admin]
delete: [admin]
Permission Declaration
Each operation key accepts either a role array or an AEL expression string:
# Role array form
update: [owner, editor, admin]
# AEL expression form
update: "isOwner() || hasAnyRole('editor', 'admin')"
Both forms produce identical enforcement behavior. The role array form is simpler and faster (direct string comparison). The AEL expression form is more powerful (conditional logic, state-dependent access). See §11.7 for AEL expressions.
Built-In Role Identifiers
| Identifier | Meaning | Enforcement |
|---|---|---|
* | Public — no authentication required | JWT validation skipped entirely |
authenticated | Any user with a valid JWT | JWT must be present and valid; no role check |
owner | The user who created or owns the record | JWT user_id matched against the resource’s owner FK |
admin | User with the admin role | JWT roles array must include "admin" |
| Any other string | Custom role name | JWT roles array must include the string |
Default Permissions
If a resource has no permissions: block, the default is admin-only for all operations:
# Implicit when permissions: is omitted
permissions:
list: [admin]
get: [admin]
create: [admin]
update: [admin]
delete: [admin]
If a specific operation is omitted from the permissions: block, it defaults to [admin].
Permission Evaluation
When an external request arrives, the middleware evaluates permissions in this order:
- Is the operation
["*"]? → Allow (skip authentication entirely). - Is the JWT present and valid? → If not, 401 Unauthorized.
- Is the operation
[authenticated]? → Allow (any valid JWT suffices). - Does the user’s role list include any role in the permission array? → Allow.
- Is
ownerin the permission array and does the user’s ID match the record’s owner field? → Allow. - Is the permission an AEL expression? → Evaluate with the permission context. If
true, allow. - Otherwise → 403 Forbidden.
11.3 Field-Level Permissions
Field-level permissions restrict which roles can read or write specific fields. They are declared in the permissions.fields block:
permissions:
list: [authenticated]
get: [authenticated]
update: [owner, admin]
fields:
status:
write: [editor, admin]
salary:
read: [admin, hr, owner]
write: [admin, hr]
internalNotes:
read: [admin, manager]
write: [admin]
secretKey:
read: [admin]
write: [admin]
mask: true
Read Restrictions
When a field has a read restriction, it is stripped from API responses for users whose role is not in the list. The field does not appear in the response at all — not as null, not as an empty string. It is absent from the JSON object.
If mask: true is set, the field appears in the response but its value is replaced with "***" for users who lack read permission. This is useful for fields like SSN where the user should know the field exists but should not see its value.
Write Restrictions
When a field has a write restriction, the middleware checks submitted fields on create and update:
- If the user’s role is in the
writelist → the field is accepted. - If the user’s role is NOT in the
writelist and the field is NOTrequired→ the field is silently ignored. The update proceeds without it. - If the user’s role is NOT in the
writelist and the field ISrequiredon create → 403 Forbidden withFIELD_FORBIDDENerror code.
{
"status": "error",
"code": 403,
"error_code": "FIELD_FORBIDDEN",
"error_message": "Field 'status' is not writable by role 'author'"
}
Inheritance
Field-level permissions narrow the operation-level permissions — they never widen them. A user must first pass the operation-level check (can they update this resource at all?) before field-level checks apply (can they update this specific field?).
If a field has no read restriction, it inherits the operation-level get/list permission. If a field has no write restriction, it inherits the operation-level create/update permission.
11.4 The owner Role
The owner role is a special dynamic role that resolves at request time by comparing the authenticated user’s ID against the record’s owner field.
Owner Resolution
The platform resolves ownership by matching Request.identity.user_id against the resource’s owner FK field. The owner FK is identified by convention:
- A field named
createdById(from theAuditMetashared object) - A field named
userId - A field named
authorId - The first
belongsTorelationship targeting aUserresource
If none of these fields exist on the resource, the owner role never matches — including owner in a permission list has no effect.
Owner on Create
On create operations, the owner role is not applicable — the record does not exist yet, so there is no owner to match against. A permission of create: [owner] effectively means “nobody” for create. Use create: [authenticated] instead.
Owner on List
On list operations, the owner role filters the result set rather than granting/denying access to the entire list. When list: [owner], the query automatically adds a WHERE createdById = :userId filter. The user sees only their own records.
When list includes both owner and other roles (e.g., list: [owner, admin]), users with the admin role see all records; users with only the owner role see their own.
11.5 Conditional Permissions
Conditional permissions vary access based on the record’s current state. They are declared in the permissions.conditional block:
permissions:
update: [owner, editor, admin]
conditional:
update:
draft: [owner, editor]
submitted: [admin, reviewer]
published: [admin]
delete:
draft: [owner, admin]
Evaluation
Conditional permissions override the operation-level permission for records in the specified state. The evaluation order:
- Check if
conditional.{operation}exists for the requested operation. - If it exists, look up the record’s current state machine value.
- If a matching state entry exists → use that permission list instead of the operation-level list.
- If no matching state entry exists → fall back to the operation-level permission.
In the example above, a draft post can be updated by [owner, editor]. A published post can only be updated by [admin]. A post in any other state falls back to [owner, editor, admin].
Conditional + AEL
Conditional permission entries MAY use AEL expressions:
conditional:
update:
draft: "isOwner() || hasRole('editor')"
published: "hasRole('admin') && !fieldChanged('authorId')"
11.6 Transition Permissions
State machine transitions have their own permission layer, declared in permissions.transitions:
permissions:
transitions:
submit: [owner, editor]
approve: [editor, admin]
publish: [admin]
archive: [admin]
reject: [editor, admin]
withdraw: [owner]
If a transition is not listed in permissions.transitions, it inherits the update operation permission. Transition permissions are checked by the state machine handler before evaluating guards — a user who lacks transition permission receives 403 before the guard even runs.
Transition permissions can also be declared inline in the state machine definition (§12). When both exist, the permissions.transitions block takes precedence.
11.7 AEL Permission Expressions
For permission logic that role arrays cannot express, ADL supports AEL expressions as permission values:
permissions:
update: "isOwner() || hasAnyRole('editor', 'admin')"
delete: "hasRole('admin') && !inState('archived')"
Available Functions
AEL permission expressions have access to the permission evaluation context (§36.4):
| Function | Signature | Description |
|---|---|---|
hasRole(role) | (string) → boolean | User has the specified role |
hasAnyRole(r1, r2, ...) | (...string) → boolean | User has at least one of the listed roles |
isOwner() | () → boolean | User ID matches the record’s owner field |
isAuthenticated() | () → boolean | Request has a valid JWT |
userId() | () → string | Current user’s ID |
inState(state) | (string) → boolean | Record is in the specified state machine state |
field(name) | (string) → any | Get the record’s field value |
hasField(name) | (string) → boolean | Record has a non-null value for the field |
Available Context Variables
| Variable | Type | Description |
|---|---|---|
user | object | Authenticated user: user.id, user.username, user.roles, user.claims |
record | object | Target record (empty object on list/create) |
op | string | Operation name: "list", "get", "create", "update", "delete" |
Examples
# Only the author can edit during draft; anyone with editor role after submission
update: "inState('draft') ? isOwner() : hasRole('editor')"
# Admin can delete anything; owner can delete only drafts
delete: "hasRole('admin') || (isOwner() && inState('draft'))"
# Read access requires authentication plus department match
get: "isAuthenticated() && (hasRole('admin') || field('departmentId') == user.departmentId)"
Coexistence with Role Arrays
Both forms can coexist within the same resource. The parser detects the format:
- YAML array value (
[owner, admin]) → role array mode - YAML string value (
"isOwner() || hasRole('admin')") → AEL expression mode
permissions:
list: ["*"] # Role array
get: [authenticated] # Role array
create: [authenticated] # Role array
update: "isOwner() || hasAnyRole('editor', 'admin')" # AEL expression
delete: [admin] # Role array
Role arrays are evaluated with zero overhead (direct string comparison). AEL expressions are compiled at domain parse time and evaluated at request time with the permission context. For simple role checks, role arrays are preferred for clarity and performance.
11.8 Schema Endpoint Permissions
The schema introspection endpoint (GET /api/v1/domains/:name/schema) includes the full permission configuration for each resource:
{
"resources": {
"Post": {
"permissions": {
"list": ["*"],
"get": ["*"],
"create": ["authenticated"],
"update": ["owner", "editor", "admin"],
"delete": ["admin"],
"fields": {
"status": {
"write": ["editor", "admin"]
}
},
"transitions": {
"publish": ["editor", "admin"],
"archive": ["admin"]
}
}
}
}
}
UI generators use this data to show/hide buttons, enable/disable form fields, and render transition controls based on the current user’s roles. The schema endpoint does not evaluate permissions — it returns the raw declaration. The UI MUST perform its own permission checks for display logic and the server enforces permissions authoritatively on every request.
11.9 Permission Design Guidance
Start Restrictive
Begin with [admin] for all operations and open access incrementally:
permissions:
list: [authenticated] # Opened from admin
get: [authenticated] # Opened from admin
create: [authenticated] # Opened from admin
update: [owner, admin] # Opened from admin
delete: [admin] # Left restrictive
A permission that is too open is a security vulnerability. A permission that is too restrictive is a support ticket. The second is always preferable.
Separate Read from Write
Most resources have more readers than writers. Design permissions with this asymmetry:
permissions:
list: ["*"] # Anyone can browse
get: ["*"] # Anyone can read
create: [authenticated] # Must be logged in
update: [owner, editor] # Must own or be editor
delete: [admin] # Only admin
Use Field-Level for Sensitive Data
Operation-level permissions are coarse — they apply to the entire record. For resources with mixed sensitivity, use field-level permissions to protect individual fields without restricting access to the resource as a whole:
permissions:
get: [authenticated]
update: [owner, admin]
fields:
salary:
read: [hr, admin, owner]
write: [hr, admin]
ssn:
read: [owner]
write: [admin]
mask: true
Use Conditional for Lifecycle Control
When a resource’s editability changes with its state, use conditional permissions instead of putting all logic into guards:
permissions:
update: [owner, editor, admin]
conditional:
update:
draft: [owner, editor]
published: [admin]
archived: [] # Nobody can edit archived records
An empty array [] means “nobody.” This is distinct from omitting the state (which falls back to the operation-level permission).
End of Chapter 11.
Next: Chapter 12 — State Machines