From 74c1e8d42cea1e4acb43b94953c303a75a6b3f16 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Sun, 28 Jun 2026 21:35:41 +0000 Subject: [PATCH] feat(core): add IdentityStore async write trait extending IdentityProvider (core/identity-store-trait) --- crates/alknet-core/src/auth.rs | 172 ++++++++++++++++++++++++++++++++- crates/alknet-core/src/lib.rs | 1 + 2 files changed, 172 insertions(+), 1 deletion(-) diff --git a/crates/alknet-core/src/auth.rs b/crates/alknet-core/src/auth.rs index 27f372c..8eac476 100644 --- a/crates/alknet-core/src/auth.rs +++ b/crates/alknet-core/src/auth.rs @@ -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; } +/// 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>, } @@ -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() {} + fn assert_not_store() {} + assert_provider::(); + assert_not_store::(); + } +} + +#[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>, + } + + impl MockIdentityStore { + fn new() -> Self { + Self { + peers: RwLock::new(HashMap::new()), + } + } + } + + impl IdentityProvider for MockIdentityStore { + fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option { + 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 { + 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() {} + assert_provider::(); + } } diff --git a/crates/alknet-core/src/lib.rs b/crates/alknet-core/src/lib.rs index 29e3f71..46a3515 100644 --- a/crates/alknet-core/src/lib.rs +++ b/crates/alknet-core/src/lib.rs @@ -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};