Sync architecture specs with Phase 2 research findings
- Add definitions.md: normative terminology disambiguation (Interface, Service, Transport, Token, Identity, Domain, Scope, CredentialProvider, etc.) - Add credentials.md: CredentialProvider trait and CredentialSet enum for outbound auth, mirroring IdentityProvider pattern for inbound auth - Rewrite interface.md: StreamInterface/MessageInterface split (ADR-035), InterfaceRequest/InterfaceResponse, HttpInterface/DnsInterface stubs, ListenerConfig with Stream/Http/Dns variants, credential presentation table - Update auth.md: API keys in DynamicConfig (ADR-037), credential presentation per (Transport, Interface) pair, ApiKeyEntry struct in AuthPolicy - Update configuration.md: API keys, ListenerConfig with Http/Dns variants, expanded TOML config examples - Update call-protocol.md: resolve OQ-IF-01 (InterfaceEvent carries EventEnvelope + Identity), add MessageInterface awareness to protocol adapter layer - Update overview.md: three-layer model now includes StreamInterface/ MessageInterface, CredentialProvider/CredentialSet exports, definitions.md reference, ADRs 035-037 - Update open-questions.md: resolve OQ-IF-01, OQ-IF-02, add OQ-P2-01 through OQ-P2-04, add OQ-CP-01 through OQ-CP-04, add OQ-DEF-01, OQ-DEF-03, OQ-DEF-08 - Update README.md: add definitions.md, credentials.md, ADRs 035-037, phase2 research docs, current state description Key architectural decisions: - ADR-035: StreamInterface/MessageInterface split (two Layer 2 traits) - ADR-036: CredentialProvider as core type (outbound auth, alknet_core::credentials) - ADR-037: API keys as DynamicConfig auth (hash-verified bearer tokens)
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
# ADR-035: StreamInterface and MessageInterface Split
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
The `Interface` trait (ADR-026) assumes a persistent byte stream from a `Transport`. It produces a `Session` that yields `InterfaceEvent` frames. This works for SSH and raw framing — both run over duplex streams.
|
||||
|
||||
However, HTTP and DNS do not fit this model. They handle individual request/response pairs, not persistent sessions. HTTP runs over a TLS connection after byte-peek protocol detection (extending the existing stealth mode pattern). DNS runs its own server on port 53. Both are stateless per-request, not session-oriented.
|
||||
|
||||
The three-layer model (Transport, Interface, Protocol) remains correct. The issue is that Layer 2 has two distinct patterns: stream-based (SSH, raw framing) where the transport provides a continuous byte stream, and message-based (HTTP, DNS) where the interface manages its own transport and handles discrete requests.
|
||||
|
||||
## Decision
|
||||
|
||||
Split the `Interface` trait into two independent traits:
|
||||
|
||||
1. **`StreamInterface`** — consumes a `TransportStream`, produces a long-lived `Session` that yields `InterfaceEvent` frames. Existing `SshInterface` and `RawFramingInterface` become `StreamInterface` implementations.
|
||||
|
||||
2. **`MessageInterface`** — handles individual `InterfaceRequest` → `InterfaceResponse` pairs. Manages its own transport (HTTP server, DNS server). `HttpInterface` and `DnsInterface` are `MessageInterface` implementations.
|
||||
|
||||
The traits are independent. They have different signatures (`accept(stream)` vs `handle_request(req)`), different lifecycles (long-lived session vs stateless per-request), and different transport ownership (provided by caller vs self-managed).
|
||||
|
||||
`ListenerConfig` gains variants for both:
|
||||
|
||||
```rust
|
||||
pub enum ListenerConfig {
|
||||
Stream {
|
||||
transport: TransportKind,
|
||||
interface: StreamInterfaceKind,
|
||||
},
|
||||
Http {
|
||||
bind_addr: SocketAddr,
|
||||
tls: bool,
|
||||
stealth: bool,
|
||||
},
|
||||
Dns {
|
||||
bind_addr: SocketAddr,
|
||||
tls: bool,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`TransportKind::Dns` is removed. DNS is a `MessageInterface` that manages its own transport (UDP/TCP port 53), not a transport variant.
|
||||
|
||||
The call protocol handler (Layer 3) is interface-agnostic: it processes `InterfaceEvent` frames from `StreamInterface` sessions and `InterfaceRequest` → `InterfaceResponse` from `MessageInterface` handlers. The dispatch logic is the same — only the framing differs.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: HTTP and DNS are first-class interfaces with proper type signatures. No forcing stateless protocols into a session model. The existing stealth mode byte-peek pattern naturally extends to `HttpInterface`. The `InterfaceRequest` / `InterfaceResponse` types normalize calls across message-based interfaces.
|
||||
|
||||
**Positive**: Removing `TransportKind::Dns` prevents a breaking change later — code should never depend on DNS as a transport variant.
|
||||
|
||||
**Positive**: `ListenerConfig` correctly models the server's accept loop: stream listeners spawn one accept loop per (transport, interface) pair, while HTTP and DNS listeners each manage their own server.
|
||||
|
||||
**Negative**: Two traits where there was one. But they serve fundamentally different purposes. A common super-trait would add complexity (`accept_stream` + `handle_request` + `transport_kind`) without practical benefit — implementations satisfy one trait or the other, never both.
|
||||
|
||||
**Negative**: The `accept()` method on the current `Interface` trait needs to be renamed. This is a rename of an existing method signature, not a semantic change — `SshInterface` and `RawFramingInterface` implementations become `StreamInterface` implementations with the same `accept()` logic.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-026 (transport/interface separation — updated by this ADR)
|
||||
- [interface.md](../interface.md) — Interface layer spec
|
||||
- [research/phase2/interface-model.md](../../research/phase2/interface-model.md) — Full analysis
|
||||
- [research/phase2/tls-transport.md](../../research/phase2/tls-transport.md) — HTTP interface, ListenerConfig
|
||||
@@ -0,0 +1,82 @@
|
||||
# ADR-036: CredentialProvider as Core Type
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Alknet's `IdentityProvider` resolves **inbound** authentication: given a
|
||||
credential (fingerprint or token), produce an `Identity`. But there is no
|
||||
corresponding abstraction for **outbound** credentials: how does alknet
|
||||
authenticate _to_ external services (vast.ai, rustfs, gitea)?
|
||||
|
||||
Without `CredentialProvider`, each service wrapper would independently solve
|
||||
credential retrieval, caching, and lifecycle management. This leads to
|
||||
duplicated effort and inconsistent security practices across service wrappers.
|
||||
|
||||
The pattern mirrors the existing `IdentityProvider` pattern: trait in core,
|
||||
default impl using simple storage, production impl using the secret service
|
||||
and database.
|
||||
|
||||
## Decision
|
||||
|
||||
Define `CredentialProvider` trait and `CredentialSet` enum in
|
||||
`alknet_core::credentials`.
|
||||
|
||||
```rust
|
||||
pub trait CredentialProvider: Send + Sync + 'static {
|
||||
fn get_credentials(&self, service: &str) -> Option<CredentialSet>;
|
||||
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet>;
|
||||
}
|
||||
|
||||
pub enum CredentialSet {
|
||||
ApiKey { header_name: String, token: String },
|
||||
Basic { username: String, password: String },
|
||||
Bearer { token: String },
|
||||
S3AccessKey { access_key: String, secret_key: String, session_token: Option<String> },
|
||||
OidcToken { access_token: String, refresh_token: Option<String>, expires_at: Option<u64> },
|
||||
Custom { scheme: String, params: HashMap<String, String> },
|
||||
}
|
||||
```
|
||||
|
||||
The trait is intentionally narrow. It returns credentials for a named service.
|
||||
It does not try to abstract the auth mechanism itself — that stays with the
|
||||
service wrapper that knows the protocol (S3 signing, OAuth2 refresh, etc.).
|
||||
|
||||
Phase 1 provides `SecretStoreCredentialProvider` (reads from
|
||||
`SecretProtocol::Decrypt`, holds in RAM). Phase 2+ adds
|
||||
`ManagedCredentialProvider` (with `CredentialManager` for lifecycle management:
|
||||
refresh, expiration, provisioning).
|
||||
|
||||
`CredentialProvider` does not depend on `IdentityProvider`, though
|
||||
`ManagedCredentialProvider` may use `Identity.id` for identity-bound credential
|
||||
lookups.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Outbound auth has a unified abstraction, just as inbound auth
|
||||
has `IdentityProvider`. Service wrappers retrieve credentials through one
|
||||
interface. `OperationEnv` can expose credentials through `context.env`.
|
||||
|
||||
**Positive**: The `CredentialSet` enum covers all identified credential types
|
||||
(API keys, bearer tokens, S3 access keys, OIDC tokens, basic auth, custom).
|
||||
This is sufficient for Phases A-C. Phase D (alknet as OIDC provider) is additive.
|
||||
|
||||
**Positive**: The trait in core, impl in service crate pattern is consistent
|
||||
with `IdentityProvider` (trait in core, `ConfigIdentityProvider` in core,
|
||||
`StorageIdentityProvider` in alknet-storage).
|
||||
|
||||
**Negative**: Adds a new core type and a new module (`credentials`). But this
|
||||
is the same pattern as `IdentityProvider` and `auth` — a small, narrow trait
|
||||
with a clear contract.
|
||||
|
||||
**Negative**: `ManagedCredentialProvider` and `CredentialManager` are Phase C
|
||||
concepts. The spec should define them as future extensions, not implement them
|
||||
now.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-029 (Identity as core type — same pattern)
|
||||
- [credentials.md](../credentials.md) — CredentialProvider spec
|
||||
- [research/phase2/credential-provider.md](../../research/phase2/credential-provider.md) — Full analysis
|
||||
- [identity.md](../identity.md) — IdentityProvider (inbound, opposite direction)
|
||||
83
docs/architecture/decisions/037-api-keys-dynamic-config.md
Normal file
83
docs/architecture/decisions/037-api-keys-dynamic-config.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# ADR-037: API Keys as DynamicConfig Auth
|
||||
|
||||
## Status
|
||||
Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Alknet's token auth uses Ed25519-signed `AuthToken`s — the same key material
|
||||
used for SSH auth. This is appropriate for interactive clients (browsers, CLI)
|
||||
that can generate and sign Ed25519 key pairs.
|
||||
|
||||
But for service accounts, automation, and simple integrations, Ed25519 key
|
||||
pairs are inconvenient. A dashboard backend, a CI/CD pipeline, or a monitoring
|
||||
script needs a simple bearer token that can be stored in an environment variable
|
||||
or config file without managing cryptographic key pairs.
|
||||
|
||||
The HTTP interface (Phase 2+) requires bearer token auth for `Authorization:
|
||||
Bearer <token>` headers. `AuthToken` works but requires client-side Ed25519
|
||||
signing. API keys offer a simpler alternative: short bearer tokens verified by
|
||||
SHA-256 hash lookup, with optional scope restrictions and TTL.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `[[auth.api_keys]]` section to `DynamicConfig`:
|
||||
|
||||
```toml
|
||||
[[auth.api_keys]]
|
||||
prefix = "alk_"
|
||||
hash = "sha256:abc..."
|
||||
scopes = ["relay:connect", "secrets:derive"]
|
||||
description = "dashboard service account"
|
||||
ttl = "30d" # optional
|
||||
```
|
||||
|
||||
`ConfigIdentityProvider::resolve_from_token()` handles both token types:
|
||||
- If the input starts with the configured prefix (default `alk_`), treat it as
|
||||
an API key: hash it with SHA-256 and look up the hash in the `api_keys` table.
|
||||
- Otherwise, treat it as an `AuthToken`: decode, verify Ed25519 signature,
|
||||
check timestamp, resolve from `authorized_keys`.
|
||||
|
||||
Both paths produce the same `Identity` result. In database-backed deployments,
|
||||
both resolve to the same account UUID.
|
||||
|
||||
API keys are stored as SHA-256 hashes (like password hashing — the cleartext
|
||||
key is never stored, only its hash). The prefix enables O(1) routing between
|
||||
AuthToken and API key verification without trying both paths.
|
||||
|
||||
The full key is provided to the client exactly once (at creation time). Subsequent
|
||||
verifications only compare hashes.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**: Simple bearer token auth for HTTP and other non-SSH interfaces.
|
||||
No cryptographic key management for service accounts. Consistent with industry
|
||||
practice (Stripe, GitHub, AWS all use prefixed API keys).
|
||||
|
||||
**Positive**: Both AuthTokens and API keys go through `resolve_from_token()`.
|
||||
The caller doesn't need to know which type they're using. This keeps the
|
||||
authentication layer unified.
|
||||
|
||||
**Positive**: Scoped API keys enable fine-grained access control for service
|
||||
accounts. A monitoring tool gets `["monitoring:read"]`, not full access.
|
||||
|
||||
**Negative**: API keys are bearer tokens — anyone who obtains the key has the
|
||||
associated permissions. The hash storage and optional TTL mitigate but do not
|
||||
eliminate this risk. Ed25519 AuthTokens remain the preferred auth method for
|
||||
interactive clients.
|
||||
|
||||
**Negative**: API key rotation requires updating `DynamicConfig` (or the
|
||||
`api_keys` database table). The `ConfigReloadHandle` / `ConfigService` reload
|
||||
mechanism handles this, but it's a deliberate operation, not automatic.
|
||||
|
||||
**Negative**: No rate limiting on API key verification is built into this ADR.
|
||||
Rate limiting on the HTTP interface is a separate concern.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-023 (unified auth, shared key material)
|
||||
- ADR-029 (Identity as core type)
|
||||
- ADR-030 (static/dynamic config split)
|
||||
- [auth.md](../auth.md) — Token auth, AuthPolicy, API keys
|
||||
- [configuration.md](../configuration.md) — DynamicConfig, AuthPolicy
|
||||
- [research/phase2/interface-model.md](../../research/phase2/interface-model.md) — API keys in config
|
||||
Reference in New Issue
Block a user