ACL: Authorization Overview

July 24, 2026 · View on GitHub

This guide walks through rela's authorization system end-to-end: how an acl.yaml policy plus the graph's member-of and inherit_roles_through edges become a per-write Allow/Deny decision with full role-attribution provenance. Read [CON-authorization] first for the vocabulary.

How it fits together

The ACL system is one moving part: a *acl.Declarative constructed from the policy plus a store-backed graph. Every write call moves through it.

graph TD
    subgraph Inputs
        Policy[acl.yaml<br/>roles · assignments<br/>role_relations<br/>inherit_roles_through]
        Graph[(Store-backed Graph<br/>member-of edges<br/>role-relation edges<br/>containment edges)]
    end

    subgraph Boot
        Declarative["*acl.Declarative<br/>(policy + graph)"]
    end

    Policy -->|NewDeclarative| Declarative
    Graph -->|NewStoreGraph| Declarative

    subgraph PerRequest [Per HTTP request]
        Middleware[attachACLRequest middleware]
        Request["*acl.Request<br/>(per-call scope<br/>caches member-of walk)"]
        Resolver{Resolver}
    end

    Middleware -->|d.ForPrincipal p| Request
    Declarative -.references.-> Middleware

    subgraph WriteCall [Per write call]
        WReq[WriteRequest<br/>Op + Subject]
        Decision[Decision<br/>Allow · RuleKind · RuleID<br/>Reason · Attributions]
        Audit[(Audit log<br/>denied-write rows)]
    end

    Request -->|AuthorizeWrite| Resolver
    WReq --> Resolver
    Resolver -->|two-layer walk:<br/>member-of × ancestors| Decision
    Decision -->|on deny:<br/>Attributions| Audit

    style Declarative fill:#e1f5ff
    style Request fill:#fff4e1
    style Decision fill:#e8f5e9
    style Audit fill:#fce4ec

Write sequence

A single PATCH from the SPA to the data-entry server traces through the stack like this:

sequenceDiagram
    participant SPA as SPA / browser
    participant Router as dataentry router
    participant Mid as attachACLRequest
    participant EM as entitymanager.Manager
    participant D as Declarative.AuthorizeWrite
    participant Res as Resolver
    participant Graph as StoreGraph
    participant Aud as Audit log

    SPA->>Router: PATCH /api/v1/tickets/TKT-001
    Router->>Mid: handler chain
    Mid->>D: d.ForPrincipal(principal.From(ctx))
    D-->>Mid: *Request (member-of not yet walked)
    Mid->>Router: ctx = acl.WithRequest(ctx, req)
    Router->>EM: UpdateEntity(ctx, e, opts)
    EM->>D: AuthorizeWrite({Op: Update, Subject: EntitySubject{TKT-001}})

    rect rgb(240, 248, 255)
        note right of D: Per-request scope —<br/>walks happen once.
        D->>Res: Request.ForEntity(ctx, "ticket", "TKT-001")
        Res->>Graph: OutgoingRelations(user, "member-of")<br/>(cached after first call)
        Graph-->>Res: members closure
        Res->>Graph: OutgoingRelations(entity, inherit_roles_through)
        Graph-->>Res: ancestor chain
        Res->>Graph: HasEdge(member, rel-type, target)<br/>for each (member, target) ∈ members × chain
        Graph-->>Res: hit / miss per probe
        Res-->>D: []RoleAttribution
    end

    D-->>EM: Decision{Allow=false, RuleKind="role-grant",<br/>RuleID="triager", Reason="...", Attributions=[...]}
    EM->>Aud: denied-write row<br/>(Subject.ID, full Attributions)
    EM-->>Router: *ForbiddenError
    Router-->>SPA: 403 {rule_kind, rule_id, reason}<br/>(attribution omitted)

The single Request is what makes the per-list response viable: a list of 50 tickets reuses one member-of walk and one ancestor chain per ticket, instead of re-walking 50× from scratch.

A worked acl.yaml

A small example that exercises every layer of the resolver:

