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_propertyunset, behaviour is byte-for-byte identical to before — the raw string is used as-is. principal_propertymust beunique: trueonuser_entity_typein 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-headerpath 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 theinternal/principalpackage doc. - Absent key ⇒ no-op. A policy without
asserted_role_assignmentsbehaves 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
assignmentstreats 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 claimadmin. Matching is otherwise exact —Admindoes not matchadmin. A key that is blank after trimming is rejected at load, since it could never match and would sit inert. everyoneis 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_typeentity 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.writeincludesticket→ allowed. - Bob on TKT-001 / Update:
Allow. Attributions:(triager, Group{triage-team}),(editor, LocalViaAncestor{ancestor=PROJ-42, relation=editor-of}),(everyone, Global).editorcame in via theeditor-ofedge to PROJ-42 plus thebelongs-toinheritance from TKT-001. - A drive-by anonymous request:
Deny.ErrUnstampedPrincipalsurfaces; the request never reachesAuthorizeWrite.
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).
Where to read next
- [GUIDE-acl-security] — hardening notes for operators: member-of
trust boundary, why nil Subject panics, why malformed
acl.yamlfails boot. - [CON-authorization] — the underlying concept and vocabulary.