Back to marketplace
216

SCIM

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.

Goal

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.

Two orthogonal concepts (recap from core)

  • AuthClient — how the user proves who they are (password, SAML, OAuth, …).
  • UserSource — who owns the user record (created it, manages its attributes, controls deletion).

A user has one UserSource and one or more AuthClients. SCIM is a UserSource only — login happens via whatever AuthClient the installation configures.

Multi-tenant model

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:

ConcernWhere it lives
Tenant configRow in scim_tenant
Bearer tokenHashed in scim_tenant.bearer_token_hash
URL prefix<base>/scim/v2/<tenant-id>/...
UserSource idscim_<tenant-id> (registered dynamically)
user.user_sourceSet to scim_<tenant-id> on provisioning

Dynamic UserSource registration

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.

Storage

TablePurpose
scim_tenantOne row per IdP connection (id, name, enabled, token hash, attribute_mapping, auth_client_ids, log_requests, auto_adopt_users).
scim_groupMaps a HumHub group to a tenant, holds the group's externalId. One row per (tenant, group).
scim_user_external_idPer-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).

Resource identifiers

  • User — the SCIM 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.
  • Group — the SCIM 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.

Endpoint surface

All paths are prefixed with <base>/scim/v2/<tenant-id>/; routing lives in config.php (verb-routed onto separate controller actions).

MethodURLNotes
GET/Usersfilter, count, startIndex
POST/Userscreate (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/Groupsfilter, count, startIndex
POST/Groupscreate
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/Bulkbatched operations
GET/ServiceProviderConfigcapabilities
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.

Attribute mapping

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:

  • Bare core fields (firstname, lastname, title, externalId, languagepreferredLanguage, time_zonetimezone) resolve to standard SCIM paths; other HumHub fields go under the extension urn:humhub:scim:2.0:User; field=>scimPath targets an explicit path.
  • The list is literal (no merge over a default). userName, primary email, active and password have fixed handling and are not remappable.
  • PROVIDER_PRESETS in AttributeMapping seeds the mapping at tenant creation.
  • The 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-ITit where possible) instead of failing the whole update.

Filter grammar

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 UnsupportedFilterException400 with scimType=invalidFilter.

Optimistic concurrency (ETag)

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.

Bulk

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.

Adopting existing users (migration)

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.

Groups

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).

Key files

PathRole
Module.php / Events.php / config.phpModule shell, event wiring, URL routing
controllers/Users, Groups, Bulk, Discovery, Admin, base ScimController
usersource/ScimUserSource.phpUserSourceInterface impl, one per enabled tenant
components/AttributeMapper.php / AttributeMapping.phpMapping engine + presets
components/FilterParser.php / FilterQueryBuilder.phpFilter AST + query builder
components/PatchProcessor.phpSCIM PATCH op application
components/BulkProcessor.php/Bulk dispatch
components/ResourceVersion.phpETag content-hash
components/BearerAuthFilter.php / RequestLogRedactor.phpAuth + request logging
models/ScimTenant, ScimGroup, ScimUserExternalId
commands/ScimController.phpscim/create-tenant, scim/delete-users

Development

  • PHP 8.2+, strict types, Yii2 patterns. @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
    

Testing

Codeception suites under tests/codeception/:

  • unit — pure logic (FilterParser, PatchProcessor, AttributeMapper, ScimResponse) plus DB-backed AttributeMapper tests.
  • functional — HTTP-level coverage of the endpoints against a seeded tenant (bearer auth, Users CRUD, listing/filtering, discovery).
  • Entra scenario tests — replay Microsoft's documented SCIM payloads through the mapping pipeline; fixtures under tests/codeception/_data/entra/.

CI runs the suite plus PHP-CS-Fixer and Rector (.github/workflows/).

Out of scope

  • Outbound provisioning (HumHub as a SCIM source) — a separate later phase. This module is inbound-only.
  • Sorting on GET /Users (sortBy/sortOrder) — deliberately not implemented; advertised as unsupported.

See the issue tracker for planned enhancements and known limitations.

Module Information

Latest version release:
1.0.0 - August 17, 2026
Website:
Compatibility:
HumHub 1.19 - 1.19