Skip to content

SCIM provisioning API

The gateway exposes a SCIM 2.0 inbound endpoint at /scim/v2/ so an identity provider (e.g. Microsoft Entra ID) can automatically provision, update, deactivate and group a tenant's users. It pairs with OIDC SSO: SSO authenticates, SCIM provisions.

SCIM is configured per tenant. A tenant admin generates the bearer token under the tenant settings (see Tenants and gateways → SCIM credential); the IdP is given the base URL and that token. The base URL is the full HTTPS URL shown in the SCIM provisioning panel, served on the admin-API host (for example https://ai-api-admin.myra.eu/scim/v2) — the split-host admin-API origin, not the ai.myra.eu application host. Use it exactly as displayed rather than hand-constructing it.


Authentication

Every /scim/v2/ request carries Authorization: Bearer <token>. The token is hashed (SHA-256) and matched to a tenant; the tenant is derived entirely from the token, never from the request payload, so one tenant's credential can only ever touch its own users and groups. A missing or unknown token returns 401. A storage fault while resolving the token is not a bad credential: it returns 503 with Retry-After: 10 (never a 401, which an IdP would treat as a revoked credential and stop retrying). We store only the hash — the plaintext is shown once at generation.


Discovery

Method Path Description
GET /scim/v2/ServiceProviderConfig Capabilities — patch and filter supported; bulk, sort, etag, changePassword not.
GET /scim/v2/ResourceTypes User and Group (with the enterprise-user extension).
GET /scim/v2/Schemas Core User, Group, and enterprise-user schemas. Each schema resource carries the RFC 7643 §7 schemas envelope and a description on every attribute definition, so an IdP conformance fetch does not abort on the missing fields.

Users

Method Path Description
POST /scim/v2/Users Create. userName is required and non-blank — a present but blank userName is 400 invalidValue regardless of emails[]; only when the key is absent is the primary emails[].value (or failing one the first non-blank emails[].value) accepted in its place; a blank or null externalId is treated as absent, a non-string one is 400 invalidValue. A duplicate externalId of a deactivated user reactivates it (the leave-and-return path); a duplicate of a live user returns 409 uniqueness. Rate-limited per token → 429 + Retry-After (see Global email namespace and provisioning rate limit).
GET /scim/v2/Users?filter=userName eq "…" The reconcile/match query (userName or externalId, eq only). An empty match is a 200 ListResponse with Resources: [] — never 404. A blank match value (empty, whitespace or format-only), one that is not valid UTF-8, or a filter parameter that is bare or repeated is refused with 400 invalidFilter — never the unfiltered list.
GET /scim/v2/Users?startIndex=1&count=100 Paginated list of the tenant's active users.
GET /scim/v2/Users/{id} Read one.
PATCH /scim/v2/Users/{id} PatchOp. active:false → deprovision (below); active:true → reactivate; attribute replace. active is accepted as a bool or the string "False"/"True", path-form or pathless, op names case-insensitive; paths may be bare (active) or fully qualified (urn:ietf:params:scim:schemas:core:2.0:User:active). Only active, userName, displayName and the enterprise department are honoured — an op addressing any other attribute (e.g. externalId) is ignored. The PatchOp shape is validated first: Operations must be a JSON array of objects whose op is add, replace or remove — anything else is 400 invalidSyntax, never a silent no-op. An enterprise department change (see Department → group mapping) remaps the user's derived group.
PUT /scim/v2/Users/{id} Full replace of attributes + active. userName is required and non-blank (RFC 7643): the same rule as POST — a present userName that is not a string or has no visible character (empty, whitespace, control or format code points such as NBSP, zero-width space or a BOM) returns 400 invalidValue and changes nothing; only when the key is absent is the primary (else first non-blank) emails[].value accepted in its place, and a body with neither is 400 invalidValue. A PATCH op that addresses userName with a blank or non-string value, with no value, or that removes it, is refused the same way (never a silent no-op). A request carrying both a rename and active:false applies the rename first, then deactivates; if the new address collides (409), the deactivation still lands — a revoke is never blocked by an unrelated attribute conflict — while a transient fault on the rename (503) applies nothing. A request that is refused as invalid (400 — a malformed PatchOp, a blank or over-long identity value) applies nothing either, including its deactivation: fix the payload and resend, so the retry re-applies both (a transient fault on the deactivation after a successful rename leaves the rename in place; the retry converges). A request carrying active:true and a userName on a deactivated user reactivates atomically under that address: a collision is a 409 with the user still deactivated (never live under a stale address), and a returning user whose old address has since been taken by someone else comes back under the new one. An absent department clears the derived group membership. externalId is not updated by PUT (and is therefore not validated there).
DELETE /scim/v2/Users/{id} Deprovision (same as active:false). Idempotent — an unknown/already-deprovisioned id returns 204, never 404, so a retried delete is safe.

Groups

Method Path Description
POST /scim/v2/Groups Create a group bound to the IdP group id (externalId; blank or null → the group is bound under its displayName, a non-string is 400 invalidValue). displayName is required and non-blank (a PATCH rename to a blank or non-string value, or a remove of it, is refused the same way — and a PATCH is validated as a whole before any of its operations is applied). Requires the tenant's SSO config (the binding references it). Idempotent on the IdP group id.
GET /scim/v2/Groups / ?filter=displayName eq "…" List / match (displayName, eq only; the same filter grammar as Users, so \" and \\ escapes work and a blank match value is refused with 400 invalidFilter).
GET /scim/v2/Groups/{id} Read one (with members).
PATCH /scim/v2/Groups/{id} Add / remove / replace members (both the value-list and the members[value eq "id"] value-path forms), and rename displayName.
DELETE /scim/v2/Groups/{id} Unlink — removes the IdP binding and the SCIM-sourced memberships only. The local group, its manually-added members, and its project grants are kept (a cascade-delete would silently strip other users' access). Idempotent — an unknown/already-unlinked id returns 204, never 404.

The deprovisioning contract

Deactivating a user (PATCH active:false or DELETE) runs one atomic transaction that completely removes the user's access — not just their group membership:

  • revokes every access surface: group memberships, direct project memberships, agent shares received and granted, the group-shares of the user's own agents, public conversation share links, third-party MCP connector credentials, and personal API tokens (revoked, so a subsequent /v1 call gets a typed token_revoked 401, not a generic error, and the auth cache is dropped so none survive even briefly);
  • removes the user's agents from the org catalog and disables their scheduled runs;
  • reassigns any project the user solely owned to a live tenant-admin successor, so nothing is orphaned;
  • blocks login and ends every session permanently on every device — the deactivation gate is checked live on every admin, API-token and scheduled request, and a per-user revocation floor (sessions_valid_after) is stamped so an older admin cookie is refused even after reactivation;
  • writes a scim.deprovision audit-log entry.

Reactivation (a returning employee — active:true, or a fresh POST with the same externalId) restores login but does not silently restore the revoked grants, sessions, or API tokens; the IdP re-provisions the grants and the user signs in afresh (the revocation floor is never cleared).

Audit symmetry. Every access-changing SCIM event is auditable, not just the revoke: provisioning a user writes scim.provision, reactivating writes scim.reactivate, and deprovisioning writes scim.deprovision. A create-inactive therefore reads as scim.provision followed by scim.deprovision.

Creates return 201 with a Location header pointing at the new resource. A PatchOp whose path is an empty string ("") is treated as pathless (applied to the resource body), not silently dropped — so an active:false is never lost.

Reconciliation model

The IdP is the single source of truth for SCIM-managed users and groups. The filterable GET endpoints serve the IdP's match and full-sync cycles; do not edit SCIM-managed users or groups directly in the admin UI, as the next sync would overwrite or recreate them.

Department to group mapping

A SCIM user's enterprise department attribute can automatically place the user in an internal group — the same automatic grouping the SAML department claim performs, driven by one shared configuration (no separate SCIM mapping to keep in sync).

Accepted shape. The department is read from the enterprise-user extension in the request body:

{
  "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User",
              "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"],
  "userName": "alice@corp.example",
  "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { "department": "Sales" }
}

Configuration. Set group_attribute to department and group_mapping to the JSON allow-list { "<department value>": "<internal group id>" } on the tenant's identity configuration. This is settable on either the OIDC SSO config (PUT /admin/v1/tenants/{id}/sso-config) or the SAML config (PUT …/saml-config) — the two fields are one shared, tenant-scoped setting, so a pure OIDC/SCIM tenant configures department grouping without any SAML setup (no IdP metadata required). group_mapping must be a JSON object string and group_attribute is required whenever group_mapping is sent; the two are saved together, and a request that omits group_mapping leaves the existing mapping unchanged (an ordinary SSO/SAML config save never disturbs it). When group_attribute is department, the SCIM department value is looked up in the allow-list and the user is reconciled into the mapped group. If group_attribute is anything else (or unset), the SCIM department path does nothing — it never reuses a mapping whose keys are not department values.

Behaviour.

  • Create / update (POST, PUT, and PATCH that carries department) reconciles the user's derived membership to exactly the mapped group.
  • Removal — a PUT whose body omits department, or a PATCH that removes it — clears the derived membership. A PATCH that does not mention department leaves membership untouched (partial-update semantics), so an unrelated active/displayName change never disturbs it.
  • The derived membership is written with its own provenance (scim_attr) and is reconciled, never accumulated: it is completely independent of memberships added manually, via SCIM /Groups, or via a SAML claim, and it never clobbers them.
  • A department value not present in group_mapping results in no membership (not an error). A deactivating request does not create derived memberships.
  • Tenant isolation is structural: a group id in the allow-list that does not belong to the tenant is silently ignored (0 rows) — a SCIM request can never place a user in another tenant's group. The department value itself is only ever an allow-list key; it is never interpolated into SQL.

Independent of SAML. The mapping is stored per tenant and driven purely by group_attribute/group_mapping; it is independent of whether SAML SSO login is configured or enabled. A pure OIDC/SCIM tenant (no SAML IdP) can define it via the OIDC SSO-config endpoint, and a tenant that has migrated to another sign-in method but still provisions via SCIM keeps its department grouping. Setting only the group mapping never turns on SAML login.

To remove the mapping, set group_mapping to an empty allow-list ({}), or delete the SSO/SAML config.

Limitation. Editing group_mapping does not retroactively re-group already-provisioned users until the IdP next sends a department update for them.

Global email namespace and provisioning rate limit

One address, one account (by design). An email address (the SCIM userName) identifies exactly one user account across the whole platform — the gateway is a business product in which one person belongs to one tenant, so the address namespace is global rather than per-tenant.

  • Accepted: POST /Users with a userName/email that is not already registered → 201 Created.
  • Rejected: a userName/email that is already registered → 409 with scimType: "uniqueness". This refusal is intentional: because an address maps to a single account, a second registration of the same address is declined rather than creating a duplicate identity. The 409 does not disclose which tenant owns the address.

Conflict vs. transient fault (the returning-user path). Reactivating a deactivated user (a POST with a returning externalId, or active:true on PATCH/PUT) and updating its attributes are each classified so the IdP reacts correctly: a genuine email collision — the address the request carries (or, without one, the returning user's old address) is now held by a different live account — returns 409 with scimType: "uniqueness" and leaves the user deactivated (a permanent conflict the IdP resolves by sending a free userName, which then reactivates the account under it), while a transient/infrastructure fault (database unavailable, lock timeout, a row disappearing under a concurrent change) returns 503 (a retryable status; note that these storage-fault 503s carry no Retry-After header — only the unreadable-body and credential-lookup 503s described above do). A retryable fault is never reported as a 409 (which the IdP would not retry) or a 500 (which reads as permanent). The address collision check is scoped to live accounts; a deactivated account's tombstoned address does not block a reactivation.

Rate limit. POST /Users is rate-limited per SCIM bearer token (default 60 requests per 60 seconds per gateway; there is exactly one bearer per tenant). Exceeding it returns 429 with a Retry-After header. IdP provisioning services (Microsoft Entra, Okta) honour Retry-After and retry with backoff, so a burst during a large initial sync is throttled, never dropped. The limit bounds automated bulk probing of the address namespace at scale; it is not intended to (and cannot) prevent a single lookup, which is inherent to a global-account model.

Request body contract

Every body-bearing SCIM method (POST, PUT, PATCH) reads its body through one reader (the same one the admin API uses, in the stricter SCIM mode), so these rules hold uniformly:

  • Accepted shape: a JSON object — inline or large. Bodies above the gateway's in-memory buffer (~320 KB) are spooled by nginx and read back in full, so a Group PATCH carrying thousands of members or a bulk full-resource PUT is applied exactly like a small one. The body may be at most 2 MB (client_max_body_size on the SCIM location); anything larger is refused by nginx with a plain 413 before the request reaches the SCIM handler — no SCIM Error document, nothing read, nothing changed.
  • Rejected — 400 with scimType: "invalidSyntax": an absent or empty body (RFC 7644 requires one on these methods), a body that is not a JSON object (null, a number, a string, a boolean, or a JSON array), malformed JSON (a truncated payload, a trailing token, a byte-order mark), and a body that is not valid UTF-8 anywhere (RFC 8259 §8.1 — checked once for the whole body, so no string field can carry an invalid byte into the database). Nothing is read from it and nothing is written. Malformed body content is never logged (a SCIM body carries personal data); only the parser's error position is.
  • Rejected — 400 with scimType: "invalidValue": identity keys beyond their storage width — userName (and emails[].value) over 254 bytes, externalId (and a Group's displayName, its key) over 255 characters — on every method that carries them (an over-long value is never a 500 or a retried 503). A User's display name (name.formatted / displayName) is not an identity: it is stored truncated to 255 characters rather than refused, so a long name never blocks a request — in particular never a deactivation carried in the same request.
  • Rejected — 503 with Retry-After: 10: a body that was sent but could not be read (a spooled body whose temp file failed to read). Not a client error; the IdP should retry after the header, not in a tight loop. It is distinct from an absent body, which is a 400.
  • Body syntax is validated before the resource lookup, so an absent, non-object, malformed or unreadable body on an unknown id answers 400/503, not 404. A well-formed object on an unknown or other-tenant id still answers 404 (the field-level checks sit after the lookup).
  • A genuine server fault while handling a request returns a SCIM Error document with status 500 whose detail carries the request id for correlation with the gateway's logs.

Input validation

The filter grammar is restricted to userName/externalId eq "value" (groups: displayName eq); the attribute maps to a fixed field and is never interpolated as a SQL column, and an unparseable filter returns 400 — never "return all". Bodies are validated; a duplicate externalId/userName returns 409 uniqueness (see Global email namespace for the global-vs-tenant scope of the address check), retries are idempotent. A sustained burst of POST /Users on one token is rate-limited to 429 with Retry-After.