ADR-0074 — Role-based access control (RBAC) enforcement, phased (log-first)
- Status: Accepted
- Date: 2026-06-27
- Human sign-off: Approved by the architect (2026-06-27) — phased / log-first rollout. (Access-control is a safety/security boundary; per methodology it requires explicit human sign-off.)
- Relates to: ADR-0011 (Keycloak OIDC), ADR-0023 (RLS tenant isolation), ADR-0050/0051 (RLS role-flip + tenant-scoped sessions), ADR-0070 (maker-checker four-eyes).
Context
Authorization today is coarse: require_role(...) (in app/api/deps/auth.py) gates a subset of
endpoints by a single role string, applied ad-hoc. There is no consistent, auditable model of which
role may do what, and many officer/MLRO-sensitive actions are not role-gated at all (they rely only on
authentication + tenant RLS). For an enterprise MLRO team, least-privilege role separation
(officer / MLRO / tenant-admin / super-admin) is a procurement and governance requirement.
Retrofitting deny-by-default onto a live system risks locking out legitimate users the instant it ships, because the role→permission matrix is a hypothesis until validated against real usage.
Decision
Introduce a permission-based model and roll it out log-first.
- Permission model. A
Permissionenum (capability-level, e.g.case.decide,sar.file,case.assign,user.manage,config.write) and aROLE_PERMISSIONSmap from role → permission set. The map is the single source of truth;require_rolecallers migrate torequire_permission(...). Roles (least → most): officer (own/assigned cases, investigations, decisions, raise SAR) → mlro (+ SAR approve/file, four-eyes second-approval, fail-closed-gate overrides) → tenant_admin (+ user management, tenant config) → super_admin (all + cross-tenant impersonation). require_permission(perm)dependency. Resolves the current user's role → its permission set. If the permission is absent:- Phase 1 (current —
rbac_enforcement_enabled = False): emit a structured, auditedrbac.would_denyevent (user, role, permission, method+path, tenant) and ALLOW the request. No user is ever blocked; the gap becomes observable. - Phase 2 (
rbac_enforcement_enabled = True, flipped after validation): raise 403.
- Phase 1 (current —
- Validation gate before Phase 2. The
rbac.would_denytelemetry is reviewed against real traffic; any legitimate would-deny means the matrix is corrected (a role genuinely needs that permission) before enforcement is enabled. Flipping to enforce is then a single config change. - Config flag
rbac_enforcement_enabled: bool = False(pydantic-settings) — a clean kill-switch / rollback. Existing hardrequire_roleguards on admin/super-admin endpoints are preserved (they already work and are not part of the risky retrofit); the phased dependency is added on top.
Consequences
- No lockout in Phase 1 — behaviour is unchanged for every user; the only new effect is telemetry.
- The matrix is empirically validated before it can deny anyone — the same "make the gap visible before acting on it" discipline as the fail-closed data-honesty contract (ADR-0067), applied to authz.
- Auditable — every would-deny (and, in Phase 2, every deny) is an immutable audit/telemetry record, supporting the least-privilege evidence an auditor/procurement expects.
- Composes with tenant RLS (ADR-0023) — RBAC is the what-you-can-do layer on top of the whose-data-you-see layer; neither replaces the other.
- Phase 2 enablement is itself a security-boundary change and will be flipped only on explicit decision after the telemetry review — not autonomously.
Phase 2 activation (2026-06-28)
rbac_enforcement_enabled was flipped to True (default) after explicit human
sign-off. Activation gate satisfied: rbac.would_deny telemetry was clean
(0 events in observed traffic), the role→permission matrix is a tested strict
superset hierarchy, and both phases are fully covered by
tests/test_rbac_permissions.py (incl. 403-on-missing-permission and
fail-closed blank/unknown roles). Verified no regression across the
RBAC-guarded endpoint suites (SAR lifecycle, maker-checker, risk-config,
case-decisions, decision-memorandum: 121 passed). Rollback is instant — set
rbac_enforcement_enabled=False. Missing-permission requests now return 403;
blank/unknown roles fail closed (least privilege).