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:
221
docs/architecture/decisions/032-forwarded-for-identity.md
Normal file
221
docs/architecture/decisions/032-forwarded-for-identity.md
Normal 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)
|
||||
Reference in New Issue
Block a user