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