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.
221 lines
11 KiB
Markdown
221 lines
11 KiB
Markdown
# 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) |