Raxx · internal docs

internal · gated

RBAC V2 — SAML Group-to-Group Wiring Design

Status: Design — documented for post-launch; no v1 implementation
Date: 2026-05-12 UTC
Owner: software-architect
Refs: #1477 (this card), #1464 (RBAC V2 design PR), design.md, saml-claims-to-groups.md (full implementation spec), migration-plan.md §Phase 12
Milestone: Post-launch (not v1)


Purpose

This document satisfies the acceptance criteria for #1477: it describes the design for wiring SAML identity-provider group claims to Raxx RBAC V2 groups. It covers the assertion flow, the idp_group_name column, fail-closed behavior, and operator provisioning.

For the full implementation specification — including the sequence diagram, audit trail requirements, security checklist, SAML library candidates, and open questions — see saml-claims-to-groups.md in this directory.


1. Scope and Non-Goals

In scope for this document:

Not in scope (all post-launch):


2. Invariants

These constraints are non-negotiable. Any implementation that violates one must stop and escalate.

# Invariant
I-1 No stored assertions. The SAML response document and assertion XML are validated and discarded. Only the derived group memberships are persisted. Nothing in storage can be used to replay authentication.
I-2 Console operators only. SAML applies exclusively to Console operator and partner logins. It never applies to product-user (Antlers) authentication, which is WebAuthn-only.
I-3 Fail-closed. If no IdP group name in the assertion matches any rbac_groups.idp_group_name row, the session receives zero RBAC permissions. No group is auto-created from the assertion.
I-4 Audit trail pre-write. Every SAML-sourced group membership grant or revoke writes a row to rbac_grants_audit (event_type='saml_grant' or 'saml_revoke') before the rbac_user_groups change is committed. If the audit write fails, the membership change is rejected.
I-5 No role names in assertions. The IdP emits group names only. Role resolution happens entirely inside Raxx using the rbac_groups → rbac_group_roles → rbac_roles path. The IdP has no knowledge of the <app>-<resource>-<level> taxonomy.
I-6 All timestamps UTC.
I-7 Secrets in infra. SAML SP private key, IdP metadata URL, and entity IDs live in Infisical (/saml/ path) or SSM. Never in files that ship.

3. The idp_group_name Column

3.1 Current state

Migration 0021 (0021_rbac_v2_additions.py, PR #1500) adds the three RBAC V2 audit tables and audit role seeds. It does not add idp_group_name to rbac_groups. The RBAC V2 design (design.md §7) always described this as a future migration addition, added only when SAML is actively scoped.

Confirmation: the idp_group_name column is not present in rbac_groups as of v1. This is expected and correct — the column provides no value until an IdP integration exists.

3.2 Migration to add the column (post-launch)

When SAML is scoped, the implementing developer adds a migration (next available version number at that point) containing:

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;

The same migration must also add 'saml_grant' and 'saml_revoke' to the ck_rga_event_type check constraint on rbac_grants_audit. On Postgres this requires DROP CONSTRAINT + ADD CONSTRAINT (no in-place ALTER VALUE).

3.3 Decoupling principle

The idp_group_name value is what the IdP emits. The rbac_groups.name is Raxx's internal name following the <app>-<resource>-<level> or group-bundle convention. These two values may differ. Decoupling them means:


4. SAML Claim Attributes

Raxx trusts two attributes from a validated SAML 2.0 assertion. All other attributes are ignored.

Attribute Trusted Purpose
email Yes, after signature validation Look up or JIT-provision the admin record
groups Yes, after signature validation Resolve RBAC group memberships for the session
Any role-level attribute Never Role names never appear in SAML assertions (I-5)

The groups attribute is multi-value. Its XML attribute name varies by IdP:

Configure the attribute name as the Queue service environment variable SAML_GROUPS_ATTRIBUTE_NAME (default: groups). This accommodates IdP variation without a code change.


5. Claim-to-Group Mapping

5.1 Matching rule

idp_group_name matching is exact string, case-sensitive. The group name emitted by the IdP must byte-for-byte match rbac_groups.idp_group_name. No regex, no glob, no case-folding.

Rationale: glob or regex matching against IdP group names creates a privilege escalation surface if group names can be influenced by end users or IdP administrators. Literal matching is unambiguous.

5.2 Lookup

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 returns the complete set of Raxx RBAC groups the session will hold.

5.3 Multiple matching groups

If the assertion carries multiple group names that each match a different rbac_groups row, the session receives membership in all matched groups. Group memberships are additive; the effective permission set is the union of all roles from all matched groups per the resolution algorithm in design.md §3.4.


6. Default-Member Behavior (No-Match / Unmapped Claims)

When SAML authentication succeeds but no group claim matches:

  1. The session is issued. The user is authenticated.
  2. The session carries zero RBAC group memberships. No fallback group is assigned.
  3. The Console renders an "insufficient access" page on first load.
  4. 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).
  5. The full unmapped names are written to customer_audit_events at dimension=operator_interaction, action=sso.saml.unmapped_group, accessible only to raptor-audit-admin.
  6. No RBAC group is auto-created.

