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.
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:
-
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. -
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.
-
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.idinstead 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_forfield is metadata, not authority. The type-system separation (checktakesidentity, notforwarded_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.requestedpayload gains a field. Wire-format addition — old servers that don't recognizeforwarded_forignore it (JSON deserialization into a struct without the field drops it); old clients that don't send it produceforwarded_for: Noneon the server. This is forward-compatible, but a server that wants to useforwarded_formust be new enough to deserialize it. OperationContextgains a field. Handlers that constructOperationContextliterals (tests, custom dispatch paths) must add the field. The composition path (OperationEnv::invoke) sets it toNonefor composed children —forwarded_foris a wire-ingress field, not a composition-ingress field.- The
Identityinforwarded_foris 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 — setforwarded_forto a fake identity. The spoke must not treatforwarded_foras 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 HTTPX-Forwarded-For— it's a claim by the forwarder, not a verified value). - One more field for the
from_callhandler to populate correctly. The handler must read the hub'sOperationContext.identityand decide whether to propagate it. This is a small implementation cost, but it's a handler-responsibility increase.
Assumptions
-
forwarded_foris 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 useforwarded_foras authoritative for security decisions — only for audit, logging, and application-context purposes when the hub is trusted. This is the same property as HTTPX-Forwarded-For. -
AccessControl::checknever readsforwarded_for. The security property is structural (the check signature doesn't accept it), not conventional. Addingforwarded_forto the ACL path would require a signature change toAccessControl::check— a visible, reviewable change. -
forwarded_foris wire-ingress only. Composed children (calls viaOperationEnv::invoke) do not inheritforwarded_for— they getNone. The field is populated fromcall.requested.forwarded_forby the dispatch path, and thefrom_callforwarding handler sets it when constructing the forwarded payload. Composition-propagation offorwarded_forwould be a separate decision (not in this ADR). -
The
Identityshape inforwarded_foris the same asIdentityonOperationContext. Both carryid,scopes,resources. Theforwarded_forvalue is a serializedIdentityfrom the forwarding node's resolution — the sameIdentitythe hub resolved for the end user, just passed along as metadata.
References
- ADR-014: Secret Material Flow and Capability Injection (
forwarded_forcarries anIdentitywith 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_fordoes not participate; the direct caller's identity is the authority) - ADR-017: Call Protocol Client and Adapter Contract (the
from_callforwarding handler that populatesforwarded_for) - ADR-029: Peer-Graph Routing Model (the migration window —
OperationContextand thefrom_callhandler are being rewritten) docs/research/alknet-storage-strategy/findings.md§6 (the forwarded-for identity decision and rationale)