RBAC registry drift — governance.write orphan, audit/sign downgrade, masked 404s

2026-08-08

rbacgovernance

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:

  1. Added governance.read / governance.write to RbacPermission (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.

  1. Removed the ROUTE_PERMISSIONS override for POST /v1/governance/audit/sign, letting

it fall through to the capability's own (now-valid) governance.write.

  1. Fixed computeRemediation() in src/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.

  1. 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.

  1. 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.readgovernance.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's recommended_action field 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.