Skip to content

Defining Agent Policy

Prerequisite: 04 — Defining a Template. Policy lives in two template blocks: authzTemplate and delegation.

There are two distinct policy systems on the platform, and the first job of this guide is to keep them separate in your head:

  1. An agent’s own authority — what a single agent may do, via authzTemplate (SpiceDB relations) + the scopes it requests. → Part 1.
  2. Delegation policy — what a parent may hand to a child, via the delegation block, enforced at token-exchange. → Part 2.

How policy is set and consumed

Policy is authored in templates (one place) but consumed at several points and moments. This map is the orientation for everything below:

SET POLICY (author-time) CONSUME POLICY (run-time)
──────────────────────── ─────────────────────────
Template author
│ POST /v1/templates
│ { authzTemplate, delegation{} }
┌─────────────┐ system of record (in-memory)
│ Registry │◀──────────────────────────────┐ GET /v1/delegation-policy
│ templates │ │ GET /v1/agents/{id}/chain
│ + records │ │
│ + revoke │──┐ │
└─────────────┘ │ on self-register: project │
│ authzTemplate relations; │
│ on revoke: drop relations │
▼ │
┌─────────────┐ ┌────────┴─────────┐
│ SpiceDB │ │ IdentityServer │
│ relation │ │ token-exchange: │
│ tuples │ │ allowedChild │
└──────┬──────┘ │ grantable (∩) │
│ per-request check │ maxDepth │
│ │ act-chain + │
▼ │ revocation │
┌─────────────┐ Bearer token └────────┬─────────┘
│ Sample API │◀───────────────┐ issues attenuated token
│ (resource │ │ (scope ⊆ ceiling, act)
│ server): │ ┌────┴─────┐ │
│ work_on │ │ Sidecar │◀─────────┘
│ on EVERY │ │ /token │ mint (audience=delegation)
│ chain hop │ │ mint / │ exchange (subject_token=…)
└──────┬──────┘ │ exchange │
▲ └────┬─────┘
│ Bearer │ /token?scope | subject_token
└──────────┬────────────┘
┌─────┴─────┐
│ Agent │
└───────────┘
Policy elementSet byStored inConsumed byWhen
authzTemplate.spiceDbRelationstemplate authorRegistry → SpiceDBSpiceDB tuples; Sample API (work_on)written at self-register; checked per protected call
requested scopeagent code(carried in token)IdentityServer (issue); Sample API (validate)per token request / per call
delegation.allowedChildTypesparent templateRegistryIdentityServer via /v1/delegation-policyat token-exchange
delegation.grantableScopesparent templateRegistryIdentityServer (attenuation ∩)at token-exchange
delegation.maxDepthparent templateRegistry (+ chain)Orchestrator (/v1/spawn-policy); IdentityServerat /spawn (chain length) + token-exchange (delegation depth)
revocation stateoperator/UI (/revoke, /resume)Registry → SpiceDBIS (chain check); SpiceDB (per-call)at /revoke + per protected call

Part 1 — An agent’s own authority

Tutorial: authorise an agent to call a protected API

Goal: let a report-builder agent read sample-api-a.

Step 1 — grant the relationship. In the template’s authzTemplate, declare the relation the agent needs. The standard tenant-membership grant is:

"authzTemplate": {
"spiceDbRelations": [
{ "resource": "tenant:{{tenant_id}}", "relation": "agent", "subject": "agent:{{agent_id}}" }
]
}

When an agent of this type self-registers, the registry expands the tokens and writes tenant:tenant-1#agent@agent:report-builder-xxxxx into SpiceDB. That tuple satisfies the work_on permission in the schema (schema.zed):

definition tenant {
relation agent: agent
permission work_on = agent // any related agent may work_on the tenant
}

Step 2 — request a scoped token. The agent asks its sidecar (via the SDK’s TokenClient) for a token carrying the scope the API requires:

import { TokenClient } from '@spawnly/sdk';
const tokens = new TokenClient();
const accessToken = await tokens.getToken('sample-api-a:read');

Step 3 — call the API. Send the Bearer token and the tenant header:

