SCIM
permdock/scim is an RFC 7644 receiver for Users and Groups provisioned by Okta, Entra ID or Google Workspace; it writes to a DirectoryStore you own and exposes the synced groups as a MembershipSource, so deprovisioning and group-to-role changes reach decisions without a token refresh and without the Cloud on the decision path.
permdock/scim receives SCIM 2.0 provisioning (RFC 7643, RFC 7644) from an identity provider and writes users, groups and group membership into a DirectoryStore the application owns. directoryMembershipSource(store) reads that store back as a MembershipSource, so a group becomes a team membership carrying the roles the application mapped to it, and a deactivated user loses every membership on the next request. PermDock Cloud hosts the same endpoint as a relay with an IdP setup wizard and a mapping UI, and replays each operation to your scimHandler; the store in your database stays authoritative.
Purpose
Enterprise buyers already pay WorkOS, Frontegg and Okta for "directory sync": turn the groups an administrator manages in the IdP into roles in the application, and remove access when the account is disabled. Two other paths work without this entry: map groups to roles in the IdP or the auth layer and read a claim, or run a sync into your own table (authentication single sign-on). This entry is for the case where the IdP pushes SCIM and the application wants a receiver it did not have to write: the SCIM protocol surface (filters, PATCH semantics, error bodies, pagination) is the part that takes weeks, and the mapping to PermDock memberships is the part that must follow the trust rules (ids, never display names; unknown roles dropped; no tenant from the body).
It is an open-source entry first so that self-hosters get the feature, and so that the Cloud has one documented target to relay to instead of writing into customer databases.
API
import {
scimHandler,
memoryDirectoryStore,
directoryMembershipSource,
} from "permdock/scim";
import { joseTokenVerifier } from "permdock/jwt";
import { cloudEndpoints } from "permdock/cloud";
import { createPermDock } from "permdock/hono";
const directory = memoryDirectoryStore(); // default; replace with a store over your tables
export const scim = scimHandler({
store: directory,
tenant: (request) => tenantFromPath(request), // '/scim/v2/:tenant' or a lookup on the credential; never the body
token: { hash: "sha256", lookup: (tenant) => db.scimTokens.hashFor(tenant) }, // static bearer per tenant (Okta, Entra ID)
verifier: joseTokenVerifier({
// or an RFC 7523 JWT bearer (the Cloud relay, IPSIE AL1)
jwks: cloudEndpoints().jwks, // the Cloud environment's JWK Set
issuer: cloudEndpoints().issuer,
}),
audience: "https://app.example.com/scim/v2", // required with `verifier`; never read from the request
groupRoles: { "g_9f2c…": ["editor"], "g_0e6f…": ["admin"] }, // optional static map; the extension attribute wins when present
sink, // a DecisionSink; receives one `directory` event per write, plus a `membership` event per affected group member
onChange: ({ tenant, userIds, kind }) => updateTag(`permdock:${tenant}`), // optional; invalidate cached snapshots
revocations, // optional RevocationFeed; deactivation ends streams, other writes revalidate them
});
app.all("/scim/v2/:tenant/*", (c) => scim(c.req.raw));
export const { permdock, protect } = createPermDock(policy, {
subject: (c) => c.get("user"),
memberships: directoryMembershipSource(directory), // groups become team memberships inside the active tenant
});scimHandler(options)returns a Fetch handler serving/Users,/Users/:id,/Groups,/Groups/:id,/ServiceProviderConfig,/ResourceTypesand/Schemasunder the mount point. Supported operations:GET(withfilter,startIndex/count, and RFC 9865cursor/nextCursor),POST,PUT,PATCH(add,replace,removewith the path forms Okta, Entra ID and Google Workspace emit) andDELETE. Responses use the SCIM media typeapplication/scim+jsonand the RFC 7644 error body (schemas,status,scimType,detail).storeis aDirectoryStore(extension interfaces).memoryDirectoryStore()is the in-process default and whatpermdock/testingasserts against; a store over Drizzle, Prisma or Kysely is the recipe at the end of this page.tenantresolves the tenant for a request from the mount path or from the credential. SCIM has no tenant attribute, so one endpoint (or one credential) per tenant is the model every IdP expects.tokenandverifierare the two credential kinds; at least one is required and both may be set.tokenis a per-tenant static bearer compared in constant time against a stored hash (never stored plain).verifieris aTokenVerifierfor an RFC 7523 JWT bearer whoseaudmust equal the configuredaudienceand whose tenant claim must equal the resolved tenant;scimHandlerthrows whenverifieris set withoutaudience; this is what the Cloud relay presents and what the IPSIE AL1 profile prescribes.groupRolesmaps a groupidto the role names its members hold. When aGrouparrives with the PermDock extension attribute (urn:permdock:scim:schemas:extension:roles:1.0with arolesarray), the attribute is stored on the group and takes precedence; the Cloud's mapping UI writes it, and an IdP that supports custom group attributes may too. Role names are offered to the tenant'sRoleSourceas custom roles first and then matched against declaredassignableroles; anything else is dropped and reported in development.sinkis aDecisionSink(audit and observability); the handler writes onedirectoryevent per accepted operation so provisioning and the decisions it later explains share one log. Group create, replace, patch and delete also write onemembershipevent per affected user (source: 'scim',via: 'group:<id>', roles from the extension).onChangereceives the tenant, the affected user ids and akindafter a write, for snapshot invalidation:session-revokedwhen the write deactivated (active: false) or deleted a user,changedotherwise.revocationsis aRevocationFeed. After a write, the handler publishes in the tenant for each affected user, once for each of the user's SCIMid,userNameandexternalId, because a principal id is whichever of themdirectoryMembershipSourcematched on. A user write that leavesactive: false, and a user delete, publish the CAEPsession-revokedkind, so the user's open streams and sockets end without revalidating; every other write (a group change, a rename, a reactivation) publisheschanged, so connections resolve their subject again and stay open only if access still holds. A deleted user's record is read before the delete; a throwing feed never fails the IdP write.directoryMembershipSource(store)returns aMembershipSourcewhosemembershipsFor(principal, { tenant })looks the principal up byexternalIdoruserName(configurablematch) with a singleeqcomparison built as a filter node, so a principal id holding quotes or filter syntax is only ever a value, returns nothing when the user is missing oractive: false, and otherwise one membership of the tenant per group:{ tenant, roles: group.roles, via: 'group:' + group.id, managedBy: 'idp' }.managedBymarks the membership as owned by the identity provider:decideRoleChangerefuses to change it withexternally-managed, andisExternallyManaged(membership)lets a member list render it read-only (Supabase token hook). Group roles hold in the tenant (no cascade from a team scope); the group id stays inviafor audit.
DirectoryStore
interface DirectoryStore {
getUser(tenant: string, id: string): Promise<DirectoryUser | null>;
findUsers(
tenant: string,
filter: ScimFilter,
page: ScimPage,
): Promise<ScimPageResult<DirectoryUser>>;
putUser(tenant: string, user: DirectoryUser): Promise<DirectoryUser>; // create or replace; id assigned by the store on create
patchUser(
tenant: string,
id: string,
ops: ScimPatchOp[],
): Promise<DirectoryUser>;
deleteUser(tenant: string, id: string): Promise<void>;
getGroup(tenant: string, id: string): Promise<DirectoryGroup | null>;
findGroups(
tenant: string,
filter: ScimFilter,
page: ScimPage,
): Promise<ScimPageResult<DirectoryGroup>>;
putGroup(tenant: string, group: DirectoryGroup): Promise<DirectoryGroup>;
patchGroup(
tenant: string,
id: string,
ops: ScimPatchOp[],
): Promise<DirectoryGroup>;
deleteGroup(tenant: string, id: string): Promise<void>;
groupsFor(tenant: string, userId: string): Promise<DirectoryGroup[]>; // what directoryMembershipSource reads
}
type DirectoryUser = {
id: string;
externalId?: string;
userName: string;
active: boolean;
emails?: { value: string; primary?: boolean }[];
meta: { created: string; lastModified: string };
};
type DirectoryGroup = {
id: string;
externalId?: string;
displayName: string;
members: { value: string }[];
roles?: string[];
meta: { created: string; lastModified: string };
};The store is a repository, unlike MembershipSource and RoleSource, which are queries: this is the one place PermDock writes membership data, and it writes only what an authenticated IdP or relay sent. displayName is stored for the mapping UI and is never used as an identifier. roles on a group is the extension attribute. Every method may throw; the handler turns a throw into a SCIM 500 and never a partial write.
Protocol subset
| RFC 7644 feature | permdock/scim | Note |
|---|---|---|
/Users, /Groups GET, POST, PUT, PATCH, DELETE | Implements | The operations Okta, Entra ID and Google Workspace send |
filter with eq, ne, co, sw, pr, and, or on userName, externalId, active, displayName, members.value | Implements | Enough for every IdP's lookup-before-create; pr matches only a non-empty value (not null, not an empty string, not an empty array, RFC 7644 section 3.4.2.2); other operators return 400 invalidFilter |
Index pagination (startIndex, count) | Implements | Required by RFC 7644 |
| Cursor pagination (RFC 9865) | Implements | Advertised in /ServiceProviderConfig; used when the store returns a cursor |
PATCH path forms (members[value eq "…"], active, emails[type eq "work"].value) | Implements, normalised | Each IdP's dialect is rewritten to one operation shape before the store sees it |
/ServiceProviderConfig, /ResourceTypes, /Schemas | Implements | Advertises the supported subset and the PermDock extension; /Schemas and /ResourceTypes are ListResponses, /Schemas/:id and /ResourceTypes/:id return one item, and a filter gets 403 (RFC 7644 section 4) |
/Me, /Bulk, /.search | Not implemented | No IdP requires them for provisioning; the routes do not exist |
ETag and If-Match | Not implemented | Stores may return meta.version; conditional requests are not enforced |
sortBy, sortOrder | Not implemented | Ignored |
The PermDock extension schema
Group-to-role mapping is application data, carried in a SCIM extension:
{
"schemas": [
"urn:ietf:params:scim:schemas:core:2.0:Group",
"urn:permdock:scim:schemas:extension:roles:1.0"
],
"id": "g_9f2c",
"externalId": "00g1abc",
"displayName": "Editors",
"members": [{ "value": "u_1" }, { "value": "u_7" }],
"urn:permdock:scim:schemas:extension:roles:1.0": { "roles": ["editor"] }
}roles is a multi-valued string attribute. The Cloud relay's mapping UI writes it, an IdP with custom group attributes may write it, and groupRoles is the fallback. The URN is under the permdock namespace in the form RFC 7643 section 10 requires of an unregistered extension; no IANA registration is planned. The relay's RFC 7523 bearer is a plain assertion (typ: JWT) with no new JWS typ value, and permdock/jwt's default accept: 'access-token' check applies to it.
IdP dialects
| IdP | What it sends | Normalisation |
|---|---|---|
| Okta | PATCH replace on active; group push as PUT of the full members list, later PATCH add / remove with members[value eq "…"]; externalId from the Okta user id | Full PUT members diffed into add / remove |
| Microsoft Entra ID | PATCH Operations with path members[value eq "…"] and remove; active as a string in older tenants; no /Groups PUT | String "False" coerced; remove with filter path mapped to member removal |
| Google Workspace | Full PUT of the user on any change; groups optional | PUT diffed against the stored resource |
| WorkOS Directory Sync (as a client) | Standard PATCH | None |
The dialect table is maintained with recorded requests in apps/examples/scim; a new IdP adds a recording, not a code path in the store.
Wire checklist
application/scim+jsonon requests and responses;application/jsonaccepted on requests.meta.locationon every returned resource, discovery resources included;meta.createdandmeta.lastModifiedin RFC 3339.- Every attribute in
/Schemasstatestype,multiValuedandrequired, as the RFC 7643 section 7 Schema schema requires. ListResponsewithtotalResultseven when paging by cursor.- The extension URN advertised under
/Schemasand in/ResourceTypesschemaExtensionswithrequired: false.
Request lifecycle
- The IdP (or the Cloud relay) sends an HTTP request to the mount point with a bearer credential.
tenantresolves the tenant. The handler authenticates: a statictokenis hashed and compared in constant time against the tenant's stored hash; a JWT bearer is passed toverifierwithaudset to the configuredaudienceand the tenant claim required to match. Failure is401withWWW-Authenticate: Bearerand no body detail.- The body is validated against the SCIM core schemas plus the PermDock extension; an unknown attribute is ignored, a malformed body is
400withscimType: invalidSyntaxorinvalidValue. - The operation is applied to the store.
PATCHoperations are normalised (Entra'smembers[value eq "…"]remove form, Okta'sreplaceonactive, Google's fullPUT) before they reachpatchUser/patchGroup, so a store implements one shape. - The response is the resource as stored (
201on create,200otherwise,204on delete) withmeta.location. - The handler writes a
directoryevent (type: 'directory',source: 'scim', the operation, the tenant, the resource type and id, and the credential kind:tokenor the JWTiss) to itssink, and callsonChangewhen given. The Cloud relay also records the same operation in its sync log. - Nothing on this path touches
decide. The nextcreatePermDockfor an affected principal callsdirectoryMembershipSource, which reads the store; a deactivated user keeps the principal but has no memberships, a new group member has the group's roles.
What it validates
| Check | Failure |
|---|---|
Bearer present and valid (token hash match, or verifier ok: true with aud and tenant claim) | 401, WWW-Authenticate: Bearer |
| Tenant resolved and equal to the credential's tenant | 403; never falls back to a default tenant |
schemas names a supported schema; required attributes present (userName, displayName) | 400 invalidSyntax / invalidValue |
filter uses a supported operator (eq, ne, co, sw, pr, and, or) on a supported attribute | 400 invalidFilter |
Uniqueness of userName and externalId within the tenant | 409 uniqueness |
Extension roles values are role names the policy declares or the tenant's RoleSource resolves | Unknown names stored as sent, contribute no grant, reported in development |
| Resource exists | 404 |
The handler never trusts the body to name the tenant or the caller, never accepts a roles value that widens beyond declared assignable roles, and never returns another tenant's resources. Bulk operations (RFC 7644 section 3.7) are not supported, because the IdPs PermDock targets do not require them. Enterprise User attributes such as department and manager stay in the store for the application to read; they never become principal fields.
How denials surface
The adapter makes no permission decisions. Its effect is visible in three places:
- Server: the next request for a deprovisioned user builds a subject with no memberships, so every tenant-scoped check is
deniedwithno-membership; a user added to a group holds its roles on the next request. - Client: the snapshot for that user is stale until it is refetched;
updateTagin the handler'sonChangecallback makes the change reach cached snapshots immediately, and therevocationsfeed ends the user's open connections on deactivation. - Audit: the
directoryevent and the later decision events share the group id (via: 'group:<id>'), so "who gave this person editor" is the provisioning event that added them to the group.
Deprovisioning latency is bounded by how memberships are read: per request through directoryMembershipSource (immediate), or from a cached snapshot (until invalidation). The threat model has the row.
Why
- Deactivation is
session-revoked, notchanged. Achangedevent makes a connection re-resolve its subject, and a connection authenticated by a locally verified JWT still resolves the same principal after the IdP deactivates the user; only the membership read turns empty, and a permission that does not need a membership would survive. Deactivation in SCIM means the person is gone, which is what CAEPsession-revokedsays, so the handler publishes that kind and the connection ends. The IdP's own CAEP transmitter, when there is one, sends the same signal throughpermdock/ssf; the two are idempotent. - The policy caps what an identity provider grants. Core drops every role a policy declares
assignable: falsefrom amanagedBy: 'idp'membership, whether or notassignablewas passed to the source, and whether it came from this handler or the Cloud relay. An identity-provider admin can then map a group toeditorbut never toowner, which the application keeps for its own flows. A name the policy does not declare stays, because only a tenant's custom role can give it meaning.onUnknownRolereports names outsideassignableon create, replace andPATCH. - The audience is configuration. Deriving it from the request URL would let a bearer minted for one deployment (a staging host, another customer's endpoint behind the same proxy) pass wherever the
Hostheader can be chosen. A fixedaudiencebinds the relay's token to this endpoint. - One signal to two places.
onChangecarries the samekindso cached snapshots and the feed agree without the application re-deriving deactivation from the SCIM body. The PermDock Cloud relay replays the SCIM write to this handler and does not send its ownsession-revokedfor it.
Hosted relay
PermDock Cloud runs a SCIM endpoint per connected tenant, with the IdP-specific setup instructions (Okta and Entra ID applications, Google Workspace auto-provisioning), a group-to-role mapping UI that writes the extension attribute, and a sync log. Each operation the Cloud accepts is replayed unchanged to your scimHandler, authenticated with an RFC 7523 JWT bearer signed by the Cloud's per-environment key and verified by your verifier against the same JWKS that signs snapshots (Cloud adapter). The Cloud keeps no authoritative copy of your directory (the relay is the bring-your-own directory mode; a Cloud-native environment has no scimHandler target and delivers memberships as token claims): if the relay is unreachable, the IdP retries, and your store keeps its last state. The Cloud never implements MembershipSource or RoleSource (invariant 15).
Store recipe
A Drizzle store is three tables (scim_users, scim_groups, scim_group_members) keyed on (tenant, id) with unique indexes on (tenant, user_name) and (tenant, external_id), and about sixty lines implementing the interface above; groupsFor is a join. permdock rls generate reads the same tables through the memberships table mapping when the memberOf node is compiled (RLS adapter). testDirectoryStore in permdock/testing runs the conformance cases (round trip, PATCH normalisation, uniqueness, tenant isolation).
Example app
apps/examples/scim: a Hono app mounting scimHandler over memoryDirectoryStore, directoryMembershipSource feeding createPermDock, and a script that replays recorded Okta and Entra ID provisioning requests (create user, add to group, deactivate) and asserts permdock.memberships() and a tenant-scoped can change accordingly. tests/integration runs the same recordings against a Postgres store.
Related standards
- SCIM 2.0: RFC 7643 schemas, RFC 7644 protocol and RFC 9865 cursor pagination.
- JWT authorization claims: the RFC 9068
groupsclaim as the token-side sibling of a synced group; both key on the SCIMvalue. - JOSE and the JWT adapter: the
TokenVerifierfor the RFC 7523 bearer. - Shared Signals and CAEP: the revocation signal that makes deprovisioning reach cached snapshots.
- Tenancy: memberships,
via,assignable. - Standards watch list: the IPSIE AL1 SCIM profile.
Last updated on
Shared Signals (SSF / CAEP)
permdock/ssf receives Shared Signals Framework security event tokens (push and poll) and maps CAEP events to snapshot and cache invalidation so permissions go stale when the IdP says so, not on a timer.
OpenAPI
permdock/openapi emits OpenAPI 3.2 security schemes, per-operation security and x-permdock-permissions from permission references, hooks into hono-openapi, @hono/zod-openapi, oRPC and trpc-to-openapi, hands an Overlay to next-openapi-gen and other producers, and imports OpenAPI documents into a catalog.