Raxx RBAC — Role-Based Access Control Design (v1, First Pass)
Status: Draft — first pass; multi-pass expected
Owner: software-architect
Last updated: 2026-04-25
Branch: design/rbac-v1-first-pass
Related ADRs: 0119
Refs: Console flat RBAC in console/app/middleware/rbac.py; ADR-0001, ADR-0002, ADR-0003
1. Context
Raxx today has a flat, four-level role column on console_admins (superadmin, ops, support, readonly) and no RBAC on Antlers (user-facing app) whatsoever. As the platform grows — multiple apps, an operator console, a secrets vault, a marketing surface, a mobile client — the flat model cannot express the permissions needed without either over-granting or hard-coding dozens of booleans.
This document specifies the shape of the target RBAC model: per-app fine-grained roles, group-mediated permission assignment, a DAG-based inheritance structure, and a data model that can survive intact when Raxx adopts SAML.
This is a design exploration, not a final spec. Edge cases and open questions are called out explicitly in §9.
2. Invariants
The following constraints are non-negotiable and override any design choice below:
- No stored credentials. Nothing in this RBAC model may store a password, recovery code, TOTP seed (outside the existing encrypted-seed pattern in the Console), or any value that could replay an authentication.
- Passkeys / WebAuthn only as auth factor. RBAC determines what an authenticated session may do; it does not change how authentication happens.
- No direct permissions on users. Permissions flow exclusively:
user → groups → roles → permissions. A permission check against a bare user ID must not exist. - Superadmin is break-glass only. It is never assigned as a working role. A human in a break-glass group must use a named role for day-to-day tasks.
- Every state-changing action that affects money, permissions, or data access writes an audit row.
- GDPR by default. Any PII stored in the RBAC data model (email, display name) follows the same retention, erasure, and portability rules as the rest of the platform.
- Credentials into infra, not into code. No RBAC secret (signing key, DB URL) appears in files that ship.
3. Core Principles
3.1 Naming convention
All roles follow the pattern <app>-<resource>-<level> or <app>-<level> for app-wide roles:
console-token-user # can read tokens in the console
console-token-admin # inherits console-token-user; can also mutate tokens
console-user # general console access — does NOT include console-token-user
console-admin # inherits console-user; app-wide admin
antlers-user # end user, free tier
antlers-founders # Founders trial tier
antlers-pro # paid Pro tier
antlers-support-readonly # support agent; read access to user-facing surfaces
raptor-admin # API admin operations
vault-reader # read secrets from Infisical (mapping layer — see §3.3)
vault-admin # rotate + create secrets in Infisical
getraxx-editor # can modify marketing content (future)
3.2 Deliberate non-inheritance
console-admin does NOT automatically carry console-token-admin. The inheritance graph only flows within a resource family (console-token-admin → console-token-user). Crossing resource families requires explicit group composition. This is intentional: it prevents a promotion on one resource from silently granting power on an unrelated one.
3.3 Vault is a mapping layer
Infisical has its own native RBAC. Raxx does not redesign it. Instead, Raxx defines local roles (vault-reader, vault-admin) whose permissions map to Infisical Machine Identity scopes. The local permission check determines whether the Raxx system issues a Infisical API call on behalf of the requesting session, not whether the session calls Infisical directly.
4. Role Taxonomy
4.1 Console (console.raxx.app)
| Role | Inherits | Description |
|---|---|---|
console-token-user |
— | Read tokens in the rotation UI |
console-token-admin |
console-token-user |
Rotate, create, delete tokens; manage rotation SOPs |
console-secrets-user |
— | View secrets index (Infisical read via vault-reader) |
console-secrets-admin |
console-secrets-user |
Trigger vault writes; manage Infisical mapping |
console-audit-user |
— | View audit log |
console-invite-admin |
— | Send admin invitations |
console-env-admin |
— | Switch environment context (prod / staging) |
console-flag-admin |
— | Toggle feature flags |
console-user |
— | Dashboard, read-only views. Does NOT auto-include any resource-level role. |
console-manager |
console-user |
Manage console configurations (flags, env, invites). Composition of console-flag-admin, console-env-admin, console-invite-admin. |
console-ops |
console-user, console-audit-user |
Operator daily driver. Add resource roles explicitly. |
Mapping from the current flat model (migration in §8):
| Old role | New group (suggested) |
|---|---|
readonly |
group with console-user |
support |
group with console-user, console-audit-user, antlers-support-readonly |
ops |
group with console-ops, console-token-admin, console-secrets-user, console-flag-admin |
superadmin |
break-glass group only; no working use |
4.2 Antlers (raxx.app) — product-facing
| Role | Inherits | Description |
|---|---|---|
antlers-user |
— | Authenticated end user, free tier |
antlers-founders |
antlers-user |
Founders trial access (gated by paper-first engine) |
antlers-pro |
antlers-founders |
Paid Pro tier |
antlers-support-readonly |
— | Operator read into user-facing surfaces (support agent) |
antlers-org-admin |
antlers-pro |
Future: team/org admin (when teams ship) |
Antlers has no RBAC code today. This taxonomy is prospective; implementation is a follow-up milestone.
4.3 Raptor (api.raxx.app)
| Role | Inherits | Description |
|---|---|---|
raptor-admin |
— | Admin endpoints (/api/admin/*); issued via console JWT |
raptor-read |
— | Read-only admin access |
Raptor's user model inherits Antlers' user roles (same identity DB). Operator access to Raptor admin endpoints uses the existing CONSOLE_RAPTOR_JWT_SECRET pattern (short-lived JWT from console). The RBAC layer governs which console role may trigger which Raptor admin call.
4.4 Getraxx (marketing site)
Minimal; mostly anonymous public. One operator role:
| Role | Description |
|---|---|
getraxx-editor |
Publish or modify marketing content (future CMS integration) |
4.5 Vault (Infisical mapping)
| Local Role | Infisical scope equivalent |
|---|---|
vault-reader |
Read secrets in /MooseQuest/* |
vault-admin |
Read + write + rotate in /MooseQuest/* |
5. Inheritance Graph
graph TD
subgraph "Console — token resource"
CT_U["console-token-user"]
CT_A["console-token-admin"] --> CT_U
end
subgraph "Console — secrets resource"
CS_U["console-secrets-user"]
CS_A["console-secrets-admin"] --> CS_U
end
subgraph "Console — app-wide"
C_U["console-user"]
C_MGR["console-manager"] --> C_U
C_OPS["console-ops"] --> C_U
C_OPS --> C_AUD["console-audit-user"]
end
subgraph "Antlers — product tiers"
A_U["antlers-user"]
A_F["antlers-founders"] --> A_U
A_P["antlers-pro"] --> A_F
end
subgraph "Vault mapping"
V_R["vault-reader"]
V_A["vault-admin"] --> V_R
end
%% Explicit NON-inheritance callouts (dashed = does NOT flow)
C_MGR -. "does NOT include" .-> CT_U
C_MGR -. "does NOT include" .-> CS_U
C_OPS -. "does NOT include" .-> CT_U
C_OPS -. "does NOT include" .-> CS_U
A_P -. "does NOT include" .-> C_U
Key deliberate non-inheritance edges:
- console-manager and console-ops do not carry console-token-user or console-secrets-user. A support agent or ops manager is not implicitly a token-handler.
- antlers-pro does not carry any console-* role. Product and operator surfaces are entirely separate.
- No console-* role inherits any antlers-* role and vice versa.
6. Group Composition Examples
graph LR
subgraph "raxx-platform-admins"
PA_CT_A["console-token-admin"]
PA_CS_A["console-secrets-admin"]
PA_C_M["console-manager"]
PA_C_A["console-audit-user"]
PA_VA["vault-admin"]
PA_RA["raptor-admin"]
end
subgraph "raxx-support-team"
ST_CU["console-user"]
ST_CA["console-audit-user"]
ST_AS["antlers-support-readonly"]
ST_RR["raptor-read"]
end
subgraph "raxx-billing-team (future)"
BT_C["console-user"]
BT_B["billing-reader (TBD)"]
end
subgraph "raxx-founders-cohort"
FC_AF["antlers-founders"]
end
subgraph "break-glass (Kristerpher bootstrap)"
BG_ALL["all roles — emergency only"]
end
| Group | Roles |
|---|---|
raxx-platform-admins |
console-token-admin, console-secrets-admin, console-manager, console-audit-user, vault-admin, raptor-admin |
raxx-support-team |
console-user, console-audit-user, antlers-support-readonly, raptor-read |
raxx-billing-team |
console-user, billing-reader (role TBD when billing ships) |
raxx-founders-cohort |
antlers-founders |
break-glass |
all roles; single member (Kristerpher); requires step-up WebAuthn + audit event; session duration capped at 2h |
7. Data Model
All RBAC tables live in the Console Postgres (identity-authoritative DB). Antlers and Raptor call the Console via short-lived JWT for permission checks, or the permission resolution result is embedded in the session token. Per-app data stays in per-app DBs; RBAC metadata lives in one place.
See ADR-0020 for the decision rationale (centralized identity vs per-app).
-- Users are managed in the existing console_admins table (operators)
-- and Raptor's users table (product users). The RBAC tables below apply
-- to operators (Console identity DB). Product-user roles (antlers-*)
-- are stored as a tier column on Raptor's users table for v1 and will
-- be migrated to full RBAC in a follow-up milestone.
CREATE TABLE groups (
id TEXT PRIMARY KEY, -- uuid v4
name TEXT UNIQUE NOT NULL, -- e.g. 'raxx-platform-admins'
description TEXT,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE roles (
id TEXT PRIMARY KEY, -- uuid v4
name TEXT UNIQUE NOT NULL, -- e.g. 'console-token-admin'
app TEXT NOT NULL, -- 'console' | 'antlers' | 'raptor' | 'vault' | 'getraxx'
description TEXT,
created_at TIMESTAMP NOT NULL
);
CREATE TABLE permissions (
id TEXT PRIMARY KEY, -- uuid v4
name TEXT UNIQUE NOT NULL, -- e.g. 'console:tokens:rotate'
description TEXT,
created_at TIMESTAMP NOT NULL
);
-- Role → Permission (M2M)
CREATE TABLE role_permissions (
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id TEXT NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- Role inheritance DAG (M2M; child inherits parent's permissions)
-- "console-token-admin inherits console-token-user"
-- → child_role_id = console-token-admin, parent_role_id = console-token-user
CREATE TABLE role_inheritance (
child_role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
parent_role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (child_role_id, parent_role_id),
CHECK (child_role_id <> parent_role_id)
);
-- Group → Role (M2M)
CREATE TABLE group_roles (
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, role_id)
);
-- User → Group (M2M; operator users only in Console DB)
CREATE TABLE user_groups (
user_id TEXT NOT NULL REFERENCES console_admins(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
-- Indexes
CREATE INDEX idx_user_groups_user ON user_groups(user_id);
CREATE INDEX idx_group_roles_group ON group_roles(group_id);
CREATE INDEX idx_role_perms_role ON role_permissions(role_id);
CREATE INDEX idx_role_inh_child ON role_inheritance(child_role_id);
What is deliberately absent: any column storing a credential, password, TOTP seed, or recovery token in these tables. The CI grep from ADR-0002 extends to cover RBAC migrations.
8. Permission Resolution Algorithm
Per-request check for can user U perform permission P?:
1. SELECT group_id FROM user_groups WHERE user_id = U
2. SELECT role_id FROM group_roles WHERE group_id IN (step 1 results)
3. Walk role_inheritance DAG: for each role R, union R with all ancestors
→ result: expanded_roles (set)
4. SELECT permission_id FROM role_permissions WHERE role_id IN expanded_roles
→ result: effective_permissions (set)
5. Return P ∈ effective_permissions
DAG cycle guard: the role_inheritance table enforces child_role_id <> parent_role_id. Application-layer must check for cycles before inserting a new inheritance edge (topological sort). A cycle in the DAG is a 422 at the admin API, not a runtime failure.
Caching strategy: Walking the full DAG on every request is too expensive. Recommended approach (decision deferred to feature-developer within these bounds):
- Option A: Session-scoped cache. On login, resolve the effective permission set and embed it in the session record (JSONB column on
console_sessions). Invalidated when any group membership or role assignment changes for the user. Suitable for v1 low-traffic console. - Option B: Postgres materialized view
v_user_effective_permissions(user_id, permission_name), refreshed on writes touser_groups,group_roles,role_permissions,role_inheritancevia triggers or explicit refresh on change. Clean SQL-only solution; refresh cost is acceptable for operator workloads. - Option C: Redis permission set per user-id, TTL 5 minutes, invalidated on write. Adds an infra dependency; defer until load justifies it.
v1 recommendation: Option A (session-embedded). Migrate to B if the console grows to >100 operators or sub-second latency matters for automated tooling.
9. SAML Readiness
v1 uses local group/role assignment (operator invites, console UI). The schema is designed so that the same group table is the join target for SAML-provided group memberships.
Flow when SAML lands:
- SAML assertion carries
groups: ["raxx-platform-admins", "raxx-support-team"](IdP emits group names, not role names — IdP does not need to know Raxx role taxonomy). - On SSO login, Raxx looks up each group name in
groups.name. If found, adds touser_groupsfor the session lifetime (or persists, depending on policy decision in §10 open question #3). - If a SAML group name does not exist in
groups→ fail-closed. The session is created but carries zero RBAC. The admin sees a "no permissions assigned" screen and the system alertsops@raxx.app. Auto-creating unknown groups from SAML assertions is a privilege-escalation risk. - Bootstrap path for existing admins (e.g., Kristerpher): local groups are the source of truth until SAML is enabled. On SAML cutover, a one-time migration script maps existing
console_admins.rolevalues to group memberships. The script is idempotent and requires manual review before run.
IdP integration note: IdP emits group names only. The mapping from IdP group names to Raxx groups must be maintained in the Console admin UI (a group can have an idp_group_name column added in a later migration). Role names never leave Raxx's internal schema.
10. Operator-Only vs Product-Facing
| Surface | Visible to customers | Managed by whom |
|---|---|---|
Antlers user-tier groups (antlers-user, antlers-founders, antlers-pro) |
Yes (tier is user-visible) | System (automated on payment/signup events) |
Console operator groups (raxx-platform-admins, raxx-support-team, etc.) |
No | Console console-invite-admin role |
| Break-glass group | No | Kristerpher directly; requires step-up |
Vault mapping roles (vault-reader, vault-admin) |
No | Console console-secrets-admin role |
Overlap case — support agent with product visibility: A support agent who needs to read user-facing surfaces AND operator views belongs to both raxx-support-team (operator) and has antlers-support-readonly (product-read) in that group. No single role spans both surfaces.
11. Migration Path from Current State
Current state: console_admins.role TEXT CHECK IN ('superadmin','ops','support','readonly').
Target state: console_admins has no role column; membership in user_groups determines everything.
Migration steps:
- Add
groups,roles,permissions,role_permissions,role_inheritance,group_roles,user_groupstables (new migration:0002_rbac_tables.sql). - Seed the role taxonomy from §4 via a data migration script (
scripts/db/seed_rbac_roles.py). - Seed the four legacy groups (
legacy-readonly,legacy-support,legacy-ops,break-glass) and assign roles per the mapping table in §4.1. - For each existing
console_adminsrow, insert intouser_groupsfor the matching legacy group. - Update
rbac.pymiddleware to resolve permissions via the new tables rather thanadmin.rolestring comparison. - Run smoke tests: all existing routes still pass with the migrated data.
- In a separate PR: remove
console_admins.rolecolumn (once all callers updated).
Rollback: Steps 1–4 are additive. Rolling back means dropping the new tables and reverting rbac.py to the string-comparison model. The role column is not removed until step 7, so rollback before that point is clean.
Antlers RBAC: No RBAC code exists today. The users.role column ('user'|'admin') is retained for now. Full Antlers RBAC (group membership, fine-grained roles) is a follow-up milestone. The schema above is designed to accommodate Raptor/Antlers users in a future migration by adding a parallel antlers_user_groups table or extending user_groups to reference a unified identity.
12. Rollout Plan
| Phase | Description |
|---|---|
| Dark | New tables exist; rbac.py still uses old role column. Tests cover both paths. |
| Flag-on (staging) | RBAC_V2=1 enables new resolution path in staging only. Legacy groups shadow old roles 1:1. |
| Beta (prod, read-only) | New path active for read operations; mutations fall back to old path. |
| GA | Old role column deprecated; new path for all operations. Remove legacy groups after 30-day soak. |
| Cleanup | Drop console_admins.role column. |
13. Security Considerations
- PII collected: admin email (existing), group membership metadata (not PII). Roles and permissions are operational data, not personal data.
- Retention: group/role/permission metadata retained indefinitely (operational).
user_groupsrows soft-deleted when user is suspended (retain for audit); purged with user record on DSR erasure. - DSR erasure: deleting a
console_adminsrow cascades touser_groupsvia FK. The group and role rows remain (they are shared resources, not personal data). - Audit trail: every write to
user_groups,group_roles, orrole_permissionsproduces aconsole_audit_logrow. Role-assignment changes are high-sensitivity; every grant and revoke is attributed. - No stored credentials: the RBAC schema contains no credential columns. CI grep from ADR-0002 applies.
- Privilege escalation guard: only the
console-invite-adminrole may modifyuser_groups. The permission check for group modification is itself resolved via the RBAC system (bootstrapped by seeded data). - Break-glass audit: access to the break-glass group must trigger a PagerDuty/Slack alert to
ops@raxx.appin addition to the standard audit row. Implementation detail deferred to feature-developer. - Kill-switch:
RBAC_V2=0env var reverts to old flat-role resolution without redeploy. Secrets rotatable without redeploy. - Breach: RBAC table exfiltration exposes group membership (operational sensitivity) but no credentials. Incident response: audit who was in which group, rotate all session tokens, notify per ADR-0003 pipeline.
- Cycle detection in DAG: insertion of a cycle into
role_inheritancewould cause infinite loops in resolution. The application layer must enforce acyclicity at write time (topological check before INSERT).
14. Open Questions (require Kristerpher's judgment)
- Antlers RBAC timeline. Product-tier roles (
antlers-user,antlers-founders,antlers-pro) are prospective here. Should they be backed by the full group/role model from day one, or should theusers.roletier column stay in place until teams/orgs ship? This affects the migration scope for Raptor. - Single identity DB vs per-app. This design puts the RBAC authority in Console Postgres. Product users (Antlers/Raptor) check permissions by calling a Console identity endpoint. Alternatively, Raptor maintains its own user RBAC tables and syncs with Console. Decision needed before implementing Antlers RBAC (the Console-authoritative approach is proposed in ADR-0020; flag if you want to reconsider).
- SAML group-name collision policy. If an IdP-provided group name matches a Raxx group name by accident during a future SAML migration, the user silently gets those permissions. Should Raxx prefix IdP-sourced groups (e.g.,
idp::raxx-platform-admins) to isolate them from locally-managed groups? This is the safest option but requires an IdP-to-local mapping layer. - Break-glass session cap. Proposal: 2-hour fixed session for break-glass group members. Is 2 hours sufficient for a real incident, or should this be configurable?
- Role deprovisioning on suspension. When a
console_adminsrow is suspended (not deleted), shoulduser_groupsrows be removed immediately (hard cut) or retained and ignored while status = suspended? Retaining makes reinstatement easier; removing is more secure. console-managervsconsole-opssplit. Kristerpher namedconsole-manageras the "account that manages configurations." Today'sopsrole covers configurations and operations. Is the split intentional (manager = config-only, ops = operational actions)? If yes, the role matrix in §4.1 holds. If no, collapse them.- Billing roles.
raxx-billing-teamand abilling-readerrole are stubbed as TBD. Should billing RBAC wait for the billing epic, or should the schema reserve the role name now to prevent a naming conflict?
End of first-pass design. See ADR-0020 for the centralized-identity decision. Sub-cards to be filed.