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
16pub const VALID_SSL_MODES: &[&str] = &[
18 "disable",
19 "allow",
20 "prefer",
21 "require",
22 "verify-ca",
23 "verify-full",
24];
25
26#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[kube(
36 group = "pgroles.io",
37 version = "v1alpha1",
38 kind = "PostgresPolicy",
39 namespaced,
40 status = "PostgresPolicyStatus",
41 shortname = "pgr",
42 category = "pgroles",
43 printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
44 printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.mode"}"#,
45 printcolumn = r#"{"name":"Recon","type":"string","jsonPath":".spec.reconciliation_mode","priority":1}"#,
46 printcolumn = r#"{"name":"Drift","type":"string","jsonPath":".status.conditions[?(@.type==\"Drifted\")].status"}"#,
47 printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.change_summary.total"}"#,
48 printcolumn = r#"{"name":"Last Reconcile","type":"date","jsonPath":".status.last_successful_reconcile_time"}"#,
49 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
50)]
51pub struct PostgresPolicySpec {
52 pub connection: ConnectionSpec,
54
55 #[serde(default = "default_interval")]
57 pub interval: String,
58
59 #[serde(default)]
61 pub suspend: bool,
62
63 #[serde(default)]
65 pub mode: PolicyMode,
66
67 #[serde(default)]
74 pub reconciliation_mode: CrdReconciliationMode,
75
76 #[serde(default)]
78 pub default_owner: Option<String>,
79
80 #[serde(default)]
82 pub profiles: std::collections::HashMap<String, ProfileSpec>,
83
84 #[serde(default)]
86 pub schemas: Vec<SchemaBinding>,
87
88 #[serde(default)]
90 pub roles: Vec<RoleSpec>,
91
92 #[serde(default)]
94 pub grants: Vec<Grant>,
95
96 #[serde(default)]
98 pub default_privileges: Vec<DefaultPrivilege>,
99
100 #[serde(default)]
102 pub memberships: Vec<Membership>,
103
104 #[serde(default)]
106 pub retirements: Vec<RoleRetirement>,
107
108 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub approval: Option<ApprovalMode>,
115}
116
117fn default_interval() -> String {
118 "5m".to_string()
119}
120
121#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
123#[serde(rename_all = "lowercase")]
124pub enum PolicyMode {
125 #[default]
126 Apply,
127 Plan,
128}
129
130#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
132#[serde(rename_all = "lowercase")]
133pub enum CrdReconciliationMode {
134 #[default]
136 Authoritative,
137 Additive,
139 Adopt,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
145pub enum ApprovalMode {
146 #[serde(rename = "manual")]
148 Manual,
149 #[serde(rename = "auto")]
151 Auto,
152}
153
154impl PostgresPolicySpec {
155 pub fn effective_approval(&self) -> ApprovalMode {
158 match &self.approval {
159 Some(mode) => mode.clone(),
160 None => match self.mode {
161 PolicyMode::Apply => ApprovalMode::Auto,
162 PolicyMode::Plan => ApprovalMode::Manual,
163 },
164 }
165 }
166}
167
168pub const PLAN_APPROVED_ANNOTATION: &str = "pgroles.io/approved";
174
175pub const PLAN_REJECTED_ANNOTATION: &str = "pgroles.io/rejected";
177
178pub const LABEL_POLICY: &str = "pgroles.io/policy";
180
181pub const LABEL_DATABASE_IDENTITY: &str = "pgroles.io/database-identity";
183
184impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
185 fn from(crd: CrdReconciliationMode) -> Self {
186 match crd {
187 CrdReconciliationMode::Authoritative => {
188 pgroles_core::diff::ReconciliationMode::Authoritative
189 }
190 CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
191 CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
192 }
193 }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
218#[serde(rename_all = "camelCase")]
219pub struct ConnectionSpec {
220 #[serde(default)]
223 pub secret_ref: Option<SecretReference>,
224
225 #[serde(default)]
228 pub secret_key: Option<String>,
229
230 #[serde(default)]
233 pub params: Option<ConnectionParams>,
234}
235
236impl ConnectionSpec {
237 pub fn effective_secret_key(&self) -> &str {
239 self.secret_key.as_deref().unwrap_or("DATABASE_URL")
240 }
241
242 pub fn collect_secret_names(&self, names: &mut BTreeSet<String>) {
244 if let Some(ref secret_ref) = self.secret_ref {
245 names.insert(secret_ref.name.clone());
246 }
247 if let Some(ref params) = self.params {
248 for sel in [
249 ¶ms.host_secret,
250 ¶ms.port_secret,
251 ¶ms.dbname_secret,
252 ¶ms.username_secret,
253 ¶ms.password_secret,
254 ¶ms.ssl_mode_secret,
255 ]
256 .into_iter()
257 .flatten()
258 {
259 names.insert(sel.name.clone());
260 }
261 }
262 }
263
264 pub fn identity_key(&self) -> String {
278 if let Some(ref secret_ref) = self.secret_ref {
279 format!("{}/{}", secret_ref.name, self.effective_secret_key())
280 } else if let Some(ref params) = self.params {
281 let port_part = params
282 .port
283 .as_ref()
284 .map(|p| format!("literal={p}"))
285 .or_else(|| {
286 params
287 .port_secret
288 .as_ref()
289 .map(|s| format!("secret={}\0{}", s.name, s.key))
290 })
291 .unwrap_or_else(|| "5432".to_string());
292 format!(
293 "params\0{}\0{}\0{}",
294 field_identity_repr(¶ms.host, ¶ms.host_secret),
295 field_identity_repr(¶ms.dbname, ¶ms.dbname_secret),
296 port_part,
297 )
298 } else {
299 "invalid-connection".to_string()
300 }
301 }
302
303 pub fn cache_key(&self, namespace: &str) -> String {
307 if let Some(ref params) = self.params {
308 let user_part = field_identity_repr(¶ms.username, ¶ms.username_secret);
309 let pass_part = field_identity_repr(¶ms.password, ¶ms.password_secret);
310 let ssl_part = params
311 .ssl_mode
312 .as_ref()
313 .map(|v| format!("literal={v}"))
314 .or_else(|| {
315 params
316 .ssl_mode_secret
317 .as_ref()
318 .map(|s| format!("secret={}\0{}", s.name, s.key))
319 })
320 .unwrap_or_default();
321 format!(
322 "{namespace}/{}\0user={user_part}\0pass={pass_part}\0ssl={ssl_part}",
323 self.identity_key()
324 )
325 } else {
326 format!("{namespace}/{}", self.identity_key())
327 }
328 }
329}
330
331fn field_identity_repr(literal: &Option<String>, secret: &Option<SecretKeySelector>) -> String {
336 if let Some(value) = literal {
337 format!("literal={value}")
338 } else if let Some(sel) = secret {
339 format!("secret={}\0{}", sel.name, sel.key)
340 } else {
341 String::new()
342 }
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
366#[serde(rename_all = "camelCase")]
367pub struct ConnectionParams {
368 #[serde(default)]
370 pub host: Option<String>,
371 #[serde(default)]
373 pub host_secret: Option<SecretKeySelector>,
374
375 #[serde(default)]
377 pub port: Option<u16>,
378 #[serde(default)]
380 pub port_secret: Option<SecretKeySelector>,
381
382 #[serde(default)]
384 pub dbname: Option<String>,
385 #[serde(default)]
387 pub dbname_secret: Option<SecretKeySelector>,
388
389 #[serde(default)]
391 pub username: Option<String>,
392 #[serde(default)]
394 pub username_secret: Option<SecretKeySelector>,
395
396 #[serde(default)]
398 pub password: Option<String>,
399 #[serde(default)]
401 pub password_secret: Option<SecretKeySelector>,
402
403 #[serde(default)]
405 pub ssl_mode: Option<String>,
406 #[serde(default)]
408 pub ssl_mode_secret: Option<SecretKeySelector>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
413pub struct SecretKeySelector {
414 pub name: String,
416 pub key: String,
418}
419
420#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
422pub struct SecretReference {
423 pub name: String,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
431pub struct ProfileSpec {
432 #[serde(default)]
433 pub login: Option<bool>,
434
435 #[serde(default)]
436 pub grants: Vec<ProfileGrantSpec>,
437
438 #[serde(default)]
439 pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
444pub struct ProfileGrantSpec {
445 pub privileges: Vec<Privilege>,
446 #[serde(alias = "on")]
447 pub object: ProfileObjectTargetSpec,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
452pub struct ProfileObjectTargetSpec {
453 #[serde(rename = "type")]
454 pub object_type: ObjectType,
455 #[serde(default)]
456 pub name: Option<String>,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
461pub struct DefaultPrivilegeGrantSpec {
462 #[serde(default)]
463 pub role: Option<String>,
464 pub privileges: Vec<Privilege>,
465 pub on_type: ObjectType,
466}
467
468#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
470pub struct RoleSpec {
471 pub name: String,
472 #[serde(default)]
473 pub login: Option<bool>,
474 #[serde(default)]
475 pub superuser: Option<bool>,
476 #[serde(default)]
477 pub createdb: Option<bool>,
478 #[serde(default)]
479 pub createrole: Option<bool>,
480 #[serde(default)]
481 pub inherit: Option<bool>,
482 #[serde(default)]
483 pub replication: Option<bool>,
484 #[serde(default)]
485 pub bypassrls: Option<bool>,
486 #[serde(default)]
487 pub connection_limit: Option<i32>,
488 #[serde(default)]
489 pub comment: Option<String>,
490 #[serde(default)]
493 pub password: Option<PasswordSpec>,
494 #[serde(default)]
496 pub password_valid_until: Option<String>,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
516#[serde(rename_all = "camelCase")]
517pub struct PasswordSpec {
518 #[serde(default)]
521 pub secret_ref: Option<SecretReference>,
522 #[serde(default)]
525 pub secret_key: Option<String>,
526 #[serde(default)]
529 pub generate: Option<GeneratePasswordSpec>,
530}
531
532impl PasswordSpec {
533 pub fn is_secret_ref(&self) -> bool {
535 self.secret_ref.is_some()
536 }
537
538 pub fn is_generate(&self) -> bool {
540 self.generate.is_some()
541 }
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
546#[serde(rename_all = "camelCase")]
547pub struct GeneratePasswordSpec {
548 #[serde(default)]
550 pub length: Option<u32>,
551 #[serde(default)]
553 pub secret_name: Option<String>,
554 #[serde(default)]
556 pub secret_key: Option<String>,
557}
558
559#[derive(Debug, Clone, thiserror::Error)]
560pub enum PasswordValidationError {
561 #[error("role \"{role}\" has a password but login is not enabled")]
562 PasswordWithoutLogin { role: String },
563
564 #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
565 InvalidPasswordMode { role: String },
566
567 #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
568 InvalidGeneratedLength { role: String, min: u32, max: u32 },
569
570 #[error(
571 "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
572 )]
573 InvalidGeneratedSecretName { role: String, name: String },
574
575 #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
576 InvalidSecretKey {
577 role: String,
578 field: &'static str,
579 key: String,
580 },
581
582 #[error(
583 "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
584 )]
585 ReservedGeneratedSecretKey { role: String, key: String },
586}
587
588#[derive(Debug, Clone, thiserror::Error)]
590pub enum ConnectionValidationError {
591 #[error("connection: exactly one of secretRef or params must be set, but both were provided")]
592 BothModesSet,
593
594 #[error("connection: exactly one of secretRef or params must be set, but neither was provided")]
595 NeitherModeSet,
596
597 #[error("connection.params.{field}: secret {detail}")]
598 EmptySecretKeyRef { field: String, detail: String },
599
600 #[error(
601 "connection.params.sslMode: \"{value}\" is not valid (expected one of: disable, allow, prefer, require, verify-ca, verify-full)"
602 )]
603 InvalidSslMode { value: String },
604
605 #[error("connection.params.{field}: literal value must not be empty or whitespace-only")]
606 EmptyLiteral { field: String },
607
608 #[error("connection.params: exactly one of {field} or {field}Secret must be set")]
609 NeitherFieldSet { field: String },
610
611 #[error(
612 "connection.params: only one of {field} or {field}Secret may be set, but both were provided"
613 )]
614 BothFieldsSet { field: String },
615}
616
617fn is_valid_secret_name(name: &str) -> bool {
621 if name.is_empty() || name.len() > crate::password::MAX_SECRET_NAME_LENGTH {
622 return false;
623 }
624 let bytes = name.as_bytes();
625 if !bytes[0].is_ascii_lowercase() {
627 return false;
628 }
629 if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
630 return false;
631 }
632 bytes
633 .iter()
634 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-' || *b == b'.')
635}
636
637fn is_valid_secret_key(key: &str) -> bool {
638 !key.is_empty()
639 && key
640 .bytes()
641 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
642}
643
644#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
650pub struct PostgresPolicyStatus {
651 #[serde(default)]
653 pub conditions: Vec<PolicyCondition>,
654
655 #[serde(default)]
657 pub observed_generation: Option<i64>,
658
659 #[serde(default)]
661 pub last_attempted_generation: Option<i64>,
662
663 #[serde(default)]
665 pub last_successful_reconcile_time: Option<String>,
666
667 #[serde(default)]
669 pub last_reconcile_time: Option<String>,
670
671 #[serde(default)]
673 pub change_summary: Option<ChangeSummary>,
674
675 #[serde(default)]
677 pub last_reconcile_mode: Option<PolicyMode>,
678
679 #[serde(default)]
681 pub planned_sql: Option<String>,
682
683 #[serde(default)]
685 pub planned_sql_truncated: bool,
686
687 #[serde(default)]
689 pub managed_database_identity: Option<String>,
690
691 #[serde(default)]
693 pub owned_roles: Vec<String>,
694
695 #[serde(default)]
697 pub owned_schemas: Vec<String>,
698
699 #[serde(default)]
701 pub last_error: Option<String>,
702
703 #[serde(default)]
705 pub applied_password_source_versions: BTreeMap<String, String>,
706
707 #[serde(default)]
709 pub transient_failure_count: i32,
710
711 #[serde(default)]
713 pub current_plan_ref: Option<PlanReference>,
714}
715
716#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
718pub struct PolicyCondition {
719 #[serde(rename = "type")]
721 pub condition_type: String,
722
723 pub status: String,
725
726 #[serde(default)]
728 pub reason: Option<String>,
729
730 #[serde(default)]
732 pub message: Option<String>,
733
734 #[serde(default)]
736 pub last_transition_time: Option<String>,
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
741pub struct PlanReference {
742 pub name: String,
743}
744
745#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
747pub struct ChangeSummary {
748 pub roles_created: i32,
749 pub roles_altered: i32,
750 pub roles_dropped: i32,
751 pub sessions_terminated: i32,
752 pub grants_added: i32,
753 pub grants_revoked: i32,
754 pub default_privileges_set: i32,
755 pub default_privileges_revoked: i32,
756 pub members_added: i32,
757 pub members_removed: i32,
758 pub passwords_set: i32,
759 pub total: i32,
760}
761
762#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
771#[kube(
772 group = "pgroles.io",
773 version = "v1alpha1",
774 kind = "PostgresPolicyPlan",
775 namespaced,
776 status = "PostgresPolicyPlanStatus",
777 shortname = "pgplan",
778 category = "pgroles",
779 printcolumn = r#"{"name":"Policy","type":"string","jsonPath":".spec.policyRef.name"}"#,
780 printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.reconciliationMode"}"#,
781 printcolumn = r#"{"name":"Approved","type":"string","jsonPath":".status.conditions[?(@.type==\"Approved\")].status"}"#,
782 printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.changeSummary.total"}"#,
783 printcolumn = r#"{"name":"SQL Stmts","type":"integer","jsonPath":".status.sqlStatements","priority":1}"#,
784 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
785 printcolumn = r#"{"name":"SQL","type":"string","jsonPath":".status.sqlRef.name","priority":1}"#,
786 printcolumn = r#"{"name":"Hash","type":"string","jsonPath":".status.sqlHash","priority":1}"#,
787 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
788)]
789#[serde(rename_all = "camelCase")]
790pub struct PostgresPolicyPlanSpec {
791 pub policy_ref: PolicyPlanRef,
793 pub policy_generation: i64,
795 pub reconciliation_mode: CrdReconciliationMode,
797 #[serde(default)]
799 pub owned_roles: Vec<String>,
800 #[serde(default)]
802 pub owned_schemas: Vec<String>,
803 pub managed_database_identity: String,
805}
806
807#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
809pub struct PolicyPlanRef {
810 pub name: String,
811}
812
813#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
815#[serde(rename_all = "camelCase")]
816pub struct PostgresPolicyPlanStatus {
817 #[serde(default)]
819 pub phase: PlanPhase,
820 #[serde(default)]
822 pub conditions: Vec<PolicyCondition>,
823 #[serde(default)]
825 pub change_summary: Option<ChangeSummary>,
826 #[serde(default)]
828 pub sql_ref: Option<SqlRef>,
829 #[serde(default)]
831 pub sql_inline: Option<String>,
832 #[serde(default)]
834 pub computed_at: Option<String>,
835 #[serde(default)]
837 pub applied_at: Option<String>,
838 #[serde(default)]
840 pub last_error: Option<String>,
841 #[serde(default)]
845 pub sql_hash: Option<String>,
846 #[serde(default)]
848 pub applying_since: Option<String>,
849 #[serde(default)]
851 pub failed_at: Option<String>,
852 #[serde(default)]
856 pub sql_statements: Option<i64>,
857}
858
859#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
861pub struct SqlRef {
862 pub name: String,
863 pub key: String,
864}
865
866#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
868pub enum PlanPhase {
869 #[default]
870 Pending,
871 Approved,
872 Applying,
873 Applied,
874 Failed,
875 Superseded,
876 Rejected,
877}
878
879impl std::fmt::Display for PlanPhase {
880 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
881 match self {
882 PlanPhase::Pending => write!(f, "Pending"),
883 PlanPhase::Approved => write!(f, "Approved"),
884 PlanPhase::Applying => write!(f, "Applying"),
885 PlanPhase::Applied => write!(f, "Applied"),
886 PlanPhase::Failed => write!(f, "Failed"),
887 PlanPhase::Superseded => write!(f, "Superseded"),
888 PlanPhase::Rejected => write!(f, "Rejected"),
889 }
890 }
891}
892
893#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
899pub struct DatabaseIdentity(String);
900
901impl DatabaseIdentity {
902 pub fn from_connection(namespace: &str, connection: &ConnectionSpec) -> Self {
904 Self(format!("{namespace}/{}", connection.identity_key()))
905 }
906
907 pub fn as_str(&self) -> &str {
908 &self.0
909 }
910}
911
912#[derive(Debug, Clone, Default, PartialEq, Eq)]
914pub struct OwnershipClaims {
915 pub roles: BTreeSet<String>,
916 pub schemas: BTreeSet<String>,
917}
918
919impl OwnershipClaims {
920 pub fn overlaps(&self, other: &Self) -> bool {
921 !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
922 }
923
924 pub fn overlap_summary(&self, other: &Self) -> String {
925 let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
926 let overlapping_schemas: Vec<_> =
927 self.schemas.intersection(&other.schemas).cloned().collect();
928
929 let mut parts = Vec::new();
930 if !overlapping_roles.is_empty() {
931 parts.push(format!("roles: {}", overlapping_roles.join(", ")));
932 }
933 if !overlapping_schemas.is_empty() {
934 parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
935 }
936
937 parts.join("; ")
938 }
939}
940
941impl PostgresPolicySpec {
946 pub fn validate_password_specs(
947 &self,
948 policy_name: &str,
949 ) -> Result<(), PasswordValidationError> {
950 for role in &self.roles {
951 let Some(password) = &role.password else {
952 continue;
953 };
954
955 if role.login != Some(true) {
956 return Err(PasswordValidationError::PasswordWithoutLogin {
957 role: role.name.clone(),
958 });
959 }
960
961 match (&password.secret_ref, &password.generate) {
962 (Some(_), None) => {
963 let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
964 if !is_valid_secret_key(secret_key) {
965 return Err(PasswordValidationError::InvalidSecretKey {
966 role: role.name.clone(),
967 field: "secretKey",
968 key: secret_key.to_string(),
969 });
970 }
971 }
972 (None, Some(generate)) => {
973 if let Some(length) = generate.length
974 && !(crate::password::MIN_PASSWORD_LENGTH
975 ..=crate::password::MAX_PASSWORD_LENGTH)
976 .contains(&length)
977 {
978 return Err(PasswordValidationError::InvalidGeneratedLength {
979 role: role.name.clone(),
980 min: crate::password::MIN_PASSWORD_LENGTH,
981 max: crate::password::MAX_PASSWORD_LENGTH,
982 });
983 }
984
985 let secret_name =
986 crate::password::generated_secret_name(policy_name, &role.name, generate);
987 if !is_valid_secret_name(&secret_name) {
988 return Err(PasswordValidationError::InvalidGeneratedSecretName {
989 role: role.name.clone(),
990 name: secret_name,
991 });
992 }
993
994 let secret_key = crate::password::generated_secret_key(generate);
995 if !is_valid_secret_key(&secret_key) {
996 return Err(PasswordValidationError::InvalidSecretKey {
997 role: role.name.clone(),
998 field: "generate.secretKey",
999 key: secret_key,
1000 });
1001 }
1002 if secret_key == crate::password::GENERATED_VERIFIER_KEY {
1003 return Err(PasswordValidationError::ReservedGeneratedSecretKey {
1004 role: role.name.clone(),
1005 key: secret_key,
1006 });
1007 }
1008 }
1009 _ => {
1010 return Err(PasswordValidationError::InvalidPasswordMode {
1011 role: role.name.clone(),
1012 });
1013 }
1014 }
1015 }
1016
1017 Ok(())
1018 }
1019
1020 pub fn validate_connection_spec(&self) -> Result<(), ConnectionValidationError> {
1025 let conn = &self.connection;
1026 match (&conn.secret_ref, &conn.params) {
1027 (Some(_), None) => {
1028 Ok(())
1030 }
1031 (None, Some(params)) => {
1032 fn validate_required_field(
1034 field: &str,
1035 literal: &Option<String>,
1036 secret: &Option<SecretKeySelector>,
1037 ) -> Result<(), ConnectionValidationError> {
1038 match (literal, secret) {
1039 (Some(_), Some(_)) => {
1040 return Err(ConnectionValidationError::BothFieldsSet {
1041 field: field.to_string(),
1042 });
1043 }
1044 (None, None) => {
1045 return Err(ConnectionValidationError::NeitherFieldSet {
1046 field: field.to_string(),
1047 });
1048 }
1049 (Some(s), None) => {
1050 if s.trim().is_empty() {
1051 return Err(ConnectionValidationError::EmptyLiteral {
1052 field: field.to_string(),
1053 });
1054 }
1055 }
1056 (None, Some(sel)) => {
1057 validate_secret_selector(field, sel)?;
1058 }
1059 }
1060 Ok(())
1061 }
1062
1063 fn validate_optional_field(
1065 field: &str,
1066 literal: &Option<impl AsRef<str>>,
1067 secret: &Option<SecretKeySelector>,
1068 ) -> Result<(), ConnectionValidationError> {
1069 let has_literal = literal.is_some();
1070 if has_literal && secret.is_some() {
1071 return Err(ConnectionValidationError::BothFieldsSet {
1072 field: field.to_string(),
1073 });
1074 }
1075 if let Some(s) = literal
1076 && s.as_ref().trim().is_empty()
1077 {
1078 return Err(ConnectionValidationError::EmptyLiteral {
1079 field: field.to_string(),
1080 });
1081 }
1082 if let Some(sel) = secret {
1083 validate_secret_selector(field, sel)?;
1084 }
1085 Ok(())
1086 }
1087
1088 fn validate_secret_selector(
1089 field: &str,
1090 sel: &SecretKeySelector,
1091 ) -> Result<(), ConnectionValidationError> {
1092 if sel.name.trim().is_empty() {
1093 return Err(ConnectionValidationError::EmptySecretKeyRef {
1094 field: field.to_string(),
1095 detail: "name must not be empty".to_string(),
1096 });
1097 }
1098 if sel.key.trim().is_empty() {
1099 return Err(ConnectionValidationError::EmptySecretKeyRef {
1100 field: field.to_string(),
1101 detail: "key must not be empty".to_string(),
1102 });
1103 }
1104 Ok(())
1105 }
1106
1107 validate_required_field("host", ¶ms.host, ¶ms.host_secret)?;
1109 validate_required_field("dbname", ¶ms.dbname, ¶ms.dbname_secret)?;
1110 validate_required_field("username", ¶ms.username, ¶ms.username_secret)?;
1111 validate_required_field("password", ¶ms.password, ¶ms.password_secret)?;
1112
1113 let port_str = params.port.map(|p| p.to_string());
1116 validate_optional_field("port", &port_str, ¶ms.port_secret)?;
1117
1118 validate_optional_field("sslMode", ¶ms.ssl_mode, ¶ms.ssl_mode_secret)?;
1119
1120 if let Some(value) = ¶ms.ssl_mode
1122 && !VALID_SSL_MODES.contains(&value.as_str())
1123 {
1124 return Err(ConnectionValidationError::InvalidSslMode {
1125 value: value.clone(),
1126 });
1127 }
1128
1129 Ok(())
1130 }
1131 (Some(_), Some(_)) => Err(ConnectionValidationError::BothModesSet),
1132 (None, None) => Err(ConnectionValidationError::NeitherModeSet),
1133 }
1134 }
1135
1136 pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
1142 let mut names = BTreeSet::new();
1143 self.connection.collect_secret_names(&mut names);
1145 for role in &self.roles {
1146 if let Some(pw) = &role.password {
1147 if let Some(secret_ref) = &pw.secret_ref {
1148 names.insert(secret_ref.name.clone());
1149 }
1150 if let Some(gen_spec) = &pw.generate {
1151 let secret_name =
1152 crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
1153 names.insert(secret_name);
1154 }
1155 }
1156 }
1157 names
1158 }
1159}
1160
1161impl PostgresPolicySpec {
1166 pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
1168 use pgroles_core::manifest::{
1169 DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
1170 ProfileObjectTarget, RoleDefinition,
1171 };
1172
1173 let profiles = self
1174 .profiles
1175 .iter()
1176 .map(|(name, spec)| {
1177 let profile = Profile {
1178 login: spec.login,
1179 grants: spec
1180 .grants
1181 .iter()
1182 .map(|g| ProfileGrant {
1183 privileges: g.privileges.clone(),
1184 object: ProfileObjectTarget {
1185 object_type: g.object.object_type,
1186 name: g.object.name.clone(),
1187 },
1188 })
1189 .collect(),
1190 default_privileges: spec
1191 .default_privileges
1192 .iter()
1193 .map(|dp| DefaultPrivilegeGrant {
1194 role: dp.role.clone(),
1195 privileges: dp.privileges.clone(),
1196 on_type: dp.on_type,
1197 })
1198 .collect(),
1199 };
1200 (name.clone(), profile)
1201 })
1202 .collect();
1203
1204 let roles = self
1205 .roles
1206 .iter()
1207 .map(|r| RoleDefinition {
1208 name: r.name.clone(),
1209 login: r.login,
1210 superuser: r.superuser,
1211 createdb: r.createdb,
1212 createrole: r.createrole,
1213 inherit: r.inherit,
1214 replication: r.replication,
1215 bypassrls: r.bypassrls,
1216 connection_limit: r.connection_limit,
1217 comment: r.comment.clone(),
1218 password: None, password_valid_until: r.password_valid_until.clone(),
1220 })
1221 .collect();
1222
1223 let memberships = self
1227 .memberships
1228 .iter()
1229 .map(|m| pgroles_core::manifest::Membership {
1230 role: m.role.clone(),
1231 members: m
1232 .members
1233 .iter()
1234 .map(|ms| MemberSpec {
1235 name: ms.name.clone(),
1236 inherit: ms.inherit,
1237 admin: ms.admin,
1238 })
1239 .collect(),
1240 })
1241 .collect();
1242
1243 PolicyManifest {
1244 default_owner: self.default_owner.clone(),
1245 auth_providers: Vec::new(),
1246 profiles,
1247 schemas: self.schemas.clone(),
1248 roles,
1249 grants: self.grants.clone(),
1250 default_privileges: self.default_privileges.clone(),
1251 memberships,
1252 retirements: self.retirements.clone(),
1253 }
1254 }
1255
1256 pub fn ownership_claims(
1261 &self,
1262 ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
1263 let manifest = self.to_policy_manifest();
1264 let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
1265
1266 let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
1267 let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
1268
1269 roles.extend(manifest.retirements.into_iter().map(|r| r.role));
1270 roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
1271 roles.extend(
1272 manifest
1273 .default_privileges
1274 .iter()
1275 .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
1276 );
1277 roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
1278 roles.extend(
1279 manifest
1280 .memberships
1281 .iter()
1282 .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
1283 );
1284
1285 schemas.extend(
1286 manifest
1287 .grants
1288 .iter()
1289 .filter_map(|g| match g.object.object_type {
1290 ObjectType::Database => None,
1291 ObjectType::Schema => g.object.name.clone(),
1292 _ => g.object.schema.clone(),
1293 }),
1294 );
1295 schemas.extend(
1296 manifest
1297 .default_privileges
1298 .iter()
1299 .map(|dp| dp.schema.clone()),
1300 );
1301
1302 Ok(OwnershipClaims { roles, schemas })
1303 }
1304}
1305
1306impl PostgresPolicyStatus {
1311 pub fn set_condition(&mut self, new: PolicyCondition) {
1316 if let Some(existing) = self
1317 .conditions
1318 .iter()
1319 .find(|c| c.condition_type == new.condition_type)
1320 && existing.status == new.status
1321 {
1322 let mut updated = new;
1324 updated.last_transition_time = existing.last_transition_time.clone();
1325 self.conditions
1326 .retain(|c| c.condition_type != updated.condition_type);
1327 self.conditions.push(updated);
1328 return;
1329 }
1330 self.conditions
1332 .retain(|c| c.condition_type != new.condition_type);
1333 self.conditions.push(new);
1334 }
1335}
1336
1337pub fn now_rfc3339() -> String {
1339 use std::time::SystemTime;
1342 let now = SystemTime::now()
1343 .duration_since(SystemTime::UNIX_EPOCH)
1344 .unwrap_or_default();
1345 let secs = now.as_secs();
1347 let days = secs / 86400;
1348 let remaining = secs % 86400;
1349 let hours = remaining / 3600;
1350 let minutes = (remaining % 3600) / 60;
1351 let seconds = remaining % 60;
1352
1353 let (year, month, day) = days_to_date(days);
1355 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
1356}
1357
1358pub fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
1360 let z = days_since_epoch + 719468;
1362 let era = z / 146097;
1363 let doe = z - era * 146097;
1364 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1365 let y = yoe + era * 400;
1366 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1367 let mp = (5 * doy + 2) / 153;
1368 let d = doy - (153 * mp + 2) / 5 + 1;
1369 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1370 let y = if m <= 2 { y + 1 } else { y };
1371 (y, m, d)
1372}
1373
1374pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1376 PolicyCondition {
1377 condition_type: "Ready".to_string(),
1378 status: if status { "True" } else { "False" }.to_string(),
1379 reason: Some(reason.to_string()),
1380 message: Some(message.to_string()),
1381 last_transition_time: Some(now_rfc3339()),
1382 }
1383}
1384
1385pub fn reconciling_condition(message: &str) -> PolicyCondition {
1387 PolicyCondition {
1388 condition_type: "Reconciling".to_string(),
1389 status: "True".to_string(),
1390 reason: Some("Reconciling".to_string()),
1391 message: Some(message.to_string()),
1392 last_transition_time: Some(now_rfc3339()),
1393 }
1394}
1395
1396pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
1398 PolicyCondition {
1399 condition_type: "Degraded".to_string(),
1400 status: "True".to_string(),
1401 reason: Some(reason.to_string()),
1402 message: Some(message.to_string()),
1403 last_transition_time: Some(now_rfc3339()),
1404 }
1405}
1406
1407pub fn paused_condition(message: &str) -> PolicyCondition {
1409 PolicyCondition {
1410 condition_type: "Paused".to_string(),
1411 status: "True".to_string(),
1412 reason: Some("Suspended".to_string()),
1413 message: Some(message.to_string()),
1414 last_transition_time: Some(now_rfc3339()),
1415 }
1416}
1417
1418pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
1420 PolicyCondition {
1421 condition_type: "Conflict".to_string(),
1422 status: "True".to_string(),
1423 reason: Some(reason.to_string()),
1424 message: Some(message.to_string()),
1425 last_transition_time: Some(now_rfc3339()),
1426 }
1427}
1428
1429pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1431 PolicyCondition {
1432 condition_type: "Drifted".to_string(),
1433 status: if status { "True" } else { "False" }.to_string(),
1434 reason: Some(reason.to_string()),
1435 message: Some(message.to_string()),
1436 last_transition_time: Some(now_rfc3339()),
1437 }
1438}
1439
1440#[cfg(test)]
1445mod tests {
1446 use super::*;
1447 use kube::CustomResourceExt;
1448
1449 #[test]
1450 fn crd_generates_valid_schema() {
1451 let crd = PostgresPolicy::crd();
1452 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
1453 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
1454 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
1455 assert!(
1456 yaml.contains("PostgresPolicy"),
1457 "kind should be PostgresPolicy"
1458 );
1459 assert!(
1460 yaml.contains("\"mode\"") || yaml.contains(" mode:"),
1461 "schema should declare spec.mode"
1462 );
1463 assert!(
1464 yaml.contains("\"object\"") || yaml.contains(" object:"),
1465 "schema should declare grant object targets using object"
1466 );
1467 }
1468
1469 #[test]
1470 fn spec_to_policy_manifest_roundtrip() {
1471 let spec = PostgresPolicySpec {
1472 connection: ConnectionSpec {
1473 secret_ref: Some(SecretReference {
1474 name: "pg-secret".to_string(),
1475 }),
1476 secret_key: Some("DATABASE_URL".to_string()),
1477 params: None,
1478 },
1479 interval: "5m".to_string(),
1480 suspend: false,
1481 mode: PolicyMode::Apply,
1482 reconciliation_mode: CrdReconciliationMode::default(),
1483 default_owner: Some("app_owner".to_string()),
1484 profiles: std::collections::HashMap::new(),
1485 schemas: vec![],
1486 roles: vec![RoleSpec {
1487 name: "analytics".to_string(),
1488 login: Some(true),
1489 superuser: None,
1490 createdb: None,
1491 createrole: None,
1492 inherit: None,
1493 replication: None,
1494 bypassrls: None,
1495 connection_limit: None,
1496 comment: Some("test role".to_string()),
1497 password: None,
1498 password_valid_until: None,
1499 }],
1500 grants: vec![],
1501 default_privileges: vec![],
1502 memberships: vec![],
1503 retirements: vec![RoleRetirement {
1504 role: "legacy-app".to_string(),
1505 reassign_owned_to: Some("app_owner".to_string()),
1506 drop_owned: true,
1507 terminate_sessions: true,
1508 }],
1509 approval: None,
1510 };
1511
1512 let manifest = spec.to_policy_manifest();
1513 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
1514 assert_eq!(manifest.roles.len(), 1);
1515 assert_eq!(manifest.roles[0].name, "analytics");
1516 assert_eq!(manifest.roles[0].login, Some(true));
1517 assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
1518 assert_eq!(manifest.retirements.len(), 1);
1519 assert_eq!(manifest.retirements[0].role, "legacy-app");
1520 assert_eq!(
1521 manifest.retirements[0].reassign_owned_to.as_deref(),
1522 Some("app_owner")
1523 );
1524 assert!(manifest.retirements[0].drop_owned);
1525 assert!(manifest.retirements[0].terminate_sessions);
1526 }
1527
1528 #[test]
1529 fn status_set_condition_replaces_existing() {
1530 let mut status = PostgresPolicyStatus::default();
1531
1532 status.set_condition(ready_condition(false, "Pending", "Initial"));
1533 assert_eq!(status.conditions.len(), 1);
1534 assert_eq!(status.conditions[0].status, "False");
1535
1536 status.set_condition(ready_condition(true, "Reconciled", "All good"));
1537 assert_eq!(status.conditions.len(), 1);
1538 assert_eq!(status.conditions[0].status, "True");
1539 assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
1540 }
1541
1542 #[test]
1543 fn status_set_condition_adds_new_type() {
1544 let mut status = PostgresPolicyStatus::default();
1545
1546 status.set_condition(ready_condition(true, "OK", "ready"));
1547 status.set_condition(degraded_condition("Error", "something broke"));
1548
1549 assert_eq!(status.conditions.len(), 2);
1550 }
1551
1552 #[test]
1553 fn paused_condition_has_expected_shape() {
1554 let paused = paused_condition("paused by spec");
1555 assert_eq!(paused.condition_type, "Paused");
1556 assert_eq!(paused.status, "True");
1557 assert_eq!(paused.reason.as_deref(), Some("Suspended"));
1558 }
1559
1560 #[test]
1561 fn ownership_claims_include_expanded_roles_and_schemas() {
1562 let mut profiles = std::collections::HashMap::new();
1563 profiles.insert(
1564 "editor".to_string(),
1565 ProfileSpec {
1566 login: Some(false),
1567 grants: vec![],
1568 default_privileges: vec![],
1569 },
1570 );
1571
1572 let spec = PostgresPolicySpec {
1573 connection: ConnectionSpec {
1574 secret_ref: Some(SecretReference {
1575 name: "pg-secret".to_string(),
1576 }),
1577 secret_key: Some("DATABASE_URL".to_string()),
1578 params: None,
1579 },
1580 interval: "5m".to_string(),
1581 suspend: false,
1582 mode: PolicyMode::Apply,
1583 reconciliation_mode: CrdReconciliationMode::default(),
1584 default_owner: None,
1585 profiles,
1586 schemas: vec![SchemaBinding {
1587 name: "inventory".to_string(),
1588 profiles: vec!["editor".to_string()],
1589 role_pattern: "{schema}-{profile}".to_string(),
1590 owner: None,
1591 }],
1592 roles: vec![RoleSpec {
1593 name: "app-service".to_string(),
1594 login: Some(true),
1595 superuser: None,
1596 createdb: None,
1597 createrole: None,
1598 inherit: None,
1599 replication: None,
1600 bypassrls: None,
1601 connection_limit: None,
1602 comment: None,
1603 password: None,
1604 password_valid_until: None,
1605 }],
1606 grants: vec![],
1607 default_privileges: vec![],
1608 memberships: vec![],
1609 retirements: vec![RoleRetirement {
1610 role: "legacy-app".to_string(),
1611 reassign_owned_to: None,
1612 drop_owned: false,
1613 terminate_sessions: false,
1614 }],
1615 approval: None,
1616 };
1617
1618 let claims = spec.ownership_claims().unwrap();
1619 assert!(claims.roles.contains("inventory-editor"));
1620 assert!(claims.roles.contains("app-service"));
1621 assert!(claims.roles.contains("legacy-app"));
1622 assert!(claims.schemas.contains("inventory"));
1623 }
1624
1625 #[test]
1626 fn ownership_overlap_summary_reports_roles_and_schemas() {
1627 let mut left = OwnershipClaims::default();
1628 left.roles.insert("analytics".to_string());
1629 left.schemas.insert("reporting".to_string());
1630
1631 let mut right = OwnershipClaims::default();
1632 right.roles.insert("analytics".to_string());
1633 right.schemas.insert("reporting".to_string());
1634 right.schemas.insert("other".to_string());
1635
1636 assert!(left.overlaps(&right));
1637 let summary = left.overlap_summary(&right);
1638 assert!(summary.contains("roles: analytics"));
1639 assert!(summary.contains("schemas: reporting"));
1640 }
1641
1642 #[test]
1643 fn database_identity_uses_namespace_and_identity_key() {
1644 let conn = ConnectionSpec {
1645 secret_ref: Some(SecretReference {
1646 name: "db-creds".to_string(),
1647 }),
1648 secret_key: Some("DATABASE_URL".to_string()),
1649 params: None,
1650 };
1651 let identity = DatabaseIdentity::from_connection("prod", &conn);
1652 assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
1653 }
1654
1655 #[test]
1656 fn identity_key_same_database_different_users_are_equal() {
1657 let user_a = ConnectionSpec {
1660 secret_ref: None,
1661 secret_key: None,
1662 params: Some(ConnectionParams {
1663 host: Some("my-host".into()),
1664 host_secret: None,
1665 port: None,
1666 port_secret: None,
1667 dbname: Some("mydb".into()),
1668 dbname_secret: None,
1669 username: Some("alice".into()),
1670 username_secret: None,
1671 password: Some("pass-a".into()),
1672 password_secret: None,
1673 ssl_mode: None,
1674 ssl_mode_secret: None,
1675 }),
1676 };
1677 let user_b = ConnectionSpec {
1678 secret_ref: None,
1679 secret_key: None,
1680 params: Some(ConnectionParams {
1681 host: Some("my-host".into()),
1682 host_secret: None,
1683 port: None,
1684 port_secret: None,
1685 dbname: Some("mydb".into()),
1686 dbname_secret: None,
1687 username: Some("bob".into()),
1688 username_secret: None,
1689 password: Some("pass-b".into()),
1690 password_secret: None,
1691 ssl_mode: None,
1692 ssl_mode_secret: None,
1693 }),
1694 };
1695
1696 assert_eq!(
1697 user_a.identity_key(),
1698 user_b.identity_key(),
1699 "same database with different users should have the same identity key"
1700 );
1701 assert_ne!(
1703 user_a.cache_key("default"),
1704 user_b.cache_key("default"),
1705 "different credentials should produce different cache keys"
1706 );
1707 }
1708
1709 #[test]
1710 fn cache_key_no_collision_between_literal_and_secret_username() {
1711 let literal_conn = ConnectionSpec {
1714 secret_ref: None,
1715 secret_key: None,
1716 params: Some(ConnectionParams {
1717 host: Some("my-host".into()),
1718 host_secret: None,
1719 port: None,
1720 port_secret: None,
1721 dbname: Some("mydb".into()),
1722 dbname_secret: None,
1723 username: Some("secret=creds\0password".into()),
1724 username_secret: None,
1725 password: Some("pass".into()),
1726 password_secret: None,
1727 ssl_mode: None,
1728 ssl_mode_secret: None,
1729 }),
1730 };
1731 let secret_conn = ConnectionSpec {
1732 secret_ref: None,
1733 secret_key: None,
1734 params: Some(ConnectionParams {
1735 host: Some("my-host".into()),
1736 host_secret: None,
1737 port: None,
1738 port_secret: None,
1739 dbname: Some("mydb".into()),
1740 dbname_secret: None,
1741 username: None,
1742 username_secret: Some(SecretKeySelector {
1743 name: "creds".into(),
1744 key: "password".into(),
1745 }),
1746 password: Some("pass".into()),
1747 password_secret: None,
1748 ssl_mode: None,
1749 ssl_mode_secret: None,
1750 }),
1751 };
1752
1753 assert_ne!(
1754 literal_conn.cache_key("default"),
1755 secret_conn.cache_key("default"),
1756 "literal and secret ref should produce different cache keys"
1757 );
1758 }
1759
1760 #[test]
1761 fn cache_key_includes_ssl_mode() {
1762 let conn_no_ssl = ConnectionSpec {
1763 secret_ref: None,
1764 secret_key: None,
1765 params: Some(ConnectionParams {
1766 host: Some("host".into()),
1767 host_secret: None,
1768 port: None,
1769 port_secret: None,
1770 dbname: Some("db".into()),
1771 dbname_secret: None,
1772 username: Some("user".into()),
1773 username_secret: None,
1774 password: Some("pass".into()),
1775 password_secret: None,
1776 ssl_mode: None,
1777 ssl_mode_secret: None,
1778 }),
1779 };
1780 let conn_with_ssl = ConnectionSpec {
1781 secret_ref: None,
1782 secret_key: None,
1783 params: Some(ConnectionParams {
1784 host: Some("host".into()),
1785 host_secret: None,
1786 port: None,
1787 port_secret: None,
1788 dbname: Some("db".into()),
1789 dbname_secret: None,
1790 username: Some("user".into()),
1791 username_secret: None,
1792 password: Some("pass".into()),
1793 password_secret: None,
1794 ssl_mode: Some("require".into()),
1795 ssl_mode_secret: None,
1796 }),
1797 };
1798
1799 assert_ne!(
1800 conn_no_ssl.cache_key("ns"),
1801 conn_with_ssl.cache_key("ns"),
1802 "cache key should differ when sslMode is present"
1803 );
1804 }
1805
1806 #[test]
1807 fn validate_connection_rejects_empty_literal_host() {
1808 let spec = spec_with_connection(ConnectionSpec {
1809 secret_ref: None,
1810 secret_key: None,
1811 params: Some(ConnectionParams {
1812 host: Some("".into()),
1813 host_secret: None,
1814 port: None,
1815 port_secret: None,
1816 dbname: Some("mydb".into()),
1817 dbname_secret: None,
1818 username: Some("user".into()),
1819 username_secret: None,
1820 password: Some("pass".into()),
1821 password_secret: None,
1822 ssl_mode: None,
1823 ssl_mode_secret: None,
1824 }),
1825 });
1826
1827 let err = spec.validate_connection_spec().unwrap_err();
1828 assert!(
1829 matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "host"),
1830 "expected EmptyLiteral for host, got: {err}"
1831 );
1832 }
1833
1834 #[test]
1835 fn validate_connection_rejects_whitespace_literal_dbname() {
1836 let spec = spec_with_connection(ConnectionSpec {
1837 secret_ref: None,
1838 secret_key: None,
1839 params: Some(ConnectionParams {
1840 host: Some("host".into()),
1841 host_secret: None,
1842 port: None,
1843 port_secret: None,
1844 dbname: Some(" ".into()),
1845 dbname_secret: None,
1846 username: Some("user".into()),
1847 username_secret: None,
1848 password: Some("pass".into()),
1849 password_secret: None,
1850 ssl_mode: None,
1851 ssl_mode_secret: None,
1852 }),
1853 });
1854
1855 let err = spec.validate_connection_spec().unwrap_err();
1856 assert!(
1857 matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "dbname"),
1858 "expected EmptyLiteral for dbname, got: {err}"
1859 );
1860 }
1861
1862 fn spec_with_connection(connection: ConnectionSpec) -> PostgresPolicySpec {
1864 PostgresPolicySpec {
1865 connection,
1866 interval: "5m".into(),
1867 suspend: false,
1868 mode: PolicyMode::Apply,
1869 reconciliation_mode: CrdReconciliationMode::default(),
1870 default_owner: None,
1871 profiles: Default::default(),
1872 schemas: vec![],
1873 roles: vec![],
1874 grants: vec![],
1875 default_privileges: vec![],
1876 memberships: vec![],
1877 retirements: vec![],
1878 approval: None,
1879 }
1880 }
1881
1882 fn url_mode_connection() -> ConnectionSpec {
1883 ConnectionSpec {
1884 secret_ref: Some(SecretReference {
1885 name: "pg-creds".into(),
1886 }),
1887 secret_key: Some("DATABASE_URL".into()),
1888 params: None,
1889 }
1890 }
1891
1892 fn params_mode_connection() -> ConnectionSpec {
1893 ConnectionSpec {
1894 secret_ref: None,
1895 secret_key: None,
1896 params: Some(ConnectionParams {
1897 host: Some("my-postgres".into()),
1898 host_secret: None,
1899 port: None,
1900 port_secret: None,
1901 dbname: Some("mydb".into()),
1902 dbname_secret: None,
1903 username: None,
1904 username_secret: Some(SecretKeySelector {
1905 name: "pg-creds".into(),
1906 key: "username".into(),
1907 }),
1908 password: None,
1909 password_secret: Some(SecretKeySelector {
1910 name: "pg-creds".into(),
1911 key: "password".into(),
1912 }),
1913 ssl_mode: None,
1914 ssl_mode_secret: None,
1915 }),
1916 }
1917 }
1918
1919 #[test]
1922 fn validate_connection_accepts_url_mode() {
1923 let spec = spec_with_connection(url_mode_connection());
1924 assert!(spec.validate_connection_spec().is_ok());
1925 }
1926
1927 #[test]
1928 fn validate_connection_accepts_params_mode() {
1929 let spec = spec_with_connection(params_mode_connection());
1930 assert!(spec.validate_connection_spec().is_ok());
1931 }
1932
1933 #[test]
1934 fn validate_connection_rejects_both_modes_set() {
1935 let spec = spec_with_connection(ConnectionSpec {
1936 secret_ref: Some(SecretReference {
1937 name: "pg-creds".into(),
1938 }),
1939 secret_key: None,
1940 params: Some(ConnectionParams {
1941 host: Some("host".into()),
1942 host_secret: None,
1943 port: None,
1944 port_secret: None,
1945 dbname: Some("db".into()),
1946 dbname_secret: None,
1947 username: Some("user".into()),
1948 username_secret: None,
1949 password: Some("pass".into()),
1950 password_secret: None,
1951 ssl_mode: None,
1952 ssl_mode_secret: None,
1953 }),
1954 });
1955 assert!(matches!(
1956 spec.validate_connection_spec(),
1957 Err(ConnectionValidationError::BothModesSet)
1958 ));
1959 }
1960
1961 #[test]
1962 fn validate_connection_rejects_neither_mode_set() {
1963 let spec = spec_with_connection(ConnectionSpec {
1964 secret_ref: None,
1965 secret_key: None,
1966 params: None,
1967 });
1968 assert!(spec.validate_connection_spec().is_err());
1969 }
1970
1971 #[test]
1972 fn validate_connection_rejects_invalid_ssl_mode() {
1973 let spec = spec_with_connection(ConnectionSpec {
1974 secret_ref: None,
1975 secret_key: None,
1976 params: Some(ConnectionParams {
1977 host: Some("host".into()),
1978 host_secret: None,
1979 port: None,
1980 port_secret: None,
1981 dbname: Some("db".into()),
1982 dbname_secret: None,
1983 username: Some("user".into()),
1984 username_secret: None,
1985 password: Some("pass".into()),
1986 password_secret: None,
1987 ssl_mode: Some("invalid-mode".into()),
1988 ssl_mode_secret: None,
1989 }),
1990 });
1991 assert!(spec.validate_connection_spec().is_err());
1992 }
1993
1994 #[test]
1995 fn validate_connection_accepts_valid_ssl_modes() {
1996 for mode in &[
1997 "disable",
1998 "allow",
1999 "prefer",
2000 "require",
2001 "verify-ca",
2002 "verify-full",
2003 ] {
2004 let spec = spec_with_connection(ConnectionSpec {
2005 secret_ref: None,
2006 secret_key: None,
2007 params: Some(ConnectionParams {
2008 host: Some("host".into()),
2009 host_secret: None,
2010 port: None,
2011 port_secret: None,
2012 dbname: Some("db".into()),
2013 dbname_secret: None,
2014 username: Some("user".into()),
2015 username_secret: None,
2016 password: Some("pass".into()),
2017 password_secret: None,
2018 ssl_mode: Some((*mode).into()),
2019 ssl_mode_secret: None,
2020 }),
2021 });
2022 assert!(
2023 spec.validate_connection_spec().is_ok(),
2024 "sslMode '{mode}' should be accepted"
2025 );
2026 }
2027 }
2028
2029 #[test]
2030 fn validate_connection_rejects_empty_secret_name() {
2031 let spec = spec_with_connection(ConnectionSpec {
2032 secret_ref: None,
2033 secret_key: None,
2034 params: Some(ConnectionParams {
2035 host: Some("host".into()),
2036 host_secret: None,
2037 port: None,
2038 port_secret: None,
2039 dbname: Some("db".into()),
2040 dbname_secret: None,
2041 username: None,
2042 username_secret: Some(SecretKeySelector {
2043 name: "".into(),
2044 key: "username".into(),
2045 }),
2046 password: Some("pass".into()),
2047 password_secret: None,
2048 ssl_mode: None,
2049 ssl_mode_secret: None,
2050 }),
2051 });
2052 assert!(spec.validate_connection_spec().is_err());
2053 }
2054
2055 #[test]
2056 fn validate_connection_rejects_both_literal_and_secret_for_same_field() {
2057 let spec = spec_with_connection(ConnectionSpec {
2058 secret_ref: None,
2059 secret_key: None,
2060 params: Some(ConnectionParams {
2061 host: Some("host".into()),
2062 host_secret: Some(SecretKeySelector {
2063 name: "s".into(),
2064 key: "k".into(),
2065 }),
2066 port: None,
2067 port_secret: None,
2068 dbname: Some("db".into()),
2069 dbname_secret: None,
2070 username: Some("user".into()),
2071 username_secret: None,
2072 password: Some("pass".into()),
2073 password_secret: None,
2074 ssl_mode: None,
2075 ssl_mode_secret: None,
2076 }),
2077 });
2078 assert!(matches!(
2079 spec.validate_connection_spec(),
2080 Err(ConnectionValidationError::BothFieldsSet { ref field }) if field == "host"
2081 ));
2082 }
2083
2084 #[test]
2085 fn validate_connection_rejects_neither_literal_nor_secret_for_required_field() {
2086 let spec = spec_with_connection(ConnectionSpec {
2087 secret_ref: None,
2088 secret_key: None,
2089 params: Some(ConnectionParams {
2090 host: None,
2091 host_secret: None,
2092 port: None,
2093 port_secret: None,
2094 dbname: Some("db".into()),
2095 dbname_secret: None,
2096 username: Some("user".into()),
2097 username_secret: None,
2098 password: Some("pass".into()),
2099 password_secret: None,
2100 ssl_mode: None,
2101 ssl_mode_secret: None,
2102 }),
2103 });
2104 assert!(matches!(
2105 spec.validate_connection_spec(),
2106 Err(ConnectionValidationError::NeitherFieldSet { ref field }) if field == "host"
2107 ));
2108 }
2109
2110 #[test]
2113 fn connection_spec_backward_compat_url_mode() {
2114 let yaml = r#"
2116secretRef:
2117 name: pg-creds
2118secretKey: DATABASE_URL
2119"#;
2120 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2121 assert!(conn.secret_ref.is_some());
2122 assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
2123 assert!(conn.params.is_none());
2124 }
2125
2126 #[test]
2127 fn connection_spec_backward_compat_default_secret_key() {
2128 let yaml = r#"
2129secretRef:
2130 name: pg-creds
2131"#;
2132 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2133 assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
2134 }
2135
2136 #[test]
2137 fn connection_spec_params_mode_deserializes_keycloak_style() {
2138 let yaml = r#"
2139params:
2140 host: my-postgres
2141 port: 5432
2142 dbname: mydb
2143 usernameSecret:
2144 name: creds
2145 key: username
2146 passwordSecret:
2147 name: creds
2148 key: password
2149 sslMode: require
2150"#;
2151 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2152 assert!(conn.secret_ref.is_none());
2153 let params = conn.params.unwrap();
2154 assert_eq!(params.host.as_deref(), Some("my-postgres"));
2155 assert_eq!(params.port, Some(5432));
2156 assert!(params.username_secret.is_some());
2157 assert_eq!(params.username_secret.as_ref().unwrap().name, "creds");
2158 assert_eq!(params.ssl_mode.as_deref(), Some("require"));
2159 }
2160
2161 #[test]
2162 fn connection_spec_params_mode_all_secrets() {
2163 let yaml = r#"
2165params:
2166 hostSecret:
2167 name: cluster-app
2168 key: host
2169 portSecret:
2170 name: cluster-app
2171 key: port
2172 dbnameSecret:
2173 name: cluster-app
2174 key: dbname
2175 usernameSecret:
2176 name: cluster-app
2177 key: user
2178 passwordSecret:
2179 name: cluster-app
2180 key: password
2181"#;
2182 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2183 let params = conn.params.unwrap();
2184 assert!(params.host.is_none());
2185 assert!(params.host_secret.is_some());
2186 assert_eq!(params.host_secret.as_ref().unwrap().name, "cluster-app");
2187 assert!(params.port.is_none());
2188 assert!(params.port_secret.is_some());
2189 }
2190
2191 #[test]
2194 fn referenced_secret_names_includes_params_secrets() {
2195 let spec = spec_with_connection(params_mode_connection());
2196 let names = spec.referenced_secret_names("test-policy");
2197 assert!(
2198 names.contains("pg-creds"),
2199 "should include the credential secret from params"
2200 );
2201 }
2202
2203 #[test]
2204 fn referenced_secret_names_deduplicates_across_modes() {
2205 let mut spec = spec_with_connection(params_mode_connection());
2207 spec.roles = vec![RoleSpec {
2208 name: "app".into(),
2209 login: Some(true),
2210 password: Some(PasswordSpec {
2211 secret_ref: Some(SecretReference {
2212 name: "pg-creds".into(),
2213 }),
2214 secret_key: Some("app-password".into()),
2215 generate: None,
2216 }),
2217 password_valid_until: None,
2218 superuser: None,
2219 createdb: None,
2220 createrole: None,
2221 inherit: None,
2222 replication: None,
2223 bypassrls: None,
2224 connection_limit: None,
2225 comment: None,
2226 }];
2227 let names = spec.referenced_secret_names("test-policy");
2228 assert_eq!(
2230 names.iter().filter(|n| *n == "pg-creds").count(),
2231 1,
2232 "BTreeSet should deduplicate"
2233 );
2234 }
2235
2236 #[test]
2239 fn connection_params_port_defaults_to_none() {
2240 let yaml = r#"
2241params:
2242 host: my-host
2243 dbname: mydb
2244 username: user
2245 password: pass
2246"#;
2247 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2248 let params = conn.params.unwrap();
2249 assert!(
2250 params.port.is_none(),
2251 "port should default to None (resolved as 5432 at runtime)"
2252 );
2253 assert!(
2254 params.port_secret.is_none(),
2255 "portSecret should also default to None"
2256 );
2257 }
2258
2259 #[test]
2260 fn now_rfc3339_produces_valid_format() {
2261 let ts = now_rfc3339();
2262 assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
2264 assert!(ts.ends_with('Z'), "should end with Z: {ts}");
2265 assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
2266 assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
2267 }
2268
2269 #[test]
2270 fn ready_condition_true_has_expected_shape() {
2271 let cond = ready_condition(true, "Reconciled", "All changes applied");
2272 assert_eq!(cond.condition_type, "Ready");
2273 assert_eq!(cond.status, "True");
2274 assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
2275 assert_eq!(cond.message.as_deref(), Some("All changes applied"));
2276 assert!(cond.last_transition_time.is_some());
2277 }
2278
2279 #[test]
2280 fn ready_condition_false_has_expected_shape() {
2281 let cond = ready_condition(false, "InvalidSpec", "bad manifest");
2282 assert_eq!(cond.condition_type, "Ready");
2283 assert_eq!(cond.status, "False");
2284 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
2285 assert_eq!(cond.message.as_deref(), Some("bad manifest"));
2286 }
2287
2288 #[test]
2289 fn degraded_condition_has_expected_shape() {
2290 let cond = degraded_condition("InvalidSpec", "expansion failed");
2291 assert_eq!(cond.condition_type, "Degraded");
2292 assert_eq!(cond.status, "True");
2293 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
2294 assert_eq!(cond.message.as_deref(), Some("expansion failed"));
2295 assert!(cond.last_transition_time.is_some());
2296 }
2297
2298 #[test]
2299 fn reconciling_condition_has_expected_shape() {
2300 let cond = reconciling_condition("Reconciliation in progress");
2301 assert_eq!(cond.condition_type, "Reconciling");
2302 assert_eq!(cond.status, "True");
2303 assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
2304 assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
2305 assert!(cond.last_transition_time.is_some());
2306 }
2307
2308 #[test]
2309 fn conflict_condition_has_expected_shape() {
2310 let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
2311 assert_eq!(cond.condition_type, "Conflict");
2312 assert_eq!(cond.status, "True");
2313 assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
2314 assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
2315 assert!(cond.last_transition_time.is_some());
2316 }
2317
2318 #[test]
2319 fn ownership_claims_no_overlap() {
2320 let mut left = OwnershipClaims::default();
2321 left.roles.insert("analytics".to_string());
2322 left.schemas.insert("reporting".to_string());
2323
2324 let mut right = OwnershipClaims::default();
2325 right.roles.insert("billing".to_string());
2326 right.schemas.insert("payments".to_string());
2327
2328 assert!(!left.overlaps(&right));
2329 let summary = left.overlap_summary(&right);
2330 assert!(summary.is_empty());
2331 }
2332
2333 #[test]
2334 fn ownership_claims_partial_role_overlap() {
2335 let mut left = OwnershipClaims::default();
2336 left.roles.insert("analytics".to_string());
2337 left.roles.insert("reporting-viewer".to_string());
2338
2339 let mut right = OwnershipClaims::default();
2340 right.roles.insert("analytics".to_string());
2341 right.roles.insert("other-role".to_string());
2342
2343 assert!(left.overlaps(&right));
2344 let summary = left.overlap_summary(&right);
2345 assert!(summary.contains("roles: analytics"));
2346 assert!(!summary.contains("schemas"));
2347 }
2348
2349 #[test]
2350 fn ownership_claims_empty_is_disjoint() {
2351 let left = OwnershipClaims::default();
2352 let right = OwnershipClaims::default();
2353 assert!(!left.overlaps(&right));
2354 }
2355
2356 #[test]
2357 fn database_identity_equality() {
2358 let conn_a = ConnectionSpec {
2359 secret_ref: Some(SecretReference {
2360 name: "db-creds".to_string(),
2361 }),
2362 secret_key: Some("DATABASE_URL".to_string()),
2363 params: None,
2364 };
2365 let a = DatabaseIdentity::from_connection("prod", &conn_a);
2366 let b = DatabaseIdentity::from_connection("prod", &conn_a);
2367 let c = DatabaseIdentity::from_connection("staging", &conn_a);
2368 assert_eq!(a, b);
2369 assert_ne!(a, c);
2370 }
2371
2372 #[test]
2373 fn database_identity_different_key() {
2374 let conn_a = ConnectionSpec {
2375 secret_ref: Some(SecretReference {
2376 name: "db-creds".to_string(),
2377 }),
2378 secret_key: Some("DATABASE_URL".to_string()),
2379 params: None,
2380 };
2381 let conn_b = ConnectionSpec {
2382 secret_ref: Some(SecretReference {
2383 name: "db-creds".to_string(),
2384 }),
2385 secret_key: Some("CUSTOM_URL".to_string()),
2386 params: None,
2387 };
2388 let a = DatabaseIdentity::from_connection("prod", &conn_a);
2389 let b = DatabaseIdentity::from_connection("prod", &conn_b);
2390 assert_ne!(a, b);
2391 }
2392
2393 #[test]
2394 fn status_default_has_empty_conditions() {
2395 let status = PostgresPolicyStatus::default();
2396 assert!(status.conditions.is_empty());
2397 assert!(status.observed_generation.is_none());
2398 assert!(status.last_attempted_generation.is_none());
2399 assert!(status.last_successful_reconcile_time.is_none());
2400 assert!(status.change_summary.is_none());
2401 assert!(status.managed_database_identity.is_none());
2402 assert!(status.owned_roles.is_empty());
2403 assert!(status.owned_schemas.is_empty());
2404 assert!(status.last_error.is_none());
2405 assert!(status.applied_password_source_versions.is_empty());
2406 }
2407
2408 #[test]
2409 fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
2410 let mut status = PostgresPolicyStatus::default();
2411
2412 status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
2414 status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
2415 status
2416 .conditions
2417 .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
2418 status.change_summary = None;
2419 status.last_error = Some("bad manifest".to_string());
2420
2421 let ready = status
2423 .conditions
2424 .iter()
2425 .find(|c| c.condition_type == "Ready")
2426 .expect("should have Ready condition");
2427 assert_eq!(ready.status, "False");
2428 assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
2429
2430 let degraded = status
2432 .conditions
2433 .iter()
2434 .find(|c| c.condition_type == "Degraded")
2435 .expect("should have Degraded condition");
2436 assert_eq!(degraded.status, "True");
2437 assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
2438
2439 assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
2441 }
2442
2443 #[test]
2444 fn status_conflict_workflow() {
2445 let mut status = PostgresPolicyStatus::default();
2446
2447 let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
2449 status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
2450 status.set_condition(conflict_condition("ConflictingPolicy", msg));
2451 status.set_condition(degraded_condition("ConflictingPolicy", msg));
2452 status
2453 .conditions
2454 .retain(|c| c.condition_type != "Reconciling");
2455 status.last_error = Some(msg.to_string());
2456
2457 let conflict = status
2459 .conditions
2460 .iter()
2461 .find(|c| c.condition_type == "Conflict")
2462 .expect("should have Conflict condition");
2463 assert_eq!(conflict.status, "True");
2464 assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
2465
2466 let ready = status
2468 .conditions
2469 .iter()
2470 .find(|c| c.condition_type == "Ready")
2471 .expect("should have Ready condition");
2472 assert_eq!(ready.status, "False");
2473
2474 let degraded = status
2476 .conditions
2477 .iter()
2478 .find(|c| c.condition_type == "Degraded")
2479 .expect("should have Degraded condition");
2480 assert_eq!(degraded.status, "True");
2481 }
2482
2483 #[test]
2484 fn status_successful_reconcile_records_generation_and_time() {
2485 let mut status = PostgresPolicyStatus::default();
2486 let generation = Some(3_i64);
2487 let summary = ChangeSummary {
2488 roles_created: 2,
2489 total: 2,
2490 ..Default::default()
2491 };
2492
2493 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
2495 status.conditions.retain(|c| {
2496 c.condition_type != "Reconciling"
2497 && c.condition_type != "Degraded"
2498 && c.condition_type != "Conflict"
2499 && c.condition_type != "Paused"
2500 });
2501 status.observed_generation = generation;
2502 status.last_attempted_generation = generation;
2503 status.last_successful_reconcile_time = Some(now_rfc3339());
2504 status.last_reconcile_time = Some(now_rfc3339());
2505 status.change_summary = Some(summary);
2506 status.last_error = None;
2507
2508 let ready = status
2510 .conditions
2511 .iter()
2512 .find(|c| c.condition_type == "Ready")
2513 .expect("should have Ready condition");
2514 assert_eq!(ready.status, "True");
2515 assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
2516
2517 assert_eq!(status.observed_generation, Some(3));
2519 assert_eq!(status.last_attempted_generation, Some(3));
2520
2521 assert!(status.last_successful_reconcile_time.is_some());
2523 assert!(status.last_reconcile_time.is_some());
2524
2525 let summary = status.change_summary.as_ref().unwrap();
2527 assert_eq!(summary.roles_created, 2);
2528 assert_eq!(summary.total, 2);
2529
2530 assert!(status.last_error.is_none());
2532
2533 assert!(
2535 status
2536 .conditions
2537 .iter()
2538 .all(|c| c.condition_type != "Degraded"
2539 && c.condition_type != "Conflict"
2540 && c.condition_type != "Paused"
2541 && c.condition_type != "Reconciling")
2542 );
2543 }
2544
2545 #[test]
2546 fn status_suspended_workflow() {
2547 let mut status = PostgresPolicyStatus::default();
2548 let generation = Some(2_i64);
2549
2550 status.set_condition(paused_condition("Reconciliation suspended by spec"));
2552 status.set_condition(ready_condition(
2553 false,
2554 "Suspended",
2555 "Reconciliation suspended by spec",
2556 ));
2557 status
2558 .conditions
2559 .retain(|c| c.condition_type != "Reconciling");
2560 status.last_attempted_generation = generation;
2561 status.last_error = None;
2562
2563 let paused = status
2565 .conditions
2566 .iter()
2567 .find(|c| c.condition_type == "Paused")
2568 .expect("should have Paused condition");
2569 assert_eq!(paused.status, "True");
2570
2571 let ready = status
2573 .conditions
2574 .iter()
2575 .find(|c| c.condition_type == "Ready")
2576 .expect("should have Ready condition");
2577 assert_eq!(ready.status, "False");
2578 assert_eq!(ready.reason.as_deref(), Some("Suspended"));
2579
2580 assert!(
2582 !status
2583 .conditions
2584 .iter()
2585 .any(|c| c.condition_type == "Reconciling")
2586 );
2587 }
2588
2589 #[test]
2590 fn status_transitions_from_degraded_to_ready() {
2591 let mut status = PostgresPolicyStatus::default();
2592
2593 status.set_condition(ready_condition(false, "InvalidSpec", "error"));
2595 status.set_condition(degraded_condition("InvalidSpec", "error"));
2596 status.last_error = Some("error".to_string());
2597
2598 assert_eq!(status.conditions.len(), 2);
2599
2600 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
2602 status.conditions.retain(|c| {
2603 c.condition_type != "Reconciling"
2604 && c.condition_type != "Degraded"
2605 && c.condition_type != "Conflict"
2606 && c.condition_type != "Paused"
2607 });
2608 status.last_error = None;
2609
2610 let ready = status
2612 .conditions
2613 .iter()
2614 .find(|c| c.condition_type == "Ready")
2615 .expect("should have Ready condition");
2616 assert_eq!(ready.status, "True");
2617
2618 assert!(
2620 !status
2621 .conditions
2622 .iter()
2623 .any(|c| c.condition_type == "Degraded")
2624 );
2625
2626 assert_eq!(status.conditions.len(), 1);
2628
2629 assert!(status.last_error.is_none());
2631 }
2632
2633 #[test]
2634 fn change_summary_default_is_all_zero() {
2635 let summary = ChangeSummary::default();
2636 assert_eq!(summary.roles_created, 0);
2637 assert_eq!(summary.roles_altered, 0);
2638 assert_eq!(summary.roles_dropped, 0);
2639 assert_eq!(summary.sessions_terminated, 0);
2640 assert_eq!(summary.grants_added, 0);
2641 assert_eq!(summary.grants_revoked, 0);
2642 assert_eq!(summary.default_privileges_set, 0);
2643 assert_eq!(summary.default_privileges_revoked, 0);
2644 assert_eq!(summary.members_added, 0);
2645 assert_eq!(summary.members_removed, 0);
2646 assert_eq!(summary.total, 0);
2647 }
2648
2649 #[test]
2650 fn status_serializes_to_json() {
2651 let mut status = PostgresPolicyStatus::default();
2652 status.set_condition(ready_condition(true, "Reconciled", "done"));
2653 status.observed_generation = Some(5);
2654 status.managed_database_identity = Some("ns/secret/key".to_string());
2655 status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
2656 status.owned_schemas = vec!["public".to_string()];
2657 status.change_summary = Some(ChangeSummary {
2658 roles_created: 1,
2659 total: 1,
2660 ..Default::default()
2661 });
2662
2663 let json = serde_json::to_string(&status).expect("should serialize");
2664 assert!(json.contains("\"Reconciled\""));
2665 assert!(json.contains("\"observed_generation\":5"));
2666 assert!(json.contains("\"role-a\""));
2667 assert!(json.contains("\"ns/secret/key\""));
2668 }
2669
2670 #[test]
2671 fn crd_spec_deserializes_from_yaml() {
2672 let yaml = r#"
2673connection:
2674 secretRef:
2675 name: pg-credentials
2676interval: "10m"
2677default_owner: app_owner
2678profiles:
2679 editor:
2680 grants:
2681 - privileges: [USAGE]
2682 object: { type: schema }
2683 - privileges: [SELECT, INSERT, UPDATE, DELETE]
2684 object: { type: table, name: "*" }
2685 default_privileges:
2686 - privileges: [SELECT, INSERT, UPDATE, DELETE]
2687 on_type: table
2688schemas:
2689 - name: inventory
2690 profiles: [editor]
2691roles:
2692 - name: analytics
2693 login: true
2694grants:
2695 - role: analytics
2696 privileges: [CONNECT]
2697 object: { type: database, name: mydb }
2698memberships:
2699 - role: inventory-editor
2700 members:
2701 - name: analytics
2702retirements:
2703 - role: legacy-app
2704 reassign_owned_to: app_owner
2705 drop_owned: true
2706 terminate_sessions: true
2707"#;
2708 let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
2709 assert_eq!(spec.interval, "10m");
2710 assert_eq!(spec.default_owner, Some("app_owner".to_string()));
2711 assert_eq!(spec.profiles.len(), 1);
2712 assert!(spec.profiles.contains_key("editor"));
2713 assert_eq!(spec.schemas.len(), 1);
2714 assert_eq!(spec.roles.len(), 1);
2715 assert_eq!(spec.grants.len(), 1);
2716 assert_eq!(spec.memberships.len(), 1);
2717 assert_eq!(spec.retirements.len(), 1);
2718 assert_eq!(spec.retirements[0].role, "legacy-app");
2719 assert!(spec.retirements[0].terminate_sessions);
2720 }
2721
2722 #[test]
2723 fn referenced_secret_names_includes_connection_secret() {
2724 let spec = PostgresPolicySpec {
2725 connection: ConnectionSpec {
2726 secret_ref: Some(SecretReference {
2727 name: "pg-conn".to_string(),
2728 }),
2729 secret_key: Some("DATABASE_URL".to_string()),
2730 params: None,
2731 },
2732 interval: "5m".to_string(),
2733 suspend: false,
2734 mode: PolicyMode::Apply,
2735 reconciliation_mode: CrdReconciliationMode::default(),
2736 default_owner: None,
2737 profiles: std::collections::HashMap::new(),
2738 schemas: vec![],
2739 roles: vec![],
2740 grants: vec![],
2741 default_privileges: vec![],
2742 memberships: vec![],
2743 retirements: vec![],
2744 approval: None,
2745 };
2746
2747 let names = spec.referenced_secret_names("test-policy");
2748 assert!(names.contains("pg-conn"));
2749 assert_eq!(names.len(), 1);
2750 }
2751
2752 #[test]
2753 fn referenced_secret_names_includes_password_secrets() {
2754 let spec = PostgresPolicySpec {
2755 connection: ConnectionSpec {
2756 secret_ref: Some(SecretReference {
2757 name: "pg-conn".to_string(),
2758 }),
2759 secret_key: Some("DATABASE_URL".to_string()),
2760 params: None,
2761 },
2762 interval: "5m".to_string(),
2763 suspend: false,
2764 mode: PolicyMode::Apply,
2765 reconciliation_mode: CrdReconciliationMode::default(),
2766 default_owner: None,
2767 profiles: std::collections::HashMap::new(),
2768 schemas: vec![],
2769 roles: vec![
2770 RoleSpec {
2771 name: "role-a".to_string(),
2772 login: Some(true),
2773 password: Some(PasswordSpec {
2774 secret_ref: Some(SecretReference {
2775 name: "role-passwords".to_string(),
2776 }),
2777 secret_key: Some("role-a".to_string()),
2778 generate: None,
2779 }),
2780 password_valid_until: None,
2781 superuser: None,
2782 createdb: None,
2783 createrole: None,
2784 inherit: None,
2785 replication: None,
2786 bypassrls: None,
2787 connection_limit: None,
2788 comment: None,
2789 },
2790 RoleSpec {
2791 name: "role-b".to_string(),
2792 login: Some(true),
2793 password: Some(PasswordSpec {
2794 secret_ref: Some(SecretReference {
2795 name: "other-secret".to_string(),
2796 }),
2797 secret_key: None,
2798 generate: None,
2799 }),
2800 password_valid_until: None,
2801 superuser: None,
2802 createdb: None,
2803 createrole: None,
2804 inherit: None,
2805 replication: None,
2806 bypassrls: None,
2807 connection_limit: None,
2808 comment: None,
2809 },
2810 RoleSpec {
2811 name: "role-c".to_string(),
2812 login: None,
2813 password: None,
2814 password_valid_until: None,
2815 superuser: None,
2816 createdb: None,
2817 createrole: None,
2818 inherit: None,
2819 replication: None,
2820 bypassrls: None,
2821 connection_limit: None,
2822 comment: None,
2823 },
2824 ],
2825 grants: vec![],
2826 default_privileges: vec![],
2827 memberships: vec![],
2828 retirements: vec![],
2829 approval: None,
2830 };
2831
2832 let names = spec.referenced_secret_names("test-policy");
2833 assert!(
2834 names.contains("pg-conn"),
2835 "should include connection secret"
2836 );
2837 assert!(
2838 names.contains("role-passwords"),
2839 "should include role-a password secret"
2840 );
2841 assert!(
2842 names.contains("other-secret"),
2843 "should include role-b password secret"
2844 );
2845 assert_eq!(names.len(), 3);
2846 }
2847
2848 #[test]
2849 fn validate_password_specs_rejects_password_without_login() {
2850 let spec = PostgresPolicySpec {
2851 connection: ConnectionSpec {
2852 secret_ref: Some(SecretReference {
2853 name: "pg-conn".to_string(),
2854 }),
2855 secret_key: Some("DATABASE_URL".to_string()),
2856 params: None,
2857 },
2858 interval: "5m".to_string(),
2859 suspend: false,
2860 mode: PolicyMode::Apply,
2861 reconciliation_mode: CrdReconciliationMode::default(),
2862 default_owner: None,
2863 profiles: std::collections::HashMap::new(),
2864 schemas: vec![],
2865 roles: vec![RoleSpec {
2866 name: "app-user".to_string(),
2867 login: Some(false),
2868 superuser: None,
2869 createdb: None,
2870 createrole: None,
2871 inherit: None,
2872 replication: None,
2873 bypassrls: None,
2874 connection_limit: None,
2875 comment: None,
2876 password: Some(PasswordSpec {
2877 secret_ref: Some(SecretReference {
2878 name: "role-passwords".to_string(),
2879 }),
2880 secret_key: None,
2881 generate: None,
2882 }),
2883 password_valid_until: None,
2884 }],
2885 grants: vec![],
2886 default_privileges: vec![],
2887 memberships: vec![],
2888 retirements: vec![],
2889 approval: None,
2890 };
2891
2892 assert!(matches!(
2893 spec.validate_password_specs("test-policy"),
2894 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
2895 ));
2896 }
2897
2898 #[test]
2899 fn validate_password_specs_rejects_password_with_login_omitted() {
2900 let spec = PostgresPolicySpec {
2901 connection: ConnectionSpec {
2902 secret_ref: Some(SecretReference {
2903 name: "pg-conn".to_string(),
2904 }),
2905 secret_key: Some("DATABASE_URL".to_string()),
2906 params: None,
2907 },
2908 interval: "5m".to_string(),
2909 suspend: false,
2910 mode: PolicyMode::Apply,
2911 reconciliation_mode: CrdReconciliationMode::default(),
2912 default_owner: None,
2913 profiles: std::collections::HashMap::new(),
2914 schemas: vec![],
2915 roles: vec![RoleSpec {
2916 name: "app-user".to_string(),
2917 login: None, superuser: None,
2919 createdb: None,
2920 createrole: None,
2921 inherit: None,
2922 replication: None,
2923 bypassrls: None,
2924 connection_limit: None,
2925 comment: None,
2926 password: Some(PasswordSpec {
2927 secret_ref: Some(SecretReference {
2928 name: "role-passwords".to_string(),
2929 }),
2930 secret_key: None,
2931 generate: None,
2932 }),
2933 password_valid_until: None,
2934 }],
2935 grants: vec![],
2936 default_privileges: vec![],
2937 memberships: vec![],
2938 retirements: vec![],
2939 approval: None,
2940 };
2941
2942 assert!(matches!(
2943 spec.validate_password_specs("test-policy"),
2944 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
2945 ));
2946 }
2947
2948 #[test]
2949 fn validate_password_specs_rejects_invalid_password_mode() {
2950 let spec = PostgresPolicySpec {
2951 connection: ConnectionSpec {
2952 secret_ref: Some(SecretReference {
2953 name: "pg-conn".to_string(),
2954 }),
2955 secret_key: Some("DATABASE_URL".to_string()),
2956 params: None,
2957 },
2958 interval: "5m".to_string(),
2959 suspend: false,
2960 mode: PolicyMode::Apply,
2961 reconciliation_mode: CrdReconciliationMode::default(),
2962 default_owner: None,
2963 profiles: std::collections::HashMap::new(),
2964 schemas: vec![],
2965 roles: vec![RoleSpec {
2966 name: "app-user".to_string(),
2967 login: Some(true),
2968 superuser: None,
2969 createdb: None,
2970 createrole: None,
2971 inherit: None,
2972 replication: None,
2973 bypassrls: None,
2974 connection_limit: None,
2975 comment: None,
2976 password: Some(PasswordSpec {
2977 secret_ref: Some(SecretReference {
2978 name: "role-passwords".to_string(),
2979 }),
2980 secret_key: None,
2981 generate: Some(GeneratePasswordSpec {
2982 length: Some(32),
2983 secret_name: None,
2984 secret_key: None,
2985 }),
2986 }),
2987 password_valid_until: None,
2988 }],
2989 grants: vec![],
2990 default_privileges: vec![],
2991 memberships: vec![],
2992 retirements: vec![],
2993 approval: None,
2994 };
2995
2996 assert!(matches!(
2997 spec.validate_password_specs("test-policy"),
2998 Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
2999 ));
3000 }
3001
3002 #[test]
3003 fn validate_password_specs_rejects_invalid_generated_length() {
3004 let spec = PostgresPolicySpec {
3005 connection: ConnectionSpec {
3006 secret_ref: Some(SecretReference {
3007 name: "pg-conn".to_string(),
3008 }),
3009 secret_key: Some("DATABASE_URL".to_string()),
3010 params: None,
3011 },
3012 interval: "5m".to_string(),
3013 suspend: false,
3014 mode: PolicyMode::Apply,
3015 reconciliation_mode: CrdReconciliationMode::default(),
3016 default_owner: None,
3017 profiles: std::collections::HashMap::new(),
3018 schemas: vec![],
3019 roles: vec![RoleSpec {
3020 name: "app-user".to_string(),
3021 login: Some(true),
3022 superuser: None,
3023 createdb: None,
3024 createrole: None,
3025 inherit: None,
3026 replication: None,
3027 bypassrls: None,
3028 connection_limit: None,
3029 comment: None,
3030 password: Some(PasswordSpec {
3031 secret_ref: None,
3032 secret_key: None,
3033 generate: Some(GeneratePasswordSpec {
3034 length: Some(8),
3035 secret_name: None,
3036 secret_key: None,
3037 }),
3038 }),
3039 password_valid_until: None,
3040 }],
3041 grants: vec![],
3042 default_privileges: vec![],
3043 memberships: vec![],
3044 retirements: vec![],
3045 approval: None,
3046 };
3047
3048 assert!(matches!(
3049 spec.validate_password_specs("test-policy"),
3050 Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
3051 ));
3052 }
3053
3054 #[test]
3055 fn validate_password_specs_rejects_invalid_generated_secret_key() {
3056 let spec = PostgresPolicySpec {
3057 connection: ConnectionSpec {
3058 secret_ref: Some(SecretReference {
3059 name: "pg-conn".to_string(),
3060 }),
3061 secret_key: Some("DATABASE_URL".to_string()),
3062 params: None,
3063 },
3064 interval: "5m".to_string(),
3065 suspend: false,
3066 mode: PolicyMode::Apply,
3067 reconciliation_mode: CrdReconciliationMode::default(),
3068 default_owner: None,
3069 profiles: std::collections::HashMap::new(),
3070 schemas: vec![],
3071 roles: vec![RoleSpec {
3072 name: "app-user".to_string(),
3073 login: Some(true),
3074 superuser: None,
3075 createdb: None,
3076 createrole: None,
3077 inherit: None,
3078 replication: None,
3079 bypassrls: None,
3080 connection_limit: None,
3081 comment: None,
3082 password: Some(PasswordSpec {
3083 secret_ref: None,
3084 secret_key: None,
3085 generate: Some(GeneratePasswordSpec {
3086 length: Some(32),
3087 secret_name: None,
3088 secret_key: Some("bad/key".to_string()),
3089 }),
3090 }),
3091 password_valid_until: None,
3092 }],
3093 grants: vec![],
3094 default_privileges: vec![],
3095 memberships: vec![],
3096 retirements: vec![],
3097 approval: None,
3098 };
3099
3100 assert!(matches!(
3101 spec.validate_password_specs("test-policy"),
3102 Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
3103 if role == "app-user" && field == "generate.secretKey"
3104 ));
3105 }
3106
3107 #[test]
3108 fn validate_password_specs_rejects_invalid_generated_secret_name() {
3109 let spec = PostgresPolicySpec {
3110 connection: ConnectionSpec {
3111 secret_ref: Some(SecretReference {
3112 name: "pg-conn".to_string(),
3113 }),
3114 secret_key: Some("DATABASE_URL".to_string()),
3115 params: None,
3116 },
3117 interval: "5m".to_string(),
3118 suspend: false,
3119 mode: PolicyMode::Apply,
3120 reconciliation_mode: CrdReconciliationMode::default(),
3121 default_owner: None,
3122 profiles: std::collections::HashMap::new(),
3123 schemas: vec![],
3124 roles: vec![RoleSpec {
3125 name: "app-user".to_string(),
3126 login: Some(true),
3127 superuser: None,
3128 createdb: None,
3129 createrole: None,
3130 inherit: None,
3131 replication: None,
3132 bypassrls: None,
3133 connection_limit: None,
3134 comment: None,
3135 password: Some(PasswordSpec {
3136 secret_ref: None,
3137 secret_key: None,
3138 generate: Some(GeneratePasswordSpec {
3139 length: Some(32),
3140 secret_name: Some("Bad_Name".to_string()),
3141 secret_key: None,
3142 }),
3143 }),
3144 password_valid_until: None,
3145 }],
3146 grants: vec![],
3147 default_privileges: vec![],
3148 memberships: vec![],
3149 retirements: vec![],
3150 approval: None,
3151 };
3152
3153 assert!(matches!(
3154 spec.validate_password_specs("test-policy"),
3155 Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
3156 ));
3157 }
3158
3159 #[test]
3160 fn validate_password_specs_rejects_reserved_generated_secret_key() {
3161 let spec = PostgresPolicySpec {
3162 connection: ConnectionSpec {
3163 secret_ref: Some(SecretReference {
3164 name: "pg-conn".to_string(),
3165 }),
3166 secret_key: Some("DATABASE_URL".to_string()),
3167 params: None,
3168 },
3169 interval: "5m".to_string(),
3170 suspend: false,
3171 mode: PolicyMode::Apply,
3172 reconciliation_mode: CrdReconciliationMode::default(),
3173 default_owner: None,
3174 profiles: std::collections::HashMap::new(),
3175 schemas: vec![],
3176 roles: vec![RoleSpec {
3177 name: "app-user".to_string(),
3178 login: Some(true),
3179 superuser: None,
3180 createdb: None,
3181 createrole: None,
3182 inherit: None,
3183 replication: None,
3184 bypassrls: None,
3185 connection_limit: None,
3186 comment: None,
3187 password: Some(PasswordSpec {
3188 secret_ref: None,
3189 secret_key: None,
3190 generate: Some(GeneratePasswordSpec {
3191 length: Some(32),
3192 secret_name: None,
3193 secret_key: Some("verifier".to_string()),
3194 }),
3195 }),
3196 password_valid_until: None,
3197 }],
3198 grants: vec![],
3199 default_privileges: vec![],
3200 memberships: vec![],
3201 retirements: vec![],
3202 approval: None,
3203 };
3204
3205 assert!(matches!(
3206 spec.validate_password_specs("test-policy"),
3207 Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
3208 if role == "app-user" && key == "verifier"
3209 ));
3210 }
3211
3212 #[test]
3213 fn plan_crd_generates_valid_schema() {
3214 let crd = PostgresPolicyPlan::crd();
3215 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
3216 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
3217 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
3218 assert!(
3219 yaml.contains("PostgresPolicyPlan"),
3220 "kind should be PostgresPolicyPlan"
3221 );
3222 assert!(yaml.contains("pgplan"), "should have shortname pgplan");
3223 }
3224
3225 #[test]
3226 fn plan_phase_display() {
3227 assert_eq!(PlanPhase::Pending.to_string(), "Pending");
3228 assert_eq!(PlanPhase::Approved.to_string(), "Approved");
3229 assert_eq!(PlanPhase::Applying.to_string(), "Applying");
3230 assert_eq!(PlanPhase::Applied.to_string(), "Applied");
3231 assert_eq!(PlanPhase::Failed.to_string(), "Failed");
3232 assert_eq!(PlanPhase::Superseded.to_string(), "Superseded");
3233 }
3234
3235 #[test]
3236 fn plan_phase_default_is_pending() {
3237 assert_eq!(PlanPhase::default(), PlanPhase::Pending);
3238 }
3239
3240 #[test]
3241 fn effective_approval_infers_from_mode() {
3242 let base = PostgresPolicySpec {
3243 connection: ConnectionSpec {
3244 secret_ref: Some(SecretReference {
3245 name: "test".into(),
3246 }),
3247 secret_key: Some("DATABASE_URL".into()),
3248 params: None,
3249 },
3250 interval: "5m".into(),
3251 suspend: false,
3252 mode: PolicyMode::Apply,
3253 reconciliation_mode: CrdReconciliationMode::Authoritative,
3254 default_owner: None,
3255 profiles: Default::default(),
3256 schemas: vec![],
3257 roles: vec![],
3258 grants: vec![],
3259 default_privileges: vec![],
3260 memberships: vec![],
3261 retirements: vec![],
3262 approval: None,
3263 };
3264
3265 assert_eq!(base.effective_approval(), ApprovalMode::Auto);
3267
3268 let plan = PostgresPolicySpec {
3270 mode: PolicyMode::Plan,
3271 ..base.clone()
3272 };
3273 assert_eq!(plan.effective_approval(), ApprovalMode::Manual);
3274
3275 let explicit = PostgresPolicySpec {
3277 approval: Some(ApprovalMode::Manual),
3278 ..base.clone()
3279 };
3280 assert_eq!(explicit.effective_approval(), ApprovalMode::Manual);
3281 }
3282
3283 #[test]
3284 fn approval_mode_serde_roundtrip() {
3285 let manual: ApprovalMode = serde_json::from_str("\"manual\"").unwrap();
3287 assert_eq!(manual, ApprovalMode::Manual);
3288 let auto: ApprovalMode = serde_json::from_str("\"auto\"").unwrap();
3289 assert_eq!(auto, ApprovalMode::Auto);
3290
3291 let manual_json = serde_json::to_value(&ApprovalMode::Manual).unwrap();
3293 assert_eq!(manual_json, serde_json::Value::String("manual".to_string()));
3294 let auto_json = serde_json::to_value(&ApprovalMode::Auto).unwrap();
3295 assert_eq!(auto_json, serde_json::Value::String("auto".to_string()));
3296 }
3297
3298 #[test]
3299 fn plan_status_default_is_empty() {
3300 let status = PostgresPolicyPlanStatus::default();
3301 assert_eq!(status.phase, PlanPhase::Pending);
3302 assert!(status.conditions.is_empty());
3303 assert!(status.change_summary.is_none());
3304 assert!(status.sql_ref.is_none());
3305 assert!(status.sql_inline.is_none());
3306 assert!(status.computed_at.is_none());
3307 assert!(status.applied_at.is_none());
3308 assert!(status.last_error.is_none());
3309 }
3310
3311 #[test]
3312 fn spec_without_approval_field_deserializes_as_none() {
3313 let json = serde_json::json!({
3314 "connection": {
3315 "secretRef": { "name": "pg-secret" },
3316 "secretKey": "DATABASE_URL"
3317 },
3318 "interval": "5m",
3319 "suspend": false,
3320 "mode": "apply",
3321 "reconciliation_mode": "authoritative"
3322 });
3323
3324 let spec: PostgresPolicySpec =
3325 serde_json::from_value(json).expect("should deserialize without approval field");
3326 assert!(
3327 spec.approval.is_none(),
3328 "approval should be None when omitted"
3329 );
3330 assert_eq!(
3331 spec.effective_approval(),
3332 ApprovalMode::Auto,
3333 "effective_approval should infer Auto from apply mode"
3334 );
3335 }
3336
3337 #[test]
3338 fn status_without_current_plan_ref_deserializes_as_none() {
3339 let json = serde_json::json!({
3340 "conditions": [],
3341 "owned_roles": [],
3342 "owned_schemas": []
3343 });
3344
3345 let status: PostgresPolicyStatus =
3346 serde_json::from_value(json).expect("should deserialize without current_plan_ref");
3347 assert!(
3348 status.current_plan_ref.is_none(),
3349 "current_plan_ref should be None when omitted"
3350 );
3351 }
3352
3353 #[test]
3354 fn effective_approval_explicit_auto_overrides_plan_mode() {
3355 let spec = PostgresPolicySpec {
3356 connection: ConnectionSpec {
3357 secret_ref: Some(SecretReference {
3358 name: "test".into(),
3359 }),
3360 secret_key: Some("DATABASE_URL".into()),
3361 params: None,
3362 },
3363 interval: "5m".into(),
3364 suspend: false,
3365 mode: PolicyMode::Plan,
3366 reconciliation_mode: CrdReconciliationMode::Authoritative,
3367 default_owner: None,
3368 profiles: Default::default(),
3369 schemas: vec![],
3370 roles: vec![],
3371 grants: vec![],
3372 default_privileges: vec![],
3373 memberships: vec![],
3374 retirements: vec![],
3375 approval: Some(ApprovalMode::Auto),
3376 };
3377
3378 assert_eq!(
3379 spec.effective_approval(),
3380 ApprovalMode::Auto,
3381 "explicit Auto should override Plan mode's default of Manual"
3382 );
3383 }
3384
3385 #[test]
3386 fn plan_phase_rejected_display() {
3387 assert_eq!(PlanPhase::Rejected.to_string(), "Rejected");
3388 }
3389
3390 #[test]
3391 fn plan_phase_all_variants_display() {
3392 let variants = [
3393 PlanPhase::Pending,
3394 PlanPhase::Approved,
3395 PlanPhase::Applying,
3396 PlanPhase::Applied,
3397 PlanPhase::Failed,
3398 PlanPhase::Superseded,
3399 PlanPhase::Rejected,
3400 ];
3401 for variant in &variants {
3402 let display = variant.to_string();
3403 assert!(
3404 !display.is_empty(),
3405 "PlanPhase::{variant:?} should have non-empty Display output"
3406 );
3407 }
3408 }
3409
3410 #[test]
3411 fn plan_status_defaults() {
3412 let status = PostgresPolicyPlanStatus::default();
3413 assert_eq!(status.phase, PlanPhase::Pending);
3414 assert!(status.conditions.is_empty());
3415 assert!(status.sql_ref.is_none());
3416 assert!(status.sql_hash.is_none());
3417 assert!(status.sql_inline.is_none());
3418 assert!(status.change_summary.is_none());
3419 assert!(status.computed_at.is_none());
3420 assert!(status.applied_at.is_none());
3421 assert!(status.last_error.is_none());
3422 }
3423
3424 #[test]
3425 fn plan_spec_camel_case_serialization() {
3426 let spec = PostgresPolicyPlanSpec {
3427 policy_ref: PolicyPlanRef {
3428 name: "my-policy".into(),
3429 },
3430 policy_generation: 3,
3431 reconciliation_mode: CrdReconciliationMode::Authoritative,
3432 owned_roles: vec!["role-a".into()],
3433 owned_schemas: vec!["public".into()],
3434 managed_database_identity: "ns/secret/key".into(),
3435 };
3436
3437 let json = serde_json::to_value(&spec).expect("should serialize to JSON");
3438 let obj = json.as_object().expect("should be a JSON object");
3439
3440 assert!(
3441 obj.contains_key("policyRef"),
3442 "should use camelCase: policyRef"
3443 );
3444 assert!(
3445 obj.contains_key("policyGeneration"),
3446 "should use camelCase: policyGeneration"
3447 );
3448 assert!(
3449 obj.contains_key("reconciliationMode"),
3450 "should use camelCase: reconciliationMode"
3451 );
3452 assert!(
3453 obj.contains_key("ownedRoles"),
3454 "should use camelCase: ownedRoles"
3455 );
3456 assert!(
3457 obj.contains_key("ownedSchemas"),
3458 "should use camelCase: ownedSchemas"
3459 );
3460 assert!(
3461 obj.contains_key("managedDatabaseIdentity"),
3462 "should use camelCase: managedDatabaseIdentity"
3463 );
3464 }
3465}