Skip to main content

IdP group mapping

Both directory paths already carry the caller's full group list — memberOf for LDAP, the configured roles claim for OIDC — and until v0.5.0 everything but a single "is this an admin" bit was discarded. Group mapping is what consumes the rest: a mapping binds one claim value to an in-app group, to a role grant, or to both.

Added by migration v378_idp_group_mapping.

Admin panel covers LDAP and OIDC

Settings → Authentication → Group mappings (GroupMappingSettings) lets a super_admin create, edit, delete and dry-run-test LDAP and OIDC mappings without touching the API. It offers only ldap/oidc as the source, so a proxy-sourced mapping still has to be created through the API below — everything else on this page (the login-time and sweep-time application, the role cap, the manual-membership protection) is live regardless of how a row was created.

What a mapping is

FieldMeaning
sourceldap, oidc or proxy — which directory's/assertion's claims this mapping matches
claim_valueThe group DN or role/group name the provider asserts (max 1024 chars)
group_uuidAn existing in-app UserGroup to place the user in (optional)
grants_roleuser or admin (optional)
descriptionFree text (optional)

A mapping must grant something — a group, a role, or both. That is checked in the Pydantic schema, in the service, and by a database CHECK constraint, because each layer is reachable without the others.

super_admin is unreachable from any identity provider

grants_role is capped at admin. This is enforced three times over: a regex on the wire contract, assert_grantable_role() before anything is persisted, and the ck_group_mapping_role_capped CHECK constraint on the table.

A super_admin account is also never demoted by directory reconciliation, in either direction. It is the break-glass account for the directory that might be the thing that is broken.

Claim comparison

  • LDAP distinguished names are compared case-insensitively, and a partial unique index (uq_group_mapping_ldap_claim_ci) keeps the table from holding two mappings that differ only in case.
  • OIDC and proxy role/group strings are opaque, case-sensitive identifiers and are compared verbatim. Folding them would silently merge Legal and legal.

How mappings are applied

One implementation, two callers — there is deliberately no second copy, because a login-only version would never revoke and a sweep-only version would leave a freshly-promoted user waiting a day for their groups.

CallerWhenSources
LoginEvery LDAP, OIDC and trusted-header (proxy) sign-inldap, oidc, proxy
Directory syncOn the configured scheduleldap only

A proxy sign-in only reconciles membership when the proxy actually forwards a groups header (PROXY_GROUPS_HEADER) — an absent header means "I do not manage your groups" and skips reconciliation entirely, while an empty one reconciles to empty. See Trusted-header setup.

For each pass:

  1. Resolve the user's asserted claim list against the mappings for that source.
  2. Add membership in every mapped group the user is not already in, marked source = <directory>.
  3. Remove every directory-sourced membership whose group is no longer in the resolved set — including when the mapping itself was deleted.
  4. Apply the role: promote to admin if any matched mapping grants it (OR-ed with the legacy ldap_admin_users / ldap_admin_groups / oidc_admin_role signal), demote adminuser if nothing does.

A privilege change revokes that user's sessions and is audited as an ADMIN_ROLE_CHANGE whose actor is the directory (idp_login or directory_sync) rather than a person.

Hand-added memberships are untouchable

Only rows whose source is a directory are ever removed. A manual membership survives every pass, is never removed, and is never converted — a mapping that would duplicate one leaves it manual. If an admin put someone in a group by hand, that decision outlives the directory.

user_group_member.source defaults to manual, so the default is the backfill: every membership that existed before v376 stays hand-managed.

Upgrading changes nothing on its own

With no mappings configured, resolve_grants returns an empty result and the pass is a no-op: no membership changes, and the legacy admin signal alone decides admin, exactly as before.

OIDC is login-time only

There is no provider-neutral "list all users" primitive for OIDC, so there is no periodic OIDC sweep. An OIDC user's groups and role are reconciled when they sign in. LDAP gets both.

API

All routes are super_admin and live under /api/admin/group-mappings.

MethodPathPurpose
GET/api/admin/group-mappingsList, optionally filtered by ?source=
POST/api/admin/group-mappingsCreate
PUT/api/admin/group-mappings/{uuid}Update (all fields optional)
DELETE/api/admin/group-mappings/{uuid}Delete
POST/api/admin/group-mappings/testDry-run a resolution — writes nothing

Create:

curl -X POST https://yourdomain.com/api/admin/group-mappings \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $CSRF" --cookie "$COOKIES" \
-d '{
"source": "oidc",
"claim_value": "opentranscribe-admins",
"grants_role": "admin",
"description": "Platform administrators from the IdP"
}'
curl -X POST https://yourdomain.com/api/admin/group-mappings \
-H 'Content-Type: application/json' \
-H "X-CSRF-Token: $CSRF" --cookie "$COOKIES" \
-d '{
"source": "ldap",
"claim_value": "CN=Legal-Team,OU=Groups,DC=example,DC=com",
"group_uuid": "019ec90a-3f41-7aaa-8000-0000000000a1"
}'

Testing a mapping before you rely on it

POST /api/admin/group-mappings/test resolves a claim list — or, for LDAP, a real directory account — and reports what would happen. Nothing is written.

# By claim values (works for both sources)
-d '{"source": "oidc", "claim_values": ["engineering", "opentranscribe-admins"]}'

# By directory account (LDAP only)
-d '{"source": "ldap", "username": "john.doe"}'

The response carries matched_claims, unmatched_claims, the resolved groups, the mapped grants_role, whether the legacy admin signal fired (legacy_admin), and the effective_role the two combine to. unmatched_claims is usually what you want: it is the list of things the provider asserted that no mapping recognised — typically a typo, or the case-sensitivity difference between the two sources.

Looking a user up by name is LDAP-only. An OIDC provider asserts group membership inside a token issued to that user, and there is no provider-neutral way to look it up for somebody else; paste the values the provider emits instead.