Skip to main content

pgroles_core/
manifest.rs

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