tsafe-cli 1.0.21

tsafe CLI — local secret and credential manager (replaces .env files)
Documentation
//! Team vault command handlers.
//!
//! Implements the `tsafe team` sub-command: init, add-member, remove-member,
//! members, keygen, show-key, and sync-keys for age-encrypted shared vaults.

use anyhow::{Context, Result};
use colored::Colorize;
use tsafe_cli::cli::TeamAction;
use tsafe_core::{audit::AuditEntry, profile};

use crate::helpers::*;

pub(crate) fn cmd_team(profile: &str, action: TeamAction) -> Result<()> {
    use tsafe_core::{age_crypto, team};
    let path = profile::vault_path(profile);

    match action {
        TeamAction::Init { identity } => {
            if path.exists() {
                anyhow::bail!("vault already exists for profile '{profile}' — delete it first or use a new profile");
            }
            let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;
            let id_content = std::fs::read_to_string(&identity)?;
            let recipients: Vec<String> = id_content
                .lines()
                .filter(|l| !l.starts_with('#') && !l.trim().is_empty())
                .filter_map(|l| {
                    l.parse::<age::x25519::Identity>()
                        .ok()
                        .map(|i| i.to_public().to_string())
                })
                .collect();
            if recipients.is_empty() {
                anyhow::bail!("no valid age identities found in {identity}");
            }
            let (file, _dek) = team::create_team_vault(&recipients)?;
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent)?;
            }
            let json = serde_json::to_string_pretty(&file)?;
            let tmp = path.with_extension("vault.tmp");
            std::fs::write(&tmp, &json)?;
            std::fs::rename(&tmp, &path)?;
            audit(profile)
                .append(&AuditEntry::success(profile, "team-init", None))
                .ok();
            println!(
                "{} Team vault created for profile '{}'",
                "".green(),
                profile
            );
            println!("  Recipients: {}", recipients.join(", "));
            drop(identities);
            Ok(())
        }
        TeamAction::AddMember {
            public_key,
            identity,
        } => {
            let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;
            let json = std::fs::read_to_string(&path)
                .with_context(|| format!("vault not found: {}", path.display()))?;
            let mut file: tsafe_core::vault::VaultFile = serde_json::from_str(&json)?;
            team::add_member(&mut file, &public_key, &identities)?;
            let new_json = serde_json::to_string_pretty(&file)?;
            let tmp = path.with_extension("vault.tmp");
            std::fs::write(&tmp, &new_json)?;
            std::fs::rename(&tmp, &path)?;
            audit(profile)
                .append(&AuditEntry::success(profile, "team-add-member", None))
                .ok();
            println!("{} Added team member: {}", "".green(), public_key);
            Ok(())
        }
        TeamAction::RemoveMember {
            public_key,
            identity,
        } => {
            let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;
            let json = std::fs::read_to_string(&path)
                .with_context(|| format!("vault not found: {}", path.display()))?;
            let mut file: tsafe_core::vault::VaultFile = serde_json::from_str(&json)?;
            team::remove_member(&mut file, &public_key, &identities)?;
            let new_json = serde_json::to_string_pretty(&file)?;
            let tmp = path.with_extension("vault.tmp");
            std::fs::write(&tmp, &new_json)?;
            std::fs::rename(&tmp, &path)?;
            audit(profile)
                .append(&AuditEntry::success(profile, "team-remove-member", None))
                .ok();
            println!("{} Removed team member: {}", "".green(), public_key);
            println!("  All secrets re-encrypted with a new data key.");
            Ok(())
        }
        TeamAction::Members => {
            let json = std::fs::read_to_string(&path)
                .with_context(|| format!("vault not found: {}", path.display()))?;
            let file: tsafe_core::vault::VaultFile = serde_json::from_str(&json)?;
            if file.age_recipients.is_empty() {
                println!("This is not a team vault (no age recipients).");
            } else {
                println!("Team members ({}):\n", file.age_recipients.len());
                for r in &file.age_recipients {
                    println!("  {r}");
                }
            }
            Ok(())
        }
        TeamAction::Keygen { name, email } => {
            let (secret, pubkey) = age_crypto::generate_identity();
            let identity_dir = directories::UserDirs::new()
                .map(|d| d.home_dir().join(".age"))
                .unwrap_or_else(|| std::path::PathBuf::from(".age"));
            std::fs::create_dir_all(&identity_dir)?;
            let identity_path = profile::default_age_identity_path(profile);
            if identity_path.exists() {
                anyhow::bail!(
                    "identity file already exists: {}\nUse `tsafe team show-key` to view your public key.",
                    identity_path.display()
                );
            }
            let file_content = format!(
                "# created: {}\n# public key: {}\n{}\n",
                chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
                pubkey,
                secret
            );
            std::fs::write(&identity_path, &file_content)?;
            // Restrict permissions on Unix.
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                std::fs::set_permissions(&identity_path, std::fs::Permissions::from_mode(0o600))?;
            }

            println!(
                "{} Identity created: {}",
                "".green(),
                identity_path.display()
            );
            println!("  Public key: {}\n", pubkey.cyan());
            println!(
                "Add this block to {} via a PR:\n",
                ".tsafe/team-keys.json".cyan()
            );

            let display_name = name.unwrap_or_else(|| {
                std::env::var("USER")
                    .or_else(|_| std::env::var("USERNAME"))
                    .unwrap_or_else(|_| "your-name".into())
            });
            let display_email =
                email.unwrap_or_else(|| format!("{display_name}@your-domain.example"));

            println!(
                "{}",
                serde_json::to_string_pretty(&serde_json::json!({
                    "name": display_name,
                    "email": display_email,
                    "public_key": pubkey
                }))?
            );
            Ok(())
        }
        TeamAction::ShowKey { identity } => {
            let identity_path = identity
                .map(std::path::PathBuf::from)
                .unwrap_or_else(|| profile::resolve_age_identity_path(profile));
            let content = std::fs::read_to_string(&identity_path)
                .with_context(|| format!("identity file not found: {}", identity_path.display()))?;
            // Extract public key from comment line.
            let from_comment = content
                .lines()
                .find_map(|l| l.strip_prefix("# public key: ").map(str::to_string));
            // Or parse the secret key to derive the public key.
            let pubkey = from_comment.or_else(|| {
                content
                    .lines()
                    .filter(|l| !l.starts_with('#') && !l.trim().is_empty())
                    .find_map(|l| {
                        l.parse::<age::x25519::Identity>()
                            .ok()
                            .map(|id| id.to_public().to_string())
                    })
            });
            match pubkey {
                Some(pk) => println!("{pk}"),
                None => anyhow::bail!("no valid age identity found in {}", identity_path.display()),
            }
            Ok(())
        }
        TeamAction::SyncKeys {
            identity,
            keys_file,
        } => {
            let keys_path = keys_file.map(std::path::PathBuf::from).unwrap_or_else(|| {
                let mut dir = std::env::current_dir().unwrap_or_default();
                loop {
                    let cur = dir.join(".tsafe").join("team-keys.json");
                    if cur.exists() {
                        break cur;
                    }
                    if !dir.pop() {
                        break std::path::PathBuf::from(".tsafe/team-keys.json");
                    }
                }
            });
            let keys_json = std::fs::read_to_string(&keys_path)
                .with_context(|| format!("team keys file not found: {}", keys_path.display()))?;

            #[derive(serde::Deserialize)]
            struct TeamKeysFile {
                members: Vec<TeamKeyEntry>,
            }
            #[derive(serde::Deserialize)]
            struct TeamKeyEntry {
                #[allow(dead_code)]
                name: String,
                #[allow(dead_code)]
                email: String,
                public_key: String,
            }

            let keys_file: TeamKeysFile =
                serde_json::from_str(&keys_json).context("invalid team-keys.json format")?;
            let desired: Vec<String> = keys_file
                .members
                .iter()
                .map(|m| m.public_key.clone())
                .collect();

            if desired.is_empty() {
                anyhow::bail!("team-keys.json has no members — at least one is required");
            }

            let vault_json = std::fs::read_to_string(&path)
                .with_context(|| format!("vault not found: {}", path.display()))?;
            let mut file: tsafe_core::vault::VaultFile = serde_json::from_str(&vault_json)?;
            let current: std::collections::HashSet<String> =
                file.age_recipients.iter().cloned().collect();
            let target: std::collections::HashSet<String> = desired.iter().cloned().collect();

            let to_add: Vec<String> = target.difference(&current).cloned().collect();
            let to_remove: Vec<String> = current.difference(&target).cloned().collect();

            if to_add.is_empty() && to_remove.is_empty() {
                println!("{} Team keys already in sync — nothing to do.", "".green());
                return Ok(());
            }

            let identities = age_crypto::load_identities(std::path::Path::new(&identity))?;

            // Add new members.
            for pk in &to_add {
                team::add_member(&mut file, pk, &identities)?;
                println!("  {} Added: {}", "+".green(), pk);
            }

            // Remove departed members (re-keys the vault).
            for pk in &to_remove {
                team::remove_member(&mut file, pk, &identities)?;
                println!("  {} Removed: {} (vault re-keyed)", "-".red(), pk);
            }

            // Write updated vault.
            let new_json = serde_json::to_string_pretty(&file)?;
            let tmp = path.with_extension("vault.tmp");
            std::fs::write(&tmp, &new_json)?;
            std::fs::rename(&tmp, &path)?;

            audit(profile)
                .append(&AuditEntry::success(profile, "team-sync-keys", None))
                .ok();
            println!(
                "\n{} Synced: {} added, {} removed, {} total members",
                "".green(),
                to_add.len(),
                to_remove.len(),
                file.age_recipients.len()
            );
            Ok(())
        }
    }
}