await fetch(`${process.env.API_A_URL}/work`, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}`, 'X-Tenant-ID': tenantId },
});

What the resource server checks

sample-api’s authorize() (cmd/sample-api/main.go) runs these gates, in order:

  1. X-Tenant-ID present — which tenant is this call for? (tenant gate — see below)
  2. Scope present — the token must carry the scope for the method (:read for GET, :write for POST). Missing scope → 403.
  3. work_on on every chain hop — for the calling agent and every actor in its act chain, SpiceDB must say tenant:T#work_on@agent:<id> (main.go:78). Any member lacking it → 403. (tenant gate)

Gate 3 is what makes suspension cascade (Part 2): drop one agent’s relation and every protected call whose chain includes it is denied.

The two tenant gates (1 and 3) are active only when the instance requires a tenant (REQUIRE_TENANT, default true). A tenant-agnostic instance skips both, validating only the token signature, audience, and scope — that’s what lets a global agent call it.


Part 2 — Delegation

Tutorial: let a parent delegate read-only access to a child

Goal: parent report-builder lets child data-fetcher read sample-api-b, but never write it — even though nothing about the child’s own identity grants it that access. The authority is delegated and attenuated.

Step 1 — declare the delegation ceiling on the parent’s template. This is the policy gate; without it, no exchange across this edge is allowed:

"delegation": {
"allowedChildTypes": ["data-fetcher"],
"grantableScopes": ["sample-api-b:read"],
"maxDepth": 3
}

Step 2 — the parent mints a delegation token. Its sidecar issues a token with the sentinel audience=delegation (not usable at any resource server, only re-exchangeable at IS) and the scope it intends to pass down:

// parent: client-credentials with audience=delegation
// (wire: GET /token?audience=delegation&scope=sample-api-b:read)
const delegationToken = await tokens.getToken('sample-api-b:read', { audience: 'delegation' });

Step 3 — the parent hands the token to the child over A2A (message metadata), and the child’s sidecar exchanges it — actor = the child’s SVID, subject = the delegation token:

// child: RFC 8693 token-exchange
// (wire: GET /token?subject_token=<delegationToken>&audience=sample-api-b&scope=sample-api-b:read)
const exchanged = await tokens.exchangeToken({
subjectToken: delegationToken,
audience: 'sample-api-b',
scope: 'sample-api-b:read',
});

Step 4 — observe the attenuation. With the exchanged token the child reads sample-api-b successfully (200) but a write is denied (403) — the scope was never granted. That 403 is the visible proof least privilege held across the boundary.

This is exactly the Scenario 3 flow; the parent-agent/child-agent reference code implements it.

What IdentityServer enforces at the exchange

TokenExchangeGrantValidator rejects the exchange unless all of these hold:

  1. Valid actor. actor_token is a valid SPIRE SVID; its SPIFFE id is the new actor (the child).
  2. Scope ⊆ parent. Every requested scope is present in the subject_token’s scope (the parent’s actual authority).
  3. Edge allowed. Registry /v1/delegation-policy?parentType&childType returns allowed: true (from the parent template’s allowedChildTypes).
  4. Scope ⊆ ceiling. Every requested scope is in the parent type’s grantableScopes.
  5. Depth. subject act-chain depth + 1 ≤ maxDepth.
  6. Whole chain active. The child and every actor named in the subject’s act chain must be active in the registry — any revoked/failed/completed member rejects the exchange.

Gates 2 and 4 together are the attenuation rule:

granted = requested ∩ parent-scopes ∩ child-type-ceiling

On success IS wraps the new actor around the existing chain: act = { sub: <child spiffe>, act: <subject_token.act> }.

Why delegation only narrows

A recurring instinct is: “if policy allows the parent→child edge, why not let the exchange mint completely different scopes the parent never held? The token would just record that the parent handed off to the child.” It’s worth being precise about why the platform refuses this.

  • A token is an authorization assertion, not a call-graph record. With sub fixed to the user down the whole chain, a downstream token asserts “this is user-1’s authority, wielded by agent:child via agent:parent.” If the child minted a scope neither the user nor the parent held, that assertion is false — you wouldn’t be recording a handoff, you’d be forging an authorization. A resource server can’t tell the difference, and shouldn’t have to: a constant sub is exactly what lets it trust “scopes here ⊆ what the subject authorized.”
  • “Different scopes, on whose behalf?” has no coherent answer. If the child exercises authority as the user, the user must have held it. If it exercises authority as itself, then sub should be the child — which is the own-authority handoff, not delegation.
  • Expansion is the confused-deputy pattern. Letting a callable child mint authority for whoever invoked it turns “P may call C” into “P may wield C’s powers” — silent escalation via composition, and an audit story where “what can P do?” becomes the transitive closure over every reachable child.
  • The lineage you want is already captured elsewhere. parentId / /v1/agents/{id}/chain and the event log record the handoff regardless of scope, so expansion buys no provenance the platform doesn’t already have — it only weakens the authority invariant.
  • In this codebase, attenuation is load-bearing. The resource server gates on the scope string + per-tenant work_on, with no per-scope SpiceDB check, so the IS requested ⊆ subject gate is the only thing stopping expansion from granting access. Relax it and grantableScopes flips from “what P may share” to “what P may summon that it never had.”

The takeaway: model different authority as the child’s own authority (its template + own ceiling) and reserve delegation for narrowing a slice of the user’s authority. Keep using token-exchange for the act chain it produces — just bound the result by the subject.

Revocation (revoke / resume)

revoke cuts off an agent’s authority and its entire descendant subtree — everything it spawned, transitively — in real time, while leaving the pods running. It is authority-only (not a kill) and reversible with resume. This is distinct from DELETE /v1/agents/{id}, which tears down a single pod and does not cascade.

Revoke walks the subtree (via parentId lineage) and, for each node that is currently active, drops its SpiceDB relations and sets its status to revoked. Ancestors and siblings are untouched, and descendants that already exited (completed/failed/killed) keep their terminal status — so a cascade never clobbers a node that finished on its own, and revoke is idempotent.

Because each node’s own relations are dropped (not just an ancestor’s), a revoked agent is denied even when it acts alone — and the act-chain check (gate 6) additionally denies anyone delegating through a revoked ancestor. The effect is enforced in three layers:

ConcernMechanismLatency
New / refreshed tokens through a revoked agentIS chain-active check (gate 6)Instant
In-flight token on a protected callSpiceDB drop on revoke → resource-server work_on fails on that hopInstant (next call)
In-flight token on any other pathShort token TTL (120s)≤ one TTL

Operations (the response lists exactly the nodes that changed):

Terminal window
curl -sf -X POST http://localhost:8080/v1/agents/<id>/revoke # {"revoked":[...]} — subtree → status=revoked, relations dropped
curl -sf -X POST http://localhost:8080/v1/agents/<id>/resume # {"resumed":[...]} — re-derive relations from template, status=active

revoke (cmd/registry/main.go:456) applies revokeNode over the subtree — DeleteAgentRelationships + status revoked; resume (cmd/registry/main.go:478) applies resumeNode, re-writing each revoked node’s template relations and restoring active. Each emits an agent_revoked / agent_resumed lifecycle event per node.


Reference appendix

SpiceDB model and extending it

Today’s schema (schema.zed) is deliberately minimal:

definition agent {}
definition tenant {
relation agent: agent
permission work_on = agent
}

To authorise a new resource, add a definition with its own relations and permissions, then have templates write the matching relations via authzTemplate.spiceDbRelations. The resource server for that resource then calls CheckPermission(resource, permission, agent:<id>). Keep the projection pattern: the registry writes tuples; resource servers only read.

Tenant-agnostic resource servers

Tenant enforcement is a per-resource-server choice, not a platform-wide rule. A sample-api instance reads REQUIRE_TENANT (default true):

REQUIRE_TENANTBehaviour
true (default)Demands X-Tenant-ID and checks work_on on every chain hop (gates 1 + 3 above).
falseTenant-agnostic: skips both tenant gates; still validates token signature, token_use, audience, and scope.

A tenant-agnostic instance is what a global agent calls — it asserts no tenant and holds no tenant: grant, so a tenant-checking instance would (correctly) deny it. The sample-api-global manifest (deploy/manifests/sample-api-global.yaml) and the global-worker template (agents/global-worker/template.json) are a worked example of the pair. Setting REQUIRE_TENANT=false relaxes only the tenant check — authn and scope are still enforced.

Scopes and audiences catalog

Defined in IdentityServer (Config.cs):

ScopeGrants
sample-api-a:read / sample-api-a:writeread / write sample-api-a
sample-api-b:read / sample-api-b:writeread / write sample-api-b
sample-apilegacy, backward-compat
Audience (aud)Meaning
sample-api-atoken usable at sample-api-a (scopes sample-api-a:*)
sample-api-btoken usable at sample-api-b (scopes sample-api-b:*)
delegationsentinel — no resource audience; only re-exchangeable at IS

Access tokens are short-lived (120s, AccessTokenLifetime), which is the TTL backstop in the revocation table.

Delegation enforcement points

Field / stateEnforced atEndpoint / check
allowedChildTypes (spawn edge)Orchestrator (at /spawn, when parentId set)GET /v1/spawn-policyallowed (deny-by-default)
allowedChildTypes (scope delegation)IdentityServerGET /v1/delegation-policyallowed
grantableScopesIdentityServerrequested ⊆ ceiling
maxDepthIdentityServerchain depth + 1 ≤ max
act chain lineageRegistryGET /v1/agents/{id}/chain
suspensionIS + SpiceDBchain-active check + dropped relations

Token modes at the sidecar /token

cmd/agent-sidecar/main.go picks a mode from the query params; the SDK’s TokenClient is the typed wrapper over each:

ParamsModeSDK callUse
scope onlyclient_credentials (cached)getToken(scope)an agent’s own protected calls
audience (+ scope)client_credentials, explicit audiencegetToken(scope, { audience })parent mints a delegation token (audience=delegation)
subject_token (+ audience, scope)token-exchangeexchangeToken({ subjectToken, audience, scope })child exchanges a delegated token, extending the act chain

For how the first row’s client_credentials mint turns a workload’s SVID into a token carrying sub = user:<id> and an act actor, see How an agent’s token is minted.

Status callouts

  • ⚠️ Suspension/revocation reflects the M3 implementation; deferred items like real user authentication and may_act are not yet enforced.
  • ⚠️ Like all registry state, suspension is in-memory — a registry restart resets agent records (templates must be re-seeded; see 04 — status callouts).
  • ⚠️ The userId that seeds a chain’s sub is currently trusted from the orchestrator, not independently authenticated (v1 simplification).

That completes the authoring series: anatomy → the three scenarios → templates → policy.