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.
178 lines
3.9 KiB
Rust
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);
|
|
}
|
|
}
|