Decisions

ADR-143: Fleet RBAC matrix (Role × Permission)

The fleet today gates HTTP endpoints with a coarse four-value scope string — admin / user / agent / readonly — checked at route-mount time via auth.Middleware(lookup, allowedScopes...) in…

#143

ADR-143: Fleet RBAC matrix (Role × Permission)

Status: Proposed Date: 2026-05-06

Context

The fleet today gates HTTP endpoints with a coarse four-value scope string — admin / user / agent / readonly — checked at route-mount time via auth.Middleware(lookup, allowedScopes...) in gyrum-labs/warp/api/internal/auth/auth.go and consumed across the warp httpapi handlers (items, claim, events, audit, agents_poll). The matching primitives in gyrum-labs/gyrum-go/pkg/auth/rbac.go already define the right shape (Role, Permission, Policy, RequirePermission middleware) but ship a placeholder legal-application matrix (PermCaseCreate, PermInvoiceCreate, …) — not the fleet's.

EPIC warp#1450 Phase 0 lifts auth to a shared substrate. Phase 1 (warp#1451 — A0.1 audit ADR) names what we record. Phase 3 (warp#1453 — A0.3 API key shape ADR) names what a key carries. This ADR (A0.2) names who can do what: the canonical Role × Permission matrix that A1.2 (gyrum-go RBAC expansion) and A2.3 (warp permission guards) implement against. Without it the implementation work has nothing concrete to grant.

The forces:

  • Existing scope checks must keep working. Every endpoint already wired with auth.Middleware(lookup, ScopeAdmin, ScopeUser, …) must resolve to the same allow/deny answer once the matrix replaces the scope-list. A regression here breaks every authenticated caller.
  • Audit must be uniform. Per warp#1290, every admin permission action lands in ~/.gyrum/admin-overrides.log. The matrix tags which permissions are admin-only so the audit hook fires automatically rather than per-handler.
  • Forward-compat is non-negotiable. New permissions are added every quarter (last six months: ticket-loan, suggest, decompose, daily-delta-by-repo). The rule for "what does an existing role get by default when a new permission appears?" has to be answered once here, not re-litigated per PR.
  • Identity provisioning resolves to a role, not a key. Magic-link / GitHub OAuth login mints a session whose role must be one of the four (admin | user | agent | readonly). The default-role rule and the admin-allowlist rule live here so login flows can't mint admin by accident.

Decision

The fleet RBAC policy is a fixed-shape four-role × N-permission matrix, declared once in the gyrum-go auth package as the canonical FleetPolicy() and reused unchanged across every gyrum service.

Roles

The four roles are preserved from warp's existing scope strings (auth.ScopeAdmin | ScopeUser | ScopeAgent | ScopeReadonly) so the in-flight cutover is a rename, not a re-architecture:

Role Identity shape Mint path
admin Human operator with elevated rights. Fresh-cluster bootstrap key + magic-link / OAuth login if the email/login is on the admin allowlist. Never default.
user Authenticated human. Magic-link or GitHub OAuth callback resolves to user by default (see "Config-driven elevation" below).
agent Long-running autonomous worker. mint_agent_token permission (admin-only) issues an wrp_… bearer with scope agent.
readonly Dashboards, reporting, embedded views. Pre-minted bearer with scope readonly. Cannot mutate.

Permissions

Granular, named, written in snake_case for human-readability in audit records. Each permission maps 1-to-1 to a real fleet capability today.

Admin-only — fleet-control plane.

Permission What it gates Existing endpoint(s)
mint_agent_token Issue a new wrp_… bearer at any scope. POST /api/v1/keys
revoke_session Invalidate a user's magic-link / OAuth session. (A2.3 — new)
manage_users Add/remove from admin allowlist; toggle readonly keys. (A2.3 — new)
view_audit_log Read ~/.gyrum/admin-overrides.log and warp's audit feed. GET /api/v1/items/{id}/audit (admin filter), bulk export routes

User + agent — ticket lifecycle.

