feat(cli): D-Bus client wrapper + full subcommand dispatcher

Lands plan tasks C2 (Daemon proxy wrapping all 9 D-Bus methods via
Proxy::new_owned), C3 (status/list/policy-show), C4 (enroll/remove/policy-
set/apply/validate), and C5 (pending/recovery). Bundled because dispatch
needs the bus wrapper to compile cleanly under -D warnings.

Phase 4/5/12 placeholders in the dispatcher: policy apply re-saves to trigger
PolicyChanged; policy validate is a TOML round-trip; pending list and
recovery list are no-op messages until the corresponding D-Bus methods land.

5 clap-parser tests pass (was 5; same — parser shape unchanged in this
commit). Workspace clippy clean.
This commit is contained in:
michael
2026-04-27 08:18:17 -07:00
parent 1b223fe4f9
commit 0697ba1c65
3 changed files with 266 additions and 5 deletions

View File

@@ -1,7 +1,208 @@
use crate::bus::Daemon;
use anyhow::Result;
pub(crate) async fn dispatch(_json: bool, cmd: super::Cmd) -> Result<()> {
eprintln!("authforgectl: subcommand dispatcher not yet wired in this build");
let _ = cmd;
pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> {
let d = Daemon::connect().await?;
match cmd {
super::Cmd::Status => {
let pol = d.get_policy().await?;
if json {
println!("{}", serde_json::to_string_pretty(&pol)?);
} else {
println!("authforge daemon: connected");
println!("policy stacks configured: {}", pol.stacks.len());
for (name, sp) in &pol.stacks {
println!(" {}: {:?} ({:?})", name, sp.mode, sp.methods);
}
}
}
super::Cmd::List { user } => {
let user = user.unwrap_or_else(whoami_or_user);
let creds = d.list_credentials(&user).await?;
if json {
println!("{}", serde_json::to_string_pretty(&creds)?);
} else if creds.is_empty() {
println!("no credentials for {user}");
} else {
for c in creds {
println!(
"{}\t{}\t{:?}\t{:?}",
c.id, c.nickname, c.method, c.transport
);
}
}
}
super::Cmd::Policy {
cmd: super::PolicyCmd::Show,
} => {
let pol = d.get_policy().await?;
if json {
println!("{}", serde_json::to_string_pretty(&pol)?);
} else {
println!("{}", toml::to_string_pretty(&pol).unwrap_or_default());
}
}
// Write-side subcommands (Task C4-C5).
super::Cmd::Enroll { user, nickname } => {
let user = user.unwrap_or_else(whoami_or_user);
let nickname = nickname.unwrap_or_else(|| "Security Key".to_string());
let cred = d.enroll_own(&user, &nickname).await?;
if json {
println!("{}", serde_json::to_string_pretty(&cred)?);
} else {
println!(
"enrolled credential {} ({}) for {}",
cred.id, cred.nickname, user
);
}
}
super::Cmd::Remove { user, cred_id } => {
let user = user.unwrap_or_else(whoami_or_user);
d.remove_own(&user, &cred_id).await?;
if !json {
println!("removed credential {cred_id} for {user}");
}
}
super::Cmd::Policy {
cmd:
super::PolicyCmd::Set {
stack,
mode,
methods,
},
} => {
use authforge_common::policy::StackPolicy;
use authforge_common::types::{Method, Mode};
let m = match mode.as_str() {
"disabled" => Mode::Disabled,
"optional" => Mode::Optional,
"required" => Mode::Required,
other => anyhow::bail!("unknown mode: {other}"),
};
let methods: Result<Vec<Method>> = methods
.into_iter()
.map(|s| match s.as_str() {
"fido2" => Ok(Method::Fido2),
"totp" => Ok(Method::Totp),
other => Err(anyhow::anyhow!("unknown method: {other}")),
})
.collect();
let mut pol = d.get_policy().await?;
pol.stacks.insert(
stack.clone(),
StackPolicy {
mode: m,
methods: methods?,
},
);
let _ = d.set_policy(&pol).await?;
if !json {
println!("policy updated for stack {stack}");
}
}
super::Cmd::Policy {
cmd: super::PolicyCmd::Apply { force: _ },
} => {
// Phase 4 wires real pam-auth-update; for now we re-save current
// policy so the daemon emits PolicyChanged.
let pol = d.get_policy().await?;
let _ = d.set_policy(&pol).await?;
if !json {
println!("policy re-applied (Phase 4 wires real pam-auth-update)");
}
}
super::Cmd::Policy {
cmd: super::PolicyCmd::Validate,
} => {
// Phase 5 wires the lockout simulator. For now: syntax round-trip.
let pol = d.get_policy().await?;
let _ = toml::to_string_pretty(&pol)?;
if !json {
println!("policy is syntactically valid");
}
}
super::Cmd::Pending {
cmd: super::PendingCmd::Set { user, methods },
} => {
use authforge_common::types::{Method, PendingFlag};
let methods: Result<Vec<Method>> = methods
.into_iter()
.map(|s| match s.as_str() {
"fido2" => Ok(Method::Fido2),
"totp" => Ok(Method::Totp),
other => Err(anyhow::anyhow!("unknown method: {other}")),
})
.collect();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let flag = PendingFlag {
required_methods: methods?,
created_unix: now,
deadline_unix: 0,
re_enroll: false,
};
d.set_pending_flag(&user, &flag).await?;
if !json {
println!("pending flag set for {user}");
}
}
super::Cmd::Pending {
cmd: super::PendingCmd::Clear { user },
} => {
d.clear_pending_flag(&user).await?;
if !json {
println!("pending flag cleared for {user}");
}
}
super::Cmd::Pending {
cmd: super::PendingCmd::List,
} => {
// ListPending lands when GUI needs it (Phase 8/12 adjustment).
if !json {
println!("(ListPending D-Bus method lands when GUI needs it)");
}
}
super::Cmd::Recovery {
cmd: super::RecoveryCmd::Generate { user },
} => {
let code = d.generate_recovery_code(&user).await?;
if json {
println!(
"{}",
serde_json::json!({ "user": user, "code": code })
);
} else {
println!("{code}");
}
}
super::Cmd::Recovery {
cmd: super::RecoveryCmd::List { user: _ },
} => {
if !json {
println!("(ListRecovery lands in Phase 12)");
}
}
}
Ok(())
}
fn whoami_or_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.unwrap_or_else(|_| "unknown".into())
}