//! 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"); } }