ADR 0024: Action-level authorization policies refine the permission tiers¶
Status: Accepted Date: 2026-07-19 Issue: #991
Context¶
ADR 0005 made role definitions deployment configuration over a fixed
six-permission vocabulary (read, operate, operate-lead,
engineer, supervise, admin, plus the off-ladder
interlock:bypass). Every gateway route is pinned in code to exactly
one permission tier (withPermission in
internal/gateway/routes.go — 268 of 287 registrations), and a
deployment's only authorization lever is the role→permission-set
mapping in its roles file. In AWS terms: managed policies only —
attach freely, no custom policies.
The #983 rbac-personas rework surfaced the product-owner expectation this cannot meet: granting or denying a specific API action per role, IAM-style. A deployment that wants operators to acknowledge alarms but not shelve them, or a lead-operator role that may respond to prompts but not command batches, has no lever — both actions sit in the same tier, and re-pinning happens only via a product release.
Kubernetes RBAC cannot close the gap: human users never touch the K8s API (the gateway acts as one service account), and resource/verb granularity cannot distinguish domain actions — alarm acknowledge, alarm shelve, and prompt response are all "update".
The tension the design must hold: the tier model is deliberately small because a six-value permission set is what a quality unit can review and sign in an IEC 62443 SR 2.1 / 21 CFR Part 11 validation, and it is what lets the UI gate affordances client-side without an entitlements query per button. A full policy engine explodes the test and compliance-evidence matrix (rejected in ADR 0005 and still rejected). The epic's own validation concern applies equally: deployments must not rot into unauditable 150-action allow lists.
Decision¶
Every authorized gateway route is assigned a stable, product-owned
action name, declared at the registration site in routes.go. A
deployment's roles file may attach an allow list and a deny
list of action names to any role, refining that role's effective
action set on top of its permission tiers. Tiers remain the shipped
defaults and the recommended configuration; policies are opt-in
deltas. The UI learns the session's resolved action set from a new
entitlements endpoint.
Concretely:
- Action catalog. Action names are
<domain>:<verb>(e.g.recipe:approve,alarm:shelve,controlmodule:write-tag), following the precedent ofinterlock:bypass. One route carries exactly one action name; a handful of routes that are the same domain action share one name (e.g. single and batch I/O read). Route registration becomeswithAction("alarm:shelve", auth.PermOperate, handler), soroutes.gostays the single source of truth for both the default tier and the action name, and the action catalog reference doc is generated/linted from it in thelint-docs-api-referencepattern. Action names are a product contract exactly like the permission vocabulary: renaming or removing one is a breaking config change and follows deprecation discipline. Unauthenticated and structural routes (login, session, entitlements, health, static pages) carry no action name and can never be policy targets. - Policy model: per-role
(tier ∪ allow) − deny. A role's effective action set is the actions its permission tiers pin, plus its allow list, minus its deny list — deny wins over allow within the role. A user's effective set is the union of their roles' effective sets, exactly as tier grants union today. A deny does NOT cross roles: denyingbatch:commandon the operator role does not strip it from a user who also holds an engineer role. This keeps each role document independently reviewable — one role's policy fully determines that role's contribution, and reading a roles file never requires cross-role precedence analysis. The multi-role interaction is documented, not special-cased. - Exact names only — no wildcards. Allow and deny lists reference
catalog names verbatim;
recipe:*is rejected. A wildcard's meaning changes silently when a product release adds an action to the family, which is exactly the unauditable drift the validation story must prevent. A policy document is a flat, finite, reviewable list. The gateway fails fast at startup on unknown action names, as it already does for unknown permission names. - Entitlements endpoint.
GET /api/v1/auth/entitlementsreturns the session identity's resolved action set in one response, fetched alongside the existing permissions fetch and cached inauthState. Page-level UI gating stays on the coarse permission tiers (epic non-goal: tiers remain the UI's coarse gate); per-affordance gating for mutating controls migrates from permission checks tohasAction(name). The gateway remains the sole enforcement point — client gating is affordance hygiene, not security. - Audit + CLI. Authorization denials and policy-refined
admissions record the action name and the deciding layer
(
tier:<permission>,allow,deny).dcs authgains inspection of the active role→action resolution so an auditor can read effective policy without reverse-engineering the config. - In-handler invariants are out of scope. Site scoping, MES fencing, separation-of-duties checks, mode gates, and the #983-era in-handler double gates remain in the handlers. Policies refine route admission only; they can narrow who reaches a handler, never widen past a structural invariant.
Alternatives Considered¶
- Allow-only refinement (no deny list) — simpler precedence. Not chosen: the motivating cases are subtractive ("operators, but without alarm shelving"); without deny, expressing them forces the deployment to abandon the tier and hand-enumerate everything the role keeps, which is precisely the 150-action-list rot the validation story forbids. Deny-as-delta keeps policies small.
- Global deny-wins across roles (AWS precedence) — familiar to IAM users. Not chosen: with multi-group industrial users, a deny attached to a junior role silently overriding a senior role's grant is a lockout footgun discoverable only at 2 a.m.; and it makes every role document non-local — reviewing one role requires reading all of them. Per-role effective sets preserve the union semantics deployments already understand from tiers.
- Wildcard/glob action references — shorter policy documents. Not chosen: globs re-resolve on every product release, so the reviewed artifact and the enforced policy drift apart; exact names keep the signed document equal to the enforced set.
- Per-route re-pinning (change a route's tier instead of naming actions) — no new vocabulary. Not chosen: it mutates the shared tier semantics per deployment (an "operate" role means different things at different sites at the tier level, poisoning docs, support, and validation baselines), and it cannot express grants that cross tiers (allow one engineer-tier action to operators without the rest of the tier).
- Full policy engine (OPA-style attribute rules) — maximum flexibility. Not chosen, reaffirming ADR 0005: arbitrary rule composition explodes the compliance-evidence matrix; named actions over named roles is the tested, documentable middle ground.
- Do nothing (tiers only) — cheapest. Not chosen: the gap is now a known product-owner blind spot, and losing a sales conversation to "can you deny one action" when the route table already enumerates every action is an avoidable forfeit.
Consequences¶
- Code that moves:
routes.goregistrations gain action names (withAction);pkg/authgrows the action registry, the roles-file schema extension (permissions/allow/denyobject form beside the existing bare-list shorthand), resolution, and startup validation; a new entitlements handler;auth.jsgainshasAction()and the mutating-affordance sweep;dcs authgains policy inspection; audit denial records gain the action name and deciding layer. - Back-compat is structural: a roles file with no
allow/denyresolves to exactly the tier-pinned action sets, so existing deployments and the shipped defaults are behavior-identical. The bare-list role form keeps parsing forever. - New product contract surface: action names join the permission vocabulary as breaking-change-controlled identifiers. A rename needs a deprecation cycle; the generated catalog doc is the customer-facing registry of record.
- Known deny-list upgrade gap: because names are exact, a product
release adding a new action to a family a deployment tried to fence
off (deny
recipe:approve, release addsrecipe:approve-bulk) admits the new action to the tier's members until the deployment updates its deny list. Accepted: new actions land tier-pinned (so only tier members gain them), and release notes must call out new action names — cheaper than wildcard drift. - Validation story improves: IEC 62443 SR 2.1 least-privilege
evidence becomes "shipped tiers + this reviewed delta document";
docs/compliance/iec62443.mdanddocs/security-operations.mdupdate with guidance that keeps plain tiers the recommended default and frames policies as change-controlled quality-system items. - UI honesty cost: any mutating affordance still gated only on a tier can render for a user whose action was policy-denied; the server 403s it. The affordance sweep closes this for shipped controls; the failure mode is a denied toast, never an admitted action.
- Reversibility: high before deployments write policies (delete the config keys, behavior reverts to tiers); low after — action names, like permissions, become contract the moment a customer's signed policy document references them.
Follow-ups¶
- Action resolution enforces the MES fence —
#1027,
resolved. Decision 6 keeps structural invariants in the handlers,
and the MES
/api/v1/mes/confinement stayed there — but the resolution functions this ADR introduced (HasAction,UserActions,RoleActions) resolved purely from(tier ∪ allow) − denyand so reported an integration identity as entitled to the whole read+operate plant action set it can never invoke. Enforcement was never affected; the two inspection surfaces added by decisions 4 and 5 were, and their entire stated value is fidelity to what the system enforces. The fence now also resolves insideActionDecision(deciding layermes-fence), so an integration identity's effective set contains only actions its confinement can reach and a policyallowcannot widen past it. The route-path fence and the action-name fence are kept in agreement bymake lint-docs-action-catalog.