diff --git a/Cargo.lock b/Cargo.lock index 5b0b25f..b4801d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1332,7 +1332,6 @@ dependencies = [ "dialoguer", "dirs", "futures", - "heck", "human-panic", "indoc", "log", diff --git a/Cargo.toml b/Cargo.toml index f3f3130..56b26d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,6 @@ secrecy = "0.10.3" validator = { version = "0.20.0", features = ["derive"] } zeroize = "1.8.1" serde = { version = "1.0.219", features = ["derive"] } -heck = "0.5.0" serde_with = "3.14.0" serde_json = "1.0.143" dialoguer = "0.12.0" diff --git a/README.md b/README.md index c8ccf64..5042236 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,8 @@ gman aws sts get-caller-identity - [Installation](#installation) - [Configuration](#configuration) - [Providers](#providers) - - [Provider: `local`](#provider-local) + - [Local](#provider-local) + - [AWS Secrets Manager](#provider-aws_secrets_manager) - [Run Configurations](#run-configurations) - [Environment Variable Secret Injection](#environment-variable-secret-injection) - [Inject Secrets via Command-Line Flags](#inject-secrets-via-command-line-flags) @@ -237,18 +238,17 @@ documented and added without breaking existing setups. The following table shows | 🚫 | Won't Add | -| Provider Name | Status | Configuration Docs | Comments | -|--------------------------------------------------------------------------------------------------------------------------|--------|--------------------------|--------------------------------------------| -| `local` | ✅ | [Local](#provider-local) | | -| [`aws_secrets_manager`](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html) | 🕒 | | | -| [`aws_ssm_parameter_store`](https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_parameterstore.html) | 🕒 | | | -| [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | | -| [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | 🕒 | | | -| [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | 🕒 | | | -| [`1password`](https://1password.com/) | 🕒 | | | -| [`bitwarden`](https://bitwarden.com/) | 🕒 | | | -| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets | -| [`lastpass`](https://www.lastpass.com/) | 🕒 | | | +| Provider Name | Status | Configuration Docs | Comments | +|--------------------------------------------------------------------------------------------------------------------------|--------|------------------------------------------------------|--------------------------------------------| +| `local` | ✅ | [Local](#provider-local) | | +| [`aws_secrets_manager`](https://docs.aws.amazon.com/secretsmanager/latest/userguide/intro.html) | ✅ | [AWS Secrets Manager](#provider-aws_secrets_manager) | | +| [`hashicorp_vault`](https://www.hashicorp.com/en/products/vault) | 🕒 | | | +| [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | 🕒 | | | +| [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | 🕒 | | | +| [`1password`](https://1password.com/) | 🕒 | | | +| [`bitwarden`](https://bitwarden.com/) | 🕒 | | | +| [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets | +| [`lastpass`](https://www.lastpass.com/) | 🕒 | | | ### Provider: `local` @@ -403,6 +403,35 @@ Then, all you need to do to run `managarr` with the secrets injected is: gman managarr ``` +### Provider: `aws_secrets_manager` + +The `aws_secrets_manager` provider stores secrets in AWS Secrets Manager using the official AWS Rust SDK. + +- Requires two fields: `aws_profile` and `aws_region`. +- Uses the shared AWS config/credentials files under the named profile to authenticate. +- Implements: `get`, `set`, `update`, `delete`, and `list`. + +Configuration example: + +```yaml +default_provider: aws +providers: + - name: aws + type: aws_secrets_manager + aws_profile: default # Name from your ~/.aws/config and ~/.aws/credentials + aws_region: us-east-1 # Region where your secrets live +``` + +Important notes: +- Deletions are immediate: the provider calls `DeleteSecret` with `force_delete_without_recovery = true`, so there is no + recovery window. If you need a recovery window, do not delete via `gman`. +- `add` uses `CreateSecret`. If the secret already exists, AWS returns an error. Use `update` to change an existing + secret value. +- IAM permissions: ensure the configured principal has `secretsmanager:GetSecretValue`, `CreateSecret`, `UpdateSecret`, + `DeleteSecret`, and `ListSecrets` for the relevant region and ARNs. +- Credential resolution: the provider explicitly selects the given `aws_profile` and `aws_region` via the AWS config + loader; it does not fall back to other profiles or env-only defaults. + ## Detailed Usage ### Storing and Managing Secrets diff --git a/src/bin/gman/cli.rs b/src/bin/gman/cli.rs index 79dcf3a..f82c52c 100644 --- a/src/bin/gman/cli.rs +++ b/src/bin/gman/cli.rs @@ -1,5 +1,5 @@ use crate::command::preview_command; -use anyhow::{anyhow, Context, Result}; +use anyhow::{Context, Result, anyhow}; use futures::future::join_all; use gman::config::{Config, RunConfig}; use gman::providers::SecretProvider; diff --git a/src/bin/gman/main.rs b/src/bin/gman/main.rs index ac26a42..e24661f 100644 --- a/src/bin/gman/main.rs +++ b/src/bin/gman/main.rs @@ -1,12 +1,12 @@ use clap::{ - crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, ValueEnum, + CommandFactory, Parser, ValueEnum, crate_authors, crate_description, crate_name, crate_version, }; use std::ffi::OsString; use anyhow::{Context, Result}; use clap::Subcommand; use crossterm::execute; -use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; +use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; use gman::config::{get_config_file_path, load_config}; use std::io::{self, IsTerminal, Read, Write}; use std::panic::PanicHookInfo; diff --git a/src/config.rs b/src/config.rs index 3d85bdf..0cb5d45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -137,7 +137,7 @@ impl ProviderConfig { /// ```no_run /// # use gman::config::ProviderConfig; /// let mut provider_config = ProviderConfig::default(); - /// let provider = provider_config.extract_provider(); + /// let provider = provider_config.extract_provider(); /// println!("using provider: {}", provider.name()); /// ``` pub fn extract_provider(&mut self) -> &mut dyn SecretProvider { diff --git a/src/providers/aws_secrets_manager.rs b/src/providers/aws_secrets_manager.rs index abb4650..66feeef 100644 --- a/src/providers/aws_secrets_manager.rs +++ b/src/providers/aws_secrets_manager.rs @@ -45,49 +45,51 @@ impl SecretProvider for 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")) + .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}'"))?; + .await? + .create_secret() + .name(key) + .secret_string(value) + .send() + .await + .with_context(|| format!("Failed to set secret '{key}'"))?; - Ok(()) + 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<()> { + async fn update_secret(&self, key: &str, value: &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(()) + .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> { diff --git a/src/providers/local.rs b/src/providers/local.rs index 5b75856..6ff70f7 100644 --- a/src/providers/local.rs +++ b/src/providers/local.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, bail, Context}; +use anyhow::{Context, anyhow, bail}; 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::general_purpose::STANDARD as B64, Engine as _}; +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; use chacha20poly1305::aead::rand_core::RngCore; use chacha20poly1305::{ - aead::{Aead, KeyInit, OsRng}, Key, XChaCha20Poly1305, XNonce, + aead::{Aead, KeyInit, OsRng}, }; -use dialoguer::{theme, Input}; +use dialoguer::{Input, theme}; use log::{debug, error}; use serde::{Deserialize, Serialize}; use serde_with::skip_serializing_none; diff --git a/src/providers/mod.rs b/src/providers/mod.rs index 20b2914..3aeb563 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -7,7 +7,7 @@ mod git_sync; pub mod local; use crate::providers::local::LocalProvider; -use anyhow::{anyhow, Result}; +use anyhow::{Result, anyhow}; use serde::{Deserialize, Serialize}; use std::fmt::{Display, Formatter}; use validator::{Validate, ValidationErrors}; diff --git a/tests/providers/aws_secrets_manager_tests.rs b/tests/providers/aws_secrets_manager_tests.rs new file mode 100644 index 0000000..f2ec555 --- /dev/null +++ b/tests/providers/aws_secrets_manager_tests.rs @@ -0,0 +1,92 @@ +use gman::config::{Config, ProviderConfig}; +use gman::providers::aws_secrets_manager::AwsSecretsManagerProvider; +use gman::providers::{SecretProvider, SupportedProvider}; +use pretty_assertions::{assert_eq, assert_str_eq}; +use validator::Validate; + +#[test] +fn test_aws_provider_name() { + let provider = AwsSecretsManagerProvider { + aws_profile: Some("default".into()), + aws_region: Some("us-east-1".into()), + }; + assert_str_eq!(provider.name(), "AwsSecretsManagerProvider"); +} + +#[test] +fn test_aws_provider_validation_ok() { + let provider = AwsSecretsManagerProvider { + aws_profile: Some("default".into()), + aws_region: Some("us-west-2".into()), + }; + assert!(provider.validate().is_ok()); +} + +#[test] +fn test_aws_provider_missing_profile() { + let provider = AwsSecretsManagerProvider { + aws_profile: None, + aws_region: Some("us-west-2".into()), + }; + assert!(provider.validate().is_err()); +} + +#[test] +fn test_aws_provider_missing_region() { + let provider = AwsSecretsManagerProvider { + aws_profile: Some("default".into()), + aws_region: None, + }; + assert!(provider.validate().is_err()); +} + +#[test] +fn test_aws_secrets_manager_provider_display_and_validate() { + let sp = SupportedProvider::AwsSecretsManager { + provider_def: AwsSecretsManagerProvider { + aws_profile: Some("default".into()), + aws_region: Some("eu-central-1".into()), + }, + }; + // Validate delegates to inner provider + assert!(sp.validate().is_ok()); + // Display formatting for the enum variant + assert_eq!(sp.to_string(), "aws_secrets_manager"); +} + +#[test] +fn test_provider_config_with_aws_deserialize_and_extract() { + // Minimal ProviderConfig YAML using the aws_secrets_manager variant + let yaml = r#"--- +name: aws +type: aws_secrets_manager +aws_profile: default +aws_region: us-east-1 +"#; + + let pc: ProviderConfig = serde_yaml::from_str(yaml).expect("valid provider config yaml"); + // It should validate (both fields present) + assert!(pc.validate().is_ok()); + + // Extract the provider and inspect its name via the trait + let mut pc_owned = pc.clone(); + let provider: &mut dyn SecretProvider = pc_owned.extract_provider(); + assert_eq!(provider.name(), "AwsSecretsManagerProvider"); + + // Round-trip through Config to ensure flattening works in a real list + let cfg_yaml = r#"--- +default_provider: aws +providers: + - name: aws + type: aws_secrets_manager + aws_profile: default + aws_region: us-east-1 +"#; + let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml"); + assert!(cfg.validate().is_ok()); + + let extracted = cfg + .extract_provider_config(None) + .expect("should find default provider"); + assert_eq!(extracted.name.as_deref(), Some("aws")); +} diff --git a/tests/providers/local_tests.rs b/tests/providers/local_tests.rs index db67c4b..96685c3 100644 --- a/tests/providers/local_tests.rs +++ b/tests/providers/local_tests.rs @@ -1,5 +1,6 @@ -use gman::config::Config; +use gman::config::{Config, ProviderConfig}; use gman::providers::local::LocalProvider; +use gman::providers::{SecretProvider, SupportedProvider}; use pretty_assertions::assert_eq; use pretty_assertions::assert_str_eq; use validator::Validate; @@ -13,6 +14,17 @@ fn test_local_provider_name() { assert_str_eq!(provider.name(), "LocalProvider"); } +#[test] +fn test_local_provider_display_and_validate() { + let sp = SupportedProvider::Local { + provider_def: LocalProvider::default(), + }; + // Validate delegates to inner provider + assert!(sp.validate().is_ok()); + // Display formatting for the enum variant + assert_eq!(sp.to_string(), "local"); +} + #[test] fn test_local_provider_valid() { let provider = LocalProvider { @@ -54,3 +66,37 @@ fn test_local_provider_default() { assert_eq!(provider.git_user_email, None); assert_eq!(provider.git_executable, None); } + +#[test] +fn test_provider_config_with_local_deserialize_and_extract() { + // Minimal ProviderConfig YAML using the local variant + let yaml = r#"--- +name: local +type: local +"#; + + let pc: ProviderConfig = serde_yaml::from_str(yaml).expect("valid provider config yaml"); + assert!(pc.validate().is_ok()); + + let mut pc_owned = pc.clone(); + let provider: &mut dyn SecretProvider = pc_owned.extract_provider(); + assert_eq!(provider.name(), "LocalProvider"); + + // Round-trip through Config to ensure flattening works in a real list + let cfg_yaml = r#"--- +default_provider: local +providers: + - name: local + type: local + password_file: /tmp/.gman_pass + git_branch: main + git_remote_url: git@github.com:username/repo.git +"#; + let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml"); + assert!(cfg.validate().is_ok()); + + let extracted = cfg + .extract_provider_config(None) + .expect("should find default provider"); + assert_eq!(extracted.name.as_deref(), Some("local")); +} diff --git a/tests/providers/mod.rs b/tests/providers/mod.rs index f334fe8..4a23bb8 100644 --- a/tests/providers/mod.rs +++ b/tests/providers/mod.rs @@ -1,2 +1,3 @@ +mod aws_secrets_manager_tests; mod local_tests; mod provider_tests;