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.
This commit is contained in:
@@ -41,8 +41,8 @@ impl Daemon {
|
|||||||
Ok(self.proxy.call("GetPolicy", &()).await?)
|
Ok(self.proxy.call("GetPolicy", &()).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_policy(&self, p: &Policy) -> Result<PolicyApplyResult> {
|
pub async fn set_policy(&self, p: &Policy, force: bool) -> Result<PolicyApplyResult> {
|
||||||
Ok(self.proxy.call("SetPolicy", &(p,)).await?)
|
Ok(self.proxy.call("SetPolicy", &(p, force)).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set_pending_flag(&self, user: &str, flag: &PendingFlag) -> Result<()> {
|
pub async fn set_pending_flag(&self, user: &str, flag: &PendingFlag) -> Result<()> {
|
||||||
|
|||||||
@@ -101,21 +101,45 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> {
|
|||||||
methods: methods?,
|
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 {
|
if !json {
|
||||||
println!("policy updated for stack {stack}");
|
println!("policy updated for stack {stack}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
super::Cmd::Policy {
|
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
|
// Re-applies the on-disk merged policy through pam-auth-update.
|
||||||
// policy so the daemon emits PolicyChanged.
|
// `--force-i-know-what-im-doing` bypasses the lockout simulator.
|
||||||
let pol = d.get_policy().await?;
|
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 {
|
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?;
|
let code = d.generate_recovery_code(&user).await?;
|
||||||
if json {
|
if json {
|
||||||
println!(
|
println!("{}", serde_json::json!({ "user": user, "code": code }));
|
||||||
"{}",
|
|
||||||
serde_json::json!({ "user": user, "code": code })
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
println!("{code}");
|
println!("{code}");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ mod bus;
|
|||||||
mod commands;
|
mod commands;
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "authforgectl", version, about = "Manage authforge configuration")]
|
#[command(
|
||||||
|
name = "authforgectl",
|
||||||
|
version,
|
||||||
|
about = "Manage authforge configuration"
|
||||||
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Emit machine-parseable JSON.
|
/// Emit machine-parseable JSON.
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
|
|||||||
@@ -100,17 +100,13 @@ impl AuthForge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_policy(&self, p: Policy) -> zbus::fdo::Result<PolicyApplyResult> {
|
async fn set_policy(&self, p: Policy, force: bool) -> zbus::fdo::Result<PolicyApplyResult> {
|
||||||
self.authz("io.dangerousthings.AuthForge.set-policy")
|
self.authz("io.dangerousthings.AuthForge.set-policy")
|
||||||
.await?;
|
.await?;
|
||||||
self.state
|
self.state
|
||||||
.set_policy(p)
|
.set_policy(p, force)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
|
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))
|
||||||
Ok(PolicyApplyResult {
|
|
||||||
applied: true,
|
|
||||||
violations: vec![],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> {
|
async fn set_pending_flag(&self, user: String, flag: PendingFlag) -> zbus::fdo::Result<()> {
|
||||||
@@ -214,17 +210,24 @@ central_path = "{}"
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.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(
|
AppState::open(
|
||||||
crate::state::StorageConfig {
|
crate::state::StorageConfig {
|
||||||
policy_dir,
|
policy_dir,
|
||||||
pending_dir: tmp.path().join("pending"),
|
pending_dir: tmp.path().join("pending"),
|
||||||
userdb_path: tmp.path().join("users.db"),
|
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()),
|
Arc::new(crate::fido::mock::MockAuthenticator::with_one_yubikey()),
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap()
|
||||||
);
|
});
|
||||||
let auth = AuthForge {
|
let auth = AuthForge {
|
||||||
state: state.clone(),
|
state: state.clone(),
|
||||||
polkit: Arc::new(Polkit::permissive()),
|
polkit: Arc::new(Polkit::permissive()),
|
||||||
@@ -333,7 +336,7 @@ central_path = "{}"
|
|||||||
stacks: stacks.clone(),
|
stacks: stacks.clone(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let r: PolicyApplyResult = p.call("SetPolicy", &(pol,)).await.unwrap();
|
let r: PolicyApplyResult = p.call("SetPolicy", &(pol, true)).await.unwrap();
|
||||||
assert!(r.applied);
|
assert!(r.applied);
|
||||||
// p2p_pair seeds 00-test.conf with [storage]; SetPolicy writes 50-local.conf.
|
// 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.
|
// The merged read shows our new stack alongside the seeded storage block.
|
||||||
|
|||||||
136
daemon/src/lockout.rs
Normal file
136
daemon/src/lockout.rs
Normal file
@@ -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<Method>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<Violation> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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<Method>) -> 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
mod dbus;
|
mod dbus;
|
||||||
mod fido;
|
mod fido;
|
||||||
|
mod lockout;
|
||||||
|
mod policy_apply;
|
||||||
mod polkit;
|
mod polkit;
|
||||||
mod state;
|
mod state;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|||||||
197
daemon/src/policy_apply.rs
Normal file
197
daemon/src/policy_apply.rs
Normal file
@@ -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<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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
use crate::fido::authenticator::Authenticator;
|
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::credentials::{CredEntry, CredentialsStore, CredsError, CredsPathResolver};
|
||||||
use crate::storage::pending::{PendingError, PendingStore};
|
use crate::storage::pending::{PendingError, PendingStore};
|
||||||
use crate::storage::policy::PolicyStore;
|
use crate::storage::policy::PolicyStore;
|
||||||
use crate::storage::userdb::{UserDb, UserDbError};
|
use crate::storage::userdb::{UserDb, UserDbError};
|
||||||
use authforge_common::policy::{Policy, PolicyError};
|
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::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -20,6 +23,8 @@ pub enum StateError {
|
|||||||
Creds(#[from] CredsError),
|
Creds(#[from] CredsError),
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
UserDb(#[from] UserDbError),
|
UserDb(#[from] UserDbError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Apply(#[from] ApplyError),
|
||||||
#[error("authenticator: {0}")]
|
#[error("authenticator: {0}")]
|
||||||
Authn(String),
|
Authn(String),
|
||||||
}
|
}
|
||||||
@@ -28,6 +33,12 @@ pub struct StorageConfig {
|
|||||||
pub policy_dir: PathBuf,
|
pub policy_dir: PathBuf,
|
||||||
pub pending_dir: PathBuf,
|
pub pending_dir: PathBuf,
|
||||||
pub userdb_path: 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 {
|
impl StorageConfig {
|
||||||
@@ -41,6 +52,8 @@ impl StorageConfig {
|
|||||||
policy_dir: env("AUTHFORGE_POLICY_DIR", "/etc/authforge/policy.d"),
|
policy_dir: env("AUTHFORGE_POLICY_DIR", "/etc/authforge/policy.d"),
|
||||||
pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"),
|
pending_dir: env("AUTHFORGE_PENDING_DIR", "/var/lib/authforge/pending"),
|
||||||
userdb_path: env("AUTHFORGE_USERDB", "/var/lib/authforge/users.db"),
|
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,
|
pending: PendingStore,
|
||||||
userdb: Mutex<UserDb>,
|
userdb: Mutex<UserDb>,
|
||||||
authn: Arc<dyn Authenticator>,
|
authn: Arc<dyn Authenticator>,
|
||||||
|
applier: PolicyApplier,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -57,11 +71,13 @@ impl AppState {
|
|||||||
let policy = PolicyStore::new(cfg.policy_dir);
|
let policy = PolicyStore::new(cfg.policy_dir);
|
||||||
let pending = PendingStore::new(cfg.pending_dir);
|
let pending = PendingStore::new(cfg.pending_dir);
|
||||||
let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?);
|
let userdb = Mutex::new(UserDb::open(&cfg.userdb_path)?);
|
||||||
|
let applier = PolicyApplier::new(cfg.pam_profile_path, cfg.pam_auth_update);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
policy,
|
policy,
|
||||||
pending,
|
pending,
|
||||||
userdb,
|
userdb,
|
||||||
authn,
|
authn,
|
||||||
|
applier,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,9 +160,57 @@ impl AppState {
|
|||||||
Ok(self.policy.load()?)
|
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<PolicyApplyResult, StateError> {
|
||||||
|
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)?;
|
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<Vec<UserEnrollment>, 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<String, HashSet<Method>> =
|
||||||
|
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> {
|
pub async fn set_pending(&self, user: &str, f: PendingFlag) -> Result<(), StateError> {
|
||||||
@@ -174,11 +238,21 @@ mod tests {
|
|||||||
use tempfile::tempdir;
|
use tempfile::tempdir;
|
||||||
|
|
||||||
fn open_in(d: &std::path::Path) -> AppState {
|
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(
|
AppState::open(
|
||||||
StorageConfig {
|
StorageConfig {
|
||||||
policy_dir: d.join("policy.d"),
|
policy_dir: d.join("policy.d"),
|
||||||
pending_dir: d.join("pending"),
|
pending_dir: d.join("pending"),
|
||||||
userdb_path: d.join("users.db"),
|
userdb_path: d.join("users.db"),
|
||||||
|
pam_profile_path: d.join("authforge-pamconf"),
|
||||||
|
pam_auth_update: shim,
|
||||||
},
|
},
|
||||||
Arc::new(MockAuthenticator::with_one_yubikey()),
|
Arc::new(MockAuthenticator::with_one_yubikey()),
|
||||||
)
|
)
|
||||||
@@ -225,11 +299,19 @@ mod tests {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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(
|
let s = AppState::open(
|
||||||
StorageConfig {
|
StorageConfig {
|
||||||
policy_dir,
|
policy_dir,
|
||||||
pending_dir: d.path().join("pending"),
|
pending_dir: d.path().join("pending"),
|
||||||
userdb_path: d.path().join("users.db"),
|
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()),
|
Arc::new(MockAuthenticator::with_one_yubikey()),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user