1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{HashMap, HashSet};
4use thiserror::Error;
5
6#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PolicyManifest {
115 #[serde(default)]
117 pub default_owner: Option<String>,
118
119 #[serde(default)]
121 pub auth_providers: Vec<AuthProvider>,
122
123 #[serde(default)]
125 pub profiles: HashMap<String, Profile>,
126
127 #[serde(default)]
129 pub schemas: Vec<SchemaBinding>,
130
131 #[serde(default)]
133 pub roles: Vec<RoleDefinition>,
134
135 #[serde(default)]
137 pub grants: Vec<Grant>,
138
139 #[serde(default)]
141 pub default_privileges: Vec<DefaultPrivilege>,
142
143 #[serde(default)]
145 pub memberships: Vec<Membership>,
146
147 #[serde(default)]
149 pub retirements: Vec<RoleRetirement>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
157#[serde(tag = "type", rename_all = "snake_case")]
158pub enum AuthProvider {
159 CloudSqlIam {
162 #[serde(default)]
164 project: Option<String>,
165 },
166 #[serde(rename = "alloydb_iam")]
169 AlloyDbIam {
170 #[serde(default)]
172 project: Option<String>,
173 #[serde(default)]
175 cluster: Option<String>,
176 },
177 RdsIam {
180 #[serde(default)]
182 region: Option<String>,
183 },
184 AzureAd {
186 #[serde(default)]
188 tenant_id: Option<String>,
189 },
190 Supabase {
192 #[serde(default)]
194 project_ref: Option<String>,
195 },
196 PlanetScale {
198 #[serde(default)]
200 organization: Option<String>,
201 },
202}
203
204#[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#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ProfileGrant {
220 pub privileges: Vec<Privilege>,
221 pub on: ProfileObjectTarget,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ProfileObjectTarget {
227 #[serde(rename = "type")]
228 pub object_type: ObjectType,
229 #[serde(default)]
231 pub name: Option<String>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
236pub struct SchemaBinding {
237 pub name: String,
238
239 pub profiles: Vec<String>,
240
241 #[serde(default = "default_role_pattern")]
244 pub role_pattern: String,
245
246 #[serde(default)]
248 pub owner: Option<String>,
249}
250
251fn default_role_pattern() -> String {
252 "{schema}-{profile}".to_string()
253}
254
255#[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#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
298pub struct ObjectTarget {
299 #[serde(rename = "type")]
300 pub object_type: ObjectType,
301
302 #[serde(default)]
304 pub schema: Option<String>,
305
306 #[serde(default)]
308 pub name: Option<String>,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313pub struct DefaultPrivilege {
314 #[serde(default)]
316 pub owner: Option<String>,
317
318 pub schema: String,
319
320 pub grant: Vec<DefaultPrivilegeGrant>,
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325pub struct DefaultPrivilegeGrant {
326 #[serde(default)]
329 pub role: Option<String>,
330
331 pub privileges: Vec<Privilege>,
332 pub on_type: ObjectType,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
337pub struct Membership {
338 pub role: String,
339 pub members: Vec<MemberSpec>,
340}
341
342#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
360pub struct RoleRetirement {
361 pub role: String,
363
364 #[serde(default)]
366 pub reassign_owned_to: Option<String>,
367
368 #[serde(default)]
370 pub drop_owned: bool,
371
372 #[serde(default)]
374 pub terminate_sessions: bool,
375}
376
377#[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
391pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
397 let manifest: PolicyManifest = serde_yaml::from_str(yaml)?;
398 Ok(manifest)
399}
400
401pub 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 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 if !schema_binding.role_pattern.contains("{profile}") {
418 return Err(ManifestError::InvalidRolePattern(
419 schema_binding.role_pattern.clone(),
420 ));
421 }
422
423 let role_name = schema_binding
425 .role_pattern
426 .replace("{schema}", &schema_binding.name)
427 .replace("{profile}", profile_name);
428
429 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 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 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 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 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 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#[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 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 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 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 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); }
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 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}