LumiBaseDocs

Permissions, Roles & Policies

Goal: the most powerful permission system among OSS headless CMSs. Supports field-level, row-level, time-bound, IP-bound, attribute-based, and composable policies.

See also the detailed investigation/blueprint: permission-builder-directus-investigation.md.

The current audit of the implementation: permission-service-compose-audit.md.

Migrating role flags to policy flags: role-policy-flag-migration.md.

1. Model

code
Realm (staff / subscriber / public / integration)   ← authentication boundary
  └─ Site (tenant; every access table is site_id-scoped)
       └─ User ──┬─► UserPolicy (direct attach, override)
                 └─► UserSite (role per site)
          Role ──► RolePolicy (priority) ──► Policy ──► Permission[] per (collection, action)
                                                          └─ fields / row rule / validation / presets
  • Realm: which authentication boundary the principal came through. Staff and subscribers are separated by role + token audience (ADR-011); public is the anonymous realm (§1.1); integrations authenticate with an API key.
  • Role: a fixed set assigned to a user (per site).
  • Policy: a reusable unit, attachable to multiple roles/users, with a priority order (lower priority = runs first, higher ones override later).
  • Permission: a specific (collection, action) rule with permissions, validation, presets, fields.

Design note 2026-06-03: Directus v11 moved admin_access, app_access, enforce_tfa, ip_access off the role and onto the policy. LumiBase still has roles.adminAccess/appAccess; treat this as a compatibility layer and migrate to policy flags so the role is only a grouping. For the detailed strategy see Migrating role flags to policy flags.

roles.parentId exists in the schema but the permission evaluator does not read it — there is no role inheritance. A child role does not receive its parent's policies. Treat the column as grouping metadata until an evaluator change lands.

1.1. Non-staff realms: public and subscriber

Two least-privilege realms exist for people who are not staff. Both start empty — they grant nothing until an operator says so.

publicsubscriber
Whoanyone, no credentialregistered + signed in on your frontend
Exists by default❌ opt-in per site✅ on first registration
Grantable actionsread onlyread/create/update/delete
Row scopespublished-onlypublished-only, own-rows-only
Studionevernever (appAccess: false)

Anonymous resolution. With public access enabled, a credential-less request resolves to the site's public role rather than skipping authorization — so row filters and field masks stay in force. Two conditions gate it, both load-bearing:

  1. the site has a public role (creating it is enabling the realm), and
  2. the request is a GET/HEAD on an allow-listed content prefix (/items, /search, /media, /files).

/graphql is excluded: operations arrive over POST, so the read-method rule cannot cover it. Opening it needs read-only operation validation alongside the existing cost limiter.

Why public is read-only. A generic write grant would hand every unauthenticated caller an unmetered write path. A public write surface (contact form, anonymous comment) needs its own throttle/captcha story behind a purpose-built endpoint, not a permission row.

Hard guards. adminAccess/appAccess on the public role and policy are pinned off by check constraints (migration 0012) — an elevation flag there would be an unauthenticated admin bypass. Route guards refuse the same edits with a readable 4xx. Policies attached to the public role by hand are screened at the attach point, since a table check cannot see across the role_policies join. The generic policy-permission editor is screened for the read-only limit too, because a grant added there lands in the same compiled bundle.

Caching. Anonymous principals share one compiled bundle per site (cache key role:{id}), and the role lookup itself is cached with negative results included — the anonymous path is the highest-volume one.

ctx.roleId is the anonymous realm's only route in. PermissionService.compile() resolves roles from four sources: user_sites, user_roles, api_key_roles and ctx.roleId. An anonymous principal has neither a user row nor an API-key row, so the first three are empty by definition — ctx.roleId is the only one left. withAuth sets it to the public role's id, and every call site that builds a MagicContext must forward auth.roleId rather than writing a literal null; otherwise every public grant compiles to an empty policy set and the feature silently does nothing. Note ctx.user.roles is not a fallback: compile() overwrites ctx.user with a snapshot read from the users table, which is null when there is no userId. A source-scan test rejects literal roleId: null in production source, with an allowlist for contexts that genuinely have no principal.

API (/api/v1/access/grants, site-admin only):

code
GET    /access/grants                             # collections + both realms' limits & grants
POST   /access/grants/public/enable               # provision the realm
POST   /access/grants/public/disable              # tear it down (removes all its grants)
POST   /access/grants/:realm                      # { collection, action?, publishedOnly?, ownOnly?, fields? }
DELETE /access/grants/:realm/:collection/:action

publishedOnly defaults on for read and off for writes. Both scopes may be combined, composing to an _and. Studio surfaces all of this at Access control → Public & subscribers.

POST /users/subscriber-access keeps its original published-only-read contract when action is omitted.

