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:
@@ -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<UserDb>,
|
||||
authn: Arc<dyn Authenticator>,
|
||||
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<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)?;
|
||||
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> {
|
||||
@@ -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()),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user