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::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    pub on: ProfileObjectTargetSpec,
175}
176
177/// Object target within a profile.
178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
179pub struct ProfileObjectTargetSpec {
180    #[serde(rename = "type")]
181    pub object_type: ObjectType,
182    #[serde(default)]
183    pub name: Option<String>,
184}
185
186/// Default privilege grant within a profile.
187#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
188pub struct DefaultPrivilegeGrantSpec {
189    #[serde(default)]
190    pub role: Option<String>,
191    pub privileges: Vec<Privilege>,
192    pub on_type: ObjectType,
193}
194
195/// A concrete role definition (CRD-compatible version).
196#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
197pub struct RoleSpec {
198    pub name: String,
199    #[serde(default)]
200    pub login: Option<bool>,
201    #[serde(default)]
202    pub superuser: Option<bool>,
203    #[serde(default)]
204    pub createdb: Option<bool>,
205    #[serde(default)]
206    pub createrole: Option<bool>,
207    #[serde(default)]
208    pub inherit: Option<bool>,
209    #[serde(default)]
210    pub replication: Option<bool>,
211    #[serde(default)]
212    pub bypassrls: Option<bool>,
213    #[serde(default)]
214    pub connection_limit: Option<i32>,
215    #[serde(default)]
216    pub comment: Option<String>,
217    /// Password source for this role. References a Kubernetes Secret key.
218    #[serde(default)]
219    pub password: Option<PasswordSecretRef>,
220    /// Password expiration timestamp (ISO 8601, e.g. "2025-12-31T00:00:00Z").
221    #[serde(default)]
222    pub password_valid_until: Option<String>,
223}
224
225/// Reference to a Kubernetes Secret key containing a role password.
226#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
227#[serde(rename_all = "camelCase")]
228pub struct PasswordSecretRef {
229    /// Reference to the Kubernetes Secret containing the password.
230    pub secret_ref: SecretReference,
231    /// Key within the Secret. Defaults to the role name.
232    #[serde(default)]
233    pub secret_key: Option<String>,
234}
235
236// ---------------------------------------------------------------------------
237// CRD status
238// ---------------------------------------------------------------------------
239
240/// Status of a `PostgresPolicy` resource.
241#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
242pub struct PostgresPolicyStatus {
243    /// Standard Kubernetes conditions.
244    #[serde(default)]
245    pub conditions: Vec<PolicyCondition>,
246
247    /// The `.metadata.generation` that was last successfully reconciled.
248    #[serde(default)]
249    pub observed_generation: Option<i64>,
250
251    /// The `.metadata.generation` that was last attempted.
252    #[serde(default)]
253    pub last_attempted_generation: Option<i64>,
254
255    /// ISO 8601 timestamp of the last successful reconciliation.
256    #[serde(default)]
257    pub last_successful_reconcile_time: Option<String>,
258
259    /// Deprecated alias retained for compatibility with older status readers.
260    #[serde(default)]
261    pub last_reconcile_time: Option<String>,
262
263    /// Summary of changes applied in the last reconciliation.
264    #[serde(default)]
265    pub change_summary: Option<ChangeSummary>,
266
267    /// The reconciliation mode used for the last successful reconcile.
268    #[serde(default)]
269    pub last_reconcile_mode: Option<PolicyMode>,
270
271    /// Planned SQL for the last successful plan-mode reconcile.
272    #[serde(default)]
273    pub planned_sql: Option<String>,
274
275    /// Whether `planned_sql` was truncated to fit safely in status.
276    #[serde(default)]
277    pub planned_sql_truncated: bool,
278
279    /// Canonical identity of the managed database target.
280    #[serde(default)]
281    pub managed_database_identity: Option<String>,
282
283    /// Roles claimed by this policy's declared ownership scope.
284    #[serde(default)]
285    pub owned_roles: Vec<String>,
286
287    /// Schemas claimed by this policy's declared ownership scope.
288    #[serde(default)]
289    pub owned_schemas: Vec<String>,
290
291    /// Last reconcile error message, if any.
292    #[serde(default)]
293    pub last_error: Option<String>,
294
295    /// Consecutive transient operational failures used for exponential backoff.
296    #[serde(default)]
297    pub transient_failure_count: i32,
298}
299
300/// A condition on the `PostgresPolicy` resource.
301#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
302pub struct PolicyCondition {
303    /// Type of condition: "Ready", "Reconciling", "Degraded".
304    #[serde(rename = "type")]
305    pub condition_type: String,
306
307    /// Status: "True", "False", or "Unknown".
308    pub status: String,
309
310    /// Human-readable reason for the condition.
311    #[serde(default)]
312    pub reason: Option<String>,
313
314    /// Human-readable message.
315    #[serde(default)]
316    pub message: Option<String>,
317
318    /// Last time the condition transitioned.
319    #[serde(default)]
320    pub last_transition_time: Option<String>,
321}
322
323/// Summary of changes applied during reconciliation.
324#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
325pub struct ChangeSummary {
326    pub roles_created: i32,
327    pub roles_altered: i32,
328    pub roles_dropped: i32,
329    pub sessions_terminated: i32,
330    pub grants_added: i32,
331    pub grants_revoked: i32,
332    pub default_privileges_set: i32,
333    pub default_privileges_revoked: i32,
334    pub members_added: i32,
335    pub members_removed: i32,
336    pub passwords_set: i32,
337    pub total: i32,
338}
339
340/// Canonical target identity for conflict detection between policies.
341#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
342pub struct DatabaseIdentity(String);
343
344impl DatabaseIdentity {
345    pub fn new(namespace: &str, secret_name: &str, secret_key: &str) -> Self {
346        Self(format!("{namespace}/{secret_name}/{secret_key}"))
347    }
348
349    pub fn as_str(&self) -> &str {
350        &self.0
351    }
352}
353
354/// Conservative ownership claims for a policy.
355#[derive(Debug, Clone, Default, PartialEq, Eq)]
356pub struct OwnershipClaims {
357    pub roles: BTreeSet<String>,
358    pub schemas: BTreeSet<String>,
359}
360
361impl OwnershipClaims {
362    pub fn overlaps(&self, other: &Self) -> bool {
363        !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
364    }
365
366    pub fn overlap_summary(&self, other: &Self) -> String {
367        let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
368        let overlapping_schemas: Vec<_> =
369            self.schemas.intersection(&other.schemas).cloned().collect();
370
371        let mut parts = Vec::new();
372        if !overlapping_roles.is_empty() {
373            parts.push(format!("roles: {}", overlapping_roles.join(", ")));
374        }
375        if !overlapping_schemas.is_empty() {
376            parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
377        }
378
379        parts.join("; ")
380    }
381}
382
383// ---------------------------------------------------------------------------
384// Conversion: CRD spec → core manifest types
385// ---------------------------------------------------------------------------
386
387impl PostgresPolicySpec {
388    /// Convert the CRD spec into a `PolicyManifest` for use with the core library.
389    pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
390        use pgroles_core::manifest::{
391            DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
392            ProfileObjectTarget, RoleDefinition,
393        };
394
395        let profiles = self
396            .profiles
397            .iter()
398            .map(|(name, spec)| {
399                let profile = Profile {
400                    login: spec.login,
401                    grants: spec
402                        .grants
403                        .iter()
404                        .map(|g| ProfileGrant {
405                            privileges: g.privileges.clone(),
406                            on: ProfileObjectTarget {
407                                object_type: g.on.object_type,
408                                name: g.on.name.clone(),
409                            },
410                        })
411                        .collect(),
412                    default_privileges: spec
413                        .default_privileges
414                        .iter()
415                        .map(|dp| DefaultPrivilegeGrant {
416                            role: dp.role.clone(),
417                            privileges: dp.privileges.clone(),
418                            on_type: dp.on_type,
419                        })
420                        .collect(),
421                };
422                (name.clone(), profile)
423            })
424            .collect();
425
426        let roles = self
427            .roles
428            .iter()
429            .map(|r| RoleDefinition {
430                name: r.name.clone(),
431                login: r.login,
432                superuser: r.superuser,
433                createdb: r.createdb,
434                createrole: r.createrole,
435                inherit: r.inherit,
436                replication: r.replication,
437                bypassrls: r.bypassrls,
438                connection_limit: r.connection_limit,
439                comment: r.comment.clone(),
440                password: None, // K8s passwords are resolved separately via Secret refs
441                password_valid_until: r.password_valid_until.clone(),
442            })
443            .collect();
444
445        // Memberships need MemberSpec conversion — the core type should
446        // already be compatible since we use it directly in the CRD spec.
447        // But we need to ensure the serde aliases work. Let's rebuild to be safe.
448        let memberships = self
449            .memberships
450            .iter()
451            .map(|m| pgroles_core::manifest::Membership {
452                role: m.role.clone(),
453                members: m
454                    .members
455                    .iter()
456                    .map(|ms| MemberSpec {
457                        name: ms.name.clone(),
458                        inherit: ms.inherit,
459                        admin: ms.admin,
460                    })
461                    .collect(),
462            })
463            .collect();
464
465        PolicyManifest {
466            default_owner: self.default_owner.clone(),
467            auth_providers: Vec::new(),
468            profiles,
469            schemas: self.schemas.clone(),
470            roles,
471            grants: self.grants.clone(),
472            default_privileges: self.default_privileges.clone(),
473            memberships,
474            retirements: self.retirements.clone(),
475        }
476    }
477
478    /// Derive a conservative ownership claim set from the policy spec.
479    ///
480    /// This intentionally claims all declared/expanded roles and all referenced
481    /// schemas so overlapping policies are rejected safely.
482    pub fn ownership_claims(
483        &self,
484    ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
485        let manifest = self.to_policy_manifest();
486        let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
487
488        let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
489        let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
490
491        roles.extend(manifest.retirements.into_iter().map(|r| r.role));
492        roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
493        roles.extend(
494            manifest
495                .default_privileges
496                .iter()
497                .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
498        );
499        roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
500        roles.extend(
501            manifest
502                .memberships
503                .iter()
504                .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
505        );
506
507        schemas.extend(
508            manifest
509                .grants
510                .iter()
511                .filter_map(|g| match g.on.object_type {
512                    ObjectType::Database => None,
513                    ObjectType::Schema => g.on.name.clone(),
514                    _ => g.on.schema.clone(),
515                }),
516        );
517        schemas.extend(
518            manifest
519                .default_privileges
520                .iter()
521                .map(|dp| dp.schema.clone()),
522        );
523
524        Ok(OwnershipClaims { roles, schemas })
525    }
526}
527
528// ---------------------------------------------------------------------------
529// Status helpers
530// ---------------------------------------------------------------------------
531
532impl PostgresPolicyStatus {
533    /// Set a condition, replacing any existing condition of the same type.
534    pub fn set_condition(&mut self, condition: PolicyCondition) {
535        if let Some(existing) = self
536            .conditions
537            .iter_mut()
538            .find(|c| c.condition_type == condition.condition_type)
539        {
540            *existing = condition;
541        } else {
542            self.conditions.push(condition);
543        }
544    }
545}
546
547/// Create a timestamp string in ISO 8601 / RFC 3339 format.
548pub fn now_rfc3339() -> String {
549    // Use k8s-openapi's chrono re-export or manual formatting.
550    // For simplicity, use the system time.
551    use std::time::SystemTime;
552    let now = SystemTime::now()
553        .duration_since(SystemTime::UNIX_EPOCH)
554        .unwrap_or_default();
555    // Format as simplified ISO 8601
556    let secs = now.as_secs();
557    let days = secs / 86400;
558    let remaining = secs % 86400;
559    let hours = remaining / 3600;
560    let minutes = (remaining % 3600) / 60;
561    let seconds = remaining % 60;
562
563    // Convert days since epoch to date (simplified — good enough for status)
564    let (year, month, day) = days_to_date(days);
565    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
566}
567
568/// Convert days since Unix epoch to (year, month, day).
569fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
570    // Civil calendar algorithm from Howard Hinnant
571    let z = days_since_epoch + 719468;
572    let era = z / 146097;
573    let doe = z - era * 146097;
574    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
575    let y = yoe + era * 400;
576    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
577    let mp = (5 * doy + 2) / 153;
578    let d = doy - (153 * mp + 2) / 5 + 1;
579    let m = if mp < 10 { mp + 3 } else { mp - 9 };
580    let y = if m <= 2 { y + 1 } else { y };
581    (y, m, d)
582}
583
584/// Helper to create a "Ready" condition.
585pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
586    PolicyCondition {
587        condition_type: "Ready".to_string(),
588        status: if status { "True" } else { "False" }.to_string(),
589        reason: Some(reason.to_string()),
590        message: Some(message.to_string()),
591        last_transition_time: Some(now_rfc3339()),
592    }
593}
594
595/// Helper to create a "Reconciling" condition.
596pub fn reconciling_condition(message: &str) -> PolicyCondition {
597    PolicyCondition {
598        condition_type: "Reconciling".to_string(),
599        status: "True".to_string(),
600        reason: Some("Reconciling".to_string()),
601        message: Some(message.to_string()),
602        last_transition_time: Some(now_rfc3339()),
603    }
604}
605
606/// Helper to create a "Degraded" condition.
607pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
608    PolicyCondition {
609        condition_type: "Degraded".to_string(),
610        status: "True".to_string(),
611        reason: Some(reason.to_string()),
612        message: Some(message.to_string()),
613        last_transition_time: Some(now_rfc3339()),
614    }
615}
616
617/// Helper to create a "Paused" condition.
618pub fn paused_condition(message: &str) -> PolicyCondition {
619    PolicyCondition {
620        condition_type: "Paused".to_string(),
621        status: "True".to_string(),
622        reason: Some("Suspended".to_string()),
623        message: Some(message.to_string()),
624        last_transition_time: Some(now_rfc3339()),
625    }
626}
627
628/// Helper to create a "Conflict" condition.
629pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
630    PolicyCondition {
631        condition_type: "Conflict".to_string(),
632        status: "True".to_string(),
633        reason: Some(reason.to_string()),
634        message: Some(message.to_string()),
635        last_transition_time: Some(now_rfc3339()),
636    }
637}
638
639/// Helper to create a "Drifted" condition.
640pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
641    PolicyCondition {
642        condition_type: "Drifted".to_string(),
643        status: if status { "True" } else { "False" }.to_string(),
644        reason: Some(reason.to_string()),
645        message: Some(message.to_string()),
646        last_transition_time: Some(now_rfc3339()),
647    }
648}
649
650// ---------------------------------------------------------------------------
651// Tests
652// ---------------------------------------------------------------------------
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657    use kube::CustomResourceExt;
658
659    #[test]
660    fn crd_generates_valid_schema() {
661        let crd = PostgresPolicy::crd();
662        let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
663        assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
664        assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
665        assert!(
666            yaml.contains("PostgresPolicy"),
667            "kind should be PostgresPolicy"
668        );
669    }
670
671    #[test]
672    fn spec_to_policy_manifest_roundtrip() {
673        let spec = PostgresPolicySpec {
674            connection: ConnectionSpec {
675                secret_ref: SecretReference {
676                    name: "pg-secret".to_string(),
677                },
678                secret_key: "DATABASE_URL".to_string(),
679            },
680            interval: "5m".to_string(),
681            suspend: false,
682            mode: PolicyMode::Apply,
683            reconciliation_mode: CrdReconciliationMode::default(),
684            default_owner: Some("app_owner".to_string()),
685            profiles: std::collections::HashMap::new(),
686            schemas: vec![],
687            roles: vec![RoleSpec {
688                name: "analytics".to_string(),
689                login: Some(true),
690                superuser: None,
691                createdb: None,
692                createrole: None,
693                inherit: None,
694                replication: None,
695                bypassrls: None,
696                connection_limit: None,
697                comment: Some("test role".to_string()),
698                password: None,
699                password_valid_until: None,
700            }],
701            grants: vec![],
702            default_privileges: vec![],
703            memberships: vec![],
704            retirements: vec![RoleRetirement {
705                role: "legacy-app".to_string(),
706                reassign_owned_to: Some("app_owner".to_string()),
707                drop_owned: true,
708                terminate_sessions: true,
709            }],
710        };
711
712        let manifest = spec.to_policy_manifest();
713        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
714        assert_eq!(manifest.roles.len(), 1);
715        assert_eq!(manifest.roles[0].name, "analytics");
716        assert_eq!(manifest.roles[0].login, Some(true));
717        assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
718        assert_eq!(manifest.retirements.len(), 1);
719        assert_eq!(manifest.retirements[0].role, "legacy-app");
720        assert_eq!(
721            manifest.retirements[0].reassign_owned_to.as_deref(),
722            Some("app_owner")
723        );
724        assert!(manifest.retirements[0].drop_owned);
725        assert!(manifest.retirements[0].terminate_sessions);
726    }
727
728    #[test]
729    fn status_set_condition_replaces_existing() {
730        let mut status = PostgresPolicyStatus::default();
731
732        status.set_condition(ready_condition(false, "Pending", "Initial"));
733        assert_eq!(status.conditions.len(), 1);
734        assert_eq!(status.conditions[0].status, "False");
735
736        status.set_condition(ready_condition(true, "Reconciled", "All good"));
737        assert_eq!(status.conditions.len(), 1);
738        assert_eq!(status.conditions[0].status, "True");
739        assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
740    }
741
742    #[test]
743    fn status_set_condition_adds_new_type() {
744        let mut status = PostgresPolicyStatus::default();
745
746        status.set_condition(ready_condition(true, "OK", "ready"));
747        status.set_condition(degraded_condition("Error", "something broke"));
748
749        assert_eq!(status.conditions.len(), 2);
750    }
751
752    #[test]
753    fn paused_condition_has_expected_shape() {
754        let paused = paused_condition("paused by spec");
755        assert_eq!(paused.condition_type, "Paused");
756        assert_eq!(paused.status, "True");
757        assert_eq!(paused.reason.as_deref(), Some("Suspended"));
758    }
759
760    #[test]
761    fn ownership_claims_include_expanded_roles_and_schemas() {
762        let mut profiles = std::collections::HashMap::new();
763        profiles.insert(
764            "editor".to_string(),
765            ProfileSpec {
766                login: Some(false),
767                grants: vec![],
768                default_privileges: vec![],
769            },
770        );
771
772        let spec = PostgresPolicySpec {
773            connection: ConnectionSpec {
774                secret_ref: SecretReference {
775                    name: "pg-secret".to_string(),
776                },
777                secret_key: "DATABASE_URL".to_string(),
778            },
779            interval: "5m".to_string(),
780            suspend: false,
781            mode: PolicyMode::Apply,
782            reconciliation_mode: CrdReconciliationMode::default(),
783            default_owner: None,
784            profiles,
785            schemas: vec![SchemaBinding {
786                name: "inventory".to_string(),
787                profiles: vec!["editor".to_string()],
788                role_pattern: "{schema}-{profile}".to_string(),
789                owner: None,
790            }],
791            roles: vec![RoleSpec {
792                name: "app-service".to_string(),
793                login: Some(true),
794                superuser: None,
795                createdb: None,
796                createrole: None,
797                inherit: None,
798                replication: None,
799                bypassrls: None,
800                connection_limit: None,
801                comment: None,
802                password: None,
803                password_valid_until: None,
804            }],
805            grants: vec![],
806            default_privileges: vec![],
807            memberships: vec![],
808            retirements: vec![RoleRetirement {
809                role: "legacy-app".to_string(),
810                reassign_owned_to: None,
811                drop_owned: false,
812                terminate_sessions: false,
813            }],
814        };
815
816        let claims = spec.ownership_claims().unwrap();
817        assert!(claims.roles.contains("inventory-editor"));
818        assert!(claims.roles.contains("app-service"));
819        assert!(claims.roles.contains("legacy-app"));
820        assert!(claims.schemas.contains("inventory"));
821    }
822
823    #[test]
824    fn ownership_overlap_summary_reports_roles_and_schemas() {
825        let mut left = OwnershipClaims::default();
826        left.roles.insert("analytics".to_string());
827        left.schemas.insert("reporting".to_string());
828
829        let mut right = OwnershipClaims::default();
830        right.roles.insert("analytics".to_string());
831        right.schemas.insert("reporting".to_string());
832        right.schemas.insert("other".to_string());
833
834        assert!(left.overlaps(&right));
835        let summary = left.overlap_summary(&right);
836        assert!(summary.contains("roles: analytics"));
837        assert!(summary.contains("schemas: reporting"));
838    }
839
840    #[test]
841    fn database_identity_uses_namespace_secret_and_key() {
842        let identity = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
843        assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
844    }
845
846    #[test]
847    fn now_rfc3339_produces_valid_format() {
848        let ts = now_rfc3339();
849        // Should match YYYY-MM-DDTHH:MM:SSZ
850        assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
851        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
852        assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
853        assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
854    }
855
856    #[test]
857    fn ready_condition_true_has_expected_shape() {
858        let cond = ready_condition(true, "Reconciled", "All changes applied");
859        assert_eq!(cond.condition_type, "Ready");
860        assert_eq!(cond.status, "True");
861        assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
862        assert_eq!(cond.message.as_deref(), Some("All changes applied"));
863        assert!(cond.last_transition_time.is_some());
864    }
865
866    #[test]
867    fn ready_condition_false_has_expected_shape() {
868        let cond = ready_condition(false, "InvalidSpec", "bad manifest");
869        assert_eq!(cond.condition_type, "Ready");
870        assert_eq!(cond.status, "False");
871        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
872        assert_eq!(cond.message.as_deref(), Some("bad manifest"));
873    }
874
875    #[test]
876    fn degraded_condition_has_expected_shape() {
877        let cond = degraded_condition("InvalidSpec", "expansion failed");
878        assert_eq!(cond.condition_type, "Degraded");
879        assert_eq!(cond.status, "True");
880        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
881        assert_eq!(cond.message.as_deref(), Some("expansion failed"));
882        assert!(cond.last_transition_time.is_some());
883    }
884
885    #[test]
886    fn reconciling_condition_has_expected_shape() {
887        let cond = reconciling_condition("Reconciliation in progress");
888        assert_eq!(cond.condition_type, "Reconciling");
889        assert_eq!(cond.status, "True");
890        assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
891        assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
892        assert!(cond.last_transition_time.is_some());
893    }
894
895    #[test]
896    fn conflict_condition_has_expected_shape() {
897        let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
898        assert_eq!(cond.condition_type, "Conflict");
899        assert_eq!(cond.status, "True");
900        assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
901        assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
902        assert!(cond.last_transition_time.is_some());
903    }
904
905    #[test]
906    fn ownership_claims_no_overlap() {
907        let mut left = OwnershipClaims::default();
908        left.roles.insert("analytics".to_string());
909        left.schemas.insert("reporting".to_string());
910
911        let mut right = OwnershipClaims::default();
912        right.roles.insert("billing".to_string());
913        right.schemas.insert("payments".to_string());
914
915        assert!(!left.overlaps(&right));
916        let summary = left.overlap_summary(&right);
917        assert!(summary.is_empty());
918    }
919
920    #[test]
921    fn ownership_claims_partial_role_overlap() {
922        let mut left = OwnershipClaims::default();
923        left.roles.insert("analytics".to_string());
924        left.roles.insert("reporting-viewer".to_string());
925
926        let mut right = OwnershipClaims::default();
927        right.roles.insert("analytics".to_string());
928        right.roles.insert("other-role".to_string());
929
930        assert!(left.overlaps(&right));
931        let summary = left.overlap_summary(&right);
932        assert!(summary.contains("roles: analytics"));
933        assert!(!summary.contains("schemas"));
934    }
935
936    #[test]
937    fn ownership_claims_empty_is_disjoint() {
938        let left = OwnershipClaims::default();
939        let right = OwnershipClaims::default();
940        assert!(!left.overlaps(&right));
941    }
942
943    #[test]
944    fn database_identity_equality() {
945        let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
946        let b = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
947        let c = DatabaseIdentity::new("staging", "db-creds", "DATABASE_URL");
948        assert_eq!(a, b);
949        assert_ne!(a, c);
950    }
951
952    #[test]
953    fn database_identity_different_key() {
954        let a = DatabaseIdentity::new("prod", "db-creds", "DATABASE_URL");
955        let b = DatabaseIdentity::new("prod", "db-creds", "CUSTOM_URL");
956        assert_ne!(a, b);
957    }
958
959    #[test]
960    fn status_default_has_empty_conditions() {
961        let status = PostgresPolicyStatus::default();
962        assert!(status.conditions.is_empty());
963        assert!(status.observed_generation.is_none());
964        assert!(status.last_attempted_generation.is_none());
965        assert!(status.last_successful_reconcile_time.is_none());
966        assert!(status.change_summary.is_none());
967        assert!(status.managed_database_identity.is_none());
968        assert!(status.owned_roles.is_empty());
969        assert!(status.owned_schemas.is_empty());
970        assert!(status.last_error.is_none());
971    }
972
973    #[test]
974    fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
975        let mut status = PostgresPolicyStatus::default();
976
977        // Simulate a failed reconciliation: Ready=False + Degraded=True
978        status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
979        status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
980        status
981            .conditions
982            .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
983        status.change_summary = None;
984        status.last_error = Some("bad manifest".to_string());
985
986        // Verify Ready=False
987        let ready = status
988            .conditions
989            .iter()
990            .find(|c| c.condition_type == "Ready")
991            .expect("should have Ready condition");
992        assert_eq!(ready.status, "False");
993        assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
994
995        // Verify Degraded=True
996        let degraded = status
997            .conditions
998            .iter()
999            .find(|c| c.condition_type == "Degraded")
1000            .expect("should have Degraded condition");
1001        assert_eq!(degraded.status, "True");
1002        assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
1003
1004        // Verify last_error is set
1005        assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
1006    }
1007
1008    #[test]
1009    fn status_conflict_workflow() {
1010        let mut status = PostgresPolicyStatus::default();
1011
1012        // Simulate a conflict
1013        let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
1014        status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
1015        status.set_condition(conflict_condition("ConflictingPolicy", msg));
1016        status.set_condition(degraded_condition("ConflictingPolicy", msg));
1017        status
1018            .conditions
1019            .retain(|c| c.condition_type != "Reconciling");
1020        status.last_error = Some(msg.to_string());
1021
1022        // Verify Conflict=True
1023        let conflict = status
1024            .conditions
1025            .iter()
1026            .find(|c| c.condition_type == "Conflict")
1027            .expect("should have Conflict condition");
1028        assert_eq!(conflict.status, "True");
1029        assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
1030
1031        // Verify Ready=False
1032        let ready = status
1033            .conditions
1034            .iter()
1035            .find(|c| c.condition_type == "Ready")
1036            .expect("should have Ready condition");
1037        assert_eq!(ready.status, "False");
1038
1039        // Verify Degraded=True
1040        let degraded = status
1041            .conditions
1042            .iter()
1043            .find(|c| c.condition_type == "Degraded")
1044            .expect("should have Degraded condition");
1045        assert_eq!(degraded.status, "True");
1046    }
1047
1048    #[test]
1049    fn status_successful_reconcile_records_generation_and_time() {
1050        let mut status = PostgresPolicyStatus::default();
1051        let generation = Some(3_i64);
1052        let summary = ChangeSummary {
1053            roles_created: 2,
1054            total: 2,
1055            ..Default::default()
1056        };
1057
1058        // Simulate a successful reconciliation
1059        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1060        status.conditions.retain(|c| {
1061            c.condition_type != "Reconciling"
1062                && c.condition_type != "Degraded"
1063                && c.condition_type != "Conflict"
1064                && c.condition_type != "Paused"
1065        });
1066        status.observed_generation = generation;
1067        status.last_attempted_generation = generation;
1068        status.last_successful_reconcile_time = Some(now_rfc3339());
1069        status.last_reconcile_time = Some(now_rfc3339());
1070        status.change_summary = Some(summary);
1071        status.last_error = None;
1072
1073        // Verify Ready=True
1074        let ready = status
1075            .conditions
1076            .iter()
1077            .find(|c| c.condition_type == "Ready")
1078            .expect("should have Ready condition");
1079        assert_eq!(ready.status, "True");
1080        assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
1081
1082        // Verify generation recorded
1083        assert_eq!(status.observed_generation, Some(3));
1084        assert_eq!(status.last_attempted_generation, Some(3));
1085
1086        // Verify timestamps set
1087        assert!(status.last_successful_reconcile_time.is_some());
1088        assert!(status.last_reconcile_time.is_some());
1089
1090        // Verify summary
1091        let summary = status.change_summary.as_ref().unwrap();
1092        assert_eq!(summary.roles_created, 2);
1093        assert_eq!(summary.total, 2);
1094
1095        // Verify no error
1096        assert!(status.last_error.is_none());
1097
1098        // Verify no Degraded/Conflict/Paused/Reconciling conditions
1099        assert!(
1100            status
1101                .conditions
1102                .iter()
1103                .all(|c| c.condition_type != "Degraded"
1104                    && c.condition_type != "Conflict"
1105                    && c.condition_type != "Paused"
1106                    && c.condition_type != "Reconciling")
1107        );
1108    }
1109
1110    #[test]
1111    fn status_suspended_workflow() {
1112        let mut status = PostgresPolicyStatus::default();
1113        let generation = Some(2_i64);
1114
1115        // Simulate a suspended reconciliation
1116        status.set_condition(paused_condition("Reconciliation suspended by spec"));
1117        status.set_condition(ready_condition(
1118            false,
1119            "Suspended",
1120            "Reconciliation suspended by spec",
1121        ));
1122        status
1123            .conditions
1124            .retain(|c| c.condition_type != "Reconciling");
1125        status.last_attempted_generation = generation;
1126        status.last_error = None;
1127
1128        // Verify Paused=True
1129        let paused = status
1130            .conditions
1131            .iter()
1132            .find(|c| c.condition_type == "Paused")
1133            .expect("should have Paused condition");
1134        assert_eq!(paused.status, "True");
1135
1136        // Verify Ready=False with Suspended reason
1137        let ready = status
1138            .conditions
1139            .iter()
1140            .find(|c| c.condition_type == "Ready")
1141            .expect("should have Ready condition");
1142        assert_eq!(ready.status, "False");
1143        assert_eq!(ready.reason.as_deref(), Some("Suspended"));
1144
1145        // Verify no Reconciling condition
1146        assert!(
1147            !status
1148                .conditions
1149                .iter()
1150                .any(|c| c.condition_type == "Reconciling")
1151        );
1152    }
1153
1154    #[test]
1155    fn status_transitions_from_degraded_to_ready() {
1156        let mut status = PostgresPolicyStatus::default();
1157
1158        // First, set degraded state
1159        status.set_condition(ready_condition(false, "InvalidSpec", "error"));
1160        status.set_condition(degraded_condition("InvalidSpec", "error"));
1161        status.last_error = Some("error".to_string());
1162
1163        assert_eq!(status.conditions.len(), 2);
1164
1165        // Then, resolve to ready
1166        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
1167        status.conditions.retain(|c| {
1168            c.condition_type != "Reconciling"
1169                && c.condition_type != "Degraded"
1170                && c.condition_type != "Conflict"
1171                && c.condition_type != "Paused"
1172        });
1173        status.last_error = None;
1174
1175        // Verify Ready=True
1176        let ready = status
1177            .conditions
1178            .iter()
1179            .find(|c| c.condition_type == "Ready")
1180            .expect("should have Ready condition");
1181        assert_eq!(ready.status, "True");
1182
1183        // Verify Degraded removed
1184        assert!(
1185            !status
1186                .conditions
1187                .iter()
1188                .any(|c| c.condition_type == "Degraded")
1189        );
1190
1191        // Verify only Ready condition remains
1192        assert_eq!(status.conditions.len(), 1);
1193
1194        // Verify error cleared
1195        assert!(status.last_error.is_none());
1196    }
1197
1198    #[test]
1199    fn change_summary_default_is_all_zero() {
1200        let summary = ChangeSummary::default();
1201        assert_eq!(summary.roles_created, 0);
1202        assert_eq!(summary.roles_altered, 0);
1203        assert_eq!(summary.roles_dropped, 0);
1204        assert_eq!(summary.sessions_terminated, 0);
1205        assert_eq!(summary.grants_added, 0);
1206        assert_eq!(summary.grants_revoked, 0);
1207        assert_eq!(summary.default_privileges_set, 0);
1208        assert_eq!(summary.default_privileges_revoked, 0);
1209        assert_eq!(summary.members_added, 0);
1210        assert_eq!(summary.members_removed, 0);
1211        assert_eq!(summary.total, 0);
1212    }
1213
1214    #[test]
1215    fn status_serializes_to_json() {
1216        let mut status = PostgresPolicyStatus::default();
1217        status.set_condition(ready_condition(true, "Reconciled", "done"));
1218        status.observed_generation = Some(5);
1219        status.managed_database_identity = Some("ns/secret/key".to_string());
1220        status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
1221        status.owned_schemas = vec!["public".to_string()];
1222        status.change_summary = Some(ChangeSummary {
1223            roles_created: 1,
1224            total: 1,
1225            ..Default::default()
1226        });
1227
1228        let json = serde_json::to_string(&status).expect("should serialize");
1229        assert!(json.contains("\"Reconciled\""));
1230        assert!(json.contains("\"observed_generation\":5"));
1231        assert!(json.contains("\"role-a\""));
1232        assert!(json.contains("\"ns/secret/key\""));
1233    }
1234
1235    #[test]
1236    fn crd_spec_deserializes_from_yaml() {
1237        let yaml = r#"
1238connection:
1239  secretRef:
1240    name: pg-credentials
1241interval: "10m"
1242default_owner: app_owner
1243profiles:
1244  editor:
1245    grants:
1246      - privileges: [USAGE]
1247        on: { type: schema }
1248      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1249        on: { type: table, name: "*" }
1250    default_privileges:
1251      - privileges: [SELECT, INSERT, UPDATE, DELETE]
1252        on_type: table
1253schemas:
1254  - name: inventory
1255    profiles: [editor]
1256roles:
1257  - name: analytics
1258    login: true
1259grants:
1260  - role: analytics
1261    privileges: [CONNECT]
1262    on: { type: database, name: mydb }
1263memberships:
1264  - role: inventory-editor
1265    members:
1266      - name: analytics
1267retirements:
1268  - role: legacy-app
1269    reassign_owned_to: app_owner
1270    drop_owned: true
1271    terminate_sessions: true
1272"#;
1273        let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
1274        assert_eq!(spec.interval, "10m");
1275        assert_eq!(spec.default_owner, Some("app_owner".to_string()));
1276        assert_eq!(spec.profiles.len(), 1);
1277        assert!(spec.profiles.contains_key("editor"));
1278        assert_eq!(spec.schemas.len(), 1);
1279        assert_eq!(spec.roles.len(), 1);
1280        assert_eq!(spec.grants.len(), 1);
1281        assert_eq!(spec.memberships.len(), 1);
1282        assert_eq!(spec.retirements.len(), 1);
1283        assert_eq!(spec.retirements[0].role, "legacy-app");
1284        assert!(spec.retirements[0].terminate_sessions);
1285    }
1286}