Skip to main content

seal/models/
secret_data.rs

1//! Secret data models and validation
2
3use zeroize::Zeroizing;
4
5/// A key-value pair for secret data with secure memory handling
6#[derive(Debug, Clone)]
7pub struct SecretKeyValue {
8    /// The secret key name (must follow Kubernetes naming rules)
9    pub key: String,
10    /// The secret value (automatically zeroed on drop)
11    pub value: Zeroizing<String>,
12}
13
14impl SecretKeyValue {
15    /// Create a new SecretKeyValue with validation
16    pub fn new(key: String, value: String) -> Result<Self, crate::ValidationError> {
17        // Validate key using the validation function
18        crate::models::secret_data::validate_key_name(&key)?;
19
20        if value.is_empty() {
21            return Err(crate::ValidationError::Other(
22                "value cannot be empty".to_string(),
23            ));
24        }
25
26        Ok(Self {
27            key,
28            value: Zeroizing::new(value),
29        })
30    }
31}
32
33/// Validate secret name follows Kubernetes naming rules
34/// Pattern: lowercase alphanumeric, hyphens, dots
35/// Must start and end with alphanumeric
36/// Max 253 characters
37pub fn validate_secret_name(name: &str) -> Result<(), crate::ValidationError> {
38    if name.is_empty() || name.len() > 253 {
39        return Err(crate::ValidationError::InvalidSecretName {
40            name: name.to_string(),
41            reason: "must be 1-253 characters".to_string(),
42        });
43    }
44
45    // Must start with alphanumeric
46    if !name
47        .chars()
48        .next()
49        .map(|c| c.is_ascii_alphanumeric())
50        .unwrap_or(false)
51    {
52        return Err(crate::ValidationError::InvalidSecretName {
53            name: name.to_string(),
54            reason: "must start with alphanumeric character".to_string(),
55        });
56    }
57
58    // Must end with alphanumeric
59    if !name
60        .chars()
61        .last()
62        .map(|c| c.is_ascii_alphanumeric())
63        .unwrap_or(false)
64    {
65        return Err(crate::ValidationError::InvalidSecretName {
66            name: name.to_string(),
67            reason: "must end with alphanumeric character".to_string(),
68        });
69    }
70
71    // Only lowercase alphanumeric, hyphens, dots allowed
72    if !name
73        .chars()
74        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.')
75    {
76        return Err(crate::ValidationError::InvalidSecretName {
77            name: name.to_string(),
78            reason: "must contain only lowercase alphanumeric, hyphens, or dots".to_string(),
79        });
80    }
81
82    Ok(())
83}
84
85/// Validate namespace follows Kubernetes naming rules
86/// Pattern: lowercase alphanumeric with hyphens
87/// Must start and end with alphanumeric
88/// Max 63 characters
89pub fn validate_namespace(namespace: &str) -> Result<(), crate::ValidationError> {
90    if namespace.is_empty() || namespace.len() > 63 {
91        return Err(crate::ValidationError::InvalidNamespace {
92            namespace: namespace.to_string(),
93            reason: "must be 1-63 characters".to_string(),
94        });
95    }
96
97    // Must start with alphanumeric
98    if !namespace
99        .chars()
100        .next()
101        .map(|c| c.is_ascii_alphanumeric())
102        .unwrap_or(false)
103    {
104        return Err(crate::ValidationError::InvalidNamespace {
105            namespace: namespace.to_string(),
106            reason: "must start with alphanumeric character".to_string(),
107        });
108    }
109
110    // Must end with alphanumeric
111    if !namespace
112        .chars()
113        .last()
114        .map(|c| c.is_ascii_alphanumeric())
115        .unwrap_or(false)
116    {
117        return Err(crate::ValidationError::InvalidNamespace {
118            namespace: namespace.to_string(),
119            reason: "must end with alphanumeric character".to_string(),
120        });
121    }
122
123    // Only lowercase alphanumeric and hyphens allowed
124    if !namespace
125        .chars()
126        .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
127    {
128        return Err(crate::ValidationError::InvalidNamespace {
129            namespace: namespace.to_string(),
130            reason: "must contain only lowercase alphanumeric or hyphens".to_string(),
131        });
132    }
133
134    Ok(())
135}
136
137/// Validate secret key name
138/// Pattern: alphanumeric with underscores/hyphens
139/// Must start with letter or underscore
140pub fn validate_key_name(key: &str) -> Result<(), crate::ValidationError> {
141    if key.is_empty() {
142        return Err(crate::ValidationError::InvalidKeyName {
143            key: key.to_string(),
144            reason: "key name cannot be empty".to_string(),
145        });
146    }
147
148    // Must start with letter or underscore
149    let first_char = key.chars().next().unwrap();
150    if !first_char.is_ascii_alphabetic() && first_char != '_' {
151        return Err(crate::ValidationError::InvalidKeyName {
152            key: key.to_string(),
153            reason: "must start with letter or underscore".to_string(),
154        });
155    }
156
157    // Only alphanumeric, underscores, hyphens allowed
158    if !key
159        .chars()
160        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
161    {
162        return Err(crate::ValidationError::InvalidKeyName {
163            key: key.to_string(),
164            reason: "must contain only alphanumeric, underscores, or hyphens".to_string(),
165        });
166    }
167
168    Ok(())
169}
170
171use base64::Engine;
172use serde::{Deserialize, Serialize};
173use std::collections::HashMap;
174
175/// Metadata for a Kubernetes Secret or SealedSecret
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct SecretMetadata {
178    pub name: String,
179    pub namespace: String,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub labels: Option<HashMap<String, String>>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub annotations: Option<HashMap<String, String>>,
184}
185
186/// Request to create a sealed secret
187#[derive(Debug, Clone)]
188pub struct SecretRequest {
189    pub name: String,
190    pub namespace: String,
191    pub labels: HashMap<String, String>,
192    pub annotations: HashMap<String, String>,
193    pub data: Vec<SecretKeyValue>,
194}
195
196impl SecretRequest {
197    /// Create a new SecretRequest with validation
198    pub fn new(
199        name: String,
200        namespace: String,
201        labels: HashMap<String, String>,
202        annotations: HashMap<String, String>,
203        data: Vec<SecretKeyValue>,
204    ) -> Result<Self, crate::ValidationError> {
205        // Validate name and namespace
206        validate_secret_name(&name)?;
207        validate_namespace(&namespace)?;
208
209        // Validate data is not empty
210        if data.is_empty() {
211            return Err(crate::ValidationError::EmptySecretData);
212        }
213
214        // Validate max 50 key-value pairs (PR-003)
215        if data.len() > 50 {
216            return Err(crate::ValidationError::TooManyKeyValuePairs {
217                count: data.len(),
218                max: 50,
219            });
220        }
221
222        Ok(Self {
223            name,
224            namespace,
225            labels,
226            annotations,
227            data,
228        })
229    }
230
231    /// Get metadata for this secret
232    pub fn metadata(&self) -> SecretMetadata {
233        SecretMetadata {
234            name: self.name.clone(),
235            namespace: self.namespace.clone(),
236            labels: if self.labels.is_empty() {
237                None
238            } else {
239                Some(self.labels.clone())
240            },
241            annotations: if self.annotations.is_empty() {
242                None
243            } else {
244                Some(self.annotations.clone())
245            },
246        }
247    }
248}
249
250/// Kubernetes Secret manifest (plaintext - temporary only)
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct SecretManifest {
253    #[serde(rename = "apiVersion")]
254    pub api_version: String,
255    pub kind: String,
256    pub metadata: SecretMetadata,
257    #[serde(rename = "type")]
258    pub secret_type: String,
259    pub data: HashMap<String, String>,
260}
261
262impl SecretManifest {
263    /// Create a SecretManifest from a SecretRequest
264    pub fn from_request(request: &SecretRequest) -> Self {
265        let mut data = HashMap::new();
266
267        // Base64 encode all secret values
268        for kv in &request.data {
269            let encoded = base64::engine::general_purpose::STANDARD.encode(kv.value.as_bytes());
270            data.insert(kv.key.clone(), encoded);
271        }
272
273        Self {
274            api_version: "v1".to_string(),
275            kind: "Secret".to_string(),
276            metadata: request.metadata(),
277            secret_type: "Opaque".to_string(),
278            data,
279        }
280    }
281
282    /// Serialize to YAML
283    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
284        serde_yaml::to_string(self)
285    }
286}
287
288/// SealedSecret manifest (encrypted - safe for version control)
289#[derive(Debug, Clone, Serialize, Deserialize)]
290pub struct SealedSecretManifest {
291    #[serde(rename = "apiVersion")]
292    pub api_version: String,
293    pub kind: String,
294    pub metadata: SecretMetadata,
295    pub spec: SealedSecretSpec,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct SealedSecretSpec {
300    #[serde(rename = "encryptedData")]
301    pub encrypted_data: HashMap<String, String>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub template: Option<SealedSecretTemplate>,
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct SealedSecretTemplate {
308    pub metadata: SecretMetadata,
309}
310
311impl SealedSecretManifest {
312    /// Parse from YAML
313    pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
314        serde_yaml::from_str(yaml)
315    }
316
317    /// Serialize to YAML
318    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
319        serde_yaml::to_string(self)
320    }
321
322    /// Validate that output matches input request
323    pub fn validate_against_request(
324        &self,
325        request: &SecretRequest,
326    ) -> Result<(), crate::ValidationError> {
327        // Verify metadata matches
328        if self.metadata.name != request.name {
329            return Err(crate::ValidationError::Other(format!(
330                "Output name mismatch: expected '{}', got '{}'",
331                request.name, self.metadata.name
332            )));
333        }
334
335        if self.metadata.namespace != request.namespace {
336            return Err(crate::ValidationError::Other(format!(
337                "Output namespace mismatch: expected '{}', got '{}'",
338                request.namespace, self.metadata.namespace
339            )));
340        }
341
342        // Verify all input keys are present in encrypted data
343        for kv in &request.data {
344            if !self.spec.encrypted_data.contains_key(&kv.key) {
345                return Err(crate::ValidationError::Other(format!(
346                    "Missing key in output: '{}'",
347                    kv.key
348                )));
349            }
350        }
351
352        Ok(())
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359    use std::collections::HashMap;
360
361    #[test]
362    fn test_secret_key_value_new() {
363        let kv = SecretKeyValue::new("my-key".to_string(), "my-value".to_string());
364        assert!(kv.is_ok());
365        let kv = kv.unwrap();
366        assert_eq!(kv.key, "my-key");
367        assert_eq!(*kv.value, "my-value");
368    }
369
370    #[test]
371    fn test_secret_key_value_empty_value() {
372        let kv = SecretKeyValue::new("my-key".to_string(), "".to_string());
373        assert!(kv.is_err());
374        assert!(kv
375            .unwrap_err()
376            .to_string()
377            .contains("value cannot be empty"));
378    }
379
380    #[test]
381    fn test_secret_key_value_invalid_key() {
382        // Keys starting with a dot are invalid
383        let kv = SecretKeyValue::new(".invalid-key".to_string(), "value".to_string());
384        assert!(kv.is_err());
385    }
386
387    #[test]
388    fn test_validate_secret_name_valid() {
389        assert!(validate_secret_name("my-secret").is_ok());
390        assert!(validate_secret_name("my.secret").is_ok());
391        assert!(validate_secret_name("secret123").is_ok());
392        assert!(validate_secret_name("a").is_ok());
393        assert!(validate_secret_name("a-b-c").is_ok());
394    }
395
396    #[test]
397    fn test_validate_secret_name_empty() {
398        let result = validate_secret_name("");
399        assert!(result.is_err());
400        assert!(result.unwrap_err().to_string().contains("1-253 characters"));
401    }
402
403    #[test]
404    fn test_validate_secret_name_too_long() {
405        let long_name = "a".repeat(254);
406        let result = validate_secret_name(&long_name);
407        assert!(result.is_err());
408        assert!(result.unwrap_err().to_string().contains("1-253 characters"));
409    }
410
411    #[test]
412    fn test_validate_secret_name_starts_with_hyphen() {
413        let result = validate_secret_name("-secret");
414        assert!(result.is_err());
415        assert!(result
416            .unwrap_err()
417            .to_string()
418            .contains("must start with alphanumeric"));
419    }
420
421    #[test]
422    fn test_validate_secret_name_ends_with_hyphen() {
423        let result = validate_secret_name("secret-");
424        assert!(result.is_err());
425        assert!(result
426            .unwrap_err()
427            .to_string()
428            .contains("must end with alphanumeric"));
429    }
430
431    #[test]
432    fn test_validate_secret_name_uppercase() {
433        let result = validate_secret_name("MySecret");
434        assert!(result.is_err());
435        assert!(result
436            .unwrap_err()
437            .to_string()
438            .contains("lowercase alphanumeric"));
439    }
440
441    #[test]
442    fn test_validate_namespace_valid() {
443        assert!(validate_namespace("default").is_ok());
444        assert!(validate_namespace("my-namespace").is_ok());
445        assert!(validate_namespace("ns123").is_ok());
446    }
447
448    #[test]
449    fn test_validate_namespace_empty() {
450        let result = validate_namespace("");
451        assert!(result.is_err());
452        assert!(result.unwrap_err().to_string().contains("1-63 characters"));
453    }
454
455    #[test]
456    fn test_validate_namespace_too_long() {
457        let long_ns = "a".repeat(64);
458        let result = validate_namespace(&long_ns);
459        assert!(result.is_err());
460        assert!(result.unwrap_err().to_string().contains("1-63 characters"));
461    }
462
463    #[test]
464    fn test_validate_key_name_valid() {
465        assert!(validate_key_name("my-key").is_ok());
466        assert!(validate_key_name("MY_KEY").is_ok());
467        assert!(validate_key_name("KEY_123").is_ok());
468        assert!(validate_key_name("_private").is_ok());
469    }
470
471    #[test]
472    fn test_validate_key_name_starts_with_digit() {
473        let result = validate_key_name("1key");
474        assert!(result.is_err());
475        assert!(result
476            .unwrap_err()
477            .to_string()
478            .contains("must start with letter or underscore"));
479    }
480
481    #[test]
482    fn test_validate_key_name_starts_with_dot() {
483        let result = validate_key_name(".key");
484        assert!(result.is_err());
485    }
486
487    #[test]
488    fn test_validate_key_name_contains_slash() {
489        let result = validate_key_name("key/name");
490        assert!(result.is_err());
491    }
492
493    #[test]
494    fn test_validate_key_name_contains_dot() {
495        // Dots are not allowed in key names
496        let result = validate_key_name("key.name");
497        assert!(result.is_err());
498    }
499
500    #[test]
501    fn test_secret_request_new() {
502        let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
503        let request = SecretRequest::new(
504            "my-secret".to_string(),
505            "default".to_string(),
506            HashMap::new(),
507            HashMap::new(),
508            data,
509        );
510        assert!(request.is_ok());
511    }
512
513    #[test]
514    fn test_secret_request_empty_data() {
515        let request = SecretRequest::new(
516            "my-secret".to_string(),
517            "default".to_string(),
518            HashMap::new(),
519            HashMap::new(),
520            vec![],
521        );
522        assert!(request.is_err());
523        assert!(request
524            .unwrap_err()
525            .to_string()
526            .contains("Secret data cannot be empty"));
527    }
528
529    #[test]
530    fn test_secret_request_invalid_name() {
531        let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
532        let request = SecretRequest::new(
533            "".to_string(), // Invalid
534            "default".to_string(),
535            HashMap::new(),
536            HashMap::new(),
537            data,
538        );
539        assert!(request.is_err());
540    }
541
542    #[test]
543    fn test_secret_manifest_from_request() {
544        let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
545        let request = SecretRequest::new(
546            "my-secret".to_string(),
547            "default".to_string(),
548            HashMap::new(),
549            HashMap::new(),
550            data,
551        )
552        .unwrap();
553
554        let manifest = SecretManifest::from_request(&request);
555        assert_eq!(manifest.metadata.name, "my-secret");
556        assert_eq!(manifest.metadata.namespace, "default");
557        assert_eq!(manifest.kind, "Secret");
558        assert!(manifest.data.contains_key("key1"));
559    }
560
561    #[test]
562    fn test_sealed_secret_validate_against_request() {
563        let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
564        let request = SecretRequest::new(
565            "my-secret".to_string(),
566            "default".to_string(),
567            HashMap::new(),
568            HashMap::new(),
569            data,
570        )
571        .unwrap();
572
573        let mut encrypted_data = HashMap::new();
574        encrypted_data.insert("key1".to_string(), "encrypted-value".to_string());
575
576        let sealed_secret = SealedSecretManifest {
577            api_version: "bitnami.com/v1alpha1".to_string(),
578            kind: "SealedSecret".to_string(),
579            metadata: SecretMetadata {
580                name: "my-secret".to_string(),
581                namespace: "default".to_string(),
582                labels: Some(HashMap::new()),
583                annotations: Some(HashMap::new()),
584            },
585            spec: SealedSecretSpec {
586                encrypted_data,
587                template: None,
588            },
589        };
590
591        assert!(sealed_secret.validate_against_request(&request).is_ok());
592    }
593
594    #[test]
595    fn test_sealed_secret_validate_name_mismatch() {
596        let data = vec![SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap()];
597        let request = SecretRequest::new(
598            "my-secret".to_string(),
599            "default".to_string(),
600            HashMap::new(),
601            HashMap::new(),
602            data,
603        )
604        .unwrap();
605
606        let sealed_secret = SealedSecretManifest {
607            api_version: "bitnami.com/v1alpha1".to_string(),
608            kind: "SealedSecret".to_string(),
609            metadata: SecretMetadata {
610                name: "wrong-name".to_string(),
611                namespace: "default".to_string(),
612                labels: Some(HashMap::new()),
613                annotations: Some(HashMap::new()),
614            },
615            spec: SealedSecretSpec {
616                encrypted_data: HashMap::new(),
617                template: None,
618            },
619        };
620
621        let result = sealed_secret.validate_against_request(&request);
622        assert!(result.is_err());
623        assert!(result.unwrap_err().to_string().contains("name mismatch"));
624    }
625
626    #[test]
627    fn test_sealed_secret_validate_missing_key() {
628        let data = vec![
629            SecretKeyValue::new("key1".to_string(), "value1".to_string()).unwrap(),
630            SecretKeyValue::new("key2".to_string(), "value2".to_string()).unwrap(),
631        ];
632        let request = SecretRequest::new(
633            "my-secret".to_string(),
634            "default".to_string(),
635            HashMap::new(),
636            HashMap::new(),
637            data,
638        )
639        .unwrap();
640
641        let mut encrypted_data = HashMap::new();
642        encrypted_data.insert("key1".to_string(), "encrypted-value".to_string());
643        // Missing key2
644
645        let sealed_secret = SealedSecretManifest {
646            api_version: "bitnami.com/v1alpha1".to_string(),
647            kind: "SealedSecret".to_string(),
648            metadata: SecretMetadata {
649                name: "my-secret".to_string(),
650                namespace: "default".to_string(),
651                labels: Some(HashMap::new()),
652                annotations: Some(HashMap::new()),
653            },
654            spec: SealedSecretSpec {
655                encrypted_data,
656                template: None,
657            },
658        };
659
660        let result = sealed_secret.validate_against_request(&request);
661        assert!(result.is_err());
662        assert!(result.unwrap_err().to_string().contains("Missing key"));
663    }
664}