1use 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
13pub const DEFAULT_PASSWORD_LENGTH: u32 = 32;
15
16pub const MIN_PASSWORD_LENGTH: u32 = 16;
18
19pub const MAX_PASSWORD_LENGTH: u32 = 128;
21
22pub const MAX_SECRET_NAME_LENGTH: usize = 253;
24
25pub const GENERATED_VERIFIER_KEY: &str = "verifier";
27
28const CHARSET: &[u8] =
30 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
31
32pub 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
44pub 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
55pub 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
195pub 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 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 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 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
310fn chrono_now_rfc3339() -> String {
314 crate::crd::now_rfc3339()
316}
317
318#[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 pub fn is_transient(&self) -> bool {
339 match self {
340 PasswordError::MissingKey { .. } | PasswordError::EmptyPassword { .. } => false,
342 PasswordError::KubeApi { source, .. } => {
344 if let kube::Error::Api(status) = &**source {
345 let code = status.code;
348 !(400..500).contains(&code) || code == 409 || code == 429
349 } else {
350 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}