Files
alknet/docs/architecture/decisions/032-forwarded-for-identity.md
glm-5.2 f224ea998c docs(arch): ADR-030..033 — repo/adapter pattern, PeerEntry, CredentialStore, forwarded-for
Land the storage and auth strategy research (findings.md) as four
accepted ADRs and amend the core and call specs to match:

- ADR-030: PeerEntry and Identity.id decoupling. Replaces
  authorized_fingerprints with peers: Vec<PeerEntry>; Identity.id becomes
  the stable peer_id, decoupled from the rotating fingerprint. Supersedes
  ADR-029 Assumption 1's UUID source (one-way door preserved, source
  changes). Resolves OQ-33 and the storage-boundary half of OQ-34. Records
  the API-key asymmetry as deliberate (OQ-35).

- ADR-031: CredentialStore repo trait + InMemoryCredentialStore default
  adapter in core. Second repo trait alongside IdentityProvider. Vault
  encrypts; the store persists the EncryptedData blob; assembly layer
  loads into Capabilities. EncryptedData core mirror includes salt for
  wire-format compat.

- ADR-032: Forwarded-for identity. forwarded_for field on call.requested
  and OperationContext — metadata only, never read by AccessControl::check
  (enforced structurally via the check signature). The from_call handler
  populates it. Wire-format one-way door, folded into the ADR-029
  migration window.

- ADR-033: Storage boundary and repo/adapter pattern. Core defines repo
  traits + in-memory defaults; persistence adapters are separate crates;
  assembly layer wires. Resolves OQ-34. Concrete adapter shapes deferred
  for exploration (OQ-36).

Amends auth.md, config.md, operation-registry.md, client-and-adapters.md,
open-questions.md, README.md, crates/core/README.md. Marks ADR-029
Accepted (Assumption 1 carries the ADR-030 superseded note). Marks the
research findings doc reviewed.
2026-06-27 12:12:25 +00:00

11 KiB

ADR-032: Forwarded-For Identity (Metadata, Not Authority)

Status

Accepted (adds a wire-format field and an OperationContext field; included with the ADR-029 migration or a companion task immediately after, since OperationContext and the from_call handler are being rewritten)

Context

