Appearance
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 hoptenant_id— preserved end-to-end; STS rejects cross-tenant exchangeact_chain— append-only[{id, type, ts}], oldest→newest, full lineagejti— replay defense (Redis denylist for the token TTL)session_id— ties to the existing L3 session + topologypurpose— 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:
- Verifies
subject_token(Zitadel JWKS on hop 1 — reusing the existing dual-issuer JWKS cache; STS's own key thereafter). - Resolves the caller via the Agent Registry.
- Enforces the same A2A delegation policy used at L3 call-time (who may delegate to whom; loop detection rejects an
audalready inact_chain). - Guards tenant equality across subject, caller, and audience.
- 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 (setSTS_SIGNING_KEY, leaveSTS_JWKS_URLunset). - 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'skidagainst that JWKS — no shared secret crosses the trust boundary. Config: CP-API takes an operator keypair viaSTS_ED25519_PRIVATE_DER_B64(PKCS#8 v2 DER, base64) or generates an ephemeral key inGATEZ_ENV=local; L3 pointsSTS_JWKS_URLathttp://control-plane-api:4001/sts/jwks. JWTs carryalg: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/agentsis 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=ongates the STS + registry endpoints in CP-API. - Frontend:
VITE_AGENT_IDENTITY=ongates 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)
- CP-API: project delegation policy to Redis on registry write.
- L3: refactor
a2a.rs::check_delegationto read the projection (removes drift). - L3: wire
sts_verifyinto the MCP tool-call + A2A send seam (dual-principal authz + replay), behind the same flag. - A2A client: auto-attach hop tokens so developers never touch token exchange.
- Canonicalize AgentCard IDs to the
agent:form (migration).