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:
michael
2026-04-27 06:26:49 -07:00
parent 9be8e4d0b3
commit ea70386c2f
5 changed files with 157 additions and 0 deletions

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