Skip to main content

pgroles_operator/
crd.rs

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