feat(core): add IdentityStore async write trait extending IdentityProvider (core/identity-store-trait)

This commit is contained in:
2026-06-28 21:35:41 +00:00
parent e0ecc9e370
commit 74c1e8d42c
2 changed files with 172 additions and 1 deletions

View File

@@ -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>();
}
}

View File

@@ -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};