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, KubeSchema};
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use std::collections::{BTreeMap, BTreeSet};
11
12use pgroles_core::manifest::{
13    DefaultPrivilege, Grant, Membership, ObjectType, Privilege, RoleRetirement, SchemaBinding,
14};
15
16/// Valid PostgreSQL SSL modes for connection params.
17pub const VALID_SSL_MODES: &[&str] = &[
18    "disable",
19    "allow",
20    "prefer",
21    "require",
22    "verify-ca",
23    "verify-full",
24];
25
26// ---------------------------------------------------------------------------
27// CRD spec
28// ---------------------------------------------------------------------------
29
30/// Spec for a `PostgresPolicy` custom resource.
31///
32/// Defines the desired state of PostgreSQL roles, grants, default privileges,
33/// and memberships for a single database connection.
34#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
35#[kube(
36    group = "pgroles.io",
37    version = "v1alpha1",
38    kind = "PostgresPolicy",
39    namespaced,
40    status = "PostgresPolicyStatus",
41    shortname = "pgr",
42    category = "pgroles",
43    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type==\"Ready\")].status"}"#,
44    printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.mode"}"#,
45    printcolumn = r#"{"name":"Recon","type":"string","jsonPath":".spec.reconciliation_mode","priority":1}"#,
46    printcolumn = r#"{"name":"Drift","type":"string","jsonPath":".status.conditions[?(@.type==\"Drifted\")].status"}"#,
47    printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.change_summary.total"}"#,
48    printcolumn = r#"{"name":"Last Reconcile","type":"date","jsonPath":".status.last_successful_reconcile_time"}"#,
49    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
50)]
51pub struct PostgresPolicySpec {
52    /// Database connection configuration.
53    pub connection: ConnectionSpec,
54
55    /// Reconciliation interval (e.g. "5m", "1h"). Defaults to "5m".
56    #[serde(default = "default_interval")]
57    pub interval: String,
58
59    /// Suspend reconciliation when true. Defaults to false.
60    #[serde(default)]
61    pub suspend: bool,
62
63    /// Reconciliation mode: `apply` executes SQL, `plan` computes drift only.
64    #[serde(default)]
65    pub mode: PolicyMode,
66
67    /// Convergence strategy: how aggressively to converge the database.
68    ///
69    /// - `authoritative` (default): full convergence — anything not in the
70    ///   manifest is revoked/dropped.
71    /// - `additive`: only grant, never revoke — safe for incremental adoption.
72    /// - `adopt`: manage declared roles fully, but never drop undeclared roles.
73    #[serde(default)]
74    pub reconciliation_mode: CrdReconciliationMode,
75
76    /// Default owner for ALTER DEFAULT PRIVILEGES (e.g. "app_owner").
77    #[serde(default)]
78    pub default_owner: Option<String>,
79
80    /// Reusable privilege profiles.
81    #[serde(default)]
82    pub profiles: std::collections::HashMap<String, ProfileSpec>,
83
84    /// Schema bindings that expand profiles into concrete roles/grants.
85    #[serde(default)]
86    pub schemas: Vec<SchemaBinding>,
87
88    /// One-off role definitions.
89    #[serde(default)]
90    pub roles: Vec<RoleSpec>,
91
92    /// One-off grants.
93    #[serde(default)]
94    pub grants: Vec<Grant>,
95
96    /// One-off default privileges.
97    #[serde(default)]
98    pub default_privileges: Vec<DefaultPrivilege>,
99
100    /// Membership edges.
101    #[serde(default)]
102    pub memberships: Vec<Membership>,
103
104    /// Explicit role-retirement workflows for roles that should be removed.
105    #[serde(default)]
106    pub retirements: Vec<RoleRetirement>,
107
108    /// Approval mode for plans: `auto` or `manual`.
109    /// When `manual`, plans require explicit approval before execution.
110    /// When `auto`, plans are approved and applied immediately.
111    ///
112    /// Omitting this is deprecated. It is still inferred from `mode`
113    /// (`apply` → `auto`, `plan` → `manual`) so existing policies keep working,
114    /// but the policy reports an `ApprovalUnset` condition until the field is
115    /// set, and a future release will reject a policy that omits it.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub approval: Option<ApprovalMode>,
118}
119
120fn default_interval() -> String {
121    "5m".to_string()
122}
123
124/// Policy reconcile mode.
125#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
126#[serde(rename_all = "lowercase")]
127pub enum PolicyMode {
128    #[default]
129    Apply,
130    Plan,
131}
132
133/// Convergence strategy for how aggressively to converge the database.
134#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
135#[serde(rename_all = "lowercase")]
136pub enum CrdReconciliationMode {
137    /// Full convergence — the manifest is the entire truth.
138    #[default]
139    Authoritative,
140    /// Only grant, never revoke — safe for incremental adoption.
141    Additive,
142    /// Manage declared roles fully, but never drop undeclared roles.
143    Adopt,
144}
145
146/// Approval mode for plans generated by this policy.
147///
148/// Set this explicitly. When omitted it is currently inferred from `spec.mode`
149/// (`apply` implies `auto`, `plan` implies `manual`), which leaves a policy's
150/// execution gate invisible on the object. That inference is deprecated: a
151/// policy relying on it reports an `ApprovalUnset` status condition, and a
152/// future release will reject a policy that omits this field.
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
154pub enum ApprovalMode {
155    /// Plans require explicit approval annotation before execution.
156    #[serde(rename = "manual")]
157    Manual,
158    /// Plans are approved and applied automatically.
159    #[serde(rename = "auto")]
160    Auto,
161}
162
163impl PostgresPolicySpec {
164    /// Resolve the effective approval mode, inferring from `mode` when not set.
165    /// `apply` → `Auto` (backward compat), `plan` → `Manual`.
166    pub fn effective_approval(&self) -> ApprovalMode {
167        match &self.approval {
168            Some(mode) => mode.clone(),
169            None => match self.mode {
170                PolicyMode::Apply => ApprovalMode::Auto,
171                PolicyMode::Plan => ApprovalMode::Manual,
172            },
173        }
174    }
175}
176
177// ---------------------------------------------------------------------------
178// Well-known annotations and labels
179// ---------------------------------------------------------------------------
180
181/// Annotation key used to approve a `PostgresPolicyPlan`.
182pub const PLAN_APPROVED_ANNOTATION: &str = "pgroles.io/approved";
183
184/// Annotation key used to reject a `PostgresPolicyPlan`.
185pub const PLAN_REJECTED_ANNOTATION: &str = "pgroles.io/rejected";
186
187/// Annotation key used to request an immediate `PostgresPolicy` reconcile.
188pub const REQUESTED_RECONCILE_ANNOTATION: &str = "reconcile.pgroles.io/requestedAt";
189
190/// Label key for the parent policy name on plan resources.
191pub const LABEL_POLICY: &str = "pgroles.io/policy";
192
193/// Label key for the managed database identity on plan resources.
194pub const LABEL_DATABASE_IDENTITY: &str = "pgroles.io/database-identity";
195
196/// Label key for the plan name on SQL storage resources.
197pub const LABEL_PLAN: &str = "pgroles.io/plan";
198/// Routing hint for requests resolved from one access-policy UID.
199pub const LABEL_ACCESS_POLICY_UID: &str = "pgroles.io/access-policy-uid";
200/// Routing hint for requests resolved against one target-policy UID.
201pub const LABEL_TARGET_POLICY_UID: &str = "pgroles.io/target-policy-uid";
202
203impl From<CrdReconciliationMode> for pgroles_core::diff::ReconciliationMode {
204    fn from(crd: CrdReconciliationMode) -> Self {
205        match crd {
206            CrdReconciliationMode::Authoritative => {
207                pgroles_core::diff::ReconciliationMode::Authoritative
208            }
209            CrdReconciliationMode::Additive => pgroles_core::diff::ReconciliationMode::Additive,
210            CrdReconciliationMode::Adopt => pgroles_core::diff::ReconciliationMode::Adopt,
211        }
212    }
213}
214
215/// Database connection configuration.
216///
217/// Supports two mutually exclusive modes:
218///
219/// **Mode 1 — Single URL** (backward-compatible):
220/// ```yaml
221/// connection:
222///   secretRef: { name: my-secret }
223///   secretKey: DATABASE_URL        # optional, defaults to DATABASE_URL
224/// ```
225///
226/// **Mode 2 — Structured params** (for Zalando/CNPG/PGO secrets):
227/// ```yaml
228/// connection:
229///   params:
230///     host: my-cluster-postgres
231///     port: 5432
232///     dbname: mydb
233///     usernameSecret: { name: zalando-creds, key: username }
234///     passwordSecret: { name: zalando-creds, key: password }
235/// ```
236///
237/// Params mode can also use provider-backed authentication instead of a static
238/// password, for example GKE Workload Identity to Cloud SQL IAM.
239#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
240#[serde(rename_all = "camelCase")]
241pub struct ConnectionSpec {
242    /// Reference to a Kubernetes Secret containing a connection URL.
243    /// Mutually exclusive with `params`.
244    #[serde(default)]
245    pub secret_ref: Option<SecretReference>,
246
247    /// Key within the Secret to read. Defaults to `DATABASE_URL`.
248    /// Only used with `secretRef`.
249    #[serde(default)]
250    pub secret_key: Option<String>,
251
252    /// Structured connection parameters. Each field is either a plain string
253    /// or a reference to a Secret key. Mutually exclusive with `secretRef`.
254    #[serde(default)]
255    pub params: Option<ConnectionParams>,
256}
257
258impl ConnectionSpec {
259    /// Effective secret key for URL mode. Defaults to `DATABASE_URL`.
260    pub fn effective_secret_key(&self) -> &str {
261        self.secret_key.as_deref().unwrap_or("DATABASE_URL")
262    }
263
264    /// Collect all Secret names referenced by this connection spec.
265    pub fn collect_secret_names(&self, names: &mut BTreeSet<String>) {
266        if let Some(ref secret_ref) = self.secret_ref {
267            names.insert(secret_ref.name.clone());
268        }
269        if let Some(ref params) = self.params {
270            for sel in [
271                &params.host_secret,
272                &params.port_secret,
273                &params.dbname_secret,
274                &params.username_secret,
275                &params.password_secret,
276                &params.ssl_mode_secret,
277            ]
278            .into_iter()
279            .flatten()
280            {
281                names.insert(sel.name.clone());
282            }
283        }
284    }
285
286    /// Deterministic identity key for this connection spec.
287    ///
288    /// - URL mode: `{secret_ref.name}/{secret_key}`
289    /// - Params mode: canonical representation of the params
290    ///
291    /// Uses `\0` as field separator since null bytes cannot appear in K8s names
292    /// or secret values, avoiding ambiguity from colons in literal values.
293    /// Deterministic identity key for per-database locking and conflict detection.
294    ///
295    /// Identifies the target database (host + port + dbname) but NOT the
296    /// credentials. Two policies targeting the same database with different
297    /// users should still be considered as targeting the same database for
298    /// locking and overlap checks.
299    pub fn identity_key(&self) -> String {
300        if let Some(ref secret_ref) = self.secret_ref {
301            format!("{}/{}", secret_ref.name, self.effective_secret_key())
302        } else if let Some(ref params) = self.params {
303            let port_part = params
304                .port
305                .as_ref()
306                .map(|p| format!("literal={p}"))
307                .or_else(|| {
308                    params
309                        .port_secret
310                        .as_ref()
311                        .map(|s| format!("secret={}\0{}", s.name, s.key))
312                })
313                .unwrap_or_else(|| "5432".to_string());
314            format!(
315                "params\0{}\0{}\0{}",
316                field_identity_repr(&params.host, &params.host_secret),
317                field_identity_repr(&params.dbname, &params.dbname_secret),
318                port_part,
319            )
320        } else {
321            "invalid-connection".to_string()
322        }
323    }
324
325    /// Cache key for pool lookup. Includes ALL connection params so that any
326    /// configuration change (credentials, sslMode, host, etc.) invalidates
327    /// the cached pool. This is strictly more specific than `identity_key`.
328    pub fn cache_key(&self, namespace: &str) -> String {
329        if let Some(ref params) = self.params {
330            let user_part = field_identity_repr(&params.username, &params.username_secret);
331            let pass_part = field_identity_repr(&params.password, &params.password_secret);
332            let auth_part = params
333                .auth
334                .as_ref()
335                .map(ConnectionAuth::cache_key)
336                .unwrap_or_default();
337            let ssl_part = params
338                .ssl_mode
339                .as_ref()
340                .map(|v| format!("literal={v}"))
341                .or_else(|| {
342                    params
343                        .ssl_mode_secret
344                        .as_ref()
345                        .map(|s| format!("secret={}\0{}", s.name, s.key))
346                })
347                .unwrap_or_default();
348            let role_part = params.set_role.as_deref().unwrap_or("");
349            format!(
350                "{namespace}/{}\0user={user_part}\0pass={pass_part}\0auth={auth_part}\0ssl={ssl_part}\0role={role_part}",
351                self.identity_key()
352            )
353        } else {
354            format!("{namespace}/{}", self.identity_key())
355        }
356    }
357}
358
359/// Deterministic string representation for a literal/secret field pair.
360///
361/// Uses a `literal=` / `secret=` prefix scheme so that a literal value
362/// can never collide with a secret reference representation.
363fn field_identity_repr(literal: &Option<String>, secret: &Option<SecretKeySelector>) -> String {
364    if let Some(value) = literal {
365        format!("literal={value}")
366    } else if let Some(sel) = secret {
367        format!("secret={}\0{}", sel.name, sel.key)
368    } else {
369        String::new()
370    }
371}
372
373/// Default OAuth scope used for Cloud SQL IAM database login tokens.
374pub const DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE: &str =
375    "https://www.googleapis.com/auth/sqlservice.login";
376
377/// Structured connection parameters for building a PostgreSQL connection URL.
378///
379/// Each field supports either a literal value or a reference to a Kubernetes
380/// Secret key. For each parameter, set either the literal field or the
381/// corresponding `*Secret` field — not both.
382///
383/// ```yaml
384/// # Zalando pattern — literals for non-sensitive, secrets for credentials
385/// params:
386///   host: my-cluster-postgres
387///   port: 5432
388///   dbname: mydb
389///   sslMode: require
390///   usernameSecret:
391///     name: pg-creds
392///     key: username
393///   passwordSecret:
394///     name: pg-creds
395///     key: password
396/// ```
397#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
398#[serde(rename_all = "camelCase")]
399pub struct ConnectionParams {
400    /// PostgreSQL host as a literal value.
401    #[serde(default)]
402    pub host: Option<String>,
403    /// PostgreSQL host from a Secret key.
404    #[serde(default)]
405    pub host_secret: Option<SecretKeySelector>,
406
407    /// Port as a literal value. Defaults to 5432 if neither port nor portSecret is set.
408    #[serde(default)]
409    pub port: Option<u16>,
410    /// Port from a Secret key.
411    #[serde(default)]
412    pub port_secret: Option<SecretKeySelector>,
413
414    /// Database name as a literal value.
415    #[serde(default)]
416    pub dbname: Option<String>,
417    /// Database name from a Secret key.
418    #[serde(default)]
419    pub dbname_secret: Option<SecretKeySelector>,
420
421    /// Username as a literal value.
422    #[serde(default)]
423    pub username: Option<String>,
424    /// Username from a Secret key.
425    #[serde(default)]
426    pub username_secret: Option<SecretKeySelector>,
427
428    /// Password as a literal value (not recommended for production).
429    #[serde(default)]
430    pub password: Option<String>,
431    /// Password from a Secret key (recommended).
432    #[serde(default)]
433    pub password_secret: Option<SecretKeySelector>,
434
435    /// Provider-backed authentication for connections that use short-lived
436    /// credentials instead of a static PostgreSQL password.
437    #[serde(default)]
438    pub auth: Option<ConnectionAuth>,
439
440    /// SSL mode as a literal value.
441    #[serde(default)]
442    pub ssl_mode: Option<String>,
443    /// SSL mode from a Secret key.
444    #[serde(default)]
445    pub ssl_mode_secret: Option<SecretKeySelector>,
446
447    /// Run `SET ROLE "<value>"` once on every pooled connection. Useful when
448    /// the operator authenticates as a low-privilege identity (e.g. a Cloud
449    /// SQL IAM user) that has been granted membership in a privileged role
450    /// like `cloudsqlsuperuser` — PostgreSQL does not inherit role
451    /// *attributes* (`CREATEROLE`, `CREATEDB`, …) through `GRANT … TO …`, so
452    /// `SET ROLE` is required for the connection to act with the parent
453    /// role's attributes.
454    ///
455    /// Must be a simple PostgreSQL identifier matching
456    /// `^[A-Za-z_][A-Za-z0-9_$-]*$`. The pattern intentionally excludes `@`
457    /// and `.` — `setRole` is for switching to a privileged *group* role
458    /// (e.g. `cloudsqlsuperuser`), not an IAM-style principal like
459    /// `pgroles-operator@project.iam`, which has no extra attributes to
460    /// inherit via `SET ROLE`.
461    #[serde(default)]
462    #[schemars(regex(pattern = SET_ROLE_PATTERN))]
463    pub set_role: Option<String>,
464}
465
466/// Provider-backed authentication for `connection.params`.
467#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
468#[serde(tag = "type")]
469pub enum ConnectionAuth {
470    /// Fetch a Cloud SQL IAM database login token using the GKE metadata
471    /// server. `username` must be the Cloud SQL PostgreSQL IAM role name
472    /// (for service accounts, the email without `.gserviceaccount.com`).
473    #[serde(rename = "gcp_workload_identity", rename_all = "camelCase")]
474    GcpWorkloadIdentity {
475        /// Target Google service account to impersonate before requesting the
476        /// Cloud SQL login token. Omit to use the pod's bound identity.
477        #[serde(default)]
478        impersonate_service_account: Option<String>,
479        /// OAuth scope requested for the access token.
480        #[serde(default)]
481        scope: Option<String>,
482    },
483}
484
485impl ConnectionAuth {
486    pub fn gcp_scope(&self) -> &str {
487        match self {
488            Self::GcpWorkloadIdentity { scope, .. } => scope
489                .as_deref()
490                .unwrap_or(DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE),
491        }
492    }
493
494    pub fn gcp_impersonate_service_account(&self) -> Option<&str> {
495        match self {
496            Self::GcpWorkloadIdentity {
497                impersonate_service_account,
498                ..
499            } => impersonate_service_account.as_deref(),
500        }
501    }
502
503    fn cache_key(&self) -> String {
504        match self {
505            Self::GcpWorkloadIdentity {
506                impersonate_service_account,
507                scope,
508            } => format!(
509                "gcp_workload_identity\0impersonate={}\0scope={}",
510                impersonate_service_account.as_deref().unwrap_or_default(),
511                scope
512                    .as_deref()
513                    .unwrap_or(DEFAULT_GCP_CLOUD_SQL_LOGIN_SCOPE)
514            ),
515        }
516    }
517}
518
519/// Reference to a specific key within a Kubernetes Secret.
520#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
521pub struct SecretKeySelector {
522    /// Name of the Secret.
523    pub name: String,
524    /// Key within the Secret.
525    pub key: String,
526}
527
528/// Reference to a Kubernetes Secret in the same namespace.
529#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
530pub struct SecretReference {
531    /// Name of the Secret.
532    pub name: String,
533}
534
535/// A reusable privilege profile (CRD-compatible version).
536///
537/// This mirrors `pgroles_core::manifest::Profile` but derives `JsonSchema`.
538#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
539pub struct ProfileSpec {
540    #[serde(default)]
541    pub login: Option<bool>,
542
543    #[serde(default)]
544    pub inherit: Option<bool>,
545
546    #[serde(default)]
547    pub grants: Vec<ProfileGrantSpec>,
548
549    #[serde(default)]
550    pub default_privileges: Vec<DefaultPrivilegeGrantSpec>,
551
552    /// Role-level configuration parameter defaults for generated roles,
553    /// applied via `ALTER ROLE ... SET parameter = value`. Values support the
554    /// `{schema}` and `{profile}` placeholders, substituted per `schema x
555    /// profile` expansion (e.g. `search_path: "{schema}"`).
556    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
557    pub config: std::collections::BTreeMap<String, pgroles_core::manifest::ConfigValue>,
558}
559
560/// Grant template within a profile.
561#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
562pub struct ProfileGrantSpec {
563    pub privileges: Vec<Privilege>,
564    #[serde(alias = "on")]
565    pub object: ProfileObjectTargetSpec,
566}
567
568/// Object target within a profile.
569#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
570pub struct ProfileObjectTargetSpec {
571    #[serde(rename = "type")]
572    pub object_type: ObjectType,
573    #[serde(default)]
574    pub name: Option<String>,
575}
576
577/// Default privilege grant within a profile.
578#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
579pub struct DefaultPrivilegeGrantSpec {
580    #[serde(default)]
581    pub role: Option<String>,
582    pub privileges: Vec<Privilege>,
583    pub on_type: ObjectType,
584}
585
586/// A concrete role definition (CRD-compatible version).
587#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
588pub struct RoleSpec {
589    pub name: String,
590    /// Treat this role as externally managed. The operator may reference it in
591    /// grants, ownership, and memberships, but will not create, alter, drop,
592    /// password-manage, or manage memberships granted from this role.
593    #[serde(default)]
594    pub external: bool,
595    #[serde(default)]
596    pub login: Option<bool>,
597    #[serde(default)]
598    pub superuser: Option<bool>,
599    #[serde(default)]
600    pub createdb: Option<bool>,
601    #[serde(default)]
602    pub createrole: Option<bool>,
603    #[serde(default)]
604    pub inherit: Option<bool>,
605    #[serde(default)]
606    pub replication: Option<bool>,
607    #[serde(default)]
608    pub bypassrls: Option<bool>,
609    #[serde(default)]
610    pub connection_limit: Option<i32>,
611    #[serde(default)]
612    pub comment: Option<String>,
613    /// Password source for this role. Either a reference to an existing Secret
614    /// or a request for the operator to generate one.
615    #[serde(default)]
616    pub password: Option<PasswordSpec>,
617    /// Password expiration timestamp (ISO 8601, e.g. "2025-12-31T00:00:00Z").
618    #[serde(default)]
619    pub password_valid_until: Option<String>,
620    /// Role-level configuration parameter defaults, applied via
621    /// `ALTER ROLE ... SET parameter = value` (e.g. `role: combined`,
622    /// `search_path: app`). Settings present on the role in the database but
623    /// absent here are RESET in authoritative mode.
624    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
625    pub config: std::collections::BTreeMap<String, pgroles_core::manifest::ConfigValue>,
626}
627
628/// Password configuration: either reference an existing Secret or have the
629/// operator generate a password and create a Secret.
630///
631/// Exactly one of `secretRef` or `generate` must be set.
632///
633/// ```yaml
634/// # Read from existing Secret:
635/// password:
636///   secretRef: { name: role-passwords }
637///   secretKey: password-user
638///
639/// # Operator generates and manages a Secret:
640/// password:
641///   generate:
642///     length: 48
643/// ```
644#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
645#[serde(rename_all = "camelCase")]
646pub struct PasswordSpec {
647    /// Reference to an existing Kubernetes Secret containing the password.
648    /// Mutually exclusive with `generate`.
649    #[serde(default)]
650    pub secret_ref: Option<SecretReference>,
651    /// Key within the referenced Secret. Defaults to the role name.
652    /// Only used with `secretRef`.
653    #[serde(default)]
654    pub secret_key: Option<String>,
655    /// Generate a random password and store it in a new Kubernetes Secret.
656    /// Mutually exclusive with `secretRef`.
657    #[serde(default)]
658    pub generate: Option<GeneratePasswordSpec>,
659}
660
661impl PasswordSpec {
662    /// Returns true if this is a reference to an existing Secret.
663    pub fn is_secret_ref(&self) -> bool {
664        self.secret_ref.is_some()
665    }
666
667    /// Returns true if this is a request to generate a password.
668    pub fn is_generate(&self) -> bool {
669        self.generate.is_some()
670    }
671}
672
673/// Configuration for operator-generated passwords.
674#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
675#[serde(rename_all = "camelCase")]
676pub struct GeneratePasswordSpec {
677    /// Password length. Defaults to 32. Minimum 16, maximum 128.
678    #[serde(default)]
679    pub length: Option<u32>,
680    /// Override the generated Secret name. Defaults to `{policy}-pgr-{role}`.
681    #[serde(default)]
682    pub secret_name: Option<String>,
683    /// Key within the generated Secret. Defaults to `password`.
684    #[serde(default)]
685    pub secret_key: Option<String>,
686}
687
688#[derive(Debug, Clone, thiserror::Error)]
689pub enum PasswordValidationError {
690    #[error("role \"{role}\" has a password but login is not enabled")]
691    PasswordWithoutLogin { role: String },
692
693    #[error("role \"{role}\" password must set exactly one of secretRef or generate")]
694    InvalidPasswordMode { role: String },
695
696    #[error("role \"{role}\" password.generate.length must be between {min} and {max}")]
697    InvalidGeneratedLength { role: String, min: u32, max: u32 },
698
699    #[error(
700        "role \"{role}\" password.generate.secretName \"{name}\" is not a valid Kubernetes Secret name"
701    )]
702    InvalidGeneratedSecretName { role: String, name: String },
703
704    #[error("role \"{role}\" password {field} \"{key}\" is not a valid Kubernetes Secret data key")]
705    InvalidSecretKey {
706        role: String,
707        field: &'static str,
708        key: String,
709    },
710
711    #[error(
712        "role \"{role}\" password.generate.secretKey \"{key}\" is reserved for the SCRAM verifier"
713    )]
714    ReservedGeneratedSecretKey { role: String, key: String },
715}
716
717/// Errors from connection spec validation.
718#[derive(Debug, Clone, thiserror::Error)]
719pub enum ConnectionValidationError {
720    #[error("connection: exactly one of secretRef or params must be set, but both were provided")]
721    BothModesSet,
722
723    #[error("connection: exactly one of secretRef or params must be set, but neither was provided")]
724    NeitherModeSet,
725
726    #[error("connection.params.{field}: secret {detail}")]
727    EmptySecretKeyRef { field: String, detail: String },
728
729    #[error(
730        "connection.params.sslMode: \"{value}\" is not valid (expected one of: disable, allow, prefer, require, verify-ca, verify-full)"
731    )]
732    InvalidSslMode { value: String },
733
734    #[error("connection.params.{field}: literal value must not be empty or whitespace-only")]
735    EmptyLiteral { field: String },
736
737    #[error("connection.params: exactly one of {field} or {field}Secret must be set")]
738    NeitherFieldSet { field: String },
739
740    #[error(
741        "connection.params: only one of {field} or {field}Secret may be set, but both were provided"
742    )]
743    BothFieldsSet { field: String },
744
745    #[error("connection.params.auth: {field} must not be empty or whitespace-only")]
746    EmptyAuthField { field: String },
747
748    #[error("connection.params: password/passwordSecret are mutually exclusive with auth")]
749    AuthWithPassword,
750
751    #[error(
752        "connection.params.setRole: \"{value}\" is not a valid PostgreSQL role identifier (must match {pattern})",
753        pattern = SET_ROLE_PATTERN,
754    )]
755    InvalidRoleName { value: String },
756}
757
758/// Regex pattern restricting `connection.params.setRole` values.
759///
760/// Single source of truth: used by the `#[schemars(...)]` attribute on the
761/// field (emitted into the CRD's OpenAPI schema), referenced by the
762/// `InvalidRoleName` error message, and pinned by `is_valid_set_role_identifier`
763/// via unit tests.
764///
765/// Intentionally rejects `@` and `.`, so IAM-email-style identifiers
766/// (e.g. `pgroles-operator@project.iam`) cannot be a `setRole` target.
767/// `SET ROLE` is meant for switching to a privileged *group* role like
768/// `cloudsqlsuperuser`; IAM principals don't carry role attributes worth
769/// switching to.
770pub(crate) const SET_ROLE_PATTERN: &str = "^[A-Za-z_][A-Za-z0-9_$-]*$";
771
772/// Returns true if `s` is a simple PostgreSQL role identifier matching
773/// [`SET_ROLE_PATTERN`].
774///
775/// `SET ROLE` does not accept bind parameters, so any value reaching the
776/// connection-pool hook is interpolated into the SQL string. Restricting
777/// identifiers at admission time is defence in depth on top of the
778/// double-quoting in the `after_connect` callback.
779pub(crate) fn is_valid_set_role_identifier(s: &str) -> bool {
780    let mut bytes = s.bytes();
781    match bytes.next() {
782        Some(b) if b.is_ascii_alphabetic() || b == b'_' => {}
783        _ => return false,
784    }
785    bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || b == b'-')
786}
787
788/// Validate a Kubernetes Secret name per RFC 1123 DNS subdomain rules.
789///
790/// Delegates to [`crate::k8s_names::is_valid_resource_name`] so Secret names
791/// are held to the same rules as every other resource name the operator builds.
792fn is_valid_secret_name(name: &str) -> bool {
793    crate::k8s_names::is_valid_resource_name(name)
794}
795
796fn is_valid_secret_key(key: &str) -> bool {
797    !key.is_empty()
798        && key
799            .bytes()
800            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
801}
802
803// ---------------------------------------------------------------------------
804// CRD status
805// ---------------------------------------------------------------------------
806
807/// Status of a `PostgresPolicy` resource.
808#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
809pub struct PostgresPolicyStatus {
810    /// Standard Kubernetes conditions.
811    #[serde(default)]
812    pub conditions: Vec<PolicyCondition>,
813
814    /// The `.metadata.generation` that was last successfully reconciled.
815    #[serde(default)]
816    pub observed_generation: Option<i64>,
817
818    /// The `.metadata.generation` that was last attempted.
819    #[serde(default)]
820    pub last_attempted_generation: Option<i64>,
821
822    /// ISO 8601 timestamp of the last successful reconciliation.
823    #[serde(default)]
824    pub last_successful_reconcile_time: Option<String>,
825
826    /// Last force-reconcile annotation value handled by the operator.
827    #[serde(default, rename = "lastHandledReconcileAt")]
828    pub last_handled_reconcile_at: Option<String>,
829
830    /// Summary of changes applied in the last reconciliation.
831    #[serde(default)]
832    pub change_summary: Option<ChangeSummary>,
833
834    /// The reconciliation mode used for the last successful reconcile.
835    #[serde(default)]
836    pub last_reconcile_mode: Option<PolicyMode>,
837
838    /// Canonical identity of the managed database target.
839    #[serde(default)]
840    pub managed_database_identity: Option<String>,
841
842    /// Roles claimed by this policy's declared ownership scope.
843    #[serde(default)]
844    pub owned_roles: Vec<String>,
845
846    /// Schemas claimed by this policy's declared ownership scope.
847    #[serde(default)]
848    pub owned_schemas: Vec<String>,
849
850    /// Last reconcile error message, if any.
851    #[serde(default)]
852    pub last_error: Option<String>,
853
854    /// Last applied password source version for each password-managed role.
855    #[serde(default)]
856    pub applied_password_source_versions: BTreeMap<String, String>,
857
858    /// Consecutive transient operational failures used for exponential backoff.
859    #[serde(default)]
860    pub transient_failure_count: i32,
861
862    /// Reference to the current/latest plan for this policy.
863    #[serde(default)]
864    pub current_plan_ref: Option<PlanReference>,
865}
866
867/// A condition on the `PostgresPolicy` resource.
868#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
869pub struct PolicyCondition {
870    /// Type of condition: "Ready", "Reconciling", "Degraded".
871    #[serde(rename = "type")]
872    pub condition_type: String,
873
874    /// Status: "True", "False", or "Unknown".
875    pub status: String,
876
877    /// Human-readable reason for the condition.
878    #[serde(default)]
879    pub reason: Option<String>,
880
881    /// Human-readable message.
882    #[serde(default)]
883    pub message: Option<String>,
884
885    /// Last time the condition transitioned.
886    #[serde(default)]
887    pub last_transition_time: Option<String>,
888}
889
890/// Reference to a `PostgresPolicyPlan` resource.
891#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
892pub struct PlanReference {
893    pub name: String,
894}
895
896/// Summary of changes applied during reconciliation.
897#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
898#[serde(default)]
899pub struct ChangeSummary {
900    #[serde(default)]
901    pub roles_created: i32,
902    #[serde(default)]
903    pub roles_altered: i32,
904    #[serde(default)]
905    pub schemas_created: i32,
906    #[serde(default)]
907    pub schema_owners_altered: i32,
908    #[serde(default)]
909    pub roles_dropped: i32,
910    #[serde(default)]
911    pub sessions_terminated: i32,
912    #[serde(default)]
913    pub grants_added: i32,
914    #[serde(default)]
915    pub grants_revoked: i32,
916    #[serde(default)]
917    pub default_privileges_set: i32,
918    #[serde(default)]
919    pub default_privileges_revoked: i32,
920    #[serde(default)]
921    pub members_added: i32,
922    #[serde(default)]
923    pub members_removed: i32,
924    #[serde(default)]
925    pub passwords_set: i32,
926    #[serde(default)]
927    pub total: i32,
928}
929
930// ---------------------------------------------------------------------------
931// PostgresPolicyPlan CRD
932// ---------------------------------------------------------------------------
933
934/// Spec for a `PostgresPolicyPlan` custom resource.
935///
936/// Represents a computed reconciliation plan for a `PostgresPolicy`. Plans are
937/// created by the operator and may require explicit approval before execution.
938#[derive(CustomResource, Debug, Clone, Serialize, Deserialize, JsonSchema)]
939#[kube(
940    group = "pgroles.io",
941    version = "v1alpha1",
942    kind = "PostgresPolicyPlan",
943    namespaced,
944    status = "PostgresPolicyPlanStatus",
945    shortname = "pgplan",
946    category = "pgroles",
947    printcolumn = r#"{"name":"Policy","type":"string","jsonPath":".spec.policyRef.name"}"#,
948    printcolumn = r#"{"name":"Mode","type":"string","jsonPath":".spec.reconciliationMode"}"#,
949    printcolumn = r#"{"name":"Approved","type":"string","jsonPath":".status.conditions[?(@.type==\"Approved\")].status"}"#,
950    printcolumn = r#"{"name":"Changes","type":"integer","jsonPath":".status.changeSummary.total"}"#,
951    printcolumn = r#"{"name":"SQL Stmts","type":"integer","jsonPath":".status.sqlStatements","priority":1}"#,
952    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
953    printcolumn = r#"{"name":"SQL","type":"string","jsonPath":".status.sqlRef.name","priority":1}"#,
954    printcolumn = r#"{"name":"Hash","type":"string","jsonPath":".status.sqlHash","priority":1}"#,
955    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
956)]
957#[serde(rename_all = "camelCase")]
958pub struct PostgresPolicyPlanSpec {
959    /// Reference to the policy that generated this plan.
960    pub policy_ref: PolicyPlanRef,
961    /// The policy's `.metadata.generation` at plan time.
962    pub policy_generation: i64,
963    /// Reconciliation mode used for this plan.
964    pub reconciliation_mode: CrdReconciliationMode,
965    /// Roles that this plan covers.
966    #[serde(default)]
967    pub owned_roles: Vec<String>,
968    /// Schemas that this plan covers.
969    #[serde(default)]
970    pub owned_schemas: Vec<String>,
971    /// Database identity string for disambiguation in multi-db setups.
972    pub managed_database_identity: String,
973    /// Origin of this plan. Omitted for ordinary durable reconciliation plans.
974    #[serde(default, skip_serializing_if = "Option::is_none")]
975    pub origin: Option<PlanOrigin>,
976    /// Narrow execution scope for a non-durable plan.
977    #[serde(default, skip_serializing_if = "Option::is_none")]
978    pub scope: Option<PlanScope>,
979}
980
981/// Immutable identity of the resource that caused a scoped plan.
982#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
983#[serde(rename_all = "camelCase")]
984pub struct PlanOrigin {
985    pub kind: String,
986    pub name: String,
987    pub uid: String,
988}
989
990/// Scope enforced when executing a non-durable plan.
991#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
992#[serde(rename_all = "camelCase")]
993pub struct PlanScope {
994    pub kind: String,
995    pub operation: ScopedPlanOperation,
996    pub bundle_hash: String,
997}
998
999#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1000pub enum ScopedPlanOperation {
1001    Activate,
1002    Revoke,
1003}
1004
1005/// Reference to the parent `PostgresPolicy` that generated a plan.
1006#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1007pub struct PolicyPlanRef {
1008    pub name: String,
1009}
1010
1011/// Status of a `PostgresPolicyPlan` resource.
1012#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
1013#[serde(rename_all = "camelCase")]
1014pub struct PostgresPolicyPlanStatus {
1015    /// Phase: Pending, Approved, Applying, Applied, Failed, Superseded.
1016    #[serde(default)]
1017    pub phase: PlanPhase,
1018    /// Standard conditions: Computed, Approved, Applied.
1019    #[serde(default)]
1020    pub conditions: Vec<PolicyCondition>,
1021    /// Summary of changes in this plan.
1022    #[serde(default)]
1023    pub change_summary: Option<ChangeSummary>,
1024    /// Reference to ConfigMap containing the full SQL (for large plans).
1025    #[serde(default)]
1026    pub sql_ref: Option<SqlRef>,
1027    /// Inline SQL for small plans (below a size threshold).
1028    #[serde(default)]
1029    pub sql_inline: Option<String>,
1030    /// True when the SQL preview was truncated because the full redacted SQL
1031    /// could not be persisted within Kubernetes object limits.
1032    #[serde(default)]
1033    pub sql_truncated: bool,
1034    /// Timestamp when the plan was computed.
1035    #[serde(default)]
1036    pub computed_at: Option<String>,
1037    /// Timestamp when the plan was applied (if applicable).
1038    #[serde(default)]
1039    pub applied_at: Option<String>,
1040    /// Error message if apply failed.
1041    #[serde(default)]
1042    pub last_error: Option<String>,
1043    /// SHA-256 hash of the planned SQL, used to detect duplicate plans.
1044    /// If a newly computed plan has the same hash as the current pending plan,
1045    /// the operator can skip creating a redundant plan.
1046    #[serde(default)]
1047    pub sql_hash: Option<String>,
1048    /// Timestamp when the plan entered Applying phase (for stuck detection).
1049    #[serde(default)]
1050    pub applying_since: Option<String>,
1051    /// Timestamp when the plan entered Failed phase (for dedup window).
1052    #[serde(default)]
1053    pub failed_at: Option<String>,
1054    /// Number of SQL statements in the plan (after wildcard expansion).
1055    /// May be significantly larger than `changeSummary.total` when wildcard
1056    /// grants expand to many per-object statements.
1057    #[serde(default)]
1058    pub sql_statements: Option<i64>,
1059    /// SHA-256 hash of the redacted SQL preview bytes. This is for storage
1060    /// integrity only; approval and deduplication continue to use `sql_hash`.
1061    #[serde(default)]
1062    pub redacted_sql_hash: Option<String>,
1063    /// Uncompressed byte length of the redacted SQL preview.
1064    #[serde(default)]
1065    pub sql_original_bytes: Option<i64>,
1066    /// Stored byte length of the SQL preview after inline/truncation/compression.
1067    #[serde(default)]
1068    pub sql_stored_bytes: Option<i64>,
1069}
1070
1071/// Reference to a ConfigMap containing SQL for a plan.
1072#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1073pub struct SqlRef {
1074    pub name: String,
1075    pub key: String,
1076    /// Compression used for the referenced SQL content. Missing means older
1077    /// uncompressed ConfigMap data.
1078    #[serde(default, skip_serializing_if = "Option::is_none")]
1079    pub compression: Option<SqlCompression>,
1080}
1081
1082/// Compression format used for persisted plan SQL previews.
1083#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1084#[serde(rename_all = "lowercase")]
1085pub enum SqlCompression {
1086    Gzip,
1087}
1088
1089/// Phase of a `PostgresPolicyPlan`.
1090#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
1091pub enum PlanPhase {
1092    #[default]
1093    Pending,
1094    Approved,
1095    Applying,
1096    Applied,
1097    Failed,
1098    Superseded,
1099    Rejected,
1100}
1101
1102impl std::fmt::Display for PlanPhase {
1103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1104        match self {
1105            PlanPhase::Pending => write!(f, "Pending"),
1106            PlanPhase::Approved => write!(f, "Approved"),
1107            PlanPhase::Applying => write!(f, "Applying"),
1108            PlanPhase::Applied => write!(f, "Applied"),
1109            PlanPhase::Failed => write!(f, "Failed"),
1110            PlanPhase::Superseded => write!(f, "Superseded"),
1111            PlanPhase::Rejected => write!(f, "Rejected"),
1112        }
1113    }
1114}
1115
1116// ---------------------------------------------------------------------------
1117// Ephemeral access CRDs
1118// ---------------------------------------------------------------------------
1119
1120pub const EPHEMERAL_BUNDLE_ENCODING_V1: &str = "pgroles.io/ephemeral-membership-bundle-v1";
1121pub const EPHEMERAL_MEMBERSHIP_SEMANTICS_V1: &str =
1122    "postgres-membership-v1-admin-false-set-server-default";
1123/// A GitOps-managed bundle of PostgreSQL memberships that may be requested.
1124#[derive(CustomResource, KubeSchema, Debug, Clone, Serialize, Deserialize)]
1125#[kube(
1126    group = "pgroles.io",
1127    version = "v1alpha1",
1128    kind = "EphemeralAccessPolicy",
1129    namespaced,
1130    status = "EphemeralAccessPolicyStatus",
1131    shortname = "pgeap",
1132    category = "pgroles",
1133    printcolumn = r#"{"name":"Target","type":"string","jsonPath":".spec.postgresPolicyRef.name"}"#,
1134    printcolumn = r#"{"name":"Accepted","type":"string","jsonPath":".status.conditions[?(@.type==\"Accepted\")].status"}"#,
1135    printcolumn = r#"{"name":"Suspended","type":"boolean","jsonPath":".spec.suspend"}"#,
1136    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
1137)]
1138#[serde(rename_all = "camelCase")]
1139pub struct EphemeralAccessPolicySpec {
1140    pub postgres_policy_ref: LocalObjectReference,
1141    #[schemars(length(min = 1, max = 32))]
1142    pub memberships: Vec<EphemeralMembership>,
1143    #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1144    pub maximum_duration: String,
1145    #[serde(default, skip_serializing_if = "Option::is_none")]
1146    #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1147    pub default_duration: Option<String>,
1148    #[serde(rename = "pendingRequestTTL", default = "default_pending_request_ttl")]
1149    #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1150    pub pending_request_ttl: String,
1151    pub justification: EphemeralJustificationPolicy,
1152    pub approval: EphemeralApprovalPolicy,
1153    #[serde(default)]
1154    pub suspend: bool,
1155    #[serde(default, skip_serializing_if = "Option::is_none")]
1156    #[schemars(length(max = 128))]
1157    pub display_name: Option<String>,
1158    #[serde(default, skip_serializing_if = "Option::is_none")]
1159    #[schemars(length(max = 2048))]
1160    pub description: Option<String>,
1161}
1162
1163fn default_pending_request_ttl() -> String {
1164    "15m".to_string()
1165}
1166
1167#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1168pub struct LocalObjectReference {
1169    #[schemars(length(min = 1, max = 253))]
1170    pub name: String,
1171}
1172
1173#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1174pub struct EphemeralMembership {
1175    #[schemars(length(min = 1, max = 63))]
1176    pub role: String,
1177    pub inherit: bool,
1178}
1179
1180#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize)]
1181pub struct EphemeralJustificationPolicy {
1182    pub required: bool,
1183}
1184
1185#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize)]
1186pub struct EphemeralApprovalPolicy {
1187    pub mode: EphemeralApprovalMode,
1188}
1189
1190#[derive(JsonSchema, Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1191pub enum EphemeralApprovalMode {
1192    Automatic,
1193    Required,
1194}
1195
1196#[derive(KubeSchema, Debug, Clone, Default, Serialize, Deserialize)]
1197#[serde(rename_all = "camelCase")]
1198pub struct EphemeralAccessPolicyStatus {
1199    #[serde(default)]
1200    pub observed_generation: Option<i64>,
1201    #[serde(default)]
1202    #[schemars(length(max = 16))]
1203    pub conditions: Vec<EphemeralAccessCondition>,
1204    #[serde(default)]
1205    #[schemars(length(max = 32), inner(length(min = 1, max = 63)))]
1206    pub resolved_roles: Vec<String>,
1207}
1208
1209/// One immutable runtime request for a bounded access bundle.
1210#[derive(CustomResource, KubeSchema, Debug, Clone, Serialize, Deserialize)]
1211#[kube(
1212    group = "pgroles.io",
1213    version = "v1alpha1",
1214    kind = "EphemeralAccessRequest",
1215    namespaced,
1216    status = "EphemeralAccessRequestStatus",
1217    shortname = "pgear",
1218    category = "pgroles",
1219    printcolumn = r#"{"name":"Access Policy","type":"string","jsonPath":".spec.accessPolicyRef.name"}"#,
1220    printcolumn = r#"{"name":"Subject","type":"string","jsonPath":".spec.subject.role"}"#,
1221    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
1222    printcolumn = r#"{"name":"Expires","type":"date","jsonPath":".status.expiresAt"}"#,
1223    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
1224)]
1225#[x_kube(
1226    validation = Rule::new("self == oldSelf").message("request spec is immutable")
1227)]
1228#[serde(rename_all = "camelCase")]
1229pub struct EphemeralAccessRequestSpec {
1230    pub access_policy_ref: LocalObjectReference,
1231    pub subject: EphemeralAccessSubject,
1232    /// Kubernetes identity which created the request. The supplied Kyverno
1233    /// reference policy overwrites this from authenticated admission `userInfo`.
1234    pub requested_by: EphemeralAccessActor,
1235    #[serde(default, skip_serializing_if = "Option::is_none")]
1236    #[schemars(length(max = 64), regex(pattern = r"^([0-9]+[smh])+$"))]
1237    pub requested_duration: Option<String>,
1238    #[serde(default, skip_serializing_if = "Option::is_none")]
1239    #[schemars(length(max = 2048))]
1240    pub justification: Option<String>,
1241}
1242
1243#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize)]
1244pub struct EphemeralAccessSubject {
1245    #[schemars(length(min = 1, max = 63))]
1246    pub role: String,
1247}
1248
1249/// Authenticated Kubernetes identity associated with an access action.
1250///
1251/// The supplied Kyverno reference policy replaces these values from the
1252/// admission request's `userInfo`. Without admission enforcement they are
1253/// assertions by the trusted request or approval broker.
1254#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1255#[serde(rename_all = "camelCase")]
1256pub struct EphemeralAccessActor {
1257    #[schemars(length(min = 1, max = 512))]
1258    pub username: String,
1259    #[serde(default, skip_serializing_if = "Option::is_none")]
1260    #[schemars(length(max = 128))]
1261    pub uid: Option<String>,
1262    #[serde(default)]
1263    #[schemars(length(max = 64), inner(length(min = 1, max = 256)))]
1264    pub groups: Vec<String>,
1265}
1266
1267#[derive(KubeSchema, Debug, Clone, Default, Serialize, Deserialize)]
1268#[x_kube(
1269    validation = Rule::new(
1270        "!has(oldSelf.resolvedAccess) || (has(self.resolvedAccess) && self.resolvedAccess == oldSelf.resolvedAccess)"
1271    ).message("resolvedAccess is write-once"),
1272    validation = Rule::new(
1273        "!(self.conditions.exists(c, c.type == 'Approved' && c.status == 'True') && self.conditions.exists(c, c.type == 'Denied' && c.status == 'True'))"
1274    ).message("Approved=True and Denied=True are mutually exclusive"),
1275    validation = Rule::new(
1276        "oldSelf.conditions.filter(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True').size() == 0 || self.conditions.filter(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True') == oldSelf.conditions.filter(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True')"
1277    ).message("approval decisions are terminal"),
1278    validation = Rule::new(
1279        "!has(oldSelf.decidedBy) || (has(self.decidedBy) && self.decidedBy == oldSelf.decidedBy)"
1280    ).message("decision identity is write-once"),
1281    validation = Rule::new(
1282        "self.conditions.exists(c, (c.type == 'Approved' || c.type == 'Denied') && c.status == 'True') == has(self.decidedBy)"
1283    ).message("a terminal approval decision and decidedBy identity must be recorded together"),
1284    validation = Rule::new(
1285        "self.conditions.all(c, c.type in ['Approved', 'Denied', 'Resolved', 'Ready', 'Applied'])"
1286    ).message("request conditions must use a declared lifecycle or decision type")
1287)]
1288#[serde(rename_all = "camelCase")]
1289pub struct EphemeralAccessRequestStatus {
1290    #[serde(default)]
1291    pub phase: EphemeralAccessRequestPhase,
1292    #[serde(default)]
1293    #[schemars(length(max = 8))]
1294    pub conditions: Vec<EphemeralAccessCondition>,
1295    #[serde(default, skip_serializing_if = "Option::is_none")]
1296    pub resolved_access: Option<ResolvedEphemeralAccess>,
1297    /// Kubernetes identity which approved or denied the request. The supplied
1298    /// Kyverno reference policy overwrites this from authenticated admission
1299    /// `userInfo` in the same status update as the terminal decision.
1300    #[serde(default, skip_serializing_if = "Option::is_none")]
1301    pub decided_by: Option<EphemeralAccessActor>,
1302    #[serde(default, skip_serializing_if = "Option::is_none")]
1303    #[schemars(length(max = 64))]
1304    pub approval_expires_at: Option<String>,
1305    #[serde(default, skip_serializing_if = "Option::is_none")]
1306    #[schemars(length(max = 64))]
1307    pub activated_at: Option<String>,
1308    #[serde(default, skip_serializing_if = "Option::is_none")]
1309    #[schemars(length(max = 64))]
1310    pub expires_at: Option<String>,
1311    #[serde(default, skip_serializing_if = "Option::is_none")]
1312    #[schemars(length(max = 64))]
1313    pub ended_at: Option<String>,
1314    #[serde(default, skip_serializing_if = "Option::is_none")]
1315    #[schemars(length(max = 4096))]
1316    pub last_error: Option<String>,
1317    #[serde(default)]
1318    #[schemars(length(max = 32))]
1319    pub retained_memberships: Vec<ResolvedEphemeralMembership>,
1320}
1321
1322#[derive(JsonSchema, Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
1323pub enum EphemeralAccessRequestPhase {
1324    #[default]
1325    Pending,
1326    PendingApproval,
1327    Applying,
1328    Active,
1329    Revoking,
1330    Ended,
1331    Revoked,
1332    Cancelled,
1333    Denied,
1334    ApprovalExpired,
1335    Failed,
1336}
1337
1338impl std::fmt::Display for EphemeralAccessRequestPhase {
1339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1340        write!(f, "{self:?}")
1341    }
1342}
1343
1344#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1345#[serde(rename_all = "camelCase")]
1346pub struct EphemeralAccessCondition {
1347    #[serde(rename = "type")]
1348    #[schemars(length(min = 1, max = 32))]
1349    pub condition_type: String,
1350    #[schemars(length(min = 1, max = 16))]
1351    pub status: String,
1352    #[serde(default, skip_serializing_if = "Option::is_none")]
1353    #[schemars(length(max = 128))]
1354    pub reason: Option<String>,
1355    #[serde(default, skip_serializing_if = "Option::is_none")]
1356    #[schemars(length(max = 2048))]
1357    pub message: Option<String>,
1358    #[serde(default, skip_serializing_if = "Option::is_none")]
1359    #[schemars(length(max = 64))]
1360    pub last_transition_time: Option<String>,
1361    #[serde(default, skip_serializing_if = "Option::is_none")]
1362    #[schemars(length(max = 71))]
1363    pub bundle_hash: Option<String>,
1364    #[serde(default, skip_serializing_if = "Option::is_none")]
1365    #[schemars(length(max = 64))]
1366    pub granted_duration: Option<String>,
1367}
1368
1369#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1370#[serde(rename_all = "camelCase")]
1371pub struct ResolvedEphemeralAccess {
1372    #[schemars(length(max = 128))]
1373    pub access_policy_uid: String,
1374    pub access_policy_generation: i64,
1375    #[schemars(length(max = 128))]
1376    pub target_policy_uid: String,
1377    pub target_policy_generation: i64,
1378    /// SHA-256 fingerprint of resolved host, port, and database name. It binds
1379    /// activation and revocation to one database without persisting secrets.
1380    #[schemars(length(max = 71))]
1381    pub target_database_fingerprint: String,
1382    #[schemars(length(max = 64))]
1383    pub granted_duration: String,
1384    #[schemars(length(max = 128))]
1385    pub bundle_encoding: String,
1386    #[schemars(length(max = 71))]
1387    pub bundle_hash: String,
1388    #[schemars(length(max = 32))]
1389    pub memberships: Vec<ResolvedEphemeralMembership>,
1390}
1391
1392#[derive(KubeSchema, Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
1393#[serde(rename_all = "camelCase")]
1394pub struct ResolvedEphemeralMembership {
1395    #[schemars(length(min = 1, max = 63))]
1396    pub role: String,
1397    #[schemars(length(min = 1, max = 63))]
1398    pub member: String,
1399    pub inherit: bool,
1400}
1401
1402#[derive(Serialize)]
1403#[serde(rename_all = "camelCase")]
1404struct CanonicalEphemeralBundle<'a> {
1405    bundle_encoding: &'a str,
1406    membership_semantics: &'a str,
1407    target_database_fingerprint: &'a str,
1408    memberships: &'a [ResolvedEphemeralMembership],
1409}
1410
1411impl ResolvedEphemeralAccess {
1412    pub fn canonical_bundle_bytes(&self) -> Vec<u8> {
1413        let mut memberships = self.memberships.clone();
1414        memberships.sort();
1415        serde_json::to_vec(&CanonicalEphemeralBundle {
1416            bundle_encoding: EPHEMERAL_BUNDLE_ENCODING_V1,
1417            membership_semantics: EPHEMERAL_MEMBERSHIP_SEMANTICS_V1,
1418            target_database_fingerprint: &self.target_database_fingerprint,
1419            memberships: &memberships,
1420        })
1421        .expect("canonical ephemeral bundle is serializable")
1422    }
1423
1424    pub fn compute_bundle_hash(&self) -> String {
1425        use sha2::{Digest, Sha256};
1426        use std::fmt::Write;
1427
1428        let digest = Sha256::digest(self.canonical_bundle_bytes());
1429        let mut hash = String::with_capacity(7 + digest.len() * 2);
1430        hash.push_str("sha256:");
1431        for byte in digest {
1432            write!(&mut hash, "{byte:02x}").expect("writing to a String cannot fail");
1433        }
1434        hash
1435    }
1436
1437    pub fn has_valid_bundle_hash(&self) -> bool {
1438        self.bundle_encoding == EPHEMERAL_BUNDLE_ENCODING_V1
1439            && self.bundle_hash == self.compute_bundle_hash()
1440    }
1441}
1442
1443// ---------------------------------------------------------------------------
1444// Conflict detection
1445// ---------------------------------------------------------------------------
1446
1447/// Canonical target identity for conflict detection between policies.
1448#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
1449pub struct DatabaseIdentity(String);
1450
1451impl DatabaseIdentity {
1452    /// Create a database identity from the namespace and connection spec's identity key.
1453    pub fn from_connection(namespace: &str, connection: &ConnectionSpec) -> Self {
1454        Self(format!("{namespace}/{}", connection.identity_key()))
1455    }
1456
1457    pub fn as_str(&self) -> &str {
1458        &self.0
1459    }
1460}
1461
1462/// Conservative ownership claims for a policy.
1463#[derive(Debug, Clone, Default, PartialEq, Eq)]
1464pub struct OwnershipClaims {
1465    pub roles: BTreeSet<String>,
1466    pub schemas: BTreeSet<String>,
1467}
1468
1469impl OwnershipClaims {
1470    pub fn overlaps(&self, other: &Self) -> bool {
1471        !self.roles.is_disjoint(&other.roles) || !self.schemas.is_disjoint(&other.schemas)
1472    }
1473
1474    pub fn overlap_summary(&self, other: &Self) -> String {
1475        let overlapping_roles: Vec<_> = self.roles.intersection(&other.roles).cloned().collect();
1476        let overlapping_schemas: Vec<_> =
1477            self.schemas.intersection(&other.schemas).cloned().collect();
1478
1479        let mut parts = Vec::new();
1480        if !overlapping_roles.is_empty() {
1481            parts.push(format!("roles: {}", overlapping_roles.join(", ")));
1482        }
1483        if !overlapping_schemas.is_empty() {
1484            parts.push(format!("schemas: {}", overlapping_schemas.join(", ")));
1485        }
1486
1487        parts.join("; ")
1488    }
1489}
1490
1491// ---------------------------------------------------------------------------
1492// Secret name helpers
1493// ---------------------------------------------------------------------------
1494
1495impl PostgresPolicySpec {
1496    pub fn validate_password_specs(
1497        &self,
1498        policy_name: &str,
1499    ) -> Result<(), PasswordValidationError> {
1500        for role in &self.roles {
1501            let Some(password) = &role.password else {
1502                continue;
1503            };
1504
1505            if role.login != Some(true) {
1506                return Err(PasswordValidationError::PasswordWithoutLogin {
1507                    role: role.name.clone(),
1508                });
1509            }
1510
1511            match (&password.secret_ref, &password.generate) {
1512                (Some(_), None) => {
1513                    let secret_key = password.secret_key.as_deref().unwrap_or(&role.name);
1514                    if !is_valid_secret_key(secret_key) {
1515                        return Err(PasswordValidationError::InvalidSecretKey {
1516                            role: role.name.clone(),
1517                            field: "secretKey",
1518                            key: secret_key.to_string(),
1519                        });
1520                    }
1521                }
1522                (None, Some(generate)) => {
1523                    if let Some(length) = generate.length
1524                        && !(crate::password::MIN_PASSWORD_LENGTH
1525                            ..=crate::password::MAX_PASSWORD_LENGTH)
1526                            .contains(&length)
1527                    {
1528                        return Err(PasswordValidationError::InvalidGeneratedLength {
1529                            role: role.name.clone(),
1530                            min: crate::password::MIN_PASSWORD_LENGTH,
1531                            max: crate::password::MAX_PASSWORD_LENGTH,
1532                        });
1533                    }
1534
1535                    let secret_name =
1536                        crate::password::generated_secret_name(policy_name, &role.name, generate);
1537                    if !is_valid_secret_name(&secret_name) {
1538                        return Err(PasswordValidationError::InvalidGeneratedSecretName {
1539                            role: role.name.clone(),
1540                            name: secret_name,
1541                        });
1542                    }
1543
1544                    let secret_key = crate::password::generated_secret_key(generate);
1545                    if !is_valid_secret_key(&secret_key) {
1546                        return Err(PasswordValidationError::InvalidSecretKey {
1547                            role: role.name.clone(),
1548                            field: "generate.secretKey",
1549                            key: secret_key,
1550                        });
1551                    }
1552                    if secret_key == crate::password::GENERATED_VERIFIER_KEY {
1553                        return Err(PasswordValidationError::ReservedGeneratedSecretKey {
1554                            role: role.name.clone(),
1555                            key: secret_key,
1556                        });
1557                    }
1558                }
1559                _ => {
1560                    return Err(PasswordValidationError::InvalidPasswordMode {
1561                        role: role.name.clone(),
1562                    });
1563                }
1564            }
1565        }
1566
1567        Ok(())
1568    }
1569
1570    /// Validate the connection spec.
1571    ///
1572    /// Ensures exactly one of `secretRef` or `params` is set, and that params
1573    /// mode has all required fields with valid values.
1574    pub fn validate_connection_spec(&self) -> Result<(), ConnectionValidationError> {
1575        let conn = &self.connection;
1576        match (&conn.secret_ref, &conn.params) {
1577            (Some(_), None) => {
1578                // URL mode — valid.
1579                Ok(())
1580            }
1581            (None, Some(params)) => {
1582                // Validate a required field pair: exactly one must be set.
1583                fn validate_required_field(
1584                    field: &str,
1585                    literal: &Option<String>,
1586                    secret: &Option<SecretKeySelector>,
1587                ) -> Result<(), ConnectionValidationError> {
1588                    match (literal, secret) {
1589                        (Some(_), Some(_)) => {
1590                            return Err(ConnectionValidationError::BothFieldsSet {
1591                                field: field.to_string(),
1592                            });
1593                        }
1594                        (None, None) => {
1595                            return Err(ConnectionValidationError::NeitherFieldSet {
1596                                field: field.to_string(),
1597                            });
1598                        }
1599                        (Some(s), None) => {
1600                            if s.trim().is_empty() {
1601                                return Err(ConnectionValidationError::EmptyLiteral {
1602                                    field: field.to_string(),
1603                                });
1604                            }
1605                        }
1606                        (None, Some(sel)) => {
1607                            validate_secret_selector(field, sel)?;
1608                        }
1609                    }
1610                    Ok(())
1611                }
1612
1613                // Validate an optional field pair: at most one may be set.
1614                fn validate_optional_field(
1615                    field: &str,
1616                    literal: &Option<impl AsRef<str>>,
1617                    secret: &Option<SecretKeySelector>,
1618                ) -> Result<(), ConnectionValidationError> {
1619                    let has_literal = literal.is_some();
1620                    if has_literal && secret.is_some() {
1621                        return Err(ConnectionValidationError::BothFieldsSet {
1622                            field: field.to_string(),
1623                        });
1624                    }
1625                    if let Some(s) = literal
1626                        && s.as_ref().trim().is_empty()
1627                    {
1628                        return Err(ConnectionValidationError::EmptyLiteral {
1629                            field: field.to_string(),
1630                        });
1631                    }
1632                    if let Some(sel) = secret {
1633                        validate_secret_selector(field, sel)?;
1634                    }
1635                    Ok(())
1636                }
1637
1638                fn validate_secret_selector(
1639                    field: &str,
1640                    sel: &SecretKeySelector,
1641                ) -> Result<(), ConnectionValidationError> {
1642                    if sel.name.trim().is_empty() {
1643                        return Err(ConnectionValidationError::EmptySecretKeyRef {
1644                            field: field.to_string(),
1645                            detail: "name must not be empty".to_string(),
1646                        });
1647                    }
1648                    if sel.key.trim().is_empty() {
1649                        return Err(ConnectionValidationError::EmptySecretKeyRef {
1650                            field: field.to_string(),
1651                            detail: "key must not be empty".to_string(),
1652                        });
1653                    }
1654                    Ok(())
1655                }
1656
1657                // Required fields: host, dbname, username. Password is only
1658                // required for static-password auth.
1659                validate_required_field("host", &params.host, &params.host_secret)?;
1660                validate_required_field("dbname", &params.dbname, &params.dbname_secret)?;
1661                validate_required_field("username", &params.username, &params.username_secret)?;
1662                if let Some(auth) = &params.auth {
1663                    if params.password.is_some() || params.password_secret.is_some() {
1664                        return Err(ConnectionValidationError::AuthWithPassword);
1665                    }
1666                    match auth {
1667                        ConnectionAuth::GcpWorkloadIdentity {
1668                            impersonate_service_account,
1669                            scope,
1670                        } => {
1671                            if let Some(value) = impersonate_service_account
1672                                && value.trim().is_empty()
1673                            {
1674                                return Err(ConnectionValidationError::EmptyAuthField {
1675                                    field: "impersonateServiceAccount".to_string(),
1676                                });
1677                            }
1678                            if let Some(value) = scope
1679                                && value.trim().is_empty()
1680                            {
1681                                return Err(ConnectionValidationError::EmptyAuthField {
1682                                    field: "scope".to_string(),
1683                                });
1684                            }
1685                        }
1686                    }
1687                } else {
1688                    validate_required_field("password", &params.password, &params.password_secret)?;
1689                }
1690
1691                // Optional fields: port, sslMode.
1692                // Port is u16 so we wrap it for the generic check.
1693                let port_str = params.port.map(|p| p.to_string());
1694                validate_optional_field("port", &port_str, &params.port_secret)?;
1695
1696                validate_optional_field("sslMode", &params.ssl_mode, &params.ssl_mode_secret)?;
1697
1698                // Validate sslMode value if it's a literal.
1699                if let Some(value) = &params.ssl_mode
1700                    && !VALID_SSL_MODES.contains(&value.as_str())
1701                {
1702                    return Err(ConnectionValidationError::InvalidSslMode {
1703                        value: value.clone(),
1704                    });
1705                }
1706
1707                // Validate setRole identifier. `SET ROLE` does not accept bind
1708                // params, so the identifier is restricted at admission time.
1709                if let Some(value) = &params.set_role {
1710                    if value.trim().is_empty() {
1711                        return Err(ConnectionValidationError::EmptyLiteral {
1712                            field: "setRole".to_string(),
1713                        });
1714                    }
1715                    if !is_valid_set_role_identifier(value) {
1716                        return Err(ConnectionValidationError::InvalidRoleName {
1717                            value: value.clone(),
1718                        });
1719                    }
1720                }
1721
1722                Ok(())
1723            }
1724            (Some(_), Some(_)) => Err(ConnectionValidationError::BothModesSet),
1725            (None, None) => Err(ConnectionValidationError::NeitherModeSet),
1726        }
1727    }
1728
1729    /// All Kubernetes Secret names referenced by this spec.
1730    ///
1731    /// Includes the connection Secret, password `secretRef` Secrets, and
1732    /// generated password Secrets. Used by the controller to trigger
1733    /// reconciliation when any of these Secrets change (or are deleted).
1734    pub fn referenced_secret_names(&self, policy_name: &str) -> BTreeSet<String> {
1735        let mut names = BTreeSet::new();
1736        // Connection secrets — either URL mode or structured params.
1737        self.connection.collect_secret_names(&mut names);
1738        for role in &self.roles {
1739            if let Some(pw) = &role.password {
1740                if let Some(secret_ref) = &pw.secret_ref {
1741                    names.insert(secret_ref.name.clone());
1742                }
1743                if let Some(gen_spec) = &pw.generate {
1744                    let secret_name =
1745                        crate::password::generated_secret_name(policy_name, &role.name, gen_spec);
1746                    names.insert(secret_name);
1747                }
1748            }
1749        }
1750        names
1751    }
1752}
1753
1754// ---------------------------------------------------------------------------
1755// Conversion: CRD spec → core manifest types
1756// ---------------------------------------------------------------------------
1757
1758impl PostgresPolicySpec {
1759    /// Convert the CRD spec into a `PolicyManifest` for use with the core library.
1760    pub fn to_policy_manifest(&self) -> pgroles_core::manifest::PolicyManifest {
1761        use pgroles_core::manifest::{
1762            DefaultPrivilegeGrant, MemberSpec, PolicyManifest, Profile, ProfileGrant,
1763            ProfileObjectTarget, RoleDefinition,
1764        };
1765
1766        let profiles = self
1767            .profiles
1768            .iter()
1769            .map(|(name, spec)| {
1770                let profile = Profile {
1771                    login: spec.login,
1772                    inherit: spec.inherit,
1773                    grants: spec
1774                        .grants
1775                        .iter()
1776                        .map(|g| ProfileGrant {
1777                            privileges: g.privileges.clone(),
1778                            object: ProfileObjectTarget {
1779                                object_type: g.object.object_type,
1780                                name: g.object.name.clone(),
1781                            },
1782                        })
1783                        .collect(),
1784                    default_privileges: spec
1785                        .default_privileges
1786                        .iter()
1787                        .map(|dp| DefaultPrivilegeGrant {
1788                            role: dp.role.clone(),
1789                            privileges: dp.privileges.clone(),
1790                            on_type: dp.on_type,
1791                        })
1792                        .collect(),
1793                    config: spec.config.clone(),
1794                };
1795                (name.clone(), profile)
1796            })
1797            .collect();
1798
1799        let roles = self
1800            .roles
1801            .iter()
1802            .map(|r| RoleDefinition {
1803                name: r.name.clone(),
1804                external: r.external,
1805                login: r.login,
1806                superuser: r.superuser,
1807                createdb: r.createdb,
1808                createrole: r.createrole,
1809                inherit: r.inherit,
1810                replication: r.replication,
1811                bypassrls: r.bypassrls,
1812                connection_limit: r.connection_limit,
1813                comment: r.comment.clone(),
1814                password: None, // K8s passwords are resolved separately via Secret refs
1815                password_valid_until: r.password_valid_until.clone(),
1816                config: r.config.clone(),
1817            })
1818            .collect();
1819
1820        // Memberships need MemberSpec conversion — the core type should
1821        // already be compatible since we use it directly in the CRD spec.
1822        // But we need to ensure the serde aliases work. Let's rebuild to be safe.
1823        let memberships = self
1824            .memberships
1825            .iter()
1826            .map(|m| pgroles_core::manifest::Membership {
1827                role: m.role.clone(),
1828                members: m
1829                    .members
1830                    .iter()
1831                    .map(|ms| MemberSpec {
1832                        name: ms.name.clone(),
1833                        inherit: ms.inherit,
1834                        admin: ms.admin,
1835                    })
1836                    .collect(),
1837            })
1838            .collect();
1839
1840        PolicyManifest {
1841            default_owner: self.default_owner.clone(),
1842            auth_providers: Vec::new(),
1843            profiles,
1844            schemas: self.schemas.clone(),
1845            roles,
1846            grants: self.grants.clone(),
1847            default_privileges: self.default_privileges.clone(),
1848            memberships,
1849            retirements: self.retirements.clone(),
1850        }
1851    }
1852
1853    /// Derive a conservative ownership claim set from the policy spec.
1854    ///
1855    /// This intentionally claims all declared/expanded roles and all referenced
1856    /// schemas so overlapping policies are rejected safely.
1857    pub fn ownership_claims(
1858        &self,
1859    ) -> Result<OwnershipClaims, pgroles_core::manifest::ManifestError> {
1860        let manifest = self.to_policy_manifest();
1861        let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
1862
1863        let mut roles: BTreeSet<String> = expanded.roles.into_iter().map(|r| r.name).collect();
1864        let mut schemas: BTreeSet<String> = self.schemas.iter().map(|s| s.name.clone()).collect();
1865
1866        roles.extend(manifest.retirements.into_iter().map(|r| r.role));
1867        roles.extend(manifest.grants.iter().map(|g| g.role.clone()));
1868        roles.extend(
1869            manifest
1870                .default_privileges
1871                .iter()
1872                .flat_map(|dp| dp.grant.iter().filter_map(|grant| grant.role.clone())),
1873        );
1874        roles.extend(manifest.memberships.iter().map(|m| m.role.clone()));
1875        roles.extend(
1876            manifest
1877                .memberships
1878                .iter()
1879                .flat_map(|m| m.members.iter().map(|member| member.name.clone())),
1880        );
1881
1882        schemas.extend(
1883            manifest
1884                .grants
1885                .iter()
1886                .filter_map(|g| match g.object.object_type {
1887                    ObjectType::Database => None,
1888                    ObjectType::Schema => g.object.name.clone(),
1889                    _ => g.object.schema.clone(),
1890                }),
1891        );
1892        schemas.extend(
1893            manifest
1894                .default_privileges
1895                .iter()
1896                .map(|dp| dp.schema.clone()),
1897        );
1898
1899        Ok(OwnershipClaims { roles, schemas })
1900    }
1901}
1902
1903// ---------------------------------------------------------------------------
1904// Status helpers
1905// ---------------------------------------------------------------------------
1906
1907impl PostgresPolicyStatus {
1908    /// Set a condition, replacing any existing condition of the same type.
1909    ///
1910    /// If the condition's `status` value has not changed, the existing
1911    /// `last_transition_time` is preserved (per Kubernetes condition conventions).
1912    pub fn set_condition(&mut self, new: PolicyCondition) {
1913        if let Some(existing) = self
1914            .conditions
1915            .iter()
1916            .find(|c| c.condition_type == new.condition_type)
1917            && existing.status == new.status
1918        {
1919            // Status unchanged — preserve the existing transition time.
1920            let mut updated = new;
1921            updated.last_transition_time = existing.last_transition_time.clone();
1922            self.conditions
1923                .retain(|c| c.condition_type != updated.condition_type);
1924            self.conditions.push(updated);
1925            return;
1926        }
1927        // New condition or status changed — use the new timestamp.
1928        self.conditions
1929            .retain(|c| c.condition_type != new.condition_type);
1930        self.conditions.push(new);
1931    }
1932}
1933
1934/// Create a timestamp string in ISO 8601 / RFC 3339 format.
1935pub fn now_rfc3339() -> String {
1936    // Use k8s-openapi's chrono re-export or manual formatting.
1937    // For simplicity, use the system time.
1938    use std::time::SystemTime;
1939    let now = SystemTime::now()
1940        .duration_since(SystemTime::UNIX_EPOCH)
1941        .unwrap_or_default();
1942    // Format as simplified ISO 8601
1943    let secs = now.as_secs();
1944    let days = secs / 86400;
1945    let remaining = secs % 86400;
1946    let hours = remaining / 3600;
1947    let minutes = (remaining % 3600) / 60;
1948    let seconds = remaining % 60;
1949
1950    // Convert days since epoch to date (simplified — good enough for status)
1951    let (year, month, day) = days_to_date(days);
1952    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
1953}
1954
1955/// Convert days since Unix epoch to (year, month, day).
1956pub fn days_to_date(days_since_epoch: u64) -> (u64, u64, u64) {
1957    // Civil calendar algorithm from Howard Hinnant
1958    let z = days_since_epoch + 719468;
1959    let era = z / 146097;
1960    let doe = z - era * 146097;
1961    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1962    let y = yoe + era * 400;
1963    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1964    let mp = (5 * doy + 2) / 153;
1965    let d = doy - (153 * mp + 2) / 5 + 1;
1966    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1967    let y = if m <= 2 { y + 1 } else { y };
1968    (y, m, d)
1969}
1970
1971/// Helper to create a "Ready" condition.
1972pub fn ready_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
1973    PolicyCondition {
1974        condition_type: "Ready".to_string(),
1975        status: if status { "True" } else { "False" }.to_string(),
1976        reason: Some(reason.to_string()),
1977        message: Some(message.to_string()),
1978        last_transition_time: Some(now_rfc3339()),
1979    }
1980}
1981
1982/// Helper to create a "Reconciling" condition.
1983pub fn reconciling_condition(message: &str) -> PolicyCondition {
1984    PolicyCondition {
1985        condition_type: "Reconciling".to_string(),
1986        status: "True".to_string(),
1987        reason: Some("Reconciling".to_string()),
1988        message: Some(message.to_string()),
1989        last_transition_time: Some(now_rfc3339()),
1990    }
1991}
1992
1993/// Helper to create a "Degraded" condition.
1994pub fn degraded_condition(reason: &str, message: &str) -> PolicyCondition {
1995    PolicyCondition {
1996        condition_type: "Degraded".to_string(),
1997        status: "True".to_string(),
1998        reason: Some(reason.to_string()),
1999        message: Some(message.to_string()),
2000        last_transition_time: Some(now_rfc3339()),
2001    }
2002}
2003
2004/// Helper to create a "Paused" condition.
2005pub fn paused_condition(message: &str) -> PolicyCondition {
2006    PolicyCondition {
2007        condition_type: "Paused".to_string(),
2008        status: "True".to_string(),
2009        reason: Some("Suspended".to_string()),
2010        message: Some(message.to_string()),
2011        last_transition_time: Some(now_rfc3339()),
2012    }
2013}
2014
2015/// Helper to create a "Conflict" condition.
2016pub fn conflict_condition(reason: &str, message: &str) -> PolicyCondition {
2017    PolicyCondition {
2018        condition_type: "Conflict".to_string(),
2019        status: "True".to_string(),
2020        reason: Some(reason.to_string()),
2021        message: Some(message.to_string()),
2022        last_transition_time: Some(now_rfc3339()),
2023    }
2024}
2025
2026/// Condition type reporting that `spec.approval` is absent and therefore
2027/// inferred from `spec.mode`. Removed once the field becomes required.
2028pub const CONDITION_APPROVAL_UNSET: &str = "ApprovalUnset";
2029
2030/// Condition type reporting that a plan carries an approval annotation which
2031/// cannot take effect, because `spec.mode: plan` never executes.
2032pub const CONDITION_APPROVAL_IGNORED: &str = "ApprovalIgnored";
2033
2034/// Helper to create an "ApprovalIgnored" condition.
2035///
2036/// Approving a plan under `spec.mode: plan` is accepted by the API server and
2037/// then does nothing at all, which is indistinguishable from an operator that
2038/// has stalled. Say so on the object, and name the combination that does gate
2039/// an apply.
2040pub fn approval_ignored_condition(plan_name: &str) -> PolicyCondition {
2041    PolicyCondition {
2042        condition_type: CONDITION_APPROVAL_IGNORED.to_string(),
2043        status: "True".to_string(),
2044        reason: Some("PlanModeNeverExecutes".to_string()),
2045        message: Some(format!(
2046            "Plan {plan_name} is approved, but spec.mode is `plan`, so it will never execute and \
2047             no SQL will run. For a reviewed apply use `mode: apply` with `approval: manual`."
2048        )),
2049        last_transition_time: Some(now_rfc3339()),
2050    }
2051}
2052
2053/// Helper to create an "ApprovalUnset" condition naming the inferred mode.
2054///
2055/// The message states the resolved value rather than only the deprecation, so
2056/// an operator reading `kubectl describe` learns what the policy does *now* as
2057/// well as what to write down to keep it doing that.
2058pub fn approval_unset_condition(inferred: ApprovalMode) -> PolicyCondition {
2059    let inferred = match inferred {
2060        ApprovalMode::Auto => "auto",
2061        ApprovalMode::Manual => "manual",
2062    };
2063    PolicyCondition {
2064        condition_type: CONDITION_APPROVAL_UNSET.to_string(),
2065        status: "True".to_string(),
2066        reason: Some("InferredFromMode".to_string()),
2067        message: Some(format!(
2068            "spec.approval is not set and is currently inferred as {inferred} from spec.mode. \
2069             This inference is deprecated and will become an error in a future release: set \
2070             `approval: {inferred}` explicitly to keep the current behaviour."
2071        )),
2072        last_transition_time: Some(now_rfc3339()),
2073    }
2074}
2075
2076/// Helper to create a "Drifted" condition.
2077pub fn drifted_condition(status: bool, reason: &str, message: &str) -> PolicyCondition {
2078    PolicyCondition {
2079        condition_type: "Drifted".to_string(),
2080        status: if status { "True" } else { "False" }.to_string(),
2081        reason: Some(reason.to_string()),
2082        message: Some(message.to_string()),
2083        last_transition_time: Some(now_rfc3339()),
2084    }
2085}
2086
2087// ---------------------------------------------------------------------------
2088// Tests
2089// ---------------------------------------------------------------------------
2090
2091#[cfg(test)]
2092mod tests {
2093    use super::*;
2094    use kube::CustomResourceExt;
2095
2096    #[test]
2097    fn crd_generates_valid_schema() {
2098        let crd = PostgresPolicy::crd();
2099        let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
2100        assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
2101        assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
2102        assert!(
2103            yaml.contains("PostgresPolicy"),
2104            "kind should be PostgresPolicy"
2105        );
2106        assert!(
2107            yaml.contains("\"mode\"") || yaml.contains(" mode:"),
2108            "schema should declare spec.mode"
2109        );
2110        assert!(
2111            yaml.contains("\"object\"") || yaml.contains(" object:"),
2112            "schema should declare grant object targets using object"
2113        );
2114    }
2115
2116    #[test]
2117    fn spec_to_policy_manifest_roundtrip() {
2118        let spec = PostgresPolicySpec {
2119            connection: ConnectionSpec {
2120                secret_ref: Some(SecretReference {
2121                    name: "pg-secret".to_string(),
2122                }),
2123                secret_key: Some("DATABASE_URL".to_string()),
2124                params: None,
2125            },
2126            interval: "5m".to_string(),
2127            suspend: false,
2128            mode: PolicyMode::Apply,
2129            reconciliation_mode: CrdReconciliationMode::default(),
2130            default_owner: Some("app_owner".to_string()),
2131            profiles: std::collections::HashMap::new(),
2132            schemas: vec![],
2133            roles: vec![RoleSpec {
2134                name: "analytics".to_string(),
2135                external: true,
2136                login: Some(true),
2137                superuser: None,
2138                createdb: None,
2139                createrole: None,
2140                inherit: None,
2141                replication: None,
2142                bypassrls: None,
2143                connection_limit: None,
2144                comment: Some("test role".to_string()),
2145                password: None,
2146                password_valid_until: None,
2147                config: Default::default(),
2148            }],
2149            grants: vec![],
2150            default_privileges: vec![],
2151            memberships: vec![],
2152            retirements: vec![RoleRetirement {
2153                role: "legacy-app".to_string(),
2154                reassign_owned_to: Some("app_owner".to_string()),
2155                drop_owned: true,
2156                terminate_sessions: true,
2157            }],
2158            approval: None,
2159        };
2160
2161        let manifest = spec.to_policy_manifest();
2162        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
2163        assert_eq!(manifest.roles.len(), 1);
2164        assert_eq!(manifest.roles[0].name, "analytics");
2165        assert!(manifest.roles[0].external);
2166        assert_eq!(manifest.roles[0].login, Some(true));
2167        assert_eq!(manifest.roles[0].comment, Some("test role".to_string()));
2168        assert_eq!(manifest.retirements.len(), 1);
2169        assert_eq!(manifest.retirements[0].role, "legacy-app");
2170        assert_eq!(
2171            manifest.retirements[0].reassign_owned_to.as_deref(),
2172            Some("app_owner")
2173        );
2174        assert!(manifest.retirements[0].drop_owned);
2175        assert!(manifest.retirements[0].terminate_sessions);
2176    }
2177
2178    #[test]
2179    fn spec_to_policy_manifest_preserves_profile_inherit() {
2180        let spec = PostgresPolicySpec {
2181            connection: ConnectionSpec {
2182                secret_ref: Some(SecretReference {
2183                    name: "pg-secret".to_string(),
2184                }),
2185                secret_key: Some("DATABASE_URL".to_string()),
2186                params: None,
2187            },
2188            interval: "5m".to_string(),
2189            suspend: false,
2190            mode: PolicyMode::Apply,
2191            reconciliation_mode: CrdReconciliationMode::default(),
2192            default_owner: None,
2193            profiles: std::collections::HashMap::from([(
2194                "editor".to_string(),
2195                ProfileSpec {
2196                    login: Some(false),
2197                    inherit: Some(false),
2198                    grants: vec![],
2199                    default_privileges: vec![],
2200                    config: Default::default(),
2201                },
2202            )]),
2203            schemas: vec![],
2204            roles: vec![],
2205            grants: vec![],
2206            default_privileges: vec![],
2207            memberships: vec![],
2208            retirements: vec![],
2209            approval: None,
2210        };
2211
2212        let manifest = spec.to_policy_manifest();
2213        assert_eq!(manifest.profiles["editor"].login, Some(false));
2214        assert_eq!(manifest.profiles["editor"].inherit, Some(false));
2215    }
2216
2217    #[test]
2218    fn status_set_condition_replaces_existing() {
2219        let mut status = PostgresPolicyStatus::default();
2220
2221        status.set_condition(ready_condition(false, "Pending", "Initial"));
2222        assert_eq!(status.conditions.len(), 1);
2223        assert_eq!(status.conditions[0].status, "False");
2224
2225        status.set_condition(ready_condition(true, "Reconciled", "All good"));
2226        assert_eq!(status.conditions.len(), 1);
2227        assert_eq!(status.conditions[0].status, "True");
2228        assert_eq!(status.conditions[0].reason.as_deref(), Some("Reconciled"));
2229    }
2230
2231    #[test]
2232    fn status_set_condition_adds_new_type() {
2233        let mut status = PostgresPolicyStatus::default();
2234
2235        status.set_condition(ready_condition(true, "OK", "ready"));
2236        status.set_condition(degraded_condition("Error", "something broke"));
2237
2238        assert_eq!(status.conditions.len(), 2);
2239    }
2240
2241    #[test]
2242    fn paused_condition_has_expected_shape() {
2243        let paused = paused_condition("paused by spec");
2244        assert_eq!(paused.condition_type, "Paused");
2245        assert_eq!(paused.status, "True");
2246        assert_eq!(paused.reason.as_deref(), Some("Suspended"));
2247    }
2248
2249    #[test]
2250    fn ownership_claims_include_expanded_roles_and_schemas() {
2251        let mut profiles = std::collections::HashMap::new();
2252        profiles.insert(
2253            "editor".to_string(),
2254            ProfileSpec {
2255                login: Some(false),
2256                inherit: None,
2257                grants: vec![],
2258                default_privileges: vec![],
2259                config: Default::default(),
2260            },
2261        );
2262
2263        let spec = PostgresPolicySpec {
2264            connection: ConnectionSpec {
2265                secret_ref: Some(SecretReference {
2266                    name: "pg-secret".to_string(),
2267                }),
2268                secret_key: Some("DATABASE_URL".to_string()),
2269                params: None,
2270            },
2271            interval: "5m".to_string(),
2272            suspend: false,
2273            mode: PolicyMode::Apply,
2274            reconciliation_mode: CrdReconciliationMode::default(),
2275            default_owner: None,
2276            profiles,
2277            schemas: vec![SchemaBinding {
2278                name: "inventory".to_string(),
2279                profiles: vec!["editor".to_string()],
2280                role_pattern: "{schema}-{profile}".to_string(),
2281                owner: None,
2282            }],
2283            roles: vec![RoleSpec {
2284                name: "app-service".to_string(),
2285                external: false,
2286                login: Some(true),
2287                superuser: None,
2288                createdb: None,
2289                createrole: None,
2290                inherit: None,
2291                replication: None,
2292                bypassrls: None,
2293                connection_limit: None,
2294                comment: None,
2295                password: None,
2296                password_valid_until: None,
2297                config: Default::default(),
2298            }],
2299            grants: vec![],
2300            default_privileges: vec![],
2301            memberships: vec![],
2302            retirements: vec![RoleRetirement {
2303                role: "legacy-app".to_string(),
2304                reassign_owned_to: None,
2305                drop_owned: false,
2306                terminate_sessions: false,
2307            }],
2308            approval: None,
2309        };
2310
2311        let claims = spec.ownership_claims().unwrap();
2312        assert!(claims.roles.contains("inventory-editor"));
2313        assert!(claims.roles.contains("app-service"));
2314        assert!(claims.roles.contains("legacy-app"));
2315        assert!(claims.schemas.contains("inventory"));
2316    }
2317
2318    #[test]
2319    fn ownership_overlap_summary_reports_roles_and_schemas() {
2320        let mut left = OwnershipClaims::default();
2321        left.roles.insert("analytics".to_string());
2322        left.schemas.insert("reporting".to_string());
2323
2324        let mut right = OwnershipClaims::default();
2325        right.roles.insert("analytics".to_string());
2326        right.schemas.insert("reporting".to_string());
2327        right.schemas.insert("other".to_string());
2328
2329        assert!(left.overlaps(&right));
2330        let summary = left.overlap_summary(&right);
2331        assert!(summary.contains("roles: analytics"));
2332        assert!(summary.contains("schemas: reporting"));
2333    }
2334
2335    #[test]
2336    fn database_identity_uses_namespace_and_identity_key() {
2337        let conn = ConnectionSpec {
2338            secret_ref: Some(SecretReference {
2339                name: "db-creds".to_string(),
2340            }),
2341            secret_key: Some("DATABASE_URL".to_string()),
2342            params: None,
2343        };
2344        let identity = DatabaseIdentity::from_connection("prod", &conn);
2345        assert_eq!(identity.as_str(), "prod/db-creds/DATABASE_URL");
2346    }
2347
2348    #[test]
2349    fn identity_key_same_database_different_users_are_equal() {
2350        // Two policies targeting the same database but with different users
2351        // should have the SAME identity key (for locking/conflict detection).
2352        let user_a = ConnectionSpec {
2353            secret_ref: None,
2354            secret_key: None,
2355            params: Some(ConnectionParams {
2356                host: Some("my-host".into()),
2357                host_secret: None,
2358                port: None,
2359                port_secret: None,
2360                dbname: Some("mydb".into()),
2361                dbname_secret: None,
2362                username: Some("alice".into()),
2363                username_secret: None,
2364                password: Some("pass-a".into()),
2365                password_secret: None,
2366                auth: None,
2367                ssl_mode: None,
2368                ssl_mode_secret: None,
2369                set_role: None,
2370            }),
2371        };
2372        let user_b = ConnectionSpec {
2373            secret_ref: None,
2374            secret_key: None,
2375            params: Some(ConnectionParams {
2376                host: Some("my-host".into()),
2377                host_secret: None,
2378                port: None,
2379                port_secret: None,
2380                dbname: Some("mydb".into()),
2381                dbname_secret: None,
2382                username: Some("bob".into()),
2383                username_secret: None,
2384                password: Some("pass-b".into()),
2385                password_secret: None,
2386                auth: None,
2387                ssl_mode: None,
2388                ssl_mode_secret: None,
2389                set_role: None,
2390            }),
2391        };
2392
2393        assert_eq!(
2394            user_a.identity_key(),
2395            user_b.identity_key(),
2396            "same database with different users should have the same identity key"
2397        );
2398        // But cache keys should differ (different credentials = different pool).
2399        assert_ne!(
2400            user_a.cache_key("default"),
2401            user_b.cache_key("default"),
2402            "different credentials should produce different cache keys"
2403        );
2404    }
2405
2406    #[test]
2407    fn cache_key_no_collision_between_literal_and_secret_username() {
2408        // A literal username containing "secret=" should not collide with a
2409        // real secret reference in the cache key.
2410        let literal_conn = ConnectionSpec {
2411            secret_ref: None,
2412            secret_key: None,
2413            params: Some(ConnectionParams {
2414                host: Some("my-host".into()),
2415                host_secret: None,
2416                port: None,
2417                port_secret: None,
2418                dbname: Some("mydb".into()),
2419                dbname_secret: None,
2420                username: Some("secret=creds\0password".into()),
2421                username_secret: None,
2422                password: Some("pass".into()),
2423                password_secret: None,
2424                auth: None,
2425                ssl_mode: None,
2426                ssl_mode_secret: None,
2427                set_role: None,
2428            }),
2429        };
2430        let secret_conn = ConnectionSpec {
2431            secret_ref: None,
2432            secret_key: None,
2433            params: Some(ConnectionParams {
2434                host: Some("my-host".into()),
2435                host_secret: None,
2436                port: None,
2437                port_secret: None,
2438                dbname: Some("mydb".into()),
2439                dbname_secret: None,
2440                username: None,
2441                username_secret: Some(SecretKeySelector {
2442                    name: "creds".into(),
2443                    key: "password".into(),
2444                }),
2445                password: Some("pass".into()),
2446                password_secret: None,
2447                auth: None,
2448                ssl_mode: None,
2449                ssl_mode_secret: None,
2450                set_role: None,
2451            }),
2452        };
2453
2454        assert_ne!(
2455            literal_conn.cache_key("default"),
2456            secret_conn.cache_key("default"),
2457            "literal and secret ref should produce different cache keys"
2458        );
2459    }
2460
2461    #[test]
2462    fn cache_key_includes_ssl_mode() {
2463        let conn_no_ssl = ConnectionSpec {
2464            secret_ref: None,
2465            secret_key: None,
2466            params: Some(ConnectionParams {
2467                host: Some("host".into()),
2468                host_secret: None,
2469                port: None,
2470                port_secret: None,
2471                dbname: Some("db".into()),
2472                dbname_secret: None,
2473                username: Some("user".into()),
2474                username_secret: None,
2475                password: Some("pass".into()),
2476                password_secret: None,
2477                auth: None,
2478                ssl_mode: None,
2479                ssl_mode_secret: None,
2480                set_role: None,
2481            }),
2482        };
2483        let conn_with_ssl = ConnectionSpec {
2484            secret_ref: None,
2485            secret_key: None,
2486            params: Some(ConnectionParams {
2487                host: Some("host".into()),
2488                host_secret: None,
2489                port: None,
2490                port_secret: None,
2491                dbname: Some("db".into()),
2492                dbname_secret: None,
2493                username: Some("user".into()),
2494                username_secret: None,
2495                password: Some("pass".into()),
2496                password_secret: None,
2497                auth: None,
2498                ssl_mode: Some("require".into()),
2499                ssl_mode_secret: None,
2500                set_role: None,
2501            }),
2502        };
2503
2504        assert_ne!(
2505            conn_no_ssl.cache_key("ns"),
2506            conn_with_ssl.cache_key("ns"),
2507            "cache key should differ when sslMode is present"
2508        );
2509    }
2510
2511    #[test]
2512    fn validate_connection_rejects_empty_literal_host() {
2513        let spec = spec_with_connection(ConnectionSpec {
2514            secret_ref: None,
2515            secret_key: None,
2516            params: Some(ConnectionParams {
2517                host: Some("".into()),
2518                host_secret: None,
2519                port: None,
2520                port_secret: None,
2521                dbname: Some("mydb".into()),
2522                dbname_secret: None,
2523                username: Some("user".into()),
2524                username_secret: None,
2525                password: Some("pass".into()),
2526                password_secret: None,
2527                auth: None,
2528                ssl_mode: None,
2529                ssl_mode_secret: None,
2530                set_role: None,
2531            }),
2532        });
2533
2534        let err = spec.validate_connection_spec().unwrap_err();
2535        assert!(
2536            matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "host"),
2537            "expected EmptyLiteral for host, got: {err}"
2538        );
2539    }
2540
2541    #[test]
2542    fn validate_connection_rejects_whitespace_literal_dbname() {
2543        let spec = spec_with_connection(ConnectionSpec {
2544            secret_ref: None,
2545            secret_key: None,
2546            params: Some(ConnectionParams {
2547                host: Some("host".into()),
2548                host_secret: None,
2549                port: None,
2550                port_secret: None,
2551                dbname: Some("  ".into()),
2552                dbname_secret: None,
2553                username: Some("user".into()),
2554                username_secret: None,
2555                password: Some("pass".into()),
2556                password_secret: None,
2557                auth: None,
2558                ssl_mode: None,
2559                ssl_mode_secret: None,
2560                set_role: None,
2561            }),
2562        });
2563
2564        let err = spec.validate_connection_spec().unwrap_err();
2565        assert!(
2566            matches!(err, ConnectionValidationError::EmptyLiteral { ref field } if field == "dbname"),
2567            "expected EmptyLiteral for dbname, got: {err}"
2568        );
2569    }
2570
2571    /// Helper to build a minimal spec with the given connection and no roles/grants.
2572    fn spec_with_connection(connection: ConnectionSpec) -> PostgresPolicySpec {
2573        PostgresPolicySpec {
2574            connection,
2575            interval: "5m".into(),
2576            suspend: false,
2577            mode: PolicyMode::Apply,
2578            reconciliation_mode: CrdReconciliationMode::default(),
2579            default_owner: None,
2580            profiles: Default::default(),
2581            schemas: vec![],
2582            roles: vec![],
2583            grants: vec![],
2584            default_privileges: vec![],
2585            memberships: vec![],
2586            retirements: vec![],
2587            approval: None,
2588        }
2589    }
2590
2591    fn url_mode_connection() -> ConnectionSpec {
2592        ConnectionSpec {
2593            secret_ref: Some(SecretReference {
2594                name: "pg-creds".into(),
2595            }),
2596            secret_key: Some("DATABASE_URL".into()),
2597            params: None,
2598        }
2599    }
2600
2601    fn params_mode_connection() -> ConnectionSpec {
2602        ConnectionSpec {
2603            secret_ref: None,
2604            secret_key: None,
2605            params: Some(ConnectionParams {
2606                host: Some("my-postgres".into()),
2607                host_secret: None,
2608                port: None,
2609                port_secret: None,
2610                dbname: Some("mydb".into()),
2611                dbname_secret: None,
2612                username: None,
2613                username_secret: Some(SecretKeySelector {
2614                    name: "pg-creds".into(),
2615                    key: "username".into(),
2616                }),
2617                password: None,
2618                password_secret: Some(SecretKeySelector {
2619                    name: "pg-creds".into(),
2620                    key: "password".into(),
2621                }),
2622                auth: None,
2623                ssl_mode: None,
2624                ssl_mode_secret: None,
2625                set_role: None,
2626            }),
2627        }
2628    }
2629
2630    // -- Connection validation tests -----------------------------------------
2631
2632    #[test]
2633    fn validate_connection_accepts_url_mode() {
2634        let spec = spec_with_connection(url_mode_connection());
2635        assert!(spec.validate_connection_spec().is_ok());
2636    }
2637
2638    #[test]
2639    fn validate_connection_accepts_params_mode() {
2640        let spec = spec_with_connection(params_mode_connection());
2641        assert!(spec.validate_connection_spec().is_ok());
2642    }
2643
2644    #[test]
2645    fn validate_connection_rejects_both_modes_set() {
2646        let spec = spec_with_connection(ConnectionSpec {
2647            secret_ref: Some(SecretReference {
2648                name: "pg-creds".into(),
2649            }),
2650            secret_key: None,
2651            params: Some(ConnectionParams {
2652                host: Some("host".into()),
2653                host_secret: None,
2654                port: None,
2655                port_secret: None,
2656                dbname: Some("db".into()),
2657                dbname_secret: None,
2658                username: Some("user".into()),
2659                username_secret: None,
2660                password: Some("pass".into()),
2661                password_secret: None,
2662                auth: None,
2663                ssl_mode: None,
2664                ssl_mode_secret: None,
2665                set_role: None,
2666            }),
2667        });
2668        assert!(matches!(
2669            spec.validate_connection_spec(),
2670            Err(ConnectionValidationError::BothModesSet)
2671        ));
2672    }
2673
2674    #[test]
2675    fn validate_connection_rejects_neither_mode_set() {
2676        let spec = spec_with_connection(ConnectionSpec {
2677            secret_ref: None,
2678            secret_key: None,
2679            params: None,
2680        });
2681        assert!(spec.validate_connection_spec().is_err());
2682    }
2683
2684    #[test]
2685    fn validate_connection_rejects_invalid_ssl_mode() {
2686        let spec = spec_with_connection(ConnectionSpec {
2687            secret_ref: None,
2688            secret_key: None,
2689            params: Some(ConnectionParams {
2690                host: Some("host".into()),
2691                host_secret: None,
2692                port: None,
2693                port_secret: None,
2694                dbname: Some("db".into()),
2695                dbname_secret: None,
2696                username: Some("user".into()),
2697                username_secret: None,
2698                password: Some("pass".into()),
2699                password_secret: None,
2700                auth: None,
2701                ssl_mode: Some("invalid-mode".into()),
2702                ssl_mode_secret: None,
2703                set_role: None,
2704            }),
2705        });
2706        assert!(spec.validate_connection_spec().is_err());
2707    }
2708
2709    fn params_with_set_role(set_role: Option<String>) -> ConnectionParams {
2710        ConnectionParams {
2711            host: Some("host".into()),
2712            host_secret: None,
2713            port: None,
2714            port_secret: None,
2715            dbname: Some("db".into()),
2716            dbname_secret: None,
2717            username: Some("user".into()),
2718            username_secret: None,
2719            password: Some("pass".into()),
2720            password_secret: None,
2721            auth: None,
2722            ssl_mode: None,
2723            ssl_mode_secret: None,
2724            set_role,
2725        }
2726    }
2727
2728    #[test]
2729    fn validate_connection_accepts_valid_set_role() {
2730        for role in [
2731            "cloudsqlsuperuser",
2732            "_underscore_start",
2733            "role-with-dash",
2734            "role_with$dollar",
2735            "Mixed_Case_Role",
2736            "r2d2",
2737        ] {
2738            let spec = spec_with_connection(ConnectionSpec {
2739                secret_ref: None,
2740                secret_key: None,
2741                params: Some(params_with_set_role(Some(role.into()))),
2742            });
2743            assert!(
2744                spec.validate_connection_spec().is_ok(),
2745                "expected {role} to be accepted"
2746            );
2747        }
2748    }
2749
2750    #[test]
2751    fn validate_connection_rejects_invalid_set_role() {
2752        for role in [
2753            "1leading_digit",
2754            "has space",
2755            "has\"quote",
2756            "has;semicolon",
2757            "has'singlequote",
2758            "ünicode",
2759        ] {
2760            let spec = spec_with_connection(ConnectionSpec {
2761                secret_ref: None,
2762                secret_key: None,
2763                params: Some(params_with_set_role(Some(role.into()))),
2764            });
2765            let err = spec
2766                .validate_connection_spec()
2767                .expect_err(&format!("expected {role} to be rejected"));
2768            assert!(
2769                matches!(err, ConnectionValidationError::InvalidRoleName { ref value } if value == role),
2770                "unexpected error for {role}: {err:?}",
2771            );
2772        }
2773    }
2774
2775    #[test]
2776    fn validate_connection_rejects_empty_set_role() {
2777        let spec = spec_with_connection(ConnectionSpec {
2778            secret_ref: None,
2779            secret_key: None,
2780            params: Some(params_with_set_role(Some("   ".into()))),
2781        });
2782        assert!(matches!(
2783            spec.validate_connection_spec(),
2784            Err(ConnectionValidationError::EmptyLiteral { ref field }) if field == "setRole"
2785        ));
2786    }
2787
2788    #[test]
2789    fn cache_key_includes_set_role() {
2790        let conn_no_role = ConnectionSpec {
2791            secret_ref: None,
2792            secret_key: None,
2793            params: Some(params_with_set_role(None)),
2794        };
2795        let conn_with_role = ConnectionSpec {
2796            secret_ref: None,
2797            secret_key: None,
2798            params: Some(params_with_set_role(Some("cloudsqlsuperuser".into()))),
2799        };
2800        assert_ne!(
2801            conn_no_role.cache_key("ns"),
2802            conn_with_role.cache_key("ns"),
2803            "cache key should differ when setRole is present"
2804        );
2805    }
2806
2807    #[test]
2808    fn validate_connection_accepts_gcp_workload_identity_without_password() {
2809        let spec = spec_with_connection(ConnectionSpec {
2810            secret_ref: None,
2811            secret_key: None,
2812            params: Some(ConnectionParams {
2813                host: Some("10.0.0.5".into()),
2814                host_secret: None,
2815                port: None,
2816                port_secret: None,
2817                dbname: Some("discovery".into()),
2818                dbname_secret: None,
2819                username: Some("pgroles-operator@my-project.iam".into()),
2820                username_secret: None,
2821                password: None,
2822                password_secret: None,
2823                auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2824                    impersonate_service_account: None,
2825                    scope: None,
2826                }),
2827                ssl_mode: None,
2828                ssl_mode_secret: None,
2829                set_role: None,
2830            }),
2831        });
2832
2833        assert!(spec.validate_connection_spec().is_ok());
2834        assert!(spec.referenced_secret_names("policy").is_empty());
2835    }
2836
2837    #[test]
2838    fn validate_connection_rejects_gcp_workload_identity_with_password() {
2839        let spec = spec_with_connection(ConnectionSpec {
2840            secret_ref: None,
2841            secret_key: None,
2842            params: Some(ConnectionParams {
2843                host: Some("10.0.0.5".into()),
2844                host_secret: None,
2845                port: None,
2846                port_secret: None,
2847                dbname: Some("discovery".into()),
2848                dbname_secret: None,
2849                username: Some("pgroles-operator@my-project.iam".into()),
2850                username_secret: None,
2851                password: Some("static-password".into()),
2852                password_secret: None,
2853                auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2854                    impersonate_service_account: None,
2855                    scope: None,
2856                }),
2857                ssl_mode: None,
2858                ssl_mode_secret: None,
2859                set_role: None,
2860            }),
2861        });
2862
2863        assert!(matches!(
2864            spec.validate_connection_spec(),
2865            Err(ConnectionValidationError::AuthWithPassword)
2866        ));
2867    }
2868
2869    #[test]
2870    fn validate_connection_rejects_empty_gcp_auth_fields() {
2871        let spec = spec_with_connection(ConnectionSpec {
2872            secret_ref: None,
2873            secret_key: None,
2874            params: Some(ConnectionParams {
2875                host: Some("10.0.0.5".into()),
2876                host_secret: None,
2877                port: None,
2878                port_secret: None,
2879                dbname: Some("discovery".into()),
2880                dbname_secret: None,
2881                username: Some("pgroles-operator@my-project.iam".into()),
2882                username_secret: None,
2883                password: None,
2884                password_secret: None,
2885                auth: Some(ConnectionAuth::GcpWorkloadIdentity {
2886                    impersonate_service_account: Some(" ".into()),
2887                    scope: None,
2888                }),
2889                ssl_mode: None,
2890                ssl_mode_secret: None,
2891                set_role: None,
2892            }),
2893        });
2894
2895        assert!(matches!(
2896            spec.validate_connection_spec(),
2897            Err(ConnectionValidationError::EmptyAuthField { ref field })
2898                if field == "impersonateServiceAccount"
2899        ));
2900    }
2901
2902    #[test]
2903    fn validate_connection_accepts_valid_ssl_modes() {
2904        for mode in &[
2905            "disable",
2906            "allow",
2907            "prefer",
2908            "require",
2909            "verify-ca",
2910            "verify-full",
2911        ] {
2912            let spec = spec_with_connection(ConnectionSpec {
2913                secret_ref: None,
2914                secret_key: None,
2915                params: Some(ConnectionParams {
2916                    host: Some("host".into()),
2917                    host_secret: None,
2918                    port: None,
2919                    port_secret: None,
2920                    dbname: Some("db".into()),
2921                    dbname_secret: None,
2922                    username: Some("user".into()),
2923                    username_secret: None,
2924                    password: Some("pass".into()),
2925                    password_secret: None,
2926                    auth: None,
2927                    ssl_mode: Some((*mode).into()),
2928                    ssl_mode_secret: None,
2929                    set_role: None,
2930                }),
2931            });
2932            assert!(
2933                spec.validate_connection_spec().is_ok(),
2934                "sslMode '{mode}' should be accepted"
2935            );
2936        }
2937    }
2938
2939    #[test]
2940    fn validate_connection_rejects_empty_secret_name() {
2941        let spec = spec_with_connection(ConnectionSpec {
2942            secret_ref: None,
2943            secret_key: None,
2944            params: Some(ConnectionParams {
2945                host: Some("host".into()),
2946                host_secret: None,
2947                port: None,
2948                port_secret: None,
2949                dbname: Some("db".into()),
2950                dbname_secret: None,
2951                username: None,
2952                username_secret: Some(SecretKeySelector {
2953                    name: "".into(),
2954                    key: "username".into(),
2955                }),
2956                password: Some("pass".into()),
2957                password_secret: None,
2958                auth: None,
2959                ssl_mode: None,
2960                ssl_mode_secret: None,
2961                set_role: None,
2962            }),
2963        });
2964        assert!(spec.validate_connection_spec().is_err());
2965    }
2966
2967    #[test]
2968    fn validate_connection_rejects_both_literal_and_secret_for_same_field() {
2969        let spec = spec_with_connection(ConnectionSpec {
2970            secret_ref: None,
2971            secret_key: None,
2972            params: Some(ConnectionParams {
2973                host: Some("host".into()),
2974                host_secret: Some(SecretKeySelector {
2975                    name: "s".into(),
2976                    key: "k".into(),
2977                }),
2978                port: None,
2979                port_secret: None,
2980                dbname: Some("db".into()),
2981                dbname_secret: None,
2982                username: Some("user".into()),
2983                username_secret: None,
2984                password: Some("pass".into()),
2985                password_secret: None,
2986                auth: None,
2987                ssl_mode: None,
2988                ssl_mode_secret: None,
2989                set_role: None,
2990            }),
2991        });
2992        assert!(matches!(
2993            spec.validate_connection_spec(),
2994            Err(ConnectionValidationError::BothFieldsSet { ref field }) if field == "host"
2995        ));
2996    }
2997
2998    #[test]
2999    fn validate_connection_rejects_neither_literal_nor_secret_for_required_field() {
3000        let spec = spec_with_connection(ConnectionSpec {
3001            secret_ref: None,
3002            secret_key: None,
3003            params: Some(ConnectionParams {
3004                host: None,
3005                host_secret: None,
3006                port: None,
3007                port_secret: None,
3008                dbname: Some("db".into()),
3009                dbname_secret: None,
3010                username: Some("user".into()),
3011                username_secret: None,
3012                password: Some("pass".into()),
3013                password_secret: None,
3014                auth: None,
3015                ssl_mode: None,
3016                ssl_mode_secret: None,
3017                set_role: None,
3018            }),
3019        });
3020        assert!(matches!(
3021            spec.validate_connection_spec(),
3022            Err(ConnectionValidationError::NeitherFieldSet { ref field }) if field == "host"
3023        ));
3024    }
3025
3026    // -- ConnectionSpec backward compatibility --------------------------------
3027
3028    #[test]
3029    fn connection_spec_backward_compat_url_mode() {
3030        // The old format with required secretRef should still deserialize.
3031        let yaml = r#"
3032secretRef:
3033  name: pg-creds
3034secretKey: DATABASE_URL
3035"#;
3036        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3037        assert!(conn.secret_ref.is_some());
3038        assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
3039        assert!(conn.params.is_none());
3040    }
3041
3042    #[test]
3043    fn connection_spec_backward_compat_default_secret_key() {
3044        let yaml = r#"
3045secretRef:
3046  name: pg-creds
3047"#;
3048        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3049        assert_eq!(conn.effective_secret_key(), "DATABASE_URL");
3050    }
3051
3052    #[test]
3053    fn connection_spec_params_mode_deserializes_keycloak_style() {
3054        let yaml = r#"
3055params:
3056  host: my-postgres
3057  port: 5432
3058  dbname: mydb
3059  usernameSecret:
3060    name: creds
3061    key: username
3062  passwordSecret:
3063    name: creds
3064    key: password
3065  sslMode: require
3066"#;
3067        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3068        assert!(conn.secret_ref.is_none());
3069        let params = conn.params.unwrap();
3070        assert_eq!(params.host.as_deref(), Some("my-postgres"));
3071        assert_eq!(params.port, Some(5432));
3072        assert!(params.username_secret.is_some());
3073        assert_eq!(params.username_secret.as_ref().unwrap().name, "creds");
3074        assert_eq!(params.ssl_mode.as_deref(), Some("require"));
3075    }
3076
3077    #[test]
3078    fn connection_spec_params_mode_deserializes_gcp_workload_identity_auth() {
3079        let yaml = r#"
3080params:
3081  host: 10.0.0.5
3082  port: 5432
3083  dbname: discovery
3084  username: pgroles-operator@my-project.iam
3085  auth:
3086    type: gcp_workload_identity
3087    impersonateServiceAccount: target@other-project.iam.gserviceaccount.com
3088    scope: https://example.com/custom-scope
3089"#;
3090        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3091        let params = conn.params.as_ref().unwrap();
3092        let auth = params.auth.as_ref().expect("auth should deserialize");
3093
3094        assert!(params.password.is_none());
3095        assert_eq!(auth.gcp_scope(), "https://example.com/custom-scope");
3096        assert_eq!(
3097            auth.gcp_impersonate_service_account(),
3098            Some("target@other-project.iam.gserviceaccount.com")
3099        );
3100        assert!(conn.cache_key("prod").contains("gcp_workload_identity"));
3101
3102        let spec = spec_with_connection(conn);
3103        assert!(spec.validate_connection_spec().is_ok());
3104    }
3105
3106    #[test]
3107    fn connection_spec_params_mode_all_secrets() {
3108        // CNPG/PGO pattern — everything from one secret.
3109        let yaml = r#"
3110params:
3111  hostSecret:
3112    name: cluster-app
3113    key: host
3114  portSecret:
3115    name: cluster-app
3116    key: port
3117  dbnameSecret:
3118    name: cluster-app
3119    key: dbname
3120  usernameSecret:
3121    name: cluster-app
3122    key: user
3123  passwordSecret:
3124    name: cluster-app
3125    key: password
3126"#;
3127        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3128        let params = conn.params.unwrap();
3129        assert!(params.host.is_none());
3130        assert!(params.host_secret.is_some());
3131        assert_eq!(params.host_secret.as_ref().unwrap().name, "cluster-app");
3132        assert!(params.port.is_none());
3133        assert!(params.port_secret.is_some());
3134    }
3135
3136    // -- referenced_secret_names with params mode ----------------------------
3137
3138    #[test]
3139    fn referenced_secret_names_includes_params_secrets() {
3140        let spec = spec_with_connection(params_mode_connection());
3141        let names = spec.referenced_secret_names("test-policy");
3142        assert!(
3143            names.contains("pg-creds"),
3144            "should include the credential secret from params"
3145        );
3146    }
3147
3148    #[test]
3149    fn referenced_secret_names_deduplicates_across_modes() {
3150        // Same secret name used in both connection and password secretRef.
3151        let mut spec = spec_with_connection(params_mode_connection());
3152        spec.roles = vec![RoleSpec {
3153            name: "app".into(),
3154            external: false,
3155            login: Some(true),
3156            password: Some(PasswordSpec {
3157                secret_ref: Some(SecretReference {
3158                    name: "pg-creds".into(),
3159                }),
3160                secret_key: Some("app-password".into()),
3161                generate: None,
3162            }),
3163            password_valid_until: None,
3164            config: Default::default(),
3165            superuser: None,
3166            createdb: None,
3167            createrole: None,
3168            inherit: None,
3169            replication: None,
3170            bypassrls: None,
3171            connection_limit: None,
3172            comment: None,
3173        }];
3174        let names = spec.referenced_secret_names("test-policy");
3175        // pg-creds appears in both connection params and password — should be deduped.
3176        assert_eq!(
3177            names.iter().filter(|n| *n == "pg-creds").count(),
3178            1,
3179            "BTreeSet should deduplicate"
3180        );
3181    }
3182
3183    // -- ConnectionParams port default ---------------------------------------
3184
3185    #[test]
3186    fn connection_params_port_defaults_to_none() {
3187        let yaml = r#"
3188params:
3189  host: my-host
3190  dbname: mydb
3191  username: user
3192  password: pass
3193"#;
3194        let conn: ConnectionSpec = serde_yaml::from_str(yaml).unwrap();
3195        let params = conn.params.unwrap();
3196        assert!(
3197            params.port.is_none(),
3198            "port should default to None (resolved as 5432 at runtime)"
3199        );
3200        assert!(
3201            params.port_secret.is_none(),
3202            "portSecret should also default to None"
3203        );
3204    }
3205
3206    #[test]
3207    fn now_rfc3339_produces_valid_format() {
3208        let ts = now_rfc3339();
3209        // Should match YYYY-MM-DDTHH:MM:SSZ
3210        assert!(ts.len() == 20, "expected 20 chars, got {}: {ts}", ts.len());
3211        assert!(ts.ends_with('Z'), "should end with Z: {ts}");
3212        assert_eq!(&ts[4..5], "-", "should have dash at pos 4: {ts}");
3213        assert_eq!(&ts[10..11], "T", "should have T at pos 10: {ts}");
3214    }
3215
3216    #[test]
3217    fn ready_condition_true_has_expected_shape() {
3218        let cond = ready_condition(true, "Reconciled", "All changes applied");
3219        assert_eq!(cond.condition_type, "Ready");
3220        assert_eq!(cond.status, "True");
3221        assert_eq!(cond.reason.as_deref(), Some("Reconciled"));
3222        assert_eq!(cond.message.as_deref(), Some("All changes applied"));
3223        assert!(cond.last_transition_time.is_some());
3224    }
3225
3226    #[test]
3227    fn ready_condition_false_has_expected_shape() {
3228        let cond = ready_condition(false, "InvalidSpec", "bad manifest");
3229        assert_eq!(cond.condition_type, "Ready");
3230        assert_eq!(cond.status, "False");
3231        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
3232        assert_eq!(cond.message.as_deref(), Some("bad manifest"));
3233    }
3234
3235    #[test]
3236    fn degraded_condition_has_expected_shape() {
3237        let cond = degraded_condition("InvalidSpec", "expansion failed");
3238        assert_eq!(cond.condition_type, "Degraded");
3239        assert_eq!(cond.status, "True");
3240        assert_eq!(cond.reason.as_deref(), Some("InvalidSpec"));
3241        assert_eq!(cond.message.as_deref(), Some("expansion failed"));
3242        assert!(cond.last_transition_time.is_some());
3243    }
3244
3245    #[test]
3246    fn reconciling_condition_has_expected_shape() {
3247        let cond = reconciling_condition("Reconciliation in progress");
3248        assert_eq!(cond.condition_type, "Reconciling");
3249        assert_eq!(cond.status, "True");
3250        assert_eq!(cond.reason.as_deref(), Some("Reconciling"));
3251        assert_eq!(cond.message.as_deref(), Some("Reconciliation in progress"));
3252        assert!(cond.last_transition_time.is_some());
3253    }
3254
3255    #[test]
3256    fn conflict_condition_has_expected_shape() {
3257        let cond = conflict_condition("ConflictingPolicy", "overlaps with ns/other");
3258        assert_eq!(cond.condition_type, "Conflict");
3259        assert_eq!(cond.status, "True");
3260        assert_eq!(cond.reason.as_deref(), Some("ConflictingPolicy"));
3261        assert_eq!(cond.message.as_deref(), Some("overlaps with ns/other"));
3262        assert!(cond.last_transition_time.is_some());
3263    }
3264
3265    #[test]
3266    fn approval_unset_condition_names_the_inferred_mode() {
3267        for (mode, expected) in [
3268            (ApprovalMode::Auto, "auto"),
3269            (ApprovalMode::Manual, "manual"),
3270        ] {
3271            let cond = approval_unset_condition(mode);
3272            assert_eq!(cond.condition_type, CONDITION_APPROVAL_UNSET);
3273            assert_eq!(cond.status, "True");
3274            assert_eq!(cond.reason.as_deref(), Some("InferredFromMode"));
3275            assert!(cond.last_transition_time.is_some());
3276
3277            let message = cond.message.expect("condition should carry a message");
3278            // The message has to state both what the policy does now and the
3279            // exact line to add, since that is the whole remediation path.
3280            assert!(
3281                message.contains(&format!("inferred as {expected}")),
3282                "message should name the inferred mode, got: {message}"
3283            );
3284            assert!(
3285                message.contains(&format!("`approval: {expected}`")),
3286                "message should show the fix to apply, got: {message}"
3287            );
3288            assert!(
3289                message.contains("deprecated"),
3290                "message should say the inference is deprecated, got: {message}"
3291            );
3292        }
3293    }
3294
3295    #[test]
3296    fn ownership_claims_no_overlap() {
3297        let mut left = OwnershipClaims::default();
3298        left.roles.insert("analytics".to_string());
3299        left.schemas.insert("reporting".to_string());
3300
3301        let mut right = OwnershipClaims::default();
3302        right.roles.insert("billing".to_string());
3303        right.schemas.insert("payments".to_string());
3304
3305        assert!(!left.overlaps(&right));
3306        let summary = left.overlap_summary(&right);
3307        assert!(summary.is_empty());
3308    }
3309
3310    #[test]
3311    fn ownership_claims_partial_role_overlap() {
3312        let mut left = OwnershipClaims::default();
3313        left.roles.insert("analytics".to_string());
3314        left.roles.insert("reporting-viewer".to_string());
3315
3316        let mut right = OwnershipClaims::default();
3317        right.roles.insert("analytics".to_string());
3318        right.roles.insert("other-role".to_string());
3319
3320        assert!(left.overlaps(&right));
3321        let summary = left.overlap_summary(&right);
3322        assert!(summary.contains("roles: analytics"));
3323        assert!(!summary.contains("schemas"));
3324    }
3325
3326    #[test]
3327    fn ownership_claims_empty_is_disjoint() {
3328        let left = OwnershipClaims::default();
3329        let right = OwnershipClaims::default();
3330        assert!(!left.overlaps(&right));
3331    }
3332
3333    #[test]
3334    fn database_identity_equality() {
3335        let conn_a = ConnectionSpec {
3336            secret_ref: Some(SecretReference {
3337                name: "db-creds".to_string(),
3338            }),
3339            secret_key: Some("DATABASE_URL".to_string()),
3340            params: None,
3341        };
3342        let a = DatabaseIdentity::from_connection("prod", &conn_a);
3343        let b = DatabaseIdentity::from_connection("prod", &conn_a);
3344        let c = DatabaseIdentity::from_connection("staging", &conn_a);
3345        assert_eq!(a, b);
3346        assert_ne!(a, c);
3347    }
3348
3349    #[test]
3350    fn database_identity_different_key() {
3351        let conn_a = ConnectionSpec {
3352            secret_ref: Some(SecretReference {
3353                name: "db-creds".to_string(),
3354            }),
3355            secret_key: Some("DATABASE_URL".to_string()),
3356            params: None,
3357        };
3358        let conn_b = ConnectionSpec {
3359            secret_ref: Some(SecretReference {
3360                name: "db-creds".to_string(),
3361            }),
3362            secret_key: Some("CUSTOM_URL".to_string()),
3363            params: None,
3364        };
3365        let a = DatabaseIdentity::from_connection("prod", &conn_a);
3366        let b = DatabaseIdentity::from_connection("prod", &conn_b);
3367        assert_ne!(a, b);
3368    }
3369
3370    #[test]
3371    fn status_default_has_empty_conditions() {
3372        let status = PostgresPolicyStatus::default();
3373        assert!(status.conditions.is_empty());
3374        assert!(status.observed_generation.is_none());
3375        assert!(status.last_attempted_generation.is_none());
3376        assert!(status.last_successful_reconcile_time.is_none());
3377        assert!(status.change_summary.is_none());
3378        assert!(status.managed_database_identity.is_none());
3379        assert!(status.owned_roles.is_empty());
3380        assert!(status.owned_schemas.is_empty());
3381        assert!(status.last_error.is_none());
3382        assert!(status.applied_password_source_versions.is_empty());
3383    }
3384
3385    #[test]
3386    fn status_degraded_workflow_sets_ready_false_and_degraded_true() {
3387        let mut status = PostgresPolicyStatus::default();
3388
3389        // Simulate a failed reconciliation: Ready=False + Degraded=True
3390        status.set_condition(ready_condition(false, "InvalidSpec", "bad manifest"));
3391        status.set_condition(degraded_condition("InvalidSpec", "bad manifest"));
3392        status
3393            .conditions
3394            .retain(|c| c.condition_type != "Reconciling" && c.condition_type != "Paused");
3395        status.change_summary = None;
3396        status.last_error = Some("bad manifest".to_string());
3397
3398        // Verify Ready=False
3399        let ready = status
3400            .conditions
3401            .iter()
3402            .find(|c| c.condition_type == "Ready")
3403            .expect("should have Ready condition");
3404        assert_eq!(ready.status, "False");
3405        assert_eq!(ready.reason.as_deref(), Some("InvalidSpec"));
3406
3407        // Verify Degraded=True
3408        let degraded = status
3409            .conditions
3410            .iter()
3411            .find(|c| c.condition_type == "Degraded")
3412            .expect("should have Degraded condition");
3413        assert_eq!(degraded.status, "True");
3414        assert_eq!(degraded.reason.as_deref(), Some("InvalidSpec"));
3415
3416        // Verify last_error is set
3417        assert_eq!(status.last_error.as_deref(), Some("bad manifest"));
3418    }
3419
3420    #[test]
3421    fn status_conflict_workflow() {
3422        let mut status = PostgresPolicyStatus::default();
3423
3424        // Simulate a conflict
3425        let msg = "policy ownership overlaps with staging/other on database target prod/db/URL";
3426        status.set_condition(ready_condition(false, "ConflictingPolicy", msg));
3427        status.set_condition(conflict_condition("ConflictingPolicy", msg));
3428        status.set_condition(degraded_condition("ConflictingPolicy", msg));
3429        status
3430            .conditions
3431            .retain(|c| c.condition_type != "Reconciling");
3432        status.last_error = Some(msg.to_string());
3433
3434        // Verify Conflict=True
3435        let conflict = status
3436            .conditions
3437            .iter()
3438            .find(|c| c.condition_type == "Conflict")
3439            .expect("should have Conflict condition");
3440        assert_eq!(conflict.status, "True");
3441        assert_eq!(conflict.reason.as_deref(), Some("ConflictingPolicy"));
3442
3443        // Verify Ready=False
3444        let ready = status
3445            .conditions
3446            .iter()
3447            .find(|c| c.condition_type == "Ready")
3448            .expect("should have Ready condition");
3449        assert_eq!(ready.status, "False");
3450
3451        // Verify Degraded=True
3452        let degraded = status
3453            .conditions
3454            .iter()
3455            .find(|c| c.condition_type == "Degraded")
3456            .expect("should have Degraded condition");
3457        assert_eq!(degraded.status, "True");
3458    }
3459
3460    #[test]
3461    fn status_successful_reconcile_records_generation_and_time() {
3462        let mut status = PostgresPolicyStatus::default();
3463        let generation = Some(3_i64);
3464        let summary = ChangeSummary {
3465            roles_created: 2,
3466            total: 2,
3467            ..Default::default()
3468        };
3469
3470        // Simulate a successful reconciliation
3471        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
3472        status.conditions.retain(|c| {
3473            c.condition_type != "Reconciling"
3474                && c.condition_type != "Degraded"
3475                && c.condition_type != "Conflict"
3476                && c.condition_type != "Paused"
3477        });
3478        status.observed_generation = generation;
3479        status.last_attempted_generation = generation;
3480        status.last_successful_reconcile_time = Some(now_rfc3339());
3481        status.change_summary = Some(summary);
3482        status.last_error = None;
3483
3484        // Verify Ready=True
3485        let ready = status
3486            .conditions
3487            .iter()
3488            .find(|c| c.condition_type == "Ready")
3489            .expect("should have Ready condition");
3490        assert_eq!(ready.status, "True");
3491        assert_eq!(ready.reason.as_deref(), Some("Reconciled"));
3492
3493        // Verify generation recorded
3494        assert_eq!(status.observed_generation, Some(3));
3495        assert_eq!(status.last_attempted_generation, Some(3));
3496
3497        // Verify timestamps set
3498        assert!(status.last_successful_reconcile_time.is_some());
3499
3500        // Verify summary
3501        let summary = status.change_summary.as_ref().unwrap();
3502        assert_eq!(summary.roles_created, 2);
3503        assert_eq!(summary.total, 2);
3504
3505        // Verify no error
3506        assert!(status.last_error.is_none());
3507
3508        // Verify no Degraded/Conflict/Paused/Reconciling conditions
3509        assert!(
3510            status
3511                .conditions
3512                .iter()
3513                .all(|c| c.condition_type != "Degraded"
3514                    && c.condition_type != "Conflict"
3515                    && c.condition_type != "Paused"
3516                    && c.condition_type != "Reconciling")
3517        );
3518    }
3519
3520    #[test]
3521    fn status_suspended_workflow() {
3522        let mut status = PostgresPolicyStatus::default();
3523        let generation = Some(2_i64);
3524
3525        // Simulate a suspended reconciliation
3526        status.set_condition(paused_condition("Reconciliation suspended by spec"));
3527        status.set_condition(ready_condition(
3528            false,
3529            "Suspended",
3530            "Reconciliation suspended by spec",
3531        ));
3532        status
3533            .conditions
3534            .retain(|c| c.condition_type != "Reconciling");
3535        status.last_attempted_generation = generation;
3536        status.last_error = None;
3537
3538        // Verify Paused=True
3539        let paused = status
3540            .conditions
3541            .iter()
3542            .find(|c| c.condition_type == "Paused")
3543            .expect("should have Paused condition");
3544        assert_eq!(paused.status, "True");
3545
3546        // Verify Ready=False with Suspended reason
3547        let ready = status
3548            .conditions
3549            .iter()
3550            .find(|c| c.condition_type == "Ready")
3551            .expect("should have Ready condition");
3552        assert_eq!(ready.status, "False");
3553        assert_eq!(ready.reason.as_deref(), Some("Suspended"));
3554
3555        // Verify no Reconciling condition
3556        assert!(
3557            !status
3558                .conditions
3559                .iter()
3560                .any(|c| c.condition_type == "Reconciling")
3561        );
3562    }
3563
3564    #[test]
3565    fn status_transitions_from_degraded_to_ready() {
3566        let mut status = PostgresPolicyStatus::default();
3567
3568        // First, set degraded state
3569        status.set_condition(ready_condition(false, "InvalidSpec", "error"));
3570        status.set_condition(degraded_condition("InvalidSpec", "error"));
3571        status.last_error = Some("error".to_string());
3572
3573        assert_eq!(status.conditions.len(), 2);
3574
3575        // Then, resolve to ready
3576        status.set_condition(ready_condition(true, "Reconciled", "All changes applied"));
3577        status.conditions.retain(|c| {
3578            c.condition_type != "Reconciling"
3579                && c.condition_type != "Degraded"
3580                && c.condition_type != "Conflict"
3581                && c.condition_type != "Paused"
3582        });
3583        status.last_error = None;
3584
3585        // Verify Ready=True
3586        let ready = status
3587            .conditions
3588            .iter()
3589            .find(|c| c.condition_type == "Ready")
3590            .expect("should have Ready condition");
3591        assert_eq!(ready.status, "True");
3592
3593        // Verify Degraded removed
3594        assert!(
3595            !status
3596                .conditions
3597                .iter()
3598                .any(|c| c.condition_type == "Degraded")
3599        );
3600
3601        // Verify only Ready condition remains
3602        assert_eq!(status.conditions.len(), 1);
3603
3604        // Verify error cleared
3605        assert!(status.last_error.is_none());
3606    }
3607
3608    #[test]
3609    fn change_summary_default_is_all_zero() {
3610        let summary = ChangeSummary::default();
3611        assert_eq!(summary.roles_created, 0);
3612        assert_eq!(summary.roles_altered, 0);
3613        assert_eq!(summary.roles_dropped, 0);
3614        assert_eq!(summary.sessions_terminated, 0);
3615        assert_eq!(summary.grants_added, 0);
3616        assert_eq!(summary.grants_revoked, 0);
3617        assert_eq!(summary.default_privileges_set, 0);
3618        assert_eq!(summary.default_privileges_revoked, 0);
3619        assert_eq!(summary.members_added, 0);
3620        assert_eq!(summary.members_removed, 0);
3621        assert_eq!(summary.total, 0);
3622    }
3623
3624    #[test]
3625    fn status_serializes_to_json() {
3626        let mut status = PostgresPolicyStatus::default();
3627        status.set_condition(ready_condition(true, "Reconciled", "done"));
3628        status.observed_generation = Some(5);
3629        status.managed_database_identity = Some("ns/secret/key".to_string());
3630        status.owned_roles = vec!["role-a".to_string(), "role-b".to_string()];
3631        status.owned_schemas = vec!["public".to_string()];
3632        status.change_summary = Some(ChangeSummary {
3633            roles_created: 1,
3634            total: 1,
3635            ..Default::default()
3636        });
3637
3638        let json = serde_json::to_string(&status).expect("should serialize");
3639        assert!(json.contains("\"Reconciled\""));
3640        assert!(json.contains("\"observed_generation\":5"));
3641        assert!(json.contains("\"role-a\""));
3642        assert!(json.contains("\"ns/secret/key\""));
3643    }
3644
3645    #[test]
3646    fn crd_spec_deserializes_from_yaml() {
3647        let yaml = r#"
3648connection:
3649  secretRef:
3650    name: pg-credentials
3651interval: "10m"
3652default_owner: app_owner
3653profiles:
3654  editor:
3655    grants:
3656      - privileges: [USAGE]
3657        object: { type: schema }
3658      - privileges: [SELECT, INSERT, UPDATE, DELETE]
3659        object: { type: table, name: "*" }
3660    default_privileges:
3661      - privileges: [SELECT, INSERT, UPDATE, DELETE]
3662        on_type: table
3663schemas:
3664  - name: inventory
3665    profiles: [editor]
3666roles:
3667  - name: analytics
3668    login: true
3669grants:
3670  - role: analytics
3671    privileges: [CONNECT]
3672    object: { type: database, name: mydb }
3673memberships:
3674  - role: inventory-editor
3675    members:
3676      - name: analytics
3677retirements:
3678  - role: legacy-app
3679    reassign_owned_to: app_owner
3680    drop_owned: true
3681    terminate_sessions: true
3682"#;
3683        let spec: PostgresPolicySpec = serde_yaml::from_str(yaml).expect("should deserialize");
3684        assert_eq!(spec.interval, "10m");
3685        assert_eq!(spec.default_owner, Some("app_owner".to_string()));
3686        assert_eq!(spec.profiles.len(), 1);
3687        assert!(spec.profiles.contains_key("editor"));
3688        assert_eq!(spec.schemas.len(), 1);
3689        assert_eq!(spec.roles.len(), 1);
3690        assert_eq!(spec.grants.len(), 1);
3691        assert_eq!(spec.memberships.len(), 1);
3692        assert_eq!(spec.retirements.len(), 1);
3693        assert_eq!(spec.retirements[0].role, "legacy-app");
3694        assert!(spec.retirements[0].terminate_sessions);
3695    }
3696
3697    #[test]
3698    fn referenced_secret_names_includes_connection_secret() {
3699        let spec = PostgresPolicySpec {
3700            connection: ConnectionSpec {
3701                secret_ref: Some(SecretReference {
3702                    name: "pg-conn".to_string(),
3703                }),
3704                secret_key: Some("DATABASE_URL".to_string()),
3705                params: None,
3706            },
3707            interval: "5m".to_string(),
3708            suspend: false,
3709            mode: PolicyMode::Apply,
3710            reconciliation_mode: CrdReconciliationMode::default(),
3711            default_owner: None,
3712            profiles: std::collections::HashMap::new(),
3713            schemas: vec![],
3714            roles: vec![],
3715            grants: vec![],
3716            default_privileges: vec![],
3717            memberships: vec![],
3718            retirements: vec![],
3719            approval: None,
3720        };
3721
3722        let names = spec.referenced_secret_names("test-policy");
3723        assert!(names.contains("pg-conn"));
3724        assert_eq!(names.len(), 1);
3725    }
3726
3727    #[test]
3728    fn referenced_secret_names_includes_password_secrets() {
3729        let spec = PostgresPolicySpec {
3730            connection: ConnectionSpec {
3731                secret_ref: Some(SecretReference {
3732                    name: "pg-conn".to_string(),
3733                }),
3734                secret_key: Some("DATABASE_URL".to_string()),
3735                params: None,
3736            },
3737            interval: "5m".to_string(),
3738            suspend: false,
3739            mode: PolicyMode::Apply,
3740            reconciliation_mode: CrdReconciliationMode::default(),
3741            default_owner: None,
3742            profiles: std::collections::HashMap::new(),
3743            schemas: vec![],
3744            roles: vec![
3745                RoleSpec {
3746                    name: "role-a".to_string(),
3747                    external: false,
3748                    login: Some(true),
3749                    password: Some(PasswordSpec {
3750                        secret_ref: Some(SecretReference {
3751                            name: "role-passwords".to_string(),
3752                        }),
3753                        secret_key: Some("role-a".to_string()),
3754                        generate: None,
3755                    }),
3756                    password_valid_until: None,
3757                    config: Default::default(),
3758                    superuser: None,
3759                    createdb: None,
3760                    createrole: None,
3761                    inherit: None,
3762                    replication: None,
3763                    bypassrls: None,
3764                    connection_limit: None,
3765                    comment: None,
3766                },
3767                RoleSpec {
3768                    name: "role-b".to_string(),
3769                    external: false,
3770                    login: Some(true),
3771                    password: Some(PasswordSpec {
3772                        secret_ref: Some(SecretReference {
3773                            name: "other-secret".to_string(),
3774                        }),
3775                        secret_key: None,
3776                        generate: None,
3777                    }),
3778                    password_valid_until: None,
3779                    config: Default::default(),
3780                    superuser: None,
3781                    createdb: None,
3782                    createrole: None,
3783                    inherit: None,
3784                    replication: None,
3785                    bypassrls: None,
3786                    connection_limit: None,
3787                    comment: None,
3788                },
3789                RoleSpec {
3790                    name: "role-c".to_string(),
3791                    external: false,
3792                    login: None,
3793                    password: None,
3794                    password_valid_until: None,
3795                    config: Default::default(),
3796                    superuser: None,
3797                    createdb: None,
3798                    createrole: None,
3799                    inherit: None,
3800                    replication: None,
3801                    bypassrls: None,
3802                    connection_limit: None,
3803                    comment: None,
3804                },
3805            ],
3806            grants: vec![],
3807            default_privileges: vec![],
3808            memberships: vec![],
3809            retirements: vec![],
3810            approval: None,
3811        };
3812
3813        let names = spec.referenced_secret_names("test-policy");
3814        assert!(
3815            names.contains("pg-conn"),
3816            "should include connection secret"
3817        );
3818        assert!(
3819            names.contains("role-passwords"),
3820            "should include role-a password secret"
3821        );
3822        assert!(
3823            names.contains("other-secret"),
3824            "should include role-b password secret"
3825        );
3826        assert_eq!(names.len(), 3);
3827    }
3828
3829    #[test]
3830    fn validate_password_specs_rejects_password_without_login() {
3831        let spec = PostgresPolicySpec {
3832            connection: ConnectionSpec {
3833                secret_ref: Some(SecretReference {
3834                    name: "pg-conn".to_string(),
3835                }),
3836                secret_key: Some("DATABASE_URL".to_string()),
3837                params: None,
3838            },
3839            interval: "5m".to_string(),
3840            suspend: false,
3841            mode: PolicyMode::Apply,
3842            reconciliation_mode: CrdReconciliationMode::default(),
3843            default_owner: None,
3844            profiles: std::collections::HashMap::new(),
3845            schemas: vec![],
3846            roles: vec![RoleSpec {
3847                name: "app-user".to_string(),
3848                external: false,
3849                login: Some(false),
3850                superuser: None,
3851                createdb: None,
3852                createrole: None,
3853                inherit: None,
3854                replication: None,
3855                bypassrls: None,
3856                connection_limit: None,
3857                comment: None,
3858                password: Some(PasswordSpec {
3859                    secret_ref: Some(SecretReference {
3860                        name: "role-passwords".to_string(),
3861                    }),
3862                    secret_key: None,
3863                    generate: None,
3864                }),
3865                password_valid_until: None,
3866                config: Default::default(),
3867            }],
3868            grants: vec![],
3869            default_privileges: vec![],
3870            memberships: vec![],
3871            retirements: vec![],
3872            approval: None,
3873        };
3874
3875        assert!(matches!(
3876            spec.validate_password_specs("test-policy"),
3877            Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
3878        ));
3879    }
3880
3881    #[test]
3882    fn validate_password_specs_rejects_password_with_login_omitted() {
3883        let spec = PostgresPolicySpec {
3884            connection: ConnectionSpec {
3885                secret_ref: Some(SecretReference {
3886                    name: "pg-conn".to_string(),
3887                }),
3888                secret_key: Some("DATABASE_URL".to_string()),
3889                params: None,
3890            },
3891            interval: "5m".to_string(),
3892            suspend: false,
3893            mode: PolicyMode::Apply,
3894            reconciliation_mode: CrdReconciliationMode::default(),
3895            default_owner: None,
3896            profiles: std::collections::HashMap::new(),
3897            schemas: vec![],
3898            roles: vec![RoleSpec {
3899                name: "app-user".to_string(),
3900                external: false,
3901                login: None, // omitted, not explicitly false
3902                superuser: None,
3903                createdb: None,
3904                createrole: None,
3905                inherit: None,
3906                replication: None,
3907                bypassrls: None,
3908                connection_limit: None,
3909                comment: None,
3910                password: Some(PasswordSpec {
3911                    secret_ref: Some(SecretReference {
3912                        name: "role-passwords".to_string(),
3913                    }),
3914                    secret_key: None,
3915                    generate: None,
3916                }),
3917                password_valid_until: None,
3918                config: Default::default(),
3919            }],
3920            grants: vec![],
3921            default_privileges: vec![],
3922            memberships: vec![],
3923            retirements: vec![],
3924            approval: None,
3925        };
3926
3927        assert!(matches!(
3928            spec.validate_password_specs("test-policy"),
3929            Err(PasswordValidationError::PasswordWithoutLogin { ref role }) if role == "app-user"
3930        ));
3931    }
3932
3933    #[test]
3934    fn validate_password_specs_rejects_invalid_password_mode() {
3935        let spec = PostgresPolicySpec {
3936            connection: ConnectionSpec {
3937                secret_ref: Some(SecretReference {
3938                    name: "pg-conn".to_string(),
3939                }),
3940                secret_key: Some("DATABASE_URL".to_string()),
3941                params: None,
3942            },
3943            interval: "5m".to_string(),
3944            suspend: false,
3945            mode: PolicyMode::Apply,
3946            reconciliation_mode: CrdReconciliationMode::default(),
3947            default_owner: None,
3948            profiles: std::collections::HashMap::new(),
3949            schemas: vec![],
3950            roles: vec![RoleSpec {
3951                name: "app-user".to_string(),
3952                external: false,
3953                login: Some(true),
3954                superuser: None,
3955                createdb: None,
3956                createrole: None,
3957                inherit: None,
3958                replication: None,
3959                bypassrls: None,
3960                connection_limit: None,
3961                comment: None,
3962                password: Some(PasswordSpec {
3963                    secret_ref: Some(SecretReference {
3964                        name: "role-passwords".to_string(),
3965                    }),
3966                    secret_key: None,
3967                    generate: Some(GeneratePasswordSpec {
3968                        length: Some(32),
3969                        secret_name: None,
3970                        secret_key: None,
3971                    }),
3972                }),
3973                password_valid_until: None,
3974                config: Default::default(),
3975            }],
3976            grants: vec![],
3977            default_privileges: vec![],
3978            memberships: vec![],
3979            retirements: vec![],
3980            approval: None,
3981        };
3982
3983        assert!(matches!(
3984            spec.validate_password_specs("test-policy"),
3985            Err(PasswordValidationError::InvalidPasswordMode { ref role }) if role == "app-user"
3986        ));
3987    }
3988
3989    #[test]
3990    fn validate_password_specs_rejects_invalid_generated_length() {
3991        let spec = PostgresPolicySpec {
3992            connection: ConnectionSpec {
3993                secret_ref: Some(SecretReference {
3994                    name: "pg-conn".to_string(),
3995                }),
3996                secret_key: Some("DATABASE_URL".to_string()),
3997                params: None,
3998            },
3999            interval: "5m".to_string(),
4000            suspend: false,
4001            mode: PolicyMode::Apply,
4002            reconciliation_mode: CrdReconciliationMode::default(),
4003            default_owner: None,
4004            profiles: std::collections::HashMap::new(),
4005            schemas: vec![],
4006            roles: vec![RoleSpec {
4007                name: "app-user".to_string(),
4008                external: false,
4009                login: Some(true),
4010                superuser: None,
4011                createdb: None,
4012                createrole: None,
4013                inherit: None,
4014                replication: None,
4015                bypassrls: None,
4016                connection_limit: None,
4017                comment: None,
4018                password: Some(PasswordSpec {
4019                    secret_ref: None,
4020                    secret_key: None,
4021                    generate: Some(GeneratePasswordSpec {
4022                        length: Some(8),
4023                        secret_name: None,
4024                        secret_key: None,
4025                    }),
4026                }),
4027                password_valid_until: None,
4028                config: Default::default(),
4029            }],
4030            grants: vec![],
4031            default_privileges: vec![],
4032            memberships: vec![],
4033            retirements: vec![],
4034            approval: None,
4035        };
4036
4037        assert!(matches!(
4038            spec.validate_password_specs("test-policy"),
4039            Err(PasswordValidationError::InvalidGeneratedLength { ref role, .. }) if role == "app-user"
4040        ));
4041    }
4042
4043    #[test]
4044    fn validate_password_specs_rejects_invalid_generated_secret_key() {
4045        let spec = PostgresPolicySpec {
4046            connection: ConnectionSpec {
4047                secret_ref: Some(SecretReference {
4048                    name: "pg-conn".to_string(),
4049                }),
4050                secret_key: Some("DATABASE_URL".to_string()),
4051                params: None,
4052            },
4053            interval: "5m".to_string(),
4054            suspend: false,
4055            mode: PolicyMode::Apply,
4056            reconciliation_mode: CrdReconciliationMode::default(),
4057            default_owner: None,
4058            profiles: std::collections::HashMap::new(),
4059            schemas: vec![],
4060            roles: vec![RoleSpec {
4061                name: "app-user".to_string(),
4062                external: false,
4063                login: Some(true),
4064                superuser: None,
4065                createdb: None,
4066                createrole: None,
4067                inherit: None,
4068                replication: None,
4069                bypassrls: None,
4070                connection_limit: None,
4071                comment: None,
4072                password: Some(PasswordSpec {
4073                    secret_ref: None,
4074                    secret_key: None,
4075                    generate: Some(GeneratePasswordSpec {
4076                        length: Some(32),
4077                        secret_name: None,
4078                        secret_key: Some("bad/key".to_string()),
4079                    }),
4080                }),
4081                password_valid_until: None,
4082                config: Default::default(),
4083            }],
4084            grants: vec![],
4085            default_privileges: vec![],
4086            memberships: vec![],
4087            retirements: vec![],
4088            approval: None,
4089        };
4090
4091        assert!(matches!(
4092            spec.validate_password_specs("test-policy"),
4093            Err(PasswordValidationError::InvalidSecretKey { ref role, field, .. })
4094                if role == "app-user" && field == "generate.secretKey"
4095        ));
4096    }
4097
4098    #[test]
4099    fn validate_password_specs_rejects_invalid_generated_secret_name() {
4100        let spec = PostgresPolicySpec {
4101            connection: ConnectionSpec {
4102                secret_ref: Some(SecretReference {
4103                    name: "pg-conn".to_string(),
4104                }),
4105                secret_key: Some("DATABASE_URL".to_string()),
4106                params: None,
4107            },
4108            interval: "5m".to_string(),
4109            suspend: false,
4110            mode: PolicyMode::Apply,
4111            reconciliation_mode: CrdReconciliationMode::default(),
4112            default_owner: None,
4113            profiles: std::collections::HashMap::new(),
4114            schemas: vec![],
4115            roles: vec![RoleSpec {
4116                name: "app-user".to_string(),
4117                external: false,
4118                login: Some(true),
4119                superuser: None,
4120                createdb: None,
4121                createrole: None,
4122                inherit: None,
4123                replication: None,
4124                bypassrls: None,
4125                connection_limit: None,
4126                comment: None,
4127                password: Some(PasswordSpec {
4128                    secret_ref: None,
4129                    secret_key: None,
4130                    generate: Some(GeneratePasswordSpec {
4131                        length: Some(32),
4132                        secret_name: Some("Bad_Name".to_string()),
4133                        secret_key: None,
4134                    }),
4135                }),
4136                password_valid_until: None,
4137                config: Default::default(),
4138            }],
4139            grants: vec![],
4140            default_privileges: vec![],
4141            memberships: vec![],
4142            retirements: vec![],
4143            approval: None,
4144        };
4145
4146        assert!(matches!(
4147            spec.validate_password_specs("test-policy"),
4148            Err(PasswordValidationError::InvalidGeneratedSecretName { ref role, .. }) if role == "app-user"
4149        ));
4150    }
4151
4152    #[test]
4153    fn validate_password_specs_rejects_reserved_generated_secret_key() {
4154        let spec = PostgresPolicySpec {
4155            connection: ConnectionSpec {
4156                secret_ref: Some(SecretReference {
4157                    name: "pg-conn".to_string(),
4158                }),
4159                secret_key: Some("DATABASE_URL".to_string()),
4160                params: None,
4161            },
4162            interval: "5m".to_string(),
4163            suspend: false,
4164            mode: PolicyMode::Apply,
4165            reconciliation_mode: CrdReconciliationMode::default(),
4166            default_owner: None,
4167            profiles: std::collections::HashMap::new(),
4168            schemas: vec![],
4169            roles: vec![RoleSpec {
4170                name: "app-user".to_string(),
4171                external: false,
4172                login: Some(true),
4173                superuser: None,
4174                createdb: None,
4175                createrole: None,
4176                inherit: None,
4177                replication: None,
4178                bypassrls: None,
4179                connection_limit: None,
4180                comment: None,
4181                password: Some(PasswordSpec {
4182                    secret_ref: None,
4183                    secret_key: None,
4184                    generate: Some(GeneratePasswordSpec {
4185                        length: Some(32),
4186                        secret_name: None,
4187                        secret_key: Some("verifier".to_string()),
4188                    }),
4189                }),
4190                password_valid_until: None,
4191                config: Default::default(),
4192            }],
4193            grants: vec![],
4194            default_privileges: vec![],
4195            memberships: vec![],
4196            retirements: vec![],
4197            approval: None,
4198        };
4199
4200        assert!(matches!(
4201            spec.validate_password_specs("test-policy"),
4202            Err(PasswordValidationError::ReservedGeneratedSecretKey { ref role, ref key })
4203                if role == "app-user" && key == "verifier"
4204        ));
4205    }
4206
4207    #[test]
4208    fn plan_crd_generates_valid_schema() {
4209        let crd = PostgresPolicyPlan::crd();
4210        let yaml = serde_yaml::to_string(&crd).expect("CRD should serialize to YAML");
4211        assert!(yaml.contains("pgroles.io"), "group should be pgroles.io");
4212        assert!(yaml.contains("v1alpha1"), "version should be v1alpha1");
4213        assert!(
4214            yaml.contains("PostgresPolicyPlan"),
4215            "kind should be PostgresPolicyPlan"
4216        );
4217        assert!(yaml.contains("pgplan"), "should have shortname pgplan");
4218    }
4219
4220    #[test]
4221    fn plan_phase_display() {
4222        assert_eq!(PlanPhase::Pending.to_string(), "Pending");
4223        assert_eq!(PlanPhase::Approved.to_string(), "Approved");
4224        assert_eq!(PlanPhase::Applying.to_string(), "Applying");
4225        assert_eq!(PlanPhase::Applied.to_string(), "Applied");
4226        assert_eq!(PlanPhase::Failed.to_string(), "Failed");
4227        assert_eq!(PlanPhase::Superseded.to_string(), "Superseded");
4228    }
4229
4230    #[test]
4231    fn plan_phase_default_is_pending() {
4232        assert_eq!(PlanPhase::default(), PlanPhase::Pending);
4233    }
4234
4235    #[test]
4236    fn effective_approval_infers_from_mode() {
4237        let base = PostgresPolicySpec {
4238            connection: ConnectionSpec {
4239                secret_ref: Some(SecretReference {
4240                    name: "test".into(),
4241                }),
4242                secret_key: Some("DATABASE_URL".into()),
4243                params: None,
4244            },
4245            interval: "5m".into(),
4246            suspend: false,
4247            mode: PolicyMode::Apply,
4248            reconciliation_mode: CrdReconciliationMode::Authoritative,
4249            default_owner: None,
4250            profiles: Default::default(),
4251            schemas: vec![],
4252            roles: vec![],
4253            grants: vec![],
4254            default_privileges: vec![],
4255            memberships: vec![],
4256            retirements: vec![],
4257            approval: None,
4258        };
4259
4260        // apply mode with no explicit approval → Auto
4261        assert_eq!(base.effective_approval(), ApprovalMode::Auto);
4262
4263        // plan mode with no explicit approval → Manual
4264        let plan = PostgresPolicySpec {
4265            mode: PolicyMode::Plan,
4266            ..base.clone()
4267        };
4268        assert_eq!(plan.effective_approval(), ApprovalMode::Manual);
4269
4270        // explicit Manual overrides apply mode
4271        let explicit = PostgresPolicySpec {
4272            approval: Some(ApprovalMode::Manual),
4273            ..base.clone()
4274        };
4275        assert_eq!(explicit.effective_approval(), ApprovalMode::Manual);
4276    }
4277
4278    #[test]
4279    fn approval_mode_serde_roundtrip() {
4280        // Deserialize
4281        let manual: ApprovalMode = serde_json::from_str("\"manual\"").unwrap();
4282        assert_eq!(manual, ApprovalMode::Manual);
4283        let auto: ApprovalMode = serde_json::from_str("\"auto\"").unwrap();
4284        assert_eq!(auto, ApprovalMode::Auto);
4285
4286        // Serialize back
4287        let manual_json = serde_json::to_value(&ApprovalMode::Manual).unwrap();
4288        assert_eq!(manual_json, serde_json::Value::String("manual".to_string()));
4289        let auto_json = serde_json::to_value(&ApprovalMode::Auto).unwrap();
4290        assert_eq!(auto_json, serde_json::Value::String("auto".to_string()));
4291    }
4292
4293    #[test]
4294    fn plan_status_default_is_empty() {
4295        let status = PostgresPolicyPlanStatus::default();
4296        assert_eq!(status.phase, PlanPhase::Pending);
4297        assert!(status.conditions.is_empty());
4298        assert!(status.change_summary.is_none());
4299        assert!(status.sql_ref.is_none());
4300        assert!(status.sql_inline.is_none());
4301        assert!(status.computed_at.is_none());
4302        assert!(status.applied_at.is_none());
4303        assert!(status.last_error.is_none());
4304    }
4305
4306    #[test]
4307    fn spec_without_approval_field_deserializes_as_none() {
4308        let json = serde_json::json!({
4309            "connection": {
4310                "secretRef": { "name": "pg-secret" },
4311                "secretKey": "DATABASE_URL"
4312            },
4313            "interval": "5m",
4314            "suspend": false,
4315            "mode": "apply",
4316            "reconciliation_mode": "authoritative"
4317        });
4318
4319        let spec: PostgresPolicySpec =
4320            serde_json::from_value(json).expect("should deserialize without approval field");
4321        assert!(
4322            spec.approval.is_none(),
4323            "approval should be None when omitted"
4324        );
4325        assert_eq!(
4326            spec.effective_approval(),
4327            ApprovalMode::Auto,
4328            "effective_approval should infer Auto from apply mode"
4329        );
4330    }
4331
4332    #[test]
4333    fn status_without_current_plan_ref_deserializes_as_none() {
4334        let json = serde_json::json!({
4335            "conditions": [],
4336            "owned_roles": [],
4337            "owned_schemas": []
4338        });
4339
4340        let status: PostgresPolicyStatus =
4341            serde_json::from_value(json).expect("should deserialize without current_plan_ref");
4342        assert!(
4343            status.current_plan_ref.is_none(),
4344            "current_plan_ref should be None when omitted"
4345        );
4346    }
4347
4348    #[test]
4349    fn effective_approval_explicit_auto_overrides_plan_mode() {
4350        let spec = PostgresPolicySpec {
4351            connection: ConnectionSpec {
4352                secret_ref: Some(SecretReference {
4353                    name: "test".into(),
4354                }),
4355                secret_key: Some("DATABASE_URL".into()),
4356                params: None,
4357            },
4358            interval: "5m".into(),
4359            suspend: false,
4360            mode: PolicyMode::Plan,
4361            reconciliation_mode: CrdReconciliationMode::Authoritative,
4362            default_owner: None,
4363            profiles: Default::default(),
4364            schemas: vec![],
4365            roles: vec![],
4366            grants: vec![],
4367            default_privileges: vec![],
4368            memberships: vec![],
4369            retirements: vec![],
4370            approval: Some(ApprovalMode::Auto),
4371        };
4372
4373        assert_eq!(
4374            spec.effective_approval(),
4375            ApprovalMode::Auto,
4376            "explicit Auto should override Plan mode's default of Manual"
4377        );
4378    }
4379
4380    #[test]
4381    fn plan_phase_rejected_display() {
4382        assert_eq!(PlanPhase::Rejected.to_string(), "Rejected");
4383    }
4384
4385    #[test]
4386    fn plan_phase_all_variants_display() {
4387        let variants = [
4388            PlanPhase::Pending,
4389            PlanPhase::Approved,
4390            PlanPhase::Applying,
4391            PlanPhase::Applied,
4392            PlanPhase::Failed,
4393            PlanPhase::Superseded,
4394            PlanPhase::Rejected,
4395        ];
4396        for variant in &variants {
4397            let display = variant.to_string();
4398            assert!(
4399                !display.is_empty(),
4400                "PlanPhase::{variant:?} should have non-empty Display output"
4401            );
4402        }
4403    }
4404
4405    #[test]
4406    fn plan_status_defaults() {
4407        let status = PostgresPolicyPlanStatus::default();
4408        assert_eq!(status.phase, PlanPhase::Pending);
4409        assert!(status.conditions.is_empty());
4410        assert!(status.sql_ref.is_none());
4411        assert!(status.sql_hash.is_none());
4412        assert!(status.sql_inline.is_none());
4413        assert!(!status.sql_truncated);
4414        assert!(status.redacted_sql_hash.is_none());
4415        assert!(status.sql_original_bytes.is_none());
4416        assert!(status.sql_stored_bytes.is_none());
4417        assert!(status.change_summary.is_none());
4418        assert!(status.computed_at.is_none());
4419        assert!(status.applied_at.is_none());
4420        assert!(status.last_error.is_none());
4421    }
4422
4423    #[test]
4424    fn sql_ref_missing_compression_deserializes_as_uncompressed_legacy_shape() {
4425        let json = serde_json::json!({
4426            "name": "legacy-plan-sql",
4427            "key": "plan.sql"
4428        });
4429
4430        let sql_ref: SqlRef = serde_json::from_value(json).expect("legacy SqlRef should decode");
4431
4432        assert_eq!(sql_ref.name, "legacy-plan-sql");
4433        assert_eq!(sql_ref.key, "plan.sql");
4434        assert_eq!(sql_ref.compression, None);
4435    }
4436
4437    #[test]
4438    fn plan_spec_camel_case_serialization() {
4439        let spec = PostgresPolicyPlanSpec {
4440            policy_ref: PolicyPlanRef {
4441                name: "my-policy".into(),
4442            },
4443            policy_generation: 3,
4444            reconciliation_mode: CrdReconciliationMode::Authoritative,
4445            owned_roles: vec!["role-a".into()],
4446            owned_schemas: vec!["public".into()],
4447            managed_database_identity: "ns/secret/key".into(),
4448            origin: None,
4449            scope: None,
4450        };
4451
4452        let json = serde_json::to_value(&spec).expect("should serialize to JSON");
4453        let obj = json.as_object().expect("should be a JSON object");
4454
4455        assert!(
4456            obj.contains_key("policyRef"),
4457            "should use camelCase: policyRef"
4458        );
4459        assert!(
4460            obj.contains_key("policyGeneration"),
4461            "should use camelCase: policyGeneration"
4462        );
4463        assert!(
4464            obj.contains_key("reconciliationMode"),
4465            "should use camelCase: reconciliationMode"
4466        );
4467        assert!(
4468            obj.contains_key("ownedRoles"),
4469            "should use camelCase: ownedRoles"
4470        );
4471        assert!(
4472            obj.contains_key("ownedSchemas"),
4473            "should use camelCase: ownedSchemas"
4474        );
4475        assert!(
4476            obj.contains_key("managedDatabaseIdentity"),
4477            "should use camelCase: managedDatabaseIdentity"
4478        );
4479    }
4480
4481    fn resolved_bundle(memberships: Vec<ResolvedEphemeralMembership>) -> ResolvedEphemeralAccess {
4482        ResolvedEphemeralAccess {
4483            access_policy_uid: "access-uid".into(),
4484            access_policy_generation: 1,
4485            target_policy_uid: "target-uid".into(),
4486            target_policy_generation: 2,
4487            target_database_fingerprint: "sha256:database".into(),
4488            granted_duration: "1800s".into(),
4489            bundle_encoding: EPHEMERAL_BUNDLE_ENCODING_V1.into(),
4490            bundle_hash: String::new(),
4491            memberships,
4492        }
4493    }
4494
4495    #[test]
4496    fn ephemeral_bundle_hash_is_order_independent_and_uid_independent() {
4497        let first = ResolvedEphemeralMembership {
4498            role: "editor".into(),
4499            member: "alice@example.com".into(),
4500            inherit: false,
4501        };
4502        let second = ResolvedEphemeralMembership {
4503            role: "auditor".into(),
4504            member: "alice@example.com".into(),
4505            inherit: true,
4506        };
4507        let original = resolved_bundle(vec![first.clone(), second.clone()]);
4508        let mut reordered = resolved_bundle(vec![second, first]);
4509        reordered.access_policy_uid = "replacement-uid".into();
4510        reordered.target_policy_generation = 99;
4511
4512        assert_eq!(
4513            original.compute_bundle_hash(),
4514            reordered.compute_bundle_hash()
4515        );
4516    }
4517
4518    #[test]
4519    fn ephemeral_bundle_hash_covers_membership_options() {
4520        let original = resolved_bundle(vec![ResolvedEphemeralMembership {
4521            role: "editor".into(),
4522            member: "alice@example.com".into(),
4523            inherit: false,
4524        }]);
4525        let changed = resolved_bundle(vec![ResolvedEphemeralMembership {
4526            role: "editor".into(),
4527            member: "alice@example.com".into(),
4528            inherit: true,
4529        }]);
4530        assert_ne!(
4531            original.compute_bundle_hash(),
4532            changed.compute_bundle_hash()
4533        );
4534    }
4535
4536    #[test]
4537    fn ephemeral_bundle_hash_covers_database_target() {
4538        let original = resolved_bundle(Vec::new());
4539        let mut retargeted = original.clone();
4540        retargeted.target_database_fingerprint = "sha256:other-database".into();
4541
4542        assert_ne!(
4543            original.compute_bundle_hash(),
4544            retargeted.compute_bundle_hash()
4545        );
4546    }
4547
4548    #[test]
4549    fn ephemeral_request_crd_contains_immutability_rules() {
4550        let json = serde_json::to_string(&EphemeralAccessRequest::crd())
4551            .expect("request CRD should serialize");
4552        assert!(json.contains("request spec is immutable"));
4553        assert!(json.contains("resolvedAccess is write-once"));
4554        assert!(json.contains("approval decisions are terminal"));
4555        assert!(json.contains("decision identity is write-once"));
4556        assert!(json.contains(
4557            "a terminal approval decision and decidedBy identity must be recorded together"
4558        ));
4559        assert!(json.contains(r#""requestedBy""#));
4560        assert!(json.contains(r#""decidedBy""#));
4561        assert!(json.contains(r#""maxItems":8"#));
4562        assert!(json.contains(r#""pattern":"^([0-9]+[smh])+$""#));
4563    }
4564
4565    fn assert_bounded_strings_and_collections(schema: &serde_json::Value, path: &str) {
4566        match schema {
4567            serde_json::Value::Object(object) => {
4568                match object.get("type").and_then(serde_json::Value::as_str) {
4569                    Some("string") if !object.contains_key("enum") => {
4570                        assert!(
4571                            object.contains_key("maxLength"),
4572                            "unbounded string schema at {path}"
4573                        );
4574                    }
4575                    Some("array") => {
4576                        assert!(
4577                            object.contains_key("maxItems"),
4578                            "unbounded collection schema at {path}"
4579                        );
4580                    }
4581                    _ => {}
4582                }
4583                for (name, child) in object {
4584                    assert_bounded_strings_and_collections(child, &format!("{path}.{name}"));
4585                }
4586            }
4587            serde_json::Value::Array(items) => {
4588                for (index, child) in items.iter().enumerate() {
4589                    assert_bounded_strings_and_collections(child, &format!("{path}[{index}]"));
4590                }
4591            }
4592            _ => {}
4593        }
4594    }
4595
4596    #[test]
4597    fn ephemeral_crds_bound_every_string_and_collection() {
4598        for (kind, crd) in [
4599            ("EphemeralAccessPolicy", EphemeralAccessPolicy::crd()),
4600            ("EphemeralAccessRequest", EphemeralAccessRequest::crd()),
4601        ] {
4602            let value = serde_json::to_value(crd).expect("CRD should serialize");
4603            let schema = &value["spec"]["versions"][0]["schema"]["openAPIV3Schema"];
4604            assert_bounded_strings_and_collections(schema, kind);
4605        }
4606    }
4607
4608    #[test]
4609    fn ephemeral_policy_crd_exposes_operational_columns() {
4610        let json = serde_json::to_string(&EphemeralAccessPolicy::crd())
4611            .expect("policy CRD should serialize");
4612        assert!(json.contains("postgresPolicyRef"));
4613        assert!(json.contains("maximumDuration"));
4614        assert!(json.contains("Accepted"));
4615        assert!(json.contains(r#""minItems":1"#));
4616        assert!(json.contains(r#""pattern":"^([0-9]+[smh])+$""#));
4617    }
4618}