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

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


10. Open Questions

These must be resolved on issue #1473 before implementation of the blocked sub-cards.

  1. AMBIGUOUS-1: Should session revocation (/admins/online/<id>/revoke) live under console-invite-admin or a new console-session-admin role? (Blocks S1, S7)
  2. AMBIGUOUS-2: Should beta management use console-manager or a new console-beta-admin role? (Blocks S3)
  3. AMBIGUOUS-3: What role governs customer session revocation (POST /console/customers/<id>/<session_id>/revoke) — console-manager, console-invite-admin, or a new console-customers-write? (Blocks S3)
  4. AMBIGUOUS-4: How is the POST /api/internal/deploys prod-vs-staging split implemented — single broadest role, inline check, or route split? (Blocks S4)
  5. console-ops group assignment: Migration 0012 defines console-ops but assigns it to no group. Is this intentional? If raxx-devops-team or raxx-platform-admins needs 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)
  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)