Skip to main content

pgroles_operator/
password.rs

1//! Password generation and Kubernetes Secret management for operator-generated passwords.
2
3use k8s_openapi::ByteString;
4use k8s_openapi::api::core::v1::Secret;
5use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
6use kube::ResourceExt;
7use kube::api::{Api, ObjectMeta, PostParams};
8use rand::Rng;
9use std::collections::BTreeMap;
10
11use crate::crd::{GeneratePasswordSpec, PostgresPolicy};
12
13/// Default password length when not specified in the CRD.
14pub const DEFAULT_PASSWORD_LENGTH: u32 = 32;
15
16/// Minimum allowed password length.
17pub const MIN_PASSWORD_LENGTH: u32 = 16;
18
19/// Maximum allowed password length.
20pub const MAX_PASSWORD_LENGTH: u32 = 128;
21
22/// Maximum length for a Kubernetes Secret name.
23pub const MAX_SECRET_NAME_LENGTH: usize = 253;
24
25/// Fixed key used to store the SCRAM verifier in generated Secrets.
26pub const GENERATED_VERIFIER_KEY: &str = "verifier";
27
28/// Character set for generated passwords: alphanumeric + common symbols.
29const CHARSET: &[u8] =
30    b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
31
32/// Generate a random password of the given length.
33pub fn generate_password(length: u32) -> String {
34    let length = length.clamp(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH) as usize;
35    let mut rng = rand::rng();
36    (0..length)
37        .map(|_| {
38            let idx = rng.random_range(0..CHARSET.len());
39            CHARSET[idx] as char
40        })
41        .collect()
42}
43
44/// Compute the Secret name for an operator-generated password.
45pub fn generated_secret_name(
46    policy_name: &str,
47    role_name: &str,
48    spec: &GeneratePasswordSpec,
49) -> String {
50    spec.secret_name
51        .clone()
52        .unwrap_or_else(|| default_generated_secret_name(policy_name, role_name))
53}
54
55/// Compute the Secret key for an operator-generated password.
56pub fn generated_secret_key(spec: &GeneratePasswordSpec) -> String {
57    spec.secret_key
58        .clone()
59        .unwrap_or_else(|| "password".to_string())
60}
61
62fn default_generated_secret_name(policy_name: &str, role_name: &str) -> String {
63    let policy = sanitize_secret_name_segment(policy_name, "policy");
64    let role = sanitize_secret_name_segment(role_name, "role");
65    let mut name = format!("{policy}-pgr-{role}");
66    if name.len() <= MAX_SECRET_NAME_LENGTH {
67        return name;
68    }
69
70    name.truncate(MAX_SECRET_NAME_LENGTH);
71    while matches!(name.as_bytes().last(), Some(b'-')) {
72        name.pop();
73    }
74    if name.is_empty() {
75        "pgroles-generated-password".to_string()
76    } else {
77        name
78    }
79}
80
81fn sanitize_secret_name_segment(input: &str, fallback: &str) -> String {
82    let mut result = String::new();
83    let mut last_was_dash = false;
84
85    for ch in input.chars() {
86        let normalized = ch.to_ascii_lowercase();
87        if normalized.is_ascii_lowercase() || normalized.is_ascii_digit() {
88            result.push(normalized);
89            last_was_dash = false;
90        } else if !last_was_dash {
91            result.push('-');
92            last_was_dash = true;
93        }
94    }
95
96    while matches!(result.as_bytes().first(), Some(b'-')) {
97        result.remove(0);
98    }
99    while matches!(result.as_bytes().last(), Some(b'-')) {
100        result.pop();
101    }
102
103    if result.is_empty() {
104        fallback.to_string()
105    } else {
106        result
107    }
108}
109
110fn secret_source_version(secret: &Secret, secret_name: &str, secret_key: &str) -> String {
111    let resource_version = secret
112        .metadata
113        .resource_version
114        .as_deref()
115        .unwrap_or("unknown");
116    format!("{secret_name}:{secret_key}:{resource_version}")
117}
118
119pub fn missing_generated_secret_source_version(secret_name: &str, secret_key: &str) -> String {
120    format!("{secret_name}:{secret_key}:missing")
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct GeneratedPasswordSecret {
125    pub password: String,
126    pub source_version: String,
127}
128
129fn generated_password_from_existing_secret(
130    secret: &Secret,
131    secret_name: &str,
132    secret_key: &str,
133) -> Result<GeneratedPasswordSecret, PasswordError> {
134    let data = secret
135        .data
136        .as_ref()
137        .ok_or_else(|| PasswordError::MissingKey {
138            secret: secret_name.to_string(),
139            key: secret_key.to_string(),
140        })?;
141    let value_bytes = data
142        .get(secret_key)
143        .ok_or_else(|| PasswordError::MissingKey {
144            secret: secret_name.to_string(),
145            key: secret_key.to_string(),
146        })?;
147    let password =
148        String::from_utf8(value_bytes.0.clone()).map_err(|_| PasswordError::MissingKey {
149            secret: secret_name.to_string(),
150            key: secret_key.to_string(),
151        })?;
152    if password.is_empty() {
153        return Err(PasswordError::EmptyPassword {
154            secret: secret_name.to_string(),
155            key: secret_key.to_string(),
156        });
157    }
158
159    Ok(GeneratedPasswordSecret {
160        password,
161        source_version: secret_source_version(secret, secret_name, secret_key),
162    })
163}
164
165async fn get_generated_secret_opt(
166    secrets_api: &Api<Secret>,
167    secret_name: &str,
168    secret_key: &str,
169) -> Result<Option<GeneratedPasswordSecret>, PasswordError> {
170    match secrets_api.get_opt(secret_name).await {
171        Ok(Some(existing)) => {
172            generated_password_from_existing_secret(&existing, secret_name, secret_key).map(Some)
173        }
174        Ok(None) => Ok(None),
175        Err(err) => Err(PasswordError::KubeApi {
176            secret: secret_name.to_string(),
177            source: Box::new(err),
178        }),
179    }
180}
181
182pub async fn get_generated_secret(
183    client: kube::Client,
184    namespace: &str,
185    policy_name: &str,
186    role_name: &str,
187    spec: &GeneratePasswordSpec,
188) -> Result<Option<GeneratedPasswordSecret>, PasswordError> {
189    let secrets_api: Api<Secret> = Api::namespaced(client, namespace);
190    let secret_name = generated_secret_name(policy_name, role_name, spec);
191    let secret_key = generated_secret_key(spec);
192    get_generated_secret_opt(&secrets_api, &secret_name, &secret_key).await
193}
194
195/// Ensure a Kubernetes Secret exists for a generated password.
196///
197/// If the Secret already exists, returns the existing password.
198/// If the Secret does not exist, generates a new password, creates the Secret
199/// with `ownerReferences` back to the `PostgresPolicy` CR, and returns the
200/// new password.
201///
202/// The generated Secret includes both the cleartext password (for application
203/// consumption) and the SCRAM-SHA-256 verifier.
204pub async fn ensure_generated_secret(
205    client: kube::Client,
206    namespace: &str,
207    policy: &PostgresPolicy,
208    role_name: &str,
209    spec: &GeneratePasswordSpec,
210) -> Result<GeneratedPasswordSecret, PasswordError> {
211    let secrets_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
212    let secret_name = generated_secret_name(&policy.name_any(), role_name, spec);
213    let secret_key = generated_secret_key(spec);
214
215    // Try to read the existing Secret first.
216    match get_generated_secret_opt(&secrets_api, &secret_name, &secret_key).await {
217        Ok(Some(existing)) => {
218            tracing::debug!(
219                secret = %secret_name,
220                role = %role_name,
221                "using existing generated password Secret"
222            );
223            Ok(existing)
224        }
225        Ok(None) => {
226            // Secret doesn't exist — generate and create.
227            let length = spec.length.unwrap_or(DEFAULT_PASSWORD_LENGTH);
228            let password = generate_password(length);
229            let verifier = pgroles_core::scram::compute_verifier(
230                &password,
231                pgroles_core::scram::DEFAULT_ITERATIONS,
232            );
233
234            let owner_ref = OwnerReference {
235                api_version: <PostgresPolicy as kube::Resource>::api_version(&()).to_string(),
236                kind: <PostgresPolicy as kube::Resource>::kind(&()).to_string(),
237                name: policy.name_any(),
238                uid: policy.uid().unwrap_or_default(),
239                controller: Some(true),
240                block_owner_deletion: Some(true),
241            };
242
243            let mut labels = BTreeMap::new();
244            labels.insert(
245                "app.kubernetes.io/managed-by".to_string(),
246                "pgroles-operator".to_string(),
247            );
248            labels.insert("pgroles.io/policy".to_string(), policy.name_any());
249            labels.insert("pgroles.io/role".to_string(), role_name.to_string());
250
251            let mut annotations = BTreeMap::new();
252            annotations.insert("pgroles.io/generated-at".to_string(), chrono_now_rfc3339());
253
254            let mut data = BTreeMap::new();
255            data.insert(secret_key.clone(), ByteString(password.as_bytes().to_vec()));
256            data.insert(
257                GENERATED_VERIFIER_KEY.to_string(),
258                ByteString(verifier.as_bytes().to_vec()),
259            );
260
261            let secret = Secret {
262                metadata: ObjectMeta {
263                    name: Some(secret_name.clone()),
264                    namespace: Some(namespace.to_string()),
265                    owner_references: Some(vec![owner_ref]),
266                    labels: Some(labels),
267                    annotations: Some(annotations),
268                    ..Default::default()
269                },
270                data: Some(data),
271                ..Default::default()
272            };
273
274            match secrets_api.create(&PostParams::default(), &secret).await {
275                Ok(created) => {
276                    tracing::info!(
277                        secret = %secret_name,
278                        role = %role_name,
279                        "created generated password Secret"
280                    );
281                    Ok(GeneratedPasswordSecret {
282                        password,
283                        source_version: secret_source_version(&created, &secret_name, &secret_key),
284                    })
285                }
286                Err(kube::Error::Api(ref api_err)) if api_err.code == 409 => {
287                    // 409 Conflict — another replica beat us. Read the Secret.
288                    tracing::debug!(
289                        secret = %secret_name,
290                        "Secret creation conflict — reading existing"
291                    );
292                    let existing = secrets_api.get(&secret_name).await.map_err(|err| {
293                        PasswordError::KubeApi {
294                            secret: secret_name.clone(),
295                            source: Box::new(err),
296                        }
297                    })?;
298                    generated_password_from_existing_secret(&existing, &secret_name, &secret_key)
299                }
300                Err(err) => Err(PasswordError::KubeApi {
301                    secret: secret_name,
302                    source: Box::new(err),
303                }),
304            }
305        }
306        Err(err) => Err(err),
307    }
308}
309
310/// Returns the current time as an RFC 3339 string.
311///
312/// Uses the same approach as the status module — no chrono dependency needed.
313fn chrono_now_rfc3339() -> String {
314    // Reuse the CRD helper which formats current time.
315    crate::crd::now_rfc3339()
316}
317
318/// Errors from password generation and Secret management.
319#[derive(Debug, thiserror::Error)]
320pub enum PasswordError {
321    #[error("generated Secret \"{secret}\" is missing key \"{key}\"")]
322    MissingKey { secret: String, key: String },
323
324    #[error("generated Secret \"{secret}\" has empty password at key \"{key}\"")]
325    EmptyPassword { secret: String, key: String },
326
327    #[error("Kubernetes API error for Secret \"{secret}\": {source}")]
328    KubeApi {
329        secret: String,
330        source: Box<kube::Error>,
331    },
332}
333
334impl PasswordError {
335    /// Returns `true` if this error is likely transient (network issues, API
336    /// server unavailability) and should be retried with exponential backoff
337    /// rather than waiting for the full policy interval.
338    pub fn is_transient(&self) -> bool {
339        match self {
340            // Missing key or empty password are spec/data issues — not transient.
341            PasswordError::MissingKey { .. } | PasswordError::EmptyPassword { .. } => false,
342            // Kube API errors: transient unless it's a clear client error (4xx).
343            PasswordError::KubeApi { source, .. } => {
344                if let kube::Error::Api(status) = &**source {
345                    // 4xx errors (except 409 Conflict and 429 Too Many Requests)
346                    // are non-transient — they indicate a spec or RBAC problem.
347                    let code = status.code;
348                    !(400..500).contains(&code) || code == 409 || code == 429
349                } else {
350                    // Transport errors, timeouts, etc. are transient.
351                    true
352                }
353            }
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn generate_password_default_length() {
364        let pw = generate_password(DEFAULT_PASSWORD_LENGTH);
365        assert_eq!(pw.len(), DEFAULT_PASSWORD_LENGTH as usize);
366    }
367
368    #[test]
369    fn generate_password_custom_length() {
370        let pw = generate_password(64);
371        assert_eq!(pw.len(), 64);
372    }
373
374    #[test]
375    fn generate_password_clamps_to_minimum() {
376        let pw = generate_password(1);
377        assert_eq!(pw.len(), MIN_PASSWORD_LENGTH as usize);
378    }
379
380    #[test]
381    fn generate_password_clamps_to_maximum() {
382        let pw = generate_password(999);
383        assert_eq!(pw.len(), MAX_PASSWORD_LENGTH as usize);
384    }
385
386    #[test]
387    fn generate_password_unique() {
388        let p1 = generate_password(32);
389        let p2 = generate_password(32);
390        assert_ne!(p1, p2, "two generated passwords should differ");
391    }
392
393    #[test]
394    fn generate_password_uses_expected_charset() {
395        let pw = generate_password(128);
396        for ch in pw.chars() {
397            assert!(
398                CHARSET.contains(&(ch as u8)),
399                "unexpected character '{ch}' in generated password"
400            );
401        }
402    }
403
404    #[test]
405    fn generated_secret_name_default() {
406        let spec = GeneratePasswordSpec {
407            length: None,
408            secret_name: None,
409            secret_key: None,
410        };
411        assert_eq!(
412            generated_secret_name("my-policy", "app-user", &spec),
413            "my-policy-pgr-app-user"
414        );
415    }
416
417    #[test]
418    fn generated_secret_name_sanitizes_invalid_default_segments() {
419        let spec = GeneratePasswordSpec {
420            length: None,
421            secret_name: None,
422            secret_key: None,
423        };
424        assert_eq!(
425            generated_secret_name("My Policy", "app_user@prod", &spec),
426            "my-policy-pgr-app-user-prod"
427        );
428    }
429
430    #[test]
431    fn generated_secret_name_custom() {
432        let spec = GeneratePasswordSpec {
433            length: None,
434            secret_name: Some("custom-secret".to_string()),
435            secret_key: None,
436        };
437        assert_eq!(
438            generated_secret_name("my-policy", "app-user", &spec),
439            "custom-secret"
440        );
441    }
442
443    #[test]
444    fn generated_secret_key_default() {
445        let spec = GeneratePasswordSpec {
446            length: None,
447            secret_name: None,
448            secret_key: None,
449        };
450        assert_eq!(generated_secret_key(&spec), "password");
451    }
452
453    #[test]
454    fn generated_secret_key_custom() {
455        let spec = GeneratePasswordSpec {
456            length: None,
457            secret_name: None,
458            secret_key: Some("my-key".to_string()),
459        };
460        assert_eq!(generated_secret_key(&spec), "my-key");
461    }
462}