Status: Design — sub-cards pending operator approval
Owner: software-architect
Date: 2026-05-09 UTC
Milestone: #6 — raxx.app v1 — first non-operator user
Related ADRs: ADR-0054, ADR-0055, ADR-0056, ADR-0057
Sibling docs: docs/architecture/rbac-design.md (first-pass concept), docs/architecture/auth-unification-rbac-reconciliation.md
Refs: #1451 (audit unified design, hard-depends on this), epic #146
The RBAC V2 model was designed at concept level in rbac-design.md (2026-04-25) and reconciled with the CF Access / Google Workspace IDP in auth-unification-rbac-reconciliation.md (2026-05-03). Migration 0012 landed the seven core tables (rbac_groups, rbac_roles, rbac_permissions, rbac_role_permissions, rbac_role_inheritance, rbac_group_roles, rbac_user_groups) and seeded 24 roles, 4 groups, and 18 permissions. The require_rbac_role decorator is live in the Console middleware and already used by billing routes.
What is missing for v1:
antlers-audit-self, raptor-audit-support, raptor-audit-admin, raptor-audit-compliance/me, /permissions/check, audit)rbac_grants_audit) that feeds into customer_audit_events for operator-actor rowsrequire_role and new require_rbac_role in parallel to measure drift before cutover@require_role to @require_permission/@require_rbac_roleNorth star: Every access control change that would otherwise require a code ship must instead be expressible as a group membership update or role grant in the Console UI. If an operator needs to give a support agent Dim-3 read access for one customer's active ticket, that is a ticket-scoped grant, not a deployment.
The following are non-negotiable and take precedence over any design choice below.
| # | Invariant |
|---|---|
| I-1 | No stored credentials. The RBAC schema contains no column that stores a password, recovery code, TOTP seed, or any value that can replay authentication. |
| I-2 | Passkeys / WebAuthn only for authentication. RBAC governs authorization; it does not alter how authentication happens. |
| I-3 | No direct permissions on users. All permission grants flow through: user → groups → roles → permissions. A permission check against a bare user ID is prohibited except for break-glass time-limited direct grants (which are audited and time-bounded). |
| I-4 | Superadmin is break-glass only. It is never an ordinary working role. Break-glass grants are time-limited (1h default, 4h max), require justification, and emit an ops alert before the session proceeds. |
| I-5 | Every state change that affects money, permissions, or data access writes an audit row. All RBAC grant/revoke events are written to rbac_grants_audit AND to customer_audit_events (dimension: operator_interaction, action: operator.rbac.grant / operator.rbac.revoke). |
| I-6 | GDPR by default. Group membership metadata (rbac_user_groups) is operational data, but it is linked to admins.id which holds PII. DSR erasure cascades via FK. Retention: lifetime of the admin record; purged with it on erasure. |
| I-7 | Self-grant prohibition. A user cannot grant themselves a role they do not already hold through their group memberships. The API enforces this at the application layer; no DB constraint can express it. |
| I-8 | Credentials into infra, not into code. No RBAC signing key, DB URL, or break-glass recovery token appears in files that ship. All secrets live in Infisical or SSM. |
| I-9 | Audit trail for RBAC mutations is pre-write. The rbac_grants_audit INSERT happens before the rbac_user_groups / rbac_group_roles mutation is committed. If the audit write fails, the mutation is rejected. |
| I-10 | All timestamps UTC. |
| I-11 | FLAG_RBAC_V2 must have a console_flag_promotions migration row before it can be promoted to production. |
These tables exist and are seeded. They are not modified by RBAC V2; only additive columns or new seed rows are added.
| Table | Purpose |
|---|---|
rbac_groups |
Named bundles of roles (e.g., raxx-platform-admins) |
rbac_roles |
Fine-grained <app>-<resource>-<level> roles |
rbac_permissions |
Atomic permission strings (<app>:<resource>:<action>) |
rbac_role_permissions |
M2M: role → permission |
rbac_role_inheritance |
DAG: child role inherits parent's permissions |
rbac_group_roles |
M2M: group → role |
rbac_user_groups |
M2M: admin user → group (with granted_by, granted_at) |
rbac_grants_audit — append-only log of every RBAC grant and revoke.
CREATE TABLE rbac_grants_audit (
id TEXT PRIMARY KEY, -- UUID v4
event_type TEXT NOT NULL
CHECK (event_type IN ('grant','revoke','ticket_grant','ticket_expire',
'break_glass_grant','break_glass_expire')),
-- who was affected
target_user_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
-- what changed (one of these is populated)
group_id TEXT REFERENCES rbac_groups(id) ON DELETE SET NULL,
role_id TEXT REFERENCES rbac_roles(id) ON DELETE SET NULL,
-- why (ticket-scoped grants)
ticket_id TEXT, -- FreeScout conversation ID, nullable
customer_id INTEGER, -- Raptor customer ID this grant is scoped to, nullable
-- justification (break-glass)
justification TEXT,
-- actor
granted_by TEXT NOT NULL, -- admin_id (soft FK; preserved on off-boarding)
-- timing
expires_at_utc TIMESTAMPTZ, -- non-null for time-limited grants
created_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Append-only enforcement
REVOKE UPDATE, DELETE ON rbac_grants_audit FROM console_app;
GRANT INSERT, SELECT ON rbac_grants_audit TO console_app;
CREATE INDEX idx_rga_target ON rbac_grants_audit (target_user_id, created_at_utc DESC);
CREATE INDEX idx_rga_ticket ON rbac_grants_audit (ticket_id) WHERE ticket_id IS NOT NULL;
CREATE INDEX idx_rga_expires ON rbac_grants_audit (expires_at_utc) WHERE expires_at_utc IS NOT NULL;
rbac_ticket_grants — active ticket-scoped temporary role grants. These are the live state; rbac_grants_audit is the permanent record.
CREATE TABLE rbac_ticket_grants (
id TEXT PRIMARY KEY, -- UUID v4
user_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES rbac_roles(id) ON DELETE CASCADE,
ticket_id TEXT NOT NULL, -- FreeScout conversation ID
customer_id INTEGER NOT NULL, -- Raptor customer PK
granted_by TEXT NOT NULL, -- admin_id (soft FK)
granted_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at_utc TIMESTAMPTZ, -- null = expires on ticket close only
revoked_at_utc TIMESTAMPTZ, -- populated when revoked
revoke_reason TEXT -- 'ticket_closed' | 'manual' | 'expired'
);
CREATE INDEX idx_rtg_user ON rbac_ticket_grants (user_id) WHERE revoked_at_utc IS NULL;
CREATE INDEX idx_rtg_ticket ON rbac_ticket_grants (ticket_id) WHERE revoked_at_utc IS NULL;
CREATE INDEX idx_rtg_customer ON rbac_ticket_grants (customer_id) WHERE revoked_at_utc IS NULL;
rbac_break_glass_sessions — time-limited direct grants for break-glass access.
CREATE TABLE rbac_break_glass_sessions (
id TEXT PRIMARY KEY, -- UUID v4
user_id TEXT NOT NULL REFERENCES admins(id) ON DELETE CASCADE,
justification TEXT NOT NULL,
granted_by TEXT NOT NULL, -- admin_id or 'self' if operator
granted_at_utc TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at_utc TIMESTAMPTZ NOT NULL, -- default +1h, max +4h
revoked_at_utc TIMESTAMPTZ -- populated on early revocation
);
CREATE INDEX idx_rbg_user ON rbac_break_glass_sessions (user_id)
WHERE revoked_at_utc IS NULL AND expires_at_utc > NOW();
Four roles added to satisfy ADR-0060. These compose into existing groups.
| Role | App | Inherits | Description |
|---|---|---|---|
antlers-audit-self |
antlers |
— | Customer reads their own Dim 1 + Dim 2 audit events |
raptor-audit-support |
raptor |
antlers-audit-self |
Support agent reads Dim 1 + Dim 2 + Dim 3 (ticket-scoped) |
raptor-audit-admin |
raptor |
raptor-audit-support |
Admin reads all dims, all customers; triggers customer notification |
raptor-audit-compliance |
raptor |
— | SOC-2 auditor: all dims, all customers, read-only, no notification |
New permissions:
| Permission | Description |
|---|---|
raptor:audit:read-self |
Read own Dim 1 + Dim 2 events |
raptor:audit:read-support |
Read Dim 1 + Dim 2 + Dim 3 with ticket linkage |
raptor:audit:read-admin |
Read all dims, all customers (triggers notification) |
raptor:audit:read-compliance |
Read all dims, all customers, no notification |
Group assignments:
- antlers-audit-self → granted to all antlers-user group members (system-assigned on signup)
- raptor-audit-support → added to raxx-support-team group
- raptor-audit-admin → added to raxx-platform-admins group
- raptor-audit-compliance → not assigned to any group at v1; assigned at SOC-2 scope
1. SELECT group_id FROM rbac_user_groups WHERE user_id = U
2. UNION SELECT role_id FROM rbac_ticket_grants WHERE user_id = U
AND ticket_id = <active ticket> AND revoked_at_utc IS NULL
-- ticket-scoped path only; skipped for non-ticket checks
3. SELECT role_id FROM rbac_group_roles WHERE group_id IN (step 1 results)
4. Walk rbac_role_inheritance DAG: for each role R, union R with all ancestors
→ result: expanded_roles (set)
5. SELECT permission_id FROM rbac_role_permissions WHERE role_id IN expanded_roles
→ result: effective_permissions (set)
6. Return P ∈ effective_permissions
Caching: Session-scoped embedding (Option A from rbac-design.md §8). On login, resolve the effective permission set and embed it in the session record. Invalidated when any rbac_user_groups or rbac_group_roles row changes for this user. Ticket-scoped grants are not cached; they are checked live per request because their state can change between requests (ticket closure).
DAG cycle guard: Application-layer topological check before any rbac_role_inheritance INSERT. A cycle is a 422 response, not a runtime failure.
The following roles are new in RBAC V2. All existing roles from migration 0012 remain unchanged.
antlers-audit-self
raptor-audit-support ← inherits antlers-audit-self
raptor-audit-admin ← inherits raptor-audit-support
raptor-audit-compliance ← standalone (no notification, no inheritance chain)
All roles follow <app>-<resource>-<level> or <app>-<level>. No exceptions.
app values: console, antlers, raptor, vault, velvet, getraxxresource is the domain noun: audit, trade, token, secrets, flags, env, invite, billinglevel is the access level: read, support, admin, compliance, write, dangerWhen a role is a composition (group of roles rather than a single resource grant), it uses <app>-<level> without a resource: console-manager, console-ops, velvet-admin.
stateDiagram-v2
[*] --> Active : POST /api/rbac/grants/ticket-scoped\n(operator creates grant)
Active --> Revoked_Manual : DELETE /api/rbac/grants/<grant_id>\n(manual revoke)
Active --> Revoked_Ticket : FreeScout webhook\nticket → RESOLVED or CLOSED
Active --> Revoked_Expired : TTL expires\n(if expires_at_utc set)
Revoked_Manual --> [*]
Revoked_Ticket --> [*]
Revoked_Expired --> [*]
In all revocation paths: rbac_ticket_grants.revoked_at_utc is set, a rbac_grants_audit row is inserted (event_type: ticket_expire or revoke), and the session cache for the affected user is invalidated.
TOCTOU guard (per T-PEN-6 in the unified-audit threat model): dim-3 ticket checks re-verify ticket status against FreeScout on every request, not from session cache. If FreeScout is unreachable, the request fails closed (503, not 200).
Break-glass grants are time-limited, require justification, and trigger immediate ops alert.
Conditions for activation:
- Only a member of raxx-platform-admins or the current break-glass group may request one (no self-escalation from lower groups).
- Requires a passkey re-authentication (WebAuthn) in addition to the existing session passkey. This is the step-up factor.
- Justification text (minimum 20 characters) is required and logged in rbac_break_glass_sessions.
- Session duration: default 1h, configurable up to 4h via request body. Operator cannot set > 4h.
- Mandatory Slack alert to ops@raxx.app before the session starts, not after.
Auto-rotation: The break-glass group's role composition is snapshotted nightly. If any change to the group's roles is detected outside a scheduled deploy event, an alert fires to ops@raxx.app. The break-glass group itself is never left with stale permissions silently.
Self-grant prohibition enforcement: The API at POST /api/rbac/grants checks that g.admin_id does not appear in target_user_id for a role the actor does not already hold transitively. This check happens before the rbac_grants_audit INSERT.
SAML is not shipping in v1. The design is SAML-ready:
rbac_groups.name is the SAML attribute mapping point. The IdP emits group names (raxx-platform-admins); Raxx looks them up in rbac_groups.name.rbac_groups, the session receives zero RBAC permissions and the admin sees an "insufficient access" page. No group is auto-created from SAML assertions.idp_group_name TEXT column to rbac_groups to support IdP name ≠ Raxx name mappings without requiring the IdP to know Raxx's internal taxonomy.These pages ship in RV-8 through RV-10. Hand off to ux-designer for mockups after this design lands.
/console/rbac/roles)console-invite-admin (read); no role creation via UI in v1 (roles are seeded by migrations)/console/rbac/groups)console-invite-admin)console-invite-admin)/console/rbac/grants)/console/customers/<id>)raptor-audit-support), linked ticket_id (auto-filled if FreeScout tab is open), optional expiry overridePOST /api/rbac/grants/ticket-scopedconsole-invite-admin/console/rbac/grants/audit)rbac_grants_auditcustomer_audit_events entry for the corresponding operator.rbac.grant action (where applicable)| Question | Answer |
|---|---|
| What PII does this collect? | rbac_user_groups.user_id is an FK to admins.id which holds email. No new PII fields beyond what admins already holds. rbac_ticket_grants.customer_id is a reference to a Raptor user — operational data, not PII in the RBAC table itself. |
| What is the retention period? | rbac_user_groups rows retained for lifetime of the admin record; cascade-deleted on admin erasure. rbac_grants_audit rows retained for 7 years (operational accountability, financial-adjacent). rbac_ticket_grants rows retained for 2 years then archived. |
| How is it deleted on DSR? | Deleting the admins row cascades via FK to rbac_user_groups and rbac_ticket_grants. rbac_grants_audit.target_user_id CASCADE-deletes. rbac_grants_audit.granted_by is a soft FK (TEXT) retained for audit trail integrity even if the granting admin is deleted. |
| What is logged for audit? | Every write to rbac_user_groups, rbac_group_roles, rbac_ticket_grants produces a row in rbac_grants_audit. Grant/revoke events for customer-affecting roles (e.g., raptor-audit-support) also produce a row in customer_audit_events with dimension=operator_interaction, action=operator.rbac.grant. |
| Does any part of this store a credential that can be replayed? | No. CI grep from ADR-0002 extends to all RBAC migration files. |
| What happens on breach? | rbac_user_groups exfiltration exposes group membership (operational sensitivity). No credentials. Response: rotate all Console session tokens, audit group membership for anomalies, notify per ADR-0003 breach pipeline within 72h. |
| Where are secrets? | No RBAC-specific secrets. DB URL in Infisical / SSM (per existing posture). |
| Is there a kill-switch? | FLAG_RBAC_V2=0 reverts to flat admin_roles resolution without redeploy. Ticket-scoped grant checks can be disabled via FLAG_TICKET_SCOPED_GRANTS=0 which causes all ticket-scoped requests to fail-closed (return 403). |
POST /api/rbac/grants.rbac_grants_audit INSERT is pre-write (I-9): if the audit write fails, the grant is not applied.console-invite-admin permission holders may write to rbac_user_groups and rbac_group_roles via the API.rbac_user_groups bypass the application layer. Mitigation: pg_audit extension logs all DML on rbac_user_groups (same requirement as for customer_audit_events per the unified-audit threat model).Per T-PEN-6 in the unified-audit threat model: dim-3 access checks re-verify FreeScout ticket status on every request, not from session cache. Fail-closed on FreeScout unavailability. The ticket_id is stored in rbac_ticket_grants.ticket_id and verified against FreeScout's conversation status endpoint per-request.
These questions are flagged before sub-cards can be fully scoped. They do not block design landing, but they block final sub-card grooming.
OQ-1 (P0-blocking): Raptor identity DB vs Console DB for product-user RBAC.
Today raptor-audit-support and raptor-audit-compliance are Console DB roles that gate Console API calls. For v1, Raptor's /api/customer-audit/* endpoints must check RBAC. Does Raptor call the Console for permission checks (centralized, per ADR-0020), or does Raptor mirror the relevant roles locally? Centralized is architecturally cleaner and consistent with ADR-0020. Local mirror is simpler operationally but creates drift risk. Decision needed before RV-3 (reader endpoint) can be scoped.
OQ-2 (P0-blocking): FreeScout webhook authentication. Ticket-scoped auto-revoke depends on a FreeScout webhook that fires on ticket state transitions (RESOLVED/CLOSED). FreeScout's webhook does not support HMAC signing natively in its base install. Options: (a) poll FreeScout API on every dim-3 request (simple, adds ~50ms latency), (b) FreeScout webhook with a shared-secret header Raxx validates (requires FreeScout webhook module config). Decision shapes RV-4 implementation.
OQ-3 (Advisory): raptor-audit-compliance notification behavior.
ADR-0060 specifies no customer notification for the compliance auditor role. At v1, this role is stubbed (not assigned to any group). Confirm: should the role be assigned to a named group or kept unassigned until SOC-2 is actively in scope? Keeping it unassigned means it can be activated via a single group assignment — no code ship needed when SOC-2 scoping begins.
ADR-0060 (PR #1451) introduced four roles: antlers-audit-self, raptor-audit-support, raptor-audit-admin, raptor-audit-compliance. This design adopts those names verbatim. No naming conflicts.
One architectural distinction: ADR-0060 specifies that "ticket-scoped, expires when ticket closes" logic is enforced at the API layer, not the RBAC layer. This design agrees: rbac_ticket_grants is the state store, but dim-3 ticket validation is a separate per-request check in the audit reader endpoint (SC-A8). The RBAC grant gives the role; the API check verifies the ticket is still active.
The raptor-audit-admin role triggers customer notification per ADR-0061 (incoming). This design's grant model supports that: when a user with raptor-audit-admin makes a dim-3 request, the audit reader endpoint (SC-A8) is responsible for queueing the notification — not the RBAC grant itself. RBAC grants access; notification is the audit reader's responsibility.