How a grant is refused. Every refusal from POST /access/grants/:realm is a request body error, so all of them answer 400 — an unchanged retry fails identically, which is what 400 means and 409 does not:

CodeMeaning
COLLECTION_REQUIREDcollection is missing
ACTION_NOT_ALLOWEDthe realm does not allow that action (e.g. create on public)
ROW_SCOPE_NOT_SUPPORTEDthe realm cannot express the requested row scope (ownOnly on public)

Enable before grant. POST /access/grants/public answers 409 PUBLIC_ACCESS_DISABLED while the site has public access off — call POST /access/grants/public/enable first. A grant used to provision the realm as a side effect, which made /disable non-sticky: an operator who deliberately closed anonymous access could have it silently reopened by any later grant. Turning the anonymous realm on deserves its own audited act (public_access_enabled). Only public is gated; subscriber is provisioned on first registration and is not operator-togglable (togglable: false), so it has no enable step to require. The Studio picker already rendered a disabled togglable realm read-only, so the server was the side out of step.

COLLECTION_NOT_GRANTABLE is the one 404: the collection must exist on the site and be neither a system nor a hidden collection — exactly the set GET /access/grants lists. Without this check a typo was accepted with 200 and wrote a permission row that could never match anything: invisible in the picker and silently inert.

The public role cannot be deleted through the role editor. DELETE /roles/:id refuses it with 409 PUBLIC_ROLE_NOT_DELETABLE. "Public access is enabled" is not a flag — it is the existence of the public role, so a raw delete here turns anonymous access off while skipping the two things POST /access/grants/public/disable also does: clearing the realm policy and its permission rows (so a later re-enable starts from a clean slate rather than silently restoring grants the operator has since forgotten), and writing the public_access_disabled audit event.

2. Permission record (JSON DSL)

json
{
  "collection": "posts",
  "action": "update",
  "fields": ["title", "body", "status"],
  "permissions": {
    "_and": [
      { "user_created": { "_eq": "$CURRENT_USER" } },
      { "status": { "_neq": "archived" } },
      { "_or": [
        { "site_id": { "_eq": "$CURRENT_SITE" } },
        { "_role": { "_in": ["admin"] } }
      ]}
    ]
  },
  "validation": {
    "status": { "_in": ["draft", "review", "published"] }
  },
  "presets": { "updated_by": "$CURRENT_USER" }
}

Supported operators

  • Logic: _and, _or, _not.
  • Comparison: _eq, _neq, _lt, _lte, _gt, _gte, _in, _nin, _contains, _starts_with, _ends_with, _between.
  • Date: _dynamic, e.g. $NOW(-7 days).
  • Magic: $CURRENT_USER, $CURRENT_ROLE, $CURRENT_SITE, $NOW, $IP, $HEADERS.x-foo.
  • Extended magic: $CURRENT_ROLES, $CURRENT_POLICIES, $CURRENT_API_KEY, $CURRENT_USER.email, $CURRENT_USER.preferences.locale, $NOW(+2 hours), $NOW(-7 days).
  • Unknown magic vars fail closed; a rule using an unsupported variable will not match.

3. Field-level

  • fields: ["*"] = all.
  • fields: ["title","body"] whitelist.
  • fields: ["-secret"] blacklist (prefix -).
  • For read: returns a field mask; the server does not serialize forbidden fields.
  • For update/create: the server rejects the request if the payload contains a disallowed field.

4. Row-level

  • Evaluate the permissions AST → SQL where (Drizzle) for read/list, or post-fetch check for update/delete (safe updates still need a WHERE).
  • Cache the "compiled rule" by (policyId, action) in KV.

5. Time-bound & IP-bound

  • A policy has optional fields at the policy level:
json
{ "activeWindow": { "from": "2025-01-01T00:00:00Z", "to": "2025-12-31T23:59:59Z" }, "allowIps": ["10.0.0.0/8"], "denyIps": [] }
  • Evaluated before the rules; reject early if outside the window.
  • Should support IPv4, IPv6, CIDR, and IP ranges. ipDeny beats ipAllow.
  • If a policy fails the IP/time check, remove that policy from the chain rather than denying the whole principal.

5.1. App access, admin access, enforce TFA

  • adminAccess=true bypasses all permission checks; an admin policy does not need seeded permission rows.
  • appAccess=true lets a principal use Studio, which is not the same as API access. An API-only user can still call the API if they have permission rows but is blocked from Studio.
  • The Studio client must send X-Lumi-Client: studio; the backend uses the effective appAccess to gate these requests.
  • An API key may never use Studio even if the policy grants app access.
  • enforceTfa=true requires the user to have enrolled and the session to have passed 2FA before a Studio request can be used.
  • Do not set these flags on the user; the user only holds identity/status/TFA enrollment.

