Raxx · internal docs

internal · gated

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

What is not in scope


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;

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:

  1. The session is issued (the user is authenticated) but carries zero RBAC group memberships.
  2. The Console renders an "insufficient access" page on first load.
  3. A Sentry alert fires: SAML_UNMAPPED_GROUP with the unmapped group names redacted to a count (not the actual names, to avoid PII leakage in Sentry). The full unmapped names are written to customer_audit_events at dimension=operator_interaction, action=sso.saml.unmapped_group, and are only accessible to raptor-audit-admin.
  4. 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:

  1. Queue creates the admin record (admins table) with email from the assertion, auth_method = 'saml', created_at = NOW() UTC.
  2. Queue resolves group memberships from the assertion (§5.2).
  3. For each matched group, Queue inserts a row into rbac_user_groups with granted_by = 'saml:jit'.
  4. Before any rbac_user_groups insert, a rbac_grants_audit row is written (event_type='saml_grant'), per I-4 and invariant I-9 from design.md.
  5. 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:

  1. Queue re-evaluates the assertion group claims.
  2. Queue computes the target group set from the claim lookup (§5.2).
  3. Queue computes the delta: - Groups present in assertion but not in rbac_user_groups with granted_by LIKE 'saml:%': grant these, insert rbac_user_groups + rbac_grants_audit row. - Groups present in rbac_user_groups (SAML-sourced) but absent from assertion: revoke these, soft-delete rbac_user_groups row + rbac_grants_audit row (event_type='saml_revoke'). - Groups unchanged: no-op.
  4. 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:

  1. rbac_grants_audit row — 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 adds idp_group_name also adds these two values to the ck_rga_event_type check constraint) - target_user_id: the admin being granted/revoked - group_id: the rbac_groups.id being granted/revoked - granted_by: 'saml:jit' (JIT) or 'saml:reauth' (re-login reconciliation) - created_at_utc: UTC

  2. customer_audit_events row — at dimension=operator_interaction, action=sso.saml.group_grant or sso.saml.group_revoke. This makes SAML provisioning events visible in the unified audit trail accessible to raptor-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

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.