description: >          # optional prose: what this deployment's access model is for
  Access model for the ticket tracker. Editors own the backlog; readers and
  the everyone role get scoped read access.

roles:
  everyone:
    read: ["*"]
  reader:
    description: Read-only observer of tickets and features.   # optional prose
    read: [ticket, feature]
  editor:
    description: Backlog maintainer — full CRUD on tickets.     # optional prose
    read: [ticket, feature]
    create: [ticket]
    update: [ticket]
    delete: [ticket]
  triager:
    read: [ticket]
    fields:
      ticket:
        - field: status
          when: "true"

assignments:
  alice: editor              # global grant
  triage-team: triager       # group grant; principals member-of triage-team inherit

role_relations:
  editor-of:                 # an edge of this type confers a role
    confers: editor

inherit_roles_through:
  - belongs-to               # walk these ancestry edges when resolving local roles

# membership_relation: member-of   # optional; the relation walked for group
#                                  # membership. Default "member-of". Set it to
#                                  # a domain relation you already model
#                                  # (e.g. heeft_rol) to avoid a parallel edge
#                                  # system. If you do, gate writes to it the
#                                  # same way (see GUIDE-acl-security).

The top-level description: and the per-role description: are optional documentation prose. They never affect an authorization decision — the resolver ignores them entirely — and exist so tooling (the rela docs generator) can narrate the role model in operator-facing documentation. Omitting them changes nothing.

membership_relation: is optional. When omitted (or blank) the resolver walks member-of, so existing policies are unaffected. Only set it to a relation type your metamodel actually defines and uses for membership (e.g. heeft_rol): the resolver walks exactly that relation, so if you name a type that doesn't exist — or one your data doesn't populate — the walk finds no edges and group roles silently never resolve. There's no separate "is this a real relation" check; the relation simply has to be there.

Resolving the principal to a user entity

By default principal.User is whatever the entry point stamped — for the data-entry server that is the reverse-proxy header value verbatim (e.g. X-Forwarded-User: jvloothuis@sourcehaven.nl). Assignments and membership walks then match on that raw string, which forces operators to either duplicate every human's identity in acl.yaml or rewrite headers in the proxy.

When your graph already models people as entities (an ISMS persoon, a user type), set two coupled keys so the resolver consults the graph instead:

user_entity_type: persoon
principal_property: email      # persoon.email holds the header value
membership_relation: heeft_rol
assignments:
  ROLE-MD: md

At the start of each request the resolver looks the raw principal up against persoon.email. On exactly one match it substitutes that entity's ID for principal.User for the rest of the request, so membership and local-role walks operate from a real entity:

Header (jvloothuis@sourcehaven.nl)
   └─ property lookup on persoon.email ─▶ PERS-JV
         └─ walkMembers via heeft_rol ─▶ ROLE-MD ─▶ role md

Rules and fallbacks:

  • Both keys required. With principal_property unset, behaviour is byte-for-byte identical to before — the raw string is used as-is.
  • principal_property must be unique: true on user_entity_type in the metamodel (enforced at boot). A non-unique key admits duplicates, which makes resolution ambiguous. See GUIDE-acl-security for how the uniqueness constraint is enforced on writes.
  • No match keeps the raw principal — so an assignment keyed on the raw UPN (assignments: { jvloothuis@sourcehaven.nl: md }) still works as a break-glass escape hatch for identities not yet in the graph.
  • Multiple matches (a data-integrity failure) keeps the raw principal and logs a warning; the resolver refuses to guess which entity is meant.
  • Lookup errors keep the raw principal (fail-open to pre-feature behaviour) so a transient store hiccup never locks everyone out.

The audit log records both identities: user is the resolved entity ID and raw_user is the original header value, so an operator can trace what authenticated and what it resolved to without a graph round-trip.

For large user-entity sets, push the property equality into a backend index — see GUIDE-acl-security.

Roles from a verified identity assertion

