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
|
||||
114
tasks/core/config-identity-provider-peerentry.md
Normal file
114
tasks/core/config-identity-provider-peerentry.md
Normal file
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: core/config-identity-provider-peerentry
|
||||
name: Rewrite ConfigIdentityProvider resolution to use PeerEntry multi-credential path (ADR-030)
|
||||
status: pending
|
||||
depends_on: [core/peer-entry-model]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Rewrite `ConfigIdentityProvider::resolve_from_fingerprint` and
|
||||
`resolve_from_token` to use the new `PeerEntry`-based resolution from
|
||||
`core/peer-entry-model`. This is the resolution-logic half of the ADR-030
|
||||
change — the data model (PeerEntry struct, AuthPolicy.peers) lands in
|
||||
`core/peer-entry-model`; this task updates the `ConfigIdentityProvider` methods
|
||||
that delegate to `AuthPolicy`.
|
||||
|
||||
### Current state (pre-ADR-030)
|
||||
|
||||
```rust
|
||||
impl IdentityProvider for ConfigIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||
let config = self.dynamic.load();
|
||||
config.auth.resolve_identity_from_fingerprint(fingerprint)
|
||||
}
|
||||
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
let config = self.dynamic.load();
|
||||
let token_str = String::from_utf8_lossy(&token.raw);
|
||||
config.auth.resolve_api_key(&token_str) // ← only ApiKeyEntry path
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Target state (ADR-030)
|
||||
|
||||
```rust
|
||||
impl IdentityProvider for ConfigIdentityProvider {
|
||||
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||
let config = self.dynamic.load();
|
||||
config.auth.resolve_identity_from_fingerprint(fingerprint)
|
||||
// Now resolves: fingerprint → PeerEntry → Identity { id: peer_id, ... }
|
||||
}
|
||||
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||
let config = self.dynamic.load();
|
||||
let token_str = String::from_utf8_lossy(&token.raw);
|
||||
config.auth.resolve_identity_from_token(&token_str)
|
||||
// Now tries PeerEntry.auth_token_hash first, falls through to ApiKeyEntry
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key change in `resolve_from_token`: it now calls
|
||||
`AuthPolicy::resolve_identity_from_token` (added in `core/peer-entry-model`),
|
||||
which tries the `PeerEntry.auth_token_hash` path first (token is one credential
|
||||
path among several for a stable logical peer → `Identity.id = peer_id`), then
|
||||
falls through to `resolve_api_key` (token IS the identity → `Identity.id =
|
||||
prefix`).
|
||||
|
||||
### ConfigIdentityProvider stays read-only
|
||||
|
||||
`ConfigIdentityProvider` still reads from `ArcSwap<DynamicConfig>` on every call
|
||||
(hot-reloadable). It does NOT implement `IdentityStore` (that's
|
||||
`core/identity-store-trait` — config reload is its write path, not a method
|
||||
call).
|
||||
|
||||
### Test migration
|
||||
|
||||
The existing auth.rs tests use `authorized_fingerprints: HashSet<String>` and
|
||||
expect `Identity.id == fingerprint`. These must migrate to the `PeerEntry` model:
|
||||
- `config_with_fingerprint` helper → `config_with_peer_entry` helper
|
||||
- Fingerprint resolution tests expect `Identity.id == peer_id` (not the fingerprint)
|
||||
- Add token-resolution-via-PeerEntry tests (auth_token_hash path)
|
||||
- Config reload tests use `PeerEntry` in the new config
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `ConfigIdentityProvider::resolve_from_fingerprint` delegates to `AuthPolicy::resolve_identity_from_fingerprint` (PeerEntry path)
|
||||
- [ ] `ConfigIdentityProvider::resolve_from_token` delegates to `AuthPolicy::resolve_identity_from_token` (PeerEntry.auth_token_hash → fall through to ApiKeyEntry)
|
||||
- [ ] `ConfigIdentityProvider` reads from ArcSwap on every call (hot-reloadable — unchanged)
|
||||
- [ ] `ConfigIdentityProvider` does NOT implement `IdentityStore`
|
||||
- [ ] Fingerprint resolution returns `Identity { id: peer_id, ... }` (stable, not the fingerprint)
|
||||
- [ ] Token resolution: PeerEntry.auth_token_hash match → `Identity { id: peer_id }`; no match → ApiKeyEntry fall-through → `Identity { id: prefix }`
|
||||
- [ ] All existing auth.rs tests migrated to PeerEntry model (no `authorized_fingerprints` references)
|
||||
- [ ] Unit test: fingerprint resolution via PeerEntry (known → Some with peer_id, unknown → None)
|
||||
- [ ] Unit test: token resolution via PeerEntry.auth_token_hash (matching → Some with peer_id)
|
||||
- [ ] Unit test: token resolution falls through to ApiKeyEntry when no PeerEntry matches
|
||||
- [ ] Unit test: config reload changes resolution results immediately (PeerEntry model)
|
||||
- [ ] Unit test: disabled PeerEntry returns None
|
||||
- [ ] `cargo test -p alknet-core` succeeds
|
||||
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/core/auth.md — ConfigIdentityProvider, multi-credential resolution
|
||||
- docs/architecture/crates/core/config.md — AuthPolicy.peers, PeerEntry
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030 §2 (resolution semantics)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the resolution-logic half of the ADR-030 change. The data model lands
|
||||
> in `core/peer-entry-model`; this task wires `ConfigIdentityProvider` to the
|
||||
> new `AuthPolicy` methods. The semantic shift: `Identity.id` changes from the
|
||||
> fingerprint to the `peer_id` on the fingerprint path. The token path gains a
|
||||
> new first-try (PeerEntry.auth_token_hash) before the existing ApiKeyEntry
|
||||
> fall-through. ConfigIdentityProvider stays read-only and ArcSwap-backed.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
144
tasks/core/credential-store-trait.md
Normal file
144
tasks/core/credential-store-trait.md
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
id: core/credential-store-trait
|
||||
name: Add CredentialStore trait, InMemoryCredentialStore, EncryptedData mirror, and StoreError (ADR-031/035)
|
||||
status: pending
|
||||
depends_on: []
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Add the second repo trait to alknet-core: `CredentialStore` for encrypted-
|
||||
credential persistence, alongside its in-memory default adapter and the shared
|
||||
`StoreError` type. Per ADR-031 (the trait) and ADR-035 (refines `put`/`delete`
|
||||
to async, renames the error to `StoreError`).
|
||||
|
||||
This task is standalone — it has no dependency on `core/peer-entry-model`. The
|
||||
`CredentialStore` trait persists `EncryptedData` blobs (the vault's encrypted
|
||||
output); it never decrypts (ADR-025 — the vault is the sole decryption
|
||||
boundary).
|
||||
|
||||
### CredentialStore trait
|
||||
|
||||
```rust
|
||||
pub trait CredentialStore: Send + Sync {
|
||||
fn get(&self, provider: &str) -> Option<EncryptedData>;
|
||||
async fn put(&self, provider: &str, data: &EncryptedData) -> Result<(), StoreError>;
|
||||
async fn delete(&self, provider: &str) -> Result<(), StoreError>;
|
||||
}
|
||||
```
|
||||
|
||||
- `get` is **sync** (cached read — the hot path; ADR-035 §1).
|
||||
- `put`/`delete` are **async** (they hit the backend; ADR-035 §3 refines
|
||||
ADR-031's sync sketch to async within the one-way door).
|
||||
- `get` returns `Option<EncryptedData>` (missing credential is the common case,
|
||||
not an error).
|
||||
- No `list` method (ADR-031 §4 — additive if needed later).
|
||||
|
||||
### InMemoryCredentialStore
|
||||
|
||||
```rust
|
||||
pub struct InMemoryCredentialStore {
|
||||
entries: RwLock<HashMap<String, EncryptedData>>,
|
||||
}
|
||||
|
||||
impl InMemoryCredentialStore {
|
||||
pub fn new() -> Self;
|
||||
pub fn with_entries(entries: HashMap<String, EncryptedData>) -> Self;
|
||||
}
|
||||
|
||||
impl CredentialStore for InMemoryCredentialStore { ... }
|
||||
```
|
||||
|
||||
The default adapter covers tests and config-loaded deployments. `put`/`delete`
|
||||
are async with no `.await` points (trivially satisfy an async trait — ADR-035
|
||||
§3). Same posture as `ConfigIdentityProvider` — no persistence, no backend
|
||||
dependency, no env vars.
|
||||
|
||||
### EncryptedData core mirror
|
||||
|
||||
A thin serializable struct mirroring the vault's `EncryptedData` (ADR-020),
|
||||
so the trait can reference it without a vault dependency (ADR-018 — vault is
|
||||
standalone with zero alknet-crate dependencies):
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct EncryptedData {
|
||||
pub key_version: u32,
|
||||
pub salt: Vec<u8>, // wire-format compat (OQ-20); unused in v2 but kept
|
||||
pub iv: Vec<u8>, // AES-GCM IV (OsRng-generated)
|
||||
pub data: Vec<u8>, // ciphertext
|
||||
}
|
||||
```
|
||||
|
||||
The `salt` field is kept for wire-format compatibility with the TS predecessor
|
||||
(OQ-20) — a core mirror that omitted it could not round-trip the vault's
|
||||
`EncryptedData`. v2 may write a zero-length salt but must not drop the field
|
||||
(ADR-035 §6).
|
||||
|
||||
### StoreError
|
||||
|
||||
```rust
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("backend error: {message}")]
|
||||
Backend { message: String },
|
||||
#[error("not found: {entity}")]
|
||||
NotFound { entity: String },
|
||||
#[error("serialization error: {message}")]
|
||||
Serialization { message: String },
|
||||
}
|
||||
```
|
||||
|
||||
Shared by both `CredentialStore` and `IdentityStore` (ADR-035 §7 renames
|
||||
ADR-031's `CredentialStoreError` to `StoreError`). `#[non_exhaustive]` so
|
||||
adapter crates can extend without breaking match arms. Lives in alknet-core
|
||||
(where the traits live).
|
||||
|
||||
### Module placement
|
||||
|
||||
Add a new `store` module (or `credential_store` module) in `alknet-core/src/`.
|
||||
Re-export `CredentialStore`, `InMemoryCredentialStore`, `EncryptedData`, and
|
||||
`StoreError` from `lib.rs`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `CredentialStore` trait with sync `get`, async `put`/`delete`
|
||||
- [ ] `InMemoryCredentialStore` with `new()` and `with_entries()`
|
||||
- [ ] `InMemoryCredentialStore` implements `CredentialStore` (async put/delete with no .await points)
|
||||
- [ ] `EncryptedData` core mirror with 4 fields (`key_version`, `salt`, `iv`, `data`), derives Serialize/Deserialize/Clone/Debug
|
||||
- [ ] `StoreError` enum with 3 variants, `#[non_exhaustive]`, `thiserror::Error`
|
||||
- [ ] No vault dependency added to alknet-core (EncryptedData is a core-owned mirror)
|
||||
- [ ] No `list` method on the trait
|
||||
- [ ] Unit test: InMemoryCredentialStore get/put/delete round-trip
|
||||
- [ ] Unit test: get returns None for missing provider
|
||||
- [ ] Unit test: EncryptedData serializes and deserializes (round-trip)
|
||||
- [ ] Unit test: StoreError Display formatting
|
||||
- [ ] `cargo test -p alknet-core` succeeds
|
||||
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/core/auth.md — CredentialStore, StoreError, EncryptedData mirror
|
||||
- docs/architecture/decisions/031-credentialstore-repo-trait.md — ADR-031 (the trait)
|
||||
- docs/architecture/decisions/035-concrete-persistence-adapter-shapes.md — ADR-035 (async put/delete, StoreError rename, schema)
|
||||
- docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md — ADR-033 (the pattern)
|
||||
|
||||
## Notes
|
||||
|
||||
> Standalone task — no dependency on PeerEntry. The `CredentialStore` trait is
|
||||
> the second repo trait in core (alongside `IdentityProvider`), establishing
|
||||
> the repo/adapter pattern concretely (ADR-033). The trait is the one-way door;
|
||||
> the in-memory default is the reference implementation; persistence adapters
|
||||
> (alknet-store-sqlite, ADR-035) are separate crates, not built in this sync.
|
||||
> `get` stays sync because the credential load happens at startup into
|
||||
> `Capabilities` (ADR-031); `put`/`delete` are async because a SQLite-backed
|
||||
> adapter cannot do a sync write without blocking (ADR-035 §3).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
127
tasks/core/fingerprint-normalization.md
Normal file
127
tasks/core/fingerprint-normalization.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
id: core/fingerprint-normalization
|
||||
name: Normalize quinn Ed25519 raw-key fingerprint to ed25519:hex format (ADR-030 §6)
|
||||
status: pending
|
||||
depends_on: [core/peer-entry-model]
|
||||
scope: narrow
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Normalize the quinn Ed25519 raw-key fingerprint extraction to produce
|
||||
`ed25519:<hex of 32-byte pub key>`, matching the iroh path. Currently
|
||||
`fingerprint_from_cert_der` produces `SHA256:<hex of cert DER>` for ALL certs,
|
||||
including RFC 7250 raw public keys. ADR-030 §6 requires that Ed25519 raw keys
|
||||
produce `ed25519:<hex>` regardless of transport (quinn or iroh), so the same
|
||||
key has the same fingerprint in `PeerEntry.fingerprints` — one entry, both
|
||||
transports.
|
||||
|
||||
### Current state
|
||||
|
||||
```rust
|
||||
// crates/alknet-core/src/endpoint.rs
|
||||
fn extract_quinn_client_fingerprint(connection: &quinn::Connection) -> Option<String> {
|
||||
let identity = connection.peer_identity()?;
|
||||
let cert = identity.iter().next()?;
|
||||
fingerprint_from_cert_der(cert.as_ref())
|
||||
}
|
||||
|
||||
fn fingerprint_from_cert_der(cert_der: &[u8]) -> Option<String> {
|
||||
// Always SHA256:<hex of DER> — wrong for Ed25519 raw keys
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(cert_der);
|
||||
Some(format!("SHA256:{}", hex::encode(hasher.finalize())))
|
||||
}
|
||||
|
||||
fn extract_iroh_client_fingerprint(connection: &iroh::endpoint::Connection) -> Option<String> {
|
||||
let node_id = connection.remote_node_id().ok()?;
|
||||
Some(format!("ed25519:{}", node_id)) // ← already correct
|
||||
}
|
||||
```
|
||||
|
||||
### Target state (ADR-030 §6)
|
||||
|
||||
`fingerprint_from_cert_der` (or a new `fingerprint_from_client_cert` function)
|
||||
must distinguish:
|
||||
|
||||
1. **RFC 7250 raw public key cert** (SPKI with Ed25519 algorithm identifier):
|
||||
extract the raw 32-byte Ed25519 public key from the SPKI DER and format as
|
||||
`ed25519:<lowercase hex of 32 bytes>`. This matches the iroh path — the same
|
||||
key has the same fingerprint regardless of transport.
|
||||
|
||||
2. **X.509 cert**: keep `SHA256:<hex of cert DER>` (the DER hash — X.509 certs
|
||||
don't have a "raw public key" form).
|
||||
|
||||
The distinction is whether the presented cert is an RFC 7250 raw public key
|
||||
(SPKI with Ed25519 algorithm identifier, no X.509 wrapper) or a full X.509
|
||||
cert. The `RawKeyCertResolver` on the server side already has the raw key bytes
|
||||
via `Ed25519SecretKey::public()`; the client-side extraction must parse the
|
||||
SPKI DER to extract the raw key.
|
||||
|
||||
### Fingerprint format table (ADR-030 §6)
|
||||
|
||||
| Transport | Source | Format |
|
||||
|-----------|--------|--------|
|
||||
| iroh (direct or relay) | peer `NodeId` (Ed25519 public key) | `ed25519:<lowercase hex of 32-byte pub key>` |
|
||||
| quinn (RFC 7250 raw key) | SPKI cert → extract raw Ed25519 pub key | `ed25519:<lowercase hex of 32-byte pub key>` (normalized) |
|
||||
| quinn (X.509) | leaf client cert DER | `SHA256:<hex of SHA-256(cert_der)>` |
|
||||
|
||||
### Implementation approach
|
||||
|
||||
Parse the cert DER to detect whether it's a raw public key (SPKI) or an X.509
|
||||
cert. If SPKI with Ed25519 algorithm identifier, extract the 32-byte public key
|
||||
and format as `ed25519:<hex>`. Otherwise, hash the full DER as `SHA256:<hex>`.
|
||||
|
||||
The `rustls-pki-types` crate (already a dependency) provides
|
||||
`CertificateDer`. The `rustls` crate's webpki or a manual DER parse of the
|
||||
SPKI's `SubjectPublicKeyInfo` → `subjectPublicKey` field can extract the raw
|
||||
key. A minimal DER parser for the SPKI structure (AlgorithmIdentifier +
|
||||
subjectPublicKey) is sufficient — the structure is small and well-defined.
|
||||
|
||||
### Test migration
|
||||
|
||||
The existing endpoint.rs tests expect `SHA256:` for all fingerprints. Tests
|
||||
with Ed25519 raw keys must migrate to expect `ed25519:`. Tests with X.509 certs
|
||||
stay `SHA256:`. Add a test that the same Ed25519 key produces the same
|
||||
fingerprint via both the quinn SPKI-extraction path and the iroh NodeId path
|
||||
(if testable without a live iroh connection, test the format function directly).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `fingerprint_from_cert_der` (or replacement) distinguishes RFC 7250 raw key SPKI from X.509 cert
|
||||
- [ ] Ed25519 raw key (SPKI) → `ed25519:<lowercase hex of 32-byte pub key>`
|
||||
- [ ] X.509 cert → `SHA256:<hex of SHA-256(cert_der)>` (unchanged)
|
||||
- [ ] iroh path already produces `ed25519:<hex>` (unchanged — verify)
|
||||
- [ ] Same Ed25519 key produces same fingerprint via quinn and iroh paths
|
||||
- [ ] No-client-cert case still produces `tls_client_fingerprint: None` (no regression)
|
||||
- [ ] Unit test: Ed25519 raw key SPKI → `ed25519:<hex>` format
|
||||
- [ ] Unit test: X.509 cert → `SHA256:<hex>` format (unchanged)
|
||||
- [ ] Unit test: fingerprint is lowercase hex
|
||||
- [ ] Unit test: 32-byte pub key extracted correctly (not the DER wrapper)
|
||||
- [ ] Existing endpoint.rs fingerprint tests migrated (Ed25519 → `ed25519:`, X.509 → `SHA256:`)
|
||||
- [ ] `cargo test -p alknet-core` succeeds
|
||||
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/core/auth.md — Fingerprint string format table
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030 §6 (normalization rationale)
|
||||
- docs/architecture/decisions/027-tls-identity-redesign-acme-rawkey-decoupling.md — ADR-027 (RawKey model)
|
||||
|
||||
## Notes
|
||||
|
||||
> The normalization is load-bearing for the peer graph: a peer that connects
|
||||
> via quinn direct and via iroh must have the same fingerprint in
|
||||
> `PeerEntry.fingerprints` — one entry, both transports. Without this, the same
|
||||
> key produces `ed25519:abc...` on iroh and `SHA256:def...` on quinn, breaking
|
||||
> the ADR-030 resolution path. The X.509 path stays `SHA256:<hex of DER>`
|
||||
> because X.509 certs don't have a "raw public key" form. This also simplifies
|
||||
> the coming WebTransport relay work (proxied Ed25519 identity is the same
|
||||
> `ed25519:<hex>` whether direct or proxied).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
98
tasks/core/identity-store-trait.md
Normal file
98
tasks/core/identity-store-trait.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
id: core/identity-store-trait
|
||||
name: Add IdentityStore async write trait extending IdentityProvider (ADR-035)
|
||||
status: pending
|
||||
depends_on: [core/peer-entry-model]
|
||||
scope: single
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Add the `IdentityStore` async write trait for peer management, extending the
|
||||
read-only `IdentityProvider` trait. Per ADR-035 §2.
|
||||
|
||||
`IdentityProvider` is read-only today and stays read-only — it is the hot-path
|
||||
trait called on every incoming connection (sync, no `.await`). Peer mutations
|
||||
(add/update/remove a `PeerEntry`) go through this separate async trait.
|
||||
|
||||
### IdentityStore trait
|
||||
|
||||
```rust
|
||||
/// Write trait — management path, async (ADR-035). ConfigIdentityProvider
|
||||
/// does NOT implement this (config reload is its write path — see below).
|
||||
/// SqliteIdentityProvider does: writes hit SQLite, emit honker NOTIFY,
|
||||
/// and the local LISTEN refreshes the in-memory read index.
|
||||
#[async_trait]
|
||||
pub trait IdentityStore: IdentityProvider {
|
||||
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError>;
|
||||
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError>;
|
||||
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError>;
|
||||
}
|
||||
```
|
||||
|
||||
- `put_peer` — insert or replace a `PeerEntry` (upsert by `peer_id`).
|
||||
- `update_peer` — update an existing `PeerEntry` (error if `peer_id` not found;
|
||||
for upsert semantics use `put_peer`).
|
||||
- `remove_peer` — delete a `PeerEntry` by `peer_id`.
|
||||
|
||||
### Why a separate trait, not async methods on IdentityProvider
|
||||
|
||||
- The hot-path read trait is consumed by the accept loop and every handler —
|
||||
those call sites are sync and must not gain `.await`. If `put_peer` were on
|
||||
`IdentityProvider`, every consumer would see the async method even though
|
||||
only the management path calls it. A separate `IdentityStore: IdentityProvider`
|
||||
supertrait keeps the read surface lean and makes the write surface opt-in.
|
||||
- `ConfigIdentityProvider` does **not** implement `IdentityStore`. Its write
|
||||
path is config reload (`ConfigReloadHandle::reload`), not a method call. This
|
||||
preserves the config-is-source-of-truth model. Implementing `IdentityStore`
|
||||
> for `ConfigIdentityProvider` "for symmetry" would violate that model — the
|
||||
> constraint is the absence of a backend, not a type-system constraint.
|
||||
|
||||
### ConfigIdentityProvider posture
|
||||
|
||||
`ConfigIdentityProvider` deliberately does NOT implement `IdentityStore`. This
|
||||
task does not change `ConfigIdentityProvider` — it only adds the trait. The
|
||||
trait is defined for future adapters (`SqliteIdentityProvider` in
|
||||
`alknet-store-sqlite`) to implement. `StoreError` is already defined by
|
||||
`core/credential-store-trait`.
|
||||
|
||||
### Module placement
|
||||
|
||||
Add `IdentityStore` alongside `IdentityProvider` in `alknet-core/src/auth.rs`
|
||||
(or a new `store` module if `CredentialStore` landed there). Re-export from
|
||||
`lib.rs`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `IdentityStore` trait with `put_peer`, `update_peer`, `remove_peer` (all async)
|
||||
- [ ] `IdentityStore: IdentityProvider` (supertrait)
|
||||
- [ ] `StoreError` used as the error type (from `core/credential-store-trait`)
|
||||
- [ ] `ConfigIdentityProvider` does NOT implement `IdentityStore`
|
||||
- [ ] `#[async_trait]` on the trait
|
||||
- [ ] No changes to `IdentityProvider` trait (stays read-only, sync)
|
||||
- [ ] Unit test: a mock/test impl of `IdentityStore` compiles and works (verify the trait is implementable)
|
||||
- [ ] Unit test: `ConfigIdentityProvider` does not implement `IdentityStore` (compile-time or trait-bound assertion)
|
||||
- [ ] `cargo test -p alknet-core` succeeds
|
||||
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/core/auth.md — IdentityStore write trait, ConfigIdentityProvider posture
|
||||
- docs/architecture/decisions/035-concrete-persistence-adapter-shapes.md — ADR-035 §2 (the trait, read/write split rationale)
|
||||
- docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md — ADR-033 (the pattern)
|
||||
|
||||
## Notes
|
||||
|
||||
> Small task but locks the trait shape — a one-way door. The read/write split
|
||||
> keeps the hot path sync (no `.await` in the accept loop). `ConfigIdentityProvider`
|
||||
> not implementing `IdentityStore` is a design posture, not a type-system
|
||||
> constraint: it holds no backend, and its write path is config reload. A
|
||||
> deployment that wants method-call peer management wires the SQLite adapter
|
||||
> (a separate crate, not built in this sync).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
175
tasks/core/peer-entry-model.md
Normal file
175
tasks/core/peer-entry-model.md
Normal file
@@ -0,0 +1,175 @@
|
||||
---
|
||||
id: core/peer-entry-model
|
||||
name: Add PeerEntry struct and replace AuthPolicy.authorized_fingerprints with peers (ADR-030)
|
||||
status: pending
|
||||
depends_on: []
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Replace `AuthPolicy.authorized_fingerprints: HashSet<String>` with
|
||||
`AuthPolicy.peers: Vec<PeerEntry>`, per ADR-030. This is the foundational data
|
||||
change for the entire ADR-029/030 sync — every downstream task (core resolution
|
||||
logic, IdentityStore, call peer-keyed routing, fingerprint normalization) depends
|
||||
on this struct and the `AuthPolicy.peers` field.
|
||||
|
||||
This task adds the `PeerEntry` struct and the `AuthPolicy.peers` field, and
|
||||
migrates the `AuthPolicy` resolution methods to the new model. The
|
||||
`ConfigIdentityProvider` rewrite (the resolution-logic half) is a separate task
|
||||
(`core/config-identity-provider-peerentry`) so this task stays focused on the
|
||||
data model + `AuthPolicy` resolution methods.
|
||||
|
||||
### PeerEntry struct
|
||||
|
||||
```rust
|
||||
pub struct PeerEntry {
|
||||
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
|
||||
/// key rotation. This becomes Identity.id on resolution, regardless of
|
||||
/// which credential path resolved the identity.
|
||||
pub peer_id: String,
|
||||
|
||||
/// TLS fingerprints for this peer — one or more. A peer may have
|
||||
/// multiple keys (e.g., an Ed25519 raw key for P2P and an X.509 cert
|
||||
/// for domain-facing). Resolution matches against any entry.
|
||||
/// Format: "ed25519:<hex of 32-byte pub key>" for RFC 7250 raw keys
|
||||
/// (normalized across quinn and iroh — ADR-030 §6), "SHA256:<hex>" for
|
||||
/// X.509 certs (DER hash). Changes on key rotation.
|
||||
pub fingerprints: Vec<String>,
|
||||
|
||||
/// Optional: bearer-token authentication for this peer. A peer that
|
||||
/// also authenticates via auth_token (e.g., HTTP clients that can't
|
||||
/// do TLS client-auth) stores the SHA-256 hash of the token here.
|
||||
/// Resolution via resolve_from_token matches this field and returns
|
||||
/// the same Identity { id: peer_id, ... } as the fingerprint path.
|
||||
pub auth_token_hash: Option<String>,
|
||||
|
||||
/// Authorization scopes granted to this peer. Resolved into
|
||||
/// Identity.scopes.
|
||||
pub scopes: Vec<String>,
|
||||
|
||||
/// Named resource lists granted to this peer. Resolved into
|
||||
/// Identity.resources.
|
||||
pub resources: HashMap<String, Vec<String>>,
|
||||
|
||||
/// Human-readable display name for logs / UIs. Optional.
|
||||
pub display_name: Option<String>,
|
||||
|
||||
/// Whether this peer is authorized at all. false = recognized but
|
||||
/// disabled (revoked). Resolution returns None.
|
||||
pub enabled: bool,
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPolicy change
|
||||
|
||||
```rust
|
||||
pub struct AuthPolicy {
|
||||
/// Replaces authorized_fingerprints: HashSet<String>. Each entry maps
|
||||
/// a stable logical peer_id to its credential paths (fingerprints,
|
||||
/// optional auth_token_hash) + scopes + resources. The list is keyed
|
||||
/// by peer_id; resolution looks up by fingerprint OR auth_token.
|
||||
pub peers: Vec<PeerEntry>,
|
||||
|
||||
/// API keys for bearer-token auth where the token IS the identity
|
||||
/// (rotation = new identity). Unchanged by ADR-030.
|
||||
pub api_keys: Vec<ApiKeyEntry>,
|
||||
}
|
||||
```
|
||||
|
||||
### AuthPolicy resolution methods (new model)
|
||||
|
||||
`AuthPolicy::resolve_identity_from_fingerprint` and a new
|
||||
`resolve_identity_from_token` method resolve via `PeerEntry`:
|
||||
|
||||
```rust
|
||||
impl AuthPolicy {
|
||||
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||
self.peers.iter()
|
||||
.find(|p| p.enabled && p.fingerprints.iter().any(|f| f == fingerprint))
|
||||
.map(|p| Identity {
|
||||
id: p.peer_id.clone(),
|
||||
scopes: p.scopes.clone(),
|
||||
resources: p.resources.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn resolve_identity_from_token(&self, token: &str) -> Option<Identity> {
|
||||
let token_hash = sha256(token);
|
||||
self.peers.iter()
|
||||
.find(|p| p.enabled && p.auth_token_hash.as_deref() == Some(&token_hash))
|
||||
.map(|p| Identity {
|
||||
id: p.peer_id.clone(),
|
||||
scopes: p.scopes.clone(),
|
||||
resources: p.resources.clone(),
|
||||
})
|
||||
.or_else(|| self.resolve_api_key(token)) // fall through to ApiKeyEntry
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The key change: `Identity.id` is now the stable `peer_id`, **not** the
|
||||
fingerprint. Key rotation changes the fingerprint but not the `peer_id`, so ACL
|
||||
entries and routing references stay stable (ADR-030 §2-3).
|
||||
|
||||
`resolve_api_key` stays unchanged (the `ApiKeyEntry` path where the token IS the
|
||||
identity — `Identity.id = prefix`).
|
||||
|
||||
### Config validation
|
||||
|
||||
`PeerEntry.peer_id` is operator-chosen and unique within a config. Add a
|
||||
validation method or assertion that duplicate `peer_id` values in
|
||||
`AuthPolicy.peers` are a config error (ADR-030 Assumption 2).
|
||||
|
||||
### What this task does NOT do
|
||||
|
||||
- Does NOT rewrite `ConfigIdentityProvider` — that's
|
||||
`core/config-identity-provider-peerentry` (the `ConfigIdentityProvider` methods
|
||||
delegate to `AuthPolicy` resolution, so they keep working once `AuthPolicy`
|
||||
is updated, but the token-resolution path in `ConfigIdentityProvider` needs to
|
||||
call the new `resolve_identity_from_token` instead of only `resolve_api_key`).
|
||||
- Does NOT normalize quinn fingerprints to `ed25519:<hex>` — that's
|
||||
`core/fingerprint-normalization`.
|
||||
- Does NOT add `IdentityStore` or `CredentialStore` — those are separate tasks.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `PeerEntry` struct with all 7 fields (`peer_id`, `fingerprints`, `auth_token_hash`, `scopes`, `resources`, `display_name`, `enabled`)
|
||||
- [ ] `AuthPolicy.authorized_fingerprints` removed; replaced with `peers: Vec<PeerEntry>`
|
||||
- [ ] `AuthPolicy.api_keys` unchanged
|
||||
- [ ] `AuthPolicy::resolve_identity_from_fingerprint` resolves fingerprint → PeerEntry → `Identity { id: peer_id, ... }`
|
||||
- [ ] `AuthPolicy::resolve_identity_from_token` resolves token hash → PeerEntry → `Identity { id: peer_id, ... }`, falls through to `resolve_api_key`
|
||||
- [ ] `Identity.id` is the `peer_id` (stable), not the fingerprint
|
||||
- [ ] Disabled peers (`enabled: false`) return `None` from resolution
|
||||
- [ ] Duplicate `peer_id` validation (config error)
|
||||
- [ ] Unit test: fingerprint resolution via PeerEntry (known → Some with peer_id, unknown → None, disabled → None)
|
||||
- [ ] Unit test: token resolution via PeerEntry.auth_token_hash (matching → Some with peer_id, non-matching → fall through to ApiKeyEntry)
|
||||
- [ ] Unit test: multi-fingerprint PeerEntry (any fingerprint in the list resolves to the same peer_id)
|
||||
- [ ] Unit test: resources populated from PeerEntry.resources on both paths
|
||||
- [ ] Unit test: duplicate peer_id detected/rejected
|
||||
- [ ] `cargo test -p alknet-core` succeeds
|
||||
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/core/config.md — PeerEntry, AuthPolicy.peers
|
||||
- docs/architecture/crates/core/auth.md — Identity.id = peer_id, multi-credential resolution
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md — ADR-030
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the foundational data change for the ADR-029/030 sync. The key
|
||||
> semantic shift: `Identity.id` changes from the fingerprint (crypto material)
|
||||
> to the `peer_id` (stable logical id). Key rotation changes the fingerprint
|
||||
> but not the `peer_id`, so ACL entries and `PeerRef::Specific(peer_id)`
|
||||
> references stay stable. `ConfigIdentityProvider` keeps working (it delegates
|
||||
> to `AuthPolicy`), but the token path needs the new
|
||||
> `resolve_identity_from_token` — that's the separate
|
||||
> `core/config-identity-provider-peerentry` task.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
123
tasks/core/review-core-sync.md
Normal file
123
tasks/core/review-core-sync.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
id: core/review-core-sync
|
||||
name: Review alknet-core ADR-029/030/031/034/035 sync for spec conformance
|
||||
status: pending
|
||||
depends_on: [core/credential-store-trait, core/identity-store-trait, core/config-identity-provider-peerentry, core/fingerprint-normalization, core/three-remote-roles-docs]
|
||||
scope: moderate
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review the alknet-core implementation after the ADR-029/030/031/034/035 sync
|
||||
for spec conformance, pattern consistency, and correctness. This is the quality
|
||||
checkpoint at the end of the core phase — before alknet-call (which depends on
|
||||
the new `Identity.id = peer_id` semantics) begins its sync.
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **PeerEntry / AuthPolicy conformance** (config.md, auth.md, ADR-030):
|
||||
- `PeerEntry` has all 7 fields (peer_id, fingerprints, auth_token_hash, scopes, resources, display_name, enabled)
|
||||
- `AuthPolicy.authorized_fingerprints` removed; `peers: Vec<PeerEntry>` in place
|
||||
- `AuthPolicy.api_keys` unchanged
|
||||
- `resolve_identity_from_fingerprint` resolves fingerprint → PeerEntry → `Identity { id: peer_id }`
|
||||
- `resolve_identity_from_token` resolves auth_token_hash → PeerEntry → falls through to ApiKeyEntry
|
||||
- `Identity.id` is the stable `peer_id`, not the fingerprint
|
||||
- Disabled peers (`enabled: false`) return None
|
||||
- Duplicate `peer_id` validation
|
||||
|
||||
2. **ConfigIdentityProvider conformance** (auth.md, ADR-030):
|
||||
- `resolve_from_fingerprint` delegates to `AuthPolicy::resolve_identity_from_fingerprint`
|
||||
- `resolve_from_token` delegates to `AuthPolicy::resolve_identity_from_token` (PeerEntry first, ApiKeyEntry fall-through)
|
||||
- Reads from ArcSwap on every call (hot-reloadable — unchanged)
|
||||
- Does NOT implement `IdentityStore`
|
||||
|
||||
3. **CredentialStore conformance** (auth.md, ADR-031/035):
|
||||
- `CredentialStore` trait with sync `get`, async `put`/`delete`
|
||||
- `InMemoryCredentialStore` default adapter (async put/delete with no .await points)
|
||||
- `EncryptedData` core mirror (4 fields, serializable, no vault dep)
|
||||
- `StoreError` enum (`#[non_exhaustive]`, thiserror, 3 variants)
|
||||
- No `list` method
|
||||
- No vault dependency added to core
|
||||
|
||||
4. **IdentityStore conformance** (auth.md, ADR-035):
|
||||
- `IdentityStore: IdentityProvider` supertrait
|
||||
- `put_peer`/`update_peer`/`remove_peer` all async
|
||||
- `ConfigIdentityProvider` does NOT implement it
|
||||
- `IdentityProvider` trait unchanged (read-only, sync)
|
||||
|
||||
5. **Fingerprint normalization conformance** (auth.md, ADR-030 §6):
|
||||
- Ed25519 raw key (SPKI) → `ed25519:<lowercase hex of 32 bytes>`
|
||||
- X.509 cert → `SHA256:<hex of DER>` (unchanged)
|
||||
- iroh path → `ed25519:<hex>` (unchanged)
|
||||
- Same key, same fingerprint across quinn and iroh
|
||||
- No-client-cert → None (no regression)
|
||||
|
||||
6. **Three remote roles documentation** (ADR-034):
|
||||
- `auth.rs` comments document the three roles and verifier selection rule
|
||||
- `endpoint.rs` comments clarify server-side vs client-side verifier concerns
|
||||
|
||||
7. **Pattern consistency**:
|
||||
- ArcSwap used consistently for DynamicConfig (unchanged)
|
||||
- Repo/adapter pattern consistent (trait + in-memory default, no backend dep in core)
|
||||
- No russh dependency in core (unchanged)
|
||||
- Feature flags (quinn, iroh) gate transport code correctly
|
||||
|
||||
8. **Security constraints**:
|
||||
- `PeerEntry.enabled: false` → resolution returns None (revoked peers)
|
||||
- `StoreError` is `#[non_exhaustive]`
|
||||
- `EncryptedData` carries no plaintext (encrypted blob only)
|
||||
- No env vars in the credential path (ADR-014 invariant preserved)
|
||||
|
||||
9. **Test coverage**:
|
||||
- PeerEntry resolution (fingerprint, auth_token_hash, ApiKeyEntry fall-through)
|
||||
- Multi-fingerprint PeerEntry
|
||||
- Disabled peer → None
|
||||
- Duplicate peer_id validation
|
||||
- CredentialStore get/put/delete round-trip
|
||||
- EncryptedData serialization round-trip
|
||||
- Fingerprint normalization (Ed25519 → ed25519:, X.509 → SHA256:)
|
||||
- Config reload with PeerEntry model
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All PeerEntry / AuthPolicy types match config.md and auth.md
|
||||
- [ ] ConfigIdentityProvider resolution matches auth.md (PeerEntry multi-credential path)
|
||||
- [ ] CredentialStore trait + InMemoryCredentialStore + EncryptedData + StoreError match ADR-031/035
|
||||
- [ ] IdentityStore trait matches ADR-035 (read/write split, ConfigIdentityProvider posture)
|
||||
- [ ] Fingerprint normalization matches ADR-030 §6 (ed25519: for raw keys, SHA256: for X.509)
|
||||
- [ ] Three remote roles documented in source comments (ADR-034)
|
||||
- [ ] No `authorized_fingerprints` references remain
|
||||
- [ ] No `remote_safe`/`trusted_peer` references in core (those are call-side)
|
||||
- [ ] ArcSwap pattern consistent
|
||||
- [ ] No russh dependency, no vault dependency in core
|
||||
- [ ] Test coverage adequate for all new functionality
|
||||
- [ ] `cargo fmt --check -p alknet-core` passes
|
||||
- [ ] `cargo clippy -p alknet-core` passes with no warnings
|
||||
- [ ] All tests pass
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/core/README.md
|
||||
- docs/architecture/crates/core/auth.md
|
||||
- docs/architecture/crates/core/config.md
|
||||
- docs/architecture/decisions/030-peerentry-and-identity-id-decoupling.md
|
||||
- docs/architecture/decisions/031-credentialstore-repo-trait.md
|
||||
- docs/architecture/decisions/033-storage-boundary-and-repo-adapter-pattern.md
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md
|
||||
- docs/architecture/decisions/035-concrete-persistence-adapter-shapes.md
|
||||
|
||||
## Notes
|
||||
|
||||
> This review verifies core is spec-conformant after the ADR-029/030/031/034/035
|
||||
> sync before alknet-call begins its sync. alknet-call depends heavily on the new
|
||||
> `Identity.id = peer_id` semantics (PeerCompositeEnv keys, PeerRef::Specific
|
||||
> routing, AccessControl-based peer authorization) — any issues here propagate
|
||||
> to call. If deviations are found, document and fix before proceeding to the
|
||||
> call phase.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
85
tasks/core/three-remote-roles-docs.md
Normal file
85
tasks/core/three-remote-roles-docs.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
id: core/three-remote-roles-docs
|
||||
name: Document the three remote roles and client-side verifier selection rule (ADR-034)
|
||||
status: pending
|
||||
depends_on: [core/peer-entry-model]
|
||||
scope: single
|
||||
risk: trivial
|
||||
impact: isolated
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Update the in-code comments and doc comments in `alknet-core/src/auth.rs` and
|
||||
`alknet-core/src/endpoint.rs` to document the three remote roles (ADR-034) and
|
||||
the client-side verifier selection rule. This is a documentation/comment task —
|
||||
the server-side endpoint code is unchanged; the client-side verifier selection
|
||||
is a call-side task (`call/call-client-verifier-selection`).
|
||||
|
||||
### Three remote roles (ADR-034 §1)
|
||||
|
||||
| Role | Identity | alknet peer? | `PeerEntry` on local side? |
|
||||
|------|----------|--------------|----------------------------|
|
||||
| **Public X.509 endpoint** | Domain + CA-issued X.509 | No (local node is a client) | No |
|
||||
| **Transport relay** (iroh's DERP-equivalent) | iroh `NodeId` (Ed25519) | No (infrastructure) | No |
|
||||
| **Hub / hosting node** | Ed25519 raw key **and/or** X.509 | Yes (full peer) | Yes |
|
||||
|
||||
`PeerEntry` (and the `PeerId` it resolves to) is the model for peers in the
|
||||
call-protocol peer graph (ADR-029). A pure-client connection to a public X.509
|
||||
endpoint is **not** in that graph on the client side: no `PeerEntry`, no
|
||||
`PeerId`, no `PeerRef::Specific` routing.
|
||||
|
||||
### Client-side verifier selection rule (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>`) |
|
||||
|
||||
### What to update
|
||||
|
||||
1. **`auth.rs` doc comments**: add the three-roles table and the verifier
|
||||
selection rule to the `Identity` / `PeerEntry` section doc comments,
|
||||
referencing ADR-034. The `auth.md` spec already has this; mirror it in the
|
||||
source comments.
|
||||
|
||||
2. **`endpoint.rs` doc comments**: clarify that the server-side
|
||||
`AcceptAnyCertVerifier` is "request-but-don't-require" mode for fingerprint
|
||||
extraction (unchanged), and that the **client-side** verifier selection is
|
||||
by `PeerEntry` presence (ADR-034 §3) — note that this is a `CallClient`
|
||||
concern, not an endpoint concern.
|
||||
|
||||
3. **No code changes** — this is comments/docs only. The server-side endpoint
|
||||
is unchanged by ADR-034. The client-side verifier is
|
||||
`call/call-client-verifier-selection`.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `auth.rs` doc comments document the three remote roles (ADR-034 §1)
|
||||
- [ ] `auth.rs` doc comments document the client-side verifier selection rule (ADR-034 §3)
|
||||
- [ ] `endpoint.rs` doc comments clarify server-side vs client-side verifier concerns
|
||||
- [ ] Comments reference ADR-034 and `auth.md`
|
||||
- [ ] No code changes (comments only)
|
||||
- [ ] `cargo test -p alknet-core` succeeds (no regressions from comment changes)
|
||||
- [ ] `cargo clippy -p alknet-core` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/core/auth.md — Three Remote Roles, Client-side verifier selection
|
||||
- docs/architecture/decisions/034-outgoing-only-x509-and-three-peer-roles.md — ADR-034
|
||||
|
||||
## Notes
|
||||
|
||||
> Documentation-only task to ensure the three-roles model and verifier selection
|
||||
> rule are visible in the source, not just the specs. The server-side endpoint
|
||||
> is unchanged by ADR-034; the client-side verifier selection is implemented in
|
||||
> `call/call-client-verifier-selection`. Folding this into a standalone task
|
||||
> keeps the fingerprint-normalization and resolution-logic tasks focused on
|
||||
> code, not prose.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in New Issue
Block a user