Directory sync (pull) — Keycloak
Have Cushy read your identity provider on a schedule so users created, disabled or moved between groups upstream appear in your organization without signing in first — the answer for Keycloak, which has no outbound SCIM client.
There are three ways a user can reach Cushy from your identity provider, and they are not interchangeable. OIDC and SAML provision a person at sign-in only — create an account in your IdP and it stays invisible in Cushy until that person personally signs in. SCIM 2.0 fixes that by having the IdP push changes. Directory sync is the third: Cushy pulls your directory on a schedule.
Keycloak 26 ships a SCIM *server* (inbound, preview); it has no built-in outbound SCIM client. So a Keycloak tenant cannot push users to Cushy without a third-party extension. Directory sync reads Keycloak's Admin API instead, using a read-only service account. If your IdP is Okta, Entra ID or Google Workspace, use SCIM push instead — all three ship a real SCIM client and pushing is immediate rather than polled.
Which channel should I use?
| Identity provider | Recommended channel | What you configure |
|---|---|---|
| Keycloak | Directory sync (pull) — this page | A service-account client with the realm-management → view-users role. |
| Okta | SCIM 2.0 push | Okta's SCIM provisioning app pointed at /api/scim/v2 with a Cushy bearer token. |
| Microsoft Entra ID | SCIM 2.0 push | Entra's enterprise-app provisioning pointed at /api/scim/v2. |
| Google Workspace | SCIM 2.0 push | A supported SCIM connector pointed at /api/scim/v2. |
| Any IdP, sign-in only | OIDC or SAML JIT | Provisions each person the first time they sign in — no directory sync at all. |
Okta, Entra ID and Google Workspace appear in the provider list but are not implemented as pull connectors, and Cushy refuses to save a configuration for them rather than storing one that silently never runs. That is deliberate: those three can push over SCIM, which is better than polling.
Step 1 — create the Keycloak service-account client
In the Keycloak admin console, inside the realm you want Cushy to read:
- Clients → Create client → Client type
OpenID Connect, Client ID e.g.cushy-directory-sync. - Capability config: turn Client authentication ON (this makes it a confidential client) and enable Service accounts roles. Turn OFF Standard flow and Direct access grants — this client never logs a human in.
- Credentials tab → copy the Client secret.
- Service accounts roles tab → Assign role → switch the filter to Filter by clients → grant
realm-management→ `view-users`.
In Keycloak, view-users is a composite role that already includes query-users and query-groups, which is everything this connector needs. Cushy only ever reads your directory: it never creates, edits or deletes anything in Keycloak. Do not grant manage-users or realm-admin.
Step 2 — configure it in Cushy
On the Team screen (/users), an Org Admin opens Directory sync (pull) → Configure and enters:
| Field | Value |
|---|---|
| Provider | Keycloak |
| Base URL | Your Keycloak origin, e.g. https://idp.example.com — no path, no trailing slash. |
| Realm | The realm holding your users, e.g. acme. |
| Service-account client ID | cushy-directory-sync (from step 1). |
| Client secret | The secret from the Credentials tab. Encrypted at rest (AES-256-GCM) and never returned by any API. |
| Default role for new users | The console role a pulled user gets when none of their groups is mapped. |
| Deactivate users removed upstream | On by default — see the deactivation rules below. |
Press Save, then Sync now. The card reports what changed: created, reactivated, deactivated, role changed and skipped counts, plus the time of the last run.
What a sync does
- Creates a Cushy user for every enabled directory account that has an email address and is not already a member. They can sign in immediately through SSO — no password is ever set.
- Links group membership: every Keycloak group a user belongs to is recorded, and the same group→role mappings that SAML and SCIM use resolve their console role (highest-privilege mapped group wins).
- Reactivates a user who returns upstream, keeping the same account.
- Deactivates a user removed from the directory or disabled in it — their sessions are revoked immediately and they can no longer sign in. This is never a delete: the account remains and
activeflips back if they return. - Skips any directory entry with no email address. A Cushy identity *is* an email address, so provisioning one without it would create an account nobody could sign in to.
The organization Owner and any platform admin are never deactivated and never role-changed by the directory — an org must not be able to lock itself out because someone left a group upstream. An existing user's role changes only when they are in a group you have actually mapped, so a first sync never rewrites roles you assigned by hand. And if the directory is larger than one pass can read, the pass is marked truncated and performs no deactivation at all — a partial view is never treated as a deletion.
When it runs
- Sync now on the Team screen — immediate, org-admin only.
- On a schedule — every 30 minutes on a self-hosted deployment, and daily via cron on the managed (Vercel) deployment. A sync is idempotent, so running it more often changes nothing.
When something is wrong, it says so
A failed pull never reports "synced 0 users" — nothing is applied, and the exact reason is stored and shown on the card:
| What you see | What it means |
|---|---|
| Keycloak rejected the service-account credential | Wrong client ID or secret, or the client doesn't have Client authentication + Service accounts roles enabled. |
| …not allowed to read users (403) | The service account is missing realm-management → view-users. The message names it. |
| Keycloak has no realm "x" at that base URL | Wrong realm name, or the base URL points at something other than Keycloak's root. |
| The identity provider is unreachable / timed out | Network, DNS or firewall between this deployment and your IdP. |
Audit trail
Every configuration change and every pass is audited in your organization's audit log: org.directory_sync_configured, org.directory_sync_enabled / _disabled / _removed, org.directory_synced (with the counts and the accounts created or deactivated) and org.directory_sync_failed. The service-account secret never appears in an audit entry, a log line or an API response.
Limitations (honest)
- Keycloak only. Okta, Entra ID and Google Workspace are declared seams that refuse rather than pretend — use SCIM push for those.
- Polled, not instant. A change upstream appears at the next scheduled pass (or when you press Sync now). SCIM push is immediate; that is the trade you make when your IdP can't push.
- Group names, not hierarchy. Keycloak groups are matched on their leaf name (the same string SAML/OIDC group claims and SCIM Groups carry), so one mapping serves all three channels. Nested-group inheritance is not expanded.
- Attributes synced: email, display name, enabled state and group membership. Nothing else is read, and nothing at all is written back to your IdP.
- One directory per organization, and a pass reads up to 2,000 users; beyond that it is marked truncated and skips deactivation.