When a hub forwards a call to a spoke (via from_call, ADR-017), the spoke authenticates the hub (resolves the hub's identity from the connection) and checks its ACL: "is the hub authorized to call this operation?" The spoke's ACL answers yes/no based on the hub's identity. This is per-node ACL (ADR-029 §3) — the correct authorization model, no "trusted" bypass.

But the spoke is blind to the originator. It knows "the hub called me" but not "alice asked the hub to call me." The hub's OperationContext.identity holds alice's identity (the hub authenticated alice), but the from_call forwarding handler authenticates as the hub (its own auth_token), so the spoke sees the hub's identity, not alice's. The originator information is at the hub, not at the spoke.

This matters for three use cases the research at docs/research/alknet-storage-strategy/findings.md §6 identified:

  1. Audit trail. A cross-node call chain is untraceable at the leaf without the originator. The spoke logs "the hub called /docker/start" but can't log "alice asked the hub to call /docker/start." For debugging, billing, and abuse investigation, the originator matters.

  2. Per-user rate limiting at the leaf. If the spoke wants to rate-limit per-user (not per-hub), or apply per-user quotas, it can't — it only sees the hub. The hub would have to proxy and track everything, which defeats the point of direct service composition.

  3. Handler context. A handler may want the originator's identity for application logic (per-user views, per-user data isolation, attribution in logs).

The question is whether to include the originator's identity in the forwarded call. The wire format is the constraint: a field is either in the call.requested payload or it isn't — it can't be bolted on later without a protocol change. This is a wire-format one-way door.

Decision

1. Add forwarded_for to the call.requested payload

{
  "operationId": "/docker/start",
  "input": { ... },
  "auth_token": "alk_...",           // the direct caller's token (the hub's)
  "forwarded_for": {                 // the original caller (the end user's)
    "id": "alice",
    "scopes": ["fs:read", "docker:start"],
    "resources": {}
  }
}

forwarded_for is optional (None when the call is not forwarded, or when the forwarder chooses not to propagate it). It carries a serialized Identity (id, scopes, resources) — the originator's resolved identity at the forwarding node.

2. Add forwarded_for to OperationContext

pub struct OperationContext {
    // ... existing fields ...

    /// The original caller when this call was forwarded (ADR-032).
    /// Metadata only — NOT used by `AccessControl::check`. The dispatch
    /// path populates it from the `call.requested.forwarded_for` field;
    /// the `from_call` handler sets it when constructing the forwarded
    /// payload (see §3). Handlers may read it for logging, auditing,
    /// per-user rate limiting, or application context. The ACL check
    /// always runs against `identity` (the direct caller), never against
    /// `forwarded_for`.
    pub forwarded_for: Option<Identity>,
}

identity is the direct caller (authorized by ACL). forwarded_for is the original caller (metadata only). The ACL check signature is AccessControl::check(identity.as_ref()) — unchanged. The forwarded_for field is a separate field, not a parameter to check.

3. The from_call handler populates forwarded_for

The hub's from_call forwarding handler constructs the call.requested payload to send to the spoke. It populates forwarded_for with the end user's identity — read from the hub's OperationContext.identity (the caller the hub authenticated) when the hub forwards the call. The hub authenticates as itself (its own auth_token); the forwarded_for field carries the originator's identity as context.

This is the hub's responsibility, not the protocol's. The protocol carries the field; the from_call handler chooses to populate it. A forwarder that doesn't want to disclose the originator can set forwarded_for: None (the spoke sees only the hub). A forwarder that wants to propagate it sets it.

4. AccessControl::check never reads forwarded_for

The security property: forwarded_for is metadata, not authority. The spoke's dispatch path makes it available on OperationContext for handlers, but AccessControl::check(identity.as_ref()) — the ACL check — always authorizes the direct caller's identity, never the forwarded-for identity. There is no path through which forwarded_for becomes an authorization input.

This is enforced structurally, not by convention: AccessControl::check takes Option<&Identity> (the direct caller's identity). The forwarded_for field is Option<Identity> on OperationContext, but the check signature doesn't accept it. If someone wants to ACL on the forwarded-for identity, they would have to change the AccessControl::check signature — a visible, reviewable change, not a quiet flag flip. The type system prevents accidental misuse.

Why include it now

The window is the ADR-029 migration. The from_call handler is being rewritten (peer-keyed overlays, AccessControl-based peer authorization, removal of remote_safe/trusted_peer), and OperationContext is being touched (the PeerCompositeEnv aggregation changes how the context is built). Adding a field to the call.requested payload and to OperationContext now is the cheapest point — the structures are already under edit. After the protocol ships without it, adding it is a breaking wire-format change (every client and server must learn the new field) and an OperationContext break (every handler that pattern-matches the struct must update).

Consequences

Positive:

  • The spoke can audit cross-node call chains. The leaf knows who actually initiated the call, not just who forwarded it.
  • Per-user rate limiting at the leaf becomes possible. The spoke can key rate-limit state on forwarded_for.id instead of only on the hub's identity.
  • Handler application logic can use the originator's identity for per-user views, per-user data isolation, or attribution.
  • The security model is unchanged: the spoke authorizes the hub (its direct caller), not the end user. The forwarded_for field is metadata, not authority. The type-system separation (check takes identity, not forwarded_for) prevents misuse.
  • The forwarder decides. A hub that doesn't want to disclose the originator (e.g., for privacy, or because the originator's identity is not meaningful to the spoke) sets forwarded_for: None. The field is opt-in by the forwarder, not mandatory.

Negative:

  • The call.requested payload gains a field. Wire-format addition — old servers that don't recognize forwarded_for ignore it (JSON deserialization into a struct without the field drops it); old clients that don't send it produce forwarded_for: None on the server. This is forward-compatible, but a server that wants to use forwarded_for must be new enough to deserialize it.
  • OperationContext gains a field. Handlers that construct OperationContext literals (tests, custom dispatch paths) must add the field. The composition path (OperationEnv::invoke) sets it to None for composed children — forwarded_for is a wire-ingress field, not a composition-ingress field.
  • The Identity in forwarded_for is a serialized value on the wire, not a server-resolved identity. The spoke receives the hub's claim about the originator's identity. A malicious hub could lie — set forwarded_for to a fake identity. The spoke must not treat forwarded_for as authoritative for anything security-relevant; it's the hub's assertion, useful for audit/attribution when the hub is trusted, but not a verified identity. This is the inherent property of forwarded-for metadata (same as HTTP X-Forwarded-For — it's a claim by the forwarder, not a verified value).
  • One more field for the from_call handler to populate correctly. The handler must read the hub's OperationContext.identity and decide whether to propagate it. This is a small implementation cost, but it's a handler-responsibility increase.

Assumptions

  1. forwarded_for is a claim by the forwarder, not a verified identity. The spoke receives the hub's assertion about the originator. A malicious hub can lie. The spoke must not use forwarded_for as authoritative for security decisions — only for audit, logging, and application-context purposes when the hub is trusted. This is the same property as HTTP X-Forwarded-For.

  2. AccessControl::check never reads forwarded_for. The security property is structural (the check signature doesn't accept it), not conventional. Adding forwarded_for to the ACL path would require a signature change to AccessControl::check — a visible, reviewable change.

  3. forwarded_for is wire-ingress only. Composed children (calls via OperationEnv::invoke) do not inherit forwarded_for — they get None. The field is populated from call.requested.forwarded_for by the dispatch path, and the from_call forwarding handler sets it when constructing the forwarded payload. Composition-propagation of forwarded_for would be a separate decision (not in this ADR).

  4. The Identity shape in forwarded_for is the same as Identity on OperationContext. Both carry id, scopes, resources. The forwarded_for value is a serialized Identity from the forwarding node's resolution — the same Identity the hub resolved for the end user, just passed along as metadata.

References

  • ADR-014: Secret Material Flow and Capability Injection (forwarded_for carries an Identity with scopes/resources, not secret material — the no-secret-material-on-the-wire invariant is preserved)
  • ADR-015: Privilege Model and Authority Context (the authority-switch model — forwarded_for does not participate; the direct caller's identity is the authority)
  • ADR-017: Call Protocol Client and Adapter Contract (the from_call forwarding handler that populates forwarded_for)
  • ADR-029: Peer-Graph Routing Model (the migration window — OperationContext and the from_call handler are being rewritten)
  • docs/research/alknet-storage-strategy/findings.md §6 (the forwarded-for identity decision and rationale)