RBAC V2 — SAML Claims-to-Groups Wiring
Status: Design — documented for post-launch; no v1 implementation
Date: 2026-05-11 UTC
Owner: software-architect
Refs: #1477 (this card), #1464 (RBAC V2 design PR), design.md, migration-plan.md §Phase 12
Milestone: Post-launch (not v1)
1. Context
RBAC V2 (design.md) introduced the rbac_groups / rbac_roles / rbac_permissions model in migration 0012 and extended it with audit tables in migration 0021. The design is intentionally SAML-ready: design.md §7 notes that IdP-provided group names map to rbac_groups rows, and flags idp_group_name TEXT as a future column for when IdP names diverge from internal Raxx names.
This document specifies the full wiring so that the post-launch implementation has a concrete spec to build against.
What is in scope here
- How a SAML assertion's group claims map to
rbac_groupsrows - The
idp_group_namecolumn (which migration adds it, what it contains) - Operator provisioning steps to wire an IdP group to an RBAC group
- Fail-closed behavior for unmapped assertions
- Audit trail requirements
- Out-of-scope items for v1
What is not in scope
- Choosing a SAML library (post-launch decision; see §8)
- Customer-facing SAML: Raxx customers use WebAuthn passkeys exclusively (invariant I-2 below). SAML applies only to operator-side Console access by enterprise partners.
- Any v1 code change.
2. Invariants
These are non-negotiable. Any SAML implementation that would violate one must stop and escalate.
| # | Invariant |
|---|---|
| I-1 | No stored credentials. The SAML implementation must never persist an assertion, a SAML response document, or any value that could replay an authentication. The only persistent artifact is the group membership derived from the assertion. |
| I-2 | WebAuthn only for Raxx customers. SAML applies exclusively to Console operator/partner logins, never to the product-user (Antlers) authentication path. |
| I-3 | Fail-closed. If an IdP group name in the assertion does not match any rbac_groups.idp_group_name, the session receives zero RBAC permissions. No group is auto-created. |
| I-4 | Audit trail for every group assignment. Every session-scoped group membership derived from a SAML assertion writes a row to rbac_grants_audit (event_type='saml_grant') before the session is issued. |
| I-5 | No role names in SAML assertions. The IdP emits group names only. Role resolution happens entirely inside Raxx. The IdP has no knowledge of the <app>-<resource>-<level> taxonomy. |
| I-6 | All timestamps UTC. |
| I-7 | Secrets in infra, not code. SAML SP private key, IdP metadata URL, and entity IDs live in Infisical or SSM. Never in files that ship. |
3. Column: rbac_groups.idp_group_name
3.1 Why the column does not exist yet
Migration 0021 (0021_rbac_v2_additions.py) landed in PR #1500. Contrary to the expectation in the issue #1477 acceptance criteria, migration 0021 does not add idp_group_name to rbac_groups. The RBAC V2 design doc (design.md §7) describes it as a "future migration" addition.
This is expected: the column is only useful when SAML is scoped. Adding it now causes no harm but provides no benefit until an IdP integration exists. The correct approach is a dedicated migration added as part of the post-launch SAML implementation work.
3.2 Migration to add the column (post-launch)
When SAML is scoped, the implementing developer adds a migration (next available version after whatever is current at that point) with the following change:
ALTER TABLE rbac_groups
ADD COLUMN idp_group_name TEXT UNIQUE;
CREATE UNIQUE INDEX uq_rbac_groups_idp_group_name
ON rbac_groups (idp_group_name)
WHERE idp_group_name IS NOT NULL;
- Column is nullable.
NULLmeans the group is not mapped to any IdP group. UNIQUEconstraint (partial, non-null) prevents two Raxx groups from claiming the same IdP group name.- No existing group data changes; all rows get
idp_group_name = NULL. - Rollback:
ALTER TABLE rbac_groups DROP COLUMN idp_group_name.
3.3 Operator provisioning
To wire an IdP group to an RBAC group:
UPDATE rbac_groups
SET idp_group_name = '<idp-group-name>'
WHERE name = '<raxx-group-name>';
This update is an audited admin action. The Console RBAC management UI (/console/rbac/groups, RV-8) should expose an "IdP group name" field on the group edit form, gated behind console-invite-admin.
Example mappings:
| IdP group name (what the IdP emits) | rbac_groups.name (Raxx internal) |
|---|---|
raxx-engineering |
raxx-platform-admins |
raxx-billing-readonly |
raxx-platform-owners |
raxx-support |
raxx-support-team |
The IdP administrator sets these group names on the identity provider side. The Raxx operator sets the corresponding idp_group_name on the rbac_groups row. The two sides are decoupled: the IdP does not need to know Raxx's internal group names.
4. SAML Assertion Model
A SAML 2.0 assertion is an XML document signed by the IdP. The relevant section is the AttributeStatement, which carries user attributes.
Minimal assertion shape (pseudocode — IdP-agnostic):
Subject: email@partner.example
Attributes:
email: email@partner.example
groups: ["raxx-engineering", "raxx-billing-readonly"]
The groups attribute is a multi-value attribute. Its name varies by IdP:
- Okta: groups
- Azure AD / Entra ID: http://schemas.microsoft.com/ws/2008/06/identity/claims/groups
- Google Workspace SAML: groups (custom attribute, must be configured)
The implementing developer must configure the SAML attribute name for groups as a Queue service environment variable (SAML_GROUPS_ATTRIBUTE_NAME), defaulting to groups. This accommodates IdP variation without a code change.
4.1 What Raxx trusts from the assertion
| Attribute | Trusted | Notes |
|---|---|---|
email |
Yes, after signature validation | Used to look up or provision the admin record |
groups |
Yes, after signature validation | Used for group mapping (this design) |
| Any role-level attribute | Never | Role names never appear in SAML assertions (I-5) |
5. Claim-to-Group Mapping Rules
5.1 Pattern syntax
idp_group_name is a literal string match, not a regex or glob. The IdP group name in the assertion must exactly match rbac_groups.idp_group_name. Case-sensitive.
Rationale: regex matching against group names creates a privilege escalation surface if IdP group names can be influenced by end users. Literal matching is unambiguous.
If an organization needs prefix-based matching (e.g., all groups starting with raxx-), the operator creates explicit rbac_groups rows with the appropriate idp_group_name values. Wildcards are not supported.
5.2 Lookup query
SELECT g.id, g.name
FROM rbac_groups g
WHERE g.idp_group_name = ANY(:assertion_groups)
AND g.idp_group_name IS NOT NULL;
Where :assertion_groups is the list of group names from the SAML assertion. This single query returns the set of Raxx RBAC groups the session should hold.
5.3 Multiple-claim / multiple-group resolution
If the assertion carries multiple group names that each match a different rbac_groups row, the session receives membership in all matched groups. There is no conflict; group memberships are additive. The permission set is the union of all roles from all matched groups (per the resolution algorithm in design.md §3.4).
5.4 No-match behavior (fail-closed)
If none of the assertion group names match any rbac_groups.idp_group_name row:
- The session is issued (the user is authenticated) but carries zero RBAC group memberships.
- The Console renders an "insufficient access" page on first load.
- A Sentry alert fires:
SAML_UNMAPPED_GROUPwith the unmapped group names redacted to a count (not the actual names, to avoid PII leakage in Sentry). The full unmapped names are written tocustomer_audit_eventsatdimension=operator_interaction,action=sso.saml.unmapped_group, and are only accessible toraptor-audit-admin. - No RBAC group is auto-created.
5.5 Empty groups attribute
If the assertion contains a groups attribute with no values (empty list), behavior is identical to no-match: zero group memberships, Sentry alert, session issued but access-denied on Console.
If the assertion contains no groups attribute at all (attribute absent), the same fail-closed path applies. The SAML attribute name must be explicitly configured and verified during IdP setup.
6. Provisioning and Session Flow
6.1 JIT (Just-In-Time) user provisioning
On first SAML login by a user whose email is not in admins:
- Queue creates the admin record (
adminstable) withemailfrom the assertion,auth_method = 'saml',created_at = NOW() UTC. - Queue resolves group memberships from the assertion (§5.2).
- For each matched group, Queue inserts a row into
rbac_user_groupswithgranted_by = 'saml:jit'. - Before any
rbac_user_groupsinsert, arbac_grants_auditrow is written (event_type='saml_grant'), per I-4 and invariant I-9 fromdesign.md. - Session is issued.
JIT provisioning means the operator does not need to pre-create admin records for SAML users. The IdP controls who can authenticate; the idp_group_name mapping controls what access they receive.
6.2 Returning SAML user (re-login)
On every SAML login by a user already in admins:
- Queue re-evaluates the assertion group claims.
- Queue computes the target group set from the claim lookup (§5.2).
- Queue computes the delta:
- Groups present in assertion but not in
rbac_user_groupswithgranted_by LIKE 'saml:%': grant these, insertrbac_user_groups+rbac_grants_auditrow. - Groups present inrbac_user_groups(SAML-sourced) but absent from assertion: revoke these, soft-deleterbac_user_groupsrow +rbac_grants_auditrow (event_type='saml_revoke'). - Groups unchanged: no-op. - Session is issued with the updated group set.
This means SAML group membership is authoritative per-login. Removing a user from an IdP group takes effect on their next login, not immediately. For immediate revocation, the operator can manually remove the rbac_user_groups row for the SAML-sourced membership via the Console grants UI.
6.3 Non-SAML groups are not touched
The SAML provisioning path only creates and revokes group memberships where rbac_user_groups.granted_by LIKE 'saml:%'. Manually granted memberships (granted by a human operator) are not affected by SAML assertion evaluation.
6.4 Sequence diagram
sequenceDiagram
participant IDP as Identity Provider
participant Queue as Queue (identity svc)
participant DB as Console DB
IDP->>Queue: POST /sso/saml/acs (ACS endpoint)<br/>SAML response with groups claim
Queue->>Queue: Validate signature + timestamps
Queue->>DB: SELECT id FROM rbac_groups<br/>WHERE idp_group_name = ANY(assertion_groups)
alt groups matched
Queue->>DB: INSERT rbac_grants_audit (event_type='saml_grant') [pre-write]
Queue->>DB: INSERT/UPDATE rbac_user_groups (granted_by='saml:jit')
Queue->>Queue: Resolve effective permissions (design.md §3.4)
Queue->>IDP: 302 → Console (session cookie set)
else no groups matched
Queue->>Queue: Emit Sentry alert SAML_UNMAPPED_GROUP
Queue->>DB: INSERT customer_audit_events (action=sso.saml.unmapped_group)
Queue->>IDP: 302 → Console /insufficient-access
end
7. Audit Trail
Every SAML-sourced group membership event produces two records:
-
rbac_grants_auditrow — pre-write, append-only, per I-4 and I-9. Fields: -event_type:'saml_grant'or'saml_revoke'(these are additions to the existing enum; the migration that addsidp_group_namealso adds these two values to theck_rga_event_typecheck constraint) -target_user_id: the admin being granted/revoked -group_id: therbac_groups.idbeing granted/revoked -granted_by:'saml:jit'(JIT) or'saml:reauth'(re-login reconciliation) -created_at_utc: UTC -
customer_audit_eventsrow — atdimension=operator_interaction,action=sso.saml.group_grantorsso.saml.group_revoke. This makes SAML provisioning events visible in the unified audit trail accessible toraptor-audit-admin.
The SAML_UNMAPPED_GROUP case writes only to customer_audit_events (no rbac_grants_audit row, because no grant occurred).
Retention: rbac_grants_audit rows are retained for 7 years (per design.md §9.1, operational accountability). customer_audit_events rows follow the retention schedule in docs/architecture/customer-audit-unified/.
8. Security Considerations
8.1 GDPR checklist
| Question | Answer |
|---|---|
| What PII does this collect? | Email address from the SAML assertion, used to look up or create the admins record. No new PII field beyond what admins already holds. The idp_group_name column contains organizational group identifiers, not personal data. |
| What is the retention period? | SAML-sourced rbac_user_groups rows: lifetime of the admin record, cascade-deleted on DSR erasure. rbac_grants_audit rows: 7 years. |
| How is it deleted on DSR? | Deleting the admins row cascades via FK to rbac_user_groups. rbac_grants_audit.target_user_id CASCADE-deletes. |
| What is logged for audit? | Every SAML-sourced grant and revoke writes to rbac_grants_audit (pre-write) and customer_audit_events. Unmapped group alerts write to customer_audit_events with group names redacted in Sentry. |
| Does any part store a credential that can be replayed? | No. The SAML response document is verified and discarded. No assertion XML is persisted. Only group membership rows are stored. |
| What happens on breach? | rbac_user_groups exfiltration reveals group membership (operational sensitivity, no credentials). Response: rotate all Console session tokens, invalidate SAML SP key pair, audit group membership for anomalies, notify per ADR-0003 breach pipeline within 72h. |
| Where are secrets? | SAML SP private key in SSM (AWS workload secret). IdP metadata URL and entity ID in Infisical (/saml/ path). Both rotatable without redeploy. |
| Is there a kill-switch? | FLAG_SAML_SSO=0 disables the ACS endpoint entirely; existing SAML-sourced sessions remain valid until they expire; no new SAML logins accepted. |
8.2 Signature validation is mandatory
The SAML response must be validated against the IdP's signing certificate before any claim is trusted. An implementation that skips signature validation is not acceptable regardless of network topology. The SP must reject:
- Responses with expired NotOnOrAfter timestamps (checked against UTC).
- Responses with future NotBefore timestamps beyond a 2-minute clock skew allowance.
- Responses whose Destination does not match the ACS URL.
- Replay attacks: a saml_assertion_id table (or short-TTL Redis key) must track processed assertion IDs within the validity window.
8.3 SAML library selection (post-launch)
The choice of SAML library is deferred to implementation time. Three candidates:
| Library | License | Notes |
|---|---|---|
python3-saml |
MIT | Widely used; wraps lxml + xmlsec; maintained by OneLogin |
pysaml2 |
Apache 2.0 | More complete spec coverage; heavier dependency chain |
flask-saml2 |
MIT | Flask-native; less maintained as of 2026 |
The implementing developer must evaluate maintenance status at implementation time. Key requirement: the library must support assertion ID tracking for replay prevention.
9. Out of Scope for v1
- No SAML ACS endpoint code.
- No
idp_group_namecolumn migration. - No SAML SP metadata endpoint.
- No IdP-facing configuration.
- No change to customer (Antlers) authentication — passkeys only.
- No change to Console WebAuthn login path.
This document is the implementation spec. When SAML is scoped post-launch, the implementing developer should claim the card linked to this doc and implement against §3–§8 above.
10. Open Questions
OQ-1 (post-launch, operator decision required before implementation): Which IdP will be used for the first enterprise partner integration? This determines the groups attribute name and shapes the SAML_GROUPS_ATTRIBUTE_NAME default. Okta and Azure AD/Entra ID are the most common; they use different attribute name conventions.
OQ-2 (post-launch): Should SAML-sourced memberships be session-scoped (evaporate when the session ends, re-derived on next login) or persistent (survive session expiry until the next login reconciliation)? This design chooses persistent + login-time reconciliation (§6.2), which is simpler but means a revoked IdP group membership persists until the user next logs in. An alternative is to set an expiry on SAML-sourced rbac_user_groups rows equal to the SAML session validity period. Decision deferred to implementation; the schema supports either approach (nullable expires_at on rbac_user_groups is not present today but can be added in the same migration as idp_group_name).
OQ-3 (architectural): The saml_grant and saml_revoke event types are not in the current ck_rga_event_type check constraint on rbac_grants_audit. The migration that adds idp_group_name must also ALTER the constraint to add these two values. On Postgres, this requires DROP CONSTRAINT + ADD CONSTRAINT (no ALTER CONSTRAINT ... ADD VALUE). The migration plan must account for this.