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