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