PermDock
Security

Delegation

The principal, actor and delegation model, the attenuation invariants that keep an agent from exceeding its user, how adapters fill the agent half of the subject, and how RAR authorization_details are emitted and verified.

A PermDock subject has two halves. principal is the human or service whose grants are evaluated; actor is the agent acting for them; delegation is the authority the principal handed over. A decision is the principal's grants intersected with the delegation. This page is the security view of that model; the API view is in subject, and the standards behind it in OAuth for agent delegation.

The three parts

const permdock = await createPermDock(policy, user, {
  actor: { id: "https://agent.example/cimd.json", kind: "mcp-client" },
  delegation: {
    scopes: ["post:read", "post:update"],
    authorizationDetails: [{ type: "post", actions: ["update"] }],
  },
});
PartWho supplies itWhat it isTrust
principaldefinePolicy's subject function from the verified session or tokenThe user's id, org, roles and other values behind subject.*Trusted after authentication
actorThe adapter, from authInfo.clientId, runtime context, signature or Agent CardWho is making the callTrusted after the runtime verified it; never from prompt content
delegationThe adapter, from token scopes, authorization_details, a GNAP access array or a delegation chainWhat the principal allowed the actor to doTrusted after token verification; intersected, never added to

A call with no actor is a human call and behaves exactly as createPermDock(policy, user). A call with an actor and no delegation (or a delegation with no scopes, authorizationDetails or access) has no delegated authority unless a policy delegation covers the actor: every check is denied with reason no-delegation, and the snapshot carries an empty scopes list so a client ceiling agrees. Token-based adapters supply the token's scopes; in-process agent adapters take a delegation option from the application and apply none by default.

Attenuation invariants

  1. Agent ≤ user. For every permission, asAgent.can(p, x) implies asUser.can(p, x). Delegation can only remove or narrow grants. This is enforced by construction: decide evaluates the principal's grants first, then filters by delegation.
  2. Chain hops only narrow. In a multi-hop chain (user to orchestrator to sub-agent), each hop's authority is the intersection of the previous hop's authority with what it passes on. The token layer enforces this when it issues the token; core carries delegation.chain opaquely for audit and evaluates only the final scopes, authorizationDetails and access.
  3. Conditions intersect. A grant's where is evaluated first and the delegation only filters the result, so a delegation cannot relax a grant condition.
  4. Approval survives delegation. A grant with approval: 'human' still requires approval when exercised through an agent; delegation cannot pre-approve. The approval token includes actor, so the approval is bound to the agent that asked.
  5. Deny is not delegable away. A deny grant applies regardless of delegation; an agent cannot be delegated around a deny.
  6. Fail closed on unknowns. An authorization_details type PermDock does not recognise, a scope that matches no permission, or a malformed chain contributes nothing; it never widens.

Policy delegations

A token says what one principal handed to one client for one session. Some delegations are standing policy instead: every member may let the company's Eve agent read and update posts for them; finance admins may let one named billing agent read invoices. Stating that in each token would repeat the policy in the authorization server, and the in-process agent adapters have no token at all. definePolicy takes delegations for this:

const policy = definePolicy(permissions, {
  roles: [member, admin],
  delegations: [
    {
      from: roles.member, // who hands over: the same selectors as approval.by
      to: actor("eve"), // which actor kind; { kind, id } names one agent, { kind, client } one named OAuth client
      permissions: [permissions.post.read, permissions.post.update],
      validUntil: "2027-06-01T00:00:00Z", // optional, as on a grant
    },
    {
      from: roles.admin,
      to: { kind: "eve", id: "agent-billing" },
      permissions: [permissions.invoice],
    },
  ],
  subject,
});

Each entry is normalised to { from, to: { kind, id?, client? }, permissions: string[], readOnly: string[], validity? } on policy.delegations, where readOnly is the subset of permissions whose readOnlyHint is true, in the fingerprint, and in the catalog's delegations section, so permdock diff reports a removed or narrowed one as breaking. from takes a role, authenticated(), a plan or assurance(); relation() and actor() are refused, because a delegation is matched without a row and the actor is to. permissions names leaves or subtrees of the policy's own tree.

