Files
inbuxa-server/docs/spec/features/scim.md
T

53 KiB

Feature spec: SCIM 2.0 provisioning

Status: draft, 2026-09-18. Feature 7 in SPEC.md §4.

Provenance

Written for the clean room (SPEC.md §3). Sources, and nothing else:

Source License Used for
This repository's surviving SCIM code: crates/scim and crates/scim-proto (manifests and stub lib.rs files) AGPL-3.0-only What exists, dependencies, where the crates are wired in
Surviving SCIM tests: tests/src/scim/conformance.rs, oidc.rs, tenant.rs AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL Observable behavior the tests assert: status codes, error types, tenancy, the OIDC interplay, advertised limits
Third-party client driver: tests/docker/scim/driver.py and Dockerfile, and tests/src/utils/containers.rs Part of this AGPL repository The lifecycle and IdP payload shapes the server must accept, and what it must return
Stalwart's registry schema: crates/registry/src/schema/*.rs and resources/schema/schema.json.gz AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL Field shapes and meanings, indexes, validation, the scimAccess permission, what upstream flags as Enterprise
AGPL code around the feature: crates/common/src/auth/{credential,authentication}.rs, crates/common/src/cache/{directory,principals}.rs, crates/jmap/src/registry/mapping/principal.rs AGPL-3.0-only OR LicenseRef-SEL, taken under the AGPL API-key bearer authentication, just-in-time directory sync, account destruction, what is not there
Strip report docs/fork/strip-reports/v0.16.22.{json,md} Ours Which SCIM files, tests and snippets were removed (names and counts only)
Stalwart documentation (website repo): docs/auth/scim/{index,configuration,endpoints,mapping,provisioning}.md, docs/auth/scim/providers/{index,entra-id,okta,keycloak}.md, docs/auth/authentication/api-key.md Unlicensed public documentation: facts used, prose not copied Endpoints, limits, attribute mapping, authority rules, deprovisioning, provider behavior
RFC 7643, RFC 7644, RFC 9865 IETF The wire contract: schemas, operations, errors, cursor pagination
Spec features/multi-tenancy.md, features/undelete.md Ours Tenancy rules (MT-) and deleted-account handling (UD-) this spec relies on

No Enterprise-only file or snippet was used. This spec was written by a fresh session that never saw Enterprise code. The removed files are known here only by the names the strip report lists. No running server was observed and no production server was contacted. Where no allowed source settles a behavior, this spec makes a Decision or lists it under "Open questions / to observe". It never fills a gap from memory of upstream code.

Identity-provider behavior (what Entra ID, Okta and the Keycloak extensions send) comes from Stalwart's public provider pages and from the payloads in driver.py. The vendors' own SCIM documentation wasn't read for this draft (see open questions).

What it is

An identity provider (Entra ID, Okta, a Keycloak extension, a script) is the system of record for people. SCIM lets it push their accounts into inbuxa-server: create the mailbox the day a person is hired, keep the name, aliases and group membership current, suspend the account when they leave, and delete it when policy says so.

inbuxa-server is the SCIM service provider only. It receives requests at /scim/v2 on its existing HTTP listeners and applies them to its own accounts. It never sends SCIM anywhere. SCIM doesn't authenticate mail users. It's meant to be deployed beside an OIDC (or LDAP or SQL) directory that does.

Upstream ships this only in its Enterprise Edition, and answers /scim/v2 with 403 Forbidden in its Community Edition. inbuxa-server ships it to everybody. There is no edition check. It's inert until an operator opens a domain to it and issues a credential.

What already exists in the fork

Surveyed 2026-09-18 at the v0.16.22 import.

Present

What State
crates/scim Cargo.toml (deps: scim-proto, common, jmap, store, registry, directory, http_proto, jmap_proto, types, utils, trc, hyper, serde, serde_json, xxhash-rust with xxh3, icu_locale; features test_mode, dev_mode, enterprise) and a src/lib.rs that is a 5-line AGPL-3.0-only header with no code
crates/scim-proto Cargo.toml (deps: serde, serde_json, hashify) and the same empty src/lib.rs
Wiring crates/http and crates/main depend on scim, and both map their enterprise feature onto scim/enterprise. Nothing calls into it. The HTTP router (crates/http/src/request.rs) has no scim path
tests/src/scim/conformance.rs (177 lines) Runs driver.py in a container: an RFC conformance checker (scim2-tester 0.2.8), a full lifecycle with scim2-client 0.7.5, and replayed Okta, Keycloak and Entra payloads
tests/src/scim/oidc.rs (332 lines) allowScimProvisioning against a Keycloak OIDC directory: no just-in-time creation, SCIM attributes survive a login, clearing the flag restores just-in-time sync
tests/src/scim/tenant.rs (362 lines) A tenant-scoped client sees and reaches only its tenant, can't provision outside its domains, and can inside them
tests/docker/scim/ Dockerfile (Python 3.12, scim2-tester, scim2-client, scim2-models 0.6.12, httpx) and driver.py (457 lines)
tests/src/utils/containers.rs ensure_scim_tester, scim_tester_exec, ensure_keycloak (with tests/docker/keycloak/stalwart-realm.json)
Registry schema x:Domain.allowScimProvisioning (bool, default false, property 932), x:UserAccount.externalId and x:GroupAccount.externalId (nullable string, property 933, search-indexed, an empty string fails validation), Permission::ScimAccess (scimAccess, 660). Stored and serialized. Nothing reads them
API keys x:ApiKey credentials on accounts; the API_… bearer token format and its validation (crates/common/src/auth/credential.rs, authentication.rs) are shared AGPL code and work today
Account destruction schedule_account_destruction and the DestroyAccount task (crates/jmap/src/registry/mapping/principal.rs)
Just-in-time sync synchronize_account and synchronize_group (crates/common/src/cache/directory.rs). They have no SCIM authority check, and the domain cache (DomainCache) doesn't carry the flag

