diff --git a/README.md b/README.md index 9f64662..c950464 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ gman aws sts get-caller-identity - [GCP Secret Manager](#provider-gcp_secret_manager) - [Azure Key Vault](#provider-azure_key_vault) - [Gopass](#provider-gopass) + - [1Password](#provider-one_password) - [Run Configurations](#run-configurations) - [Specifying a Default Provider per Run Config](#specifying-a-default-provider-per-run-config) - [Environment Variable Secret Injection](#environment-variable-secret-injection) @@ -287,7 +288,7 @@ documented and added without breaking existing setups. The following table shows | [`azure_key_vault`](https://azure.microsoft.com/en-us/products/key-vault/) | ✅ | [Azure Key Vault](#provider-azure_key_vault) | | | [`gcp_secret_manager`](https://cloud.google.com/security/products/secret-manager?hl=en) | ✅ | [GCP Secret Manager](#provider-gcp_secret_manager) | | | [`gopass`](https://www.gopass.pw/) | ✅ | | | -| [`1password`](https://1password.com/) | 🕒 | | | +| [`1password`](https://1password.com/) | ✅ | [1Password](#provider-one_password) | | | [`bitwarden`](https://bitwarden.com/) | 🕒 | | | | [`dashlane`](https://www.dashlane.com/) | 🕒 | | Waiting for CLI support for adding secrets | | [`lastpass`](https://www.lastpass.com/) | 🕒 | | | @@ -450,6 +451,42 @@ Important notes: - Secrets are managed using gopass's native commands; `gman` acts as a wrapper to interface with gopass. - Updates overwrite existing secrets - If no store is specified, the default gopass store is used and `gman sync` will sync with all configured stores. + +### Provider: `one_password` +The `one_password` provider uses the [1Password CLI (`op`)](https://developer.1password.com/docs/cli/) as the backing +storage location for secrets. + +- Optional: `vault` (string) to specify which 1Password vault to use. If omitted, the default vault is used. +- Optional: `account` (string) to specify which 1Password account to use. Useful if you have multiple accounts. If + omitted, the default signed-in account is used. + +Configuration example: + +```yaml +default_provider: op +providers: + - name: op + type: one_password + vault: Production # Optional; if omitted, uses the default vault + account: my.1password.com # Optional; if omitted, uses the default account +``` + +Authentication: +- **Interactive**: Run `op signin` to sign in interactively. +- **Service Account**: Set the `OP_SERVICE_ACCOUNT_TOKEN` environment variable for non-interactive/CI usage. +- **Desktop App Integration**: If the 1Password desktop app is installed and configured, the CLI can use biometric + authentication (Touch ID, Windows Hello, etc.). + +Important notes: +- Ensure the 1Password CLI (`op`) is installed on your system. Install instructions are at + https://developer.1password.com/docs/cli/get-started/. +- Secrets are stored as 1Password Password items. The item title is the secret name and the `password` field holds the + secret value. +- **Deletions are permanent. Deleted items are not archived.** +- `add` creates a new Password item. If an item with the same title already exists in the vault, `op` will create a + duplicate. Use `update` to change an existing secret value. +- `list` returns the titles of all items in the configured vault. + ## Run Configurations Run configurations (or "profiles") tell `gman` how to inject secrets into a command. Three modes of secret injection are diff --git a/src/config.rs b/src/config.rs index 86920b6..e63e544 100644 --- a/src/config.rs +++ b/src/config.rs @@ -169,6 +169,10 @@ impl ProviderConfig { debug!("Using Gopass provider"); provider_def } + SupportedProvider::OnePassword { provider_def } => { + debug!("Using 1Password provider"); + provider_def + } } } } diff --git a/src/providers/mod.rs b/src/providers/mod.rs index f249305..d4a9c79 100644 --- a/src/providers/mod.rs +++ b/src/providers/mod.rs @@ -8,9 +8,11 @@ pub mod gcp_secret_manager; mod git_sync; pub mod gopass; pub mod local; +pub mod one_password; use crate::providers::gopass::GopassProvider; use crate::providers::local::LocalProvider; +use crate::providers::one_password::OnePasswordProvider; use anyhow::{Context, Result, anyhow}; use aws_secrets_manager::AwsSecretsManagerProvider; use azure_key_vault::AzureKeyVaultProvider; @@ -76,6 +78,10 @@ pub enum SupportedProvider { #[serde(flatten)] provider_def: GopassProvider, }, + OnePassword { + #[serde(flatten)] + provider_def: OnePasswordProvider, + }, } impl Validate for SupportedProvider { @@ -86,6 +92,7 @@ impl Validate for SupportedProvider { SupportedProvider::GcpSecretManager { provider_def } => provider_def.validate(), SupportedProvider::AzureKeyVault { provider_def } => provider_def.validate(), SupportedProvider::Gopass { provider_def } => provider_def.validate(), + SupportedProvider::OnePassword { provider_def } => provider_def.validate(), } } } @@ -106,6 +113,7 @@ impl Display for SupportedProvider { SupportedProvider::GcpSecretManager { .. } => write!(f, "gcp_secret_manager"), SupportedProvider::AzureKeyVault { .. } => write!(f, "azure_key_vault"), SupportedProvider::Gopass { .. } => write!(f, "gopass"), + SupportedProvider::OnePassword { .. } => write!(f, "one_password"), } } } diff --git a/src/providers/one_password.rs b/src/providers/one_password.rs new file mode 100644 index 0000000..e05bdd9 --- /dev/null +++ b/src/providers/one_password.rs @@ -0,0 +1,199 @@ +use crate::providers::{ENV_PATH, SecretProvider}; +use anyhow::{Context, Result, anyhow}; +use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; +use std::io::Read; +use std::process::{Command, Stdio}; +use validator::Validate; + +#[skip_serializing_none] +/// 1Password-based secret provider. +/// See [1Password CLI](https://developer.1password.com/docs/cli/) for more +/// information. +/// +/// You must already have the 1Password CLI (`op`) installed and configured +/// on your system. +/// +/// This provider stores secrets as 1Password Password items. It requires +/// an optional vault name and an optional account identifier to be specified. +/// If no vault is specified, the user's default vault is used. If no account +/// is specified, the default signed-in account is used. +/// +/// Example +/// ```no_run +/// use gman::providers::one_password::OnePasswordProvider; +/// use gman::providers::{SecretProvider, SupportedProvider}; +/// use gman::config::Config; +/// +/// let provider = OnePasswordProvider::default(); +/// let _ = provider.set_secret("MY_SECRET", "value"); +/// ``` +#[derive(Debug, Default, Clone, Validate, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct OnePasswordProvider { + pub vault: Option, + pub account: Option, +} + +impl OnePasswordProvider { + fn base_command(&self) -> Command { + let mut cmd = Command::new("op"); + cmd.env("PATH", ENV_PATH.as_ref().expect("No ENV_PATH set")); + if let Some(account) = &self.account { + cmd.args(["--account", account]); + } + cmd + } + + fn vault_args(&self) -> Vec<&str> { + match &self.vault { + Some(vault) => vec!["--vault", vault], + None => vec![], + } + } +} + +#[async_trait::async_trait] +impl SecretProvider for OnePasswordProvider { + fn name(&self) -> &'static str { + "OnePasswordProvider" + } + + async fn get_secret(&self, key: &str) -> Result { + ensure_op_installed()?; + + let mut cmd = self.base_command(); + cmd.args(["item", "get", key, "--fields", "password", "--reveal"]); + cmd.args(self.vault_args()); + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().context("Failed to spawn op command")?; + + let mut output = String::new(); + child + .stdout + .as_mut() + .expect("Failed to open op stdout") + .read_to_string(&mut output) + .context("Failed to read op output")?; + + let status = child.wait().context("Failed to wait on op process")?; + if !status.success() { + return Err(anyhow!("op command failed with status: {}", status)); + } + + Ok(output.trim_end_matches(&['\r', '\n'][..]).to_string()) + } + + async fn set_secret(&self, key: &str, value: &str) -> Result<()> { + ensure_op_installed()?; + + let mut cmd = self.base_command(); + cmd.args(["item", "create", "--category", "password", "--title", key]); + cmd.args(self.vault_args()); + cmd.arg(format!("password={}", value)); + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().context("Failed to spawn op command")?; + + let status = child.wait().context("Failed to wait on op process")?; + if !status.success() { + return Err(anyhow!("op command failed with status: {}", status)); + } + + Ok(()) + } + + async fn update_secret(&self, key: &str, value: &str) -> Result<()> { + ensure_op_installed()?; + + let mut cmd = self.base_command(); + cmd.args(["item", "edit", key]); + cmd.args(self.vault_args()); + cmd.arg(format!("password={}", value)); + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().context("Failed to spawn op command")?; + + let status = child.wait().context("Failed to wait on op process")?; + if !status.success() { + return Err(anyhow!("op command failed with status: {}", status)); + } + + Ok(()) + } + + async fn delete_secret(&self, key: &str) -> Result<()> { + ensure_op_installed()?; + + let mut cmd = self.base_command(); + cmd.args(["item", "delete", key]); + cmd.args(self.vault_args()); + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().context("Failed to spawn op command")?; + + let status = child.wait().context("Failed to wait on op process")?; + if !status.success() { + return Err(anyhow!("op command failed with status: {}", status)); + } + + Ok(()) + } + + async fn list_secrets(&self) -> Result> { + ensure_op_installed()?; + + let mut cmd = self.base_command(); + cmd.args(["item", "list", "--format", "json"]); + cmd.args(self.vault_args()); + cmd.stdin(Stdio::inherit()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = cmd.spawn().context("Failed to spawn op command")?; + + let mut output = String::new(); + child + .stdout + .as_mut() + .expect("Failed to open op stdout") + .read_to_string(&mut output) + .context("Failed to read op output")?; + + let status = child.wait().context("Failed to wait on op process")?; + if !status.success() { + return Err(anyhow!("op command failed with status: {}", status)); + } + + let items: Vec = + serde_json::from_str(&output).context("Failed to parse op item list JSON output")?; + + let secrets: Vec = items + .iter() + .filter_map(|item| item.get("title").and_then(|t| t.as_str())) + .map(|s| s.to_string()) + .collect(); + + Ok(secrets) + } +} + +fn ensure_op_installed() -> Result<()> { + if which::which("op").is_err() { + Err(anyhow!( + "1Password CLI (op) is not installed or not found in PATH. \ + Please install it from https://developer.1password.com/docs/cli/get-started/" + )) + } else { + Ok(()) + } +} diff --git a/tests/providers/mod.rs b/tests/providers/mod.rs index c13f79e..53fd28a 100644 --- a/tests/providers/mod.rs +++ b/tests/providers/mod.rs @@ -3,4 +3,5 @@ mod azure_key_vault_tests; mod gcp_secret_manager_tests; mod gopass_tests; mod local_tests; +mod one_password_tests; mod provider_tests; diff --git a/tests/providers/one_password_tests.rs b/tests/providers/one_password_tests.rs new file mode 100644 index 0000000..d0971ff --- /dev/null +++ b/tests/providers/one_password_tests.rs @@ -0,0 +1,113 @@ +use gman::config::{Config, ProviderConfig}; +use gman::providers::{SecretProvider, SupportedProvider}; +use pretty_assertions::{assert_eq, assert_str_eq}; +use validator::Validate; + +#[test] +fn test_one_password_supported_provider_display_and_validate_from_yaml() { + let yaml = r#"--- +type: one_password +vault: Production +account: my.1password.com +"#; + + let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml"); + assert!(sp.validate().is_ok()); + assert_eq!(sp.to_string(), "one_password"); +} + +#[test] +fn test_one_password_supported_provider_minimal_yaml() { + let yaml = r#"--- +type: one_password +"#; + + let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml"); + assert!(sp.validate().is_ok()); + assert_eq!(sp.to_string(), "one_password"); +} + +#[test] +fn test_one_password_supported_provider_vault_only() { + let yaml = r#"--- +type: one_password +vault: Personal +"#; + + let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml"); + assert!(sp.validate().is_ok()); +} + +#[test] +fn test_one_password_supported_provider_account_only() { + let yaml = r#"--- +type: one_password +account: team.1password.com +"#; + + let sp: SupportedProvider = serde_yaml::from_str(yaml).expect("valid supported provider yaml"); + assert!(sp.validate().is_ok()); +} + +#[test] +fn test_one_password_supported_provider_rejects_unknown_fields() { + let yaml = r#"--- +type: one_password +vault: Production +unknown_field: bad +"#; + + let result: Result = serde_yaml::from_str(yaml); + assert!(result.is_err()); +} + +#[test] +fn test_provider_config_with_one_password_deserialize_and_extract() { + let yaml = r#"--- +name: op +type: one_password +"#; + + let pc: ProviderConfig = serde_yaml::from_str(yaml).expect("valid provider config yaml"); + assert!(pc.validate().is_ok()); + + let mut pc_owned = pc.clone(); + let provider: &mut dyn SecretProvider = pc_owned.extract_provider(); + assert_str_eq!(provider.name(), "OnePasswordProvider"); + + let cfg_yaml = r#"--- +default_provider: op +providers: + - name: op + type: one_password + vault: Production + account: my.1password.com +"#; + let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml"); + assert!(cfg.validate().is_ok()); + + let extracted = cfg + .extract_provider_config(None) + .expect("should find default provider"); + assert_eq!(extracted.name.as_deref(), Some("op")); +} + +#[test] +fn test_one_password_config_with_multiple_providers() { + let cfg_yaml = r#"--- +default_provider: local +providers: + - name: local + type: local + - name: op + type: one_password + vault: Production +"#; + let cfg: Config = serde_yaml::from_str(cfg_yaml).expect("valid config yaml"); + assert!(cfg.validate().is_ok()); + + let extracted = cfg + .extract_provider_config(Some("op".into())) + .expect("should find op provider"); + assert_eq!(extracted.name.as_deref(), Some("op")); +}