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:
177
tasks/call/call-client-verifier-selection.md
Normal file
177
tasks/call/call-client-verifier-selection.md
Normal file
@@ -0,0 +1,177 @@
|
||||
---
|
||||
id: call/call-client-verifier-selection
|
||||
name: Wire CallClient TLS client-auth and server cert verifier selection by PeerEntry presence (OQ-29, ADR-034)
|
||||
status: pending
|
||||
depends_on: [call/peer-composite-env]
|
||||
scope: moderate
|
||||
risk: high
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Wire the `CallClient` TLS client-auth (present Ed25519 key as RFC 7250 raw
|
||||
public key client cert) and the server cert verifier selection by `PeerEntry`
|
||||
presence. Per OQ-29 (resolved) and ADR-034 §2-3. This is the most
|
||||
security-critical call-side change — TLS wiring and verifier selection.
|
||||
|
||||
### Current state
|
||||
|
||||
```rust
|
||||
// crates/alknet-call/src/client/call_client.rs
|
||||
fn build_quinn_client_config(_credentials: &CallCredentials, alpn: &[u8])
|
||||
-> Result<quinn::ClientConfig, String>
|
||||
{
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(Arc::new(AcceptAnyServerCertVerifier)) // ← accepts ANY
|
||||
.with_no_client_auth(); // ← doesn't present client cert
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
`AcceptAnyServerCertVerifier` is a security hole for X.509 (accepts any cert
|
||||
without CA verification). `with_no_client_auth()` doesn't present the client's
|
||||
Ed25519 key, so the server has no client cert to extract a fingerprint from —
|
||||
the ADR-030 `PeerEntry` fingerprint → `peer_id` resolution path is not
|
||||
activated for quinn connections.
|
||||
|
||||
### Target state (OQ-29 + ADR-034)
|
||||
|
||||
#### 1. TLS client-auth: present Ed25519 key as raw public key client cert
|
||||
|
||||
Replace `with_no_client_auth()` with presenting the client's Ed25519 key as an
|
||||
RFC 7250 raw public key client cert. This is the client-side equivalent of the
|
||||
server's `RawKeyCertResolver`. The `CallCredentials.tls_identity` carries the
|
||||
`TlsIdentity::RawKey(Ed25519SecretKey)` (or X.509 cert pair).
|
||||
|
||||
```rust
|
||||
fn build_quinn_client_config(credentials: &CallCredentials, alpn: &[u8])
|
||||
-> Result<quinn::ClientConfig, String>
|
||||
{
|
||||
// 1. Client cert: present Ed25519 raw key (if configured)
|
||||
let client_auth = build_client_auth(&credentials.tls_identity)?;
|
||||
|
||||
// 2. Server cert verifier: by PeerEntry presence (ADR-034 §3)
|
||||
let verifier = select_server_verifier(&credentials.remote_identity)?;
|
||||
|
||||
let config = rustls::ClientConfig::builder()
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(verifier)
|
||||
.with_client_auth(client_auth); // ← present the key, not no_client_auth
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Server cert verifier selection by PeerEntry presence (ADR-034 §3)
|
||||
|
||||
| Local has `PeerEntry` for remote? | Remote cert type | Client verifier |
|
||||
|----------------------------------|------------------|-----------------|
|
||||
| No (public X.509 endpoint) | X.509 | `WebPkiServerVerifier` (CA verification) |
|
||||
| No | Ed25519 raw key | fails closed (no CA to fall back to) |
|
||||
| Yes (hub, Ed25519 path) | Ed25519 raw key | fingerprint match (`ed25519:<hex>`) |
|
||||
| Yes (hub, X.509 path) | X.509 | fingerprint match (`SHA256:<hex>`) |
|
||||
|
||||
`CallCredentials.remote_identity: Option<RemoteIdentity>` is load-bearing:
|
||||
- `Some(fingerprint)` → known peer → fingerprint pin (the fingerprint IS the
|
||||
trust anchor).
|
||||
- `None` → no `PeerEntry` for the remote → CA verification for X.509, fail
|
||||
closed for Ed25519 raw key. `None` is the public-X.509-endpoint state, not a
|
||||
missing field. An implementer must not default `remote_identity` to a
|
||||
placeholder, and must not treat `None` as "skip verification."
|
||||
|
||||
```rust
|
||||
fn select_server_verifier(remote_identity: &Option<RemoteIdentity>)
|
||||
-> Result<Arc<dyn ServerCertVerifier>, String>
|
||||
{
|
||||
match remote_identity {
|
||||
Some(ri) => {
|
||||
// Known peer → fingerprint pin
|
||||
Ok(Arc::new(FingerprintPinVerifier::new(ri.fingerprint.clone())))
|
||||
}
|
||||
None => {
|
||||
// Unknown remote → CA verification (WebPkiServerVerifier)
|
||||
// For Ed25519 raw-key remotes, this fails closed (no CA).
|
||||
// This is the public-X.509-endpoint path (ADR-034 §2).
|
||||
let roots = rustls::crypto::aws_lc_rs::default_provider().root_certificates;
|
||||
Ok(Arc::new(rustls::client::WebPkiServerVerifier::builder(roots.into()).build()?))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. FingerprintPinVerifier
|
||||
|
||||
A new `ServerCertVerifier` that pins a specific fingerprint:
|
||||
- For `ed25519:<hex>` remotes: extract the raw Ed25519 pub key from the
|
||||
presented cert and match against the pinned fingerprint.
|
||||
- For `SHA256:<hex>` remotes: hash the cert DER and match against the pinned
|
||||
fingerprint.
|
||||
- No match → verification failure (connection rejected).
|
||||
|
||||
#### 4. CallCredentials
|
||||
|
||||
`CallCredentials` (already defined) carries the three credential dimensions:
|
||||
|
||||
```rust
|
||||
pub struct CallCredentials {
|
||||
pub tls_identity: Option<TlsIdentity>, // RFC 7250 raw key or X.509
|
||||
pub auth_token: Option<AuthToken>, // call-protocol-level token
|
||||
pub remote_identity: Option<RemoteIdentity>, // expected fingerprint (None = CA path)
|
||||
}
|
||||
|
||||
pub struct RemoteIdentity { pub fingerprint: String }
|
||||
```
|
||||
|
||||
`remote_identity: None` is load-bearing — the public-X.509-endpoint state
|
||||
(ADR-034 §2). The implementation must not default it to a placeholder.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- Does NOT change the server-side endpoint (`AcceptAnyCertVerifier` in
|
||||
alknet-core is unchanged — it's "request-but-don't-require" for fingerprint
|
||||
extraction).
|
||||
- Does NOT build `PeerCompositeEnv` (that's `call/peer-composite-env`, a
|
||||
dependency) — but a connection with no resolved identity (no `PeerEntry`) gets
|
||||
no `PeerId` and is not added to `PeerCompositeEnv` (that's handled in
|
||||
`call/peer-composite-env` / `call/dispatch-peer-identity`).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `build_quinn_client_config` presents Ed25519 key as RFC 7250 raw public key client cert (replaces `with_no_client_auth()`)
|
||||
- [ ] `select_server_verifier` selects verifier by `remote_identity` presence
|
||||
- [ ] `Some(fingerprint)` → `FingerprintPinVerifier` (fingerprint match)
|
||||
- [ ] `None` + X.509 → `WebPkiServerVerifier` (CA verification)
|
||||
- [ ] `None` + Ed25519 raw key → fails closed (no CA to fall back to)
|
||||
- [ ] `FingerprintPinVerifier` matches `ed25519:<hex>` (raw key extraction) and `SHA256:<hex>` (DER hash)
|
||||
- [ ] `AcceptAnyServerCertVerifier` removed (security hole for X.509)
|
||||
- [ ] `CallCredentials.remote_identity: None` is load-bearing (not defaulted to placeholder)
|
||||
- [ ] No-env-vars invariant preserved (credentials from Capabilities, not env vars)
|
||||
- [ ] Unit test: `FingerprintPinVerifier` matches correct fingerprint
|
||||
- [ ] Unit test: `FingerprintPinVerifier` rejects wrong fingerprint
|
||||
- [ ] Unit test: `select_server_verifier` returns CA verifier for `None`
|
||||
- [ ] Unit test: client auth presents Ed25519 key (config built without error)
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/client-and-adapters.md — CallCredentials, verifier selection, TLS client-auth
|
||||
- docs/architecture/crates/core/auth.md — Client-side verifier selection table
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034 §2-3
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030 §6 (fingerprint normalization)
|
||||
|
||||
## Notes
|
||||
|
||||
> Most security-critical call-side change. `AcceptAnyServerCertVerifier` is a
|
||||
> security hole for X.509 — replaced by verifier selection by `PeerEntry`
|
||||
> presence. `None` + X.509 = CA verification (public X.509 endpoint); `None` +
|
||||
> Ed25519 = fail closed (raw-key remotes are always known peers). `Some` =
|
||||
> fingerprint pin (known peer). The client presents its Ed25519 key as a raw
|
||||
> public key client cert so the server can extract the fingerprint — this
|
||||
> activates the PeerEntry fingerprint → peer_id resolution path on quinn.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
129
tasks/call/dispatch-peer-identity.md
Normal file
129
tasks/call/dispatch-peer-identity.md
Normal file
@@ -0,0 +1,129 @@
|
||||
---
|
||||
id: call/dispatch-peer-identity
|
||||
name: Wire dispatch_requested to resolve peer Identity and run AccessControl::check (ADR-029 §3, ADR-030 §5)
|
||||
status: pending
|
||||
depends_on: [call/peer-composite-env, call/retire-remote-safe]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Wire `dispatch_requested` to resolve the peer's `Identity` and run
|
||||
`AccessControl::check(peer_identity)` as the authorization mechanism, and
|
||||
ensure the `PeerId` for a connection comes from `connection.identity().id`
|
||||
(IdentityProvider resolution). Per ADR-029 §3 (AccessControl-based peer
|
||||
authorization) and ADR-030 §5 (PeerId from IdentityProvider resolution).
|
||||
|
||||
### Current state
|
||||
|
||||
After `call/retire-remote-safe`, the `RemoteFilter` gate is removed.
|
||||
`dispatch_requested` resolves identity (connection-level + auth_token override)
|
||||
and `OperationRegistry::invoke` runs `AccessControl::check`. The remaining gap
|
||||
is ensuring the `PeerId` for the connection comes from `IdentityProvider`
|
||||
resolution (not a UUID), and that connections with no resolved identity get no
|
||||
`PeerId` (not added to `PeerCompositeEnv`).
|
||||
|
||||
### Target state
|
||||
|
||||
#### 1. PeerId from IdentityProvider resolution (ADR-030 §5)
|
||||
|
||||
The `PeerId` for a connection is `connection.identity().id` — the resolved
|
||||
`Identity.id` from `IdentityProvider` (= `PeerEntry.peer_id`, stable). The UUID
|
||||
workaround is removed (done in `call/peer-composite-env`). This task verifies
|
||||
the dispatch path reads `connection.identity().id` for the peer-keyed overlay
|
||||
and that a connection with no resolved identity is handled correctly.
|
||||
|
||||
#### 2. AccessControl::check(peer_identity) is the authorization gate
|
||||
|
||||
`dispatch_requested` resolves the peer's `Identity` (from the connection's TLS
|
||||
fingerprint or the `auth_token` payload, via the existing `IdentityProvider`)
|
||||
and `OperationRegistry::invoke` runs `AccessControl::check(peer_identity)`
|
||||
against the op's `AccessControl`:
|
||||
|
||||
- If the op's `AccessControl` is satisfied → dispatch (capabilities populated
|
||||
from the bundle).
|
||||
- If not → `FORBIDDEN` before the handler runs (capabilities never populated —
|
||||
the security property).
|
||||
- If the op is `Visibility::Internal` → `NOT_FOUND` before ACL (existing
|
||||
behavior).
|
||||
|
||||
This is the existing `OperationRegistry::invoke` path — the `RemoteFilter` gate
|
||||
(removed in `call/retire-remote-safe`) was a *parallel* gate. This task
|
||||
verifies the `AccessControl::check` path is the sole authorization mechanism
|
||||
and that no `remote_safe`/`trusted_peer` remnants remain.
|
||||
|
||||
#### 3. Connections with no resolved identity
|
||||
|
||||
A connection with no resolved identity (no client cert, unrecognized
|
||||
fingerprint) has no `PeerId` and is not added to `PeerCompositeEnv`
|
||||
(ADR-030 §5). The handler either rejects the connection or falls back to a
|
||||
connection-without-peer-identity path. The dispatch path must handle this case:
|
||||
- `connection.identity()` returns `None` → no `PeerId`
|
||||
- The connection's ops (if any discovered via `from_call`) are invoked through
|
||||
the `CallConnection` handle directly, not via `PeerRef::Specific`
|
||||
|
||||
### dispatch_requested flow (post-sync)
|
||||
|
||||
```rust
|
||||
async fn dispatch_requested(&self, connection: &Arc<CallConnection>,
|
||||
request_id: String, payload: Value) -> ResponseEnvelope {
|
||||
let operation_id = payload.get("operationId").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let operation_name = Self::strip_leading_slash(operation_id).to_string();
|
||||
|
||||
// No RemoteFilter gate (removed). AccessControl::check in invoke is the gate.
|
||||
|
||||
let connection_identity = connection.connection().identity().cloned();
|
||||
let identity = self.resolve_identity(connection_identity, &payload);
|
||||
let forwarded_for = payload.get("forwarded_for")
|
||||
.and_then(|v| serde_json::from_value::<Identity>(v.clone()).ok());
|
||||
|
||||
let input = payload.get("input").cloned().unwrap_or(Value::Null);
|
||||
let context = self.build_root_context(
|
||||
request_id.clone(), &operation_name, identity, forwarded_for, connection);
|
||||
|
||||
// OperationRegistry::invoke runs AccessControl::check(identity) —
|
||||
// the sole authorization mechanism (ADR-029 §3).
|
||||
self.registry.invoke(&operation_name, input, context).await
|
||||
}
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `dispatch_requested` resolves peer `Identity` (connection-level + auth_token override)
|
||||
- [ ] `OperationRegistry::invoke` runs `AccessControl::check(peer_identity)` as the sole authorization gate
|
||||
- [ ] No `RemoteFilter`/`remote_safe`/`trusted_peer` remnants in dispatch
|
||||
- [ ] `PeerId` for connection comes from `connection.identity().id` (not UUID)
|
||||
- [ ] Connection with no resolved identity → no `PeerId`, not added to `PeerCompositeEnv`
|
||||
- [ ] Op with `AccessControl::default()` dispatches to any peer
|
||||
- [ ] Op with `required_scopes` → `FORBIDDEN` for unauthorized peers (capabilities never populated)
|
||||
- [ ] Op with `Visibility::Internal` → `NOT_FOUND` before ACL
|
||||
- [ ] `forwarded_for` extracted from payload and passed to `build_root_context`
|
||||
- [ ] Unit test: authorized peer → dispatch (capabilities populated)
|
||||
- [ ] Unit test: unauthorized peer → FORBIDDEN (capabilities never populated)
|
||||
- [ ] Unit test: Internal op → NOT_FOUND from wire
|
||||
- [ ] Unit test: connection with no identity → no PeerId
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/call-protocol.md — dispatch_requested, AuthContext and Identity Resolution
|
||||
- docs/architecture/crates/call/client-and-adapters.md — peer authorization via AccessControl
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §3 (AccessControl-based peer auth)
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030 §5 (PeerId from IdentityProvider)
|
||||
|
||||
## Notes
|
||||
|
||||
> The `RemoteFilter` gate (removed in `call/retire-remote-safe`) was a parallel
|
||||
> authorization system. This task verifies `AccessControl::check` in
|
||||
> `OperationRegistry::invoke` is the sole gate and that the `PeerId` comes from
|
||||
> `IdentityProvider` resolution (not UUID). Connections with no resolved
|
||||
> identity get no `PeerId` and are not in the peer-keyed overlay — their ops
|
||||
> are invoked through the `CallConnection` handle directly.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
114
tasks/call/from-call-forwarded-for.md
Normal file
114
tasks/call/from-call-forwarded-for.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: call/from-call-forwarded-for
|
||||
name: Wire from_call forwarding handler to populate forwarded_for and use peer-keyed registration (ADR-029 §5, ADR-032)
|
||||
status: pending
|
||||
depends_on: [call/operation-context-forwarded-for, call/peer-composite-env]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Update the `from_call` forwarding handler to populate `forwarded_for` on the
|
||||
`call.requested` payload it constructs, and update `from_call` registration to
|
||||
use the peer-keyed overlay model. Per ADR-029 §5 (peer-keyed registration,
|
||||
collision rule change) and ADR-032 §3 (from_call populates forwarded_for).
|
||||
|
||||
### forwarded_for population (ADR-032 §3)
|
||||
|
||||
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.
|
||||
|
||||
```rust
|
||||
// In the from_call forwarding handler:
|
||||
let mut payload = serde_json::json!({
|
||||
"operationId": operation_id,
|
||||
"input": input,
|
||||
});
|
||||
// Populate forwarded_for from the hub's context.identity (ADR-032)
|
||||
if let Some(originator) = &context.identity {
|
||||
payload["forwarded_for"] = serde_json::to_value(originator).ok().unwrap_or(Value::Null);
|
||||
}
|
||||
// The hub authenticates as itself (its own auth_token)
|
||||
if let Some(token) = &credentials.auth_token {
|
||||
payload["auth_token"] = serde_json::Value::String(token);
|
||||
}
|
||||
```
|
||||
|
||||
The hub may set `forwarded_for: None` (omit the field) if it doesn't want to
|
||||
disclose the originator. The spoke receives it as metadata on its
|
||||
`OperationContext` — available for logging, auditing, per-user rate limiting,
|
||||
but never used by `AccessControl::check` (the spoke authorizes the hub, its
|
||||
direct caller).
|
||||
|
||||
### Peer-keyed registration (ADR-029 §5)
|
||||
|
||||
`from_call` registers into the specific peer's sub-overlay (via
|
||||
`CallConnection::register_imported`), not a flat overlay. Cross-peer collision
|
||||
dissolves: same name on different peers is fine (separate sub-overlays, no
|
||||
collision, no prefix needed). Same-peer collision stays an error (a peer
|
||||
shouldn't expose two ops with the same name).
|
||||
|
||||
`FromCallConfig::namespace_prefix` becomes optional local-naming sugar for
|
||||
when the importing node wants to expose a peer's ops under a different name
|
||||
*locally* — a local-naming concern, not a disambiguation concern. It defaults
|
||||
to `None`.
|
||||
|
||||
### Collision rule change
|
||||
|
||||
- **Same-peer collision** = error (a peer shouldn't expose two ops with the
|
||||
same name). `AdapterError::SamePeerCollision`.
|
||||
- **Cross-peer collision** dissolves (same name on different peers is fine —
|
||||
separate sub-overlays, ADR-029 §5).
|
||||
|
||||
The existing `from_call` namespace-collision check (which was flat-namespace)
|
||||
changes to same-peer-only. The `AdapterError::Conflict` variant (if it exists)
|
||||
renames to `SamePeerCollision` (OQ-26).
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- Does NOT build `PeerCompositeEnv` (that's `call/peer-composite-env`, a
|
||||
dependency) — `from_call` registers into the connection's overlay, which
|
||||
`PeerCompositeEnv` aggregates by peer.
|
||||
- Does NOT add `forwarded_for` to `OperationContext` (that's
|
||||
`call/operation-context-forwarded-for`, a dependency) — this task populates
|
||||
the wire field.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `from_call` forwarding handler populates `forwarded_for` on the `call.requested` payload from `context.identity`
|
||||
- [ ] `forwarded_for` omitted (None) when the hub chooses not to disclose the originator
|
||||
- [ ] `from_call` registers into the connection's overlay (peer-keyed via `PeerCompositeEnv`)
|
||||
- [ ] Same-peer collision = error (`AdapterError::SamePeerCollision`)
|
||||
- [ ] Cross-peer collision dissolves (same name on different peers is fine)
|
||||
- [ ] `FromCallConfig::namespace_prefix` defaults to `None` (optional local-naming sugar)
|
||||
- [ ] `AdapterError::Conflict` renamed to `SamePeerCollision` (OQ-26)
|
||||
- [ ] Unit test: `forwarded_for` populated from `context.identity` on forwarding
|
||||
- [ ] Unit test: `forwarded_for` omitted when context.identity is None
|
||||
- [ ] Unit test: same-peer collision returns `SamePeerCollision` error
|
||||
- [ ] Unit test: cross-peer same name does not collide (separate sub-overlays)
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/client-and-adapters.md — from_call, forwarded_for, namespace collision
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §5 (peer-keyed registration, collision rule)
|
||||
- docs/architecture/decisions/032-forwarded-for-identity.md — ADR-032 §3 (from_call populates forwarded_for)
|
||||
|
||||
## Notes
|
||||
|
||||
> The `from_call` handler is the hub's forwarding path. It populates
|
||||
> `forwarded_for` from the hub's `context.identity` (the end user) so the spoke
|
||||
> has the originator as metadata. The spoke authorizes the hub (its direct
|
||||
> caller), not the end user — `AccessControl::check` never reads
|
||||
> `forwarded_for`. Cross-peer collision dissolves under the peer-keyed model
|
||||
> (separate sub-overlays); same-peer collision stays an error.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
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
|
||||
156
tasks/call/operation-env-invoke-peer.md
Normal file
156
tasks/call/operation-env-invoke-peer.md
Normal file
@@ -0,0 +1,156 @@
|
||||
---
|
||||
id: call/operation-env-invoke-peer
|
||||
name: Add invoke_peer/peer_contains/PeerRef to OperationEnv trait for peer-keyed routing (ADR-029 §2)
|
||||
status: pending
|
||||
depends_on: [call/peer-composite-env]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Add the `invoke_peer` and `peer_contains` methods to the `OperationEnv` trait,
|
||||
with the `PeerRef` selector enum. Per ADR-029 §2. `PeerCompositeEnv` (built in
|
||||
`call/peer-composite-env`) overrides these with real peer-keyed routing; the
|
||||
default-impl preserves back-compat for single-layer envs
|
||||
(`LocalOperationEnv`, connection overlays) that don't route by peer.
|
||||
|
||||
### PeerRef enum
|
||||
|
||||
```rust
|
||||
pub enum PeerRef {
|
||||
Specific(PeerId), // route to this peer; NOT_FOUND if it doesn't serve the op
|
||||
Any, // first peer (insertion order) that serves it
|
||||
}
|
||||
```
|
||||
|
||||
`PeerRef::Specific(PeerId)` routes to the named peer's overlay only — no
|
||||
fallthrough (explicit routing must be honored or fail loudly, ADR-029 §2).
|
||||
`PeerRef::Any` reuses `invoke_with_policy` (the insertion-order fan-out built
|
||||
in `call/peer-composite-env`).
|
||||
|
||||
### OperationEnv trait additions
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
pub trait OperationEnv: Send + Sync {
|
||||
// ... existing invoke, invoke_with_policy, contains ...
|
||||
|
||||
/// Peer-routing composition (ADR-029 §2). Routes to a specific peer
|
||||
/// (`PeerRef::Specific`) or to the first peer that serves the op
|
||||
/// (`PeerRef::Any`). The default impl ignores the peer selector and
|
||||
/// delegates to `invoke_with_policy`, preserving back-compat for
|
||||
/// single-layer envs that don't route by peer. `PeerCompositeEnv`
|
||||
/// overrides with real peer-keyed routing.
|
||||
async fn invoke_peer(
|
||||
&self,
|
||||
peer: &PeerRef,
|
||||
namespace: &str,
|
||||
operation: &str,
|
||||
input: Value,
|
||||
parent: &OperationContext,
|
||||
policy: AbortPolicy,
|
||||
) -> ResponseEnvelope {
|
||||
let _ = peer; // unused — single-layer envs don't route by peer
|
||||
self.invoke_with_policy(namespace, operation, input, parent, policy).await
|
||||
}
|
||||
|
||||
/// Does this env contain the named op *on the named peer*? Used by
|
||||
/// `PeerCompositeEnv` to probe a specific peer's sub-overlay before
|
||||
/// dispatching via `invoke_peer` with `PeerRef::Specific`. Default impl
|
||||
/// delegates to `contains` (single-layer envs ignore the peer dimension).
|
||||
fn peer_contains(&self, _peer: &PeerId, name: &str) -> bool {
|
||||
self.contains(name)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PeerCompositeEnv overrides
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
impl OperationEnv for PeerCompositeEnv {
|
||||
// ... invoke_with_policy, contains from call/peer-composite-env ...
|
||||
|
||||
async fn invoke_peer(
|
||||
&self,
|
||||
peer: &PeerRef,
|
||||
namespace: &str,
|
||||
operation: &str,
|
||||
input: Value,
|
||||
parent: &OperationContext,
|
||||
policy: AbortPolicy,
|
||||
) -> ResponseEnvelope {
|
||||
let name = format!("{namespace}/{operation}");
|
||||
if !parent.scoped_env.allows(&name) {
|
||||
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
||||
}
|
||||
match peer {
|
||||
PeerRef::Specific(peer_id) => {
|
||||
// Route to this peer's sub-overlay only. No fallthrough —
|
||||
// explicit routing must be honored or fail loudly (ADR-029 §2).
|
||||
match self.connections.get(peer_id) {
|
||||
Some(conn_env) if conn_env.contains(&name) => {
|
||||
conn_env.invoke_with_policy(namespace, operation, input, parent, policy).await
|
||||
}
|
||||
_ => ResponseEnvelope::not_found(parent.request_id.clone(), &name),
|
||||
}
|
||||
}
|
||||
PeerRef::Any => {
|
||||
// Same as invoke_with_policy: session → peers in order → base.
|
||||
self.invoke_with_policy(namespace, operation, input, parent, policy).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_contains(&self, peer: &PeerId, name: &str) -> bool {
|
||||
self.connections.get(peer).map_or(false, |c| c.contains(name))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Back-compat
|
||||
|
||||
Existing impls (`LocalOperationEnv`, connection overlay envs) use the default
|
||||
`invoke_peer` (delegates to `invoke_with_policy`, ignores peer selector) and
|
||||
default `peer_contains` (delegates to `contains`). No changes needed to those
|
||||
impls — the trait surface grows, the behavior is preserved.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `PeerRef` enum with `Specific(PeerId)` and `Any` variants
|
||||
- [ ] `OperationEnv::invoke_peer` method with default-impl (delegates to `invoke_with_policy`)
|
||||
- [ ] `OperationEnv::peer_contains` method with default-impl (delegates to `contains`)
|
||||
- [ ] `PeerCompositeEnv` overrides `invoke_peer` with real peer-keyed routing
|
||||
- [ ] `PeerRef::Specific` routes to named peer only (no fallthrough → NOT_FOUND if peer doesn't serve op)
|
||||
- [ ] `PeerRef::Any` reuses `invoke_with_policy` (insertion-order fan-out)
|
||||
- [ ] `PeerCompositeEnv` overrides `peer_contains` (checks specific peer's sub-overlay)
|
||||
- [ ] Reachability check (`scoped_env.allows`) gates before peer routing
|
||||
- [ ] `LocalOperationEnv` and overlay envs use default-impls (no changes)
|
||||
- [ ] Unit test: `PeerRef::Specific` routes to the named peer
|
||||
- [ ] Unit test: `PeerRef::Specific` → NOT_FOUND when peer doesn't serve the op (no fallthrough)
|
||||
- [ ] Unit test: `PeerRef::Any` routes to first peer (insertion order) that serves it
|
||||
- [ ] Unit test: `peer_contains` checks specific peer's overlay
|
||||
- [ ] Unit test: default-impl `invoke_peer` delegates to `invoke_with_policy` (back-compat)
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — OperationEnv, invoke_peer, PeerRef
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §2 (PeerRef routing)
|
||||
|
||||
## Notes
|
||||
|
||||
> The default-impl preserves back-compat — existing single-layer envs
|
||||
> (`LocalOperationEnv`, connection overlays) work unchanged. `PeerCompositeEnv`
|
||||
> overrides with real peer-keyed routing. `PeerRef::Specific` has no
|
||||
> fallthrough (explicit routing must be honored or fail loudly). `PeerRef::Any`
|
||||
> reuses the `invoke_with_policy` fan-out. The reachability check
|
||||
> (`scoped_env.allows`) gates before peer routing, same as before.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
187
tasks/call/peer-composite-env.md
Normal file
187
tasks/call/peer-composite-env.md
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
id: call/peer-composite-env
|
||||
name: Replace CompositeOperationEnv with PeerCompositeEnv (peer-keyed overlays) and PeerId from Identity.id (ADR-029/030)
|
||||
status: pending
|
||||
depends_on: [call/retire-remote-safe]
|
||||
scope: broad
|
||||
risk: high
|
||||
impact: phase
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Replace `CompositeOperationEnv` (singular `connection: Option<Arc<dyn
|
||||
OperationEnv>>`) with `PeerCompositeEnv` (peer-keyed `HashMap<PeerId,
|
||||
connection_overlay>`), and change `PeerId` from a connection-assigned UUID to
|
||||
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
||||
stable across key rotation). Per ADR-029 §1 and ADR-030 §4-5.
|
||||
|
||||
This is the highest-risk call task — a structural rewrite of the composition
|
||||
env that aggregates multiple connections. The singular-connection case (one
|
||||
peer) is the degenerate case with a single-entry map.
|
||||
|
||||
### PeerCompositeEnv struct
|
||||
|
||||
```rust
|
||||
pub struct PeerCompositeEnv {
|
||||
pub base: Arc<dyn OperationEnv + Send + Sync>, // Layer 0 curated
|
||||
pub session: Option<Arc<dyn OperationEnv + Send + Sync>>, // Layer 1
|
||||
pub connections: HashMap<PeerId, Arc<dyn OperationEnv + Send + Sync>>, // Layer 2, peer-keyed
|
||||
connection_order: Vec<PeerId>, // insertion order for PeerRef::Any first-match
|
||||
}
|
||||
|
||||
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
|
||||
// = PeerEntry.peer_id (stable, not crypto material — ADR-030)
|
||||
```
|
||||
|
||||
### PeerCompositeEnv methods
|
||||
|
||||
```rust
|
||||
impl PeerCompositeEnv {
|
||||
pub fn new(base: Arc<dyn OperationEnv + Send + Sync>) -> Self;
|
||||
|
||||
pub fn with_session(mut self, session: Arc<dyn OperationEnv + Send + Sync>) -> Self;
|
||||
|
||||
/// Attach a peer's connection overlay. The `peer_id` comes from
|
||||
/// `connection.identity().id` (IdentityProvider resolution). A connection
|
||||
/// with no resolved identity has no PeerId and is NOT attached (ADR-030 §5).
|
||||
pub fn attach_peer(&mut self, peer_id: PeerId, overlay: Arc<dyn OperationEnv + Send + Sync>);
|
||||
|
||||
/// Detach a peer's overlay (on disconnect). The peer's sub-overlay drops;
|
||||
/// in-flight PeerRef::Specific(that_peer) gets NOT_FOUND.
|
||||
pub fn detach_peer(&mut self, peer_id: &PeerId);
|
||||
}
|
||||
```
|
||||
|
||||
### PeerCompositeEnv::invoke_with_policy
|
||||
|
||||
```rust
|
||||
async fn invoke_with_policy(&self, namespace: &str, operation: &str, input: Value,
|
||||
parent: &OperationContext, policy: AbortPolicy) -> ResponseEnvelope {
|
||||
let name = format!("{namespace}/{operation}");
|
||||
if !parent.scoped_env.allows(&name) {
|
||||
return ResponseEnvelope::not_found(parent.request_id.clone(), &name);
|
||||
}
|
||||
// PeerRef::Any routing (ADR-029 §2): session → peers in insertion
|
||||
// order → curated base. First overlay that contains the op wins.
|
||||
if let Some(session) = &self.session {
|
||||
if session.contains(&name) {
|
||||
return session.invoke_with_policy(namespace, operation, input, parent, policy).await;
|
||||
}
|
||||
}
|
||||
for peer_id in &self.connection_order {
|
||||
if let Some(conn_env) = self.connections.get(peer_id) {
|
||||
if conn_env.contains(&name) {
|
||||
return conn_env.invoke_with_policy(namespace, operation, input, parent, policy).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.base.invoke_with_policy(namespace, operation, input, parent, policy).await
|
||||
}
|
||||
|
||||
fn contains(&self, name: &str) -> bool {
|
||||
self.session.as_ref().map_or(false, |s| s.contains(name))
|
||||
|| self.connections.values().any(|c| c.contains(name))
|
||||
|| self.base.contains(name)
|
||||
}
|
||||
```
|
||||
|
||||
The `invoke_peer` / `peer_contains` methods (PeerRef routing) are
|
||||
`call/operation-env-invoke-peer` — this task builds the struct and the
|
||||
`invoke_with_policy` / `contains` methods; the peer-routing methods are the
|
||||
next task.
|
||||
|
||||
### compose_root_env rewrite
|
||||
|
||||
`Dispatcher::compose_root_env` builds a `PeerCompositeEnv` per incoming call:
|
||||
|
||||
```rust
|
||||
fn compose_root_env(&self, connection: &CallConnection, context: &OperationContext)
|
||||
-> Arc<dyn OperationEnv + Send + Sync>
|
||||
{
|
||||
let base = Arc::new(LocalOperationEnv::new(Arc::clone(&self.registry)));
|
||||
let session = self.session_source.as_ref()
|
||||
.and_then(|s| s.overlay_for(context));
|
||||
|
||||
let mut env = PeerCompositeEnv::new(base);
|
||||
if let Some(session) = session {
|
||||
env = env.with_session(session);
|
||||
}
|
||||
// Attach this connection's overlay, keyed by the peer's PeerId.
|
||||
// PeerId = connection.identity().id (IdentityProvider resolution).
|
||||
// A connection with no resolved identity is NOT attached to the
|
||||
// peer-keyed overlay (ADR-030 §5) — its ops are invoked through the
|
||||
// CallConnection handle directly, not via PeerRef::Specific.
|
||||
if let Some(peer_id) = connection.connection().identity().map(|id| id.id.clone()) {
|
||||
env.attach_peer(peer_id, connection.overlay_env());
|
||||
}
|
||||
Arc::new(env)
|
||||
}
|
||||
```
|
||||
|
||||
### PeerId source: Identity.id (remove UUID workaround)
|
||||
|
||||
`PeerId` is `Identity.id` from `IdentityProvider` resolution — the stable
|
||||
`PeerEntry.peer_id` (ADR-030 §4). The UUID workaround (ADR-029 Assumption 1's
|
||||
connection-assigned UUID) is removed. A connection with no resolved identity
|
||||
has no `PeerId` and is not added to `PeerCompositeEnv` (ADR-030 §5).
|
||||
|
||||
### Migration: CompositeOperationEnv → PeerCompositeEnv
|
||||
|
||||
All call sites that construct `CompositeOperationEnv::new(base, Some(conn),
|
||||
session)` migrate to `PeerCompositeEnv::new(base).with_session(session).
|
||||
attach_peer(peer_id, conn)`. The singular-connection case (one peer) is the
|
||||
degenerate case (`connections` with one entry).
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- Does NOT add `invoke_peer` / `peer_contains` / `PeerRef` — that's
|
||||
`call/operation-env-invoke-peer`. This task builds the struct and the
|
||||
`invoke_with_policy` (PeerRef::Any equivalent) + `contains` methods.
|
||||
- Does NOT change `from_call` registration — that's
|
||||
`call/from-call-forwarded-for` (peer-keyed registration, forwarded_for).
|
||||
- Does NOT change `services/list` — that's
|
||||
`call/services-list-accesscontrol-filtered`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `PeerCompositeEnv` struct with `base`, `session`, `connections: HashMap<PeerId, ...>`, `connection_order: Vec<PeerId>`
|
||||
- [ ] `PeerId = String` (= `Identity.id`, not UUID)
|
||||
- [ ] `PeerCompositeEnv::new(base)`, `with_session(session)`, `attach_peer(peer_id, overlay)`, `detach_peer(peer_id)`
|
||||
- [ ] `invoke_with_policy` routes: session → peers in insertion order → base (first `contains` wins)
|
||||
- [ ] `contains` checks session + all connections + base
|
||||
- [ ] `compose_root_env` builds `PeerCompositeEnv` per call, attaches this connection's overlay keyed by `connection.identity().id`
|
||||
- [ ] Connection with no resolved identity → not attached to peer-keyed overlay (no PeerId)
|
||||
- [ ] `CompositeOperationEnv` removed (all call sites migrated)
|
||||
- [ ] UUID workaround removed (no connection-assigned UUID for PeerId)
|
||||
- [ ] Singular-connection case works (degenerate single-entry map)
|
||||
- [ ] Unit test: PeerCompositeEnv routes to session when it contains the op
|
||||
- [ ] Unit test: PeerCompositeEnv routes to first peer (insertion order) that contains the op
|
||||
- [ ] Unit test: PeerCompositeEnv falls through to base when no overlay contains the op
|
||||
- [ ] Unit test: attach_peer + detach_peer (detach → NOT_FOUND for that peer)
|
||||
- [ ] Unit test: connection with no identity → not attached
|
||||
- [ ] Unit test: reachability check (scoped_env.allows) still gates before routing
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — PeerCompositeEnv, OperationEnv
|
||||
- docs/architecture/crates/call/call-protocol.md — compose_root_env, build_root_context
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §1 (peer-keyed overlays)
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030 §4-5 (PeerId source)
|
||||
|
||||
## Notes
|
||||
|
||||
> Highest-risk call task — structural rewrite of the composition env. The
|
||||
> singular-connection case is the degenerate case (one-entry map). PeerId is
|
||||
> `Identity.id` (stable `peer_id`), not a UUID — the UUID workaround is removed.
|
||||
> A connection with no resolved identity gets no PeerId and is not attached
|
||||
> (ADR-030 §5). The `invoke_peer` / `PeerRef` routing methods are the next
|
||||
> task (`call/operation-env-invoke-peer`); this task builds the struct and the
|
||||
> PeerRef::Any-equivalent routing (`invoke_with_policy`).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
125
tasks/call/retire-remote-safe.md
Normal file
125
tasks/call/retire-remote-safe.md
Normal file
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: call/retire-remote-safe
|
||||
name: Retire remote_safe/trusted_peer/RemoteFilter — peer authorization via AccessControl (ADR-029 §3)
|
||||
status: pending
|
||||
depends_on: [core/review-core-sync]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Remove the ADR-028 peer-authorization machinery from alknet-call. Per ADR-029
|
||||
§3, peer authorization now flows through the existing `AccessControl::check
|
||||
(peer_identity)` — the same mechanism that gates every other call. No
|
||||
`remote_safe` flag, no `trusted_peer` bypass, no `RemoteFilter` gate.
|
||||
|
||||
This task is the "remove the old" step before the "build the new" (PeerCompositeEnv,
|
||||
invoke_peer). Removing the ADR-028 machinery first means the new
|
||||
`AccessControl`-based authorization replaces the old model rather than
|
||||
coexisting.
|
||||
|
||||
### What to remove
|
||||
|
||||
1. **`HandlerRegistration.remote_safe: bool`** (`registry/registration.rs`):
|
||||
- Remove the field
|
||||
- Remove `HandlerRegistration::remote_safe()` setter
|
||||
- Remove `OperationRegistryBuilder::remote_safe()` method
|
||||
- Remove all tests asserting `remote_safe` defaults/setter
|
||||
|
||||
2. **`OperationRegistry::list_operations_peer_scoped`** (`registry/registration.rs`):
|
||||
- Remove the method (replaced by AccessControl-filtered `services/list` in
|
||||
`call/services-list-accesscontrol-filtered`)
|
||||
|
||||
3. **`services_list_handler_peer_scoped`** (`registry/discovery.rs`):
|
||||
- Remove the function (replaced by single AccessControl-filtered handler in
|
||||
`call/services-list-accesscontrol-filtered`)
|
||||
|
||||
4. **`RemoteFilter`** (`protocol/dispatch.rs`):
|
||||
- Remove the `RemoteFilter` struct and its `default_deny()`/`trusted()`/
|
||||
`allows()` methods
|
||||
- Remove the `remote_filter` field from `Dispatcher`
|
||||
- Remove the `RemoteFilter` parameter from `Dispatcher::new()`
|
||||
- Remove the `remote_filter.allows(registration.remote_safe)` gate in
|
||||
`dispatch_requested` (the AccessControl gate in `OperationRegistry::invoke`
|
||||
already handles authorization — this task removes the *parallel* gate)
|
||||
|
||||
5. **`CallClient::trusted_peer`** (`client/call_client.rs`):
|
||||
- Remove the `trusted_peer: bool` field
|
||||
- Remove `CallClient::trusted_peer()` constructor
|
||||
- Remove `CallClient::is_trusted_peer()` method
|
||||
- Remove the `RemoteFilter::trusted()`/`default_deny()` selection in
|
||||
`spawn_dispatch`
|
||||
- `CallClient::new()` stays; `spawn_dispatch` constructs `Dispatcher::new`
|
||||
without `RemoteFilter`
|
||||
|
||||
6. **All ADR-028 tests**:
|
||||
- Remove tests asserting `remote_safe` behavior, `trusted_peer` mode,
|
||||
`RemoteFilter` filtering, `list_operations_peer_scoped`,
|
||||
`services_list_handler_peer_scoped`
|
||||
- These tests verify the old model; the new model's tests land in the
|
||||
consuming tasks (`call/services-list-accesscontrol-filtered`,
|
||||
`call/dispatch-peer-identity`)
|
||||
|
||||
### Transient state
|
||||
|
||||
After this task, the dispatch path authorizes via `AccessControl::check` (which
|
||||
`OperationRegistry::invoke` already runs) — no parallel gate. The
|
||||
`PeerCompositeEnv` and `invoke_peer` are not yet built (those are
|
||||
`call/peer-composite-env` and `call/operation-env-invoke-peer`), so the
|
||||
composition env is still `CompositeOperationEnv` (singular connection). The
|
||||
`services/list` handler is the unfiltered `services_list_handler` until
|
||||
`call/services-list-accesscontrol-filtered` adds the AccessControl filter.
|
||||
|
||||
This transient state compiles and is correct — it's just the ADR-028 model
|
||||
removed without the ADR-029 peer-keyed routing yet added. The
|
||||
`AccessControl::check` gate in `OperationRegistry::invoke` is the authorization
|
||||
mechanism throughout.
|
||||
|
||||
### ADR-029 §3 mapping (the three `remote_safe` cases)
|
||||
|
||||
| `remote_safe` case | Replacement (already in place via AccessControl) |
|
||||
|---|---|
|
||||
| Op callable by any peer (was `remote_safe: true`) | `AccessControl::default()` — no restrictions |
|
||||
| Op callable only by some peers | `AccessControl { required_scopes: [...] }` — peer's `Identity.scopes` must satisfy |
|
||||
| Op never callable from wire | `Visibility::Internal` — `NOT_FOUND` before ACL |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `HandlerRegistration.remote_safe` field removed
|
||||
- [ ] `HandlerRegistration::remote_safe()` setter removed
|
||||
- [ ] `OperationRegistryBuilder::remote_safe()` removed
|
||||
- [ ] `OperationRegistry::list_operations_peer_scoped` removed
|
||||
- [ ] `services_list_handler_peer_scoped` removed
|
||||
- [ ] `RemoteFilter` struct removed from `protocol/dispatch.rs`
|
||||
- [ ] `Dispatcher.remote_filter` field removed
|
||||
- [ ] `Dispatcher::new()` no longer takes `RemoteFilter`
|
||||
- [ ] `CallClient.trusted_peer` field removed
|
||||
- [ ] `CallClient::trusted_peer()` constructor removed
|
||||
- [ ] `CallClient::is_trusted_peer()` removed
|
||||
- [ ] `dispatch_requested` no longer has the `remote_filter.allows` gate
|
||||
- [ ] All ADR-028 tests removed
|
||||
- [ ] No `remote_safe`/`trusted_peer`/`RemoteFilter` references remain in the crate
|
||||
- [ ] `cargo test -p alknet-call` succeeds (remaining tests pass — the AccessControl gate in invoke still works)
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §3 (retire remote_safe/trusted_peer)
|
||||
- docs/architecture/crates/call/operation-registry.md — HandlerRegistration (remote_safe removed)
|
||||
- docs/architecture/crates/call/client-and-adapters.md — CallClient (trusted_peer removed)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the "remove the old" step. The new model (PeerCompositeEnv,
|
||||
> invoke_peer, AccessControl-filtered services/list) lands in subsequent tasks.
|
||||
> The transient state after this task compiles and is correct —
|
||||
> `AccessControl::check` in `OperationRegistry::invoke` is the authorization
|
||||
> mechanism throughout. The ADR-028 tests are removed because they verify the
|
||||
> old model; the new model's tests land in the consuming tasks.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
155
tasks/call/review-call-sync.md
Normal file
155
tasks/call/review-call-sync.md
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
id: call/review-call-sync
|
||||
name: Review alknet-call ADR-029/030/032/034 sync for spec conformance
|
||||
status: pending
|
||||
depends_on: [call/operation-env-invoke-peer, call/services-list-accesscontrol-filtered, call/call-client-verifier-selection, call/from-call-forwarded-for, call/dispatch-peer-identity]
|
||||
scope: broad
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review the alknet-call implementation after the ADR-029/030/032/034 sync for
|
||||
spec conformance, pattern consistency, and correctness. This is the quality
|
||||
checkpoint at the end of the call phase — the most complex sync in this batch.
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **remote_safe/trusted_peer retirement** (ADR-029 §3):
|
||||
- No `remote_safe` field on `HandlerRegistration`
|
||||
- No `trusted_peer` on `CallClient`
|
||||
- No `RemoteFilter` in dispatch
|
||||
- No `list_operations_peer_scoped` / `services_list_handler_peer_scoped`
|
||||
- No ADR-028 test remnants
|
||||
- Peer authorization via `AccessControl::check(peer_identity)` only
|
||||
|
||||
2. **PeerCompositeEnv conformance** (ADR-029 §1, ADR-030 §4-5):
|
||||
- `PeerCompositeEnv` with peer-keyed `connections: HashMap<PeerId, ...>`
|
||||
- `connection_order: Vec<PeerId>` (insertion order for `PeerRef::Any`)
|
||||
- `PeerId = Identity.id` (stable `peer_id`, not UUID)
|
||||
- `compose_root_env` builds `PeerCompositeEnv` per call
|
||||
- Connection with no resolved identity → not attached (no PeerId)
|
||||
- `CompositeOperationEnv` removed (all call sites migrated)
|
||||
- Singular-connection case works (degenerate single-entry map)
|
||||
|
||||
3. **invoke_peer / PeerRef conformance** (ADR-029 §2):
|
||||
- `PeerRef::Specific(PeerId)` / `PeerRef::Any` enum
|
||||
- `OperationEnv::invoke_peer` with default-impl (back-compat)
|
||||
- `OperationEnv::peer_contains` with default-impl
|
||||
- `PeerCompositeEnv` overrides with real peer-keyed routing
|
||||
- `PeerRef::Specific` → no fallthrough (NOT_FOUND if peer doesn't serve op)
|
||||
- `PeerRef::Any` → insertion-order first-match
|
||||
- Reachability check (`scoped_env.allows`) gates before routing
|
||||
|
||||
4. **forwarded_for conformance** (ADR-032):
|
||||
- `OperationContext.forwarded_for: Option<Identity>` field
|
||||
- `build_root_context` populates from `call.requested.forwarded_for`
|
||||
- `OperationEnv::invoke` sets `None` for composed children (wire-ingress only)
|
||||
- `AccessControl::check` never reads `forwarded_for` (structural invariant)
|
||||
- `from_call` handler populates `forwarded_for` from `context.identity`
|
||||
|
||||
5. **services/list AccessControl-filtered** (ADR-029 §6):
|
||||
- `services/list` filters by `AccessControl::check(calling_peer_identity)`
|
||||
- Op with `AccessControl::default()` listed to any peer
|
||||
- Op with `required_scopes` hidden from unauthorized peers
|
||||
- `services/list-peers` opt-in (peer-attributed, AccessControl-filtered)
|
||||
- `services/schema` unchanged
|
||||
|
||||
6. **CallClient verifier selection** (OQ-29, ADR-034):
|
||||
- Client presents Ed25519 key as RFC 7250 raw public key client cert
|
||||
- `AcceptAnyServerCertVerifier` removed (security hole)
|
||||
- Verifier by `PeerEntry` presence: `Some` → fingerprint pin, `None` + X.509 → CA, `None` + Ed25519 → fail closed
|
||||
- `CallCredentials.remote_identity: None` is load-bearing (not placeholder)
|
||||
- No-env-vars invariant preserved (credentials from Capabilities)
|
||||
|
||||
7. **from_call peer-keyed registration** (ADR-029 §5):
|
||||
- `from_call` registers into peer's sub-overlay
|
||||
- Same-peer collision = error (`SamePeerCollision`)
|
||||
- Cross-peer collision dissolves (separate sub-overlays)
|
||||
- `namespace_prefix` defaults to `None` (optional local-naming sugar)
|
||||
- `forwarded_for` populated from hub's `context.identity`
|
||||
|
||||
8. **dispatch_peer_identity** (ADR-029 §3, ADR-030 §5):
|
||||
- `dispatch_requested` resolves peer `Identity`
|
||||
- `AccessControl::check(peer_identity)` is the sole authorization gate
|
||||
- `PeerId` from `connection.identity().id` (not UUID)
|
||||
- Connection with no identity → no PeerId, not in PeerCompositeEnv
|
||||
|
||||
9. **ADR conformance**:
|
||||
- ADR-029: peer-keyed overlays, PeerRef routing, AccessControl-based peer auth, retire remote_safe
|
||||
- ADR-030: PeerId = Identity.id = PeerEntry.peer_id (stable)
|
||||
- ADR-032: forwarded_for (metadata only, wire-ingress only, never in ACL)
|
||||
- ADR-034: three remote roles, verifier selection by PeerEntry presence
|
||||
|
||||
10. **Security constraints**:
|
||||
- Capabilities non-serializable, zeroized, immutable (unchanged)
|
||||
- No secret material on wire (unchanged)
|
||||
- `forwarded_for` is metadata, not authority (AccessControl::check never reads it)
|
||||
- Internal ops → NOT_FOUND from wire (unchanged)
|
||||
- Reachability check bounds composition (unchanged)
|
||||
- No-env-vars invariant (credentials from Capabilities, not env vars)
|
||||
- `AcceptAnyServerCertVerifier` removed (no security hole for X.509)
|
||||
|
||||
11. **Pattern consistency**:
|
||||
- `OperationEnv` is a trait (not concrete) — preserved
|
||||
- `PeerCompositeEnv` uses `contains()` probe before dispatch — preserved
|
||||
- Authority switch on `internal: true` — preserved
|
||||
- Deadline inheritance — preserved
|
||||
- Metadata fresh on composition (`HashMap::new()`) — preserved
|
||||
|
||||
12. **Test coverage**:
|
||||
- PeerCompositeEnv routing (session, peers in order, base fallthrough)
|
||||
- PeerRef::Specific (routes, NOT_FOUND on missing peer)
|
||||
- PeerRef::Any (insertion-order first-match)
|
||||
- forwarded_for (populated, None for composed children, never in ACL)
|
||||
- services/list AccessControl-filtered
|
||||
- AccessControl::check peer authorization (allowed, FORBIDDEN, Internal NOT_FOUND)
|
||||
- CallClient verifier selection (fingerprint pin, CA, fail closed)
|
||||
- from_call forwarded_for + peer-keyed registration + collision rules
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] No `remote_safe`/`trusted_peer`/`RemoteFilter` references remain
|
||||
- [ ] `PeerCompositeEnv` matches ADR-029 §1 (peer-keyed, insertion order)
|
||||
- [ ] `PeerId = Identity.id` (stable, not UUID)
|
||||
- [ ] `invoke_peer`/`peer_contains`/`PeerRef` match ADR-029 §2
|
||||
- [ ] `forwarded_for` matches ADR-032 (metadata only, wire-ingress only, never in ACL)
|
||||
- [ ] `services/list` AccessControl-filtered; `services/list-peers` opt-in
|
||||
- [ ] CallClient verifier selection matches ADR-034 §3
|
||||
- [ ] `from_call` peer-keyed registration + forwarded_for + collision rules
|
||||
- [ ] `AccessControl::check(peer_identity)` is the sole authorization gate
|
||||
- [ ] `AcceptAnyServerCertVerifier` removed
|
||||
- [ ] No-env-vars invariant preserved
|
||||
- [ ] `OperationEnv` is a trait (not concrete)
|
||||
- [ ] Test coverage adequate for all new functionality
|
||||
- [ ] `cargo fmt --check -p alknet-call` passes
|
||||
- [ ] `cargo clippy -p alknet-call` passes with no warnings
|
||||
- [ ] All tests pass
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/README.md
|
||||
- docs/architecture/crates/call/call-protocol.md
|
||||
- docs/architecture/crates/call/operation-registry.md
|
||||
- docs/architecture/crates/call/client-and-adapters.md
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md
|
||||
- docs/architecture/decisions/032-forwarded-for-identity.md
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the most complex sync in this batch. The review should verify that
|
||||
> the peer-keyed routing model (ADR-029), the stable PeerId (ADR-030), the
|
||||
> forwarded_for metadata field (ADR-032), and the verifier selection (ADR-034)
|
||||
> all work correctly together. The `OperationEnv` trait-object design is
|
||||
> load-bearing — verify it's still a trait, not concrete. The
|
||||
> `AccessControl::check`-based peer authorization is the security property —
|
||||
> verify no parallel gate remains. If deviations are found, document and fix
|
||||
> before considering the call sync complete.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
122
tasks/call/services-list-accesscontrol-filtered.md
Normal file
122
tasks/call/services-list-accesscontrol-filtered.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
id: call/services-list-accesscontrol-filtered
|
||||
name: Filter services/list by AccessControl::check(peer_identity) and add services/list-peers opt-in (ADR-029 §6)
|
||||
status: pending
|
||||
depends_on: [call/retire-remote-safe]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Change `services/list` to filter by `AccessControl::check(calling_peer_identity)`
|
||||
— the calling peer sees only ops it is authorized to call. Collapse the
|
||||
`services_list_handler` / `services_list_handler_peer_scoped` split (the latter
|
||||
was removed in `call/retire-remote-safe`) into a single AccessControl-filtered
|
||||
handler. Add the opt-in `services/list-peers` for peer-attributed re-export
|
||||
listing. Per ADR-029 §6.
|
||||
|
||||
### services/list (AccessControl-filtered)
|
||||
|
||||
The single `services_list_handler` filters by `AccessControl::check` against
|
||||
the calling peer's resolved `Identity`:
|
||||
|
||||
```rust
|
||||
pub fn services_list_handler(registry: Arc<OperationRegistry>) -> Handler {
|
||||
Arc::new(move |_input, context| {
|
||||
let registry = Arc::clone(®istry);
|
||||
Box::pin(async move {
|
||||
let calling_identity = &context.identity;
|
||||
let ops: Vec<Value> = registry.list_operations()
|
||||
.into_iter()
|
||||
.filter(|spec| {
|
||||
// Only list ops the calling peer is authorized to call.
|
||||
// AccessControl::check returns Allowed/Forbidden.
|
||||
spec.access_control.check(calling_identity.as_ref()).is_allowed()
|
||||
})
|
||||
.map(|spec| serde_json::json!({
|
||||
"name": spec.name,
|
||||
"namespace": spec.namespace,
|
||||
"op_type": spec.op_type,
|
||||
}))
|
||||
.collect();
|
||||
ResponseEnvelope::ok(context.request_id, serde_json::json!({ "operations": ops }))
|
||||
})
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- An op with `AccessControl::default()` (no restrictions) is listed to any
|
||||
peer — implicitly callable by any authenticated peer.
|
||||
- An op with `required_scopes` is listed only to peers whose `Identity.scopes`
|
||||
satisfy them.
|
||||
- An op with `Visibility::Internal` is never listed (excluded from
|
||||
`list_operations()` which already filters to `External`).
|
||||
|
||||
### services/list-peers (opt-in)
|
||||
|
||||
The opt-in for peer-attributed re-export listing — each peer's sub-overlay
|
||||
listed with attribution, filtered by the calling peer's authorization:
|
||||
|
||||
```rust
|
||||
pub fn services_list_peers_handler(/* ... */) -> Handler {
|
||||
// Lists ops from each peer's sub-overlay in PeerCompositeEnv,
|
||||
// attributed by peer_id, filtered by AccessControl::check(calling_identity).
|
||||
// Opt-in: the calling peer invokes this operation name explicitly.
|
||||
}
|
||||
```
|
||||
|
||||
This operation is registered alongside `services/list` and `services/schema`.
|
||||
It iterates the peer-keyed overlays (via `context.env`), lists each peer's ops
|
||||
with `peer_id` attribution, and filters by the calling peer's authorization.
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- Does NOT change `services/schema` (unchanged — returns full spec for a
|
||||
named op).
|
||||
- Does NOT build the `PeerCompositeEnv` (that's `call/peer-composite-env`) —
|
||||
but `services/list-peers` consumes it via `context.env`. If
|
||||
`PeerCompositeEnv` is not yet built, `services/list-peers` can be registered
|
||||
with a stub that returns empty until the env is ready, or this task can
|
||||
depend on `call/peer-composite-env`. **This task depends only on
|
||||
`call/retire-remote-safe`** so `services/list` (the AccessControl filter)
|
||||
can land independently; `services/list-peers` is implemented to consume
|
||||
`PeerCompositeEnv` via `context.env.peer_contains` (which has a default-impl
|
||||
that works even before `PeerCompositeEnv` is wired).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `services_list_handler` filters by `AccessControl::check(context.identity)`
|
||||
- [ ] Op with `AccessControl::default()` listed to any peer
|
||||
- [ ] Op with `required_scopes` listed only to authorized peers
|
||||
- [ ] Op with `Visibility::Internal` never listed (unchanged — `list_operations` filters to External)
|
||||
- [ ] `services_list_handler_peer_scoped` removed (was removed in `call/retire-remote-safe`; verify gone)
|
||||
- [ ] `services/list-peers` opt-in operation added (peer-attributed, AccessControl-filtered)
|
||||
- [ ] `services/schema` unchanged
|
||||
- [ ] Unit test: `services/list` lists only ops the calling peer is authorized for
|
||||
- [ ] Unit test: op with no restrictions listed to any peer
|
||||
- [ ] Unit test: op with required_scopes hidden from unauthorized peer
|
||||
- [ ] Unit test: `services/list-peers` attributes ops by peer_id
|
||||
- [ ] `cargo test -p alknet-call` succeeds
|
||||
- [ ] `cargo clippy -p alknet-call` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/call/operation-registry.md — Service Discovery
|
||||
- docs/architecture/crates/call/client-and-adapters.md — services/list AccessControl-filtered
|
||||
- docs/architecture/decisions/029-peer-graph-routing-model.md — ADR-029 §6
|
||||
|
||||
## Notes
|
||||
|
||||
> `services/list` semantics change: the filter is `AccessControl`-based, not
|
||||
> `remote_safe`-based. An op with `AccessControl::default()` is now listed to
|
||||
> any peer — this is correct (it's implicitly callable by any authenticated
|
||||
> peer). Operators who relied on `remote_safe: false` to hide ops from peers
|
||||
> must instead set `required_scopes` or `Visibility::Internal`. The
|
||||
> `services/list-peers` opt-in is for peer-attributed re-export listing.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in New Issue
Block a user