docs(arch): ADR-035 — concrete persistence adapter shapes, resolve OQ-36
Commits the concrete adapter shape deferred by ADR-033: read-sync / write-async split with honker NOTIFY/LISTEN for no-restart cache invalidation, against SQLite, in a separate alknet-store-sqlite crate. Two constraints drive the design: (1) the hot-path read trait (IdentityProvider::resolve_from_fingerprint, CredentialStore::get) is sync — called in the accept loop, no .await — so a SQLite-backed adapter must cache in memory and serve sync reads from the cache; (2) auth changes must take effect without a restart (an early issue the project already fixed for ConfigIdentityProvider via ArcSwap config reload). honker's SQLite NOTIFY/LISTEN (single-digit-ms wake, no polling) is the cache-invalidation mechanism that makes both hold: write commits to SQLite + emits NOTIFY, the running process's LISTEN wakes, the in-memory index reloads and atomically swaps, the next read sees the new state. Same ArcSwap-reload pattern as config, generalized from 'config file is source of truth' to 'SQLite is source of truth, honker signals when it changed.' New async IdentityStore write trait (put_peer / update_peer / remove_peer) extends the sync IdentityProvider read trait for peer mutations. ConfigIdentityProvider does NOT implement it (config reload is its write path — a posture enforced by the absence of a backend, not a type-system constraint); SqliteIdentityProvider implements both. CredentialStore::put/delete refined to async (within ADR-031's one-way door — the contract was get/put/delete keyed by provider persisting EncryptedData never decrypting; sync-vs-async was unspecified). CredentialStoreError renamed to shared StoreError covering both traits. alknet-store-sqlite is one crate implementing both IdentityStore and CredentialStore with shared SQLite connection + honker LISTEN infra (splitting later is a two-way door). Schema shape committed (one row per PeerEntry with JSON columns for fingerprints/scopes/resources; one row per EncryptedData blob keyed by provider); exact DDL is an implementation-detail two-way door in the adapter crate. The keypal adapter-factory pattern is intentionally not ported to Rust (runtime column-mapping is a TS affordance; in Rust each adapter is a concrete type, cross-cutting concerns are a shared helper module). Amends ADR-031 (put/delete async refinement, StoreError rename), ADR-033 (concrete adapter shape now specified, two-crate framing collapsed to one), ADR-034 (OQ-36 now resolved), auth.md (IdentityStore section, cache-invalidation summary, OQ-36 reference), config.md (two write paths note), and the OQ-36/OQ-34 entries in open-questions.md. Review fixed 4 criticals (error-type name divergence, duplicate IdentityProvider sketch, upsert/Duplicate ambiguity, 'shape unchanged' contradiction), 7 warnings, 5 suggestions.
This commit is contained in:
@@ -44,7 +44,7 @@ Core library for ALPN-based protocol dispatch. Every handler crate depends on al
|
||||
| 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 asymmetry~~ | dissolved | `PeerEntry` supports multiple credential paths; `ApiKeyEntry` is for tokens that ARE the identity |
|
||||
| OQ-36 | Concrete persistence adapter shapes | open (deferred for exploration) | The repo/adapter pattern is committed (ADR-033); in-memory adapters ship with core; persistence adapters deferred |
|
||||
| OQ-36 | Concrete persistence adapter shapes | resolved by ADR-035 | Read-sync / write-async split (`IdentityStore`); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; `alknet-store-sqlite` crate |
|
||||
| OQ-37 | X.509 outgoing-only case | resolved by ADR-034 | Three remote roles (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry correct; client-side verifier by `PeerEntry` presence (CA vs fingerprint pin) |
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
@@ -237,6 +237,63 @@ How it resolves:
|
||||
|
||||
See [ADR-030](../../decisions/030-peerentry-and-identity-id-decoupling.md) for the `PeerEntry` model, the multi-credential resolution, and the fingerprint normalization rationale.
|
||||
|
||||
## IdentityStore (write trait, ADR-035)
|
||||
|
||||
`IdentityProvider` (defined above) is **read-only** 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 a separate async write trait that extends
|
||||
`IdentityProvider`:
|
||||
|
||||
```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>;
|
||||
}
|
||||
```
|
||||
|
||||
`IdentityStore: IdentityProvider` is a supertrait — any type that
|
||||
implements `IdentityProvider` *could* implement `IdentityStore`, but
|
||||
not implementing it is a design posture, not a type-system constraint.
|
||||
`ConfigIdentityProvider` deliberately does **not** implement
|
||||
`IdentityStore`: it holds no SQLite handle and no backend, and 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 trait bound. A deployment that wants method-call
|
||||
peer management (CLI `alknet peer add`, an admin call-protocol
|
||||
operation) wires the SQLite adapter (`SqliteIdentityProvider`), which
|
||||
implements both `IdentityProvider` (sync reads from cache) and
|
||||
`IdentityStore` (async writes to SQLite + honker NOTIFY).
|
||||
|
||||
### Cache invalidation without restart (ADR-035)
|
||||
|
||||
The no-restart-on-auth-change property — already established for
|
||||
`ConfigIdentityProvider` via `ArcSwap` config reload — is preserved by
|
||||
the SQLite adapter via honker's SQLite `NOTIFY`/`LISTEN`:
|
||||
|
||||
1. A write (`put_peer` / `update_peer` / `remove_peer`) commits to
|
||||
SQLite and emits `NOTIFY 'peers_changed'`.
|
||||
2. The running alknet process's `LISTEN` wakes in single-digit ms
|
||||
(honker watches `PRAGMA data_version` — no polling, no daemon).
|
||||
3. The process reloads its in-memory index from `SELECT * FROM peers`
|
||||
and atomically swaps it (`ArcSwap`, same pattern as config reload).
|
||||
4. The next `resolve_from_fingerprint` call reads the new index. Live
|
||||
resolution changes, no restart.
|
||||
|
||||
See [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md)
|
||||
for the full adapter design (the `alknet-store-sqlite` crate, the
|
||||
schema shape, the `StoreError` type, the writer's-own-process cache
|
||||
coherence details, and why honker is a hard dependency of the SQLite
|
||||
adapter rather than an option).
|
||||
|
||||
### Resource-scoped ACLs
|
||||
|
||||
`Identity.resources` is populated on two paths:
|
||||
@@ -387,12 +444,14 @@ The endpoint's `AlknetEndpoint` also holds `Arc<dyn IdentityProvider>` for endpo
|
||||
| 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 |
|
||||
| Three remote roles and outgoing-only X.509 | [ADR-034](../../decisions/034-outgoing-only-x509-and-three-peer-roles.md) | Public X.509 endpoint / transport relay / hub; `PeerEntry` asymmetry (pure-client X.509 is not a peer); client-side verifier by `PeerEntry` presence |
|
||||
| Concrete persistence adapter shapes | [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md) | Read-sync / write-async split (`IdentityStore` async write trait); SQLite adapter caches in memory, honker NOTIFY for no-restart cache invalidation; `StoreError` type |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **OQ-29** (resolved): `CallClient` TLS client-auth — wire quinn client-auth (present Ed25519 key as raw public key client cert); key-type-aware server cert verification (raw key = fingerprint match, X.509 = CA verification); fingerprint normalization (`ed25519:` across quinn/iroh). See OQ-29 in open-questions.md.
|
||||
- **OQ-35** (dissolved): the "API key asymmetry" framing was wrong; `PeerEntry` supports multiple credential paths (fingerprints + auth_token_hash), `ApiKeyEntry` is for tokens that ARE the identity. See OQ-35 in open-questions.md.
|
||||
- **OQ-37** (resolved): X.509 outgoing-only case — three remote roles named (public X.509 endpoint, transport relay, hub); `PeerEntry` asymmetry is correct (pure-client X.509 connections are not in the peer graph on the client side); client-side verifier selection by `PeerEntry` presence (CA verification for unknown X.509, fingerprint pin for known peers). See ADR-034 and OQ-37 in open-questions.md.
|
||||
- **OQ-36** (resolved): Concrete persistence adapter shapes — read-sync / write-async split (`IdentityStore` async write trait extends the sync `IdentityProvider` read trait); SQLite adapter caches in memory and uses honker NOTIFY/LISTEN for no-restart cache invalidation; `alknet-store-sqlite` crate implements both `IdentityStore` and `CredentialStore`. See ADR-035 and OQ-36 in open-questions.md.
|
||||
|
||||
## Security Constraints
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
status: draft
|
||||
last_updated: 2026-06-27
|
||||
last_updated: 2026-06-28
|
||||
---
|
||||
|
||||
# Configuration
|
||||
@@ -245,6 +245,19 @@ 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).
|
||||
|
||||
**Two write paths for `AuthPolicy.peers`** (ADR-035): the
|
||||
**config-backed** path (`ConfigReloadHandle::reload`, used by
|
||||
`ConfigIdentityProvider` — edit the config file, signal reload, live
|
||||
resolution changes via `ArcSwap`) and the **method-call** path
|
||||
(`IdentityStore::put_peer` / `update_peer` / `remove_peer`, used by
|
||||
`SqliteIdentityProvider` — a CLI or admin op writes to SQLite, honker
|
||||
NOTIFY refreshes the in-memory index, live resolution changes). Both
|
||||
produce the same `PeerEntry` shape; the difference is the source of
|
||||
truth (config file vs. SQLite) and the write mechanism. A deployment
|
||||
picks one by wiring the corresponding adapter at the assembly layer.
|
||||
The `IdentityStore` trait is defined in [auth.md](auth.md#identitystore-write-trait-adr-035);
|
||||
the adapter design is in [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md).
|
||||
|
||||
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
|
||||
@@ -348,4 +361,5 @@ Simplified from the reference implementation. Removes proxy-specific errors (now
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Concrete persistence adapter shapes | [ADR-035](../../decisions/035-concrete-persistence-adapter-shapes.md) | `AuthPolicy.peers` is the **config-backed** write surface (reload via `ConfigReloadHandle`); the SQLite adapter's `IdentityStore` trait is the **method-call** write surface for deployments that want `alknet peer add`-style management without config edits. Both produce the same `PeerEntry` shape; the difference is the write path. |
|
||||
Reference in New Issue
Block a user