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