use std::time::{Duration, SystemTime};
use crate::cli::ApproveArgs;
use crate::commands::recipient::{
ConfigSnapshot, SOPS_CONFIG_FILE_NAME, add_key_to_sops_yaml, assume_yes, current_repo_root,
load_config, rewrap_error, run_updatekeys, sops_config_path,
};
use crate::config::{CONFIG_FILE_NAME, MemberState};
use crate::error::{Error, Result};
use crate::ui::Ui;
pub fn run(ui: &Ui, args: &ApproveArgs) -> Result<()> {
ui.header("sopsy approve — granting membership");
let repo = current_repo_root()?;
let mut config = load_config(&repo)?;
let sops_config = sops_config_path(&repo)?;
let name = args.name.trim().to_string();
let member = config.recipient(&name).cloned().ok_or_else(|| {
Error::Validation(format!(
"no member named `{name}` — did they run `sopsy join`?"
))
})?;
if !member.is_pending() {
return Err(Error::Validation(format!(
"`{name}` is already an active member"
)));
}
check_freshness(
ui,
member.requested_at.as_deref(),
config.resolved_request_ttl(),
args.force,
)?;
confirm_vouch(ui, &name, &member.public_key)?;
let snapshot = ConfigSnapshot::capture(&repo, &sops_config);
for recipient in config.recipients.iter_mut() {
if recipient.name == name {
recipient.state = MemberState::Active;
recipient.requested_at = None;
}
}
config.save_to_dir(&repo)?;
ui.success(format!("marked `{name}` active in {CONFIG_FILE_NAME}"));
let modified = add_key_to_sops_yaml(&sops_config, &member.public_key)?;
if modified == 0 {
ui.warn(format!(
"no `age:` creation_rules matched in {SOPS_CONFIG_FILE_NAME}; left unchanged"
));
} else {
ui.success(format!(
"added the key to {modified} creation rule(s) in {SOPS_CONFIG_FILE_NAME}"
));
}
if let Err(err) = run_updatekeys(ui, &repo, args.no_updatekeys) {
snapshot.restore()?;
ui.warn(format!("rolled back — `{name}` was not approved"));
return Err(rewrap_error(err));
}
ui.success(format!(
"`{name}` approved and added to all encrypted files"
));
print_next_steps(ui, &name);
Ok(())
}
fn check_freshness(ui: &Ui, requested_at: Option<&str>, ttl: Duration, force: bool) -> Result<()> {
let Some(requested_at) = requested_at else {
ui.warn("request has no timestamp; cannot verify freshness");
return Ok(());
};
match request_age(requested_at) {
Ok(age) => {
ui.info(format!(
"request submitted {} ago",
humantime::format_duration(Duration::from_secs(age.as_secs()))
));
if age > ttl && !force {
return Err(Error::Validation(format!(
"this request is older than the allowed window ({}); ask them to re-run \
`sopsy join`, or pass --force to approve anyway",
humantime::format_duration(ttl)
)));
}
if age > ttl {
ui.warn("request is stale, but --force was given; approving anyway");
}
}
Err(_) => ui.warn(format!(
"could not parse request timestamp `{requested_at}`; proceeding"
)),
}
Ok(())
}
fn request_age(requested_at: &str) -> Result<Duration> {
let when = humantime::parse_rfc3339(requested_at)
.map_err(|err| Error::Validation(format!("bad timestamp: {err}")))?;
SystemTime::now()
.duration_since(when)
.or(Ok(Duration::ZERO))
}
fn confirm_vouch(ui: &Ui, name: &str, public_key: &str) -> Result<()> {
ui.header("Verify before you vouch");
ui.info(format!("name: {name}"));
ui.info(format!("key: {public_key}"));
ui.warn("Confirm out-of-band (Slack/in person) that this key is really theirs.");
if assume_yes() {
ui.info("SOPSY_ASSUME_YES set — vouching automatically.");
return Ok(());
}
if !ui.is_interactive() {
return Err(Error::NonInteractive {
prompt: format!("vouch that this key belongs to {name}"),
flag: "an interactive terminal (or set SOPSY_ASSUME_YES for automation)".to_string(),
});
}
let vouched = ui.confirm(
&format!("Do you vouch that this key belongs to `{name}`?"),
"--non-interactive",
false,
)?;
if !vouched {
return Err(Error::Validation(
"approval cancelled — nothing changed".into(),
));
}
Ok(())
}
fn print_next_steps(ui: &Ui, name: &str) {
ui.header("Next steps");
ui.info("You (the approver):");
ui.info(format!(
" 1. Commit the changes: git add -A && git commit -m \"approve: {name}\""
));
ui.info(" 2. Push to the PR branch and merge it (rebase first if main moved).");
ui.info(format!(
"{name} then pulls main and can `sopsy edit`/`sopsy decrypt` — Touch ID unlocks it."
));
}