PermDock
Adapters

Clerk

The permdock/clerk provider maps Clerk session claims, organization roles and permissions to a PermDock subject so PermDock conditions, snapshots and adapters run on top of Clerk authentication.

permdock/clerk is a provider. Clerk supplies the authenticated user, the active organization, its role and Clerk-defined permissions; PermDock supplies conditions, decisions, snapshots and adapters. The provider reads Clerk's session claims (server) or the useAuth / useOrganization state (client) and returns the subject PermDock expects.

Purpose

Clerk's authorization primitives (has(), auth.protect(), Protect) check organization roles, custom permissions such as org:invoices:create, and billing features (landscape). They are boolean, string-keyed, tied to Clerk's organization model, and cannot express ownership or row conditions. PermDock keeps Clerk as the identity and role source and adds the permission model on top: typed references, where conditions, approval-required, agent adapters and data compilers. Where a team already defines Clerk permissions, the provider maps them to PermDock roles so nothing has to be defined twice.

API

// src/permdock/server.ts
import { createPermDock } from "permdock/next";
import { auth } from "@clerk/nextjs/server";
import { subjectFromClerk } from "permdock/clerk";

export const { getPermDock, getPermission, PermDockProvider, permdockHandler } =
  createPermDock(policy, {
    subject: async () => subjectFromClerk(await auth()),
  });
// subject.principal = {
//   id: userId,
//   tenant: orgId,                                              // the active organization
//   roles: [],                                                  // global roles only through options.globalRoles
//   memberships: [{ tenant: orgId, roles: ['org:admin'] }],     // the active organization's membership
//   clerkPermissions: ['org:invoices:create'],
//   claims: { ...sessionClaims },
// }

// policy: Clerk organization roles are tenant-scoped roles
export const policy = definePolicy(permissions, {
  roles: [
    role(
      "org:admin",
      [allow(permissions.post.delete), allow(permissions.billing.plan.change)],
      { on: "tenant" },
    ),
    role(
      "org:member",
      [
        allow(permissions.post.read),
        allow(permissions.post.update, { where: { authorId: principal.id } }),
      ],
      { on: "tenant" },
    ),
  ],
  scopes: { tenant: { key: "orgId" } },
});
  • subjectFromClerk(authObject, options?) accepts the result of auth() (Next.js), getAuth(req) (other frameworks) or a verified session token payload, and returns a principal whose id is userId, whose tenant is the active orgId, and whose memberships hold one entry for the active organization with orgRole as its role. clerkPermissions (custom permissions from the session claims) and selected sessionClaims sit next to them. No subject mapper is needed in definePolicy; the provider's output already is the principal shape (tenancy).
  • options.memberships: 'all' loads every organization the user belongs to through Clerk's Backend API (users.getOrganizationMembershipList) and adds one membership per organization, so permdock.tenants() and the tenant switcher have the full list and permdock.tenant(id) can preview another organization. The list is read in pages of 100 (Clerk's default page is 10), up to 5,000 memberships, and a row whose publicUserData.userId is another user's is dropped. A failed page drops the Backend API list and keeps the session membership. The default ('active') reads only the session, which needs no API call.
  • createClerkSubjectResolver(options) returns (authObject) => Promise<Subject> with the same options plus cache: { ttl } (a number of milliseconds, '10s' or '500ms', at most 30 seconds). The resolver keeps each user's memberships: 'all' list for ttl, so a request inside the window makes no Backend API call, and a membership removed in Clerk still grants until the entry expires. A failed list is never cached. Create the resolver once per process; subjectFromClerk never caches.
  • Both session token versions are read. Version 1 carries org_id, org_role and org_permissions. Version 2 carries the compact o claim (id, rol, slg, per, fpm): rol becomes org:<rol>, and each org:<feature>:<permission> key is rebuilt from the o: features in fea with the fpm bitmasks, the same way Clerk's SDK does. The auth() object is already decoded; its orgId, orgRole and orgPermissions win when present.
  • options.customRoles accepts a RoleSource for Clerk custom roles: a Clerk role the policy did not declare (org:billing_manager) resolves to the assignable declared roles the source returns for that organization, and to nothing when it is unknown. Clerk role sets (roles restricted per plan) map to RoleSource.assignable(tenant).
  • options.permissions maps Clerk permission strings to PermDock grants when a team keeps Clerk permissions as the source: { 'org:invoices:create': permissions.billing.invoice.create } produces an extra allow, scoped to the active organization, for subjects carrying that permission.
  • options.schema (any Standard Schema, for example a Zod object) validates and types custom session claims; an invalid claim set yields a principal without claims, never a throw (extension interfaces).
  • Client: no Clerk-specific hook is needed. The client uses the server-issued snapshot through PermDockProvider; useTenant().switchTo(orgId) calls Clerk's setActive({ organization }) when the app wires it, then invalidate() fetches the snapshot for the new active organization (UI).

