Skip to main content

pgroles_core/
manifest.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5
6// ---------------------------------------------------------------------------
7// Errors
8// ---------------------------------------------------------------------------
9
10#[derive(Debug, Error)]
11pub enum ManifestError {
12    #[error("YAML parse error: {0}")]
13    Yaml(#[from] serde_yaml::Error),
14
15    #[error("duplicate role name: \"{0}\"")]
16    DuplicateRole(String),
17
18    #[error("duplicate schema name: \"{0}\"")]
19    DuplicateSchema(String),
20
21    #[error("profile \"{0}\" referenced by schema \"{1}\" is not defined")]
22    UndefinedProfile(String, String),
23
24    #[error("role_pattern must contain {{profile}} placeholder, got: \"{0}\"")]
25    InvalidRolePattern(String),
26
27    #[error("top-level default privilege for schema \"{schema}\" must specify grant.role")]
28    MissingDefaultPrivilegeRole { schema: String },
29
30    #[error("duplicate retirement entry for role: \"{0}\"")]
31    DuplicateRetirement(String),
32
33    #[error("retirement entry for role \"{0}\" conflicts with a desired role of the same name")]
34    RetirementRoleStillDesired(String),
35
36    #[error("retirement entry for role \"{role}\" cannot reassign ownership to itself")]
37    RetirementSelfReassign { role: String },
38
39    #[error(
40        "role \"{role}\" has a password but login is not enabled — password will have no effect"
41    )]
42    PasswordWithoutLogin { role: String },
43
44    #[error(
45        "role \"{role}\" has an invalid password_valid_until value \"{value}\": expected ISO 8601 timestamp (e.g. \"2025-12-31T00:00:00Z\")"
46    )]
47    InvalidValidUntil { role: String, value: String },
48}
49
50// ---------------------------------------------------------------------------
51// Enums
52// ---------------------------------------------------------------------------
53
54/// PostgreSQL object types that can have privileges granted on them.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
56#[serde(rename_all = "snake_case")]
57pub enum ObjectType {
58    Table,
59    View,
60    #[serde(alias = "materialized_view")]
61    MaterializedView,
62    Sequence,
63    Function,
64    Schema,
65    Database,
66    Type,
67}
68
69impl std::fmt::Display for ObjectType {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            ObjectType::Table => write!(f, "table"),
73            ObjectType::View => write!(f, "view"),
74            ObjectType::MaterializedView => write!(f, "materialized_view"),
75            ObjectType::Sequence => write!(f, "sequence"),
76            ObjectType::Function => write!(f, "function"),
77            ObjectType::Schema => write!(f, "schema"),
78            ObjectType::Database => write!(f, "database"),
79            ObjectType::Type => write!(f, "type"),
80        }
81    }
82}
83
84/// PostgreSQL privilege types.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "UPPERCASE")]
87pub enum Privilege {
88    Select,
89    Insert,
90    Update,
91    Delete,
92    Truncate,
93    References,
94    Trigger,
95    Execute,
96    Usage,
97    Create,
98    Connect,
99    Temporary,
100}
101
102impl std::fmt::Display for Privilege {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        match self {
105            Privilege::Select => write!(f, "SELECT"),
106            Privilege::Insert => write!(f, "INSERT"),
107            Privilege::Update => write!(f, "UPDATE"),
108            Privilege::Delete => write!(f, "DELETE"),
109            Privilege::Truncate => write!(f, "TRUNCATE"),
110            Privilege::References => write!(f, "REFERENCES"),
111            Privilege::Trigger => write!(f, "TRIGGER"),
112            Privilege::Execute => write!(f, "EXECUTE"),
113            Privilege::Usage => write!(f, "USAGE"),
114            Privilege::Create => write!(f, "CREATE"),
115            Privilege::Connect => write!(f, "CONNECT"),
116            Privilege::Temporary => write!(f, "TEMPORARY"),
117        }
118    }
119}
120
121// ---------------------------------------------------------------------------
122// YAML manifest types
123// ---------------------------------------------------------------------------
124
125/// Top-level policy manifest — the YAML file that users write.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PolicyManifest {
128    /// Default owner for ALTER DEFAULT PRIVILEGES (e.g. "app_owner").
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub default_owner: Option<String>,
131
132    /// Cloud auth provider configurations for IAM-mapped role awareness.
133    #[serde(default)]
134    pub auth_providers: Vec<AuthProvider>,
135
136    /// Reusable privilege profiles.
137    #[serde(default)]
138    pub profiles: HashMap<String, Profile>,
139
140    /// Schema bindings that expand profiles into concrete roles/grants.
141    #[serde(default)]
142    pub schemas: Vec<SchemaBinding>,
143
144    /// One-off role definitions (not from profiles).
145    #[serde(default)]
146    pub roles: Vec<RoleDefinition>,
147
148    /// One-off grants (not from profiles).
149    #[serde(default)]
150    pub grants: Vec<Grant>,
151
152    /// One-off default privileges (not from profiles).
153    #[serde(default)]
154    pub default_privileges: Vec<DefaultPrivilege>,
155
156    /// Membership edges (opt-in).
157    #[serde(default)]
158    pub memberships: Vec<Membership>,
159
160    /// Explicit role-retirement workflows for roles that should be removed.
161    #[serde(default)]
162    pub retirements: Vec<RoleRetirement>,
163}
164
165/// Cloud authentication provider configuration.
166///
167/// Declares awareness of cloud IAM-mapped roles so pgroles can correctly
168/// reference auto-created role names in grants and memberships.
169#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
170#[serde(tag = "type", rename_all = "snake_case")]
171pub enum AuthProvider {
172    /// Google Cloud SQL IAM authentication.
173    /// Service accounts map to PG roles like `user@project.iam`.
174    CloudSqlIam {
175        /// GCP project ID (for documentation/validation).
176        #[serde(default)]
177        project: Option<String>,
178    },
179    /// Google AlloyDB IAM authentication.
180    /// IAM users and groups map to PostgreSQL roles managed by AlloyDB.
181    #[serde(rename = "alloydb_iam")]
182    AlloyDbIam {
183        /// GCP project ID (for documentation/validation).
184        #[serde(default)]
185        project: Option<String>,
186        /// AlloyDB cluster name (for documentation/validation).
187        #[serde(default)]
188        cluster: Option<String>,
189    },
190    /// AWS RDS IAM authentication.
191    /// IAM users authenticate via token; the PG role must have `rds_iam` granted.
192    RdsIam {
193        /// AWS region (for documentation/validation).
194        #[serde(default)]
195        region: Option<String>,
196    },
197    /// Azure Entra ID (AAD) authentication for Azure Database for PostgreSQL.
198    AzureAd {
199        /// Azure tenant ID (for documentation/validation).
200        #[serde(default)]
201        tenant_id: Option<String>,
202    },
203    /// Supabase-managed PostgreSQL authentication.
204    Supabase {
205        /// Supabase project ref (for documentation/validation).
206        #[serde(default)]
207        project_ref: Option<String>,
208    },
209    /// PlanetScale PostgreSQL authentication metadata.
210    PlanetScale {
211        /// PlanetScale organization (for documentation/validation).
212        #[serde(default)]
213        organization: Option<String>,
214    },
215}
216
217/// A reusable privilege profile — defines what grants a role should have.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Profile {
220    #[serde(default)]
221    pub login: Option<bool>,
222
223    #[serde(default)]
224    pub inherit: Option<bool>,
225
226    #[serde(default)]
227    pub grants: Vec<ProfileGrant>,
228
229    #[serde(default)]
230    pub default_privileges: Vec<DefaultPrivilegeGrant>,
231}
232
233/// A grant template within a profile (schema is filled in during expansion).
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct ProfileGrant {
236    pub privileges: Vec<Privilege>,
237    #[serde(alias = "on")]
238    pub object: ProfileObjectTarget,
239}
240
241/// Object target within a profile — schema is omitted (filled during expansion).
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ProfileObjectTarget {
244    #[serde(rename = "type")]
245    pub object_type: ObjectType,
246    /// Object name, or "*" for all objects of this type. Omit for schema-level grants.
247    #[serde(default)]
248    pub name: Option<String>,
249}
250
251/// A schema binding — associates a schema with one or more profiles.
252#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
253pub struct SchemaBinding {
254    pub name: String,
255
256    #[serde(default)]
257    pub profiles: Vec<String>,
258
259    /// Role naming pattern. Supports `{schema}` and `{profile}` placeholders.
260    /// Defaults to `"{schema}-{profile}"`.
261    #[serde(default = "default_role_pattern")]
262    pub role_pattern: String,
263
264    /// Override default_owner for this schema's default privileges.
265    #[serde(default)]
266    pub owner: Option<String>,
267}
268
269#[derive(
270    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
271)]
272#[serde(rename_all = "snake_case")]
273pub enum SchemaBindingFacet {
274    Owner,
275    Bindings,
276}
277
278impl std::fmt::Display for SchemaBindingFacet {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        match self {
281            SchemaBindingFacet::Owner => write!(f, "owner"),
282            SchemaBindingFacet::Bindings => write!(f, "bindings"),
283        }
284    }
285}
286
287pub(crate) fn default_role_pattern() -> String {
288    "{schema}-{profile}".to_string()
289}
290
291/// A concrete role definition.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct RoleDefinition {
294    pub name: String,
295
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub login: Option<bool>,
298
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub superuser: Option<bool>,
301
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub createdb: Option<bool>,
304
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub createrole: Option<bool>,
307
308    #[serde(default, skip_serializing_if = "Option::is_none")]
309    pub inherit: Option<bool>,
310
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub replication: Option<bool>,
313
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub bypassrls: Option<bool>,
316
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub connection_limit: Option<i32>,
319
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub comment: Option<String>,
322
323    /// Password source for this role. Passwords are never stored in the manifest
324    /// directly — only a reference to an environment variable is allowed.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub password: Option<PasswordSource>,
327
328    /// Password expiration timestamp (ISO 8601, e.g. "2025-12-31T00:00:00Z").
329    /// Maps to PostgreSQL's `VALID UNTIL` clause.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub password_valid_until: Option<String>,
332}
333
334/// Source for a role password. Passwords are never stored in YAML manifests.
335///
336/// This follows the same security model as `DATABASE_URL` — secrets come from
337/// the runtime environment, not from configuration files.
338#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
339pub struct PasswordSource {
340    /// Name of the environment variable containing the password.
341    pub from_env: String,
342}
343
344/// A concrete grant on a specific object or wildcard.
345#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
346pub struct Grant {
347    pub role: String,
348    pub privileges: Vec<Privilege>,
349    #[serde(alias = "on")]
350    pub object: ObjectTarget,
351}
352
353/// Target object for a grant.
354#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
355pub struct ObjectTarget {
356    #[serde(rename = "type")]
357    pub object_type: ObjectType,
358
359    /// Schema name. Required for most object types except database.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub schema: Option<String>,
362
363    /// Object name, or "*" for all objects. Omit for schema-level grants.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub name: Option<String>,
366}
367
368/// Default privilege configuration.
369#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
370pub struct DefaultPrivilege {
371    /// The role that owns newly created objects. If omitted, uses manifest's default_owner.
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub owner: Option<String>,
374
375    pub schema: String,
376
377    pub grant: Vec<DefaultPrivilegeGrant>,
378}
379
380/// A single default privilege grant entry.
381#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
382pub struct DefaultPrivilegeGrant {
383    /// The role receiving the default privilege. Only used in top-level default_privileges
384    /// (in profiles, the role is determined by expansion).
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub role: Option<String>,
387
388    pub privileges: Vec<Privilege>,
389    pub on_type: ObjectType,
390}
391
392/// A membership declaration — which members belong to a role.
393#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
394pub struct Membership {
395    pub role: String,
396    pub members: Vec<MemberSpec>,
397}
398
399/// A single member of a role.
400///
401/// Both `inherit` and `admin` are optional. When omitted, they default to
402/// `inherit: true` and `admin: false` at resolution time (in `RoleGraph`
403/// construction). Keeping them optional in the CRD avoids Kubernetes
404/// injecting default values into the stored resource, which causes
405/// perpetual diffs in GitOps tools like ArgoCD.
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
407pub struct MemberSpec {
408    pub name: String,
409
410    /// Whether the member inherits the role's privileges. Defaults to `true`.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub inherit: Option<bool>,
413
414    /// Whether the member can administer the role. Defaults to `false`.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub admin: Option<bool>,
417}
418
419impl MemberSpec {
420    /// Resolve `inherit` with its default (true).
421    pub fn inherit(&self) -> bool {
422        self.inherit.unwrap_or(true)
423    }
424
425    /// Resolve `admin` with its default (false).
426    pub fn admin(&self) -> bool {
427        self.admin.unwrap_or(false)
428    }
429}
430
431/// Declarative workflow for retiring an existing role.
432#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
433pub struct RoleRetirement {
434    /// The role to retire and ultimately drop.
435    pub role: String,
436
437    /// Optional successor role for `REASSIGN OWNED BY ... TO ...`.
438    #[serde(default)]
439    pub reassign_owned_to: Option<String>,
440
441    /// Whether to run `DROP OWNED BY` before dropping the role.
442    #[serde(default)]
443    pub drop_owned: bool,
444
445    /// Whether to terminate other active sessions for the role before drop.
446    #[serde(default)]
447    pub terminate_sessions: bool,
448}
449
450// ---------------------------------------------------------------------------
451// Expanded manifest — the result of profile expansion
452// ---------------------------------------------------------------------------
453
454/// The fully expanded policy — all profiles resolved into concrete roles, grants,
455/// default privileges, and memberships. Ready to be converted into a `RoleGraph`.
456#[derive(Debug, Clone)]
457pub struct ExpandedManifest {
458    pub schemas: Vec<ExpandedSchema>,
459    pub roles: Vec<RoleDefinition>,
460    pub grants: Vec<Grant>,
461    pub default_privileges: Vec<DefaultPrivilege>,
462    pub memberships: Vec<Membership>,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct ExpandedSchema {
467    pub name: String,
468    pub owner: Option<String>,
469}
470
471// ---------------------------------------------------------------------------
472// Expansion logic
473// ---------------------------------------------------------------------------
474
475/// Parse a YAML string into a `PolicyManifest`.
476///
477/// Accepts both bare manifests and Kubernetes CustomResource wrappers.
478/// If the YAML contains an `apiVersion` and `spec` field, the `spec` is
479/// extracted and parsed as a `PolicyManifest`.
480pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
481    // Check if this looks like a Kubernetes CR wrapper.
482    let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
483    if let serde_yaml::Value::Mapping(ref map) = value {
484        let api_version_key = serde_yaml::Value::String("apiVersion".into());
485        let spec_key = serde_yaml::Value::String("spec".into());
486        if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
487            let spec = map.get(&spec_key).ok_or_else(|| {
488                ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
489            })?;
490            let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
491            return Ok(manifest);
492        }
493    }
494    let manifest: PolicyManifest = serde_yaml::from_value(value)?;
495    Ok(manifest)
496}
497
498/// Expand a `PolicyManifest` by resolving all `profiles × schemas` into concrete
499/// roles, grants, and default privileges. Merges with one-off definitions.
500/// Validates no duplicate role names.
501pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
502    let mut seen_schemas: HashSet<String> = HashSet::new();
503    for schema_binding in &manifest.schemas {
504        if !seen_schemas.insert(schema_binding.name.clone()) {
505            return Err(ManifestError::DuplicateSchema(schema_binding.name.clone()));
506        }
507    }
508
509    let schemas: Vec<ExpandedSchema> = manifest
510        .schemas
511        .iter()
512        .map(|schema_binding| ExpandedSchema {
513            name: schema_binding.name.clone(),
514            owner: schema_binding
515                .owner
516                .clone()
517                .or(manifest.default_owner.clone()),
518        })
519        .collect();
520    let mut roles: Vec<RoleDefinition> = Vec::new();
521    let mut grants: Vec<Grant> = Vec::new();
522    let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
523
524    // Expand each schema × profile combination
525    for schema_binding in &manifest.schemas {
526        for profile_name in &schema_binding.profiles {
527            let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
528                ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
529            })?;
530
531            // Validate pattern contains {profile}
532            if !schema_binding.role_pattern.contains("{profile}") {
533                return Err(ManifestError::InvalidRolePattern(
534                    schema_binding.role_pattern.clone(),
535                ));
536            }
537
538            // Generate role name from pattern
539            let role_name = schema_binding
540                .role_pattern
541                .replace("{schema}", &schema_binding.name)
542                .replace("{profile}", profile_name);
543
544            // Create role definition
545            roles.push(RoleDefinition {
546                name: role_name.clone(),
547                login: profile.login,
548                superuser: None,
549                createdb: None,
550                createrole: None,
551                inherit: profile.inherit,
552                replication: None,
553                bypassrls: None,
554                connection_limit: None,
555                comment: Some(format!(
556                    "Generated from profile '{profile_name}' for schema '{}'",
557                    schema_binding.name
558                )),
559                password: None,
560                password_valid_until: None,
561            });
562
563            // Expand profile grants — fill in schema
564            for profile_grant in &profile.grants {
565                let object_target = match profile_grant.object.object_type {
566                    ObjectType::Schema => ObjectTarget {
567                        object_type: ObjectType::Schema,
568                        schema: None,
569                        name: Some(schema_binding.name.clone()),
570                    },
571                    _ => ObjectTarget {
572                        object_type: profile_grant.object.object_type,
573                        schema: Some(schema_binding.name.clone()),
574                        name: profile_grant.object.name.clone(),
575                    },
576                };
577
578                grants.push(Grant {
579                    role: role_name.clone(),
580                    privileges: profile_grant.privileges.clone(),
581                    object: object_target,
582                });
583            }
584
585            // Expand profile default privileges
586            if !profile.default_privileges.is_empty() {
587                let owner = schema_binding
588                    .owner
589                    .clone()
590                    .or(manifest.default_owner.clone());
591
592                let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
593                    .default_privileges
594                    .iter()
595                    .map(|dp| DefaultPrivilegeGrant {
596                        role: Some(role_name.clone()),
597                        privileges: dp.privileges.clone(),
598                        on_type: dp.on_type,
599                    })
600                    .collect();
601
602                default_privileges.push(DefaultPrivilege {
603                    owner,
604                    schema: schema_binding.name.clone(),
605                    grant: expanded_grants,
606                });
607            }
608        }
609    }
610
611    // Top-level default privileges must always identify the grantee role.
612    for default_priv in &manifest.default_privileges {
613        for grant in &default_priv.grant {
614            if grant.role.is_none() {
615                return Err(ManifestError::MissingDefaultPrivilegeRole {
616                    schema: default_priv.schema.clone(),
617                });
618            }
619        }
620    }
621
622    // Merge one-off definitions
623    roles.extend(manifest.roles.clone());
624    grants.extend(manifest.grants.clone());
625    default_privileges.extend(manifest.default_privileges.clone());
626    let memberships = manifest.memberships.clone();
627
628    // Validate no duplicate role names
629    let mut seen_roles: HashSet<String> = HashSet::new();
630    for role in &roles {
631        if seen_roles.contains(&role.name) {
632            return Err(ManifestError::DuplicateRole(role.name.clone()));
633        }
634        seen_roles.insert(role.name.clone());
635    }
636
637    let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
638    let mut seen_retirements: HashSet<String> = HashSet::new();
639    for retirement in &manifest.retirements {
640        if seen_retirements.contains(&retirement.role) {
641            return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
642        }
643        if desired_role_names.contains(&retirement.role) {
644            return Err(ManifestError::RetirementRoleStillDesired(
645                retirement.role.clone(),
646            ));
647        }
648        if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
649            return Err(ManifestError::RetirementSelfReassign {
650                role: retirement.role.clone(),
651            });
652        }
653        seen_retirements.insert(retirement.role.clone());
654    }
655
656    // Validate: password on a non-login role is an error.
657    // We require login to be explicitly true — if login is None (defaults to false)
658    // a password would be useless.
659    for role in &roles {
660        if role.password.is_some() && role.login != Some(true) {
661            return Err(ManifestError::PasswordWithoutLogin {
662                role: role.name.clone(),
663            });
664        }
665    }
666
667    // Validate: password_valid_until must be a valid ISO 8601 timestamp.
668    for role in &roles {
669        if let Some(value) = &role.password_valid_until
670            && !is_valid_iso8601_timestamp(value)
671        {
672            return Err(ManifestError::InvalidValidUntil {
673                role: role.name.clone(),
674                value: value.clone(),
675            });
676        }
677    }
678
679    Ok(ExpandedManifest {
680        schemas,
681        roles,
682        grants,
683        default_privileges,
684        memberships,
685    })
686}
687
688// ---------------------------------------------------------------------------
689// Validation helpers
690// ---------------------------------------------------------------------------
691
692/// Validate that a string is a plausible ISO 8601 timestamp.
693///
694/// Accepts formats like:
695/// - `2025-12-31T00:00:00Z`
696/// - `2025-12-31T00:00:00+00:00`
697/// - `2025-12-31T00:00:00-05:00`
698/// - `2025-12-31T00:00:00.123Z`
699///
700/// This validates structure and numeric ranges (month 01-12, day 01-31,
701/// hour 00-23, minute/second 00-59). It does not check calendar validity
702/// (e.g. Feb 30 passes). PostgreSQL itself will reject truly invalid dates.
703fn is_valid_iso8601_timestamp(value: &str) -> bool {
704    // Minimum valid: "YYYY-MM-DDTHH:MM:SSZ" = 20 chars
705    if value.len() < 20 {
706        return false;
707    }
708
709    let bytes = value.as_bytes();
710
711    // Check date part: YYYY-MM-DD
712    if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
713        return false;
714    }
715
716    let year = &value[0..4];
717    let month = &value[5..7];
718    let day = &value[8..10];
719
720    let Ok(y) = year.parse::<u16>() else {
721        return false;
722    };
723    let Ok(m) = month.parse::<u8>() else {
724        return false;
725    };
726    let Ok(d) = day.parse::<u8>() else {
727        return false;
728    };
729
730    if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
731        return false;
732    }
733
734    // Check time part: HH:MM:SS
735    if bytes[13] != b':' || bytes[16] != b':' {
736        return false;
737    }
738
739    let hour = &value[11..13];
740    let minute = &value[14..16];
741    let second = &value[17..19];
742
743    let Ok(h) = hour.parse::<u8>() else {
744        return false;
745    };
746    let Ok(min) = minute.parse::<u8>() else {
747        return false;
748    };
749    let Ok(sec) = second.parse::<u8>() else {
750        return false;
751    };
752
753    if h > 23 || min > 59 || sec > 59 {
754        return false;
755    }
756
757    // Remaining suffix must be a valid timezone indicator.
758    let suffix = &value[19..];
759
760    // Handle optional fractional seconds: .NNN
761    let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
762        // Skip digits after the decimal point
763        let frac_end = rest
764            .find(|c: char| !c.is_ascii_digit())
765            .unwrap_or(rest.len());
766        if frac_end == 0 {
767            return false; // "." with no digits
768        }
769        &rest[frac_end..]
770    } else {
771        suffix
772    };
773
774    // Valid timezone indicators: "Z", "+HH:MM", "-HH:MM"
775    match tz_part {
776        "Z" => true,
777        s if (s.starts_with('+') || s.starts_with('-'))
778            && s.len() == 6
779            && s.as_bytes()[3] == b':' =>
780        {
781            let Ok(tz_h) = s[1..3].parse::<u8>() else {
782                return false;
783            };
784            let Ok(tz_m) = s[4..6].parse::<u8>() else {
785                return false;
786            };
787            tz_h <= 14 && tz_m <= 59
788        }
789        _ => false,
790    }
791}
792
793// ---------------------------------------------------------------------------
794// Tests
795// ---------------------------------------------------------------------------
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    #[test]
802    fn parse_minimal_role() {
803        let yaml = r#"
804roles:
805  - name: test-role
806"#;
807        let manifest = parse_manifest(yaml).unwrap();
808        assert_eq!(manifest.roles.len(), 1);
809        assert_eq!(manifest.roles[0].name, "test-role");
810        assert!(manifest.roles[0].login.is_none());
811    }
812
813    #[test]
814    fn parse_full_policy() {
815        let yaml = r#"
816default_owner: app_owner
817
818profiles:
819  editor:
820    login: false
821    grants:
822      - privileges: [USAGE]
823        object: { type: schema }
824      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
825        object: { type: table, name: "*" }
826      - privileges: [USAGE, SELECT, UPDATE]
827        object: { type: sequence, name: "*" }
828      - privileges: [EXECUTE]
829        object: { type: function, name: "*" }
830    default_privileges:
831      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
832        on_type: table
833      - privileges: [USAGE, SELECT, UPDATE]
834        on_type: sequence
835      - privileges: [EXECUTE]
836        on_type: function
837
838schemas:
839  - name: inventory
840    profiles: [editor]
841  - name: catalog
842    profiles: [editor]
843
844roles:
845  - name: analytics-readonly
846    login: true
847
848memberships:
849  - role: inventory-editor
850    members:
851      - name: "alice@example.com"
852        inherit: true
853"#;
854        let manifest = parse_manifest(yaml).unwrap();
855        assert_eq!(manifest.profiles.len(), 1);
856        assert_eq!(manifest.schemas.len(), 2);
857        assert_eq!(manifest.roles.len(), 1);
858        assert_eq!(manifest.memberships.len(), 1);
859        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
860    }
861
862    #[test]
863    fn reject_invalid_yaml() {
864        let yaml = "not: [valid: yaml: {{";
865        assert!(parse_manifest(yaml).is_err());
866    }
867
868    #[test]
869    fn expand_profiles_basic() {
870        let yaml = r#"
871profiles:
872  editor:
873    login: false
874    grants:
875      - privileges: [USAGE]
876        object: { type: schema }
877      - privileges: [SELECT, INSERT]
878        object: { type: table, name: "*" }
879
880schemas:
881  - name: myschema
882    profiles: [editor]
883"#;
884        let manifest = parse_manifest(yaml).unwrap();
885        let expanded = expand_manifest(&manifest).unwrap();
886
887        assert_eq!(expanded.roles.len(), 1);
888        assert_eq!(expanded.roles[0].name, "myschema-editor");
889        assert_eq!(expanded.roles[0].login, Some(false));
890        assert_eq!(expanded.roles[0].inherit, None);
891
892        // Schema usage grant + table grant
893        assert_eq!(expanded.grants.len(), 2);
894        assert_eq!(expanded.grants[0].role, "myschema-editor");
895        assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
896        assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
897
898        assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
899        assert_eq!(
900            expanded.grants[1].object.schema,
901            Some("myschema".to_string())
902        );
903        assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
904    }
905
906    #[test]
907    fn expand_schema_owner_overrides_default_owner() {
908        let yaml = r#"
909default_owner: app_owner
910
911profiles:
912  editor:
913    default_privileges:
914      - privileges: [SELECT]
915        on_type: table
916
917schemas:
918  - name: inventory
919    owner: inventory_owner
920    profiles: [editor]
921  - name: catalog
922    profiles: [editor]
923"#;
924
925        let manifest = parse_manifest(yaml).unwrap();
926        let expanded = expand_manifest(&manifest).unwrap();
927
928        assert_eq!(
929            expanded.schemas,
930            vec![
931                ExpandedSchema {
932                    name: "inventory".to_string(),
933                    owner: Some("inventory_owner".to_string()),
934                },
935                ExpandedSchema {
936                    name: "catalog".to_string(),
937                    owner: Some("app_owner".to_string()),
938                },
939            ]
940        );
941    }
942
943    #[test]
944    fn expand_profiles_preserves_generated_role_inherit() {
945        let yaml = r#"
946profiles:
947  editor:
948    login: false
949    inherit: false
950    grants:
951      - privileges: [USAGE]
952        object: { type: schema }
953
954schemas:
955  - name: myschema
956    profiles: [editor]
957"#;
958
959        let manifest = parse_manifest(yaml).unwrap();
960        let expanded = expand_manifest(&manifest).unwrap();
961
962        assert_eq!(expanded.roles.len(), 1);
963        assert_eq!(expanded.roles[0].name, "myschema-editor");
964        assert_eq!(expanded.roles[0].login, Some(false));
965        assert_eq!(expanded.roles[0].inherit, Some(false));
966    }
967
968    #[test]
969    fn expand_declared_schema_with_no_profiles() {
970        let yaml = r#"
971schemas:
972  - name: cdc
973    owner: cdc_owner
974    profiles: []
975"#;
976
977        let manifest = parse_manifest(yaml).unwrap();
978        let expanded = expand_manifest(&manifest).unwrap();
979
980        assert_eq!(expanded.schemas.len(), 1);
981        assert_eq!(expanded.schemas[0].name, "cdc");
982        assert_eq!(expanded.schemas[0].owner.as_deref(), Some("cdc_owner"));
983        assert!(expanded.roles.is_empty());
984        assert!(expanded.grants.is_empty());
985        assert!(expanded.default_privileges.is_empty());
986    }
987
988    #[test]
989    fn expand_profiles_multi_schema() {
990        let yaml = r#"
991profiles:
992  editor:
993    grants:
994      - privileges: [SELECT]
995        object: { type: table, name: "*" }
996  viewer:
997    grants:
998      - privileges: [SELECT]
999        object: { type: table, name: "*" }
1000
1001schemas:
1002  - name: alpha
1003    profiles: [editor, viewer]
1004  - name: beta
1005    profiles: [editor, viewer]
1006  - name: gamma
1007    profiles: [editor]
1008"#;
1009        let manifest = parse_manifest(yaml).unwrap();
1010        let expanded = expand_manifest(&manifest).unwrap();
1011
1012        // 2 + 2 + 1 = 5 roles
1013        assert_eq!(expanded.roles.len(), 5);
1014        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
1015        assert!(role_names.contains(&"alpha-editor"));
1016        assert!(role_names.contains(&"alpha-viewer"));
1017        assert!(role_names.contains(&"beta-editor"));
1018        assert!(role_names.contains(&"beta-viewer"));
1019        assert!(role_names.contains(&"gamma-editor"));
1020    }
1021
1022    #[test]
1023    fn expand_custom_role_pattern() {
1024        let yaml = r#"
1025profiles:
1026  viewer:
1027    grants:
1028      - privileges: [SELECT]
1029        object: { type: table, name: "*" }
1030
1031schemas:
1032  - name: legacy_data
1033    profiles: [viewer]
1034    role_pattern: "legacy-{profile}"
1035"#;
1036        let manifest = parse_manifest(yaml).unwrap();
1037        let expanded = expand_manifest(&manifest).unwrap();
1038
1039        assert_eq!(expanded.roles.len(), 1);
1040        assert_eq!(expanded.roles[0].name, "legacy-viewer");
1041    }
1042
1043    #[test]
1044    fn expand_rejects_duplicate_role_name() {
1045        let yaml = r#"
1046profiles:
1047  editor:
1048    grants: []
1049
1050schemas:
1051  - name: inventory
1052    profiles: [editor]
1053
1054roles:
1055  - name: inventory-editor
1056"#;
1057        let manifest = parse_manifest(yaml).unwrap();
1058        let result = expand_manifest(&manifest);
1059        assert!(result.is_err());
1060        assert!(
1061            result
1062                .unwrap_err()
1063                .to_string()
1064                .contains("duplicate role name")
1065        );
1066    }
1067
1068    #[test]
1069    fn expand_rejects_duplicate_schema_name() {
1070        let yaml = r#"
1071schemas:
1072  - name: inventory
1073    profiles: []
1074  - name: inventory
1075    owner: inventory_owner
1076    profiles: []
1077"#;
1078
1079        let manifest = parse_manifest(yaml).unwrap();
1080        let error = expand_manifest(&manifest).unwrap_err();
1081        assert!(error.to_string().contains("duplicate schema name"));
1082    }
1083
1084    #[test]
1085    fn expand_rejects_undefined_profile() {
1086        let yaml = r#"
1087profiles: {}
1088
1089schemas:
1090  - name: inventory
1091    profiles: [nonexistent]
1092"#;
1093        let manifest = parse_manifest(yaml).unwrap();
1094        let result = expand_manifest(&manifest);
1095        assert!(result.is_err());
1096        assert!(result.unwrap_err().to_string().contains("not defined"));
1097    }
1098
1099    #[test]
1100    fn expand_rejects_invalid_pattern() {
1101        let yaml = r#"
1102profiles:
1103  editor:
1104    grants: []
1105
1106schemas:
1107  - name: inventory
1108    profiles: [editor]
1109    role_pattern: "static-name"
1110"#;
1111        let manifest = parse_manifest(yaml).unwrap();
1112        let result = expand_manifest(&manifest);
1113        assert!(result.is_err());
1114        assert!(
1115            result
1116                .unwrap_err()
1117                .to_string()
1118                .contains("{profile} placeholder")
1119        );
1120    }
1121
1122    #[test]
1123    fn expand_rejects_top_level_default_privilege_without_role() {
1124        let yaml = r#"
1125default_privileges:
1126  - schema: public
1127    grant:
1128      - privileges: [SELECT]
1129        on_type: table
1130"#;
1131        let manifest = parse_manifest(yaml).unwrap();
1132        let result = expand_manifest(&manifest);
1133        assert!(result.is_err());
1134        assert!(
1135            result
1136                .unwrap_err()
1137                .to_string()
1138                .contains("must specify grant.role")
1139        );
1140    }
1141
1142    #[test]
1143    fn expand_default_privileges_with_owner_override() {
1144        let yaml = r#"
1145default_owner: app_owner
1146
1147profiles:
1148  editor:
1149    grants: []
1150    default_privileges:
1151      - privileges: [SELECT]
1152        on_type: table
1153
1154schemas:
1155  - name: inventory
1156    profiles: [editor]
1157  - name: legacy
1158    profiles: [editor]
1159    owner: legacy_admin
1160"#;
1161        let manifest = parse_manifest(yaml).unwrap();
1162        let expanded = expand_manifest(&manifest).unwrap();
1163
1164        assert_eq!(expanded.default_privileges.len(), 2);
1165
1166        // inventory uses default_owner
1167        assert_eq!(
1168            expanded.default_privileges[0].owner,
1169            Some("app_owner".to_string())
1170        );
1171        assert_eq!(expanded.default_privileges[0].schema, "inventory");
1172
1173        // legacy uses override
1174        assert_eq!(
1175            expanded.default_privileges[1].owner,
1176            Some("legacy_admin".to_string())
1177        );
1178        assert_eq!(expanded.default_privileges[1].schema, "legacy");
1179    }
1180
1181    #[test]
1182    fn expand_merges_oneoff_roles_and_grants() {
1183        let yaml = r#"
1184profiles:
1185  editor:
1186    grants:
1187      - privileges: [SELECT]
1188        object: { type: table, name: "*" }
1189
1190schemas:
1191  - name: inventory
1192    profiles: [editor]
1193
1194roles:
1195  - name: analytics
1196    login: true
1197
1198grants:
1199  - role: analytics
1200    privileges: [SELECT]
1201    on:
1202      type: table
1203      schema: inventory
1204      name: "*"
1205"#;
1206        let manifest = parse_manifest(yaml).unwrap();
1207        let expanded = expand_manifest(&manifest).unwrap();
1208
1209        assert_eq!(expanded.roles.len(), 2);
1210        assert_eq!(expanded.grants.len(), 2); // 1 from profile + 1 one-off
1211    }
1212
1213    #[test]
1214    fn parse_manifest_accepts_legacy_on_alias() {
1215        let yaml = r#"
1216grants:
1217  - role: analytics
1218    privileges: [SELECT]
1219    on:
1220      type: table
1221      schema: public
1222      name: "*"
1223"#;
1224        let manifest = parse_manifest(yaml).unwrap();
1225        assert_eq!(manifest.grants.len(), 1);
1226        assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1227        assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1228        assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1229    }
1230
1231    #[test]
1232    fn parse_membership_with_email_roles() {
1233        let yaml = r#"
1234memberships:
1235  - role: inventory-editor
1236    members:
1237      - name: "alice@example.com"
1238        inherit: true
1239      - name: "engineering@example.com"
1240        admin: true
1241"#;
1242        let manifest = parse_manifest(yaml).unwrap();
1243        assert_eq!(manifest.memberships.len(), 1);
1244        assert_eq!(manifest.memberships[0].members.len(), 2);
1245        assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1246        assert_eq!(manifest.memberships[0].members[0].inherit, Some(true));
1247        assert_eq!(manifest.memberships[0].members[1].admin, Some(true));
1248    }
1249
1250    #[test]
1251    fn member_spec_defaults() {
1252        let yaml = r#"
1253memberships:
1254  - role: some-role
1255    members:
1256      - name: user1
1257"#;
1258        let manifest = parse_manifest(yaml).unwrap();
1259        // When omitted, both fields are None (defaults applied at resolution time).
1260        assert_eq!(manifest.memberships[0].members[0].inherit, None);
1261        assert_eq!(manifest.memberships[0].members[0].admin, None);
1262        // Accessor methods still return the expected defaults.
1263        assert!(manifest.memberships[0].members[0].inherit());
1264        assert!(!manifest.memberships[0].members[0].admin());
1265    }
1266
1267    #[test]
1268    fn expand_rejects_duplicate_retirements() {
1269        let yaml = r#"
1270retirements:
1271  - role: old-app
1272  - role: old-app
1273"#;
1274        let manifest = parse_manifest(yaml).unwrap();
1275        let result = expand_manifest(&manifest);
1276        assert!(matches!(
1277            result,
1278            Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1279        ));
1280    }
1281
1282    #[test]
1283    fn expand_rejects_retirement_for_desired_role() {
1284        let yaml = r#"
1285roles:
1286  - name: old-app
1287
1288retirements:
1289  - role: old-app
1290"#;
1291        let manifest = parse_manifest(yaml).unwrap();
1292        let result = expand_manifest(&manifest);
1293        assert!(matches!(
1294            result,
1295            Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1296        ));
1297    }
1298
1299    #[test]
1300    fn expand_rejects_self_reassign_retirement() {
1301        let yaml = r#"
1302retirements:
1303  - role: old-app
1304    reassign_owned_to: old-app
1305"#;
1306        let manifest = parse_manifest(yaml).unwrap();
1307        let result = expand_manifest(&manifest);
1308        assert!(matches!(
1309            result,
1310            Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1311        ));
1312    }
1313
1314    #[test]
1315    fn parse_auth_providers() {
1316        let yaml = r#"
1317auth_providers:
1318  - type: cloud_sql_iam
1319    project: my-gcp-project
1320  - type: alloydb_iam
1321    project: my-gcp-project
1322    cluster: analytics-prod
1323  - type: rds_iam
1324    region: us-east-1
1325  - type: azure_ad
1326    tenant_id: "abc-123"
1327  - type: supabase
1328    project_ref: myprojref
1329  - type: planet_scale
1330    organization: my-org
1331
1332roles:
1333  - name: app-service
1334"#;
1335        let manifest = parse_manifest(yaml).unwrap();
1336        assert_eq!(manifest.auth_providers.len(), 6);
1337        assert!(matches!(
1338            &manifest.auth_providers[0],
1339            AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1340        ));
1341        assert!(matches!(
1342            &manifest.auth_providers[1],
1343            AuthProvider::AlloyDbIam {
1344                project: Some(p),
1345                cluster: Some(c)
1346            } if p == "my-gcp-project" && c == "analytics-prod"
1347        ));
1348        assert!(matches!(
1349            &manifest.auth_providers[2],
1350            AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1351        ));
1352        assert!(matches!(
1353            &manifest.auth_providers[3],
1354            AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1355        ));
1356        assert!(matches!(
1357            &manifest.auth_providers[4],
1358            AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1359        ));
1360        assert!(matches!(
1361            &manifest.auth_providers[5],
1362            AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1363        ));
1364    }
1365
1366    #[test]
1367    fn parse_manifest_without_auth_providers() {
1368        let yaml = r#"
1369roles:
1370  - name: test-role
1371"#;
1372        let manifest = parse_manifest(yaml).unwrap();
1373        assert!(manifest.auth_providers.is_empty());
1374    }
1375
1376    #[test]
1377    fn parse_role_with_password_source() {
1378        let yaml = r#"
1379roles:
1380  - name: app-service
1381    login: true
1382    password:
1383      from_env: APP_SERVICE_PASSWORD
1384    password_valid_until: "2025-12-31T00:00:00Z"
1385"#;
1386        let manifest = parse_manifest(yaml).unwrap();
1387        assert_eq!(manifest.roles.len(), 1);
1388        let role = &manifest.roles[0];
1389        assert!(role.password.is_some());
1390        assert_eq!(
1391            role.password.as_ref().unwrap().from_env,
1392            "APP_SERVICE_PASSWORD"
1393        );
1394        assert_eq!(
1395            role.password_valid_until,
1396            Some("2025-12-31T00:00:00Z".to_string())
1397        );
1398    }
1399
1400    #[test]
1401    fn parse_role_without_password() {
1402        let yaml = r#"
1403roles:
1404  - name: app-service
1405    login: true
1406"#;
1407        let manifest = parse_manifest(yaml).unwrap();
1408        assert!(manifest.roles[0].password.is_none());
1409        assert!(manifest.roles[0].password_valid_until.is_none());
1410    }
1411
1412    #[test]
1413    fn reject_password_on_nologin_role() {
1414        let yaml = r#"
1415roles:
1416  - name: nologin-role
1417    login: false
1418    password:
1419      from_env: SOME_PASSWORD
1420"#;
1421        let manifest = parse_manifest(yaml).unwrap();
1422        let result = expand_manifest(&manifest);
1423        assert!(result.is_err());
1424        assert!(
1425            result
1426                .unwrap_err()
1427                .to_string()
1428                .contains("login is not enabled")
1429        );
1430    }
1431
1432    #[test]
1433    fn reject_password_on_default_login_role() {
1434        // login is None (defaults to NOLOGIN) — password should still be rejected
1435        let yaml = r#"
1436roles:
1437  - name: implicit-nologin-role
1438    password:
1439      from_env: SOME_PASSWORD
1440"#;
1441        let manifest = parse_manifest(yaml).unwrap();
1442        let result = expand_manifest(&manifest);
1443        assert!(result.is_err());
1444        assert!(
1445            result
1446                .unwrap_err()
1447                .to_string()
1448                .contains("login is not enabled")
1449        );
1450    }
1451
1452    #[test]
1453    fn reject_invalid_password_valid_until() {
1454        let yaml = r#"
1455roles:
1456  - name: bad-date
1457    login: true
1458    password_valid_until: "not-a-date"
1459"#;
1460        let manifest = parse_manifest(yaml).unwrap();
1461        let result = expand_manifest(&manifest);
1462        assert!(result.is_err());
1463        assert!(
1464            result
1465                .unwrap_err()
1466                .to_string()
1467                .contains("invalid password_valid_until")
1468        );
1469    }
1470
1471    #[test]
1472    fn reject_date_only_valid_until() {
1473        let yaml = r#"
1474roles:
1475  - name: bad-date
1476    login: true
1477    password_valid_until: "2025-12-31"
1478"#;
1479        let manifest = parse_manifest(yaml).unwrap();
1480        let result = expand_manifest(&manifest);
1481        assert!(result.is_err());
1482    }
1483
1484    #[test]
1485    fn accept_valid_iso8601_timestamps() {
1486        // UTC with Z
1487        assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1488        // With timezone offset
1489        assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1490        assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1491        // With fractional seconds
1492        assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1493    }
1494
1495    #[test]
1496    fn reject_invalid_iso8601_timestamps() {
1497        assert!(!is_valid_iso8601_timestamp("not-a-date"));
1498        assert!(!is_valid_iso8601_timestamp("2025-12-31")); // date only
1499        assert!(!is_valid_iso8601_timestamp("2025-13-31T00:00:00Z")); // month 13
1500        assert!(!is_valid_iso8601_timestamp("2025-12-31T25:00:00Z")); // hour 25
1501        assert!(!is_valid_iso8601_timestamp("2025-12-31T00:00:00")); // no timezone
1502        assert!(!is_valid_iso8601_timestamp("")); // empty
1503    }
1504
1505    #[test]
1506    fn parse_manifest_from_kubernetes_cr() {
1507        let yaml = r#"
1508apiVersion: pgroles.io/v1alpha1
1509kind: PostgresPolicy
1510metadata:
1511  name: staging-policy
1512  namespace: pgroles-system
1513spec:
1514  connection:
1515    secretRef:
1516      name: pgroles-db-credentials
1517  interval: "5m"
1518  mode: plan
1519  roles:
1520    - name: app_analytics
1521      login: true
1522    - name: app_billing
1523      login: true
1524  schemas:
1525    - name: analytics
1526      profiles: [editor, viewer]
1527  profiles:
1528    editor:
1529      grants:
1530        - object: { type: schema }
1531          privileges: [USAGE]
1532        - object: { type: table, name: "*" }
1533          privileges: [SELECT, INSERT, UPDATE, DELETE]
1534    viewer:
1535      grants:
1536        - object: { type: schema }
1537          privileges: [USAGE]
1538        - object: { type: table, name: "*" }
1539          privileges: [SELECT]
1540  memberships:
1541    - role: analytics-editor
1542      members:
1543        - { name: app_analytics }
1544    - role: analytics-viewer
1545      members:
1546        - { name: app_billing }
1547"#;
1548        let manifest = parse_manifest(yaml).unwrap();
1549        assert_eq!(manifest.roles.len(), 2);
1550        assert_eq!(manifest.roles[0].name, "app_analytics");
1551        assert_eq!(manifest.schemas.len(), 1);
1552        assert_eq!(manifest.memberships.len(), 2);
1553        assert_eq!(manifest.profiles.len(), 2);
1554    }
1555
1556    #[test]
1557    fn parse_manifest_bare_and_cr_produce_same_result() {
1558        let bare = r#"
1559roles:
1560  - name: test_role
1561    login: true
1562schemas:
1563  - name: public
1564    profiles: [viewer]
1565profiles:
1566  viewer:
1567    grants:
1568      - object: { type: schema }
1569        privileges: [USAGE]
1570"#;
1571        let cr = r#"
1572apiVersion: pgroles.io/v1alpha1
1573kind: PostgresPolicy
1574metadata:
1575  name: test
1576spec:
1577  roles:
1578    - name: test_role
1579      login: true
1580  schemas:
1581    - name: public
1582      profiles: [viewer]
1583  profiles:
1584    viewer:
1585      grants:
1586        - object: { type: schema }
1587          privileges: [USAGE]
1588"#;
1589        let from_bare = parse_manifest(bare).unwrap();
1590        let from_cr = parse_manifest(cr).unwrap();
1591        assert_eq!(from_bare.roles.len(), from_cr.roles.len());
1592        assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
1593        assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
1594    }
1595}