Repository navigation
identity: carry the delegation chain in the Delegated Session Biscuit and expose it in the border JWT #742
Description
Base:
main@ c34e3ca. Allfile:linereferences are for that commit.
Problem description
Every delegated token names a single actor: actor_node, the node that ran the exchange at
POST /token/exchange (internal/identity/biscuit.go:385-391). Later hops can append
tar_blocks (task name, rules, expiry), but those blocks don't say which node or agent appended
them. If a hop re-exchanges, the new token names only that hop and loses the earlier ones. The
full provenance of a request that crosses several agents
(user → orchestrator → specialist → egress) only exists in logs: each node's, plus the control
plane's "Border Crossing" entries. Nothing in the token links the hops together.
1. The egress destination can't see which agents were involved
// internal/controlplane/sts.go:1074
"act": map[string]any{"sub": nodeRecord.PeerID}, // the node that called /sts/token (usually the egress node)At that point the control plane already holds the origin actor_node (claims.ActorNodePeerID,
sts.go:1035) and drops it. The STS behind oidc_federation or aws_assume_role learns who the
subject is and which node requested the token, but not which agents acted in between.
forward_context adds X-Mesh-Principal, X-Mesh-Roles and X-Mesh-Task, unsigned and without
the actor (internal/node/egress.go:240-252), and it's meant for an operator inspection chain, not
for the final destination.
2. In multi-hop flows you get the user or the agent, never both
When the agent behind node A calls another service, its options today are:
- Use its own identity. Either the node's Member Biscuit, or a biscuit whose subject is the
workload itself (for example a SPIFFE ID), exchanged at the node's/oauth/token. Since node: accept RFC 7523 client assertion and jwt-bearer grants on /oauth/token #645
that also works withclient_credentials+client_assertionand withjwt-bearer; see the
still-open docs,examples: OpenShell sandboxes and Substrate actors on the mesh under their own identity #655 for OpenShell and Substrate examples. Either way, the user drops out of the
request. - Re-exchange the user's JWT at A. The new biscuit carries
actor_node(A), so B and every
earlier hop disappear (mintDelegatedBiscuitFromJWT,sts.go:855-947). Anyactin the
incoming JWT is discarded too. And someone at A has to hold the user's raw JWT: if that's the
agent, it's a bearer credential exposed to prompt injection. - Reuse B's biscuit. It can't be used for mesh calls: the provider requires
actor_nodeand
client_peer_idto match the connection peer (internal/identity/biscuit.go:247-259,
internal/node/middleware.go:273). It doesn't reach A's agent either, because the provider
stripsX-Mesh-Biscuitbefore forwarding to the backend (internal/node/node.go:2527-2535). If
B's biscuit carries atar_block, A can exchange it for a border JWT at its/oauth/token
(internal/node/sts.go:115-128), but the result names only B in the biscuit and only A in
act.sub.
What exists today, and why it isn't enough
- Offline attenuation (
tar_block). The repo already presents it as a "sub-agent hop"
(agent-architecture.md, Blueprint 4 insandboxed-agents.md). It narrows rights, but blocks
are anonymous (the holder picks thename) and only work within the same peer or towards egress. mesh_taskin the border JWT. Carries only the innermost TAR name, for auditing.ext_proc. Already exposesattributes["mesh"].actor_node
(internal/node/egress_inspect.go:94-104), but a single actor.ext_authz. Returns the biscuit, principal, roles and task
(internal/node/ext_authz.go:155-166), with a singleactor_node.- The
jwt-bearergrant (identity chaining,node-api.md:85). Crosses a trust boundary with a
single subject; no intermediate hops. /oauth/tokenwith a biscuitsubject_token_type(api/network.go:197-198,
controlplane/sts.go:1692-1727). Verifies and attenuates, but doesn't add the new node or change
the binding.- Banning revoked nodes. The provider rejects tokens whose
actor_nodeis inrevokedPeers
(internal/node/middleware.go:442-448), but it only looks at the last actor that exchanged.
Why it's worth it
-
The destination sees the full chain, and the mesh can decide on it. In the border JWT the
chain goes in RFC 8693 §4.1's nestedact. The outermostactis the current actor (the egress
node) and the innermost is the first agent:{ "sub": "alice@example.com", "act": { "sub": "<egress-node>", "act": { "sub": "<specialist-node>", "act": { "sub": "<orchestrator-node>" } } } }RFC 8693 says nested actors are informational: a compliant consumer doesn't use them for
access control. So decisions on the chain are made in two places:- inside the mesh, with the nodes'
attenuation; - at the destination, only if it opts into evaluating a top-level claim defined for that
purpose (for examplemesh_delegation: ["<orchestrator>", "<specialist>"]).
Today those rules can only be checked after the fact, by correlating logs from nodes the
destination may not even operate. - inside the mesh, with the nodes'
-
The user and the agents travel together, without handing the user's JWT to any agent.
-
Least privilege doesn't decay across hops. Today every exchange at
/token/exchangestarts
from the JWT: it re-resolves roles against the policy and doesn't inherit the request's
tar_blocks. If the control plane derives the new token from the incoming biscuit, rights can
only narrow along the chain. It does so by copying the roles from the incoming block 0 and
carrying thetar_blocks forward. -
Policy over the chain at providers (confused deputy, loops, depth). With chain facts in the
authority block, a node'sattenuation(internal/node/config.go:121-146) can express, in
biscuit-go v2.2.2 syntax:- depth limit:
deny if delegation_depth($d), $d > 3(asdeny, notcheck, so tokens
without a chain aren't rejected); - cycles:
deny if delegation($i, $p), delegation($j, $p), $i < $j; - forbidden intermediary:
deny if delegation($i, "<quarantined peer>"); - required intermediary: allow
egress://paymentsonly if the chain includes an approved
orchestrator.
- depth limit:
-
Revocation that reaches the whole chain. Today banning a node only stops tokens where that
node is the last actor. As soon as a later hop re-exchanges, the token stops naming it. With the
chain, the samerevokedPeerscheck can walk every entry. A compromised agent's requests are
cut off downstream, while the nodes it called keep serving everything that didn't pass through it. -
Side note: the docs are out of sync.
security-architecture.md:309-311and the
FactActorNodecomment (api/datalog.go:82-84) say the originactor_nodegoes into
act.sub. The code puts the node that called/sts/tokenthere (sts.go:1074), as
identity.md:168says. This can be fixed in a separate docs PR.
Related standards
- RFC 8693 §4.1 (nested
act, informational) and §4.4 (may_act: a subject-token claim
stating who may act on the subject's behalf). - draft-ietf-oauth-transaction-tokens-11 (OAuth WG). When issuing a replacement Txn-Token, the
TTS must maintain the Call Chain of the workloads that requested it, but the mechanism is out of
scope for the draft. This proposal is a concrete mechanism for that inside the mesh.
Proposal (sketch, for discussion before a PR)
Step 1 (small, independent). Nest the origin actor_node, which the control plane already
has, in the border JWT: act: {sub: <egress>, act: {sub: <origin>}}. This also fixes the
inconsistency in point 6. If the origin and the egress are the same node, it isn't nested twice.
Step 2: re-binding with a chain at /token/exchange.
- Where.
/token/exchange(protobuf, with node credential and PoP challenge,
controlplane/sts.go:748-798) accepts a Delegated Session Biscuit as the subject. That needs a
subject_token_typefield inTokenExchangeRequestand a type distinct from today'sbiscuit
(for example…:token-type:delegated-biscuit), because it's a different operation: it changes
the binding. The control plane verifies it, adds the authenticated node, and re-mints with the
chain in block 0. This plays the role of RFC 8693'sactor_token, but the actor isn't sent as an
actor_token: it's identified by the node credential and the PoP. It can't be done by the
holder: facts in attenuation blocks are invisible to the authorizer
(TestAttenuationBlockFactsAreInvisibleToTheAuthorizer), and appending a block doesn't
identify who appended it. - Next-hop consent (mandatory). The control plane only re-mints if the incoming biscuit names
the presenting node as an authorized next actor. This would be a newTaskAuthorizationRulefield
(for exampleallowed_next_actors), evaluated in Go like the rest of the TARs. It's the analogue
ofmay_act. It can't be a Datalogcheck:UnmarshalInboundrejects blocks with checks
(internal/identity/biscuit.go:112-117). Without this consent, any node that has seen the biscuit
(a provider that was called, a log leak) could turn it into its own credential acting as the user. - Valid subjects. Only delegated biscuits whose
actor_nodewas PoP-authenticated at
/token/exchange. Member Biscuits (withnode()) are rejected, and so are/oauth/token
biscuits with a declared actor (actor_peer_id, or the control plane's own peer,
sts.go:1417-1431, 1570). - Sealed biscuits. A sealed biscuit ends the chain: it's rejected as a subject. Otherwise the
exchange would undoSeal()and a leaf process could sub-delegate. - Revocation. The re-minted token is a new root. Block 0 carries
parent_revocation_id(...)
for each earlier token, and the revocation check considers them. Revoking B's token then also
invalidates A's. - Per-destination disclosure. The chain goes out in the border JWT only if the
EgressDestinationenables it, off by default, likeforward_context. Policy decides, never the
holder, because it exposes internal topology (peer IDs, hop count) to third parties. - Limits. TTL is the minimum of the incoming biscuit, each TAR's
expire_timeand
DelegatedBiscuitTTL, so the exchange can't be used to extend a token. Roles are copied, not
re-resolved.tar_blocks are copied and still count towardsMaxAttenuationBlocks = 8. Depth is
capped (MaxDelegationDepth), which also bounds size. - Stateless. Only reads keys, revocations, bans and policy. Replay defense is cryptographic
(the consent), not a single-use registry. - Cost. Compared to re-exchanging the JWT, the same: one exchange per hop. Compared to the agent
using its node identity, one extra control-plane round trip per hop, counted against the STS rate
limit. The node cache would need to key on the incoming biscuit's digest and be invalidated if
anyparent_revocation_idis revoked. - Compatibility. A single-hop exchange is semantically the same as today, plus a chain of
length 1. The bytes change: conformance vectors need regenerating and the TS and Python SDKs need
updating.
Open questions
- Fact shape. I'd suggest
delegation(index, peer_id)+delegation_depth(n): the index is
needed to detect cycles. - How the agent says "this call is on behalf of the request I received". A per-request handle
the node gives the backend (it can't be the sealed biscuit), or the node doing the exchange itself. ext_proc/ext_authz. Extendattributes["mesh"]withdelegation_chainand
delegation_depth? Add a header?- Incoming
act. If the incoming JWT already carriesact(identity chaining or an upstream
STS), should it be kept as a prefix of the chain, or still dropped? - Per-hop service names. They're declared by the node and the control plane can't verify them:
include them marked as declared, or leave them out?
If the direction sounds reasonable, I can start with step 1 and send the step 2 design separately.
Activity
[-]identity: carry the delegation chain in the Delegated Session Biscuit and expose it in the border JWT[/-][+]identity: a ban on a compromised node does not reach the delegations re-exchanged downstream of it[/+]ignaciojaneiro commentedon Oct 19, 2026
A related gap in the audit trail, which @asivanadi0 raised in the review of #750: two refusals at
POST /sts/tokenreturn 403 before the caller's Biscuit is verified and write noBorder Crossingline.served_bycheck, when the calling node doesn't serve the destination (sts.go:1025).resolveEgressAudiencerefuses (sts.go:1034-1036).An audit join on the deny lines won't see those attempts, so a node probing destinations it doesn't serve leaves no
Border Crossingtrace. Logging them as deny lines with the calling node would close the gap. They can't carryclient_peer_id, because nothing has been verified yet at that point.asivanadi0 commentedon Oct 20, 2026
Thanks for writing this up, @ignaciojaneiro. If nobody else has it, I'm happy to send it as a small follow-up PR once #750 lands, since it touches the same handler. If you'd rather fold it into #750, that works too.
The approach: in
HandleSTSToken, theserved_byrefusal (sts.go:1025) and the audience refusal (sts.go:1034-1036) both write aBorder Crossingdeny line before returning 403, in the same shape as the existing one:direction=outbound,endpoint=/sts/token,decision=deny,actor_node(the calling node),destination,reason, plus the requestedaudienceon both lines. Both refusals come after the PoP challenge has verified, soactor_nodeis proven even though the Biscuit isn't.client_peer_idis left empty, as you said. Earlier refusals (no node credential, rate limit, a failed challenge) stay as they are, since the calling node isn't authenticated yet at that point. The Mint endpoint row insecurity-architecture.mdwould then say that deny lines also cover these two refusals, withclient_peer_idempty.Test: alongside
TestSTSTokenBorderCrossingNamesTheOriginPeer, using the same go-log pipe, one case with anEgressDestinationwhoseserved_bythe calling node doesn't match, and one with a requested audience that doesn't match the destination's policy audience. Each checks for a 403 and exactly one deny line carrying the calling node, the destination and the refusal reason, with noclient_peer_id.[-]identity: a ban on a compromised node does not reach the delegations re-exchanged downstream of it[/-][+]identity: carry the delegation chain in the Delegated Session Biscuit and expose it in the border JWT[/+]