1use kube::CustomResource;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12use pgroles_core::manifest::{
13 DefaultPrivilege, Grant, Membership, ObjectType, Privilege, RoleRetirement, SchemaBinding,
14};
15
16#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
25#[kube(
26 group = "pgroles.io",
27 version = "v1alpha1",
28 kind = "PostgresPolicy",
29 namespaced,
30 status = "PostgresPolicyStatus",
31 shortname = "pgr",
32 printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
33 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
34)]
35pub struct PostgresPolicySpec {
36 pub connection: ConnectionSpec,
38
39 #[serde(default = "default_interval")]
41 pub interval: String,
42
43 #[serde(default)]
45 pub suspend: bool,
46
47 #[serde(default)]
49 pub mode: PolicyMode,
50
51 #[serde(default)]
58 pub reconciliation_mode: CrdReconciliationMode,
59
60 #[serde(default)]
62 pub default_owner: Option<String>,
63
64 #[serde(default)]
66 pub profiles: std::collections::HashMap<String, ProfileSpec>,
67
68 #[serde(default)]
70 pub schemas: Vec<SchemaBinding>,
71
72 #[serde(default)]
74 pub roles: Vec<RoleSpec>,
75
76 #[serde(default)]
78 pub grants: Vec<Grant>,
79
80 #[serde(default)]
82 pub default_privileges: Vec<DefaultPrivilege>,
83
84 #[serde(default)]
86 pub memberships: Vec<Membership>,
87
88 #[serde(default)]
90 pub retirements: Vec<RoleRetirement>,
91}
92
93fn default_interval() -> String {
94 "5m".to_string()
95}
96
97#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
99#[serde(rename_all = "lowercase")]
100pub enum PolicyMode {
101 #[default]
102 Apply,
103 Plan,
104}
105
106#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
108#[serde(rename_all = "lowercase")]
109pub enum CrdReconciliationMode {
110 #[default]
112 Authoritative,
113 Additive,
115 Adopt,
117}
118
119impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
120 fn from(crd: CrdReconciliationMode) -> Self {
121 match crd {
122 CrdReconciliationMode::Authoritative => {
123 pgroles_core::diff::ReconciliationMode::Authoritative
124 }
125 CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
126 CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
133#[serde(rename_all = "camelCase")]
134pub struct ConnectionSpec {
135 pub secret_ref: SecretReference,
138
139 #[serde(default = "default_secret_key")]
141 pub secret_key: String,
142}
143
144fn default_secret_key() -> String {
145 "DATABASE_URL".to_string()
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
150pub struct SecretReference {
151 pub name: String,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
159pub struct ProfileSpec {
160 #[serde(default)]
161 pub login: Option<bool>,
162
163 #[serde(default)]
164 pub grants: Vec<ProfileGrantSpec>,
165
166 #[serde(default)]
167 pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
172pub struct ProfileGrantSpec {
173 pub privileges: Vec<Privilege>,
174 #[serde(alias = "on")]
175 pub object: ProfileObjectTargetSpec,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
180pub struct ProfileObjectTargetSpec {
181 #[serde(rename = "type")]
182 pub object_type: ObjectType,
183 #[serde(default)]
184 pub name: Option<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
189pub struct DefaultPrivilegeGrantSpec {
190 #[serde(default)]
191 pub role: Option<String>,
192 pub privileges: Vec<Privilege>,
193 pub on_type: ObjectType,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
198pub struct RoleSpec {
199 pub name: String,
200 #[serde(default)]
201 pub login: Option<bool>,
202 #[serde(default)]
203 pub superuser: Option<bool>,
204 #[serde(default)]
205 pub createdb: Option<bool>,
206 #[serde(default)]
207 pub createrole: Option<bool>,
208 #[serde(default)]
209 pub inherit: Option<bool>,
210 #[serde(default)]
211 pub replication: Option<bool>,
212 #[serde(default)]
213 pub bypassrls: Option<bool>,
214 #[serde(default)]
215 pub connection_limit: Option<i32>,
216 #[serde(default)]
217 pub comment: Option<String>,
218 #[serde(default)]
221 pub password: Option<PasswordSpec>,
222 #[serde(default)]
224 pub password_valid_until: Option<String>,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
244#[serde(rename_all = "camelCase")]
245pub struct PasswordSpec {
246 #[serde(default)]
249 pub secret_ref: Option<SecretReference>,
250 #[serde(default)]
253 pub secret_key: Option<String>,
254 #[serde(default)]
257 pub generate: Option<GeneratePasswordSpec>,
258}
259
260impl PasswordSpec {
261 pub fn is_secret_ref(&self) -> bool {
263 self.secret_ref.is_some()
264 }
265
266 pub fn is_generate(&self) -> bool {
268 self.generate.is_some()
269 }
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
274#[serde(rename_all = "camelCase")]
275pub struct GeneratePasswordSpec {
276 #[serde(default)]
278 pub length: Option<u32>,
279 #[serde(default)]
281 pub secret_name: Option<String>,
282 #[serde(default)]
284 pub secret_key: Option<String>,
285}
286
287#[derive(Debug, Clone, thiserror::Error)]
288pub enum PasswordValidationError {
289 #[error("role \"{role}\" has a password but login is not enabled")]
290 PasswordWithoutLogin { role: String },
291
292 #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
293 InvalidPasswordMode { role: String },
294
295 #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
296 InvalidGeneratedLength { role: String, min: u32, max: u32 },
297
298 #[error(
299 "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
300 )]
301 InvalidGeneratedSecretName { role: String, name: String },
302
303 #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
304 InvalidSecretKey {
305 role: String,
306 field: &'static str,
307 key: String,
308 },
309
310 #[error(
311 "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
312 )]
313 ReservedGeneratedSecretKey { role: String, key: String },
314}
315
316fn is_valid_secret_name(name: &str) -> bool {
320 if name.is_empty() || name.len() > crate::password::MAX_SECRET_NAME_LENGTH {
321 return false;
322 }
323 let bytes = name.as_bytes();
324 if !bytes[0].is_ascii_lowercase() {
326 return false;
327 }
328 if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
329 return false;
330 }
331 bytes
332 .iter()
333 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-' || *b == b'.')
334}
335
336fn is_valid_secret_key(key: &str) -> bool {
337 !key.is_empty()
338 && key
339 .bytes()
340 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
341}
342
343#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
349pub struct PostgresPolicyStatus {
350 #[serde(default)]
352 pub conditions: Vec<PolicyCondition>,
353
354 #[serde(default)]
356 pub observed_generation: Option<i64>,
357
358 #[serde(default)]
360 pub last_attempted_generation: Option<i64>,
361
362 #[serde(default)]
364 pub last_successful_reconcile_time: Option<String>,
365
366 #[serde(default)]
368 pub last_reconcile_time: Option<String>,
369
370 #[serde(default)]
372 pub change_summary: Option<ChangeSummary>,
373
374 #[serde(default)]
376 pub last_reconcile_mode: Option<PolicyMode>,
377
378 #[serde(default)]
380 pub planned_sql: Option<String>,
381
382 #[serde(default)]
384 pub planned_sql_truncated: bool,
385
386 #[serde(default)]
388 pub managed_database_identity: Option<String>,
389
390 #[serde(default)]
392 pub owned_roles: Vec<String>,
393
394 #[serde(default)]
396 pub owned_schemas: Vec<String>,
397
398 #[serde(default)]
400 pub last_error: Option<String>,
401
402 #[serde(default)]
404 pub applied_password_source_versions: BTreeMap<String, String>,
405
406 #[serde(default)]
408 pub transient_failure_count: i32,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
413pub struct PolicyCondition {
414 #[serde(rename = "type")]
416 pub condition_type: String,
417
418 pub status: String,
420
421 #[serde(default)]
423 pub reason: Option<String>,
424
425 #[serde(default)]
427 pub message: Option<String>,
428
429 #[serde(default)]
431 pub last_transition_time: Option<String>,
432}
433
434#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
436pub struct ChangeSummary {
437 pub roles_created: i32,
438 pub roles_altered: i32,
439 pub roles_dropped: i32,
440 pub sessions_terminated: i32,
441 pub grants_added: i32,
442 pub grants_revoked: i32,
443 pub default_privileges_set: i32,
444 pub default_privileges_revoked: i32,
445 pub members_added: i32,
446 pub members_removed: i32,
447 pub passwords_set: i32,
448 pub total: i32,
449}
450
451#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
453pub struct DatabaseIdentity(String);
454
455impl DatabaseIdentity {
456 pub fn new(namespace: &str, secret_name: &str, secret_key: &str) -> Self {
457 Self(format!("{namespace}/{secret_name}/{secret_key}"))
458 }
459
460 pub fn as_str(&self) -> &str {
461 &self.0
462 }
463}
464
465#[derive(Debug, Clone, Default, PartialEq, Eq)]
467pub struct OwnershipClaims {
468 pub roles: BTreeSet<String>,
469 pub schemas: BTreeSet<String>,
470}
471
472impl OwnershipClaims {
473 pub fn overlaps(&self, other: &Self) -> bool {
474 !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
475 }
476
477 pub fn overlap_summary(&self, other: &Self) -> String {
478 let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
479 let overlapping_schemas: Vec<_> =
480 self.schemas.intersection(&other.schemas).cloned().collect();
481
482 let mut parts = Vec::new();
483 if !overlapping_roles.is_empty() {
484 parts.push(format!("roles: {}", overlapping_roles.join(", ")));
485 }
486 if !overlapping_schemas.is_empty() {
487 parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
488 }
489
490 parts.join("; ")
491 }
492}
493
494impl PostgresPolicySpec {
499 pub fn validate_password_specs(
500 &self,
501 policy_name: &str,
502 ) -> Result<(), PasswordValidationError> {
503 for role in &self.roles {
504 let Some(password) = &role.password else {
505 continue;
506 };
507
508 if role.login != Some(true) {
509 return Err(PasswordValidationError::PasswordWithoutLogin {
510 role: role.name.clone(),
511 });
512 }
513
514 match (&password.secret_ref, &password.generate) {
515 (Some(_), None) => {
516 let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
517 if !is_valid_secret_key(secret_key) {
518 return Err(PasswordValidationError::InvalidSecretKey {
519 role: role.name.clone(),
520 field: "secretKey",
521 key: secret_key.to_string(),
522 });
523 }
524 }
525 (None, Some(generate)) => {
526 if let Some(length) = generate.length
527 && !(crate::password::MIN_PASSWORD_LENGTH
528 ..=crate::password::MAX_PASSWORD_LENGTH)
529 .contains(&length)
530 {
531 return Err(PasswordValidationError::InvalidGeneratedLength {
532 role: role.name.clone(),
533 min: crate::password::MIN_PASSWORD_LENGTH,
534 max: crate::password::MAX_PASSWORD_LENGTH,
535 });
536 }
537
538 let secret_name =
539 crate::password::generated_secret_name(policy_name, &role.name, generate);
540 if !is_valid_secret_name(&secret_name) {
541 return Err(PasswordValidationError::InvalidGeneratedSecretName {
542 role: role.name.clone(),
543 name: secret_name,
544 });
545 }
546
547 let secret_key = crate::password::generated_secret_key(generate);
548 if !is_valid_secret_key(&secret_key) {
549 return Err(PasswordValidationError::InvalidSecretKey {
550 role: role.name.clone(),
551 field: "generate.secretKey",
552 key: secret_key,
553 });
554 }
555 if secret_key == crate::password::GENERATED_VERIFIER_KEY {
556 return Err(PasswordValidationError::ReservedGeneratedSecretKey {
557 role: role.name.clone(),
558 key: secret_key,
559 });
560 }
561 }
562 _ => {
563 return Err(PasswordValidationError::InvalidPasswordMode {
564 role: role.name.clone(),
565 });
566 }
567 }
568 }
569
570 Ok(())
571 }
572
573 pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
579 let mut names = BTreeSet::new();
580 names.insert(self.connection.secret_ref.name.clone());
581 for role in &self.roles {
582 if let Some(pw) = &role.password {
583 if let Some(secret_ref) = &pw.secret_ref {
584 names.insert(secret_ref.name.clone());
585 }
586 if let Some(gen_spec) = &pw.generate {
587 let secret_name =
588 crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
589 names.insert(secret_name);
590 }
591 }
592 }
593 names
594 }
595}
596
597impl PostgresPolicySpec {
602 pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
604 use pgroles_core::manifest::{
605 DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
606 ProfileObjectTarget, RoleDefinition,
607 };
608
609 let profiles = self
610 .profiles
611 .iter()
612 .map(|(name, spec)| {
613 let profile = Profile {
614 login: spec.login,
615 grants: spec
616 .grants
617 .iter()
618 .map(|g| ProfileGrant {
619 privileges: g.privileges.clone(),
620 object: ProfileObjectTarget {
621 object_type: g.object.object_type,
622 name: g.object.name.clone(),
623 },
624 })
625 .collect(),
626 default_privileges: spec
627 .default_privileges
628 .iter()
629 .map(|dp| DefaultPrivilegeGrant {
630 role: dp.role.clone(),
631 privileges: dp.privileges.clone(),
632 on_type: dp.on_type,
633 })
634 .collect(),
635 };
636 (name.clone(), profile)
637 })
638 .collect();
639
640 let roles = self
641 .roles
642 .iter()
643 .map(|r| RoleDefinition {
644 name: r.name.clone(),
645 login: r.login,
646 superuser: r.superuser,
647 createdb: r.createdb,
648 createrole: r.createrole,
649 inherit: r.inherit,
650 replication: r.replication,
651 bypassrls: r.bypassrls,
652 connection_limit: r.connection_limit,
653 comment: r.comment.clone(),
654 password: None, password_valid_until: r.password_valid_until.clone(),
656 })
657 .collect();
658
659 let memberships = self
663 .memberships
664 .iter()
665 .map(|m| pgroles_core::manifest::Membership {
666 role: m.role.clone(),
667 members: m
668 .members
669 .iter()
670 .map(|ms| MemberSpec {
671 name: ms.name.clone(),
672 inherit: ms.inherit,
673 admin: ms.admin,
674 })
675 .collect(),
676 })
677 .collect();
678
679 PolicyManifest {
680 default_owner: self.default_owner.clone(),
681 auth_providers: Vec::new(),
682 profiles,
683 schemas: self.schemas.clone(),
684 roles,
685 grants: self.grants.clone(),
686 default_privileges: self.default_privileges.clone(),
687 memberships,
688 retirements: self.retirements.clone(),
689 }
690 }
691
692 pub fn ownership_claims(
697 &self,
698 ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
699 let manifest = self.to_policy_manifest();
700 let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
701
702 let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
703 let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
704
705 roles.extend(manifest.retirements.into_iter().map(|r| r.role));
706 roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
707 roles.extend(
708 manifest
709 .default_privileges
710 .iter()
711 .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
712 );
713 roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
714 roles.extend(
715 manifest
716 .memberships
717 .iter()
718 .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
719 );
720
721 schemas.extend(
722 manifest
723 .grants
724 .iter()
725 .filter_map(|g| match g.object.object_type {
726 ObjectType::Database => None,
727 ObjectType::Schema => g.object.name.clone(),
728 _ => g.object.schema.clone(),
729 }),
730 );
731 schemas.extend(
732 manifest
733 .default_privileges
734 .iter()
735 .map(|dp| dp.schema.clone()),
736 );
737
738 Ok(OwnershipClaims { roles, schemas })
739 }
740}
741
742impl PostgresPolicyStatus {
747 pub fn set_condition(&mut self, condition: PolicyCondition) {
749 if let Some(existing) = self
750 .conditions
751 .iter_mut()
752 .find(|c| c.condition_type == condition.condition_type)
753 {
754 *existing = condition;
755 } else {
756 self.conditions.push(condition);
757 }
758 }
759}
760
761pub fn now_rfc3339() -> String {
763 use std::time::SystemTime;
766 let now = SystemTime::now()
767 .duration_since(SystemTime::UNIX_EPOCH)
768 .unwrap_or_default();
769 let secs = now.as_secs();
771 let days = secs / 86400;
772 let remaining = secs % 86400;
773 let hours = remaining / 3600;
774 let minutes = (remaining % 3600) / 60;
775 let seconds = remaining % 60;
776
777 let (year, month, day) = days_to_date(days);
779 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
780}
781
782fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
784 let z = days_since_epoch + 719468;
786 let era = z / 146097;
787 let doe = z - era * 146097;
788 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
789 let y = yoe + era * 400;
790 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
791 let mp = (5 * doy + 2) / 153;
792 let d = doy - (153 * mp + 2) / 5 + 1;
793 let m = if mp < 10 { mp + 3 } else { mp - 9 };
794 let y = if m <= 2 { y + 1 } else { y };
795 (y, m, d)
796}
797
798pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
800 PolicyCondition {
801 condition_type: "Ready".to_string(),
802 status: if status { "True" } else { "False" }.to_string(),
803 reason: Some(reason.to_string()),
804 message: Some(message.to_string()),
805 last_transition_time: Some(now_rfc3339()),
806 }
807}
808
809pub fn reconciling_condition(message: &str) -> PolicyCondition {
811 PolicyCondition {
812 condition_type: "Reconciling".to_string(),
813 status: "True".to_string(),
814 reason: Some("Reconciling".to_string()),
815 message: Some(message.to_string()),
816 last_transition_time: Some(now_rfc3339()),
817 }
818}
819
820pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
822 PolicyCondition {
823 condition_type: "Degraded".to_string(),
824 status: "True".to_string(),
825 reason: Some(reason.to_string()),
826 message: Some(message.to_string()),
827 last_transition_time: Some(now_rfc3339()),
828 }
829}
830
831pub fn paused_condition(message: &str) -> PolicyCondition {
833 PolicyCondition {
834 condition_type: "Paused".to_string(),
835 status: "True".to_string(),
836 reason: Some("Suspended".to_string()),
837 message: Some(message.to_string()),
838 last_transition_time: Some(now_rfc3339()),
839 }
840}
841
842pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
844 PolicyCondition {
845 condition_type: "Conflict".to_string(),
846 status: "True".to_string(),
847 reason: Some(reason.to_string()),
848 message: Some(message.to_string()),
849 last_transition_time: Some(now_rfc3339()),
850 }
851}
852
853pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
855 PolicyCondition {
856 condition_type: "Drifted".to_string(),
857 status: if status { "True" } else { "False" }.to_string(),
858 reason: Some(reason.to_string()),
859 message: Some(message.to_string()),
860 last_transition_time: Some(now_rfc3339()),
861 }
862}
863
864#[cfg(test)]
869mod tests {
870 use super::*;
871 use kube::CustomResourceExt;
872
873 #[test]
874 fn crd_generates_valid_schema() {
875 let crd = PostgresPolicy::crd();
876 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
877 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
878 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
879 assert!(
880 yaml.contains("PostgresPolicy"),
881 "kind should be PostgresPolicy"
882 );
883 assert!(
884 yaml.contains("\"mode\"") || yaml.contains(" mode:"),
885 "schema should declare spec.mode"
886 );
887 assert!(
888 yaml.contains("\"object\"") || yaml.contains(" object:"),
889 "schema should declare grant object targets using object"
890 );
891 }
892
893 #[test]
894 fn spec_to_policy_manifest_roundtrip() {
895 let spec = PostgresPolicySpec {
896 connection: ConnectionSpec {
897 secret_ref: SecretReference {
898 name: "pg-secret".to_string(),
899 },
900 secret_key: "DATABASE_URL".to_string(),
901 },
902 interval: "5m".to_string(),
903 suspend: false,
904 mode: PolicyMode::Apply,
905 reconciliation_mode: CrdReconciliationMode::default(),
906 default_owner: Some("app_owner".to_string()),
907 profiles: std::collections::HashMap::new(),
908 schemas: vec![],
909 roles: vec![RoleSpec {
910 name: "analytics".to_string(),
911 login: Some(true),
912 superuser: None,
913 createdb: None,
914 createrole: None,
915 inherit: None,
916 replication: None,
917 bypassrls: None,
918 connection_limit: None,
919 comment: Some("test role".to_string()),
920 password: None,
921 password_valid_until: None,
922 }],
923 grants: vec![],
924 default_privileges: vec![],
925 memberships: vec![],
926 retirements: vec![RoleRetirement {
927 role: "legacy-app".to_string(),
928 reassign_owned_to: Some("app_owner".to_string()),
929 drop_owned: true,
930 terminate_sessions: true,
931 }],
932 };
933
934 let manifest = spec.to_policy_manifest();
935 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
936 assert_eq!(manifest.roles.len(), 1);
937 assert_eq!(manifest.roles[0].name, "analytics");
938 assert_eq!(manifest.roles[0].login, Some(true));
939 assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
940 assert_eq!(manifest.retirements.len(), 1);
941 assert_eq!(manifest.retirements[0].role, "legacy-app");
942 assert_eq!(
943 manifest.retirements[0].reassign_owned_to.as_deref(),
944 Some("app_owner")
945 );
946 assert!(manifest.retirements[0].drop_owned);
947 assert!(manifest.retirements[0].terminate_sessions);
948 }
949
950 #[test]
951 fn status_set_condition_replaces_existing() {
952 let mut status = PostgresPolicyStatus::default();
953
954 status.set_condition(ready_condition(false, "Pending", "Initial"));
955 assert_eq!(status.conditions.len(), 1);
956 assert_eq!(status.conditions[0].status, "False");
957
958 status.set_condition(ready_condition(true, "Reconciled", "All good"));
959 assert_eq!(status.conditions.len(), 1);
960 assert_eq!(status.conditions[0].status, "True");
961 assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
962 }
963
964 #[test]
965 fn status_set_condition_adds_new_type() {
966 let mut status = PostgresPolicyStatus::default();
967
968 status.set_condition(ready_condition(true, "OK", "ready"));
969 status.set_condition(degraded_condition("Error", "something broke"));
970
971 assert_eq!(status.conditions.len(), 2);
972 }
973
974 #[test]
975 fn paused_condition_has_expected_shape() {
976 let paused = paused_condition("paused by spec");
977 assert_eq!(paused.condition_type, "Paused");
978 assert_eq!(paused.status, "True");
979 assert_eq!(paused.reason.as_deref(), Some("Suspended"));
980 }
981
982 #[test]
983 fn ownership_claims_include_expanded_roles_and_schemas() {
984 let mut profiles = std::collections::HashMap::new();
985 profiles.insert(
986 "editor".to_string(),
987 ProfileSpec {
988 login: Some(false),
989 grants: vec![],
990 default_privileges: vec![],
991 },
992 );
993
994 let spec = PostgresPolicySpec {
995 connection: ConnectionSpec {
996 secret_ref: SecretReference {
997 name: "pg-secret".to_string(),
998 },
999 secret_key: "DATABASE_URL".to_string(),
1000 },
1001 interval: "5m".to_string(),
1002 suspend: false,
1003 mode: PolicyMode::Apply,
1004 reconciliation_mode: CrdReconciliationMode::default(),
1005 default_owner: None,
1006 profiles,
1007 schemas: vec![SchemaBinding {
1008 name: "inventory".to_string(),
1009 profiles: vec!["editor".to_string()],
1010 role_pattern: "{schema}-{profile}".to_string(),
1011 owner: None,
1012 }],
1013 roles: vec![RoleSpec {
1014 name: "app-service".to_string(),
1015 login: Some(true),
1016 superuser: None,
1017 createdb: None,
1018 createrole: None,
1019 inherit: None,
1020 replication: None,
1021 bypassrls: None,
1022 connection_limit: None,
1023 comment: None,
1024 password: None,
1025 password_valid_until: None,
1026 }],
1027 grants: vec![],
1028 default_privileges: vec![],
1029 memberships: vec![],
1030 retirements: vec![RoleRetirement {
1031 role: "legacy-app".to_string(),
1032 reassign_owned_to: None,
1033 drop_owned: false,
1034 terminate_sessions: false,
1035 }],
1036 };
1037
1038 let claims = spec.ownership_claims().unwrap();
1039 assert!(claims.roles.contains("inventory-editor"));
1040 assert!(claims.roles.contains("app-service"));
1041 assert!(claims.roles.contains("legacy-app"));
1042 assert!(claims.schemas.contains("inventory"));
1043 }
1044
1045 #[test]
1046 fn ownership_overlap_summary_reports_roles_and_schemas() {
1047 let mut left = OwnershipClaims::default();
1048 left.roles.insert("analytics".to_string());
1049 left.schemas.insert("reporting".to_string());
1050
1051 let mut right = OwnershipClaims::default();
1052 right.roles.insert("analytics".to_string());
1053 right.schemas.insert("reporting".to_string());
1054 right.schemas.insert("other".to_string());
1055
1056 assert!(left.overlaps(&right));
1057 let summary = left.overlap_summary(&right);
1058 assert!(summary.contains("roles: analytics"));
1059 assert!(summary.contains("schemas: reporting"));
1060 }
1061
1062 #[test]
1063 fn database_identity_uses_namespace_secret_and_key() {
1064 let identity = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1065 assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
1066 }
1067
1068 #[test]
1069 fn now_rfc3339_produces_valid_format() {
1070 let ts = now_rfc3339();
1071 assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
1073 assert!(ts.ends_with('Z'), "should end with Z: {ts}");
1074 assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
1075 assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
1076 }
1077
1078 #[test]
1079 fn ready_condition_true_has_expected_shape() {
1080 let cond = ready_condition(true, "Reconciled", "All changes applied");
1081 assert_eq!(cond.condition_type, "Ready");
1082 assert_eq!(cond.status, "True");
1083 assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
1084 assert_eq!(cond.message.as_deref(), Some("All changes applied"));
1085 assert!(cond.last_transition_time.is_some());
1086 }
1087
1088 #[test]
1089 fn ready_condition_false_has_expected_shape() {
1090 let cond = ready_condition(false, "InvalidSpec", "bad manifest");
1091 assert_eq!(cond.condition_type, "Ready");
1092 assert_eq!(cond.status, "False");
1093 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
1094 assert_eq!(cond.message.as_deref(), Some("bad manifest"));
1095 }
1096
1097 #[test]
1098 fn degraded_condition_has_expected_shape() {
1099 let cond = degraded_condition("InvalidSpec", "expansion failed");
1100 assert_eq!(cond.condition_type, "Degraded");
1101 assert_eq!(cond.status, "True");
1102 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
1103 assert_eq!(cond.message.as_deref(), Some("expansion failed"));
1104 assert!(cond.last_transition_time.is_some());
1105 }
1106
1107 #[test]
1108 fn reconciling_condition_has_expected_shape() {
1109 let cond = reconciling_condition("Reconciliation in progress");
1110 assert_eq!(cond.condition_type, "Reconciling");
1111 assert_eq!(cond.status, "True");
1112 assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
1113 assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
1114 assert!(cond.last_transition_time.is_some());
1115 }
1116
1117 #[test]
1118 fn conflict_condition_has_expected_shape() {
1119 let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
1120 assert_eq!(cond.condition_type, "Conflict");
1121 assert_eq!(cond.status, "True");
1122 assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
1123 assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
1124 assert!(cond.last_transition_time.is_some());
1125 }
1126
1127 #[test]
1128 fn ownership_claims_no_overlap() {
1129 let mut left = OwnershipClaims::default();
1130 left.roles.insert("analytics".to_string());
1131 left.schemas.insert("reporting".to_string());
1132
1133 let mut right = OwnershipClaims::default();
1134 right.roles.insert("billing".to_string());
1135 right.schemas.insert("payments".to_string());
1136
1137 assert!(!left.overlaps(&right));
1138 let summary = left.overlap_summary(&right);
1139 assert!(summary.is_empty());
1140 }
1141
1142 #[test]
1143 fn ownership_claims_partial_role_overlap() {
1144 let mut left = OwnershipClaims::default();
1145 left.roles.insert("analytics".to_string());
1146 left.roles.insert("reporting-viewer".to_string());
1147
1148 let mut right = OwnershipClaims::default();
1149 right.roles.insert("analytics".to_string());
1150 right.roles.insert("other-role".to_string());
1151
1152 assert!(left.overlaps(&right));
1153 let summary = left.overlap_summary(&right);
1154 assert!(summary.contains("roles: analytics"));
1155 assert!(!summary.contains("schemas"));
1156 }
1157
1158 #[test]
1159 fn ownership_claims_empty_is_disjoint() {
1160 let left = OwnershipClaims::default();
1161 let right = OwnershipClaims::default();
1162 assert!(!left.overlaps(&right));
1163 }
1164
1165 #[test]
1166 fn database_identity_equality() {
1167 let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1168 let b = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1169 let c = DatabaseIdentity::new("staging", "db-creds", "DATABASE_URL");
1170 assert_eq!(a, b);
1171 assert_ne!(a, c);
1172 }
1173
1174 #[test]
1175 fn database_identity_different_key() {
1176 let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1177 let b = DatabaseIdentity::new("prod", "db-creds", "CUSTOM_URL");
1178 assert_ne!(a, b);
1179 }
1180
1181 #[test]
1182 fn status_default_has_empty_conditions() {
1183 let status = PostgresPolicyStatus::default();
1184 assert!(status.conditions.is_empty());
1185 assert!(status.observed_generation.is_none());
1186 assert!(status.last_attempted_generation.is_none());
1187 assert!(status.last_successful_reconcile_time.is_none());
1188 assert!(status.change_summary.is_none());
1189 assert!(status.managed_database_identity.is_none());
1190 assert!(status.owned_roles.is_empty());
1191 assert!(status.owned_schemas.is_empty());
1192 assert!(status.last_error.is_none());
1193 assert!(status.applied_password_source_versions.is_empty());
1194 }
1195
1196 #[test]
1197 fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
1198 let mut status = PostgresPolicyStatus::default();
1199
1200 status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
1202 status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
1203 status
1204 .conditions
1205 .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
1206 status.change_summary = None;
1207 status.last_error = Some("bad manifest".to_string());
1208
1209 let ready = status
1211 .conditions
1212 .iter()
1213 .find(|c| c.condition_type == "Ready")
1214 .expect("should have Ready condition");
1215 assert_eq!(ready.status, "False");
1216 assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
1217
1218 let degraded = status
1220 .conditions
1221 .iter()
1222 .find(|c| c.condition_type == "Degraded")
1223 .expect("should have Degraded condition");
1224 assert_eq!(degraded.status, "True");
1225 assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
1226
1227 assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
1229 }
1230
1231 #[test]
1232 fn status_conflict_workflow() {
1233 let mut status = PostgresPolicyStatus::default();
1234
1235 let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
1237 status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
1238 status.set_condition(conflict_condition("ConflictingPolicy", msg));
1239 status.set_condition(degraded_condition("ConflictingPolicy", msg));
1240 status
1241 .conditions
1242 .retain(|c| c.condition_type != "Reconciling");
1243 status.last_error = Some(msg.to_string());
1244
1245 let conflict = status
1247 .conditions
1248 .iter()
1249 .find(|c| c.condition_type == "Conflict")
1250 .expect("should have Conflict condition");
1251 assert_eq!(conflict.status, "True");
1252 assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
1253
1254 let ready = status
1256 .conditions
1257 .iter()
1258 .find(|c| c.condition_type == "Ready")
1259 .expect("should have Ready condition");
1260 assert_eq!(ready.status, "False");
1261
1262 let degraded = status
1264 .conditions
1265 .iter()
1266 .find(|c| c.condition_type == "Degraded")
1267 .expect("should have Degraded condition");
1268 assert_eq!(degraded.status, "True");
1269 }
1270
1271 #[test]
1272 fn status_successful_reconcile_records_generation_and_time() {
1273 let mut status = PostgresPolicyStatus::default();
1274 let generation = Some(3_i64);
1275 let summary = ChangeSummary {
1276 roles_created: 2,
1277 total: 2,
1278 ..Default::default()
1279 };
1280
1281 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1283 status.conditions.retain(|c| {
1284 c.condition_type != "Reconciling"
1285 && c.condition_type != "Degraded"
1286 && c.condition_type != "Conflict"
1287 && c.condition_type != "Paused"
1288 });
1289 status.observed_generation = generation;
1290 status.last_attempted_generation = generation;
1291 status.last_successful_reconcile_time = Some(now_rfc3339());
1292 status.last_reconcile_time = Some(now_rfc3339());
1293 status.change_summary = Some(summary);
1294 status.last_error = None;
1295
1296 let ready = status
1298 .conditions
1299 .iter()
1300 .find(|c| c.condition_type == "Ready")
1301 .expect("should have Ready condition");
1302 assert_eq!(ready.status, "True");
1303 assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
1304
1305 assert_eq!(status.observed_generation, Some(3));
1307 assert_eq!(status.last_attempted_generation, Some(3));
1308
1309 assert!(status.last_successful_reconcile_time.is_some());
1311 assert!(status.last_reconcile_time.is_some());
1312
1313 let summary = status.change_summary.as_ref().unwrap();
1315 assert_eq!(summary.roles_created, 2);
1316 assert_eq!(summary.total, 2);
1317
1318 assert!(status.last_error.is_none());
1320
1321 assert!(
1323 status
1324 .conditions
1325 .iter()
1326 .all(|c| c.condition_type != "Degraded"
1327 && c.condition_type != "Conflict"
1328 && c.condition_type != "Paused"
1329 && c.condition_type != "Reconciling")
1330 );
1331 }
1332
1333 #[test]
1334 fn status_suspended_workflow() {
1335 let mut status = PostgresPolicyStatus::default();
1336 let generation = Some(2_i64);
1337
1338 status.set_condition(paused_condition("Reconciliation suspended by spec"));
1340 status.set_condition(ready_condition(
1341 false,
1342 "Suspended",
1343 "Reconciliation suspended by spec",
1344 ));
1345 status
1346 .conditions
1347 .retain(|c| c.condition_type != "Reconciling");
1348 status.last_attempted_generation = generation;
1349 status.last_error = None;
1350
1351 let paused = status
1353 .conditions
1354 .iter()
1355 .find(|c| c.condition_type == "Paused")
1356 .expect("should have Paused condition");
1357 assert_eq!(paused.status, "True");
1358
1359 let ready = status
1361 .conditions
1362 .iter()
1363 .find(|c| c.condition_type == "Ready")
1364 .expect("should have Ready condition");
1365 assert_eq!(ready.status, "False");
1366 assert_eq!(ready.reason.as_deref(), Some("Suspended"));
1367
1368 assert!(
1370 !status
1371 .conditions
1372 .iter()
1373 .any(|c| c.condition_type == "Reconciling")
1374 );
1375 }
1376
1377 #[test]
1378 fn status_transitions_from_degraded_to_ready() {
1379 let mut status = PostgresPolicyStatus::default();
1380
1381 status.set_condition(ready_condition(false, "InvalidSpec", "error"));
1383 status.set_condition(degraded_condition("InvalidSpec", "error"));
1384 status.last_error = Some("error".to_string());
1385
1386 assert_eq!(status.conditions.len(), 2);
1387
1388 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1390 status.conditions.retain(|c| {
1391 c.condition_type != "Reconciling"
1392 && c.condition_type != "Degraded"
1393 && c.condition_type != "Conflict"
1394 && c.condition_type != "Paused"
1395 });
1396 status.last_error = None;
1397
1398 let ready = status
1400 .conditions
1401 .iter()
1402 .find(|c| c.condition_type == "Ready")
1403 .expect("should have Ready condition");
1404 assert_eq!(ready.status, "True");
1405
1406 assert!(
1408 !status
1409 .conditions
1410 .iter()
1411 .any(|c| c.condition_type == "Degraded")
1412 );
1413
1414 assert_eq!(status.conditions.len(), 1);
1416
1417 assert!(status.last_error.is_none());
1419 }
1420
1421 #[test]
1422 fn change_summary_default_is_all_zero() {
1423 let summary = ChangeSummary::default();
1424 assert_eq!(summary.roles_created, 0);
1425 assert_eq!(summary.roles_altered, 0);
1426 assert_eq!(summary.roles_dropped, 0);
1427 assert_eq!(summary.sessions_terminated, 0);
1428 assert_eq!(summary.grants_added, 0);
1429 assert_eq!(summary.grants_revoked, 0);
1430 assert_eq!(summary.default_privileges_set, 0);
1431 assert_eq!(summary.default_privileges_revoked, 0);
1432 assert_eq!(summary.members_added, 0);
1433 assert_eq!(summary.members_removed, 0);
1434 assert_eq!(summary.total, 0);
1435 }
1436
1437 #[test]
1438 fn status_serializes_to_json() {
1439 let mut status = PostgresPolicyStatus::default();
1440 status.set_condition(ready_condition(true, "Reconciled", "done"));
1441 status.observed_generation = Some(5);
1442 status.managed_database_identity = Some("ns/secret/key".to_string());
1443 status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
1444 status.owned_schemas = vec!["public".to_string()];
1445 status.change_summary = Some(ChangeSummary {
1446 roles_created: 1,
1447 total: 1,
1448 ..Default::default()
1449 });
1450
1451 let json = serde_json::to_string(&status).expect("should serialize");
1452 assert!(json.contains("\"Reconciled\""));
1453 assert!(json.contains("\"observed_generation\":5"));
1454 assert!(json.contains("\"role-a\""));
1455 assert!(json.contains("\"ns/secret/key\""));
1456 }
1457
1458 #[test]
1459 fn crd_spec_deserializes_from_yaml() {
1460 let yaml = r#"
1461connection:
1462 secretRef:
1463 name: pg-credentials
1464interval: "10m"
1465default_owner: app_owner
1466profiles:
1467 editor:
1468 grants:
1469 - privileges: [USAGE]
1470 object: { type: schema }
1471 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1472 object: { type: table, name: "*" }
1473 default_privileges:
1474 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1475 on_type: table
1476schemas:
1477 - name: inventory
1478 profiles: [editor]
1479roles:
1480 - name: analytics
1481 login: true
1482grants:
1483 - role: analytics
1484 privileges: [CONNECT]
1485 object: { type: database, name: mydb }
1486memberships:
1487 - role: inventory-editor
1488 members:
1489 - name: analytics
1490retirements:
1491 - role: legacy-app
1492 reassign_owned_to: app_owner
1493 drop_owned: true
1494 terminate_sessions: true
1495"#;
1496 let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
1497 assert_eq!(spec.interval, "10m");
1498 assert_eq!(spec.default_owner, Some("app_owner".to_string()));
1499 assert_eq!(spec.profiles.len(), 1);
1500 assert!(spec.profiles.contains_key("editor"));
1501 assert_eq!(spec.schemas.len(), 1);
1502 assert_eq!(spec.roles.len(), 1);
1503 assert_eq!(spec.grants.len(), 1);
1504 assert_eq!(spec.memberships.len(), 1);
1505 assert_eq!(spec.retirements.len(), 1);
1506 assert_eq!(spec.retirements[0].role, "legacy-app");
1507 assert!(spec.retirements[0].terminate_sessions);
1508 }
1509
1510 #[test]
1511 fn referenced_secret_names_includes_connection_secret() {
1512 let spec = PostgresPolicySpec {
1513 connection: ConnectionSpec {
1514 secret_ref: SecretReference {
1515 name: "pg-conn".to_string(),
1516 },
1517 secret_key: "DATABASE_URL".to_string(),
1518 },
1519 interval: "5m".to_string(),
1520 suspend: false,
1521 mode: PolicyMode::Apply,
1522 reconciliation_mode: CrdReconciliationMode::default(),
1523 default_owner: None,
1524 profiles: std::collections::HashMap::new(),
1525 schemas: vec![],
1526 roles: vec![],
1527 grants: vec![],
1528 default_privileges: vec![],
1529 memberships: vec![],
1530 retirements: vec![],
1531 };
1532
1533 let names = spec.referenced_secret_names("test-policy");
1534 assert!(names.contains("pg-conn"));
1535 assert_eq!(names.len(), 1);
1536 }
1537
1538 #[test]
1539 fn referenced_secret_names_includes_password_secrets() {
1540 let spec = PostgresPolicySpec {
1541 connection: ConnectionSpec {
1542 secret_ref: SecretReference {
1543 name: "pg-conn".to_string(),
1544 },
1545 secret_key: "DATABASE_URL".to_string(),
1546 },
1547 interval: "5m".to_string(),
1548 suspend: false,
1549 mode: PolicyMode::Apply,
1550 reconciliation_mode: CrdReconciliationMode::default(),
1551 default_owner: None,
1552 profiles: std::collections::HashMap::new(),
1553 schemas: vec![],
1554 roles: vec![
1555 RoleSpec {
1556 name: "role-a".to_string(),
1557 login: Some(true),
1558 password: Some(PasswordSpec {
1559 secret_ref: Some(SecretReference {
1560 name: "role-passwords".to_string(),
1561 }),
1562 secret_key: Some("role-a".to_string()),
1563 generate: None,
1564 }),
1565 password_valid_until: None,
1566 superuser: None,
1567 createdb: None,
1568 createrole: None,
1569 inherit: None,
1570 replication: None,
1571 bypassrls: None,
1572 connection_limit: None,
1573 comment: None,
1574 },
1575 RoleSpec {
1576 name: "role-b".to_string(),
1577 login: Some(true),
1578 password: Some(PasswordSpec {
1579 secret_ref: Some(SecretReference {
1580 name: "other-secret".to_string(),
1581 }),
1582 secret_key: None,
1583 generate: None,
1584 }),
1585 password_valid_until: None,
1586 superuser: None,
1587 createdb: None,
1588 createrole: None,
1589 inherit: None,
1590 replication: None,
1591 bypassrls: None,
1592 connection_limit: None,
1593 comment: None,
1594 },
1595 RoleSpec {
1596 name: "role-c".to_string(),
1597 login: None,
1598 password: None,
1599 password_valid_until: None,
1600 superuser: None,
1601 createdb: None,
1602 createrole: None,
1603 inherit: None,
1604 replication: None,
1605 bypassrls: None,
1606 connection_limit: None,
1607 comment: None,
1608 },
1609 ],
1610 grants: vec![],
1611 default_privileges: vec![],
1612 memberships: vec![],
1613 retirements: vec![],
1614 };
1615
1616 let names = spec.referenced_secret_names("test-policy");
1617 assert!(
1618 names.contains("pg-conn"),
1619 "should include connection secret"
1620 );
1621 assert!(
1622 names.contains("role-passwords"),
1623 "should include role-a password secret"
1624 );
1625 assert!(
1626 names.contains("other-secret"),
1627 "should include role-b password secret"
1628 );
1629 assert_eq!(names.len(), 3);
1630 }
1631
1632 #[test]
1633 fn validate_password_specs_rejects_password_without_login() {
1634 let spec = PostgresPolicySpec {
1635 connection: ConnectionSpec {
1636 secret_ref: SecretReference {
1637 name: "pg-conn".to_string(),
1638 },
1639 secret_key: "DATABASE_URL".to_string(),
1640 },
1641 interval: "5m".to_string(),
1642 suspend: false,
1643 mode: PolicyMode::Apply,
1644 reconciliation_mode: CrdReconciliationMode::default(),
1645 default_owner: None,
1646 profiles: std::collections::HashMap::new(),
1647 schemas: vec![],
1648 roles: vec![RoleSpec {
1649 name: "app-user".to_string(),
1650 login: Some(false),
1651 superuser: None,
1652 createdb: None,
1653 createrole: None,
1654 inherit: None,
1655 replication: None,
1656 bypassrls: None,
1657 connection_limit: None,
1658 comment: None,
1659 password: Some(PasswordSpec {
1660 secret_ref: Some(SecretReference {
1661 name: "role-passwords".to_string(),
1662 }),
1663 secret_key: None,
1664 generate: None,
1665 }),
1666 password_valid_until: None,
1667 }],
1668 grants: vec![],
1669 default_privileges: vec![],
1670 memberships: vec![],
1671 retirements: vec![],
1672 };
1673
1674 assert!(matches!(
1675 spec.validate_password_specs("test-policy"),
1676 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
1677 ));
1678 }
1679
1680 #[test]
1681 fn validate_password_specs_rejects_password_with_login_omitted() {
1682 let spec = PostgresPolicySpec {
1683 connection: ConnectionSpec {
1684 secret_ref: SecretReference {
1685 name: "pg-conn".to_string(),
1686 },
1687 secret_key: "DATABASE_URL".to_string(),
1688 },
1689 interval: "5m".to_string(),
1690 suspend: false,
1691 mode: PolicyMode::Apply,
1692 reconciliation_mode: CrdReconciliationMode::default(),
1693 default_owner: None,
1694 profiles: std::collections::HashMap::new(),
1695 schemas: vec![],
1696 roles: vec![RoleSpec {
1697 name: "app-user".to_string(),
1698 login: None, superuser: None,
1700 createdb: None,
1701 createrole: None,
1702 inherit: None,
1703 replication: None,
1704 bypassrls: None,
1705 connection_limit: None,
1706 comment: None,
1707 password: Some(PasswordSpec {
1708 secret_ref: Some(SecretReference {
1709 name: "role-passwords".to_string(),
1710 }),
1711 secret_key: None,
1712 generate: None,
1713 }),
1714 password_valid_until: None,
1715 }],
1716 grants: vec![],
1717 default_privileges: vec![],
1718 memberships: vec![],
1719 retirements: vec![],
1720 };
1721
1722 assert!(matches!(
1723 spec.validate_password_specs("test-policy"),
1724 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
1725 ));
1726 }
1727
1728 #[test]
1729 fn validate_password_specs_rejects_invalid_password_mode() {
1730 let spec = PostgresPolicySpec {
1731 connection: ConnectionSpec {
1732 secret_ref: SecretReference {
1733 name: "pg-conn".to_string(),
1734 },
1735 secret_key: "DATABASE_URL".to_string(),
1736 },
1737 interval: "5m".to_string(),
1738 suspend: false,
1739 mode: PolicyMode::Apply,
1740 reconciliation_mode: CrdReconciliationMode::default(),
1741 default_owner: None,
1742 profiles: std::collections::HashMap::new(),
1743 schemas: vec![],
1744 roles: vec![RoleSpec {
1745 name: "app-user".to_string(),
1746 login: Some(true),
1747 superuser: None,
1748 createdb: None,
1749 createrole: None,
1750 inherit: None,
1751 replication: None,
1752 bypassrls: None,
1753 connection_limit: None,
1754 comment: None,
1755 password: Some(PasswordSpec {
1756 secret_ref: Some(SecretReference {
1757 name: "role-passwords".to_string(),
1758 }),
1759 secret_key: None,
1760 generate: Some(GeneratePasswordSpec {
1761 length: Some(32),
1762 secret_name: None,
1763 secret_key: None,
1764 }),
1765 }),
1766 password_valid_until: None,
1767 }],
1768 grants: vec![],
1769 default_privileges: vec![],
1770 memberships: vec![],
1771 retirements: vec![],
1772 };
1773
1774 assert!(matches!(
1775 spec.validate_password_specs("test-policy"),
1776 Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
1777 ));
1778 }
1779
1780 #[test]
1781 fn validate_password_specs_rejects_invalid_generated_length() {
1782 let spec = PostgresPolicySpec {
1783 connection: ConnectionSpec {
1784 secret_ref: SecretReference {
1785 name: "pg-conn".to_string(),
1786 },
1787 secret_key: "DATABASE_URL".to_string(),
1788 },
1789 interval: "5m".to_string(),
1790 suspend: false,
1791 mode: PolicyMode::Apply,
1792 reconciliation_mode: CrdReconciliationMode::default(),
1793 default_owner: None,
1794 profiles: std::collections::HashMap::new(),
1795 schemas: vec![],
1796 roles: vec![RoleSpec {
1797 name: "app-user".to_string(),
1798 login: Some(true),
1799 superuser: None,
1800 createdb: None,
1801 createrole: None,
1802 inherit: None,
1803 replication: None,
1804 bypassrls: None,
1805 connection_limit: None,
1806 comment: None,
1807 password: Some(PasswordSpec {
1808 secret_ref: None,
1809 secret_key: None,
1810 generate: Some(GeneratePasswordSpec {
1811 length: Some(8),
1812 secret_name: None,
1813 secret_key: None,
1814 }),
1815 }),
1816 password_valid_until: None,
1817 }],
1818 grants: vec![],
1819 default_privileges: vec![],
1820 memberships: vec![],
1821 retirements: vec![],
1822 };
1823
1824 assert!(matches!(
1825 spec.validate_password_specs("test-policy"),
1826 Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
1827 ));
1828 }
1829
1830 #[test]
1831 fn validate_password_specs_rejects_invalid_generated_secret_key() {
1832 let spec = PostgresPolicySpec {
1833 connection: ConnectionSpec {
1834 secret_ref: SecretReference {
1835 name: "pg-conn".to_string(),
1836 },
1837 secret_key: "DATABASE_URL".to_string(),
1838 },
1839 interval: "5m".to_string(),
1840 suspend: false,
1841 mode: PolicyMode::Apply,
1842 reconciliation_mode: CrdReconciliationMode::default(),
1843 default_owner: None,
1844 profiles: std::collections::HashMap::new(),
1845 schemas: vec![],
1846 roles: vec![RoleSpec {
1847 name: "app-user".to_string(),
1848 login: Some(true),
1849 superuser: None,
1850 createdb: None,
1851 createrole: None,
1852 inherit: None,
1853 replication: None,
1854 bypassrls: None,
1855 connection_limit: None,
1856 comment: None,
1857 password: Some(PasswordSpec {
1858 secret_ref: None,
1859 secret_key: None,
1860 generate: Some(GeneratePasswordSpec {
1861 length: Some(32),
1862 secret_name: None,
1863 secret_key: Some("bad/key".to_string()),
1864 }),
1865 }),
1866 password_valid_until: None,
1867 }],
1868 grants: vec![],
1869 default_privileges: vec![],
1870 memberships: vec![],
1871 retirements: vec![],
1872 };
1873
1874 assert!(matches!(
1875 spec.validate_password_specs("test-policy"),
1876 Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
1877 if role == "app-user" && field == "generate.secretKey"
1878 ));
1879 }
1880
1881 #[test]
1882 fn validate_password_specs_rejects_invalid_generated_secret_name() {
1883 let spec = PostgresPolicySpec {
1884 connection: ConnectionSpec {
1885 secret_ref: SecretReference {
1886 name: "pg-conn".to_string(),
1887 },
1888 secret_key: "DATABASE_URL".to_string(),
1889 },
1890 interval: "5m".to_string(),
1891 suspend: false,
1892 mode: PolicyMode::Apply,
1893 reconciliation_mode: CrdReconciliationMode::default(),
1894 default_owner: None,
1895 profiles: std::collections::HashMap::new(),
1896 schemas: vec![],
1897 roles: vec![RoleSpec {
1898 name: "app-user".to_string(),
1899 login: Some(true),
1900 superuser: None,
1901 createdb: None,
1902 createrole: None,
1903 inherit: None,
1904 replication: None,
1905 bypassrls: None,
1906 connection_limit: None,
1907 comment: None,
1908 password: Some(PasswordSpec {
1909 secret_ref: None,
1910 secret_key: None,
1911 generate: Some(GeneratePasswordSpec {
1912 length: Some(32),
1913 secret_name: Some("Bad_Name".to_string()),
1914 secret_key: None,
1915 }),
1916 }),
1917 password_valid_until: None,
1918 }],
1919 grants: vec![],
1920 default_privileges: vec![],
1921 memberships: vec![],
1922 retirements: vec![],
1923 };
1924
1925 assert!(matches!(
1926 spec.validate_password_specs("test-policy"),
1927 Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
1928 ));
1929 }
1930
1931 #[test]
1932 fn validate_password_specs_rejects_reserved_generated_secret_key() {
1933 let spec = PostgresPolicySpec {
1934 connection: ConnectionSpec {
1935 secret_ref: SecretReference {
1936 name: "pg-conn".to_string(),
1937 },
1938 secret_key: "DATABASE_URL".to_string(),
1939 },
1940 interval: "5m".to_string(),
1941 suspend: false,
1942 mode: PolicyMode::Apply,
1943 reconciliation_mode: CrdReconciliationMode::default(),
1944 default_owner: None,
1945 profiles: std::collections::HashMap::new(),
1946 schemas: vec![],
1947 roles: vec![RoleSpec {
1948 name: "app-user".to_string(),
1949 login: Some(true),
1950 superuser: None,
1951 createdb: None,
1952 createrole: None,
1953 inherit: None,
1954 replication: None,
1955 bypassrls: None,
1956 connection_limit: None,
1957 comment: None,
1958 password: Some(PasswordSpec {
1959 secret_ref: None,
1960 secret_key: None,
1961 generate: Some(GeneratePasswordSpec {
1962 length: Some(32),
1963 secret_name: None,
1964 secret_key: Some("verifier".to_string()),
1965 }),
1966 }),
1967 password_valid_until: None,
1968 }],
1969 grants: vec![],
1970 default_privileges: vec![],
1971 memberships: vec![],
1972 retirements: vec![],
1973 };
1974
1975 assert!(matches!(
1976 spec.validate_password_specs("test-policy"),
1977 Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
1978 if role == "app-user" && key == "verifier"
1979 ));
1980 }
1981}