6. Composition & precedence

  • Merging multiple permissions with the same (collection, action):
    • fields: union (special case: if a blacklist appears, apply it after the union).
    • permissions rule: joined with _or (additive grants).
    • validation/presets: merged with _and / object spread by priority.
  • A role with adminAccess=true → bypass.

New recommendation: do not silently OR/union when attaching policies via Studio. The backend needs a conflict checker:

  • Block if one policy grants {} but another restricts the same collection/action.
  • Block if one policy grants ["*"] but another whitelists fields.
  • Block if validation or presets cover the same field with different values.
  • Warn if it only widens fields or OR-adds a conditional rule; the admin must confirm and it is audited.
  • The DB should have a unique (policyId, collection, action) so a policy cannot have multiple permission rows with the same key.

6.1. Publishable API keys

An API key can be minted as publishable (lbk_pub_…) for embedding in a browser bundle or mobile app, as opposed to a secret lbk_… key held server-side.

A publishable key is not a secret. Anything shipped to a client is extractable, so the only correct posture is to scope it exactly as if it were already public. What it buys over serving the same data fully anonymously: per-key rate-limit quota (the limiter already keys on k:{apiKeyId}), revocation and rotation without redeploying, audit attribution, and per-key scope (staging vs production). What it does not buy: confidentiality. If leaking it would hurt, keep a secret key server-side and proxy through your own backend.

  • The lbk_pub_ prefix is the source of truth — derived from the token, so it cannot drift from a metadata flag, and a secret scanner can alert on leaked lbk_ keys while staying quiet about publishable ones. Rotation preserves it.
  • Origin allowlistmetadata.allowedOrigins, editable live via PATCH /api-keys/:id/allowed-origins. Empty = no constraint. Studio exposes this on a live key at Access control → API keys: select the key and use the Allowed origins box in the detail panel — tightening the list, or fixing a mistyped origin, needs no token rotation and no redeploy of whatever ships the key. Saving an empty box asks for confirmation, since that widens access.
  • Enforcement is scoped to publishable keys (a secret key is used server-to-server, where no Origin exists). A non-matching Origin/Referer is 403 ORIGIN_NOT_ALLOWED and audited; an absent origin is allowed. This is deliberate: the control stops another website from using your key in a browser, which is the real failure mode. It is not a defence against curl, which can set any Origin — rejecting absent-origin requests would only break legitimate native/server callers without adding security.
  • adminAccess/appAccess cannot be attached to a publishable key (PUBLISHABLE_KEY_ELEVATION), and both halves of a role binding are screened. That matters because PermissionService grants admin when either the roles.admin_access column or an active policy's admin_access is set — so screening policies alone is not enough: a role carrying the flag on its own column, with no elevated policy (or no policy at all), would pass the screen and then grant admin at request time.

7. API

  • GET /permissions/me — returns the matrix {collection: { create, read, update, delete, share, fields, presets }} so Studio can render the UI (hide buttons, disable fields).
  • POST /permissions/check — debug: input action+payload, output allow/deny + reason trace.

8. Studio UI

  • The Access Control module:
    • Public & subscribers page: enable/disable anonymous access and check off collection × action grants per non-staff realm. Limits are read from GET /access/grants rather than hard-coded client-side, so a server-side tightening cannot be contradicted by a stale client assumption. A togglable realm that is off renders read-only, so a first checkbox click cannot quietly switch anonymous access on.
    • Roles page: list + create + assign users + attach policies.
    • Policies page: list + JSON editor + GUI builder (a form per row).
    • Permission Matrix page: a collection × action grid; click a cell to open the detail (fields, rules, presets, validation).
    • Test sandbox page: simulate a user → see the field mask, allowed rows.
    • API Keys page: create a key, rotate/revoke, attach roles/policies, preview effective permissions.
    • Role/Key attach flow: preview conflicts before saving.

8.1. Import / Export

  • GET /access/export exports roles, policies, permission rows, role-policy bindings, and API key metadata as versioned JSON.
  • POST /access/import?dryRun=true returns a diff/conflict and does not write the DB.
  • A real import runs in a transaction, uses stable keys, is fully audited, and never imports/exports a plaintext API key.

9. Caching & invalidation

  • KV: perm:{site}:{role} (compiled). TTL 5 minutes + invalidate when a policy/role changes.
  • WebSocket broadcasts a permissions.changed event → the Studio client reloads /permissions/me.

10. Audit

  • Every denial is logged to activity with the action permission_denied + the reason (rule path).

11. Tasks: Phase MVP-C, C2.

12. Compliance & user rights

This RBAC/audit machinery underpins several regulatory obligations (access control, provenance, breach detection). For how it maps to user data-subject rights and where gaps remain, see Compliance — gap analysis.

Last modified: 24/08/2026