How it applies, in decide and on the snapshot client alike:

  • When the subject has an actor, the active delegations whose to matches the actor's kind (and id, when set) and whose from the principal holds in the active tenant are united into a ceiling of permission keys. delegatedPermissions(policy.delegations, subject, heldRoles, now) is that computation, exported from permdock; snapshot.delegated carries the sorted result to the client. An actor with readOnly: true (a read-only Supabase support session) contributes only each delegation's readOnly keys, so a write it was delegated still denies with not-delegated.
  • A permission outside the ceiling is denied with reason not-delegated. Inside it, a token delegation on the same call must still cover the permission (coveredByDelegation runs as before): a token can only narrow a policy delegation, never widen it. With no token delegation, the ceiling alone decides; the no-delegation denial applies only when no policy delegation matched the actor.
  • The principal's grants are still evaluated first, so the ceiling adds nothing the user lacks, a where still filters, a deny still wins, and an approval is still required.
  • A delegation is revoked by removing it from the policy or by its validUntil. The RevocationFeed stays a connection signal: it ends sessions and never grants or shapes a decision, so dynamic revocation of a live delegation goes through the token layer or context, never through the feed.

Why

  • A standing delegation belongs in the policy. "This agent kind may act for members on these permissions" is a statement about the application, the same kind as a grant, and it was being made in three places with no shared source: the AI SDK delegation option, the token scopes an authorization server mints, and prose. Putting it in definePolicy gives it a fingerprint, a catalog entry, a diff, and a policy-matrix vector, and lets permdock doctor and an audit see which actors may act for whom.
  • It is a ceiling, not a grant. The design that would have been easiest, treating a delegation as a grant to the actor, breaks "Agent ≤ user": an agent would hold access the user does not. So the delegation never enters the allow list; it only replaces the token in the delegation check, after the principal's grants have decided. Every attenuation invariant above holds without a special case.
  • Token and policy intersect. When both are present the call must satisfy both, because either one may be the narrower statement: the token knows this session's consent, the policy knows the application's standing rule. Letting one override the other would make the wider one the effective rule.
  • A client name, not a client id. An authorization server assigns client ids per environment and at dynamic registration, so an id in the policy would make its fingerprint and catalog differ per deployment. to: { kind, client } names the client; the subject resolver maps the verified client id to that name through clients (ClientNames) on subjectFromSupabase, permdock/mcp and permdock/jwt's actor option, and sets actor.client. An id the mapping does not name, or names twice, leaves client unset, so the delegation fails closed. The name comes only from a verified id, never from the token's own claims.
  • Actor kind, optionally id. Agents are typically a fleet of one kind ('eve', 'mcp-client'), so the kind is the natural unit; a single trusted agent is { kind, id }. Matching only on id would make a renamed agent silently lose its delegation, matching only on kind could not express a one-agent rule.
  • The feed does not revoke a delegation. RevocationFeed means "end this session" and is kept that way by naming.mdc; a feed that also edited the policy would be a second decision path (invariant 15). Removing the entry, or letting validUntil pass, is the revocation, and both are visible in the catalog and diff.

Coarse OAuth scopes

A permission's OAuth scope is its key with : for . (task:read). An authorization server often issues coarser scopes instead (mcp:read, mcp:write). definePolicy({ oauthScopes }) maps each coarse scope to the permissions it covers:

export const policy = definePolicy(permissions, {
  roles: [...],
  oauthScopes: {
    "mcp:read": [permissions.task.read, permissions.task.list, permissions.project],
    "mcp:write": [permissions.task.update],
  },
  subject,
});
  • When an instance is created, a token delegation's scopes keep their order and are followed by the scopes of every permission a held coarse scope covers, so decide, snapshot, mayUse and the client snapshot all read the same expanded list. A coarse scope only adds what it lists; the user's grants and policy delegations still decide.
  • permdock/mcp and permdock/a2a accept a coarse scope in their scope pre-check and list filter. Their insufficient_scope challenges, and the WWW-Authenticate challenge of the server kernel, name the first coarse scope (in declaration order) that covers the permission, because that is the scope the authorization server can issue; a permission no coarse scope covers is challenged with its own scope.
  • A key must be an RFC 6749 scope token that is not a permission's own scope, and must cover at least one declared permission; definePolicy throws otherwise. The mapping is part of the policy fingerprint.

Coverage check

decide, snapshot and the agent kernel share one coverage check, exported from permdock as coveredByDelegation(permission, delegation, resourceId?, hasActor?). permission needs only scope, resource and action, so a leaf that crossed a serialisation boundary works. It returns undefined when the delegation covers the permission, and otherwise the denial reason decide adds:

  • no-delegation when hasActor is true and there is no delegation, a delegation with no scopes, authorizationDetails or access, or only empty scopes and access lists. decide passes hasActor: false when a policy delegation covers the actor, so that denial is reserved for an actor nothing delegated to.
  • not-delegated when no scope equals the permission's scope, no authorizationDetails entry has type equal to the resource with a matching actions list and identifier, and no GNAP access entry matches (a reference string equal to the scope, or an object whose type is the resource or ends in /<resource>).

