refactor(daemon): AppState delegates to storage modules; dbus tests use tempdir
Lands plan tasks 2.15 (AppState refactor with StorageConfig + open()) and 2.16 (dbus.rs tests switched to tempdir-backed AppState; storage errors threaded through D-Bus methods as Failed). Bundled because the AppState surface change forces dbus.rs adjustments in the same commit. - daemon/src/state.rs: AppState::open(StorageConfig) replaces with_fixtures(). StorageConfig.from_env_or_defaults() reads AUTHFORGE_POLICY_DIR / _PENDING_DIR / _USERDB env vars (defaults: /etc/authforge/policy.d, /var/lib/authforge/pending, /var/lib/authforge/users.db). State delegates list/add/remove credentials to CredsPathResolver + CredentialsStore picked per-call from current Policy; pending and userdb operate independently. - daemon/src/main.rs: opens state via env-driven config; reuses cfg.policy_dir for the watcher to keep one source of truth. - daemon/src/dbus.rs: every write method maps StateError to fdo::Error::Failed. p2p_pair seeds 00-test.conf with [storage] backend = central pointing into the tempdir so credential writes don't try to touch /home/<user>/... (alice/bob/carol aren't real accounts in tests). - Renamed: list_credentials_returns_fixture_for_alice -> list_credentials_after_enroll. Removed: with_fixtures(). - .gitignore: add .claude/ so leftover Phase 1 worktree state isn't committed. Test count: 26/26 daemon tests green (was 17). Common: 13/13. Clippy + fmt clean.
This commit is contained in:
@@ -1,143 +1,187 @@
|
||||
use authforge_common::policy::Policy;
|
||||
use crate::storage::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver};
|
||||
use crate::storage::pending::{PendingError, PendingStore};
|
||||
use crate::storage::policy::PolicyStore;
|
||||
use crate::storage::userdb::{UserDb, UserDbError};
|
||||
use authforge_common::policy::{Policy, PolicyError};
|
||||
use authforge_common::types::{Credential, Method, PendingFlag, Transport};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::RwLock;
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
inner: RwLock<StateInner>,
|
||||
#[derive(Debug, Error)]
|
||||
pub enum StateError {
|
||||
#[error(transparent)]
|
||||
Policy(#[from] PolicyError),
|
||||
#[error(transparent)]
|
||||
Pending(#[from] PendingError),
|
||||
#[error(transparent)]
|
||||
Creds(#[from] CredsError),
|
||||
#[error(transparent)]
|
||||
UserDb(#[from] UserDbError),
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct StateInner {
|
||||
credentials: HashMap<String, Vec<Credential>>,
|
||||
policy: Policy,
|
||||
pending: HashMap<String, PendingFlag>,
|
||||
pub struct StorageConfig {
|
||||
pub policy_dir: PathBuf,
|
||||
pub pending_dir: PathBuf,
|
||||
pub userdb_path: PathBuf,
|
||||
}
|
||||
|
||||
impl StorageConfig {
|
||||
pub fn from_env_or_defaults() -> Self {
|
||||
let env = |k: &str, d: &str| -> PathBuf {
|
||||
std::env::var_os(k)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from(d))
|
||||
};
|
||||
Self {
|
||||
policy_dir: env("AUTHFORGE_POLICY_DIR", "/etc/authforge/policy.d"),
|
||||
pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"),
|
||||
userdb_path: env("AUTHFORGE_USERDB", "/var/lib/authforge/users.db"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
policy: PolicyStore,
|
||||
pending: PendingStore,
|
||||
userdb: Mutex<UserDb>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn with_fixtures() -> Self {
|
||||
let mut inner = StateInner::default();
|
||||
inner.credentials.insert(
|
||||
"alice".to_string(),
|
||||
vec![Credential {
|
||||
id: "fixture-cred-1".to_string(),
|
||||
nickname: "Alice's Yubikey".to_string(),
|
||||
method: Method::Fido2,
|
||||
transport: Transport::Usb,
|
||||
created_unix: 1_700_000_000,
|
||||
}],
|
||||
);
|
||||
Self {
|
||||
inner: RwLock::new(inner),
|
||||
}
|
||||
pub fn open(cfg: StorageConfig) -> Result<Self, StateError> {
|
||||
let policy = PolicyStore::new(cfg.policy_dir);
|
||||
let pending = PendingStore::new(cfg.pending_dir);
|
||||
let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?);
|
||||
Ok(Self {
|
||||
policy,
|
||||
pending,
|
||||
userdb,
|
||||
})
|
||||
}
|
||||
|
||||
fn creds_resolver(&self) -> Result<CredsPathResolver, StateError> {
|
||||
let pol = self.policy.load()?;
|
||||
Ok(CredsPathResolver::new(pol.storage))
|
||||
}
|
||||
|
||||
/// Phase 2 returns opaque cred IDs. Richer Credential metadata (real
|
||||
/// nickname, transport, created_unix) is recorded at enrollment time
|
||||
/// in Phase 3; until then the keyHandle stands in for both id and
|
||||
/// nickname so the GUI shows *something*.
|
||||
pub async fn list_credentials(&self, user: &str) -> Vec<Credential> {
|
||||
self.inner
|
||||
.read()
|
||||
.await
|
||||
.credentials
|
||||
.get(user)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn add_credential(&self, user: &str, c: Credential) {
|
||||
self.inner
|
||||
.write()
|
||||
.await
|
||||
.credentials
|
||||
.entry(user.to_string())
|
||||
.or_default()
|
||||
.push(c);
|
||||
}
|
||||
|
||||
pub async fn remove_credential(&self, user: &str, cred_id: &str) -> bool {
|
||||
let mut g = self.inner.write().await;
|
||||
let Some(list) = g.credentials.get_mut(user) else {
|
||||
return false;
|
||||
let resolver = match self.creds_resolver() {
|
||||
Ok(r) => r,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let before = list.len();
|
||||
list.retain(|c| c.id != cred_id);
|
||||
list.len() != before
|
||||
let path = match resolver.path_for(user) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let store = CredentialsStore::new(path);
|
||||
let lines = store.list(user).unwrap_or_default();
|
||||
lines
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let id = CredEntry::cred_id(c).to_string();
|
||||
Credential {
|
||||
id: id.clone(),
|
||||
nickname: id,
|
||||
method: Method::Fido2,
|
||||
transport: Transport::Unknown,
|
||||
created_unix: 0,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn get_policy(&self) -> Policy {
|
||||
self.inner.read().await.policy.clone()
|
||||
pub async fn add_credential(&self, user: &str, c: Credential) -> Result<(), StateError> {
|
||||
let resolver = self.creds_resolver()?;
|
||||
let path = resolver.path_for(user)?;
|
||||
let store = CredentialsStore::new(path);
|
||||
// Phase 2 records id-only; Phase 3 swaps in the full pam_u2f line.
|
||||
store.add(user, &c.id)?;
|
||||
let db = self.userdb.lock().await;
|
||||
db.record_enrollment(user, c.method)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_policy(&self, p: Policy) {
|
||||
self.inner.write().await.policy = p;
|
||||
pub async fn remove_credential(&self, user: &str, cred_id: &str) -> Result<bool, StateError> {
|
||||
let resolver = self.creds_resolver()?;
|
||||
let path = resolver.path_for(user)?;
|
||||
let store = CredentialsStore::new(path);
|
||||
let removed = store.remove(user, cred_id)?;
|
||||
if removed && store.list(user)?.is_empty() {
|
||||
self.userdb
|
||||
.lock()
|
||||
.await
|
||||
.drop_enrollment(user, Method::Fido2)?;
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
pub async fn set_pending(&self, user: &str, f: PendingFlag) {
|
||||
self.inner.write().await.pending.insert(user.to_string(), f);
|
||||
pub async fn get_policy(&self) -> Result<Policy, StateError> {
|
||||
Ok(self.policy.load()?)
|
||||
}
|
||||
|
||||
pub async fn clear_pending(&self, user: &str) -> bool {
|
||||
self.inner.write().await.pending.remove(user).is_some()
|
||||
pub async fn set_policy(&self, p: Policy) -> Result<(), StateError> {
|
||||
self.policy.save(&p)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tests assert pending flag round-trips through SetPendingFlag/ClearPendingFlag
|
||||
/// via this read accessor. Production readers (PAM module, GUI status) come in
|
||||
/// later phases and will read the on-disk file directly, not this in-memory cache.
|
||||
pub async fn set_pending(&self, user: &str, f: PendingFlag) -> Result<(), StateError> {
|
||||
self.pending.set(user, &f)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn clear_pending(&self, user: &str) -> Result<bool, StateError> {
|
||||
Ok(self.pending.clear(user)?)
|
||||
}
|
||||
|
||||
/// Tests assert pending flag round-trips through SetPendingFlag /
|
||||
/// ClearPendingFlag via this read accessor. Production readers (PAM
|
||||
/// module, GUI status) read the on-disk file directly in later phases.
|
||||
#[cfg(test)]
|
||||
pub async fn has_pending(&self, user: &str) -> bool {
|
||||
self.inner.read().await.pending.contains_key(user)
|
||||
pub async fn has_pending(&self, user: &str) -> Result<bool, StateError> {
|
||||
Ok(self.pending.get(user)?.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixtures_seeded_for_alice() {
|
||||
let s = AppState::with_fixtures();
|
||||
let creds = s.list_credentials("alice").await;
|
||||
assert_eq!(creds.len(), 1);
|
||||
assert_eq!(creds[0].nickname, "Alice's Yubikey");
|
||||
fn open_in(d: &std::path::Path) -> AppState {
|
||||
AppState::open(StorageConfig {
|
||||
policy_dir: d.join("policy.d"),
|
||||
pending_dir: d.join("pending"),
|
||||
userdb_path: d.join("users.db"),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_user_has_no_creds() {
|
||||
let s = AppState::with_fixtures();
|
||||
assert!(s.list_credentials("nobody").await.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn add_then_remove_credential() {
|
||||
let s = AppState::default();
|
||||
let c = Credential {
|
||||
id: "id1".to_string(),
|
||||
nickname: "x".to_string(),
|
||||
method: Method::Fido2,
|
||||
transport: Transport::Usb,
|
||||
created_unix: 0,
|
||||
};
|
||||
s.add_credential("bob", c).await;
|
||||
assert_eq!(s.list_credentials("bob").await.len(), 1);
|
||||
assert!(s.remove_credential("bob", "id1").await);
|
||||
assert!(s.list_credentials("bob").await.is_empty());
|
||||
async fn empty_state_has_nothing() {
|
||||
let d = tempdir().unwrap();
|
||||
let s = open_in(d.path());
|
||||
assert!(s.list_credentials("alice").await.is_empty());
|
||||
assert!(!s.has_pending("alice").await.unwrap());
|
||||
assert!(s.get_policy().await.unwrap().stacks.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_set_clear_roundtrip() {
|
||||
let s = AppState::default();
|
||||
assert!(!s.has_pending("alice").await);
|
||||
s.set_pending(
|
||||
"alice",
|
||||
PendingFlag {
|
||||
required_methods: vec![Method::Fido2],
|
||||
created_unix: 1,
|
||||
deadline_unix: 0,
|
||||
re_enroll: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(s.has_pending("alice").await);
|
||||
assert!(s.clear_pending("alice").await);
|
||||
assert!(!s.has_pending("alice").await);
|
||||
let d = tempdir().unwrap();
|
||||
let s = open_in(d.path());
|
||||
let f = PendingFlag {
|
||||
required_methods: vec![Method::Fido2],
|
||||
created_unix: 1,
|
||||
deadline_unix: 0,
|
||||
re_enroll: false,
|
||||
};
|
||||
s.set_pending("alice", f).await.unwrap();
|
||||
assert!(s.has_pending("alice").await.unwrap());
|
||||
assert!(s.clear_pending("alice").await.unwrap());
|
||||
assert!(!s.has_pending("alice").await.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user