SCIM provisioning
Once Bowire knows who is calling (an auth provider) and gives each identity its own slot (per-identity storage), one thing is still manual: the list of who exists. Somebody joins, somebody leaves, somebody changes team — and an operator has to do something about it by hand, or not at all.
SCIM 2.0 (RFC 7644) is what every identity provider speaks for exactly that. Point yours at Bowire and the list keeps itself.
The half that matters. Provisioning is easy to build as bookkeeping — a flag written to disk that nothing reads. Bowire enforces it: a deactivated identity is refused at the door, and their state is moved out of reach. An install where deactivating in Okta leaves someone working until a person notices has not deprovisioned anybody.
Turning it on
{
"Bowire": {
"MultiTenant": { "Enabled": true },
"Scim": {
"Enabled": true,
"Token": "…a long random secret…"
}
}
}
Enabling without a token is refused at startup, not served open: a provisioning endpoint reachable by anyone who can route to the host is a way to create identities.
The endpoints mount at /scim/v2 — outside the workbench's own route group, and on purpose. Those routes are gated by whatever auth provider you configured, and a provisioning connector holds a shared secret rather than a user session; it could never pass that gate. SCIM authenticates itself, with its own token, on its own path.
| Key | Default | |
|---|---|---|
Bowire:Scim:Enabled |
false |
Mounts the endpoints. |
Bowire:Scim:Token |
— | The bearer token the IdP presents. Required. |
Bowire:Scim:BasePath |
/scim/v2 |
Where they mount. |
Bowire:Scim:PurgeAfter |
30.00:00:00 |
How long a deprovisioned identity's state is kept. |
Bowire:Scim:EnforceActive |
true |
Refuse a deactivated identity at the door. |
Bowire:Scim:RequireProvisioned |
false |
Refuse an identity the directory has never heard of. |
Bowire:Scim:AdminGroup |
bowire-admins |
The group whose members count as administrators. |
Bowire:Scim:DefaultPageSize / MaxPageSize |
100 / 500 |
List paging. |
Pointing an identity provider at it
Both connectors need two things: the base URL and the token.
- Okta — Applications → your app → Provisioning → Configure API Integration. Base URL
https://bowire.example.com/scim/v2, API token as configured above. Enable Create Users, Update User Attributes and Deactivate Users. - Entra ID — Enterprise applications → your app → Provisioning. Tenant URL
https://bowire.example.com/scim/v2, Secret Token as configured above. Test Connection reads/ServiceProviderConfigbefore it will save.
Which claim has to line up
This is the part that quietly does not work if it is wrong.
Provisioning identifies a person by userName and externalId. A token identifies them by whatever claim Bowire:MultiTenant:SubjectClaim names. Bowire ties the two together on the person's first request, matching the token's subject against the record's externalId, then its userName. So one of those has to be what your tokens actually carry:
- Entra ID — the connector sends the object id as
externalId, and tokens carry it asoid. SetSubjectClaimtooid. - Okta — the connector sends the Okta user id as
externalIdand the login asuserName; the defaultsubclaim usually matches the latter, so the fallback covers it.
Once matched, the subject is written onto the record and used from then on — which is what keeps a rename from orphaning somebody's work.
What happens when someone is deprovisioned
Deactivating (PATCH … active: false) or deleting (DELETE) does three things:
- The record is marked inactive. It is not removed — deprovisioning is routinely undone, and a hard delete makes those recoverable only from a backup.
- Their slot is moved aside, under
users/.deprovisioned-…. They cannot reach it, and neither can anyone else. - Their next request is refused with
403andurn:bowire:scim:deprovisioned.
Reactivating puts the slot back exactly where they left it. That is what makes "deactivate" reversible rather than a polite word for delete.
After Bowire:Scim:PurgeAfter — 30 days by default — a daily sweep deletes the record and the archived slot for good. Set it to 00:00:00 to delete immediately, if your retention policy says so.
Every one of these decisions is appended to scim/events.jsonl under the storage root: what happened, to whom, and when. The record files only ever show the current answer, and "who removed this person" gets asked months later.
What is implemented, and what is not
Said plainly, because a connector that is told something works and then gets a 404 retries the whole sync instead of falling back. /ServiceProviderConfig advertises exactly this list.
| Users | GET (list + by id), POST, PUT, PATCH, DELETE |
| Groups | GET (list + by id), POST, PUT, PATCH, DELETE |
| Discovery | /ServiceProviderConfig, /ResourceTypes, /Schemas |
| Filtering | eq and pr, joined with and / or |
| Paging | startIndex (1-based) and count |
| Not implemented | Bulk, sorting, ETags, password change |
The filter subset is deliberate. The full grammar has ten operators, complex attribute paths and value sub-filters; the connectors that matter send one shape between them, userName eq "someone@example.com". Anything outside the subset is refused with 400 and invalidFilter rather than half-evaluated — a parser that ignores the part it did not understand answers a different question, and the caller has no way to tell.
Attributes Bowire does not model — the Enterprise User extension, whatever your directory maps — are stored verbatim and returned on the next GET. A connector that reads back a resource missing what it just wrote concludes the write failed, and retries forever.
Groups and the administrator role
Group membership is stored and synced. Its one consumer today is Bowire:Scim:AdminGroup: members of that group are the identities Bowire treats as administrators. The surfaces that act on that — a user chip, scoped state copies, admin impersonation — are #98 and not shipped yet, so provisioning the group now is preparation rather than an effect you will see in the workbench.
The group name is configuration rather than a constant, because an IdP's group for this is called whatever your directory calls it.
Checking it by hand
TOKEN='…'
BASE='https://bowire.example.com/scim/v2'
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/ServiceProviderConfig"
curl -s -X POST "$BASE/Users" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/scim+json' \
-d '{"schemas":["urn:ietf:params:scim:schemas:core:2.0:User"],
"userName":"ada@example.com","externalId":"8f14e45f","active":true}'
curl -s -H "Authorization: Bearer $TOKEN" \
--get --data-urlencode 'filter=userName eq "ada@example.com"' "$BASE/Users"
bowire users list on the host shows the identity slots and what each one decided about the single-user migration; the SCIM record list is under scim/ in the storage root.
Proving it against a real directory
Everything above is exercised by tests against fixtures. That answers "does Bowire reply correctly to what we send it" and not the question a live round-trip is for, which is what a real connector does: how it walks a directory, what it sends that Bowire does not model, how it retries, and whether it treats a deactivated user as absent or as inactive. None of those are Bowire's decisions (#639).
Turn the trace on first
scim/events.jsonl records mutations and their outcome, so a provider's
reads — the paging walk, the existence filter — leave no trace at all, and
the reads are most of what the exercise is for. Set:
"Bowire": { "Scim": { "TraceProvisioning": true } }
Every request then appends one line to scim/trace.jsonl:
{"at":"2026-09-07T09:14:22Z","method":"GET","path":"/scim/v2/Users",
"query":"?startIndex=101&count=100","status":200,"ms":18.4}
{"at":"2026-09-07T09:14:31Z","method":"PATCH","path":"/scim/v2/Users/8f14e45f",
"status":200,"ms":22.1,"patchDialect":"entra",
"unmodelled":["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]}
patchDialect is read off the wire — the casing of op and whether a path
is present — never from a User-Agent, which a proxy may rewrite. A document
matching neither shape is reported as other rather than forced into one,
because that is the finding, not an inconvenience.
Turn it off again afterwards. The lines contain what the connector sent: user names, e-mail addresses, the filters a directory walk used. That is personal data about people who did not agree to be in a debug file, and it is wanted for a bounded exercise, not for normal operation.
The matrix
Run it once per provider. Each row is a thing to do in the IdP and a thing to check here — the point is the second column, because a connector reporting success is not evidence that anything happened.
| Step | Do | Confirm |
|---|---|---|
| 1 | Assign a user to the app | POST /Users in the trace, create in the events, bowire users list shows the slot |
| 2 | Let the initial sync run | The paging walk in the trace: how many GET /Users, with which startIndex and count |
| 3 | Change the display name | PATCH or PUT, update in the events, the change visible without a restart |
| 4 | Add the user to the admin group | members on the group, Bowire:Scim:AdminGroup matches |
| 5 | Sign in as that user | The subject binds — bind in the events. This is where a wrong SubjectClaim shows itself |
| 6 | Deactivate in the IdP | PATCH with active:false; note patchDialect and how long step 6 took to arrive |
| 7 | Try to sign in | Refused at the door, slot archived and still present |
| 8 | Reactivate | Slot restored with the work still in it |
| 9 | Unassign the user | DELETE, delete in the events, slot archived |
| 10 | Let the purge window elapse (or set PurgeAfter to zero) |
purge in the events, slot gone |
Two things to watch for that no fixture can produce:
unmodelled— attributes the directory maps that Bowire keeps verbatim and does not act on. Harmless until one of them is the one that mattered.- Anything with a 4xx or 5xx status. A connector that retries silently will make the sync look green while the trace says otherwise.
What was actually exercised
The point of the table is that it names versions and dates. "SCIM works" ages badly; "Okta's connector as of this date did this" does not.
| Provider | Version / build | Date | Result | Notes |
|---|---|---|---|---|
| Okta | not yet run | — | — | needs a developer org (#639) |
| Entra ID | not yet run | — | — | needs a tenant with an Enterprise App |
Fill a row when a round-trip completes, and file whatever it revealed — either fixed, or written down here as a documented limit.
Related
- Multi-user deployment — the auth gate and per-identity storage this builds on
- Where Bowire stores things — the layout
scim/andusers/live in