Skip to content

identity: carry the delegation chain in the Delegated Session Biscuit and expose it in the border JWT #742

Open

Description

@ignaciojaneiro

Base: main @ c34e3ca. All file:line references 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 with client_credentials + client_assertion and with jwt-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). Any act in 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_node and
    client_peer_id to 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
    strips X-Mesh-Biscuit before forwarding to the backend (internal/node/node.go:2527-2535). If
    B's biscuit carries a tar_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 in sandboxed-agents.md). It narrows rights, but blocks
    are anonymous (the holder picks the name) and only work within the same peer or towards egress.
  • mesh_task in the border JWT. Carries only the innermost TAR name, for auditing.
  • ext_proc. Already exposes attributes["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 single actor_node.
  • The jwt-bearer grant (identity chaining, node-api.md:85). Crosses a trust boundary with a
    single subject; no intermediate hops.
  • /oauth/token with a biscuit subject_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_node is in revokedPeers
    (internal/node/middleware.go:442-448), but it only looks at the last actor that exchanged.

Why it's worth it

  1. 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 nested act. The outermost act is 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 example mesh_delegation: ["<orchestrator>", "<specialist>"]).

    Today those rules can only be checked after the fact, by correlating logs from nodes the
    destination may not even operate.

  2. The user and the agents travel together, without handing the user's JWT to any agent.

  3. Least privilege doesn't decay across hops. Today every exchange at /token/exchange starts
    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 the tar_blocks forward.

  4. Policy over the chain at providers (confused deputy, loops, depth). With chain facts in the
    authority block, a node's attenuation (internal/node/config.go:121-146) can express, in
    biscuit-go v2.2.2 syntax:

    • depth limit: deny if delegation_depth($d), $d > 3 (as deny, not check, 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://payments only if the chain includes an approved
      orchestrator.
  5. 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 same revokedPeers check 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.

  6. Side note: the docs are out of sync. security-architecture.md:309-311 and the
    FactActorNode comment (api/datalog.go:82-84) say the origin actor_node goes into
    act.sub. The code puts the node that called /sts/token there (sts.go:1074), as
    identity.md:168 says. 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_type field in TokenExchangeRequest and a type distinct from today's biscuit
    (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's actor_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 new TaskAuthorizationRule field
    (for example allowed_next_actors), evaluated in Go like the rest of the TARs. It's the analogue
    of may_act. It can't be a Datalog check: UnmarshalInbound rejects 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_node was PoP-authenticated at
    /token/exchange. Member Biscuits (with node()) 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 undo Seal() 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
    EgressDestination enables it, off by default, like forward_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_time and
    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 towards MaxAttenuationBlocks = 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
    any parent_revocation_id is 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. Extend attributes["mesh"] with delegation_chain and
    delegation_depth? Add a header?
  • Incoming act. If the incoming JWT already carries act (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

  1. changed the title [-]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[/+] on Oct 19, 2026
  2. ignaciojaneiro commented on Oct 19, 2026

    @ignaciojaneiro
    ContributorAuthor

    A related gap in the audit trail, which @asivanadi0 raised in the review of #750: two refusals at POST /sts/token return 403 before the caller's Biscuit is verified and write no Border Crossing line.

    • The served_by check, when the calling node doesn't serve the destination (sts.go:1025).
    • The audience check, when resolveEgressAudience refuses (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 Crossing trace. Logging them as deny lines with the calling node would close the gap. They can't carry client_peer_id, because nothing has been verified yet at that point.

  3. asivanadi0 commented on Oct 20, 2026

    @asivanadi0
    Contributor

    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, the served_by refusal (sts.go:1025) and the audience refusal (sts.go:1034-1036) both write a Border Crossing deny 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 requested audience on both lines. Both refusals come after the PoP challenge has verified, so actor_node is proven even though the Biscuit isn't. client_peer_id is 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 in security-architecture.md would then say that deny lines also cover these two refusals, with client_peer_id empty.

    Test: alongside TestSTSTokenBorderCrossingNamesTheOriginPeer, using the same go-log pipe, one case with an EgressDestination whose served_by the 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 no client_peer_id.

  4. changed the title [-]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[/+] on Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions