feat(daemon): storage::policy + PolicyChanged signal + inotify watcher
Lands plan tasks 2.6 (PolicyStore wraps load_from_dir / save_local), 2.7
(PolicyChanged D-Bus signal on the AuthForge interface), and 2.8 (notify-based
inotify watcher in main.rs that emits the signal on any change in the policy.d
directory). Bundled because watcher → emit signal → wraps PolicyStore is one
data flow.
- daemon/src/storage/{mod,policy}.rs — PolicyStore::{load,save,watch}; watch
returns a (RecommendedWatcher, watch::Receiver) so the caller keeps the
watcher alive.
- daemon/src/dbus.rs — adds #[zbus(signal)] policy_changed; integration test
via p2p connection asserts the signal arrives within 2s.
- daemon/src/main.rs — spawns a task that ticks PolicyChanged on every
rx.changed(), keyed off AUTHFORGE_POLICY_DIR env var (default
/etc/authforge/policy.d). Watcher leaked via std::mem::forget; daemon
lifetime = process lifetime.
Test count: 17/17 daemon (was 14) + 13/13 common.
This commit is contained in:
88
daemon/src/storage/policy.rs
Normal file
88
daemon/src/storage/policy.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use authforge_common::policy::{Policy, PolicyError};
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) struct PolicyStore {
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
// `save` and `load` are wired through AppState in Task 2.15. Keep them now so
|
||||
// the storage module is the single owner of policy I/O — main.rs constructs a
|
||||
// store for the watcher in 2.8.
|
||||
#[allow(dead_code)]
|
||||
impl PolicyStore {
|
||||
pub fn new(dir: PathBuf) -> Self {
|
||||
Self { dir }
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Policy, PolicyError> {
|
||||
Policy::load_from_dir(&self.dir)
|
||||
}
|
||||
|
||||
pub fn save(&self, p: &Policy) -> Result<(), PolicyError> {
|
||||
p.save_local(&self.dir)
|
||||
}
|
||||
|
||||
/// Watch `self.dir` for any change. The returned receiver yields `()` each
|
||||
/// time something changes. The caller must keep the `RecommendedWatcher`
|
||||
/// alive — drop it to stop watching.
|
||||
pub fn watch(&self) -> notify::Result<(RecommendedWatcher, tokio::sync::watch::Receiver<()>)> {
|
||||
let (tx, rx) = tokio::sync::watch::channel(());
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||
if res.is_ok() {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
})?;
|
||||
// Safe even if the dir doesn't yet exist — daemon may install before
|
||||
// anyone drops a config file in.
|
||||
std::fs::create_dir_all(&self.dir).ok();
|
||||
watcher.watch(&self.dir, RecursiveMode::NonRecursive)?;
|
||||
Ok((watcher, rx))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use authforge_common::policy::StackPolicy;
|
||||
use authforge_common::types::{Method, Mode};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn save_then_load_roundtrip() {
|
||||
let d = tempdir().unwrap();
|
||||
let store = PolicyStore::new(d.path().to_path_buf());
|
||||
|
||||
let mut p = Policy::default();
|
||||
p.stacks.insert(
|
||||
"sudo".to_string(),
|
||||
StackPolicy {
|
||||
mode: Mode::Required,
|
||||
methods: vec![Method::Fido2],
|
||||
},
|
||||
);
|
||||
store.save(&p).unwrap();
|
||||
|
||||
let back = store.load().unwrap();
|
||||
assert_eq!(back, p);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn watcher_ticks_on_file_change() {
|
||||
let d = tempdir().unwrap();
|
||||
let store = PolicyStore::new(d.path().to_path_buf());
|
||||
let (_watcher, mut rx) = store.watch().unwrap();
|
||||
|
||||
std::fs::write(
|
||||
d.path().join("00-base.conf"),
|
||||
"[stacks.sudo]\nmode = \"required\"\nmethods = [\"fido2\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), rx.changed())
|
||||
.await
|
||||
.expect("no change within 2s")
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user