ADR 0012 — Admin RBAC: Entra App Roles for membership + super_admin-configurable capabilities
- Status: Proposed (2026-07-26)
- Date: 2026-07-26
- Decision-makers: rettX maintainers (Pedro)
- Relates to: spec 043 — Admin RBAC MVP
(the enforcement + rollout this ADR governs),
spec 042 — Admin app shell
(the config-driven nav whose
requiredRolesslot consumes these roles on the client), patterns.md §4 (authentication & authorization), and the program constitution Principle VI (Security baseline) — server-side authorization and least privilege.
Context
Section titled “Context”The rettX admin surface (rettxadmin) and backend (rettxapi) today have
authentication but no authorization for admins.
- Two deliberate identity planes. Admin/staff authenticate via Microsoft
Entra ID (Azure AD) using MSAL; caregivers authenticate via Auth0. This
split is intentional.
rettxapiadmin endpoints trust Entra tokens only (require_admin→get_admin_id→EntraClient,auth_provider="entra"). - No RBAC exists. In
app/authentication/entra_client.py,get_user_info()hardcodesis_admin=True, and the real roles-claim check (is_admin = payload.get('roles', []) == ["admin"]) is commented out. Any authenticated Entra admin therefore has full, undifferentiated access. - The
Principalmodel is the wrong home for admin roles.Principal(app/models/principal/principal_models.py) represents the Auth0/caregiver identity and carries only an account-lifecyclestatus(PROVISIONAL/ACTIVE/LOCKED) — not an authorization field. Crucially, admins are Entra principals and may have noPrincipalrecord at all. - On the client,
rettxadminguards routes withMsalGuard(authentication only); there is no role concept. Spec 042 introduces a config-driven nav with an optionalrequiredRolesextension point awaiting a role source.
We need a source of truth for admin authorization that (a) fits Entra
identities that may lack a Principal record, (b) is enforced server-side, and
(c) can be rolled out without locking existing admins out. In addition, the
product owner wants to tune what admin and read_only can do at runtime
(role-level, MVP) without an Azure AD change for every adjustment — so a purely
directory-driven permission model is too rigid.
Decision
Section titled “Decision”Adopt a HYBRID model: Microsoft Entra App Roles own admin role
membership, while the granular capabilities each role grants are
rettX-managed configuration that a super_admin tunes at runtime.
- Role membership rides the token. Which admins are
super_admin/admin/read_onlyis delivered in the verified Entra access-tokenrolesclaim.rettxapireads the claim it already trusts (from existing signature/issuer/audience validation) — it does not add a new identity lookup. This layer is IT/ops-managed and deliberately coarse. - Capabilities are rettX-managed config, not code and not Entra. The
granular capabilities (
area.action, e.g.patients.manage,campaigns.send) thatadminandread_onlygrant are held in a small role→capability config document persisted in rettX (e.g. anadmin_role_capabilitiesCosmos container), seeded with safe defaults (admin= all-but-super;read_only= views only). Asuper_adminedits this at runtime via a super-only endpoint — no Azure AD round-trip is needed to change product behaviour. Granularity is role-level (one set for alladmins, one for allread_onlys); no per-user tuning, no custom roles. super_adminis fixed / break-glass. It implicitly holds all capabilities, cannot be edited, diminished, or locked out, and is the only role permitted to edit the capability config (rbac.manage, super-only).- Server-side enforcement is the boundary. A
require_capability("area.action")dependency inapp/dependencies/admin_permissions.py, layered onget_admin_id, resolves the caller’s Entra role →super_adminallows anything; otherwise it allows iff the configured capability set for that role contains the required capability. A thinrequire_role(*roles)backs the super-only config endpoints.get_user_info()stops hardcodingis_admin=Trueand instead surfacesUserInfo.roles: list[str]. - Membership does NOT move onto
Principal.Principal.rolestays unused; what rettX persists is a tiny role→capability config document, not identity and not per-user role assignment. Identity/membership remain in Entra. - Flag-gated rollout. Enforcement sits behind
RBAC_ENABLED(default OFF). The enable sequence is: provision App Roles in the Entra app registration → seed capability defaults → assign roles to users/groups → verify therolesclaim appears in issued tokens → flipRBAC_ENABLED=on. - Config changes are audited (actor + role + before/after capability sets); the full audit surface is a later spec (044), but the event is emitted now.
- Client gating is UX only.
rettxadminreflects roles from the MSALrolesclaim to gate nav items (via spec 042’srequiredRoles) and routes, and hosts thesuper_admincapability editor, but never as a security control — the API decides.
Why this is not a contradiction of “no DB-driven role”
Section titled “Why this is not a contradiction of “no DB-driven role””The earlier stance rejected putting role membership / identity in the
database (a Principal.role). That still holds: membership stays in Entra.
What is now persisted in rettX is a small, coarse role→capability map — a
piece of product configuration, not identity and not a per-user assignment.
Separating the two lets IT/ops own who is an admin in the directory while the
product owner (super_admin) tunes what an admin can do at runtime, without a
directory change and without ever attaching a role to a caregiver Principal.
Consequences
Section titled “Consequences”Positive
- Fits the identity model. Admin role membership attaches to the Entra
identity that actually authenticates admins, with no dependency on a
caregiver-oriented
Principalrecord that admins may not have. - Clean split of ownership. IT/ops own who is an admin (Entra App Roles);
the product owner (
super_admin) owns what an admin can do (the rettX capability config) and can iterate at runtime without an Azure AD round-trip — fast product tuning without directory changes. - Server-side, auditable enforcement satisfies Constitution Principle VI and patterns.md §4; the client can only reflect access, never grant it. Capability config changes are audited (actor + before/after).
- Safe rollout.
RBAC_ENABLED=offpreserves today’s behaviour until App Roles are provisioned/assigned and defaults are seeded, eliminating lock-out risk.super_adminis fixed and cannot be locked out. - Extensible. Finer capabilities can be added to the catalog and toggled per role without schema or identity changes.
Negative / costs
- New moving parts. A capability catalog, a persisted role→capability store (with seeding/migration), a super_admin editor UI, and capability resolution in the enforcement path all have to be built and maintained — more than a pure role check.
- Config is security-relevant. The role→capability document governs access,
so its edits must be tightly guarded (
super_adminonly), validated (no super-only capability assignable toadmin/read_only), and audited. - Ops dependency remains. Membership still requires Azure AD App Role provisioning and assignment (a directory/admin task).
- Coordinated cross-repo change. rettxapi (catalog + store + endpoints +
enforcement + audit) and rettxadmin (role reflection + editor UI) must land
together, tracked by spec 043 and fanned out as two
squadissues. - Token/claim dependency. If App Roles are misconfigured, tokens may omit the
rolesclaim; the default-off flag and the “verify the claim” rollout step mitigate this, but it must be checked before enabling.
Alternatives considered
Section titled “Alternatives considered”- Manage the granular permissions in Entra directly (fine-grained App Roles
or Entra-side permission scopes). Rejected. Entra App Roles are coarse and
directory-managed: every product-level tweak to what
admincan do would require an Azure AD change by IT/ops, making iteration slow and putting product decisions in the wrong hands. Keeping membership in Entra but the capability map in rettX config gives the product owner runtime control while preserving the directory as the identity source of truth. - DB-driven
Principal.role(role membership in the database). Rejected. Admins are Entra principals that may have noPrincipalrecord (Principal = the Auth0/caregiver identity), so a DB role field does not cleanly cover admin identities and splits the identity source of truth. Note this is distinct from the accepted decision: we persist a coarse role→capability config, not per-user role membership or identity. - Static allow-list of admin emails in config. Rejected. Brittle and unauditable: no granularity (all-or-nothing), changes require a redeploy or config edit, and there is no directory-level record of who was granted what or when.
- Move admin to Auth0 + Auth0 RBAC. Rejected. Contradicts the backend’s
Entra-only admin trust (
auth_provider="entra"), would break admin authentication, and imposes a larger migration for no benefit — the admin plane is deliberately on Entra. - Client-side-only gating in
rettxadmin. Rejected. UI gating (and the editor UI itself) is not a security control (Constitution Principle VI); anyone can bypass a hidden menu item by calling the API directly. Client gating stays, but only as UX on top of server-side enforcement.
Cross-links
Section titled “Cross-links”- spec 043 — Admin RBAC MVP
- spec 042 — Admin app shell
- patterns.md §4 — Authentication & authorization
- Constitution Principle VI (Security baseline) — server-side authorization, least privilege, auditability.