Chapter 11 · Volume II — The ADL Domain Language

Permissions & RBAC

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:

  1. Operation-level — which roles can list, get, create, update, or delete records of this resource
  2. Field-level — which roles can read or write specific fields within a resource
  3. 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

IdentifierMeaningEnforcement
*Public — no authentication requiredJWT validation skipped entirely
authenticatedAny user with a valid JWTJWT must be present and valid; no role check
ownerThe user who created or owns the recordJWT user_id matched against the resource’s owner FK
adminUser with the admin roleJWT roles array must include "admin"
Any other stringCustom role nameJWT 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:

  1. Is the operation ["*"]? → Allow (skip authentication entirely).
  2. Is the JWT present and valid? → If not, 401 Unauthorized.
  3. Is the operation [authenticated]? → Allow (any valid JWT suffices).
  4. Does the user’s role list include any role in the permission array? → Allow.
  5. Is owner in the permission array and does the user’s ID match the record’s owner field? → Allow.
  6. Is the permission an AEL expression? → Evaluate with the permission context. If true, allow.
  7. 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 write list → the field is accepted.
  • If the user’s role is NOT in the write list and the field is NOT required → the field is silently ignored. The update proceeds without it.
  • If the user’s role is NOT in the write list and the field IS required on create → 403 Forbidden with FIELD_FORBIDDEN error 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:

  1. A field named createdById (from the AuditMeta shared object)
  2. A field named userId
  3. A field named authorId
  4. The first belongsTo relationship targeting a User resource

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:

  1. Check if conditional.{operation} exists for the requested operation.
  2. If it exists, look up the record’s current state machine value.
  3. If a matching state entry exists → use that permission list instead of the operation-level list.
  4. 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):

FunctionSignatureDescription
hasRole(role)(string) → booleanUser has the specified role
hasAnyRole(r1, r2, ...)(...string) → booleanUser has at least one of the listed roles
isOwner()() → booleanUser ID matches the record’s owner field
isAuthenticated()() → booleanRequest has a valid JWT
userId()() → stringCurrent user’s ID
inState(state)(string) → booleanRecord is in the specified state machine state
field(name)(string) → anyGet the record’s field value
hasField(name)(string) → booleanRecord has a non-null value for the field

Available Context Variables

VariableTypeDescription
userobjectAuthenticated user: user.id, user.username, user.roles, user.claims
recordobjectTarget record (empty object on list/create)
opstringOperation 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