Permission What it gates Existing endpoint(s)
claim_ticket Atomic claim. POST /api/v1/items/{id}/claim
release_ticket Voluntary unclaim. POST /api/v1/items/{id}/release
complete_ticket Terminal success with PR URL. POST /api/v1/items/{id}/complete
block_ticket Flag external blocker. POST /api/v1/items/{id}/block
unblock_ticket Clear the blocker. POST /api/v1/items/{id}/unblock
cancel_ticket Withdraw the item from the queue. POST /api/v1/items/{id}/cancel

User-only — human session controls.

Permission What it gates Existing endpoint(s)
mint_own_session Magic-link / OAuth callback issues a session bearer. GET /api/v1/auth/{magic-link/redeem,github/callback}
manage_own_devices List / revoke the caller's own session keys. (A2.3 — new)

Agent-only — worker lifecycle.

Permission What it gates Existing endpoint(s)
heartbeat Lease keepalive on a claimed ticket. POST /api/v1/items/{id}/heartbeat
update_progress Append progress lines, post poll results. POST /api/v1/items/{id}/health, POST /api/v1/agents/poll, ticket-event ingest (warp#1368)

Everyone (incl. readonly) — observation.

Permission What it gates Existing endpoint(s)
read_board List items, get item, list children, aggregates, stats, suggest, daily-delta, cumulative-flow, agents/activity, decompose-read. GET /api/v1/items*, GET /api/v1/stats, GET /api/v1/items/{id}, …
read_changes Read the audit feed and ticket-event stream. GET /api/v1/items/{id}/audit, ticket-events read
whoami Identity probe. GET /api/v1/whoami

Role × Permission matrix

The grants. = granted, = denied. Cells are exhaustive — every permission has an explicit answer for every role. No "inherited" grants; the table is the truth.

Permission admin user agent readonly
mint_agent_token
revoke_session
manage_users
view_audit_log
claim_ticket
release_ticket
complete_ticket
block_ticket
unblock_ticket
cancel_ticket
mint_own_session
manage_own_devices
heartbeat
update_progress
read_board
read_changes
whoami

admin holds every permission by construction (see forward-compat rule 1). readonly holds the three "everyone" permissions and nothing else. user and agent overlap on the ticket-lifecycle six and diverge on the human/worker-specific four.

Cross-check: every existing warp endpoint maps to a permission

This was the acceptance criterion of warp#1452. Audit of grep -n "auth.Middleware" api/internal/httpapi/*.go:

Endpoint Current scope check Permission
GET /whoami admin|user|agent|readonly whoami
GET /items*, GET /items/{id}, GET /items/{id}/children, GET /items/aggregate, GET /stats, GET /items/content-bloat admin|user|agent|readonly read_board
POST /items admin|user|agent claim_ticket (creating an item is the same write-class; A2.3 may split into create_ticket if needed — see forward-compat)
PATCH /items/{id} admin|user|agent claim_ticket (idem)
DELETE /items/{id} admin|user|agent cancel_ticket
POST /items/{id}/decompose admin|agent update_progress (decompose is an agent-side restructure)
POST /items/{id}/claim admin|user|agent claim_ticket
POST /items/{id}/heartbeat admin|user|agent heartbeat (note: user keeps grandfathered access via cutover transition; long-term this is agent-only — see consequences)
POST /items/{id}/complete admin|user|agent complete_ticket
POST /items/{id}/block admin|user|agent block_ticket
POST /items/{id}/unblock admin|user|agent unblock_ticket
POST /items/{id}/release admin|user|agent release_ticket
POST /items/{id}/cancel admin|user|agent cancel_ticket
POST /items/{id}/health admin|agent update_progress
POST /agents/poll admin|user|agent update_progress
GET /items/{id}/audit admin|user|agent|readonly read_changes
ticket-events ingest (warp#1368) admin|user|agent update_progress
POST /keys, GET /keys admin only mint_agent_token
bulk export routes admin only view_audit_log
*/auth/{github,magic-link}/* public (no bearer) mint_own_session (resolves to a role on success; pre-auth is unguarded)

Every existing route resolves cleanly. The two cells worth flagging (heartbeat granted to user for cutover; claim_ticket/cancel_ticket covering item create/patch/delete) are explicit transitions, not oversights — A2.3 lands them in a single PR with the matrix as the authoritative diff target.

Forward-compat rules

Rule 1 — adding a new permission. A new Permission constant defaults to granted to admin, denied to every other role. Explicit grants to user, agent, or readonly are added in the same PR as the constant. A reviewer (per ADR-115) rejects a permission constant landing without explicit grants for at least one of {admin, user, agent} — every permission must have at least one role that holds it, or it is dead code.

Rule 2 — adding a new role. A new Role constant defaults to denied for every permission. The PR adding the role enumerates each existing permission and the new role's grant for it. No "inherits from user" shortcut — explicit-grant is the rule because inherited grants are how privilege escalations slip through review.

Rule 3 — splitting an existing permission. When a permission proves too coarse (e.g. claim_ticket covering both create and mutate), the split PR (a) introduces the new finer permission, (b) grants the new permission to every role that held the old one, (c) keeps the old permission as an alias for one release, (d) the release after that removes the alias. Old grants are not silently dropped.

Rule 4 — deprecating a permission. Mark with a godoc Deprecated: comment naming the replacement. The constant remains for one minor release so external callers can migrate. The PR removing the constant runs the regression corpus (per ADR-117 §4) to verify no live grant references it.

Rule 5 — admin holds everything. Rule 1 states this for new permissions; the invariant is maintained for the existing set by construction. A grant table where admin is denied any permission is rejected by the validator. Admin is the break-glass role; if a permission needs to be admin-denied, it needs to be removed from the policy entirely (no permission at all).

Audit integration

Every check of an admin-only permission writes a JSONL line to ~/.gyrum/admin-overrides.log per warp#1290 conventions:

{"ts": "2026-05-06T12:34:56Z", "actor": "key_id:abc123", "permission": "mint_agent_token", "resource": "/api/v1/keys", "result": "granted"}

Implementation lives in A1.2: the RequirePermission middleware inspects Permission.IsAdmin() (a method on the Permission type populated from the matrix) and appends the line on every check — granted or denied — before delegating. User/agent/readonly permissions do not log to admin-overrides; they ride the existing warp ticket-events audit feed (warp#1316).

Config-driven elevation rules

Magic-link and GitHub OAuth callbacks resolve a verified email/login to a role via this rule, in order:

  1. Empty allowlist → first user is admin (bootstrap). When ~/.config/gyrum/admin-allowlist.yaml is empty and the warp_api_keys table contains zero admin-scope rows, the first successful magic-link / OAuth callback mints an admin session. Logged conspicuously to ~/.gyrum/admin-overrides.log with bootstrap: true.
  2. Allowlist hit → admin. Verified email or GitHub login matches an entry in ~/.config/gyrum/admin-allowlist.yaml. The allowlist is read at request time (no caching) so revocation is immediate.
  3. Otherwise → user. Default for any other authenticated identity. Never agent (agents are minted, not logged in) and never readonly (readonly is pre-minted by an admin).

The allowlist file lives outside the repo (lifted from ansible-managed inventory in production, hand-edited locally in dev) so a stolen repo does not grant cluster admin. Rule 1 exists to make first-run bootstrap possible without a chicken-and-egg "need an admin to add yourself to the allowlist" deadlock.

Consequences

Easier:

  • A1.2 (gyrum-go RBAC expansion) implements FleetPolicy() against this exact table — no design questions, just a transcription of the matrix above into Grant(...) calls.
  • A2.3 (warp permission guards) replaces every auth.Middleware(lookup, scopes...) call with auth.RequirePermission(policy, perm) mechanically; the audit table in this ADR is the migration plan.
  • Future ADRs need not re-litigate "should agents claim tickets?" — the matrix is the canonical answer and the forward-compat rules bound how it changes.
  • Admin audit becomes uniform — every admin permission action lands in one JSONL file, queryable by ops without per-handler instrumentation.

Harder:

  • Existing handlers that accept a scope list now accept a permission — the call-site shape changes once during the cutover. A2.3 is the single PR that lands this; a partial cutover would leave warp with two parallel auth shapes, which is worse than either.
  • The matrix is now a versioned artefact. Adding a permission means editing a markdown table and the corresponding Go grant in lockstep (same PR, validator-enforced). The discipline is real.
  • The user-grant on heartbeat is a transition; A2.3 must remove it once warp's CLI-driven user heartbeat callers (a small set, enumerated in the cutover PR) are migrated to long-running agent tokens. This ADR records the future-deny so the transition has a target.
  • The bootstrap rule (empty allowlist → first user is admin) is a real privilege grant that must be loud. Operators read the audit log at first-run; sloppy ops with a stale admin-allowlist.yaml on a fresh cluster will mint an admin to whoever logs in first. Mitigated by the conspicuous bootstrap: true log line and the ansible runbook making allowlist provisioning a Day 0 task.

Operate / maintain / revisit:

  • Validator at gyrum-validate-policy (A1.2 ships this) rejects a policy where admin is denied any permission, where any permission has zero grants, or where a role is missing an explicit grant for any permission. CI runs it on every gyrum-go PR.
  • Regression corpus at gyrum-go/pkg/auth/testdata/rbac-grants.csv — every (role, permission) pair and the expected allow/deny — runs in CI on every PR and on the nightly drift cron. A diff in the corpus without a matching ADR PR is rejected.
  • The matrix is reviewed on the EPIC retrospective cadence (per warp#1450). New permissions accumulated since the last review are ratified or deprecated explicitly; the matrix never drifts as "add-only".

Alternatives considered

  • Keep the four-string scope. Every endpoint declares a scope list at mount time, no permission abstraction. Lost because (a) the lists are duplicated across handlers (admin|user|agent appears 7 times in warp's httpapi alone), (b) there is no audit hook for "this was an admin action", (c) adding a fifth role (e.g. service) means touching every mount call, and (d) the shape doesn't compose with gyrum-go's RequirePermission — the fleet would carry two auth idioms forever.
  • Per-service policy. Each service (warp, ai-frontend, factory-cli) defines its own matrix. Lost because the same agent identity hits multiple services and "what can a user do?" must have one answer fleet-wide. Per-service policy is how ACLs go inconsistent the day after the second service ships.
  • Capability-based tokens (no roles). Every token carries an explicit list of permissions it holds; roles are syntactic sugar. Lost because (a) magic-link / OAuth callbacks don't have a clean way to mint a permission list (the human just authenticated — what's the list?), (b) audit lines get noisier (every token has a different permission set), (c) the four-role abstraction matches operators' mental model of the fleet. Capabilities are the right primitive under roles — see ADR-114 for capability provisioning of agent tokens — but the user-facing surface is roles.
  • Hierarchical roles (admin > user > agent > readonly). A role inherits every permission of the role below. Lost because (a) agent and user have non-overlapping permission sets (heartbeat is agent-only; manage_own_devices is user-only) — there is no linear order, (b) inheritance is exactly how privilege escalations slip past review (a permission added to user silently granting it to admin is the canonical failure mode of every hierarchical RBAC system in production). The flat table is more verbose and strictly safer.

Supersedes: none Superseded by: (leave blank until a later ADR reverses this one)

Refs:

  • warp#1452 (this ADR's ticket)
  • warp#1450 (parent EPIC: fleet auth substrate)
  • warp#1451 (sister A0.1: audit ADR)
  • warp#1453 (sister A0.3: API key shape ADR)
  • warp#1290 (factory-cli admin-overrides.log conventions)
  • warp#1316 / warp#1368 (ticket-events audit feed)
  • warp#1353 (admin-reachable-from-browser; gates on mint_agent_token)
  • ADR-115 (principle-aware reviewers — quotes forward-compat rules above)
  • ADR-117 (module guidelines — gyrum-go auth-package guideline cites this matrix)
  • gyrum-go pkg/auth/rbac.go (existing Role, Permission, Policy types)
  • warp api/internal/auth/auth.go (existing scope strings)