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)]
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    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}