Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- OnePassword provider: Significant performance improvement by caching authentication status
and using batch fetching with parallel threads. Reduces CLI calls from 2N sequential to
~2 sequential + N parallel for N secrets.

## [0.6.2] - 2026-01-27

### Added
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ trybuild = "1.0"
insta = "1.34"
linkme = "0.3"
secrecy = { version = "0.10.3", features = ["serde"] }
once_cell = "1.21"
google-cloud-secretmanager-v1 = "1.2"
tokio = { version = "1", features = ["rt"] }
secretspec-derive = { version = "0.6.2", path = "./secretspec-derive" }
Expand Down
1 change: 1 addition & 0 deletions secretspec/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ url.workspace = true
whoami = { workspace = true, optional = true }
linkme.workspace = true
secrecy.workspace = true
once_cell.workspace = true
google-cloud-secretmanager-v1 = { workspace = true, optional = true }
tokio = { workspace = true, optional = true }

Expand Down
36 changes: 36 additions & 0 deletions secretspec/src/provider/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,42 @@ pub trait Provider: Send + Sync {
self.name()
)))
}

/// Retrieves multiple secrets from the provider in a single batch operation.
///
/// This method allows providers to optimize fetching multiple secrets at once,
/// which can significantly improve performance for providers with high latency
/// per request (like cloud-based secret managers).
///
/// # Arguments
///
/// * `project` - The project namespace for the secrets
/// * `keys` - A slice of secret keys to retrieve
/// * `profile` - The profile context (e.g., "default", "production")
///
/// # Returns
///
/// A HashMap where keys are the secret names and values are the secret values.
/// Secrets that don't exist are not included in the result.
///
/// # Default Implementation
///
/// The default implementation calls `get()` for each key sequentially.
/// Providers should override this for better performance when possible.
fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
let mut results = HashMap::new();
for key in keys {
if let Some(value) = self.get(project, key, profile)? {
results.insert((*key).to_string(), value);
}
}
Ok(results)
}
}

impl TryFrom<String> for Box<dyn Provider> {
Expand Down
181 changes: 165 additions & 16 deletions secretspec/src/provider/onepassword.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use crate::provider::Provider;
use crate::{Result, SecretSpecError};
use once_cell::sync::OnceCell;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::process::Command;
use url::Url;

Expand Down Expand Up @@ -240,6 +242,8 @@ pub struct OnePasswordProvider {
config: OnePasswordConfig,
/// The OnePassword CLI command to use (either "op" or a custom path).
op_command: String,
/// Cached authentication status to avoid repeated `op whoami` calls.
auth_verified: OnceCell<bool>,
}

crate::register_provider! {
Expand All @@ -265,7 +269,11 @@ impl OnePasswordProvider {
"op".to_string()
}
});
Self { config, op_command }
Self {
config,
op_command,
auth_verified: OnceCell::new(),
}
}

/// Executes a OnePassword CLI command with proper error handling.
Expand Down Expand Up @@ -369,7 +377,7 @@ impl OnePasswordProvider {
.map_err(|e| SecretSpecError::ProviderOperationFailed(e.to_string()))
}

/// Checks if the user is authenticated with OnePassword.
/// Checks if the user is authenticated with OnePassword (uncached).
///
/// Uses the `op whoami` command to verify authentication status.
/// This is non-intrusive and doesn't require any permissions.
Expand All @@ -391,6 +399,28 @@ impl OnePasswordProvider {
}
}

/// Ensures the user is authenticated, caching the result for subsequent calls.
///
/// This method only calls `op whoami` once per provider instance, significantly
/// improving performance when checking multiple secrets.
///
/// # Returns
///
/// * `Ok(())` - User is authenticated
/// * `Err(_)` - User is not authenticated or command failed
fn ensure_authenticated(&self) -> Result<()> {
let is_authenticated = self.auth_verified.get_or_try_init(|| self.whoami())?;

if *is_authenticated {
Ok(())
} else {
Err(SecretSpecError::ProviderOperationFailed(
"OnePassword authentication required. Please run 'eval $(op signin)' first."
.to_string(),
))
}
}

