cuenv_1password/secrets/
resolver.rs

1//! 1Password secret resolver with auto-negotiating dual-mode (HTTP via WASM SDK + CLI)
2
3use super::core::SharedCore;
4use super::wasm;
5use async_trait::async_trait;
6use cuenv_secrets::{SecretError, SecretResolver, SecretSpec, SecureSecret};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use tokio::process::Command;
10
11/// Configuration for 1Password secret resolution
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "camelCase")]
14pub struct OnePasswordConfig {
15    /// Secret reference (e.g., `op://vault/item/field`)
16    #[serde(rename = "ref")]
17    pub reference: String,
18}
19
20impl OnePasswordConfig {
21    /// Create a new 1Password secret config
22    #[must_use]
23    pub fn new(reference: impl Into<String>) -> Self {
24        Self {
25            reference: reference.into(),
26        }
27    }
28}
29
30/// Resolves secrets from 1Password
31///
32/// Mode is auto-negotiated based on environment:
33/// - If `OP_SERVICE_ACCOUNT_TOKEN` is set AND WASM SDK is installed → HTTP mode
34/// - Otherwise → CLI mode (uses `op` CLI)
35///
36/// To enable HTTP mode, run: `cuenv secrets setup onepassword`
37///
38/// The `source` field in [`SecretSpec`] can be:
39/// - A JSON-encoded [`OnePasswordConfig`]
40/// - A simple reference string (e.g., `op://vault/item/field`)
41pub struct OnePasswordResolver {
42    /// Client ID for WASM SDK (when using HTTP mode)
43    client_id: Option<u64>,
44}
45
46impl std::fmt::Debug for OnePasswordResolver {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.debug_struct("OnePasswordResolver")
49            .field("mode", &if self.can_use_http() { "http" } else { "cli" })
50            .finish()
51    }
52}
53
54impl OnePasswordResolver {
55    /// Create a new 1Password resolver with auto-detected mode
56    ///
57    /// If 1Password service account token is available AND the WASM SDK is installed,
58    /// uses HTTP mode. Otherwise, CLI mode will be used.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the 1Password WASM client cannot be initialized.
63    pub fn new() -> Result<Self, SecretError> {
64        let client_id = if Self::http_mode_available() {
65            match Self::init_wasm_client() {
66                Ok(id) => {
67                    tracing::debug!("1Password WASM client initialized successfully");
68                    Some(id)
69                }
70                Err(e) => {
71                    tracing::warn!(
72                        "Failed to initialize 1Password WASM client, falling back to CLI: {e}"
73                    );
74                    None
75                }
76            }
77        } else {
78            tracing::debug!("1Password HTTP mode not available, using CLI");
79            None
80        };
81
82        Ok(Self { client_id })
83    }
84
85    /// Check if HTTP mode is available (token set + WASM installed)
86    fn http_mode_available() -> bool {
87        let token_set = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok();
88        let wasm_available = wasm::onepassword_wasm_available();
89        tracing::trace!(
90            token_set,
91            wasm_available,
92            "1Password HTTP mode availability check"
93        );
94        token_set && wasm_available
95    }
96
97    /// Check if HTTP credentials are available in environment
98    #[allow(dead_code)]
99    fn http_credentials_available() -> bool {
100        std::env::var("OP_SERVICE_ACCOUNT_TOKEN").is_ok()
101    }
102
103    /// Initialize the WASM client and return the client ID
104    fn init_wasm_client() -> Result<u64, SecretError> {
105        let token = std::env::var("OP_SERVICE_ACCOUNT_TOKEN").map_err(|_| {
106            SecretError::ResolutionFailed {
107                name: "onepassword".to_string(),
108                message: "OP_SERVICE_ACCOUNT_TOKEN not set".to_string(),
109            }
110        })?;
111
112        let core_mutex = SharedCore::get_or_init()?;
113        let mut guard = core_mutex
114            .lock()
115            .map_err(|_| SecretError::ResolutionFailed {
116                name: "onepassword".to_string(),
117                message: "Failed to acquire shared core lock".to_string(),
118            })?;
119
120        let core = guard
121            .as_mut()
122            .ok_or_else(|| SecretError::ResolutionFailed {
123                name: "onepassword".to_string(),
124                message: "SharedCore not initialized".to_string(),
125            })?;
126
127        core.init_client(&token)
128    }
129
130    /// Check if this resolver can use HTTP mode
131    fn can_use_http(&self) -> bool {
132        self.client_id.is_some()
133    }
134
135    /// Resolve using the 1Password WASM SDK (HTTP mode)
136    fn resolve_http(&self, name: &str, config: &OnePasswordConfig) -> Result<String, SecretError> {
137        let client_id = self
138            .client_id
139            .ok_or_else(|| SecretError::ResolutionFailed {
140                name: name.to_string(),
141                message: "HTTP client not initialized".to_string(),
142            })?;
143
144        let core_mutex = SharedCore::get_or_init()?;
145        let mut guard = core_mutex
146            .lock()
147            .map_err(|_| SecretError::ResolutionFailed {
148                name: name.to_string(),
149                message: "Failed to acquire shared core lock".to_string(),
150            })?;
151
152        let core = guard
153            .as_mut()
154            .ok_or_else(|| SecretError::ResolutionFailed {
155                name: name.to_string(),
156                message: "SharedCore not initialized".to_string(),
157            })?;
158
159        // Invoke the SecretsResolve method (Go SDK uses this name, not "Secrets.Resolve")
160        let mut params = serde_json::Map::new();
161        params.insert(
162            "secret_reference".to_string(),
163            serde_json::Value::String(config.reference.clone()),
164        );
165
166        let result = core.invoke(client_id, "SecretsResolve", &params)?;
167
168        // Parse the response - the Go SDK returns a JSON-encoded string
169        // The invoke response is the raw string from WASM, which is a JSON-quoted secret value
170        let secret: String =
171            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
172                name: name.to_string(),
173                message: format!("Failed to parse resolve response: {e}"),
174            })?;
175
176        Ok(secret)
177    }
178
179    /// Resolve using the op CLI
180    async fn resolve_cli(
181        &self,
182        name: &str,
183        config: &OnePasswordConfig,
184    ) -> Result<String, SecretError> {
185        let output = Command::new("op")
186            .args(["read", &config.reference])
187            .output()
188            .await
189            .map_err(|e| SecretError::ResolutionFailed {
190                name: name.to_string(),
191                message: format!("Failed to execute op CLI: {e}"),
192            })?;
193
194        if !output.status.success() {
195            let stderr = String::from_utf8_lossy(&output.stderr);
196            return Err(SecretError::ResolutionFailed {
197                name: name.to_string(),
198                message: format!("op CLI failed: {stderr}"),
199            });
200        }
201
202        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
203    }
204
205    /// Resolve a secret - tries HTTP first if available, falls back to CLI
206    async fn resolve_with_config(
207        &self,
208        name: &str,
209        config: &OnePasswordConfig,
210    ) -> Result<String, SecretError> {
211        // Try HTTP mode if available
212        if self.client_id.is_some() {
213            return self.resolve_http(name, config);
214        }
215
216        // Fallback to CLI
217        self.resolve_cli(name, config).await
218    }
219
220    /// Resolve multiple secrets using Secrets.ResolveAll (HTTP mode)
221    fn resolve_batch_http(
222        &self,
223        secrets: &HashMap<String, SecretSpec>,
224    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
225        let client_id = self
226            .client_id
227            .ok_or_else(|| SecretError::ResolutionFailed {
228                name: "batch".to_string(),
229                message: "HTTP client not initialized".to_string(),
230            })?;
231
232        let core_mutex = SharedCore::get_or_init()?;
233        let mut guard = core_mutex
234            .lock()
235            .map_err(|_| SecretError::ResolutionFailed {
236                name: "batch".to_string(),
237                message: "Failed to acquire shared core lock".to_string(),
238            })?;
239
240        let core = guard
241            .as_mut()
242            .ok_or_else(|| SecretError::ResolutionFailed {
243                name: "batch".to_string(),
244                message: "SharedCore not initialized".to_string(),
245            })?;
246
247        // Build list of references and track mapping back to names
248        let mut ref_to_names: HashMap<String, Vec<String>> = HashMap::new();
249        let mut references: Vec<String> = Vec::new();
250
251        for (name, spec) in secrets {
252            let config = serde_json::from_str::<OnePasswordConfig>(&spec.source)
253                .unwrap_or_else(|_| OnePasswordConfig::new(spec.source.clone()));
254
255            ref_to_names
256                .entry(config.reference.clone())
257                .or_default()
258                .push(name.clone());
259
260            if !references.contains(&config.reference) {
261                references.push(config.reference);
262            }
263        }
264
265        // Invoke SecretsResolveAll with array of references
266        let mut params = serde_json::Map::new();
267        params.insert(
268            "secret_references".to_string(),
269            serde_json::Value::Array(
270                references
271                    .iter()
272                    .map(|r| serde_json::Value::String(r.clone()))
273                    .collect(),
274            ),
275        );
276
277        let result = core.invoke(client_id, "SecretsResolveAll", &params)?;
278
279        // Parse the response
280        let response: serde_json::Value =
281            serde_json::from_str(&result).map_err(|e| SecretError::ResolutionFailed {
282                name: "batch".to_string(),
283                message: format!("Failed to parse ResolveAll response: {e}"),
284            })?;
285
286        // Extract individual responses
287        let individual_responses = response["individualResponses"].as_array().ok_or_else(|| {
288            SecretError::ResolutionFailed {
289                name: "batch".to_string(),
290                message: "No individualResponses in response".to_string(),
291            }
292        })?;
293
294        // Map responses back to original names
295        let mut resolved: HashMap<String, SecureSecret> = HashMap::new();
296
297        for (i, resp) in individual_responses.iter().enumerate() {
298            let reference = references
299                .get(i)
300                .ok_or_else(|| SecretError::ResolutionFailed {
301                    name: "batch".to_string(),
302                    message: "Response index out of bounds".to_string(),
303                })?;
304
305            // Check for errors
306            if let Some(error) = resp.get("error")
307                && !error.is_null()
308            {
309                let error_type = error["type"].as_str().unwrap_or("Unknown");
310                let error_msg = error["message"].as_str().unwrap_or("Unknown error");
311                tracing::warn!(
312                    reference = %reference,
313                    error_type = %error_type,
314                    message = %error_msg,
315                    "Failed to resolve secret in batch"
316                );
317                continue;
318            }
319
320            // Extract secret value
321            let secret = resp["content"]["secret"]
322                .as_str()
323                .or_else(|| resp["result"].as_str())
324                .ok_or_else(|| SecretError::ResolutionFailed {
325                    name: reference.clone(),
326                    message: "No secret value in response".to_string(),
327                })?;
328
329            // Map to all names that use this reference
330            if let Some(names) = ref_to_names.get(reference) {
331                for name in names {
332                    resolved.insert(name.clone(), SecureSecret::new(secret.to_string()));
333                }
334            }
335        }
336
337        Ok(resolved)
338    }
339
340    /// Resolve multiple secrets using CLI (fallback, concurrent)
341    async fn resolve_batch_cli(
342        &self,
343        secrets: &HashMap<String, SecretSpec>,
344    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
345        use futures::future::try_join_all;
346
347        let futures: Vec<_> = secrets
348            .iter()
349            .map(|(name, spec)| {
350                let name = name.clone();
351                let spec = spec.clone();
352                async move {
353                    let value = self.resolve(&name, &spec).await?;
354                    Ok::<_, SecretError>((name, SecureSecret::new(value)))
355                }
356            })
357            .collect();
358
359        try_join_all(futures).await.map(|v| v.into_iter().collect())
360    }
361}
362
363impl Drop for OnePasswordResolver {
364    fn drop(&mut self) {
365        if let Some(client_id) = self.client_id
366            && let Ok(core_mutex) = SharedCore::get_or_init()
367            && let Ok(mut guard) = core_mutex.lock()
368            && let Some(core) = guard.as_mut()
369        {
370            core.release_client(client_id);
371        }
372    }
373}
374
375#[async_trait]
376impl SecretResolver for OnePasswordResolver {
377    fn provider_name(&self) -> &'static str {
378        "onepassword"
379    }
380
381    fn supports_native_batch(&self) -> bool {
382        // 1Password SDK supports Secrets.ResolveAll
383        true
384    }
385
386    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
387        // Try to parse source as JSON OnePasswordConfig
388        if let Ok(config) = serde_json::from_str::<OnePasswordConfig>(&spec.source) {
389            return self.resolve_with_config(name, &config).await;
390        }
391
392        // Fallback: treat source as a simple reference string
393        let config = OnePasswordConfig::new(spec.source.clone());
394        self.resolve_with_config(name, &config).await
395    }
396
397    async fn resolve_batch(
398        &self,
399        secrets: &HashMap<String, SecretSpec>,
400    ) -> Result<HashMap<String, SecureSecret>, SecretError> {
401        if secrets.is_empty() {
402            return Ok(HashMap::new());
403        }
404
405        // Use Secrets.ResolveAll if HTTP mode is available
406        if self.client_id.is_some() {
407            return self.resolve_batch_http(secrets);
408        }
409
410        // Fallback to concurrent CLI calls
411        self.resolve_batch_cli(secrets).await
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn test_onepassword_config_serialization() {
421        let config = OnePasswordConfig {
422            reference: "op://vault/item/password".to_string(),
423        };
424
425        let json = serde_json::to_string(&config).unwrap();
426        assert!(json.contains("\"ref\""));
427
428        let parsed: OnePasswordConfig = serde_json::from_str(&json).unwrap();
429        assert_eq!(config, parsed);
430    }
431
432    #[test]
433    fn test_simple_config() {
434        let config = OnePasswordConfig::new("op://Personal/GitHub/token");
435        assert_eq!(config.reference, "op://Personal/GitHub/token");
436    }
437
438    #[test]
439    fn test_http_credentials_check() {
440        // This test just ensures the function exists and doesn't panic
441        let _ = OnePasswordResolver::http_credentials_available();
442    }
443}