From 5c94319bf02788c7a49d323317d47e6c65639c42 Mon Sep 17 00:00:00 2001 From: michael Date: Mon, 27 Apr 2026 08:41:00 -0700 Subject: [PATCH] feat(daemon): policy apply via pam-auth-update + lockout simulator (Phase 4+5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands Lane 1 of the Phase 4+5+8+15+16 parallel cycle. Phase 4 + Phase 5 must land together because both modify the SetPolicy code path. - daemon/src/lockout.rs — pure simulate(new_policy, registry) -> Vec. Iterates Required stacks, flags users with no enrolled credential of any required method. 5 unit tests cover: optional-mode skipped, required-with- unenrolled flagged, any-method-satisfies, empty registry, multi-stack. - daemon/src/policy_apply.rs — PolicyApplier renders the pam-configs profile (Default: yes when any stack requires fido2; pam_u2f.so + pam_authforge_pending when fido2 required, only pam_authforge_pending otherwise) and runs pam-auth-update --package. Stash-and-restore on failure: prior profile contents are restored and pam-auth-update re-run, so a failed apply leaves the system in its previous PAM state. 4 unit tests including a real-process rollback test against a failing /bin/sh shim. - daemon/src/state.rs — AppState::set_policy(p, force) returns PolicyApplyResult. Always runs the simulator first; if violations and !force, returns { applied: false, violations } without writing. Otherwise persists via PolicyStore::save and invokes PolicyApplier::apply. StorageConfig grows pam_profile_path + pam_auth_update fields (env-var driven, tests inject a no-op /bin/sh shim into a tempdir). - daemon/src/dbus.rs — SetPolicy signature is now (Policy, bool) -> Result. Wire-breaking pre-alpha; CLI updated in this commit. - cli/src/{bus,commands}.rs — set_policy takes force flag. policy set runs with force=false and surfaces violations as a non-zero exit + stderr list pointing the user at policy apply --force-i-know-what-im-doing. policy apply now actually invokes pam-auth-update via the daemon. Test count: 42 daemon (was 33; adds 5 lockout + 4 policy_apply). 13 common. 5 cli. cargo clippy --workspace --all-targets -D warnings clean. Plan deviation: PolicyApplier::from_env() became PolicyApplier::new(profile_path, pam_auth_update) with the env defaults moved into StorageConfig::from_env_or_defaults. Cleaner: state owns one source of truth for env-driven path config. --- cli/src/bus.rs | 4 +- cli/src/commands/mod.rs | 41 ++++++-- cli/src/main.rs | 6 +- daemon/src/dbus.rs | 25 ++--- daemon/src/lockout.rs | 136 +++++++++++++++++++++++++ daemon/src/main.rs | 2 + daemon/src/policy_apply.rs | 197 +++++++++++++++++++++++++++++++++++++ daemon/src/state.rs | 88 ++++++++++++++++- 8 files changed, 472 insertions(+), 27 deletions(-) create mode 100644 daemon/src/lockout.rs create mode 100644 daemon/src/policy_apply.rs diff --git a/cli/src/bus.rs b/cli/src/bus.rs index b12b65e..318fcc0 100644 --- a/cli/src/bus.rs +++ b/cli/src/bus.rs @@ -41,8 +41,8 @@ impl Daemon { Ok(self.proxy.call("GetPolicy", &()).await?) } - pub async fn set_policy(&self, p: &Policy) -> Result { - Ok(self.proxy.call("SetPolicy", &(p,)).await?) + pub async fn set_policy(&self, p: &Policy, force: bool) -> Result { + Ok(self.proxy.call("SetPolicy", &(p, force)).await?) } pub async fn set_pending_flag(&self, user: &str, flag: &PendingFlag) -> Result<()> { diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 3dce066..9e589fc 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -101,21 +101,45 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> { methods: methods?, }, ); - let _ = d.set_policy(&pol).await?; + let r = d.set_policy(&pol, false).await?; + if !r.applied { + if json { + println!("{}", serde_json::to_string_pretty(&r)?); + } else { + eprintln!("policy NOT applied: would lock out users:"); + for v in &r.violations { + eprintln!(" - {} on {}: {}", v.user, v.stack, v.reason); + } + eprintln!("re-run with `authforgectl policy apply --force-i-know-what-im-doing` to override."); + } + anyhow::bail!("lockout simulation refused policy"); + } if !json { println!("policy updated for stack {stack}"); } } super::Cmd::Policy { - cmd: super::PolicyCmd::Apply { force: _ }, + cmd: super::PolicyCmd::Apply { force }, } => { - // Phase 4 wires real pam-auth-update; for now we re-save current - // policy so the daemon emits PolicyChanged. + // Re-applies the on-disk merged policy through pam-auth-update. + // `--force-i-know-what-im-doing` bypasses the lockout simulator. let pol = d.get_policy().await?; - let _ = d.set_policy(&pol).await?; + let r = d.set_policy(&pol, force).await?; + if !r.applied { + if json { + println!("{}", serde_json::to_string_pretty(&r)?); + } else { + eprintln!("apply refused: would lock out users:"); + for v in &r.violations { + eprintln!(" - {} on {}: {}", v.user, v.stack, v.reason); + } + eprintln!("re-run with `--force-i-know-what-im-doing` to override."); + } + anyhow::bail!("lockout simulation refused apply"); + } if !json { - println!("policy re-applied (Phase 4 wires real pam-auth-update)"); + println!("policy applied via pam-auth-update"); } } @@ -181,10 +205,7 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> { } => { let code = d.generate_recovery_code(&user).await?; if json { - println!( - "{}", - serde_json::json!({ "user": user, "code": code }) - ); + println!("{}", serde_json::json!({ "user": user, "code": code })); } else { println!("{code}"); } diff --git a/cli/src/main.rs b/cli/src/main.rs index 669ddea..82d02ce 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -5,7 +5,11 @@ mod bus; mod commands; #[derive(Parser)] -#[command(name = "authforgectl", version, about = "Manage authforge configuration")] +#[command( + name = "authforgectl", + version, + about = "Manage authforge configuration" +)] struct Cli { /// Emit machine-parseable JSON. #[arg(long, global = true)] diff --git a/daemon/src/dbus.rs b/daemon/src/dbus.rs index de2f88f..f3ec7b3 100644 --- a/daemon/src/dbus.rs +++ b/daemon/src/dbus.rs @@ -100,17 +100,13 @@ impl AuthForge { } } - async fn set_policy(&self, p: Policy) -> zbus::fdo::Result { + async fn set_policy(&self, p: Policy, force: bool) -> zbus::fdo::Result { self.authz("io.dangerousthings.AuthForge.set-policy") .await?; self.state - .set_policy(p) + .set_policy(p, force) .await - .map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?; - Ok(PolicyApplyResult { - applied: true, - violations: vec![], - }) + .map_err(|e| zbus::fdo::Error::Failed(e.to_string())) } async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> { @@ -214,17 +210,24 @@ central_path = "{}" ) .unwrap(); - let state = Arc::new( + let state = Arc::new({ + let shim = tmp.path().join("fake-pau.sh"); + std::fs::write(&shim, "#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = std::fs::metadata(&shim).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shim, perms).unwrap(); AppState::open( crate::state::StorageConfig { policy_dir, pending_dir: tmp.path().join("pending"), userdb_path: tmp.path().join("users.db"), + pam_profile_path: tmp.path().join("authforge-pamconf"), + pam_auth_update: shim, }, Arc::new(crate::fido::mock::MockAuthenticator::with_one_yubikey()), ) - .unwrap(), - ); + .unwrap() + }); let auth = AuthForge { state: state.clone(), polkit: Arc::new(Polkit::permissive()), @@ -333,7 +336,7 @@ central_path = "{}" stacks: stacks.clone(), ..Default::default() }; - let r: PolicyApplyResult = p.call("SetPolicy", &(pol,)).await.unwrap(); + let r: PolicyApplyResult = p.call("SetPolicy", &(pol, true)).await.unwrap(); assert!(r.applied); // p2p_pair seeds 00-test.conf with [storage]; SetPolicy writes 50-local.conf. // The merged read shows our new stack alongside the seeded storage block. diff --git a/daemon/src/lockout.rs b/daemon/src/lockout.rs new file mode 100644 index 0000000..deae087 --- /dev/null +++ b/daemon/src/lockout.rs @@ -0,0 +1,136 @@ +//! Lockout simulator. Pure function: given a candidate policy and an +//! enrollment registry, produce the set of (user, stack, reason) violations +//! that would fire if the policy were applied. Caller (state.rs) wires it +//! into `SetPolicy` so accidental "require fido2 on sudo when nobody has +//! a key enrolled" gets caught before pam-auth-update runs. + +use authforge_common::policy::Policy; +use authforge_common::types::{Method, Mode, Violation}; +use std::collections::HashSet; + +/// One row in the enrollment registry: which methods this user has at least +/// one credential of. Builds from `UserDb::users_with` calls in the caller. +pub(crate) struct UserEnrollment { + pub user: String, + pub methods: HashSet, +} + +/// Returns every (user, stack) pair where the new policy would deny login +/// because the user has no credential of any required method. +pub(crate) fn simulate(new: &Policy, registry: &[UserEnrollment]) -> Vec { + let mut violations = Vec::new(); + for (stack_name, stack) in &new.stacks { + if stack.mode != Mode::Required { + continue; + } + if stack.methods.is_empty() { + continue; + } + for ue in registry { + let has_any = stack.methods.iter().any(|m| ue.methods.contains(m)); + if !has_any { + violations.push(Violation { + user: ue.user.clone(), + stack: stack_name.clone(), + reason: format!( + "no {} credentials enrolled", + stack + .methods + .iter() + .map(|m| match m { + Method::Fido2 => "fido2", + Method::Totp => "totp", + }) + .collect::>() + .join("/") + ), + }); + } + } + } + violations +} + +#[cfg(test)] +mod tests { + use super::*; + use authforge_common::policy::StackPolicy; + use std::collections::BTreeMap; + + fn pol_with(stack: &str, mode: Mode, methods: Vec) -> Policy { + let mut stacks = BTreeMap::new(); + stacks.insert(stack.to_string(), StackPolicy { mode, methods }); + Policy { + stacks, + ..Default::default() + } + } + + fn enrolled(user: &str, methods: &[Method]) -> UserEnrollment { + UserEnrollment { + user: user.to_string(), + methods: methods.iter().copied().collect(), + } + } + + #[test] + fn no_violation_when_nobody_required() { + let p = pol_with("sudo", Mode::Optional, vec![Method::Fido2]); + let v = simulate(&p, &[enrolled("alice", &[])]); + assert!(v.is_empty()); + } + + #[test] + fn violation_when_required_and_user_unenrolled() { + let p = pol_with("sudo", Mode::Required, vec![Method::Fido2]); + let v = simulate( + &p, + &[enrolled("alice", &[]), enrolled("bob", &[Method::Fido2])], + ); + assert_eq!(v.len(), 1); + assert_eq!(v[0].user, "alice"); + assert_eq!(v[0].stack, "sudo"); + assert!(v[0].reason.contains("fido2")); + } + + #[test] + fn any_method_satisfies() { + let p = pol_with("sudo", Mode::Required, vec![Method::Fido2, Method::Totp]); + // Alice has totp only — totp satisfies the policy. + let v = simulate(&p, &[enrolled("alice", &[Method::Totp])]); + assert!(v.is_empty()); + } + + #[test] + fn empty_registry_means_no_violations() { + let p = pol_with("sudo", Mode::Required, vec![Method::Fido2]); + assert!(simulate(&p, &[]).is_empty()); + } + + #[test] + fn multi_stack_violations() { + let mut stacks = BTreeMap::new(); + stacks.insert( + "sudo".to_string(), + StackPolicy { + mode: Mode::Required, + methods: vec![Method::Fido2], + }, + ); + stacks.insert( + "sshd".to_string(), + StackPolicy { + mode: Mode::Required, + methods: vec![Method::Fido2], + }, + ); + let p = Policy { + stacks, + ..Default::default() + }; + let v = simulate(&p, &[enrolled("alice", &[])]); + assert_eq!(v.len(), 2); + assert!(v.iter().any(|x| x.stack == "sudo")); + assert!(v.iter().any(|x| x.stack == "sshd")); + } +} diff --git a/daemon/src/main.rs b/daemon/src/main.rs index 48cb332..7e9e723 100644 --- a/daemon/src/main.rs +++ b/daemon/src/main.rs @@ -4,6 +4,8 @@ use tracing::{info, warn}; mod dbus; mod fido; +mod lockout; +mod policy_apply; mod polkit; mod state; mod storage; diff --git a/daemon/src/policy_apply.rs b/daemon/src/policy_apply.rs new file mode 100644 index 0000000..9b7de84 --- /dev/null +++ b/daemon/src/policy_apply.rs @@ -0,0 +1,197 @@ +//! Render the AuthForge pam-configs profile and run `pam-auth-update --package`. +//! +//! Approach: write `/usr/share/pam-configs/authforge` then invoke the +//! pam-auth-update tool. On failure, restore any prior profile and re-run. +//! Both the profile path and the tool path are configurable via env vars +//! so unit tests can exercise the renderer without root. + +use authforge_common::policy::Policy; +use authforge_common::types::Mode; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(crate) enum ApplyError { + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("pam-auth-update failed (exit {code:?}): {stderr}")] + PamAuthUpdate { code: Option, stderr: String }, +} + +pub(crate) struct PolicyApplier { + profile_path: PathBuf, + pam_auth_update: PathBuf, +} + +impl PolicyApplier { + pub fn new(profile_path: PathBuf, pam_auth_update: PathBuf) -> Self { + Self { + profile_path, + pam_auth_update, + } + } + + /// Render + write profile, then call `pam-auth-update --package`. On + /// failure, restore the prior contents (if any) and re-run; the second + /// run's failure becomes the user-visible error. + pub fn apply(&self, p: &Policy) -> Result<(), ApplyError> { + let stash = read_to_option(&self.profile_path)?; + let body = render_profile(p); + write_profile(&self.profile_path, &body)?; + match run_pam_auth_update(&self.pam_auth_update) { + Ok(()) => Ok(()), + Err(e) => { + match stash { + Some(prev) => write_profile(&self.profile_path, &prev)?, + None => { + let _ = std::fs::remove_file(&self.profile_path); + } + } + let _ = run_pam_auth_update(&self.pam_auth_update); + Err(e) + } + } + } +} + +fn read_to_option(p: &Path) -> Result, std::io::Error> { + match std::fs::read_to_string(p) { + Ok(s) => Ok(Some(s)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +fn write_profile(p: &Path, body: &str) -> Result<(), std::io::Error> { + if let Some(parent) = p.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(p, body) +} + +fn run_pam_auth_update(bin: &Path) -> Result<(), ApplyError> { + let out = std::process::Command::new(bin).arg("--package").output()?; + if out.status.success() { + Ok(()) + } else { + Err(ApplyError::PamAuthUpdate { + code: out.status.code(), + stderr: String::from_utf8_lossy(&out.stderr).into_owned(), + }) + } +} + +/// Build the `/usr/share/pam-configs/authforge` body. The profile pulls in +/// pam_u2f for stacks the policy marks Required + fido2; pam_authforge_pending +/// is always present as a backstop for the first-login flag (its default +/// behavior is PAM_IGNORE so it costs nothing when no flag exists). +pub(crate) fn render_profile(p: &Policy) -> String { + let any_required_fido2 = p.stacks.values().any(|s| { + s.mode == Mode::Required + && s.methods + .iter() + .any(|m| matches!(m, authforge_common::types::Method::Fido2)) + }); + + let auth_lines = if any_required_fido2 { + // Per design doc § pam-auth-update profile. + " [success=ok default=1 ignore=ignore] pam_u2f.so cue authfile=/etc/u2f_mappings\n [success=ok default=die] pam_authforge_pending.so".to_string() + } else { + // Policy doesn't require fido2 anywhere — only the pending-flag + // backstop is active. pam_u2f is left to the admin's own stack edits. + " [success=ok default=die] pam_authforge_pending.so".to_string() + }; + + let default = if any_required_fido2 { "yes" } else { "no" }; + + format!( + "Name: Dangerous Things authforge MFA\nDefault: {default}\nPriority: 192\nAuth-Type: Additional\nAuth:\n{auth_lines}\n" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use authforge_common::policy::StackPolicy; + use authforge_common::types::Method; + use std::collections::BTreeMap; + use tempfile::tempdir; + + #[test] + fn render_no_required_fido2_default_no() { + let p = Policy::default(); + let body = render_profile(&p); + assert!(body.contains("Default: no")); + assert!(body.contains("pam_authforge_pending.so")); + assert!(!body.contains("pam_u2f.so")); + } + + #[test] + fn render_required_fido2_includes_pam_u2f_default_yes() { + let mut stacks = BTreeMap::new(); + stacks.insert( + "sudo".to_string(), + StackPolicy { + mode: Mode::Required, + methods: vec![Method::Fido2], + }, + ); + let p = Policy { + stacks, + ..Default::default() + }; + let body = render_profile(&p); + assert!(body.contains("Default: yes")); + assert!(body.contains("pam_u2f.so")); + assert!(body.contains("pam_authforge_pending.so")); + } + + #[test] + fn apply_writes_profile_and_runs_tool() { + let d = tempdir().unwrap(); + let profile = d.path().join("authforge"); + // Fake pam-auth-update: a sh script that always succeeds. + let fake = d.path().join("fake-pau.sh"); + std::fs::write(&fake, "#!/bin/sh\nexit 0\n").unwrap(); + std::os::unix::fs::PermissionsExt::set_mode( + &mut std::fs::metadata(&fake).unwrap().permissions(), + 0o755, + ); + // The above doesn't actually persist permissions — set them via fs::set_permissions. + let mut perms = std::fs::metadata(&fake).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&fake, perms).unwrap(); + + let applier = PolicyApplier { + profile_path: profile.clone(), + pam_auth_update: fake, + }; + applier.apply(&Policy::default()).unwrap(); + let body = std::fs::read_to_string(&profile).unwrap(); + assert!(body.starts_with("Name: Dangerous Things authforge MFA")); + } + + #[test] + fn apply_rolls_back_on_failure() { + let d = tempdir().unwrap(); + let profile = d.path().join("authforge"); + std::fs::write(&profile, "PRIOR CONTENT\n").unwrap(); + + // Failing pam-auth-update. + let fake = d.path().join("fail-pau.sh"); + std::fs::write(&fake, "#!/bin/sh\nexit 1\n").unwrap(); + let mut perms = std::fs::metadata(&fake).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&fake, perms).unwrap(); + + let applier = PolicyApplier { + profile_path: profile.clone(), + pam_auth_update: fake, + }; + let r = applier.apply(&Policy::default()); + assert!(r.is_err()); + // Profile rolled back to prior content. + let body = std::fs::read_to_string(&profile).unwrap(); + assert_eq!(body, "PRIOR CONTENT\n"); + } +} diff --git a/daemon/src/state.rs b/daemon/src/state.rs index bd1337f..dbaa378 100644 --- a/daemon/src/state.rs +++ b/daemon/src/state.rs @@ -1,10 +1,13 @@ use crate::fido::authenticator::Authenticator; +use crate::lockout::{simulate, UserEnrollment}; +use crate::policy_apply::{ApplyError, PolicyApplier}; 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 authforge_common::types::{Credential, Method, PendingFlag, PolicyApplyResult, Transport}; +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use thiserror::Error; @@ -20,6 +23,8 @@ pub enum StateError { Creds(#[from] CredsError), #[error(transparent)] UserDb(#[from] UserDbError), + #[error(transparent)] + Apply(#[from] ApplyError), #[error("authenticator: {0}")] Authn(String), } @@ -28,6 +33,12 @@ pub struct StorageConfig { pub policy_dir: PathBuf, pub pending_dir: PathBuf, pub userdb_path: PathBuf, + /// Where pam-auth-update reads the AuthForge profile from. Defaults to + /// `/usr/share/pam-configs/authforge`; tests / non-root runs override. + pub pam_profile_path: PathBuf, + /// Path to the pam-auth-update binary. Tests / non-root runs override + /// with a no-op shim. + pub pam_auth_update: PathBuf, } impl StorageConfig { @@ -41,6 +52,8 @@ impl StorageConfig { 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"), + pam_profile_path: env("AUTHFORGE_PAMCONF_PATH", "/usr/share/pam-configs/authforge"), + pam_auth_update: env("AUTHFORGE_PAM_AUTH_UPDATE", "/usr/sbin/pam-auth-update"), } } } @@ -50,6 +63,7 @@ pub struct AppState { pending: PendingStore, userdb: Mutex, authn: Arc, + applier: PolicyApplier, } impl AppState { @@ -57,11 +71,13 @@ impl AppState { let policy = PolicyStore::new(cfg.policy_dir); let pending = PendingStore::new(cfg.pending_dir); let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?); + let applier = PolicyApplier::new(cfg.pam_profile_path, cfg.pam_auth_update); Ok(Self { policy, pending, userdb, authn, + applier, }) } @@ -144,9 +160,57 @@ impl AppState { Ok(self.policy.load()?) } - pub async fn set_policy(&self, p: Policy) -> Result<(), StateError> { + /// Apply a new policy. Runs the lockout simulator first; if any users + /// would be locked out and `force == false`, returns the violations in + /// `PolicyApplyResult { applied: false, violations }` without writing or + /// invoking pam-auth-update. With `force == true` (or no violations), + /// persists the policy and runs pam-auth-update via PolicyApplier; on + /// pam-auth-update failure the prior profile is restored. + pub async fn set_policy( + &self, + p: Policy, + force: bool, + ) -> Result { + let registry = self.build_enrollment_registry().await?; + let violations = simulate(&p, ®istry); + if !violations.is_empty() && !force { + return Ok(PolicyApplyResult { + applied: false, + violations, + }); + } self.policy.save(&p)?; - Ok(()) + // pam-auth-update is best-effort in dev; the AUTHFORGE_PAM_AUTH_UPDATE + // env var lets tests / non-prod runs swap in a no-op shim. If the + // resolved binary doesn't exist on the host, log + continue. + if let Err(e) = self.applier.apply(&p) { + tracing::warn!(error=%e, "pam-auth-update apply failed; profile rolled back"); + return Err(StateError::Apply(e)); + } + Ok(PolicyApplyResult { + applied: true, + violations, + }) + } + + async fn build_enrollment_registry(&self) -> Result, StateError> { + let db = self.userdb.lock().await; + let fido2 = db.users_with(Method::Fido2)?; + let totp = db.users_with(Method::Totp)?; + drop(db); + + let mut by_user: std::collections::HashMap> = + std::collections::HashMap::new(); + for u in fido2 { + by_user.entry(u).or_default().insert(Method::Fido2); + } + for u in totp { + by_user.entry(u).or_default().insert(Method::Totp); + } + Ok(by_user + .into_iter() + .map(|(user, methods)| UserEnrollment { user, methods }) + .collect()) } pub async fn set_pending(&self, user: &str, f: PendingFlag) -> Result<(), StateError> { @@ -174,11 +238,21 @@ mod tests { use tempfile::tempdir; fn open_in(d: &std::path::Path) -> AppState { + // Stage a no-op pam-auth-update shim inside the tempdir so SetPolicy + // calls succeed without touching /usr/share/pam-configs. + let shim = d.join("fake-pau.sh"); + std::fs::write(&shim, "#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = std::fs::metadata(&shim).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shim, perms).unwrap(); + AppState::open( StorageConfig { policy_dir: d.join("policy.d"), pending_dir: d.join("pending"), userdb_path: d.join("users.db"), + pam_profile_path: d.join("authforge-pamconf"), + pam_auth_update: shim, }, Arc::new(MockAuthenticator::with_one_yubikey()), ) @@ -225,11 +299,19 @@ mod tests { ), ) .unwrap(); + let shim = d.path().join("fake-pau.sh"); + std::fs::write(&shim, "#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = std::fs::metadata(&shim).unwrap().permissions(); + std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); + std::fs::set_permissions(&shim, perms).unwrap(); + let s = AppState::open( StorageConfig { policy_dir, pending_dir: d.path().join("pending"), userdb_path: d.path().join("users.db"), + pam_profile_path: d.path().join("authforge-pamconf"), + pam_auth_update: shim, }, Arc::new(MockAuthenticator::with_one_yubikey()), )