Raxx · internal docs

internal · gated ↑ index

RBAC V2 — Design

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


1. Context

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:

  1. Four new audit-specific roles demanded by ADR-0060 (PR #1451): antlers-audit-self, raptor-audit-support, raptor-audit-admin, raptor-audit-compliance
  2. Ticket-scoped temporary grants with auto-revoke on FreeScout ticket close
  3. A grant-management API (writer: grant/revoke; reader: /me, /permissions/check, audit)
  4. A grant audit table (rbac_grants_audit) that feeds into customer_audit_events for operator-actor rows
  5. Break-glass role time-limits (1h default, 4h max), justification requirement, and auto-rotation
  6. Dual-mode middleware that can call both old require_role and new require_rbac_role in parallel to measure drift before cutover
  7. Cutover of all Console and Raptor blueprints from @require_role to @require_permission/@require_rbac_role
  8. Console UI for managing groups, roles, grants, and ticket-scoped access
  9. SAML claim-to-group wiring (documented; not shipping in v1 unless explicitly scoped in)

North 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.


2. Invariants

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.

3. Data Model

3.1 Existing tables (migration 0012 — already live)

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)

3.2 New tables (RBAC V2 additions — migration 0021)

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();

3.3 New seed rows — audit roles (migration 0021)

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

3.4 Permission resolution algorithm

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.


4. Role Taxonomy (additions to migration 0012)

The following roles are new in RBAC V2. All existing roles from migration 0012 remain unchanged.

4.1 Audit roles (from ADR-0060)

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)

4.2 Naming convention summary

All roles follow <app>-<resource>-<level> or <app>-<level>. No exceptions.

When 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.


5. State Machine: Ticket-Scoped Grant

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).


6. Break-Glass Design

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.


7. SAML Readiness

SAML is not shipping in v1. The design is SAML-ready:


8. Console UI (wireframe-level)

These pages ship in RV-8 through RV-10. Hand off to ux-designer for mockups after this design lands.

8.1 Roles page (/console/rbac/roles)

8.2 Groups page (/console/rbac/groups)

8.3 Grants page (/console/rbac/grants)

8.4 Per-customer ticket-scoped grant (/console/customers/<id>)

8.5 Grants audit timeline (/console/rbac/grants/audit)


9. Security Considerations

9.1 GDPR checklist

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).

9.2 Privilege escalation guards

9.3 Ticket re-validation on every dim-3 request

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.


10. Open Questions (operator decision required)

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.


11. Conflicts with ADR-0060 (audit v2)

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.