Files
alknet/docs/architecture/credentials.md
glm-5.1 cfc44008d3 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)
2026-06-09 08:09:45 +00:00

263 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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