The same behavior applies when:

There is no "default member" group for SAML users. Access requires an explicit idp_group_name mapping.


7. Precedence: SAML Claims vs. Direct RBAC Grants

A user's session may have both SAML-sourced group memberships and direct RBAC grants made by a human operator. The precedence rule:

SAML-sourced memberships and manually granted memberships are independent and additive.

Consequence: if a human operator grants a user group membership directly, that membership persists across SAML re-logins. It is not revoked when the SAML assertion no longer contains a matching group claim. To remove it, a human operator must explicitly revoke it via the Console grants UI or grant API.

This design is intentional: it allows operators to grant elevated access to specific users beyond what their IdP group membership provides, without coupling those grants to IdP state.


8. Operator Provisioning Steps

To wire an IdP group to a Raxx RBAC group (post-launch, after the idp_group_name column migration):

Step 1: Identify the target Raxx group.

All Raxx RBAC groups follow the naming conventions from design.md. Groups are bundles of roles; see design.md §3.1 for the group table. The four seeded groups as of migration 0012 are:

rbac_groups.name Roles included Typical IdP mapping
raxx-platform-admins All platform admin roles; includes raptor-audit-admin IdP group for engineering/platform team
raxx-platform-owners Owner-level billing and configuration roles IdP group for founders / C-level
raxx-support-team raptor-audit-support and ticket-scoped access roles IdP group for support staff
raxx-compliance-auditors raptor-audit-compliance (read-only, no notification) IdP group for SOC-2 auditors (provisioned empty at v1)

Step 2: Set the idp_group_name value.

Via the Console RBAC group edit form (/console/rbac/groups, requires console-invite-admin):

Edit Group: raxx-platform-admins
IdP group name: raxx-engineering

Or directly via an audited SQL update (for bootstrap before the UI lands in RV-8):

UPDATE rbac_groups
   SET idp_group_name = '<idp-group-name>'
 WHERE name = '<raxx-group-name>';

Step 3: Coordinate with the IdP administrator.

Provide the IdP administrator with the exact group name you set in idp_group_name. They must configure their IdP to include that group name in the groups attribute of assertions issued for the SAML application. The IdP administrator does not need to know rbac_groups.name — only the idp_group_name value.

Step 4: Verify with a test login.

After mapping, a test user with the IdP group assigned should log in. The Queue service logs will show the group lookup. Confirm the user's session shows the expected group membership via GET /api/rbac/me.


9. Audit Trail

Every SAML-sourced group membership event produces two records (per I-4 and design.md §I-9):

  1. rbac_grants_audit row — pre-write, append-only: - event_type: 'saml_grant' or 'saml_revoke' - target_user_id: the admin record being affected - group_id: the rbac_groups.id being granted or revoked - granted_by: 'saml:jit' (first login) or 'saml:reauth' (re-login reconciliation) - created_at_utc: UTC

  2. customer_audit_events rowdimension=operator_interaction, action=sso.saml.group_grant or sso.saml.group_revoke. Visible in the unified audit trail to raptor-audit-admin.

Unmapped group alerts write only to customer_audit_events (action=sso.saml.unmapped_group). No rbac_grants_audit row is written because no grant occurred.

Retention: rbac_grants_audit rows are retained for 7 years. customer_audit_events rows follow the schedule in docs/architecture/customer-audit-unified/.


10. Re-Login Reconciliation

On every SAML login by a user already in admins:

  1. Queue re-evaluates the assertion group claims against rbac_groups.idp_group_name.
  2. Groups present in the assertion but not yet in rbac_user_groups (SAML-sourced): grant — insert rbac_user_groups row + rbac_grants_audit pre-write.
  3. Groups absent from the assertion but present in rbac_user_groups where granted_by LIKE 'saml:%': revoke — soft-delete rbac_user_groups row + rbac_grants_audit pre-write (event_type='saml_revoke').
  4. Groups unchanged: no-op.
  5. Session is issued with the updated group set.

Implication: removing a user from an IdP group takes effect at their next login, not immediately. For immediate revocation, the operator manually removes the SAML-sourced rbac_user_groups row via the Console grants UI (/console/rbac/grants).


11. Cross-References