ADR-015: SSO provisioning and IdP group mapping
Source of truth:
docs/adr/015-sso-provisioning-and-idp-group-mapping.md(synced at build time — edit the original, not this copy). Status: Accepted (2026-09-26)
Amended by ADR-016: identity owns the team directory. Groups still never become teams.
Decision owner: Danny
Amends: ADR-008’s grant model (grants can now come from an IdP group) and the SCIM Groups denial recorded in conformance/identity-application-v1.json and apps/identity/src/contracts/application-contract.ts.
Context
Section titled “Context”Veltro identity could sign people in with OIDC or SAML only when an administrator had already created their account. A first OIDC login from someone without an account ended on the “Signed out” card with no explanation, and there was no setting to create accounts on first login or to give them product access from their IdP groups. Every new analyst needed a manual account and manual grants in each product.
SCIM had two problems. The console asked for a SCIM base URL, which Veltro already knows from VELTRO_IDENTITY_PUBLIC_ORIGIN, and it offered to rotate a bearer token before one existed. Worse, /api/scim/v2/Groups was denied. PocketID treats a non-200 response from GET /Groups as a failed sync and stops, so no user ever arrived from PocketID either. Other IdPs push groups too, and they log the same failure even when they carry on.
VectorFlow’s standalone build already had JIT accounts and a group mapping table (src/auth.ts, src/server/services/group-mappings.ts, src/app/api/scim/v2/Groups). This port keeps its behaviour where it fits the fixed realm and drops its Organization claims.
Decision
Section titled “Decision”Each provider gets a policy, stored in veltro.sso_provider_policy:
- OIDC: create accounts on first login, trust an unverified
emailclaim, an extra scope to request groups (PocketID and Keycloak needgroups), and the claim to read them from. - SAML: create accounts on first login, and the attribute that carries groups.
Creating accounts is off by default. With it off, a person without an account gets a sign-in error that says so, and the only write is the refusal audit event from migration 019. With it on, authority_sso_sign_in creates a member account, records it in veltro.sso_provisioned_users, stores the groups from that login in veltro.user_sso_groups, and reconciles grants. An unverified email is refused unless the provider trusts it, because Veltro adopts an existing account by email (ADR-008, migrations 017 and 019) and an unverified claim would let an IdP user take over that account.
Administrators map an IdP group name to one product role in veltro.idp_group_mappings, and the role applies across the whole product. Teams live inside each product and identity only knows their raw ids, so team access stays a per-user grant on the Users page. The table’s team columns stay for existing rows and schema stability, but since 2026-09-26 the API no longer accepts them. A person’s groups are the union of the groups from their last SSO login and their SCIM group memberships. Reconciliation follows these rules:
- Grants created from mappings are recorded in
veltro.idp_owned_grants. Only those grants are ever changed or removed by a login, a SCIM push or a mapping edit. - If a person is in several mapped groups for the same product and scope, the highest role wins.
- A grant an administrator made by hand keeps its slot. A mapping never overwrites or removes it.
- When an administrator edits a mapped grant, it becomes a manual grant (the
application_grants_manual_on_edittrigger releases ownership) and stays as edited.
SCIM Groups are now supported, for role mapping only. Groups don’t become teams, and a group push can’t grant anything without a mapping. The SCIM endpoint shown in the console comes from the public origin. The bearer token is minted the first time SCIM is enabled and changes only when an administrator rotates it.
SCIM links to an existing local account by email but doesn’t manage it: managed stays false, so SCIM can’t rename, re-address or disable an account an administrator created. The database enforces this, not just the HTTP handler: migration 023 replaces authority_scim_update_user so a direct call on an unmanaged account may only change its external id. An account that SSO created becomes SCIM-managed when SCIM links it.
GET /Users lists only accounts SCIM created or linked. PocketID deletes every remote user it doesn’t recognise, so listing the local admin made each sync try to delete it and report failure. When the IdP removes a linked local account, Veltro drops the link and that account’s SCIM group memberships (and with them any grants those groups mapped), keeps the account, and answers 204.
Accounts that SSO or SCIM created have no local password. Sign-in and session checks resolve them by id without one, and every password path (password login, forced reset, password change, TOTP setup) refuses them.
The OIDC issuer has to be an https URL when it’s saved. Login already refused anything else, so accepting an http issuer only moved the failure to the first person who tried to sign in.
Consequences
Section titled “Consequences”Every write goes through SECURITY DEFINER functions owned by the migration role. The runtime role can’t insert into any of the new tables, and apps/identity/db/verify-postgres.sh checks that along with a rolled-back trace of the rules above.
The new state lives in side tables, not new columns on identity_settings or application_grants, because the schema fingerprint pins column positions and a downgrade has to reproduce migration 022’s fingerprint exactly. scim_base_url remains as an always-NULL column for the same reason.
Migration 023 refuses to downgrade while SCIM is enabled or while any IdP-owned grant exists, since 022 can’t represent either.
An IdP that sends a very large group list is cut to IDP_GROUP_LIMIT names of at most IDP_GROUP_NAME_MAX characters each. Only mapped names affect access, so the cut can’t grant anything.
PocketID sends a group’s friendly name over SCIM (“SOC Analysts”) and its short name in the OIDC groups claim (“soc”), so one group can reach Veltro under two names. Identity settings lists every name it has seen as a known group; map the one your sign-in path delivers, or both.