1use kube::CustomResource;
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::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 pub on: ProfileObjectTargetSpec,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
179pub struct ProfileObjectTargetSpec {
180 #[serde(rename = "type")]
181 pub object_type: ObjectType,
182 #[serde(default)]
183 pub name: Option<String>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
188pub struct DefaultPrivilegeGrantSpec {
189 #[serde(default)]
190 pub role: Option<String>,
191 pub privileges: Vec<Privilege>,
192 pub on_type: ObjectType,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
197pub struct RoleSpec {
198 pub name: String,
199 #[serde(default)]
200 pub login: Option<bool>,
201 #[serde(default)]
202 pub superuser: Option<bool>,
203 #[serde(default)]
204 pub createdb: Option<bool>,
205 #[serde(default)]
206 pub createrole: Option<bool>,
207 #[serde(default)]
208 pub inherit: Option<bool>,
209 #[serde(default)]
210 pub replication: Option<bool>,
211 #[serde(default)]
212 pub bypassrls: Option<bool>,
213 #[serde(default)]
214 pub connection_limit: Option<i32>,
215 #[serde(default)]
216 pub comment: Option<String>,
217 #[serde(default)]
219 pub password: Option<PasswordSecretRef>,
220 #[serde(default)]
222 pub password_valid_until: Option<String>,
223}
224
225#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
227#[serde(rename_all = "camelCase")]
228pub struct PasswordSecretRef {
229 pub secret_ref: SecretReference,
231 #[serde(default)]
233 pub secret_key: Option<String>,
234}
235
236#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
242pub struct PostgresPolicyStatus {
243 #[serde(default)]
245 pub conditions: Vec<PolicyCondition>,
246
247 #[serde(default)]
249 pub observed_generation: Option<i64>,
250
251 #[serde(default)]
253 pub last_attempted_generation: Option<i64>,
254
255 #[serde(default)]
257 pub last_successful_reconcile_time: Option<String>,
258
259 #[serde(default)]
261 pub last_reconcile_time: Option<String>,
262
263 #[serde(default)]
265 pub change_summary: Option<ChangeSummary>,
266
267 #[serde(default)]
269 pub last_reconcile_mode: Option<PolicyMode>,
270
271 #[serde(default)]
273 pub planned_sql: Option<String>,
274
275 #[serde(default)]
277 pub planned_sql_truncated: bool,
278
279 #[serde(default)]
281 pub managed_database_identity: Option<String>,
282
283 #[serde(default)]
285 pub owned_roles: Vec<String>,
286
287 #[serde(default)]
289 pub owned_schemas: Vec<String>,
290
291 #[serde(default)]
293 pub last_error: Option<String>,
294
295 #[serde(default)]
297 pub transient_failure_count: i32,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
302pub struct PolicyCondition {
303 #[serde(rename = "type")]
305 pub condition_type: String,
306
307 pub status: String,
309
310 #[serde(default)]
312 pub reason: Option<String>,
313
314 #[serde(default)]
316 pub message: Option<String>,
317
318 #[serde(default)]
320 pub last_transition_time: Option<String>,
321}
322
323#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
325pub struct ChangeSummary {
326 pub roles_created: i32,
327 pub roles_altered: i32,
328 pub roles_dropped: i32,
329 pub sessions_terminated: i32,
330 pub grants_added: i32,
331 pub grants_revoked: i32,
332 pub default_privileges_set: i32,
333 pub default_privileges_revoked: i32,
334 pub members_added: i32,
335 pub members_removed: i32,
336 pub passwords_set: i32,
337 pub total: i32,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
342pub struct DatabaseIdentity(String);
343
344impl DatabaseIdentity {
345 pub fn new(namespace: &str, secret_name: &str, secret_key: &str) -> Self {
346 Self(format!("{namespace}/{secret_name}/{secret_key}"))
347 }
348
349 pub fn as_str(&self) -> &str {
350 &self.0
351 }
352}
353
354#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct OwnershipClaims {
357 pub roles: BTreeSet<String>,
358 pub schemas: BTreeSet<String>,
359}
360
361impl OwnershipClaims {
362 pub fn overlaps(&self, other: &Self) -> bool {
363 !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
364 }
365
366 pub fn overlap_summary(&self, other: &Self) -> String {
367 let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
368 let overlapping_schemas: Vec<_> =
369 self.schemas.intersection(&other.schemas).cloned().collect();
370
371 let mut parts = Vec::new();
372 if !overlapping_roles.is_empty() {
373 parts.push(format!("roles: {}", overlapping_roles.join(", ")));
374 }
375 if !overlapping_schemas.is_empty() {
376 parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
377 }
378
379 parts.join("; ")
380 }
381}
382
383impl PostgresPolicySpec {
388 pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
390 use pgroles_core::manifest::{
391 DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
392 ProfileObjectTarget, RoleDefinition,
393 };
394
395 let profiles = self
396 .profiles
397 .iter()
398 .map(|(name, spec)| {
399 let profile = Profile {
400 login: spec.login,
401 grants: spec
402 .grants
403 .iter()
404 .map(|g| ProfileGrant {
405 privileges: g.privileges.clone(),
406 on: ProfileObjectTarget {
407 object_type: g.on.object_type,
408 name: g.on.name.clone(),
409 },
410 })
411 .collect(),
412 default_privileges: spec
413 .default_privileges
414 .iter()
415 .map(|dp| DefaultPrivilegeGrant {
416 role: dp.role.clone(),
417 privileges: dp.privileges.clone(),
418 on_type: dp.on_type,
419 })
420 .collect(),
421 };
422 (name.clone(), profile)
423 })
424 .collect();
425
426 let roles = self
427 .roles
428 .iter()
429 .map(|r| RoleDefinition {
430 name: r.name.clone(),
431 login: r.login,
432 superuser: r.superuser,
433 createdb: r.createdb,
434 createrole: r.createrole,
435 inherit: r.inherit,
436 replication: r.replication,
437 bypassrls: r.bypassrls,
438 connection_limit: r.connection_limit,
439 comment: r.comment.clone(),
440 password: None, password_valid_until: r.password_valid_until.clone(),
442 })
443 .collect();
444
445 let memberships = self
449 .memberships
450 .iter()
451 .map(|m| pgroles_core::manifest::Membership {
452 role: m.role.clone(),
453 members: m
454 .members
455 .iter()
456 .map(|ms| MemberSpec {
457 name: ms.name.clone(),
458 inherit: ms.inherit,
459 admin: ms.admin,
460 })
461 .collect(),
462 })
463 .collect();
464
465 PolicyManifest {
466 default_owner: self.default_owner.clone(),
467 auth_providers: Vec::new(),
468 profiles,
469 schemas: self.schemas.clone(),
470 roles,
471 grants: self.grants.clone(),
472 default_privileges: self.default_privileges.clone(),
473 memberships,
474 retirements: self.retirements.clone(),
475 }
476 }
477
478 pub fn ownership_claims(
483 &self,
484 ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
485 let manifest = self.to_policy_manifest();
486 let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
487
488 let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
489 let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
490
491 roles.extend(manifest.retirements.into_iter().map(|r| r.role));
492 roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
493 roles.extend(
494 manifest
495 .default_privileges
496 .iter()
497 .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
498 );
499 roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
500 roles.extend(
501 manifest
502 .memberships
503 .iter()
504 .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
505 );
506
507 schemas.extend(
508 manifest
509 .grants
510 .iter()
511 .filter_map(|g| match g.on.object_type {
512 ObjectType::Database => None,
513 ObjectType::Schema => g.on.name.clone(),
514 _ => g.on.schema.clone(),
515 }),
516 );
517 schemas.extend(
518 manifest
519 .default_privileges
520 .iter()
521 .map(|dp| dp.schema.clone()),
522 );
523
524 Ok(OwnershipClaims { roles, schemas })
525 }
526}
527
528impl PostgresPolicyStatus {
533 pub fn set_condition(&mut self, condition: PolicyCondition) {
535 if let Some(existing) = self
536 .conditions
537 .iter_mut()
538 .find(|c| c.condition_type == condition.condition_type)
539 {
540 *existing = condition;
541 } else {
542 self.conditions.push(condition);
543 }
544 }
545}
546
547pub fn now_rfc3339() -> String {
549 use std::time::SystemTime;
552 let now = SystemTime::now()
553 .duration_since(SystemTime::UNIX_EPOCH)
554 .unwrap_or_default();
555 let secs = now.as_secs();
557 let days = secs / 86400;
558 let remaining = secs % 86400;
559 let hours = remaining / 3600;
560 let minutes = (remaining % 3600) / 60;
561 let seconds = remaining % 60;
562
563 let (year, month, day) = days_to_date(days);
565 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
566}
567
568fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
570 let z = days_since_epoch + 719468;
572 let era = z / 146097;
573 let doe = z - era * 146097;
574 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
575 let y = yoe + era * 400;
576 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
577 let mp = (5 * doy + 2) / 153;
578 let d = doy - (153 * mp + 2) / 5 + 1;
579 let m = if mp < 10 { mp + 3 } else { mp - 9 };
580 let y = if m <= 2 { y + 1 } else { y };
581 (y, m, d)
582}
583
584pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
586 PolicyCondition {
587 condition_type: "Ready".to_string(),
588 status: if status { "True" } else { "False" }.to_string(),
589 reason: Some(reason.to_string()),
590 message: Some(message.to_string()),
591 last_transition_time: Some(now_rfc3339()),
592 }
593}
594
595pub fn reconciling_condition(message: &str) -> PolicyCondition {
597 PolicyCondition {
598 condition_type: "Reconciling".to_string(),
599 status: "True".to_string(),
600 reason: Some("Reconciling".to_string()),
601 message: Some(message.to_string()),
602 last_transition_time: Some(now_rfc3339()),
603 }
604}
605
606pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
608 PolicyCondition {
609 condition_type: "Degraded".to_string(),
610 status: "True".to_string(),
611 reason: Some(reason.to_string()),
612 message: Some(message.to_string()),
613 last_transition_time: Some(now_rfc3339()),
614 }
615}
616
617pub fn paused_condition(message: &str) -> PolicyCondition {
619 PolicyCondition {
620 condition_type: "Paused".to_string(),
621 status: "True".to_string(),
622 reason: Some("Suspended".to_string()),
623 message: Some(message.to_string()),
624 last_transition_time: Some(now_rfc3339()),
625 }
626}
627
628pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
630 PolicyCondition {
631 condition_type: "Conflict".to_string(),
632 status: "True".to_string(),
633 reason: Some(reason.to_string()),
634 message: Some(message.to_string()),
635 last_transition_time: Some(now_rfc3339()),
636 }
637}
638
639pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
641 PolicyCondition {
642 condition_type: "Drifted".to_string(),
643 status: if status { "True" } else { "False" }.to_string(),
644 reason: Some(reason.to_string()),
645 message: Some(message.to_string()),
646 last_transition_time: Some(now_rfc3339()),
647 }
648}
649
650#[cfg(test)]
655mod tests {
656 use super::*;
657 use kube::CustomResourceExt;
658
659 #[test]
660 fn crd_generates_valid_schema() {
661 let crd = PostgresPolicy::crd();
662 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
663 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
664 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
665 assert!(
666 yaml.contains("PostgresPolicy"),
667 "kind should be PostgresPolicy"
668 );
669 }
670
671 #[test]
672 fn spec_to_policy_manifest_roundtrip() {
673 let spec = PostgresPolicySpec {
674 connection: ConnectionSpec {
675 secret_ref: SecretReference {
676 name: "pg-secret".to_string(),
677 },
678 secret_key: "DATABASE_URL".to_string(),
679 },
680 interval: "5m".to_string(),
681 suspend: false,
682 mode: PolicyMode::Apply,
683 reconciliation_mode: CrdReconciliationMode::default(),
684 default_owner: Some("app_owner".to_string()),
685 profiles: std::collections::HashMap::new(),
686 schemas: vec![],
687 roles: vec![RoleSpec {
688 name: "analytics".to_string(),
689 login: Some(true),
690 superuser: None,
691 createdb: None,
692 createrole: None,
693 inherit: None,
694 replication: None,
695 bypassrls: None,
696 connection_limit: None,
697 comment: Some("test role".to_string()),
698 password: None,
699 password_valid_until: None,
700 }],
701 grants: vec![],
702 default_privileges: vec![],
703 memberships: vec![],
704 retirements: vec![RoleRetirement {
705 role: "legacy-app".to_string(),
706 reassign_owned_to: Some("app_owner".to_string()),
707 drop_owned: true,
708 terminate_sessions: true,
709 }],
710 };
711
712 let manifest = spec.to_policy_manifest();
713 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
714 assert_eq!(manifest.roles.len(), 1);
715 assert_eq!(manifest.roles[0].name, "analytics");
716 assert_eq!(manifest.roles[0].login, Some(true));
717 assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
718 assert_eq!(manifest.retirements.len(), 1);
719 assert_eq!(manifest.retirements[0].role, "legacy-app");
720 assert_eq!(
721 manifest.retirements[0].reassign_owned_to.as_deref(),
722 Some("app_owner")
723 );
724 assert!(manifest.retirements[0].drop_owned);
725 assert!(manifest.retirements[0].terminate_sessions);
726 }
727
728 #[test]
729 fn status_set_condition_replaces_existing() {
730 let mut status = PostgresPolicyStatus::default();
731
732 status.set_condition(ready_condition(false, "Pending", "Initial"));
733 assert_eq!(status.conditions.len(), 1);
734 assert_eq!(status.conditions[0].status, "False");
735
736 status.set_condition(ready_condition(true, "Reconciled", "All good"));
737 assert_eq!(status.conditions.len(), 1);
738 assert_eq!(status.conditions[0].status, "True");
739 assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
740 }
741
742 #[test]
743 fn status_set_condition_adds_new_type() {
744 let mut status = PostgresPolicyStatus::default();
745
746 status.set_condition(ready_condition(true, "OK", "ready"));
747 status.set_condition(degraded_condition("Error", "something broke"));
748
749 assert_eq!(status.conditions.len(), 2);
750 }
751
752 #[test]
753 fn paused_condition_has_expected_shape() {
754 let paused = paused_condition("paused by spec");
755 assert_eq!(paused.condition_type, "Paused");
756 assert_eq!(paused.status, "True");
757 assert_eq!(paused.reason.as_deref(), Some("Suspended"));
758 }
759
760 #[test]
761 fn ownership_claims_include_expanded_roles_and_schemas() {
762 let mut profiles = std::collections::HashMap::new();
763 profiles.insert(
764 "editor".to_string(),
765 ProfileSpec {
766 login: Some(false),
767 grants: vec![],
768 default_privileges: vec![],
769 },
770 );
771
772 let spec = PostgresPolicySpec {
773 connection: ConnectionSpec {
774 secret_ref: SecretReference {
775 name: "pg-secret".to_string(),
776 },
777 secret_key: "DATABASE_URL".to_string(),
778 },
779 interval: "5m".to_string(),
780 suspend: false,
781 mode: PolicyMode::Apply,
782 reconciliation_mode: CrdReconciliationMode::default(),
783 default_owner: None,
784 profiles,
785 schemas: vec![SchemaBinding {
786 name: "inventory".to_string(),
787 profiles: vec!["editor".to_string()],
788 role_pattern: "{schema}-{profile}".to_string(),
789 owner: None,
790 }],
791 roles: vec![RoleSpec {
792 name: "app-service".to_string(),
793 login: Some(true),
794 superuser: None,
795 createdb: None,
796 createrole: None,
797 inherit: None,
798 replication: None,
799 bypassrls: None,
800 connection_limit: None,
801 comment: None,
802 password: None,
803 password_valid_until: None,
804 }],
805 grants: vec![],
806 default_privileges: vec![],
807 memberships: vec![],
808 retirements: vec![RoleRetirement {
809 role: "legacy-app".to_string(),
810 reassign_owned_to: None,
811 drop_owned: false,
812 terminate_sessions: false,
813 }],
814 };
815
816 let claims = spec.ownership_claims().unwrap();
817 assert!(claims.roles.contains("inventory-editor"));
818 assert!(claims.roles.contains("app-service"));
819 assert!(claims.roles.contains("legacy-app"));
820 assert!(claims.schemas.contains("inventory"));
821 }
822
823 #[test]
824 fn ownership_overlap_summary_reports_roles_and_schemas() {
825 let mut left = OwnershipClaims::default();
826 left.roles.insert("analytics".to_string());
827 left.schemas.insert("reporting".to_string());
828
829 let mut right = OwnershipClaims::default();
830 right.roles.insert("analytics".to_string());
831 right.schemas.insert("reporting".to_string());
832 right.schemas.insert("other".to_string());
833
834 assert!(left.overlaps(&right));
835 let summary = left.overlap_summary(&right);
836 assert!(summary.contains("roles: analytics"));
837 assert!(summary.contains("schemas: reporting"));
838 }
839
840 #[test]
841 fn database_identity_uses_namespace_secret_and_key() {
842 let identity = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
843 assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
844 }
845
846 #[test]
847 fn now_rfc3339_produces_valid_format() {
848 let ts = now_rfc3339();
849 assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
851 assert!(ts.ends_with('Z'), "should end with Z: {ts}");
852 assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
853 assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
854 }
855
856 #[test]
857 fn ready_condition_true_has_expected_shape() {
858 let cond = ready_condition(true, "Reconciled", "All changes applied");
859 assert_eq!(cond.condition_type, "Ready");
860 assert_eq!(cond.status, "True");
861 assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
862 assert_eq!(cond.message.as_deref(), Some("All changes applied"));
863 assert!(cond.last_transition_time.is_some());
864 }
865
866 #[test]
867 fn ready_condition_false_has_expected_shape() {
868 let cond = ready_condition(false, "InvalidSpec", "bad manifest");
869 assert_eq!(cond.condition_type, "Ready");
870 assert_eq!(cond.status, "False");
871 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
872 assert_eq!(cond.message.as_deref(), Some("bad manifest"));
873 }
874
875 #[test]
876 fn degraded_condition_has_expected_shape() {
877 let cond = degraded_condition("InvalidSpec", "expansion failed");
878 assert_eq!(cond.condition_type, "Degraded");
879 assert_eq!(cond.status, "True");
880 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
881 assert_eq!(cond.message.as_deref(), Some("expansion failed"));
882 assert!(cond.last_transition_time.is_some());
883 }
884
885 #[test]
886 fn reconciling_condition_has_expected_shape() {
887 let cond = reconciling_condition("Reconciliation in progress");
888 assert_eq!(cond.condition_type, "Reconciling");
889 assert_eq!(cond.status, "True");
890 assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
891 assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
892 assert!(cond.last_transition_time.is_some());
893 }
894
895 #[test]
896 fn conflict_condition_has_expected_shape() {
897 let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
898 assert_eq!(cond.condition_type, "Conflict");
899 assert_eq!(cond.status, "True");
900 assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
901 assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
902 assert!(cond.last_transition_time.is_some());
903 }
904
905 #[test]
906 fn ownership_claims_no_overlap() {
907 let mut left = OwnershipClaims::default();
908 left.roles.insert("analytics".to_string());
909 left.schemas.insert("reporting".to_string());
910
911 let mut right = OwnershipClaims::default();
912 right.roles.insert("billing".to_string());
913 right.schemas.insert("payments".to_string());
914
915 assert!(!left.overlaps(&right));
916 let summary = left.overlap_summary(&right);
917 assert!(summary.is_empty());
918 }
919
920 #[test]
921 fn ownership_claims_partial_role_overlap() {
922 let mut left = OwnershipClaims::default();
923 left.roles.insert("analytics".to_string());
924 left.roles.insert("reporting-viewer".to_string());
925
926 let mut right = OwnershipClaims::default();
927 right.roles.insert("analytics".to_string());
928 right.roles.insert("other-role".to_string());
929
930 assert!(left.overlaps(&right));
931 let summary = left.overlap_summary(&right);
932 assert!(summary.contains("roles: analytics"));
933 assert!(!summary.contains("schemas"));
934 }
935
936 #[test]
937 fn ownership_claims_empty_is_disjoint() {
938 let left = OwnershipClaims::default();
939 let right = OwnershipClaims::default();
940 assert!(!left.overlaps(&right));
941 }
942
943 #[test]
944 fn database_identity_equality() {
945 let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
946 let b = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
947 let c = DatabaseIdentity::new("staging", "db-creds", "DATABASE_URL");
948 assert_eq!(a, b);
949 assert_ne!(a, c);
950 }
951
952 #[test]
953 fn database_identity_different_key() {
954 let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
955 let b = DatabaseIdentity::new("prod", "db-creds", "CUSTOM_URL");
956 assert_ne!(a, b);
957 }
958
959 #[test]
960 fn status_default_has_empty_conditions() {
961 let status = PostgresPolicyStatus::default();
962 assert!(status.conditions.is_empty());
963 assert!(status.observed_generation.is_none());
964 assert!(status.last_attempted_generation.is_none());
965 assert!(status.last_successful_reconcile_time.is_none());
966 assert!(status.change_summary.is_none());
967 assert!(status.managed_database_identity.is_none());
968 assert!(status.owned_roles.is_empty());
969 assert!(status.owned_schemas.is_empty());
970 assert!(status.last_error.is_none());
971 }
972
973 #[test]
974 fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
975 let mut status = PostgresPolicyStatus::default();
976
977 status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
979 status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
980 status
981 .conditions
982 .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
983 status.change_summary = None;
984 status.last_error = Some("bad manifest".to_string());
985
986 let ready = status
988 .conditions
989 .iter()
990 .find(|c| c.condition_type == "Ready")
991 .expect("should have Ready condition");
992 assert_eq!(ready.status, "False");
993 assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
994
995 let degraded = status
997 .conditions
998 .iter()
999 .find(|c| c.condition_type == "Degraded")
1000 .expect("should have Degraded condition");
1001 assert_eq!(degraded.status, "True");
1002 assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
1003
1004 assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
1006 }
1007
1008 #[test]
1009 fn status_conflict_workflow() {
1010 let mut status = PostgresPolicyStatus::default();
1011
1012 let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
1014 status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
1015 status.set_condition(conflict_condition("ConflictingPolicy", msg));
1016 status.set_condition(degraded_condition("ConflictingPolicy", msg));
1017 status
1018 .conditions
1019 .retain(|c| c.condition_type != "Reconciling");
1020 status.last_error = Some(msg.to_string());
1021
1022 let conflict = status
1024 .conditions
1025 .iter()
1026 .find(|c| c.condition_type == "Conflict")
1027 .expect("should have Conflict condition");
1028 assert_eq!(conflict.status, "True");
1029 assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
1030
1031 let ready = status
1033 .conditions
1034 .iter()
1035 .find(|c| c.condition_type == "Ready")
1036 .expect("should have Ready condition");
1037 assert_eq!(ready.status, "False");
1038
1039 let degraded = status
1041 .conditions
1042 .iter()
1043 .find(|c| c.condition_type == "Degraded")
1044 .expect("should have Degraded condition");
1045 assert_eq!(degraded.status, "True");
1046 }
1047
1048 #[test]
1049 fn status_successful_reconcile_records_generation_and_time() {
1050 let mut status = PostgresPolicyStatus::default();
1051 let generation = Some(3_i64);
1052 let summary = ChangeSummary {
1053 roles_created: 2,
1054 total: 2,
1055 ..Default::default()
1056 };
1057
1058 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1060 status.conditions.retain(|c| {
1061 c.condition_type != "Reconciling"
1062 && c.condition_type != "Degraded"
1063 && c.condition_type != "Conflict"
1064 && c.condition_type != "Paused"
1065 });
1066 status.observed_generation = generation;
1067 status.last_attempted_generation = generation;
1068 status.last_successful_reconcile_time = Some(now_rfc3339());
1069 status.last_reconcile_time = Some(now_rfc3339());
1070 status.change_summary = Some(summary);
1071 status.last_error = None;
1072
1073 let ready = status
1075 .conditions
1076 .iter()
1077 .find(|c| c.condition_type == "Ready")
1078 .expect("should have Ready condition");
1079 assert_eq!(ready.status, "True");
1080 assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
1081
1082 assert_eq!(status.observed_generation, Some(3));
1084 assert_eq!(status.last_attempted_generation, Some(3));
1085
1086 assert!(status.last_successful_reconcile_time.is_some());
1088 assert!(status.last_reconcile_time.is_some());
1089
1090 let summary = status.change_summary.as_ref().unwrap();
1092 assert_eq!(summary.roles_created, 2);
1093 assert_eq!(summary.total, 2);
1094
1095 assert!(status.last_error.is_none());
1097
1098 assert!(
1100 status
1101 .conditions
1102 .iter()
1103 .all(|c| c.condition_type != "Degraded"
1104 && c.condition_type != "Conflict"
1105 && c.condition_type != "Paused"
1106 && c.condition_type != "Reconciling")
1107 );
1108 }
1109
1110 #[test]
1111 fn status_suspended_workflow() {
1112 let mut status = PostgresPolicyStatus::default();
1113 let generation = Some(2_i64);
1114
1115 status.set_condition(paused_condition("Reconciliation suspended by spec"));
1117 status.set_condition(ready_condition(
1118 false,
1119 "Suspended",
1120 "Reconciliation suspended by spec",
1121 ));
1122 status
1123 .conditions
1124 .retain(|c| c.condition_type != "Reconciling");
1125 status.last_attempted_generation = generation;
1126 status.last_error = None;
1127
1128 let paused = status
1130 .conditions
1131 .iter()
1132 .find(|c| c.condition_type == "Paused")
1133 .expect("should have Paused condition");
1134 assert_eq!(paused.status, "True");
1135
1136 let ready = status
1138 .conditions
1139 .iter()
1140 .find(|c| c.condition_type == "Ready")
1141 .expect("should have Ready condition");
1142 assert_eq!(ready.status, "False");
1143 assert_eq!(ready.reason.as_deref(), Some("Suspended"));
1144
1145 assert!(
1147 !status
1148 .conditions
1149 .iter()
1150 .any(|c| c.condition_type == "Reconciling")
1151 );
1152 }
1153
1154 #[test]
1155 fn status_transitions_from_degraded_to_ready() {
1156 let mut status = PostgresPolicyStatus::default();
1157
1158 status.set_condition(ready_condition(false, "InvalidSpec", "error"));
1160 status.set_condition(degraded_condition("InvalidSpec", "error"));
1161 status.last_error = Some("error".to_string());
1162
1163 assert_eq!(status.conditions.len(), 2);
1164
1165 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1167 status.conditions.retain(|c| {
1168 c.condition_type != "Reconciling"
1169 && c.condition_type != "Degraded"
1170 && c.condition_type != "Conflict"
1171 && c.condition_type != "Paused"
1172 });
1173 status.last_error = None;
1174
1175 let ready = status
1177 .conditions
1178 .iter()
1179 .find(|c| c.condition_type == "Ready")
1180 .expect("should have Ready condition");
1181 assert_eq!(ready.status, "True");
1182
1183 assert!(
1185 !status
1186 .conditions
1187 .iter()
1188 .any(|c| c.condition_type == "Degraded")
1189 );
1190
1191 assert_eq!(status.conditions.len(), 1);
1193
1194 assert!(status.last_error.is_none());
1196 }
1197
1198 #[test]
1199 fn change_summary_default_is_all_zero() {
1200 let summary = ChangeSummary::default();
1201 assert_eq!(summary.roles_created, 0);
1202 assert_eq!(summary.roles_altered, 0);
1203 assert_eq!(summary.roles_dropped, 0);
1204 assert_eq!(summary.sessions_terminated, 0);
1205 assert_eq!(summary.grants_added, 0);
1206 assert_eq!(summary.grants_revoked, 0);
1207 assert_eq!(summary.default_privileges_set, 0);
1208 assert_eq!(summary.default_privileges_revoked, 0);
1209 assert_eq!(summary.members_added, 0);
1210 assert_eq!(summary.members_removed, 0);
1211 assert_eq!(summary.total, 0);
1212 }
1213
1214 #[test]
1215 fn status_serializes_to_json() {
1216 let mut status = PostgresPolicyStatus::default();
1217 status.set_condition(ready_condition(true, "Reconciled", "done"));
1218 status.observed_generation = Some(5);
1219 status.managed_database_identity = Some("ns/secret/key".to_string());
1220 status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
1221 status.owned_schemas = vec!["public".to_string()];
1222 status.change_summary = Some(ChangeSummary {
1223 roles_created: 1,
1224 total: 1,
1225 ..Default::default()
1226 });
1227
1228 let json = serde_json::to_string(&status).expect("should serialize");
1229 assert!(json.contains("\"Reconciled\""));
1230 assert!(json.contains("\"observed_generation\":5"));
1231 assert!(json.contains("\"role-a\""));
1232 assert!(json.contains("\"ns/secret/key\""));
1233 }
1234
1235 #[test]
1236 fn crd_spec_deserializes_from_yaml() {
1237 let yaml = r#"
1238connection:
1239 secretRef:
1240 name: pg-credentials
1241interval: "10m"
1242default_owner: app_owner
1243profiles:
1244 editor:
1245 grants:
1246 - privileges: [USAGE]
1247 on: { type: schema }
1248 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1249 on: { type: table, name: "*" }
1250 default_privileges:
1251 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1252 on_type: table
1253schemas:
1254 - name: inventory
1255 profiles: [editor]
1256roles:
1257 - name: analytics
1258 login: true
1259grants:
1260 - role: analytics
1261 privileges: [CONNECT]
1262 on: { type: database, name: mydb }
1263memberships:
1264 - role: inventory-editor
1265 members:
1266 - name: analytics
1267retirements:
1268 - role: legacy-app
1269 reassign_owned_to: app_owner
1270 drop_owned: true
1271 terminate_sessions: true
1272"#;
1273 let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
1274 assert_eq!(spec.interval, "10m");
1275 assert_eq!(spec.default_owner, Some("app_owner".to_string()));
1276 assert_eq!(spec.profiles.len(), 1);
1277 assert!(spec.profiles.contains_key("editor"));
1278 assert_eq!(spec.schemas.len(), 1);
1279 assert_eq!(spec.roles.len(), 1);
1280 assert_eq!(spec.grants.len(), 1);
1281 assert_eq!(spec.memberships.len(), 1);
1282 assert_eq!(spec.retirements.len(), 1);
1283 assert_eq!(spec.retirements[0].role, "legacy-app");
1284 assert!(spec.retirements[0].terminate_sessions);
1285 }
1286}