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::RngExt;
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 = crate::k8s_names::MAX_RESOURCE_NAME_LENGTH;
25
26pub const GENERATED_VERIFIER_KEY: &str = "verifier";
28
29const CHARSET: &[u8] =
31 b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*-_=+";
32
33pub 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
45pub 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
56pub 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
160pub 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 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 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 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
275fn chrono_now_rfc3339() -> String {
279 crate::crd::now_rfc3339()
281}
282
283#[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 pub fn is_transient(&self) -> bool {
304 match self {
305 PasswordError::MissingKey { .. } | PasswordError::EmptyPassword { .. } => false,
307 PasswordError::KubeApi { source, .. } => {
309 if let kube::Error::Api(status) = &**source {
310 let code = status.code;
313 !(400..500).contains(&code) || code == 409 || code == 429
314 } else {
315 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}