Skip to main content

cuenv_1password/secrets/
resolver.rs

1//! 1Password secret resolver with auto-negotiating dual-mode (HTTP via WASM SDK + CLI)
2
3// Complex WASM+CLI dual-mode resolver with mutex-based shared core management
4#![allow(
5    clippy::cognitive_complexity,
6    clippy::too_many_lines,
7    clippy::significant_drop_tightening
8)]
9
10use super::core::SharedCore;
11use super::wasm;
12use async_trait::async_trait;
13use cuenv_secrets::{SecretError, SecretResolver, SecretSpec, SecureSecret};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use tokio::process::Command;
17
18/// Configuration for 1Password secret resolution
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "camelCase")]
21pub struct OnePasswordConfig {
22    /// Secret reference (e.g., `op://vault/item/field`)
23    #[serde(rename = "ref")]
24    pub reference: String,
25}
26
27impl OnePasswordConfig {
28    /// Create a new 1Password secret config
29    #[must_use]
30    pub fn new(reference: impl Into<String>) -> Self {
31        Self {
32            reference: reference.into(),
33        }
34    }
35}
36
37/// Resolves secrets from 1Password
38///
39/// Mode is auto-negotiated based on environment:
40/// - If `OP_SERVICE_ACCOUNT_TOKEN` is set AND WASM SDK is installed → HTTP mode
41/// - Otherwise → CLI mode (uses `op` CLI)
42///
43/// To enable HTTP mode, run: `cuenv secrets setup onepassword`
44///
45/// The `source` field in [`SecretSpec`] can be:
46/// - A JSON-encoded [`OnePasswordConfig`]
47/// - A simple reference string (e.g., `op://vault/item/field`)
48pub struct OnePasswordResolver {
49    /// Client ID for WASM SDK (when using HTTP mode)
50    client_id: Option<u64>,
51}
52
53impl std::fmt::Debug for OnePasswordResolver {
54    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55        f.debug_struct("OnePasswordResolver")
56            .field("mode", &if self.can_use_http() { "http" } else { "cli" })
57            .finish()
58    }
59}
60
61impl OnePasswordResolver {
62    /// Create a new 1Password resolver with auto-detected mode
63    ///
64    /// If 1Password service account token is available AND the WASM SDK is installed,
65    /// uses HTTP mode. Otherwise, CLI mode will be used.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if HTTP mode is detected (WASM + token) but WASM initialization fails.
70    /// This prevents silent fallback to CLI mode which masks configuration errors.
71    pub fn new() -> Result<Self, SecretError> {
72        let client_id = if Self::http_mode_available() {
73            // HTTP mode is available (WASM + token), WASM MUST initialize successfully
74            // Do NOT silently fall back to CLI - that masks the real error
75            let id = Self::init_wasm_client().map_err(|e| SecretError::ResolutionFailed {
76                name: "onepassword".to_string(),
77                message: format!(
78                    "1Password HTTP mode detected (WASM + token) but initialization failed: {e}\n\
79                    \n\
80                    This indicates a platform/runtime compatibility issue.\n\
81                    To use CLI mode instead, unset OP_SERVICE_ACCOUNT_TOKEN or remove the WASM file."
82                ),
83            })?;
84            tracing::debug!("1Password WASM client initialized successfully");
85            Some(id)
86        } else {
87            tracing::debug!("1Password HTTP mode not available, using CLI");
88            None
89        };
90
91        Ok(Self { client_id })
92    }
93
94    /// Check if HTTP mode is available (token set + WASM installed)
95    fn http_mode_available() -> bool {
96        let token_set = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok();
97        let wasm_available = wasm::onepassword_wasm_available();
98        tracing::trace!(
99            token_set,
100            wasm_available,
101            "1Password HTTP mode availability check"
102        );
103        token_set && wasm_available
104    }
105
106    /// Initialize the WASM client and return the client ID
107    fn init_wasm_client() -> Result<u64, SecretError> {
108        let token = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| {
109            SecretError::ResolutionFailed {
110                name: "onepassword".to_string(),
111                message: "OP_SERVICE_ACCOUNT_TOKEN not set".to_string(),
112            }
113        })?;
114
115        let core_mutex = SharedCore::get_or_init()?;
116        let mut guard = core_mutex
117            .lock()
118            .map_err(|_| SecretError::ResolutionFailed {
119                name: "onepassword".to_string(),
120                message: "Failed to acquire shared core lock".to_string(),
121            })?;
122
123        let core = guard
124            .as_mut()
125            .ok_or_else(|| SecretError::ResolutionFailed {
126                name: "onepassword".to_string(),
127                message: "SharedCore not initialized".to_string(),
128            })?;
129
130        core.init_client(&token)
131    }
132
133    /// Check if this resolver can use HTTP mode
134    const fn can_use_http(&self) -> bool {
135        self.client_id.is_some()
136    }
137
138    /// Resolve using the 1Password WASM SDK (HTTP mode)
139    fn resolve_http(&self, name: &str, config: &OnePasswordConfig) -> Result<String, SecretError> {
140        let client_id = self
141            .client_id
142            .ok_or_else(|| SecretError::ResolutionFailed {
143                name: name.to_string(),
144                message: "HTTP client not initialized".to_string(),
145            })?;
146
147        let core_mutex = SharedCore::get_or_init()?;
148        let mut guard = core_mutex
149            .lock()
150            .map_err(|_| SecretError::ResolutionFailed {
151                name: name.to_string(),
152                message: "Failed to acquire shared core lock".to_string(),
153            })?;
154
155        let core = guard
156            .as_mut()
157            .ok_or_else(|| SecretError::ResolutionFailed {
158                name: name.to_string(),
159                message: "SharedCore not initialized".to_string(),
160            })?;
161
162        // Invoke the SecretsResolve method (Go SDK uses this name, not "Secrets.Resolve")
163        let mut params = serde_json::Map::new();
164        params.insert(
165            "secret_reference".to_string(),
166            serde_json::Value::String(config.reference.clone()),
167        );
168
169        let result = core.invoke(client_id, "SecretsResolve", &params, &config.reference)?;
170
171        // Parse the response - the Go SDK returns a JSON-encoded string
172        // The invoke response is the raw string from WASM, which is a JSON-quoted secret value
173        let secret: String =
174            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
175                name: name.to_string(),
176                message: format!("Failed to parse resolve response: {e}"),
177            })?;
178
179        Ok(secret)
180    }
181
182    /// Resolve using the op CLI
183    async fn resolve_cli(
184        &self,
185        name: &str,
186        config: &OnePasswordConfig,
187    ) -> Result<String, SecretError> {
188        tracing::debug!(
189            name = name,
190            reference = config.reference,
191            "1Password resolve_cli"
192        );
193        let output = Command::new("op")
194            .args(["read", &config.reference])
195            .output()
196            .await
197            .map_err(|e| SecretError::ResolutionFailed {
198                name: name.to_string(),
199                message: format!("Failed to execute op CLI: {e}"),
200            })?;
201
202        if !output.status.success() {
203            let stderr = String::from_utf8_lossy(&output.stderr);
204            return Err(SecretError::ResolutionFailed {
205                name: name.to_string(),
206                message: format!("op CLI failed: {stderr}"),
207            });
208        }
209
210        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
211    }
212
213    /// Resolve a secret - tries HTTP first if available, falls back to CLI
214    async fn resolve_with_config(
215        &self,
216        name: &str,
217        config: &OnePasswordConfig,
218    ) -> Result<String, SecretError> {
219        // Try HTTP mode if available
220        if self.client_id.is_some() {
221            return self.resolve_http(name, config);
222        }
223
224        // Fallback to CLI
225        self.resolve_cli(name, config).await
226    }
227
228    /// Resolve multiple secrets using Secrets.ResolveAll (HTTP mode)
229    fn resolve_batch_http(
230        &self,
231        secrets: &HashMap<String, SecretSpec>,
232    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
233        let client_id = self
234            .client_id
235            .ok_or_else(|| SecretError::ResolutionFailed {
236                name: "batch".to_string(),
237                message: "HTTP client not initialized".to_string(),
238            })?;
239
240        let core_mutex = SharedCore::get_or_init()?;
241        let mut guard = core_mutex
242            .lock()
243            .map_err(|_| SecretError::ResolutionFailed {
244                name: "batch".to_string(),
245                message: "Failed to acquire shared core lock".to_string(),
246            })?;
247
248        let core = guard
249            .as_mut()
250            .ok_or_else(|| SecretError::ResolutionFailed {
251                name: "batch".to_string(),
252                message: "SharedCore not initialized".to_string(),
253            })?;
254
255        // Build list of references and track mapping back to names
256        let mut ref_to_names: HashMap<String, Vec<String>> = HashMap::new();
257        let mut references: Vec<String> = Vec::new();
258
259        for (name, spec) in secrets {
260            let config = serde_json::from_str::<OnePasswordConfig>(&spec.source)
261                .unwrap_or_else(|_| OnePasswordConfig::new(spec.source.clone()));
262
263            ref_to_names
264                .entry(config.reference.clone())
265                .or_default()
266                .push(name.clone());
267
268            if !references.contains(&config.reference) {
269                references.push(config.reference);
270            }
271        }
272
273        // Invoke SecretsResolveAll with array of references
274        let mut params = serde_json::Map::new();
275        params.insert(
276            "secret_references".to_string(),
277            serde_json::Value::Array(
278                references
279                    .iter()
280                    .map(|r| serde_json::Value::String(r.clone()))
281                    .collect(),
282            ),
283        );
284
285        // Use first reference as context for top-level errors
286        let context = references.first().map_or("batch", String::as_str);
287        let result = core.invoke(client_id, "SecretsResolveAll", &params, context)?;
288
289        // Parse the response
290        let response: serde_json::Value =
291            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
292                name: "batch".to_string(),
293                message: format!("Failed to parse ResolveAll response: {e}"),
294            })?;
295
296        // Extract individual responses
297        let individual_responses = response["individualResponses"].as_array().ok_or_else(|| {
298            SecretError::ResolutionFailed {
299                name: "batch".to_string(),
300                message: "No individualResponses in response".to_string(),
301            }
302        })?;
303
304        // Map responses back to original names
305        let mut resolved: HashMap<String, SecureSecret> = HashMap::new();
306
307        for (i, resp) in individual_responses.iter().enumerate() {
308            let reference = references
309                .get(i)
310                .ok_or_else(|| SecretError::ResolutionFailed {
311                    name: "batch".to_string(),
312                    message: "Response index out of bounds".to_string(),
313                })?;
314
315            // Check for errors - fail immediately with the specific secret reference
316            if let Some(error) = resp.get("error")
317                && !error.is_null()
318            {
319                let error_type = error["type"].as_str().unwrap_or("Unknown");
320                let error_msg = error["message"].as_str().unwrap_or("Unknown error");
321                return Err(SecretError::ResolutionFailed {
322                    name: reference.clone(),
323                    message: format!("1Password error ({error_type}): {error_msg}"),
324                });
325            }
326
327            // Extract secret value
328            let secret = resp["content"]["secret"]
329                .as_str()
330                .or_else(|| resp["result"].as_str())
331                .ok_or_else(|| SecretError::ResolutionFailed {
332                    name: reference.clone(),
333                    message: "No secret value in response".to_string(),
334                })?;
335
336            // Map to all names that use this reference
337            if let Some(names) = ref_to_names.get(reference) {
338                for name in names {
339                    resolved.insert(name.clone(), SecureSecret::new(secret.to_string()));
340                }
341            }
342        }
343
344        Ok(resolved)
345    }
346
347    /// Resolve multiple secrets using CLI (fallback, concurrent)
348    async fn resolve_batch_cli(
349        &self,
350        secrets: &HashMap<String, SecretSpec>,
351    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
352        use futures::future::try_join_all;
353
354        let futures: Vec<_> = secrets
355            .iter()
356            .map(|(name, spec)| {
357                let name = name.clone();
358                let spec = spec.clone();
359                async move {
360                    let value = self.resolve(&name, &spec).await?;
361                    Ok::<_, SecretError>((name, SecureSecret::new(value)))
362                }
363            })
364            .collect();
365
366        try_join_all(futures).await.map(|v| v.into_iter().collect())
367    }
368}
369
370impl Drop for OnePasswordResolver {
371    fn drop(&mut self) {
372        if let Some(client_id) = self.client_id
373            && let Ok(core_mutex) = SharedCore::get_or_init()
374            && let Ok(mut guard) = core_mutex.lock()
375            && let Some(core) = guard.as_mut()
376        {
377            core.release_client(client_id);
378        }
379    }
380}
381
382#[async_trait]
383impl SecretResolver for OnePasswordResolver {
384    fn provider_name(&self) -> &'static str {
385        "onepassword"
386    }
387
388    fn supports_native_batch(&self) -> bool {
389        // 1Password SDK supports Secrets.ResolveAll
390        true
391    }
392
393    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
394        // Try to parse source as JSON OnePasswordConfig
395        if let Ok(config) = serde_json::from_str::<OnePasswordConfig>(&spec.source) {
396            return self.resolve_with_config(name, &config).await;
397        }
398
399        // Fallback: treat source as a simple reference string
400        let config = OnePasswordConfig::new(spec.source.clone());
401        self.resolve_with_config(name, &config).await
402    }
403
404    async fn resolve_batch(
405        &self,
406        secrets: &HashMap<String, SecretSpec>,
407    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
408        if secrets.is_empty() {
409            return Ok(HashMap::new());
410        }
411
412        // Use Secrets.ResolveAll if HTTP mode is available
413        if self.client_id.is_some() {
414            return self.resolve_batch_http(secrets);
415        }
416
417        // Fallback to concurrent CLI calls
418        self.resolve_batch_cli(secrets).await
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_onepassword_config_serialization() {
428        let config = OnePasswordConfig {
429            reference: "op://vault/item/password".to_string(),
430        };
431
432        let json = serde_json::to_string(&config).unwrap();
433        assert!(json.contains("\"ref\""));
434
435        let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
436        assert_eq!(config, parsed);
437    }
438
439    #[test]
440    fn test_simple_config() {
441        let config = OnePasswordConfig::new("op://Personal/GitHub/token");
442        assert_eq!(config.reference, "op://Personal/GitHub/token");
443    }
444
445    #[test]
446    fn test_config_new_from_string() {
447        let config = OnePasswordConfig::new(String::from("op://vault/item/field"));
448        assert_eq!(config.reference, "op://vault/item/field");
449    }
450
451    #[test]
452    fn test_config_new_from_str_slice() {
453        let ref_str = "op://vault/item/field";
454        let config = OnePasswordConfig::new(ref_str);
455        assert_eq!(config.reference, ref_str);
456    }
457
458    #[test]
459    fn test_config_equality() {
460        let config1 = OnePasswordConfig::new("op://vault/item/field");
461        let config2 = OnePasswordConfig::new("op://vault/item/field");
462        let config3 = OnePasswordConfig::new("op://other/item/field");
463
464        assert_eq!(config1, config2);
465        assert_ne!(config1, config3);
466    }
467
468    #[test]
469    fn test_config_clone() {
470        let config = OnePasswordConfig::new("op://vault/item/field");
471        let cloned = config.clone();
472        assert_eq!(config, cloned);
473    }
474
475    #[test]
476    fn test_config_debug() {
477        let config = OnePasswordConfig::new("op://vault/item/field");
478        let debug = format!("{config:?}");
479        assert!(debug.contains("OnePasswordConfig"));
480        assert!(debug.contains("op://vault/item/field"));
481    }
482
483    #[test]
484    fn test_config_deserialization_with_ref_key() {
485        let json = r#"{"ref": "op://vault/item/field"}"#;
486        let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
487        assert_eq!(config.reference, "op://vault/item/field");
488    }
489
490    #[test]
491    fn test_config_deserialization_camel_case() {
492        // Since serde uses camelCase, the field is "ref"
493        let json = r#"{"ref": "op://example/test/password"}"#;
494        let config: OnePasswordConfig = serde_json::from_str(json).unwrap();
495        assert_eq!(config.reference, "op://example/test/password");
496    }
497
498    #[test]
499    fn test_config_deserialization_missing_ref() {
500        let json = r"{}";
501        let result = serde_json::from_str::<OnePasswordConfig>(json);
502        assert!(result.is_err());
503    }
504
505    #[test]
506    fn test_config_with_special_characters() {
507        let config = OnePasswordConfig::new("op://My Vault/My Item 2024/api-key_v1");
508        assert!(config.reference.contains("My Vault"));
509        assert!(config.reference.contains("api-key_v1"));
510    }
511
512    #[test]
513    fn test_http_mode_available_without_env() {
514        // Without OP_SERVICE_ACCOUNT_TOKEN, HTTP mode should not be available
515        // (unless already set in environment)
516        let result = OnePasswordResolver::http_mode_available();
517        // Just verify it returns a boolean without panicking
518        let _ = result;
519    }
520
521    #[test]
522    fn test_resolver_provider_name() {
523        // Create a resolver in CLI mode (without WASM)
524        // If WASM is not available and token is not set, this should work
525        if (!wasm::onepassword_wasm_available()
526            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
527            && let Ok(resolver) = OnePasswordResolver::new()
528        {
529            assert_eq!(resolver.provider_name(), "onepassword");
530        }
531    }
532
533    #[test]
534    fn test_resolver_supports_native_batch() {
535        if (!wasm::onepassword_wasm_available()
536            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
537            && let Ok(resolver) = OnePasswordResolver::new()
538        {
539            assert!(resolver.supports_native_batch());
540        }
541    }
542
543    #[test]
544    fn test_resolver_can_use_http_false_without_client() {
545        // A resolver without client_id should return false for can_use_http
546        if (!wasm::onepassword_wasm_available()
547            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
548            && let Ok(resolver) = OnePasswordResolver::new()
549        {
550            assert!(!resolver.can_use_http());
551        }
552    }
553
554    #[test]
555    fn test_resolver_debug_output() {
556        if (!wasm::onepassword_wasm_available()
557            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
558            && let Ok(resolver) = OnePasswordResolver::new()
559        {
560            let debug = format!("{resolver:?}");
561            assert!(debug.contains("OnePasswordResolver"));
562            // Should show mode as cli when no WASM client
563            assert!(debug.contains("cli") || debug.contains("http"));
564        }
565    }
566
567    #[tokio::test]
568    async fn test_resolve_batch_empty() {
569        if (!wasm::onepassword_wasm_available()
570            || std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_err())
571            && let Ok(resolver) = OnePasswordResolver::new()
572        {
573            let empty: HashMap<String, SecretSpec> = HashMap::new();
574            let result = resolver.resolve_batch(&empty).await;
575            assert!(result.is_ok());
576            assert!(result.unwrap().is_empty());
577        }
578    }
579
580    #[test]
581    fn test_config_roundtrip_serialization() {
582        let original = OnePasswordConfig::new("op://vault/item/field");
583        let json = serde_json::to_string(&original).unwrap();
584        let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
585        assert_eq!(original, parsed);
586    }
587
588    #[test]
589    fn test_config_empty_reference() {
590        // Empty reference should be allowed at config level
591        let config = OnePasswordConfig::new("");
592        assert_eq!(config.reference, "");
593    }
594
595    #[test]
596    fn test_config_unicode_reference() {
597        let config = OnePasswordConfig::new("op://vault/项目/密码");
598        assert_eq!(config.reference, "op://vault/项目/密码");
599    }
600}