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:
michael
2026-04-27 08:41:00 -07:00
parent 865e37b6aa
commit 5c94319bf0
8 changed files with 472 additions and 27 deletions

View File

@@ -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")
.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.