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
37// ---------------------------------------------------------------------------
38// Enums
39// ---------------------------------------------------------------------------
40
41/// PostgreSQL object types that can have privileges granted on them.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
43#[serde(rename_all = "snake_case")]
44pub enum ObjectType {
45    Table,
46    View,
47    #[serde(alias = "materialized_view")]
48    MaterializedView,
49    Sequence,
50    Function,
51    Schema,
52    Database,
53    Type,
54}
55
56impl std::fmt::Display for ObjectType {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            ObjectType::Table => write!(f, "table"),
60            ObjectType::View => write!(f, "view"),
61            ObjectType::MaterializedView => write!(f, "materialized_view"),
62            ObjectType::Sequence => write!(f, "sequence"),
63            ObjectType::Function => write!(f, "function"),
64            ObjectType::Schema => write!(f, "schema"),
65            ObjectType::Database => write!(f, "database"),
66            ObjectType::Type => write!(f, "type"),
67        }
68    }
69}
70
71/// PostgreSQL privilege types.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
73#[serde(rename_all = "UPPERCASE")]
74pub enum Privilege {
75    Select,
76    Insert,
77    Update,
78    Delete,
79    Truncate,
80    References,
81    Trigger,
82    Execute,
83    Usage,
84    Create,
85    Connect,
86    Temporary,
87}
88
89impl std::fmt::Display for Privilege {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        match self {
92            Privilege::Select => write!(f, "SELECT"),
93            Privilege::Insert => write!(f, "INSERT"),
94            Privilege::Update => write!(f, "UPDATE"),
95            Privilege::Delete => write!(f, "DELETE"),
96            Privilege::Truncate => write!(f, "TRUNCATE"),
97            Privilege::References => write!(f, "REFERENCES"),
98            Privilege::Trigger => write!(f, "TRIGGER"),
99            Privilege::Execute => write!(f, "EXECUTE"),
100            Privilege::Usage => write!(f, "USAGE"),
101            Privilege::Create => write!(f, "CREATE"),
102            Privilege::Connect => write!(f, "CONNECT"),
103            Privilege::Temporary => write!(f, "TEMPORARY"),
104        }
105    }
106}
107
108// ---------------------------------------------------------------------------
109// YAML manifest types
110// ---------------------------------------------------------------------------
111
112/// Top-level policy manifest — the YAML file that users write.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PolicyManifest {
115    /// Default owner for ALTER DEFAULT PRIVILEGES (e.g. "app_owner").
116    #[serde(default)]
117    pub default_owner: Option<String>,
118
119    /// Cloud auth provider configurations for IAM-mapped role awareness.
120    #[serde(default)]
121    pub auth_providers: Vec<AuthProvider>,
122
123    /// Reusable privilege profiles.
124    #[serde(default)]
125    pub profiles: HashMap<String, Profile>,
126
127    /// Schema bindings that expand profiles into concrete roles/grants.
128    #[serde(default)]
129    pub schemas: Vec<SchemaBinding>,
130
131    /// One-off role definitions (not from profiles).
132    #[serde(default)]
133    pub roles: Vec<RoleDefinition>,
134
135    /// One-off grants (not from profiles).
136    #[serde(default)]
137    pub grants: Vec<Grant>,
138
139    /// One-off default privileges (not from profiles).
140    #[serde(default)]
141    pub default_privileges: Vec<DefaultPrivilege>,
142
143    /// Membership edges (opt-in).
144    #[serde(default)]
145    pub memberships: Vec<Membership>,
146
147    /// Explicit role-retirement workflows for roles that should be removed.
148    #[serde(default)]
149    pub retirements: Vec<RoleRetirement>,
150}
151
152/// Cloud authentication provider configuration.
153///
154/// Declares awareness of cloud IAM-mapped roles so pgroles can correctly
155/// reference auto-created role names in grants and memberships.
156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157#[serde(tag = "type", rename_all = "snake_case")]
158pub enum AuthProvider {
159    /// Google Cloud SQL IAM authentication.
160    /// Service accounts map to PG roles like `user@project.iam`.
161    CloudSqlIam {
162        /// GCP project ID (for documentation/validation).
163        #[serde(default)]
164        project: Option<String>,
165    },
166    /// Google AlloyDB IAM authentication.
167    /// IAM users and groups map to PostgreSQL roles managed by AlloyDB.
168    #[serde(rename = "alloydb_iam")]
169    AlloyDbIam {
170        /// GCP project ID (for documentation/validation).
171        #[serde(default)]
172        project: Option<String>,
173        /// AlloyDB cluster name (for documentation/validation).
174        #[serde(default)]
175        cluster: Option<String>,
176    },
177    /// AWS RDS IAM authentication.
178    /// IAM users authenticate via token; the PG role must have `rds_iam` granted.
179    RdsIam {
180        /// AWS region (for documentation/validation).
181        #[serde(default)]
182        region: Option<String>,
183    },
184    /// Azure Entra ID (AAD) authentication for Azure Database for PostgreSQL.
185    AzureAd {
186        /// Azure tenant ID (for documentation/validation).
187        #[serde(default)]
188        tenant_id: Option<String>,
189    },
190    /// Supabase-managed PostgreSQL authentication.
191    Supabase {
192        /// Supabase project ref (for documentation/validation).
193        #[serde(default)]
194        project_ref: Option<String>,
195    },
196    /// PlanetScale PostgreSQL authentication metadata.
197    PlanetScale {
198        /// PlanetScale organization (for documentation/validation).
199        #[serde(default)]
200        organization: Option<String>,
201    },
202}
203
204/// A reusable privilege profile — defines what grants a role should have.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct Profile {
207    #[serde(default)]
208    pub login: Option<bool>,
209
210    #[serde(default)]
211    pub grants: Vec<ProfileGrant>,
212
213    #[serde(default)]
214    pub default_privileges: Vec<DefaultPrivilegeGrant>,
215}
216
217/// A grant template within a profile (schema is filled in during expansion).
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ProfileGrant {
220    pub privileges: Vec<Privilege>,
221    pub on: ProfileObjectTarget,
222}
223
224/// Object target within a profile — schema is omitted (filled during expansion).
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ProfileObjectTarget {
227    #[serde(rename = "type")]
228    pub object_type: ObjectType,
229    /// Object name, or "*" for all objects of this type. Omit for schema-level grants.
230    #[serde(default)]
231    pub name: Option<String>,
232}
233
234/// A schema binding — associates a schema with one or more profiles.
235#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
236pub struct SchemaBinding {
237    pub name: String,
238
239    pub profiles: Vec<String>,
240
241    /// Role naming pattern. Supports `{schema}` and `{profile}` placeholders.
242    /// Defaults to `"{schema}-{profile}"`.
243    #[serde(default = "default_role_pattern")]
244    pub role_pattern: String,
245
246    /// Override default_owner for this schema's default privileges.
247    #[serde(default)]
248    pub owner: Option<String>,
249}
250
251fn default_role_pattern() -> String {
252    "{schema}-{profile}".to_string()
253}
254
255/// A concrete role definition.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct RoleDefinition {
258    pub name: String,
259
260    #[serde(default)]
261    pub login: Option<bool>,
262
263    #[serde(default)]
264    pub superuser: Option<bool>,
265
266    #[serde(default)]
267    pub createdb: Option<bool>,
268
269    #[serde(default)]
270    pub createrole: Option<bool>,
271
272    #[serde(default)]
273    pub inherit: Option<bool>,
274
275    #[serde(default)]
276    pub replication: Option<bool>,
277
278    #[serde(default)]
279    pub bypassrls: Option<bool>,
280
281    #[serde(default)]
282    pub connection_limit: Option<i32>,
283
284    #[serde(default)]
285    pub comment: Option<String>,
286}
287
288/// A concrete grant on a specific object or wildcard.
289#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
290pub struct Grant {
291    pub role: String,
292    pub privileges: Vec<Privilege>,
293    pub on: ObjectTarget,
294}
295
296/// Target object for a grant.
297#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
298pub struct ObjectTarget {
299    #[serde(rename = "type")]
300    pub object_type: ObjectType,
301
302    /// Schema name. Required for most object types except database.
303    #[serde(default)]
304    pub schema: Option<String>,
305
306    /// Object name, or "*" for all objects. Omit for schema-level grants.
307    #[serde(default)]
308    pub name: Option<String>,
309}
310
311/// Default privilege configuration.
312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313pub struct DefaultPrivilege {
314    /// The role that owns newly created objects. If omitted, uses manifest's default_owner.
315    #[serde(default)]
316    pub owner: Option<String>,
317
318    pub schema: String,
319
320    pub grant: Vec<DefaultPrivilegeGrant>,
321}
322
323/// A single default privilege grant entry.
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325pub struct DefaultPrivilegeGrant {
326    /// The role receiving the default privilege. Only used in top-level default_privileges
327    /// (in profiles, the role is determined by expansion).
328    #[serde(default)]
329    pub role: Option<String>,
330
331    pub privileges: Vec<Privilege>,
332    pub on_type: ObjectType,
333}
334
335/// A membership declaration — which members belong to a role.
336#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
337pub struct Membership {
338    pub role: String,
339    pub members: Vec<MemberSpec>,
340}
341
342/// A single member of a role.
343#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
344pub struct MemberSpec {
345    pub name: String,
346
347    #[serde(default = "default_true")]
348    pub inherit: bool,
349
350    #[serde(default)]
351    pub admin: bool,
352}
353
354fn default_true() -> bool {
355    true
356}
357
358/// Declarative workflow for retiring an existing role.
359#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
360pub struct RoleRetirement {
361    /// The role to retire and ultimately drop.
362    pub role: String,
363
364    /// Optional successor role for `REASSIGN OWNED BY ... TO ...`.
365    #[serde(default)]
366    pub reassign_owned_to: Option<String>,
367
368    /// Whether to run `DROP OWNED BY` before dropping the role.
369    #[serde(default)]
370    pub drop_owned: bool,
371
372    /// Whether to terminate other active sessions for the role before drop.
373    #[serde(default)]
374    pub terminate_sessions: bool,
375}
376
377// ---------------------------------------------------------------------------
378// Expanded manifest — the result of profile expansion
379// ---------------------------------------------------------------------------
380
381/// The fully expanded policy — all profiles resolved into concrete roles, grants,
382/// default privileges, and memberships. Ready to be converted into a `RoleGraph`.
383#[derive(Debug, Clone)]
384pub struct ExpandedManifest {
385    pub roles: Vec<RoleDefinition>,
386    pub grants: Vec<Grant>,
387    pub default_privileges: Vec<DefaultPrivilege>,
388    pub memberships: Vec<Membership>,
389}
390
391// ---------------------------------------------------------------------------
392// Expansion logic
393// ---------------------------------------------------------------------------
394
395/// Parse a YAML string into a `PolicyManifest`.
396pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
397    let manifest: PolicyManifest = serde_yaml::from_str(yaml)?;
398    Ok(manifest)
399}
400
401/// Expand a `PolicyManifest` by resolving all `profiles × schemas` into concrete
402/// roles, grants, and default privileges. Merges with one-off definitions.
403/// Validates no duplicate role names.
404pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
405    let mut roles: Vec<RoleDefinition> = Vec::new();
406    let mut grants: Vec<Grant> = Vec::new();
407    let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
408
409    // Expand each schema × profile combination
410    for schema_binding in &manifest.schemas {
411        for profile_name in &schema_binding.profiles {
412            let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
413                ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
414            })?;
415
416            // Validate pattern contains {profile}
417            if !schema_binding.role_pattern.contains("{profile}") {
418                return Err(ManifestError::InvalidRolePattern(
419                    schema_binding.role_pattern.clone(),
420                ));
421            }
422
423            // Generate role name from pattern
424            let role_name = schema_binding
425                .role_pattern
426                .replace("{schema}", &schema_binding.name)
427                .replace("{profile}", profile_name);
428
429            // Create role definition
430            roles.push(RoleDefinition {
431                name: role_name.clone(),
432                login: profile.login,
433                superuser: None,
434                createdb: None,
435                createrole: None,
436                inherit: None,
437                replication: None,
438                bypassrls: None,
439                connection_limit: None,
440                comment: Some(format!(
441                    "Generated from profile '{profile_name}' for schema '{}'",
442                    schema_binding.name
443                )),
444            });
445
446            // Expand profile grants — fill in schema
447            for profile_grant in &profile.grants {
448                let object_target = match profile_grant.on.object_type {
449                    ObjectType::Schema => ObjectTarget {
450                        object_type: ObjectType::Schema,
451                        schema: None,
452                        name: Some(schema_binding.name.clone()),
453                    },
454                    _ => ObjectTarget {
455                        object_type: profile_grant.on.object_type,
456                        schema: Some(schema_binding.name.clone()),
457                        name: profile_grant.on.name.clone(),
458                    },
459                };
460
461                grants.push(Grant {
462                    role: role_name.clone(),
463                    privileges: profile_grant.privileges.clone(),
464                    on: object_target,
465                });
466            }
467
468            // Expand profile default privileges
469            if !profile.default_privileges.is_empty() {
470                let owner = schema_binding
471                    .owner
472                    .clone()
473                    .or(manifest.default_owner.clone());
474
475                let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
476                    .default_privileges
477                    .iter()
478                    .map(|dp| DefaultPrivilegeGrant {
479                        role: Some(role_name.clone()),
480                        privileges: dp.privileges.clone(),
481                        on_type: dp.on_type,
482                    })
483                    .collect();
484
485                default_privileges.push(DefaultPrivilege {
486                    owner,
487                    schema: schema_binding.name.clone(),
488                    grant: expanded_grants,
489                });
490            }
491        }
492    }
493
494    // Top-level default privileges must always identify the grantee role.
495    for default_priv in &manifest.default_privileges {
496        for grant in &default_priv.grant {
497            if grant.role.is_none() {
498                return Err(ManifestError::MissingDefaultPrivilegeRole {
499                    schema: default_priv.schema.clone(),
500                });
501            }
502        }
503    }
504
505    // Merge one-off definitions
506    roles.extend(manifest.roles.clone());
507    grants.extend(manifest.grants.clone());
508    default_privileges.extend(manifest.default_privileges.clone());
509    let memberships = manifest.memberships.clone();
510
511    // Validate no duplicate role names
512    let mut seen_roles: HashSet<String> = HashSet::new();
513    for role in &roles {
514        if seen_roles.contains(&role.name) {
515            return Err(ManifestError::DuplicateRole(role.name.clone()));
516        }
517        seen_roles.insert(role.name.clone());
518    }
519
520    let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
521    let mut seen_retirements: HashSet<String> = HashSet::new();
522    for retirement in &manifest.retirements {
523        if seen_retirements.contains(&retirement.role) {
524            return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
525        }
526        if desired_role_names.contains(&retirement.role) {
527            return Err(ManifestError::RetirementRoleStillDesired(
528                retirement.role.clone(),
529            ));
530        }
531        if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
532            return Err(ManifestError::RetirementSelfReassign {
533                role: retirement.role.clone(),
534            });
535        }
536        seen_retirements.insert(retirement.role.clone());
537    }
538
539    Ok(ExpandedManifest {
540        roles,
541        grants,
542        default_privileges,
543        memberships,
544    })
545}
546
547// ---------------------------------------------------------------------------
548// Tests
549// ---------------------------------------------------------------------------
550
551#[cfg(test)]
552mod tests {
553    use super::*;
554
555    #[test]
556    fn parse_minimal_role() {
557        let yaml = r#"
558roles:
559  - name: test-role
560"#;
561        let manifest = parse_manifest(yaml).unwrap();
562        assert_eq!(manifest.roles.len(), 1);
563        assert_eq!(manifest.roles[0].name, "test-role");
564        assert!(manifest.roles[0].login.is_none());
565    }
566
567    #[test]
568    fn parse_full_policy() {
569        let yaml = r#"
570default_owner: app_owner
571
572profiles:
573  editor:
574    login: false
575    grants:
576      - privileges: [USAGE]
577        on: { type: schema }
578      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
579        on: { type: table, name: "*" }
580      - privileges: [USAGE, SELECT, UPDATE]
581        on: { type: sequence, name: "*" }
582      - privileges: [EXECUTE]
583        on: { type: function, name: "*" }
584    default_privileges:
585      - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
586        on_type: table
587      - privileges: [USAGE, SELECT, UPDATE]
588        on_type: sequence
589      - privileges: [EXECUTE]
590        on_type: function
591
592schemas:
593  - name: inventory
594    profiles: [editor]
595  - name: catalog
596    profiles: [editor]
597
598roles:
599  - name: analytics-readonly
600    login: true
601
602memberships:
603  - role: inventory-editor
604    members:
605      - name: "alice@example.com"
606        inherit: true
607"#;
608        let manifest = parse_manifest(yaml).unwrap();
609        assert_eq!(manifest.profiles.len(), 1);
610        assert_eq!(manifest.schemas.len(), 2);
611        assert_eq!(manifest.roles.len(), 1);
612        assert_eq!(manifest.memberships.len(), 1);
613        assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
614    }
615
616    #[test]
617    fn reject_invalid_yaml() {
618        let yaml = "not: [valid: yaml: {{";
619        assert!(parse_manifest(yaml).is_err());
620    }
621
622    #[test]
623    fn expand_profiles_basic() {
624        let yaml = r#"
625profiles:
626  editor:
627    login: false
628    grants:
629      - privileges: [USAGE]
630        on: { type: schema }
631      - privileges: [SELECT, INSERT]
632        on: { type: table, name: "*" }
633
634schemas:
635  - name: myschema
636    profiles: [editor]
637"#;
638        let manifest = parse_manifest(yaml).unwrap();
639        let expanded = expand_manifest(&manifest).unwrap();
640
641        assert_eq!(expanded.roles.len(), 1);
642        assert_eq!(expanded.roles[0].name, "myschema-editor");
643        assert_eq!(expanded.roles[0].login, Some(false));
644
645        // Schema usage grant + table grant
646        assert_eq!(expanded.grants.len(), 2);
647        assert_eq!(expanded.grants[0].role, "myschema-editor");
648        assert_eq!(expanded.grants[0].on.object_type, ObjectType::Schema);
649        assert_eq!(expanded.grants[0].on.name, Some("myschema".to_string()));
650
651        assert_eq!(expanded.grants[1].on.object_type, ObjectType::Table);
652        assert_eq!(expanded.grants[1].on.schema, Some("myschema".to_string()));
653        assert_eq!(expanded.grants[1].on.name, Some("*".to_string()));
654    }
655
656    #[test]
657    fn expand_profiles_multi_schema() {
658        let yaml = r#"
659profiles:
660  editor:
661    grants:
662      - privileges: [SELECT]
663        on: { type: table, name: "*" }
664  viewer:
665    grants:
666      - privileges: [SELECT]
667        on: { type: table, name: "*" }
668
669schemas:
670  - name: alpha
671    profiles: [editor, viewer]
672  - name: beta
673    profiles: [editor, viewer]
674  - name: gamma
675    profiles: [editor]
676"#;
677        let manifest = parse_manifest(yaml).unwrap();
678        let expanded = expand_manifest(&manifest).unwrap();
679
680        // 2 + 2 + 1 = 5 roles
681        assert_eq!(expanded.roles.len(), 5);
682        let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
683        assert!(role_names.contains(&"alpha-editor"));
684        assert!(role_names.contains(&"alpha-viewer"));
685        assert!(role_names.contains(&"beta-editor"));
686        assert!(role_names.contains(&"beta-viewer"));
687        assert!(role_names.contains(&"gamma-editor"));
688    }
689
690    #[test]
691    fn expand_custom_role_pattern() {
692        let yaml = r#"
693profiles:
694  viewer:
695    grants:
696      - privileges: [SELECT]
697        on: { type: table, name: "*" }
698
699schemas:
700  - name: legacy_data
701    profiles: [viewer]
702    role_pattern: "legacy-{profile}"
703"#;
704        let manifest = parse_manifest(yaml).unwrap();
705        let expanded = expand_manifest(&manifest).unwrap();
706
707        assert_eq!(expanded.roles.len(), 1);
708        assert_eq!(expanded.roles[0].name, "legacy-viewer");
709    }
710
711    #[test]
712    fn expand_rejects_duplicate_role_name() {
713        let yaml = r#"
714profiles:
715  editor:
716    grants: []
717
718schemas:
719  - name: inventory
720    profiles: [editor]
721
722roles:
723  - name: inventory-editor
724"#;
725        let manifest = parse_manifest(yaml).unwrap();
726        let result = expand_manifest(&manifest);
727        assert!(result.is_err());
728        assert!(
729            result
730                .unwrap_err()
731                .to_string()
732                .contains("duplicate role name")
733        );
734    }
735
736    #[test]
737    fn expand_rejects_undefined_profile() {
738        let yaml = r#"
739profiles: {}
740
741schemas:
742  - name: inventory
743    profiles: [nonexistent]
744"#;
745        let manifest = parse_manifest(yaml).unwrap();
746        let result = expand_manifest(&manifest);
747        assert!(result.is_err());
748        assert!(result.unwrap_err().to_string().contains("not defined"));
749    }
750
751    #[test]
752    fn expand_rejects_invalid_pattern() {
753        let yaml = r#"
754profiles:
755  editor:
756    grants: []
757
758schemas:
759  - name: inventory
760    profiles: [editor]
761    role_pattern: "static-name"
762"#;
763        let manifest = parse_manifest(yaml).unwrap();
764        let result = expand_manifest(&manifest);
765        assert!(result.is_err());
766        assert!(
767            result
768                .unwrap_err()
769                .to_string()
770                .contains("{profile} placeholder")
771        );
772    }
773
774    #[test]
775    fn expand_rejects_top_level_default_privilege_without_role() {
776        let yaml = r#"
777default_privileges:
778  - schema: public
779    grant:
780      - privileges: [SELECT]
781        on_type: table
782"#;
783        let manifest = parse_manifest(yaml).unwrap();
784        let result = expand_manifest(&manifest);
785        assert!(result.is_err());
786        assert!(
787            result
788                .unwrap_err()
789                .to_string()
790                .contains("must specify grant.role")
791        );
792    }
793
794    #[test]
795    fn expand_default_privileges_with_owner_override() {
796        let yaml = r#"
797default_owner: app_owner
798
799profiles:
800  editor:
801    grants: []
802    default_privileges:
803      - privileges: [SELECT]
804        on_type: table
805
806schemas:
807  - name: inventory
808    profiles: [editor]
809  - name: legacy
810    profiles: [editor]
811    owner: legacy_admin
812"#;
813        let manifest = parse_manifest(yaml).unwrap();
814        let expanded = expand_manifest(&manifest).unwrap();
815
816        assert_eq!(expanded.default_privileges.len(), 2);
817
818        // inventory uses default_owner
819        assert_eq!(
820            expanded.default_privileges[0].owner,
821            Some("app_owner".to_string())
822        );
823        assert_eq!(expanded.default_privileges[0].schema, "inventory");
824
825        // legacy uses override
826        assert_eq!(
827            expanded.default_privileges[1].owner,
828            Some("legacy_admin".to_string())
829        );
830        assert_eq!(expanded.default_privileges[1].schema, "legacy");
831    }
832
833    #[test]
834    fn expand_merges_oneoff_roles_and_grants() {
835        let yaml = r#"
836profiles:
837  editor:
838    grants:
839      - privileges: [SELECT]
840        on: { type: table, name: "*" }
841
842schemas:
843  - name: inventory
844    profiles: [editor]
845
846roles:
847  - name: analytics
848    login: true
849
850grants:
851  - role: analytics
852    privileges: [SELECT]
853    on:
854      type: table
855      schema: inventory
856      name: "*"
857"#;
858        let manifest = parse_manifest(yaml).unwrap();
859        let expanded = expand_manifest(&manifest).unwrap();
860
861        assert_eq!(expanded.roles.len(), 2);
862        assert_eq!(expanded.grants.len(), 2); // 1 from profile + 1 one-off
863    }
864
865    #[test]
866    fn parse_membership_with_email_roles() {
867        let yaml = r#"
868memberships:
869  - role: inventory-editor
870    members:
871      - name: "alice@example.com"
872        inherit: true
873      - name: "engineering@example.com"
874        admin: true
875"#;
876        let manifest = parse_manifest(yaml).unwrap();
877        assert_eq!(manifest.memberships.len(), 1);
878        assert_eq!(manifest.memberships[0].members.len(), 2);
879        assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
880        assert!(manifest.memberships[0].members[0].inherit);
881        assert!(manifest.memberships[0].members[1].admin);
882    }
883
884    #[test]
885    fn member_spec_defaults() {
886        let yaml = r#"
887memberships:
888  - role: some-role
889    members:
890      - name: user1
891"#;
892        let manifest = parse_manifest(yaml).unwrap();
893        // inherit defaults to true, admin defaults to false
894        assert!(manifest.memberships[0].members[0].inherit);
895        assert!(!manifest.memberships[0].members[0].admin);
896    }
897
898    #[test]
899    fn expand_rejects_duplicate_retirements() {
900        let yaml = r#"
901retirements:
902  - role: old-app
903  - role: old-app
904"#;
905        let manifest = parse_manifest(yaml).unwrap();
906        let result = expand_manifest(&manifest);
907        assert!(matches!(
908            result,
909            Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
910        ));
911    }
912
913    #[test]
914    fn expand_rejects_retirement_for_desired_role() {
915        let yaml = r#"
916roles:
917  - name: old-app
918
919retirements:
920  - role: old-app
921"#;
922        let manifest = parse_manifest(yaml).unwrap();
923        let result = expand_manifest(&manifest);
924        assert!(matches!(
925            result,
926            Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
927        ));
928    }
929
930    #[test]
931    fn expand_rejects_self_reassign_retirement() {
932        let yaml = r#"
933retirements:
934  - role: old-app
935    reassign_owned_to: old-app
936"#;
937        let manifest = parse_manifest(yaml).unwrap();
938        let result = expand_manifest(&manifest);
939        assert!(matches!(
940            result,
941            Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
942        ));
943    }
944
945    #[test]
946    fn parse_auth_providers() {
947        let yaml = r#"
948auth_providers:
949  - type: cloud_sql_iam
950    project: my-gcp-project
951  - type: alloydb_iam
952    project: my-gcp-project
953    cluster: analytics-prod
954  - type: rds_iam
955    region: us-east-1
956  - type: azure_ad
957    tenant_id: "abc-123"
958  - type: supabase
959    project_ref: myprojref
960  - type: planet_scale
961    organization: my-org
962
963roles:
964  - name: app-service
965"#;
966        let manifest = parse_manifest(yaml).unwrap();
967        assert_eq!(manifest.auth_providers.len(), 6);
968        assert!(matches!(
969            &manifest.auth_providers[0],
970            AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
971        ));
972        assert!(matches!(
973            &manifest.auth_providers[1],
974            AuthProvider::AlloyDbIam {
975                project: Some(p),
976                cluster: Some(c)
977            } if p == "my-gcp-project" && c == "analytics-prod"
978        ));
979        assert!(matches!(
980            &manifest.auth_providers[2],
981            AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
982        ));
983        assert!(matches!(
984            &manifest.auth_providers[3],
985            AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
986        ));
987        assert!(matches!(
988            &manifest.auth_providers[4],
989            AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
990        ));
991        assert!(matches!(
992            &manifest.auth_providers[5],
993            AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
994        ));
995    }
996
997    #[test]
998    fn parse_manifest_without_auth_providers() {
999        let yaml = r#"
1000roles:
1001  - name: test-role
1002"#;
1003        let manifest = parse_manifest(yaml).unwrap();
1004        assert!(manifest.auth_providers.is_empty());
1005    }
1006}