A PDP outside PermDock core, such as the PermDock Cloud AuthZEN endpoint, calls it to stay identical to local decisions instead of reimplementing the rules. It narrows only; the principal's grants still decide.

How adapters fill actor and delegation

Adapteractordelegation
permdock/mcpauthInfo.clientId (an OAuth client id or a CIMD URL), kind: 'mcp-client', or actorKindauthInfo.scopes; authorization_details when the token carries them (including ID-JAG-derived tokens under Enterprise-Managed Authorization)
permdock/ai-sdkactor: ({ runtimeContext }) => ({ id: runtimeContext.agentId, kind: 'ai-sdk' })The delegation option, resolved per call by the application; none by default, so an agent without it is denied
permdock/claude-agentThe Claude Agent SDK session, kind: 'claude-agent'The delegation option, resolved per session; none by default. The tools map separately bounds what the agent can even ask for
permdock/openaiThe actor option, from the run contextThe delegation option, from the run context; none by default
permdock/eveactorFromSession: the current principal when it differs from the initiator, otherwise eve:app, kind: 'eve'The delegation option, from the session; none by default
permdock/a2aThe calling agent's identity from its card or token, kind: 'a2a'Caller's token scopes and authorization_details
permdock/supabaseact by its kind (oauth-client, support with sessionId and readOnly, impersonation), else client_id as oauth-clientFor oauth-client only: the scope claim without the OpenID Connect identity scopes, and the act chain. Support and impersonation get none, so only a policy delegation reaches them
HTTP adapters with Web Bot AuthVerified RFC 9421 signer key id, kind: 'web-bot-auth'From the bearer token on the same request, if any; otherwise empty
permdock/webmcpThe page's agent context, if the browser exposes oneThe client snapshot is the ceiling; the server re-checks with the real delegation
Human-only adapters (permdock/next, permdock/react)NoneNone; principal grants apply directly

The AI SDK case deserves a note: the SDK gives PermDock the agent's identity but not a token, so what the agent is delegated is an application decision, stated in the delegation option of createPermDock. There is deliberately no default: an in-process agent with no delegation is denied every check with reason no-delegation, so an application that wants the agent to act as the user lists those scopes explicitly. See the ai-sdk adapter.

One OAuth access token can reach an application through several adapters: an MCP tool that calls the app's own API with the caller's token is decided by permdock/mcp and then by permdock/supabase (or permdock/jwt). Those adapters name the client oauth-client, and permdock/mcp names it mcp-client unless actorKind says otherwise. Set actorKind: 'oauth-client' on the MCP adapter and on subjectFromMcp so the token is one actor kind everywhere and one policy delegation or actor('oauth-client') grant covers it; keep 'mcp-client' when MCP clients should get rules of their own.

RAR authorization_details

Every permission reference carries a scope and an authorizationDetails type. This lets PermDock participate in RFC 9396 Rich Authorization Requests in both directions:

  • Emission for consent screens. Given a set of permissions an agent wants, PermDock produces authorization_details entries whose type is the resource name, actions the actions and identifier the resource id for an instance action. The objects carry no condition payload: the grant's conditions stay in the policy and are evaluated on every call.
  • Verification on incoming tokens. delegation.authorizationDetails entries are matched to a permission by type equal to its resource, when present actions containing its action, and when present identifier equal to the resource id (an entry with an identifier never covers a collection check). Unknown types contribute nothing (fail closed).
[
  { "type": "post", "actions": ["read"] },
  { "type": "post", "actions": ["update"], "identifier": "post_123" }
]

Scopes remain the coarse layer: post:update in delegation.scopes is required for permissions.post.update to be exercisable at all; the authorization_details entry, when present, narrows it further.

GNAP access as a third input

RFC 9635 (GNAP) describes delegated rights as an access array whose objects carry type, actions, locations, datatypes, identifier and privileges, a structure the RFC itself calls analogous to RAR. delegation.access accepts those objects next to scopes and authorizationDetails. At a resource server the normative source is RFC 9767 (GNAP Resource Server Connections): permdock/jwt fills it from the access claim of a JWT-formatted access token (RFC 9767 sections 2.1 and 2.2), and subjectFromIntrospection fills it from the access array of an introspection response (section 3.3), which the AS may have filtered to what this RS is allowed to see (GNAP). The three fields are unioned into one delegated set before the intersection with the principal's grants: an unknown type contributes nothing, a type matches the resource name or a URI ending in /<resource>, identifier narrows to one resource, privileges never adds a role.