Removed by the strip, to be rebuilt

  • All SCIM code, 33 files. crates/scim-proto/src/: attributes.rs, etag.rs, filter.rs, json.rs, lib.rs, path.rs, message/{bulk,error,list,mod,patch,search}.rs, schema/{group,mod,spc,user}.rs (16). crates/scim/src/: auth.rs, bulk.rs, context.rs, discovery.rs, error.rs, lib.rs, request.rs, groups/{get,mod,patch,set}.rs, query/{cursor,mod}.rs, users/{get,mod,patch,set}.rs (17). The file names are the only thing known about them.
  • Eight test files: tests/src/scim/{auth,bulk,discovery,groups,limits,mod,query,users}.rs. mod.rs held the shared helpers the three surviving files import (ScimTest, ScimClient, SCIM_DOMAIN, HTTP_PORT, api_key, user_body, group_body, patch_body, query, jmap_session_status) and scim_proto exported SCHEMA_USER and MESSAGE_BULK_REQUEST. The strip also removed pub mod scim; from tests/src/lib.rs, so the surviving tests aren't compiled.
  • Snippets in shared files. The report gives counts, not contents. Six were cut from crates/common/src/cache/directory.rs, the just-in-time sync that SCIM-58 changes, and four each from crates/http/src/request.rs and crates/http/src/api/mod.rs. What they held isn't known and doesn't matter: this spec says what the behavior must be.
  • A dependency not in any spec yet. Per-domain directories (x:Domain.directoryId, also flagged Enterprise) aren't rebuilt: get_directory_for_domain returns the default directory. oidc.rs binds an OIDC directory to one domain, so it needs that first (see open questions).

Data model

Unchanged from upstream, so existing data opens as it is (SPEC.md §7). Upstream flags three fields as Enterprise. In inbuxa-server they're ordinary fields, readable and writable over JMAP by anyone with the matching sysDomain* or sysAccount* permission.

Field Type Meaning
x:Domain.allowScimProvisioning boolean, default false SCIM may write accounts on this domain, and SCIM is authoritative for them: just-in-time directory sync stops creating or changing them
x:UserAccount.externalId string or null The identity provider's own identifier for the user. Search-indexed. Not unique in the index
x:GroupAccount.externalId string or null The same, for a group

Everything else SCIM touches already exists: x:UserAccount (name, domainId, emailAddress, aliases, description, locale, timeZone, memberGroupIds, memberTenantId, permissions, roles, createdAt), x:GroupAccount (name, domainId, description, memberTenantId, createdAt), x:ApiKey (description, secret, permissions, allowedIps, expiresAt, createdAt), and the permissions authenticate, scimAccess, sysAccountGet, sysAccountCreate, sysAccountUpdate, sysAccountDestroy and unlimitedRequests.

There is no "provisioned by SCIM" marker and no modification timestamp on accounts. Nothing new is stored. The ETag (SCIM-44) is computed, and cursors (SCIM-49) carry their own state.

How SCIM resources map

User (urn:ietf:params:scim:schemas:core:2.0:User) is an x:UserAccount:

SCIM attribute Account field Mutability Notes
id the account's id readOnly The same id JMAP uses
externalId externalId readWrite case-exact
userName name + domainId readWrite, required A full email address
displayName description readWrite
name.formatted description readWrite The same stored value as displayName
active the effective authenticate permission readWrite SCIM-27
emails primary address, then aliases primary readOnly, others readWrite SCIM-25
locale, preferredLanguage locale readWrite One stored value
timezone timeZone readWrite IANA name
groups memberGroupIds readOnly value, display, $ref
meta createdAt, computed version readOnly resourceType, created, location, version

Group (urn:ietf:params:scim:schemas:core:2.0:Group) is an x:GroupAccount:

SCIM attribute Account field Mutability Notes
id the group's id readOnly
externalId externalId readWrite case-exact
displayName description readWrite, required Unique among groups in scope
members the members' memberGroupIds readWrite Users only
meta createdAt, computed version readOnly Version covers membership

Required behavior

Each requirement has an ID, and tests name the IDs they check.

The endpoint

  • SCIM-1. SCIM is served under /scim/v2 on every HTTP listener, beside JMAP. Every response, errors included, has content type application/scim+json. Requests with application/scim+json or application/json bodies are accepted (RFC 7644 §3.1).
  • SCIM-2. Paths and methods are those in "Interfaces". An unknown path under /scim/v2 answers 404. A known path with the wrong method answers 405 with an Allow header. OPTIONS on any path answers 204. /Me answers 501 for every method, which RFC 7644 §3.11 allows: the caller is a service account, not a provisionable user.
  • SCIM-3. The discovery endpoints (/ServiceProviderConfig, /ResourceTypes, /Schemas, and their single-item forms) need no authentication and return no account data. They're subject to the anonymous HTTP rate limit. A filter parameter on them answers 403 (RFC 7644 §4).
  • SCIM-4. /ServiceProviderConfig is fixed: patch supported, bulk supported with maxOperations 1000 and maxPayloadSize 1048576, filter supported with maxResults 200, changePassword not supported, sort supported, etag supported, and one authentication scheme of type oauthbearertoken, marked primary. It also carries the RFC 9865 pagination object: cursor true, index true, defaultPaginationMethod index, defaultPageSize 100, maxPageSize 200, cursorTimeout 3600 (SCIM-49), and interopProfileConformant false (SCIM-33). Decision: any documentationUri points at INBUXA's own documentation, never upstream's.
  • SCIM-5. /ResourceTypes lists User (endpoint /Users) and Group (endpoint /Groups). Decision: no schemaExtensions are listed. The enterprise User extension is accepted in requests (SCIM-33) but not published, since nothing in it is stored.
  • SCIM-6. /Schemas publishes only the attributes in the mapping tables above, with RFC 7643 characteristics (mutability, returned, uniqueness, caseExact, required). password isn't published. members.type publishes User as its only canonical value. userName has uniqueness: server and caseExact: false. externalId has caseExact: true.

