IdP → Organization Mapping
This guide shows how to drive Hegemony organization membership from an identity provider, so that logging in with the right group automatically grants the right role in the right org — no manual invites. It supports Keycloak-local users and any brokered upstream IdP (Microsoft Entra ID / Azure AD, Okta, Google, ADFS, another Keycloak), including several IdPs at once.
For the underlying tenancy model, see Multi-Tenancy; for the auth layer, see Authentication & Authorization.
The model in one picture
Hegemony follows the industry-standard federated-identity pattern used by Grafana, GitLab, and Vault: the application owns the org model; the IdP is only an authentication source.
Azure AD ┐
Okta ┼─▶ Keycloak (broker, single realm) ──JWT with `groups`──▶ Hegemony
Local ┘ maps upstream groups → KC groups maps `groups` → org role- Keycloak is the single trust anchor. Every user — local or brokered from an upstream IdP — ends up as a Keycloak user and receives a Keycloak-signed JWT. Hegemony validates exactly one issuer.
- The JWT carries a
groupsclaim (a list of group paths/ids). - Hegemony stores IdP mappings per org: "a token whose
groupsclaim containsXgrants roleRin this org." - On each login, Hegemony reconciles the user's memberships from their claims (JIT provisioning). The app database stays the source of truth for who is in which org.
Because membership is derived from a claim value — not from which upstream IdP issued it — adding a second or third IdP needs zero Hegemony changes: broker it in Keycloak, make sure its users land in the right groups, done.
What happens on login
On the first authenticated request of a session, the RBAC middleware (apps/api/auth/rbac_middleware.py) runs, best-effort and never fatal:
- Upsert the local
usersrow bysub. - If
HEGEMONY_ORG_IDP_SYNC=true, callreconcile_idp_memberships()(apps/api/services/org_provisioning.py):- Read the configured claim (default
groups) from the token. - Match every active IdP mapping whose
valueis present in the claim; collect the desired{org → strongest role}. - Add / upgrade
idp-sourced memberships to match. - Never touch
manual-sourced memberships (an explicit grant in the UI/API always wins over the IdP). - In authoritative mode (default), remove
idp-sourced memberships the claims no longer justify.
- Read the configured claim (default
- Otherwise (or additionally, as a fallback for users with no memberships), the default-org auto-join applies (see Multi-Tenancy → provisioning).
Each membership row records a source (manual or idp) so the two systems never fight. The Org Members admin page shows idp members read-only and points you to IdP Mappings to manage them.
Settings
| Setting | Env var | Default | Purpose |
|---|---|---|---|
org_idp_sync | HEGEMONY_ORG_IDP_SYNC | false | Master switch: derive org membership from IdP group claims on login |
org_idp_group_claim | HEGEMONY_ORG_IDP_GROUP_CLAIM | groups | JWT claim to read group values from |
org_idp_sync_authoritative | HEGEMONY_ORG_IDP_SYNC_AUTHORITATIVE | true | When true, revoke idp memberships the claims no longer grant (mirror). When false, only ever add (additive) |
Recommended rollout: leave org_idp_sync=false while you author mappings, then flip it on. Start additive (authoritative=false) to observe, then switch to authoritative once mappings are trusted so leaving a group actually removes access.
manual grants are always preserved regardless of mode — authoritative sync only reconciles rows it created.
Step 1 — Keycloak must emit the group claim
Hegemony reads groups from the access token. The bundled demo realm already does this; for your own realm add a Group Membership mapper to the client scope the hegemony-ui / hegemony-automation clients use (usually a dedicated roles/groups scope or the client's dedicated mappers):
- Mapper type: Group Membership (
oidc-group-membership-mapper) - Token Claim Name:
groups - Full group path:
ON→ values look like/NetOps,/Admins(turnOFFto emit bare names likeNetOps— then use bare names as the mapping value) - Add to ID token / Access token / userinfo:
ON
The shared application realm mapper (deploy/compose/keycloak/realm-export.json):
{
"name": "groups",
"protocol": "openid-connect",
"protocolMapper": "oidc-group-membership-mapper",
"config": {
"full.path": "true",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "groups",
"userinfo.token.claim": "true"
}
}Verify the claim is present by decoding a user's access token (jwt.io, or Authenticate → inspect in the browser dev tools) and confirming a "groups": ["/NetOps", ...] array.
Step 2 — Create IdP mappings in Hegemony
In the UI: Settings → Organizations → (row) → IdP Mappings → Add Mapping. Each mapping is claim + value → role:
| Field | Example | Notes |
|---|---|---|
| Token claim | groups | Usually groups; must match org_idp_group_claim. Immutable after creation |
| Claim value | /NetOps | The exact value your IdP emits (full path, bare name, or an Azure AD group object id). Immutable |
| Role | operator | The org role granted. When a user matches several mappings for one org, the strongest role wins |
| Active | on | Disabled mappings are ignored during reconciliation |
Equivalent REST call (org admin or platform admin):
curl -X POST "$HEGEMONY_URL/api/orgs/$ORG/idp-mappings" \
-H "Authorization: Bearer $TOKEN" \
-H "X-Org-Id: $ORG" \
-H 'Content-Type: application/json' \
-d '{"claim": "groups", "value": "/NetOps", "role": "operator", "is_active": true}'(claim, value, org) is unique — a duplicate returns 409. List / patch / delete live under the same /api/orgs/{org_ref}/idp-mappings prefix.
Brokering external IdPs in Keycloak
The Hegemony side is identical for every IdP; only the Keycloak brokering differs. The goal is always the same: the brokered user ends up in a Keycloak group that appears in the groups claim.
Microsoft Entra ID (Azure AD) — OIDC
- In Entra ID → App registrations, register an app for Keycloak. Redirect URI:
https://<keycloak>/realms/<realm>/broker/oidc/endpoint. Add a client secret. - Under Token configuration, add the groups optional claim (emit Group ID — Entra sends group object ids, not names).
- In Keycloak → Identity providers → Add OpenID Connect v1.0:
- Discovery URL:
https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration - Client ID / secret from step 1; default scopes
openid profile email.
- Discovery URL:
- Surface the Entra groups in Keycloak's own
groupsclaim, one of:- Recommended — map into Keycloak groups. Add an IdP mapper of type Advanced Claim to Group (or Claim to Group) that matches an Entra group id in the incoming
groupsclaim and assigns a Keycloak group (e.g./NetOps). Then use/NetOpsas the Hegemony mapping value — clean, human-readable, and identical to local users. - Or pass through. Add an Attribute Importer copying the incoming
groupsclaim to a user attribute and emit it via a User Attribute mapper. Then use the raw Entra group object id as the Hegemony mapping value (opaque but works).
- Recommended — map into Keycloak groups. Add an IdP mapper of type Advanced Claim to Group (or Claim to Group) that matches an Entra group id in the incoming
Either way Hegemony only sees Keycloak's groups claim; the choice is purely whether mapping values are readable names or Entra ids.
Generic OIDC (Okta, Google, another Keycloak, …)
Same shape as Entra: add an OpenID Connect v1.0 identity provider with the vendor's discovery URL and client credentials, ensure the vendor emits a groups/roles claim, and add a Claim to Group IdP mapper so brokered users land in the matching Keycloak groups.
SAML
Add a SAML v2.0 identity provider, then a SAML Attribute to Group mapper keyed on the assertion's group/memberOf attribute. From Hegemony's perspective nothing changes — it still reads Keycloak's groups claim.
Running several IdPs at once
Because Hegemony keys on the claim value, not the issuer, multiple IdPs coexist naturally:
- Broker each IdP in the same Keycloak realm.
- Normalize every IdP's groups into a shared Keycloak group taxonomy (
/NetOps,/Auditors,/Admins, …) using per-IdP Claim to Group mappers. Employees from Entra and contractors from Okta can both land in/NetOps. - Write Hegemony IdP mappings once against that taxonomy.
If you deliberately want IdP-specific access (e.g. only Entra users become org admins), keep the values distinct (map Entra /NetOps and Okta /ext-netops to different roles/orgs).
Demo walk-through
The demo-data realm ships the groups mapper and three groups (/Admins, /NetOps, /Auditors) with demo users assigned. To see cross-org, claim-driven membership end to end:
# Bring up the demo stack (Keycloak + API + UI + OpenBao + Temporal).
# See docs/demo.md for prerequisites (plugin wheels, demo data).
task compose:demo:upThen, as the platform admin (admin / hegemony):
Settings → Organizations → Add Organization: create
netops-team("NetOps Team").Open its IdP Mappings and add:
groups=/NetOps→operatorgroups=/Admins→admin
Enable sync (additive first is fine for the demo):
bash# set on the API service, then restart it HEGEMONY_ORG_IDP_SYNC=trueLog in as
operator/hegemony. That user is in/NetOps, so on login they are auto-provisioned into NetOps Team asoperator. The Org Switcher now lists it; the membership shows source IdP on the Members page.viewer(in no group) gets no NetOps membership.
Add a second org and map /Auditors there to watch one user land in multiple orgs with different roles — all from group claims, no manual invites.
Troubleshooting
- No memberships appear. Confirm
HEGEMONY_ORG_IDP_SYNC=true, the token actually carries the claim (org_idp_group_claim, defaultgroups), and the mapping value matches the claim exactly (full path vs bare name). - Brokered user has an empty
groupsclaim. The Claim-to-Group / group mapper on the identity provider is missing or the upstream didn't send the groups claim (Entra: add the optionalgroupsclaim; large group counts fall back to the Graph "overage" — filter to app-assigned groups). - A user won't lose access after leaving a group. That is additive mode — set
HEGEMONY_ORG_IDP_SYNC_AUTHORITATIVE=truefor mirror semantics. - A manual grant is being "ignored" by sync. By design:
manualmemberships are never modified by IdP sync. Change or remove it on the Members page. - Role didn't change after editing a mapping. Reconciliation runs on the user's next login; existing sessions keep their current membership until they re-authenticate.