Skip to main content

cuenv_gcp/
secrets.rs

1//! GCP Secret Manager secret resolver with auto-negotiating dual-mode (HTTP + CLI)
2
3use async_trait::async_trait;
4use cuenv_secrets::{SecretError, SecretResolver, SecretSpec};
5use serde::{Deserialize, Serialize};
6use tokio::process::Command;
7
8/// Configuration for GCP Secret Manager resolution
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10#[serde(rename_all = "camelCase")]
11pub struct GcpSecretConfig {
12    /// GCP project ID
13    pub project: String,
14
15    /// Secret name
16    pub secret: String,
17
18    /// Version (defaults to "latest")
19    #[serde(default = "default_version")]
20    pub version: String,
21}
22
23fn default_version() -> String {
24    "latest".to_string()
25}
26
27impl GcpSecretConfig {
28    /// Create a new GCP secret config
29    #[must_use]
30    pub fn new(project: impl Into<String>, secret: impl Into<String>) -> Self {
31        Self {
32            project: project.into(),
33            secret: secret.into(),
34            version: "latest".to_string(),
35        }
36    }
37
38    /// Get the full resource name for the secret version
39    #[must_use]
40    pub fn resource_name(&self) -> String {
41        format!(
42            "projects/{}/secrets/{}/versions/{}",
43            self.project, self.secret, self.version
44        )
45    }
46}
47
48/// Resolves secrets from GCP Secret Manager
49///
50/// Mode is auto-negotiated based on environment:
51/// - If `GOOGLE_APPLICATION_CREDENTIALS` is set → HTTP mode
52/// - Otherwise → CLI mode (uses `gcloud` CLI)
53///
54/// The `source` field in [`SecretSpec`] can be:
55/// - A JSON-encoded [`GcpSecretConfig`]
56/// - A resource name like "projects/PROJECT/secrets/SECRET/versions/VERSION"
57pub struct GcpResolver {
58    use_http: bool,
59}
60
61impl std::fmt::Debug for GcpResolver {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("GcpResolver")
64            .field("mode", &if self.can_use_http() { "http" } else { "cli" })
65            .finish()
66    }
67}
68
69impl GcpResolver {
70    /// Create a new GCP resolver with auto-detected mode
71    ///
72    /// If GCP credentials are available in environment, uses HTTP mode.
73    /// Otherwise, CLI mode will be used.
74    ///
75    /// # Errors
76    /// Returns error if GCP credentials cannot be loaded.
77    pub fn new() -> Result<Self, SecretError> {
78        let use_http = Self::http_credentials_available();
79        Ok(Self { use_http })
80    }
81
82    /// Check if HTTP credentials are available in environment
83    fn http_credentials_available() -> bool {
84        std::env::var("GOOGLE_APPLICATION_CREDENTIALS").is_ok()
85    }
86
87    /// Check if this resolver can use HTTP mode
88    const fn can_use_http(&self) -> bool {
89        self.use_http
90    }
91
92    /// Resolve using the GCP Secret Manager API (HTTP mode)
93    async fn resolve_http(
94        &self,
95        name: &str,
96        config: &GcpSecretConfig,
97    ) -> Result<String, SecretError> {
98        // TODO: Implement using google-secretmanager1 crate
99        // For now, fall back to CLI
100        tracing::warn!(
101            "GCP HTTP mode not yet fully implemented, falling back to CLI for secret '{}'",
102            name
103        );
104        self.resolve_cli(name, config).await
105    }
106
107    /// Resolve using the gcloud CLI
108    async fn resolve_cli(
109        &self,
110        name: &str,
111        config: &GcpSecretConfig,
112    ) -> Result<String, SecretError> {
113        let output = Command::new("gcloud")
114            .args([
115                "secrets",
116                "versions",
117                "access",
118                &config.version,
119                "--secret",
120                &config.secret,
121                "--project",
122                &config.project,
123            ])
124            .output()
125            .await
126            .map_err(|e| SecretError::ResolutionFailed {
127                name: name.to_string(),
128                message: format!("Failed to execute gcloud CLI: {e}"),
129            })?;
130
131        if !output.status.success() {
132            let stderr = String::from_utf8_lossy(&output.stderr);
133            return Err(SecretError::ResolutionFailed {
134                name: name.to_string(),
135                message: format!("gcloud CLI failed: {stderr}"),
136            });
137        }
138
139        Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
140    }
141
142    /// Parse a resource name into a config
143    fn parse_resource_name(resource_name: &str) -> Option<GcpSecretConfig> {
144        // Format: projects/PROJECT/secrets/SECRET/versions/VERSION
145        let parts: Vec<&str> = resource_name.split('/').collect();
146        if parts.len() >= 6
147            && parts[0] == "projects"
148            && parts[2] == "secrets"
149            && parts[4] == "versions"
150        {
151            Some(GcpSecretConfig {
152                project: parts[1].to_string(),
153                secret: parts[3].to_string(),
154                version: parts[5].to_string(),
155            })
156        } else {
157            None
158        }
159    }
160
161    /// Resolve a secret - tries HTTP first if available, falls back to CLI
162    async fn resolve_with_config(
163        &self,
164        name: &str,
165        config: &GcpSecretConfig,
166    ) -> Result<String, SecretError> {
167        // Try HTTP mode if available
168        if self.use_http {
169            return self.resolve_http(name, config).await;
170        }
171
172        // Fallback to CLI
173        self.resolve_cli(name, config).await
174    }
175}
176
177#[async_trait]
178impl SecretResolver for GcpResolver {
179    fn provider_name(&self) -> &'static str {
180        "gcp"
181    }
182
183    async fn resolve(&self, name: &str, spec: &SecretSpec) -> Result<String, SecretError> {
184        // Try to parse source as JSON GcpSecretConfig
185        if let Ok(config) = serde_json::from_str::<GcpSecretConfig>(&spec.source) {
186            return self.resolve_with_config(name, &config).await;
187        }
188
189        // Try to parse as a resource name
190        if let Some(config) = Self::parse_resource_name(&spec.source) {
191            return self.resolve_with_config(name, &config).await;
192        }
193
194        Err(SecretError::ResolutionFailed {
195            name: name.to_string(),
196            message: format!(
197                "Invalid GCP secret source. Expected JSON config or resource name, got: {}",
198                spec.source
199            ),
200        })
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_gcp_config_serialization() {
210        let config = GcpSecretConfig {
211            project: "my-project".to_string(),
212            secret: "my-secret".to_string(),
213            version: "latest".to_string(),
214        };
215
216        let json = serde_json::to_string(&config).unwrap();
217        let parsed: GcpSecretConfig = serde_json::from_str(&json).unwrap();
218        assert_eq!(config, parsed);
219    }
220
221    #[test]
222    fn test_resource_name() {
223        let config = GcpSecretConfig::new("my-project", "my-secret");
224        assert_eq!(
225            config.resource_name(),
226            "projects/my-project/secrets/my-secret/versions/latest"
227        );
228    }
229
230    #[test]
231    fn test_parse_resource_name() {
232        let config =
233            GcpResolver::parse_resource_name("projects/my-project/secrets/my-secret/versions/5")
234                .unwrap();
235        assert_eq!(config.project, "my-project");
236        assert_eq!(config.secret, "my-secret");
237        assert_eq!(config.version, "5");
238    }
239
240    #[test]
241    fn test_parse_invalid_resource_name() {
242        assert!(GcpResolver::parse_resource_name("invalid/path").is_none());
243    }
244
245    #[test]
246    fn test_http_credentials_check() {
247        // This test just ensures the function exists and doesn't panic
248        let _ = GcpResolver::http_credentials_available();
249    }
250
251    #[test]
252    fn test_gcp_config_new() {
253        let config = GcpSecretConfig::new("my-project", "api-key");
254        assert_eq!(config.project, "my-project");
255        assert_eq!(config.secret, "api-key");
256        assert_eq!(config.version, "latest");
257    }
258
259    #[test]
260    fn test_gcp_config_new_with_string_types() {
261        let config = GcpSecretConfig::new(String::from("project-id"), String::from("secret-name"));
262        assert_eq!(config.project, "project-id");
263        assert_eq!(config.secret, "secret-name");
264    }
265
266    #[test]
267    fn test_default_version_function() {
268        assert_eq!(default_version(), "latest");
269    }
270
271    #[test]
272    fn test_resource_name_with_specific_version() {
273        let config = GcpSecretConfig {
274            project: "prod-project".to_string(),
275            secret: "database-password".to_string(),
276            version: "42".to_string(),
277        };
278        assert_eq!(
279            config.resource_name(),
280            "projects/prod-project/secrets/database-password/versions/42"
281        );
282    }
283
284    #[test]
285    fn test_gcp_config_clone() {
286        let config = GcpSecretConfig {
287            project: "my-project".to_string(),
288            secret: "my-secret".to_string(),
289            version: "latest".to_string(),
290        };
291        let cloned = config.clone();
292        assert_eq!(config, cloned);
293    }
294
295    #[test]
296    fn test_gcp_config_debug() {
297        let config = GcpSecretConfig::new("test-project", "test-secret");
298        let debug_str = format!("{config:?}");
299        assert!(debug_str.contains("GcpSecretConfig"));
300        assert!(debug_str.contains("test-project"));
301        assert!(debug_str.contains("test-secret"));
302    }
303
304    #[test]
305    fn test_parse_resource_name_with_latest() {
306        let config = GcpResolver::parse_resource_name(
307            "projects/my-project/secrets/my-secret/versions/latest",
308        )
309        .unwrap();
310        assert_eq!(config.project, "my-project");
311        assert_eq!(config.secret, "my-secret");
312        assert_eq!(config.version, "latest");
313    }
314
315    #[test]
316    fn test_parse_resource_name_missing_projects() {
317        assert!(
318            GcpResolver::parse_resource_name("my-project/secrets/my-secret/versions/1").is_none()
319        );
320    }
321
322    #[test]
323    fn test_parse_resource_name_missing_secrets() {
324        assert!(
325            GcpResolver::parse_resource_name("projects/my-project/my-secret/versions/1").is_none()
326        );
327    }
328
329    #[test]
330    fn test_parse_resource_name_missing_versions() {
331        assert!(
332            GcpResolver::parse_resource_name("projects/my-project/secrets/my-secret/1").is_none()
333        );
334    }
335
336    #[test]
337    fn test_parse_resource_name_too_short() {
338        assert!(GcpResolver::parse_resource_name("projects/my-project/secrets").is_none());
339    }
340
341    #[test]
342    fn test_gcp_config_deserialization_with_defaults() {
343        // When version is missing, it should default to "latest"
344        let json = r#"{"project": "my-project", "secret": "my-secret"}"#;
345        let config: GcpSecretConfig = serde_json::from_str(json).unwrap();
346        assert_eq!(config.project, "my-project");
347        assert_eq!(config.secret, "my-secret");
348        assert_eq!(config.version, "latest");
349    }
350
351    #[test]
352    fn test_gcp_config_full_serialization() {
353        let config = GcpSecretConfig {
354            project: "prod".to_string(),
355            secret: "api-key".to_string(),
356            version: "5".to_string(),
357        };
358        let json = serde_json::to_string(&config).unwrap();
359        // Verify camelCase serialization
360        assert!(json.contains("\"project\":\"prod\""));
361        assert!(json.contains("\"secret\":\"api-key\""));
362        assert!(json.contains("\"version\":\"5\""));
363    }
364
365    #[test]
366    fn test_gcp_config_equality() {
367        let config1 = GcpSecretConfig::new("project", "secret");
368        let config2 = GcpSecretConfig::new("project", "secret");
369        let config3 = GcpSecretConfig::new("project", "other-secret");
370
371        assert_eq!(config1, config2);
372        assert_ne!(config1, config3);
373    }
374
375    #[test]
376    fn test_gcp_resolver_new() {
377        let resolver = GcpResolver::new();
378        assert!(resolver.is_ok());
379    }
380
381    #[test]
382    fn test_gcp_resolver_debug() {
383        let resolver = GcpResolver::new().unwrap();
384        let debug_str = format!("{resolver:?}");
385        assert!(debug_str.contains("GcpResolver"));
386        // Should show either "http" or "cli" mode
387        assert!(debug_str.contains("mode"));
388    }
389
390    #[test]
391    fn test_gcp_resolver_provider_name() {
392        let resolver = GcpResolver::new().unwrap();
393        assert_eq!(resolver.provider_name(), "gcp");
394    }
395}