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