SCIM provisioning: deactivation without an administrator¶
Disabling a person at your identity provider doesn't end what the hub has
already issued them. Their dashboard session lasts until cloop next checks
with the provider. Their API tokens, glasses links and sandbox leases last
until they expire, and their running tasks keep running. The
runbook's "Somebody leaves" lists
all of it, and cloop hub user offboard ends it, but only once someone
remembers to run it.
With SCIM 2.0 provisioning (RFC 7643/7644), the identity provider (Microsoft
Entra ID or Okta) keeps a copy of its directory on the hub at /scim/v2. When
it deactivates an account, the hub suspends that person in one request, and
nobody has to do anything.
What the provider can make the hub do¶
| At the identity provider | It sends | The hub |
|---|---|---|
| A person is assigned to the application | POST /Users |
Records a provisioned user. This grants no access: roles still come from the role policy |
| They are disabled, deleted, unassigned, or fall out of a scoping filter | active: false (a PATCH or PUT) |
Suspends them (below) |
| They are re-enabled, restored or reassigned | active: true |
Lifts what the suspension wrote. Anything it ended stays ended |
| They are permanently deleted | DELETE /Users/{id} |
Runs the full offboarding, cloop hub user offboard, honouring a legal hold |
| A group is assigned, renamed, or its members change | POST/PATCH /Groups |
The membership becomes a claim, scim_group, read at every authorization decision |
A suspension¶
One transaction writes all of this, so either all of it applies or none of it does:
- a deny binding on every identifier the person is known by (their email,
subject, and the
preferred_usernameoroidthe hub matched them on); - their sessions end: the dashboard's WebSocket closes with
credential_ended, and so do theircloopterminal sessions; - their API tokens and glasses links are revoked;
- every live grant over their personal secrets is suspended. A project that leases one of their credentials is refused it, with an error naming the credential, until they are reactivated.
Then, because these cannot join a database transaction, each in turn:
- their sandbox leases are revoked on whichever hub member or executor holds them;
- their running tasks are told to stop.
A suspension keeps their project memberships and stored credentials (personal secrets, Claude Code logins), because a disabled account is often temporary and a returning person should find their projects as they left them. Nothing is reissued: a reactivated person signs in again and creates new tokens.
A reactivation removes only what its suspension wrote. A deny binding that an administrator added for the same person, before or during the suspension, stays.
The audit row is scim.user_suspend, with a count for each kind of access
ended. If a lease or task could not be reached, scim.user_suspend_incomplete
says which. Alert on it.
Deletion, and accounts kept disabled¶
A DELETE runs the full offboarding as the actor scim:<credential name>,
with via: scim: everything above, plus destroying the stored credentials,
removing the memberships and reporting the projects. It is final. The hub
forgets the provisioned user, and recreating them starts from nothing, except
that the offboarding's deny bindings stay. A rehire with the same address or
username is refused until an administrator removes those bindings
(cloop hub role list --identity <email>, then
cloop hub role delete <id> --reason "…").
Entra sends a DELETE 30 days after an account is deleted, and never for an
account that was only unassigned. You can have the hub finish the job itself:
The hub's leader then fully offboards anyone the provider has kept deactivated
for 30 days, within 15 minutes of the deadline, as
scim.user_offboard_expired. That is final too: if the provider reactivates
the account afterwards, the hub marks it active but keeps the offboarding's
deny binding until an administrator removes it the same way, and what was
destroyed stays destroyed.
Setting it up¶
You need single sign-on (ui.oidc) first. Provisioning keeps the hub's view of
people who sign in up to date, and /scim/v2 answers 404 on a hub without it.
1. Choose what a provisioned user is matched on. The provider sends a user
record, and the hub has to know which signed-in identity it describes.
ui.oidc.scim.match_claim names the claim, and the provider attribute must
carry the same value:
match_claim |
Provider attribute | ID token claim | Use it for |
|---|---|---|---|
email (default) |
primary (or work) email | email |
Okta; Entra when every user has a mailbox |
preferred_username |
userName |
preferred_username |
Entra's default mappings: both are the user principal name |
oid |
externalId |
oid |
Entra, with objectId mapped to externalId. Immutable, never reused |
sub |
externalId |
sub |
Okta, which sends its user ID as externalId and as sub |
A provisioned user whose value two hub identities carry is ambiguous. The
hub doesn't guess: it denies that value only and gives neither identity the
user's groups (scim.match_ambiguous). A user who carries no value is
unmatchable, and deactivating them reaches nobody. cloop hub scim status
and cloop hub doctor count both.
2. Create the provider's credential, on the hub or under Settings → Provisioning (SCIM):
$ cloop hub scim credential create entra
Minted SCIM credential entra (3f9c1a7e5b2d4c60)
cloop_scim_3f9c1a7e5b2d4c60_…
This is the only time it is shown. Paste it into the identity provider as the secret token,
with the tenant URL https://<this hub>/scim/v2. The hub keeps only an HMAC of it.
The name becomes the audit actor for everything the provider does,
scim:entra.
3. Point the provider at <external_url>/scim/v2 with that credential as a
bearer token (Entra or Okta below).
4. Check it. After the provider's first cycle:
Microsoft Entra ID¶
The Terraform module
creates all of this with scim_provisioning = {}. By hand, in the Entra admin
center:
- Enterprise applications → New application → Create your own application, choose Integrate any other application you don't find in the gallery (Non-gallery). It must be separate from the application users sign in with, because Entra provisions only applications created this way.
- Provisioning → New configuration (or Get started, mode Automatic).
Authentication: bearer token. Tenant URL
https://<hub>/scim/v2, Secret token the credential. Test connection (the hub answers the probe for a random user with an empty list), then save. - Users and groups: assign the people and groups who can sign in to the hub. Keep the scope at Sync only assigned users and groups. With all users, Entra copies the whole tenant into the hub.
- Attribute mapping, only for
match_claim: oid: under Provision Microsoft Entra ID Users, change the source ofexternalIdfrommailNicknametoobjectId. Until then the hub counts every user unmatchable. - Properties: a notification email for quarantine, and accidental deletions prevention, which pauses the job when one cycle would deactivate more users than a threshold, as unassigning the wrong group would.
- Start provisioning. Provision on demand pushes one user now, so you can test a deactivation without waiting for a cycle.
Entra runs a cycle about every 40 minutes, and a disabled account reaches the hub at the next one. To cut someone off within five minutes, offboard them by hand.
The hub handles Entra's dialect: capitalised operations ("op": "Replace"),
booleans as the strings "True" and "False", member removal by a list of
values, the ADSCIM group schema, a manager given as a bare ID, and the
validator's filtered add that creates the element it names.
pkg/ui/testdata/scim holds recorded Entra provisioning and SCIM Validator
conversations, which TestSCIM_ReplaysRecordedProvisioningSequences replays
against a real hub.
Okta¶
- On the hub's app integration, General → App Settings → Provisioning: SCIM, save.
- Provisioning → Integration: SCIM connector base URL
https://<hub>/scim/v2, unique identifier fielduserName, provisioning actions Push New Users, Push Profile Updates and Push Groups, authentication mode HTTP Header with the credential as the bearer token. Test Connector Configuration, save. - Provisioning → To App: enable Create Users, Update User Attributes and Deactivate Users.
- Set
match_claim: sub: Okta sends its user ID asexternalId, and the same ID is thesubof its tokens.
Okta deactivates with a path-less PATCH or, on older integrations, a full
PUT with active: false. It sends a password, which the hub discards and
never returns, and it renames a group with a value that repeats the group's own
ID. The recorded Okta sequence covers all of this too.
Provisioned groups as a claim¶
Groups the provider pushes become a claim kind, scim_group, that role
bindings can name. It matches the group's display name or its externalId
(Entra sends the group's object ID there):
ui:
oidc:
role_mappings:
- {claim: scim_group, value: "Platform Engineering", role: admin}
- {claim: scim_group, value: "Payments", role: maintainer, project: payments}
or at runtime, cloop hub role grant scim_group Payments --role maintainer
--project payments --reason "…".
The hub reads membership from its copy of the directory at every authorization
decision, not from the token. Removing someone from the group at the provider
demotes them on their next request after the provider's cycle, and there is no
200-group overage. Only an active, uniquely matched provisioned user has
groups. A deny binding can name a group too
(cloop hub role revoke scim_group Contractors --reason "…").
Legal holds¶
A hold records that an identity's stored credentials must be kept:
$ cloop hub user hold place alice@example.com --reason "litigation LH-17"
$ cloop hub user hold list
$ cloop hub user hold release alice@example.com --reason "LH-17 closed"
Every offboarding of a held identity (a DELETE from the provider,
offboard_after_days, or an operator's cloop hub user offboard) then
behaves like --keep-credentials: access is cut off, personal secrets and
Claude Code homes are kept, and user.offboard_hold records that the hold was
in force. Releasing the hold destroys nothing by itself. Run the offboarding
again to finish it. Holds are keyed by email or by sub:<subject>, so give the
one the provider's user carries. Settings → Provisioning lists and edits
them too.
The credential¶
- Created on the hub, shown once, stored as an HMAC. Through
Settings → Provisioning,
cloop hub scim credential create, orPOST /api/scim/credentials. Managing credentials needsuser.manage. - Accepted on
/scim/v2only, not as an API token or on the dashboard, and only in theAuthorizationheader. It is checked before anything else, so a correct credential is accepted even from a locked-out address. A wrong one counts against SCIM's own lockout (five a minute per address, then429for a minute), kept apart from the dashboard's sign-in lockout so that neither a neighbour on the provider's egress address nor a misconfigured client beside the dashboard can lock out the other. - Rate-limited per credential:
ui.oidc.scim.rate_limit_per_minute(default 600, in bursts of up to a sixth). Past it, the hub answers429withRetry-After, which Entra and Okta both honour. - Rotated with an overlap:
cloop hub scim credential rotate entra --grace 1hprints a new secret and keeps the old one working for the hour, so paste the new one into the provider within it. The grace is at most seven days. - Revoked at once:
cloop hub scim credential revoke entra --reason "…". The provider's next request is refused with 401 and its job goes into quarantine, which is what you want if the credential leaked.
Refused requests are audited, with a per-minute cap, as scim.request_refused
with the reason: no credential, an unknown or revoked one, or rate limited.
Every accepted change is audited under scim:<name>, and the
audit reference lists each action.
Retries, reordering and conflicts¶
Providers retry, and their requests can arrive out of order. The hub handles both:
- A repeated request changes nothing. A second deactivation of a suspended user writes no second suspension, a member added twice is a member once, and removing an absent member succeeds.
- Every resource carries a version (
meta.version, theETagheader). A request whoseIf-Matchnames an older version is refused with 412. One withoutIf-Matchis applied to the current state, and retried on a concurrent write. - A
PUTthat leaves outactiveleaves it as it is. A provider replaying an old profile update cannot reactivate a suspended user. - Unique attributes are enforced (
userNameand whatever the match claim reads) with 409uniqueness, before anything is suspended.
What the hub does not support¶
/scim/v2/ServiceProviderConfig advertises bulk operations, sorting and
password changes as unsupported. A request to /Bulk or /Me gets a SCIM
error, sortBy is ignored (results come in creation order), and a password a
provider sends anyway is discarded and never returned.
Troubleshooting¶
| Symptom | Cause |
|---|---|
| Every request answers 404 | The hub has no ui.oidc (SCIM is served only with single sign-on), or the provider's URL is not <external_url>/scim/v2 |
| 401, and the provider quarantines the job | Credential unknown, revoked, or rotated and past its grace period. Check cloop hub scim credential list, then rotate and paste |
| 429 during the first cycle | The rate limit. Raise ui.oidc.scim.rate_limit_per_minute for a large directory. The provider paces itself either way |
409 uniqueness for a user |
Another provisioned user already has that userName or match value, usually an account recreated at the provider while the old one is still provisioned |
| A deactivated person keeps working | Not matched. cloop hub scim status counts unmatchable and ambiguous users. With oid, check the externalId mapping |
| A group binding grants nothing | The user is inactive, ambiguous or unmatched, or the group was not assigned to the provisioning application, so the provider never pushed it |
See also¶
- Configuration reference:
ui.oidc.scim - Commands:
cloop hub scim,cloop hub user hold - HTTP API: the
/scim/v2routes and/api/scim - Threat model: what the credential can and cannot do
- Runbook: somebody leaves