When rela runs behind an OIDC identity proxy that signs an identity assertion (see GUIDE-server-security for the --jwt-* flags), the assertion's roles claim can grant policy roles directly — so an operator maintains group membership in the IdP rather than restating it per-user in assignments.

asserted_role_assignments:
  admin: editor                # a claim value → one role
  compliance: [editor, auditor] # …or several

A claim value never names a rela role directly: it selects an entry the operator wrote here. An IdP therefore cannot grant a role the deployment did not choose to expose, however its own role names change.

Rules and fallbacks:

  • Verified assertions only. Roles are populated exclusively by the JWT resolver, after signature verification. The --principal-header path and direct-loopback requests never carry roles, and no header is read as a role source. This is enforced by the type system, not by convention — see the internal/principal package doc.
  • Absent key ⇒ no-op. A policy without asserted_role_assignments behaves byte-for-byte as before.
  • No match grants nothing; an unmapped claim is simply ignored.
  • Undeclared target role is dropped silently at resolution, matching how assignments treats an unknown role name.
  • Exact matching after trimming. Surrounding whitespace is stripped from both the policy key and the incoming claim, so a padded key like " admin " still matches the claim admin. Matching is otherwise exact — Admin does not match admin. A key that is blank after trimming is rejected at load, since it could never match and would sit inert.
  • everyone is rejected as a target — it already applies to every principal, so granting it from a claim would double-report the role with no effect.
  • Multiple claims accumulate. A principal holding ["admin", "compliance"] gets the union of both mappings, deduplicated.
  • No user entity required. A verified principal whose subject resolves to no user_entity_type entity still receives its asserted roles — this is an SSO-provisioned user's first request. It does not receive anything keyed on a graph entity (local roles, ancestry).

Asserted roles are attributed with source kind asserted, carrying the claim that granted them, so rela acl who-can and the audit log distinguish "granted by our policy" from "granted by a claim in a token".

rela acl who-can reports these in a separate section from the everyone grant, and deliberately does not enumerate holders: whoever presents the claim gets the role, and that population lives in the IdP, not the graph. Listing them as principals — or folding them into the everyone line — would tell an operator the grant reaches everybody.

The assertion's org_id / org_slug are recorded on the principal for audit attribution. Nothing evaluates them — see GUIDE-acl-security.

Given the graph:

alice ──member-of──▶ triage-team
bob ──member-of──▶ triage-team
bob ──editor-of──▶ PROJ-42
PROJ-42 ◀──belongs-to── TKT-001

The resolver's decisions:

  • Alice on TKT-001 / Update: Allow. Attributions: (editor, Global), (triager, Group{triage-team}), (everyone, Global). editor.write includes ticket → allowed.
  • Bob on TKT-001 / Update: Allow. Attributions: (triager, Group{triage-team}), (editor, LocalViaAncestor{ancestor=PROJ-42, relation=editor-of}), (everyone, Global). editor came in via the editor-of edge to PROJ-42 plus the belongs-to inheritance from TKT-001.
  • A drive-by anonymous request: Deny. ErrUnstampedPrincipal surfaces; the request never reaches AuthorizeWrite.

How to read a deny

A 403 from a write looks like this on the wire:

{
  "error": "forbidden: role 'reader' does not grant write on 'ticket'",
  "rule_kind": "role-grant",
  "rule_id": "reader"
}

The wire body is deliberately terse: it names the rule that fired but not the attribution chain — leaking that would tell an attacker how the principal-to-role topology is structured. The same denial in the audit log carries the full attribution set:

ts=... user=alice tool=data-entry op=update id=TKT-001
  outcome=denied rule_kind=role-grant rule_id=reader
  attribution="(reader, Global), (everyone, Global)"

So an operator investigating "why was alice denied?" reads the audit log, sees the role set the resolver considered, and can fix either the policy (give alice editor) or the graph (add alice to a group that has it).

  • [GUIDE-acl-security] — hardening notes for operators: member-of trust boundary, why nil Subject panics, why malformed acl.yaml fails boot.
  • [CON-authorization] — the underlying concept and vocabulary.