# SCIM provisioning

> Let your identity provider create, update and suspend QueryProxy users and sync groups over SCIM 2.0. Tokens, what is supported, how groups become team roles.

- Documentation: Guides
- Source: https://queryproxy.com/docs/scim/
- Language: en-US
- Author: Muhammet Şafak

---
SCIM 2.0 lets your identity provider manage QueryProxy users on its own: it
creates a user when someone joins, updates the profile, **suspends** the account
when someone leaves, and keeps group membership in step. It complements
[single sign-on](/docs/sso-oidc/): sign-on decides who may get in *now*, while
SCIM takes access away *between* sign-ins. Without it, a person removed at the
provider keeps any open QueryProxy session until it ends.

SCIM is **off by default**. While it is off, every path under `/scim/v2` answers
`404`.

## Turn it on

```dotenv
QUERYPROXY_SCIM_ENABLED=true
```

Restart the containers after changing it. Then point the provider at:

```text
https://queryproxy.example.com/scim/v2
```

That is `APP_URL` plus `/scim/v2`. QueryProxy answers with
`application/scim+json` and reports its abilities at
`/scim/v2/ServiceProviderConfig`, with `/scim/v2/Schemas` and
`/scim/v2/ResourceTypes` next to it.

## SCIM tokens

The provider authenticates with a bearer token made for SCIM. It is not a
[personal access token](/docs/api/) and is created and revoked separately.

Create one in the web UI, as a system administrator, under **Administration →
SCIM** (`/settings/scim`). The page shows the endpoint URL, lists the tokens
(name, who created it, last use, status), creates and revokes them. The token
value is shown **once**, and only after you confirm your identity again with your
password, a TOTP code or, for an SSO-only administrator, an SSO sign-in within
the last 15 minutes, the same rule as for API tokens. An SSO-only administrator
who is sent to the provider for that confirmation lands back on the SCIM page.
While SCIM is off, the page explains how to turn it on; existing tokens stay
listed and can still be revoked, so a leaked token can be closed at any time.

Or use the command line:

```bash
php artisan queryproxy:scim-token create --name=entra --by=admin@example.com
php artisan queryproxy:scim-token list
php artisan queryproxy:scim-token revoke --id=3 --by=admin@example.com
```

`--by` is the administrator's e-mail, for the audit log. A token starts with
`qpscim_`. QueryProxy stores only its SHA-256 hash, so a lost token cannot be
shown again: revoke it and create a new one.

