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