PermDock
Adapters

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, /ResourceTypes and /Schemas under the mount point. Supported operations: GET (with filter, startIndex / count, and RFC 9865 cursor / nextCursor), POST, PUT, PATCH (add, replace, remove with the path forms Okta, Entra ID and Google Workspace emit) and DELETE. Responses use the SCIM media type application/scim+json and the RFC 7644 error body (schemas, status, scimType, detail).
  • store is a DirectoryStore (extension interfaces). memoryDirectoryStore() is the in-process default and what permdock/testing asserts against; a store over Drizzle, Prisma or Kysely is the recipe at the end of this page.
  • tenant resolves 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.
  • token and verifier are the two credential kinds; at least one is required and both may be set. token is a per-tenant static bearer compared in constant time against a stored hash (never stored plain). verifier is a TokenVerifier for an RFC 7523 JWT bearer whose aud must equal the configured audience and whose tenant claim must equal the resolved tenant; scimHandler throws when verifier is set without audience; this is what the Cloud relay presents and what the IPSIE AL1 profile prescribes.
  • groupRoles maps a group id to the role names its members hold. When a Group arrives with the PermDock extension attribute (urn:permdock:scim:schemas:extension:roles:1.0 with a roles array), 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's RoleSource as custom roles first and then matched against declared assignable roles; anything else is dropped and reported in development.
  • sink is a DecisionSink (audit and observability); the handler writes one directory event per accepted operation so provisioning and the decisions it later explains share one log. Group create, replace, patch and delete also write one membership event per affected user (source: 'scim', via: 'group:<id>', roles from the extension). onChange receives the tenant, the affected user ids and a kind after a write, for snapshot invalidation: session-revoked when the write deactivated (active: false) or deleted a user, changed otherwise.
  • revocations is a RevocationFeed. After a write, the handler publishes in the tenant for each affected user, once for each of the user's SCIM id, userName and externalId, because a principal id is whichever of them directoryMembershipSource matched on. A user write that leaves active: false, and a user delete, publish the CAEP session-revoked kind, so the user's open streams and sockets end without revalidating; every other write (a group change, a rename, a reactivation) publishes changed, 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 a MembershipSource whose membershipsFor(principal, { tenant }) looks the principal up by externalId or userName (configurable match) with a single eq comparison 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 or active: false, and otherwise one membership of the tenant per group: { tenant, roles: group.roles, via: 'group:' + group.id, managedBy: 'idp' }. managedBy marks the membership as owned by the identity provider: decideRoleChange refuses to change it with externally-managed, and isExternallyManaged(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 in via for 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 featurepermdock/scimNote
/Users, /Groups GET, POST, PUT, PATCH, DELETEImplementsThe operations Okta, Entra ID and Google Workspace send
filter with eq, ne, co, sw, pr, and, or on userName, externalId, active, displayName, members.valueImplementsEnough 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)ImplementsRequired by RFC 7644
Cursor pagination (RFC 9865)ImplementsAdvertised in /ServiceProviderConfig; used when the store returns a cursor
PATCH path forms (members[value eq "…"], active, emails[type eq "work"].value)Implements, normalisedEach IdP's dialect is rewritten to one operation shape before the store sees it
/ServiceProviderConfig, /ResourceTypes, /SchemasImplementsAdvertises 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, /.searchNot implementedNo IdP requires them for provisioning; the routes do not exist
ETag and If-MatchNot implementedStores may return meta.version; conditional requests are not enforced
sortBy, sortOrderNot implementedIgnored

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

IdPWhat it sendsNormalisation
OktaPATCH 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 idFull PUT members diffed into add / remove
Microsoft Entra IDPATCH Operations with path members[value eq "…"] and remove; active as a string in older tenants; no /Groups PUTString "False" coerced; remove with filter path mapped to member removal
Google WorkspaceFull PUT of the user on any change; groups optionalPUT diffed against the stored resource
WorkOS Directory Sync (as a client)Standard PATCHNone

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+json on requests and responses; application/json accepted on requests.
  • meta.location on every returned resource, discovery resources included; meta.created and meta.lastModified in RFC 3339.
  • Every attribute in /Schemas states type, multiValued and required, as the RFC 7643 section 7 Schema schema requires.
  • ListResponse with totalResults even when paging by cursor.
  • The extension URN advertised under /Schemas and in /ResourceTypes schemaExtensions with required: false.

Request lifecycle

  1. The IdP (or the Cloud relay) sends an HTTP request to the mount point with a bearer credential.
  2. tenant resolves the tenant. The handler authenticates: a static token is hashed and compared in constant time against the tenant's stored hash; a JWT bearer is passed to verifier with aud set to the configured audience and the tenant claim required to match. Failure is 401 with WWW-Authenticate: Bearer and no body detail.
  3. The body is validated against the SCIM core schemas plus the PermDock extension; an unknown attribute is ignored, a malformed body is 400 with scimType: invalidSyntax or invalidValue.
  4. The operation is applied to the store. PATCH operations are normalised (Entra's members[value eq "…"] remove form, Okta's replace on active, Google's full PUT) before they reach patchUser / patchGroup, so a store implements one shape.
  5. The response is the resource as stored (201 on create, 200 otherwise, 204 on delete) with meta.location.
  6. The handler writes a directory event (type: 'directory', source: 'scim', the operation, the tenant, the resource type and id, and the credential kind: token or the JWT iss) to its sink, and calls onChange when given. The Cloud relay also records the same operation in its sync log.
  7. Nothing on this path touches decide. The next createPermDock for an affected principal calls directoryMembershipSource, 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

CheckFailure
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 tenant403; 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 attribute400 invalidFilter
Uniqueness of userName and externalId within the tenant409 uniqueness
Extension roles values are role names the policy declares or the tenant's RoleSource resolvesUnknown names stored as sent, contribute no grant, reported in development
Resource exists404

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 denied with no-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; updateTag in the handler's onChange callback makes the change reach cached snapshots immediately, and the revocations feed ends the user's open connections on deactivation.
  • Audit: the directory event 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, not changed. A changed event 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 CAEP session-revoked says, so the handler publishes that kind and the connection ends. The IdP's own CAEP transmitter, when there is one, sends the same signal through permdock/ssf; the two are idempotent.
  • The policy caps what an identity provider grants. Core drops every role a policy declares assignable: false from a managedBy: 'idp' membership, whether or not assignable was passed to the source, and whether it came from this handler or the Cloud relay. An identity-provider admin can then map a group to editor but never to owner, 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. onUnknownRole reports names outside assignable on create, replace and PATCH.
  • 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 Host header can be chosen. A fixed audience binds the relay's token to this endpoint.
  • One signal to two places. onChange carries the same kind so 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 own session-revoked for 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.

Last updated on

On this page