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 |
CRITICAL GAP (pre-cutover blocker): Several legacy gates use ops or superadmin
combinations that map naturally to console-ops or console-manager, but those roles
have no explicit group assignment for ops-equivalent operators in the current seed data.
Migration 0012 assigns console-ops to no group. This must be resolved before sub-cards
are implemented. See Open Question #5 in §10.
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-invite-admin") |
Yes (0012) |
/console/admins/online/<session_id>/revoke |
POST | require_role("superadmin") |
require_rbac_role("console-invite-admin") |
Yes (0012) |
Rationale: Viewing and revoking other operators' sessions is admin management of
identities — semantically aligned with console-invite-admin (which grants
console:admins:invite and console:groups:write). The legacy superadmin gate maps to
the highest-trust working group; console-invite-admin is the closest V2 equivalent for
user management actions.
AMBIGUOUS-1 (operator decision required): Should session revocation require a
dedicated console:admins:revoke-session permission (not yet seeded) rather than reusing
console-invite-admin? Options: (A) reuse console-invite-admin as proposed above, (B)
add a new permission console:admins:revoke-session and role console-session-admin in a
pre-cutover migration. This must be decided before sub-card S1 is 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-manager") |
Rationale: console-manager is assigned to raxx-platform-admins and break-glass —
the V2 equivalent of superadmin. Beta management is operator-only and PII-touching
(invites, deletes, resends).
AMBIGUOUS-2 (operator decision required): Should beta management use a dedicated
console-beta-admin role rather than console-manager? Options: (A) console-manager as
proposed, (B) new console-beta-admin role requiring a pre-cutover migration. Recommend
option A for v1 given single-operator team size; revisit when a second operator is added.
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") |
AMBIGUOUS-3 |
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 (operator decision required): The customer session-revoke route
(POST /console/customers/<id>/<session_id>/revoke) is a write action currently gated at
ops-level. Mapping it to console-audit-user (a read role) would be a security
regression — audit-user was not intended to authorize mutations. Options:
(A) console-manager (narrowest, matches destructive intent),
(B) console-invite-admin (user management semantic),
(C) new console-customers-write role (cleanest but requires migration).
This must be decided before sub-card S3 is claimed.
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 |
require_role("ops", "superadmin") |
AMBIGUOUS-4 |
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 (operator decision required): The POST /api/internal/deploys route
applies a single @require_role("ops", "superadmin") but then performs an inline TOTP
check for prod targets (determined from request body). Under V2, prod vs staging deploys
ideally map to different roles (console-prod-services-write vs
console-staging-services-write). Options:
(A) Gate the whole POST on console-prod-services-write (conservative; ops group already
holds both staging and prod roles per 0097 seed),
(B) Outer gate on console-staging-services-write; inline check for console-prod-services-write
on prod-targeted requests (mirrors existing inline TOTP pattern),
(C) Split the route into /deploys/staging and /deploys/prod.
Option B is the recommended approach. Must be confirmed before sub-card S4 is claimed.
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 — Operator Decisions Required
These items BLOCK the sub-cards they are labeled against. Feature-developer must not claim the relevant sub-card until the operator has recorded a decision on #1473.
| ID | Blueprint | Route(s) | Question | Options | Blocking |
|---|---|---|---|---|---|
| AMBIGUOUS-1 | admins_online | All routes | Session revoke vs invite semantics — right role for session revocation? | (A) console-invite-admin as proposed; (B) new console-session-admin role |
S1 |
| AMBIGUOUS-2 | beta | All routes | Beta management bespoke role vs console-manager reuse? |
(A) console-manager (proposed); (B) new console-beta-admin role |
S2 |
| AMBIGUOUS-3 | customers | /revoke route |
Write action mapped to read role — potential security regression | (A) console-manager; (B) console-invite-admin; (C) new console-customers-write |
S3 |
| AMBIGUOUS-4 | deploys | POST /api/internal/deploys |
Single route, two target environments — how to split the V2 gate? | (A) Single console-prod-services-write; (B) inline check for prod branch; (C) route split |
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
These must be resolved on issue #1473 before implementation of the blocked sub-cards.
- AMBIGUOUS-1: Should session revocation (
/admins/online/<id>/revoke) live underconsole-invite-adminor a newconsole-session-adminrole? (Blocks S1, S7) - AMBIGUOUS-2: Should beta management use
console-manageror a newconsole-beta-adminrole? (Blocks S3) - AMBIGUOUS-3: What role governs customer session revocation
(
POST /console/customers/<id>/<session_id>/revoke) —console-manager,console-invite-admin, or a newconsole-customers-write? (Blocks S3) - AMBIGUOUS-4: How is the
POST /api/internal/deploysprod-vs-staging split implemented — single broadest role, inline check, or route split? (Blocks S4) console-opsgroup assignment: Migration 0012 definesconsole-opsbut assigns it to no group. Is this intentional? Ifraxx-devops-teamorraxx-platform-adminsneeds ops-tier access via a composition role rather than individual role grants, a new group assignment migration may be required before cutover. (Pre-cutover verification)- 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)