diff --git a/Cargo.lock b/Cargo.lock index beb0011..5b0b25f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -135,6 +135,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1166,6 +1177,21 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1173,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1181,6 +1208,34 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -1199,10 +1254,16 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -1257,6 +1318,7 @@ dependencies = [ "anyhow", "argon2", "assert_cmd", + "async-trait", "aws-config", "aws-sdk-secretsmanager", "backtrace", @@ -1269,6 +1331,7 @@ dependencies = [ "crossterm", "dialoguer", "dirs", + "futures", "heck", "human-panic", "indoc", @@ -1285,7 +1348,6 @@ dependencies = [ "serde_with", "serde_yaml", "tempfile", - "thiserror", "tokio", "validator", "zeroize", diff --git a/Cargo.toml b/Cargo.toml index 25305f7..f3f3130 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,6 @@ validator = { version = "0.20.0", features = ["derive"] } zeroize = "1.8.1" serde = { version = "1.0.219", features = ["derive"] } heck = "0.5.0" -thiserror = "2.0.16" serde_with = "3.14.0" serde_json = "1.0.143" dialoguer = "0.12.0" @@ -52,6 +51,8 @@ tempfile = "3.22.0" aws-sdk-secretsmanager = "1.88.0" tokio = { version = "1.47.1", features = ["full"] } aws-config = { version = "1.8.6", features = ["behavior-version-latest"] } +async-trait = "0.1.89" +futures = "0.3.31" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/README.md b/README.md index 9e24986..c8ccf64 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,25 @@ gman aws sts get-caller-identity - **Git sync for local vaults** to move secrets across machines - **Command wrapping** to inject secrets for any program - **Customizable run profiles** (env, flags, or files) -- **Consistent secret naming**: input is snake_case; injected as UPPER_SNAKE_CASE - **Direct secret retrieval** via `gman get ...` - **Dry-run** to preview wrapped commands and secret injection +## Table of Contents +- [Features](#features) +- [Installation](#installation) +- [Configuration](#configuration) +- [Providers](#providers) + - [Provider: `local`](#provider-local) +- [Run Configurations](#run-configurations) + - [Environment Variable Secret Injection](#environment-variable-secret-injection) + - [Inject Secrets via Command-Line Flags](#inject-secrets-via-command-line-flags) + - [Inject Secrets into Files](#inject-secrets-into-files) +- [Detailed Usage](#detailed-usage) + - [Storing and Managing Secrets](#storing-and-managing-secrets) + - [Running Commands](#running-commands) + - [Multiple Providers and Switching](#multiple-providers-and-switching) +- [Creator](#creator) + ## Installation ### Cargo @@ -302,8 +317,6 @@ will error out and report that it could not find the run config with that name. You can manually specify which run configuration to use with the `--profile` flag. Again, if no profile is found with that name, `gman` will error out. -#### Important: Secret names are always injected in `UPPER_SNAKE_CASE` format. - ### Environment Variable Secret Injection By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`. @@ -313,8 +326,8 @@ By default, secrets are injected as environment variables. The two required fiel run_configs: - name: aws secrets: - - aws_access_key_id - - aws_secret_access_key + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY ``` When you run `gman aws ...`, `gman` will fetch these two secrets and expose them as environment variables to the `aws` process. @@ -335,8 +348,8 @@ This requires three additional fields: `flag`, `flag_position`, and `arg_format` run_configs: - name: docker secrets: - - my_app_api_key - - my_app_db_password + - MY_APP_API_KEY + - MY_APP_DB_PASSWORD flag: -e flag_position: 2 # In 'docker run ...', the flag comes after 'run', so position 2. arg_format: "{{key}}={{value}}" @@ -363,8 +376,8 @@ specified secrets, it will leave the file unchanged. run_configs: - name: managarr secrets: - - radarr_api_key - - sonarr_api_key # Remember that secret names are always converted to UPPER_SNAKE_CASE + - RADARR_API_KEY + - SONARR_API_KEY files: - /home/user/.config/managarr/config.yml ``` @@ -381,7 +394,7 @@ sonarr: - name: Sonarr host: 192.168.0.105 port: 8989 - api_token: '{{sonarr_api_key}}' # gman is case-insensitive, so this will also be replaced correctly + api_token: '{{SONARR_API_KEY}}' ``` Then, all you need to do to run `managarr` with the secrets injected is: @@ -394,8 +407,6 @@ gman managarr ### Storing and Managing Secrets -All secret names are automatically converted to `snake_case`. - - **Add a secret:** ```sh # The value is read from standard input @@ -480,8 +491,8 @@ providers: run_configs: - name: aws secrets: - - aws_access_key_id - - aws_secret_access_key + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY ``` Switch providers on the fly using the provider name defined in `providers`: diff --git a/src/bin/gman/cli.rs b/src/bin/gman/cli.rs index 9427bac..79dcf3a 100644 --- a/src/bin/gman/cli.rs +++ b/src/bin/gman/cli.rs @@ -1,8 +1,8 @@ use crate::command::preview_command; -use anyhow::{Context, Result, anyhow}; +use anyhow::{anyhow, Context, Result}; +use futures::future::join_all; use gman::config::{Config, RunConfig}; use gman::providers::SecretProvider; -use heck::ToSnakeCase; use log::{debug, error}; use regex::Regex; use std::collections::HashMap; @@ -14,7 +14,7 @@ use std::process::Command; const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}"; const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; -pub fn wrap_and_run_command( +pub async fn wrap_and_run_command( secrets_provider: &mut dyn SecretProvider, config: &Config, tokens: Vec, @@ -36,43 +36,40 @@ pub fn wrap_and_run_command( .find(|c| c.name.as_deref() == Some(run_config_profile_name)) }); if let Some(run_cfg) = run_config_opt { - let secrets_result = run_cfg + let secrets_result_futures = run_cfg .secrets .as_ref() .ok_or_else(|| { anyhow!("No secrets configured for run profile '{run_config_profile_name}'") })? .iter() - .map(|key| { - let secret_name = key.to_snake_case().to_uppercase(); + .map(async |key| { debug!( - "Retrieving secret '{secret_name}' for run profile '{}'", + "Retrieving secret '{key}' for run profile '{}'", run_config_profile_name ); - secrets_provider - .get_secret(key.to_snake_case().to_uppercase().as_str()) - .ok() - .map_or_else( - || { - debug!("Failed to fetch secret '{secret_name}' from secret provider"); - ( - key.to_uppercase(), - Err(anyhow!( - "Failed to fetch secret '{secret_name}' from secret provider" - )), - ) - }, - |value| { - if dry_run { - (key.to_uppercase(), Ok("*****".into())) - } else { - (key.to_uppercase(), Ok(value)) - } - }, - ) + secrets_provider.get_secret(key).await.ok().map_or_else( + || { + debug!("Failed to fetch secret '{key}' from secret provider"); + ( + key, + Err(anyhow!( + "Failed to fetch secret '{key}' from secret provider" + )), + ) + }, + |value| { + if dry_run { + (key, Ok("*****".into())) + } else { + (key, Ok(value)) + } + }, + ) }); + let secrets_result = join_all(secrets_result_futures).await; let err = secrets_result - .clone() + .iter() .filter(|(_, r)| r.is_err()) .collect::>(); if !err.is_empty() { @@ -86,14 +83,15 @@ pub fn wrap_and_run_command( )); } let secrets = secrets_result - .map(|(k, r)| (k, r.unwrap())) + .into_iter() + .map(|(k, r)| (k.as_str(), r.unwrap())) .collect::>(); let mut cmd_def = Command::new(prog); if run_cfg.flag.is_some() { let args = parse_args(args, run_cfg, secrets.clone(), dry_run)?; run_cmd(cmd_def.args(&args), dry_run)?; } else if run_cfg.files.is_some() { - let injected_files = generate_files_secret_injections(secrets.clone(), run_cfg) + let injected_files = generate_files_secret_injections(secrets, run_cfg) .with_context(|| "failed to inject secrets into files")?; for (file, original_content, new_content) in &injected_files { if dry_run { @@ -115,7 +113,7 @@ pub fn wrap_and_run_command( e ); debug!("Restoring original content to file '{}'", file.display()); - fs::write(file, original_content) .with_context(|| format!("failed to restore original content to file '{}' after injection failure: {}", file.display(), e))?; + fs::write(file, original_content).with_context(|| format!("failed to restore original content to file '{}' after injection failure: {}", file.display(), e))?; return Err(e); } } @@ -143,7 +141,7 @@ pub fn wrap_and_run_command( file.display() ); debug!("Restoring original content to file '{}'", file.display()); - fs::write(file, original_content) .with_context(|| format!("failed to restore original content to file '{}' after command execution failure: {}", file.display(), e))?; + fs::write(file, original_content).with_context(|| format!("failed to restore original content to file '{}' after command execution failure: {}", file.display(), e))?; } } return Err(e); @@ -162,9 +160,9 @@ pub fn wrap_and_run_command( } fn generate_files_secret_injections( - secrets: HashMap, + secrets: HashMap<&str, String>, run_config: &RunConfig, -) -> Result> { +) -> Result> { let re = Regex::new(r"\{\{([A-Za-z0-9_]+)\}\}")?; let mut results = Vec::new(); for file in run_config @@ -184,12 +182,16 @@ fn generate_files_secret_injections( })?; let new_content = re.replace_all(&original_content, |caps: ®ex::Captures| { secrets - .get(&caps[1].to_snake_case().to_uppercase()) + .get(&caps[1]) .map(|s| s.as_str()) .unwrap_or(&caps[0]) .to_string() }); - results.push((file, original_content.to_string(), new_content.to_string())); + results.push(( + file.into(), + original_content.to_string(), + new_content.to_string(), + )); } Ok(results) } @@ -207,7 +209,7 @@ pub fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> { pub fn parse_args( args: &[OsString], run_config: &RunConfig, - secrets: HashMap, + secrets: HashMap<&str, String>, dry_run: bool, ) -> Result> { let mut args = args.to_vec(); @@ -259,20 +261,21 @@ mod tests { use std::ffi::OsString; struct DummyProvider; + #[async_trait::async_trait] impl SecretProvider for DummyProvider { fn name(&self) -> &'static str { "Dummy" } - fn get_secret(&self, key: &str) -> Result { + async fn get_secret(&self, key: &str) -> Result { Ok(format!("{}_VAL", key)) } - fn set_secret(&self, _key: &str, _value: &str) -> Result<()> { + async fn set_secret(&self, _key: &str, _value: &str) -> Result<()> { Ok(()) } - fn delete_secret(&self, _key: &str) -> Result<()> { + async fn delete_secret(&self, _key: &str) -> Result<()> { Ok(()) } - fn sync(&mut self) -> Result<()> { + async fn sync(&mut self) -> Result<()> { Ok(()) } } @@ -280,14 +283,14 @@ mod tests { #[test] fn test_generate_files_secret_injections() { let mut secrets = HashMap::new(); - secrets.insert("SECRET1".to_string(), "value1".to_string()); + secrets.insert("SECRET1", "value1".to_string()); let temp_dir = tempfile::tempdir().unwrap(); let file_path = temp_dir.path().join("test.txt"); - fs::write(&file_path, "{{secret1}}").unwrap(); + fs::write(&file_path, "{{SECRET1}}").unwrap(); let run_config = RunConfig { name: Some("test".to_string()), - secrets: Some(vec!["secret1".to_string()]), + secrets: Some(vec!["SECRET1".to_string()]), files: Some(vec![file_path.clone()]), flag: None, flag_position: None, @@ -297,8 +300,8 @@ mod tests { let result = generate_files_secret_injections(secrets, &run_config).unwrap(); assert_eq!(result.len(), 1); - assert_eq!(result[0].0, &file_path); - assert_str_eq!(result[0].1, "{{secret1}}"); + assert_eq!(result[0].0, file_path); + assert_str_eq!(result[0].1, "{{SECRET1}}"); assert_str_eq!(result[0].2, "value1"); } @@ -313,7 +316,7 @@ mod tests { arg_format: Some("{{key}}={{value}}".into()), }; let mut secrets = HashMap::new(); - secrets.insert("API_KEY".into(), "xyz".into()); + secrets.insert("API_KEY", "xyz".into()); // Insert at position let args = vec![OsString::from("run"), OsString::from("image")]; @@ -341,18 +344,20 @@ mod tests { ); } - #[test] - fn test_wrap_and_run_command_no_profile() { + #[tokio::test] + async fn test_wrap_and_run_command_no_profile() { let cfg = Config::default(); let mut dummy = DummyProvider; let prov: &mut dyn SecretProvider = &mut dummy; let tokens = vec![OsString::from("echo"), OsString::from("hi")]; - let err = wrap_and_run_command(prov, &cfg, tokens, None, true).unwrap_err(); + let err = wrap_and_run_command(prov, &cfg, tokens, None, true) + .await + .unwrap_err(); assert!(err.to_string().contains("No run profile found")); } - #[test] - fn test_wrap_and_run_command_env_injection_dry_run() { + #[tokio::test] + async fn test_wrap_and_run_command_env_injection_dry_run() { // Create a config with a matching run profile for command "echo" let run_cfg = RunConfig { name: Some("echo".into()), @@ -372,7 +377,7 @@ mod tests { // Capture stderr for dry_run preview let tokens = vec![OsString::from("echo"), OsString::from("hello")]; // Best-effort: ensure function does not error under dry_run - let res = wrap_and_run_command(prov, &cfg, tokens, None, true); + let res = wrap_and_run_command(prov, &cfg, tokens, None, true).await; assert!(res.is_ok()); // Not asserting output text to keep test platform-agnostic } diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index 492a053..ac26a42 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -1,14 +1,13 @@ use clap::{ - CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version, + crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, ValueEnum, }; use std::ffi::OsString; use anyhow::{Context, Result}; use clap::Subcommand; use crossterm::execute; -use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; +use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; use gman::config::{get_config_file_path, load_config}; -use heck::ToSnakeCase; use std::io::{self, IsTerminal, Read, Write}; use std::panic::PanicHookInfo; @@ -143,22 +142,22 @@ async fn main() -> Result<()> { Commands::Add { name } => { let plaintext = read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; - let snake_case_name = name.to_snake_case().to_uppercase(); secrets_provider - .set_secret(&snake_case_name, plaintext.trim_end()) + .set_secret(&name, plaintext.trim_end()) + .await .map(|_| match cli.output { Some(_) => (), - None => println!("✓ Secret '{snake_case_name}' added to the vault."), + None => println!("✓ Secret '{name}' added to the vault."), })?; } Commands::Get { name } => { - let snake_case_name = name.to_snake_case().to_uppercase(); secrets_provider - .get_secret(&snake_case_name) + .get_secret(&name) + .await .map(|secret| match cli.output { Some(OutputFormat::Json) => { let json_output = serde_json::json!({ - snake_case_name: secret + name: secret }); println!( "{}", @@ -174,24 +173,23 @@ async fn main() -> Result<()> { Commands::Update { name } => { let plaintext = read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; - let snake_case_name = name.to_snake_case().to_uppercase(); secrets_provider - .update_secret(&snake_case_name, plaintext.trim_end()) + .update_secret(&name, plaintext.trim_end()) + .await .map(|_| match cli.output { Some(_) => (), - None => println!("✓ Secret '{snake_case_name}' updated in the vault."), + None => println!("✓ Secret '{name}' updated in the vault."), })?; } Commands::Delete { name } => { - let snake_case_name = name.to_snake_case().to_uppercase(); - secrets_provider.delete_secret(&snake_case_name).map(|_| { + secrets_provider.delete_secret(&name).await.map(|_| { if cli.output.is_none() { - println!("✓ Secret '{snake_case_name}' deleted from the vault.") + println!("✓ Secret '{name}' deleted from the vault.") } })?; } Commands::List {} => { - let secrets = secrets_provider.list_secrets()?; + let secrets = secrets_provider.list_secrets().await?; if secrets.is_empty() { match cli.output { Some(OutputFormat::Json) => { @@ -217,14 +215,15 @@ async fn main() -> Result<()> { } } Commands::Sync {} => { - secrets_provider.sync().map(|_| { + secrets_provider.sync().await.map(|_| { if cli.output.is_none() { println!("✓ Secrets synchronized with remote") } })?; } Commands::External(tokens) => { - wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run)?; + wrap_and_run_command(secrets_provider, &config, tokens, cli.profile, cli.dry_run) + .await?; } Commands::Completions { shell } => { let mut cmd = Cli::command(); diff --git a/src/config.rs b/src/config.rs index c2ef7ea..3d85bdf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -136,8 +136,9 @@ impl ProviderConfig { /// /// ```no_run /// # use gman::config::ProviderConfig; - /// let provider_config = ProviderConfig::default().extract_provider(); - /// println!("using provider: {}", provider_config.name()); + /// let mut provider_config = ProviderConfig::default(); + /// let provider = provider_config.extract_provider(); + /// println!("using provider: {}", provider.name()); /// ``` pub fn extract_provider(&mut self) -> &mut dyn SecretProvider { match &mut self.provider_type { @@ -145,6 +146,10 @@ impl ProviderConfig { debug!("Using local secret provider"); provider_def } + SupportedProvider::AwsSecretsManager { provider_def } => { + debug!("Using AWS Secrets Manager provider"); + provider_def + } } } } @@ -278,15 +283,14 @@ pub fn load_config() -> Result { .providers .iter_mut() .filter(|p| matches!(p.provider_type, SupportedProvider::Local { .. })) - .for_each(|p| match p.provider_type { - SupportedProvider::Local { + .for_each(|p| { + if let SupportedProvider::Local { ref mut provider_def, - } => { - if provider_def.password_file.is_none() - && let Some(local_password_file) = Config::local_provider_password_file() - { - provider_def.password_file = Some(local_password_file); - } + } = p.provider_type + && provider_def.password_file.is_none() + && let Some(local_password_file) = Config::local_provider_password_file() + { + provider_def.password_file = Some(local_password_file); } }); diff --git a/src/providers/aws_secrets_manager.rs b/src/providers/aws_secrets_manager.rs index e69de29..abb4650 100644 --- a/src/providers/aws_secrets_manager.rs +++ b/src/providers/aws_secrets_manager.rs @@ -0,0 +1,124 @@ +use crate::providers::SecretProvider; +use anyhow::Context; +use anyhow::Result; +use aws_config::Region; +use aws_sdk_secretsmanager::Client; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use validator::Validate; + +#[skip_serializing_none] +/// Configuration for AWS Secrets Manager provider +/// See [AWS Secrets Manager](https://aws.amazon.com/secrets-manager/) +/// for more information. +/// +/// This provider stores secrets in AWS Secrets Manager. It requires +/// AWS credentials to be configured in the AWS configuration +/// files for different AWS profiles. +/// +/// Example +/// ```no_run +/// use gman::providers::{SecretProvider, SupportedProvider}; +/// use gman::config::{Config, ProviderConfig}; +/// use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider; +/// +/// let provider = AwsSecretsManagerProvider { +/// aws_profile: Some("prod".to_string()), +/// aws_region: Some("us-west-2".to_string()), +/// }; +/// let _ = provider.set_secret("MY_SECRET", "value"); +/// ``` +#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct AwsSecretsManagerProvider { + #[validate(required)] + pub aws_profile: Option, + #[validate(required)] + pub aws_region: Option, +} + +#[async_trait::async_trait] +impl SecretProvider for AwsSecretsManagerProvider { + fn name(&self) -> &'static str { + "AwsSecretsManagerProvider" + } + + async fn get_secret(&self, key: &str) -> Result { + self.get_client() + .await? + .get_secret_value() + .secret_id(key) + .send() + .await? + .secret_string + .with_context(|| format!("Secret '{key}' not found")) + } + + async fn set_secret(&self, key: &str, value: &str) -> Result<()> { + self.get_client() + .await? + .create_secret() + .name(key) + .secret_string(value) + .send() + .await.with_context(|| format!("Failed to set secret '{key}'"))?; + + Ok(()) + } + + async fn update_secret(&self, key: &str, value: &str) -> Result<()> { + self.get_client() + .await? + .update_secret() + .secret_id(key) + .secret_string(value) + .send() + .await.with_context(|| format!("Failed to update secret '{key}'"))?; + + Ok(()) + } + + async fn delete_secret(&self, key: &str) -> Result<()> { + self.get_client() + .await? + .delete_secret() + .secret_id(key) + .force_delete_without_recovery(true) + .send() + .await + .with_context(|| format!("Failed to delete secret '{key}'"))?; + Ok(()) + } + + async fn list_secrets(&self) -> Result> { + self.get_client() + .await? + .list_secrets() + .send() + .await? + .secret_list + .with_context(|| "No secrets found") + .map(|secrets| secrets.into_iter().filter_map(|s| s.name).collect()) + } +} + +impl AwsSecretsManagerProvider { + async fn get_client(&self) -> Result { + let region = self + .aws_region + .clone() + .with_context(|| "aws_region is required")?; + let profile = self + .aws_profile + .clone() + .with_context(|| "aws_profile is required")?; + + let config = aws_config::from_env() + .region(Region::new(region)) + .profile_name(profile) + .load() + .await; + + Ok(Client::new(&config)) + } +} diff --git a/src/providers/local.rs b/src/providers/local.rs index 3f2b604..5b75856 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, anyhow, bail}; +use anyhow::{anyhow, bail, Context}; use secrecy::{ExposeSecret, SecretString}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -6,20 +6,20 @@ use std::{env, fs}; use zeroize::Zeroize; use crate::config::Config; +use crate::providers::git_sync::{repo_name_from_url, sync_and_push, SyncOpts}; use crate::providers::SecretProvider; -use crate::providers::git_sync::{SyncOpts, repo_name_from_url, sync_and_push}; use crate::{ ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION, }; use anyhow::Result; use argon2::{Algorithm, Argon2, Params, Version}; -use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; +use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use chacha20poly1305::aead::rand_core::RngCore; use chacha20poly1305::{ - Key, XChaCha20Poly1305, XNonce, aead::{Aead, KeyInit, OsRng}, + Key, XChaCha20Poly1305, XNonce, }; -use dialoguer::{Input, theme}; +use dialoguer::{theme, Input}; use log::{debug, error}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; @@ -36,15 +36,13 @@ use validator::Validate; /// Example /// ```no_run /// use gman::providers::local::LocalProvider; -/// use gman::providers::SecretProvider; -/// use gman::config::Config; +/// use gman::providers::{SecretProvider, SupportedProvider}; +/// use gman::config::{Config, ProviderConfig}; /// /// let provider = LocalProvider::default(); -/// let cfg = Config::default(); /// // Will prompt for a password when reading/writing secrets unless a /// // password file is configured. -/// // provider.set_secret(&cfg, "MY_SECRET", "value")?; -/// # Ok::<(), anyhow::Error>(()) +/// let _ = provider.set_secret("MY_SECRET", "value"); /// ``` #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -71,12 +69,13 @@ impl Default for LocalProvider { } } +#[async_trait::async_trait] impl SecretProvider for LocalProvider { fn name(&self) -> &'static str { "LocalProvider" } - fn get_secret(&self, key: &str) -> Result { + async fn get_secret(&self, key: &str) -> Result { let vault_path = self.active_vault_path()?; let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let envelope = vault @@ -90,7 +89,7 @@ impl SecretProvider for LocalProvider { Ok(plaintext) } - fn set_secret(&self, key: &str, value: &str) -> Result<()> { + async fn set_secret(&self, key: &str, value: &str) -> Result<()> { let vault_path = self.active_vault_path()?; let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); if vault.contains_key(key) { @@ -109,7 +108,7 @@ impl SecretProvider for LocalProvider { store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } - fn update_secret(&self, key: &str, value: &str) -> Result<()> { + async fn update_secret(&self, key: &str, value: &str) -> Result<()> { let vault_path = self.active_vault_path()?; let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); @@ -132,7 +131,7 @@ impl SecretProvider for LocalProvider { store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } - fn delete_secret(&self, key: &str) -> Result<()> { + async fn delete_secret(&self, key: &str) -> Result<()> { let vault_path = self.active_vault_path()?; let mut vault: HashMap = load_vault(&vault_path).unwrap_or_default(); if !vault.contains_key(key) { @@ -144,7 +143,7 @@ impl SecretProvider for LocalProvider { store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault") } - fn list_secrets(&self) -> Result> { + async fn list_secrets(&self) -> Result> { let vault_path = self.active_vault_path()?; let vault: HashMap = load_vault(&vault_path).unwrap_or_default(); let keys: Vec = vault.keys().cloned().collect(); @@ -152,7 +151,7 @@ impl SecretProvider for LocalProvider { Ok(keys) } - fn sync(&mut self) -> Result<()> { + async fn sync(&mut self) -> Result<()> { let mut config_changed = false; if self.git_branch.is_none() { diff --git a/src/providers/mod.rs b/src/providers/mod.rs index a76db9b..20b2914 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -2,43 +2,42 @@ //! //! Implementations provide storage/backends for secrets and a common //! interface used by the CLI. +pub mod aws_secrets_manager; mod git_sync; pub mod local; use crate::providers::local::LocalProvider; -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; -use thiserror::Error; use validator::{Validate, ValidationErrors}; -/// A secret storage backend capable of CRUD and sync, with optional -/// update and listing -pub trait SecretProvider { +/// A secret storage backend capable of CRUD, with optional +/// update, listing, and sync support. +#[async_trait::async_trait] +pub trait SecretProvider: Send + Sync { fn name(&self) -> &'static str; - fn get_secret(&self, key: &str) -> Result; - fn set_secret(&self, key: &str, value: &str) -> Result<()>; - fn update_secret(&self, _key: &str, _value: &str) -> Result<()> { + async fn get_secret(&self, key: &str) -> Result; + async fn set_secret(&self, key: &str, value: &str) -> Result<()>; + async fn update_secret(&self, _key: &str, _value: &str) -> Result<()> { Err(anyhow!( "update secret not supported for provider {}", self.name() )) } - fn delete_secret(&self, key: &str) -> Result<()>; - fn list_secrets(&self) -> Result> { + async fn delete_secret(&self, key: &str) -> Result<()>; + async fn list_secrets(&self) -> Result> { Err(anyhow!( "list secrets is not supported for the provider {}", self.name() )) } - fn sync(&mut self) -> Result<()>; -} - -/// Errors when parsing a provider identifier. -#[derive(Debug, Error)] -pub enum ParseProviderError { - #[error("unsupported provider '{0}'")] - Unsupported(String), + async fn sync(&mut self) -> Result<()> { + Err(anyhow!( + "sync is not supported for the provider {}", + self.name() + )) + } } /// Registry of built-in providers. @@ -50,12 +49,17 @@ pub enum SupportedProvider { #[serde(flatten)] provider_def: LocalProvider, }, + AwsSecretsManager { + #[serde(flatten)] + provider_def: aws_secrets_manager::AwsSecretsManagerProvider, + }, } impl Validate for SupportedProvider { fn validate(&self) -> Result<(), ValidationErrors> { match self { SupportedProvider::Local { provider_def } => provider_def.validate(), + SupportedProvider::AwsSecretsManager { provider_def } => provider_def.validate(), } } } @@ -72,6 +76,7 @@ impl Display for SupportedProvider { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { SupportedProvider::Local { .. } => write!(f, "local"), + SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"), } } } diff --git a/tests/bin/cli_tests.rs b/tests/bin/cli_tests.rs index c5b8037..4fd4932 100644 --- a/tests/bin/cli_tests.rs +++ b/tests/bin/cli_tests.rs @@ -113,7 +113,7 @@ fn cli_add_get_list_update_delete_roundtrip() { .env("XDG_CACHE_HOME", &xdg_cache) .args(["--output", "json", "get", "my_api_key"]); get_json.assert().success().stdout( - predicate::str::contains("MY_API_KEY").and(predicate::str::contains("super_secret")), + predicate::str::contains("my_api_key").and(predicate::str::contains("super_secret")), ); // list @@ -123,7 +123,7 @@ fn cli_add_get_list_update_delete_roundtrip() { .arg("list"); list.assert() .success() - .stdout(predicate::str::contains("MY_API_KEY")); + .stdout(predicate::str::contains("my_api_key")); // update let mut update = Command::cargo_bin("gman").unwrap(); diff --git a/tests/providers/provider_tests.rs b/tests/providers/provider_tests.rs index 215f704..f399bf5 100644 --- a/tests/providers/provider_tests.rs +++ b/tests/providers/provider_tests.rs @@ -1,14 +1,6 @@ use gman::config::ProviderConfig; -use gman::providers::ParseProviderError; -use pretty_assertions::assert_eq; use validator::Validate; -#[test] -fn test_parse_provider_error_display() { - let err = ParseProviderError::Unsupported("test".to_string()); - assert_eq!(err.to_string(), "unsupported provider 'test'"); -} - #[test] fn test_provider_config_missing_name() { let config = ProviderConfig {