A request without a valid token gets `401` with a SCIM error body. See
[Rate limits](#rate-limits) for what repeated failures do.

## What is supported

| | |
| :-- | :-- |
| Resources | `Users` and `Groups` |
| Operations | `GET` (one and list), `POST`, `PUT`, `PATCH`, `DELETE` |
| Filter | `eq` on `userName`, `externalId`, `id` and `emails.value` for users; on `displayName`, `externalId` and `id` for groups |
| Pagination | `startIndex` and `count`, up to 200 resources per page |
| Not supported | bulk operations, sorting, ETags, password changes |

Errors use the SCIM `Error` schema. An unexpected failure answers `500` in that
schema and leaves the internal reason out of the body.

## Users

- **Create.** `POST /Users` makes an account **without a password**. The user
  signs in through SSO; on the first sign-in QueryProxy links the account to the
  provider by e-mail address, if the provider says the address is verified (see
  [first sign-in and linking](/docs/sso-oidc/#first-sign-in-linking-and-verified-e-mail)).
  If `userName` is not an e-mail address, as with an Entra ID user principal
  name, the primary address in `emails` is the identity and `userName` is kept
  for matching.
- **Update.** `PUT` and `PATCH` change the name, the e-mail address and the
  active flag. A `PUT` that leaves out `active` keeps the current state. Paths
  QueryProxy has no use for (a job title, the enterprise extension) are ignored
  rather than refused, because providers send them on every sync. A value that is
  wrong for a path it does know gets `400` with `invalidValue`. The strings
  `"True"` and `"False"`, which Entra ID sends for booleans, are accepted.
- **Suspend, never delete.** `active=false` and `DELETE` **suspend** the user:
  open sessions and personal access tokens stop working at once and the account
  cannot sign in. Nothing is deleted. After a `DELETE` the user still exists and
  reads back with `active=false`.
- **Lift a suspension.** `active=true` lifts only a suspension **SCIM itself
  set**. If an administrator suspended the account by hand, SCIM leaves it
  suspended and writes no audit entry. If an administrator suspends an account SCIM
  already suspended, the administrator takes it over, and SCIM can no longer lift
  it.
- **Names must be free.** A `userName` or an e-mail address that already belongs
  to another account answers `409` with `uniqueness`.

SCIM manages **only the users it created and the users already linked to the
provider** through SSO. Local accounts, including break-glass administrators, are
invisible to it: `GET`, `PATCH` and `DELETE` on them answer `404`, and creating a
user with the same e-mail address answers `409`. A stolen SCIM token therefore
cannot suspend or rename the account you keep for emergencies. SCIM never sets
the system administrator flag either.

An account SCIM created counts as an SSO account. It gets no password through the
reset link, and the administrator's **Reset password** action refuses it. See
[Single sign-on](/docs/sso-oidc/#password-login-next-to-sso).

## Groups and team roles

`Groups` are stored in QueryProxy, with their members. They turn into team roles
through the same `QUERYPROXY_OIDC_ROLE_MAP` that single sign-on uses; see
[the role map](/docs/sso-oidc/#the-role-map). The map is the one place that says
which provider group means which role in which team.

A SCIM group matches a role-map entry by its **`displayName` or its
`externalId`**. Which one carries the value you wrote depends on the provider:

- **Entra ID.** Microsoft's SCIM reference maps the group's `objectId` to
  `externalId` (see [Microsoft's SCIM guide](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups)),
  and Entra ID puts object IDs in the `groups` claim, so the role map is written
  with the object ID:
  `1f9c0c2e-0000-0000-0000-000000000000:payments:dba`. Check your enterprise
  application's group attribute mapping before you rely on it.
- **A provider that sends the group name** as `displayName` matches an entry
  written with the name: `platform-dba:payments:dba`.

What happens when membership changes:

- QueryProxy recomputes the roles of the affected user **only in the teams named
  by the role-map entries that match the changed group**. Roles in other teams,
  including ones given at sign-in or by hand, are not touched.
- In a team, the user's role is the highest one among all their SCIM groups
  (`dba`, then `developer`, then `auditor`).
- A group that no entry matches is accepted and has no effect on roles.
- Renaming a group, or changing its `externalId`, recomputes every member. That
  runs inside the request, so renaming a group of thousands of members is slow.
- Every role change is written as `user.role_synced`, with `via` set to `scim`.

SCIM and sign-in both write roles. If the provider reports different groups in
the ID token than over SCIM, whichever wrote last wins, so keep both sources
consistent. Without a role map, SCIM can create and suspend users but gives no
team roles.

## Rate limits

`QUERYPROXY_SCIM_RATE_LIMIT` (default `300`) is the request budget of each token
per minute. It is also the number of failed authentications one address may make
per minute: past it, the address gets `429` with `Retry-After` instead of `401`.

| Variable | Default | Meaning |
| :-- | :-- | :-- |
| `QUERYPROXY_SCIM_ENABLED` | `false` | Serve `/scim/v2`; while off every SCIM path answers `404` |
| `QUERYPROXY_SCIM_RATE_LIMIT` | `300` | Requests per minute for each token, and failed authentications per address |

## In the audit log

| Event | When |
| :-- | :-- |
| `scim.user_provisioned` | A user was created |
| `scim.user_updated` | A user's details changed |
| `scim.user_suspended` | `active=false` or `DELETE` |
| `scim.user_unsuspended` | SCIM lifted its own suspension |
| `scim.group_synced` | A group or its members changed |
| `scim.token_created`, `scim.token_revoked` | A SCIM token was created or revoked |

## Upgrading

Run the migrations as usual. They add the SCIM tables (`scim_tokens`,
`scim_groups`, `scim_group_user`) and four columns on `users`; all can be rolled
back. Nothing changes until you set `QUERYPROXY_SCIM_ENABLED=true`.

## Next steps

- [Single sign-on](/docs/sso-oidc/) for the sign-in side and the role map.
- [REST API and tokens](/docs/api/) for the other kind of token.
- [Audit chain and SIEM export](/docs/audit-export/) for sending the events on.
