1use kube::{CustomResource, KubeSchema};
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")]
117 pub approval: Option<ApprovalMode>,
118}
119
120fn default_interval() -> String {
121 "5m".to_string()
122}
123
124#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
126#[serde(rename_all = "lowercase")]
127pub enum PolicyMode {
128 #[default]
129 Apply,
130 Plan,
131}
132
133#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
135#[serde(rename_all = "lowercase")]
136pub enum CrdReconciliationMode {
137 #[default]
139 Authoritative,
140 Additive,
142 Adopt,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
154pub enum ApprovalMode {
155 #[serde(rename = "manual")]
157 Manual,
158 #[serde(rename = "auto")]
160 Auto,
161}
162
163impl PostgresPolicySpec {
164 pub fn effective_approval(&self) -> ApprovalMode {
167 match &self.approval {
168 Some(mode) => mode.clone(),
169 None => match self.mode {
170 PolicyMode::Apply => ApprovalMode::Auto,
171 PolicyMode::Plan => ApprovalMode::Manual,
172 },
173 }
174 }
175}
176
177pub const PLAN_APPROVED_ANNOTATION: &str = "pgroles.io/approved";
183
184pub const PLAN_REJECTED_ANNOTATION: &str = "pgroles.io/rejected";
186
187pub const REQUESTED_RECONCILE_ANNOTATION: &str = "reconcile.pgroles.io/requestedAt";
189
190pub const LABEL_POLICY: &str = "pgroles.io/policy";
192
193pub const LABEL_DATABASE_IDENTITY: &str = "pgroles.io/database-identity";
195
196pub const LABEL_PLAN: &str = "pgroles.io/plan";
198pub const LABEL_ACCESS_POLICY_UID: &str = "pgroles.io/access-policy-uid";
200pub const LABEL_TARGET_POLICY_UID: &str = "pgroles.io/target-policy-uid";
202
203impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
204 fn from(crd: CrdReconciliationMode) -> Self {
205 match crd {
206 CrdReconciliationMode::Authoritative => {
207 pgroles_core::diff::ReconciliationMode::Authoritative
208 }
209 CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
210 CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
240#[serde(rename_all = "camelCase")]
241pub struct ConnectionSpec {
242 #[serde(default)]
245 pub secret_ref: Option<SecretReference>,
246
247 #[serde(default)]
250 pub secret_key: Option<String>,
251
252 #[serde(default)]
255 pub params: Option<ConnectionParams>,
256}
257
258impl ConnectionSpec {
259 pub fn effective_secret_key(&self) -> &str {
261 self.secret_key.as_deref().unwrap_or("DATABASE_URL")
262 }
263
264 pub fn collect_secret_names(&self, names: &mut BTreeSet<String>) {
266 if let Some(ref secret_ref) = self.secret_ref {
267 names.insert(secret_ref.name.clone());
268 }
269 if let Some(ref params) = self.params {
270 for sel in [
271 ¶ms.host_secret,
272 ¶ms.port_secret,
273 ¶ms.dbname_secret,
274 ¶ms.username_secret,
275 ¶ms.password_secret,
276 ¶ms.ssl_mode_secret,
277 ]
278 .into_iter()
279 .flatten()
280 {
281 names.insert(sel.name.clone());
282 }
283 }
284 }
285
286 pub fn identity_key(&self) -> String {
300 if let Some(ref secret_ref) = self.secret_ref {
301 format!("{}/{}", secret_ref.name, self.effective_secret_key())
302 } else if let Some(ref params) = self.params {
303 let port_part = params
304 .port
305 .as_ref()
306 .map(|p| format!("literal={p}"))
307 .or_else(|| {
308 params
309 .port_secret
310 .as_ref()
311 .map(|s| format!("secret={}\0{}", s.name, s.key))
312 })
313 .unwrap_or_else(|| "5432".to_string());
314 format!(
315 "params\0{}\0{}\0{}",
316 field_identity_repr(¶ms.host, ¶ms.host_secret),
317 field_identity_repr(¶ms.dbname, ¶ms.dbname_secret),
318 port_part,
319 )
320 } else {
321 "invalid-connection".to_string()
322 }
323 }
324
325 pub fn cache_key(&self, namespace: &str) -> String {
329 if let Some(ref params) = self.params {
330 let user_part = field_identity_repr(¶ms.username, ¶ms.username_secret);
331 let pass_part = field_identity_repr(¶ms.password, ¶ms.password_secret);
332 let auth_part = params
333 .auth
334 .as_ref()
335 .map(ConnectionAuth::cache_key)
336 .unwrap_or_default();
337 let ssl_part = params
338 .ssl_mode
339 .as_ref()
340 .map(|v| format!("literal={v}"))
341 .or_else(|| {
342 params
343 .ssl_mode_secret
344 .as_ref()
345 .map(|s| format!("secret={}\0{}", s.name, s.key))
346 })
347 .unwrap_or_default();
348 let role_part = params.set_role.as_deref().unwrap_or("");
349 format!(
350 "{namespace}/{}\0user={user_part}\0pass={pass_part}\0auth={auth_part}\0ssl={ssl_part}\0role={role_part}",
351 self.identity_key()
352 )
353 } else {
354 format!("{namespace}/{}", self.identity_key())
355 }
356 }
357}
358
359fn field_identity_repr(literal: &Option<String>, secret: &Option<SecretKeySelector>) -> String {
364 if let Some(value) = literal {
365 format!("literal={value}")
366 } else if let Some(sel) = secret {
367 format!("secret={}\0{}", sel.name, sel.key)
368 } else {
369 String::new()
370 }
371}
372
373pub const DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE: &str =
375 "https://www.googleapis.com/auth/sqlservice.login";
376
377#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
398#[serde(rename_all = "camelCase")]
399pub struct ConnectionParams {
400 #[serde(default)]
402 pub host: Option<String>,
403 #[serde(default)]
405 pub host_secret: Option<SecretKeySelector>,
406
407 #[serde(default)]
409 pub port: Option<u16>,
410 #[serde(default)]
412 pub port_secret: Option<SecretKeySelector>,
413
414 #[serde(default)]
416 pub dbname: Option<String>,
417 #[serde(default)]
419 pub dbname_secret: Option<SecretKeySelector>,
420
421 #[serde(default)]
423 pub username: Option<String>,
424 #[serde(default)]
426 pub username_secret: Option<SecretKeySelector>,
427
428 #[serde(default)]
430 pub password: Option<String>,
431 #[serde(default)]
433 pub password_secret: Option<SecretKeySelector>,
434
435 #[serde(default)]
438 pub auth: Option<ConnectionAuth>,
439
440 #[serde(default)]
442 pub ssl_mode: Option<String>,
443 #[serde(default)]
445 pub ssl_mode_secret: Option<SecretKeySelector>,
446
447 #[serde(default)]
462 #[schemars(regex(pattern = SET_ROLE_PATTERN))]
463 pub set_role: Option<String>,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
468#[serde(tag = "type")]
469pub enum ConnectionAuth {
470 #[serde(rename = "gcp_workload_identity", rename_all = "camelCase")]
474 GcpWorkloadIdentity {
475 #[serde(default)]
478 impersonate_service_account: Option<String>,
479 #[serde(default)]
481 scope: Option<String>,
482 },
483}
484
485impl ConnectionAuth {
486 pub fn gcp_scope(&self) -> &str {
487 match self {
488 Self::GcpWorkloadIdentity { scope, .. } => scope
489 .as_deref()
490 .unwrap_or(DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE),
491 }
492 }
493
494 pub fn gcp_impersonate_service_account(&self) -> Option<&str> {
495 match self {
496 Self::GcpWorkloadIdentity {
497 impersonate_service_account,
498 ..
499 } => impersonate_service_account.as_deref(),
500 }
501 }
502
503 fn cache_key(&self) -> String {
504 match self {
505 Self::GcpWorkloadIdentity {
506 impersonate_service_account,
507 scope,
508 } => format!(
509 "gcp_workload_identity\0impersonate={}\0scope={}",
510 impersonate_service_account.as_deref().unwrap_or_default(),
511 scope
512 .as_deref()
513 .unwrap_or(DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE)
514 ),
515 }
516 }
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
521pub struct SecretKeySelector {
522 pub name: String,
524 pub key: String,
526}
527
528#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
530pub struct SecretReference {
531 pub name: String,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
539pub struct ProfileSpec {
540 #[serde(default)]
541 pub login: Option<bool>,
542
543 #[serde(default)]
544 pub inherit: Option<bool>,
545
546 #[serde(default)]
547 pub grants: Vec<ProfileGrantSpec>,
548
549 #[serde(default)]
550 pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
551
552 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
557 pub config: std::collections::BTreeMap<String, pgroles_core::manifest::ConfigValue>,
558}
559
560#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
562pub struct ProfileGrantSpec {
563 pub privileges: Vec<Privilege>,
564 #[serde(alias = "on")]
565 pub object: ProfileObjectTargetSpec,
566}
567
568#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
570pub struct ProfileObjectTargetSpec {
571 #[serde(rename = "type")]
572 pub object_type: ObjectType,
573 #[serde(default)]
574 pub name: Option<String>,
575}
576
577#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
579pub struct DefaultPrivilegeGrantSpec {
580 #[serde(default)]
581 pub role: Option<String>,
582 pub privileges: Vec<Privilege>,
583 pub on_type: ObjectType,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
588pub struct RoleSpec {
589 pub name: String,
590 #[serde(default)]
594 pub external: bool,
595 #[serde(default)]
596 pub login: Option<bool>,
597 #[serde(default)]
598 pub superuser: Option<bool>,
599 #[serde(default)]
600 pub createdb: Option<bool>,
601 #[serde(default)]
602 pub createrole: Option<bool>,
603 #[serde(default)]
604 pub inherit: Option<bool>,
605 #[serde(default)]
606 pub replication: Option<bool>,
607 #[serde(default)]
608 pub bypassrls: Option<bool>,
609 #[serde(default)]
610 pub connection_limit: Option<i32>,
611 #[serde(default)]
612 pub comment: Option<String>,
613 #[serde(default)]
616 pub password: Option<PasswordSpec>,
617 #[serde(default)]
619 pub password_valid_until: Option<String>,
620 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
625 pub config: std::collections::BTreeMap<String, pgroles_core::manifest::ConfigValue>,
626}
627
628#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
645#[serde(rename_all = "camelCase")]
646pub struct PasswordSpec {
647 #[serde(default)]
650 pub secret_ref: Option<SecretReference>,
651 #[serde(default)]
654 pub secret_key: Option<String>,
655 #[serde(default)]
658 pub generate: Option<GeneratePasswordSpec>,
659}
660
661impl PasswordSpec {
662 pub fn is_secret_ref(&self) -> bool {
664 self.secret_ref.is_some()
665 }
666
667 pub fn is_generate(&self) -> bool {
669 self.generate.is_some()
670 }
671}
672
673#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
675#[serde(rename_all = "camelCase")]
676pub struct GeneratePasswordSpec {
677 #[serde(default)]
679 pub length: Option<u32>,
680 #[serde(default)]
682 pub secret_name: Option<String>,
683 #[serde(default)]
685 pub secret_key: Option<String>,
686}
687
688#[derive(Debug, Clone, thiserror::Error)]
689pub enum PasswordValidationError {
690 #[error("role \"{role}\" has a password but login is not enabled")]
691 PasswordWithoutLogin { role: String },
692
693 #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
694 InvalidPasswordMode { role: String },
695
696 #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
697 InvalidGeneratedLength { role: String, min: u32, max: u32 },
698
699 #[error(
700 "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
701 )]
702 InvalidGeneratedSecretName { role: String, name: String },
703
704 #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
705 InvalidSecretKey {
706 role: String,
707 field: &'static str,
708 key: String,
709 },
710
711 #[error(
712 "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
713 )]
714 ReservedGeneratedSecretKey { role: String, key: String },
715}
716
717#[derive(Debug, Clone, thiserror::Error)]
719pub enum ConnectionValidationError {
720 #[error("connection: exactly one of secretRef or params must be set, but both were provided")]
721 BothModesSet,
722
723 #[error("connection: exactly one of secretRef or params must be set, but neither was provided")]
724 NeitherModeSet,
725
726 #[error("connection.params.{field}: secret {detail}")]
727 EmptySecretKeyRef { field: String, detail: String },
728
729 #[error(
730 "connection.params.sslMode: \"{value}\" is not valid (expected one of: disable, allow, prefer, require, verify-ca, verify-full)"
731 )]
732 InvalidSslMode { value: String },
733
734 #[error("connection.params.{field}: literal value must not be empty or whitespace-only")]
735 EmptyLiteral { field: String },
736
737 #[error("connection.params: exactly one of {field} or {field}Secret must be set")]
738 NeitherFieldSet { field: String },
739
740 #[error(
741 "connection.params: only one of {field} or {field}Secret may be set, but both were provided"
742 )]
743 BothFieldsSet { field: String },
744
745 #[error("connection.params.auth: {field} must not be empty or whitespace-only")]
746 EmptyAuthField { field: String },
747
748 #[error("connection.params: password/passwordSecret are mutually exclusive with auth")]
749 AuthWithPassword,
750
751 #[error(
752 "connection.params.setRole: \"{value}\" is not a valid PostgreSQL role identifier (must match {pattern})",
753 pattern = SET_ROLE_PATTERN,
754 )]
755 InvalidRoleName { value: String },
756}
757
758pub(crate) const SET_ROLE_PATTERN: &str = "^[A-Za-z_][A-Za-z0-9_$-]*$";
771
772pub(crate) fn is_valid_set_role_identifier(s: &str) -> bool {
780 let mut bytes = s.bytes();
781 match bytes.next() {
782 Some(b) if b.is_ascii_alphabetic() || b == b'_' => {}
783 _ => return false,
784 }
785 bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'-')
786}
787
788fn is_valid_secret_name(name: &str) -> bool {
793 crate::k8s_names::is_valid_resource_name(name)
794}
795
796fn is_valid_secret_key(key: &str) -> bool {
797 !key.is_empty()
798 && key
799 .bytes()
800 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
801}
802
803#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
809pub struct PostgresPolicyStatus {
810 #[serde(default)]
812 pub conditions: Vec<PolicyCondition>,
813
814 #[serde(default)]
816 pub observed_generation: Option<i64>,
817
818 #[serde(default)]
820 pub last_attempted_generation: Option<i64>,
821
822 #[serde(default)]
824 pub last_successful_reconcile_time: Option<String>,
825
826 #[serde(default, rename = "lastHandledReconcileAt")]
828 pub last_handled_reconcile_at: Option<String>,
829
830 #[serde(default)]
832 pub change_summary: Option<ChangeSummary>,
833
834 #[serde(default)]
836 pub last_reconcile_mode: Option<PolicyMode>,
837
838 #[serde(default)]
840 pub managed_database_identity: Option<String>,
841
842 #[serde(default)]
844 pub owned_roles: Vec<String>,
845
846 #[serde(default)]
848 pub owned_schemas: Vec<String>,
849
850 #[serde(default)]
852 pub last_error: Option<String>,
853
854 #[serde(default)]
856 pub applied_password_source_versions: BTreeMap<String, String>,
857
858 #[serde(default)]
860 pub transient_failure_count: i32,
861
862 #[serde(default)]
864 pub current_plan_ref: Option<PlanReference>,
865}
866
867#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
869pub struct PolicyCondition {
870 #[serde(rename = "type")]
872 pub condition_type: String,
873
874 pub status: String,
876
877 #[serde(default)]
879 pub reason: Option<String>,
880
881 #[serde(default)]
883 pub message: Option<String>,
884
885 #[serde(default)]
887 pub last_transition_time: Option<String>,
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
892pub struct PlanReference {
893 pub name: String,
894}
895
896#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
898#[serde(default)]
899pub struct ChangeSummary {
900 #[serde(default)]
901 pub roles_created: i32,
902 #[serde(default)]
903 pub roles_altered: i32,
904 #[serde(default)]
905 pub schemas_created: i32,
906 #[serde(default)]
907 pub schema_owners_altered: i32,
908 #[serde(default)]
909 pub roles_dropped: i32,
910 #[serde(default)]
911 pub sessions_terminated: i32,
912 #[serde(default)]
913 pub grants_added: i32,
914 #[serde(default)]
915 pub grants_revoked: i32,
916 #[serde(default)]
917 pub default_privileges_set: i32,
918 #[serde(default)]
919 pub default_privileges_revoked: i32,
920 #[serde(default)]
921 pub members_added: i32,
922 #[serde(default)]
923 pub members_removed: i32,
924 #[serde(default)]
925 pub passwords_set: i32,
926 #[serde(default)]
927 pub total: i32,
928}
929
930#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
939#[kube(
940 group = "pgroles.io",
941 version = "v1alpha1",
942 kind = "PostgresPolicyPlan",
943 namespaced,
944 status = "PostgresPolicyPlanStatus",
945 shortname = "pgplan",
946 category = "pgroles",
947 printcolumn = r#"{"name":"Policy","type":"string","jsonPath":".spec.policyRef.name"}"#,
948 printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.reconciliationMode"}"#,
949 printcolumn = r#"{"name":"Approved","type":"string","jsonPath":".status.conditions[?(@.type==\"Approved\")].status"}"#,
950 printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.changeSummary.total"}"#,
951 printcolumn = r#"{"name":"SQL Stmts","type":"integer","jsonPath":".status.sqlStatements","priority":1}"#,
952 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
953 printcolumn = r#"{"name":"SQL","type":"string","jsonPath":".status.sqlRef.name","priority":1}"#,
954 printcolumn = r#"{"name":"Hash","type":"string","jsonPath":".status.sqlHash","priority":1}"#,
955 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
956)]
957#[serde(rename_all = "camelCase")]
958pub struct PostgresPolicyPlanSpec {
959 pub policy_ref: PolicyPlanRef,
961 pub policy_generation: i64,
963 pub reconciliation_mode: CrdReconciliationMode,
965 #[serde(default)]
967 pub owned_roles: Vec<String>,
968 #[serde(default)]
970 pub owned_schemas: Vec<String>,
971 pub managed_database_identity: String,
973 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub origin: Option<PlanOrigin>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub scope: Option<PlanScope>,
979}
980
981#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
983#[serde(rename_all = "camelCase")]
984pub struct PlanOrigin {
985 pub kind: String,
986 pub name: String,
987 pub uid: String,
988}
989
990#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
992#[serde(rename_all = "camelCase")]
993pub struct PlanScope {
994 pub kind: String,
995 pub operation: ScopedPlanOperation,
996 pub bundle_hash: String,
997}
998
999#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1000pub enum ScopedPlanOperation {
1001 Activate,
1002 Revoke,
1003}
1004
1005#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1007pub struct PolicyPlanRef {
1008 pub name: String,
1009}
1010
1011#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1013#[serde(rename_all = "camelCase")]
1014pub struct PostgresPolicyPlanStatus {
1015 #[serde(default)]
1017 pub phase: PlanPhase,
1018 #[serde(default)]
1020 pub conditions: Vec<PolicyCondition>,
1021 #[serde(default)]
1023 pub change_summary: Option<ChangeSummary>,
1024 #[serde(default)]
1026 pub sql_ref: Option<SqlRef>,
1027 #[serde(default)]
1029 pub sql_inline: Option<String>,
1030 #[serde(default)]
1033 pub sql_truncated: bool,
1034 #[serde(default)]
1036 pub computed_at: Option<String>,
1037 #[serde(default)]
1039 pub applied_at: Option<String>,
1040 #[serde(default)]
1042 pub last_error: Option<String>,
1043 #[serde(default)]
1047 pub sql_hash: Option<String>,
1048 #[serde(default)]
1050 pub applying_since: Option<String>,
1051 #[serde(default)]
1053 pub failed_at: Option<String>,
1054 #[serde(default)]
1058 pub sql_statements: Option<i64>,
1059 #[serde(default)]
1062 pub redacted_sql_hash: Option<String>,
1063 #[serde(default)]
1065 pub sql_original_bytes: Option<i64>,
1066 #[serde(default)]
1068 pub sql_stored_bytes: Option<i64>,
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1073pub struct SqlRef {
1074 pub name: String,
1075 pub key: String,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub compression: Option<SqlCompression>,
1080}
1081
1082#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1084#[serde(rename_all = "lowercase")]
1085pub enum SqlCompression {
1086 Gzip,
1087}
1088
1089#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1091pub enum PlanPhase {
1092 #[default]
1093 Pending,
1094 Approved,
1095 Applying,
1096 Applied,
1097 Failed,
1098 Superseded,
1099 Rejected,
1100}
1101
1102impl std::fmt::Display for PlanPhase {
1103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1104 match self {
1105 PlanPhase::Pending => write!(f, "Pending"),
1106 PlanPhase::Approved => write!(f, "Approved"),
1107 PlanPhase::Applying => write!(f, "Applying"),
1108 PlanPhase::Applied => write!(f, "Applied"),
1109 PlanPhase::Failed => write!(f, "Failed"),
1110 PlanPhase::Superseded => write!(f, "Superseded"),
1111 PlanPhase::Rejected => write!(f, "Rejected"),
1112 }
1113 }
1114}
1115
1116pub const EPHEMERAL_BUNDLE_ENCODING_V1: &str = "pgroles.io/ephemeral-membership-bundle-v1";
1121pub const EPHEMERAL_MEMBERSHIP_SEMANTICS_V1: &str =
1122 "postgres-membership-v1-admin-false-set-server-default";
1123#[derive(CustomResource, KubeSchema, Debug, Clone, Serialize, Deserialize)]
1125#[kube(
1126 group = "pgroles.io",
1127 version = "v1alpha1",
1128 kind = "EphemeralAccessPolicy",
1129 namespaced,
1130 status = "EphemeralAccessPolicyStatus",
1131 shortname = "pgeap",
1132 category = "pgroles",
1133 printcolumn = r#"{"name":"Target","type":"string","jsonPath":".spec.postgresPolicyRef.name"}"#,
1134 printcolumn = r#"{"name":"Accepted","type":"string","jsonPath":".status.conditions[?(@.type==\"Accepted\")].status"}"#,
1135 printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.suspend"}"#,
1136 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
1137)]
1138#[serde(rename_all = "camelCase")]
1139pub struct EphemeralAccessPolicySpec {
1140 pub postgres_policy_ref: LocalObjectReference,
1141 #[schemars(length(min = 1, max = 32))]
1142 pub memberships: Vec<EphemeralMembership>,
1143 #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1144 pub maximum_duration: String,
1145 #[serde(default, skip_serializing_if = "Option::is_none")]
1146 #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1147 pub default_duration: Option<String>,
1148 #[serde(rename = "pendingRequestTTL", default = "default_pending_request_ttl")]
1149 #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1150 pub pending_request_ttl: String,
1151 pub justification: EphemeralJustificationPolicy,
1152 pub approval: EphemeralApprovalPolicy,
1153 #[serde(default)]
1154 pub suspend: bool,
1155 #[serde(default, skip_serializing_if = "Option::is_none")]
1156 #[schemars(length(max = 128))]
1157 pub display_name: Option<String>,
1158 #[serde(default, skip_serializing_if = "Option::is_none")]
1159 #[schemars(length(max = 2048))]
1160 pub description: Option<String>,
1161}
1162
1163fn default_pending_request_ttl() -> String {
1164 "15m".to_string()
1165}
1166
1167#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1168pub struct LocalObjectReference {
1169 #[schemars(length(min = 1, max = 253))]
1170 pub name: String,
1171}
1172
1173#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1174pub struct EphemeralMembership {
1175 #[schemars(length(min = 1, max = 63))]
1176 pub role: String,
1177 pub inherit: bool,
1178}
1179
1180#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize)]
1181pub struct EphemeralJustificationPolicy {
1182 pub required: bool,
1183}
1184
1185#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize)]
1186pub struct EphemeralApprovalPolicy {
1187 pub mode: EphemeralApprovalMode,
1188}
1189
1190#[derive(JsonSchema, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1191pub enum EphemeralApprovalMode {
1192 Automatic,
1193 Required,
1194}
1195
1196#[derive(KubeSchema, Debug, Clone, Default, Serialize, Deserialize)]
1197#[serde(rename_all = "camelCase")]
1198pub struct EphemeralAccessPolicyStatus {
1199 #[serde(default)]
1200 pub observed_generation: Option<i64>,
1201 #[serde(default)]
1202 #[schemars(length(max = 16))]
1203 pub conditions: Vec<EphemeralAccessCondition>,
1204 #[serde(default)]
1205 #[schemars(length(max = 32), inner(length(min = 1, max = 63)))]
1206 pub resolved_roles: Vec<String>,
1207}
1208
1209#[derive(CustomResource, KubeSchema, Debug, Clone, Serialize, Deserialize)]
1211#[kube(
1212 group = "pgroles.io",
1213 version = "v1alpha1",
1214 kind = "EphemeralAccessRequest",
1215 namespaced,
1216 status = "EphemeralAccessRequestStatus",
1217 shortname = "pgear",
1218 category = "pgroles",
1219 printcolumn = r#"{"name":"Access Policy","type":"string","jsonPath":".spec.accessPolicyRef.name"}"#,
1220 printcolumn = r#"{"name":"Subject","type":"string","jsonPath":".spec.subject.role"}"#,
1221 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
1222 printcolumn = r#"{"name":"Expires","type":"date","jsonPath":".status.expiresAt"}"#,
1223 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
1224)]
1225#[x_kube(
1226 validation = Rule::new("self == oldSelf").message("request spec is immutable")
1227)]
1228#[serde(rename_all = "camelCase")]
1229pub struct EphemeralAccessRequestSpec {
1230 pub access_policy_ref: LocalObjectReference,
1231 pub subject: EphemeralAccessSubject,
1232 pub requested_by: EphemeralAccessActor,
1235 #[serde(default, skip_serializing_if = "Option::is_none")]
1236 #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1237 pub requested_duration: Option<String>,
1238 #[serde(default, skip_serializing_if = "Option::is_none")]
1239 #[schemars(length(max = 2048))]
1240 pub justification: Option<String>,
1241}
1242
1243#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize)]
1244pub struct EphemeralAccessSubject {
1245 #[schemars(length(min = 1, max = 63))]
1246 pub role: String,
1247}
1248
1249#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1255#[serde(rename_all = "camelCase")]
1256pub struct EphemeralAccessActor {
1257 #[schemars(length(min = 1, max = 512))]
1258 pub username: String,
1259 #[serde(default, skip_serializing_if = "Option::is_none")]
1260 #[schemars(length(max = 128))]
1261 pub uid: Option<String>,
1262 #[serde(default)]
1263 #[schemars(length(max = 64), inner(length(min = 1, max = 256)))]
1264 pub groups: Vec<String>,
1265}
1266
1267#[derive(KubeSchema, Debug, Clone, Default, Serialize, Deserialize)]
1268#[x_kube(
1269 validation = Rule::new(
1270 "!has(oldSelf.resolvedAccess) || (has(self.resolvedAccess) && self.resolvedAccess == oldSelf.resolvedAccess)"
1271 ).message("resolvedAccess is write-once"),
1272 validation = Rule::new(
1273 "!(self.conditions.exists(c, c.type == 'Approved' && c.status == 'True') && self.conditions.exists(c, c.type == 'Denied' && c.status == 'True'))"
1274 ).message("Approved=True and Denied=True are mutually exclusive"),
1275 validation = Rule::new(
1276 "oldSelf.conditions.filter(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True').size() == 0 || self.conditions.filter(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True') == oldSelf.conditions.filter(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True')"
1277 ).message("approval decisions are terminal"),
1278 validation = Rule::new(
1279 "!has(oldSelf.decidedBy) || (has(self.decidedBy) && self.decidedBy == oldSelf.decidedBy)"
1280 ).message("decision identity is write-once"),
1281 validation = Rule::new(
1282 "self.conditions.exists(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True') == has(self.decidedBy)"
1283 ).message("a terminal approval decision and decidedBy identity must be recorded together"),
1284 validation = Rule::new(
1285 "self.conditions.all(c, c.type in ['Approved', 'Denied', 'Resolved', 'Ready', 'Applied'])"
1286 ).message("request conditions must use a declared lifecycle or decision type")
1287)]
1288#[serde(rename_all = "camelCase")]
1289pub struct EphemeralAccessRequestStatus {
1290 #[serde(default)]
1291 pub phase: EphemeralAccessRequestPhase,
1292 #[serde(default)]
1293 #[schemars(length(max = 8))]
1294 pub conditions: Vec<EphemeralAccessCondition>,
1295 #[serde(default, skip_serializing_if = "Option::is_none")]
1296 pub resolved_access: Option<ResolvedEphemeralAccess>,
1297 #[serde(default, skip_serializing_if = "Option::is_none")]
1301 pub decided_by: Option<EphemeralAccessActor>,
1302 #[serde(default, skip_serializing_if = "Option::is_none")]
1303 #[schemars(length(max = 64))]
1304 pub approval_expires_at: Option<String>,
1305 #[serde(default, skip_serializing_if = "Option::is_none")]
1306 #[schemars(length(max = 64))]
1307 pub activated_at: Option<String>,
1308 #[serde(default, skip_serializing_if = "Option::is_none")]
1309 #[schemars(length(max = 64))]
1310 pub expires_at: Option<String>,
1311 #[serde(default, skip_serializing_if = "Option::is_none")]
1312 #[schemars(length(max = 64))]
1313 pub ended_at: Option<String>,
1314 #[serde(default, skip_serializing_if = "Option::is_none")]
1315 #[schemars(length(max = 4096))]
1316 pub last_error: Option<String>,
1317 #[serde(default)]
1318 #[schemars(length(max = 32))]
1319 pub retained_memberships: Vec<ResolvedEphemeralMembership>,
1320}
1321
1322#[derive(JsonSchema, Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1323pub enum EphemeralAccessRequestPhase {
1324 #[default]
1325 Pending,
1326 PendingApproval,
1327 Applying,
1328 Active,
1329 Revoking,
1330 Ended,
1331 Revoked,
1332 Cancelled,
1333 Denied,
1334 ApprovalExpired,
1335 Failed,
1336}
1337
1338impl std::fmt::Display for EphemeralAccessRequestPhase {
1339 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1340 write!(f, "{self:?}")
1341 }
1342}
1343
1344#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1345#[serde(rename_all = "camelCase")]
1346pub struct EphemeralAccessCondition {
1347 #[serde(rename = "type")]
1348 #[schemars(length(min = 1, max = 32))]
1349 pub condition_type: String,
1350 #[schemars(length(min = 1, max = 16))]
1351 pub status: String,
1352 #[serde(default, skip_serializing_if = "Option::is_none")]
1353 #[schemars(length(max = 128))]
1354 pub reason: Option<String>,
1355 #[serde(default, skip_serializing_if = "Option::is_none")]
1356 #[schemars(length(max = 2048))]
1357 pub message: Option<String>,
1358 #[serde(default, skip_serializing_if = "Option::is_none")]
1359 #[schemars(length(max = 64))]
1360 pub last_transition_time: Option<String>,
1361 #[serde(default, skip_serializing_if = "Option::is_none")]
1362 #[schemars(length(max = 71))]
1363 pub bundle_hash: Option<String>,
1364 #[serde(default, skip_serializing_if = "Option::is_none")]
1365 #[schemars(length(max = 64))]
1366 pub granted_duration: Option<String>,
1367}
1368
1369#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1370#[serde(rename_all = "camelCase")]
1371pub struct ResolvedEphemeralAccess {
1372 #[schemars(length(max = 128))]
1373 pub access_policy_uid: String,
1374 pub access_policy_generation: i64,
1375 #[schemars(length(max = 128))]
1376 pub target_policy_uid: String,
1377 pub target_policy_generation: i64,
1378 #[schemars(length(max = 71))]
1381 pub target_database_fingerprint: String,
1382 #[schemars(length(max = 64))]
1383 pub granted_duration: String,
1384 #[schemars(length(max = 128))]
1385 pub bundle_encoding: String,
1386 #[schemars(length(max = 71))]
1387 pub bundle_hash: String,
1388 #[schemars(length(max = 32))]
1389 pub memberships: Vec<ResolvedEphemeralMembership>,
1390}
1391
1392#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1393#[serde(rename_all = "camelCase")]
1394pub struct ResolvedEphemeralMembership {
1395 #[schemars(length(min = 1, max = 63))]
1396 pub role: String,
1397 #[schemars(length(min = 1, max = 63))]
1398 pub member: String,
1399 pub inherit: bool,
1400}
1401
1402#[derive(Serialize)]
1403#[serde(rename_all = "camelCase")]
1404struct CanonicalEphemeralBundle<'a> {
1405 bundle_encoding: &'a str,
1406 membership_semantics: &'a str,
1407 target_database_fingerprint: &'a str,
1408 memberships: &'a [ResolvedEphemeralMembership],
1409}
1410
1411impl ResolvedEphemeralAccess {
1412 pub fn canonical_bundle_bytes(&self) -> Vec<u8> {
1413 let mut memberships = self.memberships.clone();
1414 memberships.sort();
1415 serde_json::to_vec(&CanonicalEphemeralBundle {
1416 bundle_encoding: EPHEMERAL_BUNDLE_ENCODING_V1,
1417 membership_semantics: EPHEMERAL_MEMBERSHIP_SEMANTICS_V1,
1418 target_database_fingerprint: &self.target_database_fingerprint,
1419 memberships: &memberships,
1420 })
1421 .expect("canonical ephemeral bundle is serializable")
1422 }
1423
1424 pub fn compute_bundle_hash(&self) -> String {
1425 use sha2::{Digest, Sha256};
1426 use std::fmt::Write;
1427
1428 let digest = Sha256::digest(self.canonical_bundle_bytes());
1429 let mut hash = String::with_capacity(7 + digest.len() * 2);
1430 hash.push_str("sha256:");
1431 for byte in digest {
1432 write!(&mut hash, "{byte:02x}").expect("writing to a String cannot fail");
1433 }
1434 hash
1435 }
1436
1437 pub fn has_valid_bundle_hash(&self) -> bool {
1438 self.bundle_encoding == EPHEMERAL_BUNDLE_ENCODING_V1
1439 && self.bundle_hash == self.compute_bundle_hash()
1440 }
1441}
1442
1443#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1449pub struct DatabaseIdentity(String);
1450
1451impl DatabaseIdentity {
1452 pub fn from_connection(namespace: &str, connection: &ConnectionSpec) -> Self {
1454 Self(format!("{namespace}/{}", connection.identity_key()))
1455 }
1456
1457 pub fn as_str(&self) -> &str {
1458 &self.0
1459 }
1460}
1461
1462#[derive(Debug, Clone, Default, PartialEq, Eq)]
1464pub struct OwnershipClaims {
1465 pub roles: BTreeSet<String>,
1466 pub schemas: BTreeSet<String>,
1467}
1468
1469impl OwnershipClaims {
1470 pub fn overlaps(&self, other: &Self) -> bool {
1471 !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
1472 }
1473
1474 pub fn overlap_summary(&self, other: &Self) -> String {
1475 let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
1476 let overlapping_schemas: Vec<_> =
1477 self.schemas.intersection(&other.schemas).cloned().collect();
1478
1479 let mut parts = Vec::new();
1480 if !overlapping_roles.is_empty() {
1481 parts.push(format!("roles: {}", overlapping_roles.join(", ")));
1482 }
1483 if !overlapping_schemas.is_empty() {
1484 parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
1485 }
1486
1487 parts.join("; ")
1488 }
1489}
1490
1491impl PostgresPolicySpec {
1496 pub fn validate_password_specs(
1497 &self,
1498 policy_name: &str,
1499 ) -> Result<(), PasswordValidationError> {
1500 for role in &self.roles {
1501 let Some(password) = &role.password else {
1502 continue;
1503 };
1504
1505 if role.login != Some(true) {
1506 return Err(PasswordValidationError::PasswordWithoutLogin {
1507 role: role.name.clone(),
1508 });
1509 }
1510
1511 match (&password.secret_ref, &password.generate) {
1512 (Some(_), None) => {
1513 let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
1514 if !is_valid_secret_key(secret_key) {
1515 return Err(PasswordValidationError::InvalidSecretKey {
1516 role: role.name.clone(),
1517 field: "secretKey",
1518 key: secret_key.to_string(),
1519 });
1520 }
1521 }
1522 (None, Some(generate)) => {
1523 if let Some(length) = generate.length
1524 && !(crate::password::MIN_PASSWORD_LENGTH
1525 ..=crate::password::MAX_PASSWORD_LENGTH)
1526 .contains(&length)
1527 {
1528 return Err(PasswordValidationError::InvalidGeneratedLength {
1529 role: role.name.clone(),
1530 min: crate::password::MIN_PASSWORD_LENGTH,
1531 max: crate::password::MAX_PASSWORD_LENGTH,
1532 });
1533 }
1534
1535 let secret_name =
1536 crate::password::generated_secret_name(policy_name, &role.name, generate);
1537 if !is_valid_secret_name(&secret_name) {
1538 return Err(PasswordValidationError::InvalidGeneratedSecretName {
1539 role: role.name.clone(),
1540 name: secret_name,
1541 });
1542 }
1543
1544 let secret_key = crate::password::generated_secret_key(generate);
1545 if !is_valid_secret_key(&secret_key) {
1546 return Err(PasswordValidationError::InvalidSecretKey {
1547 role: role.name.clone(),
1548 field: "generate.secretKey",
1549 key: secret_key,
1550 });
1551 }
1552 if secret_key == crate::password::GENERATED_VERIFIER_KEY {
1553 return Err(PasswordValidationError::ReservedGeneratedSecretKey {
1554 role: role.name.clone(),
1555 key: secret_key,
1556 });
1557 }
1558 }
1559 _ => {
1560 return Err(PasswordValidationError::InvalidPasswordMode {
1561 role: role.name.clone(),
1562 });
1563 }
1564 }
1565 }
1566
1567 Ok(())
1568 }
1569
1570 pub fn validate_connection_spec(&self) -> Result<(), ConnectionValidationError> {
1575 let conn = &self.connection;
1576 match (&conn.secret_ref, &conn.params) {
1577 (Some(_), None) => {
1578 Ok(())
1580 }
1581 (None, Some(params)) => {
1582 fn validate_required_field(
1584 field: &str,
1585 literal: &Option<String>,
1586 secret: &Option<SecretKeySelector>,
1587 ) -> Result<(), ConnectionValidationError> {
1588 match (literal, secret) {
1589 (Some(_), Some(_)) => {
1590 return Err(ConnectionValidationError::BothFieldsSet {
1591 field: field.to_string(),
1592 });
1593 }
1594 (None, None) => {
1595 return Err(ConnectionValidationError::NeitherFieldSet {
1596 field: field.to_string(),
1597 });
1598 }
1599 (Some(s), None) => {
1600 if s.trim().is_empty() {
1601 return Err(ConnectionValidationError::EmptyLiteral {
1602 field: field.to_string(),
1603 });
1604 }
1605 }
1606 (None, Some(sel)) => {
1607 validate_secret_selector(field, sel)?;
1608 }
1609 }
1610 Ok(())
1611 }
1612
1613 fn validate_optional_field(
1615 field: &str,
1616 literal: &Option<impl AsRef<str>>,
1617 secret: &Option<SecretKeySelector>,
1618 ) -> Result<(), ConnectionValidationError> {
1619 let has_literal = literal.is_some();
1620 if has_literal && secret.is_some() {
1621 return Err(ConnectionValidationError::BothFieldsSet {
1622 field: field.to_string(),
1623 });
1624 }
1625 if let Some(s) = literal
1626 && s.as_ref().trim().is_empty()
1627 {
1628 return Err(ConnectionValidationError::EmptyLiteral {
1629 field: field.to_string(),
1630 });
1631 }
1632 if let Some(sel) = secret {
1633 validate_secret_selector(field, sel)?;
1634 }
1635 Ok(())
1636 }
1637
1638 fn validate_secret_selector(
1639 field: &str,
1640 sel: &SecretKeySelector,
1641 ) -> Result<(), ConnectionValidationError> {
1642 if sel.name.trim().is_empty() {
1643 return Err(ConnectionValidationError::EmptySecretKeyRef {
1644 field: field.to_string(),
1645 detail: "name must not be empty".to_string(),
1646 });
1647 }
1648 if sel.key.trim().is_empty() {
1649 return Err(ConnectionValidationError::EmptySecretKeyRef {
1650 field: field.to_string(),
1651 detail: "key must not be empty".to_string(),
1652 });
1653 }
1654 Ok(())
1655 }
1656
1657 validate_required_field("host", ¶ms.host, ¶ms.host_secret)?;
1660 validate_required_field("dbname", ¶ms.dbname, ¶ms.dbname_secret)?;
1661 validate_required_field("username", ¶ms.username, ¶ms.username_secret)?;
1662 if let Some(auth) = ¶ms.auth {
1663 if params.password.is_some() || params.password_secret.is_some() {
1664 return Err(ConnectionValidationError::AuthWithPassword);
1665 }
1666 match auth {
1667 ConnectionAuth::GcpWorkloadIdentity {
1668 impersonate_service_account,
1669 scope,
1670 } => {
1671 if let Some(value) = impersonate_service_account
1672 && value.trim().is_empty()
1673 {
1674 return Err(ConnectionValidationError::EmptyAuthField {
1675 field: "impersonateServiceAccount".to_string(),
1676 });
1677 }
1678 if let Some(value) = scope
1679 && value.trim().is_empty()
1680 {
1681 return Err(ConnectionValidationError::EmptyAuthField {
1682 field: "scope".to_string(),
1683 });
1684 }
1685 }
1686 }
1687 } else {
1688 validate_required_field("password", ¶ms.password, ¶ms.password_secret)?;
1689 }
1690
1691 let port_str = params.port.map(|p| p.to_string());
1694 validate_optional_field("port", &port_str, ¶ms.port_secret)?;
1695
1696 validate_optional_field("sslMode", ¶ms.ssl_mode, ¶ms.ssl_mode_secret)?;
1697
1698 if let Some(value) = ¶ms.ssl_mode
1700 && !VALID_SSL_MODES.contains(&value.as_str())
1701 {
1702 return Err(ConnectionValidationError::InvalidSslMode {
1703 value: value.clone(),
1704 });
1705 }
1706
1707 if let Some(value) = ¶ms.set_role {
1710 if value.trim().is_empty() {
1711 return Err(ConnectionValidationError::EmptyLiteral {
1712 field: "setRole".to_string(),
1713 });
1714 }
1715 if !is_valid_set_role_identifier(value) {
1716 return Err(ConnectionValidationError::InvalidRoleName {
1717 value: value.clone(),
1718 });
1719 }
1720 }
1721
1722 Ok(())
1723 }
1724 (Some(_), Some(_)) => Err(ConnectionValidationError::BothModesSet),
1725 (None, None) => Err(ConnectionValidationError::NeitherModeSet),
1726 }
1727 }
1728
1729 pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
1735 let mut names = BTreeSet::new();
1736 self.connection.collect_secret_names(&mut names);
1738 for role in &self.roles {
1739 if let Some(pw) = &role.password {
1740 if let Some(secret_ref) = &pw.secret_ref {
1741 names.insert(secret_ref.name.clone());
1742 }
1743 if let Some(gen_spec) = &pw.generate {
1744 let secret_name =
1745 crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
1746 names.insert(secret_name);
1747 }
1748 }
1749 }
1750 names
1751 }
1752}
1753
1754impl PostgresPolicySpec {
1759 pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
1761 use pgroles_core::manifest::{
1762 DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
1763 ProfileObjectTarget, RoleDefinition,
1764 };
1765
1766 let profiles = self
1767 .profiles
1768 .iter()
1769 .map(|(name, spec)| {
1770 let profile = Profile {
1771 login: spec.login,
1772 inherit: spec.inherit,
1773 grants: spec
1774 .grants
1775 .iter()
1776 .map(|g| ProfileGrant {
1777 privileges: g.privileges.clone(),
1778 object: ProfileObjectTarget {
1779 object_type: g.object.object_type,
1780 name: g.object.name.clone(),
1781 },
1782 })
1783 .collect(),
1784 default_privileges: spec
1785 .default_privileges
1786 .iter()
1787 .map(|dp| DefaultPrivilegeGrant {
1788 role: dp.role.clone(),
1789 privileges: dp.privileges.clone(),
1790 on_type: dp.on_type,
1791 })
1792 .collect(),
1793 config: spec.config.clone(),
1794 };
1795 (name.clone(), profile)
1796 })
1797 .collect();
1798
1799 let roles = self
1800 .roles
1801 .iter()
1802 .map(|r| RoleDefinition {
1803 name: r.name.clone(),
1804 external: r.external,
1805 login: r.login,
1806 superuser: r.superuser,
1807 createdb: r.createdb,
1808 createrole: r.createrole,
1809 inherit: r.inherit,
1810 replication: r.replication,
1811 bypassrls: r.bypassrls,
1812 connection_limit: r.connection_limit,
1813 comment: r.comment.clone(),
1814 password: None, password_valid_until: r.password_valid_until.clone(),
1816 config: r.config.clone(),
1817 })
1818 .collect();
1819
1820 let memberships = self
1824 .memberships
1825 .iter()
1826 .map(|m| pgroles_core::manifest::Membership {
1827 role: m.role.clone(),
1828 members: m
1829 .members
1830 .iter()
1831 .map(|ms| MemberSpec {
1832 name: ms.name.clone(),
1833 inherit: ms.inherit,
1834 admin: ms.admin,
1835 })
1836 .collect(),
1837 })
1838 .collect();
1839
1840 PolicyManifest {
1841 default_owner: self.default_owner.clone(),
1842 auth_providers: Vec::new(),
1843 profiles,
1844 schemas: self.schemas.clone(),
1845 roles,
1846 grants: self.grants.clone(),
1847 default_privileges: self.default_privileges.clone(),
1848 memberships,
1849 retirements: self.retirements.clone(),
1850 }
1851 }
1852
1853 pub fn ownership_claims(
1858 &self,
1859 ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
1860 let manifest = self.to_policy_manifest();
1861 let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
1862
1863 let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
1864 let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
1865
1866 roles.extend(manifest.retirements.into_iter().map(|r| r.role));
1867 roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
1868 roles.extend(
1869 manifest
1870 .default_privileges
1871 .iter()
1872 .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
1873 );
1874 roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
1875 roles.extend(
1876 manifest
1877 .memberships
1878 .iter()
1879 .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
1880 );
1881
1882 schemas.extend(
1883 manifest
1884 .grants
1885 .iter()
1886 .filter_map(|g| match g.object.object_type {
1887 ObjectType::Database => None,
1888 ObjectType::Schema => g.object.name.clone(),
1889 _ => g.object.schema.clone(),
1890 }),
1891 );
1892 schemas.extend(
1893 manifest
1894 .default_privileges
1895 .iter()
1896 .map(|dp| dp.schema.clone()),
1897 );
1898
1899 Ok(OwnershipClaims { roles, schemas })
1900 }
1901}
1902
1903impl PostgresPolicyStatus {
1908 pub fn set_condition(&mut self, new: PolicyCondition) {
1913 if let Some(existing) = self
1914 .conditions
1915 .iter()
1916 .find(|c| c.condition_type == new.condition_type)
1917 && existing.status == new.status
1918 {
1919 let mut updated = new;
1921 updated.last_transition_time = existing.last_transition_time.clone();
1922 self.conditions
1923 .retain(|c| c.condition_type != updated.condition_type);
1924 self.conditions.push(updated);
1925 return;
1926 }
1927 self.conditions
1929 .retain(|c| c.condition_type != new.condition_type);
1930 self.conditions.push(new);
1931 }
1932}
1933
1934pub fn now_rfc3339() -> String {
1936 use std::time::SystemTime;
1939 let now = SystemTime::now()
1940 .duration_since(SystemTime::UNIX_EPOCH)
1941 .unwrap_or_default();
1942 let secs = now.as_secs();
1944 let days = secs / 86400;
1945 let remaining = secs % 86400;
1946 let hours = remaining / 3600;
1947 let minutes = (remaining % 3600) / 60;
1948 let seconds = remaining % 60;
1949
1950 let (year, month, day) = days_to_date(days);
1952 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
1953}
1954
1955pub fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
1957 let z = days_since_epoch + 719468;
1959 let era = z / 146097;
1960 let doe = z - era * 146097;
1961 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1962 let y = yoe + era * 400;
1963 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1964 let mp = (5 * doy + 2) / 153;
1965 let d = doy - (153 * mp + 2) / 5 + 1;
1966 let m = if mp < 10 { mp + 3 } else { mp - 9 };
1967 let y = if m <= 2 { y + 1 } else { y };
1968 (y, m, d)
1969}
1970
1971pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1973 PolicyCondition {
1974 condition_type: "Ready".to_string(),
1975 status: if status { "True" } else { "False" }.to_string(),
1976 reason: Some(reason.to_string()),
1977 message: Some(message.to_string()),
1978 last_transition_time: Some(now_rfc3339()),
1979 }
1980}
1981
1982pub fn reconciling_condition(message: &str) -> PolicyCondition {
1984 PolicyCondition {
1985 condition_type: "Reconciling".to_string(),
1986 status: "True".to_string(),
1987 reason: Some("Reconciling".to_string()),
1988 message: Some(message.to_string()),
1989 last_transition_time: Some(now_rfc3339()),
1990 }
1991}
1992
1993pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
1995 PolicyCondition {
1996 condition_type: "Degraded".to_string(),
1997 status: "True".to_string(),
1998 reason: Some(reason.to_string()),
1999 message: Some(message.to_string()),
2000 last_transition_time: Some(now_rfc3339()),
2001 }
2002}
2003
2004pub fn paused_condition(message: &str) -> PolicyCondition {
2006 PolicyCondition {
2007 condition_type: "Paused".to_string(),
2008 status: "True".to_string(),
2009 reason: Some("Suspended".to_string()),
2010 message: Some(message.to_string()),
2011 last_transition_time: Some(now_rfc3339()),
2012 }
2013}
2014
2015pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
2017 PolicyCondition {
2018 condition_type: "Conflict".to_string(),
2019 status: "True".to_string(),
2020 reason: Some(reason.to_string()),
2021 message: Some(message.to_string()),
2022 last_transition_time: Some(now_rfc3339()),
2023 }
2024}
2025
2026pub const CONDITION_APPROVAL_UNSET: &str = "ApprovalUnset";
2029
2030pub const CONDITION_APPROVAL_IGNORED: &str = "ApprovalIgnored";
2033
2034pub fn approval_ignored_condition(plan_name: &str) -> PolicyCondition {
2041 PolicyCondition {
2042 condition_type: CONDITION_APPROVAL_IGNORED.to_string(),
2043 status: "True".to_string(),
2044 reason: Some("PlanModeNeverExecutes".to_string()),
2045 message: Some(format!(
2046 "Plan {plan_name} is approved, but spec.mode is `plan`, so it will never execute and \
2047 no SQL will run. For a reviewed apply use `mode: apply` with `approval: manual`."
2048 )),
2049 last_transition_time: Some(now_rfc3339()),
2050 }
2051}
2052
2053pub fn approval_unset_condition(inferred: ApprovalMode) -> PolicyCondition {
2059 let inferred = match inferred {
2060 ApprovalMode::Auto => "auto",
2061 ApprovalMode::Manual => "manual",
2062 };
2063 PolicyCondition {
2064 condition_type: CONDITION_APPROVAL_UNSET.to_string(),
2065 status: "True".to_string(),
2066 reason: Some("InferredFromMode".to_string()),
2067 message: Some(format!(
2068 "spec.approval is not set and is currently inferred as {inferred} from spec.mode. \
2069 This inference is deprecated and will become an error in a future release: set \
2070 `approval: {inferred}` explicitly to keep the current behaviour."
2071 )),
2072 last_transition_time: Some(now_rfc3339()),
2073 }
2074}
2075
2076pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
2078 PolicyCondition {
2079 condition_type: "Drifted".to_string(),
2080 status: if status { "True" } else { "False" }.to_string(),
2081 reason: Some(reason.to_string()),
2082 message: Some(message.to_string()),
2083 last_transition_time: Some(now_rfc3339()),
2084 }
2085}
2086
2087#[cfg(test)]
2092mod tests {
2093 use super::*;
2094 use kube::CustomResourceExt;
2095
2096 #[test]
2097 fn crd_generates_valid_schema() {
2098 let crd = PostgresPolicy::crd();
2099 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
2100 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
2101 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
2102 assert!(
2103 yaml.contains("PostgresPolicy"),
2104 "kind should be PostgresPolicy"
2105 );
2106 assert!(
2107 yaml.contains("\"mode\"") || yaml.contains(" mode:"),
2108 "schema should declare spec.mode"
2109 );
2110 assert!(
2111 yaml.contains("\"object\"") || yaml.contains(" object:"),
2112 "schema should declare grant object targets using object"
2113 );
2114 }
2115
2116 #[test]
2117 fn spec_to_policy_manifest_roundtrip() {
2118 let spec = PostgresPolicySpec {
2119 connection: ConnectionSpec {
2120 secret_ref: Some(SecretReference {
2121 name: "pg-secret".to_string(),
2122 }),
2123 secret_key: Some("DATABASE_URL".to_string()),
2124 params: None,
2125 },
2126 interval: "5m".to_string(),
2127 suspend: false,
2128 mode: PolicyMode::Apply,
2129 reconciliation_mode: CrdReconciliationMode::default(),
2130 default_owner: Some("app_owner".to_string()),
2131 profiles: std::collections::HashMap::new(),
2132 schemas: vec![],
2133 roles: vec![RoleSpec {
2134 name: "analytics".to_string(),
2135 external: true,
2136 login: Some(true),
2137 superuser: None,
2138 createdb: None,
2139 createrole: None,
2140 inherit: None,
2141 replication: None,
2142 bypassrls: None,
2143 connection_limit: None,
2144 comment: Some("test role".to_string()),
2145 password: None,
2146 password_valid_until: None,
2147 config: Default::default(),
2148 }],
2149 grants: vec![],
2150 default_privileges: vec![],
2151 memberships: vec![],
2152 retirements: vec![RoleRetirement {
2153 role: "legacy-app".to_string(),
2154 reassign_owned_to: Some("app_owner".to_string()),
2155 drop_owned: true,
2156 terminate_sessions: true,
2157 }],
2158 approval: None,
2159 };
2160
2161 let manifest = spec.to_policy_manifest();
2162 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
2163 assert_eq!(manifest.roles.len(), 1);
2164 assert_eq!(manifest.roles[0].name, "analytics");
2165 assert!(manifest.roles[0].external);
2166 assert_eq!(manifest.roles[0].login, Some(true));
2167 assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
2168 assert_eq!(manifest.retirements.len(), 1);
2169 assert_eq!(manifest.retirements[0].role, "legacy-app");
2170 assert_eq!(
2171 manifest.retirements[0].reassign_owned_to.as_deref(),
2172 Some("app_owner")
2173 );
2174 assert!(manifest.retirements[0].drop_owned);
2175 assert!(manifest.retirements[0].terminate_sessions);
2176 }
2177
2178 #[test]
2179 fn spec_to_policy_manifest_preserves_profile_inherit() {
2180 let spec = PostgresPolicySpec {
2181 connection: ConnectionSpec {
2182 secret_ref: Some(SecretReference {
2183 name: "pg-secret".to_string(),
2184 }),
2185 secret_key: Some("DATABASE_URL".to_string()),
2186 params: None,
2187 },
2188 interval: "5m".to_string(),
2189 suspend: false,
2190 mode: PolicyMode::Apply,
2191 reconciliation_mode: CrdReconciliationMode::default(),
2192 default_owner: None,
2193 profiles: std::collections::HashMap::from([(
2194 "editor".to_string(),
2195 ProfileSpec {
2196 login: Some(false),
2197 inherit: Some(false),
2198 grants: vec![],
2199 default_privileges: vec![],
2200 config: Default::default(),
2201 },
2202 )]),
2203 schemas: vec![],
2204 roles: vec![],
2205 grants: vec![],
2206 default_privileges: vec![],
2207 memberships: vec![],
2208 retirements: vec![],
2209 approval: None,
2210 };
2211
2212 let manifest = spec.to_policy_manifest();
2213 assert_eq!(manifest.profiles["editor"].login, Some(false));
2214 assert_eq!(manifest.profiles["editor"].inherit, Some(false));
2215 }
2216
2217 #[test]
2218 fn status_set_condition_replaces_existing() {
2219 let mut status = PostgresPolicyStatus::default();
2220
2221 status.set_condition(ready_condition(false, "Pending", "Initial"));
2222 assert_eq!(status.conditions.len(), 1);
2223 assert_eq!(status.conditions[0].status, "False");
2224
2225 status.set_condition(ready_condition(true, "Reconciled", "All good"));
2226 assert_eq!(status.conditions.len(), 1);
2227 assert_eq!(status.conditions[0].status, "True");
2228 assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
2229 }
2230
2231 #[test]
2232 fn status_set_condition_adds_new_type() {
2233 let mut status = PostgresPolicyStatus::default();
2234
2235 status.set_condition(ready_condition(true, "OK", "ready"));
2236 status.set_condition(degraded_condition("Error", "something broke"));
2237
2238 assert_eq!(status.conditions.len(), 2);
2239 }
2240
2241 #[test]
2242 fn paused_condition_has_expected_shape() {
2243 let paused = paused_condition("paused by spec");
2244 assert_eq!(paused.condition_type, "Paused");
2245 assert_eq!(paused.status, "True");
2246 assert_eq!(paused.reason.as_deref(), Some("Suspended"));
2247 }
2248
2249 #[test]
2250 fn ownership_claims_include_expanded_roles_and_schemas() {
2251 let mut profiles = std::collections::HashMap::new();
2252 profiles.insert(
2253 "editor".to_string(),
2254 ProfileSpec {
2255 login: Some(false),
2256 inherit: None,
2257 grants: vec![],
2258 default_privileges: vec![],
2259 config: Default::default(),
2260 },
2261 );
2262
2263 let spec = PostgresPolicySpec {
2264 connection: ConnectionSpec {
2265 secret_ref: Some(SecretReference {
2266 name: "pg-secret".to_string(),
2267 }),
2268 secret_key: Some("DATABASE_URL".to_string()),
2269 params: None,
2270 },
2271 interval: "5m".to_string(),
2272 suspend: false,
2273 mode: PolicyMode::Apply,
2274 reconciliation_mode: CrdReconciliationMode::default(),
2275 default_owner: None,
2276 profiles,
2277 schemas: vec![SchemaBinding {
2278 name: "inventory".to_string(),
2279 profiles: vec!["editor".to_string()],
2280 role_pattern: "{schema}-{profile}".to_string(),
2281 owner: None,
2282 }],
2283 roles: vec![RoleSpec {
2284 name: "app-service".to_string(),
2285 external: false,
2286 login: Some(true),
2287 superuser: None,
2288 createdb: None,
2289 createrole: None,
2290 inherit: None,
2291 replication: None,
2292 bypassrls: None,
2293 connection_limit: None,
2294 comment: None,
2295 password: None,
2296 password_valid_until: None,
2297 config: Default::default(),
2298 }],
2299 grants: vec![],
2300 default_privileges: vec![],
2301 memberships: vec![],
2302 retirements: vec![RoleRetirement {
2303 role: "legacy-app".to_string(),
2304 reassign_owned_to: None,
2305 drop_owned: false,
2306 terminate_sessions: false,
2307 }],
2308 approval: None,
2309 };
2310
2311 let claims = spec.ownership_claims().unwrap();
2312 assert!(claims.roles.contains("inventory-editor"));
2313 assert!(claims.roles.contains("app-service"));
2314 assert!(claims.roles.contains("legacy-app"));
2315 assert!(claims.schemas.contains("inventory"));
2316 }
2317
2318 #[test]
2319 fn ownership_overlap_summary_reports_roles_and_schemas() {
2320 let mut left = OwnershipClaims::default();
2321 left.roles.insert("analytics".to_string());
2322 left.schemas.insert("reporting".to_string());
2323
2324 let mut right = OwnershipClaims::default();
2325 right.roles.insert("analytics".to_string());
2326 right.schemas.insert("reporting".to_string());
2327 right.schemas.insert("other".to_string());
2328
2329 assert!(left.overlaps(&right));
2330 let summary = left.overlap_summary(&right);
2331 assert!(summary.contains("roles: analytics"));
2332 assert!(summary.contains("schemas: reporting"));
2333 }
2334
2335 #[test]
2336 fn database_identity_uses_namespace_and_identity_key() {
2337 let conn = ConnectionSpec {
2338 secret_ref: Some(SecretReference {
2339 name: "db-creds".to_string(),
2340 }),
2341 secret_key: Some("DATABASE_URL".to_string()),
2342 params: None,
2343 };
2344 let identity = DatabaseIdentity::from_connection("prod", &conn);
2345 assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
2346 }
2347
2348 #[test]
2349 fn identity_key_same_database_different_users_are_equal() {
2350 let user_a = ConnectionSpec {
2353 secret_ref: None,
2354 secret_key: None,
2355 params: Some(ConnectionParams {
2356 host: Some("my-host".into()),
2357 host_secret: None,
2358 port: None,
2359 port_secret: None,
2360 dbname: Some("mydb".into()),
2361 dbname_secret: None,
2362 username: Some("alice".into()),
2363 username_secret: None,
2364 password: Some("pass-a".into()),
2365 password_secret: None,
2366 auth: None,
2367 ssl_mode: None,
2368 ssl_mode_secret: None,
2369 set_role: None,
2370 }),
2371 };
2372 let user_b = ConnectionSpec {
2373 secret_ref: None,
2374 secret_key: None,
2375 params: Some(ConnectionParams {
2376 host: Some("my-host".into()),
2377 host_secret: None,
2378 port: None,
2379 port_secret: None,
2380 dbname: Some("mydb".into()),
2381 dbname_secret: None,
2382 username: Some("bob".into()),
2383 username_secret: None,
2384 password: Some("pass-b".into()),
2385 password_secret: None,
2386 auth: None,
2387 ssl_mode: None,
2388 ssl_mode_secret: None,
2389 set_role: None,
2390 }),
2391 };
2392
2393 assert_eq!(
2394 user_a.identity_key(),
2395 user_b.identity_key(),
2396 "same database with different users should have the same identity key"
2397 );
2398 assert_ne!(
2400 user_a.cache_key("default"),
2401 user_b.cache_key("default"),
2402 "different credentials should produce different cache keys"
2403 );
2404 }
2405
2406 #[test]
2407 fn cache_key_no_collision_between_literal_and_secret_username() {
2408 let literal_conn = ConnectionSpec {
2411 secret_ref: None,
2412 secret_key: None,
2413 params: Some(ConnectionParams {
2414 host: Some("my-host".into()),
2415 host_secret: None,
2416 port: None,
2417 port_secret: None,
2418 dbname: Some("mydb".into()),
2419 dbname_secret: None,
2420 username: Some("secret=creds\0password".into()),
2421 username_secret: None,
2422 password: Some("pass".into()),
2423 password_secret: None,
2424 auth: None,
2425 ssl_mode: None,
2426 ssl_mode_secret: None,
2427 set_role: None,
2428 }),
2429 };
2430 let secret_conn = ConnectionSpec {
2431 secret_ref: None,
2432 secret_key: None,
2433 params: Some(ConnectionParams {
2434 host: Some("my-host".into()),
2435 host_secret: None,
2436 port: None,
2437 port_secret: None,
2438 dbname: Some("mydb".into()),
2439 dbname_secret: None,
2440 username: None,
2441 username_secret: Some(SecretKeySelector {
2442 name: "creds".into(),
2443 key: "password".into(),
2444 }),
2445 password: Some("pass".into()),
2446 password_secret: None,
2447 auth: None,
2448 ssl_mode: None,
2449 ssl_mode_secret: None,
2450 set_role: None,
2451 }),
2452 };
2453
2454 assert_ne!(
2455 literal_conn.cache_key("default"),
2456 secret_conn.cache_key("default"),
2457 "literal and secret ref should produce different cache keys"
2458 );
2459 }
2460
2461 #[test]
2462 fn cache_key_includes_ssl_mode() {
2463 let conn_no_ssl = ConnectionSpec {
2464 secret_ref: None,
2465 secret_key: None,
2466 params: Some(ConnectionParams {
2467 host: Some("host".into()),
2468 host_secret: None,
2469 port: None,
2470 port_secret: None,
2471 dbname: Some("db".into()),
2472 dbname_secret: None,
2473 username: Some("user".into()),
2474 username_secret: None,
2475 password: Some("pass".into()),
2476 password_secret: None,
2477 auth: None,
2478 ssl_mode: None,
2479 ssl_mode_secret: None,
2480 set_role: None,
2481 }),
2482 };
2483 let conn_with_ssl = ConnectionSpec {
2484 secret_ref: None,
2485 secret_key: None,
2486 params: Some(ConnectionParams {
2487 host: Some("host".into()),
2488 host_secret: None,
2489 port: None,
2490 port_secret: None,
2491 dbname: Some("db".into()),
2492 dbname_secret: None,
2493 username: Some("user".into()),
2494 username_secret: None,
2495 password: Some("pass".into()),
2496 password_secret: None,
2497 auth: None,
2498 ssl_mode: Some("require".into()),
2499 ssl_mode_secret: None,
2500 set_role: None,
2501 }),
2502 };
2503
2504 assert_ne!(
2505 conn_no_ssl.cache_key("ns"),
2506 conn_with_ssl.cache_key("ns"),
2507 "cache key should differ when sslMode is present"
2508 );
2509 }
2510
2511 #[test]
2512 fn validate_connection_rejects_empty_literal_host() {
2513 let spec = spec_with_connection(ConnectionSpec {
2514 secret_ref: None,
2515 secret_key: None,
2516 params: Some(ConnectionParams {
2517 host: Some("".into()),
2518 host_secret: None,
2519 port: None,
2520 port_secret: None,
2521 dbname: Some("mydb".into()),
2522 dbname_secret: None,
2523 username: Some("user".into()),
2524 username_secret: None,
2525 password: Some("pass".into()),
2526 password_secret: None,
2527 auth: None,
2528 ssl_mode: None,
2529 ssl_mode_secret: None,
2530 set_role: None,
2531 }),
2532 });
2533
2534 let err = spec.validate_connection_spec().unwrap_err();
2535 assert!(
2536 matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "host"),
2537 "expected EmptyLiteral for host, got: {err}"
2538 );
2539 }
2540
2541 #[test]
2542 fn validate_connection_rejects_whitespace_literal_dbname() {
2543 let spec = spec_with_connection(ConnectionSpec {
2544 secret_ref: None,
2545 secret_key: None,
2546 params: Some(ConnectionParams {
2547 host: Some("host".into()),
2548 host_secret: None,
2549 port: None,
2550 port_secret: None,
2551 dbname: Some(" ".into()),
2552 dbname_secret: None,
2553 username: Some("user".into()),
2554 username_secret: None,
2555 password: Some("pass".into()),
2556 password_secret: None,
2557 auth: None,
2558 ssl_mode: None,
2559 ssl_mode_secret: None,
2560 set_role: None,
2561 }),
2562 });
2563
2564 let err = spec.validate_connection_spec().unwrap_err();
2565 assert!(
2566 matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "dbname"),
2567 "expected EmptyLiteral for dbname, got: {err}"
2568 );
2569 }
2570
2571 fn spec_with_connection(connection: ConnectionSpec) -> PostgresPolicySpec {
2573 PostgresPolicySpec {
2574 connection,
2575 interval: "5m".into(),
2576 suspend: false,
2577 mode: PolicyMode::Apply,
2578 reconciliation_mode: CrdReconciliationMode::default(),
2579 default_owner: None,
2580 profiles: Default::default(),
2581 schemas: vec![],
2582 roles: vec![],
2583 grants: vec![],
2584 default_privileges: vec![],
2585 memberships: vec![],
2586 retirements: vec![],
2587 approval: None,
2588 }
2589 }
2590
2591 fn url_mode_connection() -> ConnectionSpec {
2592 ConnectionSpec {
2593 secret_ref: Some(SecretReference {
2594 name: "pg-creds".into(),
2595 }),
2596 secret_key: Some("DATABASE_URL".into()),
2597 params: None,
2598 }
2599 }
2600
2601 fn params_mode_connection() -> ConnectionSpec {
2602 ConnectionSpec {
2603 secret_ref: None,
2604 secret_key: None,
2605 params: Some(ConnectionParams {
2606 host: Some("my-postgres".into()),
2607 host_secret: None,
2608 port: None,
2609 port_secret: None,
2610 dbname: Some("mydb".into()),
2611 dbname_secret: None,
2612 username: None,
2613 username_secret: Some(SecretKeySelector {
2614 name: "pg-creds".into(),
2615 key: "username".into(),
2616 }),
2617 password: None,
2618 password_secret: Some(SecretKeySelector {
2619 name: "pg-creds".into(),
2620 key: "password".into(),
2621 }),
2622 auth: None,
2623 ssl_mode: None,
2624 ssl_mode_secret: None,
2625 set_role: None,
2626 }),
2627 }
2628 }
2629
2630 #[test]
2633 fn validate_connection_accepts_url_mode() {
2634 let spec = spec_with_connection(url_mode_connection());
2635 assert!(spec.validate_connection_spec().is_ok());
2636 }
2637
2638 #[test]
2639 fn validate_connection_accepts_params_mode() {
2640 let spec = spec_with_connection(params_mode_connection());
2641 assert!(spec.validate_connection_spec().is_ok());
2642 }
2643
2644 #[test]
2645 fn validate_connection_rejects_both_modes_set() {
2646 let spec = spec_with_connection(ConnectionSpec {
2647 secret_ref: Some(SecretReference {
2648 name: "pg-creds".into(),
2649 }),
2650 secret_key: None,
2651 params: Some(ConnectionParams {
2652 host: Some("host".into()),
2653 host_secret: None,
2654 port: None,
2655 port_secret: None,
2656 dbname: Some("db".into()),
2657 dbname_secret: None,
2658 username: Some("user".into()),
2659 username_secret: None,
2660 password: Some("pass".into()),
2661 password_secret: None,
2662 auth: None,
2663 ssl_mode: None,
2664 ssl_mode_secret: None,
2665 set_role: None,
2666 }),
2667 });
2668 assert!(matches!(
2669 spec.validate_connection_spec(),
2670 Err(ConnectionValidationError::BothModesSet)
2671 ));
2672 }
2673
2674 #[test]
2675 fn validate_connection_rejects_neither_mode_set() {
2676 let spec = spec_with_connection(ConnectionSpec {
2677 secret_ref: None,
2678 secret_key: None,
2679 params: None,
2680 });
2681 assert!(spec.validate_connection_spec().is_err());
2682 }
2683
2684 #[test]
2685 fn validate_connection_rejects_invalid_ssl_mode() {
2686 let spec = spec_with_connection(ConnectionSpec {
2687 secret_ref: None,
2688 secret_key: None,
2689 params: Some(ConnectionParams {
2690 host: Some("host".into()),
2691 host_secret: None,
2692 port: None,
2693 port_secret: None,
2694 dbname: Some("db".into()),
2695 dbname_secret: None,
2696 username: Some("user".into()),
2697 username_secret: None,
2698 password: Some("pass".into()),
2699 password_secret: None,
2700 auth: None,
2701 ssl_mode: Some("invalid-mode".into()),
2702 ssl_mode_secret: None,
2703 set_role: None,
2704 }),
2705 });
2706 assert!(spec.validate_connection_spec().is_err());
2707 }
2708
2709 fn params_with_set_role(set_role: Option<String>) -> ConnectionParams {
2710 ConnectionParams {
2711 host: Some("host".into()),
2712 host_secret: None,
2713 port: None,
2714 port_secret: None,
2715 dbname: Some("db".into()),
2716 dbname_secret: None,
2717 username: Some("user".into()),
2718 username_secret: None,
2719 password: Some("pass".into()),
2720 password_secret: None,
2721 auth: None,
2722 ssl_mode: None,
2723 ssl_mode_secret: None,
2724 set_role,
2725 }
2726 }
2727
2728 #[test]
2729 fn validate_connection_accepts_valid_set_role() {
2730 for role in [
2731 "cloudsqlsuperuser",
2732 "_underscore_start",
2733 "role-with-dash",
2734 "role_with$dollar",
2735 "Mixed_Case_Role",
2736 "r2d2",
2737 ] {
2738 let spec = spec_with_connection(ConnectionSpec {
2739 secret_ref: None,
2740 secret_key: None,
2741 params: Some(params_with_set_role(Some(role.into()))),
2742 });
2743 assert!(
2744 spec.validate_connection_spec().is_ok(),
2745 "expected {role} to be accepted"
2746 );
2747 }
2748 }
2749
2750 #[test]
2751 fn validate_connection_rejects_invalid_set_role() {
2752 for role in [
2753 "1leading_digit",
2754 "has space",
2755 "has\"quote",
2756 "has;semicolon",
2757 "has'singlequote",
2758 "ünicode",
2759 ] {
2760 let spec = spec_with_connection(ConnectionSpec {
2761 secret_ref: None,
2762 secret_key: None,
2763 params: Some(params_with_set_role(Some(role.into()))),
2764 });
2765 let err = spec
2766 .validate_connection_spec()
2767 .expect_err(&format!("expected {role} to be rejected"));
2768 assert!(
2769 matches!(err, ConnectionValidationError::InvalidRoleName { ref value } if value == role),
2770 "unexpected error for {role}: {err:?}",
2771 );
2772 }
2773 }
2774
2775 #[test]
2776 fn validate_connection_rejects_empty_set_role() {
2777 let spec = spec_with_connection(ConnectionSpec {
2778 secret_ref: None,
2779 secret_key: None,
2780 params: Some(params_with_set_role(Some(" ".into()))),
2781 });
2782 assert!(matches!(
2783 spec.validate_connection_spec(),
2784 Err(ConnectionValidationError::EmptyLiteral { ref field }) if field == "setRole"
2785 ));
2786 }
2787
2788 #[test]
2789 fn cache_key_includes_set_role() {
2790 let conn_no_role = ConnectionSpec {
2791 secret_ref: None,
2792 secret_key: None,
2793 params: Some(params_with_set_role(None)),
2794 };
2795 let conn_with_role = ConnectionSpec {
2796 secret_ref: None,
2797 secret_key: None,
2798 params: Some(params_with_set_role(Some("cloudsqlsuperuser".into()))),
2799 };
2800 assert_ne!(
2801 conn_no_role.cache_key("ns"),
2802 conn_with_role.cache_key("ns"),
2803 "cache key should differ when setRole is present"
2804 );
2805 }
2806
2807 #[test]
2808 fn validate_connection_accepts_gcp_workload_identity_without_password() {
2809 let spec = spec_with_connection(ConnectionSpec {
2810 secret_ref: None,
2811 secret_key: None,
2812 params: Some(ConnectionParams {
2813 host: Some("10.0.0.5".into()),
2814 host_secret: None,
2815 port: None,
2816 port_secret: None,
2817 dbname: Some("discovery".into()),
2818 dbname_secret: None,
2819 username: Some("pgroles-operator@my-project.iam".into()),
2820 username_secret: None,
2821 password: None,
2822 password_secret: None,
2823 auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2824 impersonate_service_account: None,
2825 scope: None,
2826 }),
2827 ssl_mode: None,
2828 ssl_mode_secret: None,
2829 set_role: None,
2830 }),
2831 });
2832
2833 assert!(spec.validate_connection_spec().is_ok());
2834 assert!(spec.referenced_secret_names("policy").is_empty());
2835 }
2836
2837 #[test]
2838 fn validate_connection_rejects_gcp_workload_identity_with_password() {
2839 let spec = spec_with_connection(ConnectionSpec {
2840 secret_ref: None,
2841 secret_key: None,
2842 params: Some(ConnectionParams {
2843 host: Some("10.0.0.5".into()),
2844 host_secret: None,
2845 port: None,
2846 port_secret: None,
2847 dbname: Some("discovery".into()),
2848 dbname_secret: None,
2849 username: Some("pgroles-operator@my-project.iam".into()),
2850 username_secret: None,
2851 password: Some("static-password".into()),
2852 password_secret: None,
2853 auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2854 impersonate_service_account: None,
2855 scope: None,
2856 }),
2857 ssl_mode: None,
2858 ssl_mode_secret: None,
2859 set_role: None,
2860 }),
2861 });
2862
2863 assert!(matches!(
2864 spec.validate_connection_spec(),
2865 Err(ConnectionValidationError::AuthWithPassword)
2866 ));
2867 }
2868
2869 #[test]
2870 fn validate_connection_rejects_empty_gcp_auth_fields() {
2871 let spec = spec_with_connection(ConnectionSpec {
2872 secret_ref: None,
2873 secret_key: None,
2874 params: Some(ConnectionParams {
2875 host: Some("10.0.0.5".into()),
2876 host_secret: None,
2877 port: None,
2878 port_secret: None,
2879 dbname: Some("discovery".into()),
2880 dbname_secret: None,
2881 username: Some("pgroles-operator@my-project.iam".into()),
2882 username_secret: None,
2883 password: None,
2884 password_secret: None,
2885 auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2886 impersonate_service_account: Some(" ".into()),
2887 scope: None,
2888 }),
2889 ssl_mode: None,
2890 ssl_mode_secret: None,
2891 set_role: None,
2892 }),
2893 });
2894
2895 assert!(matches!(
2896 spec.validate_connection_spec(),
2897 Err(ConnectionValidationError::EmptyAuthField { ref field })
2898 if field == "impersonateServiceAccount"
2899 ));
2900 }
2901
2902 #[test]
2903 fn validate_connection_accepts_valid_ssl_modes() {
2904 for mode in &[
2905 "disable",
2906 "allow",
2907 "prefer",
2908 "require",
2909 "verify-ca",
2910 "verify-full",
2911 ] {
2912 let spec = spec_with_connection(ConnectionSpec {
2913 secret_ref: None,
2914 secret_key: None,
2915 params: Some(ConnectionParams {
2916 host: Some("host".into()),
2917 host_secret: None,
2918 port: None,
2919 port_secret: None,
2920 dbname: Some("db".into()),
2921 dbname_secret: None,
2922 username: Some("user".into()),
2923 username_secret: None,
2924 password: Some("pass".into()),
2925 password_secret: None,
2926 auth: None,
2927 ssl_mode: Some((*mode).into()),
2928 ssl_mode_secret: None,
2929 set_role: None,
2930 }),
2931 });
2932 assert!(
2933 spec.validate_connection_spec().is_ok(),
2934 "sslMode '{mode}' should be accepted"
2935 );
2936 }
2937 }
2938
2939 #[test]
2940 fn validate_connection_rejects_empty_secret_name() {
2941 let spec = spec_with_connection(ConnectionSpec {
2942 secret_ref: None,
2943 secret_key: None,
2944 params: Some(ConnectionParams {
2945 host: Some("host".into()),
2946 host_secret: None,
2947 port: None,
2948 port_secret: None,
2949 dbname: Some("db".into()),
2950 dbname_secret: None,
2951 username: None,
2952 username_secret: Some(SecretKeySelector {
2953 name: "".into(),
2954 key: "username".into(),
2955 }),
2956 password: Some("pass".into()),
2957 password_secret: None,
2958 auth: None,
2959 ssl_mode: None,
2960 ssl_mode_secret: None,
2961 set_role: None,
2962 }),
2963 });
2964 assert!(spec.validate_connection_spec().is_err());
2965 }
2966
2967 #[test]
2968 fn validate_connection_rejects_both_literal_and_secret_for_same_field() {
2969 let spec = spec_with_connection(ConnectionSpec {
2970 secret_ref: None,
2971 secret_key: None,
2972 params: Some(ConnectionParams {
2973 host: Some("host".into()),
2974 host_secret: Some(SecretKeySelector {
2975 name: "s".into(),
2976 key: "k".into(),
2977 }),
2978 port: None,
2979 port_secret: None,
2980 dbname: Some("db".into()),
2981 dbname_secret: None,
2982 username: Some("user".into()),
2983 username_secret: None,
2984 password: Some("pass".into()),
2985 password_secret: None,
2986 auth: None,
2987 ssl_mode: None,
2988 ssl_mode_secret: None,
2989 set_role: None,
2990 }),
2991 });
2992 assert!(matches!(
2993 spec.validate_connection_spec(),
2994 Err(ConnectionValidationError::BothFieldsSet { ref field }) if field == "host"
2995 ));
2996 }
2997
2998 #[test]
2999 fn validate_connection_rejects_neither_literal_nor_secret_for_required_field() {
3000 let spec = spec_with_connection(ConnectionSpec {
3001 secret_ref: None,
3002 secret_key: None,
3003 params: Some(ConnectionParams {
3004 host: None,
3005 host_secret: None,
3006 port: None,
3007 port_secret: None,
3008 dbname: Some("db".into()),
3009 dbname_secret: None,
3010 username: Some("user".into()),
3011 username_secret: None,
3012 password: Some("pass".into()),
3013 password_secret: None,
3014 auth: None,
3015 ssl_mode: None,
3016 ssl_mode_secret: None,
3017 set_role: None,
3018 }),
3019 });
3020 assert!(matches!(
3021 spec.validate_connection_spec(),
3022 Err(ConnectionValidationError::NeitherFieldSet { ref field }) if field == "host"
3023 ));
3024 }
3025
3026 #[test]
3029 fn connection_spec_backward_compat_url_mode() {
3030 let yaml = r#"
3032secretRef:
3033 name: pg-creds
3034secretKey: DATABASE_URL
3035"#;
3036 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3037 assert!(conn.secret_ref.is_some());
3038 assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
3039 assert!(conn.params.is_none());
3040 }
3041
3042 #[test]
3043 fn connection_spec_backward_compat_default_secret_key() {
3044 let yaml = r#"
3045secretRef:
3046 name: pg-creds
3047"#;
3048 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3049 assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
3050 }
3051
3052 #[test]
3053 fn connection_spec_params_mode_deserializes_keycloak_style() {
3054 let yaml = r#"
3055params:
3056 host: my-postgres
3057 port: 5432
3058 dbname: mydb
3059 usernameSecret:
3060 name: creds
3061 key: username
3062 passwordSecret:
3063 name: creds
3064 key: password
3065 sslMode: require
3066"#;
3067 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3068 assert!(conn.secret_ref.is_none());
3069 let params = conn.params.unwrap();
3070 assert_eq!(params.host.as_deref(), Some("my-postgres"));
3071 assert_eq!(params.port, Some(5432));
3072 assert!(params.username_secret.is_some());
3073 assert_eq!(params.username_secret.as_ref().unwrap().name, "creds");
3074 assert_eq!(params.ssl_mode.as_deref(), Some("require"));
3075 }
3076
3077 #[test]
3078 fn connection_spec_params_mode_deserializes_gcp_workload_identity_auth() {
3079 let yaml = r#"
3080params:
3081 host: 10.0.0.5
3082 port: 5432
3083 dbname: discovery
3084 username: pgroles-operator@my-project.iam
3085 auth:
3086 type: gcp_workload_identity
3087 impersonateServiceAccount: target@other-project.iam.gserviceaccount.com
3088 scope: https://example.com/custom-scope
3089"#;
3090 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3091 let params = conn.params.as_ref().unwrap();
3092 let auth = params.auth.as_ref().expect("auth should deserialize");
3093
3094 assert!(params.password.is_none());
3095 assert_eq!(auth.gcp_scope(), "https://example.com/custom-scope");
3096 assert_eq!(
3097 auth.gcp_impersonate_service_account(),
3098 Some("target@other-project.iam.gserviceaccount.com")
3099 );
3100 assert!(conn.cache_key("prod").contains("gcp_workload_identity"));
3101
3102 let spec = spec_with_connection(conn);
3103 assert!(spec.validate_connection_spec().is_ok());
3104 }
3105
3106 #[test]
3107 fn connection_spec_params_mode_all_secrets() {
3108 let yaml = r#"
3110params:
3111 hostSecret:
3112 name: cluster-app
3113 key: host
3114 portSecret:
3115 name: cluster-app
3116 key: port
3117 dbnameSecret:
3118 name: cluster-app
3119 key: dbname
3120 usernameSecret:
3121 name: cluster-app
3122 key: user
3123 passwordSecret:
3124 name: cluster-app
3125 key: password
3126"#;
3127 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3128 let params = conn.params.unwrap();
3129 assert!(params.host.is_none());
3130 assert!(params.host_secret.is_some());
3131 assert_eq!(params.host_secret.as_ref().unwrap().name, "cluster-app");
3132 assert!(params.port.is_none());
3133 assert!(params.port_secret.is_some());
3134 }
3135
3136 #[test]
3139 fn referenced_secret_names_includes_params_secrets() {
3140 let spec = spec_with_connection(params_mode_connection());
3141 let names = spec.referenced_secret_names("test-policy");
3142 assert!(
3143 names.contains("pg-creds"),
3144 "should include the credential secret from params"
3145 );
3146 }
3147
3148 #[test]
3149 fn referenced_secret_names_deduplicates_across_modes() {
3150 let mut spec = spec_with_connection(params_mode_connection());
3152 spec.roles = vec![RoleSpec {
3153 name: "app".into(),
3154 external: false,
3155 login: Some(true),
3156 password: Some(PasswordSpec {
3157 secret_ref: Some(SecretReference {
3158 name: "pg-creds".into(),
3159 }),
3160 secret_key: Some("app-password".into()),
3161 generate: None,
3162 }),
3163 password_valid_until: None,
3164 config: Default::default(),
3165 superuser: None,
3166 createdb: None,
3167 createrole: None,
3168 inherit: None,
3169 replication: None,
3170 bypassrls: None,
3171 connection_limit: None,
3172 comment: None,
3173 }];
3174 let names = spec.referenced_secret_names("test-policy");
3175 assert_eq!(
3177 names.iter().filter(|n| *n == "pg-creds").count(),
3178 1,
3179 "BTreeSet should deduplicate"
3180 );
3181 }
3182
3183 #[test]
3186 fn connection_params_port_defaults_to_none() {
3187 let yaml = r#"
3188params:
3189 host: my-host
3190 dbname: mydb
3191 username: user
3192 password: pass
3193"#;
3194 let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3195 let params = conn.params.unwrap();
3196 assert!(
3197 params.port.is_none(),
3198 "port should default to None (resolved as 5432 at runtime)"
3199 );
3200 assert!(
3201 params.port_secret.is_none(),
3202 "portSecret should also default to None"
3203 );
3204 }
3205
3206 #[test]
3207 fn now_rfc3339_produces_valid_format() {
3208 let ts = now_rfc3339();
3209 assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
3211 assert!(ts.ends_with('Z'), "should end with Z: {ts}");
3212 assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
3213 assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
3214 }
3215
3216 #[test]
3217 fn ready_condition_true_has_expected_shape() {
3218 let cond = ready_condition(true, "Reconciled", "All changes applied");
3219 assert_eq!(cond.condition_type, "Ready");
3220 assert_eq!(cond.status, "True");
3221 assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
3222 assert_eq!(cond.message.as_deref(), Some("All changes applied"));
3223 assert!(cond.last_transition_time.is_some());
3224 }
3225
3226 #[test]
3227 fn ready_condition_false_has_expected_shape() {
3228 let cond = ready_condition(false, "InvalidSpec", "bad manifest");
3229 assert_eq!(cond.condition_type, "Ready");
3230 assert_eq!(cond.status, "False");
3231 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
3232 assert_eq!(cond.message.as_deref(), Some("bad manifest"));
3233 }
3234
3235 #[test]
3236 fn degraded_condition_has_expected_shape() {
3237 let cond = degraded_condition("InvalidSpec", "expansion failed");
3238 assert_eq!(cond.condition_type, "Degraded");
3239 assert_eq!(cond.status, "True");
3240 assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
3241 assert_eq!(cond.message.as_deref(), Some("expansion failed"));
3242 assert!(cond.last_transition_time.is_some());
3243 }
3244
3245 #[test]
3246 fn reconciling_condition_has_expected_shape() {
3247 let cond = reconciling_condition("Reconciliation in progress");
3248 assert_eq!(cond.condition_type, "Reconciling");
3249 assert_eq!(cond.status, "True");
3250 assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
3251 assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
3252 assert!(cond.last_transition_time.is_some());
3253 }
3254
3255 #[test]
3256 fn conflict_condition_has_expected_shape() {
3257 let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
3258 assert_eq!(cond.condition_type, "Conflict");
3259 assert_eq!(cond.status, "True");
3260 assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
3261 assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
3262 assert!(cond.last_transition_time.is_some());
3263 }
3264
3265 #[test]
3266 fn approval_unset_condition_names_the_inferred_mode() {
3267 for (mode, expected) in [
3268 (ApprovalMode::Auto, "auto"),
3269 (ApprovalMode::Manual, "manual"),
3270 ] {
3271 let cond = approval_unset_condition(mode);
3272 assert_eq!(cond.condition_type, CONDITION_APPROVAL_UNSET);
3273 assert_eq!(cond.status, "True");
3274 assert_eq!(cond.reason.as_deref(), Some("InferredFromMode"));
3275 assert!(cond.last_transition_time.is_some());
3276
3277 let message = cond.message.expect("condition should carry a message");
3278 assert!(
3281 message.contains(&format!("inferred as {expected}")),
3282 "message should name the inferred mode, got: {message}"
3283 );
3284 assert!(
3285 message.contains(&format!("`approval: {expected}`")),
3286 "message should show the fix to apply, got: {message}"
3287 );
3288 assert!(
3289 message.contains("deprecated"),
3290 "message should say the inference is deprecated, got: {message}"
3291 );
3292 }
3293 }
3294
3295 #[test]
3296 fn ownership_claims_no_overlap() {
3297 let mut left = OwnershipClaims::default();
3298 left.roles.insert("analytics".to_string());
3299 left.schemas.insert("reporting".to_string());
3300
3301 let mut right = OwnershipClaims::default();
3302 right.roles.insert("billing".to_string());
3303 right.schemas.insert("payments".to_string());
3304
3305 assert!(!left.overlaps(&right));
3306 let summary = left.overlap_summary(&right);
3307 assert!(summary.is_empty());
3308 }
3309
3310 #[test]
3311 fn ownership_claims_partial_role_overlap() {
3312 let mut left = OwnershipClaims::default();
3313 left.roles.insert("analytics".to_string());
3314 left.roles.insert("reporting-viewer".to_string());
3315
3316 let mut right = OwnershipClaims::default();
3317 right.roles.insert("analytics".to_string());
3318 right.roles.insert("other-role".to_string());
3319
3320 assert!(left.overlaps(&right));
3321 let summary = left.overlap_summary(&right);
3322 assert!(summary.contains("roles: analytics"));
3323 assert!(!summary.contains("schemas"));
3324 }
3325
3326 #[test]
3327 fn ownership_claims_empty_is_disjoint() {
3328 let left = OwnershipClaims::default();
3329 let right = OwnershipClaims::default();
3330 assert!(!left.overlaps(&right));
3331 }
3332
3333 #[test]
3334 fn database_identity_equality() {
3335 let conn_a = ConnectionSpec {
3336 secret_ref: Some(SecretReference {
3337 name: "db-creds".to_string(),
3338 }),
3339 secret_key: Some("DATABASE_URL".to_string()),
3340 params: None,
3341 };
3342 let a = DatabaseIdentity::from_connection("prod", &conn_a);
3343 let b = DatabaseIdentity::from_connection("prod", &conn_a);
3344 let c = DatabaseIdentity::from_connection("staging", &conn_a);
3345 assert_eq!(a, b);
3346 assert_ne!(a, c);
3347 }
3348
3349 #[test]
3350 fn database_identity_different_key() {
3351 let conn_a = ConnectionSpec {
3352 secret_ref: Some(SecretReference {
3353 name: "db-creds".to_string(),
3354 }),
3355 secret_key: Some("DATABASE_URL".to_string()),
3356 params: None,
3357 };
3358 let conn_b = ConnectionSpec {
3359 secret_ref: Some(SecretReference {
3360 name: "db-creds".to_string(),
3361 }),
3362 secret_key: Some("CUSTOM_URL".to_string()),
3363 params: None,
3364 };
3365 let a = DatabaseIdentity::from_connection("prod", &conn_a);
3366 let b = DatabaseIdentity::from_connection("prod", &conn_b);
3367 assert_ne!(a, b);
3368 }
3369
3370 #[test]
3371 fn status_default_has_empty_conditions() {
3372 let status = PostgresPolicyStatus::default();
3373 assert!(status.conditions.is_empty());
3374 assert!(status.observed_generation.is_none());
3375 assert!(status.last_attempted_generation.is_none());
3376 assert!(status.last_successful_reconcile_time.is_none());
3377 assert!(status.change_summary.is_none());
3378 assert!(status.managed_database_identity.is_none());
3379 assert!(status.owned_roles.is_empty());
3380 assert!(status.owned_schemas.is_empty());
3381 assert!(status.last_error.is_none());
3382 assert!(status.applied_password_source_versions.is_empty());
3383 }
3384
3385 #[test]
3386 fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
3387 let mut status = PostgresPolicyStatus::default();
3388
3389 status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
3391 status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
3392 status
3393 .conditions
3394 .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
3395 status.change_summary = None;
3396 status.last_error = Some("bad manifest".to_string());
3397
3398 let ready = status
3400 .conditions
3401 .iter()
3402 .find(|c| c.condition_type == "Ready")
3403 .expect("should have Ready condition");
3404 assert_eq!(ready.status, "False");
3405 assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
3406
3407 let degraded = status
3409 .conditions
3410 .iter()
3411 .find(|c| c.condition_type == "Degraded")
3412 .expect("should have Degraded condition");
3413 assert_eq!(degraded.status, "True");
3414 assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
3415
3416 assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
3418 }
3419
3420 #[test]
3421 fn status_conflict_workflow() {
3422 let mut status = PostgresPolicyStatus::default();
3423
3424 let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
3426 status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
3427 status.set_condition(conflict_condition("ConflictingPolicy", msg));
3428 status.set_condition(degraded_condition("ConflictingPolicy", msg));
3429 status
3430 .conditions
3431 .retain(|c| c.condition_type != "Reconciling");
3432 status.last_error = Some(msg.to_string());
3433
3434 let conflict = status
3436 .conditions
3437 .iter()
3438 .find(|c| c.condition_type == "Conflict")
3439 .expect("should have Conflict condition");
3440 assert_eq!(conflict.status, "True");
3441 assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
3442
3443 let ready = status
3445 .conditions
3446 .iter()
3447 .find(|c| c.condition_type == "Ready")
3448 .expect("should have Ready condition");
3449 assert_eq!(ready.status, "False");
3450
3451 let degraded = status
3453 .conditions
3454 .iter()
3455 .find(|c| c.condition_type == "Degraded")
3456 .expect("should have Degraded condition");
3457 assert_eq!(degraded.status, "True");
3458 }
3459
3460 #[test]
3461 fn status_successful_reconcile_records_generation_and_time() {
3462 let mut status = PostgresPolicyStatus::default();
3463 let generation = Some(3_i64);
3464 let summary = ChangeSummary {
3465 roles_created: 2,
3466 total: 2,
3467 ..Default::default()
3468 };
3469
3470 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
3472 status.conditions.retain(|c| {
3473 c.condition_type != "Reconciling"
3474 && c.condition_type != "Degraded"
3475 && c.condition_type != "Conflict"
3476 && c.condition_type != "Paused"
3477 });
3478 status.observed_generation = generation;
3479 status.last_attempted_generation = generation;
3480 status.last_successful_reconcile_time = Some(now_rfc3339());
3481 status.change_summary = Some(summary);
3482 status.last_error = None;
3483
3484 let ready = status
3486 .conditions
3487 .iter()
3488 .find(|c| c.condition_type == "Ready")
3489 .expect("should have Ready condition");
3490 assert_eq!(ready.status, "True");
3491 assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
3492
3493 assert_eq!(status.observed_generation, Some(3));
3495 assert_eq!(status.last_attempted_generation, Some(3));
3496
3497 assert!(status.last_successful_reconcile_time.is_some());
3499
3500 let summary = status.change_summary.as_ref().unwrap();
3502 assert_eq!(summary.roles_created, 2);
3503 assert_eq!(summary.total, 2);
3504
3505 assert!(status.last_error.is_none());
3507
3508 assert!(
3510 status
3511 .conditions
3512 .iter()
3513 .all(|c| c.condition_type != "Degraded"
3514 && c.condition_type != "Conflict"
3515 && c.condition_type != "Paused"
3516 && c.condition_type != "Reconciling")
3517 );
3518 }
3519
3520 #[test]
3521 fn status_suspended_workflow() {
3522 let mut status = PostgresPolicyStatus::default();
3523 let generation = Some(2_i64);
3524
3525 status.set_condition(paused_condition("Reconciliation suspended by spec"));
3527 status.set_condition(ready_condition(
3528 false,
3529 "Suspended",
3530 "Reconciliation suspended by spec",
3531 ));
3532 status
3533 .conditions
3534 .retain(|c| c.condition_type != "Reconciling");
3535 status.last_attempted_generation = generation;
3536 status.last_error = None;
3537
3538 let paused = status
3540 .conditions
3541 .iter()
3542 .find(|c| c.condition_type == "Paused")
3543 .expect("should have Paused condition");
3544 assert_eq!(paused.status, "True");
3545
3546 let ready = status
3548 .conditions
3549 .iter()
3550 .find(|c| c.condition_type == "Ready")
3551 .expect("should have Ready condition");
3552 assert_eq!(ready.status, "False");
3553 assert_eq!(ready.reason.as_deref(), Some("Suspended"));
3554
3555 assert!(
3557 !status
3558 .conditions
3559 .iter()
3560 .any(|c| c.condition_type == "Reconciling")
3561 );
3562 }
3563
3564 #[test]
3565 fn status_transitions_from_degraded_to_ready() {
3566 let mut status = PostgresPolicyStatus::default();
3567
3568 status.set_condition(ready_condition(false, "InvalidSpec", "error"));
3570 status.set_condition(degraded_condition("InvalidSpec", "error"));
3571 status.last_error = Some("error".to_string());
3572
3573 assert_eq!(status.conditions.len(), 2);
3574
3575 status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
3577 status.conditions.retain(|c| {
3578 c.condition_type != "Reconciling"
3579 && c.condition_type != "Degraded"
3580 && c.condition_type != "Conflict"
3581 && c.condition_type != "Paused"
3582 });
3583 status.last_error = None;
3584
3585 let ready = status
3587 .conditions
3588 .iter()
3589 .find(|c| c.condition_type == "Ready")
3590 .expect("should have Ready condition");
3591 assert_eq!(ready.status, "True");
3592
3593 assert!(
3595 !status
3596 .conditions
3597 .iter()
3598 .any(|c| c.condition_type == "Degraded")
3599 );
3600
3601 assert_eq!(status.conditions.len(), 1);
3603
3604 assert!(status.last_error.is_none());
3606 }
3607
3608 #[test]
3609 fn change_summary_default_is_all_zero() {
3610 let summary = ChangeSummary::default();
3611 assert_eq!(summary.roles_created, 0);
3612 assert_eq!(summary.roles_altered, 0);
3613 assert_eq!(summary.roles_dropped, 0);
3614 assert_eq!(summary.sessions_terminated, 0);
3615 assert_eq!(summary.grants_added, 0);
3616 assert_eq!(summary.grants_revoked, 0);
3617 assert_eq!(summary.default_privileges_set, 0);
3618 assert_eq!(summary.default_privileges_revoked, 0);
3619 assert_eq!(summary.members_added, 0);
3620 assert_eq!(summary.members_removed, 0);
3621 assert_eq!(summary.total, 0);
3622 }
3623
3624 #[test]
3625 fn status_serializes_to_json() {
3626 let mut status = PostgresPolicyStatus::default();
3627 status.set_condition(ready_condition(true, "Reconciled", "done"));
3628 status.observed_generation = Some(5);
3629 status.managed_database_identity = Some("ns/secret/key".to_string());
3630 status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
3631 status.owned_schemas = vec!["public".to_string()];
3632 status.change_summary = Some(ChangeSummary {
3633 roles_created: 1,
3634 total: 1,
3635 ..Default::default()
3636 });
3637
3638 let json = serde_json::to_string(&status).expect("should serialize");
3639 assert!(json.contains("\"Reconciled\""));
3640 assert!(json.contains("\"observed_generation\":5"));
3641 assert!(json.contains("\"role-a\""));
3642 assert!(json.contains("\"ns/secret/key\""));
3643 }
3644
3645 #[test]
3646 fn crd_spec_deserializes_from_yaml() {
3647 let yaml = r#"
3648connection:
3649 secretRef:
3650 name: pg-credentials
3651interval: "10m"
3652default_owner: app_owner
3653profiles:
3654 editor:
3655 grants:
3656 - privileges: [USAGE]
3657 object: { type: schema }
3658 - privileges: [SELECT, INSERT, UPDATE, DELETE]
3659 object: { type: table, name: "*" }
3660 default_privileges:
3661 - privileges: [SELECT, INSERT, UPDATE, DELETE]
3662 on_type: table
3663schemas:
3664 - name: inventory
3665 profiles: [editor]
3666roles:
3667 - name: analytics
3668 login: true
3669grants:
3670 - role: analytics
3671 privileges: [CONNECT]
3672 object: { type: database, name: mydb }
3673memberships:
3674 - role: inventory-editor
3675 members:
3676 - name: analytics
3677retirements:
3678 - role: legacy-app
3679 reassign_owned_to: app_owner
3680 drop_owned: true
3681 terminate_sessions: true
3682"#;
3683 let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
3684 assert_eq!(spec.interval, "10m");
3685 assert_eq!(spec.default_owner, Some("app_owner".to_string()));
3686 assert_eq!(spec.profiles.len(), 1);
3687 assert!(spec.profiles.contains_key("editor"));
3688 assert_eq!(spec.schemas.len(), 1);
3689 assert_eq!(spec.roles.len(), 1);
3690 assert_eq!(spec.grants.len(), 1);
3691 assert_eq!(spec.memberships.len(), 1);
3692 assert_eq!(spec.retirements.len(), 1);
3693 assert_eq!(spec.retirements[0].role, "legacy-app");
3694 assert!(spec.retirements[0].terminate_sessions);
3695 }
3696
3697 #[test]
3698 fn referenced_secret_names_includes_connection_secret() {
3699 let spec = PostgresPolicySpec {
3700 connection: ConnectionSpec {
3701 secret_ref: Some(SecretReference {
3702 name: "pg-conn".to_string(),
3703 }),
3704 secret_key: Some("DATABASE_URL".to_string()),
3705 params: None,
3706 },
3707 interval: "5m".to_string(),
3708 suspend: false,
3709 mode: PolicyMode::Apply,
3710 reconciliation_mode: CrdReconciliationMode::default(),
3711 default_owner: None,
3712 profiles: std::collections::HashMap::new(),
3713 schemas: vec![],
3714 roles: vec![],
3715 grants: vec![],
3716 default_privileges: vec![],
3717 memberships: vec![],
3718 retirements: vec![],
3719 approval: None,
3720 };
3721
3722 let names = spec.referenced_secret_names("test-policy");
3723 assert!(names.contains("pg-conn"));
3724 assert_eq!(names.len(), 1);
3725 }
3726
3727 #[test]
3728 fn referenced_secret_names_includes_password_secrets() {
3729 let spec = PostgresPolicySpec {
3730 connection: ConnectionSpec {
3731 secret_ref: Some(SecretReference {
3732 name: "pg-conn".to_string(),
3733 }),
3734 secret_key: Some("DATABASE_URL".to_string()),
3735 params: None,
3736 },
3737 interval: "5m".to_string(),
3738 suspend: false,
3739 mode: PolicyMode::Apply,
3740 reconciliation_mode: CrdReconciliationMode::default(),
3741 default_owner: None,
3742 profiles: std::collections::HashMap::new(),
3743 schemas: vec![],
3744 roles: vec![
3745 RoleSpec {
3746 name: "role-a".to_string(),
3747 external: false,
3748 login: Some(true),
3749 password: Some(PasswordSpec {
3750 secret_ref: Some(SecretReference {
3751 name: "role-passwords".to_string(),
3752 }),
3753 secret_key: Some("role-a".to_string()),
3754 generate: None,
3755 }),
3756 password_valid_until: None,
3757 config: Default::default(),
3758 superuser: None,
3759 createdb: None,
3760 createrole: None,
3761 inherit: None,
3762 replication: None,
3763 bypassrls: None,
3764 connection_limit: None,
3765 comment: None,
3766 },
3767 RoleSpec {
3768 name: "role-b".to_string(),
3769 external: false,
3770 login: Some(true),
3771 password: Some(PasswordSpec {
3772 secret_ref: Some(SecretReference {
3773 name: "other-secret".to_string(),
3774 }),
3775 secret_key: None,
3776 generate: None,
3777 }),
3778 password_valid_until: None,
3779 config: Default::default(),
3780 superuser: None,
3781 createdb: None,
3782 createrole: None,
3783 inherit: None,
3784 replication: None,
3785 bypassrls: None,
3786 connection_limit: None,
3787 comment: None,
3788 },
3789 RoleSpec {
3790 name: "role-c".to_string(),
3791 external: false,
3792 login: None,
3793 password: None,
3794 password_valid_until: None,
3795 config: Default::default(),
3796 superuser: None,
3797 createdb: None,
3798 createrole: None,
3799 inherit: None,
3800 replication: None,
3801 bypassrls: None,
3802 connection_limit: None,
3803 comment: None,
3804 },
3805 ],
3806 grants: vec![],
3807 default_privileges: vec![],
3808 memberships: vec![],
3809 retirements: vec![],
3810 approval: None,
3811 };
3812
3813 let names = spec.referenced_secret_names("test-policy");
3814 assert!(
3815 names.contains("pg-conn"),
3816 "should include connection secret"
3817 );
3818 assert!(
3819 names.contains("role-passwords"),
3820 "should include role-a password secret"
3821 );
3822 assert!(
3823 names.contains("other-secret"),
3824 "should include role-b password secret"
3825 );
3826 assert_eq!(names.len(), 3);
3827 }
3828
3829 #[test]
3830 fn validate_password_specs_rejects_password_without_login() {
3831 let spec = PostgresPolicySpec {
3832 connection: ConnectionSpec {
3833 secret_ref: Some(SecretReference {
3834 name: "pg-conn".to_string(),
3835 }),
3836 secret_key: Some("DATABASE_URL".to_string()),
3837 params: None,
3838 },
3839 interval: "5m".to_string(),
3840 suspend: false,
3841 mode: PolicyMode::Apply,
3842 reconciliation_mode: CrdReconciliationMode::default(),
3843 default_owner: None,
3844 profiles: std::collections::HashMap::new(),
3845 schemas: vec![],
3846 roles: vec![RoleSpec {
3847 name: "app-user".to_string(),
3848 external: false,
3849 login: Some(false),
3850 superuser: None,
3851 createdb: None,
3852 createrole: None,
3853 inherit: None,
3854 replication: None,
3855 bypassrls: None,
3856 connection_limit: None,
3857 comment: None,
3858 password: Some(PasswordSpec {
3859 secret_ref: Some(SecretReference {
3860 name: "role-passwords".to_string(),
3861 }),
3862 secret_key: None,
3863 generate: None,
3864 }),
3865 password_valid_until: None,
3866 config: Default::default(),
3867 }],
3868 grants: vec![],
3869 default_privileges: vec![],
3870 memberships: vec![],
3871 retirements: vec![],
3872 approval: None,
3873 };
3874
3875 assert!(matches!(
3876 spec.validate_password_specs("test-policy"),
3877 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
3878 ));
3879 }
3880
3881 #[test]
3882 fn validate_password_specs_rejects_password_with_login_omitted() {
3883 let spec = PostgresPolicySpec {
3884 connection: ConnectionSpec {
3885 secret_ref: Some(SecretReference {
3886 name: "pg-conn".to_string(),
3887 }),
3888 secret_key: Some("DATABASE_URL".to_string()),
3889 params: None,
3890 },
3891 interval: "5m".to_string(),
3892 suspend: false,
3893 mode: PolicyMode::Apply,
3894 reconciliation_mode: CrdReconciliationMode::default(),
3895 default_owner: None,
3896 profiles: std::collections::HashMap::new(),
3897 schemas: vec![],
3898 roles: vec![RoleSpec {
3899 name: "app-user".to_string(),
3900 external: false,
3901 login: None, superuser: None,
3903 createdb: None,
3904 createrole: None,
3905 inherit: None,
3906 replication: None,
3907 bypassrls: None,
3908 connection_limit: None,
3909 comment: None,
3910 password: Some(PasswordSpec {
3911 secret_ref: Some(SecretReference {
3912 name: "role-passwords".to_string(),
3913 }),
3914 secret_key: None,
3915 generate: None,
3916 }),
3917 password_valid_until: None,
3918 config: Default::default(),
3919 }],
3920 grants: vec![],
3921 default_privileges: vec![],
3922 memberships: vec![],
3923 retirements: vec![],
3924 approval: None,
3925 };
3926
3927 assert!(matches!(
3928 spec.validate_password_specs("test-policy"),
3929 Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
3930 ));
3931 }
3932
3933 #[test]
3934 fn validate_password_specs_rejects_invalid_password_mode() {
3935 let spec = PostgresPolicySpec {
3936 connection: ConnectionSpec {
3937 secret_ref: Some(SecretReference {
3938 name: "pg-conn".to_string(),
3939 }),
3940 secret_key: Some("DATABASE_URL".to_string()),
3941 params: None,
3942 },
3943 interval: "5m".to_string(),
3944 suspend: false,
3945 mode: PolicyMode::Apply,
3946 reconciliation_mode: CrdReconciliationMode::default(),
3947 default_owner: None,
3948 profiles: std::collections::HashMap::new(),
3949 schemas: vec![],
3950 roles: vec![RoleSpec {
3951 name: "app-user".to_string(),
3952 external: false,
3953 login: Some(true),
3954 superuser: None,
3955 createdb: None,
3956 createrole: None,
3957 inherit: None,
3958 replication: None,
3959 bypassrls: None,
3960 connection_limit: None,
3961 comment: None,
3962 password: Some(PasswordSpec {
3963 secret_ref: Some(SecretReference {
3964 name: "role-passwords".to_string(),
3965 }),
3966 secret_key: None,
3967 generate: Some(GeneratePasswordSpec {
3968 length: Some(32),
3969 secret_name: None,
3970 secret_key: None,
3971 }),
3972 }),
3973 password_valid_until: None,
3974 config: Default::default(),
3975 }],
3976 grants: vec![],
3977 default_privileges: vec![],
3978 memberships: vec![],
3979 retirements: vec![],
3980 approval: None,
3981 };
3982
3983 assert!(matches!(
3984 spec.validate_password_specs("test-policy"),
3985 Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
3986 ));
3987 }
3988
3989 #[test]
3990 fn validate_password_specs_rejects_invalid_generated_length() {
3991 let spec = PostgresPolicySpec {
3992 connection: ConnectionSpec {
3993 secret_ref: Some(SecretReference {
3994 name: "pg-conn".to_string(),
3995 }),
3996 secret_key: Some("DATABASE_URL".to_string()),
3997 params: None,
3998 },
3999 interval: "5m".to_string(),
4000 suspend: false,
4001 mode: PolicyMode::Apply,
4002 reconciliation_mode: CrdReconciliationMode::default(),
4003 default_owner: None,
4004 profiles: std::collections::HashMap::new(),
4005 schemas: vec![],
4006 roles: vec![RoleSpec {
4007 name: "app-user".to_string(),
4008 external: false,
4009 login: Some(true),
4010 superuser: None,
4011 createdb: None,
4012 createrole: None,
4013 inherit: None,
4014 replication: None,
4015 bypassrls: None,
4016 connection_limit: None,
4017 comment: None,
4018 password: Some(PasswordSpec {
4019 secret_ref: None,
4020 secret_key: None,
4021 generate: Some(GeneratePasswordSpec {
4022 length: Some(8),
4023 secret_name: None,
4024 secret_key: None,
4025 }),
4026 }),
4027 password_valid_until: None,
4028 config: Default::default(),
4029 }],
4030 grants: vec![],
4031 default_privileges: vec![],
4032 memberships: vec![],
4033 retirements: vec![],
4034 approval: None,
4035 };
4036
4037 assert!(matches!(
4038 spec.validate_password_specs("test-policy"),
4039 Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
4040 ));
4041 }
4042
4043 #[test]
4044 fn validate_password_specs_rejects_invalid_generated_secret_key() {
4045 let spec = PostgresPolicySpec {
4046 connection: ConnectionSpec {
4047 secret_ref: Some(SecretReference {
4048 name: "pg-conn".to_string(),
4049 }),
4050 secret_key: Some("DATABASE_URL".to_string()),
4051 params: None,
4052 },
4053 interval: "5m".to_string(),
4054 suspend: false,
4055 mode: PolicyMode::Apply,
4056 reconciliation_mode: CrdReconciliationMode::default(),
4057 default_owner: None,
4058 profiles: std::collections::HashMap::new(),
4059 schemas: vec![],
4060 roles: vec![RoleSpec {
4061 name: "app-user".to_string(),
4062 external: false,
4063 login: Some(true),
4064 superuser: None,
4065 createdb: None,
4066 createrole: None,
4067 inherit: None,
4068 replication: None,
4069 bypassrls: None,
4070 connection_limit: None,
4071 comment: None,
4072 password: Some(PasswordSpec {
4073 secret_ref: None,
4074 secret_key: None,
4075 generate: Some(GeneratePasswordSpec {
4076 length: Some(32),
4077 secret_name: None,
4078 secret_key: Some("bad/key".to_string()),
4079 }),
4080 }),
4081 password_valid_until: None,
4082 config: Default::default(),
4083 }],
4084 grants: vec![],
4085 default_privileges: vec![],
4086 memberships: vec![],
4087 retirements: vec![],
4088 approval: None,
4089 };
4090
4091 assert!(matches!(
4092 spec.validate_password_specs("test-policy"),
4093 Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
4094 if role == "app-user" && field == "generate.secretKey"
4095 ));
4096 }
4097
4098 #[test]
4099 fn validate_password_specs_rejects_invalid_generated_secret_name() {
4100 let spec = PostgresPolicySpec {
4101 connection: ConnectionSpec {
4102 secret_ref: Some(SecretReference {
4103 name: "pg-conn".to_string(),
4104 }),
4105 secret_key: Some("DATABASE_URL".to_string()),
4106 params: None,
4107 },
4108 interval: "5m".to_string(),
4109 suspend: false,
4110 mode: PolicyMode::Apply,
4111 reconciliation_mode: CrdReconciliationMode::default(),
4112 default_owner: None,
4113 profiles: std::collections::HashMap::new(),
4114 schemas: vec![],
4115 roles: vec![RoleSpec {
4116 name: "app-user".to_string(),
4117 external: false,
4118 login: Some(true),
4119 superuser: None,
4120 createdb: None,
4121 createrole: None,
4122 inherit: None,
4123 replication: None,
4124 bypassrls: None,
4125 connection_limit: None,
4126 comment: None,
4127 password: Some(PasswordSpec {
4128 secret_ref: None,
4129 secret_key: None,
4130 generate: Some(GeneratePasswordSpec {
4131 length: Some(32),
4132 secret_name: Some("Bad_Name".to_string()),
4133 secret_key: None,
4134 }),
4135 }),
4136 password_valid_until: None,
4137 config: Default::default(),
4138 }],
4139 grants: vec![],
4140 default_privileges: vec![],
4141 memberships: vec![],
4142 retirements: vec![],
4143 approval: None,
4144 };
4145
4146 assert!(matches!(
4147 spec.validate_password_specs("test-policy"),
4148 Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
4149 ));
4150 }
4151
4152 #[test]
4153 fn validate_password_specs_rejects_reserved_generated_secret_key() {
4154 let spec = PostgresPolicySpec {
4155 connection: ConnectionSpec {
4156 secret_ref: Some(SecretReference {
4157 name: "pg-conn".to_string(),
4158 }),
4159 secret_key: Some("DATABASE_URL".to_string()),
4160 params: None,
4161 },
4162 interval: "5m".to_string(),
4163 suspend: false,
4164 mode: PolicyMode::Apply,
4165 reconciliation_mode: CrdReconciliationMode::default(),
4166 default_owner: None,
4167 profiles: std::collections::HashMap::new(),
4168 schemas: vec![],
4169 roles: vec![RoleSpec {
4170 name: "app-user".to_string(),
4171 external: false,
4172 login: Some(true),
4173 superuser: None,
4174 createdb: None,
4175 createrole: None,
4176 inherit: None,
4177 replication: None,
4178 bypassrls: None,
4179 connection_limit: None,
4180 comment: None,
4181 password: Some(PasswordSpec {
4182 secret_ref: None,
4183 secret_key: None,
4184 generate: Some(GeneratePasswordSpec {
4185 length: Some(32),
4186 secret_name: None,
4187 secret_key: Some("verifier".to_string()),
4188 }),
4189 }),
4190 password_valid_until: None,
4191 config: Default::default(),
4192 }],
4193 grants: vec![],
4194 default_privileges: vec![],
4195 memberships: vec![],
4196 retirements: vec![],
4197 approval: None,
4198 };
4199
4200 assert!(matches!(
4201 spec.validate_password_specs("test-policy"),
4202 Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
4203 if role == "app-user" && key == "verifier"
4204 ));
4205 }
4206
4207 #[test]
4208 fn plan_crd_generates_valid_schema() {
4209 let crd = PostgresPolicyPlan::crd();
4210 let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
4211 assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
4212 assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
4213 assert!(
4214 yaml.contains("PostgresPolicyPlan"),
4215 "kind should be PostgresPolicyPlan"
4216 );
4217 assert!(yaml.contains("pgplan"), "should have shortname pgplan");
4218 }
4219
4220 #[test]
4221 fn plan_phase_display() {
4222 assert_eq!(PlanPhase::Pending.to_string(), "Pending");
4223 assert_eq!(PlanPhase::Approved.to_string(), "Approved");
4224 assert_eq!(PlanPhase::Applying.to_string(), "Applying");
4225 assert_eq!(PlanPhase::Applied.to_string(), "Applied");
4226 assert_eq!(PlanPhase::Failed.to_string(), "Failed");
4227 assert_eq!(PlanPhase::Superseded.to_string(), "Superseded");
4228 }
4229
4230 #[test]
4231 fn plan_phase_default_is_pending() {
4232 assert_eq!(PlanPhase::default(), PlanPhase::Pending);
4233 }
4234
4235 #[test]
4236 fn effective_approval_infers_from_mode() {
4237 let base = PostgresPolicySpec {
4238 connection: ConnectionSpec {
4239 secret_ref: Some(SecretReference {
4240 name: "test".into(),
4241 }),
4242 secret_key: Some("DATABASE_URL".into()),
4243 params: None,
4244 },
4245 interval: "5m".into(),
4246 suspend: false,
4247 mode: PolicyMode::Apply,
4248 reconciliation_mode: CrdReconciliationMode::Authoritative,
4249 default_owner: None,
4250 profiles: Default::default(),
4251 schemas: vec![],
4252 roles: vec![],
4253 grants: vec![],
4254 default_privileges: vec![],
4255 memberships: vec![],
4256 retirements: vec![],
4257 approval: None,
4258 };
4259
4260 assert_eq!(base.effective_approval(), ApprovalMode::Auto);
4262
4263 let plan = PostgresPolicySpec {
4265 mode: PolicyMode::Plan,
4266 ..base.clone()
4267 };
4268 assert_eq!(plan.effective_approval(), ApprovalMode::Manual);
4269
4270 let explicit = PostgresPolicySpec {
4272 approval: Some(ApprovalMode::Manual),
4273 ..base.clone()
4274 };
4275 assert_eq!(explicit.effective_approval(), ApprovalMode::Manual);
4276 }
4277
4278 #[test]
4279 fn approval_mode_serde_roundtrip() {
4280 let manual: ApprovalMode = serde_json::from_str("\"manual\"").unwrap();
4282 assert_eq!(manual, ApprovalMode::Manual);
4283 let auto: ApprovalMode = serde_json::from_str("\"auto\"").unwrap();
4284 assert_eq!(auto, ApprovalMode::Auto);
4285
4286 let manual_json = serde_json::to_value(&ApprovalMode::Manual).unwrap();
4288 assert_eq!(manual_json, serde_json::Value::String("manual".to_string()));
4289 let auto_json = serde_json::to_value(&ApprovalMode::Auto).unwrap();
4290 assert_eq!(auto_json, serde_json::Value::String("auto".to_string()));
4291 }
4292
4293 #[test]
4294 fn plan_status_default_is_empty() {
4295 let status = PostgresPolicyPlanStatus::default();
4296 assert_eq!(status.phase, PlanPhase::Pending);
4297 assert!(status.conditions.is_empty());
4298 assert!(status.change_summary.is_none());
4299 assert!(status.sql_ref.is_none());
4300 assert!(status.sql_inline.is_none());
4301 assert!(status.computed_at.is_none());
4302 assert!(status.applied_at.is_none());
4303 assert!(status.last_error.is_none());
4304 }
4305
4306 #[test]
4307 fn spec_without_approval_field_deserializes_as_none() {
4308 let json = serde_json::json!({
4309 "connection": {
4310 "secretRef": { "name": "pg-secret" },
4311 "secretKey": "DATABASE_URL"
4312 },
4313 "interval": "5m",
4314 "suspend": false,
4315 "mode": "apply",
4316 "reconciliation_mode": "authoritative"
4317 });
4318
4319 let spec: PostgresPolicySpec =
4320 serde_json::from_value(json).expect("should deserialize without approval field");
4321 assert!(
4322 spec.approval.is_none(),
4323 "approval should be None when omitted"
4324 );
4325 assert_eq!(
4326 spec.effective_approval(),
4327 ApprovalMode::Auto,
4328 "effective_approval should infer Auto from apply mode"
4329 );
4330 }
4331
4332 #[test]
4333 fn status_without_current_plan_ref_deserializes_as_none() {
4334 let json = serde_json::json!({
4335 "conditions": [],
4336 "owned_roles": [],
4337 "owned_schemas": []
4338 });
4339
4340 let status: PostgresPolicyStatus =
4341 serde_json::from_value(json).expect("should deserialize without current_plan_ref");
4342 assert!(
4343 status.current_plan_ref.is_none(),
4344 "current_plan_ref should be None when omitted"
4345 );
4346 }
4347
4348 #[test]
4349 fn effective_approval_explicit_auto_overrides_plan_mode() {
4350 let spec = PostgresPolicySpec {
4351 connection: ConnectionSpec {
4352 secret_ref: Some(SecretReference {
4353 name: "test".into(),
4354 }),
4355 secret_key: Some("DATABASE_URL".into()),
4356 params: None,
4357 },
4358 interval: "5m".into(),
4359 suspend: false,
4360 mode: PolicyMode::Plan,
4361 reconciliation_mode: CrdReconciliationMode::Authoritative,
4362 default_owner: None,
4363 profiles: Default::default(),
4364 schemas: vec![],
4365 roles: vec![],
4366 grants: vec![],
4367 default_privileges: vec![],
4368 memberships: vec![],
4369 retirements: vec![],
4370 approval: Some(ApprovalMode::Auto),
4371 };
4372
4373 assert_eq!(
4374 spec.effective_approval(),
4375 ApprovalMode::Auto,
4376 "explicit Auto should override Plan mode's default of Manual"
4377 );
4378 }
4379
4380 #[test]
4381 fn plan_phase_rejected_display() {
4382 assert_eq!(PlanPhase::Rejected.to_string(), "Rejected");
4383 }
4384
4385 #[test]
4386 fn plan_phase_all_variants_display() {
4387 let variants = [
4388 PlanPhase::Pending,
4389 PlanPhase::Approved,
4390 PlanPhase::Applying,
4391 PlanPhase::Applied,
4392 PlanPhase::Failed,
4393 PlanPhase::Superseded,
4394 PlanPhase::Rejected,
4395 ];
4396 for variant in &variants {
4397 let display = variant.to_string();
4398 assert!(
4399 !display.is_empty(),
4400 "PlanPhase::{variant:?} should have non-empty Display output"
4401 );
4402 }
4403 }
4404
4405 #[test]
4406 fn plan_status_defaults() {
4407 let status = PostgresPolicyPlanStatus::default();
4408 assert_eq!(status.phase, PlanPhase::Pending);
4409 assert!(status.conditions.is_empty());
4410 assert!(status.sql_ref.is_none());
4411 assert!(status.sql_hash.is_none());
4412 assert!(status.sql_inline.is_none());
4413 assert!(!status.sql_truncated);
4414 assert!(status.redacted_sql_hash.is_none());
4415 assert!(status.sql_original_bytes.is_none());
4416 assert!(status.sql_stored_bytes.is_none());
4417 assert!(status.change_summary.is_none());
4418 assert!(status.computed_at.is_none());
4419 assert!(status.applied_at.is_none());
4420 assert!(status.last_error.is_none());
4421 }
4422
4423 #[test]
4424 fn sql_ref_missing_compression_deserializes_as_uncompressed_legacy_shape() {
4425 let json = serde_json::json!({
4426 "name": "legacy-plan-sql",
4427 "key": "plan.sql"
4428 });
4429
4430 let sql_ref: SqlRef = serde_json::from_value(json).expect("legacy SqlRef should decode");
4431
4432 assert_eq!(sql_ref.name, "legacy-plan-sql");
4433 assert_eq!(sql_ref.key, "plan.sql");
4434 assert_eq!(sql_ref.compression, None);
4435 }
4436
4437 #[test]
4438 fn plan_spec_camel_case_serialization() {
4439 let spec = PostgresPolicyPlanSpec {
4440 policy_ref: PolicyPlanRef {
4441 name: "my-policy".into(),
4442 },
4443 policy_generation: 3,
4444 reconciliation_mode: CrdReconciliationMode::Authoritative,
4445 owned_roles: vec!["role-a".into()],
4446 owned_schemas: vec!["public".into()],
4447 managed_database_identity: "ns/secret/key".into(),
4448 origin: None,
4449 scope: None,
4450 };
4451
4452 let json = serde_json::to_value(&spec).expect("should serialize to JSON");
4453 let obj = json.as_object().expect("should be a JSON object");
4454
4455 assert!(
4456 obj.contains_key("policyRef"),
4457 "should use camelCase: policyRef"
4458 );
4459 assert!(
4460 obj.contains_key("policyGeneration"),
4461 "should use camelCase: policyGeneration"
4462 );
4463 assert!(
4464 obj.contains_key("reconciliationMode"),
4465 "should use camelCase: reconciliationMode"
4466 );
4467 assert!(
4468 obj.contains_key("ownedRoles"),
4469 "should use camelCase: ownedRoles"
4470 );
4471 assert!(
4472 obj.contains_key("ownedSchemas"),
4473 "should use camelCase: ownedSchemas"
4474 );
4475 assert!(
4476 obj.contains_key("managedDatabaseIdentity"),
4477 "should use camelCase: managedDatabaseIdentity"
4478 );
4479 }
4480
4481 fn resolved_bundle(memberships: Vec<ResolvedEphemeralMembership>) -> ResolvedEphemeralAccess {
4482 ResolvedEphemeralAccess {
4483 access_policy_uid: "access-uid".into(),
4484 access_policy_generation: 1,
4485 target_policy_uid: "target-uid".into(),
4486 target_policy_generation: 2,
4487 target_database_fingerprint: "sha256:database".into(),
4488 granted_duration: "1800s".into(),
4489 bundle_encoding: EPHEMERAL_BUNDLE_ENCODING_V1.into(),
4490 bundle_hash: String::new(),
4491 memberships,
4492 }
4493 }
4494
4495 #[test]
4496 fn ephemeral_bundle_hash_is_order_independent_and_uid_independent() {
4497 let first = ResolvedEphemeralMembership {
4498 role: "editor".into(),
4499 member: "alice@example.com".into(),
4500 inherit: false,
4501 };
4502 let second = ResolvedEphemeralMembership {
4503 role: "auditor".into(),
4504 member: "alice@example.com".into(),
4505 inherit: true,
4506 };
4507 let original = resolved_bundle(vec![first.clone(), second.clone()]);
4508 let mut reordered = resolved_bundle(vec![second, first]);
4509 reordered.access_policy_uid = "replacement-uid".into();
4510 reordered.target_policy_generation = 99;
4511
4512 assert_eq!(
4513 original.compute_bundle_hash(),
4514 reordered.compute_bundle_hash()
4515 );
4516 }
4517
4518 #[test]
4519 fn ephemeral_bundle_hash_covers_membership_options() {
4520 let original = resolved_bundle(vec![ResolvedEphemeralMembership {
4521 role: "editor".into(),
4522 member: "alice@example.com".into(),
4523 inherit: false,
4524 }]);
4525 let changed = resolved_bundle(vec![ResolvedEphemeralMembership {
4526 role: "editor".into(),
4527 member: "alice@example.com".into(),
4528 inherit: true,
4529 }]);
4530 assert_ne!(
4531 original.compute_bundle_hash(),
4532 changed.compute_bundle_hash()
4533 );
4534 }
4535
4536 #[test]
4537 fn ephemeral_bundle_hash_covers_database_target() {
4538 let original = resolved_bundle(Vec::new());
4539 let mut retargeted = original.clone();
4540 retargeted.target_database_fingerprint = "sha256:other-database".into();
4541
4542 assert_ne!(
4543 original.compute_bundle_hash(),
4544 retargeted.compute_bundle_hash()
4545 );
4546 }
4547
4548 #[test]
4549 fn ephemeral_request_crd_contains_immutability_rules() {
4550 let json = serde_json::to_string(&EphemeralAccessRequest::crd())
4551 .expect("request CRD should serialize");
4552 assert!(json.contains("request spec is immutable"));
4553 assert!(json.contains("resolvedAccess is write-once"));
4554 assert!(json.contains("approval decisions are terminal"));
4555 assert!(json.contains("decision identity is write-once"));
4556 assert!(json.contains(
4557 "a terminal approval decision and decidedBy identity must be recorded together"
4558 ));
4559 assert!(json.contains(r#""requestedBy""#));
4560 assert!(json.contains(r#""decidedBy""#));
4561 assert!(json.contains(r#""maxItems":8"#));
4562 assert!(json.contains(r#""pattern":"^([0-9]+[smh])+$""#));
4563 }
4564
4565 fn assert_bounded_strings_and_collections(schema: &serde_json::Value, path: &str) {
4566 match schema {
4567 serde_json::Value::Object(object) => {
4568 match object.get("type").and_then(serde_json::Value::as_str) {
4569 Some("string") if !object.contains_key("enum") => {
4570 assert!(
4571 object.contains_key("maxLength"),
4572 "unbounded string schema at {path}"
4573 );
4574 }
4575 Some("array") => {
4576 assert!(
4577 object.contains_key("maxItems"),
4578 "unbounded collection schema at {path}"
4579 );
4580 }
4581 _ => {}
4582 }
4583 for (name, child) in object {
4584 assert_bounded_strings_and_collections(child, &format!("{path}.{name}"));
4585 }
4586 }
4587 serde_json::Value::Array(items) => {
4588 for (index, child) in items.iter().enumerate() {
4589 assert_bounded_strings_and_collections(child, &format!("{path}[{index}]"));
4590 }
4591 }
4592 _ => {}
4593 }
4594 }
4595
4596 #[test]
4597 fn ephemeral_crds_bound_every_string_and_collection() {
4598 for (kind, crd) in [
4599 ("EphemeralAccessPolicy", EphemeralAccessPolicy::crd()),
4600 ("EphemeralAccessRequest", EphemeralAccessRequest::crd()),
4601 ] {
4602 let value = serde_json::to_value(crd).expect("CRD should serialize");
4603 let schema = &value["spec"]["versions"][0]["schema"]["openAPIV3Schema"];
4604 assert_bounded_strings_and_collections(schema, kind);
4605 }
4606 }
4607
4608 #[test]
4609 fn ephemeral_policy_crd_exposes_operational_columns() {
4610 let json = serde_json::to_string(&EphemeralAccessPolicy::crd())
4611 .expect("policy CRD should serialize");
4612 assert!(json.contains("postgresPolicyRef"));
4613 assert!(json.contains("maximumDuration"));
4614 assert!(json.contains("Accepted"));
4615 assert!(json.contains(r#""minItems":1"#));
4616 assert!(json.contains(r#""pattern":"^([0-9]+[smh])+$""#));
4617 }
4618}