SAML Attribute Mapping — Per-Tenant Configurable Claims
HECVAT question: “Do you allow the customer to specify attribute mappings for any needed information beyond a user identifier? (e.g., eduPerson, ePPA/ePPN/ePE)”
Short answer today: Partially. A tenant admin can name the SAML attribute that carries email, full name, and one group/department attribute (with a value→department map). There is no stable-identifier mapping (ePPN / pairwise-id / subject-id), no given/surname, no affiliation→role rule, and users are bound to accounts by email only.
1. What exists today (verified in code, 2026-09-23)
1.1 Storage — tenant_sso_config (migration supabase/migrations/20260816020000_254_tenant_sso.sql)
| Column | Meaning |
|---|---|
nameid_format | Requested NameID format (default urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress) |
email_attribute | Attribute name carrying the email. NULL → fall back to email/mail, then NameID |
name_attribute | Attribute name carrying a single full/display name |
department_attribute | Attribute name carrying group/OU value(s) (multi-valued OK) |
department_map | JSONB { "<idp value>": "<department slug>" } |
allowed_email_domains | Anti-takeover allowlist — required to enable SSO |
allow_jit_provisioning | Create unknown users on first login |
No other mapping columns exist. sso_intake (migration 20260903020000_275_sso_intake.sql)
captures only email_attribute from prospects.
1.2 Mapping logic — workers/api/src/services/saml.ts
profileToIdentity() (lines ~113–141) is the entire mapping engine:
email = profile[email_attribute] ?? profile.email ?? profile.mail ?? nameIDfullName = profile[name_attribute](string only; an array is dropped tonull)groupValues = profile[department_attribute](string or string[])- Everything else is carried as
attributes: profilein theSamlIdentity(packages/shared/src/types.ts:907) but nothing downstream reads it.
Attribute names are matched as exact keys of the node-saml profile, so a tenant can
already type a full URI such as urn:oid:0.9.2342.19200300.100.1.3 or
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress — but only for the
three slots above.
1.3 Provisioning — workers/api/src/services/sso-provisioning.ts
- Account binding is by normalized email (
profiles.email = <asserted email>). The NameID is only written to thetenant_sso_loginsaudit row (name_id); it is never stored against the profile. Consequences: an email rename at the IdP creates a second account (JIT) or fails; email reassignment (common when a student/employee leaves and the address is recycled) would log the new person into the old account. full_nameis written only on JIT create, never refreshed on later logins.department_idis refreshed on every login (first matching mapped group wins) — the only attribute that stays in sync.- No role assignment: SSO users are ordinary members.
tenant_admins/department_adminsroles are managed by hand.
1.4 Admin API + UI
workers/api/src/routes/tenant-sso-admin.ts—putSchemaacceptsemailAttribute,nameAttribute,departmentAttribute(free-text, ≤200 chars) anddepartmentMap. No validation that the attribute is ever released by the IdP.apps/web/src/app/tenant-admin/sso/page.tsx— step 3 “Who can sign in, and how you map them” has three text inputs plus a group→department row editor (labels/sr-onlylabels are present; a reasonable a11y baseline).apps/web/src/app/tenant-admin/sso/SsoLoginsTable.tsx— last 20 attempts; shows no asserted attributes, so an admin cannot see what their IdP actually released.
1.5 Docs
docs/admin/access/sso-status.md (intake checklist rows 4, 6, 7) already tells customers
to supply the email OID, the display-name OID urn:oid:2.16.840.1.113730.3.1.241, and
“eduPersonAffiliation → dept slug”. sso-implementation.md describes the original
Supabase-native design (superseded — we are our own SP).
1.6 Exactly what is mappable today
| Information | Mappable? | How |
|---|---|---|
| Yes | email_attribute (any attribute name) or NameID | |
| Display / full name | Yes, create-only | name_attribute (single-valued) |
Department (from any one group-ish attribute, e.g. eduPersonAffiliation, ou, Entra groups) | Yes | department_attribute + department_map |
| Stable identifier (ePPN, eduPersonTargetedID, pairwise-id, subject-id, Entra objectidentifier) | No — email is the key | — |
| Given name / surname | No | — |
| Scoped affiliation → role (admin/viewer) | No | — |
Entitlement (eduPersonEntitlement) → access gating | No | — |
| Attribute value transforms (strip scope, case-fold, regex) | No | — |
2. Goals
- A tenant admin can map any released attribute to each Gevell profile field.
- Users are keyed on a stable, non-reassignable identifier chosen by the tenant, with email as a (verified-domain-gated) fallback for first binding only.
- Affiliation/group/entitlement values can drive department and role
assignment, and optionally gate access (e.g. deny
affiliate/alum). - Presets for InCommon/Shibboleth (eduPerson), Microsoft Entra ID, Okta, Google Workspace so most admins never type an OID.
- Admin can see the last assertion’s released attributes to debug mappings.
Non-goals (this phase): SCIM, OIDC, encrypted assertions, SLO — tracked in
sso-status.md Phase 3 / issue #2588.
3. Reference attribute names
3.1 eduPerson / InCommon (SAML2 URI name format)
| Field | Friendly name | Attribute Name |
|---|---|---|
mail | urn:oid:0.9.2342.19200300.100.1.3 | |
| Display name | displayName | urn:oid:2.16.840.1.113730.3.1.241 |
| Given name | givenName | urn:oid:2.5.4.42 |
| Surname | sn | urn:oid:2.5.4.4 |
| Common name | cn | urn:oid:2.5.4.3 |
| Principal name (ePPN) | eduPersonPrincipalName | urn:oid:1.3.6.1.4.1.5923.1.1.1.6 |
| Targeted ID (legacy, NameID-valued) | eduPersonTargetedID | urn:oid:1.3.6.1.4.1.5923.1.1.1.10 |
| Unique ID | eduPersonUniqueId | urn:oid:1.3.6.1.4.1.5923.1.1.1.13 |
| Affiliation (ePA) | eduPersonAffiliation | urn:oid:1.3.6.1.4.1.5923.1.1.1.1 |
| Scoped affiliation (ePSA) | eduPersonScopedAffiliation | urn:oid:1.3.6.1.4.1.5923.1.1.1.9 |
| Primary affiliation (ePPA) | eduPersonPrimaryAffiliation | urn:oid:1.3.6.1.4.1.5923.1.1.1.5 |
| Entitlement (ePE) | eduPersonEntitlement | urn:oid:1.3.6.1.4.1.5923.1.1.1.7 |
| Org unit DN | eduPersonOrgUnitDN | urn:oid:1.3.6.1.4.1.5923.1.1.1.4 |
| Org unit | ou | urn:oid:2.5.4.11 |
| Department number | departmentNumber | urn:oid:2.16.840.1.113730.3.1.2 |
| SAML subject-id (REFEDS/OASIS) | subject-id | urn:oasis:names:tc:SAML:attribute:subject-id |
| SAML pairwise-id | pairwise-id | urn:oasis:names:tc:SAML:attribute:pairwise-id |
3.2 Microsoft Entra ID (default claim URIs)
| Field | Claim |
|---|---|
http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress | |
| UPN / name | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name |
| Given name | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname |
| Surname | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname |
| Display name | http://schemas.microsoft.com/identity/claims/displayname |
| Stable object ID | http://schemas.microsoft.com/identity/claims/objectidentifier |
| Tenant ID | http://schemas.microsoft.com/identity/claims/tenantid |
| Groups | http://schemas.microsoft.com/ws/2008/06/identity/claims/groups (object IDs unless configured for names) |
| App roles | http://schemas.microsoft.com/ws/2008/06/identity/claims/role |
| Department (optional claim) | typically department or http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department — admin-configured |
Okta and Google Workspace let the admin name attributes freely; presets will suggest
email, firstName, lastName, groups, and the IdP’s immutable user id.
4. Design
4.1 Mapping model
Replace the three fixed columns with a field → source mapping, keeping the old columns as a read-through fallback during migration.
// tenant_sso_config.attribute_mapping (JSONB){ "preset": "incommon", // incommon | entra | okta | google | custom "subject": { // stable identifier — REQUIRED once enabled "source": "urn:oasis:names:tc:SAML:attribute:pairwise-id", "fallbacks": ["urn:oid:1.3.6.1.4.1.5923.1.1.1.6"], // ePPN "useNameId": false }, "email": { "source": "urn:oid:0.9.2342.19200300.100.1.3" }, "displayName":{ "source": "urn:oid:2.16.840.1.113730.3.1.241" }, "givenName": { "source": "urn:oid:2.5.4.42" }, "surname": { "source": "urn:oid:2.5.4.4" }, "affiliation":{ "source": "urn:oid:1.3.6.1.4.1.5923.1.1.1.9", "stripScope": true }, "department": { "source": "urn:oid:2.16.840.1.113730.3.1.2" }, "entitlement":{ "source": "urn:oid:1.3.6.1.4.1.5923.1.1.1.7" }}Rules (department, role, gate) are stored separately so they are ordered and auditable:
// tenant_sso_config.attribute_rules (JSONB array, evaluated top to bottom)[ { "when": { "field": "affiliation", "in": ["alum", "affiliate"] }, "then": { "deny": "affiliation_not_allowed" } }, { "when": { "field": "entitlement", "equals": "urn:mace:uth.edu:gevell:admin" }, "then": { "tenantRole": "admin" } }, { "when": { "field": "department", "equals": "1234" }, "then": { "department": "school-of-nursing" } }, { "when": { "field": "affiliation", "in": ["faculty", "staff"] }, "then": { "departmentDefault": "general" } }]department_mapstays supported and is expressed internally asdepartmentrules.- Role rules may only grant
viewer/manager/adminat org or department scope — neverowner(customer super-admin stays manual), never platformprofiles.role='admin'. Role grants from SSO are markedsource='sso'so a later login that no longer matches revokes them, while manually granted roles are untouched. stripScope:faculty@uth.edu→faculty, and the scope must be inallowed_email_domains(or an explicitallowed_scopeslist) or the value is ignored — prevents a federation IdP from asserting another school’s scope.
4.2 Stable identifier binding
New table tenant_sso_identities:
| Column | Notes |
|---|---|
tenant_id UUID FK tenants | |
subject TEXT | Value of the configured subject attribute (case-preserved; pairwise-id/subject-id are case-insensitive per spec — normalize to lowercase for those) |
subject_source TEXT | Which attribute produced it (for audit/debug) |
user_id UUID FK auth.users | |
first_seen_at, last_seen_at | |
UNIQUE (tenant_id, subject) |
Resolution order in provisionSsoUser:
- Look up
(tenant_id, subject)→ user. If found, that is the user, even if the email changed (email is then updated on the profile if the new email’s domain is allowed and no other profile holds it). - Not found → existing email path (verified-domain gate + cross-tenant guard, unchanged), then insert the identity row. This is the one-time “link” step.
- If a subject row exists for a different user than the email match → deny
subject_email_conflictand log; never silently re-bind.
eduPersonTargetedID is a NameID-valued attribute; node-saml surfaces it as an object,
not a string. The resolver must extract the inner value and qualify it as
<NameQualifier>!<SPNameQualifier>!<value> (the conventional flattened form). Covered
by a fixture test.
4.3 Resolver refactor — workers/api/src/services/saml.ts
- New pure module
workers/api/src/services/saml-attribute-mapping.ts:resolveAttributes(profile, mapping) → MappedIdentityandevaluateRules(mapped, rules) → { deny?, departmentSlug?, roles[] }. Pure so it is exhaustively unit-testable (same pattern asprofileToIdentity). - Lookup matches an attribute by Name first, then by FriendlyName (node-saml
exposes only Name keys today; FriendlyName needs the raw assertion via
profile.getAssertion(), already used byextractAssertionId). - Multi-valued handling is explicit per field: scalar fields take the first non-empty value and record a warning when >1 value was released.
profileToIdentitybecomes a thin adapter so legacy configs (noattribute_mapping) behave byte-for-byte as today.SamlIdentity(packages/shared/src/types.ts) gainssubject,givenName,surname,affiliations[],entitlements[].
4.4 Profile sync
On every successful login, update full_name (display name, or given + surname),
department_id, and SSO-sourced roles. Write-only-if-changed to avoid churn. Store the
last released attribute names and value counts (not values) in
tenant_sso_logins.metadata for the admin debug view; store full values only in an
opt-in, 24-hour “debug capture” row (see 4.6) to limit PII retention.
4.5 Schema change (migration)
New file supabase/migrations/<ts>_<next>_tenant_sso_attribute_mapping.sql (next free
number at authoring time — currently after 324):
ALTER TABLE public.tenant_sso_config ADD COLUMN IF NOT EXISTS attribute_mapping JSONB NOT NULL DEFAULT '{}', ADD COLUMN IF NOT EXISTS attribute_rules JSONB NOT NULL DEFAULT '[]', ADD COLUMN IF NOT EXISTS require_subject BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN IF NOT EXISTS debug_capture_until TIMESTAMPTZ;
ALTER TABLE public.tenant_sso_logins ADD COLUMN IF NOT EXISTS subject TEXT, ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}';
CREATE TABLE IF NOT EXISTS public.tenant_sso_identities ( ... as in 4.2 ... );
-- Role provenance so SSO can revoke only what SSO granted.ALTER TABLE public.tenant_admins ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual','sso'));ALTER TABLE public.department_admins ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual','sso'));
-- RLS for tenant_sso_identities mirrors tenant_sso_config (org owner/admin/manager-- read; service role writes). Backfill: none required — identities link lazily on-- next login.Additive only; safe to apply before the Lambda deploy (same posture as migration 254).
4.6 Admin API — workers/api/src/routes/tenant-sso-admin.ts
- Extend
putSchemawithattributeMapping,attributeRules,requireSubject, using zod discriminated unions; attribute names validated as^(urn:[A-Za-z0-9:._-]+|https?://\S+|[A-Za-z][\w.-]{0,127})$, ≤ 50 rules, rule values ≤ 256 chars, department slugs must exist indepartmentsfor the tenant (validated server-side, batched in onein()query). GET /presets— static preset catalogue (section 3).POST /debug-capture— enables a 24h capture of the next assertion’s full attribute set;GET /last-assertionreturns names + values from that capture (managing roles only; values auto-purged by the existing daily cron pattern).POST /test-mapping— body: a pasted attribute set (or the captured one) → returns the resolved identity and rule outcome without logging anyone in (dry run).- Enabling SSO with
requireSubject=truerequiresattributeMapping.subject.source. - Every config change writes a tenant audit event (see
tenant-audit-log-plan.md, eventsso.config_updatedwith a field-level diff that redacts the certificate body).
4.7 Admin UI — apps/web/src/app/tenant-admin/sso/
Split step 3 into its own component file (page.tsx is already 491 lines, over the
300-line standard): AttributeMappingStep.tsx, AttributeRulesEditor.tsx,
LastAssertionPanel.tsx.
- Preset picker: radio group (
fieldset/legend) — InCommon, Entra ID, Okta, Google, Custom. Choosing a preset fills the fields; each field stays editable. - Field table: one row per Gevell field; each row a labelled text input with a
datalistof known URIs and a visible friendly-name hint (“eduPersonPrincipalName”). - Rules editor: ordered list; each rule is a row of labelled selects (field, operator, value, action). Reordering uses Move up / Move down buttons (no drag — WCAG 2.5.7), with a polite live-region announcement “Rule 3 moved to position 2”.
- Last assertion panel: table with
<caption>,th scope="col"; highlights which mapped fields were found/missing using text + icon, not color alone (1.4.1). - Test mapping: result rendered into a
role="status"region. - WCAG 2.1 AA requirements for this page (checked in the PR):
labels bound with
htmlFor; errors associated viaaria-describedbyand summarized at the top with focus moved to the summary on submit (3.3.1/3.3.3); 44×44 px targets; 3px#046307focus ring per the Gevell design system; 4.5:1 contrast in light and dark mode; works at 320 px width / 400% zoom; no information conveyed by color alone. - Add
e2e/tenant-admin-sso.a11y.spec.ts(authenticated fixture, gating assertion).
4.8 Intake
Add idp_stack-driven preset suggestion and optional subject_attribute /
affiliation_attribute fields to sso_intake and the /sso-intake form.
5. Validation & security considerations
- Domain gate stays the root control: a stable subject never bypasses
allowed_email_domainson first link. - Role rules cannot grant
owneror platform admin; rule changes are audited. - Scoped values (
ePSA, ePPN) must match an allowed scope, else ignored. - Deny rules fail closed with a clear, accessible deny page reason (via
denyPage()inworkers/api/src/utils/sso-http.ts). - Debug capture is opt-in, time-boxed, managing-role only, and purged.
- Multi-valued / oversized attributes are truncated (≤ 100 values, ≤ 1 KB each) before rule evaluation to bound CPU.
6. Tests
| Suite | New cases |
|---|---|
workers/api/src/__tests__/services/saml-attribute-mapping.test.ts (new) | each preset against fixture profiles (Shibboleth, Entra, Okta); multi-valued; FriendlyName fallback; stripScope + scope rejection; ePTID NameID-object flattening; pairwise-id case-folding; rule ordering, deny, role grant/revoke |
.../services/sso-provisioning.test.ts | subject-first lookup; email-change keeps same user; recycled-email conflict → deny; SSO role revoke leaves manual roles; profile sync write-if-changed |
.../services/saml-signed.test.ts | end-to-end signed assertion carrying eduPerson attrs |
.../routes/tenant-sso-admin.test.ts | schema validation, unknown department slug rejected, viewer role forbidden, debug-capture expiry, test-mapping dry run |
apps/web vitest | AttributeRulesEditor move up/down + announcements |
Playwright a11y | new gated axe spec for /tenant-admin/sso |
Route-test-coverage check (scripts/check-route-test-coverage.mjs standard) is
satisfied because tenant-sso-admin.test.ts already exists.
7. Rollout
- Migration applied (additive). Deploy Lambda and the Node API (EC2) if any route is
Node-mounted (SSO protocol routes are Lambda-only today per
routes/sso.ts). - Feature flag
SSO_ATTRIBUTE_MAPPING_V2(per-tenant viatenant_sso_config.metadatafor pilot). Legacy tenants with emptyattribute_mappinguse the old path. - Pilot with the first live SSO tenant (UTHealth runbook,
sso-uth-runbook.md): enable debug capture, confirm released attributes, set subject, thenrequire_subject=true. - Backfill happens lazily on next login; monitor
subject_email_conflictdenials. - Update customer docs:
docs/user/src/content/docs/sso/(IdP setup guide attribute table) anddocs/admin/access/sso-status.mdintake checklist.
8. Effort estimate
| Work item | Days |
|---|---|
| Migration + shared types | 0.5 |
| Mapping/rules resolver (pure) incl. ePTID/scope handling | 2.0 |
| Stable-identifier binding + provisioning changes + role provenance | 2.0 |
| Admin API (schema, presets, debug capture, test-mapping) | 1.5 |
| Admin UI (3 components, a11y) | 2.5 |
| Tests (unit, route, signed E2E, axe spec) | 1.5 |
| Docs + pilot rollout support | 1.0 |
| Total | ≈ 11 engineer-days (±2) |
A minimum viable slice that answers the HECVAT question with a plain “Yes” — given/surname + stable subject binding + affiliation→department, no role rules, no debug capture — is ≈ 5–6 days.
9. Questionnaire wording
Before (accurate today):
Partially. Institution administrators configure SAML 2.0 SSO in a self-service admin console and can specify which released attribute supplies the user’s email address and display name, and one group or affiliation attribute (for example eduPersonAffiliation or an Entra ID group claim) that is mapped to departments within the institution’s account. Accounts are currently matched on email address. Mapping of a persistent identifier (ePPN, pairwise-id/subject-id, eduPersonTargetedID), given name/surname, and affiliation- or entitlement-based roles is on our roadmap.
After (once this plan ships):
Yes. Institution administrators configure attribute mappings self-service, with presets for InCommon/eduPerson, Microsoft Entra ID, Okta and Google Workspace, or any custom attribute name/OID. Mappable fields include a persistent user identifier (SAML pairwise-id or subject-id, eduPersonPrincipalName, eduPersonUniqueId, eduPersonTargetedID, or Entra objectidentifier), email, display name, given name and surname, eduPersonAffiliation / ScopedAffiliation / PrimaryAffiliation, eduPersonEntitlement, and department/org-unit attributes. Ordered rules can assign departments and scoped administrative roles, or deny access, based on affiliation, entitlement or group values. Scoped values are validated against the institution’s verified domains, and all mapping changes are recorded in the institution’s audit log.