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 category = "pgroles",
33 printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
34 printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.mode"}"#,
35 printcolumn = r#"{"name":"Recon","type":"string","jsonPath":".spec.reconciliation_mode","priority":1}"#,
36 printcolumn = r#"{"name":"Drift","type":"string","jsonPath":".status.conditions[?(@.type==\"Drifted\")].status"}"#,
37 printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.change_summary.total"}"#,
38 printcolumn = r#"{"name":"Last Reconcile","type":"date","jsonPath":".status.last_successful_reconcile_time"}"#,
39 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
40)]
41pub struct PostgresPolicySpec {
42 pub connection: ConnectionSpec,
44
45 #[serde(default = "default_interval")]
47 pub interval: String,
48
49 #[serde(default)]
51 pub suspend: bool,
52
53 #[serde(default)]
55 pub mode: PolicyMode,
56
57 #[serde(default)]
64 pub reconciliation_mode: CrdReconciliationMode,
65
66 #[serde(default)]
68 pub default_owner: Option<String>,
69
70 #[serde(default)]
72 pub profiles: std::collections::HashMap<String, ProfileSpec>,
73
74 #[serde(default)]
76 pub schemas: Vec<SchemaBinding>,
77
78 #[serde(default)]
80 pub roles: Vec<RoleSpec>,
81
82 #[serde(default)]
84 pub grants: Vec<Grant>,
85
86 #[serde(default)]
88 pub default_privileges: Vec<DefaultPrivilege>,
89
90 #[serde(default)]
92 pub memberships: Vec<Membership>,
93
94 #[serde(default)]
96 pub retirements: Vec<RoleRetirement>,
97
98 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub approval: Option<ApprovalMode>,
105}
106
107fn default_interval() -> String {
108 "5m".to_string()
109}
110
111#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
113#[serde(rename_all = "lowercase")]
114pub enum PolicyMode {
115 #[default]
116 Apply,
117 Plan,
118}
119
120#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
122#[serde(rename_all = "lowercase")]
123pub enum CrdReconciliationMode {
124 #[default]
126 Authoritative,
127 Additive,
129 Adopt,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
135pub enum ApprovalMode {
136 #[serde(rename = "manual")]
138 Manual,
139 #[serde(rename = "auto")]
141 Auto,
142}
143
144impl PostgresPolicySpec {
145 pub fn effective_approval(&self) -> ApprovalMode {
148 match &self.approval {
149 Some(mode) => mode.clone(),
150 None => match self.mode {
151 PolicyMode::Apply => ApprovalMode::Auto,
152 PolicyMode::Plan => ApprovalMode::Manual,
153 },
154 }
155 }
156}
157
158pub const PLAN_APPROVED_ANNOTATION: &str = "pgroles.io/approved";
164
165pub const PLAN_REJECTED_ANNOTATION: &str = "pgroles.io/rejected";
167
168pub const LABEL_POLICY: &str = "pgroles.io/policy";
170
171pub const LABEL_DATABASE_IDENTITY: &str = "pgroles.io/database-identity";
173
174impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
175 fn from(crd: CrdReconciliationMode) -> Self {
176 match crd {
177 CrdReconciliationMode::Authoritative => {
178 pgroles_core::diff::ReconciliationMode::Authoritative
179 }
180 CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
181 CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
188#[serde(rename_all = "camelCase")]
189pub struct ConnectionSpec {
190 pub secret_ref: SecretReference,
193
194 #[serde(default = "default_secret_key")]
196 pub secret_key: String,
197}
198
199fn default_secret_key() -> String {
200 "DATABASE_URL".to_string()
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
205pub struct SecretReference {
206 pub name: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
214pub struct ProfileSpec {
215 #[serde(default)]
216 pub login: Option<bool>,
217
218 #[serde(default)]
219 pub grants: Vec<ProfileGrantSpec>,
220
221 #[serde(default)]
222 pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
227pub struct ProfileGrantSpec {
228 pub privileges: Vec<Privilege>,
229 #[serde(alias = "on")]
230 pub object: ProfileObjectTargetSpec,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
235pub struct ProfileObjectTargetSpec {
236 #[serde(rename = "type")]
237 pub object_type: ObjectType,
238 #[serde(default)]
239 pub name: Option<String>,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
244pub struct DefaultPrivilegeGrantSpec {
245 #[serde(default)]
246 pub role: Option<String>,
247 pub privileges: Vec<Privilege>,
248 pub on_type: ObjectType,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253pub struct RoleSpec {
254 pub name: String,
255 #[serde(default)]
256 pub login: Option<bool>,
257 #[serde(default)]
258 pub superuser: Option<bool>,
259 #[serde(default)]
260 pub createdb: Option<bool>,
261 #[serde(default)]
262 pub createrole: Option<bool>,
263 #[serde(default)]
264 pub inherit: Option<bool>,
265 #[serde(default)]
266 pub replication: Option<bool>,
267 #[serde(default)]
268 pub bypassrls: Option<bool>,
269 #[serde(default)]
270 pub connection_limit: Option<i32>,
271 #[serde(default)]
272 pub comment: Option<String>,
273 #[serde(default)]
276 pub password: Option<PasswordSpec>,
277 #[serde(default)]
279 pub password_valid_until: Option<String>,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
299#[serde(rename_all = "camelCase")]
300pub struct PasswordSpec {
301 #[serde(default)]
304 pub secret_ref: Option<SecretReference>,
305 #[serde(default)]
308 pub secret_key: Option<String>,
309 #[serde(default)]
312 pub generate: Option<GeneratePasswordSpec>,
313}
314
315impl PasswordSpec {
316 pub fn is_secret_ref(&self) -> bool {
318 self.secret_ref.is_some()
319 }
320
321 pub fn is_generate(&self) -> bool {
323 self.generate.is_some()
324 }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
329#[serde(rename_all = "camelCase")]
330pub struct GeneratePasswordSpec {
331 #[serde(default)]
333 pub length: Option<u32>,
334 #[serde(default)]
336 pub secret_name: Option<String>,
337 #[serde(default)]
339 pub secret_key: Option<String>,
340}
341
342#[derive(Debug, Clone, thiserror::Error)]
343pub enum PasswordValidationError {
344 #[error("role \"{role}\" has a password but login is not enabled")]
345 PasswordWithoutLogin { role: String },
346
347 #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
348 InvalidPasswordMode { role: String },
349
350 #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
351 InvalidGeneratedLength { role: String, min: u32, max: u32 },
352
353 #[error(
354 "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
355 )]
356 InvalidGeneratedSecretName { role: String, name: String },
357
358 #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
359 InvalidSecretKey {
360 role: String,
361 field: &'static str,
362 key: String,
363 },
364
365 #[error(
366 "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
367 )]
368 ReservedGeneratedSecretKey { role: String, key: String },
369}
370
371fn is_valid_secret_name(name: &str) -> bool {
375 if name.is_empty() || name.len() > crate::password::MAX_SECRET_NAME_LENGTH {
376 return false;
377 }
378 let bytes = name.as_bytes();
379 if !bytes[0].is_ascii_lowercase() {
381 return false;
382 }
383 if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
384 return false;
385 }
386 bytes
387 .iter()
388 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-' || *b == b'.')
389}
390
391fn is_valid_secret_key(key: &str) -> bool {
392 !key.is_empty()
393 && key
394 .bytes()
395 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
396}
397
398#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
404pub struct PostgresPolicyStatus {
405 #[serde(default)]
407 pub conditions: Vec<PolicyCondition>,
408
409 #[serde(default)]
411 pub observed_generation: Option<i64>,
412
413 #[serde(default)]
415 pub last_attempted_generation: Option<i64>,
416
417 #[serde(default)]
419 pub last_successful_reconcile_time: Option<String>,
420
421 #[serde(default)]
423 pub last_reconcile_time: Option<String>,
424
425 #[serde(default)]
427 pub change_summary: Option<ChangeSummary>,
428
429 #[serde(default)]
431 pub last_reconcile_mode: Option<PolicyMode>,
432
433 #[serde(default)]
435 pub planned_sql: Option<String>,
436
437 #[serde(default)]
439 pub planned_sql_truncated: bool,
440
441 #[serde(default)]
443 pub managed_database_identity: Option<String>,
444
445 #[serde(default)]
447 pub owned_roles: Vec<String>,
448
449 #[serde(default)]
451 pub owned_schemas: Vec<String>,
452
453 #[serde(default)]
455 pub last_error: Option<String>,
456
457 #[serde(default)]
459 pub applied_password_source_versions: BTreeMap<String, String>,
460
461 #[serde(default)]
463 pub transient_failure_count: i32,
464
465 #[serde(default)]
467 pub current_plan_ref: Option<PlanReference>,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
472pub struct PolicyCondition {
473 #[serde(rename = "type")]
475 pub condition_type: String,
476
477 pub status: String,
479
480 #[serde(default)]
482 pub reason: Option<String>,
483
484 #[serde(default)]
486 pub message: Option<String>,
487
488 #[serde(default)]
490 pub last_transition_time: Option<String>,
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
495pub struct PlanReference {
496 pub name: String,
497}
498
499#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
501pub struct ChangeSummary {
502 pub roles_created: i32,
503 pub roles_altered: i32,
504 pub roles_dropped: i32,
505 pub sessions_terminated: i32,
506 pub grants_added: i32,
507 pub grants_revoked: i32,
508 pub default_privileges_set: i32,
509 pub default_privileges_revoked: i32,
510 pub members_added: i32,
511 pub members_removed: i32,
512 pub passwords_set: i32,
513 pub total: i32,
514}
515
516#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
525#[kube(
526 group = "pgroles.io",
527 version = "v1alpha1",
528 kind = "PostgresPolicyPlan",
529 namespaced,
530 status = "PostgresPolicyPlanStatus",
531 shortname = "pgplan",
532 category = "pgroles",
533 printcolumn = r#"{"name":"Policy","type":"string","jsonPath":".spec.policyRef.name"}"#,
534 printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.reconciliationMode"}"#,
535 printcolumn = r#"{"name":"Approved","type":"string","jsonPath":".status.conditions[?(@.type==\"Approved\")].status"}"#,
536 printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.changeSummary.total"}"#,
537 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
538 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
539)]
540#[serde(rename_all = "camelCase")]
541pub struct PostgresPolicyPlanSpec {
542 pub policy_ref: PolicyPlanRef,
544 pub policy_generation: i64,
546 pub reconciliation_mode: CrdReconciliationMode,
548 #[serde(default)]
550 pub owned_roles: Vec<String>,
551 #[serde(default)]
553 pub owned_schemas: Vec<String>,
554 pub managed_database_identity: String,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
560pub struct PolicyPlanRef {
561 pub name: String,
562}
563
564#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
566#[serde(rename_all = "camelCase")]
567pub struct PostgresPolicyPlanStatus {
568 #[serde(default)]
570 pub phase: PlanPhase,
571 #[serde(default)]
573 pub conditions: Vec<PolicyCondition>,
574 #[serde(default)]
576 pub change_summary: Option<ChangeSummary>,
577 #[serde(default)]
579 pub sql_ref: Option<SqlRef>,
580 #[serde(default)]
582 pub sql_inline: Option<String>,
583 #[serde(default)]
585 pub computed_at: Option<String>,
586 #[serde(default)]
588 pub applied_at: Option<String>,
589 #[serde(default)]
591 pub last_error: Option<String>,
592 #[serde(default)]
596 pub sql_hash: Option<String>,
597 #[serde(default)]
599 pub applying_since: Option<String>,
600 #[serde(default)]
602 pub failed_at: Option<String>,
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
607pub struct SqlRef {
608 pub name: String,
609 pub key: String,
610}
611
612#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
614pub enum PlanPhase {
615 #[default]
616 Pending,
617 Approved,
618 Applying,
619 Applied,
620 Failed,
621 Superseded,
622 Rejected,
623}
624
625impl std::fmt::Display for PlanPhase {
626 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627 match self {
628 PlanPhase::Pending => write!(f, "Pending"),
629 PlanPhase::Approved => write!(f, "Approved"),
630 PlanPhase::Applying => write!(f, "Applying"),
631 PlanPhase::Applied => write!(f, "Applied"),
632 PlanPhase::Failed => write!(f, "Failed"),
633 PlanPhase::Superseded => write!(f, "Superseded"),
634 PlanPhase::Rejected => write!(f, "Rejected"),
635 }
636 }
637}
638
639#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
645pub struct DatabaseIdentity(String);
646
647impl DatabaseIdentity {
648 pub fn new(namespace: &str, secret_name: &str, secret_key: &str) -> Self {
649 Self(format!("{namespace}/{secret_name}/{secret_key}"))
650 }
651
652 pub fn as_str(&self) -> &str {
653 &self.0
654 }
655}
656
657#[derive(Debug, Clone, Default, PartialEq, Eq)]
659pub struct OwnershipClaims {
660 pub roles: BTreeSet<String>,
661 pub schemas: BTreeSet<String>,
662}
663
664impl OwnershipClaims {
665 pub fn overlaps(&self, other: &Self) -> bool {
666 !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
667 }
668
669 pub fn overlap_summary(&self, other: &Self) -> String {
670 let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
671 let overlapping_schemas: Vec<_> =
672 self.schemas.intersection(&other.schemas).cloned().collect();
673
674 let mut parts = Vec::new();
675 if !overlapping_roles.is_empty() {
676 parts.push(format!("roles: {}", overlapping_roles.join(", ")));
677 }
678 if !overlapping_schemas.is_empty() {
679 parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
680 }
681
682 parts.join("; ")
683 }
684}
685
686impl PostgresPolicySpec {
691 pub fn validate_password_specs(
692 &self,
693 policy_name: &str,
694 ) -> Result<(), PasswordValidationError> {
695 for role in &self.roles {
696 let Some(password) = &role.password else {
697 continue;
698 };
699
700 if role.login != Some(true) {
701 return Err(PasswordValidationError::PasswordWithoutLogin {
702 role: role.name.clone(),
703 });
704 }
705
706 match (&password.secret_ref, &password.generate) {
707 (Some(_), None) => {
708 let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
709 if !is_valid_secret_key(secret_key) {
710 return Err(PasswordValidationError::InvalidSecretKey {
711 role: role.name.clone(),
712 field: "secretKey",
713 key: secret_key.to_string(),
714 });
715 }
716 }
717 (None, Some(generate)) => {
718 if let Some(length) = generate.length
719 && !(crate::password::MIN_PASSWORD_LENGTH
720 ..=crate::password::MAX_PASSWORD_LENGTH)
721 .contains(&length)
722 {
723 return Err(PasswordValidationError::InvalidGeneratedLength {
724 role: role.name.clone(),
725 min: crate::password::MIN_PASSWORD_LENGTH,
726 max: crate::password::MAX_PASSWORD_LENGTH,
727 });
728 }
729
730 let secret_name =
731 crate::password::generated_secret_name(policy_name, &role.name, generate);
732 if !is_valid_secret_name(&secret_name) {
733 return Err(PasswordValidationError::InvalidGeneratedSecretName {
734 role: role.name.clone(),
735 name: secret_name,
736 });
737 }
738
739 let secret_key = crate::password::generated_secret_key(generate);
740 if !is_valid_secret_key(&secret_key) {
741 return Err(PasswordValidationError::InvalidSecretKey {
742 role: role.name.clone(),
743 field: "generate.secretKey",
744 key: secret_key,
745 });
746 }
747 if secret_key == crate::password::GENERATED_VERIFIER_KEY {
748 return Err(PasswordValidationError::ReservedGeneratedSecretKey {
749 role: role.name.clone(),
750 key: secret_key,
751 });
752 }
753 }
754 _ => {
755 return Err(PasswordValidationError::InvalidPasswordMode {
756 role: role.name.clone(),
757 });
758 }
759 }
760 }
761
762 Ok(())
763 }
764
765 pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
771 let mut names = BTreeSet::new();
772 names.insert(self.connection.secret_ref.name.clone());
773 for role in &self.roles {
774 if let Some(pw) = &role.password {
775 if let Some(secret_ref) = &pw.secret_ref {
776 names.insert(secret_ref.name.clone());
777 }
778 if let Some(gen_spec) = &pw.generate {
779 let secret_name =
780 crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
781 names.insert(secret_name);
782 }
783 }
784 }
785 names
786 }
787}
788
789impl PostgresPolicySpec {
794 pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
796 use pgroles_core::manifest::{
797 DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
798 ProfileObjectTarget, RoleDefinition,
799 };
800
801 let profiles = self
802 .profiles
803 .iter()
804 .map(|(name, spec)| {
805 let profile = Profile {
806 login: spec.login,
807 grants: spec
808 .grants
809 .iter()
810 .map(|g| ProfileGrant {
811 privileges: g.privileges.clone(),
812 object: ProfileObjectTarget {
813 object_type: g.object.object_type,
814 name: g.object.name.clone(),
815 },
816 })
817 .collect(),
818 default_privileges: spec
819 .default_privileges
820 .iter()
821 .map(|dp| DefaultPrivilegeGrant {
822 role: dp.role.clone(),
823 privileges: dp.privileges.clone(),
824 on_type: dp.on_type,
825 })
826 .collect(),
827 };
828 (name.clone(), profile)
829 })
830 .collect();
831
832 let roles = self
833 .roles
834 .iter()
835 .map(|r| RoleDefinition {
836 name: r.name.clone(),
837 login: r.login,
838 superuser: r.superuser,
839 createdb: r.createdb,
840 createrole: r.createrole,
841 inherit: r.inherit,
842 replication: r.replication,
843 bypassrls: r.bypassrls,
844 connection_limit: r.connection_limit,
845 comment: r.comment.clone(),
846 password: None, password_valid_until: r.password_valid_until.clone(),
848 })
849 .collect();
850
851 let memberships = self
855 .memberships
856 .iter()
857 .map(|m| pgroles_core::manifest::Membership {
858 role: m.role.clone(),
859 members: m
860 .members
861 .iter()
862 .map(|ms| MemberSpec {
863 name: ms.name.clone(),
864 inherit: ms.inherit,
865 admin: ms.admin,
866 })
867 .collect(),
868 })
869 .collect();
870
871 PolicyManifest {
872 default_owner: self.default_owner.clone(),
873 auth_providers: Vec::new(),
874 profiles,
875 schemas: self.schemas.clone(),
876 roles,
877 grants: self.grants.clone(),
878 default_privileges: self.default_privileges.clone(),
879 memberships,
880 retirements: self.retirements.clone(),
881 }
882 }
883
884 pub fn ownership_claims(
889 &self,
890 ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
891 let manifest = self.to_policy_manifest();
892 let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
893
894 let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
895 let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
896
897 roles.extend(manifest.retirements.into_iter().map(|r| r.role));
898 roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
899 roles.extend(
900 manifest
901 .default_privileges
902 .iter()
903 .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
904 );
905 roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
906 roles.extend(
907 manifest
908 .memberships
909 .iter()
910 .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
911 );
912
913 schemas.extend(
914 manifest
915 .grants
916 .iter()
917 .filter_map(|g| match g.object.object_type {
918 ObjectType::Database => None,
919 ObjectType::Schema => g.object.name.clone(),
920 _ => g.object.schema.clone(),
921 }),
922 );
923 schemas.extend(
924 manifest
925 .default_privileges
926 .iter()
927 .map(|dp| dp.schema.clone()),
928 );
929
930 Ok(OwnershipClaims { roles, schemas })
931 }
932}
933
934impl PostgresPolicyStatus {
939 pub fn set_condition(&mut self, new: PolicyCondition) {
944 if let Some(existing) = self
945 .conditions
946 .iter()
947 .find(|c| c.condition_type == new.condition_type)
948 && existing.status == new.status
949 {
950 let mut updated = new;
952 updated.last_transition_time = existing.last_transition_time.clone();
953 self.conditions
954 .retain(|c| c.condition_type != updated.condition_type);
955 self.conditions.push(updated);
956 return;
957 }
958 self.conditions
960 .retain(|c| c.condition_type != new.condition_type);
961 self.conditions.push(new);
962 }
963}
964
965pub fn now_rfc3339() -> String {
967 use std::time::SystemTime;
970 let now = SystemTime::now()
971 .duration_since(SystemTime::UNIX_EPOCH)
972 .unwrap_or_default();
973 let secs = now.as_secs();
975 let days = secs / 86400;
976 let remaining = secs % 86400;
977 let hours = remaining / 3600;
978 let minutes = (remaining % 3600) / 60;
979 let seconds = remaining % 60;
980
981 let (year, month, day) = days_to_date(days);
983 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
984}
985
986pub fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
988 let z = days_since_epoch + 719468;
990 let era = z / 146097;
991 let doe = z - era * 146097;
992 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
993 let y = yoe + era * 400;
994 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
995 let mp = (5 * doy + 2) / 153;
996 let d = doy - (153 * mp + 2) / 5 + 1;
997 let m = if mp < 10 { mp + 3 } else { mp - 9 };
998 let y = if m <= 2 { y + 1 } else { y };
999 (y, m, d)
1000}
1001
1002pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1004 PolicyCondition {
1005 condition_type: "Ready".to_string(),
1006 status: if status { "True" } else { "False" }.to_string(),
1007 reason: Some(reason.to_string()),
1008 message: Some(message.to_string()),
1009 last_transition_time: Some(now_rfc3339()),
1010 }
1011}
1012
1013pub fn reconciling_condition(message: &str) -> PolicyCondition {
1015 PolicyCondition {
1016 condition_type: "Reconciling".to_string(),
1017 status: "True".to_string(),
1018 reason: Some("Reconciling".to_string()),
1019 message: Some(message.to_string()),
1020 last_transition_time: Some(now_rfc3339()),
1021 }
1022}
1023
1024pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
1026 PolicyCondition {
1027 condition_type: "Degraded".to_string(),
1028 status: "True".to_string(),
1029 reason: Some(reason.to_string()),
1030 message: Some(message.to_string()),
1031 last_transition_time: Some(now_rfc3339()),
1032 }
1033}
1034
1035pub fn paused_condition(message: &str) -> PolicyCondition {
1037 PolicyCondition {
1038 condition_type: "Paused".to_string(),
1039 status: "True".to_string(),
1040 reason: Some("Suspended".to_string()),
1041 message: Some(message.to_string()),
1042 last_transition_time: Some(now_rfc3339()),
1043 }
1044}
1045
1046pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
1048 PolicyCondition {
1049 condition_type: "Conflict".to_string(),
1050 status: "True".to_string(),
1051 reason: Some(reason.to_string()),
1052 message: Some(message.to_string()),
1053 last_transition_time: Some(now_rfc3339()),
1054 }
1055}
1056
1057pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1059 PolicyCondition {
1060 condition_type: "Drifted".to_string(),
1061 status: if status { "True" } else { "False" }.to_string(),
1062 reason: Some(reason.to_string()),
1063 message: Some(message.to_string()),
1064 last_transition_time: Some(now_rfc3339()),
1065 }
1066}
1067
1068#[cfg(test)]
1073mod tests {
1074 use super::*;
1075 use kube::CustomResourceExt;
1076
1077 #[test]
1078 fn crd_generates_valid_schema() {
1079 let crd = PostgresPolicy::crd();
1080 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
1081 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
1082 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
1083 assert!(
1084 yaml.contains("PostgresPolicy"),
1085 "kind should be PostgresPolicy"
1086 );
1087 assert!(
1088 yaml.contains("\"mode\"") || yaml.contains(" mode:"),
1089 "schema should declare spec.mode"
1090 );
1091 assert!(
1092 yaml.contains("\"object\"") || yaml.contains(" object:"),
1093 "schema should declare grant object targets using object"
1094 );
1095 }
1096
1097 #[test]
1098 fn spec_to_policy_manifest_roundtrip() {
1099 let spec = PostgresPolicySpec {
1100 connection: ConnectionSpec {
1101 secret_ref: SecretReference {
1102 name: "pg-secret".to_string(),
1103 },
1104 secret_key: "DATABASE_URL".to_string(),
1105 },
1106 interval: "5m".to_string(),
1107 suspend: false,
1108 mode: PolicyMode::Apply,
1109 reconciliation_mode: CrdReconciliationMode::default(),
1110 default_owner: Some("app_owner".to_string()),
1111 profiles: std::collections::HashMap::new(),
1112 schemas: vec![],
1113 roles: vec![RoleSpec {
1114 name: "analytics".to_string(),
1115 login: Some(true),
1116 superuser: None,
1117 createdb: None,
1118 createrole: None,
1119 inherit: None,
1120 replication: None,
1121 bypassrls: None,
1122 connection_limit: None,
1123 comment: Some("test role".to_string()),
1124 password: None,
1125 password_valid_until: None,
1126 }],
1127 grants: vec![],
1128 default_privileges: vec![],
1129 memberships: vec![],
1130 retirements: vec![RoleRetirement {
1131 role: "legacy-app".to_string(),
1132 reassign_owned_to: Some("app_owner".to_string()),
1133 drop_owned: true,
1134 terminate_sessions: true,
1135 }],
1136 approval: None,
1137 };
1138
1139 let manifest = spec.to_policy_manifest();
1140 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
1141 assert_eq!(manifest.roles.len(), 1);
1142 assert_eq!(manifest.roles[0].name, "analytics");
1143 assert_eq!(manifest.roles[0].login, Some(true));
1144 assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
1145 assert_eq!(manifest.retirements.len(), 1);
1146 assert_eq!(manifest.retirements[0].role, "legacy-app");
1147 assert_eq!(
1148 manifest.retirements[0].reassign_owned_to.as_deref(),
1149 Some("app_owner")
1150 );
1151 assert!(manifest.retirements[0].drop_owned);
1152 assert!(manifest.retirements[0].terminate_sessions);
1153 }
1154
1155 #[test]
1156 fn status_set_condition_replaces_existing() {
1157 let mut status = PostgresPolicyStatus::default();
1158
1159 status.set_condition(ready_condition(false, "Pending", "Initial"));
1160 assert_eq!(status.conditions.len(), 1);
1161 assert_eq!(status.conditions[0].status, "False");
1162
1163 status.set_condition(ready_condition(true, "Reconciled", "All good"));
1164 assert_eq!(status.conditions.len(), 1);
1165 assert_eq!(status.conditions[0].status, "True");
1166 assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
1167 }
1168
1169 #[test]
1170 fn status_set_condition_adds_new_type() {
1171 let mut status = PostgresPolicyStatus::default();
1172
1173 status.set_condition(ready_condition(true, "OK", "ready"));
1174 status.set_condition(degraded_condition("Error", "something broke"));
1175
1176 assert_eq!(status.conditions.len(), 2);
1177 }
1178
1179 #[test]
1180 fn paused_condition_has_expected_shape() {
1181 let paused = paused_condition("paused by spec");
1182 assert_eq!(paused.condition_type, "Paused");
1183 assert_eq!(paused.status, "True");
1184 assert_eq!(paused.reason.as_deref(), Some("Suspended"));
1185 }
1186
1187 #[test]
1188 fn ownership_claims_include_expanded_roles_and_schemas() {
1189 let mut profiles = std::collections::HashMap::new();
1190 profiles.insert(
1191 "editor".to_string(),
1192 ProfileSpec {
1193 login: Some(false),
1194 grants: vec![],
1195 default_privileges: vec![],
1196 },
1197 );
1198
1199 let spec = PostgresPolicySpec {
1200 connection: ConnectionSpec {
1201 secret_ref: SecretReference {
1202 name: "pg-secret".to_string(),
1203 },
1204 secret_key: "DATABASE_URL".to_string(),
1205 },
1206 interval: "5m".to_string(),
1207 suspend: false,
1208 mode: PolicyMode::Apply,
1209 reconciliation_mode: CrdReconciliationMode::default(),
1210 default_owner: None,
1211 profiles,
1212 schemas: vec![SchemaBinding {
1213 name: "inventory".to_string(),
1214 profiles: vec!["editor".to_string()],
1215 role_pattern: "{schema}-{profile}".to_string(),
1216 owner: None,
1217 }],
1218 roles: vec![RoleSpec {
1219 name: "app-service".to_string(),
1220 login: Some(true),
1221 superuser: None,
1222 createdb: None,
1223 createrole: None,
1224 inherit: None,
1225 replication: None,
1226 bypassrls: None,
1227 connection_limit: None,
1228 comment: None,
1229 password: None,
1230 password_valid_until: None,
1231 }],
1232 grants: vec![],
1233 default_privileges: vec![],
1234 memberships: vec![],
1235 retirements: vec![RoleRetirement {
1236 role: "legacy-app".to_string(),
1237 reassign_owned_to: None,
1238 drop_owned: false,
1239 terminate_sessions: false,
1240 }],
1241 approval: None,
1242 };
1243
1244 let claims = spec.ownership_claims().unwrap();
1245 assert!(claims.roles.contains("inventory-editor"));
1246 assert!(claims.roles.contains("app-service"));
1247 assert!(claims.roles.contains("legacy-app"));
1248 assert!(claims.schemas.contains("inventory"));
1249 }
1250
1251 #[test]
1252 fn ownership_overlap_summary_reports_roles_and_schemas() {
1253 let mut left = OwnershipClaims::default();
1254 left.roles.insert("analytics".to_string());
1255 left.schemas.insert("reporting".to_string());
1256
1257 let mut right = OwnershipClaims::default();
1258 right.roles.insert("analytics".to_string());
1259 right.schemas.insert("reporting".to_string());
1260 right.schemas.insert("other".to_string());
1261
1262 assert!(left.overlaps(&right));
1263 let summary = left.overlap_summary(&right);
1264 assert!(summary.contains("roles: analytics"));
1265 assert!(summary.contains("schemas: reporting"));
1266 }
1267
1268 #[test]
1269 fn database_identity_uses_namespace_secret_and_key() {
1270 let identity = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1271 assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
1272 }
1273
1274 #[test]
1275 fn now_rfc3339_produces_valid_format() {
1276 let ts = now_rfc3339();
1277 assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
1279 assert!(ts.ends_with('Z'), "should end with Z: {ts}");
1280 assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
1281 assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
1282 }
1283
1284 #[test]
1285 fn ready_condition_true_has_expected_shape() {
1286 let cond = ready_condition(true, "Reconciled", "All changes applied");
1287 assert_eq!(cond.condition_type, "Ready");
1288 assert_eq!(cond.status, "True");
1289 assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
1290 assert_eq!(cond.message.as_deref(), Some("All changes applied"));
1291 assert!(cond.last_transition_time.is_some());
1292 }
1293
1294 #[test]
1295 fn ready_condition_false_has_expected_shape() {
1296 let cond = ready_condition(false, "InvalidSpec", "bad manifest");
1297 assert_eq!(cond.condition_type, "Ready");
1298 assert_eq!(cond.status, "False");
1299 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
1300 assert_eq!(cond.message.as_deref(), Some("bad manifest"));
1301 }
1302
1303 #[test]
1304 fn degraded_condition_has_expected_shape() {
1305 let cond = degraded_condition("InvalidSpec", "expansion failed");
1306 assert_eq!(cond.condition_type, "Degraded");
1307 assert_eq!(cond.status, "True");
1308 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
1309 assert_eq!(cond.message.as_deref(), Some("expansion failed"));
1310 assert!(cond.last_transition_time.is_some());
1311 }
1312
1313 #[test]
1314 fn reconciling_condition_has_expected_shape() {
1315 let cond = reconciling_condition("Reconciliation in progress");
1316 assert_eq!(cond.condition_type, "Reconciling");
1317 assert_eq!(cond.status, "True");
1318 assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
1319 assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
1320 assert!(cond.last_transition_time.is_some());
1321 }
1322
1323 #[test]
1324 fn conflict_condition_has_expected_shape() {
1325 let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
1326 assert_eq!(cond.condition_type, "Conflict");
1327 assert_eq!(cond.status, "True");
1328 assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
1329 assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
1330 assert!(cond.last_transition_time.is_some());
1331 }
1332
1333 #[test]
1334 fn ownership_claims_no_overlap() {
1335 let mut left = OwnershipClaims::default();
1336 left.roles.insert("analytics".to_string());
1337 left.schemas.insert("reporting".to_string());
1338
1339 let mut right = OwnershipClaims::default();
1340 right.roles.insert("billing".to_string());
1341 right.schemas.insert("payments".to_string());
1342
1343 assert!(!left.overlaps(&right));
1344 let summary = left.overlap_summary(&right);
1345 assert!(summary.is_empty());
1346 }
1347
1348 #[test]
1349 fn ownership_claims_partial_role_overlap() {
1350 let mut left = OwnershipClaims::default();
1351 left.roles.insert("analytics".to_string());
1352 left.roles.insert("reporting-viewer".to_string());
1353
1354 let mut right = OwnershipClaims::default();
1355 right.roles.insert("analytics".to_string());
1356 right.roles.insert("other-role".to_string());
1357
1358 assert!(left.overlaps(&right));
1359 let summary = left.overlap_summary(&right);
1360 assert!(summary.contains("roles: analytics"));
1361 assert!(!summary.contains("schemas"));
1362 }
1363
1364 #[test]
1365 fn ownership_claims_empty_is_disjoint() {
1366 let left = OwnershipClaims::default();
1367 let right = OwnershipClaims::default();
1368 assert!(!left.overlaps(&right));
1369 }
1370
1371 #[test]
1372 fn database_identity_equality() {
1373 let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1374 let b = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1375 let c = DatabaseIdentity::new("staging", "db-creds", "DATABASE_URL");
1376 assert_eq!(a, b);
1377 assert_ne!(a, c);
1378 }
1379
1380 #[test]
1381 fn database_identity_different_key() {
1382 let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1383 let b = DatabaseIdentity::new("prod", "db-creds", "CUSTOM_URL");
1384 assert_ne!(a, b);
1385 }
1386
1387 #[test]
1388 fn status_default_has_empty_conditions() {
1389 let status = PostgresPolicyStatus::default();
1390 assert!(status.conditions.is_empty());
1391 assert!(status.observed_generation.is_none());
1392 assert!(status.last_attempted_generation.is_none());
1393 assert!(status.last_successful_reconcile_time.is_none());
1394 assert!(status.change_summary.is_none());
1395 assert!(status.managed_database_identity.is_none());
1396 assert!(status.owned_roles.is_empty());
1397 assert!(status.owned_schemas.is_empty());
1398 assert!(status.last_error.is_none());
1399 assert!(status.applied_password_source_versions.is_empty());
1400 }
1401
1402 #[test]
1403 fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
1404 let mut status = PostgresPolicyStatus::default();
1405
1406 status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
1408 status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
1409 status
1410 .conditions
1411 .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
1412 status.change_summary = None;
1413 status.last_error = Some("bad manifest".to_string());
1414
1415 let ready = status
1417 .conditions
1418 .iter()
1419 .find(|c| c.condition_type == "Ready")
1420 .expect("should have Ready condition");
1421 assert_eq!(ready.status, "False");
1422 assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
1423
1424 let degraded = status
1426 .conditions
1427 .iter()
1428 .find(|c| c.condition_type == "Degraded")
1429 .expect("should have Degraded condition");
1430 assert_eq!(degraded.status, "True");
1431 assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
1432
1433 assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
1435 }
1436
1437 #[test]
1438 fn status_conflict_workflow() {
1439 let mut status = PostgresPolicyStatus::default();
1440
1441 let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
1443 status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
1444 status.set_condition(conflict_condition("ConflictingPolicy", msg));
1445 status.set_condition(degraded_condition("ConflictingPolicy", msg));
1446 status
1447 .conditions
1448 .retain(|c| c.condition_type != "Reconciling");
1449 status.last_error = Some(msg.to_string());
1450
1451 let conflict = status
1453 .conditions
1454 .iter()
1455 .find(|c| c.condition_type == "Conflict")
1456 .expect("should have Conflict condition");
1457 assert_eq!(conflict.status, "True");
1458 assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
1459
1460 let ready = status
1462 .conditions
1463 .iter()
1464 .find(|c| c.condition_type == "Ready")
1465 .expect("should have Ready condition");
1466 assert_eq!(ready.status, "False");
1467
1468 let degraded = status
1470 .conditions
1471 .iter()
1472 .find(|c| c.condition_type == "Degraded")
1473 .expect("should have Degraded condition");
1474 assert_eq!(degraded.status, "True");
1475 }
1476
1477 #[test]
1478 fn status_successful_reconcile_records_generation_and_time() {
1479 let mut status = PostgresPolicyStatus::default();
1480 let generation = Some(3_i64);
1481 let summary = ChangeSummary {
1482 roles_created: 2,
1483 total: 2,
1484 ..Default::default()
1485 };
1486
1487 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1489 status.conditions.retain(|c| {
1490 c.condition_type != "Reconciling"
1491 && c.condition_type != "Degraded"
1492 && c.condition_type != "Conflict"
1493 && c.condition_type != "Paused"
1494 });
1495 status.observed_generation = generation;
1496 status.last_attempted_generation = generation;
1497 status.last_successful_reconcile_time = Some(now_rfc3339());
1498 status.last_reconcile_time = Some(now_rfc3339());
1499 status.change_summary = Some(summary);
1500 status.last_error = None;
1501
1502 let ready = status
1504 .conditions
1505 .iter()
1506 .find(|c| c.condition_type == "Ready")
1507 .expect("should have Ready condition");
1508 assert_eq!(ready.status, "True");
1509 assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
1510
1511 assert_eq!(status.observed_generation, Some(3));
1513 assert_eq!(status.last_attempted_generation, Some(3));
1514
1515 assert!(status.last_successful_reconcile_time.is_some());
1517 assert!(status.last_reconcile_time.is_some());
1518
1519 let summary = status.change_summary.as_ref().unwrap();
1521 assert_eq!(summary.roles_created, 2);
1522 assert_eq!(summary.total, 2);
1523
1524 assert!(status.last_error.is_none());
1526
1527 assert!(
1529 status
1530 .conditions
1531 .iter()
1532 .all(|c| c.condition_type != "Degraded"
1533 && c.condition_type != "Conflict"
1534 && c.condition_type != "Paused"
1535 && c.condition_type != "Reconciling")
1536 );
1537 }
1538
1539 #[test]
1540 fn status_suspended_workflow() {
1541 let mut status = PostgresPolicyStatus::default();
1542 let generation = Some(2_i64);
1543
1544 status.set_condition(paused_condition("Reconciliation suspended by spec"));
1546 status.set_condition(ready_condition(
1547 false,
1548 "Suspended",
1549 "Reconciliation suspended by spec",
1550 ));
1551 status
1552 .conditions
1553 .retain(|c| c.condition_type != "Reconciling");
1554 status.last_attempted_generation = generation;
1555 status.last_error = None;
1556
1557 let paused = status
1559 .conditions
1560 .iter()
1561 .find(|c| c.condition_type == "Paused")
1562 .expect("should have Paused condition");
1563 assert_eq!(paused.status, "True");
1564
1565 let ready = status
1567 .conditions
1568 .iter()
1569 .find(|c| c.condition_type == "Ready")
1570 .expect("should have Ready condition");
1571 assert_eq!(ready.status, "False");
1572 assert_eq!(ready.reason.as_deref(), Some("Suspended"));
1573
1574 assert!(
1576 !status
1577 .conditions
1578 .iter()
1579 .any(|c| c.condition_type == "Reconciling")
1580 );
1581 }
1582
1583 #[test]
1584 fn status_transitions_from_degraded_to_ready() {
1585 let mut status = PostgresPolicyStatus::default();
1586
1587 status.set_condition(ready_condition(false, "InvalidSpec", "error"));
1589 status.set_condition(degraded_condition("InvalidSpec", "error"));
1590 status.last_error = Some("error".to_string());
1591
1592 assert_eq!(status.conditions.len(), 2);
1593
1594 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1596 status.conditions.retain(|c| {
1597 c.condition_type != "Reconciling"
1598 && c.condition_type != "Degraded"
1599 && c.condition_type != "Conflict"
1600 && c.condition_type != "Paused"
1601 });
1602 status.last_error = None;
1603
1604 let ready = status
1606 .conditions
1607 .iter()
1608 .find(|c| c.condition_type == "Ready")
1609 .expect("should have Ready condition");
1610 assert_eq!(ready.status, "True");
1611
1612 assert!(
1614 !status
1615 .conditions
1616 .iter()
1617 .any(|c| c.condition_type == "Degraded")
1618 );
1619
1620 assert_eq!(status.conditions.len(), 1);
1622
1623 assert!(status.last_error.is_none());
1625 }
1626
1627 #[test]
1628 fn change_summary_default_is_all_zero() {
1629 let summary = ChangeSummary::default();
1630 assert_eq!(summary.roles_created, 0);
1631 assert_eq!(summary.roles_altered, 0);
1632 assert_eq!(summary.roles_dropped, 0);
1633 assert_eq!(summary.sessions_terminated, 0);
1634 assert_eq!(summary.grants_added, 0);
1635 assert_eq!(summary.grants_revoked, 0);
1636 assert_eq!(summary.default_privileges_set, 0);
1637 assert_eq!(summary.default_privileges_revoked, 0);
1638 assert_eq!(summary.members_added, 0);
1639 assert_eq!(summary.members_removed, 0);
1640 assert_eq!(summary.total, 0);
1641 }
1642
1643 #[test]
1644 fn status_serializes_to_json() {
1645 let mut status = PostgresPolicyStatus::default();
1646 status.set_condition(ready_condition(true, "Reconciled", "done"));
1647 status.observed_generation = Some(5);
1648 status.managed_database_identity = Some("ns/secret/key".to_string());
1649 status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
1650 status.owned_schemas = vec!["public".to_string()];
1651 status.change_summary = Some(ChangeSummary {
1652 roles_created: 1,
1653 total: 1,
1654 ..Default::default()
1655 });
1656
1657 let json = serde_json::to_string(&status).expect("should serialize");
1658 assert!(json.contains("\"Reconciled\""));
1659 assert!(json.contains("\"observed_generation\":5"));
1660 assert!(json.contains("\"role-a\""));
1661 assert!(json.contains("\"ns/secret/key\""));
1662 }
1663
1664 #[test]
1665 fn crd_spec_deserializes_from_yaml() {
1666 let yaml = r#"
1667connection:
1668 secretRef:
1669 name: pg-credentials
1670interval: "10m"
1671default_owner: app_owner
1672profiles:
1673 editor:
1674 grants:
1675 - privileges: [USAGE]
1676 object: { type: schema }
1677 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1678 object: { type: table, name: "*" }
1679 default_privileges:
1680 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1681 on_type: table
1682schemas:
1683 - name: inventory
1684 profiles: [editor]
1685roles:
1686 - name: analytics
1687 login: true
1688grants:
1689 - role: analytics
1690 privileges: [CONNECT]
1691 object: { type: database, name: mydb }
1692memberships:
1693 - role: inventory-editor
1694 members:
1695 - name: analytics
1696retirements:
1697 - role: legacy-app
1698 reassign_owned_to: app_owner
1699 drop_owned: true
1700 terminate_sessions: true
1701"#;
1702 let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
1703 assert_eq!(spec.interval, "10m");
1704 assert_eq!(spec.default_owner, Some("app_owner".to_string()));
1705 assert_eq!(spec.profiles.len(), 1);
1706 assert!(spec.profiles.contains_key("editor"));
1707 assert_eq!(spec.schemas.len(), 1);
1708 assert_eq!(spec.roles.len(), 1);
1709 assert_eq!(spec.grants.len(), 1);
1710 assert_eq!(spec.memberships.len(), 1);
1711 assert_eq!(spec.retirements.len(), 1);
1712 assert_eq!(spec.retirements[0].role, "legacy-app");
1713 assert!(spec.retirements[0].terminate_sessions);
1714 }
1715
1716 #[test]
1717 fn referenced_secret_names_includes_connection_secret() {
1718 let spec = PostgresPolicySpec {
1719 connection: ConnectionSpec {
1720 secret_ref: SecretReference {
1721 name: "pg-conn".to_string(),
1722 },
1723 secret_key: "DATABASE_URL".to_string(),
1724 },
1725 interval: "5m".to_string(),
1726 suspend: false,
1727 mode: PolicyMode::Apply,
1728 reconciliation_mode: CrdReconciliationMode::default(),
1729 default_owner: None,
1730 profiles: std::collections::HashMap::new(),
1731 schemas: vec![],
1732 roles: vec![],
1733 grants: vec![],
1734 default_privileges: vec![],
1735 memberships: vec![],
1736 retirements: vec![],
1737 approval: None,
1738 };
1739
1740 let names = spec.referenced_secret_names("test-policy");
1741 assert!(names.contains("pg-conn"));
1742 assert_eq!(names.len(), 1);
1743 }
1744
1745 #[test]
1746 fn referenced_secret_names_includes_password_secrets() {
1747 let spec = PostgresPolicySpec {
1748 connection: ConnectionSpec {
1749 secret_ref: SecretReference {
1750 name: "pg-conn".to_string(),
1751 },
1752 secret_key: "DATABASE_URL".to_string(),
1753 },
1754 interval: "5m".to_string(),
1755 suspend: false,
1756 mode: PolicyMode::Apply,
1757 reconciliation_mode: CrdReconciliationMode::default(),
1758 default_owner: None,
1759 profiles: std::collections::HashMap::new(),
1760 schemas: vec![],
1761 roles: vec![
1762 RoleSpec {
1763 name: "role-a".to_string(),
1764 login: Some(true),
1765 password: Some(PasswordSpec {
1766 secret_ref: Some(SecretReference {
1767 name: "role-passwords".to_string(),
1768 }),
1769 secret_key: Some("role-a".to_string()),
1770 generate: None,
1771 }),
1772 password_valid_until: None,
1773 superuser: None,
1774 createdb: None,
1775 createrole: None,
1776 inherit: None,
1777 replication: None,
1778 bypassrls: None,
1779 connection_limit: None,
1780 comment: None,
1781 },
1782 RoleSpec {
1783 name: "role-b".to_string(),
1784 login: Some(true),
1785 password: Some(PasswordSpec {
1786 secret_ref: Some(SecretReference {
1787 name: "other-secret".to_string(),
1788 }),
1789 secret_key: None,
1790 generate: None,
1791 }),
1792 password_valid_until: None,
1793 superuser: None,
1794 createdb: None,
1795 createrole: None,
1796 inherit: None,
1797 replication: None,
1798 bypassrls: None,
1799 connection_limit: None,
1800 comment: None,
1801 },
1802 RoleSpec {
1803 name: "role-c".to_string(),
1804 login: None,
1805 password: None,
1806 password_valid_until: None,
1807 superuser: None,
1808 createdb: None,
1809 createrole: None,
1810 inherit: None,
1811 replication: None,
1812 bypassrls: None,
1813 connection_limit: None,
1814 comment: None,
1815 },
1816 ],
1817 grants: vec![],
1818 default_privileges: vec![],
1819 memberships: vec![],
1820 retirements: vec![],
1821 approval: None,
1822 };
1823
1824 let names = spec.referenced_secret_names("test-policy");
1825 assert!(
1826 names.contains("pg-conn"),
1827 "should include connection secret"
1828 );
1829 assert!(
1830 names.contains("role-passwords"),
1831 "should include role-a password secret"
1832 );
1833 assert!(
1834 names.contains("other-secret"),
1835 "should include role-b password secret"
1836 );
1837 assert_eq!(names.len(), 3);
1838 }
1839
1840 #[test]
1841 fn validate_password_specs_rejects_password_without_login() {
1842 let spec = PostgresPolicySpec {
1843 connection: ConnectionSpec {
1844 secret_ref: SecretReference {
1845 name: "pg-conn".to_string(),
1846 },
1847 secret_key: "DATABASE_URL".to_string(),
1848 },
1849 interval: "5m".to_string(),
1850 suspend: false,
1851 mode: PolicyMode::Apply,
1852 reconciliation_mode: CrdReconciliationMode::default(),
1853 default_owner: None,
1854 profiles: std::collections::HashMap::new(),
1855 schemas: vec![],
1856 roles: vec![RoleSpec {
1857 name: "app-user".to_string(),
1858 login: Some(false),
1859 superuser: None,
1860 createdb: None,
1861 createrole: None,
1862 inherit: None,
1863 replication: None,
1864 bypassrls: None,
1865 connection_limit: None,
1866 comment: None,
1867 password: Some(PasswordSpec {
1868 secret_ref: Some(SecretReference {
1869 name: "role-passwords".to_string(),
1870 }),
1871 secret_key: None,
1872 generate: None,
1873 }),
1874 password_valid_until: None,
1875 }],
1876 grants: vec![],
1877 default_privileges: vec![],
1878 memberships: vec![],
1879 retirements: vec![],
1880 approval: None,
1881 };
1882
1883 assert!(matches!(
1884 spec.validate_password_specs("test-policy"),
1885 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
1886 ));
1887 }
1888
1889 #[test]
1890 fn validate_password_specs_rejects_password_with_login_omitted() {
1891 let spec = PostgresPolicySpec {
1892 connection: ConnectionSpec {
1893 secret_ref: SecretReference {
1894 name: "pg-conn".to_string(),
1895 },
1896 secret_key: "DATABASE_URL".to_string(),
1897 },
1898 interval: "5m".to_string(),
1899 suspend: false,
1900 mode: PolicyMode::Apply,
1901 reconciliation_mode: CrdReconciliationMode::default(),
1902 default_owner: None,
1903 profiles: std::collections::HashMap::new(),
1904 schemas: vec![],
1905 roles: vec![RoleSpec {
1906 name: "app-user".to_string(),
1907 login: None, superuser: None,
1909 createdb: None,
1910 createrole: None,
1911 inherit: None,
1912 replication: None,
1913 bypassrls: None,
1914 connection_limit: None,
1915 comment: None,
1916 password: Some(PasswordSpec {
1917 secret_ref: Some(SecretReference {
1918 name: "role-passwords".to_string(),
1919 }),
1920 secret_key: None,
1921 generate: None,
1922 }),
1923 password_valid_until: None,
1924 }],
1925 grants: vec![],
1926 default_privileges: vec![],
1927 memberships: vec![],
1928 retirements: vec![],
1929 approval: None,
1930 };
1931
1932 assert!(matches!(
1933 spec.validate_password_specs("test-policy"),
1934 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
1935 ));
1936 }
1937
1938 #[test]
1939 fn validate_password_specs_rejects_invalid_password_mode() {
1940 let spec = PostgresPolicySpec {
1941 connection: ConnectionSpec {
1942 secret_ref: SecretReference {
1943 name: "pg-conn".to_string(),
1944 },
1945 secret_key: "DATABASE_URL".to_string(),
1946 },
1947 interval: "5m".to_string(),
1948 suspend: false,
1949 mode: PolicyMode::Apply,
1950 reconciliation_mode: CrdReconciliationMode::default(),
1951 default_owner: None,
1952 profiles: std::collections::HashMap::new(),
1953 schemas: vec![],
1954 roles: vec![RoleSpec {
1955 name: "app-user".to_string(),
1956 login: Some(true),
1957 superuser: None,
1958 createdb: None,
1959 createrole: None,
1960 inherit: None,
1961 replication: None,
1962 bypassrls: None,
1963 connection_limit: None,
1964 comment: None,
1965 password: Some(PasswordSpec {
1966 secret_ref: Some(SecretReference {
1967 name: "role-passwords".to_string(),
1968 }),
1969 secret_key: None,
1970 generate: Some(GeneratePasswordSpec {
1971 length: Some(32),
1972 secret_name: None,
1973 secret_key: None,
1974 }),
1975 }),
1976 password_valid_until: None,
1977 }],
1978 grants: vec![],
1979 default_privileges: vec![],
1980 memberships: vec![],
1981 retirements: vec![],
1982 approval: None,
1983 };
1984
1985 assert!(matches!(
1986 spec.validate_password_specs("test-policy"),
1987 Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
1988 ));
1989 }
1990
1991 #[test]
1992 fn validate_password_specs_rejects_invalid_generated_length() {
1993 let spec = PostgresPolicySpec {
1994 connection: ConnectionSpec {
1995 secret_ref: SecretReference {
1996 name: "pg-conn".to_string(),
1997 },
1998 secret_key: "DATABASE_URL".to_string(),
1999 },
2000 interval: "5m".to_string(),
2001 suspend: false,
2002 mode: PolicyMode::Apply,
2003 reconciliation_mode: CrdReconciliationMode::default(),
2004 default_owner: None,
2005 profiles: std::collections::HashMap::new(),
2006 schemas: vec![],
2007 roles: vec![RoleSpec {
2008 name: "app-user".to_string(),
2009 login: Some(true),
2010 superuser: None,
2011 createdb: None,
2012 createrole: None,
2013 inherit: None,
2014 replication: None,
2015 bypassrls: None,
2016 connection_limit: None,
2017 comment: None,
2018 password: Some(PasswordSpec {
2019 secret_ref: None,
2020 secret_key: None,
2021 generate: Some(GeneratePasswordSpec {
2022 length: Some(8),
2023 secret_name: None,
2024 secret_key: None,
2025 }),
2026 }),
2027 password_valid_until: None,
2028 }],
2029 grants: vec![],
2030 default_privileges: vec![],
2031 memberships: vec![],
2032 retirements: vec![],
2033 approval: None,
2034 };
2035
2036 assert!(matches!(
2037 spec.validate_password_specs("test-policy"),
2038 Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
2039 ));
2040 }
2041
2042 #[test]
2043 fn validate_password_specs_rejects_invalid_generated_secret_key() {
2044 let spec = PostgresPolicySpec {
2045 connection: ConnectionSpec {
2046 secret_ref: SecretReference {
2047 name: "pg-conn".to_string(),
2048 },
2049 secret_key: "DATABASE_URL".to_string(),
2050 },
2051 interval: "5m".to_string(),
2052 suspend: false,
2053 mode: PolicyMode::Apply,
2054 reconciliation_mode: CrdReconciliationMode::default(),
2055 default_owner: None,
2056 profiles: std::collections::HashMap::new(),
2057 schemas: vec![],
2058 roles: vec![RoleSpec {
2059 name: "app-user".to_string(),
2060 login: Some(true),
2061 superuser: None,
2062 createdb: None,
2063 createrole: None,
2064 inherit: None,
2065 replication: None,
2066 bypassrls: None,
2067 connection_limit: None,
2068 comment: None,
2069 password: Some(PasswordSpec {
2070 secret_ref: None,
2071 secret_key: None,
2072 generate: Some(GeneratePasswordSpec {
2073 length: Some(32),
2074 secret_name: None,
2075 secret_key: Some("bad/key".to_string()),
2076 }),
2077 }),
2078 password_valid_until: None,
2079 }],
2080 grants: vec![],
2081 default_privileges: vec![],
2082 memberships: vec![],
2083 retirements: vec![],
2084 approval: None,
2085 };
2086
2087 assert!(matches!(
2088 spec.validate_password_specs("test-policy"),
2089 Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
2090 if role == "app-user" && field == "generate.secretKey"
2091 ));
2092 }
2093
2094 #[test]
2095 fn validate_password_specs_rejects_invalid_generated_secret_name() {
2096 let spec = PostgresPolicySpec {
2097 connection: ConnectionSpec {
2098 secret_ref: SecretReference {
2099 name: "pg-conn".to_string(),
2100 },
2101 secret_key: "DATABASE_URL".to_string(),
2102 },
2103 interval: "5m".to_string(),
2104 suspend: false,
2105 mode: PolicyMode::Apply,
2106 reconciliation_mode: CrdReconciliationMode::default(),
2107 default_owner: None,
2108 profiles: std::collections::HashMap::new(),
2109 schemas: vec![],
2110 roles: vec![RoleSpec {
2111 name: "app-user".to_string(),
2112 login: Some(true),
2113 superuser: None,
2114 createdb: None,
2115 createrole: None,
2116 inherit: None,
2117 replication: None,
2118 bypassrls: None,
2119 connection_limit: None,
2120 comment: None,
2121 password: Some(PasswordSpec {
2122 secret_ref: None,
2123 secret_key: None,
2124 generate: Some(GeneratePasswordSpec {
2125 length: Some(32),
2126 secret_name: Some("Bad_Name".to_string()),
2127 secret_key: None,
2128 }),
2129 }),
2130 password_valid_until: None,
2131 }],
2132 grants: vec![],
2133 default_privileges: vec![],
2134 memberships: vec![],
2135 retirements: vec![],
2136 approval: None,
2137 };
2138
2139 assert!(matches!(
2140 spec.validate_password_specs("test-policy"),
2141 Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
2142 ));
2143 }
2144
2145 #[test]
2146 fn validate_password_specs_rejects_reserved_generated_secret_key() {
2147 let spec = PostgresPolicySpec {
2148 connection: ConnectionSpec {
2149 secret_ref: SecretReference {
2150 name: "pg-conn".to_string(),
2151 },
2152 secret_key: "DATABASE_URL".to_string(),
2153 },
2154 interval: "5m".to_string(),
2155 suspend: false,
2156 mode: PolicyMode::Apply,
2157 reconciliation_mode: CrdReconciliationMode::default(),
2158 default_owner: None,
2159 profiles: std::collections::HashMap::new(),
2160 schemas: vec![],
2161 roles: vec![RoleSpec {
2162 name: "app-user".to_string(),
2163 login: Some(true),
2164 superuser: None,
2165 createdb: None,
2166 createrole: None,
2167 inherit: None,
2168 replication: None,
2169 bypassrls: None,
2170 connection_limit: None,
2171 comment: None,
2172 password: Some(PasswordSpec {
2173 secret_ref: None,
2174 secret_key: None,
2175 generate: Some(GeneratePasswordSpec {
2176 length: Some(32),
2177 secret_name: None,
2178 secret_key: Some("verifier".to_string()),
2179 }),
2180 }),
2181 password_valid_until: None,
2182 }],
2183 grants: vec![],
2184 default_privileges: vec![],
2185 memberships: vec![],
2186 retirements: vec![],
2187 approval: None,
2188 };
2189
2190 assert!(matches!(
2191 spec.validate_password_specs("test-policy"),
2192 Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
2193 if role == "app-user" && key == "verifier"
2194 ));
2195 }
2196
2197 #[test]
2198 fn plan_crd_generates_valid_schema() {
2199 let crd = PostgresPolicyPlan::crd();
2200 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
2201 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
2202 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
2203 assert!(
2204 yaml.contains("PostgresPolicyPlan"),
2205 "kind should be PostgresPolicyPlan"
2206 );
2207 assert!(yaml.contains("pgplan"), "should have shortname pgplan");
2208 }
2209
2210 #[test]
2211 fn plan_phase_display() {
2212 assert_eq!(PlanPhase::Pending.to_string(), "Pending");
2213 assert_eq!(PlanPhase::Approved.to_string(), "Approved");
2214 assert_eq!(PlanPhase::Applying.to_string(), "Applying");
2215 assert_eq!(PlanPhase::Applied.to_string(), "Applied");
2216 assert_eq!(PlanPhase::Failed.to_string(), "Failed");
2217 assert_eq!(PlanPhase::Superseded.to_string(), "Superseded");
2218 }
2219
2220 #[test]
2221 fn plan_phase_default_is_pending() {
2222 assert_eq!(PlanPhase::default(), PlanPhase::Pending);
2223 }
2224
2225 #[test]
2226 fn effective_approval_infers_from_mode() {
2227 let base = PostgresPolicySpec {
2228 connection: ConnectionSpec {
2229 secret_ref: SecretReference {
2230 name: "test".into(),
2231 },
2232 secret_key: "DATABASE_URL".into(),
2233 },
2234 interval: "5m".into(),
2235 suspend: false,
2236 mode: PolicyMode::Apply,
2237 reconciliation_mode: CrdReconciliationMode::Authoritative,
2238 default_owner: None,
2239 profiles: Default::default(),
2240 schemas: vec![],
2241 roles: vec![],
2242 grants: vec![],
2243 default_privileges: vec![],
2244 memberships: vec![],
2245 retirements: vec![],
2246 approval: None,
2247 };
2248
2249 assert_eq!(base.effective_approval(), ApprovalMode::Auto);
2251
2252 let plan = PostgresPolicySpec {
2254 mode: PolicyMode::Plan,
2255 ..base.clone()
2256 };
2257 assert_eq!(plan.effective_approval(), ApprovalMode::Manual);
2258
2259 let explicit = PostgresPolicySpec {
2261 approval: Some(ApprovalMode::Manual),
2262 ..base.clone()
2263 };
2264 assert_eq!(explicit.effective_approval(), ApprovalMode::Manual);
2265 }
2266
2267 #[test]
2268 fn approval_mode_serde_roundtrip() {
2269 let manual: ApprovalMode = serde_json::from_str("\"manual\"").unwrap();
2271 assert_eq!(manual, ApprovalMode::Manual);
2272 let auto: ApprovalMode = serde_json::from_str("\"auto\"").unwrap();
2273 assert_eq!(auto, ApprovalMode::Auto);
2274
2275 let manual_json = serde_json::to_value(&ApprovalMode::Manual).unwrap();
2277 assert_eq!(manual_json, serde_json::Value::String("manual".to_string()));
2278 let auto_json = serde_json::to_value(&ApprovalMode::Auto).unwrap();
2279 assert_eq!(auto_json, serde_json::Value::String("auto".to_string()));
2280 }
2281
2282 #[test]
2283 fn plan_status_default_is_empty() {
2284 let status = PostgresPolicyPlanStatus::default();
2285 assert_eq!(status.phase, PlanPhase::Pending);
2286 assert!(status.conditions.is_empty());
2287 assert!(status.change_summary.is_none());
2288 assert!(status.sql_ref.is_none());
2289 assert!(status.sql_inline.is_none());
2290 assert!(status.computed_at.is_none());
2291 assert!(status.applied_at.is_none());
2292 assert!(status.last_error.is_none());
2293 }
2294
2295 #[test]
2296 fn spec_without_approval_field_deserializes_as_none() {
2297 let json = serde_json::json!({
2298 "connection": {
2299 "secretRef": { "name": "pg-secret" },
2300 "secretKey": "DATABASE_URL"
2301 },
2302 "interval": "5m",
2303 "suspend": false,
2304 "mode": "apply",
2305 "reconciliation_mode": "authoritative"
2306 });
2307
2308 let spec: PostgresPolicySpec =
2309 serde_json::from_value(json).expect("should deserialize without approval field");
2310 assert!(
2311 spec.approval.is_none(),
2312 "approval should be None when omitted"
2313 );
2314 assert_eq!(
2315 spec.effective_approval(),
2316 ApprovalMode::Auto,
2317 "effective_approval should infer Auto from apply mode"
2318 );
2319 }
2320
2321 #[test]
2322 fn status_without_current_plan_ref_deserializes_as_none() {
2323 let json = serde_json::json!({
2324 "conditions": [],
2325 "owned_roles": [],
2326 "owned_schemas": []
2327 });
2328
2329 let status: PostgresPolicyStatus =
2330 serde_json::from_value(json).expect("should deserialize without current_plan_ref");
2331 assert!(
2332 status.current_plan_ref.is_none(),
2333 "current_plan_ref should be None when omitted"
2334 );
2335 }
2336
2337 #[test]
2338 fn effective_approval_explicit_auto_overrides_plan_mode() {
2339 let spec = PostgresPolicySpec {
2340 connection: ConnectionSpec {
2341 secret_ref: SecretReference {
2342 name: "test".into(),
2343 },
2344 secret_key: "DATABASE_URL".into(),
2345 },
2346 interval: "5m".into(),
2347 suspend: false,
2348 mode: PolicyMode::Plan,
2349 reconciliation_mode: CrdReconciliationMode::Authoritative,
2350 default_owner: None,
2351 profiles: Default::default(),
2352 schemas: vec![],
2353 roles: vec![],
2354 grants: vec![],
2355 default_privileges: vec![],
2356 memberships: vec![],
2357 retirements: vec![],
2358 approval: Some(ApprovalMode::Auto),
2359 };
2360
2361 assert_eq!(
2362 spec.effective_approval(),
2363 ApprovalMode::Auto,
2364 "explicit Auto should override Plan mode's default of Manual"
2365 );
2366 }
2367
2368 #[test]
2369 fn plan_phase_rejected_display() {
2370 assert_eq!(PlanPhase::Rejected.to_string(), "Rejected");
2371 }
2372
2373 #[test]
2374 fn plan_phase_all_variants_display() {
2375 let variants = [
2376 PlanPhase::Pending,
2377 PlanPhase::Approved,
2378 PlanPhase::Applying,
2379 PlanPhase::Applied,
2380 PlanPhase::Failed,
2381 PlanPhase::Superseded,
2382 PlanPhase::Rejected,
2383 ];
2384 for variant in &variants {
2385 let display = variant.to_string();
2386 assert!(
2387 !display.is_empty(),
2388 "PlanPhase::{variant:?} should have non-empty Display output"
2389 );
2390 }
2391 }
2392
2393 #[test]
2394 fn plan_status_defaults() {
2395 let status = PostgresPolicyPlanStatus::default();
2396 assert_eq!(status.phase, PlanPhase::Pending);
2397 assert!(status.conditions.is_empty());
2398 assert!(status.sql_ref.is_none());
2399 assert!(status.sql_hash.is_none());
2400 assert!(status.sql_inline.is_none());
2401 assert!(status.change_summary.is_none());
2402 assert!(status.computed_at.is_none());
2403 assert!(status.applied_at.is_none());
2404 assert!(status.last_error.is_none());
2405 }
2406
2407 #[test]
2408 fn plan_spec_camel_case_serialization() {
2409 let spec = PostgresPolicyPlanSpec {
2410 policy_ref: PolicyPlanRef {
2411 name: "my-policy".into(),
2412 },
2413 policy_generation: 3,
2414 reconciliation_mode: CrdReconciliationMode::Authoritative,
2415 owned_roles: vec!["role-a".into()],
2416 owned_schemas: vec!["public".into()],
2417 managed_database_identity: "ns/secret/key".into(),
2418 };
2419
2420 let json = serde_json::to_value(&spec).expect("should serialize to JSON");
2421 let obj = json.as_object().expect("should be a JSON object");
2422
2423 assert!(
2424 obj.contains_key("policyRef"),
2425 "should use camelCase: policyRef"
2426 );
2427 assert!(
2428 obj.contains_key("policyGeneration"),
2429 "should use camelCase: policyGeneration"
2430 );
2431 assert!(
2432 obj.contains_key("reconciliationMode"),
2433 "should use camelCase: reconciliationMode"
2434 );
2435 assert!(
2436 obj.contains_key("ownedRoles"),
2437 "should use camelCase: ownedRoles"
2438 );
2439 assert!(
2440 obj.contains_key("ownedSchemas"),
2441 "should use camelCase: ownedSchemas"
2442 );
2443 assert!(
2444 obj.contains_key("managedDatabaseIdentity"),
2445 "should use camelCase: managedDatabaseIdentity"
2446 );
2447 }
2448}