Delegation & act-chain

TL;DR — When agent A calls agent B that calls agent C on behalf of user U, AuthPlane records the full chain in the token’s act claim per RFC 8693 §4.1. sub stays as U (the original principal); act.sub names the current actor; act.act.sub names the actor before them. Full chain reconstructable from the token alone — no external correlation needed. AuthPlane caps chain depth (token_exchange.max_chain_depth, default 5) to prevent runaway nesting. Only the outermost actor is authoritative for access control per §4.1 ¶6 — inner-hop metadata is audit-only.

The problem

Multi-agent flows lose accountability. Agent A hands work to agent B who fans out to C and D. Downstream services see a token — whose token, exactly? U’s? A’s? B’s? Auditing “who actually made this call” requires correlating across N systems and hoping the correlation IDs match.

act claim solves this: the token IS the audit trail. Any downstream service can read the token and reconstruct the delegation chain.

Simple delegation

User U’s agent A wants to hand work to sub-agent B:

1. A holds a token: sub=user-42
2. A → AS   POST /oauth/token
             grant_type=urn:ietf:params:oauth:grant-type:token-exchange
             subject_token=<A's token>
             subject_token_type=urn:ietf:params:oauth:token-type:access_token
             resource=https://downstream.example.com
             scope=tools/read
             (optional: actor_token=<...> + REQUIRED actor_token_type)
3. AS mints a delegated token — the actor identity is the *authenticated
   client* on this token endpoint call (RFC 8693 §4.1 Figure 6). Passing
   actor_token is only needed when the client wants to override that.

The issued token:

{
  "sub": "user-42",                        // preserved from subject_token
  "aud": "https://downstream.example.com",
  "scope": "tools/read",
  "act": {
    "sub": "agent-B",                      // whoever's acting on user's behalf
    "actor_type": "agent"
  }
}
  • sub = original user (audit trail says “this ultimately serves user-42”).
  • act.sub = the acting agent (audit trail says “agent-B is the one actually holding this token”).
  • act.actor_type = “agent” or “service” — hint at whether the actor is a human-serving agent or a machine.

Downstream sees the token, knows: request is on behalf of user-42, currently held by agent-B.

Chained delegation (A → B → C)

Agent B now hands to sub-agent C:

1. B holds the delegated token: sub=user-42, act.sub=agent-B
2. B → AS   POST /oauth/token
             grant_type=urn:ietf:params:oauth:grant-type:token-exchange
             subject_token=<B's delegated token>
             resource=https://downstream.example.com
             client_id=agent-C (or whoever's initiating)

The issued token now has TWO levels of act:

{
  "sub": "user-42",
  "act": {
    "sub": "agent-C",                     // outermost — current actor
    "actor_type": "agent",
    "act": {
      "sub": "agent-B",                    // previous actor
      "actor_type": "agent"
    }
  }
}

Reading:

  • sub = user-42 — this call serves user-42.
  • act.sub = agent-C — agent-C is holding this token right now.
  • act.act.sub = agent-B — agent-C got the token from agent-B, who was acting on user-42’s behalf.

Full chain: user-42 → agent-B → agent-C. Reconstructable from the token alone.

Only the outermost actor is authoritative

Per RFC 8693 §4.1 ¶6, downstream services MUST use only top-level claims + the outermost act.sub for access-control decisions. Inner-hop metadata (act.act.sub, act.act.act.sub, …) is informational — audit + display only. Do NOT gate access on inner hops.

Why: only the outermost actor’s identity is directly verifiable from the OAuth client authentication that made the exchange call. Inner-hop values are self-reported and only as trustworthy as the issuer that stamped them.

Trust model — cross-issuer subject tokens

Inner act values are only as trustworthy as the issuer of the original subject token. AuthPlane only accepts its own tokens on the exchange path today, so every inner-hop value that exists was stamped by AuthPlane on a prior exchange — inherently trustworthy.

If AuthPlane ever gains federation on the exchange path (accepting subject tokens from other AS issuers), this changes: the AS you’re federating with could stamp arbitrary act values that AuthPlane would then pass through unverified. The SanitizeNonIdentityClaims function in internal/domain/token/exchange.go proactively strips structural claims (exp, nbf, aud, iat, jti) from inner hops so a hypothetical federated future can’t smuggle those either.

Chain depth limit

Chains can nest indefinitely without a cap. AuthPlane enforces token_exchange.max_chain_depth (default 5) — an exchange that would produce a chain deeper than the limit is rejected with chain_too_deep. Bump for pathological multi-hop deployments; keep low for normal flows.

Impersonation vs delegation

RFC 8693 distinguishes:

  • Impersonation — no act claim in the result. The exchanged token looks like it was directly issued to the original subject. Legitimate for narrow-scoping (e.g., “give me a version of my own token with fewer scopes”). Guarded by token_exchange.allow_self_exchange (default false — reject self-impersonation) and per-resource policy.exchange.allowed_client_ids.
  • Delegationact claim added, growing the chain. Under RFC 8693 §4.1 the actor identity comes from the authenticated client on the token endpoint call; an actor_token is only needed when the client wants to assert an identity other than its own (and when present, actor_token_type is REQUIRED per §2.1). This is the multi-agent case above.

AuthPlane’s default posture is to allow delegation and refuse self-impersonation. Change with token_exchange.allow_self_exchange: true if you specifically need self-narrowing.

The actor_type extra claim

AuthPlane adds actor_type inside act"agent" when the actor is registered with is_agent: true, "service" otherwise. Downstream services can distinguish “an agent is acting on the user’s behalf” from “a machine-to-machine service in the chain”. Informational; not authoritative.

agent_id and agent_chain — AuthPlane extensions

Alongside RFC 8693 act, AuthPlane emits two flat claims:

  • agent_id — the outermost agent’s client_id when it’s registered with is_agent: true.
  • agent_chain — ordered list of agent client_ids extracted from the act chain (capped at 8).

These are AuthPlane extensions (not standardized). They make it easy for downstream services to filter/log by agent without walking the nested act tree. See Concepts: Agent identity.