Skip to main content

alien_bindings/providers/vault/
kubernetes_secret.rs

1use crate::error::{ErrorData, Result};
2use alien_error::{Context, ContextError, IntoAlienError};
3use alien_k8s_clients::secrets::SecretsApi;
4use async_trait::async_trait;
5use k8s_openapi::api::core::v1::Secret;
6use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
7use std::collections::BTreeMap;
8use std::sync::Arc;
9
10/// Kubernetes Secret vault binding implementation.
11///
12/// Stores secrets as Kubernetes Secret resources with naming convention:
13/// {vaultPrefix}-{secretName} (e.g., "acme-monitoring-secrets-API_KEY")
14///
15/// Each secret is a separate Kubernetes Secret resource to enable:
16/// - Granular access control via RBAC
17/// - Individual secret rotation without affecting others
18/// - Simpler secret management (no parsing of bundled secrets)
19#[derive(Debug)]
20pub struct KubernetesSecretVault {
21    client: Arc<dyn SecretsApi>,
22    namespace: String,
23    vault_prefix: String,
24}
25
26impl KubernetesSecretVault {
27    /// Create a new Kubernetes Secret vault binding.
28    ///
29    /// # Arguments
30    /// * `client` - Kubernetes Secrets API client
31    /// * `namespace` - Kubernetes namespace where secrets are stored
32    /// * `vault_prefix` - Prefix for secret names (e.g., "acme-monitoring-secrets")
33    pub fn new(client: Arc<dyn SecretsApi>, namespace: String, vault_prefix: String) -> Self {
34        Self {
35            client,
36            namespace,
37            vault_prefix,
38        }
39    }
40
41    /// Generate the Kubernetes Secret name for a given secret key.
42    /// Format: {vault_prefix}-{secret_name}
43    /// Example: "acme-monitoring-secrets-api-key"
44    fn secret_resource_name(&self, secret_name: &str) -> String {
45        let combined = format!("{}-{}", self.vault_prefix, secret_name);
46
47        // Kubernetes names must be lowercase and follow DNS-1123 label requirements
48        let clean = combined
49            .chars()
50            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
51            .collect::<String>()
52            .to_lowercase()
53            .replace('_', "-");
54
55        // Truncate to 253 characters (Kubernetes Secret name limit)
56        if clean.len() > 253 {
57            clean[..253].to_string()
58        } else {
59            clean
60        }
61    }
62}
63
64#[async_trait]
65impl crate::traits::Binding for KubernetesSecretVault {}
66
67#[async_trait]
68impl crate::traits::Vault for KubernetesSecretVault {
69    /// Get a secret value by name
70    async fn get_secret(&self, secret_name: &str) -> Result<String> {
71        let secret_resource_name = self.secret_resource_name(secret_name);
72
73        // Get the Kubernetes Secret
74        let secret = self
75            .client
76            .get_secret(&self.namespace, &secret_resource_name)
77            .await
78            .context(ErrorData::CloudPlatformError {
79                message: format!("Failed to get secret '{}'", secret_name),
80                resource_id: None,
81            })?;
82
83        // Extract the secret value from the "value" key in secret data
84        let value = secret
85            .data
86            .as_ref()
87            .and_then(|data| data.get("value"))
88            .ok_or_else(|| {
89                alien_error::AlienError::new(ErrorData::CloudPlatformError {
90                    message: format!("Secret '{}' has no 'value' field", secret_name),
91                    resource_id: None,
92                })
93            })?;
94
95        // Decode base64 value (Kubernetes stores secret data as base64)
96        let decoded = String::from_utf8(value.0.clone())
97            .into_alien_error()
98            .context(ErrorData::CloudPlatformError {
99                message: format!("Failed to decode secret '{}' value", secret_name),
100                resource_id: None,
101            })?;
102
103        Ok(decoded)
104    }
105
106    /// Set a secret value, creating it if it doesn't exist or updating it if it does
107    async fn set_secret(&self, secret_name: &str, value: &str) -> Result<()> {
108        let secret_resource_name = self.secret_resource_name(secret_name);
109
110        // Build the Secret resource
111        let mut data = BTreeMap::new();
112        data.insert(
113            "value".to_string(),
114            k8s_openapi::ByteString(value.as_bytes().to_vec()),
115        );
116
117        let secret = Secret {
118            metadata: ObjectMeta {
119                name: Some(secret_resource_name.clone()),
120                namespace: Some(self.namespace.clone()),
121                labels: Some({
122                    let mut labels = BTreeMap::new();
123                    labels.insert("managed-by".to_string(), "operator".to_string());
124                    labels.insert("vault-prefix".to_string(), self.vault_prefix.clone());
125                    labels
126                }),
127                ..Default::default()
128            },
129            data: Some(data),
130            ..Default::default()
131        };
132
133        // Try to create the secret first
134        match self.client.create_secret(&self.namespace, &secret).await {
135            Ok(_) => Ok(()),
136            Err(e) => {
137                // If secret already exists, update it instead
138                if matches!(
139                    e.error,
140                    Some(alien_client_core::ErrorData::RemoteResourceConflict { .. })
141                ) {
142                    self.client
143                        .update_secret(&self.namespace, &secret_resource_name, &secret)
144                        .await
145                        .context(ErrorData::CloudPlatformError {
146                            message: format!("Failed to update secret '{}'", secret_name),
147                            resource_id: None,
148                        })?;
149                    Ok(())
150                } else {
151                    Err(e.context(ErrorData::CloudPlatformError {
152                        message: format!("Failed to create secret '{}'", secret_name),
153                        resource_id: None,
154                    }))
155                }
156            }
157        }
158    }
159
160    /// Delete a secret
161    async fn delete_secret(&self, secret_name: &str) -> Result<()> {
162        let secret_resource_name = self.secret_resource_name(secret_name);
163
164        match self
165            .client
166            .delete_secret(&self.namespace, &secret_resource_name)
167            .await
168        {
169            Ok(()) => Ok(()),
170            Err(error)
171                if matches!(
172                    error.error,
173                    Some(alien_client_core::ErrorData::RemoteResourceNotFound { .. })
174                ) =>
175            {
176                Ok(())
177            }
178            Err(error) => Err(error.context(ErrorData::CloudPlatformError {
179                message: format!("Failed to delete secret '{secret_name}'"),
180                resource_id: None,
181            })),
182        }
183    }
184
185    /// List secret names by selecting the Secret resources this vault manages
186    /// (via the `managed-by`/`vault-prefix` labels set in [`Self::set_secret`])
187    /// and stripping the `{vault_prefix}-` resource-name prefix back off.
188    async fn list_secrets(&self) -> Result<Vec<String>> {
189        // Match the labels set in set_secret exactly: vault-prefix is stored
190        // with the vault's raw (un-sanitized) prefix.
191        let label_selector = format!("managed-by=operator,vault-prefix={}", self.vault_prefix);
192
193        let list = self
194            .client
195            .list_secrets(&self.namespace, Some(label_selector), None)
196            .await
197            .context(ErrorData::CloudPlatformError {
198                message: format!(
199                    "Failed to list secrets for vault prefix '{}'",
200                    self.vault_prefix
201                ),
202                resource_id: None,
203            })?;
204
205        // Resource names are the sanitized `{vault_prefix}-{secret}`; strip the
206        // sanitized prefix so results round-trip through get_secret.
207        let name_prefix = self.secret_resource_name("");
208
209        let names = list
210            .items
211            .into_iter()
212            .filter_map(|secret| secret.metadata.name)
213            .filter_map(|name| name.strip_prefix(&name_prefix).map(str::to_string))
214            .collect();
215
216        Ok(names)
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_secret_resource_name_generation() {
226        // Test basic generation
227        let vault_prefix = "acme-monitoring-secrets";
228        let secret_name = "API_KEY";
229        let combined = format!("{}-{}", vault_prefix, secret_name);
230        let result = combined
231            .chars()
232            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
233            .collect::<String>()
234            .to_lowercase()
235            .replace('_', "-");
236
237        assert_eq!(result, "acme-monitoring-secrets-api-key");
238
239        // Test character filtering (underscores become hyphens)
240        let secret_name2 = "MY_SECRET_KEY";
241        let combined2 = format!("{}-{}", vault_prefix, secret_name2);
242        let result2 = combined2
243            .chars()
244            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
245            .collect::<String>()
246            .to_lowercase()
247            .replace('_', "-");
248
249        assert_eq!(result2, "acme-monitoring-secrets-my-secret-key");
250
251        // Test length truncation
252        let long_name = "A".repeat(300);
253        let combined3 = format!("{}-{}", vault_prefix, long_name);
254        let result3 = combined3
255            .chars()
256            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
257            .collect::<String>()
258            .to_lowercase();
259        let truncated = if result3.len() > 253 {
260            result3[..253].to_string()
261        } else {
262            result3
263        };
264        assert!(truncated.len() <= 253);
265    }
266}