Raxx · internal docs

internal · gated

Console Blueprint RBAC V2 Cutover Design

Status: Design locked 2026-06-18 Owner: software-architect Refs: #1473 (parent card), #1464 (RBAC V2 design PR), issues #971/#972/#973 (resolution-DAG) Related docs: rbac-design.md, auth-unification-rbac-reconciliation.md Related ADR: 0129-rbac-blueprint-cutover-rollout.md


1. Context

The Console has 141 legacy @require_role(...) decorator call sites across the 17 blueprints listed below. Each call invokes admin.has_role(...) against the flat admin_roles table (superadmin / ops / support / readonly). The RBAC V2 tables (rbac_roles, rbac_user_groups, rbac_group_roles, rbac_role_permissions) were provisioned in migration 0012. Fine-grained decorators (@require_rbac_role, @require_rbac_permission, and the billing-specific @require_permission) already exist in console/app/middleware/rbac.py and are used by ~54 call sites that were already ported (billing, api_billing, api_billing_dashboard, api_rbac_grants).

This design governs the remaining 141-site cutover, grouped into 17 blueprints. The correctness artifact is the per-route permission mapping in §3. A wrong cell can lock out a legitimate operator or over-expose a privileged surface before launch.


2. Invariants

These constraints take precedence over every design choice below.

# Invariant
I-1 No stored credentials. The RBAC V2 path reads live from rbac_* tables per request; no credential caching.
I-2 Audit trail on every 403. _write_access_denied() already fires in both @require_role and @require_rbac_role. The cutover must not remove audit rows.
I-3 TOTP elevation routes are unchanged. @require_totp_elevation is applied after the role/permission gate on the same stack. No cutover touches the TOTP chain.
I-4 require_permission billing gates are out of scope. The billing @require_permission(...) decorator (with its cap and retention logic) is already V2-equivalent and is not replaced.
I-5 FLAG_RBAC_V2 is the single kill-switch. Legacy @require_role stays the live path until the flag is on; flip-back is a one-command rollback.
I-6 No mapping guess on ambiguous surfaces. Where legacy role intent and V2 role are not obvious, the mapping is flagged AMBIGUOUS and escalated to an operator decision before the sub-card can be claimed.
I-7 Machine-to-machine endpoints are excluded. Routes using Bearer token / HMAC auth (audit_ingest, internal_api, xenv, freescout_webhook_rbac, deploy status callback) do not use session-based auth and are not touched.
I-8 @require_totp_elevation dependency on g.admin must be preserved. Any decorator stack that includes TOTP elevation must keep the session-auth decorator first.

3. Per-Blueprint, Per-Route Permission Mapping

How to read this table

Role names follow <app>-<resource>-<level> per rbac-design.md §3.1.

Legend of roles cited

From migration 0012 (_ROLES) and subsequent migrations:

V2 Role Seeded in Permitted by group
console-user 0012 raxx-platform-admins, raxx-support-team, raxx-devops-team
console-ops 0012 (inherits console-user + console-audit-user; no group assigned directly)
console-audit-user 0012 raxx-platform-admins, raxx-support-team, raxx-devops-team
console-flag-admin 0012 raxx-platform-admins (via console-manager), raxx-devops-team
console-secrets-user 0012 raxx-platform-admins (via console-secrets-admin inheritance)
console-secrets-admin 0012 raxx-platform-admins, break-glass
console-invite-admin 0012 raxx-platform-admins (via console-manager), break-glass
console-env-admin 0012 raxx-platform-admins (via console-manager), raxx-devops-team
console-manager 0012 raxx-platform-admins
console-billing-read 0014 raxx-platform-admins (added in 0016)
console-billing-write 0052 raxx-platform-admins (implied)
console-flag_promotion_queue-read 0097 ops-admins, staging-admins
console-flag_promotion_queue-write 0097 ops-admins
console-prod-services-write 0097 ops-admins
console-staging-services-write 0097 ops-admins, staging-admins
raptor-audit-admin 0021 raxx-platform-admins
console-session-admin 0199 raxx-platform-admins, break-glass
console-beta-admin 0199 raxx-platform-admins, break-glass

