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:
- Empty allowlist → first user is admin (bootstrap). When
~/.config/gyrum/admin-allowlist.yamlis empty and thewarp_api_keystable contains zeroadmin-scope rows, the first successful magic-link / OAuth callback mints an admin session. Logged conspicuously to~/.gyrum/admin-overrides.logwithbootstrap: true. - 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. - Otherwise → user. Default for any other authenticated
identity. Never
agent(agents are minted, not logged in) and neverreadonly(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 intoGrant(...)calls. - A2.3 (warp permission guards) replaces every
auth.Middleware(lookup, scopes...)call withauth.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 onheartbeatis 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.yamlon a fresh cluster will mint an admin to whoever logs in first. Mitigated by the conspicuousbootstrap: truelog 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 whereadminis 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|agentappears 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'sRequirePermission— 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
userdo?" 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)
agentanduserhave 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 tousersilently granting it toadminis 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)