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.
This commit is contained in:
2026-06-27 12:12:25 +00:00
parent 347bff257c
commit f224ea998c
13 changed files with 1307 additions and 144 deletions

View File

@@ -0,0 +1,221 @@
# 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
```json
{
"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`
```rust
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)