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
- Legacy gate — the current
@require_role(...)argument set. - V2 decorator — the
@require_rbac_role("<role>")replacement. - V2 role exists? — whether the named V2 role was seeded in migration 0012 or a subsequent migration.
- AMBIGUOUS — operator decision required before implementation.
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
- PII: No PII is collected or exposed. Decorators read
admin_idfrom session (UUID). - Retention: Audit rows from
_write_access_deniedare unchanged in schema and retention. - Deletion on DSR: No new tables introduced.
- Audit trail: Every 403 continues to write an
access_deniedaudit row. V2 and legacy decorators both call_write_access_denied. Audit coverage is not reduced. - Stored credentials: None. V2 resolution reads live from
rbac_*tables per request. - Breach notification: No change to existing breach path.
- Kill-switch:
FLAG_RBAC_V2=0+ redeploy previous SHA (see §8). - Over-exposure risk: Primary risk is mapping a
superadmin-only gate to a V2 role held by a broader group. Everyconsole-managermapping in this document is held only byraxx-platform-adminsandbreak-glass, which is the correct superadmin-equivalent scope. The AMBIGUOUS items in §4 are the only sites where over-exposure is not yet ruled out — they must be resolved before the affected sub-cards are claimed.
10. Open Questions
Items 1–5 were resolved by the operator (2026-06-18) and implemented in migration 0199. Item 6 remains open.
- AMBIGUOUS-1 — RESOLVED: Option B. New
console-session-adminrole +console:admins:revoke-sessionpermission. Seeded in migration 0199. S7 unblocked. - AMBIGUOUS-2 — RESOLVED: Option B. New
console-beta-adminrole +console:beta:managepermission. Seeded in migration 0199. S3 (beta) unblocked. - AMBIGUOUS-3 — RESOLVED: Option B. Existing
console-invite-adminreused forPOST /console/customers/<id>/<session_id>/revoke. No migration needed. S3 unblocked. - AMBIGUOUS-4 — RESOLVED: Option C. Route split into
/deploys/staging+/deploys/prod. Permissionsconsole:deploys:staging+console:deploys:prodseeded in migration 0199. Role-to-permission wiring + actual route split deferred to S4. console-opsgroup assignment — RESOLVED: Omission confirmed unintentional.console-opsadded toraxx-platform-adminsgroup in migration 0199. Pre-cutover blocker OQ-5 closed.- Machine-token compatibility: Routes decorated with
@machine_auth_or_sessionsetg.adminfrom a machine token. Machine tokens have norbac_user_groupsrows. 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)