feat: AWS Secrets Manager support

This commit is contained in:
2025-09-12 17:11:44 -06:00
parent ae7f04a423
commit 81989f8c94
11 changed files with 344 additions and 142 deletions
Generated
+63 -1
View File
@@ -135,6 +135,17 @@ dependencies = [
"wait-timeout", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -1166,6 +1177,21 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" 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]] [[package]]
name = "futures-channel" name = "futures-channel"
version = "0.3.31" version = "0.3.31"
@@ -1173,6 +1199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-sink",
] ]
[[package]] [[package]]
@@ -1181,6 +1208,34 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" 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]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
@@ -1199,10 +1254,16 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [ dependencies = [
"futures-channel",
"futures-core", "futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task", "futures-task",
"memchr",
"pin-project-lite", "pin-project-lite",
"pin-utils", "pin-utils",
"slab",
] ]
[[package]] [[package]]
@@ -1257,6 +1318,7 @@ dependencies = [
"anyhow", "anyhow",
"argon2", "argon2",
"assert_cmd", "assert_cmd",
"async-trait",
"aws-config", "aws-config",
"aws-sdk-secretsmanager", "aws-sdk-secretsmanager",
"backtrace", "backtrace",
@@ -1269,6 +1331,7 @@ dependencies = [
"crossterm", "crossterm",
"dialoguer", "dialoguer",
"dirs", "dirs",
"futures",
"heck", "heck",
"human-panic", "human-panic",
"indoc", "indoc",
@@ -1285,7 +1348,6 @@ dependencies = [
"serde_with", "serde_with",
"serde_yaml", "serde_yaml",
"tempfile", "tempfile",
"thiserror",
"tokio", "tokio",
"validator", "validator",
"zeroize", "zeroize",
+2 -1
View File
@@ -40,7 +40,6 @@ validator = { version = "0.20.0", features = ["derive"] }
zeroize = "1.8.1" zeroize = "1.8.1"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
heck = "0.5.0" heck = "0.5.0"
thiserror = "2.0.16"
serde_with = "3.14.0" serde_with = "3.14.0"
serde_json = "1.0.143" serde_json = "1.0.143"
dialoguer = "0.12.0" dialoguer = "0.12.0"
@@ -52,6 +51,8 @@ tempfile = "3.22.0"
aws-sdk-secretsmanager = "1.88.0" aws-sdk-secretsmanager = "1.88.0"
tokio = { version = "1.47.1", features = ["full"] } tokio = { version = "1.47.1", features = ["full"] }
aws-config = { version = "1.8.6", features = ["behavior-version-latest"] } aws-config = { version = "1.8.6", features = ["behavior-version-latest"] }
async-trait = "0.1.89"
futures = "0.3.31"
[dev-dependencies] [dev-dependencies]
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
+25 -14
View File
@@ -82,10 +82,25 @@ gman aws sts get-caller-identity
- **Git sync for local vaults** to move secrets across machines - **Git sync for local vaults** to move secrets across machines
- **Command wrapping** to inject secrets for any program - **Command wrapping** to inject secrets for any program
- **Customizable run profiles** (env, flags, or files) - **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 ...` - **Direct secret retrieval** via `gman get ...`
- **Dry-run** to preview wrapped commands and secret injection - **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 ## Installation
### Cargo ### 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 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. that name, `gman` will error out.
#### Important: Secret names are always injected in `UPPER_SNAKE_CASE` format.
### Environment Variable Secret Injection ### Environment Variable Secret Injection
By default, secrets are injected as environment variables. The two required fields are `name` and `secrets`. 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: run_configs:
- name: aws - name: aws
secrets: secrets:
- aws_access_key_id - AWS_ACCESS_KEY_ID
- aws_secret_access_key - AWS_SECRET_ACCESS_KEY
``` ```
When you run `gman aws ...`, `gman` will fetch these two secrets and expose them as environment variables to the `aws` When you run `gman aws ...`, `gman` will fetch these two secrets and expose them as environment variables to the `aws`
process. process.
@@ -335,8 +348,8 @@ This requires three additional fields: `flag`, `flag_position`, and `arg_format`
run_configs: run_configs:
- name: docker - name: docker
secrets: secrets:
- my_app_api_key - MY_APP_API_KEY
- my_app_db_password - MY_APP_DB_PASSWORD
flag: -e flag: -e
flag_position: 2 # In 'docker run ...', the flag comes after 'run', so position 2. flag_position: 2 # In 'docker run ...', the flag comes after 'run', so position 2.
arg_format: "{{key}}={{value}}" arg_format: "{{key}}={{value}}"
@@ -363,8 +376,8 @@ specified secrets, it will leave the file unchanged.
run_configs: run_configs:
- name: managarr - name: managarr
secrets: secrets:
- radarr_api_key - RADARR_API_KEY
- sonarr_api_key # Remember that secret names are always converted to UPPER_SNAKE_CASE - SONARR_API_KEY
files: files:
- /home/user/.config/managarr/config.yml - /home/user/.config/managarr/config.yml
``` ```
@@ -381,7 +394,7 @@ sonarr:
- name: Sonarr - name: Sonarr
host: 192.168.0.105 host: 192.168.0.105
port: 8989 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: Then, all you need to do to run `managarr` with the secrets injected is:
@@ -394,8 +407,6 @@ gman managarr
### Storing and Managing Secrets ### Storing and Managing Secrets
All secret names are automatically converted to `snake_case`.
- **Add a secret:** - **Add a secret:**
```sh ```sh
# The value is read from standard input # The value is read from standard input
@@ -480,8 +491,8 @@ providers:
run_configs: run_configs:
- name: aws - name: aws
secrets: secrets:
- aws_access_key_id - AWS_ACCESS_KEY_ID
- aws_secret_access_key - AWS_SECRET_ACCESS_KEY
``` ```
Switch providers on the fly using the provider name defined in `providers`: Switch providers on the fly using the provider name defined in `providers`:
+45 -40
View File
@@ -1,8 +1,8 @@
use crate::command::preview_command; 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::config::{Config, RunConfig};
use gman::providers::SecretProvider; use gman::providers::SecretProvider;
use heck::ToSnakeCase;
use log::{debug, error}; use log::{debug, error};
use regex::Regex; use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
@@ -14,7 +14,7 @@ use std::process::Command;
const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}"; const ARG_FORMAT_PLACEHOLDER_KEY: &str = "{{key}}";
const ARG_FORMAT_PLACEHOLDER_VALUE: &str = "{{value}}"; 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, secrets_provider: &mut dyn SecretProvider,
config: &Config, config: &Config,
tokens: Vec<OsString>, tokens: Vec<OsString>,
@@ -36,43 +36,40 @@ pub fn wrap_and_run_command(
.find(|c| c.name.as_deref() == Some(run_config_profile_name)) .find(|c| c.name.as_deref() == Some(run_config_profile_name))
}); });
if let Some(run_cfg) = run_config_opt { if let Some(run_cfg) = run_config_opt {
let secrets_result = run_cfg let secrets_result_futures = run_cfg
.secrets .secrets
.as_ref() .as_ref()
.ok_or_else(|| { .ok_or_else(|| {
anyhow!("No secrets configured for run profile '{run_config_profile_name}'") anyhow!("No secrets configured for run profile '{run_config_profile_name}'")
})? })?
.iter() .iter()
.map(|key| { .map(async |key| {
let secret_name = key.to_snake_case().to_uppercase();
debug!( debug!(
"Retrieving secret '{secret_name}' for run profile '{}'", "Retrieving secret '{key}' for run profile '{}'",
run_config_profile_name run_config_profile_name
); );
secrets_provider secrets_provider.get_secret(key).await.ok().map_or_else(
.get_secret(key.to_snake_case().to_uppercase().as_str())
.ok()
.map_or_else(
|| { || {
debug!("Failed to fetch secret '{secret_name}' from secret provider"); debug!("Failed to fetch secret '{key}' from secret provider");
( (
key.to_uppercase(), key,
Err(anyhow!( Err(anyhow!(
"Failed to fetch secret '{secret_name}' from secret provider" "Failed to fetch secret '{key}' from secret provider"
)), )),
) )
}, },
|value| { |value| {
if dry_run { if dry_run {
(key.to_uppercase(), Ok("*****".into())) (key, Ok("*****".into()))
} else { } else {
(key.to_uppercase(), Ok(value)) (key, Ok(value))
} }
}, },
) )
}); });
let secrets_result = join_all(secrets_result_futures).await;
let err = secrets_result let err = secrets_result
.clone() .iter()
.filter(|(_, r)| r.is_err()) .filter(|(_, r)| r.is_err())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
if !err.is_empty() { if !err.is_empty() {
@@ -86,14 +83,15 @@ pub fn wrap_and_run_command(
)); ));
} }
let secrets = secrets_result let secrets = secrets_result
.map(|(k, r)| (k, r.unwrap())) .into_iter()
.map(|(k, r)| (k.as_str(), r.unwrap()))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let mut cmd_def = Command::new(prog); let mut cmd_def = Command::new(prog);
if run_cfg.flag.is_some() { if run_cfg.flag.is_some() {
let args = parse_args(args, run_cfg, secrets.clone(), dry_run)?; let args = parse_args(args, run_cfg, secrets.clone(), dry_run)?;
run_cmd(cmd_def.args(&args), dry_run)?; run_cmd(cmd_def.args(&args), dry_run)?;
} else if run_cfg.files.is_some() { } 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")?; .with_context(|| "failed to inject secrets into files")?;
for (file, original_content, new_content) in &injected_files { for (file, original_content, new_content) in &injected_files {
if dry_run { if dry_run {
@@ -162,9 +160,9 @@ pub fn wrap_and_run_command(
} }
fn generate_files_secret_injections( fn generate_files_secret_injections(
secrets: HashMap<String, String>, secrets: HashMap<&str, String>,
run_config: &RunConfig, run_config: &RunConfig,
) -> Result<Vec<(&PathBuf, String, String)>> { ) -> Result<Vec<(PathBuf, String, String)>> {
let re = Regex::new(r"\{\{([A-Za-z0-9_]+)\}\}")?; let re = Regex::new(r"\{\{([A-Za-z0-9_]+)\}\}")?;
let mut results = Vec::new(); let mut results = Vec::new();
for file in run_config for file in run_config
@@ -184,12 +182,16 @@ fn generate_files_secret_injections(
})?; })?;
let new_content = re.replace_all(&original_content, |caps: &regex::Captures| { let new_content = re.replace_all(&original_content, |caps: &regex::Captures| {
secrets secrets
.get(&caps[1].to_snake_case().to_uppercase()) .get(&caps[1])
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or(&caps[0]) .unwrap_or(&caps[0])
.to_string() .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) Ok(results)
} }
@@ -207,7 +209,7 @@ pub fn run_cmd(cmd: &mut Command, dry_run: bool) -> Result<()> {
pub fn parse_args( pub fn parse_args(
args: &[OsString], args: &[OsString],
run_config: &RunConfig, run_config: &RunConfig,
secrets: HashMap<String, String>, secrets: HashMap<&str, String>,
dry_run: bool, dry_run: bool,
) -> Result<Vec<OsString>> { ) -> Result<Vec<OsString>> {
let mut args = args.to_vec(); let mut args = args.to_vec();
@@ -259,20 +261,21 @@ mod tests {
use std::ffi::OsString; use std::ffi::OsString;
struct DummyProvider; struct DummyProvider;
#[async_trait::async_trait]
impl SecretProvider for DummyProvider { impl SecretProvider for DummyProvider {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"Dummy" "Dummy"
} }
fn get_secret(&self, key: &str) -> Result<String> { async fn get_secret(&self, key: &str) -> Result<String> {
Ok(format!("{}_VAL", key)) Ok(format!("{}_VAL", key))
} }
fn set_secret(&self, _key: &str, _value: &str) -> Result<()> { async fn set_secret(&self, _key: &str, _value: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn delete_secret(&self, _key: &str) -> Result<()> { async fn delete_secret(&self, _key: &str) -> Result<()> {
Ok(()) Ok(())
} }
fn sync(&mut self) -> Result<()> { async fn sync(&mut self) -> Result<()> {
Ok(()) Ok(())
} }
} }
@@ -280,14 +283,14 @@ mod tests {
#[test] #[test]
fn test_generate_files_secret_injections() { fn test_generate_files_secret_injections() {
let mut secrets = HashMap::new(); 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 temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("test.txt"); 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 { let run_config = RunConfig {
name: Some("test".to_string()), name: Some("test".to_string()),
secrets: Some(vec!["secret1".to_string()]), secrets: Some(vec!["SECRET1".to_string()]),
files: Some(vec![file_path.clone()]), files: Some(vec![file_path.clone()]),
flag: None, flag: None,
flag_position: None, flag_position: None,
@@ -297,8 +300,8 @@ mod tests {
let result = generate_files_secret_injections(secrets, &run_config).unwrap(); let result = generate_files_secret_injections(secrets, &run_config).unwrap();
assert_eq!(result.len(), 1); assert_eq!(result.len(), 1);
assert_eq!(result[0].0, &file_path); assert_eq!(result[0].0, file_path);
assert_str_eq!(result[0].1, "{{secret1}}"); assert_str_eq!(result[0].1, "{{SECRET1}}");
assert_str_eq!(result[0].2, "value1"); assert_str_eq!(result[0].2, "value1");
} }
@@ -313,7 +316,7 @@ mod tests {
arg_format: Some("{{key}}={{value}}".into()), arg_format: Some("{{key}}={{value}}".into()),
}; };
let mut secrets = HashMap::new(); let mut secrets = HashMap::new();
secrets.insert("API_KEY".into(), "xyz".into()); secrets.insert("API_KEY", "xyz".into());
// Insert at position // Insert at position
let args = vec![OsString::from("run"), OsString::from("image")]; let args = vec![OsString::from("run"), OsString::from("image")];
@@ -341,18 +344,20 @@ mod tests {
); );
} }
#[test] #[tokio::test]
fn test_wrap_and_run_command_no_profile() { async fn test_wrap_and_run_command_no_profile() {
let cfg = Config::default(); let cfg = Config::default();
let mut dummy = DummyProvider; let mut dummy = DummyProvider;
let prov: &mut dyn SecretProvider = &mut dummy; let prov: &mut dyn SecretProvider = &mut dummy;
let tokens = vec![OsString::from("echo"), OsString::from("hi")]; 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")); assert!(err.to_string().contains("No run profile found"));
} }
#[test] #[tokio::test]
fn test_wrap_and_run_command_env_injection_dry_run() { async fn test_wrap_and_run_command_env_injection_dry_run() {
// Create a config with a matching run profile for command "echo" // Create a config with a matching run profile for command "echo"
let run_cfg = RunConfig { let run_cfg = RunConfig {
name: Some("echo".into()), name: Some("echo".into()),
@@ -372,7 +377,7 @@ mod tests {
// Capture stderr for dry_run preview // Capture stderr for dry_run preview
let tokens = vec![OsString::from("echo"), OsString::from("hello")]; let tokens = vec![OsString::from("echo"), OsString::from("hello")];
// Best-effort: ensure function does not error under dry_run // 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()); assert!(res.is_ok());
// Not asserting output text to keep test platform-agnostic // Not asserting output text to keep test platform-agnostic
} }
+17 -18
View File
@@ -1,14 +1,13 @@
use clap::{ 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 std::ffi::OsString;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use clap::Subcommand; use clap::Subcommand;
use crossterm::execute; 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 gman::config::{get_config_file_path, load_config};
use heck::ToSnakeCase;
use std::io::{self, IsTerminal, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::panic::PanicHookInfo; use std::panic::PanicHookInfo;
@@ -143,22 +142,22 @@ async fn main() -> Result<()> {
Commands::Add { name } => { Commands::Add { name } => {
let plaintext = let plaintext =
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider
.set_secret(&snake_case_name, plaintext.trim_end()) .set_secret(&name, plaintext.trim_end())
.await
.map(|_| match cli.output { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' added to the vault."), None => println!("✓ Secret '{name}' added to the vault."),
})?; })?;
} }
Commands::Get { name } => { Commands::Get { name } => {
let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider
.get_secret(&snake_case_name) .get_secret(&name)
.await
.map(|secret| match cli.output { .map(|secret| match cli.output {
Some(OutputFormat::Json) => { Some(OutputFormat::Json) => {
let json_output = serde_json::json!({ let json_output = serde_json::json!({
snake_case_name: secret name: secret
}); });
println!( println!(
"{}", "{}",
@@ -174,24 +173,23 @@ async fn main() -> Result<()> {
Commands::Update { name } => { Commands::Update { name } => {
let plaintext = let plaintext =
read_all_stdin().with_context(|| "unable to read plaintext from stdin")?; read_all_stdin().with_context(|| "unable to read plaintext from stdin")?;
let snake_case_name = name.to_snake_case().to_uppercase();
secrets_provider secrets_provider
.update_secret(&snake_case_name, plaintext.trim_end()) .update_secret(&name, plaintext.trim_end())
.await
.map(|_| match cli.output { .map(|_| match cli.output {
Some(_) => (), Some(_) => (),
None => println!("✓ Secret '{snake_case_name}' updated in the vault."), None => println!("✓ Secret '{name}' updated in the vault."),
})?; })?;
} }
Commands::Delete { name } => { Commands::Delete { name } => {
let snake_case_name = name.to_snake_case().to_uppercase(); secrets_provider.delete_secret(&name).await.map(|_| {
secrets_provider.delete_secret(&snake_case_name).map(|_| {
if cli.output.is_none() { if cli.output.is_none() {
println!("✓ Secret '{snake_case_name}' deleted from the vault.") println!("✓ Secret '{name}' deleted from the vault.")
} }
})?; })?;
} }
Commands::List {} => { Commands::List {} => {
let secrets = secrets_provider.list_secrets()?; let secrets = secrets_provider.list_secrets().await?;
if secrets.is_empty() { if secrets.is_empty() {
match cli.output { match cli.output {
Some(OutputFormat::Json) => { Some(OutputFormat::Json) => {
@@ -217,14 +215,15 @@ async fn main() -> Result<()> {
} }
} }
Commands::Sync {} => { Commands::Sync {} => {
secrets_provider.sync().map(|_| { secrets_provider.sync().await.map(|_| {
if cli.output.is_none() { if cli.output.is_none() {
println!("✓ Secrets synchronized with remote") println!("✓ Secrets synchronized with remote")
} }
})?; })?;
} }
Commands::External(tokens) => { 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 } => { Commands::Completions { shell } => {
let mut cmd = Cli::command(); let mut cmd = Cli::command();
+11 -7
View File
@@ -136,8 +136,9 @@ impl ProviderConfig {
/// ///
/// ```no_run /// ```no_run
/// # use gman::config::ProviderConfig; /// # use gman::config::ProviderConfig;
/// let provider_config = ProviderConfig::default().extract_provider(); /// let mut provider_config = ProviderConfig::default();
/// println!("using provider: {}", provider_config.name()); /// let provider = provider_config.extract_provider();
/// println!("using provider: {}", provider.name());
/// ``` /// ```
pub fn extract_provider(&mut self) -> &mut dyn SecretProvider { pub fn extract_provider(&mut self) -> &mut dyn SecretProvider {
match &mut self.provider_type { match &mut self.provider_type {
@@ -145,6 +146,10 @@ impl ProviderConfig {
debug!("Using local secret provider"); debug!("Using local secret provider");
provider_def provider_def
} }
SupportedProvider::AwsSecretsManager { provider_def } => {
debug!("Using AWS Secrets Manager provider");
provider_def
}
} }
} }
} }
@@ -278,16 +283,15 @@ pub fn load_config() -> Result<Config> {
.providers .providers
.iter_mut() .iter_mut()
.filter(|p| matches!(p.provider_type, SupportedProvider::Local { .. })) .filter(|p| matches!(p.provider_type, SupportedProvider::Local { .. }))
.for_each(|p| match p.provider_type { .for_each(|p| {
SupportedProvider::Local { if let SupportedProvider::Local {
ref mut provider_def, ref mut provider_def,
} => { } = p.provider_type
if provider_def.password_file.is_none() && provider_def.password_file.is_none()
&& let Some(local_password_file) = Config::local_provider_password_file() && let Some(local_password_file) = Config::local_provider_password_file()
{ {
provider_def.password_file = Some(local_password_file); provider_def.password_file = Some(local_password_file);
} }
}
}); });
Ok(config) Ok(config)
+124
View File
@@ -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<String>,
#[validate(required)]
pub aws_region: Option<String>,
}
#[async_trait::async_trait]
impl SecretProvider for AwsSecretsManagerProvider {
fn name(&self) -> &'static str {
"AwsSecretsManagerProvider"
}
async fn get_secret(&self, key: &str) -> Result<String> {
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<Vec<String>> {
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<Client> {
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))
}
}
+15 -16
View File
@@ -1,4 +1,4 @@
use anyhow::{Context, anyhow, bail}; use anyhow::{anyhow, bail, Context};
use secrecy::{ExposeSecret, SecretString}; use secrecy::{ExposeSecret, SecretString};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -6,20 +6,20 @@ use std::{env, fs};
use zeroize::Zeroize; use zeroize::Zeroize;
use crate::config::Config; use crate::config::Config;
use crate::providers::git_sync::{repo_name_from_url, sync_and_push, SyncOpts};
use crate::providers::SecretProvider; use crate::providers::SecretProvider;
use crate::providers::git_sync::{SyncOpts, repo_name_from_url, sync_and_push};
use crate::{ use crate::{
ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION, ARGON_M_COST_KIB, ARGON_P, ARGON_T_COST, HEADER, KDF, KEY_LEN, NONCE_LEN, SALT_LEN, VERSION,
}; };
use anyhow::Result; use anyhow::Result;
use argon2::{Algorithm, Argon2, Params, Version}; 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::aead::rand_core::RngCore;
use chacha20poly1305::{ use chacha20poly1305::{
Key, XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, OsRng}, aead::{Aead, KeyInit, OsRng},
Key, XChaCha20Poly1305, XNonce,
}; };
use dialoguer::{Input, theme}; use dialoguer::{theme, Input};
use log::{debug, error}; use log::{debug, error};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none; use serde_with::skip_serializing_none;
@@ -36,15 +36,13 @@ use validator::Validate;
/// Example /// Example
/// ```no_run /// ```no_run
/// use gman::providers::local::LocalProvider; /// use gman::providers::local::LocalProvider;
/// use gman::providers::SecretProvider; /// use gman::providers::{SecretProvider, SupportedProvider};
/// use gman::config::Config; /// use gman::config::{Config, ProviderConfig};
/// ///
/// let provider = LocalProvider::default(); /// let provider = LocalProvider::default();
/// let cfg = Config::default();
/// // Will prompt for a password when reading/writing secrets unless a /// // Will prompt for a password when reading/writing secrets unless a
/// // password file is configured. /// // password file is configured.
/// // provider.set_secret(&cfg, "MY_SECRET", "value")?; /// let _ = provider.set_secret("MY_SECRET", "value");
/// # Ok::<(), anyhow::Error>(())
/// ``` /// ```
#[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -71,12 +69,13 @@ impl Default for LocalProvider {
} }
} }
#[async_trait::async_trait]
impl SecretProvider for LocalProvider { impl SecretProvider for LocalProvider {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
"LocalProvider" "LocalProvider"
} }
fn get_secret(&self, key: &str) -> Result<String> { async fn get_secret(&self, key: &str) -> Result<String> {
let vault_path = self.active_vault_path()?; let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let envelope = vault let envelope = vault
@@ -90,7 +89,7 @@ impl SecretProvider for LocalProvider {
Ok(plaintext) 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 vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if vault.contains_key(key) { 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") 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 vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = 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") 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 vault_path = self.active_vault_path()?;
let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let mut vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
if !vault.contains_key(key) { 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") store_vault(&vault_path, &vault).with_context(|| "failed to save secret to the vault")
} }
fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self) -> Result<Vec<String>> {
let vault_path = self.active_vault_path()?; let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default(); let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let keys: Vec<String> = vault.keys().cloned().collect(); let keys: Vec<String> = vault.keys().cloned().collect();
@@ -152,7 +151,7 @@ impl SecretProvider for LocalProvider {
Ok(keys) Ok(keys)
} }
fn sync(&mut self) -> Result<()> { async fn sync(&mut self) -> Result<()> {
let mut config_changed = false; let mut config_changed = false;
if self.git_branch.is_none() { if self.git_branch.is_none() {
+22 -17
View File
@@ -2,43 +2,42 @@
//! //!
//! Implementations provide storage/backends for secrets and a common //! Implementations provide storage/backends for secrets and a common
//! interface used by the CLI. //! interface used by the CLI.
pub mod aws_secrets_manager;
mod git_sync; mod git_sync;
pub mod local; pub mod local;
use crate::providers::local::LocalProvider; use crate::providers::local::LocalProvider;
use anyhow::{Result, anyhow}; use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use thiserror::Error;
use validator::{Validate, ValidationErrors}; use validator::{Validate, ValidationErrors};
/// A secret storage backend capable of CRUD and sync, with optional /// A secret storage backend capable of CRUD, with optional
/// update and listing /// update, listing, and sync support.
pub trait SecretProvider { #[async_trait::async_trait]
pub trait SecretProvider: Send + Sync {
fn name(&self) -> &'static str; fn name(&self) -> &'static str;
fn get_secret(&self, key: &str) -> Result<String>; async fn get_secret(&self, key: &str) -> Result<String>;
fn set_secret(&self, key: &str, value: &str) -> Result<()>; async fn set_secret(&self, key: &str, value: &str) -> Result<()>;
fn update_secret(&self, _key: &str, _value: &str) -> Result<()> { async fn update_secret(&self, _key: &str, _value: &str) -> Result<()> {
Err(anyhow!( Err(anyhow!(
"update secret not supported for provider {}", "update secret not supported for provider {}",
self.name() self.name()
)) ))
} }
fn delete_secret(&self, key: &str) -> Result<()>; async fn delete_secret(&self, key: &str) -> Result<()>;
fn list_secrets(&self) -> Result<Vec<String>> { async fn list_secrets(&self) -> Result<Vec<String>> {
Err(anyhow!( Err(anyhow!(
"list secrets is not supported for the provider {}", "list secrets is not supported for the provider {}",
self.name() self.name()
)) ))
} }
fn sync(&mut self) -> Result<()>; async fn sync(&mut self) -> Result<()> {
Err(anyhow!(
"sync is not supported for the provider {}",
self.name()
))
} }
/// Errors when parsing a provider identifier.
#[derive(Debug, Error)]
pub enum ParseProviderError {
#[error("unsupported provider '{0}'")]
Unsupported(String),
} }
/// Registry of built-in providers. /// Registry of built-in providers.
@@ -50,12 +49,17 @@ pub enum SupportedProvider {
#[serde(flatten)] #[serde(flatten)]
provider_def: LocalProvider, provider_def: LocalProvider,
}, },
AwsSecretsManager {
#[serde(flatten)]
provider_def: aws_secrets_manager::AwsSecretsManagerProvider,
},
} }
impl Validate for SupportedProvider { impl Validate for SupportedProvider {
fn validate(&self) -> Result<(), ValidationErrors> { fn validate(&self) -> Result<(), ValidationErrors> {
match self { match self {
SupportedProvider::Local { provider_def } => provider_def.validate(), 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 { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self { match self {
SupportedProvider::Local { .. } => write!(f, "local"), SupportedProvider::Local { .. } => write!(f, "local"),
SupportedProvider::AwsSecretsManager { .. } => write!(f, "aws_secrets_manager"),
} }
} }
} }
+2 -2
View File
@@ -113,7 +113,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
.env("XDG_CACHE_HOME", &xdg_cache) .env("XDG_CACHE_HOME", &xdg_cache)
.args(["--output", "json", "get", "my_api_key"]); .args(["--output", "json", "get", "my_api_key"]);
get_json.assert().success().stdout( 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 // list
@@ -123,7 +123,7 @@ fn cli_add_get_list_update_delete_roundtrip() {
.arg("list"); .arg("list");
list.assert() list.assert()
.success() .success()
.stdout(predicate::str::contains("MY_API_KEY")); .stdout(predicate::str::contains("my_api_key"));
// update // update
let mut update = Command::cargo_bin("gman").unwrap(); let mut update = Command::cargo_bin("gman").unwrap();
-8
View File
@@ -1,14 +1,6 @@
use gman::config::ProviderConfig; use gman::config::ProviderConfig;
use gman::providers::ParseProviderError;
use pretty_assertions::assert_eq;
use validator::Validate; 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] #[test]
fn test_provider_config_missing_name() { fn test_provider_config_missing_name() {
let config = ProviderConfig { let config = ProviderConfig {