SCIM deprovisioning
Your IdP pushes
active: false; lumnik archives the user; the very next request is refused, even with a still-valid token. The departure half of the identity loop.
Edition
The whole /scim/v2/* surface and its token endpoints ship in lumnik Pro, the image the
deploy scripts pull. They are absent from lumnik Open.
Editions
sequenceDiagram
participant I as your IdP
participant H as hub
participant U as user
I->>H: PATCH /scim/v2/Users/{id} · active:false · Bearer lmscim_…
H->>H: archive the user (archived_at)
H-->>I: 200
U->>H: any request, token still valid
H-->>U: 403 {"error":"user is deactivated"}
Why
- Arrival is automatic: a user who logs in through your IdP is created at first login (BYO IdP). Departure is not: the IdP keeps issuing tokens until the account is disabled there, and the hub would keep honouring them.
- SCIM closes the loop. Arrival and departure address the same row: JIT keys it
(idp_issuer, idp_subject), SCIM reaches it through(issuer, externalId). - JIT is passive: it creates, never reactivates. Once archived, only SCIM (or an admin) brings a user back.
- The integrator's departure is a different checklist: Offboarding.
The auth model
- SCIM requests carry no user JWT. Each tenant mints opaque bearer tokens
(
lmscim_+ 32 random bytes, base64url: 256 bits); your IdP presents one on every call. The surface is not rate-limited; the entropy is the defence. - Minting needs
lm_admin, for your own tenant only. The plaintext is returned once; only its SHA-256 hash is stored, never logged, never shown again. - A token is scoped to one tenant and one issuer: it cannot see local users, another
IdP's users, or another tenant's. Such lookups answer a SCIM
404, not a leak. - Missing, unknown, revoked, or a user JWT instead of a SCIM token:
401, same body in every case by design. The application log tells them apart:SCIM auth refused: GET /scim/v2/… reason=missingorreason=unknown-or-revoked.
$ curl -s https://<hub>/scim/v2/Users -H "Authorization: Bearer <a user JWT>"
{"schemas":["urn:ietf:params:scim:api:messages:2.0:Error"],"detail":"invalid SCIM token","status":"401"}
Wiring your IdP
-
Mint a token, as an
lm_adminof the tenant.issuermust be the exactissyour IdP puts in its JWTs, byte for byte, or SCIM manages a parallel set of users logins never hit:curl -X POST "https://<hub>/api/tenants/<tid>/scim-tokens" \ -H "Authorization: Bearer <your lm_admin JWT>" \ -H "Content-Type: application/json" \ -d '{"issuer": "https://idp.example.com/realms/acme", "label": "okta-prod"}' # → 201 {"id":1,"issuer":"…","label":"okta-prod","token":"lmscim_…"} ← save the token NOW -
Configure the IdP's SCIM app: base URL
https://<hub>/scim/v2, authentication Bearer token with thelmscim_…value, content typeapplication/scim+json(PATCH also acceptsapplication/json). - Map attributes:
externalId= the JWT subject claim (subby default); it is the join key.userName,displayName/nameand the primaryemailsentry are synced as profile. - Verify: most IdPs test
GET /scim/v2/ServiceProviderConfig, then a dedup lookupGET /scim/v2/Users?filter=userName eq "jdoe"before creating.
The end-to-end loop, provision → login 200 → active:false → login 403, is exercised by the
Helm smoke test on every oidc-external rehearsal.
The /scim/v2/Users contract
| Verb | Path | Does |
|---|---|---|
POST |
/Users |
find-or-create by (issuer, externalId), then apply active + profile. 201. Authoritative: a re-POST revives a soft-deleted user and may re-activate an archived one. externalId required |
GET |
/Users/{id} |
one user by lumnik id. 404 if absent, deleted, or keyed to another issuer |
GET |
/Users?filter=… |
only externalId eq "x" and userName eq "x"; 0 or 1 result. Any other filter answers an empty list. Soft-deleted excluded, deactivated included |
PUT |
/Users/{id} |
full replace: active, display name, primary email |
PATCH |
/Users/{id} |
the deprovisioning operation: replace of top-level active only. A non-boolean active, or another op on it, is 400; a malformed value is never coerced into a deactivation. Other paths are silently ignored |
DELETE |
/Users/{id} |
soft-delete, 204. The row stays, keeping the key occupied so a later login resolves to it and is refused |
Deactivate vs erase
active: falsearchives. The row and its history stay;archived_atis stamped. Denial is immediate for both token shapes,user_id-claim or external identity. Only SCIM, being authoritative, pushes the user back withactive: true.DELETEsoft-deletes. Reversible by a re-POST of the same(issuer, externalId).- Neither is a GDPR erasure. The row remains until an operator removes it: GDPR & personal data.
- A SCIM write leaves no audit row. Deprovisioning, the event an access reviewer most wants, is not recorded. The audit trail
Not supported
- Groups: no
/scim/v2/Groups. Roles are managed in the IdP realm, not pushed over SCIM. - Bulk, sorting, etags, changePassword: declared unsupported in
ServiceProviderConfig. - Pagination: the only list forms are the two
eqfilters above. - The full PATCH path grammar:
emails[type eq "work"].valueis ignored; usePUT. /Schemasand/ResourceTypes: IdPs that require schema discovery need their mappings configured by hand.- Unfiltered
GET /Usersanswers an empty list, not the tenant's population.
Token lifecycle
All three need lm_admin and your own tenant id in the path:
# Mint — plaintext in the response ONCE, never retrievable again
POST /api/tenants/{tid}/scim-tokens {"issuer": "<idp iss>", "label": "okta-prod"}
# List active tokens — id, issuer, label, createdAt; never the hash, never the plaintext
GET /api/tenants/{tid}/scim-tokens
# Revoke — effective immediately, 204
DELETE /api/tenants/{tid}/scim-tokens/{id}
Rotation is mint-then-revoke: several tokens can be active at once (label tells them apart),
so switching the IdP over needs no provisioning outage.
Troubleshooting
| Symptom | Likely cause |
|---|---|
Plain 404 on every /scim/v2/* call, not the SCIM-shaped body above |
lumnik Open: the SCIM surface is not in that build |
401 invalid SCIM token |
Missing Authorization: Bearer, a token never minted for this hub, a revoked one, or a user JWT. Identical response in every case |
403 when minting, listing or revoking |
Your JWT lacks lm_admin, or the {tenantId} in the path is not your own tenant |
404 on GET/PUT/PATCH/DELETE /Users/{id} |
No such user, soft-deleted, or the user's issuer differs from the token's: the most common wiring mistake. Compare the token's issuer with the exact iss of your IdP's JWTs (scheme, host, port, path) |
Dedup search finds nothing, then POST returns an existing user |
Normal: filters exclude soft-deleted rows, POST is find-or-create |
| Deactivated user can still call the API | A request already executing finishes; every request after it is denied. If later calls still succeed, confirm the PATCH landed (GET /Users/{id} → "active": false) and the issuer matches |
See also
- Bring your own IdP: the arrival side, issuer and subject configuration.
- Offboarding the integrator · GDPR & personal data