feat(cli): authforgectl recovery list and revoke

* RecoveryCmd::List no longer takes a user — lists all active codes
  across users, matching the daemon's ListRecoveryCodes signature.
* RecoveryCmd::Revoke <user> calls RevokeRecoveryCode; exits 1 with
  a stderr message when the user has no active code.
* bus.rs gains list_recovery_codes() / revoke_recovery_code(user).
* Two new clap-parser tests cover the new subcommand shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
michael
2026-04-27 09:51:52 -07:00
parent 56ba4dd924
commit 982b9403d0
3 changed files with 59 additions and 5 deletions

View File

@@ -4,7 +4,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use authforge_common::policy::Policy; use authforge_common::policy::Policy;
use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult}; use authforge_common::types::{Credential, PendingFlag, PolicyApplyResult, RecoveryCodeSummary};
const BUS_NAME: &str = "io.dangerousthings.AuthForge"; const BUS_NAME: &str = "io.dangerousthings.AuthForge";
const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge"; const OBJECT_PATH: &str = "/io/dangerousthings/AuthForge";
@@ -56,4 +56,12 @@ impl Daemon {
pub async fn generate_recovery_code(&self, user: &str) -> Result<String> { pub async fn generate_recovery_code(&self, user: &str) -> Result<String> {
Ok(self.proxy.call("GenerateRecoveryCode", &(user,)).await?) Ok(self.proxy.call("GenerateRecoveryCode", &(user,)).await?)
} }
pub async fn list_recovery_codes(&self) -> Result<Vec<RecoveryCodeSummary>> {
Ok(self.proxy.call("ListRecoveryCodes", &()).await?)
}
pub async fn revoke_recovery_code(&self, user: &str) -> Result<bool> {
Ok(self.proxy.call("RevokeRecoveryCode", &(user,)).await?)
}
} }

View File

@@ -212,10 +212,33 @@ pub(crate) async fn dispatch(json: bool, cmd: super::Cmd) -> Result<()> {
} }
super::Cmd::Recovery { super::Cmd::Recovery {
cmd: super::RecoveryCmd::List { user: _ }, cmd: super::RecoveryCmd::List,
} => { } => {
let entries = d.list_recovery_codes().await?;
if json {
println!("{}", serde_json::to_string_pretty(&entries)?);
} else if entries.is_empty() {
println!("(no active recovery codes)");
} else {
for e in &entries {
println!("{:<24} expires_unix={}", e.user, e.expires_unix);
}
}
}
super::Cmd::Recovery {
cmd: super::RecoveryCmd::Revoke { user },
} => {
let removed = d.revoke_recovery_code(&user).await?;
if removed {
if !json { if !json {
println!("(ListRecovery lands in Phase 12)"); println!("revoked recovery code for {user}");
}
} else {
if !json {
eprintln!("no recovery code for {user}");
}
std::process::exit(1);
} }
} }
} }

View File

@@ -95,7 +95,8 @@ enum PendingCmd {
#[derive(Subcommand)] #[derive(Subcommand)]
enum RecoveryCmd { enum RecoveryCmd {
Generate { user: String }, Generate { user: String },
List { user: String }, List,
Revoke { user: String },
} }
#[tokio::main] #[tokio::main]
@@ -174,4 +175,26 @@ mod tests {
let c = Cli::try_parse_from(["authforgectl", "--json", "status"]).unwrap(); let c = Cli::try_parse_from(["authforgectl", "--json", "status"]).unwrap();
assert!(c.json); assert!(c.json);
} }
#[test]
fn parse_recovery_list_takes_no_args() {
let c = Cli::try_parse_from(["authforgectl", "recovery", "list"]).unwrap();
assert!(matches!(
c.cmd,
Cmd::Recovery {
cmd: RecoveryCmd::List
}
));
}
#[test]
fn parse_recovery_revoke_with_user() {
let c = Cli::try_parse_from(["authforgectl", "recovery", "revoke", "alice"]).unwrap();
match c.cmd {
Cmd::Recovery {
cmd: RecoveryCmd::Revoke { user },
} => assert_eq!(user, "alice"),
_ => panic!("wrong subcommand"),
}
}
} }