Status: Draft — first pass; multi-pass expected
Owner: software-architect
Last updated: 2026-04-25
Branch: design/rbac-v1-first-pass
Related ADRs: 0020
Refs: Console flat RBAC in console/app/middleware/rbac.py; ADR-0001, ADR-0002, ADR-0003
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.
The following constraints are non-negotiable and override any design choice below:
user → groups → roles → permissions. A permission check against a bare user ID must not exist.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)
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.
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.
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 |
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.
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.
Minimal; mostly anonymous public. One operator role:
| Role | Description |
|---|---|
getraxx-editor |
Publish or modify marketing content (future CMS integration) |
| Local Role | Infisical scope equivalent |
|---|---|
vault-reader |
Read secrets in /MooseQuest/* |
vault-admin |
Read + write + rotate in /MooseQuest/* |
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.
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 |
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.
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):
console_sessions). Invalidated when any group membership or role assignment changes for the user. Suitable for v1 low-traffic console.v_user_effective_permissions(user_id, permission_name), refreshed on writes to user_groups, group_roles, role_permissions, role_inheritance via triggers or explicit refresh on change. Clean SQL-only solution; refresh cost is acceptable for operator workloads.v1 recommendation: Option A (session-embedded). Migrate to B if the console grows to >100 operators or sub-second latency matters for automated tooling.
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:
groups: ["raxx-platform-admins", "raxx-support-team"] (IdP emits group names, not role names — IdP does not need to know Raxx role taxonomy).groups.name. If found, adds to user_groups for the session lifetime (or persists, depending on policy decision in §10 open question #3).groups → fail-closed. The session is created but carries zero RBAC. The admin sees a "no permissions assigned" screen and the system alerts ops@raxx.app. Auto-creating unknown groups from SAML assertions is a privilege-escalation risk.console_admins.role values 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.
| 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.
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:
groups, roles, permissions, role_permissions, role_inheritance, group_roles, user_groups tables (new migration: 0002_rbac_tables.sql).scripts/db/seed_rbac_roles.py).legacy-readonly, legacy-support, legacy-ops, break-glass) and assign roles per the mapping table in §4.1.console_admins row, insert into user_groups for the matching legacy group.rbac.py middleware to resolve permissions via the new tables rather than admin.role string comparison.console_admins.role column (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.
| 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. |
user_groups rows soft-deleted when user is suspended (retain for audit); purged with user record on DSR erasure.console_admins row cascades to user_groups via FK. The group and role rows remain (they are shared resources, not personal data).user_groups, group_roles, or role_permissions produces a console_audit_log row. Role-assignment changes are high-sensitivity; every grant and revoke is attributed.console-invite-admin role may modify user_groups. The permission check for group modification is itself resolved via the RBAC system (bootstrapped by seeded data).ops@raxx.app in addition to the standard audit row. Implementation detail deferred to feature-developer.RBAC_V2=0 env var reverts to old flat-role resolution without redeploy. Secrets rotatable without redeploy.role_inheritance would cause infinite loops in resolution. The application layer must enforce acyclicity at write time (topological check before INSERT).antlers-user, antlers-founders, antlers-pro) are prospective here. Should they be backed by the full group/role model from day one, or should the users.role tier column stay in place until teams/orgs ship? This affects the migration scope for Raptor.idp::raxx-platform-admins) to isolate them from locally-managed groups? This is the safest option but requires an IdP-to-local mapping layer.console_admins row is suspended (not deleted), should user_groups rows be removed immediately (hard cut) or retained and ignored while status = suspended? Retaining makes reinstatement easier; removing is more secure.console-manager vs console-ops split. Kristerpher named console-manager as the "account that manages configurations." Today's ops role 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.raxx-billing-team and a billing-reader role 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.