Skip to main content

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.

  1. Permission model. A Permission enum (capability-level, e.g. case.decide, sar.file, case.assign, user.manage, config.write) and a ROLE_PERMISSIONS map from role → permission set. The map is the single source of truth; require_role callers migrate to require_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).
  2. 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, audited rbac.would_deny event (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.
  3. Validation gate before Phase 2. The rbac.would_deny telemetry 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.
  4. Config flag rbac_enforcement_enabled: bool = False (pydantic-settings) — a clean kill-switch / rollback. Existing hard require_role guards 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).