882 lines
54 KiB
Markdown
882 lines
54 KiB
Markdown
# 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.
|
|
**Decision** (2026-09-19): in a `POST` or `PUT` body, a `groups` value that
|
|
equals the current membership (so `[]` on create, as Okta sends) isn't a
|
|
write: it's accepted and ignored. A value that would change membership is
|
|
`400 mutability`, and `PATCH` on `groups` always is.
|
|
- **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.
|
|
**Decision** (2026-09-19): SCIM is built before per-domain directories
|
|
(feature 9). Acceptance test 5 (`oidc.rs`) binds an OIDC directory to one
|
|
domain, so it stays ignored until feature 9 is built, and runs unchanged
|
|
then. Until then SCIM-58 to SCIM-60 are tested against 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.
|
|
**Decision** (2026-09-19) on 12 and 13: SCIM is built to this spec without
|
|
reading the profile drafts or the vendors' documentation, since each point
|
|
they'd inform already has a Decision here. Both stay open, to check after
|
|
the build; the replayed Okta, Keycloak and Entra payloads in `driver.py`
|
|
cover the clients meanwhile.
|
|
|
|
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.
|