feat(core): add IdentityStore async write trait extending IdentityProvider (core/identity-store-trait)
This commit is contained in:
@@ -54,8 +54,10 @@ use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::config::DynamicConfig;
|
||||
use crate::config::{DynamicConfig, PeerEntry};
|
||||
use crate::store::StoreError;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Identity {
|
||||
@@ -82,6 +84,18 @@ pub trait IdentityProvider: Send + Sync + 'static {
|
||||
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
|
||||
}
|
||||
|
||||
/// Write trait — management path, async (ADR-035). `ConfigIdentityProvider`
|
||||
/// does NOT implement this (config reload is its write path). A persistence
|
||||
/// adapter (e.g. `SqliteIdentityProvider` in `alknet-store-sqlite`) does:
|
||||
/// writes hit the backend, emit a honker `NOTIFY`, and the local `LISTEN`
|
||||
/// refreshes the in-memory read index.
|
||||
#[async_trait]
|
||||
pub trait IdentityStore: IdentityProvider {
|
||||
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError>;
|
||||
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError>;
|
||||
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError>;
|
||||
}
|
||||
|
||||
pub struct ConfigIdentityProvider {
|
||||
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||
}
|
||||
@@ -342,4 +356,160 @@ mod tests {
|
||||
|
||||
assert!(provider.resolve_from_fingerprint("SHA256:abc123").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_identity_provider_is_identity_provider_not_store() {
|
||||
fn assert_provider<T: IdentityProvider>() {}
|
||||
fn assert_not_store<T>() {}
|
||||
assert_provider::<ConfigIdentityProvider>();
|
||||
assert_not_store::<ConfigIdentityProvider>();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod identity_store_tests {
|
||||
use super::*;
|
||||
use crate::config::PeerEntry;
|
||||
use std::collections::HashMap as StdHashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
fn make_peer(peer_id: &str) -> PeerEntry {
|
||||
PeerEntry {
|
||||
peer_id: peer_id.to_string(),
|
||||
fingerprints: vec![format!("SHA256:{peer_id}")],
|
||||
auth_token_hash: None,
|
||||
scopes: vec!["relay:connect".to_string()],
|
||||
resources: StdHashMap::new(),
|
||||
display_name: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
struct MockIdentityStore {
|
||||
peers: RwLock<HashMap<String, PeerEntry>>,
|
||||
}
|
||||
|
||||
impl MockIdentityStore {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
peers: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IdentityProvider for MockIdentityStore {
|
||||
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||
let peers = self.peers.read().unwrap_or_else(|e| e.into_inner());
|
||||
peers.values().find_map(|p| {
|
||||
if p.fingerprints.iter().any(|f| f == fingerprint) && p.enabled {
|
||||
Some(Identity {
|
||||
id: p.peer_id.clone(),
|
||||
scopes: p.scopes.clone(),
|
||||
resources: p.resources.clone(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl IdentityStore for MockIdentityStore {
|
||||
async fn put_peer(&self, peer: &PeerEntry) -> Result<(), StoreError> {
|
||||
let mut peers = self.peers.write().unwrap_or_else(|e| e.into_inner());
|
||||
peers.insert(peer.peer_id.clone(), peer.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_peer(&self, peer_id: &str, peer: &PeerEntry) -> Result<(), StoreError> {
|
||||
let mut peers = self.peers.write().unwrap_or_else(|e| e.into_inner());
|
||||
if !peers.contains_key(peer_id) {
|
||||
return Err(StoreError::NotFound {
|
||||
entity: peer_id.to_string(),
|
||||
});
|
||||
}
|
||||
peers.remove(peer_id);
|
||||
peers.insert(peer.peer_id.clone(), peer.clone());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn remove_peer(&self, peer_id: &str) -> Result<(), StoreError> {
|
||||
let mut peers = self.peers.write().unwrap_or_else(|e| e.into_inner());
|
||||
if peers.remove(peer_id).is_none() {
|
||||
return Err(StoreError::NotFound {
|
||||
entity: peer_id.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_put_peer_upserts() {
|
||||
let store = MockIdentityStore::new();
|
||||
let mut peer = make_peer("worker-a");
|
||||
store.put_peer(&peer).await.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.resolve_from_fingerprint("SHA256:worker-a")
|
||||
.unwrap()
|
||||
.id,
|
||||
"worker-a"
|
||||
);
|
||||
|
||||
peer.display_name = Some("renamed".to_string());
|
||||
store.put_peer(&peer).await.unwrap();
|
||||
let peers = store.peers.read().unwrap_or_else(|e| e.into_inner());
|
||||
assert_eq!(peers.len(), 1);
|
||||
assert_eq!(
|
||||
peers.get("worker-a").unwrap().display_name.as_deref(),
|
||||
Some("renamed")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_update_peer_existing_succeeds() {
|
||||
let store = MockIdentityStore::new();
|
||||
store.put_peer(&make_peer("worker-a")).await.unwrap();
|
||||
let updated = make_peer("worker-b");
|
||||
store.update_peer("worker-a", &updated).await.unwrap();
|
||||
assert!(store.resolve_from_fingerprint("SHA256:worker-a").is_none());
|
||||
assert!(store.resolve_from_fingerprint("SHA256:worker-b").is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_update_peer_missing_returns_not_found() {
|
||||
let store = MockIdentityStore::new();
|
||||
let err = store
|
||||
.update_peer("ghost", &make_peer("ghost"))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, StoreError::NotFound { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_remove_peer_existing_succeeds() {
|
||||
let store = MockIdentityStore::new();
|
||||
store.put_peer(&make_peer("worker-a")).await.unwrap();
|
||||
store.remove_peer("worker-a").await.unwrap();
|
||||
assert!(store.resolve_from_fingerprint("SHA256:worker-a").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mock_remove_peer_missing_returns_not_found() {
|
||||
let store = MockIdentityStore::new();
|
||||
let err = store.remove_peer("ghost").await.unwrap_err();
|
||||
assert!(matches!(err, StoreError::NotFound { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mock_identity_store_is_identity_provider() {
|
||||
fn assert_provider<T: IdentityProvider>() {}
|
||||
assert_provider::<MockIdentityStore>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,5 @@ pub mod endpoint;
|
||||
pub mod store;
|
||||
pub mod types;
|
||||
|
||||
pub use auth::{IdentityProvider, IdentityStore};
|
||||
pub use store::{CredentialStore, EncryptedData, InMemoryCredentialStore, StoreError};
|
||||
|
||||
Reference in New Issue
Block a user