Skip to content

ADR-008: Per-Hop Agent Identity via STS Token Exchange

Status

Proposed — 2026-06-21

Context

Gatez's identity model is tenant-centric: every request carries a tenant_id claim (validated at L1 by APISIX/Zitadel), and agent delegation lineage is reconstructed after the fact from ClickHouse audit logs and the L3 A2A topology graph. This answers "which tenant" but not "which agent is acting" or "on whose behalf" in a way that downstream hops can authorize on live.

Uber's "Solving the Agent Identity Crisis" (2026) frames the gap: an agent is "authorized to act for or in the place of another," and that delegation must survive every hop cryptographically. Their architecture converges almost 1:1 with Gatez (their AI Gateway ≈ L2, MCP Gateway ≈ L3 tool enforcement, AI Agent Mesh ≈ A2A topology), with one piece Gatez lacks: a Security Token Service (STS) that mints short-lived, single-hop tokens carrying a full actor chain.

Decision

Introduce gatez-sts, a token-exchange service (shipped first inside the Control Plane API on :4001, extractable later) that mints per-hop, short-TTL JWTs carrying an append-only act_chain of [human, agent₁, agent₂…]. L3 verifies these tokens and authorizes on the originating human AND the acting agent simultaneously, turning the audit trail from descriptive into enforced.

Token format

Single-hop JWT, ~120s TTL, custom claims:

  • sub — the acting agent (agent:investigation-agent)
  • aud — the single next hop ONLY (agent:orders-agent); invalid at any other hop
  • tenant_id — preserved end-to-end; STS rejects cross-tenant exchange
  • act_chain — append-only [{id, type, ts}], oldest→newest, full lineage
  • jti — replay defense (Redis denylist for the token TTL)
  • session_id — ties to the existing L3 session + topology
  • purpose — optional intent claim (future ABAC)

Token exchange (RFC 8693)

POST /sts/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange. The subject_token is the Zitadel human JWT on the first hop, or the prior-hop STS JWT on subsequent hops; the actor_token proves the calling agent's identity. STS:

  1. Verifies subject_token (Zitadel JWKS on hop 1 — reusing the existing dual-issuer JWKS cache; STS's own key thereafter).
  2. Resolves the caller via the Agent Registry.
  3. Enforces the same A2A delegation policy used at L3 call-time (who may delegate to whom; loop detection rejects an aud already in act_chain).
  4. Guards tenant equality across subject, caller, and audience.
  5. Appends the caller to act_chain, mints the hop JWT, logs issuance to ClickHouse.

Agent identity bootstrap (phased)

  • P0: per-agent Zitadel service-user credential (credential_ref) — zero new infra.
  • P1: mTLS client cert per agent workload.
  • P2: SPIFFE/SPIRE SVID (Uber-equivalent cryptographic workload attestation).

Signing (phased)

  • P0: HS256 with STS_SIGNING_KEY (symmetric, internal network only — stub). Retained as an explicit legacy fallback (set STS_SIGNING_KEY, leave STS_JWKS_URL unset).
  • P1 (implemented): EdDSA (Ed25519) asymmetric. CP-API publishes its public key at GET /sts/jwks (kty:OKP, crv:Ed25519); L3 verifies per-hop tokens by resolving the token's kid against that JWKS — no shared secret crosses the trust boundary. Config: CP-API takes an operator keypair via STS_ED25519_PRIVATE_DER_B64 (PKCS#8 v2 DER, base64) or generates an ephemeral key in GATEZ_ENV=local; L3 points STS_JWKS_URL at http://control-plane-api:4001/sts/jwks. JWTs carry alg:EdDSA + kid.
  • P2: SPIFFE/SPIRE SVID (Uber-equivalent cryptographic workload attestation).

Rationale

  • Reuses Gatez's existing Zitadel/JWKS, Redis, A2A-policy, and ClickHouse plumbing.
  • Keeps multi-tenancy (rule #4) and two-portal isolation (rule #7) as hard invariants inside the STS — Uber's design has no tenant concept.
  • Single source of truth for delegation policy at both issue-time and call-time avoids inconsistent allow/deny splits.

Consequences

  • L3 gains dual-principal authorization (human + agent) per tool call and A2A hop.
  • HITL approval cards can show the full verified chain, not a log-stitched guess.
  • Per-hop signing must stay within the latency budget (target P99 < ~10ms; EdDSA over RSA). Uber reports STS P99 < 40ms across thousands of agents.
  • Key rotation needs overlap windows so in-flight short-TTL tokens don't break.

Standards tracked

IETF WIMSE working group; draft-klrc-aiagent-auth-01 (AI Agent Authentication and Authorization). Token exchange is conceptually RFC 8693, customized for agent workflows.


Addendum (2026-06-22): registry unification, canonical IDs, rollout

Resolving the overlap between this STS identity registry (/api/agents, control plane) and the pre-existing L3 A2A AgentCard registry (/v1/a2a/agents, data plane), plus the delegation policy that today lives in three places (L3 a2a.rs::DelegationPolicy, L3 AgentCard, and this STS registry's allowed_audiences).

Source of truth

  • CP-API /api/agents is the system of record for the agent principal: identity, tenant, allowed_audiences (delegation policy), allowed_tools, credential_ref. Operators author it here.
  • L3 AgentCard stays as runtime capability advertisement (URL, capabilities) — it does NOT independently own delegation policy.
  • Both enforcement points read one projection. CP-API projects delegation policy to Redis on every registry write; the STS minter (issue-time) and L3 check_delegation (call-time) both read that projection. This is the same control-plane→Redis seeding pattern already used for plan rate limits and traffic labels. Eliminates drift, which here is a security bug (allow-at-mint / deny-at-call).

Canonical identifiers

All agents use the prefixed form agent:<name>; humans use user:<name>. The act_chain, aud, and sub claims already depend on this. The existing AgentCard id ("shopping-agent") must be migrated to the prefixed form so the two registries join on a single key.

Don't physically merge the endpoints

They live in different planes and serve different runtime needs (control-plane config vs data-plane advertisement). Unify the schema and the authority, not the transport. One operator "Agent Identity" page manages the principal via CP-API; the A2A Topology page stays a read-only runtime view, joined on agent_id.

Rollout — feature-flagged, inert by default

The whole feature ships behind a flag and stays off until a regulated-vertical demo:

  • Server: GATEZ_AGENT_IDENTITY=on gates the STS + registry endpoints in CP-API.
  • Frontend: VITE_AGENT_IDENTITY=on gates the operator "Agent Identity" page + nav. When unset, the endpoints return 404 and the UI is hidden — zero surface area.

Remaining work to reach end-to-end (each its own commit, cargo test required)

  1. CP-API: project delegation policy to Redis on registry write.
  2. L3: refactor a2a.rs::check_delegation to read the projection (removes drift).
  3. L3: wire sts_verify into the MCP tool-call + A2A send seam (dual-principal authz + replay), behind the same flag.
  4. A2A client: auto-attach hop tokens so developers never touch token exchange.
  5. Canonicalize AgentCard IDs to the agent: form (migration).

Enterprise API + AI + Agent Gateway