ADR 0033 — Workspace roles: Admin / Member / Viewer RBAC on the two-axis model¶
- Status: Accepted
- Date: 2026-07-09
- Deciders: @TheurgicDuke771
- Amends: ADR 0027 — the workspace-admin source moves from the
WORKSPACE_ADMIN_EMAILSenv allowlist to a storedusers.role(this stales 0027's "/me-layer resolved from the allowlist" implementation line and its Related ADR-0010 "off a config allowlist" pointer, not just the top-level framing); the implicit-suite-admin rule is unchanged; the grant model gains one rule (noeditshares to Viewers). ADR 0032 — its OTP signup contract gainsAUTH_OTP_DEFAULT_ROLE, and its Decision-6 trust statement widens: mailbox compromise of any stored-role admin (not only allowlisted addresses) is admin compromise. The inline amendment blockquotes landed in 0027/0032 with ratification (2026-07-09). - Related: ADR 0026 (richer-principals direction; PAT-inherits-user preserved), 0020 (change-audit lineage for role mutations — #310), 0010 (generic identity attributes only — roles are DataQ-stored, no IdP claims read)
- Issue: umbrella #744 → slices #740 (role model) · #741 (enforcement) · #742 (management) · #743 (frontend). Ratified 2026-07-09 — slices unblocked.
Context¶
Authorization today is two axes with one axis degenerate. The fine axis works: every suite-scoped endpoint (REST and MCP identically) gates through require_permission on the view < edit < admin < owner ladder (ADR 0027). The coarse axis is a binary env allowlist: WORKSPACE_ADMIN_EMAILS makes you workspace-admin; everyone else is an undifferentiated "user". Consequences recorded as gap G-e ("config-allowlist admin, one validated IdP — fine internally, not sellable"): admins are managed by env-edit + restart; there is no read-only participant tier; and the largest unscoped hole — connections are workspace-global, so any authenticated user (even one holding only view shares) can delete or re-credential the Snowflake connection every suite runs on (connection_service has no ownership gate; created_by is display-only).
What this ADR is not: it does not touch the per-suite ladder, build groups/teams/custom roles, or add per-connection ACLs. The suite ladder remains the fine-grained axis; this ADR formalizes the coarse one.
Decision¶
- Three stored workspace roles —
admin | member | viewer— as ausers.rolecolumn (server-defaultmember, CHECK-constrained; one additive migration, no roles table). Named Viewer, not Guest:AZURE_ALLOW_GUEST_USERSalready means Entra B2B guests, and two "guest" concepts would collide in config and docs. - The two axes compose; neither replaces the other. Workspace role says what kind of user you are; per-suite grants say what you can touch. A Member with no share on a suite still cannot see it (404 existence-hiding unchanged). Workspace-admin remains implicit
adminon every suite exactly per ADR 0027 — only its source moves tousers.role. - Capability matrix (the normative table):
| Capability | Admin | Member | Viewer |
|---|---|---|---|
| See/use suites shared to them | ✅ | ✅ | ✅ (view only) |
| Create/import suites (become owner) | ✅ | ✅ | ❌ |
Receive edit shares |
✅ | ✅ | ❌ — capped at view |
| Connections: mutate (create/edit/delete/re-auth) | ✅ | ❌ | ❌ |
| Connections: list/reference in suites | ✅ | ✅ | list only |
Connections: test |
✅ | ✅ | ❌ |
| Mint PATs (token inherits the user, ADR 0026) | ✅ | ✅ | ✅ |
/admin endpoints, implicit suite-admin, workspace-wide visibility |
✅ | ❌ | ❌ |
| Manage roles in-app | ✅ | ❌ | ❌ |
- Connection mutations become Admin-only — the load-bearing row. Connections are shared infrastructure holding credentials; Members consume them, Admins manage them. This is a breaking change for Members who managed connections (promote them before upgrading — release-notes obligation in #741). Credentials stay unreadable through every tier; per-connection ACLs/ownership stay deferred and can layer on later without conflicting with this gate.
- Viewer is capped belt-and-braces: granting
editto a Viewer is rejected at grant time, ANDeffective_permissioncaps a Viewer's resolved level atview(covers legacy rows and demote-after-grant). Suite creation requires Member+. WORKSPACE_ADMIN_EMAILSdemotes to bootstrap + break-glass. On user upsert, an allowlisted email resolves toadmin(write-through), so existing deployments upgrade with zero config change; the env path also recovers a workspace whose last admin left.is_workspace_admin()= stored role OR allowlist.- In-app management with a last-admin guard.
PATCH /admin/users/{id}/role(admin-gated). The guard's counting rule: a change must leave ≥ 1 stored-role admin; allowlist-resolved admins never satisfy the invariant — the env path is the recovery mechanism, not the invariant, so the guard can't be talked out of protection by an env entry that may later disappear. Every role change emits a structured audit log line (actor, target, old→new); the durable table for change events is ADR 0020's deferred cross-entity audit log (#310 — a mutation event, not G-d/#431's data-access read audit). Roles resolve per request, so a change — including for the target's PATs, which are their user — takes effect on their next request with no session machinery. - ADR 0032 interplay (amends 0032): OTP signups get
AUTH_OTP_DEFAULT_ROLE(defaultmember; setviewerfor cautious domain-wide allowlists) — delivered with the role-model slice #740, activating when the OTP slices land. Precedence: the allowlist write-through wins over any signup default — an operator on both the OTP signup allowlist andWORKSPACE_ADMIN_EMAILSgets storedadminat first sign-in, nevermember-stored-but-admin-effective, so dropping the env entry later can't silently demote the bootstrap admin the last-admin guard was counting.
Consequences¶
Positive — G-e's "config-allowlist admin" objection is answered with one column and two small gates; the connection-deletion hole closes; a safe read-only tier exists for stakeholders; enforcement stays in the existing seams (require_permission, a new require_role mirroring require_workspace_admin), so REST/MCP can't drift — with MCP parity for each new gate (suite-create, share-grant validation, connection mutations) an explicit test obligation in #741, not an assumption. Groups, custom roles, and per-connection ACLs layer on later without schema rewrite; when ADR 0026 phase 2 generalizes users → principals, the role attribute moves with that migration (service-account principals carry an explicit role at creation) — this ADR deliberately does not pre-decide the principal schema.
Negative / accepted — Members lose connection-write (breaking; migration note + promote-first guidance in #741); a fixed three-role enum won't satisfy enterprises wanting custom roles (deliberately deferred); role changes are audit-logged but not yet audit-tabled (#310); the allowlist break-glass means an env-level actor can always mint an admin — unchanged from today, recorded here and carried into docs/security.md / docs/compliance-posture.md by #742 (the ADR alone is not where operators/auditors read the admin model).
Alternatives considered¶
- Custom roles / permission-matrix tables — rejected for now: single-tenant, small-team product; a fixed enum covers the sellability gap and the matrix can be introduced later behind the same
require_roleseam. - External policy engine (OPA / Casbin / SpiceDB) — rejected: heavy operational dependency for three roles and one resource ladder; nothing in the current model needs relationship-graph evaluation.
- Folding the suite ladder into roles (role decides everything) — rejected: loses per-resource control; a Member must not see unshared suites.
- Per-connection ACLs now — rejected: Admin-only mutation closes the actual hole with one rule; ownership semantics (who may grant, what happens on owner deletion) deserve their own decision when multi-team demand is real.
- "Guest" naming — rejected: collides with Entra B2B guest terminology already present in config (
AZURE_ALLOW_GUEST_USERS). - Keep the env allowlist as the only admin source — rejected: that is G-e; but it survives as bootstrap/break-glass rather than being removed.