feat(core): add PeerEntry struct and replace AuthPolicy.authorized_fingerprints with peers (core/peer-entry-model)
This commit is contained in:
@@ -55,14 +55,14 @@ impl IdentityProvider for ConfigIdentityProvider {
|
|||||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity> {
|
||||||
let config = self.dynamic.load();
|
let config = self.dynamic.load();
|
||||||
let token_str = String::from_utf8_lossy(&token.raw);
|
let token_str = String::from_utf8_lossy(&token.raw);
|
||||||
config.auth.resolve_api_key(&token_str)
|
config.auth.resolve_identity_from_token(&token_str)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::config::{ApiKeyEntry, AuthPolicy, DynamicConfig, RateLimitConfig};
|
use crate::config::{ApiKeyEntry, AuthPolicy, DynamicConfig, PeerEntry, RateLimitConfig};
|
||||||
|
|
||||||
fn compute_api_key_hash(token: &str) -> String {
|
fn compute_api_key_hash(token: &str) -> String {
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -80,12 +80,22 @@ mod tests {
|
|||||||
(provider, arc_swap)
|
(provider, arc_swap)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config_with_fingerprint(fingerprint: &str) -> DynamicConfig {
|
fn peer_entry_with_fingerprint(peer_id: &str, fingerprint: &str) -> PeerEntry {
|
||||||
let mut fingerprints = std::collections::HashSet::new();
|
PeerEntry {
|
||||||
fingerprints.insert(fingerprint.to_string());
|
peer_id: peer_id.to_string(),
|
||||||
|
fingerprints: vec![fingerprint.to_string()],
|
||||||
|
auth_token_hash: None,
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: std::collections::HashMap::new(),
|
||||||
|
display_name: None,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_with_fingerprint(peer_id: &str, fingerprint: &str) -> DynamicConfig {
|
||||||
DynamicConfig {
|
DynamicConfig {
|
||||||
auth: AuthPolicy {
|
auth: AuthPolicy {
|
||||||
authorized_fingerprints: fingerprints,
|
peers: vec![peer_entry_with_fingerprint(peer_id, fingerprint)],
|
||||||
api_keys: Vec::new(),
|
api_keys: Vec::new(),
|
||||||
},
|
},
|
||||||
rate_limits: RateLimitConfig::default(),
|
rate_limits: RateLimitConfig::default(),
|
||||||
@@ -95,7 +105,7 @@ mod tests {
|
|||||||
fn config_with_api_key(entry: ApiKeyEntry) -> DynamicConfig {
|
fn config_with_api_key(entry: ApiKeyEntry) -> DynamicConfig {
|
||||||
DynamicConfig {
|
DynamicConfig {
|
||||||
auth: AuthPolicy {
|
auth: AuthPolicy {
|
||||||
authorized_fingerprints: std::collections::HashSet::new(),
|
peers: Vec::new(),
|
||||||
api_keys: vec![entry],
|
api_keys: vec![entry],
|
||||||
},
|
},
|
||||||
rate_limits: RateLimitConfig::default(),
|
rate_limits: RateLimitConfig::default(),
|
||||||
@@ -143,18 +153,18 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fingerprint_resolution_known_returns_some() {
|
fn fingerprint_resolution_known_returns_some() {
|
||||||
let (provider, _) = make_provider(config_with_fingerprint("SHA256:abc123"));
|
let (provider, _) = make_provider(config_with_fingerprint("worker-a", "SHA256:abc123"));
|
||||||
let identity = provider
|
let identity = provider
|
||||||
.resolve_from_fingerprint("SHA256:abc123")
|
.resolve_from_fingerprint("SHA256:abc123")
|
||||||
.expect("known fingerprint resolves");
|
.expect("known fingerprint resolves");
|
||||||
assert_eq!(identity.id, "SHA256:abc123");
|
assert_eq!(identity.id, "worker-a");
|
||||||
assert_eq!(identity.scopes, vec!["relay:connect".to_string()]);
|
assert_eq!(identity.scopes, vec!["relay:connect".to_string()]);
|
||||||
assert!(identity.resources.is_empty());
|
assert!(identity.resources.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fingerprint_resolution_unknown_returns_none() {
|
fn fingerprint_resolution_unknown_returns_none() {
|
||||||
let (provider, _) = make_provider(config_with_fingerprint("SHA256:abc123"));
|
let (provider, _) = make_provider(config_with_fingerprint("worker-a", "SHA256:abc123"));
|
||||||
assert!(provider
|
assert!(provider
|
||||||
.resolve_from_fingerprint("SHA256:unknown")
|
.resolve_from_fingerprint("SHA256:unknown")
|
||||||
.is_none());
|
.is_none());
|
||||||
@@ -256,7 +266,7 @@ mod tests {
|
|||||||
let (provider, arc_swap) = make_provider(DynamicConfig::default());
|
let (provider, arc_swap) = make_provider(DynamicConfig::default());
|
||||||
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
|
||||||
|
|
||||||
let new_config = config_with_fingerprint("SHA256:abc123");
|
let new_config = config_with_fingerprint("worker-a", "SHA256:abc123");
|
||||||
arc_swap.store(Arc::new(new_config));
|
arc_swap.store(Arc::new(new_config));
|
||||||
|
|
||||||
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
||||||
@@ -264,7 +274,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn config_reload_removes_fingerprint_access_immediately() {
|
fn config_reload_removes_fingerprint_access_immediately() {
|
||||||
let (provider, arc_swap) = make_provider(config_with_fingerprint("SHA256:abc123"));
|
let (provider, arc_swap) =
|
||||||
|
make_provider(config_with_fingerprint("worker-a", "SHA256:abc123"));
|
||||||
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
||||||
|
|
||||||
arc_swap.store(Arc::new(DynamicConfig::default()));
|
arc_swap.store(Arc::new(DynamicConfig::default()));
|
||||||
@@ -281,7 +292,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_none());
|
||||||
|
|
||||||
handle.reload(config_with_fingerprint("SHA256:abc123"));
|
handle.reload(config_with_fingerprint("worker-a", "SHA256:abc123"));
|
||||||
|
|
||||||
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
//! `auth::ConfigIdentityProvider`. The remaining types (`StaticConfig`,
|
//! `auth::ConfigIdentityProvider`. The remaining types (`StaticConfig`,
|
||||||
//! `TlsIdentity`, `ConfigError`) are filled in by the core/config task.
|
//! `TlsIdentity`, `ConfigError`) are filled in by the core/config task.
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::HashMap;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -103,9 +103,20 @@ pub struct DynamicConfig {
|
|||||||
pub rate_limits: RateLimitConfig,
|
pub rate_limits: RateLimitConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct PeerEntry {
|
||||||
|
pub peer_id: String,
|
||||||
|
pub fingerprints: Vec<String>,
|
||||||
|
pub auth_token_hash: Option<String>,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub resources: HashMap<String, Vec<String>>,
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct AuthPolicy {
|
pub struct AuthPolicy {
|
||||||
pub authorized_fingerprints: HashSet<String>,
|
pub peers: Vec<PeerEntry>,
|
||||||
pub api_keys: Vec<ApiKeyEntry>,
|
pub api_keys: Vec<ApiKeyEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,15 +135,39 @@ impl AuthPolicy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||||
if self.authorized_fingerprints.contains(fingerprint) {
|
self.peers
|
||||||
Some(Identity {
|
.iter()
|
||||||
id: fingerprint.to_string(),
|
.find(|p| p.enabled && p.fingerprints.iter().any(|f| f == fingerprint))
|
||||||
scopes: vec!["relay:connect".to_string()],
|
.map(|p| Identity {
|
||||||
resources: std::collections::HashMap::new(),
|
id: p.peer_id.clone(),
|
||||||
|
scopes: p.scopes.clone(),
|
||||||
|
resources: p.resources.clone(),
|
||||||
})
|
})
|
||||||
} else {
|
}
|
||||||
None
|
|
||||||
|
pub fn resolve_identity_from_token(&self, token: &str) -> Option<Identity> {
|
||||||
|
let token_hash = sha256_hex(token);
|
||||||
|
self.peers
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.enabled && p.auth_token_hash.as_deref() == Some(&token_hash))
|
||||||
|
.map(|p| Identity {
|
||||||
|
id: p.peer_id.clone(),
|
||||||
|
scopes: p.scopes.clone(),
|
||||||
|
resources: p.resources.clone(),
|
||||||
|
})
|
||||||
|
.or_else(|| self.resolve_api_key(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_peer_ids(&self) -> Result<(), DuplicatePeerId> {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for peer in &self.peers {
|
||||||
|
if !seen.insert(peer.peer_id.as_str()) {
|
||||||
|
return Err(DuplicatePeerId {
|
||||||
|
peer_id: peer.peer_id.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve_api_key(&self, token: &str) -> Option<Identity> {
|
pub fn resolve_api_key(&self, token: &str) -> Option<Identity> {
|
||||||
@@ -147,11 +182,7 @@ impl AuthPolicy {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|e| prefix_part.starts_with(&e.prefix))?;
|
.find(|e| prefix_part.starts_with(&e.prefix))?;
|
||||||
|
|
||||||
use sha2::{Digest, Sha256};
|
let expected_hash = sha256_hex(token);
|
||||||
let mut hasher = Sha256::new();
|
|
||||||
hasher.update(token.as_bytes());
|
|
||||||
let result = hasher.finalize();
|
|
||||||
let expected_hash = format!("sha256:{}", hex::encode(result));
|
|
||||||
|
|
||||||
if entry.hash != expected_hash {
|
if entry.hash != expected_hash {
|
||||||
return None;
|
return None;
|
||||||
@@ -175,6 +206,20 @@ impl AuthPolicy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sha256_hex(input: &str) -> String {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(input.as_bytes());
|
||||||
|
let result = hasher.finalize();
|
||||||
|
format!("sha256:{}", hex::encode(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||||
|
#[error("duplicate peer_id: {peer_id}")]
|
||||||
|
pub struct DuplicatePeerId {
|
||||||
|
pub peer_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct RateLimitConfig {
|
pub struct RateLimitConfig {
|
||||||
pub max_connections_per_ip: usize,
|
pub max_connections_per_ip: usize,
|
||||||
@@ -249,7 +294,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn dynamic_config_default() {
|
fn dynamic_config_default() {
|
||||||
let cfg = DynamicConfig::default();
|
let cfg = DynamicConfig::default();
|
||||||
assert!(cfg.auth.authorized_fingerprints.is_empty());
|
assert!(cfg.auth.peers.is_empty());
|
||||||
assert!(cfg.auth.api_keys.is_empty());
|
assert!(cfg.auth.api_keys.is_empty());
|
||||||
assert_eq!(cfg.rate_limits.max_connections_per_ip, 100);
|
assert_eq!(cfg.rate_limits.max_connections_per_ip, 100);
|
||||||
assert_eq!(cfg.rate_limits.max_auth_attempts, 5);
|
assert_eq!(cfg.rate_limits.max_auth_attempts, 5);
|
||||||
@@ -258,7 +303,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn auth_policy_default() {
|
fn auth_policy_default() {
|
||||||
let policy = AuthPolicy::default();
|
let policy = AuthPolicy::default();
|
||||||
assert!(policy.authorized_fingerprints.is_empty());
|
assert!(policy.peers.is_empty());
|
||||||
assert!(policy.api_keys.is_empty());
|
assert!(policy.api_keys.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,12 +356,20 @@ mod tests {
|
|||||||
let handle = ConfigReloadHandle::new(dynamic.clone());
|
let handle = ConfigReloadHandle::new(dynamic.clone());
|
||||||
|
|
||||||
let initial = handle.dynamic();
|
let initial = handle.dynamic();
|
||||||
assert!(initial.auth.authorized_fingerprints.is_empty());
|
assert!(initial.auth.peers.is_empty());
|
||||||
|
|
||||||
let mut new_auth = AuthPolicy::default();
|
let new_auth = AuthPolicy {
|
||||||
new_auth
|
peers: vec![PeerEntry {
|
||||||
.authorized_fingerprints
|
peer_id: "worker-a".to_string(),
|
||||||
.insert("aa:bb:cc".to_string());
|
fingerprints: vec!["aa:bb:cc".to_string()],
|
||||||
|
auth_token_hash: None,
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
display_name: None,
|
||||||
|
enabled: true,
|
||||||
|
}],
|
||||||
|
api_keys: Vec::new(),
|
||||||
|
};
|
||||||
let new_config = DynamicConfig {
|
let new_config = DynamicConfig {
|
||||||
auth: new_auth,
|
auth: new_auth,
|
||||||
rate_limits: RateLimitConfig::default(),
|
rate_limits: RateLimitConfig::default(),
|
||||||
@@ -324,8 +377,9 @@ mod tests {
|
|||||||
handle.reload(new_config);
|
handle.reload(new_config);
|
||||||
|
|
||||||
let after = handle.dynamic();
|
let after = handle.dynamic();
|
||||||
assert!(after.auth.authorized_fingerprints.contains("aa:bb:cc"));
|
assert_eq!(after.auth.peers.len(), 1);
|
||||||
assert!(initial.auth.authorized_fingerprints.is_empty());
|
assert_eq!(after.auth.peers[0].peer_id, "worker-a");
|
||||||
|
assert!(initial.auth.peers.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -378,11 +432,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_api_key_returns_empty_resources() {
|
fn resolve_api_key_returns_empty_resources() {
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
let token = "alk_test_secret";
|
let token = "alk_test_secret";
|
||||||
let mut hasher = Sha256::new();
|
let hash = sha256_hex(token);
|
||||||
hasher.update(token.as_bytes());
|
|
||||||
let hash = format!("sha256:{}", hex::encode(hasher.finalize()));
|
|
||||||
|
|
||||||
let entry = ApiKeyEntry {
|
let entry = ApiKeyEntry {
|
||||||
prefix: "alk_tes".to_string(),
|
prefix: "alk_tes".to_string(),
|
||||||
@@ -392,12 +443,15 @@ mod tests {
|
|||||||
expires_at: None,
|
expires_at: None,
|
||||||
};
|
};
|
||||||
let policy = AuthPolicy {
|
let policy = AuthPolicy {
|
||||||
authorized_fingerprints: HashSet::new(),
|
peers: Vec::new(),
|
||||||
api_keys: vec![entry],
|
api_keys: vec![entry],
|
||||||
};
|
};
|
||||||
|
|
||||||
let identity = policy.resolve_api_key(token);
|
let identity = policy.resolve_api_key(token);
|
||||||
assert!(identity.is_some(), "api key with matching prefix and hash should resolve");
|
assert!(
|
||||||
|
identity.is_some(),
|
||||||
|
"api key with matching prefix and hash should resolve"
|
||||||
|
);
|
||||||
let identity = identity.unwrap();
|
let identity = identity.unwrap();
|
||||||
assert_eq!(identity.id, "alk_tes");
|
assert_eq!(identity.id, "alk_tes");
|
||||||
assert_eq!(identity.scopes, vec!["admin"]);
|
assert_eq!(identity.scopes, vec!["admin"]);
|
||||||
@@ -408,20 +462,198 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_identity_from_fingerprint_returns_empty_resources() {
|
fn resolve_identity_from_fingerprint_uses_peer_id() {
|
||||||
let policy = AuthPolicy {
|
let policy = AuthPolicy {
|
||||||
authorized_fingerprints: HashSet::from(["SHA256:known".to_string()]),
|
peers: vec![PeerEntry {
|
||||||
|
peer_id: "worker-a".to_string(),
|
||||||
|
fingerprints: vec!["SHA256:known".to_string()],
|
||||||
|
auth_token_hash: None,
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
display_name: None,
|
||||||
|
enabled: true,
|
||||||
|
}],
|
||||||
api_keys: vec![],
|
api_keys: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
let identity = policy
|
let identity = policy
|
||||||
.resolve_identity_from_fingerprint("SHA256:known")
|
.resolve_identity_from_fingerprint("SHA256:known")
|
||||||
.expect("known fingerprint should resolve");
|
.expect("known fingerprint should resolve");
|
||||||
assert_eq!(identity.id, "SHA256:known");
|
assert_eq!(identity.id, "worker-a");
|
||||||
assert!(
|
assert_eq!(identity.scopes, vec!["relay:connect"]);
|
||||||
identity.resources.is_empty(),
|
}
|
||||||
"fingerprint-resolved identities must have empty resources (Option B — scopes only)"
|
|
||||||
);
|
// --- PeerEntry model (ADR-030) ---------------------------------------
|
||||||
|
|
||||||
|
fn peer_entry(peer_id: &str, fingerprints: &[&str]) -> PeerEntry {
|
||||||
|
PeerEntry {
|
||||||
|
peer_id: peer_id.to_string(),
|
||||||
|
fingerprints: fingerprints.iter().map(|s| s.to_string()).collect(),
|
||||||
|
auth_token_hash: None,
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
display_name: None,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_resolution_known_returns_some_with_peer_id() {
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![peer_entry("worker-a", &["ed25519:abc"])],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
let identity = policy
|
||||||
|
.resolve_identity_from_fingerprint("ed25519:abc")
|
||||||
|
.expect("known fingerprint resolves");
|
||||||
|
assert_eq!(identity.id, "worker-a");
|
||||||
|
assert_eq!(identity.scopes, vec!["relay:connect"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_resolution_unknown_returns_none() {
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![peer_entry("worker-a", &["ed25519:abc"])],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
assert!(policy
|
||||||
|
.resolve_identity_from_fingerprint("ed25519:unknown")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_resolution_disabled_returns_none() {
|
||||||
|
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
|
||||||
|
entry.enabled = false;
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![entry],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
assert!(policy
|
||||||
|
.resolve_identity_from_fingerprint("ed25519:abc")
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_matching_peer_returns_some_with_peer_id() {
|
||||||
|
let token = "bearer-secret";
|
||||||
|
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
|
||||||
|
entry.auth_token_hash = Some(sha256_hex(token));
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![entry],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
let identity = policy
|
||||||
|
.resolve_identity_from_token(token)
|
||||||
|
.expect("matching auth_token_hash resolves");
|
||||||
|
assert_eq!(identity.id, "worker-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_non_matching_falls_through_to_api_key() {
|
||||||
|
let api_token = "alk_test_secret";
|
||||||
|
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
|
||||||
|
entry.auth_token_hash = Some(sha256_hex("different-token"));
|
||||||
|
let api_entry = ApiKeyEntry {
|
||||||
|
prefix: "alk_tes".to_string(),
|
||||||
|
hash: sha256_hex(api_token),
|
||||||
|
scopes: vec!["admin".to_string()],
|
||||||
|
description: "test key".to_string(),
|
||||||
|
expires_at: None,
|
||||||
|
};
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![entry],
|
||||||
|
api_keys: vec![api_entry],
|
||||||
|
};
|
||||||
|
let identity = policy
|
||||||
|
.resolve_identity_from_token(api_token)
|
||||||
|
.expect("api key fall-through resolves");
|
||||||
|
assert_eq!(identity.id, "alk_tes");
|
||||||
|
assert_eq!(identity.scopes, vec!["admin"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn token_resolution_no_match_returns_none() {
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![peer_entry("worker-a", &["ed25519:abc"])],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
assert!(policy.resolve_identity_from_token("unknown").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_fingerprint_peer_any_resolves_to_same_peer_id() {
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![peer_entry("worker-a", &["ed25519:abc", "SHA256:def"])],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
let id1 = policy
|
||||||
|
.resolve_identity_from_fingerprint("ed25519:abc")
|
||||||
|
.expect("first fingerprint resolves");
|
||||||
|
let id2 = policy
|
||||||
|
.resolve_identity_from_fingerprint("SHA256:def")
|
||||||
|
.expect("second fingerprint resolves");
|
||||||
|
assert_eq!(id1.id, "worker-a");
|
||||||
|
assert_eq!(id2.id, "worker-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resources_populated_on_fingerprint_path() {
|
||||||
|
let mut resources = HashMap::new();
|
||||||
|
resources.insert("service".to_string(), vec!["gitea".to_string()]);
|
||||||
|
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
|
||||||
|
entry.resources = resources.clone();
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![entry],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
let identity = policy
|
||||||
|
.resolve_identity_from_fingerprint("ed25519:abc")
|
||||||
|
.expect("known fingerprint resolves");
|
||||||
|
assert_eq!(identity.resources, resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resources_populated_on_token_path() {
|
||||||
|
let token = "bearer-secret";
|
||||||
|
let mut resources = HashMap::new();
|
||||||
|
resources.insert("service".to_string(), vec!["gitea".to_string()]);
|
||||||
|
let mut entry = peer_entry("worker-a", &["ed25519:abc"]);
|
||||||
|
entry.auth_token_hash = Some(sha256_hex(token));
|
||||||
|
entry.resources = resources.clone();
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![entry],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
let identity = policy
|
||||||
|
.resolve_identity_from_token(token)
|
||||||
|
.expect("matching token resolves");
|
||||||
|
assert_eq!(identity.resources, resources);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_peer_id_validation_rejects() {
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![
|
||||||
|
peer_entry("worker-a", &["ed25519:abc"]),
|
||||||
|
peer_entry("worker-a", &["ed25519:def"]),
|
||||||
|
],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
let err = policy.validate_peer_ids().expect_err("duplicate detected");
|
||||||
|
assert_eq!(err.peer_id, "worker-a");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unique_peer_ids_validate_ok() {
|
||||||
|
let policy = AuthPolicy {
|
||||||
|
peers: vec![
|
||||||
|
peer_entry("worker-a", &["ed25519:abc"]),
|
||||||
|
peer_entry("worker-b", &["ed25519:def"]),
|
||||||
|
],
|
||||||
|
api_keys: vec![],
|
||||||
|
};
|
||||||
|
assert!(policy.validate_peer_ids().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Ed25519SecretKey -------------------------------------------------
|
// --- Ed25519SecretKey -------------------------------------------------
|
||||||
|
|||||||
@@ -140,8 +140,7 @@ impl AlknetEndpoint {
|
|||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let tls_setup = TlsSetup::new(tls_identity, &alpns).await?;
|
let tls_setup = TlsSetup::new(tls_identity, &alpns).await?;
|
||||||
let server_config =
|
let server_config = build_quinn_server_config_from_rustls(tls_setup.server_config)?;
|
||||||
build_quinn_server_config_from_rustls(tls_setup.server_config)?;
|
|
||||||
let endpoint = quinn::Endpoint::server(server_config, listen_addr)
|
let endpoint = quinn::Endpoint::server(server_config, listen_addr)
|
||||||
.map_err(EndpointError::BindFailed)?;
|
.map_err(EndpointError::BindFailed)?;
|
||||||
#[cfg(feature = "acme")]
|
#[cfg(feature = "acme")]
|
||||||
@@ -482,10 +481,7 @@ struct TlsSetup {
|
|||||||
}
|
}
|
||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
impl TlsSetup {
|
impl TlsSetup {
|
||||||
async fn new(
|
async fn new(tls_identity: &TlsIdentity, alpns: &[Vec<u8>]) -> Result<Self, EndpointError> {
|
||||||
tls_identity: &TlsIdentity,
|
|
||||||
alpns: &[Vec<u8>],
|
|
||||||
) -> Result<Self, EndpointError> {
|
|
||||||
match tls_identity {
|
match tls_identity {
|
||||||
TlsIdentity::Acme {
|
TlsIdentity::Acme {
|
||||||
domains,
|
domains,
|
||||||
@@ -1084,7 +1080,9 @@ mod tests {
|
|||||||
async fn endpoint_constructs_with_iroh_raw_key_identity() {
|
async fn endpoint_constructs_with_iroh_raw_key_identity() {
|
||||||
let static_config = StaticConfig {
|
let static_config = StaticConfig {
|
||||||
listen_addr: None,
|
listen_addr: None,
|
||||||
tls_identity: Some(TlsIdentity::RawKey(crate::config::Ed25519SecretKey::generate())),
|
tls_identity: Some(TlsIdentity::RawKey(
|
||||||
|
crate::config::Ed25519SecretKey::generate(),
|
||||||
|
)),
|
||||||
iroh_relay: None,
|
iroh_relay: None,
|
||||||
drain_timeout: Duration::from_millis(10),
|
drain_timeout: Duration::from_millis(10),
|
||||||
};
|
};
|
||||||
@@ -1265,10 +1263,7 @@ mod tests {
|
|||||||
fn acme_directory_production_url() {
|
fn acme_directory_production_url() {
|
||||||
use crate::config::AcmeDirectory;
|
use crate::config::AcmeDirectory;
|
||||||
let dir = AcmeDirectory::Production;
|
let dir = AcmeDirectory::Production;
|
||||||
assert_eq!(
|
assert_eq!(dir.url(), "https://acme-v02.api.letsencrypt.org/directory");
|
||||||
dir.url(),
|
|
||||||
"https://acme-v02.api.letsencrypt.org/directory"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1340,7 +1335,9 @@ mod tests {
|
|||||||
fn has_iroh_identity_true_for_raw_key() {
|
fn has_iroh_identity_true_for_raw_key() {
|
||||||
let cfg = StaticConfig {
|
let cfg = StaticConfig {
|
||||||
listen_addr: None,
|
listen_addr: None,
|
||||||
tls_identity: Some(TlsIdentity::RawKey(crate::config::Ed25519SecretKey::generate())),
|
tls_identity: Some(TlsIdentity::RawKey(
|
||||||
|
crate::config::Ed25519SecretKey::generate(),
|
||||||
|
)),
|
||||||
iroh_relay: None,
|
iroh_relay: None,
|
||||||
drain_timeout: Duration::from_millis(10),
|
drain_timeout: Duration::from_millis(10),
|
||||||
};
|
};
|
||||||
@@ -1437,7 +1434,9 @@ mod tests {
|
|||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
#[test]
|
#[test]
|
||||||
fn load_private_key_returns_error_when_file_missing() {
|
fn load_private_key_returns_error_when_file_missing() {
|
||||||
let err = load_private_key(std::path::Path::new("/nonexistent/alknet-coverage/missing.key"));
|
let err = load_private_key(std::path::Path::new(
|
||||||
|
"/nonexistent/alknet-coverage/missing.key",
|
||||||
|
));
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, Err(EndpointError::TlsConfig(_))),
|
matches!(err, Err(EndpointError::TlsConfig(_))),
|
||||||
"missing key file must yield TlsConfig error, got {err:?}"
|
"missing key file must yield TlsConfig error, got {err:?}"
|
||||||
@@ -1447,7 +1446,9 @@ mod tests {
|
|||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
#[test]
|
#[test]
|
||||||
fn load_cert_chain_returns_error_when_file_missing() {
|
fn load_cert_chain_returns_error_when_file_missing() {
|
||||||
let err = load_cert_chain(std::path::Path::new("/nonexistent/alknet-coverage/missing.pem"));
|
let err = load_cert_chain(std::path::Path::new(
|
||||||
|
"/nonexistent/alknet-coverage/missing.pem",
|
||||||
|
));
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, Err(EndpointError::TlsConfig(_))),
|
matches!(err, Err(EndpointError::TlsConfig(_))),
|
||||||
"missing cert file must yield TlsConfig error, got {err:?}"
|
"missing cert file must yield TlsConfig error, got {err:?}"
|
||||||
@@ -1474,7 +1475,10 @@ mod tests {
|
|||||||
let verifier = AcceptAnyCertVerifier;
|
let verifier = AcceptAnyCertVerifier;
|
||||||
let cert = CertificateDer::from(b"fake-cert-der".to_vec());
|
let cert = CertificateDer::from(b"fake-cert-der".to_vec());
|
||||||
let result = verifier.verify_client_cert(&cert, &[], UnixTime::now());
|
let result = verifier.verify_client_cert(&cert, &[], UnixTime::now());
|
||||||
assert!(result.is_ok(), "AcceptAnyCertVerifier must accept any client cert");
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"AcceptAnyCertVerifier must accept any client cert"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
@@ -1505,7 +1509,10 @@ mod tests {
|
|||||||
let sk = crate::config::Ed25519SecretKey::generate();
|
let sk = crate::config::Ed25519SecretKey::generate();
|
||||||
let signing_key = Ed25519SigningKey::new(sk);
|
let signing_key = Ed25519SigningKey::new(sk);
|
||||||
let signer = signing_key.choose_scheme(&[rustls::SignatureScheme::ED25519]);
|
let signer = signing_key.choose_scheme(&[rustls::SignatureScheme::ED25519]);
|
||||||
assert!(signer.is_some(), "must produce a signer when ED25519 is offered");
|
assert!(
|
||||||
|
signer.is_some(),
|
||||||
|
"must produce a signer when ED25519 is offered"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "quinn")]
|
#[cfg(feature = "quinn")]
|
||||||
@@ -1581,6 +1588,7 @@ mod tests {
|
|||||||
let static_config = StaticConfig {
|
let static_config = StaticConfig {
|
||||||
listen_addr: None,
|
listen_addr: None,
|
||||||
tls_identity: Some(TlsIdentity::RawKey(sk)),
|
tls_identity: Some(TlsIdentity::RawKey(sk)),
|
||||||
|
#[cfg(feature = "iroh")]
|
||||||
iroh_relay: None,
|
iroh_relay: None,
|
||||||
drain_timeout: Duration::from_millis(10),
|
drain_timeout: Duration::from_millis(10),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -691,11 +691,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn handler_error_display_covers_all_variants() {
|
fn handler_error_display_covers_all_variants() {
|
||||||
assert_eq!(format!("{}", HandlerError::ConnectionClosed), "connection closed");
|
assert_eq!(
|
||||||
|
format!("{}", HandlerError::ConnectionClosed),
|
||||||
|
"connection closed"
|
||||||
|
);
|
||||||
let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "boom");
|
let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "boom");
|
||||||
let s = format!("{}", HandlerError::StreamError(io_err));
|
let s = format!("{}", HandlerError::StreamError(io_err));
|
||||||
assert!(s.starts_with("stream error: "));
|
assert!(s.starts_with("stream error: "));
|
||||||
assert_eq!(format!("{}", HandlerError::AuthRequired), "authentication required");
|
assert_eq!(
|
||||||
|
format!("{}", HandlerError::AuthRequired),
|
||||||
|
"authentication required"
|
||||||
|
);
|
||||||
let inner: Box<dyn std::error::Error + Send + Sync> = "oops".into();
|
let inner: Box<dyn std::error::Error + Send + Sync> = "oops".into();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format!("{}", HandlerError::Internal(inner)),
|
format!("{}", HandlerError::Internal(inner)),
|
||||||
@@ -708,11 +714,18 @@ mod tests {
|
|||||||
use std::error::Error;
|
use std::error::Error;
|
||||||
assert!(HandlerError::ConnectionClosed.source().is_none());
|
assert!(HandlerError::ConnectionClosed.source().is_none());
|
||||||
assert!(HandlerError::AuthRequired.source().is_none());
|
assert!(HandlerError::AuthRequired.source().is_none());
|
||||||
let stream_err = HandlerError::StreamError(io::Error::new(io::ErrorKind::BrokenPipe, "boom"));
|
let stream_err =
|
||||||
assert!(stream_err.source().is_some(), "StreamError must expose its io::Error as source");
|
HandlerError::StreamError(io::Error::new(io::ErrorKind::BrokenPipe, "boom"));
|
||||||
|
assert!(
|
||||||
|
stream_err.source().is_some(),
|
||||||
|
"StreamError must expose its io::Error as source"
|
||||||
|
);
|
||||||
let internal_inner: Box<dyn std::error::Error + Send + Sync> = "boom".into();
|
let internal_inner: Box<dyn std::error::Error + Send + Sync> = "boom".into();
|
||||||
let internal_err = HandlerError::Internal(internal_inner);
|
let internal_err = HandlerError::Internal(internal_inner);
|
||||||
assert!(internal_err.source().is_some(), "Internal must expose its inner error as source");
|
assert!(
|
||||||
|
internal_err.source().is_some(),
|
||||||
|
"Internal must expose its inner error as source"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -725,14 +738,20 @@ mod tests {
|
|||||||
format!("{:?}", StreamError::StreamClosed),
|
format!("{:?}", StreamError::StreamClosed),
|
||||||
"StreamError::StreamClosed"
|
"StreamError::StreamClosed"
|
||||||
);
|
);
|
||||||
assert_eq!(format!("{:?}", StreamError::Timeout), "StreamError::Timeout");
|
assert_eq!(
|
||||||
|
format!("{:?}", StreamError::Timeout),
|
||||||
|
"StreamError::Timeout"
|
||||||
|
);
|
||||||
let dbg = format!("{:?}", StreamError::Internal(io::Error::other("x")));
|
let dbg = format!("{:?}", StreamError::Internal(io::Error::other("x")));
|
||||||
assert!(dbg.contains("StreamError::Internal"));
|
assert!(dbg.contains("StreamError::Internal"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stream_error_display_covers_all_variants() {
|
fn stream_error_display_covers_all_variants() {
|
||||||
assert_eq!(format!("{}", StreamError::ConnectionClosed), "connection closed");
|
assert_eq!(
|
||||||
|
format!("{}", StreamError::ConnectionClosed),
|
||||||
|
"connection closed"
|
||||||
|
);
|
||||||
assert_eq!(format!("{}", StreamError::StreamClosed), "stream closed");
|
assert_eq!(format!("{}", StreamError::StreamClosed), "stream closed");
|
||||||
assert_eq!(format!("{}", StreamError::Timeout), "stream timed out");
|
assert_eq!(format!("{}", StreamError::Timeout), "stream timed out");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -748,7 +767,10 @@ mod tests {
|
|||||||
assert!(StreamError::StreamClosed.source().is_none());
|
assert!(StreamError::StreamClosed.source().is_none());
|
||||||
assert!(StreamError::Timeout.source().is_none());
|
assert!(StreamError::Timeout.source().is_none());
|
||||||
let internal = StreamError::Internal(io::Error::other("x"));
|
let internal = StreamError::Internal(io::Error::other("x"));
|
||||||
assert!(internal.source().is_some(), "Internal must expose its io::Error as source");
|
assert!(
|
||||||
|
internal.source().is_some(),
|
||||||
|
"Internal must expose its io::Error as source"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- map_*_connection_error -------------------------------------------
|
// --- map_*_connection_error -------------------------------------------
|
||||||
@@ -817,12 +839,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn map_iroh_connection_error_application_closed_maps_to_connection_closed() {
|
fn map_iroh_connection_error_application_closed_maps_to_connection_closed() {
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
let close = iroh::endpoint::ConnectionError::ApplicationClosed(
|
let close =
|
||||||
iroh::endpoint::ApplicationClose {
|
iroh::endpoint::ConnectionError::ApplicationClosed(iroh::endpoint::ApplicationClose {
|
||||||
error_code: iroh::endpoint::VarInt::from_u32(1),
|
error_code: iroh::endpoint::VarInt::from_u32(1),
|
||||||
reason: Bytes::new(),
|
reason: Bytes::new(),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
map_iroh_connection_error(close),
|
map_iroh_connection_error(close),
|
||||||
StreamError::ConnectionClosed
|
StreamError::ConnectionClosed
|
||||||
|
|||||||
Reference in New Issue
Block a user