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:
2026-06-09 08:09:45 +00:00
parent d1af216334
commit cfc44008d3
12 changed files with 1314 additions and 151 deletions

View File

@@ -0,0 +1,263 @@
---
status: draft
last_updated: 2026-06-09
---
# Credentials (Outbound Auth)
## What
The `CredentialProvider` trait and `CredentialSet` enum handle **outbound**
authentication: how alknet authenticates _to_ external and self-hosted services.
This is the complement to `IdentityProvider`, which handles **inbound**
authentication (who is calling alknet).
## Why
Without `CredentialProvider`, each service wrapper would independently solve
credential retrieval, caching, and lifecycle management. Cloud API integrations
(vast.ai, runpod) need API keys. Self-hosted services (rustfs, gitea) need
S3 access keys or OIDC tokens. The secret service can store these at rest, but
the wiring between "decrypt a credential from storage" and "use it in an HTTP
request" doesn't exist yet.
`CredentialProvider` provides a unified abstraction — just as `IdentityProvider`
unifies inbound auth, `CredentialProvider` unifies outbound auth. Handlers
access credentials through `OperationEnv`, not by reaching into storage directly.
## Architecture
### Direction: Inbound vs Outbound
| | IdentityProvider | CredentialProvider |
|---|---|---|
| **Direction** | Inbound (who is calling alknet) | Outbound (how alknet calls others) |
| **Resolves** | Fingerprint/token → `Identity` | Service name → `CredentialSet` |
| **Storage** | `peer_credentials`, `api_keys` | Encrypted nodes in metagraph |
| **Lifecycle** | Stateless lookup | May need refresh (OIDC tokens, S3 sessions) |
| **Location** | `alknet_core::auth` | `alknet_core::credentials` |
Both live at the same architectural layer. A handler receives an
`OperationContext` with `identity` (who called us) and can access credentials
through `context.env` (how we call out).
### CredentialProvider Trait
```rust
pub trait CredentialProvider: Send + Sync + 'static {
fn get_credentials(&self, service: &str) -> Option<CredentialSet>;
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet>;
}
```
The trait is intentionally narrow. It returns credentials for a named service.
It does not abstract the auth mechanism — that stays with the service wrapper
that knows the protocol (S3 signing, OAuth2 refresh, etc.).
### CredentialSet
```rust
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>,
},
}
```
Each variant carries the data needed for a specific auth mechanism. The service
wrapper that requested the credentials knows what variant it expects and how to
use it.
### CredentialProvider vs IdentityProvider
These are opposite-direction abstractions that compose through `OperationEnv`:
```
Incoming Request
IdentityProvider (credential → Identity)
├── SSH fingerprint → Identity.id, .scopes, .resources
├── Bearer AuthToken → Identity.id, .scopes, .resources
└── API key → Identity.id, .scopes, .resources
OperationContext { identity, env, ... }
├── context.env.invoke("git", "push", input)
│ └── GitService handler
│ └── CredentialProvider (outbound)
│ └── get_credentials("rustfs")
│ └── S3AccessKey { access_key, secret_key }
└── context.env.invoke("secrets", "derive", input)
└── local dispatch to SecretProtocol
Two directions: Inbound (who is calling us)
Outbound (how we call others)
```
### SecretStoreCredentialProvider (Phase 1 Default)
The default `CredentialProvider` implementation. Decrypts credentials via
`SecretProtocol::Decrypt` and holds them in RAM:
```rust
pub struct SecretStoreCredentialProvider {
credentials: ArcSwap<HashMap<String, CredentialSet>>,
}
```
At startup, the CLI or NAPI assembly loads credentials from the secret service
and populates the `ArcSwap`. The `refresh_credentials()` method re-decrypts
after a `Lock`/`Unlock` cycle on the secret service.
### ManagedCredentialProvider (Phase C Future)
For self-hosted services that need active lifecycle management (S3 session
token rotation, OIDC token refresh). Wraps `SecretStoreCredentialProvider`
with per-service `CredentialManager` instances:
```rust
pub struct ManagedCredentialProvider {
base: SecretStoreCredentialProvider,
managers: HashMap<String, Arc<dyn CredentialManager>>,
}
pub trait CredentialManager: Send + Sync + 'static {
fn refresh(&self, current: &CredentialSet) -> Option<CredentialSet>;
fn is_expired(&self, current: &CredentialSet) -> bool;
fn provision(&self, identity: &Identity) -> Option<CredentialSet>;
}
```
- `refresh`: OIDC token refresh, S3 session token rotation
- `is_expired`: Check TTL before use
- `provision`: Create credentials on a self-hosted service for a given identity
This is a Phase C concept. The spec defines the extension point but defers
implementation.
### Integration with OperationEnv
Handlers access credentials through `OperationEnv`:
```rust
// Handler needs outbound credentials for a service
let creds = context.env.get_credentials("rustfs");
```
This is analogous to how `context.env.invoke(namespace, op, input)` works for
operation dispatch — the handler doesn't know whether the credential comes from
config, the secret service, or a managed provider.
### Integration with SecretProtocol
Credentials are stored encrypted in the metagraph via `SecretProtocol`:
1. Operator configures credentials: `alknet credential add vast-ai --type bearer --token-file ./key.txt`
2. CLI encrypts via `SecretProtocol::Encrypt` (AES-256-GCM, key at path `m/74'/2'/0'/0'`)
3. Encrypted credential stored as `EncryptedData` node in metagraph, tagged with service name
4. At startup, `SecretStoreCredentialProvider` calls `SecretProtocol::Decrypt` for each configured service
5. Decrypted credentials held in RAM with same lifecycle as the seed (purged on `Lock`)
The `EncryptedData` wire format is shared with alknet-storage by type-level
compatibility, not a crate dependency.
### Identity-Bound Credentials (Phase B+ Future)
For multi-tenant setups where different alknet users have different access levels
on the same external service:
```rust
// Service-level credential (all users share one key):
credential_provider.get_credentials("rustfs")
// Identity-bound credential (per-user key):
credential_provider.get_credentials_for("rustfs", &identity.id)
```
The trait-level method is service-level. The identity-bound method is an
extension in alknet-storage that uses `Identity.id` (the account UUID in
database-backed deployments) as the lookup key. No separate `account_id` field
needed — `Identity.id` IS the account identifier.
## Constraints
- `CredentialProvider` and `CredentialSet` live in `alknet_core::credentials`.
No database dependency at the core level.
- `CredentialProvider` does not depend on `IdentityProvider`. They compose
through `OperationEnv`, not through dependency.
- `ManagedCredentialProvider` and `CredentialManager` are Phase C concepts.
They are defined as extension points but not implemented yet.
- Identity-bound credentials use `Identity.id` as the account key. In
config-backed deployments, this is the fingerprint or key prefix. In
database-backed deployments, this is the account UUID.
- `SecretStoreCredentialProvider` depends on `SecretProtocol::Decrypt`, which
requires the alknet-secret crate. A stub impl that reads from config is
sufficient for Phase 2 when alknet-secret isn't available.
- The `CredentialSet` variants cover all identified credential types (Phases
AC). Phase D (alknet as OIDC provider) is additive.
## Phase Progression
| Phase | CredentialProvider Scope | Notes |
|-------|-------------------------|-------|
| Phase 2 (now) | Trait + `CredentialSet` in core. `SecretStoreCredentialProvider` stub reads from config. | Enables Phase 2 HTTP auth |
| Phase A | `SecretStoreCredentialProvider` backed by `SecretProtocol::Decrypt`. CLI command for credential management. | Full secret service integration |
| Phase B | `FromOpenAPI` integration. `CredentialProvider` populates `HttpServiceConfig.auth`. | Auto-registration of external services |
| Phase C | `ManagedCredentialProvider` + `CredentialManager`. S3 signing, OIDC refresh, identity-bound credentials. | Production self-hosted services |
| Phase D | Alknet as OIDC provider. Eliminates stored credentials for OIDC-compatible services. | Long-term goal |
## Open Questions
- **OQ-CP-01**: Should `CredentialProvider` support per-identity credentials
(`get_credentials(service, identity)`)? See [open-questions.md](open-questions.md).
- **OQ-CP-02**: Where should OIDC provider operations live if alknet becomes
an OIDC provider (Phase D)? See [open-questions.md](open-questions.md).
- **OQ-CP-03**: How do credential rotations propagate across a cluster? See
[open-questions.md](open-questions.md).
- **OQ-CP-04**: Should `CredentialSet` include request-signing capability?
See [open-questions.md](open-questions.md).
## Design Decisions
| ADR | Decision | Summary |
|-----|----------|---------|
| [036](decisions/036-credentialprovider-core-type.md) | CredentialProvider as core type | Outbound credentials in `alknet_core::credentials`, parallel to IdentityProvider |
| [029](decisions/029-identity-core-type.md) | Identity as core type | Inbound auth — the opposite direction |
| [032](decisions/032-event-boundary-discipline.md) | Event boundary | Secret service domain events stay internal |
## References
- [identity.md](identity.md) — IdentityProvider (inbound auth, opposite direction)
- [secret-service.md](secret-service.md) — SecretProtocol, EncryptedData
- [services.md](services.md) — OperationEnv, OperationContext
- [definitions.md](definitions.md) — IdentityProvider vs CredentialProvider disambiguation
- [research/phase2/credential-provider.md](../research/phase2/credential-provider.md) — Full analysis with rustfs/gitea integration