Files
authforge/cli/src/main.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

178 lines
3.9 KiB
Rust

use anyhow::Result;
use clap::{Parser, Subcommand};
mod bus;
mod commands;
#[derive(Parser)]
#[command(
name = "authforgectl",
version,
about = "Manage authforge configuration"
)]
struct Cli {
/// Emit machine-parseable JSON.
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Show daemon + policy status.
Status,
/// Enroll a security key for a user (interactive on the daemon side).
Enroll {
#[arg(long)]
user: Option<String>,
#[arg(long)]
nickname: Option<String>,
},
/// List enrolled credentials for a user.
List {
#[arg(long)]
user: Option<String>,
},
/// Remove a credential by ID.
Remove {
#[arg(long)]
user: Option<String>,
cred_id: String,
},
/// Manage MFA policy.
Policy {
#[command(subcommand)]
cmd: PolicyCmd,
},
/// Manage first-login pending flags.
Pending {
#[command(subcommand)]
cmd: PendingCmd,
},
/// Manage recovery codes.
Recovery {
#[command(subcommand)]
cmd: RecoveryCmd,
},
}
#[derive(Subcommand)]
enum PolicyCmd {
/// Print the merged on-disk policy.
Show,
/// Set a stack's mode and methods.
Set {
stack: String,
/// disabled | optional | required
mode: String,
#[arg(long, value_delimiter = ',')]
methods: Vec<String>,
},
/// Re-apply the current policy.
Apply {
#[arg(long = "force-i-know-what-im-doing")]
force: bool,
},
/// Validate the policy without applying.
Validate,
}
#[derive(Subcommand)]
enum PendingCmd {
Set {
user: String,
#[arg(long, value_delimiter = ',')]
methods: Vec<String>,
},
Clear {
user: String,
},
List,
}
#[derive(Subcommand)]
enum RecoveryCmd {
Generate { user: String },
List { user: String },
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
commands::dispatch(cli.json, cli.cmd).await
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
#[test]
fn parse_status_subcommand() {
let c = Cli::try_parse_from(["authforgectl", "status"]).unwrap();
assert!(matches!(c.cmd, Cmd::Status));
}
#[test]
fn parse_policy_set_with_methods() {
let c = Cli::try_parse_from([
"authforgectl",
"policy",
"set",
"sudo",
"required",
"--methods",
"fido2,totp",
])
.unwrap();
match c.cmd {
Cmd::Policy {
cmd:
PolicyCmd::Set {
stack,
mode,
methods,
},
} => {
assert_eq!(stack, "sudo");
assert_eq!(mode, "required");
assert_eq!(methods, vec!["fido2".to_string(), "totp".to_string()]);
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn parse_enroll_with_user_and_nickname() {
let c = Cli::try_parse_from([
"authforgectl",
"enroll",
"--user",
"alice",
"--nickname",
"Yellow",
])
.unwrap();
match c.cmd {
Cmd::Enroll { user, nickname } => {
assert_eq!(user.as_deref(), Some("alice"));
assert_eq!(nickname.as_deref(), Some("Yellow"));
}
_ => panic!("wrong subcommand"),
}
}
#[test]
fn json_flag_is_global() {
let c = Cli::try_parse_from(["authforgectl", "--json", "status"]).unwrap();
assert!(c.json);
}
}