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// ---------------------------------------------------------------------------
17// CRD spec
18// ---------------------------------------------------------------------------
19
20/// Spec for a `PostgresPolicy` custom resource.
21///
22/// Defines the desired state of PostgreSQL roles, grants, default privileges,
23/// and memberships for a single database connection.
24#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
25#[kube(
26    group = "pgroles.io",
27    version = "v1alpha1",
28    kind = "PostgresPolicy",
29    namespaced,
30    status = "PostgresPolicyStatus",
31    shortname = "pgr",
32    category = "pgroles",
33    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
34    printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.mode"}"#,
35    printcolumn = r#"{"name":"Recon","type":"string","jsonPath":".spec.reconciliation_mode","priority":1}"#,
36    printcolumn = r#"{"name":"Drift","type":"string","jsonPath":".status.conditions[?(@.type==\"Drifted\")].status"}"#,
37    printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.change_summary.total"}"#,
38    printcolumn = r#"{"name":"Last Reconcile","type":"date","jsonPath":".status.last_successful_reconcile_time"}"#,
39    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
40)]
41pub struct PostgresPolicySpec {
42    /// Database connection configuration.
43    pub connection: ConnectionSpec,
44
45    /// Reconciliation interval (e.g. "5m", "1h"). Defaults to "5m".
46    #[serde(default = "default_interval")]
47    pub interval: String,
48
49    /// Suspend reconciliation when true. Defaults to false.
50    #[serde(default)]
51    pub suspend: bool,
52
53    /// Reconciliation mode: `apply` executes SQL, `plan` computes drift only.
54    #[serde(default)]
55    pub mode: PolicyMode,
56
57    /// Convergence strategy: how aggressively to converge the database.
58    ///
59    /// - `authoritative` (default): full convergence — anything not in the
60    ///   manifest is revoked/dropped.
61    /// - `additive`: only grant, never revoke — safe for incremental adoption.
62    /// - `adopt`: manage declared roles fully, but never drop undeclared roles.
63    #[serde(default)]
64    pub reconciliation_mode: CrdReconciliationMode,
65
66    /// Default owner for ALTER DEFAULT PRIVILEGES (e.g. "app_owner").
67    #[serde(default)]
68    pub default_owner: Option<String>,
69
70    /// Reusable privilege profiles.
71    #[serde(default)]
72    pub profiles: std::collections::HashMap<String, ProfileSpec>,
73
74    /// Schema bindings that expand profiles into concrete roles/grants.
75    #[serde(default)]
76    pub schemas: Vec<SchemaBinding>,
77
78    /// One-off role definitions.
79    #[serde(default)]
80    pub roles: Vec<RoleSpec>,
81
82    /// One-off grants.
83    #[serde(default)]
84    pub grants: Vec<Grant>,
85
86    /// One-off default privileges.
87    #[serde(default)]
88    pub default_privileges: Vec<DefaultPrivilege>,
89
90    /// Membership edges.
91    #[serde(default)]
92    pub memberships: Vec<Membership>,
93
94    /// Explicit role-retirement workflows for roles that should be removed.
95    #[serde(default)]
96    pub retirements: Vec<RoleRetirement>,
97
98    /// Approval mode for plans: `auto` or `manual`.
99    /// When `manual`, plans require explicit approval before execution.
100    /// When `auto`, plans are approved and applied immediately.
101    /// When omitted, inferred from `mode`: `apply` → `auto`, `plan` → `manual`.
102    /// This ensures backward compatibility for existing `mode: apply` users.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub approval: Option<ApprovalMode>,
105}
106
107fn default_interval() -> String {
108    "5m".to_string()
109}
110
111/// Policy reconcile mode.
112#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
113#[serde(rename_all = "lowercase")]
114pub enum PolicyMode {
115    #[default]
116    Apply,
117    Plan,
118}
119
120/// Convergence strategy for how aggressively to converge the database.
121#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
122#[serde(rename_all = "lowercase")]
123pub enum CrdReconciliationMode {
124    /// Full convergence — the manifest is the entire truth.
125    #[default]
126    Authoritative,
127    /// Only grant, never revoke — safe for incremental adoption.
128    Additive,
129    /// Manage declared roles fully, but never drop undeclared roles.
130    Adopt,
131}
132
133/// Approval mode for plans generated by this policy.
134#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
135pub enum ApprovalMode {
136    /// Plans require explicit approval annotation before execution.
137    #[serde(rename = "manual")]
138    Manual,
139    /// Plans are approved and applied automatically.
140    #[serde(rename = "auto")]
141    Auto,
142}
143
144impl PostgresPolicySpec {
145    /// Resolve the effective approval mode, inferring from `mode` when not set.
146    /// `apply` → `Auto` (backward compat), `plan` → `Manual`.
147    pub fn effective_approval(&self) -> ApprovalMode {
148        match &self.approval {
149            Some(mode) => mode.clone(),
150            None => match self.mode {
151                PolicyMode::Apply => ApprovalMode::Auto,
152                PolicyMode::Plan => ApprovalMode::Manual,
153            },
154        }
155    }
156}
157
158// ---------------------------------------------------------------------------
159// Well-known annotations and labels
160// ---------------------------------------------------------------------------
161
162/// Annotation key used to approve a `PostgresPolicyPlan`.
163pub const PLAN_APPROVED_ANNOTATION: &str = "pgroles.io/approved";
164
165/// Annotation key used to reject a `PostgresPolicyPlan`.
166pub const PLAN_REJECTED_ANNOTATION: &str = "pgroles.io/rejected";
167
168/// Label key for the parent policy name on plan resources.
169pub const LABEL_POLICY: &str = "pgroles.io/policy";
170
171/// Label key for the managed database identity on plan resources.
172pub const LABEL_DATABASE_IDENTITY: &str = "pgroles.io/database-identity";
173
174impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
175    fn from(crd: CrdReconciliationMode) -> Self {
176        match crd {
177            CrdReconciliationMode::Authoritative => {
178                pgroles_core::diff::ReconciliationMode::Authoritative
179            }
180            CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
181            CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
182        }
183    }
184}
185
186/// Database connection configuration.
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
188#[serde(rename_all = "camelCase")]
189pub struct ConnectionSpec {
190    /// Reference to a Kubernetes Secret containing the connection string.
191    /// The secret must have a key named `DATABASE_URL`.
192    pub secret_ref: SecretReference,
193
194    /// Override the key in the Secret to read. Defaults to `DATABASE_URL`.
195    #[serde(default = "default_secret_key")]
196    pub secret_key: String,
197}
198
199fn default_secret_key() -> String {
200    "DATABASE_URL".to_string()
201}
202
203/// Reference to a Kubernetes Secret in the same namespace.
204#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
205pub struct SecretReference {
206    /// Name of the Secret.
207    pub name: String,
208}
209
210/// A reusable privilege profile (CRD-compatible version).
211///
212/// This mirrors `pgroles_core::manifest::Profile` but derives `JsonSchema`.
213#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
214pub struct ProfileSpec {
215    #[serde(default)]
216    pub login: Option<bool>,
217
218    #[serde(default)]
219    pub grants: Vec<ProfileGrantSpec>,
220
221    #[serde(default)]
222    pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
223}
224
225/// Grant template within a profile.
226#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
227pub struct ProfileGrantSpec {
228    pub privileges: Vec<Privilege>,
229    #[serde(alias = "on")]
230    pub object: ProfileObjectTargetSpec,
231}
232
233/// Object target within a profile.
234#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
235pub struct ProfileObjectTargetSpec {
236    #[serde(rename = "type")]
237    pub object_type: ObjectType,
238    #[serde(default)]
239    pub name: Option<String>,
240}
241
242/// Default privilege grant within a profile.
243#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
244pub struct DefaultPrivilegeGrantSpec {
245    #[serde(default)]
246    pub role: Option<String>,
247    pub privileges: Vec<Privilege>,
248    pub on_type: ObjectType,
249}
250
251/// A concrete role definition (CRD-compatible version).
252#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253pub struct RoleSpec {
254    pub name: String,
255    #[serde(default)]
256    pub login: Option<bool>,
257    #[serde(default)]
258    pub superuser: Option<bool>,
259    #[serde(default)]
260    pub createdb: Option<bool>,
261    #[serde(default)]
262    pub createrole: Option<bool>,
263    #[serde(default)]
264    pub inherit: Option<bool>,
265    #[serde(default)]
266    pub replication: Option<bool>,
267    #[serde(default)]
268    pub bypassrls: Option<bool>,
269    #[serde(default)]
270    pub connection_limit: Option<i32>,
271    #[serde(default)]
272    pub comment: Option<String>,
273    /// Password source for this role. Either a reference to an existing Secret
274    /// or a request for the operator to generate one.
275    #[serde(default)]
276    pub password: Option<PasswordSpec>,
277    /// Password expiration timestamp (ISO 8601, e.g. "2025-12-31T00:00:00Z").
278    #[serde(default)]
279    pub password_valid_until: Option<String>,
280}
281
282/// Password configuration: either reference an existing Secret or have the
283/// operator generate a password and create a Secret.
284///
285/// Exactly one of `secretRef` or `generate` must be set.
286///
287/// ```yaml
288/// # Read from existing Secret:
289/// password:
290///   secretRef: { name: role-passwords }
291///   secretKey: password-user
292///
293/// # Operator generates and manages a Secret:
294/// password:
295///   generate:
296///     length: 48
297/// ```
298#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
299#[serde(rename_all = "camelCase")]
300pub struct PasswordSpec {
301    /// Reference to an existing Kubernetes Secret containing the password.
302    /// Mutually exclusive with `generate`.
303    #[serde(default)]
304    pub secret_ref: Option<SecretReference>,
305    /// Key within the referenced Secret. Defaults to the role name.
306    /// Only used with `secretRef`.
307    #[serde(default)]
308    pub secret_key: Option<String>,
309    /// Generate a random password and store it in a new Kubernetes Secret.
310    /// Mutually exclusive with `secretRef`.
311    #[serde(default)]
312    pub generate: Option<GeneratePasswordSpec>,
313}
314
315impl PasswordSpec {
316    /// Returns true if this is a reference to an existing Secret.
317    pub fn is_secret_ref(&self) -> bool {
318        self.secret_ref.is_some()
319    }
320
321    /// Returns true if this is a request to generate a password.
322    pub fn is_generate(&self) -> bool {
323        self.generate.is_some()
324    }
325}
326
327/// Configuration for operator-generated passwords.
328#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
329#[serde(rename_all = "camelCase")]
330pub struct GeneratePasswordSpec {
331    /// Password length. Defaults to 32. Minimum 16, maximum 128.
332    #[serde(default)]
333    pub length: Option<u32>,
334    /// Override the generated Secret name. Defaults to `{policy}-pgr-{role}`.
335    #[serde(default)]
336    pub secret_name: Option<String>,
337    /// Key within the generated Secret. Defaults to `password`.
338    #[serde(default)]
339    pub secret_key: Option<String>,
340}
341
342#[derive(Debug, Clone, thiserror::Error)]
343pub enum PasswordValidationError {
344    #[error("role \"{role}\" has a password but login is not enabled")]
345    PasswordWithoutLogin { role: String },
346
347    #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
348    InvalidPasswordMode { role: String },
349
350    #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
351    InvalidGeneratedLength { role: String, min: u32, max: u32 },
352
353    #[error(
354        "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
355    )]
356    InvalidGeneratedSecretName { role: String, name: String },
357
358    #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
359    InvalidSecretKey {
360        role: String,
361        field: &'static str,
362        key: String,
363    },
364
365    #[error(
366        "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
367    )]
368    ReservedGeneratedSecretKey { role: String, key: String },
369}
370
371/// Validate a Kubernetes Secret name per RFC 1123 DNS subdomain rules:
372/// lowercase alpha start, alphanumeric end, body allows lowercase alpha,
373/// digits, `-`, and `.`.
374fn is_valid_secret_name(name: &str) -> bool {
375    if name.is_empty() || name.len() > crate::password::MAX_SECRET_NAME_LENGTH {
376        return false;
377    }
378    let bytes = name.as_bytes();
379    // RFC 1123: must start with a lowercase letter.
380    if !bytes[0].is_ascii_lowercase() {
381        return false;
382    }
383    if !bytes[bytes.len() - 1].is_ascii_lowercase() && !bytes[bytes.len() - 1].is_ascii_digit() {
384        return false;
385    }
386    bytes
387        .iter()
388        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'-' || *b == b'.')
389}
390
391fn is_valid_secret_key(key: &str) -> bool {
392    !key.is_empty()
393        && key
394            .bytes()
395            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
396}
397
398// ---------------------------------------------------------------------------
399// CRD status
400// ---------------------------------------------------------------------------
401
402/// Status of a `PostgresPolicy` resource.
403#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
404pub struct PostgresPolicyStatus {
405    /// Standard Kubernetes conditions.
406    #[serde(default)]
407    pub conditions: Vec<PolicyCondition>,
408
409    /// The `.metadata.generation` that was last successfully reconciled.
410    #[serde(default)]
411    pub observed_generation: Option<i64>,
412
413    /// The `.metadata.generation` that was last attempted.
414    #[serde(default)]
415    pub last_attempted_generation: Option<i64>,
416
417    /// ISO 8601 timestamp of the last successful reconciliation.
418    #[serde(default)]
419    pub last_successful_reconcile_time: Option<String>,
420
421    /// Deprecated alias retained for compatibility with older status readers.
422    #[serde(default)]
423    pub last_reconcile_time: Option<String>,
424
425    /// Summary of changes applied in the last reconciliation.
426    #[serde(default)]
427    pub change_summary: Option<ChangeSummary>,
428
429    /// The reconciliation mode used for the last successful reconcile.
430    #[serde(default)]
431    pub last_reconcile_mode: Option<PolicyMode>,
432
433    /// Planned SQL for the last successful plan-mode reconcile.
434    #[serde(default)]
435    pub planned_sql: Option<String>,
436
437    /// Whether `planned_sql` was truncated to fit safely in status.
438    #[serde(default)]
439    pub planned_sql_truncated: bool,
440
441    /// Canonical identity of the managed database target.
442    #[serde(default)]
443    pub managed_database_identity: Option<String>,
444
445    /// Roles claimed by this policy's declared ownership scope.
446    #[serde(default)]
447    pub owned_roles: Vec<String>,
448
449    /// Schemas claimed by this policy's declared ownership scope.
450    #[serde(default)]
451    pub owned_schemas: Vec<String>,
452
453    /// Last reconcile error message, if any.
454    #[serde(default)]
455    pub last_error: Option<String>,
456
457    /// Last applied password source version for each password-managed role.
458    #[serde(default)]
459    pub applied_password_source_versions: BTreeMap<String, String>,
460
461    /// Consecutive transient operational failures used for exponential backoff.
462    #[serde(default)]
463    pub transient_failure_count: i32,
464
465    /// Reference to the current/latest plan for this policy.
466    #[serde(default)]
467    pub current_plan_ref: Option<PlanReference>,
468}
469
470/// A condition on the `PostgresPolicy` resource.
471#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
472pub struct PolicyCondition {
473    /// Type of condition: "Ready", "Reconciling", "Degraded".
474    #[serde(rename = "type")]
475    pub condition_type: String,
476
477    /// Status: "True", "False", or "Unknown".
478    pub status: String,
479
480    /// Human-readable reason for the condition.
481    #[serde(default)]
482    pub reason: Option<String>,
483
484    /// Human-readable message.
485    #[serde(default)]
486    pub message: Option<String>,
487
488    /// Last time the condition transitioned.
489    #[serde(default)]
490    pub last_transition_time: Option<String>,
491}
492
493/// Reference to a `PostgresPolicyPlan` resource.
494#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
495pub struct PlanReference {
496    pub name: String,
497}
498
499/// Summary of changes applied during reconciliation.
500#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
501pub struct ChangeSummary {
502    pub roles_created: i32,
503    pub roles_altered: i32,
504    pub roles_dropped: i32,
505    pub sessions_terminated: i32,
506    pub grants_added: i32,
507    pub grants_revoked: i32,
508    pub default_privileges_set: i32,
509    pub default_privileges_revoked: i32,
510    pub members_added: i32,
511    pub members_removed: i32,
512    pub passwords_set: i32,
513    pub total: i32,
514}
515
516// ---------------------------------------------------------------------------
517// PostgresPolicyPlan CRD
518// ---------------------------------------------------------------------------
519
520/// Spec for a `PostgresPolicyPlan` custom resource.
521///
522/// Represents a computed reconciliation plan for a `PostgresPolicy`. Plans are
523/// created by the operator and may require explicit approval before execution.
524#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
525#[kube(
526    group = "pgroles.io",
527    version = "v1alpha1",
528    kind = "PostgresPolicyPlan",
529    namespaced,
530    status = "PostgresPolicyPlanStatus",
531    shortname = "pgplan",
532    category = "pgroles",
533    printcolumn = r#"{"name":"Policy","type":"string","jsonPath":".spec.policyRef.name"}"#,
534    printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.reconciliationMode"}"#,
535    printcolumn = r#"{"name":"Approved","type":"string","jsonPath":".status.conditions[?(@.type==\"Approved\")].status"}"#,
536    printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.changeSummary.total"}"#,
537    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
538    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
539)]
540#[serde(rename_all = "camelCase")]
541pub struct PostgresPolicyPlanSpec {
542    /// Reference to the policy that generated this plan.
543    pub policy_ref: PolicyPlanRef,
544    /// The policy's `.metadata.generation` at plan time.
545    pub policy_generation: i64,
546    /// Reconciliation mode used for this plan.
547    pub reconciliation_mode: CrdReconciliationMode,
548    /// Roles that this plan covers.
549    #[serde(default)]
550    pub owned_roles: Vec<String>,
551    /// Schemas that this plan covers.
552    #[serde(default)]
553    pub owned_schemas: Vec<String>,
554    /// Database identity string for disambiguation in multi-db setups.
555    pub managed_database_identity: String,
556}
557
558/// Reference to the parent `PostgresPolicy` that generated a plan.
559#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
560pub struct PolicyPlanRef {
561    pub name: String,
562}
563
564/// Status of a `PostgresPolicyPlan` resource.
565#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
566#[serde(rename_all = "camelCase")]
567pub struct PostgresPolicyPlanStatus {
568    /// Phase: Pending, Approved, Applying, Applied, Failed, Superseded.
569    #[serde(default)]
570    pub phase: PlanPhase,
571    /// Standard conditions: Computed, Approved, Applied.
572    #[serde(default)]
573    pub conditions: Vec<PolicyCondition>,
574    /// Summary of changes in this plan.
575    #[serde(default)]
576    pub change_summary: Option<ChangeSummary>,
577    /// Reference to ConfigMap containing the full SQL (for large plans).
578    #[serde(default)]
579    pub sql_ref: Option<SqlRef>,
580    /// Inline SQL for small plans (below a size threshold).
581    #[serde(default)]
582    pub sql_inline: Option<String>,
583    /// Timestamp when the plan was computed.
584    #[serde(default)]
585    pub computed_at: Option<String>,
586    /// Timestamp when the plan was applied (if applicable).
587    #[serde(default)]
588    pub applied_at: Option<String>,
589    /// Error message if apply failed.
590    #[serde(default)]
591    pub last_error: Option<String>,
592    /// SHA-256 hash of the planned SQL, used to detect duplicate plans.
593    /// If a newly computed plan has the same hash as the current pending plan,
594    /// the operator can skip creating a redundant plan.
595    #[serde(default)]
596    pub sql_hash: Option<String>,
597    /// Timestamp when the plan entered Applying phase (for stuck detection).
598    #[serde(default)]
599    pub applying_since: Option<String>,
600    /// Timestamp when the plan entered Failed phase (for dedup window).
601    #[serde(default)]
602    pub failed_at: Option<String>,
603}
604
605/// Reference to a ConfigMap containing SQL for a plan.
606#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
607pub struct SqlRef {
608    pub name: String,
609    pub key: String,
610}
611
612/// Phase of a `PostgresPolicyPlan`.
613#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
614pub enum PlanPhase {
615    #[default]
616    Pending,
617    Approved,
618    Applying,
619    Applied,
620    Failed,
621    Superseded,
622    Rejected,
623}
624
625impl std::fmt::Display for PlanPhase {
626    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627        match self {
628            PlanPhase::Pending => write!(f, "Pending"),
629            PlanPhase::Approved => write!(f, "Approved"),
630            PlanPhase::Applying => write!(f, "Applying"),
631            PlanPhase::Applied => write!(f, "Applied"),
632            PlanPhase::Failed => write!(f, "Failed"),
633            PlanPhase::Superseded => write!(f, "Superseded"),
634            PlanPhase::Rejected => write!(f, "Rejected"),
635        }
636    }
637}
638
639// ---------------------------------------------------------------------------
640// Conflict detection
641// ---------------------------------------------------------------------------
642
643/// Canonical target identity for conflict detection between policies.
644#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
645pub struct DatabaseIdentity(String);
646
647impl DatabaseIdentity {
648    pub fn new(namespace: &str, secret_name: &str, secret_key: &str) -> Self {
649        Self(format!("{namespace}/{secret_name}/{secret_key}"))
650    }
651
652    pub fn as_str(&self) -> &str {
653        &self.0
654    }
655}
656
657/// Conservative ownership claims for a policy.
658#[derive(Debug, Clone, Default, PartialEq, Eq)]
659pub struct OwnershipClaims {
660    pub roles: BTreeSet<String>,
661    pub schemas: BTreeSet<String>,
662}
663
664impl OwnershipClaims {
665    pub fn overlaps(&self, other: &Self) -> bool {
666        !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
667    }
668
669    pub fn overlap_summary(&self, other: &Self) -> String {
670        let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
671        let overlapping_schemas: Vec<_> =
672            self.schemas.intersection(&other.schemas).cloned().collect();
673
674        let mut parts = Vec::new();
675        if !overlapping_roles.is_empty() {
676            parts.push(format!("roles: {}", overlapping_roles.join(", ")));
677        }
678        if !overlapping_schemas.is_empty() {
679            parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
680        }
681
682        parts.join("; ")
683    }
684}
685
686// ---------------------------------------------------------------------------
687// Secret name helpers
688// ---------------------------------------------------------------------------
689
690impl PostgresPolicySpec {
691    pub fn validate_password_specs(
692        &self,
693        policy_name: &str,
694    ) -> Result<(), PasswordValidationError> {
695        for role in &self.roles {
696            let Some(password) = &role.password else {
697                continue;
698            };
699
700            if role.login != Some(true) {
701                return Err(PasswordValidationError::PasswordWithoutLogin {
702                    role: role.name.clone(),
703                });
704            }
705
706            match (&password.secret_ref, &password.generate) {
707                (Some(_), None) => {
708                    let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
709                    if !is_valid_secret_key(secret_key) {
710                        return Err(PasswordValidationError::InvalidSecretKey {
711                            role: role.name.clone(),
712                            field: "secretKey",
713                            key: secret_key.to_string(),
714                        });
715                    }
716                }
717                (None, Some(generate)) => {
718                    if let Some(length) = generate.length
719                        && !(crate::password::MIN_PASSWORD_LENGTH
720                            ..=crate::password::MAX_PASSWORD_LENGTH)
721                            .contains(&length)
722                    {
723                        return Err(PasswordValidationError::InvalidGeneratedLength {
724                            role: role.name.clone(),
725                            min: crate::password::MIN_PASSWORD_LENGTH,
726                            max: crate::password::MAX_PASSWORD_LENGTH,
727                        });
728                    }
729
730                    let secret_name =
731                        crate::password::generated_secret_name(policy_name, &role.name, generate);
732                    if !is_valid_secret_name(&secret_name) {
733                        return Err(PasswordValidationError::InvalidGeneratedSecretName {
734                            role: role.name.clone(),
735                            name: secret_name,
736                        });
737                    }
738
739                    let secret_key = crate::password::generated_secret_key(generate);
740                    if !is_valid_secret_key(&secret_key) {
741                        return Err(PasswordValidationError::InvalidSecretKey {
742                            role: role.name.clone(),
743                            field: "generate.secretKey",
744                            key: secret_key,
745                        });
746                    }
747                    if secret_key == crate::password::GENERATED_VERIFIER_KEY {
748                        return Err(PasswordValidationError::ReservedGeneratedSecretKey {
749                            role: role.name.clone(),
750                            key: secret_key,
751                        });
752                    }
753                }
754                _ => {
755                    return Err(PasswordValidationError::InvalidPasswordMode {
756                        role: role.name.clone(),
757                    });
758                }
759            }
760        }
761
762        Ok(())
763    }
764
765    /// All Kubernetes Secret names referenced by this spec.
766    ///
767    /// Includes the connection Secret, password `secretRef` Secrets, and
768    /// generated password Secrets. Used by the controller to trigger
769    /// reconciliation when any of these Secrets change (or are deleted).
770    pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
771        let mut names = BTreeSet::new();
772        names.insert(self.connection.secret_ref.name.clone());
773        for role in &self.roles {
774            if let Some(pw) = &role.password {
775                if let Some(secret_ref) = &pw.secret_ref {
776                    names.insert(secret_ref.name.clone());
777                }
778                if let Some(gen_spec) = &pw.generate {
779                    let secret_name =
780                        crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
781                    names.insert(secret_name);
782                }
783            }
784        }
785        names
786    }
787}
788
789// ---------------------------------------------------------------------------
790// Conversion: CRD spec → core manifest types
791// ---------------------------------------------------------------------------
792
793impl PostgresPolicySpec {
794    /// Convert the CRD spec into a `PolicyManifest` for use with the core library.
795    pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
796        use pgroles_core::manifest::{
797            DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
798            ProfileObjectTarget, RoleDefinition,
799        };
800
801        let profiles = self
802            .profiles
803            .iter()
804            .map(|(name, spec)| {
805                let profile = Profile {
806                    login: spec.login,
807                    grants: spec
808                        .grants
809                        .iter()
810                        .map(|g| ProfileGrant {
811                            privileges: g.privileges.clone(),
812                            object: ProfileObjectTarget {
813                                object_type: g.object.object_type,
814                                name: g.object.name.clone(),
815                            },
816                        })
817                        .collect(),
818                    default_privileges: spec
819                        .default_privileges
820                        .iter()
821                        .map(|dp| DefaultPrivilegeGrant {
822                            role: dp.role.clone(),
823                            privileges: dp.privileges.clone(),
824                            on_type: dp.on_type,
825                        })
826                        .collect(),
827                };
828                (name.clone(), profile)
829            })
830            .collect();
831
832        let roles = self
833            .roles
834            .iter()
835            .map(|r| RoleDefinition {
836                name: r.name.clone(),
837                login: r.login,
838                superuser: r.superuser,
839                createdb: r.createdb,
840                createrole: r.createrole,
841                inherit: r.inherit,
842                replication: r.replication,
843                bypassrls: r.bypassrls,
844                connection_limit: r.connection_limit,
845                comment: r.comment.clone(),
846                password: None, // K8s passwords are resolved separately via Secret refs
847                password_valid_until: r.password_valid_until.clone(),
848            })
849            .collect();
850
851        // Memberships need MemberSpec conversion — the core type should
852        // already be compatible since we use it directly in the CRD spec.
853        // But we need to ensure the serde aliases work. Let's rebuild to be safe.
854        let memberships = self
855            .memberships
856            .iter()
857            .map(|m| pgroles_core::manifest::Membership {
858                role: m.role.clone(),
859                members: m
860                    .members
861                    .iter()
862                    .map(|ms| MemberSpec {
863                        name: ms.name.clone(),
864                        inherit: ms.inherit,
865                        admin: ms.admin,
866                    })
867                    .collect(),
868            })
869            .collect();
870
871        PolicyManifest {
872            default_owner: self.default_owner.clone(),
873            auth_providers: Vec::new(),
874            profiles,
875            schemas: self.schemas.clone(),
876            roles,
877            grants: self.grants.clone(),
878            default_privileges: self.default_privileges.clone(),
879            memberships,
880            retirements: self.retirements.clone(),
881        }
882    }
883
884    /// Derive a conservative ownership claim set from the policy spec.
885    ///
886    /// This intentionally claims all declared/expanded roles and all referenced
887    /// schemas so overlapping policies are rejected safely.
888    pub fn ownership_claims(
889        &self,
890    ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
891        let manifest = self.to_policy_manifest();
892        let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
893
894        let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
895        let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
896
897        roles.extend(manifest.retirements.into_iter().map(|r| r.role));
898        roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
899        roles.extend(
900            manifest
901                .default_privileges
902                .iter()
903                .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
904        );
905        roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
906        roles.extend(
907            manifest
908                .memberships
909                .iter()
910                .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
911        );
912
913        schemas.extend(
914            manifest
915                .grants
916                .iter()
917                .filter_map(|g| match g.object.object_type {
918                    ObjectType::Database => None,
919                    ObjectType::Schema => g.object.name.clone(),
920                    _ => g.object.schema.clone(),
921                }),
922        );
923        schemas.extend(
924            manifest
925                .default_privileges
926                .iter()
927                .map(|dp| dp.schema.clone()),
928        );
929
930        Ok(OwnershipClaims { roles, schemas })
931    }
932}
933
934// ---------------------------------------------------------------------------
935// Status helpers
936// ---------------------------------------------------------------------------
937
938impl PostgresPolicyStatus {
939    /// Set a condition, replacing any existing condition of the same type.
940    ///
941    /// If the condition's `status` value has not changed, the existing
942    /// `last_transition_time` is preserved (per Kubernetes condition conventions).
943    pub fn set_condition(&mut self, new: PolicyCondition) {
944        if let Some(existing) = self
945            .conditions
946            .iter()
947            .find(|c| c.condition_type == new.condition_type)
948            && existing.status == new.status
949        {
950            // Status unchanged — preserve the existing transition time.
951            let mut updated = new;
952            updated.last_transition_time = existing.last_transition_time.clone();
953            self.conditions
954                .retain(|c| c.condition_type != updated.condition_type);
955            self.conditions.push(updated);
956            return;
957        }
958        // New condition or status changed — use the new timestamp.
959        self.conditions
960            .retain(|c| c.condition_type != new.condition_type);
961        self.conditions.push(new);
962    }
963}
964
965/// Create a timestamp string in ISO 8601 / RFC 3339 format.
966pub fn now_rfc3339() -> String {
967    // Use k8s-openapi's chrono re-export or manual formatting.
968    // For simplicity, use the system time.
969    use std::time::SystemTime;
970    let now = SystemTime::now()
971        .duration_since(SystemTime::UNIX_EPOCH)
972        .unwrap_or_default();
973    // Format as simplified ISO 8601
974    let secs = now.as_secs();
975    let days = secs / 86400;
976    let remaining = secs % 86400;
977    let hours = remaining / 3600;
978    let minutes = (remaining % 3600) / 60;
979    let seconds = remaining % 60;
980
981    // Convert days since epoch to date (simplified — good enough for status)
982    let (year, month, day) = days_to_date(days);
983    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
984}
985
986/// Convert days since Unix epoch to (year, month, day).
987pub fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
988    // Civil calendar algorithm from Howard Hinnant
989    let z = days_since_epoch + 719468;
990    let era = z / 146097;
991    let doe = z - era * 146097;
992    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
993    let y = yoe + era * 400;
994    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
995    let mp = (5 * doy + 2) / 153;
996    let d = doy - (153 * mp + 2) / 5 + 1;
997    let m = if mp < 10 { mp + 3 } else { mp - 9 };
998    let y = if m <= 2 { y + 1 } else { y };
999    (y, m, d)
1000}
1001
1002/// Helper to create a "Ready" condition.
1003pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1004    PolicyCondition {
1005        condition_type: "Ready".to_string(),
1006        status: if status { "True" } else { "False" }.to_string(),
1007        reason: Some(reason.to_string()),
1008        message: Some(message.to_string()),
1009        last_transition_time: Some(now_rfc3339()),
1010    }
1011}
1012
1013/// Helper to create a "Reconciling" condition.
1014pub fn reconciling_condition(message: &str) -> PolicyCondition {
1015    PolicyCondition {
1016        condition_type: "Reconciling".to_string(),
1017        status: "True".to_string(),
1018        reason: Some("Reconciling".to_string()),
1019        message: Some(message.to_string()),
1020        last_transition_time: Some(now_rfc3339()),
1021    }
1022}
1023
1024/// Helper to create a "Degraded" condition.
1025pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
1026    PolicyCondition {
1027        condition_type: "Degraded".to_string(),
1028        status: "True".to_string(),
1029        reason: Some(reason.to_string()),
1030        message: Some(message.to_string()),
1031        last_transition_time: Some(now_rfc3339()),
1032    }
1033}
1034
1035/// Helper to create a "Paused" condition.
1036pub fn paused_condition(message: &str) -> PolicyCondition {
1037    PolicyCondition {
1038        condition_type: "Paused".to_string(),
1039        status: "True".to_string(),
1040        reason: Some("Suspended".to_string()),
1041        message: Some(message.to_string()),
1042        last_transition_time: Some(now_rfc3339()),
1043    }
1044}
1045
1046/// Helper to create a "Conflict" condition.
1047pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
1048    PolicyCondition {
1049        condition_type: "Conflict".to_string(),
1050        status: "True".to_string(),
1051        reason: Some(reason.to_string()),
1052        message: Some(message.to_string()),
1053        last_transition_time: Some(now_rfc3339()),
1054    }
1055}
1056
1057/// Helper to create a "Drifted" condition.
1058pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1059    PolicyCondition {
1060        condition_type: "Drifted".to_string(),
1061        status: if status { "True" } else { "False" }.to_string(),
1062        reason: Some(reason.to_string()),
1063        message: Some(message.to_string()),
1064        last_transition_time: Some(now_rfc3339()),
1065    }
1066}
1067
1068// ---------------------------------------------------------------------------
1069// Tests
1070// ---------------------------------------------------------------------------
1071
1072#[cfg(test)]
1073mod tests {
1074    use super::*;
1075    use kube::CustomResourceExt;
1076
1077    #[test]
1078    fn crd_generates_valid_schema() {
1079        let crd = PostgresPolicy::crd();
1080        let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
1081        assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
1082        assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
1083        assert!(
1084            yaml.contains("PostgresPolicy"),
1085            "kind should be PostgresPolicy"
1086        );
1087        assert!(
1088            yaml.contains("\"mode\"") || yaml.contains(" mode:"),
1089            "schema should declare spec.mode"
1090        );
1091        assert!(
1092            yaml.contains("\"object\"") || yaml.contains(" object:"),
1093            "schema should declare grant object targets using object"
1094        );
1095    }
1096
1097    #[test]
1098    fn spec_to_policy_manifest_roundtrip() {
1099        let spec = PostgresPolicySpec {
1100            connection: ConnectionSpec {
1101                secret_ref: SecretReference {
1102                    name: "pg-secret".to_string(),
1103                },
1104                secret_key: "DATABASE_URL".to_string(),
1105            },
1106            interval: "5m".to_string(),
1107            suspend: false,
1108            mode: PolicyMode::Apply,
1109            reconciliation_mode: CrdReconciliationMode::default(),
1110            default_owner: Some("app_owner".to_string()),
1111            profiles: std::collections::HashMap::new(),
1112            schemas: vec![],
1113            roles: vec![RoleSpec {
1114                name: "analytics".to_string(),
1115                login: Some(true),
1116                superuser: None,
1117                createdb: None,
1118                createrole: None,
1119                inherit: None,
1120                replication: None,
1121                bypassrls: None,
1122                connection_limit: None,
1123                comment: Some("test role".to_string()),
1124                password: None,
1125                password_valid_until: None,
1126            }],
1127            grants: vec![],
1128            default_privileges: vec![],
1129            memberships: vec![],
1130            retirements: vec![RoleRetirement {
1131                role: "legacy-app".to_string(),
1132                reassign_owned_to: Some("app_owner".to_string()),
1133                drop_owned: true,
1134                terminate_sessions: true,
1135            }],
1136            approval: None,
1137        };
1138
1139        let manifest = spec.to_policy_manifest();
1140        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
1141        assert_eq!(manifest.roles.len(), 1);
1142        assert_eq!(manifest.roles[0].name, "analytics");
1143        assert_eq!(manifest.roles[0].login, Some(true));
1144        assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
1145        assert_eq!(manifest.retirements.len(), 1);
1146        assert_eq!(manifest.retirements[0].role, "legacy-app");
1147        assert_eq!(
1148            manifest.retirements[0].reassign_owned_to.as_deref(),
1149            Some("app_owner")
1150        );
1151        assert!(manifest.retirements[0].drop_owned);
1152        assert!(manifest.retirements[0].terminate_sessions);
1153    }
1154
1155    #[test]
1156    fn status_set_condition_replaces_existing() {
1157        let mut status = PostgresPolicyStatus::default();
1158
1159        status.set_condition(ready_condition(false, "Pending", "Initial"));
1160        assert_eq!(status.conditions.len(), 1);
1161        assert_eq!(status.conditions[0].status, "False");
1162
1163        status.set_condition(ready_condition(true, "Reconciled", "All good"));
1164        assert_eq!(status.conditions.len(), 1);
1165        assert_eq!(status.conditions[0].status, "True");
1166        assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
1167    }
1168
1169    #[test]
1170    fn status_set_condition_adds_new_type() {
1171        let mut status = PostgresPolicyStatus::default();
1172
1173        status.set_condition(ready_condition(true, "OK", "ready"));
1174        status.set_condition(degraded_condition("Error", "something broke"));
1175
1176        assert_eq!(status.conditions.len(), 2);
1177    }
1178
1179    #[test]
1180    fn paused_condition_has_expected_shape() {
1181        let paused = paused_condition("paused by spec");
1182        assert_eq!(paused.condition_type, "Paused");
1183        assert_eq!(paused.status, "True");
1184        assert_eq!(paused.reason.as_deref(), Some("Suspended"));
1185    }
1186
1187    #[test]
1188    fn ownership_claims_include_expanded_roles_and_schemas() {
1189        let mut profiles = std::collections::HashMap::new();
1190        profiles.insert(
1191            "editor".to_string(),
1192            ProfileSpec {
1193                login: Some(false),
1194                grants: vec![],
1195                default_privileges: vec![],
1196            },
1197        );
1198
1199        let spec = PostgresPolicySpec {
1200            connection: ConnectionSpec {
1201                secret_ref: SecretReference {
1202                    name: "pg-secret".to_string(),
1203                },
1204                secret_key: "DATABASE_URL".to_string(),
1205            },
1206            interval: "5m".to_string(),
1207            suspend: false,
1208            mode: PolicyMode::Apply,
1209            reconciliation_mode: CrdReconciliationMode::default(),
1210            default_owner: None,
1211            profiles,
1212            schemas: vec![SchemaBinding {
1213                name: "inventory".to_string(),
1214                profiles: vec!["editor".to_string()],
1215                role_pattern: "{schema}-{profile}".to_string(),
1216                owner: None,
1217            }],
1218            roles: vec![RoleSpec {
1219                name: "app-service".to_string(),
1220                login: Some(true),
1221                superuser: None,
1222                createdb: None,
1223                createrole: None,
1224                inherit: None,
1225                replication: None,
1226                bypassrls: None,
1227                connection_limit: None,
1228                comment: None,
1229                password: None,
1230                password_valid_until: None,
1231            }],
1232            grants: vec![],
1233            default_privileges: vec![],
1234            memberships: vec![],
1235            retirements: vec![RoleRetirement {
1236                role: "legacy-app".to_string(),
1237                reassign_owned_to: None,
1238                drop_owned: false,
1239                terminate_sessions: false,
1240            }],
1241            approval: None,
1242        };
1243
1244        let claims = spec.ownership_claims().unwrap();
1245        assert!(claims.roles.contains("inventory-editor"));
1246        assert!(claims.roles.contains("app-service"));
1247        assert!(claims.roles.contains("legacy-app"));
1248        assert!(claims.schemas.contains("inventory"));
1249    }
1250
1251    #[test]
1252    fn ownership_overlap_summary_reports_roles_and_schemas() {
1253        let mut left = OwnershipClaims::default();
1254        left.roles.insert("analytics".to_string());
1255        left.schemas.insert("reporting".to_string());
1256
1257        let mut right = OwnershipClaims::default();
1258        right.roles.insert("analytics".to_string());
1259        right.schemas.insert("reporting".to_string());
1260        right.schemas.insert("other".to_string());
1261
1262        assert!(left.overlaps(&right));
1263        let summary = left.overlap_summary(&right);
1264        assert!(summary.contains("roles: analytics"));
1265        assert!(summary.contains("schemas: reporting"));
1266    }
1267
1268    #[test]
1269    fn database_identity_uses_namespace_secret_and_key() {
1270        let identity = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1271        assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
1272    }
1273
1274    #[test]
1275    fn now_rfc3339_produces_valid_format() {
1276        let ts = now_rfc3339();
1277        // Should match YYYY-MM-DDTHH:MM:SSZ
1278        assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
1279        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
1280        assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
1281        assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
1282    }
1283
1284    #[test]
1285    fn ready_condition_true_has_expected_shape() {
1286        let cond = ready_condition(true, "Reconciled", "All changes applied");
1287        assert_eq!(cond.condition_type, "Ready");
1288        assert_eq!(cond.status, "True");
1289        assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
1290        assert_eq!(cond.message.as_deref(), Some("All changes applied"));
1291        assert!(cond.last_transition_time.is_some());
1292    }
1293
1294    #[test]
1295    fn ready_condition_false_has_expected_shape() {
1296        let cond = ready_condition(false, "InvalidSpec", "bad manifest");
1297        assert_eq!(cond.condition_type, "Ready");
1298        assert_eq!(cond.status, "False");
1299        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
1300        assert_eq!(cond.message.as_deref(), Some("bad manifest"));
1301    }
1302
1303    #[test]
1304    fn degraded_condition_has_expected_shape() {
1305        let cond = degraded_condition("InvalidSpec", "expansion failed");
1306        assert_eq!(cond.condition_type, "Degraded");
1307        assert_eq!(cond.status, "True");
1308        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
1309        assert_eq!(cond.message.as_deref(), Some("expansion failed"));
1310        assert!(cond.last_transition_time.is_some());
1311    }
1312
1313    #[test]
1314    fn reconciling_condition_has_expected_shape() {
1315        let cond = reconciling_condition("Reconciliation in progress");
1316        assert_eq!(cond.condition_type, "Reconciling");
1317        assert_eq!(cond.status, "True");
1318        assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
1319        assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
1320        assert!(cond.last_transition_time.is_some());
1321    }
1322
1323    #[test]
1324    fn conflict_condition_has_expected_shape() {
1325        let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
1326        assert_eq!(cond.condition_type, "Conflict");
1327        assert_eq!(cond.status, "True");
1328        assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
1329        assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
1330        assert!(cond.last_transition_time.is_some());
1331    }
1332
1333    #[test]
1334    fn ownership_claims_no_overlap() {
1335        let mut left = OwnershipClaims::default();
1336        left.roles.insert("analytics".to_string());
1337        left.schemas.insert("reporting".to_string());
1338
1339        let mut right = OwnershipClaims::default();
1340        right.roles.insert("billing".to_string());
1341        right.schemas.insert("payments".to_string());
1342
1343        assert!(!left.overlaps(&right));
1344        let summary = left.overlap_summary(&right);
1345        assert!(summary.is_empty());
1346    }
1347
1348    #[test]
1349    fn ownership_claims_partial_role_overlap() {
1350        let mut left = OwnershipClaims::default();
1351        left.roles.insert("analytics".to_string());
1352        left.roles.insert("reporting-viewer".to_string());
1353
1354        let mut right = OwnershipClaims::default();
1355        right.roles.insert("analytics".to_string());
1356        right.roles.insert("other-role".to_string());
1357
1358        assert!(left.overlaps(&right));
1359        let summary = left.overlap_summary(&right);
1360        assert!(summary.contains("roles: analytics"));
1361        assert!(!summary.contains("schemas"));
1362    }
1363
1364    #[test]
1365    fn ownership_claims_empty_is_disjoint() {
1366        let left = OwnershipClaims::default();
1367        let right = OwnershipClaims::default();
1368        assert!(!left.overlaps(&right));
1369    }
1370
1371    #[test]
1372    fn database_identity_equality() {
1373        let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1374        let b = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1375        let c = DatabaseIdentity::new("staging", "db-creds", "DATABASE_URL");
1376        assert_eq!(a, b);
1377        assert_ne!(a, c);
1378    }
1379
1380    #[test]
1381    fn database_identity_different_key() {
1382        let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
1383        let b = DatabaseIdentity::new("prod", "db-creds", "CUSTOM_URL");
1384        assert_ne!(a, b);
1385    }
1386
1387    #[test]
1388    fn status_default_has_empty_conditions() {
1389        let status = PostgresPolicyStatus::default();
1390        assert!(status.conditions.is_empty());
1391        assert!(status.observed_generation.is_none());
1392        assert!(status.last_attempted_generation.is_none());
1393        assert!(status.last_successful_reconcile_time.is_none());
1394        assert!(status.change_summary.is_none());
1395        assert!(status.managed_database_identity.is_none());
1396        assert!(status.owned_roles.is_empty());
1397        assert!(status.owned_schemas.is_empty());
1398        assert!(status.last_error.is_none());
1399        assert!(status.applied_password_source_versions.is_empty());
1400    }
1401
1402    #[test]
1403    fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
1404        let mut status = PostgresPolicyStatus::default();
1405
1406        // Simulate a failed reconciliation: Ready=False + Degraded=True
1407        status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
1408        status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
1409        status
1410            .conditions
1411            .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
1412        status.change_summary = None;
1413        status.last_error = Some("bad manifest".to_string());
1414
1415        // Verify Ready=False
1416        let ready = status
1417            .conditions
1418            .iter()
1419            .find(|c| c.condition_type == "Ready")
1420            .expect("should have Ready condition");
1421        assert_eq!(ready.status, "False");
1422        assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
1423
1424        // Verify Degraded=True
1425        let degraded = status
1426            .conditions
1427            .iter()
1428            .find(|c| c.condition_type == "Degraded")
1429            .expect("should have Degraded condition");
1430        assert_eq!(degraded.status, "True");
1431        assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
1432
1433        // Verify last_error is set
1434        assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
1435    }
1436
1437    #[test]
1438    fn status_conflict_workflow() {
1439        let mut status = PostgresPolicyStatus::default();
1440
1441        // Simulate a conflict
1442        let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
1443        status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
1444        status.set_condition(conflict_condition("ConflictingPolicy", msg));
1445        status.set_condition(degraded_condition("ConflictingPolicy", msg));
1446        status
1447            .conditions
1448            .retain(|c| c.condition_type != "Reconciling");
1449        status.last_error = Some(msg.to_string());
1450
1451        // Verify Conflict=True
1452        let conflict = status
1453            .conditions
1454            .iter()
1455            .find(|c| c.condition_type == "Conflict")
1456            .expect("should have Conflict condition");
1457        assert_eq!(conflict.status, "True");
1458        assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
1459
1460        // Verify Ready=False
1461        let ready = status
1462            .conditions
1463            .iter()
1464            .find(|c| c.condition_type == "Ready")
1465            .expect("should have Ready condition");
1466        assert_eq!(ready.status, "False");
1467
1468        // Verify Degraded=True
1469        let degraded = status
1470            .conditions
1471            .iter()
1472            .find(|c| c.condition_type == "Degraded")
1473            .expect("should have Degraded condition");
1474        assert_eq!(degraded.status, "True");
1475    }
1476
1477    #[test]
1478    fn status_successful_reconcile_records_generation_and_time() {
1479        let mut status = PostgresPolicyStatus::default();
1480        let generation = Some(3_i64);
1481        let summary = ChangeSummary {
1482            roles_created: 2,
1483            total: 2,
1484            ..Default::default()
1485        };
1486
1487        // Simulate a successful reconciliation
1488        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1489        status.conditions.retain(|c| {
1490            c.condition_type != "Reconciling"
1491                && c.condition_type != "Degraded"
1492                && c.condition_type != "Conflict"
1493                && c.condition_type != "Paused"
1494        });
1495        status.observed_generation = generation;
1496        status.last_attempted_generation = generation;
1497        status.last_successful_reconcile_time = Some(now_rfc3339());
1498        status.last_reconcile_time = Some(now_rfc3339());
1499        status.change_summary = Some(summary);
1500        status.last_error = None;
1501
1502        // Verify Ready=True
1503        let ready = status
1504            .conditions
1505            .iter()
1506            .find(|c| c.condition_type == "Ready")
1507            .expect("should have Ready condition");
1508        assert_eq!(ready.status, "True");
1509        assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
1510
1511        // Verify generation recorded
1512        assert_eq!(status.observed_generation, Some(3));
1513        assert_eq!(status.last_attempted_generation, Some(3));
1514
1515        // Verify timestamps set
1516        assert!(status.last_successful_reconcile_time.is_some());
1517        assert!(status.last_reconcile_time.is_some());
1518
1519        // Verify summary
1520        let summary = status.change_summary.as_ref().unwrap();
1521        assert_eq!(summary.roles_created, 2);
1522        assert_eq!(summary.total, 2);
1523
1524        // Verify no error
1525        assert!(status.last_error.is_none());
1526
1527        // Verify no Degraded/Conflict/Paused/Reconciling conditions
1528        assert!(
1529            status
1530                .conditions
1531                .iter()
1532                .all(|c| c.condition_type != "Degraded"
1533                    && c.condition_type != "Conflict"
1534                    && c.condition_type != "Paused"
1535                    && c.condition_type != "Reconciling")
1536        );
1537    }
1538
1539    #[test]
1540    fn status_suspended_workflow() {
1541        let mut status = PostgresPolicyStatus::default();
1542        let generation = Some(2_i64);
1543
1544        // Simulate a suspended reconciliation
1545        status.set_condition(paused_condition("Reconciliation suspended by spec"));
1546        status.set_condition(ready_condition(
1547            false,
1548            "Suspended",
1549            "Reconciliation suspended by spec",
1550        ));
1551        status
1552            .conditions
1553            .retain(|c| c.condition_type != "Reconciling");
1554        status.last_attempted_generation = generation;
1555        status.last_error = None;
1556
1557        // Verify Paused=True
1558        let paused = status
1559            .conditions
1560            .iter()
1561            .find(|c| c.condition_type == "Paused")
1562            .expect("should have Paused condition");
1563        assert_eq!(paused.status, "True");
1564
1565        // Verify Ready=False with Suspended reason
1566        let ready = status
1567            .conditions
1568            .iter()
1569            .find(|c| c.condition_type == "Ready")
1570            .expect("should have Ready condition");
1571        assert_eq!(ready.status, "False");
1572        assert_eq!(ready.reason.as_deref(), Some("Suspended"));
1573
1574        // Verify no Reconciling condition
1575        assert!(
1576            !status
1577                .conditions
1578                .iter()
1579                .any(|c| c.condition_type == "Reconciling")
1580        );
1581    }
1582
1583    #[test]
1584    fn status_transitions_from_degraded_to_ready() {
1585        let mut status = PostgresPolicyStatus::default();
1586
1587        // First, set degraded state
1588        status.set_condition(ready_condition(false, "InvalidSpec", "error"));
1589        status.set_condition(degraded_condition("InvalidSpec", "error"));
1590        status.last_error = Some("error".to_string());
1591
1592        assert_eq!(status.conditions.len(), 2);
1593
1594        // Then, resolve to ready
1595        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1596        status.conditions.retain(|c| {
1597            c.condition_type != "Reconciling"
1598                && c.condition_type != "Degraded"
1599                && c.condition_type != "Conflict"
1600                && c.condition_type != "Paused"
1601        });
1602        status.last_error = None;
1603
1604        // Verify Ready=True
1605        let ready = status
1606            .conditions
1607            .iter()
1608            .find(|c| c.condition_type == "Ready")
1609            .expect("should have Ready condition");
1610        assert_eq!(ready.status, "True");
1611
1612        // Verify Degraded removed
1613        assert!(
1614            !status
1615                .conditions
1616                .iter()
1617                .any(|c| c.condition_type == "Degraded")
1618        );
1619
1620        // Verify only Ready condition remains
1621        assert_eq!(status.conditions.len(), 1);
1622
1623        // Verify error cleared
1624        assert!(status.last_error.is_none());
1625    }
1626
1627    #[test]
1628    fn change_summary_default_is_all_zero() {
1629        let summary = ChangeSummary::default();
1630        assert_eq!(summary.roles_created, 0);
1631        assert_eq!(summary.roles_altered, 0);
1632        assert_eq!(summary.roles_dropped, 0);
1633        assert_eq!(summary.sessions_terminated, 0);
1634        assert_eq!(summary.grants_added, 0);
1635        assert_eq!(summary.grants_revoked, 0);
1636        assert_eq!(summary.default_privileges_set, 0);
1637        assert_eq!(summary.default_privileges_revoked, 0);
1638        assert_eq!(summary.members_added, 0);
1639        assert_eq!(summary.members_removed, 0);
1640        assert_eq!(summary.total, 0);
1641    }
1642
1643    #[test]
1644    fn status_serializes_to_json() {
1645        let mut status = PostgresPolicyStatus::default();
1646        status.set_condition(ready_condition(true, "Reconciled", "done"));
1647        status.observed_generation = Some(5);
1648        status.managed_database_identity = Some("ns/secret/key".to_string());
1649        status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
1650        status.owned_schemas = vec!["public".to_string()];
1651        status.change_summary = Some(ChangeSummary {
1652            roles_created: 1,
1653            total: 1,
1654            ..Default::default()
1655        });
1656
1657        let json = serde_json::to_string(&status).expect("should serialize");
1658        assert!(json.contains("\"Reconciled\""));
1659        assert!(json.contains("\"observed_generation\":5"));
1660        assert!(json.contains("\"role-a\""));
1661        assert!(json.contains("\"ns/secret/key\""));
1662    }
1663
1664    #[test]
1665    fn crd_spec_deserializes_from_yaml() {
1666        let yaml = r#"
1667connection:
1668  secretRef:
1669    name: pg-credentials
1670interval: "10m"
1671default_owner: app_owner
1672profiles:
1673  editor:
1674    grants:
1675      - privileges: [USAGE]
1676        object: { type: schema }
1677      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1678        object: { type: table, name: "*" }
1679    default_privileges:
1680      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1681        on_type: table
1682schemas:
1683  - name: inventory
1684    profiles: [editor]
1685roles:
1686  - name: analytics
1687    login: true
1688grants:
1689  - role: analytics
1690    privileges: [CONNECT]
1691    object: { type: database, name: mydb }
1692memberships:
1693  - role: inventory-editor
1694    members:
1695      - name: analytics
1696retirements:
1697  - role: legacy-app
1698    reassign_owned_to: app_owner
1699    drop_owned: true
1700    terminate_sessions: true
1701"#;
1702        let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
1703        assert_eq!(spec.interval, "10m");
1704        assert_eq!(spec.default_owner, Some("app_owner".to_string()));
1705        assert_eq!(spec.profiles.len(), 1);
1706        assert!(spec.profiles.contains_key("editor"));
1707        assert_eq!(spec.schemas.len(), 1);
1708        assert_eq!(spec.roles.len(), 1);
1709        assert_eq!(spec.grants.len(), 1);
1710        assert_eq!(spec.memberships.len(), 1);
1711        assert_eq!(spec.retirements.len(), 1);
1712        assert_eq!(spec.retirements[0].role, "legacy-app");
1713        assert!(spec.retirements[0].terminate_sessions);
1714    }
1715
1716    #[test]
1717    fn referenced_secret_names_includes_connection_secret() {
1718        let spec = PostgresPolicySpec {
1719            connection: ConnectionSpec {
1720                secret_ref: SecretReference {
1721                    name: "pg-conn".to_string(),
1722                },
1723                secret_key: "DATABASE_URL".to_string(),
1724            },
1725            interval: "5m".to_string(),
1726            suspend: false,
1727            mode: PolicyMode::Apply,
1728            reconciliation_mode: CrdReconciliationMode::default(),
1729            default_owner: None,
1730            profiles: std::collections::HashMap::new(),
1731            schemas: vec![],
1732            roles: vec![],
1733            grants: vec![],
1734            default_privileges: vec![],
1735            memberships: vec![],
1736            retirements: vec![],
1737            approval: None,
1738        };
1739
1740        let names = spec.referenced_secret_names("test-policy");
1741        assert!(names.contains("pg-conn"));
1742        assert_eq!(names.len(), 1);
1743    }
1744
1745    #[test]
1746    fn referenced_secret_names_includes_password_secrets() {
1747        let spec = PostgresPolicySpec {
1748            connection: ConnectionSpec {
1749                secret_ref: SecretReference {
1750                    name: "pg-conn".to_string(),
1751                },
1752                secret_key: "DATABASE_URL".to_string(),
1753            },
1754            interval: "5m".to_string(),
1755            suspend: false,
1756            mode: PolicyMode::Apply,
1757            reconciliation_mode: CrdReconciliationMode::default(),
1758            default_owner: None,
1759            profiles: std::collections::HashMap::new(),
1760            schemas: vec![],
1761            roles: vec![
1762                RoleSpec {
1763                    name: "role-a".to_string(),
1764                    login: Some(true),
1765                    password: Some(PasswordSpec {
1766                        secret_ref: Some(SecretReference {
1767                            name: "role-passwords".to_string(),
1768                        }),
1769                        secret_key: Some("role-a".to_string()),
1770                        generate: None,
1771                    }),
1772                    password_valid_until: None,
1773                    superuser: None,
1774                    createdb: None,
1775                    createrole: None,
1776                    inherit: None,
1777                    replication: None,
1778                    bypassrls: None,
1779                    connection_limit: None,
1780                    comment: None,
1781                },
1782                RoleSpec {
1783                    name: "role-b".to_string(),
1784                    login: Some(true),
1785                    password: Some(PasswordSpec {
1786                        secret_ref: Some(SecretReference {
1787                            name: "other-secret".to_string(),
1788                        }),
1789                        secret_key: None,
1790                        generate: None,
1791                    }),
1792                    password_valid_until: None,
1793                    superuser: None,
1794                    createdb: None,
1795                    createrole: None,
1796                    inherit: None,
1797                    replication: None,
1798                    bypassrls: None,
1799                    connection_limit: None,
1800                    comment: None,
1801                },
1802                RoleSpec {
1803                    name: "role-c".to_string(),
1804                    login: None,
1805                    password: None,
1806                    password_valid_until: None,
1807                    superuser: None,
1808                    createdb: None,
1809                    createrole: None,
1810                    inherit: None,
1811                    replication: None,
1812                    bypassrls: None,
1813                    connection_limit: None,
1814                    comment: None,
1815                },
1816            ],
1817            grants: vec![],
1818            default_privileges: vec![],
1819            memberships: vec![],
1820            retirements: vec![],
1821            approval: None,
1822        };
1823
1824        let names = spec.referenced_secret_names("test-policy");
1825        assert!(
1826            names.contains("pg-conn"),
1827            "should include connection secret"
1828        );
1829        assert!(
1830            names.contains("role-passwords"),
1831            "should include role-a password secret"
1832        );
1833        assert!(
1834            names.contains("other-secret"),
1835            "should include role-b password secret"
1836        );
1837        assert_eq!(names.len(), 3);
1838    }
1839
1840    #[test]
1841    fn validate_password_specs_rejects_password_without_login() {
1842        let spec = PostgresPolicySpec {
1843            connection: ConnectionSpec {
1844                secret_ref: SecretReference {
1845                    name: "pg-conn".to_string(),
1846                },
1847                secret_key: "DATABASE_URL".to_string(),
1848            },
1849            interval: "5m".to_string(),
1850            suspend: false,
1851            mode: PolicyMode::Apply,
1852            reconciliation_mode: CrdReconciliationMode::default(),
1853            default_owner: None,
1854            profiles: std::collections::HashMap::new(),
1855            schemas: vec![],
1856            roles: vec![RoleSpec {
1857                name: "app-user".to_string(),
1858                login: Some(false),
1859                superuser: None,
1860                createdb: None,
1861                createrole: None,
1862                inherit: None,
1863                replication: None,
1864                bypassrls: None,
1865                connection_limit: None,
1866                comment: None,
1867                password: Some(PasswordSpec {
1868                    secret_ref: Some(SecretReference {
1869                        name: "role-passwords".to_string(),
1870                    }),
1871                    secret_key: None,
1872                    generate: None,
1873                }),
1874                password_valid_until: None,
1875            }],
1876            grants: vec![],
1877            default_privileges: vec![],
1878            memberships: vec![],
1879            retirements: vec![],
1880            approval: None,
1881        };
1882
1883        assert!(matches!(
1884            spec.validate_password_specs("test-policy"),
1885            Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
1886        ));
1887    }
1888
1889    #[test]
1890    fn validate_password_specs_rejects_password_with_login_omitted() {
1891        let spec = PostgresPolicySpec {
1892            connection: ConnectionSpec {
1893                secret_ref: SecretReference {
1894                    name: "pg-conn".to_string(),
1895                },
1896                secret_key: "DATABASE_URL".to_string(),
1897            },
1898            interval: "5m".to_string(),
1899            suspend: false,
1900            mode: PolicyMode::Apply,
1901            reconciliation_mode: CrdReconciliationMode::default(),
1902            default_owner: None,
1903            profiles: std::collections::HashMap::new(),
1904            schemas: vec![],
1905            roles: vec![RoleSpec {
1906                name: "app-user".to_string(),
1907                login: None, // omitted, not explicitly false
1908                superuser: None,
1909                createdb: None,
1910                createrole: None,
1911                inherit: None,
1912                replication: None,
1913                bypassrls: None,
1914                connection_limit: None,
1915                comment: None,
1916                password: Some(PasswordSpec {
1917                    secret_ref: Some(SecretReference {
1918                        name: "role-passwords".to_string(),
1919                    }),
1920                    secret_key: None,
1921                    generate: None,
1922                }),
1923                password_valid_until: None,
1924            }],
1925            grants: vec![],
1926            default_privileges: vec![],
1927            memberships: vec![],
1928            retirements: vec![],
1929            approval: None,
1930        };
1931
1932        assert!(matches!(
1933            spec.validate_password_specs("test-policy"),
1934            Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
1935        ));
1936    }
1937
1938    #[test]
1939    fn validate_password_specs_rejects_invalid_password_mode() {
1940        let spec = PostgresPolicySpec {
1941            connection: ConnectionSpec {
1942                secret_ref: SecretReference {
1943                    name: "pg-conn".to_string(),
1944                },
1945                secret_key: "DATABASE_URL".to_string(),
1946            },
1947            interval: "5m".to_string(),
1948            suspend: false,
1949            mode: PolicyMode::Apply,
1950            reconciliation_mode: CrdReconciliationMode::default(),
1951            default_owner: None,
1952            profiles: std::collections::HashMap::new(),
1953            schemas: vec![],
1954            roles: vec![RoleSpec {
1955                name: "app-user".to_string(),
1956                login: Some(true),
1957                superuser: None,
1958                createdb: None,
1959                createrole: None,
1960                inherit: None,
1961                replication: None,
1962                bypassrls: None,
1963                connection_limit: None,
1964                comment: None,
1965                password: Some(PasswordSpec {
1966                    secret_ref: Some(SecretReference {
1967                        name: "role-passwords".to_string(),
1968                    }),
1969                    secret_key: None,
1970                    generate: Some(GeneratePasswordSpec {
1971                        length: Some(32),
1972                        secret_name: None,
1973                        secret_key: None,
1974                    }),
1975                }),
1976                password_valid_until: None,
1977            }],
1978            grants: vec![],
1979            default_privileges: vec![],
1980            memberships: vec![],
1981            retirements: vec![],
1982            approval: None,
1983        };
1984
1985        assert!(matches!(
1986            spec.validate_password_specs("test-policy"),
1987            Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
1988        ));
1989    }
1990
1991    #[test]
1992    fn validate_password_specs_rejects_invalid_generated_length() {
1993        let spec = PostgresPolicySpec {
1994            connection: ConnectionSpec {
1995                secret_ref: SecretReference {
1996                    name: "pg-conn".to_string(),
1997                },
1998                secret_key: "DATABASE_URL".to_string(),
1999            },
2000            interval: "5m".to_string(),
2001            suspend: false,
2002            mode: PolicyMode::Apply,
2003            reconciliation_mode: CrdReconciliationMode::default(),
2004            default_owner: None,
2005            profiles: std::collections::HashMap::new(),
2006            schemas: vec![],
2007            roles: vec![RoleSpec {
2008                name: "app-user".to_string(),
2009                login: Some(true),
2010                superuser: None,
2011                createdb: None,
2012                createrole: None,
2013                inherit: None,
2014                replication: None,
2015                bypassrls: None,
2016                connection_limit: None,
2017                comment: None,
2018                password: Some(PasswordSpec {
2019                    secret_ref: None,
2020                    secret_key: None,
2021                    generate: Some(GeneratePasswordSpec {
2022                        length: Some(8),
2023                        secret_name: None,
2024                        secret_key: None,
2025                    }),
2026                }),
2027                password_valid_until: None,
2028            }],
2029            grants: vec![],
2030            default_privileges: vec![],
2031            memberships: vec![],
2032            retirements: vec![],
2033            approval: None,
2034        };
2035
2036        assert!(matches!(
2037            spec.validate_password_specs("test-policy"),
2038            Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
2039        ));
2040    }
2041
2042    #[test]
2043    fn validate_password_specs_rejects_invalid_generated_secret_key() {
2044        let spec = PostgresPolicySpec {
2045            connection: ConnectionSpec {
2046                secret_ref: SecretReference {
2047                    name: "pg-conn".to_string(),
2048                },
2049                secret_key: "DATABASE_URL".to_string(),
2050            },
2051            interval: "5m".to_string(),
2052            suspend: false,
2053            mode: PolicyMode::Apply,
2054            reconciliation_mode: CrdReconciliationMode::default(),
2055            default_owner: None,
2056            profiles: std::collections::HashMap::new(),
2057            schemas: vec![],
2058            roles: vec![RoleSpec {
2059                name: "app-user".to_string(),
2060                login: Some(true),
2061                superuser: None,
2062                createdb: None,
2063                createrole: None,
2064                inherit: None,
2065                replication: None,
2066                bypassrls: None,
2067                connection_limit: None,
2068                comment: None,
2069                password: Some(PasswordSpec {
2070                    secret_ref: None,
2071                    secret_key: None,
2072                    generate: Some(GeneratePasswordSpec {
2073                        length: Some(32),
2074                        secret_name: None,
2075                        secret_key: Some("bad/key".to_string()),
2076                    }),
2077                }),
2078                password_valid_until: None,
2079            }],
2080            grants: vec![],
2081            default_privileges: vec![],
2082            memberships: vec![],
2083            retirements: vec![],
2084            approval: None,
2085        };
2086
2087        assert!(matches!(
2088            spec.validate_password_specs("test-policy"),
2089            Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
2090                if role == "app-user" && field == "generate.secretKey"
2091        ));
2092    }
2093
2094    #[test]
2095    fn validate_password_specs_rejects_invalid_generated_secret_name() {
2096        let spec = PostgresPolicySpec {
2097            connection: ConnectionSpec {
2098                secret_ref: SecretReference {
2099                    name: "pg-conn".to_string(),
2100                },
2101                secret_key: "DATABASE_URL".to_string(),
2102            },
2103            interval: "5m".to_string(),
2104            suspend: false,
2105            mode: PolicyMode::Apply,
2106            reconciliation_mode: CrdReconciliationMode::default(),
2107            default_owner: None,
2108            profiles: std::collections::HashMap::new(),
2109            schemas: vec![],
2110            roles: vec![RoleSpec {
2111                name: "app-user".to_string(),
2112                login: Some(true),
2113                superuser: None,
2114                createdb: None,
2115                createrole: None,
2116                inherit: None,
2117                replication: None,
2118                bypassrls: None,
2119                connection_limit: None,
2120                comment: None,
2121                password: Some(PasswordSpec {
2122                    secret_ref: None,
2123                    secret_key: None,
2124                    generate: Some(GeneratePasswordSpec {
2125                        length: Some(32),
2126                        secret_name: Some("Bad_Name".to_string()),
2127                        secret_key: None,
2128                    }),
2129                }),
2130                password_valid_until: None,
2131            }],
2132            grants: vec![],
2133            default_privileges: vec![],
2134            memberships: vec![],
2135            retirements: vec![],
2136            approval: None,
2137        };
2138
2139        assert!(matches!(
2140            spec.validate_password_specs("test-policy"),
2141            Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
2142        ));
2143    }
2144
2145    #[test]
2146    fn validate_password_specs_rejects_reserved_generated_secret_key() {
2147        let spec = PostgresPolicySpec {
2148            connection: ConnectionSpec {
2149                secret_ref: SecretReference {
2150                    name: "pg-conn".to_string(),
2151                },
2152                secret_key: "DATABASE_URL".to_string(),
2153            },
2154            interval: "5m".to_string(),
2155            suspend: false,
2156            mode: PolicyMode::Apply,
2157            reconciliation_mode: CrdReconciliationMode::default(),
2158            default_owner: None,
2159            profiles: std::collections::HashMap::new(),
2160            schemas: vec![],
2161            roles: vec![RoleSpec {
2162                name: "app-user".to_string(),
2163                login: Some(true),
2164                superuser: None,
2165                createdb: None,
2166                createrole: None,
2167                inherit: None,
2168                replication: None,
2169                bypassrls: None,
2170                connection_limit: None,
2171                comment: None,
2172                password: Some(PasswordSpec {
2173                    secret_ref: None,
2174                    secret_key: None,
2175                    generate: Some(GeneratePasswordSpec {
2176                        length: Some(32),
2177                        secret_name: None,
2178                        secret_key: Some("verifier".to_string()),
2179                    }),
2180                }),
2181                password_valid_until: None,
2182            }],
2183            grants: vec![],
2184            default_privileges: vec![],
2185            memberships: vec![],
2186            retirements: vec![],
2187            approval: None,
2188        };
2189
2190        assert!(matches!(
2191            spec.validate_password_specs("test-policy"),
2192            Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
2193                if role == "app-user" && key == "verifier"
2194        ));
2195    }
2196
2197    #[test]
2198    fn plan_crd_generates_valid_schema() {
2199        let crd = PostgresPolicyPlan::crd();
2200        let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
2201        assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
2202        assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
2203        assert!(
2204            yaml.contains("PostgresPolicyPlan"),
2205            "kind should be PostgresPolicyPlan"
2206        );
2207        assert!(yaml.contains("pgplan"), "should have shortname pgplan");
2208    }
2209
2210    #[test]
2211    fn plan_phase_display() {
2212        assert_eq!(PlanPhase::Pending.to_string(), "Pending");
2213        assert_eq!(PlanPhase::Approved.to_string(), "Approved");
2214        assert_eq!(PlanPhase::Applying.to_string(), "Applying");
2215        assert_eq!(PlanPhase::Applied.to_string(), "Applied");
2216        assert_eq!(PlanPhase::Failed.to_string(), "Failed");
2217        assert_eq!(PlanPhase::Superseded.to_string(), "Superseded");
2218    }
2219
2220    #[test]
2221    fn plan_phase_default_is_pending() {
2222        assert_eq!(PlanPhase::default(), PlanPhase::Pending);
2223    }
2224
2225    #[test]
2226    fn effective_approval_infers_from_mode() {
2227        let base = PostgresPolicySpec {
2228            connection: ConnectionSpec {
2229                secret_ref: SecretReference {
2230                    name: "test".into(),
2231                },
2232                secret_key: "DATABASE_URL".into(),
2233            },
2234            interval: "5m".into(),
2235            suspend: false,
2236            mode: PolicyMode::Apply,
2237            reconciliation_mode: CrdReconciliationMode::Authoritative,
2238            default_owner: None,
2239            profiles: Default::default(),
2240            schemas: vec![],
2241            roles: vec![],
2242            grants: vec![],
2243            default_privileges: vec![],
2244            memberships: vec![],
2245            retirements: vec![],
2246            approval: None,
2247        };
2248
2249        // apply mode with no explicit approval → Auto
2250        assert_eq!(base.effective_approval(), ApprovalMode::Auto);
2251
2252        // plan mode with no explicit approval → Manual
2253        let plan = PostgresPolicySpec {
2254            mode: PolicyMode::Plan,
2255            ..base.clone()
2256        };
2257        assert_eq!(plan.effective_approval(), ApprovalMode::Manual);
2258
2259        // explicit Manual overrides apply mode
2260        let explicit = PostgresPolicySpec {
2261            approval: Some(ApprovalMode::Manual),
2262            ..base.clone()
2263        };
2264        assert_eq!(explicit.effective_approval(), ApprovalMode::Manual);
2265    }
2266
2267    #[test]
2268    fn approval_mode_serde_roundtrip() {
2269        // Deserialize
2270        let manual: ApprovalMode = serde_json::from_str("\"manual\"").unwrap();
2271        assert_eq!(manual, ApprovalMode::Manual);
2272        let auto: ApprovalMode = serde_json::from_str("\"auto\"").unwrap();
2273        assert_eq!(auto, ApprovalMode::Auto);
2274
2275        // Serialize back
2276        let manual_json = serde_json::to_value(&ApprovalMode::Manual).unwrap();
2277        assert_eq!(manual_json, serde_json::Value::String("manual".to_string()));
2278        let auto_json = serde_json::to_value(&ApprovalMode::Auto).unwrap();
2279        assert_eq!(auto_json, serde_json::Value::String("auto".to_string()));
2280    }
2281
2282    #[test]
2283    fn plan_status_default_is_empty() {
2284        let status = PostgresPolicyPlanStatus::default();
2285        assert_eq!(status.phase, PlanPhase::Pending);
2286        assert!(status.conditions.is_empty());
2287        assert!(status.change_summary.is_none());
2288        assert!(status.sql_ref.is_none());
2289        assert!(status.sql_inline.is_none());
2290        assert!(status.computed_at.is_none());
2291        assert!(status.applied_at.is_none());
2292        assert!(status.last_error.is_none());
2293    }
2294
2295    #[test]
2296    fn spec_without_approval_field_deserializes_as_none() {
2297        let json = serde_json::json!({
2298            "connection": {
2299                "secretRef": { "name": "pg-secret" },
2300                "secretKey": "DATABASE_URL"
2301            },
2302            "interval": "5m",
2303            "suspend": false,
2304            "mode": "apply",
2305            "reconciliation_mode": "authoritative"
2306        });
2307
2308        let spec: PostgresPolicySpec =
2309            serde_json::from_value(json).expect("should deserialize without approval field");
2310        assert!(
2311            spec.approval.is_none(),
2312            "approval should be None when omitted"
2313        );
2314        assert_eq!(
2315            spec.effective_approval(),
2316            ApprovalMode::Auto,
2317            "effective_approval should infer Auto from apply mode"
2318        );
2319    }
2320
2321    #[test]
2322    fn status_without_current_plan_ref_deserializes_as_none() {
2323        let json = serde_json::json!({
2324            "conditions": [],
2325            "owned_roles": [],
2326            "owned_schemas": []
2327        });
2328
2329        let status: PostgresPolicyStatus =
2330            serde_json::from_value(json).expect("should deserialize without current_plan_ref");
2331        assert!(
2332            status.current_plan_ref.is_none(),
2333            "current_plan_ref should be None when omitted"
2334        );
2335    }
2336
2337    #[test]
2338    fn effective_approval_explicit_auto_overrides_plan_mode() {
2339        let spec = PostgresPolicySpec {
2340            connection: ConnectionSpec {
2341                secret_ref: SecretReference {
2342                    name: "test".into(),
2343                },
2344                secret_key: "DATABASE_URL".into(),
2345            },
2346            interval: "5m".into(),
2347            suspend: false,
2348            mode: PolicyMode::Plan,
2349            reconciliation_mode: CrdReconciliationMode::Authoritative,
2350            default_owner: None,
2351            profiles: Default::default(),
2352            schemas: vec![],
2353            roles: vec![],
2354            grants: vec![],
2355            default_privileges: vec![],
2356            memberships: vec![],
2357            retirements: vec![],
2358            approval: Some(ApprovalMode::Auto),
2359        };
2360
2361        assert_eq!(
2362            spec.effective_approval(),
2363            ApprovalMode::Auto,
2364            "explicit Auto should override Plan mode's default of Manual"
2365        );
2366    }
2367
2368    #[test]
2369    fn plan_phase_rejected_display() {
2370        assert_eq!(PlanPhase::Rejected.to_string(), "Rejected");
2371    }
2372
2373    #[test]
2374    fn plan_phase_all_variants_display() {
2375        let variants = [
2376            PlanPhase::Pending,
2377            PlanPhase::Approved,
2378            PlanPhase::Applying,
2379            PlanPhase::Applied,
2380            PlanPhase::Failed,
2381            PlanPhase::Superseded,
2382            PlanPhase::Rejected,
2383        ];
2384        for variant in &variants {
2385            let display = variant.to_string();
2386            assert!(
2387                !display.is_empty(),
2388                "PlanPhase::{variant:?} should have non-empty Display output"
2389            );
2390        }
2391    }
2392
2393    #[test]
2394    fn plan_status_defaults() {
2395        let status = PostgresPolicyPlanStatus::default();
2396        assert_eq!(status.phase, PlanPhase::Pending);
2397        assert!(status.conditions.is_empty());
2398        assert!(status.sql_ref.is_none());
2399        assert!(status.sql_hash.is_none());
2400        assert!(status.sql_inline.is_none());
2401        assert!(status.change_summary.is_none());
2402        assert!(status.computed_at.is_none());
2403        assert!(status.applied_at.is_none());
2404        assert!(status.last_error.is_none());
2405    }
2406
2407    #[test]
2408    fn plan_spec_camel_case_serialization() {
2409        let spec = PostgresPolicyPlanSpec {
2410            policy_ref: PolicyPlanRef {
2411                name: "my-policy".into(),
2412            },
2413            policy_generation: 3,
2414            reconciliation_mode: CrdReconciliationMode::Authoritative,
2415            owned_roles: vec!["role-a".into()],
2416            owned_schemas: vec!["public".into()],
2417            managed_database_identity: "ns/secret/key".into(),
2418        };
2419
2420        let json = serde_json::to_value(&spec).expect("should serialize to JSON");
2421        let obj = json.as_object().expect("should be a JSON object");
2422
2423        assert!(
2424            obj.contains_key("policyRef"),
2425            "should use camelCase: policyRef"
2426        );
2427        assert!(
2428            obj.contains_key("policyGeneration"),
2429            "should use camelCase: policyGeneration"
2430        );
2431        assert!(
2432            obj.contains_key("reconciliationMode"),
2433            "should use camelCase: reconciliationMode"
2434        );
2435        assert!(
2436            obj.contains_key("ownedRoles"),
2437            "should use camelCase: ownedRoles"
2438        );
2439        assert!(
2440            obj.contains_key("ownedSchemas"),
2441            "should use camelCase: ownedSchemas"
2442        );
2443        assert!(
2444            obj.contains_key("managedDatabaseIdentity"),
2445            "should use camelCase: managedDatabaseIdentity"
2446        );
2447    }
2448}