use std::collections::HashMap;
use std::process::Command;
use std::sync::Mutex;
use secrecy::ExposeSecret;
use secrecy::SecretString;
use serde::Deserialize;
use serde::Serialize;
use crate::MonosecretError;
use crate::Result;
use crate::provider::Address;
use crate::provider::Provider;
use crate::provider::ProviderUrl;
use crate::provider::onepassword::strip_op_session_env;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OnePasswordEnvConfig {
pub account: Option<String>,
pub environment_id: String,
#[serde(skip)]
pub service_account_token: Option<String>,
}
impl TryFrom<&ProviderUrl> for OnePasswordEnvConfig {
type Error = MonosecretError;
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
let scheme = url.scheme();
match scheme {
"onepassword+env" | "onepassword+env+token" => {}
_ => {
return Err(MonosecretError::ProviderOperationFailed(format!(
"Invalid scheme '{scheme}' for OnePassword Environments provider. \
Use 'onepassword+env' or 'onepassword+env+token'"
)));
}
}
let is_token_scheme = scheme == "onepassword+env+token";
let mut config = Self::default();
if let Some(host) = url.host()
&& host != "localhost"
{
config.environment_id.clone_from(&host);
}
if config.environment_id.is_empty() {
let path = url.path();
let path = path.trim_start_matches('/');
if !path.is_empty() {
config.environment_id = path.to_string();
}
}
if config.environment_id.is_empty() {
return Err(MonosecretError::ProviderOperationFailed(
"OnePassword Environments provider requires an environment ID in the URI \
(e.g. onepassword+env://blgexucrwfr2dtsxe2q4uu7dp4)"
.into(),
));
}
let username = url.username();
if !username.is_empty() {
if is_token_scheme {
if let Some(password) = url.password() {
config.service_account_token = Some(password);
} else {
config.service_account_token = Some(username.clone());
}
} else {
config.account = Some(username.clone());
}
}
Ok(config)
}
}
crate::register_provider! {
struct: OnePasswordEnvProvider,
config: OnePasswordEnvConfig,
name: "onepassword-env",
description: "1Password Environments (beta) — key-value secrets via op environment read",
schemes: ["onepassword+env", "onepassword+env+token"],
examples: [
"onepassword+env://blgexucrwfr2dtsxe2q4uu7dp4",
"onepassword+env://work@blgexucrwfr2dtsxe2q4uu7dp4",
"onepassword+env+token://ops_abc123@xyz789",
],
}
pub struct OnePasswordEnvProvider {
config: OnePasswordEnvConfig,
op_command: String,
dependency_env: HashMap<String, SecretString>,
cache: Mutex<Option<HashMap<String, SecretString>>>,
}
impl OnePasswordEnvProvider {
pub fn new(config: OnePasswordEnvConfig) -> Self {
let op_command = std::env::var("MONOSECRET_OPCLI_PATH")
.or_else(|_| std::env::var("SECRETSPEC_OPCLI_PATH"))
.unwrap_or_else(|_| "op".to_string());
Self {
config,
op_command,
dependency_env: HashMap::new(),
cache: Mutex::new(None),
}
}
fn cached_variables(
&self,
) -> Result<std::sync::MutexGuard<'_, Option<HashMap<String, SecretString>>>> {
let mut guard = self
.cache
.lock()
.map_err(|e| MonosecretError::ProviderOperationFailed(e.to_string()))?;
if guard.is_none() {
let output = self.fetch_environment()?;
let mut vars = HashMap::new();
for line in output.lines() {
if let Some((k, v)) = line.split_once('=') {
vars.insert(k.to_string(), SecretString::new(v.to_string().into()));
}
}
*guard = Some(vars);
}
Ok(guard)
}
fn fetch_environment(&self) -> Result<String> {
let mut cmd = Command::new(&self.op_command);
strip_op_session_env(&mut cmd);
if let Some(ref token) = self.config.service_account_token {
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", token);
} else if let Some(token) = self.dependency_env.get("OP_SERVICE_ACCOUNT_TOKEN") {
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", token.expose_secret());
}
if let Some(ref account) = self.config.account {
cmd.arg("--account").arg(account);
}
cmd.arg("environment")
.arg("read")
.arg(&self.config.environment_id);
let output = cmd.output().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
MonosecretError::ProviderOperationFailed(
"The 'op' CLI is not installed. Install it from https://1password.com/downloads/command-line".into(),
)
} else {
MonosecretError::Io(e)
}
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let msg = stderr.trim().to_string();
if msg.contains("isn't an environment")
|| msg.contains("not found")
|| msg.contains("doesn't exist")
{
return Err(MonosecretError::ProviderOperationFailed(format!(
"1Password environment '{}' not found",
self.config.environment_id
)));
}
if msg.contains("not currently signed in") || msg.contains("no active session") {
return Err(MonosecretError::ProviderOperationFailed(
"Not signed in to 1Password. Sign in via the desktop app or set a service account token.".into(),
));
}
return Err(MonosecretError::ProviderOperationFailed(msg));
}
String::from_utf8(output.stdout)
.map_err(|e| MonosecretError::ProviderOperationFailed(e.to_string()))
}
}
impl Provider for OnePasswordEnvProvider {
fn configure_dependency_secrets(
&mut self,
dependencies: &[(String, SecretString)],
) -> Result<()> {
for (name, value) in dependencies {
if name == "OP_SERVICE_ACCOUNT_TOKEN" {
self.dependency_env.insert(name.clone(), value.clone());
}
}
Ok(())
}
fn convention_address(
&self,
_project: &str,
_profile: &str,
key: &str,
) -> Result<crate::config::NativeAddress> {
Ok(crate::config::NativeAddress {
item: key.to_string(),
field: None,
vault: None,
section: None,
version: None,
})
}
fn get(&self, addr: Address<'_>) -> Result<Option<SecretString>> {
let key = match addr {
Address::Convention { key, .. } => key,
Address::Native(reference) => reference.field.as_deref().unwrap_or(&reference.item),
};
let guard = self.cached_variables()?;
let vars = guard.as_ref().expect("cache was just populated");
Ok(vars.get(key).cloned())
}
fn set(&self, addr: Address<'_>, _value: &SecretString) -> Result<()> {
self.check_writable(addr)
}
fn check_writable(&self, _addr: Address<'_>) -> Result<()> {
Err(MonosecretError::ProviderOperationFailed(
"1Password Environments provider is read-only. Manage environment variables in the 1Password desktop app (Developer > View Environments).".into(),
))
}
fn name(&self) -> &'static str {
Self::PROVIDER_NAME
}
fn uri(&self) -> String {
let mut uri = if self.config.service_account_token.is_some() {
"onepassword+env+token://".to_string()
} else {
"onepassword+env://".to_string()
};
if let Some(ref account) = self.config.account {
uri.push_str(&ProviderUrl::encode(account));
uri.push('@');
}
uri.push_str(&self.config.environment_id);
uri
}
}
#[cfg(all(test, unix))]
mod dependency_env_tests {
use std::fs;
use std::os::unix::fs::PermissionsExt;
use secrecy::ExposeSecret;
use super::*;
fn write_op_env_stub(script: &std::path::Path, log: &std::path::Path) {
let script_body = format!(
r#"#!/bin/sh
printf '%s\n' "$OP_SERVICE_ACCOUNT_TOKEN" >> '{}'
printf 'API_KEY=%s\nOTHER=value\n' "$OP_SERVICE_ACCOUNT_TOKEN"
"#,
log.display()
);
fs::write(script, script_body).unwrap();
let mut permissions = fs::metadata(script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(script, permissions).unwrap();
}
#[test]
fn dependency_token_is_command_scoped_when_fetching_environment() {
let temp = tempfile::TempDir::new().unwrap();
let script = temp.path().join("op-env-stub");
let log = temp.path().join("calls.log");
write_op_env_stub(&script, &log);
let mut provider = OnePasswordEnvProvider::new(OnePasswordEnvConfig {
environment_id: "env-id".into(),
service_account_token: None,
..OnePasswordEnvConfig::default()
});
provider.op_command = script.display().to_string();
provider
.configure_dependency_secrets(&[
("IGNORED".into(), SecretString::new("ignored".into())),
(
"OP_SERVICE_ACCOUNT_TOKEN".into(),
SecretString::new("dependency-token".into()),
),
])
.unwrap();
let first = provider
.get(Address::convention("project", "default", "API_KEY"))
.unwrap()
.unwrap();
let second = provider
.get(Address::convention("project", "default", "API_KEY"))
.unwrap()
.unwrap();
assert_eq!(first.expose_secret(), "dependency-token");
assert_eq!(second.expose_secret(), "dependency-token");
assert_eq!(fs::read_to_string(log).unwrap(), "dependency-token\n");
}
#[test]
fn explicit_env_uri_token_takes_precedence_over_dependency_token() {
let temp = tempfile::TempDir::new().unwrap();
let script = temp.path().join("op-env-stub");
let log = temp.path().join("calls.log");
write_op_env_stub(&script, &log);
let mut provider = OnePasswordEnvProvider::new(OnePasswordEnvConfig {
environment_id: "env-id".into(),
service_account_token: Some("uri-token".into()),
..OnePasswordEnvConfig::default()
});
provider.op_command = script.display().to_string();
provider
.configure_dependency_secrets(&[(
"OP_SERVICE_ACCOUNT_TOKEN".into(),
SecretString::new("dependency-token".into()),
)])
.unwrap();
let value = provider
.get(Address::convention("project", "default", "API_KEY"))
.unwrap()
.unwrap();
assert_eq!(value.expose_secret(), "uri-token");
assert_eq!(fs::read_to_string(log).unwrap(), "uri-token\n");
}
}