Authentication and the credential

  • SCIM-7. Every non-discovery request authenticates with an API key of the service principal's, sent as Authorization: Bearer API_…. Missing, malformed, unknown, expired or revoked keys answer 401. HTTP Basic answers 401 with a detail telling the caller to use a bearer token. Decision: other bearer tokens (the server's own OAuth access tokens, or an external OIDC provider's) are refused on /scim/v2 with 401, so that a person's sign-in token can't drive provisioning. A 401 carries WWW-Authenticate: Bearer (RFC 6750).
  • SCIM-8. The key is issued the way every API key is today: as an x:ApiKey credential on the service principal's account, created over JMAP (INBUXA Admin, or ihasmail's administration). The server generates the secret, returns it once at creation and stores it hashed. The account's maxApiKeys quota applies. Nothing SCIM-specific is added to issuing.
  • SCIM-9. The key's own controls apply to SCIM like any other use: allowedIps (a request from elsewhere is 401), expiresAt, and its permission mode (Inherit, Disable, Replace). Revocation is deleting the credential, or disabling or deleting the service principal. It takes effect on the next request, with no cache delay. Rotation is a second key, the identity provider updated, and the first key deleted.
  • SCIM-10. Decision: OAuth client credentials (RFC 6749 §4.4) aren't part of this feature. Every client this spec targets can send a static bearer token: Entra ID's "Secret Token", Okta's "HTTP Header" mode, and the Keycloak extensions. Adding the grant belongs with the OAuth work in contract.md, and would reuse the same permission checks.

