# Directory sync (SCIM)

> Provision and deactivate members from Okta, Entra ID or any SCIM 2.0 client, with groups mapped to roles.

Source: https://critlab.app/docs/enterprise/scim

SCIM 2.0 lets Okta, Microsoft Entra ID or any RFC 7644 client create, update and deactivate the members of one workspace, and choose their roles through group membership. It is an Enterprise feature, configured by a workspace owner in **Settings → Directory sync** or with `crit scim`.

SCIM is built into web; there is no extra service and no environment variable to set.

## Set up

1. In **Settings → Directory sync**, create a token (`crit scim token create <name>`). It starts with `crit_scim_` and is **shown once**; critlab stores only a hash.
2. In your IdP's SCIM app, enter:

| IdP field | Value |
| --- | --- |
| SCIM base URL | `https://critlab.example.com/scim/v2` |
| Authentication | HTTP header / OAuth bearer token |
| Token | the `crit_scim_…` token |

3. Map attributes (below), assign people and push groups.

To rotate a token, create a second one, switch the IdP to it, then revoke the first. Revoking stops provisioning immediately and changes no members.

The SCIM endpoints accept the bearer token only, never a browser session, and allow 60 requests per minute per token.

## Attribute mapping

| SCIM attribute | critlab | Notes |
| --- | --- | --- |
| `userName` | Email | **Must be an email address**; anything else is refused with `invalidValue`. |
| `emails[type eq "work"].value` | Email | Primary, then work, then the first address. |
| `name.givenName`, `name.familyName` | Display name | Stored as one name. |
| `externalId` | External id | Keeps a person matched across an email change. |
| `active` | Membership active | `false` deactivates (below). |

Roles are read-only over SCIM: a directory sets a role through groups.

## Groups and roles

Pushed groups arrive with **no role**, so naming a group `critlab-admins` grants nothing by itself. A group's role comes from, in order:

1. the mapping an owner sets in **Settings → Directory sync** (`crit scim map <group> <role>`),
2. otherwise an entry of the same name in the workspace's SAML role map, so a workspace that already configured SSO gets working roles with no extra setup,
3. otherwise nothing.

A member's role is the strongest role any of their groups grants, else the workspace's default role, else member. It is recomputed on every membership change, in both directions: leaving an admin group removes admin.

Two rules the directory cannot override:

- **SCIM never grants owner.** Group-derived roles are capped at admin. Ownership is granted by a person in critlab.
- **SCIM never changes or deactivates an owner.** The directory gets a success response (so it does not retry forever) and the skipped change is logged as `scim.role_sync_skipped`.

Groups can map to **guest**, but SCIM never grants folder access: a directory-provisioned guest sees an empty workspace until an owner or admin grants folders. See [Roles and guest access](/docs/enterprise/roles).

## Deactivation

`active: false`, or `DELETE /Users/{id}`, deactivates the membership rather than deleting it:

- the person loses access to the workspace immediately and stops counting as a seat;
- **all of their sessions are revoked**, including in other workspaces, because deactivation is what happens when someone leaves;
- their comments, and their account, stay;
- reactivating restores their role and groups.

## Signing in

A provisioned person has no password and signs in through the workspace's SAML connection (configure [Single sign-on](/docs/enterprise/sso) first). Configure the IdP to send the email address as the SAML Name ID, which is the default in Okta and Entra ID SAML apps.

## Supported operations

| Method | Path |
| --- | --- |
| `GET` | `/scim/v2/ServiceProviderConfig`, `/scim/v2/ResourceTypes`, `/scim/v2/Schemas` |
| `GET`, `POST` | `/scim/v2/Users` (filter: one `eq` on `userName`, `externalId`, `emails.value`, `id` or `active`; `startIndex`, `count`) |
| `GET`, `PUT`, `PATCH`, `DELETE` | `/scim/v2/Users/{id}` |
| `GET`, `POST` | `/scim/v2/Groups` |
| `GET`, `PUT`, `PATCH`, `DELETE` | `/scim/v2/Groups/{id}` |

Bulk operations, sorting, ETags and compound filters (`and`/`or`) are not supported and are declared as such in `ServiceProviderConfig`; an unsupported filter is refused, never answered approximately. Errors use the SCIM error envelope.

To check a token by hand:

```bash
curl -s -H "Authorization: Bearer $SCIM_TOKEN" \
  --get "https://critlab.example.com/scim/v2/Users" \
  --data-urlencode 'filter=userName eq "ana@acme.com"'
```

## Audit trail

Every change is recorded with the actor shown as the SCIM token, so directory changes are easy to tell from people's: `scim.user_created`, `scim.user_updated`, `scim.user_deactivated`, `scim.user_reactivated`, `scim.role_synced`, `scim.role_sync_skipped`, `scim.group_created`, `scim.group_updated`, `scim.group_deleted`, `scim.group_members_changed`, `scim.token_created`, `scim.token_revoked`.

> **Open questions**
>
> - **Live-tenant verification.** The Okta and Entra ID request sequences are covered by integration tests; neither has been run against a real tenant yet.
> - **Opaque or transient Name IDs.** An IdP that sends a non-email Name ID may fail to match a provisioned person at SAML sign-in. The remedy today is configuring email as the Name ID.
