tasks: decompose ADR-029/030/031/032/034/035 source sync into 17 tasks
Decompose the source-to-spec sync for the core and call crates into atomic, dependency-ordered tasks for implementation agents: Core (7 tasks + review): - peer-entry-model: PeerEntry struct, AuthPolicy.peers (ADR-030 keystone) - credential-store-trait: CredentialStore/InMemoryCredentialStore/StoreError (ADR-031/035) - identity-store-trait: IdentityStore async write trait (ADR-035) - config-identity-provider-peerentry: ConfigIdentityProvider PeerEntry resolution (ADR-030) - fingerprint-normalization: ed25519:hex for raw keys across quinn/iroh (ADR-030 §6) - three-remote-roles-docs: document ADR-034 roles and verifier selection - review-core-sync: phase gate before call consumes new identity semantics Call (9 tasks + review): - retire-remote-safe: remove ADR-028 machinery, AccessControl is the gate (ADR-029 §3) - operation-context-forwarded-for: forwarded_for field, wire-ingress only (ADR-032) - peer-composite-env: PeerCompositeEnv, PeerId=Identity.id, remove UUID (ADR-029/030) - operation-env-invoke-peer: invoke_peer/peer_contains/PeerRef (ADR-029 §2) - services-list-accesscontrol-filtered: AccessControl filter, list-peers opt-in (ADR-029 §6) - call-client-verifier-selection: TLS client-auth, verifier by PeerEntry (OQ-29, ADR-034) - from-call-forwarded-for: populate forwarded_for, peer-keyed registration (ADR-029 §5, ADR-032) - dispatch-peer-identity: AccessControl::check(peer_identity), PeerId from resolution (ADR-029 §3, ADR-030 §5) - review-call-sync: phase gate for the call sync Validated: 58 tasks, no cycles, logical topo order, two review checkpoints.
This commit is contained in:
148
tasks/call/operation-context-forwarded-for.md
Normal file
148
tasks/call/operation-context-forwarded-for.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
id: call/operation-context-forwarded-for
|
||||
name: Add forwarded_for field to OperationContext and wire from call.requested (ADR-032)
|
||||
status: pending
|
||||
depends_on: [call/retire-remote-safe]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Add the `forwarded_for: Option<Identity>` field to `OperationContext` and wire
|
||||
it from the `call.requested` payload. Per ADR-032, `forwarded_for` is metadata
|
||||
only — `AccessControl::check` never reads it; the ACL always authorizes the
|
||||
direct caller's `identity`. This is the wire-format one-way door folded into
|
||||
the `OperationContext` edit window (ADR-032 says this is the cheapest point
|
||||
since `OperationContext` is under edit for the ADR-029 migration).
|
||||
|
||||
### OperationContext change
|
||||
|
||||
```rust
|
||||
pub struct OperationContext {
|
||||
// ... existing fields ...
|
||||
pub forwarded_for: Option<Identity>, // NEW (ADR-032)
|
||||
}
|
||||
```
|
||||
|
||||
- `forwarded_for`: The original caller when this call was forwarded by a
|
||||
`from_call` handler (ADR-032). **Metadata only** — `AccessControl::check`
|
||||
never reads it; the ACL always authorizes `identity` (the direct caller).
|
||||
Handlers may read it for logging, auditing, per-user rate limiting, or
|
||||
application context. The forwarder's claim, not a verified identity — a
|
||||
malicious hub can lie (same property as HTTP `X-Forwarded-For`).
|
||||
|
||||
### Wire format (call.requested payload)
|
||||
|
||||
The `call.requested` payload already carries `operationId`, `input`, and
|
||||
optional `auth_token`. Add optional `forwarded_for`:
|
||||
|
||||
```json
|
||||
{
|
||||
"operationId": "/docker/start",
|
||||
"input": { ... },
|
||||
"auth_token": "alk_...",
|
||||
"forwarded_for": { // optional (ADR-032)
|
||||
"id": "alice",
|
||||
"scopes": ["fs:read", "docker:start"],
|
||||
"resources": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The payload is a `serde_json::Value` (not a typed struct), so `forwarded_for`
|
||||
is read from `payload.get("forwarded_for")` and deserialized into `Identity`.
|
||||
|
||||
### build_root_context wiring
|
||||
|
||||
`Dispatcher::build_root_context` reads `forwarded_for` from the payload and
|
||||
populates the field:
|
||||
|
||||
```rust
|
||||
fn build_root_context(
|
||||
&self,
|
||||
request_id: String,
|
||||
operation_name: &str,
|
||||
identity: Option<Identity>,
|
||||
forwarded_for: Option<Identity>, // NEW parameter
|
||||
connection: &CallConnection,
|
||||
) -> OperationContext {
|
||||
// ...
|
||||
OperationContext {
|
||||
// ...
|
||||
forwarded_for, // from call.requested.forwarded_for
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dispatch_requested` extracts `forwarded_for` from the payload:
|
||||
|
||||
```rust
|
||||
let forwarded_for = payload.get("forwarded_for")
|
||||
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
||||
```
|
||||
|
||||
### OperationEnv::invoke sets None for composed children
|
||||
|
||||
`forwarded_for` is **wire-ingress only** (ADR-032 Assumption 3). 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 (that's `call/from-call-forwarded-for`).
|
||||
|
||||
In `LocalOperationEnv::invoke_with_policy` (and `PeerCompositeEnv` when built):
|
||||
|
||||
```rust
|
||||
let context = OperationContext {
|
||||
// ...
|
||||
forwarded_for: None, // composed children do not inherit (ADR-032)
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### AccessControl::check never reads forwarded_for
|
||||
|
||||
The security property is structural: `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. Verify this invariant holds — no code path passes `forwarded_for`
|
||||
to `AccessControl::check`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `OperationContext.forwarded_for: Option<Identity>` field added
|
||||
- [ ] `build_root_context` accepts `forwarded_for` parameter and populates the field
|
||||
- [ ] `dispatch_requested` extracts `forwarded_for` from `payload.get("forwarded_for")`
|
||||
- [ ] `forwarded_for` deserialized from JSON `{ id, scopes, resources }` into `Identity`
|
||||
- [ ] `OperationEnv::invoke_with_policy` sets `forwarded_for: None` for composed children
|
||||
- [ ] `AccessControl::check` never reads `forwarded_for` (verify no code path passes it)
|
||||
- [ ] Missing `forwarded_for` in payload → `None` (no error)
|
||||
- [ ] Unit test: `forwarded_for` populated from payload
|
||||
- [ ] Unit test: missing `forwarded_for` → None
|
||||
- [ ] Unit test: composed children get `forwarded_for: None`
|
||||
- [ ] Unit test: `AccessControl::check` still uses `identity` (not `forwarded_for`)
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — OperationContext.forwarded_for
|
||||
- docs/architecture/crates/call/call-protocol.md — call.requested payload, build_root_context
|
||||
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032
|
||||
|
||||
## Notes
|
||||
|
||||
> `forwarded_for` is a wire-format one-way door (ADR-032) — folded into the
|
||||
> OperationContext edit window because ADR-029 is already rewriting the
|
||||
> composition env. The field is metadata only: `AccessControl::check` never
|
||||
> reads it; the ACL always authorizes the direct caller's `identity`. The
|
||||
> `from_call` handler populates it when forwarding (that's
|
||||
> `call/from-call-forwarded-for`). Composed children get `None` (wire-ingress
|
||||
> only, not composition-ingress).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in New Issue
Block a user