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",
]
[[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",
+2 -1
View File
@@ -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"
+25 -14
View File
@@ -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`:
+59 -54
View File
@@ -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<OsString>,
@@ -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::<Vec<_>>();
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::<HashMap<_, _>>();
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<String, String>,
secrets: HashMap<&str, String>,
run_config: &RunConfig,
) -> Result<Vec<(&PathBuf, String, String)>> {
) -> Result<Vec<(PathBuf, String, String)>> {
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: &regex::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<String, String>,
secrets: HashMap<&str, String>,
dry_run: bool,
) -> Result<Vec<OsString>> {
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<String> {
async fn get_secret(&self, key: &str) -> Result<String> {
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
}
+17 -18
View File
@@ -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();
+14 -10
View File
@@ -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<Config> {
.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);
}
});
+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 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<String> {
async fn get_secret(&self, key: &str) -> Result<String> {
let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = 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<String, String> = 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<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")
}
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<String, String> = 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<Vec<String>> {
async fn list_secrets(&self) -> Result<Vec<String>> {
let vault_path = self.active_vault_path()?;
let vault: HashMap<String, String> = load_vault(&vault_path).unwrap_or_default();
let keys: Vec<String> = 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() {
+23 -18
View File
@@ -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<String>;
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<String>;
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<Vec<String>> {
async fn delete_secret(&self, key: &str) -> Result<()>;
async fn list_secrets(&self) -> Result<Vec<String>> {
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"),
}
}
}
+2 -2
View File
@@ -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();
-8
View File
@@ -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 {