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 REQUESTED_RECONCILE_ANNOTATION: &str = "reconcile.pgroles.io/requestedAt";
180
181pub const LABEL_POLICY: &str = "pgroles.io/policy";
183
184pub const LABEL_DATABASE_IDENTITY: &str = "pgroles.io/database-identity";
186
187pub const LABEL_PLAN: &str = "pgroles.io/plan";
189
190impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
191 fn from(crd: CrdReconciliationMode) -> Self {
192 match crd {
193 CrdReconciliationMode::Authoritative => {
194 pgroles_core::diff::ReconciliationMode::Authoritative
195 }
196 CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
197 CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
198 }
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
227#[serde(rename_all = "camelCase")]
228pub struct ConnectionSpec {
229 #[serde(default)]
232 pub secret_ref: Option<SecretReference>,
233
234 #[serde(default)]
237 pub secret_key: Option<String>,
238
239 #[serde(default)]
242 pub params: Option<ConnectionParams>,
243}
244
245impl ConnectionSpec {
246 pub fn effective_secret_key(&self) -> &str {
248 self.secret_key.as_deref().unwrap_or("DATABASE_URL")
249 }
250
251 pub fn collect_secret_names(&self, names: &mut BTreeSet<String>) {
253 if let Some(ref secret_ref) = self.secret_ref {
254 names.insert(secret_ref.name.clone());
255 }
256 if let Some(ref params) = self.params {
257 for sel in [
258 ¶ms.host_secret,
259 ¶ms.port_secret,
260 ¶ms.dbname_secret,
261 ¶ms.username_secret,
262 ¶ms.password_secret,
263 ¶ms.ssl_mode_secret,
264 ]
265 .into_iter()
266 .flatten()
267 {
268 names.insert(sel.name.clone());
269 }
270 }
271 }
272
273 pub fn identity_key(&self) -> String {
287 if let Some(ref secret_ref) = self.secret_ref {
288 format!("{}/{}", secret_ref.name, self.effective_secret_key())
289 } else if let Some(ref params) = self.params {
290 let port_part = params
291 .port
292 .as_ref()
293 .map(|p| format!("literal={p}"))
294 .or_else(|| {
295 params
296 .port_secret
297 .as_ref()
298 .map(|s| format!("secret={}\0{}", s.name, s.key))
299 })
300 .unwrap_or_else(|| "5432".to_string());
301 format!(
302 "params\0{}\0{}\0{}",
303 field_identity_repr(¶ms.host, ¶ms.host_secret),
304 field_identity_repr(¶ms.dbname, ¶ms.dbname_secret),
305 port_part,
306 )
307 } else {
308 "invalid-connection".to_string()
309 }
310 }
311
312 pub fn cache_key(&self, namespace: &str) -> String {
316 if let Some(ref params) = self.params {
317 let user_part = field_identity_repr(¶ms.username, ¶ms.username_secret);
318 let pass_part = field_identity_repr(¶ms.password, ¶ms.password_secret);
319 let auth_part = params
320 .auth
321 .as_ref()
322 .map(ConnectionAuth::cache_key)
323 .unwrap_or_default();
324 let ssl_part = params
325 .ssl_mode
326 .as_ref()
327 .map(|v| format!("literal={v}"))
328 .or_else(|| {
329 params
330 .ssl_mode_secret
331 .as_ref()
332 .map(|s| format!("secret={}\0{}", s.name, s.key))
333 })
334 .unwrap_or_default();
335 let role_part = params.set_role.as_deref().unwrap_or("");
336 format!(
337 "{namespace}/{}\0user={user_part}\0pass={pass_part}\0auth={auth_part}\0ssl={ssl_part}\0role={role_part}",
338 self.identity_key()
339 )
340 } else {
341 format!("{namespace}/{}", self.identity_key())
342 }
343 }
344}
345
346fn field_identity_repr(literal: &Option<String>, secret: &Option<SecretKeySelector>) -> String {
351 if let Some(value) = literal {
352 format!("literal={value}")
353 } else if let Some(sel) = secret {
354 format!("secret={}\0{}", sel.name, sel.key)
355 } else {
356 String::new()
357 }
358}
359
360pub const DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE: &str =
362 "https://www.googleapis.com/auth/sqlservice.login";
363
364#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
385#[serde(rename_all = "camelCase")]
386pub struct ConnectionParams {
387 #[serde(default)]
389 pub host: Option<String>,
390 #[serde(default)]
392 pub host_secret: Option<SecretKeySelector>,
393
394 #[serde(default)]
396 pub port: Option<u16>,
397 #[serde(default)]
399 pub port_secret: Option<SecretKeySelector>,
400
401 #[serde(default)]
403 pub dbname: Option<String>,
404 #[serde(default)]
406 pub dbname_secret: Option<SecretKeySelector>,
407
408 #[serde(default)]
410 pub username: Option<String>,
411 #[serde(default)]
413 pub username_secret: Option<SecretKeySelector>,
414
415 #[serde(default)]
417 pub password: Option<String>,
418 #[serde(default)]
420 pub password_secret: Option<SecretKeySelector>,
421
422 #[serde(default)]
425 pub auth: Option<ConnectionAuth>,
426
427 #[serde(default)]
429 pub ssl_mode: Option<String>,
430 #[serde(default)]
432 pub ssl_mode_secret: Option<SecretKeySelector>,
433
434 #[serde(default)]
449 #[schemars(regex(pattern = SET_ROLE_PATTERN))]
450 pub set_role: Option<String>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
455#[serde(tag = "type")]
456pub enum ConnectionAuth {
457 #[serde(rename = "gcp_workload_identity", rename_all = "camelCase")]
461 GcpWorkloadIdentity {
462 #[serde(default)]
465 impersonate_service_account: Option<String>,
466 #[serde(default)]
468 scope: Option<String>,
469 },
470}
471
472impl ConnectionAuth {
473 pub fn gcp_scope(&self) -> &str {
474 match self {
475 Self::GcpWorkloadIdentity { scope, .. } => scope
476 .as_deref()
477 .unwrap_or(DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE),
478 }
479 }
480
481 pub fn gcp_impersonate_service_account(&self) -> Option<&str> {
482 match self {
483 Self::GcpWorkloadIdentity {
484 impersonate_service_account,
485 ..
486 } => impersonate_service_account.as_deref(),
487 }
488 }
489
490 fn cache_key(&self) -> String {
491 match self {
492 Self::GcpWorkloadIdentity {
493 impersonate_service_account,
494 scope,
495 } => format!(
496 "gcp_workload_identity\0impersonate={}\0scope={}",
497 impersonate_service_account.as_deref().unwrap_or_default(),
498 scope
499 .as_deref()
500 .unwrap_or(DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE)
501 ),
502 }
503 }
504}
505
506#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
508pub struct SecretKeySelector {
509 pub name: String,
511 pub key: String,
513}
514
515#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
517pub struct SecretReference {
518 pub name: String,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
526pub struct ProfileSpec {
527 #[serde(default)]
528 pub login: Option<bool>,
529
530 #[serde(default)]
531 pub inherit: Option<bool>,
532
533 #[serde(default)]
534 pub grants: Vec<ProfileGrantSpec>,
535
536 #[serde(default)]
537 pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
538
539 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
544 pub config: std::collections::BTreeMap<String, pgroles_core::manifest::ConfigValue>,
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
549pub struct ProfileGrantSpec {
550 pub privileges: Vec<Privilege>,
551 #[serde(alias = "on")]
552 pub object: ProfileObjectTargetSpec,
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
557pub struct ProfileObjectTargetSpec {
558 #[serde(rename = "type")]
559 pub object_type: ObjectType,
560 #[serde(default)]
561 pub name: Option<String>,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
566pub struct DefaultPrivilegeGrantSpec {
567 #[serde(default)]
568 pub role: Option<String>,
569 pub privileges: Vec<Privilege>,
570 pub on_type: ObjectType,
571}
572
573#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
575pub struct RoleSpec {
576 pub name: String,
577 #[serde(default)]
581 pub external: bool,
582 #[serde(default)]
583 pub login: Option<bool>,
584 #[serde(default)]
585 pub superuser: Option<bool>,
586 #[serde(default)]
587 pub createdb: Option<bool>,
588 #[serde(default)]
589 pub createrole: Option<bool>,
590 #[serde(default)]
591 pub inherit: Option<bool>,
592 #[serde(default)]
593 pub replication: Option<bool>,
594 #[serde(default)]
595 pub bypassrls: Option<bool>,
596 #[serde(default)]
597 pub connection_limit: Option<i32>,
598 #[serde(default)]
599 pub comment: Option<String>,
600 #[serde(default)]
603 pub password: Option<PasswordSpec>,
604 #[serde(default)]
606 pub password_valid_until: Option<String>,
607 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
612 pub config: std::collections::BTreeMap<String, pgroles_core::manifest::ConfigValue>,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
632#[serde(rename_all = "camelCase")]
633pub struct PasswordSpec {
634 #[serde(default)]
637 pub secret_ref: Option<SecretReference>,
638 #[serde(default)]
641 pub secret_key: Option<String>,
642 #[serde(default)]
645 pub generate: Option<GeneratePasswordSpec>,
646}
647
648impl PasswordSpec {
649 pub fn is_secret_ref(&self) -> bool {
651 self.secret_ref.is_some()
652 }
653
654 pub fn is_generate(&self) -> bool {
656 self.generate.is_some()
657 }
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
662#[serde(rename_all = "camelCase")]
663pub struct GeneratePasswordSpec {
664 #[serde(default)]
666 pub length: Option<u32>,
667 #[serde(default)]
669 pub secret_name: Option<String>,
670 #[serde(default)]
672 pub secret_key: Option<String>,
673}
674
675#[derive(Debug, Clone, thiserror::Error)]
676pub enum PasswordValidationError {
677 #[error("role \"{role}\" has a password but login is not enabled")]
678 PasswordWithoutLogin { role: String },
679
680 #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
681 InvalidPasswordMode { role: String },
682
683 #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
684 InvalidGeneratedLength { role: String, min: u32, max: u32 },
685
686 #[error(
687 "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
688 )]
689 InvalidGeneratedSecretName { role: String, name: String },
690
691 #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
692 InvalidSecretKey {
693 role: String,
694 field: &'static str,
695 key: String,
696 },
697
698 #[error(
699 "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
700 )]
701 ReservedGeneratedSecretKey { role: String, key: String },
702}
703
704#[derive(Debug, Clone, thiserror::Error)]
706pub enum ConnectionValidationError {
707 #[error("connection: exactly one of secretRef or params must be set, but both were provided")]
708 BothModesSet,
709
710 #[error("connection: exactly one of secretRef or params must be set, but neither was provided")]
711 NeitherModeSet,
712
713 #[error("connection.params.{field}: secret {detail}")]
714 EmptySecretKeyRef { field: String, detail: String },
715
716 #[error(
717 "connection.params.sslMode: \"{value}\" is not valid (expected one of: disable, allow, prefer, require, verify-ca, verify-full)"
718 )]
719 InvalidSslMode { value: String },
720
721 #[error("connection.params.{field}: literal value must not be empty or whitespace-only")]
722 EmptyLiteral { field: String },
723
724 #[error("connection.params: exactly one of {field} or {field}Secret must be set")]
725 NeitherFieldSet { field: String },
726
727 #[error(
728 "connection.params: only one of {field} or {field}Secret may be set, but both were provided"
729 )]
730 BothFieldsSet { field: String },
731
732 #[error("connection.params.auth: {field} must not be empty or whitespace-only")]
733 EmptyAuthField { field: String },
734
735 #[error("connection.params: password/passwordSecret are mutually exclusive with auth")]
736 AuthWithPassword,
737
738 #[error(
739 "connection.params.setRole: \"{value}\" is not a valid PostgreSQL role identifier (must match {pattern})",
740 pattern = SET_ROLE_PATTERN,
741 )]
742 InvalidRoleName { value: String },
743}
744
745pub(crate) const SET_ROLE_PATTERN: &str = "^[A-Za-z_][A-Za-z0-9_$-]*$";
758
759pub(crate) fn is_valid_set_role_identifier(s: &str) -> bool {
767 let mut bytes = s.bytes();
768 match bytes.next() {
769 Some(b) if b.is_ascii_alphabetic() || b == b'_' => {}
770 _ => return false,
771 }
772 bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'-')
773}
774
775fn is_valid_secret_name(name: &str) -> bool {
780 crate::k8s_names::is_valid_resource_name(name)
781}
782
783fn is_valid_secret_key(key: &str) -> bool {
784 !key.is_empty()
785 && key
786 .bytes()
787 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
788}
789
790#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
796pub struct PostgresPolicyStatus {
797 #[serde(default)]
799 pub conditions: Vec<PolicyCondition>,
800
801 #[serde(default)]
803 pub observed_generation: Option<i64>,
804
805 #[serde(default)]
807 pub last_attempted_generation: Option<i64>,
808
809 #[serde(default)]
811 pub last_successful_reconcile_time: Option<String>,
812
813 #[serde(default)]
815 pub last_reconcile_time: Option<String>,
816
817 #[serde(default, rename = "lastHandledReconcileAt")]
819 pub last_handled_reconcile_at: Option<String>,
820
821 #[serde(default)]
823 pub change_summary: Option<ChangeSummary>,
824
825 #[serde(default)]
827 pub last_reconcile_mode: Option<PolicyMode>,
828
829 #[serde(default)]
831 pub planned_sql: Option<String>,
832
833 #[serde(default)]
835 pub planned_sql_truncated: bool,
836
837 #[serde(default)]
839 pub managed_database_identity: Option<String>,
840
841 #[serde(default)]
843 pub owned_roles: Vec<String>,
844
845 #[serde(default)]
847 pub owned_schemas: Vec<String>,
848
849 #[serde(default)]
851 pub last_error: Option<String>,
852
853 #[serde(default)]
855 pub applied_password_source_versions: BTreeMap<String, String>,
856
857 #[serde(default)]
859 pub transient_failure_count: i32,
860
861 #[serde(default)]
863 pub current_plan_ref: Option<PlanReference>,
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
868pub struct PolicyCondition {
869 #[serde(rename = "type")]
871 pub condition_type: String,
872
873 pub status: String,
875
876 #[serde(default)]
878 pub reason: Option<String>,
879
880 #[serde(default)]
882 pub message: Option<String>,
883
884 #[serde(default)]
886 pub last_transition_time: Option<String>,
887}
888
889#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
891pub struct PlanReference {
892 pub name: String,
893}
894
895#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
897#[serde(default)]
898pub struct ChangeSummary {
899 #[serde(default)]
900 pub roles_created: i32,
901 #[serde(default)]
902 pub roles_altered: i32,
903 #[serde(default)]
904 pub schemas_created: i32,
905 #[serde(default)]
906 pub schema_owners_altered: i32,
907 #[serde(default)]
908 pub roles_dropped: i32,
909 #[serde(default)]
910 pub sessions_terminated: i32,
911 #[serde(default)]
912 pub grants_added: i32,
913 #[serde(default)]
914 pub grants_revoked: i32,
915 #[serde(default)]
916 pub default_privileges_set: i32,
917 #[serde(default)]
918 pub default_privileges_revoked: i32,
919 #[serde(default)]
920 pub members_added: i32,
921 #[serde(default)]
922 pub members_removed: i32,
923 #[serde(default)]
924 pub passwords_set: i32,
925 #[serde(default)]
926 pub total: i32,
927}
928
929#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
938#[kube(
939 group = "pgroles.io",
940 version = "v1alpha1",
941 kind = "PostgresPolicyPlan",
942 namespaced,
943 status = "PostgresPolicyPlanStatus",
944 shortname = "pgplan",
945 category = "pgroles",
946 printcolumn = r#"{"name":"Policy","type":"string","jsonPath":".spec.policyRef.name"}"#,
947 printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.reconciliationMode"}"#,
948 printcolumn = r#"{"name":"Approved","type":"string","jsonPath":".status.conditions[?(@.type==\"Approved\")].status"}"#,
949 printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.changeSummary.total"}"#,
950 printcolumn = r#"{"name":"SQL Stmts","type":"integer","jsonPath":".status.sqlStatements","priority":1}"#,
951 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
952 printcolumn = r#"{"name":"SQL","type":"string","jsonPath":".status.sqlRef.name","priority":1}"#,
953 printcolumn = r#"{"name":"Hash","type":"string","jsonPath":".status.sqlHash","priority":1}"#,
954 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
955)]
956#[serde(rename_all = "camelCase")]
957pub struct PostgresPolicyPlanSpec {
958 pub policy_ref: PolicyPlanRef,
960 pub policy_generation: i64,
962 pub reconciliation_mode: CrdReconciliationMode,
964 #[serde(default)]
966 pub owned_roles: Vec<String>,
967 #[serde(default)]
969 pub owned_schemas: Vec<String>,
970 pub managed_database_identity: String,
972}
973
974#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
976pub struct PolicyPlanRef {
977 pub name: String,
978}
979
980#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
982#[serde(rename_all = "camelCase")]
983pub struct PostgresPolicyPlanStatus {
984 #[serde(default)]
986 pub phase: PlanPhase,
987 #[serde(default)]
989 pub conditions: Vec<PolicyCondition>,
990 #[serde(default)]
992 pub change_summary: Option<ChangeSummary>,
993 #[serde(default)]
995 pub sql_ref: Option<SqlRef>,
996 #[serde(default)]
998 pub sql_inline: Option<String>,
999 #[serde(default)]
1002 pub sql_truncated: bool,
1003 #[serde(default)]
1005 pub computed_at: Option<String>,
1006 #[serde(default)]
1008 pub applied_at: Option<String>,
1009 #[serde(default)]
1011 pub last_error: Option<String>,
1012 #[serde(default)]
1016 pub sql_hash: Option<String>,
1017 #[serde(default)]
1019 pub applying_since: Option<String>,
1020 #[serde(default)]
1022 pub failed_at: Option<String>,
1023 #[serde(default)]
1027 pub sql_statements: Option<i64>,
1028 #[serde(default)]
1031 pub redacted_sql_hash: Option<String>,
1032 #[serde(default)]
1034 pub sql_original_bytes: Option<i64>,
1035 #[serde(default)]
1037 pub sql_stored_bytes: Option<i64>,
1038}
1039
1040#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1042pub struct SqlRef {
1043 pub name: String,
1044 pub key: String,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub compression: Option<SqlCompression>,
1049}
1050
1051#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1053#[serde(rename_all = "lowercase")]
1054pub enum SqlCompression {
1055 Gzip,
1056}
1057
1058#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1060pub enum PlanPhase {
1061 #[default]
1062 Pending,
1063 Approved,
1064 Applying,
1065 Applied,
1066 Failed,
1067 Superseded,
1068 Rejected,
1069}
1070
1071impl std::fmt::Display for PlanPhase {
1072 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1073 match self {
1074 PlanPhase::Pending => write!(f, "Pending"),
1075 PlanPhase::Approved => write!(f, "Approved"),
1076 PlanPhase::Applying => write!(f, "Applying"),
1077 PlanPhase::Applied => write!(f, "Applied"),
1078 PlanPhase::Failed => write!(f, "Failed"),
1079 PlanPhase::Superseded => write!(f, "Superseded"),
1080 PlanPhase::Rejected => write!(f, "Rejected"),
1081 }
1082 }
1083}
1084
1085#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1091pub struct DatabaseIdentity(String);
1092
1093impl DatabaseIdentity {
1094 pub fn from_connection(namespace: &str, connection: &ConnectionSpec) -> Self {
1096 Self(format!("{namespace}/{}", connection.identity_key()))
1097 }
1098
1099 pub fn as_str(&self) -> &str {
1100 &self.0
1101 }
1102}
1103
1104#[derive(Debug, Clone, Default, PartialEq, Eq)]
1106pub struct OwnershipClaims {
1107 pub roles: BTreeSet<String>,
1108 pub schemas: BTreeSet<String>,
1109}
1110
1111impl OwnershipClaims {
1112 pub fn overlaps(&self, other: &Self) -> bool {
1113 !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
1114 }
1115
1116 pub fn overlap_summary(&self, other: &Self) -> String {
1117 let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
1118 let overlapping_schemas: Vec<_> =
1119 self.schemas.intersection(&other.schemas).cloned().collect();
1120
1121 let mut parts = Vec::new();
1122 if !overlapping_roles.is_empty() {
1123 parts.push(format!("roles: {}", overlapping_roles.join(", ")));
1124 }
1125 if !overlapping_schemas.is_empty() {
1126 parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
1127 }
1128
1129 parts.join("; ")
1130 }
1131}
1132
1133impl PostgresPolicySpec {
1138 pub fn validate_password_specs(
1139 &self,
1140 policy_name: &str,
1141 ) -> Result<(), PasswordValidationError> {
1142 for role in &self.roles {
1143 let Some(password) = &role.password else {
1144 continue;
1145 };
1146
1147 if role.login != Some(true) {
1148 return Err(PasswordValidationError::PasswordWithoutLogin {
1149 role: role.name.clone(),
1150 });
1151 }
1152
1153 match (&password.secret_ref, &password.generate) {
1154 (Some(_), None) => {
1155 let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
1156 if !is_valid_secret_key(secret_key) {
1157 return Err(PasswordValidationError::InvalidSecretKey {
1158 role: role.name.clone(),
1159 field: "secretKey",
1160 key: secret_key.to_string(),
1161 });
1162 }
1163 }
1164 (None, Some(generate)) => {
1165 if let Some(length) = generate.length
1166 && !(crate::password::MIN_PASSWORD_LENGTH
1167 ..=crate::password::MAX_PASSWORD_LENGTH)
1168 .contains(&length)
1169 {
1170 return Err(PasswordValidationError::InvalidGeneratedLength {
1171 role: role.name.clone(),
1172 min: crate::password::MIN_PASSWORD_LENGTH,
1173 max: crate::password::MAX_PASSWORD_LENGTH,
1174 });
1175 }
1176
1177 let secret_name =
1178 crate::password::generated_secret_name(policy_name, &role.name, generate);
1179 if !is_valid_secret_name(&secret_name) {
1180 return Err(PasswordValidationError::InvalidGeneratedSecretName {
1181 role: role.name.clone(),
1182 name: secret_name,
1183 });
1184 }
1185
1186 let secret_key = crate::password::generated_secret_key(generate);
1187 if !is_valid_secret_key(&secret_key) {
1188 return Err(PasswordValidationError::InvalidSecretKey {
1189 role: role.name.clone(),
1190 field: "generate.secretKey",
1191 key: secret_key,
1192 });
1193 }
1194 if secret_key == crate::password::GENERATED_VERIFIER_KEY {
1195 return Err(PasswordValidationError::ReservedGeneratedSecretKey {
1196 role: role.name.clone(),
1197 key: secret_key,
1198 });
1199 }
1200 }
1201 _ => {
1202 return Err(PasswordValidationError::InvalidPasswordMode {
1203 role: role.name.clone(),
1204 });
1205 }
1206 }
1207 }
1208
1209 Ok(())
1210 }
1211
1212 pub fn validate_connection_spec(&self) -> Result<(), ConnectionValidationError> {
1217 let conn = &self.connection;
1218 match (&conn.secret_ref, &conn.params) {
1219 (Some(_), None) => {
1220 Ok(())
1222 }
1223 (None, Some(params)) => {
1224 fn validate_required_field(
1226 field: &str,
1227 literal: &Option<String>,
1228 secret: &Option<SecretKeySelector>,
1229 ) -> Result<(), ConnectionValidationError> {
1230 match (literal, secret) {
1231 (Some(_), Some(_)) => {
1232 return Err(ConnectionValidationError::BothFieldsSet {
1233 field: field.to_string(),
1234 });
1235 }
1236 (None, None) => {
1237 return Err(ConnectionValidationError::NeitherFieldSet {
1238 field: field.to_string(),
1239 });
1240 }
1241 (Some(s), None) => {
1242 if s.trim().is_empty() {
1243 return Err(ConnectionValidationError::EmptyLiteral {
1244 field: field.to_string(),
1245 });
1246 }
1247 }
1248 (None, Some(sel)) => {
1249 validate_secret_selector(field, sel)?;
1250 }
1251 }
1252 Ok(())
1253 }
1254
1255 fn validate_optional_field(
1257 field: &str,
1258 literal: &Option<impl AsRef<str>>,
1259 secret: &Option<SecretKeySelector>,
1260 ) -> Result<(), ConnectionValidationError> {
1261 let has_literal = literal.is_some();
1262 if has_literal && secret.is_some() {
1263 return Err(ConnectionValidationError::BothFieldsSet {
1264 field: field.to_string(),
1265 });
1266 }
1267 if let Some(s) = literal
1268 && s.as_ref().trim().is_empty()
1269 {
1270 return Err(ConnectionValidationError::EmptyLiteral {
1271 field: field.to_string(),
1272 });
1273 }
1274 if let Some(sel) = secret {
1275 validate_secret_selector(field, sel)?;
1276 }
1277 Ok(())
1278 }
1279
1280 fn validate_secret_selector(
1281 field: &str,
1282 sel: &SecretKeySelector,
1283 ) -> Result<(), ConnectionValidationError> {
1284 if sel.name.trim().is_empty() {
1285 return Err(ConnectionValidationError::EmptySecretKeyRef {
1286 field: field.to_string(),
1287 detail: "name must not be empty".to_string(),
1288 });
1289 }
1290 if sel.key.trim().is_empty() {
1291 return Err(ConnectionValidationError::EmptySecretKeyRef {
1292 field: field.to_string(),
1293 detail: "key must not be empty".to_string(),
1294 });
1295 }
1296 Ok(())
1297 }
1298
1299 validate_required_field("host", ¶ms.host, ¶ms.host_secret)?;
1302 validate_required_field("dbname", ¶ms.dbname, ¶ms.dbname_secret)?;
1303 validate_required_field("username", ¶ms.username, ¶ms.username_secret)?;
1304 if let Some(auth) = ¶ms.auth {
1305 if params.password.is_some() || params.password_secret.is_some() {
1306 return Err(ConnectionValidationError::AuthWithPassword);
1307 }
1308 match auth {
1309 ConnectionAuth::GcpWorkloadIdentity {
1310 impersonate_service_account,
1311 scope,
1312 } => {
1313 if let Some(value) = impersonate_service_account
1314 && value.trim().is_empty()
1315 {
1316 return Err(ConnectionValidationError::EmptyAuthField {
1317 field: "impersonateServiceAccount".to_string(),
1318 });
1319 }
1320 if let Some(value) = scope
1321 && value.trim().is_empty()
1322 {
1323 return Err(ConnectionValidationError::EmptyAuthField {
1324 field: "scope".to_string(),
1325 });
1326 }
1327 }
1328 }
1329 } else {
1330 validate_required_field("password", ¶ms.password, ¶ms.password_secret)?;
1331 }
1332
1333 let port_str = params.port.map(|p| p.to_string());
1336 validate_optional_field("port", &port_str, ¶ms.port_secret)?;
1337
1338 validate_optional_field("sslMode", ¶ms.ssl_mode, ¶ms.ssl_mode_secret)?;
1339
1340 if let Some(value) = ¶ms.ssl_mode
1342 && !VALID_SSL_MODES.contains(&value.as_str())
1343 {
1344 return Err(ConnectionValidationError::InvalidSslMode {
1345 value: value.clone(),
1346 });
1347 }
1348
1349 if let Some(value) = ¶ms.set_role {
1352 if value.trim().is_empty() {
1353 return Err(ConnectionValidationError::EmptyLiteral {
1354 field: "setRole".to_string(),
1355 });
1356 }
1357 if !is_valid_set_role_identifier(value) {
1358 return Err(ConnectionValidationError::InvalidRoleName {
1359 value: value.clone(),
1360 });
1361 }
1362 }
1363
1364 Ok(())
1365 }
1366 (Some(_), Some(_)) => Err(ConnectionValidationError::BothModesSet),
1367 (None, None) => Err(ConnectionValidationError::NeitherModeSet),
1368 }
1369 }
1370
1371 pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
1377 let mut names = BTreeSet::new();
1378 self.connection.collect_secret_names(&mut names);
1380 for role in &self.roles {
1381 if let Some(pw) = &role.password {
1382 if let Some(secret_ref) = &pw.secret_ref {
1383 names.insert(secret_ref.name.clone());
1384 }
1385 if let Some(gen_spec) = &pw.generate {
1386 let secret_name =
1387 crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
1388 names.insert(secret_name);
1389 }
1390 }
1391 }
1392 names
1393 }
1394}
1395
1396impl PostgresPolicySpec {
1401 pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
1403 use pgroles_core::manifest::{
1404 DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
1405 ProfileObjectTarget, RoleDefinition,
1406 };
1407
1408 let profiles = self
1409 .profiles
1410 .iter()
1411 .map(|(name, spec)| {
1412 let profile = Profile {
1413 login: spec.login,
1414 inherit: spec.inherit,
1415 grants: spec
1416 .grants
1417 .iter()
1418 .map(|g| ProfileGrant {
1419 privileges: g.privileges.clone(),
1420 object: ProfileObjectTarget {
1421 object_type: g.object.object_type,
1422 name: g.object.name.clone(),
1423 },
1424 })
1425 .collect(),
1426 default_privileges: spec
1427 .default_privileges
1428 .iter()
1429 .map(|dp| DefaultPrivilegeGrant {
1430 role: dp.role.clone(),
1431 privileges: dp.privileges.clone(),
1432 on_type: dp.on_type,
1433 })
1434 .collect(),
1435 config: spec.config.clone(),
1436 };
1437 (name.clone(), profile)
1438 })
1439 .collect();
1440
1441 let roles = self
1442 .roles
1443 .iter()
1444 .map(|r| RoleDefinition {
1445 name: r.name.clone(),
1446 external: r.external,
1447 login: r.login,
1448 superuser: r.superuser,
1449 createdb: r.createdb,
1450 createrole: r.createrole,
1451 inherit: r.inherit,
1452 replication: r.replication,
1453 bypassrls: r.bypassrls,
1454 connection_limit: r.connection_limit,
1455 comment: r.comment.clone(),
1456 password: None, password_valid_until: r.password_valid_until.clone(),
1458 config: r.config.clone(),
1459 })
1460 .collect();
1461
1462 let memberships = self
1466 .memberships
1467 .iter()
1468 .map(|m| pgroles_core::manifest::Membership {
1469 role: m.role.clone(),
1470 members: m
1471 .members
1472 .iter()
1473 .map(|ms| MemberSpec {
1474 name: ms.name.clone(),
1475 inherit: ms.inherit,
1476 admin: ms.admin,
1477 })
1478 .collect(),
1479 })
1480 .collect();
1481
1482 PolicyManifest {
1483 default_owner: self.default_owner.clone(),
1484 auth_providers: Vec::new(),
1485 profiles,
1486 schemas: self.schemas.clone(),
1487 roles,
1488 grants: self.grants.clone(),
1489 default_privileges: self.default_privileges.clone(),
1490 memberships,
1491 retirements: self.retirements.clone(),
1492 }
1493 }
1494
1495 pub fn ownership_claims(
1500 &self,
1501 ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
1502 let manifest = self.to_policy_manifest();
1503 let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
1504
1505 let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
1506 let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
1507
1508 roles.extend(manifest.retirements.into_iter().map(|r| r.role));
1509 roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
1510 roles.extend(
1511 manifest
1512 .default_privileges
1513 .iter()
1514 .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
1515 );
1516 roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
1517 roles.extend(
1518 manifest
1519 .memberships
1520 .iter()
1521 .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
1522 );
1523
1524 schemas.extend(
1525 manifest
1526 .grants
1527 .iter()
1528 .filter_map(|g| match g.object.object_type {
1529 ObjectType::Database => None,
1530 ObjectType::Schema => g.object.name.clone(),
1531 _ => g.object.schema.clone(),
1532 }),
1533 );
1534 schemas.extend(
1535 manifest
1536 .default_privileges
1537 .iter()
1538 .map(|dp| dp.schema.clone()),
1539 );
1540
1541 Ok(OwnershipClaims { roles, schemas })
1542 }
1543}
1544
1545impl PostgresPolicyStatus {
1550 pub fn set_condition(&mut self, new: PolicyCondition) {
1555 if let Some(existing) = self
1556 .conditions
1557 .iter()
1558 .find(|c| c.condition_type == new.condition_type)
1559 && existing.status == new.status
1560 {
1561 let mut updated = new;
1563 updated.last_transition_time = existing.last_transition_time.clone();
1564 self.conditions
1565 .retain(|c| c.condition_type != updated.condition_type);
1566 self.conditions.push(updated);
1567 return;
1568 }
1569 self.conditions
1571 .retain(|c| c.condition_type != new.condition_type);
1572 self.conditions.push(new);
1573 }
1574}
1575
1576pub fn now_rfc3339() -> String {
1578 use std::time::SystemTime;
1581 let now = SystemTime::now()
1582 .duration_since(SystemTime::UNIX_EPOCH)
1583 .unwrap_or_default();
1584 let secs = now.as_secs();
1586 let days = secs / 86400;
1587 let remaining = secs % 86400;
1588 let hours = remaining / 3600;
1589 let minutes = (remaining % 3600) / 60;
1590 let seconds = remaining % 60;
1591
1592 let (year, month, day) = days_to_date(days);
1594 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
1595}
1596
1597pub fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
1599 let z = days_since_epoch + 719468;
1601 let era = z / 146097;
1602 let doe = z - era * 146097;
1603 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1604 let y = yoe + era * 400;
1605 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1606 let mp = (5 * doy + 2) / 153;
1607 let d = doy - (153 * mp + 2) / 5 + 1;
1608 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1609 let y = if m <= 2 { y + 1 } else { y };
1610 (y, m, d)
1611}
1612
1613pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1615 PolicyCondition {
1616 condition_type: "Ready".to_string(),
1617 status: if status { "True" } else { "False" }.to_string(),
1618 reason: Some(reason.to_string()),
1619 message: Some(message.to_string()),
1620 last_transition_time: Some(now_rfc3339()),
1621 }
1622}
1623
1624pub fn reconciling_condition(message: &str) -> PolicyCondition {
1626 PolicyCondition {
1627 condition_type: "Reconciling".to_string(),
1628 status: "True".to_string(),
1629 reason: Some("Reconciling".to_string()),
1630 message: Some(message.to_string()),
1631 last_transition_time: Some(now_rfc3339()),
1632 }
1633}
1634
1635pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
1637 PolicyCondition {
1638 condition_type: "Degraded".to_string(),
1639 status: "True".to_string(),
1640 reason: Some(reason.to_string()),
1641 message: Some(message.to_string()),
1642 last_transition_time: Some(now_rfc3339()),
1643 }
1644}
1645
1646pub fn paused_condition(message: &str) -> PolicyCondition {
1648 PolicyCondition {
1649 condition_type: "Paused".to_string(),
1650 status: "True".to_string(),
1651 reason: Some("Suspended".to_string()),
1652 message: Some(message.to_string()),
1653 last_transition_time: Some(now_rfc3339()),
1654 }
1655}
1656
1657pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
1659 PolicyCondition {
1660 condition_type: "Conflict".to_string(),
1661 status: "True".to_string(),
1662 reason: Some(reason.to_string()),
1663 message: Some(message.to_string()),
1664 last_transition_time: Some(now_rfc3339()),
1665 }
1666}
1667
1668pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1670 PolicyCondition {
1671 condition_type: "Drifted".to_string(),
1672 status: if status { "True" } else { "False" }.to_string(),
1673 reason: Some(reason.to_string()),
1674 message: Some(message.to_string()),
1675 last_transition_time: Some(now_rfc3339()),
1676 }
1677}
1678
1679#[cfg(test)]
1684mod tests {
1685 use super::*;
1686 use kube::CustomResourceExt;
1687
1688 #[test]
1689 fn crd_generates_valid_schema() {
1690 let crd = PostgresPolicy::crd();
1691 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
1692 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
1693 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
1694 assert!(
1695 yaml.contains("PostgresPolicy"),
1696 "kind should be PostgresPolicy"
1697 );
1698 assert!(
1699 yaml.contains("\"mode\"") || yaml.contains(" mode:"),
1700 "schema should declare spec.mode"
1701 );
1702 assert!(
1703 yaml.contains("\"object\"") || yaml.contains(" object:"),
1704 "schema should declare grant object targets using object"
1705 );
1706 }
1707
1708 #[test]
1709 fn spec_to_policy_manifest_roundtrip() {
1710 let spec = PostgresPolicySpec {
1711 connection: ConnectionSpec {
1712 secret_ref: Some(SecretReference {
1713 name: "pg-secret".to_string(),
1714 }),
1715 secret_key: Some("DATABASE_URL".to_string()),
1716 params: None,
1717 },
1718 interval: "5m".to_string(),
1719 suspend: false,
1720 mode: PolicyMode::Apply,
1721 reconciliation_mode: CrdReconciliationMode::default(),
1722 default_owner: Some("app_owner".to_string()),
1723 profiles: std::collections::HashMap::new(),
1724 schemas: vec![],
1725 roles: vec![RoleSpec {
1726 name: "analytics".to_string(),
1727 external: true,
1728 login: Some(true),
1729 superuser: None,
1730 createdb: None,
1731 createrole: None,
1732 inherit: None,
1733 replication: None,
1734 bypassrls: None,
1735 connection_limit: None,
1736 comment: Some("test role".to_string()),
1737 password: None,
1738 password_valid_until: None,
1739 config: Default::default(),
1740 }],
1741 grants: vec![],
1742 default_privileges: vec![],
1743 memberships: vec![],
1744 retirements: vec![RoleRetirement {
1745 role: "legacy-app".to_string(),
1746 reassign_owned_to: Some("app_owner".to_string()),
1747 drop_owned: true,
1748 terminate_sessions: true,
1749 }],
1750 approval: None,
1751 };
1752
1753 let manifest = spec.to_policy_manifest();
1754 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
1755 assert_eq!(manifest.roles.len(), 1);
1756 assert_eq!(manifest.roles[0].name, "analytics");
1757 assert!(manifest.roles[0].external);
1758 assert_eq!(manifest.roles[0].login, Some(true));
1759 assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
1760 assert_eq!(manifest.retirements.len(), 1);
1761 assert_eq!(manifest.retirements[0].role, "legacy-app");
1762 assert_eq!(
1763 manifest.retirements[0].reassign_owned_to.as_deref(),
1764 Some("app_owner")
1765 );
1766 assert!(manifest.retirements[0].drop_owned);
1767 assert!(manifest.retirements[0].terminate_sessions);
1768 }
1769
1770 #[test]
1771 fn spec_to_policy_manifest_preserves_profile_inherit() {
1772 let spec = PostgresPolicySpec {
1773 connection: ConnectionSpec {
1774 secret_ref: Some(SecretReference {
1775 name: "pg-secret".to_string(),
1776 }),
1777 secret_key: Some("DATABASE_URL".to_string()),
1778 params: None,
1779 },
1780 interval: "5m".to_string(),
1781 suspend: false,
1782 mode: PolicyMode::Apply,
1783 reconciliation_mode: CrdReconciliationMode::default(),
1784 default_owner: None,
1785 profiles: std::collections::HashMap::from([(
1786 "editor".to_string(),
1787 ProfileSpec {
1788 login: Some(false),
1789 inherit: Some(false),
1790 grants: vec![],
1791 default_privileges: vec![],
1792 config: Default::default(),
1793 },
1794 )]),
1795 schemas: vec![],
1796 roles: vec![],
1797 grants: vec![],
1798 default_privileges: vec![],
1799 memberships: vec![],
1800 retirements: vec![],
1801 approval: None,
1802 };
1803
1804 let manifest = spec.to_policy_manifest();
1805 assert_eq!(manifest.profiles["editor"].login, Some(false));
1806 assert_eq!(manifest.profiles["editor"].inherit, Some(false));
1807 }
1808
1809 #[test]
1810 fn status_set_condition_replaces_existing() {
1811 let mut status = PostgresPolicyStatus::default();
1812
1813 status.set_condition(ready_condition(false, "Pending", "Initial"));
1814 assert_eq!(status.conditions.len(), 1);
1815 assert_eq!(status.conditions[0].status, "False");
1816
1817 status.set_condition(ready_condition(true, "Reconciled", "All good"));
1818 assert_eq!(status.conditions.len(), 1);
1819 assert_eq!(status.conditions[0].status, "True");
1820 assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
1821 }
1822
1823 #[test]
1824 fn status_set_condition_adds_new_type() {
1825 let mut status = PostgresPolicyStatus::default();
1826
1827 status.set_condition(ready_condition(true, "OK", "ready"));
1828 status.set_condition(degraded_condition("Error", "something broke"));
1829
1830 assert_eq!(status.conditions.len(), 2);
1831 }
1832
1833 #[test]
1834 fn paused_condition_has_expected_shape() {
1835 let paused = paused_condition("paused by spec");
1836 assert_eq!(paused.condition_type, "Paused");
1837 assert_eq!(paused.status, "True");
1838 assert_eq!(paused.reason.as_deref(), Some("Suspended"));
1839 }
1840
1841 #[test]
1842 fn ownership_claims_include_expanded_roles_and_schemas() {
1843 let mut profiles = std::collections::HashMap::new();
1844 profiles.insert(
1845 "editor".to_string(),
1846 ProfileSpec {
1847 login: Some(false),
1848 inherit: None,
1849 grants: vec![],
1850 default_privileges: vec![],
1851 config: Default::default(),
1852 },
1853 );
1854
1855 let spec = PostgresPolicySpec {
1856 connection: ConnectionSpec {
1857 secret_ref: Some(SecretReference {
1858 name: "pg-secret".to_string(),
1859 }),
1860 secret_key: Some("DATABASE_URL".to_string()),
1861 params: None,
1862 },
1863 interval: "5m".to_string(),
1864 suspend: false,
1865 mode: PolicyMode::Apply,
1866 reconciliation_mode: CrdReconciliationMode::default(),
1867 default_owner: None,
1868 profiles,
1869 schemas: vec![SchemaBinding {
1870 name: "inventory".to_string(),
1871 profiles: vec!["editor".to_string()],
1872 role_pattern: "{schema}-{profile}".to_string(),
1873 owner: None,
1874 }],
1875 roles: vec![RoleSpec {
1876 name: "app-service".to_string(),
1877 external: false,
1878 login: Some(true),
1879 superuser: None,
1880 createdb: None,
1881 createrole: None,
1882 inherit: None,
1883 replication: None,
1884 bypassrls: None,
1885 connection_limit: None,
1886 comment: None,
1887 password: None,
1888 password_valid_until: None,
1889 config: Default::default(),
1890 }],
1891 grants: vec![],
1892 default_privileges: vec![],
1893 memberships: vec![],
1894 retirements: vec![RoleRetirement {
1895 role: "legacy-app".to_string(),
1896 reassign_owned_to: None,
1897 drop_owned: false,
1898 terminate_sessions: false,
1899 }],
1900 approval: None,
1901 };
1902
1903 let claims = spec.ownership_claims().unwrap();
1904 assert!(claims.roles.contains("inventory-editor"));
1905 assert!(claims.roles.contains("app-service"));
1906 assert!(claims.roles.contains("legacy-app"));
1907 assert!(claims.schemas.contains("inventory"));
1908 }
1909
1910 #[test]
1911 fn ownership_overlap_summary_reports_roles_and_schemas() {
1912 let mut left = OwnershipClaims::default();
1913 left.roles.insert("analytics".to_string());
1914 left.schemas.insert("reporting".to_string());
1915
1916 let mut right = OwnershipClaims::default();
1917 right.roles.insert("analytics".to_string());
1918 right.schemas.insert("reporting".to_string());
1919 right.schemas.insert("other".to_string());
1920
1921 assert!(left.overlaps(&right));
1922 let summary = left.overlap_summary(&right);
1923 assert!(summary.contains("roles: analytics"));
1924 assert!(summary.contains("schemas: reporting"));
1925 }
1926
1927 #[test]
1928 fn database_identity_uses_namespace_and_identity_key() {
1929 let conn = ConnectionSpec {
1930 secret_ref: Some(SecretReference {
1931 name: "db-creds".to_string(),
1932 }),
1933 secret_key: Some("DATABASE_URL".to_string()),
1934 params: None,
1935 };
1936 let identity = DatabaseIdentity::from_connection("prod", &conn);
1937 assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
1938 }
1939
1940 #[test]
1941 fn identity_key_same_database_different_users_are_equal() {
1942 let user_a = ConnectionSpec {
1945 secret_ref: None,
1946 secret_key: None,
1947 params: Some(ConnectionParams {
1948 host: Some("my-host".into()),
1949 host_secret: None,
1950 port: None,
1951 port_secret: None,
1952 dbname: Some("mydb".into()),
1953 dbname_secret: None,
1954 username: Some("alice".into()),
1955 username_secret: None,
1956 password: Some("pass-a".into()),
1957 password_secret: None,
1958 auth: None,
1959 ssl_mode: None,
1960 ssl_mode_secret: None,
1961 set_role: None,
1962 }),
1963 };
1964 let user_b = ConnectionSpec {
1965 secret_ref: None,
1966 secret_key: None,
1967 params: Some(ConnectionParams {
1968 host: Some("my-host".into()),
1969 host_secret: None,
1970 port: None,
1971 port_secret: None,
1972 dbname: Some("mydb".into()),
1973 dbname_secret: None,
1974 username: Some("bob".into()),
1975 username_secret: None,
1976 password: Some("pass-b".into()),
1977 password_secret: None,
1978 auth: None,
1979 ssl_mode: None,
1980 ssl_mode_secret: None,
1981 set_role: None,
1982 }),
1983 };
1984
1985 assert_eq!(
1986 user_a.identity_key(),
1987 user_b.identity_key(),
1988 "same database with different users should have the same identity key"
1989 );
1990 assert_ne!(
1992 user_a.cache_key("default"),
1993 user_b.cache_key("default"),
1994 "different credentials should produce different cache keys"
1995 );
1996 }
1997
1998 #[test]
1999 fn cache_key_no_collision_between_literal_and_secret_username() {
2000 let literal_conn = ConnectionSpec {
2003 secret_ref: None,
2004 secret_key: None,
2005 params: Some(ConnectionParams {
2006 host: Some("my-host".into()),
2007 host_secret: None,
2008 port: None,
2009 port_secret: None,
2010 dbname: Some("mydb".into()),
2011 dbname_secret: None,
2012 username: Some("secret=creds\0password".into()),
2013 username_secret: None,
2014 password: Some("pass".into()),
2015 password_secret: None,
2016 auth: None,
2017 ssl_mode: None,
2018 ssl_mode_secret: None,
2019 set_role: None,
2020 }),
2021 };
2022 let secret_conn = ConnectionSpec {
2023 secret_ref: None,
2024 secret_key: None,
2025 params: Some(ConnectionParams {
2026 host: Some("my-host".into()),
2027 host_secret: None,
2028 port: None,
2029 port_secret: None,
2030 dbname: Some("mydb".into()),
2031 dbname_secret: None,
2032 username: None,
2033 username_secret: Some(SecretKeySelector {
2034 name: "creds".into(),
2035 key: "password".into(),
2036 }),
2037 password: Some("pass".into()),
2038 password_secret: None,
2039 auth: None,
2040 ssl_mode: None,
2041 ssl_mode_secret: None,
2042 set_role: None,
2043 }),
2044 };
2045
2046 assert_ne!(
2047 literal_conn.cache_key("default"),
2048 secret_conn.cache_key("default"),
2049 "literal and secret ref should produce different cache keys"
2050 );
2051 }
2052
2053 #[test]
2054 fn cache_key_includes_ssl_mode() {
2055 let conn_no_ssl = ConnectionSpec {
2056 secret_ref: None,
2057 secret_key: None,
2058 params: Some(ConnectionParams {
2059 host: Some("host".into()),
2060 host_secret: None,
2061 port: None,
2062 port_secret: None,
2063 dbname: Some("db".into()),
2064 dbname_secret: None,
2065 username: Some("user".into()),
2066 username_secret: None,
2067 password: Some("pass".into()),
2068 password_secret: None,
2069 auth: None,
2070 ssl_mode: None,
2071 ssl_mode_secret: None,
2072 set_role: None,
2073 }),
2074 };
2075 let conn_with_ssl = ConnectionSpec {
2076 secret_ref: None,
2077 secret_key: None,
2078 params: Some(ConnectionParams {
2079 host: Some("host".into()),
2080 host_secret: None,
2081 port: None,
2082 port_secret: None,
2083 dbname: Some("db".into()),
2084 dbname_secret: None,
2085 username: Some("user".into()),
2086 username_secret: None,
2087 password: Some("pass".into()),
2088 password_secret: None,
2089 auth: None,
2090 ssl_mode: Some("require".into()),
2091 ssl_mode_secret: None,
2092 set_role: None,
2093 }),
2094 };
2095
2096 assert_ne!(
2097 conn_no_ssl.cache_key("ns"),
2098 conn_with_ssl.cache_key("ns"),
2099 "cache key should differ when sslMode is present"
2100 );
2101 }
2102
2103 #[test]
2104 fn validate_connection_rejects_empty_literal_host() {
2105 let spec = spec_with_connection(ConnectionSpec {
2106 secret_ref: None,
2107 secret_key: None,
2108 params: Some(ConnectionParams {
2109 host: Some("".into()),
2110 host_secret: None,
2111 port: None,
2112 port_secret: None,
2113 dbname: Some("mydb".into()),
2114 dbname_secret: None,
2115 username: Some("user".into()),
2116 username_secret: None,
2117 password: Some("pass".into()),
2118 password_secret: None,
2119 auth: None,
2120 ssl_mode: None,
2121 ssl_mode_secret: None,
2122 set_role: None,
2123 }),
2124 });
2125
2126 let err = spec.validate_connection_spec().unwrap_err();
2127 assert!(
2128 matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "host"),
2129 "expected EmptyLiteral for host, got: {err}"
2130 );
2131 }
2132
2133 #[test]
2134 fn validate_connection_rejects_whitespace_literal_dbname() {
2135 let spec = spec_with_connection(ConnectionSpec {
2136 secret_ref: None,
2137 secret_key: None,
2138 params: Some(ConnectionParams {
2139 host: Some("host".into()),
2140 host_secret: None,
2141 port: None,
2142 port_secret: None,
2143 dbname: Some(" ".into()),
2144 dbname_secret: None,
2145 username: Some("user".into()),
2146 username_secret: None,
2147 password: Some("pass".into()),
2148 password_secret: None,
2149 auth: None,
2150 ssl_mode: None,
2151 ssl_mode_secret: None,
2152 set_role: None,
2153 }),
2154 });
2155
2156 let err = spec.validate_connection_spec().unwrap_err();
2157 assert!(
2158 matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "dbname"),
2159 "expected EmptyLiteral for dbname, got: {err}"
2160 );
2161 }
2162
2163 fn spec_with_connection(connection: ConnectionSpec) -> PostgresPolicySpec {
2165 PostgresPolicySpec {
2166 connection,
2167 interval: "5m".into(),
2168 suspend: false,
2169 mode: PolicyMode::Apply,
2170 reconciliation_mode: CrdReconciliationMode::default(),
2171 default_owner: None,
2172 profiles: Default::default(),
2173 schemas: vec![],
2174 roles: vec![],
2175 grants: vec![],
2176 default_privileges: vec![],
2177 memberships: vec![],
2178 retirements: vec![],
2179 approval: None,
2180 }
2181 }
2182
2183 fn url_mode_connection() -> ConnectionSpec {
2184 ConnectionSpec {
2185 secret_ref: Some(SecretReference {
2186 name: "pg-creds".into(),
2187 }),
2188 secret_key: Some("DATABASE_URL".into()),
2189 params: None,
2190 }
2191 }
2192
2193 fn params_mode_connection() -> ConnectionSpec {
2194 ConnectionSpec {
2195 secret_ref: None,
2196 secret_key: None,
2197 params: Some(ConnectionParams {
2198 host: Some("my-postgres".into()),
2199 host_secret: None,
2200 port: None,
2201 port_secret: None,
2202 dbname: Some("mydb".into()),
2203 dbname_secret: None,
2204 username: None,
2205 username_secret: Some(SecretKeySelector {
2206 name: "pg-creds".into(),
2207 key: "username".into(),
2208 }),
2209 password: None,
2210 password_secret: Some(SecretKeySelector {
2211 name: "pg-creds".into(),
2212 key: "password".into(),
2213 }),
2214 auth: None,
2215 ssl_mode: None,
2216 ssl_mode_secret: None,
2217 set_role: None,
2218 }),
2219 }
2220 }
2221
2222 #[test]
2225 fn validate_connection_accepts_url_mode() {
2226 let spec = spec_with_connection(url_mode_connection());
2227 assert!(spec.validate_connection_spec().is_ok());
2228 }
2229
2230 #[test]
2231 fn validate_connection_accepts_params_mode() {
2232 let spec = spec_with_connection(params_mode_connection());
2233 assert!(spec.validate_connection_spec().is_ok());
2234 }
2235
2236 #[test]
2237 fn validate_connection_rejects_both_modes_set() {
2238 let spec = spec_with_connection(ConnectionSpec {
2239 secret_ref: Some(SecretReference {
2240 name: "pg-creds".into(),
2241 }),
2242 secret_key: None,
2243 params: Some(ConnectionParams {
2244 host: Some("host".into()),
2245 host_secret: None,
2246 port: None,
2247 port_secret: None,
2248 dbname: Some("db".into()),
2249 dbname_secret: None,
2250 username: Some("user".into()),
2251 username_secret: None,
2252 password: Some("pass".into()),
2253 password_secret: None,
2254 auth: None,
2255 ssl_mode: None,
2256 ssl_mode_secret: None,
2257 set_role: None,
2258 }),
2259 });
2260 assert!(matches!(
2261 spec.validate_connection_spec(),
2262 Err(ConnectionValidationError::BothModesSet)
2263 ));
2264 }
2265
2266 #[test]
2267 fn validate_connection_rejects_neither_mode_set() {
2268 let spec = spec_with_connection(ConnectionSpec {
2269 secret_ref: None,
2270 secret_key: None,
2271 params: None,
2272 });
2273 assert!(spec.validate_connection_spec().is_err());
2274 }
2275
2276 #[test]
2277 fn validate_connection_rejects_invalid_ssl_mode() {
2278 let spec = spec_with_connection(ConnectionSpec {
2279 secret_ref: None,
2280 secret_key: None,
2281 params: Some(ConnectionParams {
2282 host: Some("host".into()),
2283 host_secret: None,
2284 port: None,
2285 port_secret: None,
2286 dbname: Some("db".into()),
2287 dbname_secret: None,
2288 username: Some("user".into()),
2289 username_secret: None,
2290 password: Some("pass".into()),
2291 password_secret: None,
2292 auth: None,
2293 ssl_mode: Some("invalid-mode".into()),
2294 ssl_mode_secret: None,
2295 set_role: None,
2296 }),
2297 });
2298 assert!(spec.validate_connection_spec().is_err());
2299 }
2300
2301 fn params_with_set_role(set_role: Option<String>) -> ConnectionParams {
2302 ConnectionParams {
2303 host: Some("host".into()),
2304 host_secret: None,
2305 port: None,
2306 port_secret: None,
2307 dbname: Some("db".into()),
2308 dbname_secret: None,
2309 username: Some("user".into()),
2310 username_secret: None,
2311 password: Some("pass".into()),
2312 password_secret: None,
2313 auth: None,
2314 ssl_mode: None,
2315 ssl_mode_secret: None,
2316 set_role,
2317 }
2318 }
2319
2320 #[test]
2321 fn validate_connection_accepts_valid_set_role() {
2322 for role in [
2323 "cloudsqlsuperuser",
2324 "_underscore_start",
2325 "role-with-dash",
2326 "role_with$dollar",
2327 "Mixed_Case_Role",
2328 "r2d2",
2329 ] {
2330 let spec = spec_with_connection(ConnectionSpec {
2331 secret_ref: None,
2332 secret_key: None,
2333 params: Some(params_with_set_role(Some(role.into()))),
2334 });
2335 assert!(
2336 spec.validate_connection_spec().is_ok(),
2337 "expected {role} to be accepted"
2338 );
2339 }
2340 }
2341
2342 #[test]
2343 fn validate_connection_rejects_invalid_set_role() {
2344 for role in [
2345 "1leading_digit",
2346 "has space",
2347 "has\"quote",
2348 "has;semicolon",
2349 "has'singlequote",
2350 "ünicode",
2351 ] {
2352 let spec = spec_with_connection(ConnectionSpec {
2353 secret_ref: None,
2354 secret_key: None,
2355 params: Some(params_with_set_role(Some(role.into()))),
2356 });
2357 let err = spec
2358 .validate_connection_spec()
2359 .expect_err(&format!("expected {role} to be rejected"));
2360 assert!(
2361 matches!(err, ConnectionValidationError::InvalidRoleName { ref value } if value == role),
2362 "unexpected error for {role}: {err:?}",
2363 );
2364 }
2365 }
2366
2367 #[test]
2368 fn validate_connection_rejects_empty_set_role() {
2369 let spec = spec_with_connection(ConnectionSpec {
2370 secret_ref: None,
2371 secret_key: None,
2372 params: Some(params_with_set_role(Some(" ".into()))),
2373 });
2374 assert!(matches!(
2375 spec.validate_connection_spec(),
2376 Err(ConnectionValidationError::EmptyLiteral { ref field }) if field == "setRole"
2377 ));
2378 }
2379
2380 #[test]
2381 fn cache_key_includes_set_role() {
2382 let conn_no_role = ConnectionSpec {
2383 secret_ref: None,
2384 secret_key: None,
2385 params: Some(params_with_set_role(None)),
2386 };
2387 let conn_with_role = ConnectionSpec {
2388 secret_ref: None,
2389 secret_key: None,
2390 params: Some(params_with_set_role(Some("cloudsqlsuperuser".into()))),
2391 };
2392 assert_ne!(
2393 conn_no_role.cache_key("ns"),
2394 conn_with_role.cache_key("ns"),
2395 "cache key should differ when setRole is present"
2396 );
2397 }
2398
2399 #[test]
2400 fn validate_connection_accepts_gcp_workload_identity_without_password() {
2401 let spec = spec_with_connection(ConnectionSpec {
2402 secret_ref: None,
2403 secret_key: None,
2404 params: Some(ConnectionParams {
2405 host: Some("10.0.0.5".into()),
2406 host_secret: None,
2407 port: None,
2408 port_secret: None,
2409 dbname: Some("discovery".into()),
2410 dbname_secret: None,
2411 username: Some("pgroles-operator@my-project.iam".into()),
2412 username_secret: None,
2413 password: None,
2414 password_secret: None,
2415 auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2416 impersonate_service_account: None,
2417 scope: None,
2418 }),
2419 ssl_mode: None,
2420 ssl_mode_secret: None,
2421 set_role: None,
2422 }),
2423 });
2424
2425 assert!(spec.validate_connection_spec().is_ok());
2426 assert!(spec.referenced_secret_names("policy").is_empty());
2427 }
2428
2429 #[test]
2430 fn validate_connection_rejects_gcp_workload_identity_with_password() {
2431 let spec = spec_with_connection(ConnectionSpec {
2432 secret_ref: None,
2433 secret_key: None,
2434 params: Some(ConnectionParams {
2435 host: Some("10.0.0.5".into()),
2436 host_secret: None,
2437 port: None,
2438 port_secret: None,
2439 dbname: Some("discovery".into()),
2440 dbname_secret: None,
2441 username: Some("pgroles-operator@my-project.iam".into()),
2442 username_secret: None,
2443 password: Some("static-password".into()),
2444 password_secret: None,
2445 auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2446 impersonate_service_account: None,
2447 scope: None,
2448 }),
2449 ssl_mode: None,
2450 ssl_mode_secret: None,
2451 set_role: None,
2452 }),
2453 });
2454
2455 assert!(matches!(
2456 spec.validate_connection_spec(),
2457 Err(ConnectionValidationError::AuthWithPassword)
2458 ));
2459 }
2460
2461 #[test]
2462 fn validate_connection_rejects_empty_gcp_auth_fields() {
2463 let spec = spec_with_connection(ConnectionSpec {
2464 secret_ref: None,
2465 secret_key: None,
2466 params: Some(ConnectionParams {
2467 host: Some("10.0.0.5".into()),
2468 host_secret: None,
2469 port: None,
2470 port_secret: None,
2471 dbname: Some("discovery".into()),
2472 dbname_secret: None,
2473 username: Some("pgroles-operator@my-project.iam".into()),
2474 username_secret: None,
2475 password: None,
2476 password_secret: None,
2477 auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2478 impersonate_service_account: Some(" ".into()),
2479 scope: None,
2480 }),
2481 ssl_mode: None,
2482 ssl_mode_secret: None,
2483 set_role: None,
2484 }),
2485 });
2486
2487 assert!(matches!(
2488 spec.validate_connection_spec(),
2489 Err(ConnectionValidationError::EmptyAuthField { ref field })
2490 if field == "impersonateServiceAccount"
2491 ));
2492 }
2493
2494 #[test]
2495 fn validate_connection_accepts_valid_ssl_modes() {
2496 for mode in &[
2497 "disable",
2498 "allow",
2499 "prefer",
2500 "require",
2501 "verify-ca",
2502 "verify-full",
2503 ] {
2504 let spec = spec_with_connection(ConnectionSpec {
2505 secret_ref: None,
2506 secret_key: None,
2507 params: Some(ConnectionParams {
2508 host: Some("host".into()),
2509 host_secret: None,
2510 port: None,
2511 port_secret: None,
2512 dbname: Some("db".into()),
2513 dbname_secret: None,
2514 username: Some("user".into()),
2515 username_secret: None,
2516 password: Some("pass".into()),
2517 password_secret: None,
2518 auth: None,
2519 ssl_mode: Some((*mode).into()),
2520 ssl_mode_secret: None,
2521 set_role: None,
2522 }),
2523 });
2524 assert!(
2525 spec.validate_connection_spec().is_ok(),
2526 "sslMode '{mode}' should be accepted"
2527 );
2528 }
2529 }
2530
2531 #[test]
2532 fn validate_connection_rejects_empty_secret_name() {
2533 let spec = spec_with_connection(ConnectionSpec {
2534 secret_ref: None,
2535 secret_key: None,
2536 params: Some(ConnectionParams {
2537 host: Some("host".into()),
2538 host_secret: None,
2539 port: None,
2540 port_secret: None,
2541 dbname: Some("db".into()),
2542 dbname_secret: None,
2543 username: None,
2544 username_secret: Some(SecretKeySelector {
2545 name: "".into(),
2546 key: "username".into(),
2547 }),
2548 password: Some("pass".into()),
2549 password_secret: None,
2550 auth: None,
2551 ssl_mode: None,
2552 ssl_mode_secret: None,
2553 set_role: None,
2554 }),
2555 });
2556 assert!(spec.validate_connection_spec().is_err());
2557 }
2558
2559 #[test]
2560 fn validate_connection_rejects_both_literal_and_secret_for_same_field() {
2561 let spec = spec_with_connection(ConnectionSpec {
2562 secret_ref: None,
2563 secret_key: None,
2564 params: Some(ConnectionParams {
2565 host: Some("host".into()),
2566 host_secret: Some(SecretKeySelector {
2567 name: "s".into(),
2568 key: "k".into(),
2569 }),
2570 port: None,
2571 port_secret: None,
2572 dbname: Some("db".into()),
2573 dbname_secret: None,
2574 username: Some("user".into()),
2575 username_secret: None,
2576 password: Some("pass".into()),
2577 password_secret: None,
2578 auth: None,
2579 ssl_mode: None,
2580 ssl_mode_secret: None,
2581 set_role: None,
2582 }),
2583 });
2584 assert!(matches!(
2585 spec.validate_connection_spec(),
2586 Err(ConnectionValidationError::BothFieldsSet { ref field }) if field == "host"
2587 ));
2588 }
2589
2590 #[test]
2591 fn validate_connection_rejects_neither_literal_nor_secret_for_required_field() {
2592 let spec = spec_with_connection(ConnectionSpec {
2593 secret_ref: None,
2594 secret_key: None,
2595 params: Some(ConnectionParams {
2596 host: None,
2597 host_secret: None,
2598 port: None,
2599 port_secret: None,
2600 dbname: Some("db".into()),
2601 dbname_secret: None,
2602 username: Some("user".into()),
2603 username_secret: None,
2604 password: Some("pass".into()),
2605 password_secret: None,
2606 auth: None,
2607 ssl_mode: None,
2608 ssl_mode_secret: None,
2609 set_role: None,
2610 }),
2611 });
2612 assert!(matches!(
2613 spec.validate_connection_spec(),
2614 Err(ConnectionValidationError::NeitherFieldSet { ref field }) if field == "host"
2615 ));
2616 }
2617
2618 #[test]
2621 fn connection_spec_backward_compat_url_mode() {
2622 let yaml = r#"
2624secretRef:
2625 name: pg-creds
2626secretKey: DATABASE_URL
2627"#;
2628 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2629 assert!(conn.secret_ref.is_some());
2630 assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
2631 assert!(conn.params.is_none());
2632 }
2633
2634 #[test]
2635 fn connection_spec_backward_compat_default_secret_key() {
2636 let yaml = r#"
2637secretRef:
2638 name: pg-creds
2639"#;
2640 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2641 assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
2642 }
2643
2644 #[test]
2645 fn connection_spec_params_mode_deserializes_keycloak_style() {
2646 let yaml = r#"
2647params:
2648 host: my-postgres
2649 port: 5432
2650 dbname: mydb
2651 usernameSecret:
2652 name: creds
2653 key: username
2654 passwordSecret:
2655 name: creds
2656 key: password
2657 sslMode: require
2658"#;
2659 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2660 assert!(conn.secret_ref.is_none());
2661 let params = conn.params.unwrap();
2662 assert_eq!(params.host.as_deref(), Some("my-postgres"));
2663 assert_eq!(params.port, Some(5432));
2664 assert!(params.username_secret.is_some());
2665 assert_eq!(params.username_secret.as_ref().unwrap().name, "creds");
2666 assert_eq!(params.ssl_mode.as_deref(), Some("require"));
2667 }
2668
2669 #[test]
2670 fn connection_spec_params_mode_deserializes_gcp_workload_identity_auth() {
2671 let yaml = r#"
2672params:
2673 host: 10.0.0.5
2674 port: 5432
2675 dbname: discovery
2676 username: pgroles-operator@my-project.iam
2677 auth:
2678 type: gcp_workload_identity
2679 impersonateServiceAccount: target@other-project.iam.gserviceaccount.com
2680 scope: https://example.com/custom-scope
2681"#;
2682 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2683 let params = conn.params.as_ref().unwrap();
2684 let auth = params.auth.as_ref().expect("auth should deserialize");
2685
2686 assert!(params.password.is_none());
2687 assert_eq!(auth.gcp_scope(), "https://example.com/custom-scope");
2688 assert_eq!(
2689 auth.gcp_impersonate_service_account(),
2690 Some("target@other-project.iam.gserviceaccount.com")
2691 );
2692 assert!(conn.cache_key("prod").contains("gcp_workload_identity"));
2693
2694 let spec = spec_with_connection(conn);
2695 assert!(spec.validate_connection_spec().is_ok());
2696 }
2697
2698 #[test]
2699 fn connection_spec_params_mode_all_secrets() {
2700 let yaml = r#"
2702params:
2703 hostSecret:
2704 name: cluster-app
2705 key: host
2706 portSecret:
2707 name: cluster-app
2708 key: port
2709 dbnameSecret:
2710 name: cluster-app
2711 key: dbname
2712 usernameSecret:
2713 name: cluster-app
2714 key: user
2715 passwordSecret:
2716 name: cluster-app
2717 key: password
2718"#;
2719 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2720 let params = conn.params.unwrap();
2721 assert!(params.host.is_none());
2722 assert!(params.host_secret.is_some());
2723 assert_eq!(params.host_secret.as_ref().unwrap().name, "cluster-app");
2724 assert!(params.port.is_none());
2725 assert!(params.port_secret.is_some());
2726 }
2727
2728 #[test]
2731 fn referenced_secret_names_includes_params_secrets() {
2732 let spec = spec_with_connection(params_mode_connection());
2733 let names = spec.referenced_secret_names("test-policy");
2734 assert!(
2735 names.contains("pg-creds"),
2736 "should include the credential secret from params"
2737 );
2738 }
2739
2740 #[test]
2741 fn referenced_secret_names_deduplicates_across_modes() {
2742 let mut spec = spec_with_connection(params_mode_connection());
2744 spec.roles = vec![RoleSpec {
2745 name: "app".into(),
2746 external: false,
2747 login: Some(true),
2748 password: Some(PasswordSpec {
2749 secret_ref: Some(SecretReference {
2750 name: "pg-creds".into(),
2751 }),
2752 secret_key: Some("app-password".into()),
2753 generate: None,
2754 }),
2755 password_valid_until: None,
2756 config: Default::default(),
2757 superuser: None,
2758 createdb: None,
2759 createrole: None,
2760 inherit: None,
2761 replication: None,
2762 bypassrls: None,
2763 connection_limit: None,
2764 comment: None,
2765 }];
2766 let names = spec.referenced_secret_names("test-policy");
2767 assert_eq!(
2769 names.iter().filter(|n| *n == "pg-creds").count(),
2770 1,
2771 "BTreeSet should deduplicate"
2772 );
2773 }
2774
2775 #[test]
2778 fn connection_params_port_defaults_to_none() {
2779 let yaml = r#"
2780params:
2781 host: my-host
2782 dbname: mydb
2783 username: user
2784 password: pass
2785"#;
2786 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
2787 let params = conn.params.unwrap();
2788 assert!(
2789 params.port.is_none(),
2790 "port should default to None (resolved as 5432 at runtime)"
2791 );
2792 assert!(
2793 params.port_secret.is_none(),
2794 "portSecret should also default to None"
2795 );
2796 }
2797
2798 #[test]
2799 fn now_rfc3339_produces_valid_format() {
2800 let ts = now_rfc3339();
2801 assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
2803 assert!(ts.ends_with('Z'), "should end with Z: {ts}");
2804 assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
2805 assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
2806 }
2807
2808 #[test]
2809 fn ready_condition_true_has_expected_shape() {
2810 let cond = ready_condition(true, "Reconciled", "All changes applied");
2811 assert_eq!(cond.condition_type, "Ready");
2812 assert_eq!(cond.status, "True");
2813 assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
2814 assert_eq!(cond.message.as_deref(), Some("All changes applied"));
2815 assert!(cond.last_transition_time.is_some());
2816 }
2817
2818 #[test]
2819 fn ready_condition_false_has_expected_shape() {
2820 let cond = ready_condition(false, "InvalidSpec", "bad manifest");
2821 assert_eq!(cond.condition_type, "Ready");
2822 assert_eq!(cond.status, "False");
2823 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
2824 assert_eq!(cond.message.as_deref(), Some("bad manifest"));
2825 }
2826
2827 #[test]
2828 fn degraded_condition_has_expected_shape() {
2829 let cond = degraded_condition("InvalidSpec", "expansion failed");
2830 assert_eq!(cond.condition_type, "Degraded");
2831 assert_eq!(cond.status, "True");
2832 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
2833 assert_eq!(cond.message.as_deref(), Some("expansion failed"));
2834 assert!(cond.last_transition_time.is_some());
2835 }
2836
2837 #[test]
2838 fn reconciling_condition_has_expected_shape() {
2839 let cond = reconciling_condition("Reconciliation in progress");
2840 assert_eq!(cond.condition_type, "Reconciling");
2841 assert_eq!(cond.status, "True");
2842 assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
2843 assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
2844 assert!(cond.last_transition_time.is_some());
2845 }
2846
2847 #[test]
2848 fn conflict_condition_has_expected_shape() {
2849 let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
2850 assert_eq!(cond.condition_type, "Conflict");
2851 assert_eq!(cond.status, "True");
2852 assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
2853 assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
2854 assert!(cond.last_transition_time.is_some());
2855 }
2856
2857 #[test]
2858 fn ownership_claims_no_overlap() {
2859 let mut left = OwnershipClaims::default();
2860 left.roles.insert("analytics".to_string());
2861 left.schemas.insert("reporting".to_string());
2862
2863 let mut right = OwnershipClaims::default();
2864 right.roles.insert("billing".to_string());
2865 right.schemas.insert("payments".to_string());
2866
2867 assert!(!left.overlaps(&right));
2868 let summary = left.overlap_summary(&right);
2869 assert!(summary.is_empty());
2870 }
2871
2872 #[test]
2873 fn ownership_claims_partial_role_overlap() {
2874 let mut left = OwnershipClaims::default();
2875 left.roles.insert("analytics".to_string());
2876 left.roles.insert("reporting-viewer".to_string());
2877
2878 let mut right = OwnershipClaims::default();
2879 right.roles.insert("analytics".to_string());
2880 right.roles.insert("other-role".to_string());
2881
2882 assert!(left.overlaps(&right));
2883 let summary = left.overlap_summary(&right);
2884 assert!(summary.contains("roles: analytics"));
2885 assert!(!summary.contains("schemas"));
2886 }
2887
2888 #[test]
2889 fn ownership_claims_empty_is_disjoint() {
2890 let left = OwnershipClaims::default();
2891 let right = OwnershipClaims::default();
2892 assert!(!left.overlaps(&right));
2893 }
2894
2895 #[test]
2896 fn database_identity_equality() {
2897 let conn_a = ConnectionSpec {
2898 secret_ref: Some(SecretReference {
2899 name: "db-creds".to_string(),
2900 }),
2901 secret_key: Some("DATABASE_URL".to_string()),
2902 params: None,
2903 };
2904 let a = DatabaseIdentity::from_connection("prod", &conn_a);
2905 let b = DatabaseIdentity::from_connection("prod", &conn_a);
2906 let c = DatabaseIdentity::from_connection("staging", &conn_a);
2907 assert_eq!(a, b);
2908 assert_ne!(a, c);
2909 }
2910
2911 #[test]
2912 fn database_identity_different_key() {
2913 let conn_a = ConnectionSpec {
2914 secret_ref: Some(SecretReference {
2915 name: "db-creds".to_string(),
2916 }),
2917 secret_key: Some("DATABASE_URL".to_string()),
2918 params: None,
2919 };
2920 let conn_b = ConnectionSpec {
2921 secret_ref: Some(SecretReference {
2922 name: "db-creds".to_string(),
2923 }),
2924 secret_key: Some("CUSTOM_URL".to_string()),
2925 params: None,
2926 };
2927 let a = DatabaseIdentity::from_connection("prod", &conn_a);
2928 let b = DatabaseIdentity::from_connection("prod", &conn_b);
2929 assert_ne!(a, b);
2930 }
2931
2932 #[test]
2933 fn status_default_has_empty_conditions() {
2934 let status = PostgresPolicyStatus::default();
2935 assert!(status.conditions.is_empty());
2936 assert!(status.observed_generation.is_none());
2937 assert!(status.last_attempted_generation.is_none());
2938 assert!(status.last_successful_reconcile_time.is_none());
2939 assert!(status.change_summary.is_none());
2940 assert!(status.managed_database_identity.is_none());
2941 assert!(status.owned_roles.is_empty());
2942 assert!(status.owned_schemas.is_empty());
2943 assert!(status.last_error.is_none());
2944 assert!(status.applied_password_source_versions.is_empty());
2945 }
2946
2947 #[test]
2948 fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
2949 let mut status = PostgresPolicyStatus::default();
2950
2951 status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
2953 status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
2954 status
2955 .conditions
2956 .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
2957 status.change_summary = None;
2958 status.last_error = Some("bad manifest".to_string());
2959
2960 let ready = status
2962 .conditions
2963 .iter()
2964 .find(|c| c.condition_type == "Ready")
2965 .expect("should have Ready condition");
2966 assert_eq!(ready.status, "False");
2967 assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
2968
2969 let degraded = status
2971 .conditions
2972 .iter()
2973 .find(|c| c.condition_type == "Degraded")
2974 .expect("should have Degraded condition");
2975 assert_eq!(degraded.status, "True");
2976 assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
2977
2978 assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
2980 }
2981
2982 #[test]
2983 fn status_conflict_workflow() {
2984 let mut status = PostgresPolicyStatus::default();
2985
2986 let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
2988 status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
2989 status.set_condition(conflict_condition("ConflictingPolicy", msg));
2990 status.set_condition(degraded_condition("ConflictingPolicy", msg));
2991 status
2992 .conditions
2993 .retain(|c| c.condition_type != "Reconciling");
2994 status.last_error = Some(msg.to_string());
2995
2996 let conflict = status
2998 .conditions
2999 .iter()
3000 .find(|c| c.condition_type == "Conflict")
3001 .expect("should have Conflict condition");
3002 assert_eq!(conflict.status, "True");
3003 assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
3004
3005 let ready = status
3007 .conditions
3008 .iter()
3009 .find(|c| c.condition_type == "Ready")
3010 .expect("should have Ready condition");
3011 assert_eq!(ready.status, "False");
3012
3013 let degraded = status
3015 .conditions
3016 .iter()
3017 .find(|c| c.condition_type == "Degraded")
3018 .expect("should have Degraded condition");
3019 assert_eq!(degraded.status, "True");
3020 }
3021
3022 #[test]
3023 fn status_successful_reconcile_records_generation_and_time() {
3024 let mut status = PostgresPolicyStatus::default();
3025 let generation = Some(3_i64);
3026 let summary = ChangeSummary {
3027 roles_created: 2,
3028 total: 2,
3029 ..Default::default()
3030 };
3031
3032 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
3034 status.conditions.retain(|c| {
3035 c.condition_type != "Reconciling"
3036 && c.condition_type != "Degraded"
3037 && c.condition_type != "Conflict"
3038 && c.condition_type != "Paused"
3039 });
3040 status.observed_generation = generation;
3041 status.last_attempted_generation = generation;
3042 status.last_successful_reconcile_time = Some(now_rfc3339());
3043 status.last_reconcile_time = Some(now_rfc3339());
3044 status.change_summary = Some(summary);
3045 status.last_error = None;
3046
3047 let ready = status
3049 .conditions
3050 .iter()
3051 .find(|c| c.condition_type == "Ready")
3052 .expect("should have Ready condition");
3053 assert_eq!(ready.status, "True");
3054 assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
3055
3056 assert_eq!(status.observed_generation, Some(3));
3058 assert_eq!(status.last_attempted_generation, Some(3));
3059
3060 assert!(status.last_successful_reconcile_time.is_some());
3062 assert!(status.last_reconcile_time.is_some());
3063
3064 let summary = status.change_summary.as_ref().unwrap();
3066 assert_eq!(summary.roles_created, 2);
3067 assert_eq!(summary.total, 2);
3068
3069 assert!(status.last_error.is_none());
3071
3072 assert!(
3074 status
3075 .conditions
3076 .iter()
3077 .all(|c| c.condition_type != "Degraded"
3078 && c.condition_type != "Conflict"
3079 && c.condition_type != "Paused"
3080 && c.condition_type != "Reconciling")
3081 );
3082 }
3083
3084 #[test]
3085 fn status_suspended_workflow() {
3086 let mut status = PostgresPolicyStatus::default();
3087 let generation = Some(2_i64);
3088
3089 status.set_condition(paused_condition("Reconciliation suspended by spec"));
3091 status.set_condition(ready_condition(
3092 false,
3093 "Suspended",
3094 "Reconciliation suspended by spec",
3095 ));
3096 status
3097 .conditions
3098 .retain(|c| c.condition_type != "Reconciling");
3099 status.last_attempted_generation = generation;
3100 status.last_error = None;
3101
3102 let paused = status
3104 .conditions
3105 .iter()
3106 .find(|c| c.condition_type == "Paused")
3107 .expect("should have Paused condition");
3108 assert_eq!(paused.status, "True");
3109
3110 let ready = status
3112 .conditions
3113 .iter()
3114 .find(|c| c.condition_type == "Ready")
3115 .expect("should have Ready condition");
3116 assert_eq!(ready.status, "False");
3117 assert_eq!(ready.reason.as_deref(), Some("Suspended"));
3118
3119 assert!(
3121 !status
3122 .conditions
3123 .iter()
3124 .any(|c| c.condition_type == "Reconciling")
3125 );
3126 }
3127
3128 #[test]
3129 fn status_transitions_from_degraded_to_ready() {
3130 let mut status = PostgresPolicyStatus::default();
3131
3132 status.set_condition(ready_condition(false, "InvalidSpec", "error"));
3134 status.set_condition(degraded_condition("InvalidSpec", "error"));
3135 status.last_error = Some("error".to_string());
3136
3137 assert_eq!(status.conditions.len(), 2);
3138
3139 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
3141 status.conditions.retain(|c| {
3142 c.condition_type != "Reconciling"
3143 && c.condition_type != "Degraded"
3144 && c.condition_type != "Conflict"
3145 && c.condition_type != "Paused"
3146 });
3147 status.last_error = None;
3148
3149 let ready = status
3151 .conditions
3152 .iter()
3153 .find(|c| c.condition_type == "Ready")
3154 .expect("should have Ready condition");
3155 assert_eq!(ready.status, "True");
3156
3157 assert!(
3159 !status
3160 .conditions
3161 .iter()
3162 .any(|c| c.condition_type == "Degraded")
3163 );
3164
3165 assert_eq!(status.conditions.len(), 1);
3167
3168 assert!(status.last_error.is_none());
3170 }
3171
3172 #[test]
3173 fn change_summary_default_is_all_zero() {
3174 let summary = ChangeSummary::default();
3175 assert_eq!(summary.roles_created, 0);
3176 assert_eq!(summary.roles_altered, 0);
3177 assert_eq!(summary.roles_dropped, 0);
3178 assert_eq!(summary.sessions_terminated, 0);
3179 assert_eq!(summary.grants_added, 0);
3180 assert_eq!(summary.grants_revoked, 0);
3181 assert_eq!(summary.default_privileges_set, 0);
3182 assert_eq!(summary.default_privileges_revoked, 0);
3183 assert_eq!(summary.members_added, 0);
3184 assert_eq!(summary.members_removed, 0);
3185 assert_eq!(summary.total, 0);
3186 }
3187
3188 #[test]
3189 fn status_serializes_to_json() {
3190 let mut status = PostgresPolicyStatus::default();
3191 status.set_condition(ready_condition(true, "Reconciled", "done"));
3192 status.observed_generation = Some(5);
3193 status.managed_database_identity = Some("ns/secret/key".to_string());
3194 status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
3195 status.owned_schemas = vec!["public".to_string()];
3196 status.change_summary = Some(ChangeSummary {
3197 roles_created: 1,
3198 total: 1,
3199 ..Default::default()
3200 });
3201
3202 let json = serde_json::to_string(&status).expect("should serialize");
3203 assert!(json.contains("\"Reconciled\""));
3204 assert!(json.contains("\"observed_generation\":5"));
3205 assert!(json.contains("\"role-a\""));
3206 assert!(json.contains("\"ns/secret/key\""));
3207 }
3208
3209 #[test]
3210 fn crd_spec_deserializes_from_yaml() {
3211 let yaml = r#"
3212connection:
3213 secretRef:
3214 name: pg-credentials
3215interval: "10m"
3216default_owner: app_owner
3217profiles:
3218 editor:
3219 grants:
3220 - privileges: [USAGE]
3221 object: { type: schema }
3222 - privileges: [SELECT, INSERT, UPDATE, DELETE]
3223 object: { type: table, name: "*" }
3224 default_privileges:
3225 - privileges: [SELECT, INSERT, UPDATE, DELETE]
3226 on_type: table
3227schemas:
3228 - name: inventory
3229 profiles: [editor]
3230roles:
3231 - name: analytics
3232 login: true
3233grants:
3234 - role: analytics
3235 privileges: [CONNECT]
3236 object: { type: database, name: mydb }
3237memberships:
3238 - role: inventory-editor
3239 members:
3240 - name: analytics
3241retirements:
3242 - role: legacy-app
3243 reassign_owned_to: app_owner
3244 drop_owned: true
3245 terminate_sessions: true
3246"#;
3247 let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
3248 assert_eq!(spec.interval, "10m");
3249 assert_eq!(spec.default_owner, Some("app_owner".to_string()));
3250 assert_eq!(spec.profiles.len(), 1);
3251 assert!(spec.profiles.contains_key("editor"));
3252 assert_eq!(spec.schemas.len(), 1);
3253 assert_eq!(spec.roles.len(), 1);
3254 assert_eq!(spec.grants.len(), 1);
3255 assert_eq!(spec.memberships.len(), 1);
3256 assert_eq!(spec.retirements.len(), 1);
3257 assert_eq!(spec.retirements[0].role, "legacy-app");
3258 assert!(spec.retirements[0].terminate_sessions);
3259 }
3260
3261 #[test]
3262 fn referenced_secret_names_includes_connection_secret() {
3263 let spec = PostgresPolicySpec {
3264 connection: ConnectionSpec {
3265 secret_ref: Some(SecretReference {
3266 name: "pg-conn".to_string(),
3267 }),
3268 secret_key: Some("DATABASE_URL".to_string()),
3269 params: None,
3270 },
3271 interval: "5m".to_string(),
3272 suspend: false,
3273 mode: PolicyMode::Apply,
3274 reconciliation_mode: CrdReconciliationMode::default(),
3275 default_owner: None,
3276 profiles: std::collections::HashMap::new(),
3277 schemas: vec![],
3278 roles: vec![],
3279 grants: vec![],
3280 default_privileges: vec![],
3281 memberships: vec![],
3282 retirements: vec![],
3283 approval: None,
3284 };
3285
3286 let names = spec.referenced_secret_names("test-policy");
3287 assert!(names.contains("pg-conn"));
3288 assert_eq!(names.len(), 1);
3289 }
3290
3291 #[test]
3292 fn referenced_secret_names_includes_password_secrets() {
3293 let spec = PostgresPolicySpec {
3294 connection: ConnectionSpec {
3295 secret_ref: Some(SecretReference {
3296 name: "pg-conn".to_string(),
3297 }),
3298 secret_key: Some("DATABASE_URL".to_string()),
3299 params: None,
3300 },
3301 interval: "5m".to_string(),
3302 suspend: false,
3303 mode: PolicyMode::Apply,
3304 reconciliation_mode: CrdReconciliationMode::default(),
3305 default_owner: None,
3306 profiles: std::collections::HashMap::new(),
3307 schemas: vec![],
3308 roles: vec![
3309 RoleSpec {
3310 name: "role-a".to_string(),
3311 external: false,
3312 login: Some(true),
3313 password: Some(PasswordSpec {
3314 secret_ref: Some(SecretReference {
3315 name: "role-passwords".to_string(),
3316 }),
3317 secret_key: Some("role-a".to_string()),
3318 generate: None,
3319 }),
3320 password_valid_until: None,
3321 config: Default::default(),
3322 superuser: None,
3323 createdb: None,
3324 createrole: None,
3325 inherit: None,
3326 replication: None,
3327 bypassrls: None,
3328 connection_limit: None,
3329 comment: None,
3330 },
3331 RoleSpec {
3332 name: "role-b".to_string(),
3333 external: false,
3334 login: Some(true),
3335 password: Some(PasswordSpec {
3336 secret_ref: Some(SecretReference {
3337 name: "other-secret".to_string(),
3338 }),
3339 secret_key: None,
3340 generate: None,
3341 }),
3342 password_valid_until: None,
3343 config: Default::default(),
3344 superuser: None,
3345 createdb: None,
3346 createrole: None,
3347 inherit: None,
3348 replication: None,
3349 bypassrls: None,
3350 connection_limit: None,
3351 comment: None,
3352 },
3353 RoleSpec {
3354 name: "role-c".to_string(),
3355 external: false,
3356 login: None,
3357 password: None,
3358 password_valid_until: None,
3359 config: Default::default(),
3360 superuser: None,
3361 createdb: None,
3362 createrole: None,
3363 inherit: None,
3364 replication: None,
3365 bypassrls: None,
3366 connection_limit: None,
3367 comment: None,
3368 },
3369 ],
3370 grants: vec![],
3371 default_privileges: vec![],
3372 memberships: vec![],
3373 retirements: vec![],
3374 approval: None,
3375 };
3376
3377 let names = spec.referenced_secret_names("test-policy");
3378 assert!(
3379 names.contains("pg-conn"),
3380 "should include connection secret"
3381 );
3382 assert!(
3383 names.contains("role-passwords"),
3384 "should include role-a password secret"
3385 );
3386 assert!(
3387 names.contains("other-secret"),
3388 "should include role-b password secret"
3389 );
3390 assert_eq!(names.len(), 3);
3391 }
3392
3393 #[test]
3394 fn validate_password_specs_rejects_password_without_login() {
3395 let spec = PostgresPolicySpec {
3396 connection: ConnectionSpec {
3397 secret_ref: Some(SecretReference {
3398 name: "pg-conn".to_string(),
3399 }),
3400 secret_key: Some("DATABASE_URL".to_string()),
3401 params: None,
3402 },
3403 interval: "5m".to_string(),
3404 suspend: false,
3405 mode: PolicyMode::Apply,
3406 reconciliation_mode: CrdReconciliationMode::default(),
3407 default_owner: None,
3408 profiles: std::collections::HashMap::new(),
3409 schemas: vec![],
3410 roles: vec![RoleSpec {
3411 name: "app-user".to_string(),
3412 external: false,
3413 login: Some(false),
3414 superuser: None,
3415 createdb: None,
3416 createrole: None,
3417 inherit: None,
3418 replication: None,
3419 bypassrls: None,
3420 connection_limit: None,
3421 comment: None,
3422 password: Some(PasswordSpec {
3423 secret_ref: Some(SecretReference {
3424 name: "role-passwords".to_string(),
3425 }),
3426 secret_key: None,
3427 generate: None,
3428 }),
3429 password_valid_until: None,
3430 config: Default::default(),
3431 }],
3432 grants: vec![],
3433 default_privileges: vec![],
3434 memberships: vec![],
3435 retirements: vec![],
3436 approval: None,
3437 };
3438
3439 assert!(matches!(
3440 spec.validate_password_specs("test-policy"),
3441 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
3442 ));
3443 }
3444
3445 #[test]
3446 fn validate_password_specs_rejects_password_with_login_omitted() {
3447 let spec = PostgresPolicySpec {
3448 connection: ConnectionSpec {
3449 secret_ref: Some(SecretReference {
3450 name: "pg-conn".to_string(),
3451 }),
3452 secret_key: Some("DATABASE_URL".to_string()),
3453 params: None,
3454 },
3455 interval: "5m".to_string(),
3456 suspend: false,
3457 mode: PolicyMode::Apply,
3458 reconciliation_mode: CrdReconciliationMode::default(),
3459 default_owner: None,
3460 profiles: std::collections::HashMap::new(),
3461 schemas: vec![],
3462 roles: vec![RoleSpec {
3463 name: "app-user".to_string(),
3464 external: false,
3465 login: None, superuser: None,
3467 createdb: None,
3468 createrole: None,
3469 inherit: None,
3470 replication: None,
3471 bypassrls: None,
3472 connection_limit: None,
3473 comment: None,
3474 password: Some(PasswordSpec {
3475 secret_ref: Some(SecretReference {
3476 name: "role-passwords".to_string(),
3477 }),
3478 secret_key: None,
3479 generate: None,
3480 }),
3481 password_valid_until: None,
3482 config: Default::default(),
3483 }],
3484 grants: vec![],
3485 default_privileges: vec![],
3486 memberships: vec![],
3487 retirements: vec![],
3488 approval: None,
3489 };
3490
3491 assert!(matches!(
3492 spec.validate_password_specs("test-policy"),
3493 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
3494 ));
3495 }
3496
3497 #[test]
3498 fn validate_password_specs_rejects_invalid_password_mode() {
3499 let spec = PostgresPolicySpec {
3500 connection: ConnectionSpec {
3501 secret_ref: Some(SecretReference {
3502 name: "pg-conn".to_string(),
3503 }),
3504 secret_key: Some("DATABASE_URL".to_string()),
3505 params: None,
3506 },
3507 interval: "5m".to_string(),
3508 suspend: false,
3509 mode: PolicyMode::Apply,
3510 reconciliation_mode: CrdReconciliationMode::default(),
3511 default_owner: None,
3512 profiles: std::collections::HashMap::new(),
3513 schemas: vec![],
3514 roles: vec![RoleSpec {
3515 name: "app-user".to_string(),
3516 external: false,
3517 login: Some(true),
3518 superuser: None,
3519 createdb: None,
3520 createrole: None,
3521 inherit: None,
3522 replication: None,
3523 bypassrls: None,
3524 connection_limit: None,
3525 comment: None,
3526 password: Some(PasswordSpec {
3527 secret_ref: Some(SecretReference {
3528 name: "role-passwords".to_string(),
3529 }),
3530 secret_key: None,
3531 generate: Some(GeneratePasswordSpec {
3532 length: Some(32),
3533 secret_name: None,
3534 secret_key: None,
3535 }),
3536 }),
3537 password_valid_until: None,
3538 config: Default::default(),
3539 }],
3540 grants: vec![],
3541 default_privileges: vec![],
3542 memberships: vec![],
3543 retirements: vec![],
3544 approval: None,
3545 };
3546
3547 assert!(matches!(
3548 spec.validate_password_specs("test-policy"),
3549 Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
3550 ));
3551 }
3552
3553 #[test]
3554 fn validate_password_specs_rejects_invalid_generated_length() {
3555 let spec = PostgresPolicySpec {
3556 connection: ConnectionSpec {
3557 secret_ref: Some(SecretReference {
3558 name: "pg-conn".to_string(),
3559 }),
3560 secret_key: Some("DATABASE_URL".to_string()),
3561 params: None,
3562 },
3563 interval: "5m".to_string(),
3564 suspend: false,
3565 mode: PolicyMode::Apply,
3566 reconciliation_mode: CrdReconciliationMode::default(),
3567 default_owner: None,
3568 profiles: std::collections::HashMap::new(),
3569 schemas: vec![],
3570 roles: vec![RoleSpec {
3571 name: "app-user".to_string(),
3572 external: false,
3573 login: Some(true),
3574 superuser: None,
3575 createdb: None,
3576 createrole: None,
3577 inherit: None,
3578 replication: None,
3579 bypassrls: None,
3580 connection_limit: None,
3581 comment: None,
3582 password: Some(PasswordSpec {
3583 secret_ref: None,
3584 secret_key: None,
3585 generate: Some(GeneratePasswordSpec {
3586 length: Some(8),
3587 secret_name: None,
3588 secret_key: None,
3589 }),
3590 }),
3591 password_valid_until: None,
3592 config: Default::default(),
3593 }],
3594 grants: vec![],
3595 default_privileges: vec![],
3596 memberships: vec![],
3597 retirements: vec![],
3598 approval: None,
3599 };
3600
3601 assert!(matches!(
3602 spec.validate_password_specs("test-policy"),
3603 Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
3604 ));
3605 }
3606
3607 #[test]
3608 fn validate_password_specs_rejects_invalid_generated_secret_key() {
3609 let spec = PostgresPolicySpec {
3610 connection: ConnectionSpec {
3611 secret_ref: Some(SecretReference {
3612 name: "pg-conn".to_string(),
3613 }),
3614 secret_key: Some("DATABASE_URL".to_string()),
3615 params: None,
3616 },
3617 interval: "5m".to_string(),
3618 suspend: false,
3619 mode: PolicyMode::Apply,
3620 reconciliation_mode: CrdReconciliationMode::default(),
3621 default_owner: None,
3622 profiles: std::collections::HashMap::new(),
3623 schemas: vec![],
3624 roles: vec![RoleSpec {
3625 name: "app-user".to_string(),
3626 external: false,
3627 login: Some(true),
3628 superuser: None,
3629 createdb: None,
3630 createrole: None,
3631 inherit: None,
3632 replication: None,
3633 bypassrls: None,
3634 connection_limit: None,
3635 comment: None,
3636 password: Some(PasswordSpec {
3637 secret_ref: None,
3638 secret_key: None,
3639 generate: Some(GeneratePasswordSpec {
3640 length: Some(32),
3641 secret_name: None,
3642 secret_key: Some("bad/key".to_string()),
3643 }),
3644 }),
3645 password_valid_until: None,
3646 config: Default::default(),
3647 }],
3648 grants: vec![],
3649 default_privileges: vec![],
3650 memberships: vec![],
3651 retirements: vec![],
3652 approval: None,
3653 };
3654
3655 assert!(matches!(
3656 spec.validate_password_specs("test-policy"),
3657 Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
3658 if role == "app-user" && field == "generate.secretKey"
3659 ));
3660 }
3661
3662 #[test]
3663 fn validate_password_specs_rejects_invalid_generated_secret_name() {
3664 let spec = PostgresPolicySpec {
3665 connection: ConnectionSpec {
3666 secret_ref: Some(SecretReference {
3667 name: "pg-conn".to_string(),
3668 }),
3669 secret_key: Some("DATABASE_URL".to_string()),
3670 params: None,
3671 },
3672 interval: "5m".to_string(),
3673 suspend: false,
3674 mode: PolicyMode::Apply,
3675 reconciliation_mode: CrdReconciliationMode::default(),
3676 default_owner: None,
3677 profiles: std::collections::HashMap::new(),
3678 schemas: vec![],
3679 roles: vec![RoleSpec {
3680 name: "app-user".to_string(),
3681 external: false,
3682 login: Some(true),
3683 superuser: None,
3684 createdb: None,
3685 createrole: None,
3686 inherit: None,
3687 replication: None,
3688 bypassrls: None,
3689 connection_limit: None,
3690 comment: None,
3691 password: Some(PasswordSpec {
3692 secret_ref: None,
3693 secret_key: None,
3694 generate: Some(GeneratePasswordSpec {
3695 length: Some(32),
3696 secret_name: Some("Bad_Name".to_string()),
3697 secret_key: None,
3698 }),
3699 }),
3700 password_valid_until: None,
3701 config: Default::default(),
3702 }],
3703 grants: vec![],
3704 default_privileges: vec![],
3705 memberships: vec![],
3706 retirements: vec![],
3707 approval: None,
3708 };
3709
3710 assert!(matches!(
3711 spec.validate_password_specs("test-policy"),
3712 Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
3713 ));
3714 }
3715
3716 #[test]
3717 fn validate_password_specs_rejects_reserved_generated_secret_key() {
3718 let spec = PostgresPolicySpec {
3719 connection: ConnectionSpec {
3720 secret_ref: Some(SecretReference {
3721 name: "pg-conn".to_string(),
3722 }),
3723 secret_key: Some("DATABASE_URL".to_string()),
3724 params: None,
3725 },
3726 interval: "5m".to_string(),
3727 suspend: false,
3728 mode: PolicyMode::Apply,
3729 reconciliation_mode: CrdReconciliationMode::default(),
3730 default_owner: None,
3731 profiles: std::collections::HashMap::new(),
3732 schemas: vec![],
3733 roles: vec![RoleSpec {
3734 name: "app-user".to_string(),
3735 external: false,
3736 login: Some(true),
3737 superuser: None,
3738 createdb: None,
3739 createrole: None,
3740 inherit: None,
3741 replication: None,
3742 bypassrls: None,
3743 connection_limit: None,
3744 comment: None,
3745 password: Some(PasswordSpec {
3746 secret_ref: None,
3747 secret_key: None,
3748 generate: Some(GeneratePasswordSpec {
3749 length: Some(32),
3750 secret_name: None,
3751 secret_key: Some("verifier".to_string()),
3752 }),
3753 }),
3754 password_valid_until: None,
3755 config: Default::default(),
3756 }],
3757 grants: vec![],
3758 default_privileges: vec![],
3759 memberships: vec![],
3760 retirements: vec![],
3761 approval: None,
3762 };
3763
3764 assert!(matches!(
3765 spec.validate_password_specs("test-policy"),
3766 Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
3767 if role == "app-user" && key == "verifier"
3768 ));
3769 }
3770
3771 #[test]
3772 fn plan_crd_generates_valid_schema() {
3773 let crd = PostgresPolicyPlan::crd();
3774 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
3775 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
3776 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
3777 assert!(
3778 yaml.contains("PostgresPolicyPlan"),
3779 "kind should be PostgresPolicyPlan"
3780 );
3781 assert!(yaml.contains("pgplan"), "should have shortname pgplan");
3782 }
3783
3784 #[test]
3785 fn plan_phase_display() {
3786 assert_eq!(PlanPhase::Pending.to_string(), "Pending");
3787 assert_eq!(PlanPhase::Approved.to_string(), "Approved");
3788 assert_eq!(PlanPhase::Applying.to_string(), "Applying");
3789 assert_eq!(PlanPhase::Applied.to_string(), "Applied");
3790 assert_eq!(PlanPhase::Failed.to_string(), "Failed");
3791 assert_eq!(PlanPhase::Superseded.to_string(), "Superseded");
3792 }
3793
3794 #[test]
3795 fn plan_phase_default_is_pending() {
3796 assert_eq!(PlanPhase::default(), PlanPhase::Pending);
3797 }
3798
3799 #[test]
3800 fn effective_approval_infers_from_mode() {
3801 let base = PostgresPolicySpec {
3802 connection: ConnectionSpec {
3803 secret_ref: Some(SecretReference {
3804 name: "test".into(),
3805 }),
3806 secret_key: Some("DATABASE_URL".into()),
3807 params: None,
3808 },
3809 interval: "5m".into(),
3810 suspend: false,
3811 mode: PolicyMode::Apply,
3812 reconciliation_mode: CrdReconciliationMode::Authoritative,
3813 default_owner: None,
3814 profiles: Default::default(),
3815 schemas: vec![],
3816 roles: vec![],
3817 grants: vec![],
3818 default_privileges: vec![],
3819 memberships: vec![],
3820 retirements: vec![],
3821 approval: None,
3822 };
3823
3824 assert_eq!(base.effective_approval(), ApprovalMode::Auto);
3826
3827 let plan = PostgresPolicySpec {
3829 mode: PolicyMode::Plan,
3830 ..base.clone()
3831 };
3832 assert_eq!(plan.effective_approval(), ApprovalMode::Manual);
3833
3834 let explicit = PostgresPolicySpec {
3836 approval: Some(ApprovalMode::Manual),
3837 ..base.clone()
3838 };
3839 assert_eq!(explicit.effective_approval(), ApprovalMode::Manual);
3840 }
3841
3842 #[test]
3843 fn approval_mode_serde_roundtrip() {
3844 let manual: ApprovalMode = serde_json::from_str("\"manual\"").unwrap();
3846 assert_eq!(manual, ApprovalMode::Manual);
3847 let auto: ApprovalMode = serde_json::from_str("\"auto\"").unwrap();
3848 assert_eq!(auto, ApprovalMode::Auto);
3849
3850 let manual_json = serde_json::to_value(&ApprovalMode::Manual).unwrap();
3852 assert_eq!(manual_json, serde_json::Value::String("manual".to_string()));
3853 let auto_json = serde_json::to_value(&ApprovalMode::Auto).unwrap();
3854 assert_eq!(auto_json, serde_json::Value::String("auto".to_string()));
3855 }
3856
3857 #[test]
3858 fn plan_status_default_is_empty() {
3859 let status = PostgresPolicyPlanStatus::default();
3860 assert_eq!(status.phase, PlanPhase::Pending);
3861 assert!(status.conditions.is_empty());
3862 assert!(status.change_summary.is_none());
3863 assert!(status.sql_ref.is_none());
3864 assert!(status.sql_inline.is_none());
3865 assert!(status.computed_at.is_none());
3866 assert!(status.applied_at.is_none());
3867 assert!(status.last_error.is_none());
3868 }
3869
3870 #[test]
3871 fn spec_without_approval_field_deserializes_as_none() {
3872 let json = serde_json::json!({
3873 "connection": {
3874 "secretRef": { "name": "pg-secret" },
3875 "secretKey": "DATABASE_URL"
3876 },
3877 "interval": "5m",
3878 "suspend": false,
3879 "mode": "apply",
3880 "reconciliation_mode": "authoritative"
3881 });
3882
3883 let spec: PostgresPolicySpec =
3884 serde_json::from_value(json).expect("should deserialize without approval field");
3885 assert!(
3886 spec.approval.is_none(),
3887 "approval should be None when omitted"
3888 );
3889 assert_eq!(
3890 spec.effective_approval(),
3891 ApprovalMode::Auto,
3892 "effective_approval should infer Auto from apply mode"
3893 );
3894 }
3895
3896 #[test]
3897 fn status_without_current_plan_ref_deserializes_as_none() {
3898 let json = serde_json::json!({
3899 "conditions": [],
3900 "owned_roles": [],
3901 "owned_schemas": []
3902 });
3903
3904 let status: PostgresPolicyStatus =
3905 serde_json::from_value(json).expect("should deserialize without current_plan_ref");
3906 assert!(
3907 status.current_plan_ref.is_none(),
3908 "current_plan_ref should be None when omitted"
3909 );
3910 }
3911
3912 #[test]
3913 fn effective_approval_explicit_auto_overrides_plan_mode() {
3914 let spec = PostgresPolicySpec {
3915 connection: ConnectionSpec {
3916 secret_ref: Some(SecretReference {
3917 name: "test".into(),
3918 }),
3919 secret_key: Some("DATABASE_URL".into()),
3920 params: None,
3921 },
3922 interval: "5m".into(),
3923 suspend: false,
3924 mode: PolicyMode::Plan,
3925 reconciliation_mode: CrdReconciliationMode::Authoritative,
3926 default_owner: None,
3927 profiles: Default::default(),
3928 schemas: vec![],
3929 roles: vec![],
3930 grants: vec![],
3931 default_privileges: vec![],
3932 memberships: vec![],
3933 retirements: vec![],
3934 approval: Some(ApprovalMode::Auto),
3935 };
3936
3937 assert_eq!(
3938 spec.effective_approval(),
3939 ApprovalMode::Auto,
3940 "explicit Auto should override Plan mode's default of Manual"
3941 );
3942 }
3943
3944 #[test]
3945 fn plan_phase_rejected_display() {
3946 assert_eq!(PlanPhase::Rejected.to_string(), "Rejected");
3947 }
3948
3949 #[test]
3950 fn plan_phase_all_variants_display() {
3951 let variants = [
3952 PlanPhase::Pending,
3953 PlanPhase::Approved,
3954 PlanPhase::Applying,
3955 PlanPhase::Applied,
3956 PlanPhase::Failed,
3957 PlanPhase::Superseded,
3958 PlanPhase::Rejected,
3959 ];
3960 for variant in &variants {
3961 let display = variant.to_string();
3962 assert!(
3963 !display.is_empty(),
3964 "PlanPhase::{variant:?} should have non-empty Display output"
3965 );
3966 }
3967 }
3968
3969 #[test]
3970 fn plan_status_defaults() {
3971 let status = PostgresPolicyPlanStatus::default();
3972 assert_eq!(status.phase, PlanPhase::Pending);
3973 assert!(status.conditions.is_empty());
3974 assert!(status.sql_ref.is_none());
3975 assert!(status.sql_hash.is_none());
3976 assert!(status.sql_inline.is_none());
3977 assert!(!status.sql_truncated);
3978 assert!(status.redacted_sql_hash.is_none());
3979 assert!(status.sql_original_bytes.is_none());
3980 assert!(status.sql_stored_bytes.is_none());
3981 assert!(status.change_summary.is_none());
3982 assert!(status.computed_at.is_none());
3983 assert!(status.applied_at.is_none());
3984 assert!(status.last_error.is_none());
3985 }
3986
3987 #[test]
3988 fn sql_ref_missing_compression_deserializes_as_uncompressed_legacy_shape() {
3989 let json = serde_json::json!({
3990 "name": "legacy-plan-sql",
3991 "key": "plan.sql"
3992 });
3993
3994 let sql_ref: SqlRef = serde_json::from_value(json).expect("legacy SqlRef should decode");
3995
3996 assert_eq!(sql_ref.name, "legacy-plan-sql");
3997 assert_eq!(sql_ref.key, "plan.sql");
3998 assert_eq!(sql_ref.compression, None);
3999 }
4000
4001 #[test]
4002 fn plan_spec_camel_case_serialization() {
4003 let spec = PostgresPolicyPlanSpec {
4004 policy_ref: PolicyPlanRef {
4005 name: "my-policy".into(),
4006 },
4007 policy_generation: 3,
4008 reconciliation_mode: CrdReconciliationMode::Authoritative,
4009 owned_roles: vec!["role-a".into()],
4010 owned_schemas: vec!["public".into()],
4011 managed_database_identity: "ns/secret/key".into(),
4012 };
4013
4014 let json = serde_json::to_value(&spec).expect("should serialize to JSON");
4015 let obj = json.as_object().expect("should be a JSON object");
4016
4017 assert!(
4018 obj.contains_key("policyRef"),
4019 "should use camelCase: policyRef"
4020 );
4021 assert!(
4022 obj.contains_key("policyGeneration"),
4023 "should use camelCase: policyGeneration"
4024 );
4025 assert!(
4026 obj.contains_key("reconciliationMode"),
4027 "should use camelCase: reconciliationMode"
4028 );
4029 assert!(
4030 obj.contains_key("ownedRoles"),
4031 "should use camelCase: ownedRoles"
4032 );
4033 assert!(
4034 obj.contains_key("ownedSchemas"),
4035 "should use camelCase: ownedSchemas"
4036 );
4037 assert!(
4038 obj.contains_key("managedDatabaseIdentity"),
4039 "should use camelCase: managedDatabaseIdentity"
4040 );
4041 }
4042}