sherpack_engine/
secrets.rs

1//! Secret generation integration for MiniJinja templates
2//!
3//! This module provides the `generate_secret()` template function that generates
4//! deterministic, stateful secrets for Kubernetes deployments.
5//!
6//! # Usage in Templates
7//!
8//! ```jinja2
9//! # Generate a 16-char alphanumeric secret
10//! password: {{ generate_secret("db-password", 16) }}
11//!
12//! # Generate a 32-char hex secret
13//! token: {{ generate_secret("api-token", 32, "hex") }}
14//!
15//! # Supported charsets: alphanumeric, alpha, numeric, hex, base64, urlsafe
16//! ```
17//!
18//! # How It Works
19//!
20//! Unlike Helm's `randAlphaNum` which generates different values on each render:
21//!
22//! 1. **First install**: Secrets are generated randomly and stored in cluster state
23//! 2. **Subsequent renders**: Same values are returned from state
24//! 3. **Result**: Deterministic output, GitOps compatible
25//!
26//! # Integration
27//!
28//! ```rust,no_run
29//! use sherpack_engine::secrets::SecretFunctionState;
30//! use sherpack_core::SecretState;
31//! use minijinja::Environment;
32//!
33//! // Create from existing state (loaded from K8s)
34//! let existing_state = SecretState::new();
35//! let secret_fn = SecretFunctionState::with_state(existing_state);
36//!
37//! // Register with MiniJinja environment
38//! let mut env = Environment::new();
39//! secret_fn.register(&mut env);
40//!
41//! // After rendering, extract state for persistence
42//! let state = secret_fn.take_state();
43//! if state.is_dirty() {
44//!     // Persist to Kubernetes
45//! }
46//! ```
47
48use minijinja::{Environment, Error, ErrorKind};
49use sherpack_core::{SecretCharset, SecretGenerator, SecretState};
50use std::sync::Arc;
51
52/// Wrapper around SecretGenerator for MiniJinja integration
53///
54/// Uses `Arc<Mutex<>>` to provide interior mutability needed by MiniJinja
55/// functions which capture state but need to mutate it.
56///
57/// Note: Uses `Arc<Mutex<>>` for thread-safety compatibility with MiniJinja's
58/// `Send + Sync` requirements for global functions.
59#[derive(Debug, Clone)]
60pub struct SecretFunctionState {
61    generator: Arc<std::sync::Mutex<SecretGenerator>>,
62}
63
64impl Default for SecretFunctionState {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl SecretFunctionState {
71    /// Create a new state with empty generator
72    pub fn new() -> Self {
73        Self {
74            generator: Arc::new(std::sync::Mutex::new(SecretGenerator::new())),
75        }
76    }
77
78    /// Create from existing state (loaded from Kubernetes)
79    pub fn with_state(state: SecretState) -> Self {
80        Self {
81            generator: Arc::new(std::sync::Mutex::new(SecretGenerator::with_state(state))),
82        }
83    }
84
85    /// Check if any new secrets were generated
86    pub fn is_dirty(&self) -> bool {
87        self.generator.lock().unwrap().is_dirty()
88    }
89
90    /// Take ownership of the state (consumes internal generator)
91    ///
92    /// Note: This replaces the generator with a new empty one. Use this
93    /// only when you're done rendering and want to extract the state.
94    pub fn take_state(&self) -> SecretState {
95        let mut generator = self.generator.lock().unwrap();
96        let state = std::mem::take(&mut *generator);
97        state.into_state()
98    }
99
100    /// Register the `generate_secret` function on a MiniJinja environment
101    ///
102    /// # Arguments accepted by the function
103    ///
104    /// - `name` (required): Unique identifier for this secret
105    /// - `length` (required): Length of the secret in characters
106    /// - `charset` (optional): One of: alphanumeric, alpha, numeric, hex, base64, urlsafe
107    ///
108    /// # Example
109    ///
110    /// ```jinja2
111    /// {{ generate_secret("my-password", 24) }}
112    /// {{ generate_secret("hex-token", 32, "hex") }}
113    /// ```
114    pub fn register(&self, env: &mut Environment<'static>) {
115        let generator = Arc::clone(&self.generator);
116
117        env.add_function(
118            "generate_secret",
119            move |name: String, length: i64, charset: Option<String>| -> Result<String, Error> {
120                // Validate name
121                if name.is_empty() {
122                    return Err(Error::new(
123                        ErrorKind::InvalidOperation,
124                        "generate_secret: name cannot be empty",
125                    ));
126                }
127
128                // Validate length
129                if length < 1 {
130                    return Err(Error::new(
131                        ErrorKind::InvalidOperation,
132                        format!("generate_secret: length must be positive, got {}", length),
133                    ));
134                }
135
136                if length > 4096 {
137                    return Err(Error::new(
138                        ErrorKind::InvalidOperation,
139                        format!("generate_secret: length {} exceeds maximum of 4096", length),
140                    ));
141                }
142
143                // Parse optional charset
144                let charset = match charset {
145                    Some(ref charset_str) => {
146                        SecretCharset::parse(charset_str).ok_or_else(|| {
147                            Error::new(
148                                ErrorKind::InvalidOperation,
149                                format!(
150                                    "generate_secret: unknown charset '{}'. Valid options: \
151                                 alphanumeric, alpha, numeric, hex, base64, urlsafe",
152                                    charset_str
153                                ),
154                            )
155                        })?
156                    }
157                    None => SecretCharset::default(),
158                };
159
160                // Generate or retrieve the secret
161                let mut secret_gen = generator.lock().unwrap();
162                let secret =
163                    secret_gen.get_or_generate_with_charset(&name, length as usize, charset);
164
165                Ok(secret)
166            },
167        );
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn test_generate_secret_basic() {
177        let mut env = Environment::new();
178        let state = SecretFunctionState::new();
179        state.register(&mut env);
180
181        let template = r#"{{ generate_secret("test-password", 16) }}"#;
182        let result = env.render_str(template, ()).unwrap();
183
184        assert_eq!(result.len(), 16);
185        assert!(result.chars().all(|c| c.is_ascii_alphanumeric()));
186    }
187
188    #[test]
189    fn test_generate_secret_idempotent() {
190        let mut env = Environment::new();
191        let state = SecretFunctionState::new();
192        state.register(&mut env);
193
194        // Render twice with same name
195        let template1 = r#"{{ generate_secret("db-password", 20) }}"#;
196        let result1 = env.render_str(template1, ()).unwrap();
197        let result2 = env.render_str(template1, ()).unwrap();
198
199        // Should return the same value
200        assert_eq!(result1, result2);
201    }
202
203    #[test]
204    fn test_generate_secret_different_names() {
205        let mut env = Environment::new();
206        let state = SecretFunctionState::new();
207        state.register(&mut env);
208
209        let template =
210            r#"{{ generate_secret("password1", 16) }}-{{ generate_secret("password2", 16) }}"#;
211        let result = env.render_str(template, ()).unwrap();
212
213        let parts: Vec<&str> = result.split('-').collect();
214        assert_eq!(parts.len(), 2);
215        assert_ne!(parts[0], parts[1]);
216    }
217
218    #[test]
219    fn test_generate_secret_hex_charset() {
220        let mut env = Environment::new();
221        let state = SecretFunctionState::new();
222        state.register(&mut env);
223
224        let template = r#"{{ generate_secret("hex-token", 32, "hex") }}"#;
225        let result = env.render_str(template, ()).unwrap();
226
227        assert_eq!(result.len(), 32);
228        assert!(result.chars().all(|c| c.is_ascii_hexdigit()));
229    }
230
231    #[test]
232    fn test_generate_secret_numeric_charset() {
233        let mut env = Environment::new();
234        let state = SecretFunctionState::new();
235        state.register(&mut env);
236
237        let template = r#"{{ generate_secret("pin", 6, "numeric") }}"#;
238        let result = env.render_str(template, ()).unwrap();
239
240        assert_eq!(result.len(), 6);
241        assert!(result.chars().all(|c| c.is_ascii_digit()));
242    }
243
244    #[test]
245    fn test_generate_secret_invalid_charset() {
246        let mut env = Environment::new();
247        let state = SecretFunctionState::new();
248        state.register(&mut env);
249
250        let template = r#"{{ generate_secret("test", 16, "invalid") }}"#;
251        let result = env.render_str(template, ());
252
253        assert!(result.is_err());
254        let err = result.unwrap_err();
255        assert!(err.to_string().contains("unknown charset"));
256    }
257
258    #[test]
259    fn test_generate_secret_missing_args() {
260        let mut env = Environment::new();
261        let state = SecretFunctionState::new();
262        state.register(&mut env);
263
264        // Missing length argument - MiniJinja handles this with its own error
265        let template = r#"{{ generate_secret("only-name") }}"#;
266        let result = env.render_str(template, ());
267
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn test_generate_secret_invalid_length() {
273        let mut env = Environment::new();
274        let state = SecretFunctionState::new();
275        state.register(&mut env);
276
277        // Zero length
278        let result = env.render_str(r#"{{ generate_secret("test", 0) }}"#, ());
279        assert!(result.is_err());
280        assert!(result.unwrap_err().to_string().contains("must be positive"));
281
282        // Negative length - need fresh env because function is already registered
283        let mut env2 = Environment::new();
284        let state2 = SecretFunctionState::new();
285        state2.register(&mut env2);
286        let result = env2.render_str(r#"{{ generate_secret("test", -5) }}"#, ());
287        assert!(result.is_err());
288
289        // Too long
290        let mut env3 = Environment::new();
291        let state3 = SecretFunctionState::new();
292        state3.register(&mut env3);
293        let result = env3.render_str(r#"{{ generate_secret("test", 10000) }}"#, ());
294        assert!(result.is_err());
295        assert!(result.unwrap_err().to_string().contains("exceeds maximum"));
296    }
297
298    #[test]
299    fn test_state_is_dirty() {
300        let state = SecretFunctionState::new();
301        assert!(!state.is_dirty());
302
303        let mut env = Environment::new();
304        state.register(&mut env);
305
306        env.render_str(r#"{{ generate_secret("new-secret", 16) }}"#, ())
307            .unwrap();
308
309        assert!(state.is_dirty());
310    }
311
312    #[test]
313    fn test_state_persistence() {
314        // First "install" - generate secrets
315        let state1 = SecretFunctionState::new();
316        let mut env1 = Environment::new();
317        state1.register(&mut env1);
318
319        let secret = env1
320            .render_str(r#"{{ generate_secret("db-password", 24) }}"#, ())
321            .unwrap();
322
323        // Simulate persisting state
324        let persisted = state1.take_state();
325        let json = serde_json::to_string(&persisted).unwrap();
326
327        // "Upgrade" - load existing state
328        let loaded: SecretState = serde_json::from_str(&json).unwrap();
329        let state2 = SecretFunctionState::with_state(loaded);
330        let mut env2 = Environment::new();
331        state2.register(&mut env2);
332
333        let secret2 = env2
334            .render_str(r#"{{ generate_secret("db-password", 24) }}"#, ())
335            .unwrap();
336
337        // Should return same value
338        assert_eq!(secret, secret2);
339        // Should NOT be dirty (secret already existed)
340        assert!(!state2.is_dirty());
341    }
342
343    #[test]
344    fn test_multiple_secrets_in_template() {
345        let state = SecretFunctionState::new();
346        let mut env = Environment::new();
347        state.register(&mut env);
348
349        let template = r#"
350postgres-password: {{ generate_secret("postgres-password", 24) }}
351replication-password: {{ generate_secret("replication-password", 24) }}
352api-key: {{ generate_secret("api-key", 32, "hex") }}
353"#;
354
355        let result = env.render_str(template, ()).unwrap();
356
357        // Verify we got different values for each
358        let lines: Vec<&str> = result.lines().filter(|l| !l.is_empty()).collect();
359        assert_eq!(lines.len(), 3);
360
361        let postgres_pw = lines[0].split(": ").nth(1).unwrap();
362        let repl_pw = lines[1].split(": ").nth(1).unwrap();
363        let api_key = lines[2].split(": ").nth(1).unwrap();
364
365        assert_ne!(postgres_pw, repl_pw);
366        assert_ne!(postgres_pw, api_key);
367        assert_eq!(postgres_pw.len(), 24);
368        assert_eq!(repl_pw.len(), 24);
369        assert_eq!(api_key.len(), 32);
370    }
371}