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