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, verbatimField 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 sourcesThe 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. rolesselects which globalrole()grants apply.membershipsselects scoped roles: each entry names a tenant, a team inside a tenant, or one resource, and the roles held there;tenantis 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 theRoleSource, 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 asubjectFrom<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:
| Adapter | actor.kind | actor.id | Source |
|---|---|---|---|
permdock/mcp | 'mcp-client', or actorKind | OAuth client_id | authInfo 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 readOnly | a better-supabase support session; no delegation, so only a policy delegation reaches it |
permdock/supabase, act.kind: 'impersonation' | 'impersonation' | the admin's act.sub | better-supabase actingAs; no delegation, so only a policy delegation reaches it |
permdock/ai-sdk | 'ai-sdk' | your runtimeContext.agentId | the actor option of createPermDock |
permdock/claude-agent | 'claude-agent' | session or agent name | the actor option |
| HTTP adapters with Web Bot Auth | 'web-bot-auth' | the verified signer keyid | RFC 9421 HTTP Message Signatures via webBotAuth |
permdock/a2a | 'a2a-agent' | the calling Agent Card identity | A2A securitySchemes on the authenticated extended card |
| none | absent | absent | a 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:
kind | Who | principal.id | Typical material |
|---|---|---|---|
'user' (default) | A human | The identity provider's sub | Session, ID token, access token |
'service' | An application-level service account | Your service account id | A 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 identity | A SPIFFE ID (spiffe://trust-domain/ns/prod/sa/reports) or the WIMSE workload identifier | Client-credentials token, SPIFFE SVID over mTLS, transaction token |
'link' | Whoever holds a share link | The 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:
jktis DPoP,x5t#S256is mTLS;jwkandkidare 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
principalwhen the token identifies a user or workload acting for itself, and onactorwhen the token carries anactchain (the key belongs to the presenting agent). - Core does not check it. Proof-of-possession needs the HTTP request (the
DPoPheader) or the TLS client certificate, which core never sees. The check belongs to the adapter that has the request:verifyDpopProof(request, claims)inpermdock/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 (thecnfmember 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'inpermdock/jwta 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)- Evaluate the policy for the principal as if no actor existed.
deniedstaysdenied. - If there is a
delegation, check the permission against it: scope present, or anauthorization_detailsobject whosetype,actionsandidentifiercover this permission and this row. - If the delegation does not cover it, the outcome is
deniedwith a denial{ role: null, reason: 'not-delegated' }, andalternativeslists the permissions on the same resource that both the principal holds and the delegation covers. approval-requiredsurvives 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.subjectongrantedis the narrowed subject (principalnon-null).on('decision')events carryprincipal.idandprincipal.issuer,principal.roles,tenant, the matchingmembershipand itsvia,actoranddelegationso audit can distinguish "Alice deleted a post" from "the reporting agent deleted a post for Alice under scopepost: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
principaltosubject, putmembershipsundersubject.properties, and putactor,delegationand the activetenantundercontext(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.
| Value | Where it goes | Why |
|---|---|---|
The user id and issuer, kind, assurance (acr, amr, authTime, verified) | principal fields | Referenced by conditions (subject.assurance.acr), step-up challenges and the token binding |
Global roles (support, platform-admin, pro) | principal.roles | Apply everywhere |
| A role held in a tenant, a team or on a document | principal.memberships | Scoped roles, memberOf, tenant-scoped audit, the UI's role chips |
| The tenant this request is about | principal.tenant | Selected by the server from the URL or the provider's active organisation, only when a membership matches |
| Settings, flags, plan | context (or roles when they select a role) | Not roles, not memberships; still portable |
| Display names, avatars, emails | Nowhere | The 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
Conditions
One portable condition AST evaluates in memory, filters arrays, compiles to Drizzle, Prisma and Kysely where clauses, and generates Postgres RLS.
Named scopes
A policy declares its scopes in order (an organization, the customers inside it); roles are held at one scope, memberships name a scope instance, and a grant reaches a row only through that scope's own key, with no cascade between scopes.