SCIM provisioning deprecated
SCIM provisioning
The middleware can act as a SCIM 2.0 service provider (RFC 7643, RFC 7644): an identity provider or identity governance platform drives the users of a context with the SCIM connector it already ships, and nobody has to write scripts against SOAP or the provisioning API. Entra ID, Okta, authentik and the Univention Nubus provisioning client speak it.
What SCIM covers is the identity lifecycle of a context: joiners, movers and leavers, including deactivation and reactivation, groups, the resources of the address book, rooms and devices, the shared accounts of the context, deputies and the secondary mail accounts of the users, each as a resource type of its own. What it does not cover stays where it is: contexts are created over the provisioning API.
Everything SCIM writes goes through the same provisioning services as SOAP, the command line tools and the HTTP gateway, which also issues the tokens this endpoint authenticates with. Contexts themselves are provisioned separately, see Context Provisioning, and the requests of an identity provider count against the context administrator's provisioning rate limit, which is counted per context.
Before you start
- The context exists; contexts are created over the provisioning API, not over SCIM.
- The
adminrole runs with the featurescim, which is the default, see below. Consider pods of its own for it. - The update tasks have run for the context's schema; the token table comes with one.
- A route from a public hostname reaches
/scim/on the admin pods, and only that prefix. - A provisioning token of scope
SCIMhas been created for the context, see Authentication. - The delete mode and the settings for SCIM-driven contexts are decided.
- For deputies on the module
mail, the middleware has an administrative way into the mailboxes. - For JSON Web Tokens instead of provisioning tokens, the OAuth provider is set up, see JSON Web Tokens.
Enabling it
The endpoint lives in the package open-xchange-scim, which the chart ships as feature scim. The feature is disabled everywhere except on the admin role, where it is enabled by default, so the endpoint runs where the provisioning services run. The tables it needs come with open-xchange-core and are created by an update task.
Where the admin role runs is a matter of scaling. With the chart's defaultScaling, one kind of pod carries every role, http-api included, so the endpoint and the load of an initial sync share the pods that serve end users. Give admin pods of its own under scaling.nodes; scaling replaces defaultScaling as a whole, so it lists every node type:
scaling:
nodes:
default:
replicas: 3
roles:
- http-api
- sync
- businessmobility
- request-analyzer
- documents
admin:
replicas: 1
roles:
- admin
roles:
admin:
values:
features:
status:
scim: enabled # the default; set to disabled to switch the endpoint off
Reaching it
The endpoint is served on the admin pods' HTTP port under the prefix /scim/. Identity providers call from the internet and require TLS with a publicly trusted certificate, so route exactly this prefix from a public hostname, for example scim.example.com, to the admin service, and nothing else. The chart defines no Ingress for any of its services; the route is an Ingress, an Istio VirtualService or whatever the installation uses, pointed at the Service of the admin role, <release>-core-mw-admin on port 80 with the default naming. The admin pods serve the whole HTTP API on the same port; a route that forwards more than /scim/ exposes more than intended. Where the vendor publishes source addresses, restrict the route to them.
Behind the route, the middleware builds absolute URLs from X-Forwarded-Proto, X-Forwarded-Host and X-Forwarded-Prefix, so set them on the route if the public URL differs from what the pod sees.
The context is the tenant
SCIM has no notion of tenants. Here, the context is the tenant, and it sits in the base URL the identity provider is configured with:
https://scim.example.com/scim/v2/contexts/{context_id}
Everything below that base URL belongs to that context: /ServiceProviderConfig, /ResourceTypes, /Schemas, /Users, /Groups, /Resources, /SharedAccounts, /Deputies and /SecondaryAccounts. One identity-provider application per context, with a credential valid for that context. User identifiers are the numeric user identifiers of the context.
Authentication
The identity provider authenticates with a provisioning token of scope SCIM, issued for the context by the context administrator, a reseller administrator owning the context or, where MASTER_ACCOUNT_OVERRIDE permits it, the master administrator. An identity provider serving several contexts may hold one cross-context token instead, created with --contexts for all of them by the master administrator or a reseller administrator owning them all, where MASTER_ACCOUNT_OVERRIDE permits it; it is accepted on the endpoint of each of those contexts and nowhere else. The command line tools work in every installation, run on a pod of the admin role:
kubectl exec -it <admin-pod> -- /opt/open-xchange/sbin/createprovisioningtoken \
-c 1 -A oxadmin -P secret --label "Entra ID"
listprovisioningtokens shows the tokens of a context with their last use, and revokeprovisioningtoken revokes one. The SOAP service OXProvisioningTokenService offers the same three operations (create, list, revoke; WSDL at /webservices/OXProvisioningTokenService?wsdl). In a multi-site installation, run the tools, or call the SOAP service, on the site the context belongs to: unlike the site-aware provisioning tools, they do not forward a call to another site. Where the HTTP gateway is enabled (provisioningGateway.enabled, off by default), the same works over HTTP:
curl -u oxadmin:secret -H 'Content-Type: application/json' \
-d '{"label": "Entra ID", "scope": "SCIM"}' \
https://<gateway-host>/prov/v1/contexts/1/tokens
The gateway only fronts the gRPC service ProvisioningTokenService, which also answers directly on the admin pods' gRPC port (8066 by default); server reflection lets grpcurl call it without the proto files:
kubectl port-forward <admin-pod> 8066:8066
grpcurl -plaintext -H "authorization: Basic $(printf 'oxadmin:secret' | base64)" \
-d '{"context_id": 1, "create": {"label": "Entra ID", "scope": "SCIM"}}' \
localhost:8066 com.openexchange.grpc.provisioning.ProvisioningTokenService/CreateToken
The secret each of them returns is what goes into the identity provider's "secret token" field. It is shown once; revoke and re-create it to rotate. A token opens the SCIM endpoint of its context and nothing else, and requests made with it act as the context administrator.
HTTP basic credentials are accepted as well, checked the way every provisioning operation checks them: the context administrator, a reseller administrator owning the context, or the master administrator where MASTER_ACCOUNT_OVERRIDE permits it. Three rules keep this safe on an endpoint the internet can reach:
- The endpoint never grants access to a resource without a credential, whatever
CONTEXT_AUTHENTICATION_DISABLEDsays. - While the deployment has administrator authentication switched off (
CONTEXT_AUTHENTICATION_DISABLED=true, orMASTER_AUTHENTICATION_DISABLED=truetogether withMASTER_ACCOUNT_OVERRIDE=true), the provisioning services accept any password. The SCIM endpoint therefore refuses basic credentials altogether in that state; tokens keep working. - Ten failed basic authentications of a login within fifteen minutes lock that login on the node for the rest of the window, answered with
429andRetry-After.
A token-only endpoint exposes no password to guessing at all. To switch basic credentials off, set com.openexchange.scim.allowBasicAuth=false (config-cascade aware down to the context).
The discovery endpoints answer without a credential. /ServiceProviderConfig, /ResourceTypes and /Schemas describe the protocol, not the context: which resource types exist, what their attributes are and what the endpoint supports. An identity provider reads them before it has anything to authenticate with - authentik polls the service provider configuration every ten seconds and sends no credential at all - and a provider that is answered with 401 never learns which filters, which PATCH and which versions it may use. A credential that is presented is still checked, so a wrong one does not pass unnoticed. Set com.openexchange.scim.allowUnauthenticatedDiscovery=false (config-cascade aware down to the context) where even that surface is unwanted; identity providers that discover before they authenticate then work from their own defaults.
JSON Web Tokens
An identity provider that issues its own access tokens can present them as Bearer credential instead of a provisioning token, once com.openexchange.scim.allowJwt=true is set for the context and the OAuth provider is configured to validate them:
com.openexchange.oauth.provider.enabled=true
com.openexchange.oauth.provider.mode=expect_jwt
com.openexchange.oauth.provider.jwt.jwksUri=https://idp.example.com/.well-known/jwks.json
com.openexchange.oauth.provider.allowedIssuer=https://idp.example.com
com.openexchange.oauth.provider.jwt.audience=ox-scim
The scope the token has to carry is scim; if the identity provider calls it differently, map its name with com.openexchange.oauth.provider.scope.<name>=scim. Only mode expect_jwt is supported: the issuer and audience checks are part of the JWT validation and do not apply to tokens looked up through introspection.
The provider resolves context and user from claims: with the defaults, contextLookupClaim=sub with contextLookupNamePart=domain and userLookupClaim=sub with userLookupNamePart=local-part, the subject has to look like oxadmin@context1.example.com, where the domain is one of the context's login mappings and the local part the administrator's login. Identity providers that put an opaque identifier into sub need a claim of their own for this, for example preferred_username, named in both lookup properties. Note that enabling the OAuth provider on the admin pods also switches on their OAuth resource server for the rest of the HTTP API.
The token is accepted when the OAuth provider validates it, it resolves to the context in the URL (contextLookupClaim), its subject resolves to the context administrator (userLookupClaim), and its scope carries scim; a valid token for another user or without the scope is answered with 403. Signatures with the RSA and EC algorithms are accepted, a token without expiry is not; set allowedIssuer and jwt.audience, so that only tokens minted for this endpoint pass. A refused token is logged with its client (azp) and the reason, but never counted against the basic authentication lock, and 503 is answered while the provider cannot reach its key source. Discovery lists the scheme once it is enabled and a provider is around. The refusal reason within the provider (expiry, issuer, audience, an unknown login mapping) is logged by com.openexchange.oauth.provider.impl at DEBUG.
What an identity provider can do
| Request | Effect |
|---|---|
GET /Users?filter=userName eq "..." | Lookup by login name, displayName, externalId or emails.value, which Entra ID writes as emails[type eq "work"].value; terms may be combined with and. Other filters are refused with invalidFilter. |
GET /Users, GET /Users/{id} | Listing with startIndex and count (at most 200), and single reads. Only regular accounts are visible; guests and shared accounts are not. A single read also carries the access combination and the capability overrides, and the groups the user is a member of when asked for with ?attributes=groups; a listing leaves all three out. |
POST /Users | Creates the account. userName and an e-mail address are required; a userName that is an address serves as one. Everything else is derived where the provider sends nothing (see below). |
PUT /Users/{id} | Replaces the mapped attributes; attributes the document omits are cleared. |
PATCH /Users/{id} | Applies add, replace and remove operations, including paths such as emails[type eq "work"].value and the string booleans Entra ID sends for active. A path may also be a schema URN, which addresses that extension as a whole. A path to a read-only attribute such as groups is refused with mutability; read-only attributes in a path-less value are ignored, as on PUT. |
DELETE /Users/{id} | Deactivates the account by default; see the delete mode below. |
GET /Groups?filter=displayName eq "..." | Lookup by displayName, externalId or the App Suite name; terms may be combined with and. |
GET /Groups, GET /Groups/{id} | Listing with startIndex and count, and single reads. Members are rendered with value, $ref and type; a single read asked for with ?attributes=members adds their display name for groups of up to 200 members. |
POST /Groups | Creates the group. displayName is required and unique within the context; the unique name is derived from it unless the extension carries one. |
PUT /Groups/{id} | Replaces display name and members; a document without members empties the group. |
PATCH /Groups/{id} | Applies the member operations providers send: add with a list of members, remove with members[value eq "..."] or with the members named in value, replace of displayName. Adding a member twice is harmless. |
DELETE /Groups/{id} | Deletes the group. Groups are membership only, so nothing but the membership is lost. |
GET /Resources?filter=name eq "..." | Lookup by name, displayName, email or externalId; terms may be combined with and. |
GET /Resources, GET /Resources/{id}, POST, PUT, PATCH, DELETE /Resources/{id} | The rooms, devices and the like of the context, under the schema urn:ietf:params:scim:schemas:extension:openxchange:2.0:Resource: displayName and email are required, name is derived from the display name unless sent, description, available (true by default) and permissions (who may book how: value is a user or group identifier with its $ref, type User or Group, privilege one of none, ask_to_book, book_directly, delegate). Permissions omitted on a replace stay as they are, and so does name; an empty list, or removing the attribute, restores the default, which reads back as book_directly for group 0. An entity may appear once. |
GET /SharedAccounts?filter=userName eq "..." | Lookup by userName, displayName, emails.value or externalId; terms may be combined with and. |
GET /SharedAccounts, GET /SharedAccounts/{id}, POST, PUT, PATCH, DELETE /SharedAccounts/{id} | The shared accounts of the context, mailboxes and calendars several users work in, under the schema urn:ietf:params:scim:schemas:extension:openxchange:2.0:SharedAccount: the profile attributes of a user without active, password (write only, random when omitted), aliases, and permissions, one entry per user or group (value with its $ref, type User or Group, mail and calendar as permission level none, viewer, editor, author or admin, absent for no access, grantedCapabilities and deniedCapabilities such as sendAs). Only a single read carries permissions; a listing leaves them out. A PUT changes what it carries and keeps the rest, since the provisioning API cannot clear a shared account's attributes; permissions omitted on a replace stay, an empty list, or removing the attribute, removes them all. DELETE removes the account and its mailbox. |
GET /Deputies?filter=grantor.value eq "..." | Lookup by grantor.value, deputy.value, deputy.type or externalId; terms may be combined with and. The two questions deputies raise: what has this user given away, and for whom does this user act. |
GET /Deputies, GET /Deputies/{id}, POST, PUT, PATCH, DELETE /Deputies/{id} | What one user of the context may do on behalf of another, under the schema urn:ietf:params:scim:schemas:extension:openxchange:2.0:Deputy: grantor (the user delegating, required and immutable), deputy (value with its $ref and type User or Group), sendOnBehalfOf, folderMode (default, all, specific), classifiedAccess (none, confidential, private) and modules, one entry per module with its permission (none, viewer, editor, author, admin) and, for folderMode specific, the folders it covers, and the externalId of the client's own system. A PUT changes what it carries and keeps the rest; "externalId": null removes the external identifier. DELETE revokes the grant and touches nothing else. |
GET /SecondaryAccounts?filter=user.value eq "..." | Lookup by user.value, primaryAddress or externalId; terms may be combined with and. A term on the user lists that user's accounts only. |
GET /SecondaryAccounts, GET /SecondaryAccounts/{id}, POST, PUT, PATCH, DELETE /SecondaryAccounts/{id} | The further mail accounts the users of the context work in next to their own, under the schema urn:ietf:params:scim:schemas:extension:openxchange:2.0:SecondaryAccount: user (required and immutable), primaryAddress (required, unique among the accounts of its user), name (the primary address when omitted), personal, replyTo, login (required), password (write only), mail and transport with server, port, protocol, secure, startTls and, for transport, a login and password of its own, the standard folders, the spamHandler and the externalId of the client's own system. A PUT changes what it carries and keeps the rest; "externalId": null removes the external identifier. |
externalId is stored and searchable, so a provider can correlate by its own key. The context administrator appears in listings but cannot be deactivated or deleted, and neither its password nor its mail servers or password state can be set over SCIM: a token must never be able to turn itself into the administrator's password, and pointing the administrator's mail account at another host would hand that password over.
Only regular users can be group members; a member referring to a guest or an unknown user is refused with 400 invalidValue. The context's standard group, which every new account is put into, is readable but refuses changes and deletion with 403.
Request bodies are limited to one megabyte; larger documents are refused with 413.
Every read, listings included, honors excludedAttributes (RFC 7644, section 3.4.2.5): a comma-separated list of attributes to leave out, top-level, as a sub-attribute such as name.familyName, or prefixed with the schema URI; an extension's URI alone leaves out the whole extension. id, schemas and meta are always returned. Entra ID reads every group with excludedAttributes=members.
An endpoint the context does not serve, /Bulk for instance, is answered with 404 and an error document, so a client that parses every answer as SCIM finds one. Below an endpoint that exists, a path the resource does not know, /Users/1/extra, is answered by the JAX-RS runtime itself and carries no body.
An OpenAPI document describing every path, parameter and response is published alongside the other API documentation:
https://documentation.open-xchange.com/components/middleware/scim/<version>/
That page renders the description in a browser. The document itself sits next to it as scim.openapi.json and is the file to point a client generator at.
Attribute mapping
The mapping is explicit and lossy by design: only attributes with a home in the provisioning user are stored, and a read returns exactly what was stored. The /Schemas endpoint declares the supported subset.
| SCIM | Provisioning user |
|---|---|
userName | name (login) |
name.givenName, familyName, middleName, honorificPrefix, honorificSuffix | given_name, sur_name, middle_name, title, suffix |
displayName, nickName, title, userType | display_name, nickname, position, employee_type |
preferredLanguage or locale (de-DE) | language (de_DE) |
timezone, profileUrl | timezone, url |
active | mailenabled: login is refused and sessions end, data is kept |
password | password, write only |
emails of type work (or the primary one) | primary_email and email1, the mailbox address; home and other map to email2 and email3 |
phoneNumbers by type work, home, mobile, fax, pager, other | the corresponding telephone fields, one per type |
addresses by type work, home, other | street, city, state, postal code and country of the business, home and other address |
ims | instant_messenger1 and instant_messenger2 |
enterprise employeeNumber, organization, division, department, manager.displayName | number_of_employee, company, branches, department, manager_name |
A second entry of the same type in a multi-valued attribute is refused with invalidValue; aliases are never derived from emails.
Groups. displayName maps to the group's display name and members[].value to the member identifiers. The provisioning API needs a second, unique name per group, which SCIM does not know: it lives in the extension urn:ietf:params:scim:schemas:extension:openxchange:2.0:Group as name and is derived from the display name when a client does not send it: lower-cased, blanks turned into dashes, accented letters reduced to their base letter, other characters dropped (group if nothing remains), and a counter appended where the name is taken. A name sent explicitly is checked against CHECK_GROUP_UID_REGEXP like any other group name. A second group with the same displayName is refused with 409 uniqueness, so providers can correlate groups by name.
Resources. name and email are unique within the context, both refused with 409 uniqueness when taken, the address also when a user has it as mailbox address. A name sent explicitly is checked against CHECK_RES_UID_REGEXP. Permissions follow com.openexchange.resource.simplePermissionMode: in simple mode, the default, a resource is either bookable by everyone (book_directly for group 0) or managed by delegates (ask_to_book for group 0 plus delegate for the managers); none and any other combination need com.openexchange.resource.simplePermissionMode=false.
Shared accounts. A shared account is a user row the provisioning API keeps apart: it needs com.openexchange.sharedaccount.enabled, cannot log in, and uses the reversible password mechanism so the middleware can open its mailbox. userName and displayName are unique within the context, both refused with 409 uniqueness. The permissions SCIM shows and sets are the ones granted to users and groups of the account's own context; the provisioning API can also grant users of other contexts, which SCIM neither shows nor touches. Entities with the same access are set in one call. A permission naming a user or group that does not exist, or a capability the account does not know, is refused with 400 invalidValue. A PATCH path such as permissions[value eq "3"] matches by value alone, and a user and a group may share an identifier; where both hold a permission, send the whole list with replace instead. The permissions cost a lookup per account, so a listing leaves them out and a single read carries them, the way it is with a user's access combination.
Requested attributes. Attributes declared returned: request in /Schemas cost a lookup of their own and are rendered only when the attributes parameter of a single read names them: groups on users, members on groups. The version of a resource does not depend on them. The access combination and the capability overrides of a user, and the permissions of a shared account, cost a lookup each as well; a single read always carries them and they count for its version, a listing leaves them out.
That matters for an identity provider that syncs from listings: what a listing shows is the state of the account, not of its permissions. A provider that has to see them reads the account itself, which it does anyway before it writes.
Deputies. A deputy is not an account but a relation: the grantor delegates, the deputy acts. It is addressed by the identifier the provisioning API assigns when the grant is given - opaque, not a number like the other types. A client that keeps a key of its own for the grant stores it as externalId, searchable like on every other type. The grantor belongs to the grant and cannot be changed: moving a delegation to another user is a revoke and a new grant, and a document that names a different grantor is answered with 400 mutability. What a deputy may do is given per module as one of the levels the interface offers as well - none, viewer, editor, author, admin - which is what the deputy service accepts in its simple permission mode. A grant made outside that mode can hold a combination of its own; it reads as custom and cannot be written back. A deputy of another context - some modules allow one - is listed with its identifier but without a $ref, since this endpoint serves one context. Granting or revoking over SCIM sends no notification mail to the deputy; App Suite does that when a user delegates in the interface, a provisioning call does not.
The module mail asks more of the deployment than the others: the endpoint acts for the grantor without their password, so the middleware needs a way into that mailbox - a token the mail authentication passes on and Dovecot accepts, the mail master account, or DoveAdm. Without one of the three there is no administrative mail access at all, and a grant naming mail cannot be made; the other modules are unaffected. The same holds for a grantor whose mailbox the configured way cannot resolve - a user provisioned in App Suite but unknown to the mail backend.
A grant covers a pair of users and its folders once. Granting the same module to the same deputy a second time is answered with 409 uniqueness and names the folder that already carries it; change the grant that exists instead.
Secondary accounts. The provisioning API hands one account definition to many users at once, but stores it as one mail account per user, and so does SCIM: a resource is one account of one user, addressed as {user}-{account}, such as 3-2 for account 2 of user 3. That identifier stays the same when the address changes. Rolling an account out to a group is a POST per member - the identity provider knows them, and every answer stays one resource. On create, mail.source and transport.source say where an end-point comes from when the document leaves it out: none, the default, needs it spelled out, primary takes that of the user's own account, localhost the local defaults; anything else is refused rather than read as none. The provisioning API reads the passwords back in the clear; SCIM never renders them, so a password change leaves the version of the account as it is.
The App Suite user extension
Settings a directory does not model, but an operator wants to drive per user, live in the extension urn:ietf:params:scim:schemas:extension:openxchange:2.0:User. Unlike the core attributes, the extension is a set of settings rather than profile data: an attribute the document omits keeps its value on PUT, only what is sent changes. An explicit null, and a PATCH that removes the attribute, clears the lists (aliases, capabilities). The scalar settings cannot be cleared: the provisioning API has no notion of an unset server or login, a cleared server becomes localhost on the primary mail account, so such a request is refused with 400 and the message names the value to send instead.
| Attribute | Provisioning user |
|---|---|
accessCombinationName | the named module access combination from ModuleAccessDefinitions.properties, for example groupware_standard. Returned on single reads only; an unknown name is refused with 400. |
capabilities | the per-user capability overrides, as changecapabilities sees them: name grants, -name denies. A sent list becomes the stored set: names it adds are granted or denied, names it no longer carries are dropped so the configuration applies again. Returned on single reads only; a name the permission configuration forbids is refused with 400. |
aliases | the alias set. The mailbox address and the default sender address are always added, as the provisioning API insists on them; when the mailbox address changes, the old one is dropped. |
defaultSenderAddress | defaultSenderAddress; added to the aliases where missing. When the mailbox address changes, a sender that equaled it follows. Cannot be cleared; send the mailbox address. |
maxQuota | maxQuota in megabytes, -1 for unlimited |
imapLogin, imapServer, smtpServer | the per-user mail login and server URLs |
passwordExpired | whether the next login has to change the password |
driveUserFolderMode | driveUserFolderMode on create only: default, normal or none. The provisioning API does not store it, so it is never returned and a PUT or PATCH carrying it is refused with 400. An identity provider has to map it for creation only (Entra ID: "Apply this mapping: Only during object creation"), or every update fails. |
filestoreId | filestoreId: the user's own file storage, chosen on create together with a maxQuota, which the provisioning API needs to validate the storage. A user without own storage refuses a later maxQuota other than -1 with 400, unless ALLOW_CHANGING_QUOTA_IF_NO_FILESTORE_SET in AdminUser.properties is enabled; then one is assigned and the user's files are moved, as with changeuser. Afterwards the value is read-only; moving a user's files is a job of the provisioning API (movefromcontexttouserfilestore and friends), and a PUT with another value is refused with 400. Absent while the user shares the context's storage. |
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User", "urn:ietf:params:scim:schemas:extension:openxchange:2.0:User"],
"userName": "jane.doe@example.com",
"emails": [{"value": "jane.doe@example.com", "primary": true}],
"urn:ietf:params:scim:schemas:extension:openxchange:2.0:User": {
"accessCombinationName": "groupware_standard",
"capabilities": ["-portal", "boxcom"],
"aliases": ["jane@example.com"],
"maxQuota": 10240,
"driveUserFolderMode": "none"
}
}
What is synthesized on create. The provisioning API requires a password and name parts that an identity provider does not send. A user created over SCIM gets a random, unusable password unless the document carries one, so such users log in through single sign-on. Display name, given name and family name are derived from each other or from the login name where they are missing. The mailbox address is the primary work e-mail address, or the userName if that is an address.
Versions and concurrent writes
Every resource carries a version in meta.version and the ETag header, derived from its attributes rather than from a stored modification stamp, so all nodes agree on it. A client that wants to make sure a PUT, PATCH or DELETE acts on the state it read sends the version back in If-Match; a resource that changed in between is answered with 412 and nothing is written. If-None-Match on a single read yields 304. Without these headers the last writer wins, which is what identity providers expect. Two limits follow from the design: the check and the write are not one atomic step, so two writes arriving within the same few milliseconds can still both pass, and a change that does not show in the representation leaves the version as it is. A version in a listing covers what the listing shows; a single read also covers what only it carries, the access combination and the capability overrides of a user and the permissions of a shared account, so its version is the one to send in If-Match.
Delete mode
Deletion in the middleware is irreversible and takes the user's mail, files and calendar with it. An identity provider whose assignment scope is misconfigured would wipe a context's worth of data in one sync cycle, so DELETE deactivates by default. The property is config-cascade aware down to the context:
com.openexchange.scim.deleteMode=deactivate # default: keep the account and its data, refuse login
com.openexchange.scim.deleteMode=softDelete # hide and lock the account, keep its data for the retention period
com.openexchange.scim.deleteMode=delete # remove the account and its data
A deactivated account keeps its login name, so a later POST with the same userName is answered with 409 uniqueness; identity providers that look up before they create find the account and reactivate it with active=true.
softDelete is the leaver state described in Soft-deleted users: the account disappears from the address book, cannot be invited or log in, and its data is kept until the retention period has passed. A soft-deleted account is gone from the identity provider's point of view: it is not listed, a GET yields 404, and a POST with the same userName is answered with 409 uniqueness because the login name stays reserved. Group members and the permissions of resources and shared accounts do not show the account either; a PUT or PATCH that cannot see such a reference keeps it, and a document that names the account is answered with 400. Restoring it is an administrator's decision, taken through the provisioning API rather than through SCIM.
A single call can override the configured mode with the query parameter deleteMode, taking the same three values. DELETE /Users/{id}?deleteMode=delete removes the account at once even in a context configured for softDelete, and it also removes an account that is soft-deleted already, bypassing the retention period: the way to honor a request under the right to be forgotten without waiting. An unknown value is answered with 400 invalidValue.
Monitoring and audit trail
Requests are measured by the REST layer, so every endpoint appears in /metrics as appsuite_restapi_requests_seconds with the templated path, the method and the status, for example path="/scim/v2/contexts/{context_id}/Users/{id}". What that cannot show is how a caller authenticated, since a wrong password, a token for another context and a scheme switched off by configuration all end up as one more 401. That is counted separately:
appsuite_scim_authentications_total{scheme="token|basic|jwt|none",result="accepted|missing|invalid|disabled|forbidden|throttled|unavailable"}
disabled means the scheme is switched off for the context or the deployment, which is worth telling apart from a refused credential: it is a configuration change, not an attack. A login locked after repeated failures counts as throttled and is logged with WARN when the lock snaps.
Everything an identity provider changes goes through the provisioning services as the context administrator, and their log entries would therefore look like a command line call. For the duration of a request the endpoint puts its origin into the log properties, so the entries of that request carry com.openexchange.provisioning.origin, the provisioning ones included:
com.openexchange.provisioning.origin=scim token 7 as oxadmin in context 1
The credential is named, not just the scheme, which is what tells two integrations of the same context apart. A provisioning token appears by its identifier, a JSON Web Token by its client, and basic credentials by their login. One kind of entry does not carry it: the machine-readable records of extended logging drop the log properties by design, so an ExtensionLogs record still names only the administrator.
Beyond that, the endpoint writes one entry per request to the logger com.openexchange.scim.auditTrail: writes at INFO, reads at DEBUG, each with method, path, status, duration, caller and, for a create, the location of the new resource. Route it to an appender of its own where the trail is to be kept:
<logger name="com.openexchange.scim.auditTrail" level="INFO" additivity="false">
<appender-ref ref="SCIM_AUDIT" />
</logger>
Give that appender a pattern with %lmdc, as the shipped ones have, or the trail loses the client address and the tracking identifier, which is what correlates an entry with the rest of the log. This is a trail of its own, next to the AuditLogService that logins and mail use: that one is switched off by default and its vocabulary knows neither method nor path nor status. The lifecycle of a user (soft-deletion, restore, deletion, a failed purge) is recorded once more by the provisioning layer in com.openexchange.provisioning.userLifecycleAuditTrail, naming the SCIM origin, see Soft-deleted users.
What a request costs
The provisioning rate limit counts provisioning calls, not requests, and one SCIM request makes several of them. The table gives the calls of a request as they count against the context administrator, measured against a local installation over a provisioning token:
| Resource type | Listing | Single read | Create | Replace | Patch | Delete |
|---|---|---|---|---|---|---|
/Users | 3 | 3 | 5 | 5 | 8 | 2 |
/Groups | 2 | 2 | 6 | 7 | 8 | 3 |
/Resources | 2 | 2 | 5 | 4 | 6 | 2 |
/SharedAccounts | 2 | 4 | 7 | 6 | 12 | 2 |
Discovery costs nothing: /ServiceProviderConfig, /ResourceTypes and /Schemas are answered without touching the provisioning services, and so is a request whose token is refused. Basic credentials are checked against the provisioning services, so even a wrong password costs a call. A read that finds nothing still costs its lookup.
What the numbers say about pacing a synchronization:
- A page costs the same whatever it holds. A listing of one and a listing of twenty-five cost the same, which is why the attributes that would cost a lookup per entry are left to the single read.
- A
PATCHis the most expensive request. It reads the resource, writes it and reads it back for the answer, so it costs roughly as much as a read plus a replace. Sending aPUTwhere the provider knows the whole resource is cheaper than patching it. - Naming attributes costs a call.
?attributes=groupson a user and?attributes=memberson a group each add one. - The numbers are for a plain attribute change. An operation that also touches the capabilities of a user or the permissions of a shared account adds the read and the write of those.
An initial synchronization. Measured against a context of two thousand users: a page of two hundred takes about 1.2 seconds, the whole context is read in eleven pages and around ten seconds, and every page costs the same three calls. The time follows the number of entries rendered, about six milliseconds each, not the size of the context - a page of a single entry takes 85 milliseconds whether the context holds fifteen users or two thousand. Reading those users one by one instead costs around 110 milliseconds and three calls each: minutes rather than seconds, and six thousand counts against the rate limit rather than thirty. A provider that syncs from listings and reads a single user only when it has to therefore stays far below any limit worth setting.
A token or a JSON Web Token spends two further calls per request on looking up the context and its administrator, and those count under the identity of the token, not of the administrator - a /SharedAccounts listing over a token therefore costs two against the administrator and two against the token itself. Basic credentials need no such lookup but pay one call to verify the credential, which counts against the administrator like the rest.
Settings for SCIM-driven contexts
Two provisioning defaults get in the way of an identity provider and are worth reviewing:
USERNAME_CHANGEABLE=falseinAdminUser.propertiesrefuses a renameduserNamewith400mutability. Directories propagate name changes, so considertruefor contexts a provider drives.com.openexchange.user.enforceUniqueDisplayName=truerefuses a second user with the same display name with409. Two people with the same name in one directory are common; considerfalse.
Login names are checked against CHECK_USER_UID_REGEXP; a userName with characters outside it, such as spaces, is refused with 400 invalidValue.
Known limitations
These are decisions for the first release, announced where the protocol has a place for it:
- Filters are equality (
eq) terms combined withand.or,not, grouping,pr,ne,co,sw,ewand the comparisons are refused withinvalidFilter. Microsoft Entra ID, Okta and authentik correlate witheq, Entra ID addsand. attributesnames the attributes a read leaves out otherwise because they cost a lookup of their own,groupson a user and the display names of a group's members; it does not narrow a response to the attributes it names.excludedAttributesis fully supported.- No
POST /.search, no/Bulkand no sorting. The service provider configuration announces bulk operations and sorting as unsupported. - No
meta.createdormeta.lastModified: the provisioning API keeps no such timestamps per entity.meta.versionand theETagare returned on every resource.
Identity provider setup in short
- Base URL / tenant URL:
https://scim.example.com/scim/v2/contexts/{context_id} - Secret token: the token created above
- Correlation:
userNameorexternalIdfor users,displayNameorexternalIdfor groups,name,emailorexternalIdfor resources,grantor.valuetogether withdeputy.valueorexternalIdfor deputies,primaryAddresstogether withuser.valueorexternalIdfor secondary accounts; all are searchable - Deprovisioning:
active=falseon unassignment keeps data;DELETEfollows the delete mode - Resource types: directory products such as Microsoft Entra ID, Okta or authentik provision users and groups.
/Resources,/SharedAccounts,/Deputiesand/SecondaryAccountsare for clients written against App Suite, such as an identity management system or an operator's script. - Tested clients: authentik, Microsoft Entra ID and Okta against a running middleware; the request sequences Entra ID and Okta document for their SCIM clients are also replayed by the integration tests.
- User extension: a provider sends the core attributes on its own.
aliases,capabilities,maxQuotaand the other settings of the App Suite user extension arrive only through a mapping that targets the extension.
authentik
Verified with authentik 2026.8, whose SCIM provider pushes users and groups as they change:
- Create a provider of type SCIM with the base URL as URL and the provisioning token as token. Keep its default user and group mappings.
- Create an application that uses the provider. Changes are pushed right away; a full sync runs on authentik's schedule.
authentik reads /ServiceProviderConfig without a credential and caches it for an hour; keep com.openexchange.scim.allowUnauthenticatedDiscovery at true, so that it learns what the endpoint supports.
Scope. authentik syncs all users and groups, its own included (akadmin, authentik Admins). The group filters of the provider (group_filters in its API) limit the groups only; users are synced regardless. Keeping a user out takes a property mapping that raises SkipObject, see authentik's provider property mappings.
The user extension. Keep the values as attributes of the authentik user, for example ox_aliases and ox_capabilities, and add a SCIM provider property mapping next to the default user mapping; authentik merges the two:
extension = {}
attributes = request.user.attributes or {}
if attributes.get("ox_aliases"):
extension["aliases"] = list(attributes["ox_aliases"])
if attributes.get("ox_capabilities"):
extension["capabilities"] = list(attributes["ox_capabilities"])
if not extension:
return {}
return {
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:openxchange:2.0:User",
],
"urn:ietf:params:scim:schemas:extension:openxchange:2.0:User": extension,
}
authentik updates a user with a full PUT; extension attributes the mapping leaves out keep their values, see The App Suite user extension.
Starting over. authentik remembers the identifiers App Suite returned. After objects were removed in App Suite behind its back, it keeps sending them: a user is created anew once its PUT answers 404, but a group update naming a removed member is refused with 400 on every attempt. Delete and re-create the provider to sync from scratch.
Microsoft Entra ID
Verified with an Entra ID tenant on the free license:
- Under Enterprise applications, create your own application of the kind "Integrate any other application you don't find in the gallery (Non-gallery)".
- Under Provisioning, choose bearer authentication with the base URL as tenant URL and the provisioning token as secret token. Test Connection looks up a user named by a random GUID and expects an empty list.
- Keep the default attribute mappings. An account without a mailbox has no
mail; App Suite then takes the user principal name, which is an address, as its e-mail address.
Entra ID updates with PATCH and sends active as the string "False" under the operation "Replace"; both are accepted. A deleted user is deactivated in the next cycle, which keeps its data.
Scope. On the free license, groups cannot be assigned to the application. Set the scope to "Sync all users and groups" to provision them; that includes every account of the tenant, the administrator's own as well.
Provision on demand evaluates only the group members selected in the dialog: a member removed in Entra ID leaves the group in App Suite once it is selected there, or with the next regular cycle.
Restarting provisioning discards what Entra ID knows about the provisioned objects. A user deleted before the restart is no longer deactivated in App Suite; do that there.
Okta
Verified with an Okta Integrator Free Plan org:
- Under Applications, create an app integration of type SWA; its login page URL does not matter. Under General, set Provisioning to SCIM.
- Under Provisioning → Integration, enter the base URL as SCIM connector base URL,
userNameas unique identifier, and HTTP header authentication with the provisioning token. Enable Push New Users, Push Profile Updates and Push Groups. Leave the imports off unless the users of the context shall become Okta users: an import lists all of them. Test Connector Configuration readsGET /Users?startIndex=1&count=2. - Under Provisioning → To App, enable Create Users, Update User Attributes and Deactivate Users.
Okta writes with a full PUT, profile changes, deactivation and group memberships alike. It sends back the App Suite user extension as it read it, so extension values stay as they are. Removing a user from the application deactivates it; Okta never deletes.
Pushed groups carry only members that belong to the Okta group and are assigned to the application. A group pushed before it has such members arrives empty, and is filled on the next push.