Field mapping:

Clerk auth objectPermDock subject
userIdprincipal.id
orgIdprincipal.tenant (active tenant) and the tenant of the session membership
orgRolememberships[0].roles, a tenant-scoped role in the policy; global roles only through options.globalRoles (a claim path or a function over the verified claims)
orgPermissionsclerkPermissions, turned into tenant-scoped grants through options.permissions
Backend API organization memberships (options.memberships: 'all')One { tenant, roles } membership per organization
sessionClaims.*claims.* (custom claims configured in the Clerk session token template; typed through options.schema)
sessionClaims.plao: plans: entitlements on the session organization's membership; u: plans: principal.plans (prefix stripped)
sessionClaims.fearoles entries through options.features (below): o: features on the session organization's membership, u: features on principal.roles; never grants on their own

Clerk Billing puts the active plan (pla, for example u:pro or o:enterprise) and the enabled features (fea, for example o:reporting,u:api_access) in the session token, and Clerk's own has({ feature }) and has({ plan }) read them. A user plan lands on principal.plans; an organization plan lands on the session organization's membership as entitlements, so to: plans.pro matches it only while that organization is the active tenant. Features stay role fragments: options.features: { reporting: 'reporting' } adds the reporting role to subjects whose fea claim carries that feature, and the policy grants under role('reporting', [...]). A feature the plan does not include therefore has no grants, which is the same outcome Clerk's has({ permission }) produces when a plan lacks the feature, expressed once in the policy instead of in every guard.

Verified material

subjectFromClerk(auth) consumes only what Clerk has verified; it does not read cookies, headers or tokens itself (Authentication and PermDock).

  • Input. The object returned by auth() in Next.js or getAuth(req) in other frameworks, after clerkMiddleware has verified the session token's signature against Clerk's JWKS, or a session token payload the app verified with Clerk's backend SDK. Anything else (a decoded-but-unverified JWT, a value read from localStorage, a userId sent by the client) is not accepted: the type is Clerk's AuthObject, and a plain object shaped like one produces the anonymous subject with a development warning.
  • Fields used. userId becomes principal.id; orgId becomes the active tenant; orgRole and orgPermissions become the membership for that organization; sessionClaims supplies the standard claims (sub, exp, sid) plus any custom claims. sessionClaims.exp becomes subject.expiresAt, and sid is carried on audit events so a session revocation can be traced.
  • Custom claims. Values a policy condition needs beyond the organization role (a plan, a region) must come from Clerk's session token template, where they are populated by Clerk from user or organization metadata at token issuance. Only metadata written through the backend API (public and private metadata set server-side) should be projected into claims; metadata the client can edit (unsafe metadata) must not be, because subjectFromClerk cannot tell them apart once they are in the token.
  • Billing claims. pla and fea are written by Clerk at token issuance from the subscription state and cannot be edited by the user, so they are eligible role sources. They describe the organisation's plan when the session has an active organisation (o: prefix) and the user's plan otherwise (u: prefix); options.features keys are matched against the unprefixed feature slug and the prefix is recorded on the subject so a policy can distinguish the two if it needs to. An organization's plan and features are bound to that organization: with memberships: 'all', switching the active tenant to another organization leaves them behind, because the token only says what the session organization pays for.
  • Memberships. The session carries only the active organization, so the default subject holds one membership. With memberships: 'all' the provider calls the Backend API with the server secret; the list is server-fetched material and eligible for grants. Organization ids, never organization names or slugs, are the tenant values. A user with no active organization has tenant undefined and no memberships: tenant-scoped roles contribute nothing, global roles still apply.
  • Signed-out and expired. userId of null, a missing auth object or an expired session yields principal: null. Nothing throws; only anonymous grants apply.
  • MFA. Clerk's session token carries fva (factor verification age): a pair of minutes since first-factor and second-factor verification. A second element of -1 means no second factor is registered. subjectFromClerk does not invent RFC 8176 amr; map fva[1] >= 0 in a wrapper when a grant uses assurance({ amr: ['mfa'] }).
  • Not read. Clerk's client-side hooks (useAuth, useOrganization) are never a source for the server subject; they only trigger invalidate() on the client.

