PermDock
Concepts

Subject

A subject is a principal, an optional actor and the authority delegated between them; a decision is principal grants intersected with delegation.

Who is asking? In a classic web app the answer is "the logged-in user". In 2026 the answer is often "an agent, acting for a user, with a token that lets it do part of what the user can". PermDock models both with one subject type: a principal (the human or service whose grants apply), an optional actor (the agent doing the asking) and a delegation (the authority the principal handed to the actor). A decision is the principal's grants intersected with the delegated authority, so an agent can never exceed its user (ADR 0012).

Shape

type Subject = {
  principal: Principal | null                 // from the policy's `subject` function; null = anonymous
  actor?: { id: string; kind: string; binding?: Binding; [k: string]: unknown }
  delegation?: {
    scopes?: string[]                          // OAuth scopes, matched against permission.scope
    authorizationDetails?: AuthorizationDetail[] // RFC 9396 objects, matched per permission
    access?: GnapAccess[]                      // GNAP access array (RFC 9635), objects and reference strings
    chain?: unknown                            // attenuated delegation chain (Later)
  }
  context: Record<string, unknown>            // loaded by the policy's `context` function
  expiresAt?: number                           // from the token's exp / session_expiry; copied into snapshots
}

type Principal = {
  id: string                                  // `sub`, a user id, a client id or a SPIFFE ID
  kind?: 'user' | 'service' | 'workload'      // default 'user'
  roles?: string[]                            // global roles
  memberships?: Membership[]                  // roles held in a tenant, a team or on one resource
  tenant?: string                             // the active tenant for this request; never defaulted
  assurance?: string                          // `aal` / `acr` from the token, for step-up conditions
  binding?: Binding                           // sender constraint, see "Binding"
  [k: string]: unknown                        // any other value conditions reference
}

type Membership = { tenant?: string; team?: string; on?: { resource: string; id: string }; roles: string[]; via?: string; expiresAt?: number }
type Binding = { method: 'dpop' | 'mtls'; thumbprint: string }
const permdock = await createPermDock(policy, user)                        // human or service: principal only
const permdock = await createPermDock(policy, user, { actor, delegation }) // agent on behalf of user
const permdock = await createPermDock(policy, user, { tenant, memberships, customRoles }) // active tenant and the two subject-input sources

The decision endpoint, MCP, AI SDK, HTTP and A2A adapters build the third argument for you; see "How adapters fill actor and delegation" below.

Principal

The principal is whatever the policy's subject function returns:

definePolicy(permissions, {
  roles,
  subject: (user: User | null) => user && { id: user.id, orgId: user.orgId, roles: user.roles },
})
  • Its fields are the values that subject.<field> references read in conditions. Nothing else about the user is visible to the policy.
  • roles selects which global role() grants apply. memberships selects scoped roles: each entry names a tenant, a team inside a tenant, or one resource, and the roles held there; tenant is the active tenant this request is about. A membership with a role name the policy never declared is resolved as a tenant-defined custom role through the RoleSource, or dropped. Full rules on tenants, teams and scoped roles.
  • It is computed once per createPermDock, frozen, and included in the snapshot so the client evaluates the same conditions against the same values.
  • It is never taken from a model, a tool argument or a request body. Adapters derive it from verified auth material (session cookie, bearer token authInfo, provider session) through a subjectFrom<Source> function; what counts as verified is defined in Authentication and PermDock. See also the threat model.

The principal may be a service account. A cron job or a backend-to-backend call has a principal with roles and no actor.

Anonymous

