SCIM 2.0 server for inbound user provisioning from external identity providers (Azure AD / Entra ID, Okta, Workday, ...).
Architecture, endpoint surface and development setup. For admin/usage instructions see ?#manual.
Expose a SCIM 2.0 inbound-provisioning server in HumHub so that external identity providers (Microsoft Entra ID, Okta, OneLogin, Google Workspace, Workday, …) can push users and groups into a HumHub installation.
The module relies on the UserSource architecture introduced in HumHub core
1.19 (see core docs/develop/user-source.md). SCIM is pure provisioning — it
never authenticates users — and was the original motivating use case for
splitting UserSource from AuthClient.
A user has one UserSource and one or more AuthClients. SCIM is a UserSource only — login happens via whatever AuthClient the installation configures.
A single HumHub installation may receive SCIM pushes from several IdPs in parallel. A tenant is a first-class runtime entity rather than PHP configuration:
| Concern | Where it lives |
|---|---|
| Tenant config | Row in scim_tenant |
| Bearer token | Hashed in scim_tenant.bearer_token_hash |
| URL prefix | <base>/scim/v2/<tenant-id>/... |
| UserSource id | scim_<tenant-id> (registered dynamically) |
user.user_source | Set to scim_<tenant-id> on provisioning |
The module hooks UserSourceCollection::EVENT_BEFORE_USER_SOURCES_SET
(Events::onUserSourceCollectionSet). On each request it loads the enabled
tenants and adds one ScimUserSource entry per tenant to the event payload. If
the scim_tenant table does not exist yet (module enabled before its migration
ran), registration is skipped silently.
| Table | Purpose |
|---|---|
scim_tenant | One row per IdP connection (id, name, enabled, token hash, attribute_mapping, auth_client_ids, log_requests, auto_adopt_users). |
scim_group | Maps a HumHub group to a tenant, holds the group's externalId. One row per (tenant, group). |
scim_user_external_id | Per-tenant SCIM externalId for a user. Composite PK (tenant_id, user_id), UNIQUE (tenant_id, external_id). Both FKs cascade on tenant delete and user hard-delete. |
group_user (core) | SCIM group membership — the standard HumHub table. |
Migrations under migrations/ build this incrementally (m260516…_init
through m260710…_add_auto_adopt_users).
id is user.guid (HumHub's stable internal id). The
IdP's own identifier is the separate externalId, stored per-tenant in
scim_user_external_id — never in user.guid.id is the HumHub group.id.externalId uniqueness is server-scoped per tenant (RFC 7643 §4.1.1): two
tenants may use the same externalId for different users.
All paths are prefixed with <base>/scim/v2/<tenant-id>/; routing lives in
config.php (verb-routed onto separate controller actions).
| Method | URL | Notes |
|---|---|---|
| GET | /Users | filter, count, startIndex |
| POST | /Users | create (or adopt — see below) |
| GET | /Users/{id} | by SCIM id (user.guid) |
| PUT | /Users/{id} | replace (Okta-style) |
| PATCH | /Users/{id} | ops (Azure-AD-style) |
| DELETE | /Users/{id} | soft-delete (STATUS_SOFT_DELETED) |
| GET | /Groups | filter, count, startIndex |
| POST | /Groups | create |
| GET | /Groups/{id} | by SCIM id (group.id) |
| PUT | /Groups/{id} | full replace |
| PATCH | /Groups/{id} | member ops (Azure-AD-style) |
| DELETE | /Groups/{id} | deprovision |
| POST | /Bulk | batched operations |
| GET | /ServiceProviderConfig | capabilities |
| GET | /ResourceTypes[/{id}] | User + Group |
| GET | /Schemas[/{id}] | core User + Group + HumHub extension |
Authentication is Authorization: Bearer <token>, enforced by
BearerAuthFilter against scim_tenant.bearer_token_hash.
SCIM DELETE on a user soft-deletes it (RFC 7644 §3.6 — "the resource is
gone"), distinct from active: false, which disables but keeps it visible.
Subsequent DELETE / GET / PATCH on a soft-deleted id return 404, and the
/Users listing no longer surfaces it.
AttributeMapping parses the per-tenant newline-separated list into a
humhubField => scimPath map; AttributeMapper applies it in both directions
(fromScim on write, toScim on read). See ?#manual
for the syntax. Highlights:
firstname, lastname, title, externalId,
language → preferredLanguage, time_zone → timezone) resolve to
standard SCIM paths; other HumHub fields go under the extension
urn:humhub:scim:2.0:User; field=>scimPath targets an explicit path.userName, primary email,
active and password have fixed handling and are not remappable.PROVIDER_PRESETS in AttributeMapping seeds the mapping at tenant creation.User /Schemas response is generated from the tenant's mapping, so an
IdP introspecting the schema sees exactly what the tenant round-trips.fromScim drops language/time_zone values HumHub cannot persist (falling
back it-IT → it where possible) instead of failing the whole update.GET /Users?filter=... accepts the full RFC 7644 §3.4.2.2 grammar:
FilterParser — hand-written tokeniser + recursive-descent parser producing a
storage-agnostic AST: comparison operators (eq ne co sw ew gt ge lt le), the
presence test pr, and/or/not, ( ... ) grouping, and value-path
expressions (attr[...], kept on the AST as valueFilter).FilterQueryBuilder — turns the AST into a HumHub User query. userName,
id, emails.value and active map to user columns; externalId maps to
an id IN (...) subquery against scim_user_external_id (tenant-scoped), and
name.givenName/name.familyName to a subquery against profile.Filters always reference the canonical SCIM User attributes — per-tenant
mapping affects serialisation only. Unknown attributes / unsupported operators
raise UnsupportedFilterException → 400 with scimType=invalidFilter.
Every serialised resource carries meta.version — a weak entity-tag that is a
content hash of the body (ResourceVersion), not a row timestamp (a group's
membership lives in group_user, which never moves the group row's
updated_at). Single-resource responses also send it as the ETag header.
Mutating requests honour If-Match (stale → 412 Precondition Failed); GET
honours If-None-Match (match → 304 Not Modified). * matches any existing
resource.
POST /Bulk (RFC 7644 §3.7) — BulkProcessor re-dispatches each operation
through the same UsersController / GroupsController actions a standalone
request hits (swaps the request body, invokes the action, reads the response
back off Yii::$app->response). Bulk semantics (validation, 409 uniqueness,
ETag stamping) are therefore identical to the non-bulk endpoints for free.
bulkId forward references are resolved in document order; a failing operation
never aborts the batch unless failOnErrors is reached. The HTTP response is
always a 200 BulkResponse.
With a tenant's off-by-default auto_adopt_users flag on, POST /Users looks
up an existing HumHub user by the incoming primary email before rejecting the
create as a duplicate. A match is adopted: user_source flips to this tenant's
SCIM source, the externalId is recorded and the mapped attribute set applied —
the IdP is authoritative from then on, and the response is a regular
201 Created. Users already owned by any SCIM tenant, soft-deleted and
pending-approval users are never adopted (adoption of a site admin is logged at
warning level). The email is the join key and must not change during the
migration window.
HumHub groups are global, so a tenant's SCIM groups are isolated through the
scim_group join table. members[].value references a user by SCIM id
(user.guid); only users provisioned by the same tenant can be added — a
member that does not resolve to one of the tenant's users is skipped and logged.
PATCH supports the Azure AD member add/remove shapes, including the
members[value eq "<id>"] removal path. Group creation/rename is strict about
displayName and externalId collisions (409 uniqueness).
| Path | Role |
|---|---|
Module.php / Events.php / config.php | Module shell, event wiring, URL routing |
controllers/ | Users, Groups, Bulk, Discovery, Admin, base ScimController |
usersource/ScimUserSource.php | UserSourceInterface impl, one per enabled tenant |
components/AttributeMapper.php / AttributeMapping.php | Mapping engine + presets |
components/FilterParser.php / FilterQueryBuilder.php | Filter AST + query builder |
components/PatchProcessor.php | SCIM PATCH op application |
components/BulkProcessor.php | /Bulk dispatch |
components/ResourceVersion.php | ETag content-hash |
components/BearerAuthFilter.php / RequestLogRedactor.php | Auth + request logging |
models/ | ScimTenant, ScimGroup, ScimUserExternalId |
commands/ScimController.php | scim/create-tenant, scim/delete-users |
@since 1.0.0 on new symbols.Coding style via humhub/module-coding-standards:
composer rector # static analysis / automated refactors
composer fixer # PHP-CS-Fixer
Codeception suites under tests/codeception/:
FilterParser, PatchProcessor, AttributeMapper,
ScimResponse) plus DB-backed AttributeMapper tests.tests/codeception/_data/entra/.CI runs the suite plus PHP-CS-Fixer and Rector
(.github/workflows/).
GET /Users (sortBy/sortOrder) — deliberately not
implemented; advertised as unsupported.See the issue tracker for planned enhancements and known limitations.