Request lifecycle

  1. Clerk middleware authenticates the request and populates auth() with userId, orgId, orgRole, orgPermissions and session claims.
  2. subjectFromClerk maps these to the PermDock subject; the active organization defines principal.tenant and its membership.
  3. createPermDock builds the request-scoped instance; getPermDock() / getPermission() and the snapshot resolver use it.
  4. On organization switch or role change, Clerk emits a new session token; the app calls updateTag for the user (Clerk webhooks organizationMembership.updated are the server trigger) and the client invalidate() runs on useOrganization change. From those webhooks, emit membershipEvent({ source: 'clerk', operation, principal: { id: data.public_user_id }, scope: 'tenant', id: data.organization.id, roles: { added, removed } }) to the same sink used for decisions. There is no Clerk package entry for this.

On Expo, Clerk's SDK feeds the same snapshot path through React Native; there is no Clerk-specific persisted-snapshot helper.

Membership events

import { membershipEvent } from "permdock";

// inside organizationMembership.created | .updated | .deleted
await sink.write([
  membershipEvent({
    source: "clerk",
    operation: event.type.endsWith("deleted")
      ? "removed"
      : event.type.endsWith("created")
        ? "added"
        : "changed",
    principal: { id: data.public_user_id },
    scope: "tenant",
    id: data.organization.id,
    roles: { added: data.role ? [data.role] : [], removed: [] },
  }),
]);

Sessions and devices

List and revoke sessions through Clerk's Backend API (Session list / revoke). PermDock does not own a device list UI. Revocation reaches PermDock through Back-Channel Logout or CAEP into permdock/ssf, joined on subject.session (sid). With approvals set on the SSF factory, pending approval requests for that session are cancelled.

What it validates

  • Token verification is Clerk's; the provider only reads the already-verified auth object or a payload the app verified with Clerk's SDK.
  • Role and permission strings from Clerk must match names declared in definePolicy or options.permissions; unknown strings are dropped with a development warning (fail closed).
  • Custom session claims used in conditions (for example a plan claim) are exposed under subject.claims, validated by the schema option when one is passed, and typed through SubjectOf.
  • Clerk permission strings become direct, tenant-scoped grants through options.permissions, not one PermDock role per permission.
  • Nothing is read from the client-side Clerk state for authorization decisions.

How denials surface

  • Through PermDock, per the shared contract on adapters; assert runs the Next.js redirect() handler when configured.
  • Clerk's Protect and has() remain available for Clerk-native checks (billing features, plugin routes); apps are encouraged to use Protected from permdock/react for everything expressed as a PermDock permission so there is one denial path.
  • Signed-out users yield the anonymous subject; only anonymous grants apply.
  • A row belonging to another organization is denied with reason tenant-mismatch; a request for an organization the user is not a member of is denied with no-membership (tenancy).

Example app

apps/examples/clerk: a Hono server that boots with pnpm start on 127.0.0.1:3464. GET /health is the liveness probe. createPermDock from permdock/hono resolves the subject with createClerkSubjectResolver over a fixed Clerk auth object (org:member plus org:invoices:create), memberships: 'all' and cache: { ttl: '10s' }. protect guards PATCH /posts/:id and POST /posts/:id/delete, so a denial is a Problem Details 403. No Clerk secret and no Next.js app.

Last updated on

On this page