delegation: {
  scopes: ['post:read'],
  authorizationDetails: [{ type: 'post', actions: ['update'] }],
  access: [{ type: 'https://api.example.com/permdock/resources/post', actions: ['update'], identifier: 'post_123' }],
}

RAR metadata and error remediation

When a delegated check is denied with reason not-delegated, the caller's next move is a step-up: go back to the authorization server and ask for the authority that was missing. RFC 6750 only offers insufficient_scope and a flat scope hint, which cannot express "you need post.update restricted to posts you authored". The IETF draft OAuth 2.0 RAR Metadata and Error Remediation (August 2026) fills that gap with two pieces: authorization-server metadata describing the RAR type values it supports, and a structured remediation object returned on insufficient authorization that names the authorization_details the client should request.

PermDock already computes the content of that object. Decision.alternatives lists the permissions on the same resource that the principal holds and that the delegation could cover, and every permission carries an authorizationDetails type with its constraint payload. When the caller is an OAuth client, HTTP adapters and permdock/mcp therefore render a denied Decision as follows:

  • WWW-Authenticate: Bearer error="insufficient_scope" with the scope values of the alternatives, for clients that only understand RFC 6750.
  • The RFC 9457 Problem Details body with alternatives expressed as authorization_details objects in the draft's remediation shape, so a RAR-aware client can copy them straight into its next authorization request.
  • For MCP clients, the same alternatives inside the scopeChallenge, since SEP-2350 step-up accumulates scopes (MCP authorization).

The mapping is one-directional and fail-closed: remediation tells the client what to ask for; it never changes the decision that produced it, and the authorization server remains free to refuse. The draft is a working-group document and its field names may change; the Problem Details alternatives member is stable regardless, and the remediation shape is emitted next to it rather than instead of it. Its draft posture is therefore build: the HTTP adapters pin the draft-ietf-oauth-rar-metadata-remediation revision they render in the Problem Details fixtures, alternatives is the stable twin, and a pin bump is a maintainer change with fixtures and a changeset. The metadata half of the draft is where an authorization server would publish the resource type values it accepts.

Transaction tokens

Inside one trust domain (a set of services behind the same gateway, owned by the same team) requests fan out across several services, and each of them needs to know who the original requester was and what they were authorized to do. Re-sending the user's access token to every hop leaks a long-lived credential; re-authenticating at every hop is slow and loses the delegation context. The IETF OAuth working group's Transaction Tokens draft (revision 11, July 2026) solves this with a short-lived JWT, issued by a Transaction Token Service through RFC 8693 token exchange, that carries the requester's identity in sub_id, the request context, and an authorization-details object in azd, and that is valid only for an aud inside the domain.

PermDock's design for it:

  • Carrier, not evaluator. The service at the edge builds a PermDock from the verified access token, takes the snapshot() and the Decision for the entry-point permission, and places their identifiers, the snapshot id and the decision id, in azd when it requests the transaction token. Downstream services do not re-run the edge decision; they re-derive the subject from sub_id and azd with permdock/jwt and evaluate their own permissions against the same principal, actor and delegation.
  • Delegation travels intact. azd carries the scopes, authorization_details and access that the edge saw, so the attenuation invariants hold at every hop: a downstream service can be more restrictive than the edge, never less.
  • Never from outside the domain. A transaction token is accepted only when its iss is the domain's Transaction Token Service and its aud names this service; one that arrives from outside the trust boundary, or whose aud is another service, resolves to the anonymous subject and every check is denied. Cross-domain propagation is the separate Identity and Authorization Chaining specification (RFC Editor queue) and maps to delegation.chain, not to azd.
  • Audit correlates by id. The snapshot id and decision id in azd appear on every downstream on('decision') event, so an incident review can walk from the edge decision through every hop that acted on it.

The Transaction Tokens for Agents and Cross-domain Transaction Tokens drafts extend the same claims to agent context and to federated domains; both are tracked on the watch list and neither changes the rule above. WIMSE's architecture uses transaction tokens for security-context propagation between workloads, which is why a workload appears in PermDock as a service principal rather than as an actor without a principal. A service acting on its own behalf is always a principal (kind: 'service' or 'workload') with its own roles, never a distinct subject shape.

Audit

Every on('decision') event includes actor and delegation alongside the principal, outcome and reasons, and permdock/otel records them as span attributes. Two questions an incident review must answer, "which agent did this" and "under whose authority", are answered by the event itself. The AuthZEN endpoint carries the same fields in subject.properties.actor and context.delegation so a remote PDP sees them too.

Sources

Last updated on

On this page