Skip to main content

pgroles_core/
manifest.rs

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