When subject returns null the principal is anonymous. Anonymous has no roles, so with the default policy every check is denied with reason anonymous, and assert narrows nothing. The granted branch of a Decision types subject.principal as non-null, which is how assert gives you a narrowed subject for the rest of the handler (Kilpi's Grant(subject) trick, carried through RSC and client types where Kilpi loses it).

can on an anonymous PermDock still works and still returns false; it never throws.

Context

context is for relations you need in conditions but that are not on the user record: team memberships, org settings, a feature flag. It is loaded once per createPermDock, which makes createPermDock async only when the policy declares it:

definePolicy(permissions, {
  roles,
  subject: (user) => user && { id: user.id, roles: user.roles },
  context: async (user) => ({ teamIds: user ? await loadTeamIds(user.id) : [] }),
})

allow(permissions.post.read, { where: { teamId: { in: subject.context.teamIds } } })

Context values must be JSON so they can travel in the snapshot and compile to SQL (team_id IN (select team_id from team_user where user_id = (select auth.uid())) on the RLS side; the compiler maps subject.context.teamIds to that subselect through a per-key mapping declared on the RLS adapter). This is PermDock's answer to the "no real ReBAC" complaint against permix (permix #25): relations are resolved up front and become ordinary data, so checks stay synchronous and portable.

When the relation is a role held somewhere (a team the user leads, a document shared with them), prefer memberships over a context array: a membership carries roles, is understood by scoped role declarations and the memberOf condition node, and reaches RLS through the membership table mapping instead of a per-key subselect. context remains right for settings, flags and values that are not memberships.

Actor

The actor identifies the agent. It has an id and a kind plus whatever the adapter knows:

Adapteractor.kindactor.idSource
permdock/mcp'mcp-client'OAuth client_idauthInfo from the MCP TypeScript SDK (OAuth 2.1 resource server)
permdock/ai-sdk'ai-sdk'your runtimeContext.agentIdthe actor option of createPermDock
permdock/claude-agent'claude-agent'session or agent namethe actor option
HTTP adapters with Web Bot Auth'web-bot'the verified Signature-Agent key idRFC 9421 HTTP Message Signatures
permdock/a2a'a2a-agent'the calling Agent Card identityA2A securitySchemes on the authenticated extended card
noneabsentabsenta plain human or service request

The actor appears on every decision and audit event so you can answer "which agent did this, for whom". The actor never has grants of its own; only the principal has roles.

Delegation

Delegation is the authority the principal has given the actor. Two carriers are understood today:

OAuth scopes

Every permission has a scope string (permissions.post.update.scope === 'post:update'). With delegation.scopes present, a permission is only grantable when its scope is in the list. MCP servers get this for free: protectServer reads authInfo.scopes, and when a scope is missing the server answers with a scopeChallenge so the client can step up (MCP authorization).

RFC 9396 authorization_details

Rich Authorization Requests carry structured objects instead of flat scopes. PermDock treats each permission as an authorization_details type, so it can both emit an object for a consent screen and verify one on a token:

{
  "type": "post",
  "actions": ["update"],
  "identifier": "post_123"
}

permissions.post.update carries an authorizationDetails type that says which fields may appear (type from the resource, actions from the action, identifier from the resource id field, optional locations). A decision on post_456 with the object above is denied with reason not-delegated. See OAuth agent delegation.

Delegation chains

The Agent Delegation Chain and Credential Delegation drafts describe tokens that attenuate across agent hops. PermDock accepts the chain as opaque delegation.chain today and leaves verification to the token layer; Phase 4 may verify monotonic attenuation in core. See the open questions.

Example: an MCP server

import { createPermDock } from 'permdock/mcp'

const { protectServer } = createPermDock(policy, {
  subject: (authInfo) => userFrom(authInfo), // principal from the verified token
})
// actor     = { id: authInfo.clientId, kind: 'mcp-client' }
// delegation = { scopes: authInfo.scopes, authorizationDetails: authInfo.extra?.authorization_details }

You write the principal mapping once; the adapter fills actor and delegation from authInfo on every request. When a scope is missing, the tool result is a refusal plus a scopeChallenge naming permission.scope, and the MCP client can step up (scopes accumulate across step-ups per SEP-2350). See the MCP adapter.

Service principals

A backend job or a partner service is a principal with roles of its own, created with createPermDock(policy, serviceUser) and no actor. Do not model it as an actor without a principal: actors never hold grants, so such a subject is denied everything. If a service calls on behalf of a user, the user is the principal and the service is the actor with a delegation.

Workload principals

principal.kind distinguishes three kinds of caller that act on their own authority:

kindWhoprincipal.idTypical material
'user' (default)A humanThe identity provider's subSession, ID token, access token
'service'An application-level service account or API-key ownerYour service account idAPI key lookup, client-credentials token with a sub you assigned
'workload'A running workload with a platform identityA SPIFFE ID (spiffe://trust-domain/ns/prod/sa/reports) or the WIMSE workload identifierClient-credentials token, SPIFFE SVID over mTLS, transaction token

The WIMSE architecture treats workloads as principals in their own right: a workload has an identity, obtains credentials, and is the subject that security context is propagated for (WIMSE architecture). PermDock follows that: a workload calling on its own behalf is a principal with roles, exactly like a service account, and kind: 'workload' exists so policies can say where: { subject: { kind: 'workload' } } or audit can separate human from machine traffic.

Transaction tokens. Inside a trust domain, the OAuth Transaction Tokens draft propagates the original user, the workload chain and an authorization context (azd) through every hop of a call chain. PermDock maps them as: the token's subject is the principal (the user the transaction is for, or the originating workload); each workload in the chain is recorded as actor with kind: 'workload'; azd is merged into principal.context so conditions can reference values the first hop asserted. The aud of a transaction token is the trust domain, and permdock/jwt checks it like any audience.

SPIFFE SVIDs as actor material. When a workload calls with a user's delegated token (a backend-for-frontend forwarding a user request, a job running under an OBO token) it is an actor, and its SPIFFE identity is the verified material for that half: the X.509-SVID presented over mTLS, or a JWT-SVID in a header, yields actor: { id: '<spiffe id>', kind: 'workload' } while the principal comes from the user token. Verification of the SVID is upstream (the mesh, the TLS terminator or the SPIRE Workload API), the same rule as every other token (authentication); PermDock reads the result. This is a recipe over permdock/jwt and the server kernel, not an adapter, and it is scheduled with the other workload rows in Phase 4 (watch list).

Why a workload is a principal and an agent is an actor. The distinction is whose authority applies. A nightly report job runs under its own grants; nobody delegated anything to it, so it is a principal. An MCP client, an AI SDK agent or a Web Bot Auth signer exercises a user's authority under a delegation, so it is an actor and the user is the principal. The same binary can be both on different requests: a workload is a principal when it runs its own schedule and an actor when it calls with a user's delegated token. What decides is the token, not the process.

Binding

Sender-constrained tokens carry a cnf claim that binds the token to a key the presenter must prove it holds. PermDock keeps that binding on the subject:

binding: { method: 'dpop', thumbprint: 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs' }   // cnf.jkt, RFC 9449
binding: { method: 'mtls', thumbprint: 'A4DtL2JmUMhAsvJj5tKyn64SqzmuXbMrJa0n761y5v0' }   // cnf.x5t#S256, RFC 8705
  • It sits on principal when the token identifies a user or workload acting for itself, and on actor when the token carries an act chain (the key belongs to the presenting agent).
  • Core does not check it. Proof-of-possession needs the HTTP request (the DPoP header) or the TLS client certificate, which core never sees. The check belongs to the adapter that has the request: verifyDpopProof(request, claims) in permdock/jwt, or the mTLS thumbprint comparison in the server kernel. A failed check yields the anonymous subject, so the binding never reaches core in an unverified state.
  • It appears on on('decision') events and OTel spans (method and thumbprint) so an audit can prove that a decision was made for a token bound to a specific key. It is not included in snapshots.
  • Under profile: 'fapi2' in permdock/jwt a token without a binding is rejected (FAPI 2.0).

See Authentication and PermDock for where bindings come from.

The intersection rule

decision = evaluate(principal.roles, permission, data)  ∩  delegated(delegation, permission, data)
  1. Evaluate the policy for the principal as if no actor existed. denied stays denied.
  2. If there is a delegation, check the permission against it: scope present, or an authorization_details object whose type, actions and identifier cover this permission and this row.
  3. If the delegation does not cover it, the outcome is denied with a denial { role: null, reason: 'not-delegated' }, and alternatives lists the permissions on the same resource that both the principal holds and the delegation covers.
  4. approval-required survives the intersection: a delegated actor still needs the human gate.

An actor with no delegation at all is treated as having none: every check is denied with reason no-delegation. This is the fail-closed default; an adapter that cannot find scopes on a token does not silently grant everything the user has.

Where the subject shows up

  • Decision.subject on granted is the narrowed subject (principal non-null).
  • on('decision') events carry principal.id, principal.roles, tenant, the matching membership and its via, actor and delegation so audit can distinguish "Alice deleted a post" from "the reporting agent deleted a post for Alice under scope post:delete", and "as a design-team lead" from "as an Acme admin".
  • Snapshots include the principal (memberships and active tenant included) and the delegation, not the actor's secrets, so the client can evaluate the same intersection offline.
  • AuthZEN messages map principal to subject, put memberships under subject.properties, and put actor, delegation and the active tenant under context (AuthZEN).

Choosing what goes in the principal

Put in the principal only what conditions reference and what you are willing to ship to the client in a snapshot. An email address that no condition uses is noise on the wire; a tenantId that RLS filters on belongs there. Keep it flat and JSON: strings, numbers, booleans, arrays of those, and the memberships array.

ValueWhere it goesWhy
The user id, kind, assuranceprincipal fieldsReferenced by conditions and the token binding
Global roles (support, platform-admin, pro)principal.rolesApply everywhere
A role held in a tenant, a team or on a documentprincipal.membershipsScoped roles, memberOf, tenant-scoped audit, the UI's role chips
The tenant this request is aboutprincipal.tenantSelected by the server from the URL or the provider's active organisation, only when a membership matches
Settings, flags, plancontext (or roles when they select a role)Not roles, not memberships; still portable
Display names, avatars, emailsNowhereThe provider owns them; conditions do not need them

Provider mappers (subjectFromSupabase, subjectFromClerk, subjectFromBetterAuth, subjectFromJwt) fill the first four rows from verified material and expose a schema option for custom claims, typed from its output (extension interfaces). PrincipalOf<typeof policy> is the resulting type.

Open questions

  • Whether delegation-chain verification (monotonic attenuation across hops) belongs in core or stays in the token layer.
  • The exact authorization_details mapping when a permission is a collection action with no identifier.
  • Whether actor should be allowed to carry its own roles for service accounts that call on their own behalf, or whether such calls should simply use the service as principal (the current design).
  • Whether a principal has a home tenant distinct from its memberships (cross-tenant guests), see tenancy.
  • How anonymous principals receive grants, if at all (see policies).

On this page