/// Determines the vault name to use.
///
/// # Arguments
Expand Down Expand Up @@ -607,13 +637,8 @@ impl Provider for OnePasswordProvider {
/// * `Ok(None)` - No secret found with the given key
/// * `Err(_)` - Authentication or retrieval error
fn get(&self, project: &str, key: &str, profile: &str) -> Result<Option<SecretString>> {
// Check authentication status first
if !self.whoami()? {
return Err(SecretSpecError::ProviderOperationFailed(
"OnePassword authentication required. Please run 'eval $(op signin)' first."
.to_string(),
));
}
// Check authentication status first (cached)
self.ensure_authenticated()?;

let vault = self.get_vault_name(profile);
let item_name = self.format_item_name(project, key, profile);
Expand Down Expand Up @@ -671,13 +696,8 @@ impl Provider for OnePasswordProvider {
/// - Item creation/update failures
/// - Temporary file creation errors
fn set(&self, project: &str, key: &str, value: &SecretString, profile: &str) -> Result<()> {
// Check authentication status first
if !self.whoami()? {
return Err(SecretSpecError::ProviderOperationFailed(
"OnePassword authentication required. Please run 'eval $(op signin)' first."
.to_string(),
));
}
// Check authentication status first (cached)
self.ensure_authenticated()?;

let vault = self.get_vault_name(profile);
let item_name = self.format_item_name(project, key, profile);
Expand Down Expand Up @@ -710,6 +730,135 @@ impl Provider for OnePasswordProvider {

Ok(())
}

/// Retrieves multiple secrets from OnePassword in a single batch operation.
///
/// This optimized implementation:
/// 1. Authenticates once (cached)
/// 2. Lists all items in the vault once to identify which secrets exist
/// 3. Fetches only the items that exist, using parallel threads
///
/// This significantly improves performance compared to fetching secrets one-by-one,
/// especially when checking many secrets.
fn get_batch(
&self,
project: &str,
keys: &[&str],
profile: &str,
) -> Result<HashMap<String, SecretString>> {
use std::thread;

if keys.is_empty() {
return Ok(HashMap::new());
}

// Check authentication status first (cached)
self.ensure_authenticated()?;

let vault = self.get_vault_name(profile);

// List all items in the vault once
let args = vec!["item", "list", "--vault", &vault, "--format", "json"];
let output = self.execute_op_command(&args, None)?;

#[derive(Deserialize)]
struct ListItem {
id: String,
title: String,
}

let items: Vec<ListItem> = serde_json::from_str(&output).unwrap_or_default();

// Build a map of item titles to IDs for quick lookup
let item_map: HashMap<String, String> = items
.into_iter()
.map(|item| (item.title, item.id))
.collect();

// Find which keys exist and need to be fetched
let keys_to_fetch: Vec<(&str, String)> = keys
.iter()
.filter_map(|key| {
let item_name = self.format_item_name(project, key, profile);
item_map.get(&item_name).map(|id| (*key, id.clone()))
})
.collect();

// Fetch items in parallel using threads
let vault_clone = vault.clone();
let op_command = self.op_command.clone();
let service_token = self.config.service_account_token.clone();
let account = self.config.account.clone();

let handles: Vec<_> = keys_to_fetch
.into_iter()
.map(|(key, item_id)| {
let vault = vault_clone.clone();
let op_cmd = op_command.clone();
let token = service_token.clone();
let acct = account.clone();
let key_owned = key.to_string();

thread::spawn(move || {
let mut cmd = Command::new(&op_cmd);

if let Some(ref t) = token {
cmd.env("OP_SERVICE_ACCOUNT_TOKEN", t);
}
if let Some(ref a) = acct {
cmd.arg("--account").arg(a);
}

cmd.args([
"item", "get", &item_id, "--vault", &vault, "--format", "json",
]);

match cmd.output() {
Ok(output) if output.status.success() => {
let stdout = String::from_utf8_lossy(&output.stdout);
// Parse the item and extract value
if let Ok(item) = serde_json::from_str::<OnePasswordItem>(&stdout) {
// Look for "value" field first
for field in &item.fields {
if field.label.as_deref() == Some("value") {
if let Some(ref v) = field.value {
return Some((
key_owned,
SecretString::new(v.clone().into()),
));
}
}
}
// Fallback: look for password/concealed field
for field in &item.fields {
if field.field_type == "CONCEALED" || field.id == "password" {
if let Some(ref v) = field.value {
return Some((
key_owned,
SecretString::new(v.clone().into()),
));
}
}
}
}
None
}
_ => None,
}
})
})
.collect();

// Collect results from all threads
let mut results = HashMap::new();
for handle in handles {
if let Ok(Some((key, value))) = handle.join() {
results.insert(key, value);
}
}

Ok(results)
}
}

impl Default for OnePasswordProvider {
Expand Down
Loading