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.

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
  session?: string; // `sid`: the login this request belongs to; the join key for logout and CAEP events
  expiresAt?: number; // NumericDate seconds, 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
  issuer?: string; // `iss`; identity is issuer + id, always set by subjectFrom*
  kind?: "user" | "service" | "workload" | "link"; // 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?: Assurance; // `acr`, `amr`, `auth_time`, 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 Assurance = {
  acr?: string;
  amr?: string[];
  authTime?: number;
  verified?: VerifiedClaims[];
}; // OIDC Core section 2; provider `aal` maps into `acr`
type VerifiedClaims = {
  verification: { trust_framework: string } & Record<string, unknown>;
  claims: Record<string, unknown>;
}; // OIDC4IDA `verified_claims`, opaque
type Binding = {
  jkt?: string;
  "x5t#S256"?: string;
  jwk?: JsonWebKey;
  kid?: string;
}; // the RFC 7800 `cnf` members, verbatim

Field names follow one rule: where a specification names the concept, PermDock uses that name or maps to it exactly once, in the "Spec names" table on naming. id, issuer, expiresAt, actor, delegation.scopes and authorizationDetails are the camelCase spellings of sub, iss, exp, act, scope and authorization_details; assurance and binding carry the claims' own member names inside.

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. An actor the adapter supplies without a delegation is still covered when the policy declares a standing one for its kind in definePolicy({ delegations }); that ceiling, the sorted permission keys, is snapshot.delegated (policy delegations).

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. The one way to grant to anonymous callers is to: anyone(), the only selector that matches a null principal; there is no magic anonymous role (policies).

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: 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 context.teamIds to that subselect through a per-key mapping declared on the RLS adapter). A thrown or rejected context function is fail-closed: the instance still builds, subject.context is empty, and on('auth') reports reason: 'source-threw'. 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', or actorKindOAuth client_idauthInfo from the MCP TypeScript SDK (OAuth 2.1 resource server)
permdock/jwt with an act claim'oauth-client'outermost act.sub, the current actor (RFC 8693)the verified token; the nesting is delegation.chain
permdock/supabase, act.kind: 'support''support'the admin's act.sub; also sessionId and readOnlya better-supabase support session; no delegation, so only a policy delegation reaches it
permdock/supabase, act.kind: 'impersonation''impersonation'the admin's act.subbetter-supabase actingAs; no delegation, so only a policy delegation reaches it
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-auth'the verified signer keyidRFC 9421 HTTP Message Signatures via webBotAuth
permdock/a2a'a2a-agent'the calling Agent Card identityA2A securitySchemes on the authenticated extended card
noneabsentabsenta plain human or service request

This table is the closed list of actor.kind values; the transaction-token and SPIFFE recipes below add 'workload', and the agent-runtime adapters add their own name ('eve', 'openai-agent', 'claude-agent', 'terminal') on their pages. An adapter adds its row here before using a new value.

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. An actor with readOnly: true gets only the read-only permissions of the policy delegations that name it (policy delegations).

Delegation

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

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. For a collection action the object has no identifier. 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 and leaves verification of monotonic attenuation to the token layer; core never inspects the chain.

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 four 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 accountYour service account idA service key verified by subjectFromApiKey (one tenant membership, via: 'credential', API keys), a 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
'link'Whoever holds a share linkThe link id (capability.id)A permdock-capability+jwt verified by subjectFromCapability; the principal holds one resource membership and no roles (link capabilities)

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 (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 (RFC 7800) that binds the token to a key the presenter must prove it holds. PermDock keeps that binding on the subject as the cnf object itself, member names unchanged, so it round-trips into signed outputs and into a verifier in another language without translation:

binding: { jkt: 'NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs' }          // DPoP, RFC 9449: JWK thumbprint of the proof key
binding: { 'x5t#S256': 'A4DtL2JmUMhAsvJj5tKyn64SqzmuXbMrJa0n761y5v0' }   // mTLS, RFC 8705: certificate thumbprint
  • The method is implied by the member: jkt is DPoP, x5t#S256 is mTLS; jwk and kid are accepted for RFC 7800 completeness but no adapter verifies them. An object with none of the four members is not a binding and is dropped.
  • 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 (the cnf member and its value) 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 and principal.issuer, 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 and issuer, kind, assurance (acr, amr, authTime, verified)principal fieldsReferenced by conditions (subject.assurance.acr), step-up challenges 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.

Last updated on

On this page