SCIM outbound
Push a tenant's members and groups to downstream SaaS applications over SCIM 2.0, with previews, account adoption, and automatic deprovisioning.
(System for Cross-domain Identity Management) is the standard HTTP API that applications expose so that another system can create, update, and remove their user accounts. Most business SaaS tools accept it: Slack, GitHub, Zoom, Atlassian, and many more.
The problem it solves. When Better IAM knows who belongs to an organization, the people in that organization also need accounts in the other tools they use, with the right name, email, and department. More importantly, they need to lose those accounts when they leave. Doing that by hand leaves orphaned accounts behind, which is how former employees keep access to company data.
createScimProvisioner fixes this by acting as a SCIM client. For each downstream application you add a
target: the application's SCIM URL and a token it issued. The provisioner then keeps that application's user
directory in step with the 's members: it creates accounts for people who join, updates them when their
details change, and deactivates or deletes them when people leave.
Who configures it. Your team sets up the provisioner and schedules its syncs once. An organization administrator then adds targets in your admin UI or the console's App provisioning page, using a token from each application's own admin console. Your platform can also add targets itself when it launches downstream services per organization. This is the opposite direction to SCIM inbound, where a customer's directory pushes people to you.
Set up the provisioner
Create the provisioner with the host callbacks and an encryption key for the stored tokens, mount its management API, and keep it in sync both on events and on a schedule:
import { createScimProvisioner } from 'better-iam/scim';
export const provisioner = createScimProvisioner({
...iam.protocolHost,
encryptionKey: secrets.base64Encoded32ByteKey,
basePath: '/api/iam/provisioning',
});
iam.useProtocol(provisioner); // serves the JSON management API
provisioner.subscribe(iam.events); // sync after member changes
setInterval(() => void provisioner.syncAll(), 15 * 60_000).unref(); // and on a scheduleProp
Type
Add a target
A target is one downstream application for one organization. Adding it needs only the application's SCIM URL and the token it issued; nothing is sent until the next sync:
const target = await provisioner.createTarget(credential, {
tenantId,
name: 'Slack',
baseUrl: 'https://api.slack.com/scim/v2',
token: slackScimToken,
groupIds: [engineeringGroupId], // omit for every member
attributeMapping: { department: 'department', title: 'jobTitle' },
});Prop
Type
createTarget returns the target summary: its settings, provisioned (the number of linked, active downstream
accounts), tokenUpdatedAt, and lastRun once a sync has run. The token is never returned.
What the application receives
Each target receives every active user member in scope as a SCIM user. With the mapping above, a member with the
identity attributes department: "Research" and jobTitle: "Principal Engineer" is sent as:
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
],
"externalId": "5d1c9f3e-7a2b-4c8d-9e0f-1a2b3c4d5e6f",
"userName": "ada@acme.com",
"displayName": "Ada Lovelace",
"name": { "formatted": "Ada Lovelace" },
"emails": [{ "value": "ada@acme.com", "type": "work", "primary": true }],
"title": "Principal Engineer",
"active": true,
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": { "department": "Research" }
}externalId is the Better IAM identity ID, which never changes, and userName plus the primary work email are the
member's email address.
Who is in scope. A member is provisioned when they are a user (not a
), active, have an email address, are not past their expiry, and,
when groupIds is set, belong to one of those . Temporary and
memberships stop counting at their end, before the purge worker
removes them.
How a sync works
- Adoption. A first sync adopts an existing downstream user with the same
externalIdoruserNameinstead of creating a duplicate, so turning provisioning on for an app people already use is safe. If several downstream users match, that member fails and is reported. - Updates. Later runs replace users whose data changed and skip unchanged ones without calling the service.
A user the service lost (
404) is recreated. - Leaving scope. Members who are disabled, deleted, past their expiry, or no longer in the scoped groups are
deactivated downstream (
PATCH active: false), or deleted whendeprovision: 'delete'. - Returning. The same downstream account is reactivated when they come back.
- Everything off. A disabled target, or a suspended tenant, deprovisions everyone at the next run.
A failure is retried by the next run and never stops the rest of the run. One run per target executes at a time; concurrent requests for the same target share the running one.
Keep targets current
Three calls run syncs, for different situations:
| Call | Use it for |
|---|---|
subscribe(iam.events, { debounceMs?, onError? }) | Near-real-time updates. It schedules a sync of the tenant's targets after identity, group, access-package, tenant, SCIM, and invitation events, waiting debounceMs (default 2 seconds) so a burst of changes becomes one run. It ignores the provisioner's own events and returns an unsubscribe function. |
syncAll({ tenantId? }) | A periodic safety net that also catches changes without events, such as expiries. A deployment operation for schedulers: it takes no credential, so never expose it over HTTP. See Protocol jobs. |
syncTarget(credential, { tenantId, targetId }) | A "Sync now" button. Runs one target on demand (iam:scim:targets:sync) and returns the run report. |
Because inbound SCIM changes are IAM events too, a person deactivated by their company's directory is deactivated
in every connected application at the next dispatch of audit events plus the debounce: within about a minute when
you dispatch every minute. Subscriptions fire only when iam.events.dispatch() runs in the same process; see
Protocol jobs and
Offboarding end to end.
Preview a sync
Before turning on a new target, or after changing its scope, check what would happen:
const preview = await provisioner.previewTarget(credential, { tenantId, targetId: target.id });
// preview.counts: { create, adopt, update, reactivate, deactivate, delete, unchanged }
// preview.changes: [{ identityId, email, action }], the first 200
// preview.errors: lookups that failedpreviewTarget (iam:scim:targets:read) shows what the next sync would do without doing it. It sends only the
read-only lookups that detect adoptable accounts, writes nothing to the store, and leaves lastRun untouched.
Push groups
Some applications grant access by group rather than per user. With pushGroups: true, the target's groupIds
groups are maintained downstream as SCIM groups:
externalIdis the group ID,displayNamethe group name, andmembersthe provisioned users of that group.- Groups are adopted by
externalIdordisplayName, and replaced only when the name or membership changes. - They are deleted downstream when they leave
groupIds, are deleted, orpushGroupsis turned off.
pushGroups needs at least one group in groupIds. lastRun.groups counts created, updated, deleted, and
unchanged groups, and group failures are reported with groupId.
Read the run report
Each target keeps its latest run in lastRun:
Prop
Type
Manage targets
| Method | Permission on scim/outbound/{targetId} | What it does |
|---|---|---|
createTarget(credential, input) | iam:scim:targets:create | Adds an application. Audited as iam:scim:CreateTarget. |
listTargets(credential, { tenantId }) | iam:scim:targets:read | The tenant's targets with provisioned counts and last runs. |
getTarget(credential, { tenantId, targetId }) | iam:scim:targets:read | One target. |
updateTarget(credential, { tenantId, targetId, ...changes }) | iam:scim:targets:update | Changes settings; token replaces the stored token. Scope changes apply at the next sync. Audited as iam:scim:UpdateTarget. |
deleteTarget(credential, { tenantId, targetId }) | iam:scim:targets:delete | Removes the target and its links. Downstream accounts are left as they are. Audited as iam:scim:DeleteTarget. |
previewTarget(credential, { tenantId, targetId }) | iam:scim:targets:read | The plan of the next sync. |
syncTarget(credential, { tenantId, targetId }) | iam:scim:targets:sync | Runs one target now. Audited as iam:scim:SyncTarget. |
provisioner.handler serves the same operations as a JSON API for browsers: POST {basePath}/targets/{list,get,create,update,delete,sync,preview} with the caller's IAM cookie or bearer token. It
applies the IAM API's CSRF rule (X-Better-IAM: 1 and a JSON body of at most 64 KiB) and answers with the
{ data } / { error } envelope. Mounted under the IAM path, the typed client reaches
it through $request:
const targets = await client.$request('provisioning/targets/list', { tenantId });The console's App provisioning page works this way.
Rotate the encryption key
To replace encryptionKey, deploy the new key with the old one in previousEncryptionKeys, then re-seal the
stored tokens:
const provisioner = createScimProvisioner({
...iam.protocolHost,
encryptionKey: secrets.newProvisioningKey,
previousEncryptionKeys: [secrets.oldProvisioningKey],
});
const { resealed, current, unreadable } = await provisioner.rotateKeys();rotateKeys() is a deployment operation (not served over HTTP) and is idempotent. Once unreadable is 0 and
every instance runs with the new key, remove the old one.
Security
- The downstream bearer token is encrypted with
encryptionKey(AES-256-GCM), is write-only, and is replaced throughupdateTarget({ token }). - Downstream calls require HTTPS (
allowInsecureLocalhostpermits loopback HTTP for development), time out aftertimeoutMs(10 seconds), and do not follow redirects, so the token is never sent anywhere but the configured URL. - Target management is authorized per target and audited as
iam:scim:{Create,Update,Delete,Sync}Target.
Next steps
Better IAM is created by Sean Filimon
Last updated