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