docs(arch): ADR-030..033 — repo/adapter pattern, PeerEntry, CredentialStore, forwarded-for
Land the storage and auth strategy research (findings.md) as four accepted ADRs and amend the core and call specs to match: - ADR-030: PeerEntry and Identity.id decoupling. Replaces authorized_fingerprints with peers: Vec<PeerEntry>; Identity.id becomes the stable peer_id, decoupled from the rotating fingerprint. Supersedes ADR-029 Assumption 1's UUID source (one-way door preserved, source changes). Resolves OQ-33 and the storage-boundary half of OQ-34. Records the API-key asymmetry as deliberate (OQ-35). - ADR-031: CredentialStore repo trait + InMemoryCredentialStore default adapter in core. Second repo trait alongside IdentityProvider. Vault encrypts; the store persists the EncryptedData blob; assembly layer loads into Capabilities. EncryptedData core mirror includes salt for wire-format compat. - ADR-032: Forwarded-for identity. forwarded_for field on call.requested and OperationContext — metadata only, never read by AccessControl::check (enforced structurally via the check signature). The from_call handler populates it. Wire-format one-way door, folded into the ADR-029 migration window. - ADR-033: Storage boundary and repo/adapter pattern. Core defines repo traits + in-memory defaults; persistence adapters are separate crates; assembly layer wires. Resolves OQ-34. Concrete adapter shapes deferred for exploration (OQ-36). Amends auth.md, config.md, operation-registry.md, client-and-adapters.md, open-questions.md, README.md, crates/core/README.md. Marks ADR-029 Accepted (Assumption 1 carries the ADR-030 superseded note). Marks the research findings doc reviewed.
This commit is contained in:
@@ -173,7 +173,8 @@ pub struct PeerCompositeEnv {
|
||||
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; // logical id (UUID v1), NOT Identity.id — see OQ-33
|
||||
pub type PeerId = String; // = Identity.id from IdentityProvider resolution
|
||||
// = PeerEntry.peer_id (stable, not crypto material — ADR-030)
|
||||
```
|
||||
|
||||
`OperationEnv` gains a peer-routing method with a `PeerRef` selector
|
||||
@@ -307,6 +308,16 @@ they do. `from_call` means "I trust the remote node as much as my own
|
||||
handlers." The abort cascade (ADR-016) crosses the node boundary transparently
|
||||
through the forwarding handler's `parent_request_id`.
|
||||
|
||||
**Forwarded-for identity** (ADR-032): the `from_call` forwarding handler
|
||||
populates `forwarded_for` on the `call.requested` payload it constructs to
|
||||
send to the spoke. The hub reads its own `OperationContext.identity` (the
|
||||
end user it authenticated) and sets `forwarded_for` to that identity when
|
||||
forwarding. 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,
|
||||
not the end user). The hub may set `forwarded_for: None` if it doesn't
|
||||
want to disclose the originator. See [ADR-032](../../decisions/032-forwarded-for-identity.md).
|
||||
|
||||
### from_jsonschema
|
||||
|
||||
Schema-only registration: produces `HandlerRegistration` bundles with no
|
||||
@@ -587,6 +598,9 @@ Based on the gap analysis and the downstream unblock chain:
|
||||
|----------|-----|---------|
|
||||
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `CallClient` opens connections; `from_call` imports remote ops; connection direction independent of call direction; trait is async; adapters produce `HandlerRegistration` bundles |
|
||||
| Peer-graph routing model (DC-1, supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via existing `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` |
|
||||
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `PeerId` source changes from UUID to `Identity.id` (= `PeerEntry.peer_id`, stable across key rotation); `Identity.id` decoupled from crypto material on the fingerprint path |
|
||||
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `call.requested` and `OperationContext`; the `from_call` handler populates it; metadata only, never used by `AccessControl::check` |
|
||||
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
||||
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~Default-deny; `remote_safe: bool`; trusted-peer opt-in~~ — superseded by ADR-029 (flat-namespace single-peer model couldn't express head→N-workers; parallel auth system duplicated existing `AccessControl`) |
|
||||
| Secret material flow and capability injection | [ADR-014](../../decisions/014-secret-material-flow-and-capability-injection.md) | The no-env-vars invariant's foundation; capabilities injected at assembly layer |
|
||||
| Handler registration, provenance, and composition authority | [ADR-022](../../decisions/022-handler-registration-provenance-and-composition-authority.md) | The registration bundle adapters produce; `composition_authority: None` for leaves |
|
||||
@@ -631,12 +645,23 @@ See [open-questions.md](../../open-questions.md) for full details.
|
||||
- **OQ-32** (open): Multi-hop federation — v1 is one-hop; the peer-keyed
|
||||
overlay model extends to multi-hop without redesign; petgraph is the
|
||||
candidate if path-finding becomes real (ADR-029 §3.7).
|
||||
- **OQ-33** (resolved): `PeerId` is a logical id (connection-assigned UUID),
|
||||
not `Identity.id` — decoupling from crypto material keeps the door open for
|
||||
key-rotation-safe ACLs. See OQ-33 in open-questions.md.
|
||||
- **OQ-34** (open): Persistent peer registry — the storage dimension OQ-33
|
||||
surfaced; not a v1 blocker (UUID works), tracked so the no-DB posture's
|
||||
limit is deliberate. See OQ-34 in open-questions.md.
|
||||
- **OQ-33** (resolved by ADR-030): `PeerId` is a logical id. Source is
|
||||
`Identity.id` from `IdentityProvider` resolution (= `PeerEntry.peer_id`,
|
||||
stable across key rotation), not a connection-assigned UUID. The UUID
|
||||
workaround is removed. See OQ-33 in open-questions.md.
|
||||
- **OQ-34** (resolved by ADR-030 + ADR-033): Persistent peer registry —
|
||||
the storage boundary is `core trait + in-memory default` (config-backed
|
||||
`ConfigIdentityProvider` now; persistence adapters additive in separate
|
||||
crates). See OQ-34 in open-questions.md.
|
||||
- **OQ-35** (recorded by ADR-030): API key identity vs peer identity — the
|
||||
asymmetry between the fingerprint path (gets `PeerEntry` id-decoupling)
|
||||
and the API-key path (doesn't) is deliberate. See OQ-35 in
|
||||
open-questions.md.
|
||||
- **OQ-36** (tracked by ADR-033): Concrete adapter shapes — the repo/adapter
|
||||
pattern is committed (core trait + in-memory default; persistence adapters
|
||||
are separate crates); the concrete adapter shapes (table schemas, backend
|
||||
choice, indexing) are deferred for exploration. See OQ-36 in
|
||||
open-questions.md.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-23
|
||||
last_updated: 2026-06-27
|
||||
---
|
||||
|
||||
# Operation Registry
|
||||
@@ -113,6 +113,7 @@ pub struct OperationContext {
|
||||
pub parent_request_id: Option<String>,
|
||||
pub identity: Option<Identity>, // Caller's identity (inbound — who invoked me)
|
||||
pub handler_identity: Option<CompositionAuthority>, // Handler's composition authority (ADR-022)
|
||||
pub forwarded_for: Option<Identity>, // Original caller when forwarded (ADR-032, metadata only — NOT used by AccessControl::check)
|
||||
pub capabilities: Capabilities,
|
||||
pub metadata: HashMap<String, Value>,
|
||||
/// Reachability set — the operations this handler may compose.
|
||||
@@ -176,6 +177,7 @@ impl OperationContext {
|
||||
- `parent_request_id`: Set when this call was initiated by another operation (via `OperationEnv`). Records the agency chain — the call tree is the principal→agent chain (ADR-015)
|
||||
- `identity`: The authenticated caller (from `IdentityProvider`) — inbound auth (who is calling me). For external calls, this is who sent the `call.requested`. For internal calls, this is the parent handler's `handler_identity` (propagated through `OperationEnv::invoke()`)
|
||||
- `handler_identity`: The composition authority of the handler processing this call. `None` for leaves (`FromOpenAPI`, `FromMCP`, `FromCall`) — they don't compose. `Some(...)` for `Local` and `Session` ops that can compose children. For internal calls (`internal: true`), the ACL check runs against this authority (ADR-015, ADR-022). This is NOT a peer `Identity` — it's a declared authority bundle set at registration by the assembly layer
|
||||
- `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 the direct caller's `identity`. Handlers may read it for logging, auditing, per-user rate limiting, or application context. Populated from `call.requested.forwarded_for` by the dispatch path; set to `None` for composed children (wire-ingress only). The forwarder's claim, not a verified identity — a malicious hub can lie (same property as HTTP `X-Forwarded-For`). See ADR-032.
|
||||
- `capabilities`: Outbound credentials the handler may use (decrypted API keys, scoped vault access) — see [Capability Injection](#capability-injection) below
|
||||
- `metadata`: Request-scoped context (tracing IDs, connection info). **Must not hold secret material** — see ADR-014. **Does not propagate through `OperationEnv::invoke()`** — nested calls get fresh metadata. The tracing link between parent and child is `parent_request_id`, not metadata propagation. Anything a handler needs to pass to a child goes in the call `input`.
|
||||
- `scoped_env`: The reachability set — the operations this handler may compose. Populated from the registration bundle's `scoped_env` (ADR-022). The reachability check in `OperationEnv::invoke()` consults `scoped_env.allows(&name)`. This is *data* (a `ScopedOperationEnv` struct), not a dispatch trait. `None`/empty for leaves.
|
||||
@@ -420,6 +422,9 @@ impl OperationEnv for LocalOperationEnv {
|
||||
// None for leaves — they don't compose, so this is never used
|
||||
// for ACL on a grandchild.
|
||||
handler_identity: registration.composition_authority.clone(),
|
||||
// Composed children do not inherit forwarded_for — it's a
|
||||
// wire-ingress field, not a composition-ingress field (ADR-032).
|
||||
forwarded_for: None,
|
||||
capabilities: parent.capabilities.clone(), // Inherit caller's capabilities
|
||||
metadata: HashMap::new(), // Fresh — does NOT propagate parent metadata (ADR-014)
|
||||
abort_policy: policy, // Explicit policy (from invoke() default or invoke_with_policy)
|
||||
@@ -666,6 +671,7 @@ The `Capabilities` type holds non-serializable, zeroized secret material. It doe
|
||||
| Operation error schemas | [ADR-023](../../decisions/023-operation-error-schemas.md) | Operations declare domain errors; `call.error` carries typed `details`; adapter fidelity for `from_openapi`/`to_openapi` |
|
||||
| Call protocol client and adapter contract | [ADR-017](../../decisions/017-call-protocol-client-and-adapter-contract.md) | `from_call`/`from_jsonschema`/`OperationAdapter` produce `HandlerRegistration` bundles; adapter-registered ops are `Internal` leaves. Surface specced in [client-and-adapters.md](client-and-adapters.md) |
|
||||
| Peer-graph routing model (supersedes ADR-028) | [ADR-029](../../decisions/029-peer-graph-routing-model.md) | Peer-keyed overlays + `PeerRef` routing; peer authorization via `AccessControl::check(peer_identity)`; retires `remote_safe`/`trusted_peer` (the field this doc's `HandlerRegistration` previously gained) |
|
||||
| Forwarded-for identity | [ADR-032](../../decisions/032-forwarded-for-identity.md) | `forwarded_for` field on `OperationContext` and `call.requested`; metadata only — `AccessControl::check` never reads it; the `from_call` handler populates it |
|
||||
| ~~Peer-scoped registry filtering~~ (superseded) | ~~[ADR-028](../../decisions/028-callclient-peer-scoped-registry-filtering.md)~~ | ~~`remote_safe` marking on `HandlerRegistration`~~ — superseded by ADR-029 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22-21
|
||||
last_updated: 2026-06-27
|
||||
---
|
||||
|
||||
# alknet-core
|
||||
@@ -13,8 +13,8 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
|----------|--------|-------------|
|
||||
| [core-types.md](core-types.md) | draft | ProtocolHandler trait, HandlerError, Connection, BiStream, StreamError |
|
||||
| [endpoint.md](endpoint.md) | draft | ALPN router, HandlerRegistry, accept loop, graceful shutdown |
|
||||
| [auth.md](auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow |
|
||||
| [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle |
|
||||
| [auth.md](auth.md) | draft | AuthContext, Identity, IdentityProvider, AuthToken, resolution flow, PeerEntry, CredentialStore |
|
||||
| [config.md](config.md) | draft | StaticConfig, DynamicConfig, ArcSwap, ConfigReloadHandle, AuthPolicy.peers |
|
||||
|
||||
## Applicable ADRs
|
||||
|
||||
@@ -30,6 +30,9 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
| [010](../../decisions/010-alpn-router-and-endpoint.md) | ALPN Router and Endpoint | Endpoint, HandlerRegistry, accept loop |
|
||||
| [011](../../decisions/011-authcontext-structure.md) | AuthContext Structure | AuthContext fields and resolution flow |
|
||||
| [015](../../decisions/015-privilege-model-and-authority-context.md) | Privilege Model and Authority Context | Per-request identity on OperationContext; admin scope for config reload |
|
||||
| [030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | PeerEntry and Identity.id Decoupling | `authorized_fingerprints` → `peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable) |
|
||||
| [031](../../decisions/031-credentialstore-repo-trait.md) | CredentialStore Repo Trait | Second repo trait in core; `InMemoryCredentialStore` default adapter |
|
||||
| [033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Storage Boundary and Repo/Adapter Pattern | Core defines traits + in-memory defaults; persistence adapters are separate crates |
|
||||
|
||||
## Relevant Open Questions
|
||||
|
||||
@@ -38,6 +41,10 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
| OQ-04 | Dynamic handler registration | resolved (start static) | HandlerRegistry is immutable at startup |
|
||||
| OQ-05 | Multi-connectivity endpoint | resolved (quinn + iroh) | AlknetEndpoint supports both, both feature-gated |
|
||||
| OQ-11 | Handler-level auth resolution observability | resolved | Handlers store resolved identity on Connection; two identity scopes (connection-level for observability, per-request for ACL) |
|
||||
| OQ-33 | PeerId — logical id vs crypto identity | resolved by ADR-030 | `PeerId` = `Identity.id` = `PeerEntry.peer_id` (stable across key rotation) |
|
||||
| OQ-34 | Persistent peer registry (storage boundary) | resolved by ADR-030+031+033 | Core defines repo traits + in-memory defaults; persistence adapters are separate crates |
|
||||
| OQ-35 | API key identity vs peer identity | resolved (recorded by ADR-030) | The asymmetry between fingerprint and API-key paths is deliberate |
|
||||
| OQ-36 | Concrete adapter shapes | open (deferred for exploration) | The repo/adapter pattern is committed (ADR-033); concrete adapter shapes are not |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-21
|
||||
last_updated: 2026-06-27
|
||||
---
|
||||
|
||||
# Authentication
|
||||
@@ -91,21 +91,41 @@ The authenticated peer identity. Carries authorization information.
|
||||
```rust
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Identity {
|
||||
/// Unique identifier string. Fingerprint, key prefix, or principal name.
|
||||
/// Stable logical identifier. On the fingerprint path, this is the
|
||||
/// `PeerEntry.peer_id` (stable across key rotation, ADR-030). On the
|
||||
/// API-key path, this is the key prefix (changes with the key — see
|
||||
/// "API keys vs peer entries" below). On the composition path, this
|
||||
/// is the `CompositionAuthority` label (ADR-022).
|
||||
pub id: String,
|
||||
|
||||
/// Authorization scopes. e.g., ["relay:connect", "secrets:derive"]
|
||||
pub scopes: Vec<String>,
|
||||
|
||||
/// Named resource lists. e.g., {"service": ["gitea", "registry"]}
|
||||
/// Populated from `PeerEntry.resources` on the fingerprint path
|
||||
/// (ADR-030), from `CompositionAuthority.resources` on the
|
||||
/// composition path (ADR-022), and empty on the API-key path.
|
||||
pub resources: HashMap<String, Vec<String>>,
|
||||
}
|
||||
```
|
||||
|
||||
This is the same structure as the reference implementation (`alknet-main/crates/alknet-core/src/auth/identity.rs`), minus the russh dependency. The `id` field is ALPN-agnostic:
|
||||
- SSH key auth: `"SHA256:abc123..."` (key fingerprint)
|
||||
- API key auth: `"alk_test"` (key prefix)
|
||||
- Certificate auth: `"username"` (principal name)
|
||||
- SSH key / TLS cert auth (fingerprint path): the `PeerEntry.peer_id` (ADR-030) — a stable logical name like `"worker-a"`, **not** the fingerprint. The fingerprint is the *credential*; the `peer_id` is the *identity*. Decoupling them means key rotation changes the credential but not the identity, so ACL entries and routing references stay stable.
|
||||
- API key auth: `"alk_test"` (key prefix) — the prefix IS the identity; rotation = new identity (see "API keys vs peer entries" below).
|
||||
- Composition path: the `CompositionAuthority` label (ADR-022) — e.g., `"agent-chat"`.
|
||||
|
||||
### API keys vs peer entries
|
||||
|
||||
The fingerprint and API-key auth paths have different identity semantics, by design (ADR-030):
|
||||
|
||||
| Axis | Fingerprint (PeerEntry) | API key (ApiKeyEntry) |
|
||||
|------|-------------------------|------------------------|
|
||||
| Identity source | TLS handshake / SSH key | Bearer token in protocol frame |
|
||||
| Key rotation | Same logical node, new material | New identity (revocation = new key) |
|
||||
| `Identity.id` | `peer_id` (stable across rotation) | `prefix` (changes with the key) |
|
||||
| `Identity.resources` | Populated from `PeerEntry.resources` | Empty (resources are composition-only) |
|
||||
|
||||
An API key's prefix IS the identity — rotating the key means a new prefix and a new identity, by design (revocation is the rotation mechanism for API keys). Decoupling the API key identity from the prefix would solve a problem API keys don't have: they're bearer tokens, not node identities. The fingerprint path gets the `PeerEntry` treatment because node identity must survive key rotation; the API-key path doesn't because bearer-token identity IS the token. The asymmetry is deliberate, not an oversight — see ADR-030 §"API keys".
|
||||
|
||||
## AuthToken
|
||||
|
||||
@@ -149,30 +169,32 @@ pub struct ConfigIdentityProvider {
|
||||
The "Config" prefix indicates that identities are resolved from configuration (as opposed to a database or external service). This reads from `ArcSwap<DynamicConfig>`, which is hot-reloadable — not from `StaticConfig`. An alternative name would be `DynamicConfigIdentityProvider` to make this clearer, but `ConfigIdentityProvider` is consistent with the reference implementation and the naming is unlikely to cause confusion in practice.
|
||||
|
||||
How it resolves:
|
||||
- **Fingerprint**: Look up in `DynamicConfig::auth::authorized_keys_fingerprints`. If found, return `Identity { id: fingerprint, scopes: ["relay:connect"], resources: {} }`.
|
||||
- **Token**: Parse as UTF-8. If it starts with `alk_`, look up in `DynamicConfig::auth::api_keys` by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: {} }`.
|
||||
- **Fingerprint**: Look up in `DynamicConfig::auth.peers` for the matching `PeerEntry` (by `fingerprint`). If found and `enabled`, return `Identity { id: peer.peer_id, scopes: peer.scopes, resources: peer.resources }`. The `Identity.id` is 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).
|
||||
- **Token**: Parse as UTF-8. If it starts with `alk_`, look up in `DynamicConfig::auth.api_keys` by prefix match + SHA-256 hash. If found and not expired, return `Identity { id: prefix, scopes: entry.scopes, resources: {} }`. The `Identity.id` is the key prefix — API key rotation = new identity (see "API keys vs peer entries" above).
|
||||
|
||||
> **Resource-scoped ACLs and external identities.** `Identity.resources` is
|
||||
> populated only by the composition path (`CompositionAuthority::as_identity`,
|
||||
> ADR-015/022) — never by token or fingerprint resolvers. API keys and
|
||||
> fingerprints grant **scopes only**; resource-scoped access is an
|
||||
> internal-composition concern. An `OperationSpec` that declares
|
||||
> `resource_type`/`resource_action` will return `FORBIDDEN` when the caller
|
||||
> authenticated via token or fingerprint, because `Identity.resources` is
|
||||
> empty. This is a documented limitation, not a bug: if a future crate needs
|
||||
> per-key resource binding, it must earn a dedicated ADR that adds a
|
||||
> `resources` field to `ApiKeyEntry` and the fingerprint config path, rather
|
||||
> than silently widening the external-auth contract.
|
||||
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) for the `PeerEntry` model and the id-fingerprint decoupling rationale.
|
||||
|
||||
### Resource-scoped ACLs
|
||||
|
||||
`Identity.resources` is populated on three paths:
|
||||
|
||||
| Path | Source of `resources` | Use case |
|
||||
|------|----------------------|----------|
|
||||
| Fingerprint resolution (`ConfigIdentityProvider`) | `PeerEntry.resources` (ADR-030) | External fingerprint-authenticated callers with per-peer resource binding |
|
||||
| API key resolution (`ConfigIdentityProvider`) | Empty (by design) | API keys grant scopes only; resource-scoped access is composition-only |
|
||||
| Composition (`CompositionAuthority::as_identity`, ADR-015/022) | `CompositionAuthority.resources` | Internal composition calls with declared resource binding |
|
||||
|
||||
An `OperationSpec` that declares `resource_type`/`resource_action` will return `FORBIDDEN` when the caller authenticated via API key (because `Identity.resources` is empty), but succeeds when the caller authenticated via fingerprint with matching `PeerEntry.resources`, or via composition with matching `CompositionAuthority.resources`. The API-key limitation is deliberate (see "API keys vs peer entries" above); the fingerprint path's resource binding is the ADR-030 change that lifts the pre-ADR-030 limitation.
|
||||
|
||||
Changes to `DynamicConfig` via `ConfigReloadHandle` are reflected immediately — `ConfigIdentityProvider` reads from `ArcSwap` on every call.
|
||||
|
||||
### Fingerprint string format
|
||||
|
||||
`tls_client_fingerprint` and `authorized_fingerprints` use a prefixed-hex
|
||||
`tls_client_fingerprint` and `PeerEntry.fingerprint` use a prefixed-hex
|
||||
format. The prefix identifies the key type; the body is the hex-encoded
|
||||
hash or raw key bytes. `AuthPolicy::resolve_identity_from_fingerprint`
|
||||
does a literal `HashSet::contains()` — no normalization — so the extractor
|
||||
and the operator config must use the same format.
|
||||
scans `peers` for a matching `fingerprint` field — no normalization — so
|
||||
the extractor and the operator config must use the same format.
|
||||
|
||||
| Transport | Source | Format |
|
||||
|-----------|--------|--------|
|
||||
@@ -196,10 +218,10 @@ normally with `tls_client_fingerprint: None`.
|
||||
|
||||
The verifier accepts any presented cert without CA verification because
|
||||
alknet's identity model is fingerprint-based, not PKI-based — the
|
||||
`AuthPolicy::authorized_fingerprints` set is the trust anchor, not a
|
||||
root CA store. The cert bytes are extracted at the TLS layer and hashed
|
||||
to a fingerprint string; the fingerprint is then matched against the
|
||||
configured set by `IdentityProvider::resolve_from_fingerprint()`.
|
||||
`AuthPolicy::peers` set is the trust anchor, not a root CA store. The
|
||||
cert bytes are extracted at the TLS layer and hashed to a fingerprint
|
||||
string; the fingerprint is then matched against the configured `PeerEntry.fingerprint`
|
||||
fields by `IdentityProvider::resolve_from_fingerprint()`.
|
||||
|
||||
## Resolution Flow
|
||||
|
||||
@@ -293,10 +315,13 @@ The endpoint's `AlknetEndpoint` also holds `Arc<dyn IdentityProvider>` for endpo
|
||||
| AuthContext is immutable in handle() | [ADR-011](../../decisions/011-authcontext-structure.md) | Handlers create local variables for resolved identity |
|
||||
| Two resolution paths | [ADR-004](../../decisions/004-auth-as-shared-core.md) | Fingerprint and token, not phased auth |
|
||||
| Handler stores resolved identity on Connection | OQ-11 (resolved) | `connection.set_identity()` — write-once-read-many for observability |
|
||||
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `authorized_fingerprints` → `peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable), not fingerprint; key rotation changes fingerprint, not identity |
|
||||
| CredentialStore repo trait | [ADR-031](../../decisions/031-credentialstore-repo-trait.md) | Second repo trait in core (alongside `IdentityProvider`); `InMemoryCredentialStore` default adapter |
|
||||
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines traits + in-memory defaults; persistence adapters are separate crates |
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. All auth-related open questions are resolved.
|
||||
- **OQ-35**: API key identity vs peer identity — the asymmetry between the fingerprint path (gets `PeerEntry` id-decoupling) and the API-key path (doesn't) is deliberate. See ADR-030 §"API keys" and "API keys vs peer entries" above.
|
||||
|
||||
## Security Constraints
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-22-21
|
||||
last_updated: 2026-06-27
|
||||
---
|
||||
|
||||
# Configuration
|
||||
@@ -165,27 +165,78 @@ pub struct DynamicConfig {
|
||||
|
||||
### AuthPolicy
|
||||
|
||||
Authorization policy derived from authorized keys, certificate authorities, and API keys.
|
||||
Authorization policy derived from peer entries and API keys.
|
||||
|
||||
```rust
|
||||
pub struct AuthPolicy {
|
||||
/// SHA-256 fingerprints of authorized keys (SSH keys, TLS client certs).
|
||||
/// Stored as strings to avoid russh dependency in core.
|
||||
pub authorized_fingerprints: HashSet<String>,
|
||||
/// Peer entries: each maps a stable logical peer_id to its current
|
||||
/// fingerprint, scopes, resources, and enabled state. Replaces the
|
||||
/// pre-ADR-030 `authorized_fingerprints: HashSet<String>`. The list
|
||||
/// is keyed by `peer_id`; resolution looks up by `fingerprint`.
|
||||
/// See ADR-030.
|
||||
pub peers: Vec<PeerEntry>,
|
||||
|
||||
/// API keys for token-based auth.
|
||||
/// API keys for token-based auth. Unchanged by ADR-030 — API keys
|
||||
/// don't get the PeerEntry treatment (rotation = new identity is the
|
||||
/// correct semantics for bearer tokens). See ADR-030 §"API keys".
|
||||
pub api_keys: Vec<ApiKeyEntry>,
|
||||
}
|
||||
```
|
||||
|
||||
Certificate authority entries for cert-based auth will be added when
|
||||
alknet-ssh is implemented. The `cert_authorities` field is omitted from v1
|
||||
to avoid referencing an undefined type. Adding it back is additive (a new
|
||||
### PeerEntry
|
||||
|
||||
A peer entry maps a stable logical peer identity to its current
|
||||
cryptographic material and authorization scopes. The `peer_id` is stable
|
||||
across key rotation; the `fingerprint` changes when the node rotates its
|
||||
TLS key. `ConfigIdentityProvider::resolve_from_fingerprint` resolves
|
||||
fingerprint → `PeerEntry` → `Identity { id: peer_id, ... }`, so
|
||||
`Identity.id` is the stable `peer_id`, not the rotating fingerprint.
|
||||
|
||||
```rust
|
||||
pub struct PeerEntry {
|
||||
/// Stable logical peer id ("worker-a", "alice"). Does NOT change on
|
||||
/// key rotation. This becomes Identity.id on resolution.
|
||||
pub peer_id: String,
|
||||
|
||||
/// Current cryptographic material — the fingerprint the endpoint
|
||||
/// extracts from the TLS handshake (SHA256:... for X.509, ed25519:...
|
||||
/// for RFC 7250 raw keys). Changes on key rotation.
|
||||
pub fingerprint: 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. Populated from config (ADR-030 lifts the
|
||||
/// pre-ADR-030 limitation that fingerprint-resolved identities had
|
||||
/// empty 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 = the fingerprint
|
||||
/// is recognized but the peer is disabled (token-revoked-equivalent
|
||||
/// for fingerprints). Resolution returns None.
|
||||
pub enabled: bool,
|
||||
}
|
||||
```
|
||||
|
||||
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md)
|
||||
for the `PeerEntry` model, the id-fingerprint decoupling rationale, and
|
||||
the key-rotation story (vault rotates locally; the remote side updates
|
||||
the `PeerEntry.fingerprint` field; the `peer_id` and all ACL / routing
|
||||
references stay stable).
|
||||
|
||||
Certificate authority entries for cert-based auth are omitted from
|
||||
`AuthPolicy` until alknet-ssh is implemented, to avoid referencing an
|
||||
undefined type. Adding the `cert_authorities` field is additive (a new
|
||||
field on `AuthPolicy` is non-breaking for existing config files that don't
|
||||
use it). alknet-ssh will define `CertAuthorityEntry` with the necessary
|
||||
fields (public key, principals, options).
|
||||
|
||||
This replaces the reference implementation's `AuthPolicy` which depended on `russh::keys::PublicKey`. The new version stores fingerprints as strings, not russh types. This removes the russh dependency from alknet-core.
|
||||
This replaces the reference implementation's `AuthPolicy` which depended on `russh::keys::PublicKey`. The new version stores fingerprints as strings (in `PeerEntry.fingerprint`), not russh types. This removes the russh dependency from alknet-core.
|
||||
|
||||
### ApiKeyEntry
|
||||
|
||||
@@ -286,4 +337,6 @@ Simplified from the reference implementation. Removes proxy-specific errors (now
|
||||
|----------|-----|---------|
|
||||
| No russh dependency in core | [ADR-003](../../decisions/003-crate-decomposition.md) | Core is ALPN-agnostic; russh is an alknet-ssh dependency |
|
||||
| ArcSwap for dynamic config | Carry-forward from reference | Lock-free reads, atomic swaps |
|
||||
| No ListenerConfig | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | Single endpoint, ALPN replaces multiple listener types |
|
||||
| No ListenerConfig | [ADR-001](../../decisions/001-alpn-protocol-dispatch.md) | Single endpoint, ALPN replaces multiple listener types |
|
||||
| PeerEntry and Identity.id decoupling | [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) | `authorized_fingerprints: HashSet<String>` → `peers: Vec<PeerEntry>`; `Identity.id` = `peer_id` (stable), not fingerprint |
|
||||
| Storage boundary and repo/adapter pattern | [ADR-033](../../decisions/033-storage-boundary-and-repo-adapter-pattern.md) | Core defines repo traits + in-memory defaults; `AuthPolicy.peers` is the config model for the in-memory `ConfigIdentityProvider` adapter; persistence adapters are separate crates |
|
||||
Reference in New Issue
Block a user