Authorization

  • SCIM-11. Two gates, both on the effective permissions of the key (the account's, cut down by the key's mode, and by the tenant's ceiling, MT-13):
    • authenticate and scimAccess for every non-discovery request. Without either the answer is 403, and the detail names the missing one.
    • Per operation: sysAccountGet for every read and query, .search included; sysAccountCreate for POST; sysAccountUpdate for PUT, PATCH, and any membership change, including a group created with members; sysAccountDestroy for DELETE. Missing: 403, naming the permission, with nothing changed.
  • SCIM-12. A service principal in a tenant needs its tenant to allow scimAccess (MT-13, MT-14). A tenant whose ceiling lacks it can't provision, whatever its principal is granted.
  • SCIM-13. A request that would deactivate, delete or rename the service principal the request authenticated as answers 403, and changes nothing. Decision: there is no other protected class. Administrators on a SCIM-enabled domain can be deactivated or deleted by SCIM like anyone, since a departed administrator is exactly who must be cut off. Operators who want admins out of the identity provider's reach keep them on a domain that isn't SCIM-enabled.
  • SCIM-14. Authenticated HTTP rate limits apply per principal. Over the limit the answer is 429 with Retry-After. unlimitedRequests exempts the principal, as elsewhere. A /Bulk request counts as one request.

Scope: domains and tenants

  • SCIM-15. Domain authority. Every address a write touches (the userName, every alias in emails, and a new group's derived address) must be on a domain with allowScimProvisioning true, in the caller's scope. Otherwise the whole request is refused with 400 invalidValue and a detail naming the domain. Domains are created by administrators, never by SCIM. A userName that isn't an email address at all is 400 invalidValue, and its detail contains "is not a valid email address".
  • SCIM-16. Visibility. A principal's SCIM scope is every account of the resource's type that it could reach over JMAP (MT-1, MT-2): all of them for a server-level principal, its own tenant's for a tenant principal. Decision: SCIM further limits reads, queries and writes to accounts whose domain has allowScimProvisioning true. An identity provider has no business listing mailboxes it may not manage. Accounts in scope include ones created by hand (SCIM-36): the service principal and any other account on the domain appear in /Users.
  • SCIM-17. Tenant boundaries don't leak. A resource outside the caller's tenant answers 404 to GET, PUT, PATCH and DELETE, the same as a resource that doesn't exist, and the same inside /Bulk operations. Filters never match it. An address on a domain in another tenant (or, for a tenant principal, on a server-level domain) answers 404, with a detail naming the domain. A domain in the caller's tenant that isn't SCIM-enabled answers 400 invalidValue (SCIM-15). Error detail never contains another tenant's data.
  • SCIM-18. Where new accounts land. A new user is in its domain's tenant (MT-7). A new group is on the service principal's own domain, because a SCIM Group has no address to take a domain from. So a principal whose own domain isn't SCIM-enabled can manage users but can't create groups: 400 invalidValue, naming its domain.
  • SCIM-19. Membership stays inside a tenant. Adding a member in a different tenant from the group (no tenant counts as different) is 400 invalidValue (MT-3).
  • SCIM-20. Tenant quotas. A create that would pass a tenant's maxAccounts or maxGroups is refused and emits limit.tenant-quota, as MT-17 requires. Decision: SCIM reports it as 403 with a detail naming the limit. RFC 7644 has no quota error type, and 403 is its code for an operation the caller may not perform.

User resources

  • SCIM-21. id is the account's registry id as a string, the same value JMAP uses. It never changes. An identity provider connected to an upstream Enterprise server keeps working after cutover without matching accounts again.
  • SCIM-22. userName is the account's full address, name@domain. The server splits it into name and domainId. It's compared case-insensitively. Decision: it's stored and returned lowercased. A userName already used by any account, alias or list is 409 uniqueness, naming the address.
  • SCIM-23. Changing userName (by PUT or PATCH) moves the account to the new address. The new domain must pass SCIM-15 and be in the account's tenant (MT-7), or the change is refused. Decision: the old address is released, not kept as an alias. An identity provider that wants it kept sends it in emails.
  • SCIM-24. The display name is stored in description. Precedence on write: displayName, then name.formatted, then name.givenName and name.familyName joined by one space (either may be missing). It's returned under both displayName and name.formatted, never as structured parts. With none of them sent, there is no display name, and neither attribute is returned.
  • SCIM-25. Emails. The primary address always comes first in emails, with primary: true and type: "work". It's derived from userName, and its sub-attributes are read-only: trying to remove it, retype it or make it non-primary through emails is 400 mutability, with a detail pointing at userName. Every other entry is an alias, returned after the primary in stored order with primary: false. Decision: aliases are returned without a type, and any type sent for them is dropped. Entries that repeat the primary, or each other, are skipped. Each alias must pass SCIM-15 and be in the account's tenant. An alias another account already holds is 409 uniqueness. PUT replaces the alias set. PATCH adds or removes aliases one by one, so an address dropped upstream is dropped here.
  • SCIM-26. Locale and time zone. locale and preferredLanguage are one stored value. If both are sent, locale wins. Both are returned, with the same value. SCIM's hyphen form (en-US) maps to the stored underscore form (en_US) both ways, case-insensitively, and variants such as ca-ES@valencia are accepted. A locale the server has no translation for is 400 invalidValue. timezone is an IANA name. An unknown one is 400 invalidValue.
  • SCIM-27. active. It isn't stored. Reading it gives the account's effective authenticate permission, from the account, its roles and its tenant.
    • Setting false adds authenticate to the account's disabled permissions, overriding its roles. If the account's permissions was Inherit, it becomes Merge with only that entry.
    • Setting true removes only that entry. An account that was Inherit before goes back to exactly Inherit. Every other permission an administrator set is left as it was.
    • If authenticate still isn't effective after true (a role or the tenant withholds it), the request succeeds and the response shows active: false. SCIM grants nothing beyond undoing its own suspension.
    • PATCH accepts JSON booleans and the strings "true" and "false" in any case (Entra ID sends "False").
  • SCIM-28. groups on a user is read-only. Each entry has value (the group's id), display (the group's display name) and $ref. A write to it is 400 mutability. Membership is changed through the Group.
  • SCIM-29. externalId is stored exactly as sent and never interpreted. It's matched case-exactly. An empty string is 400 invalidValue, as the schema already requires. Decision: within one tenant (or the server-level scope), two users, or two groups, may not share an externalId. A write that would cause it is 409 uniqueness. Duplicates already in stored data are left alone, and a filter on that value returns them all.
  • SCIM-30. meta has resourceType, created (the account's createdAt), location and version. lastModified isn't returned, because accounts don't record one. location, $ref values and the Location header are absolute URLs built from the server's public URL (INBUXA_PUBLIC_URL).
  • SCIM-31. Defaults on create. A new user gets the User role, Inherit permissions (so the server's and tenant's defaults), the server's default locale, no time zone, and no credentials of any kind. It can sign in only through the directory that serves its domain, until someone gives it a password another way.
  • SCIM-32. Local settings stay local. SCIM never reads or writes quotas, roles, permissions other than the authenticate entry of SCIM-27, credentials, encryption settings, Sieve scripts, mailboxes or any other field. An administrator's changes to them survive every sync.
  • SCIM-33. What is accepted and ignored. Attributes of the core RFC 7643 User and Group schemas that aren't in the mapping tables, and everything under urn:ietf:params:scim:schemas:extension:enterprise:2.0:User, are accepted in POST, PUT and PATCH and discarded: never stored, never returned. That covers password (never written to the credential store, and never echoed in any response), phoneNumbers, addresses, photos, ims, title, userType, nickName, profileUrl, entitlements, roles, x509Certificates, the other name parts, and a Group's description. An attribute in no schema the server knows, an unknown schema URI, a duplicated attribute, or a missing schemas value is 400 invalidSyntax, with a detail naming it. This follows RFC 7644 §3.1, not the interoperability profile's rule that unknown attributes must be rejected, which is why interopProfileConformant is false. Attribute names are case-insensitive (RFC 7643 §2.1).

Group resources

  • SCIM-34. displayName is required and stored in the group's description. It must be unique among groups in the caller's scope, compared case-insensitively (Decision on case). A clash is 409 uniqueness.
  • SCIM-35. The group's address. Derived once, at creation, from displayName: lowercased, every run of characters other than ASCII letters and digits replaced by one hyphen, leading and trailing hyphens trimmed, and cut to 64 characters, so Sales EMEA gives sales-emea on the service principal's domain (SCIM-18). If the address is taken, a numeric suffix is added until one is free. Decision on the details: the suffix is -2, -3 and so on, the cut to 64 leaves room for it, and an empty result becomes group. If no free address can be found, 409 uniqueness. Renaming the group later doesn't change its address.
  • SCIM-36. Members. Only users. A member that is a group is 400 invalidValue. Nested groups aren't supported either way. Decision: a member id that doesn't exist in scope is 400 invalidValue, naming it. Membership is stored on each user (memberGroupIds), so a membership change writes each affected user, and needs sysAccountUpdate. Each entry returned has value, display (the user's display name), type: "User" and $ref. Entries are added or removed, never edited in place.
  • SCIM-37. Reading a group with more than 200 members, without excludedAttributes=members, is 400 tooMany, and the detail says to exclude members and read membership from the users' groups.
  • SCIM-38. A group's externalId and meta behave as a user's (SCIM-29, SCIM-30). Its version covers its membership, so adding or removing a member changes it.

Operations

  • SCIM-39. Create (POST /Users, POST /Groups): 201, the full resource, Location and ETag headers. The response has the id the identity provider must keep.
  • SCIM-40. Read (GET /{type}/{id}): 200 and the resource, with ETag. attributes and excludedAttributes (RFC 7644 §3.9) work here and on every query. A resource that doesn't exist or is out of scope is 404.
  • SCIM-41. Replace (PUT): the body is the whole resource. Every readWrite attribute left out goes back to its default: no display name, no aliases, the default locale, no time zone, active true (undoing only SCIM's own suspension, SCIM-27), no externalId, and for a group no members. id in the body is ignored if it matches, and 400 mutability if it doesn't. 200 and the resource.
  • SCIM-42. Modify (PATCH, RFC 7644 §3.5.2): a PatchOp with add, remove and replace, op names in any case (Entra ID sends Replace).
    • With no path, add and replace take an object of attributes, as Keycloak's extensions send {"active": "false"}.
    • Paths may be simple (displayName), sub-attributes (name.givenName), fully qualified with a schema URN (enterprise-extension paths are accepted and discarded, SCIM-33), or value-filtered on a multi-valued attribute (emails[value eq "a@b"], members[value eq "id"]).
    • remove needs a path. remove on members with no filter removes every member. Decision: removing a member or alias that isn't there succeeds with no change, because identity providers retry.
    • A path the server doesn't support is 400 invalidPath. A write to a readOnly attribute is 400 mutability.
    • All the operations in one request apply together or not at all. The answer is 200 with the full resource, never 204.
  • SCIM-43. Delete (DELETE): 204. The resource is gone at once: GET answers 404. What deletion does to mail is SCIM-52.
  • SCIM-44. Versions and conditional requests. Every resource has meta.version, also sent as the ETag header. It's computed from the resource's content, so it changes when the resource does and only then. Decision: it's a weak ETag, W/"…". If-None-Match on GET answers 304 when unchanged. If-Match on PUT, PATCH or DELETE answers 412 when the resource has changed. Without the header, writes are unconditional, and the last one wins. Decision: versions may differ from the ones an upstream server returned, so the first conditional request after cutover may get 412 and read again. None of the identity providers covered here send conditional requests.

Queries

  • SCIM-45. Filters work on GET /Users, GET /Groups and every .search. The operators are eq and and only, in any case. Every other operator (ne, co, sw, ew, gt, ge, lt, le, pr, or, not) is 400 invalidFilter, and the whole filter is parsed first so the detail names the construct. Filterable attributes:

    • User: id, externalId, userName, emails, emails.value, active, displayName, name.formatted, groups, groups.value;
    • Group: id, externalId, displayName, members, members.value.

    Any other attribute is 400 invalidFilter. emails, groups and members without a sub-attribute mean their value. A value filter (attr[sub eq "x"]) inside filter is 400 invalidFilter. Comparison follows each attribute's caseExact (SCIM-6). A filter that matches nothing is 200 with an empty ListResponse: that's Entra ID's connection test.

  • SCIM-46. active, displayName and name.formatted can't be answered from an index. They're checked after the indexed part of the filter has narrowed the candidates. If more than 200 candidates remain, the request is 400 tooMany, and the detail asks for a narrower filter. active eq false on its own is allowed when it fits under that limit, since operators use it to find suspended accounts.

  • SCIM-47. Sorting. sortBy accepts id, and userName for users. sortOrder accepts ascending and descending. Anything else is 400 invalidValue. Decision: with no sortBy, results are in ascending id order, so pages are stable.

  • SCIM-48. Index pagination (RFC 7644 §3.4.2.4), the default: startIndex is 1-based, and a value below 1 is treated as 1. count defaults to 100 and is capped at 200. count=0 returns only totalResults. Responses carry totalResults, startIndex and itemsPerPage.

  • SCIM-49. Cursor pagination (RFC 9865): cursor empty for the first page, then each response's nextCursor. The last page has no nextCursor. previousCursor isn't offered. A cursor is opaque and tamper-evident, and bound to the principal, the filter, the sort and the count that produced it. A cursor presented with any of those changed, or a forged one, is 400 invalidCursor (a changed count is 400 invalidCount). Decision: a cursor is good for at least 3600 seconds (advertised as cursorTimeout), then 400 expiredCursor. It needs no server-side state. Each page is computed with the principal's permissions at the time, so a permission change can't leak anything through an old cursor. startIndex and cursor together are 400 invalidValue.

  • SCIM-50. Query by POST. POST /Users/.search and POST /Groups/.search take a SearchRequest with the same parameters. POST /.search searches both types and returns one ListResponse. Decision: in the combined result, users come before groups, each in the requested order, and each resource carries its schemas so the client can tell them apart.

Bulk

  • SCIM-51. POST /Bulk (RFC 7644 §3.7) takes up to 1000 operations and 1 MiB. More operations, or a larger body, is 413. It supports failOnErrors and bulkId: a later operation may refer to a resource created earlier in the same request, as bulkId:<id> in a path or in a member value. Operations run in the order sent. A reference that can't be resolved, or a circular one, fails that operation with 409 invalidValue. Each operation is authorized and scoped exactly as it would be on its own (SCIM-11, SCIM-17), and reports its own status (a string, as RFC 7644 requires), location and version. Bulk isn't atomic: a failure doesn't undo earlier successes. The response is 200 unless the request as a whole is malformed.

Deprovisioning and mail

  • SCIM-52. Suspend and delete are different.
    • active: false (suspend). The account can't authenticate on any protocol with any credential: password, app password, API key, OAuth tokens already issued, and external OIDC tokens. It takes effect on the next request, without waiting for a permission cache to expire, the way MT-16 requires for tenant changes. Decision: long-lived sessions the account already has open (IMAP IDLE, JMAP push and event streams, WebSockets, ManageSieve) are ended. Mail keeps arriving and is kept, and the account's Sieve rules, forwarding and vacation reply keep running (Decision; mail flow is unchanged by suspension). Its shares with others stay. It still counts against quotas.
    • DELETE. The account is destroyed through the same path as an administrator's x:Account destroy: the DestroyAccount task, shares other accounts held on it revoked, and its data destroyed. Mail to its addresses is refused as for an unknown recipient. If undelete's archiveDeletedAccountsFor is set, the account is kept for that period and its addresses stay reserved (UD-15, UD-16), so the identity provider re-creating the same userName meanwhile gets 409 uniqueness.
    • Without sysAccountDestroy, DELETE is 403 and the account is left as it was, typically already suspended. Operators who want suspension only leave that permission off the key.
  • SCIM-53. Deleting a group destroys the group account the same way. Its members lose the membership, and mail to its address is refused as for an unknown recipient.
  • SCIM-54. Decision: every SCIM write emits an event naming the service principal, the resource, its externalId and the change (created, updated, suspended, reactivated, deleted), so operators can audit what the identity provider did. Filter values from query strings aren't added to any new log field, since RFC 7644 §7.5.2 warns they can carry personal data.

Conflict with accounts made another way

  • SCIM-55. Nothing marks an account as SCIM-owned. Every account in scope (SCIM-16) can be managed by SCIM, whether SCIM created it, an administrator did, or just-in-time sync did before the flag was set. An externalId is the only trace SCIM leaves.
  • SCIM-56. POST never adopts an existing account. If the userName or an alias is taken, the answer is 409 uniqueness naming the address. Identity providers look for a match first (filter=userName eq … or externalId eq …) and then update what they find with PATCH or PUT, which is how an account made by hand comes under the identity provider.
  • SCIM-57. Administrators may still change SCIM-mapped fields over JMAP, externalId included. The next sync may overwrite them. That's expected, and not an error.

Just-in-time sync and authority

  • SCIM-58. When a domain's allowScimProvisioning is true, SCIM is authoritative there. Just-in-time directory sync (on sign-in, and on recipient lookup for LDAP and SQL directories) becomes read-only for that domain:
    • it creates no account. A person who authenticates at the directory before being provisioned gets an ordinary authentication failure (401 over HTTP), not a half-made account;
    • it changes nothing on an existing account: not the display name, aliases, group membership or stored secret. The account's SCIM version doesn't change on sign-in;
    • it creates no group from a groups claim;
    • the directory still authenticates the person. A provisioned, active account signs in normally.
  • SCIM-59. When the flag is false (the default), just-in-time sync works exactly as it does today, and SCIM writes to the domain are refused (SCIM-15).
  • SCIM-60. Changing the flag moves nothing. Turning it on leaves existing accounts where they are; they stop being updated by sync, and the identity provider takes them over only when it matches them (SCIM-56). Turning it off leaves SCIM-made accounts in place; sync resumes and may overwrite their display name and replace their groups on the next sign-in, and SCIM can no longer see them (SCIM-16). The change takes effect without a restart.
  • SCIM-61. The rule follows whichever directory serves the domain. Until per-domain directories (Domain.directoryId) are rebuilt, that's the default directory.

Interfaces

Endpoints

All under /scim/v2.

Path Methods Authentication
/Users GET, POST API key
/Users/{id} GET, PUT, PATCH, DELETE API key
/Users/.search POST API key
/Groups GET, POST API key
/Groups/{id} GET, PUT, PATCH, DELETE API key
/Groups/.search POST API key
/.search POST API key
/Bulk POST API key
/ServiceProviderConfig GET none
/ResourceTypes, /ResourceTypes/{id} GET none
/Schemas, /Schemas/{urn} GET none
/Me any answers 501
any path OPTIONS none, answers 204

Setting it up

What an operator does, all with existing objects:

  1. Set allowScimProvisioning on each domain the identity provider may manage.
  2. Create a dedicated service-principal account on one of those domains (its domain is where SCIM groups go, SCIM-18). In a tenant, create it in that tenant, and make sure the tenant allows scimAccess (SCIM-12).
  3. Give it an API key. The narrowest is Replace mode with authenticate, scimAccess, sysAccountGet, sysAccountCreate, sysAccountUpdate, and sysAccountDestroy only if deletion should be honored. Add allowedIps if the identity provider's addresses are known.
  4. Give the identity provider the base URL https://<public host>/scim/v2 and the key.

The endpoint can be confined further with the existing x:Http.allowedEndpoints expression (for example by remote address or listener). SCIM adds no setting of its own.

Errors

SCIM error documents (RFC 7644 §3.12): schemas ["urn:ietf:params:scim:api:messages:2.0:Error"], status as a string, detail, and scimType where one applies.

Status scimType When
400 invalidSyntax Malformed JSON; an attribute or schema URI in no known schema; a duplicated attribute; schemas missing
400 invalidFilter Unsupported operator or attribute, or a value filter, in filter
400 invalidPath Unsupported PATCH path
400 invalidValue A value the server can't accept: a domain not open to SCIM, a non-address userName, an unknown locale or time zone, a group as a member, a cross-tenant member, an unknown member id, a bad sort, startIndex with cursor
400 mutability A write to a readOnly attribute or to the primary email; a mismatched id on PUT
400 tooMany Over 200 candidates for an unindexed filter; a group over 200 members read with its members
400 invalidCursor, expiredCursor, invalidCount Cursor pagination (RFC 9865)
401 No, bad, expired or revoked API key; Basic auth; a bearer token that isn't an API key; a disallowed IP
403 A missing permission (named in detail); the service principal deactivating, deleting or renaming itself; a tenant limit reached; filter on a discovery endpoint
404 Unknown resource or path; anything outside the caller's tenant
405 Wrong method; Allow lists the right ones
409 uniqueness An address, group name or externalId already in use; no free group address
409 invalidValue An unresolvable or circular bulkId (inside a /Bulk result)
412 If-Match no longer holds
413 /Bulk over 1000 operations or 1 MiB
429 Rate limited; Retry-After says when to retry
501 /Me

JMAP

  • Existing, unchanged: x:Domain.allowScimProvisioning, x:UserAccount.externalId and x:GroupAccount.externalId through x:Domain and x:Account /get, /set and /query, as ordinary fields; the scimAccess permission; x:ApiKey credentials.
  • New, Decision: x:Account/query accepts an externalId filter (the index already exists), so administration screens can find the account an identity provider means.

ihasmail changes

These go in ihasmail-inbuxa, not public ihasmail, which stays Stalwart-facing (SPEC.md §5).

  • Domains: an "Allow SCIM provisioning" switch on the domain form. Turning it on warns that sign-in sync stops creating and updating accounts on the domain (SCIM-58), and turning it off warns that sync resumes (SCIM-60).
  • Accounts and groups: show externalId when set, as "Managed by the identity provider", and say that edits to the name, display name, aliases, groups or sign-in status may be overwritten at the next sync (SCIM-57). Allow clearing it.
  • Suspended accounts: show an account whose authenticate permission is disabled as suspended, with a filter for them, so operators can review what Okta leaves behind (it never deletes). Reactivating from ihasmail undoes the suspension the same way SCIM-27 does.
  • Service principal and key: creating an API key with the SCIM permissions stays in INBUXA Admin. ihasmail links there from the domain switch rather than growing its own screen (SPEC.md §5.4).
  • Every string this adds is new translation work for ihasmail's nine languages.

Acceptance tests

Every test runs against inbuxa-server built with no Enterprise code. The three surviving suites come back first: rebuild tests/src/scim/mod.rs with the helpers they import (from this spec, not from the removed file), put pub mod scim; back in tests/src/lib.rs, and re-export the constants they use from scim-proto. oidc.rs also needs per-domain directories (see open questions). The rest are new, written from this spec to cover what the removed suites (auth, bulk, discovery, groups, limits, query, users) covered.

  1. Third-party lifecycle (conformance.rs, lifecycle): discovery values, create user with an alias, read by id and by filter, .search, PATCH display name and active, PUT that resets active and the display name, group created with a member, membership shown on the user, members removed, group and user deleted, user 404 (SCIM-4, SCIM-22, SCIM-24, SCIM-25, SCIM-27, SCIM-28, SCIM-30, SCIM-36, SCIM-39 to SCIM-44, SCIM-50).
  2. Real client payloads (conformance.rs, clients): Okta create with password and extra attributes, Keycloak create with only structured names, Entra create with the enterprise extension, Okta PUT, Keycloak no-path PATCH with "false", Entra Replace with extension path, lookup by userName, and dispalyName refused invalidSyntax. No response contains password (SCIM-24, SCIM-27, SCIM-33, SCIM-42).
  3. Conformance checker (conformance.rs): scim2-tester reports no error, critical or deviation beyond the generated non-address userName (SCIM-1 to SCIM-6, SCIM-15).
  4. Tenant isolation (tenant.rs): a tenant client lists only its two accounts, filters and .search never find an outsider, every operation on an outsider is 404 (in /Bulk too), adding an outsider to a group is 400 invalidValue, an address on a server-level domain is 404 naming it, and provisioning inside its own domain works end to end (SCIM-16, SCIM-17, SCIM-19, SCIM-51).
  5. OIDC authority (oidc.rs): with the flag on, an unprovisioned Keycloak user's sign-in is 401 and creates nothing; a SCIM user signs in, and its display name, groups and version are unchanged after two sign-ins; no group is created from the claim. With the flag off, sign-in creates the account and its claimed group, and replaces the SCIM name and groups (SCIM-58 to SCIM-60).
  6. Basic auth, a missing token, an OAuth access token, an expired key, a deleted key and a key from a disallowed IP: all 401. A Replace key without scimAccess: 403 naming it. Deleting the key: the next request is 401 (SCIM-7, SCIM-9, SCIM-11).
  7. A key without sysAccountDestroy: DELETE is 403, PATCH active=false works (SCIM-11, SCIM-52).
  8. The service principal deactivating, deleting or renaming itself: 403, unchanged (SCIM-13).
  9. A tenant whose ceiling lacks scimAccess: its principal is refused even with the permission on its account and key (SCIM-12).
  10. Discovery without auth: 200. filter on /Schemas: 403. Unknown path 404, wrong method 405 with Allow, OPTIONS 204, /Me 501 (SCIM-2, SCIM-3).
  11. userName on a domain with the flag off: 400 invalidValue naming it. An alias on such a domain in an otherwise valid create: the whole create refused (SCIM-15).
  12. Accounts on a domain without the flag don't appear in /Users (SCIM-16).
  13. Group created by a principal whose domain isn't SCIM-enabled: 400 invalidValue (SCIM-18).
  14. maxAccounts reached in a tenant: 403 naming the limit, and limit.tenant-quota emitted (SCIM-20).
  15. Rename by userName: the account moves, the old address is refused as unknown, the id is unchanged (SCIM-21, SCIM-23).
  16. Duplicate address on create: 409 uniqueness, and the existing account is untouched (SCIM-22, SCIM-56).
  17. Emails: the primary first and read-only, a duplicate alias skipped, an alias dropped by PATCH remove gone, an alias another account holds 409 (SCIM-25).
  18. EN-us, ca-ES@valencia and preferredLanguage alone all store and read back; xx-YY and Mars/Olympus are 400 invalidValue (SCIM-26).
  19. active round trip on an Inherit account leaves permissions exactly Inherit; on an account with custom permissions, leaves them as they were. An account whose role lacks authenticate reads active: false (SCIM-27).
  20. Same externalId on two users in one tenant: 409. In two tenants: allowed. Filter on it: case-exact (SCIM-29).
  21. Groups: displayName clash in any case 409; Sales EMEA gives sales-emea, a second gives sales-emea-2; a rename keeps the address; a group as member 400; 201 members read without exclusion 400 tooMany; the version changes when a member is added (SCIM-34 to SCIM-38).
  22. PATCH atomicity: one bad operation among good ones changes nothing. Removing a non-member succeeds (SCIM-42).
  23. If-None-Match gives 304; a stale If-Match gives 412 on PUT, PATCH and DELETE (SCIM-44).
  24. Filters: each supported attribute answers; co, or, pr are 400 invalidFilter; an unfiltered active eq false over 200 candidates is 400 tooMany (SCIM-45, SCIM-46).
  25. Sorting and index pages: count 500 gives 200; count=0 gives totals only; pages in stable id order (SCIM-47, SCIM-48).
  26. Cursors: walk 450 users in pages of 200 to the end; a cursor with a changed filter invalidCursor, changed count invalidCount, a tampered cursor invalidCursor (SCIM-49).
  27. Bulk: create a user and a group that references it by bulkId; failOnErrors: 1 stops after the first failure; 1001 operations 413 (SCIM-51).
  28. Suspend: IMAP login, JMAP with an existing OAuth token, and an app password all fail on the next attempt; an open IMAP IDLE is closed; mail to the account is still delivered (SCIM-52).
  29. Delete: mail to the address is refused as unknown; with archiveDeletedAccountsFor set, re-creating the same userName is 409 until the hold ends (SCIM-52).
  30. An account created by an administrator is found by userName filter, adopted by PATCH setting externalId, and its quota and roles are unchanged (SCIM-32, SCIM-55, SCIM-56).
  31. (compat) On a copy of INBUXA's data: every externalId and flag reads back unchanged, and the SCIM ids equal the account ids JMAP returns (SCIM-21).

Open questions / to observe

  1. Per-domain directories. x:Domain.directoryId is flagged Enterprise, isn't rebuilt (get_directory_for_domain returns the default), and no feature in SPEC.md §4 claims it. oidc.rs needs it. It needs a home: its own small spec, or a section of this one.
  2. Does INBUXA use SCIM today? If any identity provider provisions into the live server, cutover must keep it working (SCIM-21, SCIM-44), and that should be checked on a copy before cutover, not assumed.
  3. Visibility of accounts on domains without the flag (SCIM-16). The public docs don't say whether upstream lists them. Observe, and decide whether matching upstream matters to any client.
  4. externalId uniqueness (SCIM-29) is our Decision. The index isn't unique and the docs are silent.
  5. userName case (SCIM-22). Whether upstream returns the case sent or a lowercased form. Strict clients compare it.
  6. Old address on rename (SCIM-23), alias type (SCIM-25), suffix format and empty slugs (SCIM-35), unknown member ids (SCIM-36), removing a non-member (SCIM-42), combined .search order (SCIM-50): details the docs don't settle, decided here.
  7. active: true when a role withholds authenticate (SCIM-27). Our answer is to succeed and report false. Some identity providers may loop on that. Check Entra ID's and Okta's handling.
  8. Suspended accounts' Sieve, forwarding and vacation (SCIM-52), and ending open sessions: whether upstream does either.
  9. Other bearer tokens on /scim/v2 (SCIM-7). Upstream's docs name only API keys. Whether it also accepts OAuth access tokens is unknown; we refuse them.
  10. OAuth client credentials (SCIM-10). Entra ID gallery apps and Okta's OAuth mode would want it. Decide with contract.md's OAuth work.
  11. Tenant quota status (SCIM-20) and the rate-limit accounting of /Bulk (SCIM-14) are our Decisions.
  12. The interop and IPSIE profiles. Upstream's docs say it follows draft-zollner-scim-interop-profile (except unknown-attribute rejection) and the IPSIE profile's lifecycle rules. Neither draft was read for this spec. Read both and add any requirement they impose that isn't here.
  13. Vendor documentation. Microsoft's, Okta's and the Keycloak extensions' own SCIM documentation wasn't read. Check them against SCIM-7, SCIM-27, SCIM-42 and SCIM-45 before implementation.
  14. SCIM_DOMAIN and HTTP_PORT in the test helpers. The surviving tests imply scim.example.com and port 8899 (from addresses and URLs in them). Confirm when rebuilding mod.rs.

Observed

Settled on 2026-09-18 against INBUXA's live Enterprise server (Stalwart 0.16.22), read-only, as a server-level administrator and the throwaway test account. No upstream code was read.

  1. SCIM isn't used (open question 2). No domain has allowScimProvisioning on, and none of the 8 accounts has an externalId. Cutover has nothing to carry.
  2. Discovery is anonymous. /scim/v2/ServiceProviderConfig, /Schemas and /ResourceTypes answer 200 with no credentials, as application/scim+json. /scim/v2/Users without credentials answers 401 with WWW-Authenticate: Bearer realm="Stalwart SCIM".
  3. What upstream advertises: PATCH, Bulk (1,000 operations, 1 MiB), filtering (maxResults 200), sorting and ETags supported; password change not supported; pagination by index and by cursor, index by default, page size 100, at most 200. One authentication scheme, oauthbearertoken, described as an API key presented as a bearer token. interopProfileConformant is false. /ResourceTypes lists User and Group with no schema extensions, which matches this spec's decision. documentationUri points at upstream's documentation. The fork points it at its own.

The per-account behaviors (open questions 3 and 5 to 9) need an API key and a SCIM-enabled domain, so they stay open.