Skip to content

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)

ColumnMeaning
nameid_formatRequested NameID format (default urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress)
email_attributeAttribute name carrying the email. NULL → fall back to email/mail, then NameID
name_attributeAttribute name carrying a single full/display name
department_attributeAttribute name carrying group/OU value(s) (multi-valued OK)
department_mapJSONB { "<idp value>": "<department slug>" }
allowed_email_domainsAnti-takeover allowlist — required to enable SSO
allow_jit_provisioningCreate 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 ?? nameID
  • fullName = profile[name_attribute] (string only; an array is dropped to null)
  • groupValues = profile[department_attribute] (string or string[])
  • Everything else is carried as attributes: profile in the SamlIdentity (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 the tenant_sso_logins audit 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_name is written only on JIT create, never refreshed on later logins.
  • department_id is 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_admins roles are managed by hand.

1.4 Admin API + UI

  • workers/api/src/routes/tenant-sso-admin.ts — putSchema accepts emailAttribute, nameAttribute, departmentAttribute (free-text, ≤200 chars) and departmentMap. 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-only labels 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

InformationMappable?How
EmailYesemail_attribute (any attribute name) or NameID
Display / full nameYes, create-onlyname_attribute (single-valued)
Department (from any one group-ish attribute, e.g. eduPersonAffiliation, ou, Entra groups)Yesdepartment_attribute + department_map
Stable identifier (ePPN, eduPersonTargetedID, pairwise-id, subject-id, Entra objectidentifier)No — email is the key—
Given name / surnameNo—
Scoped affiliation → role (admin/viewer)No—
Entitlement (eduPersonEntitlement) → access gatingNo—
Attribute value transforms (strip scope, case-fold, regex)No—

2. Goals

  1. A tenant admin can map any released attribute to each Gevell profile field.
  2. 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.
  3. Affiliation/group/entitlement values can drive department and role assignment, and optionally gate access (e.g. deny affiliate / alum).
  4. Presets for InCommon/Shibboleth (eduPerson), Microsoft Entra ID, Okta, Google Workspace so most admins never type an OID.
  5. 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)

FieldFriendly nameAttribute Name
Emailmailurn:oid:0.9.2342.19200300.100.1.3
Display namedisplayNameurn:oid:2.16.840.1.113730.3.1.241
Given namegivenNameurn:oid:2.5.4.42
Surnamesnurn:oid:2.5.4.4
Common namecnurn:oid:2.5.4.3
Principal name (ePPN)eduPersonPrincipalNameurn:oid:1.3.6.1.4.1.5923.1.1.1.6
Targeted ID (legacy, NameID-valued)eduPersonTargetedIDurn:oid:1.3.6.1.4.1.5923.1.1.1.10
Unique IDeduPersonUniqueIdurn:oid:1.3.6.1.4.1.5923.1.1.1.13
Affiliation (ePA)eduPersonAffiliationurn:oid:1.3.6.1.4.1.5923.1.1.1.1
Scoped affiliation (ePSA)eduPersonScopedAffiliationurn:oid:1.3.6.1.4.1.5923.1.1.1.9
Primary affiliation (ePPA)eduPersonPrimaryAffiliationurn:oid:1.3.6.1.4.1.5923.1.1.1.5
Entitlement (ePE)eduPersonEntitlementurn:oid:1.3.6.1.4.1.5923.1.1.1.7
Org unit DNeduPersonOrgUnitDNurn:oid:1.3.6.1.4.1.5923.1.1.1.4
Org unitouurn:oid:2.5.4.11
Department numberdepartmentNumberurn:oid:2.16.840.1.113730.3.1.2
SAML subject-id (REFEDS/OASIS)subject-idurn:oasis:names:tc:SAML:attribute:subject-id
SAML pairwise-idpairwise-idurn:oasis:names:tc:SAML:attribute:pairwise-id

3.2 Microsoft Entra ID (default claim URIs)

FieldClaim
Emailhttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress
UPN / namehttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/name
Given namehttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname
Surnamehttp://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname
Display namehttp://schemas.microsoft.com/identity/claims/displayname
Stable object IDhttp://schemas.microsoft.com/identity/claims/objectidentifier
Tenant IDhttp://schemas.microsoft.com/identity/claims/tenantid
Groupshttp://schemas.microsoft.com/ws/2008/06/identity/claims/groups (object IDs unless configured for names)
App roleshttp://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_map stays supported and is expressed internally as department rules.
  • Role rules may only grant viewer / manager / admin at org or department scope — never owner (customer super-admin stays manual), never platform profiles.role='admin'. Role grants from SSO are marked source='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 in allowed_email_domains (or an explicit allowed_scopes list) 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:

ColumnNotes
tenant_id UUID FK tenants
subject TEXTValue of the configured subject attribute (case-preserved; pairwise-id/subject-id are case-insensitive per spec — normalize to lowercase for those)
subject_source TEXTWhich 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:

  1. 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).
  2. Not found → existing email path (verified-domain gate + cross-tenant guard, unchanged), then insert the identity row. This is the one-time “link” step.
  3. If a subject row exists for a different user than the email match → deny subject_email_conflict and 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) → MappedIdentity and evaluateRules(mapped, rules) → { deny?, departmentSlug?, roles[] }. Pure so it is exhaustively unit-testable (same pattern as profileToIdentity).
  • 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 by extractAssertionId).
  • Multi-valued handling is explicit per field: scalar fields take the first non-empty value and record a warning when >1 value was released.
  • profileToIdentity becomes a thin adapter so legacy configs (no attribute_mapping) behave byte-for-byte as today.
  • SamlIdentity (packages/shared/src/types.ts) gains subject, 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 putSchema with attributeMapping, 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 in departments for the tenant (validated server-side, batched in one in() 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-assertion returns 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=true requires attributeMapping.subject.source.
  • Every config change writes a tenant audit event (see tenant-audit-log-plan.md, event sso.config_updated with 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 datalist of 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 via aria-describedby and summarized at the top with focus moved to the summary on submit (3.3.1/3.3.3); 44×44 px targets; 3px #046307 focus 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_domains on first link.
  • Role rules cannot grant owner or 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() in workers/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

SuiteNew 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.tssubject-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.tsend-to-end signed assertion carrying eduPerson attrs
.../routes/tenant-sso-admin.test.tsschema validation, unknown department slug rejected, viewer role forbidden, debug-capture expiry, test-mapping dry run
apps/web vitestAttributeRulesEditor move up/down + announcements
Playwright a11ynew 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

  1. 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).
  2. Feature flag SSO_ATTRIBUTE_MAPPING_V2 (per-tenant via tenant_sso_config.metadata for pilot). Legacy tenants with empty attribute_mapping use the old path.
  3. Pilot with the first live SSO tenant (UTHealth runbook, sso-uth-runbook.md): enable debug capture, confirm released attributes, set subject, then require_subject=true.
  4. Backfill happens lazily on next login; monitor subject_email_conflict denials.
  5. Update customer docs: docs/user/src/content/docs/sso/ (IdP setup guide attribute table) and docs/admin/access/sso-status.md intake checklist.

8. Effort estimate

Work itemDays
Migration + shared types0.5
Mapping/rules resolver (pure) incl. ePTID/scope handling2.0
Stable-identifier binding + provisioning changes + role provenance2.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 support1.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.