Files
authforge/daemon/src/policy_apply.rs
michael 5c94319bf0 feat(daemon): policy apply via pam-auth-update + lockout simulator (Phase 4+5)
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<Violation>.
  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.
2026-04-27 08:41:00 -07:00

198 lines
7.0 KiB
Rust

//! 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<i32>, 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<Option<String>, 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");
}
}