Add CredentialProvider trait, CredentialSet enum, and ConfigCredentialProvider

Define the outbound authentication abstraction in alknet_core::credentials:
- CredentialProvider trait with get_credentials and refresh_credentials
- CredentialSet enum with ApiKey, Basic, Bearer, S3AccessKey, OidcToken, Custom variants
- ConfigCredentialProvider reads credentials from DynamicConfig.credentials
- SecretStoreCredentialProvider stub returns None for all lookups (Phase 3)
- Wire CredentialProvider into OperationEnv via credentials() method
- Add credentials HashMap field to DynamicConfig
This commit is contained in:
2026-06-09 10:51:54 +00:00
parent 752e61df4f
commit f8b4fb66b3
6 changed files with 345 additions and 1 deletions

View File

@@ -5,19 +5,44 @@ use serde_json::Value;
use crate::call::context::OperationContext;
use crate::call::registry::OperationRegistry;
use crate::call::response::ResponseEnvelope;
use crate::credentials::{CredentialProvider, CredentialSet, SecretStoreCredentialProvider};
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct OperationEnv {
registry: Arc<OperationRegistry>,
credential_provider: Arc<dyn CredentialProvider>,
}
impl std::fmt::Debug for OperationEnv {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OperationEnv")
.field("registry", &self.registry)
.finish()
}
}
impl OperationEnv {
pub fn local(registry: OperationRegistry) -> Self {
Self {
registry: Arc::new(registry),
credential_provider: Arc::new(SecretStoreCredentialProvider::new()),
}
}
pub fn with_credential_provider(
registry: OperationRegistry,
credential_provider: Arc<dyn CredentialProvider>,
) -> Self {
Self {
registry: Arc::new(registry),
credential_provider,
}
}
pub fn credentials(&self, service: &str) -> Option<CredentialSet> {
self.credential_provider.get_credentials(service)
}
pub fn invoke(&self, namespace: &str, operation: &str, input: Value) -> ResponseEnvelope {
let name = format!("/{namespace}/{operation}");
let request_id = format!("env{name}");
@@ -42,6 +67,10 @@ mod tests {
use super::*;
use crate::call::registry::OperationRegistryBuilder;
use crate::call::spec::{AccessControl, OperationSpec, OperationType};
use crate::config::{AuthPolicy, DynamicConfig};
use crate::credentials::ConfigCredentialProvider;
use arc_swap::ArcSwap;
use std::collections::HashMap;
fn make_spec(name: &str, namespace: &str) -> OperationSpec {
OperationSpec {
@@ -101,4 +130,61 @@ mod tests {
let result = env.invoke("auth", "verify", serde_json::json!(null));
assert!(result.result.is_ok());
}
#[test]
fn operation_env_provides_credentials_from_handler_context() {
let mut credentials = HashMap::new();
credentials.insert(
"vast-ai".to_string(),
CredentialSet::Bearer {
token: "test-token".to_string(),
},
);
let config = DynamicConfig::new(AuthPolicy::empty()).with_credentials(credentials);
let dynamic = Arc::new(ArcSwap::new(Arc::new(config)));
let provider = Arc::new(ConfigCredentialProvider::new(dynamic));
let registry = OperationRegistryBuilder::new()
.with(
make_spec("/test/creds", "test"),
Arc::new(|_input, ctx| {
let creds = ctx.env.credentials("vast-ai");
match creds {
Some(CredentialSet::Bearer { token }) => ResponseEnvelope::ok(
&ctx.request_id,
serde_json::json!({"token": token}),
),
_ => ResponseEnvelope::ok(
&ctx.request_id,
serde_json::json!({"found": false}),
),
}
}),
)
.build();
let env = OperationEnv::with_credential_provider(registry, provider);
let result = env.invoke("test", "creds", serde_json::json!(null));
assert!(result.result.is_ok());
let value = result.result.unwrap();
assert_eq!(value["token"], "test-token");
}
#[test]
fn operation_env_credentials_returns_none_for_missing_service() {
let config = DynamicConfig::default();
let dynamic = Arc::new(ArcSwap::new(Arc::new(config)));
let provider = Arc::new(ConfigCredentialProvider::new(dynamic));
let registry = OperationRegistry::new();
let env = OperationEnv::with_credential_provider(registry, provider);
assert!(env.credentials("nonexistent").is_none());
}
#[test]
fn operation_env_default_credentials_returns_none() {
let registry = OperationRegistry::new();
let env = OperationEnv::local(registry);
assert!(env.credentials("vast-ai").is_none());
}
}

View File

@@ -79,6 +79,7 @@ mod tests {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::deny_all(),
rate_limits: RateLimitConfig::default(),
credentials: std::collections::HashMap::new(),
};
service.reload(new_config);

View File

@@ -11,6 +11,7 @@ use russh::keys::ssh_key::HashAlg;
use crate::auth::identity::Identity;
use crate::auth::ServerAuthConfig;
use crate::config::forwarding::ForwardingPolicy;
use crate::credentials::CredentialSet;
pub struct AuthPolicy {
pub authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
@@ -238,6 +239,7 @@ pub struct DynamicConfig {
pub auth: AuthPolicy,
pub forwarding: ForwardingPolicy,
pub rate_limits: RateLimitConfig,
pub credentials: HashMap<String, CredentialSet>,
}
impl DynamicConfig {
@@ -246,6 +248,7 @@ impl DynamicConfig {
auth,
forwarding: ForwardingPolicy::allow_all(),
rate_limits: RateLimitConfig::default(),
credentials: HashMap::new(),
}
}
@@ -258,6 +261,7 @@ impl DynamicConfig {
auth,
forwarding,
rate_limits,
credentials: HashMap::new(),
}
}
@@ -270,6 +274,11 @@ impl DynamicConfig {
self.rate_limits = limits;
self
}
pub fn with_credentials(mut self, credentials: HashMap<String, CredentialSet>) -> Self {
self.credentials = credentials;
self
}
}
impl Default for DynamicConfig {
@@ -278,6 +287,7 @@ impl Default for DynamicConfig {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::allow_all(),
rate_limits: RateLimitConfig::default(),
credentials: HashMap::new(),
}
}
}
@@ -351,6 +361,7 @@ mod tests {
auth: AuthPolicy::empty(),
forwarding: ForwardingPolicy::deny_all(),
rate_limits: RateLimitConfig::default(),
credentials: HashMap::new(),
};
handle.reload(new_config);

View File

@@ -0,0 +1,241 @@
use std::collections::HashMap;
use std::sync::Arc;
use arc_swap::ArcSwap;
use serde::{Deserialize, Serialize};
use crate::config::DynamicConfig;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
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>,
},
}
pub trait CredentialProvider: Send + Sync + 'static {
fn get_credentials(&self, service: &str) -> Option<CredentialSet>;
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet>;
}
pub struct ConfigCredentialProvider {
dynamic: Arc<ArcSwap<DynamicConfig>>,
}
impl ConfigCredentialProvider {
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
Self { dynamic }
}
}
impl CredentialProvider for ConfigCredentialProvider {
fn get_credentials(&self, service: &str) -> Option<CredentialSet> {
let config = self.dynamic.load();
config.credentials.get(service).cloned()
}
fn refresh_credentials(&self, service: &str) -> Option<CredentialSet> {
self.get_credentials(service)
}
}
pub struct SecretStoreCredentialProvider;
impl SecretStoreCredentialProvider {
pub fn new() -> Self {
Self
}
}
impl Default for SecretStoreCredentialProvider {
fn default() -> Self {
Self::new()
}
}
impl CredentialProvider for SecretStoreCredentialProvider {
fn get_credentials(&self, _service: &str) -> Option<CredentialSet> {
None
}
fn refresh_credentials(&self, _service: &str) -> Option<CredentialSet> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AuthPolicy;
fn make_dynamic_with_credentials() -> Arc<ArcSwap<DynamicConfig>> {
let mut credentials = HashMap::new();
credentials.insert(
"vast-ai".to_string(),
CredentialSet::Bearer {
token: "secret-token".to_string(),
},
);
credentials.insert(
"custom-service".to_string(),
CredentialSet::ApiKey {
header_name: "X-API-Key".to_string(),
token: "api-key-123".to_string(),
},
);
let config = DynamicConfig::new(AuthPolicy::empty()).with_credentials(credentials);
Arc::new(ArcSwap::new(Arc::new(config)))
}
fn make_dynamic_empty() -> Arc<ArcSwap<DynamicConfig>> {
let config = DynamicConfig::default();
Arc::new(ArcSwap::new(Arc::new(config)))
}
#[test]
fn config_credential_provider_returns_configured_credentials() {
let dynamic = make_dynamic_with_credentials();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("vast-ai");
assert!(creds.is_some());
match creds.unwrap() {
CredentialSet::Bearer { token } => assert_eq!(token, "secret-token"),
_ => panic!("expected Bearer variant"),
}
}
#[test]
fn config_credential_provider_returns_api_key_variant() {
let dynamic = make_dynamic_with_credentials();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("custom-service");
assert!(creds.is_some());
match creds.unwrap() {
CredentialSet::ApiKey { header_name, token } => {
assert_eq!(header_name, "X-API-Key");
assert_eq!(token, "api-key-123");
}
_ => panic!("expected ApiKey variant"),
}
}
#[test]
fn config_credential_provider_returns_none_for_unknown_service() {
let dynamic = make_dynamic_with_credentials();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("nonexistent");
assert!(creds.is_none());
}
#[test]
fn config_credential_provider_empty_config_returns_none() {
let dynamic = make_dynamic_empty();
let provider = ConfigCredentialProvider::new(dynamic);
let creds = provider.get_credentials("vast-ai");
assert!(creds.is_none());
}
#[test]
fn secret_store_credential_provider_returns_none() {
let provider = SecretStoreCredentialProvider::new();
assert!(provider.get_credentials("vast-ai").is_none());
assert!(provider.get_credentials("rustfs").is_none());
assert!(provider.get_credentials("gitea").is_none());
}
#[test]
fn secret_store_credential_provider_refresh_returns_none() {
let provider = SecretStoreCredentialProvider::new();
assert!(provider.refresh_credentials("vast-ai").is_none());
}
#[test]
fn credential_set_bearer_serialization() {
let creds = CredentialSet::Bearer {
token: "tok".to_string(),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_s3_access_key_serialization() {
let creds = CredentialSet::S3AccessKey {
access_key: "AKIA123".to_string(),
secret_key: "secret".to_string(),
session_token: Some("session".to_string()),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_oidc_token_serialization() {
let creds = CredentialSet::OidcToken {
access_token: "access".to_string(),
refresh_token: Some("refresh".to_string()),
expires_at: Some(1234567890),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_custom_serialization() {
let mut params = HashMap::new();
params.insert("key1".to_string(), "val1".to_string());
let creds = CredentialSet::Custom {
scheme: "X-Custom".to_string(),
params,
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_basic_serialization() {
let creds = CredentialSet::Basic {
username: "user".to_string(),
password: "pass".to_string(),
};
let json = serde_json::to_string(&creds).unwrap();
let deserialized: CredentialSet = serde_json::from_str(&json).unwrap();
assert_eq!(creds, deserialized);
}
#[test]
fn credential_set_clone() {
let creds = CredentialSet::Bearer {
token: "tok".to_string(),
};
let cloned = creds.clone();
assert_eq!(creds, cloned);
}
}

View File

@@ -55,6 +55,7 @@ pub mod auth;
pub mod call;
pub mod client;
pub mod config;
pub mod credentials;
pub mod error;
pub mod interface;
pub mod server;
@@ -84,6 +85,9 @@ pub use config::{
AuthPolicy, ConfigReloadHandle, ConfigServiceImpl, DynamicConfig, ForwardingAction,
ForwardingPolicy, ForwardingRule, RateLimitConfig, StaticConfig, TargetPattern,
};
pub use credentials::{
ConfigCredentialProvider, CredentialProvider, CredentialSet, SecretStoreCredentialProvider,
};
pub use error::{AuthError, ChannelError, ConfigError, ForwardError, TransportError};
pub use interface::{
is_valid_pair, DnsInterface, DnsInterfaceConfig, HttpInterface, HttpInterfaceConfig,

View File

@@ -869,6 +869,7 @@ mod tests {
auth: dynamic.auth.clone(),
forwarding: deny_policy,
rate_limits: dynamic.rate_limits.clone(),
credentials: dynamic.credentials.clone(),
};
drop(dynamic);
auth_config.store(Arc::new(new_dynamic));