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, and the shared accounts of the context, each as a resource type of its own. What it does not cover stays where it is: contexts are created over the provisioning API, and deputies remain OX-specific.

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.

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 and nowhere else. The tables it needs come with open-xchange-core and are created by an update task.

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 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 and /Resources. 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 over the provisioning API (see Provisioning over HTTP):

curl -u oxadmin:secret -H 'Content-Type: application/json' \
     -d '{"label": "Entra ID", "scope": "SCIM"}' \
     https://<host>/prov/v1/contexts/1/tokens

The secret in the response 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 without a credential, whatever CONTEXT_AUTHENTICATION_DISABLED says.
  • While the deployment has administrator authentication switched off (CONTEXT_AUTHENTICATION_DISABLED=true, or MASTER_AUTHENTICATION_DISABLED=true together with MASTER_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 429 and Retry-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).

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

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.

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. Every listing entry costs a permission lookup, which is fine for the handful of shared accounts a context has.

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 cost a lookup each as well; a single read always carries them and they count for its version, a listing leaves them out.

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 gets one assigned automatically when a maxQuota is set later, 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. The version of a user in a listing covers what the listing shows; a single read also covers the access combination and the capability overrides, 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.

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.

Settings for SCIM-driven contexts

Two provisioning defaults get in the way of an identity provider and are worth reviewing:

  • USERNAME_CHANGEABLE=false in AdminUser.properties refuses a renamed userName with 400 mutability. Directories propagate name changes, so consider true for contexts a provider drives.
  • com.openexchange.user.enforceUniqueDisplayName=true refuses a second user with the same display name with 409. Two people with the same name in one directory are common; consider false.

Login names are checked against CHECK_USER_UID_REGEXP; a userName with characters outside it, such as spaces, is refused with 400 invalidValue.

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: userName or externalId for users, displayName or externalId for groups, name, email or externalId for resources; all are searchable
  • Deprovisioning: active=false on unassignment keeps data; DELETE follows the delete mode