RBAC registry drift — governance.write orphan, audit/sign downgrade, masked 404s
2026-08-08
LOCKSTEP TRACEABILITY MATRIX --- api_endpoints: ["none — no routes added/removed/moved, only permission resolution changed"] sdk_methods_updated: ["none"] mcp_tools_updated: ["none"] ---
What We Built
Fixed a class of RBAC registry drift found in a live production review (build 59c0d0d): POST /v1/governance/sovereignty/policy (and 4 sibling governance routes) required a permission — governance.write — that was never added to the RbacPermission union and that no role granted. The control was unconfigurable by anyone, including admin, and the 403 body's own roles_that_include_permission: [] proved it while still claiming self_service_possible: true and recommending patch_key, a dead end (PATCH /v1/api-keys rejects unknown permissions outright).
Separately, POST /v1/governance/audit/sign declared governance.write as its intended permission but a stale ROUTE_PERMISSIONS entry pinned it to audit.read, silently downgrading a write-tier action to a read-tier gate.
Fixes:
- Added
governance.read/governance.writetoRbacPermission(src/security/rbac.ts)
and granted governance.write to admin only; granted governance.read to admin, developer, operator, and auditor (mirroring the existing audit.read distribution). Auditor deliberately does not get governance.write — every other domain keeps auditor read-only, and sovereignty policy / credential issuance are higher-impact than plain audit logging.
- Removed the
ROUTE_PERMISSIONSoverride forPOST /v1/governance/audit/sign, letting
it fall through to the capability's own (now-valid) governance.write.
- Fixed
computeRemediation()insrc/api/middleware/rbac.ts: when no role grants the
required permission (grantingRoles.length === 0), it previously computed self_service_possible: true via Math.min(...[]) === Infinity vacuously passing the hierarchy check. It now returns self_service_possible: false with a new unassigned_permission_contact_support action.
- Unmapped GET routes now return a genuine 404 instead of a masked 403 (mutating
verbs keep the 403 mask — see rationale in rbacMiddleware()). Mapped bare GET /v1/usage (no trailing slash), which previously fell through the unmapped-route branch entirely. Confirmed /v1/analytics/ never existed (only /auth/analytics/, a different, non-RBAC-gated mount) and that site/public/routes.json never advertised it — no phantom-route cleanup needed there.
- New boot-verifiable invariant (
src/security/rbac-registry-invariants.ts+
.test.ts, run in CI): for every /v1/ capability, the _effective_ permission (post ROUTE_PERMISSIONS override) must be granted by at least one role, and any override must not downgrade a declared .write/.admin tier to a weaker one. The test's own allowlist documents everything the scan found outside this task's scope (platform.admin ungranted to any role, god-mode./liaison.open orphans, several a2a/mesh/mcp naming mismatches, one more write→read downgrade in POST /v1/forensics/export) — flagged for a follow-up, not silently swallowed.
Why It Matters
Data residency / sovereignty policy is the constraint most likely to be contractually binding for an enterprise tenant. A gateway that cannot let a paying admin configure it — while telling them it's self-service — is a trust-destroying bug, not a cosmetic one.
How It Works
checkCapability() resolves each /v1/ capability's effective permission the same way rbacMiddleware() does at request time (resolvePermission(): exact ROUTE_PERMISSIONS match → prefix match → capability's own declared permission), then checks it against the live ROLE_PERMISSIONS grant set. It's exported standalone (not just the whole-registry scan) so tests can inject a synthetic grantedPermissions set and prove the checker itself — not just today's fixed registry — would have caught the defect. Reverting the src/security/rbac.ts union/role changes reproduces exactly the pre-fix failure: all 6 /v1/governance/ routes report orphan_effective_permission.
The invariant deliberately does not hard-fail on every declared-vs-enforced mismatch — running it unscoped surfaced ~180 pre-existing mismatches across the registry (a widespread, apparently deliberate pattern where a capability's permission field is a documentary label and ROUTE_PERMISSIONS is the real gate). Failing CI on all of them would have required editing dozens of files outside this task's scope, including src/api/routes/completions/**, actively being changed by another engineer on this branch. Only genuine tier downgrades (.write/.admin declared, .read enforced) are treated as violations; everything else is either a non-issue (equal-or-stronger override) or explicitly allowlisted with a note for follow-up.
The Numbers
- 5 previously-unreachable
/v1/governance/*POST routes now resolve to a valid, granted
permission: credentials/issue, credentials/verify, knowledge/snapshot, sovereignty/policy, sovereignty/purge.
- 1 route permission downgrade fixed (
audit/sign:audit.read→governance.write). - 16 pre-existing, out-of-scope violations discovered and tracked (not fixed here).
Lockstep Checklist
- [x] API Routes: N/A — no route added, removed, or moved; only permission
resolution and error-body content changed on existing routes.
- [x] TS SDK: N/A — no request/response shape change.
- [x] Python SDK: N/A.
- [x] MCP Schemas: N/A —
br_my_permissions'srecommended_actionfield is a
free-form string in its schema already; the new unassigned_permission_contact_support value needs no schema change.
- [x] Master Record: No new capability surface; not applicable.