console-ops group-assignment fix (verifier #5 — RESOLVED 2026-06-18): Migration 0012 defined console-ops but assigned it to no group. Migration 0199 adds the raxx-platform-admins group assignment. This was an unintentional omission; the role description ("Operator daily driver: console-user + console-audit-user") confirms the intent. The pre-cutover blocker (OQ-5) is now closed. See §10 for the updated status.


Blueprint 1: admins_online.py — 2 routes, 2 legacy sites

Route Method Legacy gate V2 decorator V2 role exists?
/console/admins/online GET require_role("superadmin") require_rbac_role("console-session-admin") Yes (0199)
/console/admins/online/<session_id>/revoke POST require_role("superadmin") require_rbac_role("console-session-admin") Yes (0199)

Rationale: Both routes gate on the new console-session-admin role, which carries the console:admins:revoke-session permission. This keeps session-lifecycle management distinct from invitation semantics (console-invite-admin carries invite + groups:write, which are unrelated to session revocation).

AMBIGUOUS-1 — RESOLVED (2026-06-18): Option B chosen by operator. New role console-session-admin + permission console:admins:revoke-session seeded in migration 0199. Assigned to raxx-platform-admins + break-glass. Sub-card S7 may now be claimed.


Blueprint 2: alerts.py — 4 routes, 4 legacy sites

All 4 routes (/api/_internal/alerts/unread-count, /_alerts/drawer, /_alerts/dismiss/<id>, /_alerts/dismiss-all) use:

Route Legacy gate V2 decorator
All 4 routes require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")

Rationale: Any authenticated Console operator should see alerts. console-user is the broadest role held by all three groups. This preserves the "all roles" intent exactly.


Blueprint 3: api_billing.py — 3 routes, 0 legacy sites

Already fully ported. No action required.


Blueprint 4: api_billing_dashboard.py — 7 routes, 0 legacy sites

Already fully ported. No action required.


Blueprint 5: api_rbac_grants.py — 4 routes, 0 legacy sites

Already fully ported. No action required.


Blueprint 6: api_status.py — 4 routes, 4 legacy sites

Route Legacy gate V2 decorator
/api/status/sites require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
/api/status/sites/<id> require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
/api/status/builds require_role("superadmin", "ops", "support") require_rbac_role("console-audit-user")
/api/status/secrets require_role("superadmin") require_rbac_role("console-secrets-admin")

Rationale for /builds: support can read builds but not readonly. console-audit-user is held by raxx-platform-admins, raxx-support-team, raxx-devops-team — matching the support-and-above intent. readonly is the only tier excluded, which matches the legacy gate.

Machine-token compatibility note (Open Question #6): @machine_auth_or_session is applied before @require_role on /sites and /builds. Machine tokens have no rbac_user_groups rows and will 403 under V2 unless enrolled in a group. Feature-developer must verify or add machine-token enrollment before S5 ships.


Blueprint 7: audit_ingest.py — 1 route, 0 legacy sites

Uses Bearer token auth. Not session-based. Excluded per Invariant I-7.


Blueprint 8: audit_viewer.py — 1 route, 1 legacy site

Route Legacy gate V2 decorator
/console/audit require_role("ops", "superadmin") require_rbac_role("console-audit-user")

Rationale: console-audit-user grants console:audit:read — exact permission match.


Blueprint 9: beta.py — 12 routes, 15 legacy sites

All 12 routes use @require_role("superadmin").

V2 decorator
require_rbac_role("console-beta-admin")

Rationale: Dedicated console-beta-admin role carrying console:beta:manage permission. Beta management is PII-touching (invites, deletes, resends) and semantically distinct from general platform management. A dedicated role enables future fine-grained restriction (e.g., a future support operator who can view but not delete beta testers) without re-migrations.

AMBIGUOUS-2 — RESOLVED (2026-06-18): Option B chosen by operator. New role console-beta-admin + permission console:beta:manage seeded in migration 0199. Assigned to raxx-platform-admins + break-glass. Sub-card S3 (beta cluster) may now be claimed on this axis.


Blueprint 10: billing.py — 3 routes, 0 legacy sites

Already fully ported. No action required.


Blueprint 11: console_versions.py — 1 route, 1 legacy site

Route Legacy gate V2 decorator
POST /api/internal/console-versions/promote require_role("superadmin") + @require_totp_elevation require_rbac_role("console-manager") + @require_totp_elevation

TOTP elevation preserved. The V2 decorator replaces only the session/role gate.


Blueprint 12: console_versions_ui.py — 1 route, 1 legacy site

Route Legacy gate V2 decorator
/admin/console-versions require_role("superadmin") require_rbac_role("console-manager")

Blueprint 13: customers.py — 16 routes, 16 legacy sites

Route Legacy gate V2 decorator
GET /console/customers/ require_role("ops", "superadmin") require_rbac_role("console-audit-user")
GET /console/customers/invite require_role("superadmin") require_rbac_role("console-invite-admin")
POST /console/customers/invite (+ inline TOTP) require_role("superadmin") require_rbac_role("console-invite-admin")
GET /console/customers/<id> require_role("ops", "superadmin") require_rbac_role("console-audit-user")
POST /console/customers/<id>/<session_id>/revoke require_role("ops", "superadmin") require_rbac_role("console-invite-admin")
POST /console/customers/<id>/revoke-bootstrap-token require_role("superadmin") require_rbac_role("console-invite-admin")
GET /console/customers/<id>/tickets require_role("ops", "superadmin") require_rbac_role("console-audit-user")
GET /console/customers/<id>/rbac-grants require_role("ops", "superadmin") require_rbac_role("console-audit-user")
GET /console/customers/<id>/rbac-grants/agents require_role("ops", "superadmin") require_rbac_role("console-audit-user")
GET /console/customers/<id>/rbac-grants/roles require_role("ops", "superadmin") require_rbac_role("console-audit-user")
POST /console/customers/<id>/rbac-grants require_role("ops", "superadmin") require_rbac_role("console-invite-admin")
DELETE /console/customers/<id>/rbac-grants/<grant_id> require_role("ops", "superadmin") require_rbac_role("console-invite-admin")
GET /console/customers/<id>/audit require_role("ops", "superadmin") require_rbac_role("console-audit-user")
GET /console/customers/<id>/danger-zone require_role("superadmin") require_rbac_role("console-manager")
POST /console/customers/<id>/delete require_role("superadmin") require_rbac_role("console-manager")
POST /console/customers/<id>/reset require_role("superadmin") require_rbac_role("console-manager")

AMBIGUOUS-3 — RESOLVED (2026-06-18): Option B chosen by operator. Route maps to existing console-invite-admin (user management semantic; carries console:admins:invite + console:groups:write — scoped to user-management intent, not read-only audit). No new role or migration needed. The customers cutover sub-card (S3) will wire the decorator directly.


Blueprint 14: dashboard.py — 9 routes, 10 legacy sites

Route Legacy gate V2 decorator
GET /dashboard require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
GET /dashboard/_status_grid require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
GET /dashboard/_activity require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
POST /dashboard/_force_poll require_role("superadmin", "ops") require_rbac_role("console-flag-admin")
GET /dashboard/sites/<id> require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
GET /dashboard/api/sites/<id>/latency-history require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
GET /dashboard/api/sites/<id>/probe-history require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
GET /dashboard/api/sites/<id>/audit require_role("superadmin", "ops") require_rbac_role("console-audit-user")
POST /dashboard/<site>/flag/<flag>/toggle require_role("superadmin", "ops") require_rbac_role("console-flag-admin")
POST /dashboard/status/<surface_id>/config require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")

Note on _force_poll: This is a mutation (force-triggers polling). console-flag-admin is the V2 role for operational write actions on Console infrastructure.


Blueprint 15: deploy_freeze.py — 2 routes, 2 legacy sites

/api/internal/deploy-freeze/state uses service-token auth — excluded per Invariant I-7.

Route Legacy gate V2 decorator
GET /console/deploy-freeze require_role("superadmin") require_rbac_role("console-manager")
POST /console/deploy-freeze/toggle require_role("superadmin") require_rbac_role("console-prod-services-write")

Rationale: Reading the freeze page is informational (manager-level). Toggling the freeze is a production write action — console-prod-services-write was seeded in 0097 precisely for production mutation gates.


Blueprint 16: deploys.py — 4 routes, 4 legacy sites

/api/internal/deploys/<id>/status (callback) and /api/internal/deploys/<id>/xenv use Bearer/service-token auth — excluded per Invariant I-7.

Route Legacy gate V2 decorator
POST /api/internal/deploys/staging (new endpoint) require_role("ops", "superadmin") require_rbac_permission("console:deploys:staging")
POST /api/internal/deploys/prod (new endpoint) require_role("ops", "superadmin") require_rbac_permission("console:deploys:prod")
GET /api/internal/deploys/<id> require_role("ops", "superadmin", "support", "readonly") require_rbac_role("console-user")
GET /api/internal/deploys/<id>/logs require_role("ops", "superadmin", "support", "readonly") require_rbac_role("console-user")
GET /api/internal/deploys require_role("ops", "superadmin", "support", "readonly") require_rbac_role("console-user")

AMBIGUOUS-4 — RESOLVED (2026-06-18): Option C chosen by operator. Route split into two distinct endpoints: POST /api/internal/deploys/staging (gated on console:deploys:staging) and POST /api/internal/deploys/prod (gated on console:deploys:prod). Both permissions seeded as standalone entries in migration 0199; role-to-permission wiring and the actual route split are deferred to sub-card S4. The sub-card must define which roles receive each permission (likely console-staging-services-write for staging and console-prod-services-write for prod, mirroring the 0097 group pattern).


Blueprint 17: flags.py — 14 routes, 22 legacy sites

Route Legacy gate V2 decorator
GET /console/flags/drift require_role("ops", "superadmin") require_rbac_role("console-flag-admin")
GET /console/flags require_role("ops", "superadmin") require_rbac_role("console-flag-admin")
GET /console/api/flags require_role("ops", "superadmin") require_rbac_role("console-flag-admin")
POST /console/flags/<key>/verify require_role("ops", "superadmin") require_rbac_role("console-flag-admin")
POST /console/flags/<key>/flip require_role("ops", "superadmin") require_rbac_role("console-flag-admin")
POST /console/flags/<key>/mark-promote require_role("superadmin") require_rbac_role("console-flag_promotion_queue-write")
POST /console/flags/promotions/<id>/promote require_role("superadmin") + @require_totp_elevation require_rbac_role("console-prod-services-write") + @require_totp_elevation
GET /console/flags/promotions/<id>/status require_role("ops", "superadmin") require_rbac_role("console-flag_promotion_queue-read")
POST /console/flags/promotions/<id>/reject require_role("superadmin") + @require_totp_elevation require_rbac_role("console-prod-services-write") + @require_totp_elevation
POST /console/flags/<key>/mark-synced require_role("superadmin") + @require_totp_elevation require_rbac_role("console-prod-services-write") + @require_totp_elevation
POST /console/flags/<key>/resolve-drift require_role("ops", "superadmin") require_rbac_role("console-flag-admin")
GET /console/flags/promotions require_role("ops", "superadmin") require_rbac_role("console-flag_promotion_queue-read")
POST /console/flags/promotions/<id>/force-fail require_role("superadmin") + @require_totp_elevation require_rbac_role("console-prod-services-write") + @require_totp_elevation
GET /console/flags/promotions/fragment require_role("ops", "superadmin") require_rbac_role("console-flag_promotion_queue-read")

The flags.py blueprint contains an inner _flag_gated_rbac_perm helper that wraps require_rbac_permission conditionally on FLAG_FLAG_PROMOTION_QUEUE_RBAC. This inner guard must be preserved independently of the outer @require_role swap.


Blueprint 18: internal.py — 5 routes, 5 legacy sites

Route Legacy gate V2 decorator
GET /poller-status require_role("superadmin") require_rbac_role("console-manager")
GET /active-sessions require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
GET /surfaces/<id>/p5 require_role("superadmin", "ops", "support", "readonly") require_rbac_role("console-user")
GET /flag-drift/pending require_role("ops", "superadmin") require_rbac_role("console-flag_promotion_queue-read")
GET /promotions/badge-fragment require_role("superadmin") require_rbac_role("console-flag_promotion_queue-write")

Blueprint 19: ops.py — 3 routes, 3 legacy sites

Route Legacy gate V2 decorator
GET /ops require_role("superadmin") require_rbac_role("console-manager")
POST /ops/dispatch/<action_id> require_role("superadmin") require_rbac_role("console-manager")
GET /ops/status/<job_id> require_role("superadmin") require_rbac_role("console-manager")

Blueprint 20: rbac_grants_audit_ui.py — 1 route, 1 legacy site

Route Legacy gate V2 decorator
GET /console/rbac/grants/audit require_role("ops", "superadmin") require_rbac_role("raptor-audit-admin")

The docstring already states the intended gate is raptor-audit-admin. The legacy @require_role("ops", "superadmin") is a placeholder; this cutover corrects the under-specification.


Blueprint 21: rbac_shadow_report.py — 2 routes, 2 legacy sites

These routes are deleted (not migrated) as part of this card. The dual-mode drift reporter removal (#1473 AC) eliminates the shadow comparison report entirely.

Route Action
GET /console/rbac/shadow-comparison Delete route + view
GET /api/rbac/shadow-comparison Delete route + view

Sub-card S1 includes the route deletion.


Blueprint 22: replay.py — 4 routes, 4 legacy sites

Route Legacy gate V2 decorator
GET /replay require_role("superadmin") require_rbac_role("console-manager")
GET /replay/events require_role("superadmin") require_rbac_role("console-manager")
GET /replay/event-detail require_role("superadmin") require_rbac_role("console-manager")
GET /replay/user-state require_role("superadmin") require_rbac_role("console-manager")

Blueprint 23: secrets.py — 14 routes, 19 legacy sites

Route Legacy gate V2 decorator
GET /secrets require_role("superadmin") require_rbac_role("console-secrets-admin")
GET /secrets/history require_role("superadmin") require_rbac_role("console-secrets-admin")
GET /secrets/_list require_role("superadmin") require_rbac_role("console-secrets-admin")
POST /secrets/refresh-cache require_role("superadmin") require_rbac_role("console-secrets-admin")
GET /secrets/<name>/rotate-modal require_role("superadmin") require_rbac_role("console-secrets-admin")
GET /secrets/<name>/sop require_role("readonly", "support", "ops", "superadmin") require_rbac_role("console-secrets-user")
POST /api/secrets/<name>/rotate (+ TOTP) require_role("superadmin") + @require_totp_elevation require_rbac_role("console-secrets-admin") + @require_totp_elevation
POST /secrets/<name>/rotate/htmx (+ TOTP) require_role("superadmin") + @require_totp_elevation require_rbac_role("console-secrets-admin") + @require_totp_elevation
GET /secrets/<name>/rotate/<job_id>/progress require_role("superadmin") require_rbac_role("console-secrets-admin")
GET /secrets/<name>/rotate/<job_id>/cell require_role("superadmin") require_rbac_role("console-secrets-admin")
GET /api/secrets/<name>/rotate/<job_id> require_role("superadmin") require_rbac_role("console-secrets-admin")
GET /secrets/<name>/rotate-modal-v2 require_role("superadmin") require_rbac_role("console-secrets-admin")
POST /api/secrets/<name>/rotate-v2 (+ TOTP) require_role("superadmin") + @require_totp_elevation require_rbac_role("console-secrets-admin") + @require_totp_elevation
POST /secrets/<name>/rotate-v2/htmx (+ TOTP) require_role("superadmin") + @require_totp_elevation require_rbac_role("console-secrets-admin") + @require_totp_elevation

Rationale for /sop: All roles can view the SOP docs (runbook links, not secret values). console-secrets-user is inherited by console-secrets-admin and can be assigned to support/devops groups. See OQ-5 in reconciliation doc.


Blueprint 24: security.py — 2 routes, 2 legacy sites

Route Legacy gate V2 decorator
GET /security require_role("ops", "superadmin") require_rbac_role("console-audit-user")
GET /api/internal/security-posture-summary require_role("superadmin") require_rbac_role("console-manager")

Blueprint 25: status_page.py — 2 routes, 2 legacy sites

Route Legacy gate V2 decorator
GET /console/status and GET /status require_role("readonly", "support", "ops", "superadmin") require_rbac_role("console-user")
POST /console/status/<id>/investigate require_role("ops", "superadmin") require_rbac_role("console-audit-user")

Blueprint 26: waitlist.py — 2 routes, 2 legacy sites

Route Legacy gate V2 decorator
GET /console/waitlist require_role("superadmin") require_rbac_role("console-manager")
POST /console/waitlist/invite require_role("superadmin") require_rbac_role("console-manager")

4. Ambiguous Mappings — Resolved

All four ambiguous mappings were resolved by the operator (2026-06-18) and seeded in migration 0199. Sub-cards S3, S4, S7 are now unblocked.

ID Blueprint Route(s) Decision Seeded in Unblocks
AMBIGUOUS-1 admins_online All routes Option B: new console-session-admin role + console:admins:revoke-session permission 0199 S7
AMBIGUOUS-2 beta All routes Option B: new console-beta-admin role + console:beta:manage permission 0199 S3 (beta cluster)
AMBIGUOUS-3 customers /revoke route Option B: reuse existing console-invite-admin (no migration needed) n/a S3 (customers cluster)
AMBIGUOUS-4 deploys POST /api/internal/deploys Option C: route split into /deploys/staging + /deploys/prod; permissions console:deploys:staging + console:deploys:prod defined in 0199 0199 S4

5. Flag-Gated Rollout Strategy

Current dual-mode behaviour

@require_role calls _run_shadow_check() which lazily imports app.middleware.rbac_dual_mode.shadow_check. That shadow check runs both paths when FLAG_RBAC_V2 is on, logs drift, but never alters the authoritative decision. The legacy path is always authoritative.

The shadow check is removed in this card. After cutover, each blueprint is either on the legacy decorator or the V2 decorator — not both. There is no dual-evaluation shim.

Flag as deployment gate, not runtime gate

Flask evaluates decorators at import time (startup), not at request time. A module-level flag check is frozen at process start. Therefore FLAG_RBAC_V2 controls which deployment is live, not which decorator fires per request.

Implementation pattern: Feature-developer replaces decorators directly. The FLAG_RBAC_V2 flag serves as a deployment signal: - Staging: deploy V2-decorated code, set FLAG_RBAC_V2=1 in staging config. - Prod: promote only after staging soak; FLAG_RBAC_V2=1 signals the promoted build. - Rollback: redeploy the legacy-baseline SHA (see §8).

Per-blueprint granularity: Each sub-card targets one cluster of blueprints (see §6). Sub-cards are promoted independently to staging, allowing per-blueprint soak before the cluster is promoted to prod.


6. Phased Sub-Card Breakdown

S1 — Shadow report deletion + low-risk read-only clusters

Blueprints: rbac_shadow_report.py (delete), alerts.py, status_page.py, waitlist.py, console_versions_ui.py Approx. legacy sites: 11 Blocked on: AMBIGUOUS-1 is NOT blocking S1 (admins_online is a separate sub-card) Why first: Removes dead code. Read-only blueprints map uniformly to console-user. Lowest blast radius.

S2 — Audit, security, replay, ops

Blueprints: audit_viewer.py, security.py, replay.py, ops.py Approx. legacy sites: 10 Why second: All map to console-manager or console-audit-user with no ambiguity. No TOTP chains. Straightforward mechanical swap.

S3 — Beta + customers

Blueprints: beta.py, customers.py Approx. legacy sites: 31 Blocked on: AMBIGUOUS-2, AMBIGUOUS-3

S4 — Deploys + deploy_freeze + internal + console_versions

Blueprints: deploys.py, deploy_freeze.py, internal.py, console_versions.py Approx. legacy sites: 14 Blocked on: AMBIGUOUS-4 (deploys POST pattern) Special: deploys.py has inline TOTP logic that must not be moved.

S5 — Dashboard + api_status

Blueprints: dashboard.py, api_status.py Approx. legacy sites: 14 Special: Machine-token compatibility verification required before merging (Open Question #6).

S6 — Flags

Blueprint: flags.py Approx. legacy sites: 22 Why last of the main cluster: Largest single blueprint. 5 routes with TOTP elevation. Inner _flag_gated_rbac_perm helper must be preserved independent of outer role swap.

S7 — Admins online

Blueprint: admins_online.py Approx. legacy sites: 2 Blocked on: AMBIGUOUS-1

S8 — Secrets (last)

Blueprint: secrets.py Approx. legacy sites: 19 Why last: Highest privilege surface. 5 routes with TOTP elevation. Ship only after S1–S7 have soaked in staging for at least 24 hours.

S9 — RBAC grants audit UI

Blueprint: rbac_grants_audit_ui.py Approx. legacy sites: 1 Why separate: Small blast radius; can be picked up between any two other sub-cards.

S10 — CI lint gate (RV-14)

Add grep -r '@require_role' console/app/blueprints/ to CI as a zero-match enforcement gate. Ship as a co-requirement with or immediately after S8.


7. Test Strategy

Pre-cutover smoke test (required before first sub-card ships)

console/tests/test_rbac_cutover_smoke.py: asserts that every V2 role name cited in the mapping tables above exists in the rbac_roles seed data. Prevents "role name typo" class of bugs before any PR is merged.

Per-sub-card table-driven tests

Each sub-card PR adds console/tests/test_rbac_cutover_<cluster>.py with:

For each route in the cluster:
  assert: unauthenticated request -> 302 to /auth/login
  assert: session with V2 role -> 200
  assert: session without any V2 role -> 403
  assert: 403 response writes access_denied audit row

Test fixture creates admin with exactly the proposed V2 role via rbac_user_groups/ rbac_group_roles tables (not via legacy admin_roles).

Golden test per cluster

One test that runs the full route list for the cluster with a single authorized admin and asserts 200 on all. Catches "missed a route" class of bugs.

No double-evaluation in test

Tests run with FLAG_RBAC_V2 forced on for V2 tests and forced off for legacy tests via the existing conftest fixture. Shadow dual-mode is removed in S1.


8. Rollback

Rollback after a sub-card's PR is merged and deployed requires redeploying the previous SHA.

Pre-cutover action (required): Before any sub-card is promoted to prod, tag the last all-legacy SHA:

git tag rbac-legacy-baseline <sha>
git push origin rbac-legacy-baseline

Rollback command (prod emergency):

git push heroku rbac-legacy-baseline:main

FLAG_RBAC_V2 alone is not sufficient as a live rollback once decorators are replaced (evaluated at startup). The flag remains meaningful as a staging deployment gate.

Low-tech per-file rollback: Feature-developer keeps the legacy @require_role on a commented line directly above the new @require_rbac_role for all TOTP-elevation routes. A two-line change re-activates legacy without a full redeploy.


9. Security Considerations


10. Open Questions

Items 1–5 were resolved by the operator (2026-06-18) and implemented in migration 0199. Item 6 remains open.

  1. AMBIGUOUS-1 — RESOLVED: Option B. New console-session-admin role + console:admins:revoke-session permission. Seeded in migration 0199. S7 unblocked.
  2. AMBIGUOUS-2 — RESOLVED: Option B. New console-beta-admin role + console:beta:manage permission. Seeded in migration 0199. S3 (beta) unblocked.
  3. AMBIGUOUS-3 — RESOLVED: Option B. Existing console-invite-admin reused for POST /console/customers/<id>/<session_id>/revoke. No migration needed. S3 unblocked.
  4. AMBIGUOUS-4 — RESOLVED: Option C. Route split into /deploys/staging + /deploys/prod. Permissions console:deploys:staging + console:deploys:prod seeded in migration 0199. Role-to-permission wiring + actual route split deferred to S4.
  5. console-ops group assignment — RESOLVED: Omission confirmed unintentional. console-ops added to raxx-platform-admins group in migration 0199. Pre-cutover blocker OQ-5 closed.
  6. Machine-token compatibility: Routes decorated with @machine_auth_or_session set g.admin from a machine token. Machine tokens have no rbac_user_groups rows. Will they pass _admin_has_rbac_role()? If not, machine tokens must be enrolled in an RBAC group before S5 ships. (Blocks S5 — still open)