feat: AWS Secrets Manager support
This commit is contained in:
Generated
+63
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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: ®ex::Captures| {
|
let new_content = re.replace_all(&original_content, |caps: ®ex::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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user