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 #[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
124pub struct PolicyManifest {
125 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub default_owner: Option<String>,
128
129 #[serde(default)]
131 pub auth_providers: Vec<AuthProvider>,
132
133 #[serde(default)]
135 pub profiles: HashMap<String, Profile>,
136
137 #[serde(default)]
139 pub schemas: Vec<SchemaBinding>,
140
141 #[serde(default)]
143 pub roles: Vec<RoleDefinition>,
144
145 #[serde(default)]
147 pub grants: Vec<Grant>,
148
149 #[serde(default)]
151 pub default_privileges: Vec<DefaultPrivilege>,
152
153 #[serde(default)]
155 pub memberships: Vec<Membership>,
156
157 #[serde(default)]
159 pub retirements: Vec<RoleRetirement>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
167#[serde(tag = "type", rename_all = "snake_case")]
168pub enum AuthProvider {
169 CloudSqlIam {
172 #[serde(default)]
174 project: Option<String>,
175 },
176 #[serde(rename = "alloydb_iam")]
179 AlloyDbIam {
180 #[serde(default)]
182 project: Option<String>,
183 #[serde(default)]
185 cluster: Option<String>,
186 },
187 RdsIam {
190 #[serde(default)]
192 region: Option<String>,
193 },
194 AzureAd {
196 #[serde(default)]
198 tenant_id: Option<String>,
199 },
200 Supabase {
202 #[serde(default)]
204 project_ref: Option<String>,
205 },
206 PlanetScale {
208 #[serde(default)]
210 organization: Option<String>,
211 },
212}
213
214#[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#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct ProfileGrant {
230 pub privileges: Vec<Privilege>,
231 #[serde(alias = "on")]
232 pub object: ProfileObjectTarget,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ProfileObjectTarget {
238 #[serde(rename = "type")]
239 pub object_type: ObjectType,
240 #[serde(default)]
242 pub name: Option<String>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
247pub struct SchemaBinding {
248 pub name: String,
249
250 pub profiles: Vec<String>,
251
252 #[serde(default = "default_role_pattern")]
255 pub role_pattern: String,
256
257 #[serde(default)]
259 pub owner: Option<String>,
260}
261
262fn default_role_pattern() -> String {
263 "{schema}-{profile}".to_string()
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct RoleDefinition {
269 pub name: String,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub login: Option<bool>,
273
274 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub superuser: Option<bool>,
276
277 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub createdb: Option<bool>,
279
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub createrole: Option<bool>,
282
283 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub inherit: Option<bool>,
285
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub replication: Option<bool>,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub bypassrls: Option<bool>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub connection_limit: Option<i32>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub comment: Option<String>,
297
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub password: Option<PasswordSource>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub password_valid_until: Option<String>,
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
314pub struct PasswordSource {
315 pub from_env: String,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
321pub struct Grant {
322 pub role: String,
323 pub privileges: Vec<Privilege>,
324 #[serde(alias = "on")]
325 pub object: ObjectTarget,
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
330pub struct ObjectTarget {
331 #[serde(rename = "type")]
332 pub object_type: ObjectType,
333
334 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub schema: Option<String>,
337
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub name: Option<String>,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
345pub struct DefaultPrivilege {
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub owner: Option<String>,
349
350 pub schema: String,
351
352 pub grant: Vec<DefaultPrivilegeGrant>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
357pub struct DefaultPrivilegeGrant {
358 #[serde(default, skip_serializing_if = "Option::is_none")]
361 pub role: Option<String>,
362
363 pub privileges: Vec<Privilege>,
364 pub on_type: ObjectType,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
369pub struct Membership {
370 pub role: String,
371 pub members: Vec<MemberSpec>,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
376pub struct MemberSpec {
377 pub name: String,
378
379 #[serde(default = "default_true")]
380 pub inherit: bool,
381
382 #[serde(default)]
383 pub admin: bool,
384}
385
386fn default_true() -> bool {
387 true
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
392pub struct RoleRetirement {
393 pub role: String,
395
396 #[serde(default)]
398 pub reassign_owned_to: Option<String>,
399
400 #[serde(default)]
402 pub drop_owned: bool,
403
404 #[serde(default)]
406 pub terminate_sessions: bool,
407}
408
409#[derive(Debug, Clone)]
416pub struct ExpandedManifest {
417 pub roles: Vec<RoleDefinition>,
418 pub grants: Vec<Grant>,
419 pub default_privileges: Vec<DefaultPrivilege>,
420 pub memberships: Vec<Membership>,
421}
422
423pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
429 let manifest: PolicyManifest = serde_yaml::from_str(yaml)?;
430 Ok(manifest)
431}
432
433pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
437 let mut roles: Vec<RoleDefinition> = Vec::new();
438 let mut grants: Vec<Grant> = Vec::new();
439 let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
440
441 for schema_binding in &manifest.schemas {
443 for profile_name in &schema_binding.profiles {
444 let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
445 ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
446 })?;
447
448 if !schema_binding.role_pattern.contains("{profile}") {
450 return Err(ManifestError::InvalidRolePattern(
451 schema_binding.role_pattern.clone(),
452 ));
453 }
454
455 let role_name = schema_binding
457 .role_pattern
458 .replace("{schema}", &schema_binding.name)
459 .replace("{profile}", profile_name);
460
461 roles.push(RoleDefinition {
463 name: role_name.clone(),
464 login: profile.login,
465 superuser: None,
466 createdb: None,
467 createrole: None,
468 inherit: None,
469 replication: None,
470 bypassrls: None,
471 connection_limit: None,
472 comment: Some(format!(
473 "Generated from profile '{profile_name}' for schema '{}'",
474 schema_binding.name
475 )),
476 password: None,
477 password_valid_until: None,
478 });
479
480 for profile_grant in &profile.grants {
482 let object_target = match profile_grant.object.object_type {
483 ObjectType::Schema => ObjectTarget {
484 object_type: ObjectType::Schema,
485 schema: None,
486 name: Some(schema_binding.name.clone()),
487 },
488 _ => ObjectTarget {
489 object_type: profile_grant.object.object_type,
490 schema: Some(schema_binding.name.clone()),
491 name: profile_grant.object.name.clone(),
492 },
493 };
494
495 grants.push(Grant {
496 role: role_name.clone(),
497 privileges: profile_grant.privileges.clone(),
498 object: object_target,
499 });
500 }
501
502 if !profile.default_privileges.is_empty() {
504 let owner = schema_binding
505 .owner
506 .clone()
507 .or(manifest.default_owner.clone());
508
509 let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
510 .default_privileges
511 .iter()
512 .map(|dp| DefaultPrivilegeGrant {
513 role: Some(role_name.clone()),
514 privileges: dp.privileges.clone(),
515 on_type: dp.on_type,
516 })
517 .collect();
518
519 default_privileges.push(DefaultPrivilege {
520 owner,
521 schema: schema_binding.name.clone(),
522 grant: expanded_grants,
523 });
524 }
525 }
526 }
527
528 for default_priv in &manifest.default_privileges {
530 for grant in &default_priv.grant {
531 if grant.role.is_none() {
532 return Err(ManifestError::MissingDefaultPrivilegeRole {
533 schema: default_priv.schema.clone(),
534 });
535 }
536 }
537 }
538
539 roles.extend(manifest.roles.clone());
541 grants.extend(manifest.grants.clone());
542 default_privileges.extend(manifest.default_privileges.clone());
543 let memberships = manifest.memberships.clone();
544
545 let mut seen_roles: HashSet<String> = HashSet::new();
547 for role in &roles {
548 if seen_roles.contains(&role.name) {
549 return Err(ManifestError::DuplicateRole(role.name.clone()));
550 }
551 seen_roles.insert(role.name.clone());
552 }
553
554 let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
555 let mut seen_retirements: HashSet<String> = HashSet::new();
556 for retirement in &manifest.retirements {
557 if seen_retirements.contains(&retirement.role) {
558 return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
559 }
560 if desired_role_names.contains(&retirement.role) {
561 return Err(ManifestError::RetirementRoleStillDesired(
562 retirement.role.clone(),
563 ));
564 }
565 if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
566 return Err(ManifestError::RetirementSelfReassign {
567 role: retirement.role.clone(),
568 });
569 }
570 seen_retirements.insert(retirement.role.clone());
571 }
572
573 for role in &roles {
577 if role.password.is_some() && role.login != Some(true) {
578 return Err(ManifestError::PasswordWithoutLogin {
579 role: role.name.clone(),
580 });
581 }
582 }
583
584 for role in &roles {
586 if let Some(value) = &role.password_valid_until
587 && !is_valid_iso8601_timestamp(value)
588 {
589 return Err(ManifestError::InvalidValidUntil {
590 role: role.name.clone(),
591 value: value.clone(),
592 });
593 }
594 }
595
596 Ok(ExpandedManifest {
597 roles,
598 grants,
599 default_privileges,
600 memberships,
601 })
602}
603
604fn is_valid_iso8601_timestamp(value: &str) -> bool {
620 if value.len() < 20 {
622 return false;
623 }
624
625 let bytes = value.as_bytes();
626
627 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
629 return false;
630 }
631
632 let year = &value[0..4];
633 let month = &value[5..7];
634 let day = &value[8..10];
635
636 let Ok(y) = year.parse::<u16>() else {
637 return false;
638 };
639 let Ok(m) = month.parse::<u8>() else {
640 return false;
641 };
642 let Ok(d) = day.parse::<u8>() else {
643 return false;
644 };
645
646 if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
647 return false;
648 }
649
650 if bytes[13] != b':' || bytes[16] != b':' {
652 return false;
653 }
654
655 let hour = &value[11..13];
656 let minute = &value[14..16];
657 let second = &value[17..19];
658
659 let Ok(h) = hour.parse::<u8>() else {
660 return false;
661 };
662 let Ok(min) = minute.parse::<u8>() else {
663 return false;
664 };
665 let Ok(sec) = second.parse::<u8>() else {
666 return false;
667 };
668
669 if h > 23 || min > 59 || sec > 59 {
670 return false;
671 }
672
673 let suffix = &value[19..];
675
676 let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
678 let frac_end = rest
680 .find(|c: char| !c.is_ascii_digit())
681 .unwrap_or(rest.len());
682 if frac_end == 0 {
683 return false; }
685 &rest[frac_end..]
686 } else {
687 suffix
688 };
689
690 match tz_part {
692 "Z" => true,
693 s if (s.starts_with('+') || s.starts_with('-'))
694 && s.len() == 6
695 && s.as_bytes()[3] == b':' =>
696 {
697 let Ok(tz_h) = s[1..3].parse::<u8>() else {
698 return false;
699 };
700 let Ok(tz_m) = s[4..6].parse::<u8>() else {
701 return false;
702 };
703 tz_h <= 14 && tz_m <= 59
704 }
705 _ => false,
706 }
707}
708
709#[cfg(test)]
714mod tests {
715 use super::*;
716
717 #[test]
718 fn parse_minimal_role() {
719 let yaml = r#"
720roles:
721 - name: test-role
722"#;
723 let manifest = parse_manifest(yaml).unwrap();
724 assert_eq!(manifest.roles.len(), 1);
725 assert_eq!(manifest.roles[0].name, "test-role");
726 assert!(manifest.roles[0].login.is_none());
727 }
728
729 #[test]
730 fn parse_full_policy() {
731 let yaml = r#"
732default_owner: app_owner
733
734profiles:
735 editor:
736 login: false
737 grants:
738 - privileges: [USAGE]
739 object: { type: schema }
740 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
741 object: { type: table, name: "*" }
742 - privileges: [USAGE, SELECT, UPDATE]
743 object: { type: sequence, name: "*" }
744 - privileges: [EXECUTE]
745 object: { type: function, name: "*" }
746 default_privileges:
747 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
748 on_type: table
749 - privileges: [USAGE, SELECT, UPDATE]
750 on_type: sequence
751 - privileges: [EXECUTE]
752 on_type: function
753
754schemas:
755 - name: inventory
756 profiles: [editor]
757 - name: catalog
758 profiles: [editor]
759
760roles:
761 - name: analytics-readonly
762 login: true
763
764memberships:
765 - role: inventory-editor
766 members:
767 - name: "alice@example.com"
768 inherit: true
769"#;
770 let manifest = parse_manifest(yaml).unwrap();
771 assert_eq!(manifest.profiles.len(), 1);
772 assert_eq!(manifest.schemas.len(), 2);
773 assert_eq!(manifest.roles.len(), 1);
774 assert_eq!(manifest.memberships.len(), 1);
775 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
776 }
777
778 #[test]
779 fn reject_invalid_yaml() {
780 let yaml = "not: [valid: yaml: {{";
781 assert!(parse_manifest(yaml).is_err());
782 }
783
784 #[test]
785 fn expand_profiles_basic() {
786 let yaml = r#"
787profiles:
788 editor:
789 login: false
790 grants:
791 - privileges: [USAGE]
792 object: { type: schema }
793 - privileges: [SELECT, INSERT]
794 object: { type: table, name: "*" }
795
796schemas:
797 - name: myschema
798 profiles: [editor]
799"#;
800 let manifest = parse_manifest(yaml).unwrap();
801 let expanded = expand_manifest(&manifest).unwrap();
802
803 assert_eq!(expanded.roles.len(), 1);
804 assert_eq!(expanded.roles[0].name, "myschema-editor");
805 assert_eq!(expanded.roles[0].login, Some(false));
806
807 assert_eq!(expanded.grants.len(), 2);
809 assert_eq!(expanded.grants[0].role, "myschema-editor");
810 assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
811 assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
812
813 assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
814 assert_eq!(
815 expanded.grants[1].object.schema,
816 Some("myschema".to_string())
817 );
818 assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
819 }
820
821 #[test]
822 fn expand_profiles_multi_schema() {
823 let yaml = r#"
824profiles:
825 editor:
826 grants:
827 - privileges: [SELECT]
828 object: { type: table, name: "*" }
829 viewer:
830 grants:
831 - privileges: [SELECT]
832 object: { type: table, name: "*" }
833
834schemas:
835 - name: alpha
836 profiles: [editor, viewer]
837 - name: beta
838 profiles: [editor, viewer]
839 - name: gamma
840 profiles: [editor]
841"#;
842 let manifest = parse_manifest(yaml).unwrap();
843 let expanded = expand_manifest(&manifest).unwrap();
844
845 assert_eq!(expanded.roles.len(), 5);
847 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
848 assert!(role_names.contains(&"alpha-editor"));
849 assert!(role_names.contains(&"alpha-viewer"));
850 assert!(role_names.contains(&"beta-editor"));
851 assert!(role_names.contains(&"beta-viewer"));
852 assert!(role_names.contains(&"gamma-editor"));
853 }
854
855 #[test]
856 fn expand_custom_role_pattern() {
857 let yaml = r#"
858profiles:
859 viewer:
860 grants:
861 - privileges: [SELECT]
862 object: { type: table, name: "*" }
863
864schemas:
865 - name: legacy_data
866 profiles: [viewer]
867 role_pattern: "legacy-{profile}"
868"#;
869 let manifest = parse_manifest(yaml).unwrap();
870 let expanded = expand_manifest(&manifest).unwrap();
871
872 assert_eq!(expanded.roles.len(), 1);
873 assert_eq!(expanded.roles[0].name, "legacy-viewer");
874 }
875
876 #[test]
877 fn expand_rejects_duplicate_role_name() {
878 let yaml = r#"
879profiles:
880 editor:
881 grants: []
882
883schemas:
884 - name: inventory
885 profiles: [editor]
886
887roles:
888 - name: inventory-editor
889"#;
890 let manifest = parse_manifest(yaml).unwrap();
891 let result = expand_manifest(&manifest);
892 assert!(result.is_err());
893 assert!(
894 result
895 .unwrap_err()
896 .to_string()
897 .contains("duplicate role name")
898 );
899 }
900
901 #[test]
902 fn expand_rejects_undefined_profile() {
903 let yaml = r#"
904profiles: {}
905
906schemas:
907 - name: inventory
908 profiles: [nonexistent]
909"#;
910 let manifest = parse_manifest(yaml).unwrap();
911 let result = expand_manifest(&manifest);
912 assert!(result.is_err());
913 assert!(result.unwrap_err().to_string().contains("not defined"));
914 }
915
916 #[test]
917 fn expand_rejects_invalid_pattern() {
918 let yaml = r#"
919profiles:
920 editor:
921 grants: []
922
923schemas:
924 - name: inventory
925 profiles: [editor]
926 role_pattern: "static-name"
927"#;
928 let manifest = parse_manifest(yaml).unwrap();
929 let result = expand_manifest(&manifest);
930 assert!(result.is_err());
931 assert!(
932 result
933 .unwrap_err()
934 .to_string()
935 .contains("{profile} placeholder")
936 );
937 }
938
939 #[test]
940 fn expand_rejects_top_level_default_privilege_without_role() {
941 let yaml = r#"
942default_privileges:
943 - schema: public
944 grant:
945 - privileges: [SELECT]
946 on_type: table
947"#;
948 let manifest = parse_manifest(yaml).unwrap();
949 let result = expand_manifest(&manifest);
950 assert!(result.is_err());
951 assert!(
952 result
953 .unwrap_err()
954 .to_string()
955 .contains("must specify grant.role")
956 );
957 }
958
959 #[test]
960 fn expand_default_privileges_with_owner_override() {
961 let yaml = r#"
962default_owner: app_owner
963
964profiles:
965 editor:
966 grants: []
967 default_privileges:
968 - privileges: [SELECT]
969 on_type: table
970
971schemas:
972 - name: inventory
973 profiles: [editor]
974 - name: legacy
975 profiles: [editor]
976 owner: legacy_admin
977"#;
978 let manifest = parse_manifest(yaml).unwrap();
979 let expanded = expand_manifest(&manifest).unwrap();
980
981 assert_eq!(expanded.default_privileges.len(), 2);
982
983 assert_eq!(
985 expanded.default_privileges[0].owner,
986 Some("app_owner".to_string())
987 );
988 assert_eq!(expanded.default_privileges[0].schema, "inventory");
989
990 assert_eq!(
992 expanded.default_privileges[1].owner,
993 Some("legacy_admin".to_string())
994 );
995 assert_eq!(expanded.default_privileges[1].schema, "legacy");
996 }
997
998 #[test]
999 fn expand_merges_oneoff_roles_and_grants() {
1000 let yaml = r#"
1001profiles:
1002 editor:
1003 grants:
1004 - privileges: [SELECT]
1005 object: { type: table, name: "*" }
1006
1007schemas:
1008 - name: inventory
1009 profiles: [editor]
1010
1011roles:
1012 - name: analytics
1013 login: true
1014
1015grants:
1016 - role: analytics
1017 privileges: [SELECT]
1018 on:
1019 type: table
1020 schema: inventory
1021 name: "*"
1022"#;
1023 let manifest = parse_manifest(yaml).unwrap();
1024 let expanded = expand_manifest(&manifest).unwrap();
1025
1026 assert_eq!(expanded.roles.len(), 2);
1027 assert_eq!(expanded.grants.len(), 2); }
1029
1030 #[test]
1031 fn parse_manifest_accepts_legacy_on_alias() {
1032 let yaml = r#"
1033grants:
1034 - role: analytics
1035 privileges: [SELECT]
1036 on:
1037 type: table
1038 schema: public
1039 name: "*"
1040"#;
1041 let manifest = parse_manifest(yaml).unwrap();
1042 assert_eq!(manifest.grants.len(), 1);
1043 assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1044 assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1045 assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1046 }
1047
1048 #[test]
1049 fn parse_membership_with_email_roles() {
1050 let yaml = r#"
1051memberships:
1052 - role: inventory-editor
1053 members:
1054 - name: "alice@example.com"
1055 inherit: true
1056 - name: "engineering@example.com"
1057 admin: true
1058"#;
1059 let manifest = parse_manifest(yaml).unwrap();
1060 assert_eq!(manifest.memberships.len(), 1);
1061 assert_eq!(manifest.memberships[0].members.len(), 2);
1062 assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1063 assert!(manifest.memberships[0].members[0].inherit);
1064 assert!(manifest.memberships[0].members[1].admin);
1065 }
1066
1067 #[test]
1068 fn member_spec_defaults() {
1069 let yaml = r#"
1070memberships:
1071 - role: some-role
1072 members:
1073 - name: user1
1074"#;
1075 let manifest = parse_manifest(yaml).unwrap();
1076 assert!(manifest.memberships[0].members[0].inherit);
1078 assert!(!manifest.memberships[0].members[0].admin);
1079 }
1080
1081 #[test]
1082 fn expand_rejects_duplicate_retirements() {
1083 let yaml = r#"
1084retirements:
1085 - role: old-app
1086 - role: old-app
1087"#;
1088 let manifest = parse_manifest(yaml).unwrap();
1089 let result = expand_manifest(&manifest);
1090 assert!(matches!(
1091 result,
1092 Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1093 ));
1094 }
1095
1096 #[test]
1097 fn expand_rejects_retirement_for_desired_role() {
1098 let yaml = r#"
1099roles:
1100 - name: old-app
1101
1102retirements:
1103 - role: old-app
1104"#;
1105 let manifest = parse_manifest(yaml).unwrap();
1106 let result = expand_manifest(&manifest);
1107 assert!(matches!(
1108 result,
1109 Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1110 ));
1111 }
1112
1113 #[test]
1114 fn expand_rejects_self_reassign_retirement() {
1115 let yaml = r#"
1116retirements:
1117 - role: old-app
1118 reassign_owned_to: old-app
1119"#;
1120 let manifest = parse_manifest(yaml).unwrap();
1121 let result = expand_manifest(&manifest);
1122 assert!(matches!(
1123 result,
1124 Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1125 ));
1126 }
1127
1128 #[test]
1129 fn parse_auth_providers() {
1130 let yaml = r#"
1131auth_providers:
1132 - type: cloud_sql_iam
1133 project: my-gcp-project
1134 - type: alloydb_iam
1135 project: my-gcp-project
1136 cluster: analytics-prod
1137 - type: rds_iam
1138 region: us-east-1
1139 - type: azure_ad
1140 tenant_id: "abc-123"
1141 - type: supabase
1142 project_ref: myprojref
1143 - type: planet_scale
1144 organization: my-org
1145
1146roles:
1147 - name: app-service
1148"#;
1149 let manifest = parse_manifest(yaml).unwrap();
1150 assert_eq!(manifest.auth_providers.len(), 6);
1151 assert!(matches!(
1152 &manifest.auth_providers[0],
1153 AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1154 ));
1155 assert!(matches!(
1156 &manifest.auth_providers[1],
1157 AuthProvider::AlloyDbIam {
1158 project: Some(p),
1159 cluster: Some(c)
1160 } if p == "my-gcp-project" && c == "analytics-prod"
1161 ));
1162 assert!(matches!(
1163 &manifest.auth_providers[2],
1164 AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1165 ));
1166 assert!(matches!(
1167 &manifest.auth_providers[3],
1168 AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1169 ));
1170 assert!(matches!(
1171 &manifest.auth_providers[4],
1172 AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1173 ));
1174 assert!(matches!(
1175 &manifest.auth_providers[5],
1176 AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1177 ));
1178 }
1179
1180 #[test]
1181 fn parse_manifest_without_auth_providers() {
1182 let yaml = r#"
1183roles:
1184 - name: test-role
1185"#;
1186 let manifest = parse_manifest(yaml).unwrap();
1187 assert!(manifest.auth_providers.is_empty());
1188 }
1189
1190 #[test]
1191 fn parse_role_with_password_source() {
1192 let yaml = r#"
1193roles:
1194 - name: app-service
1195 login: true
1196 password:
1197 from_env: APP_SERVICE_PASSWORD
1198 password_valid_until: "2025-12-31T00:00:00Z"
1199"#;
1200 let manifest = parse_manifest(yaml).unwrap();
1201 assert_eq!(manifest.roles.len(), 1);
1202 let role = &manifest.roles[0];
1203 assert!(role.password.is_some());
1204 assert_eq!(
1205 role.password.as_ref().unwrap().from_env,
1206 "APP_SERVICE_PASSWORD"
1207 );
1208 assert_eq!(
1209 role.password_valid_until,
1210 Some("2025-12-31T00:00:00Z".to_string())
1211 );
1212 }
1213
1214 #[test]
1215 fn parse_role_without_password() {
1216 let yaml = r#"
1217roles:
1218 - name: app-service
1219 login: true
1220"#;
1221 let manifest = parse_manifest(yaml).unwrap();
1222 assert!(manifest.roles[0].password.is_none());
1223 assert!(manifest.roles[0].password_valid_until.is_none());
1224 }
1225
1226 #[test]
1227 fn reject_password_on_nologin_role() {
1228 let yaml = r#"
1229roles:
1230 - name: nologin-role
1231 login: false
1232 password:
1233 from_env: SOME_PASSWORD
1234"#;
1235 let manifest = parse_manifest(yaml).unwrap();
1236 let result = expand_manifest(&manifest);
1237 assert!(result.is_err());
1238 assert!(
1239 result
1240 .unwrap_err()
1241 .to_string()
1242 .contains("login is not enabled")
1243 );
1244 }
1245
1246 #[test]
1247 fn reject_password_on_default_login_role() {
1248 let yaml = r#"
1250roles:
1251 - name: implicit-nologin-role
1252 password:
1253 from_env: SOME_PASSWORD
1254"#;
1255 let manifest = parse_manifest(yaml).unwrap();
1256 let result = expand_manifest(&manifest);
1257 assert!(result.is_err());
1258 assert!(
1259 result
1260 .unwrap_err()
1261 .to_string()
1262 .contains("login is not enabled")
1263 );
1264 }
1265
1266 #[test]
1267 fn reject_invalid_password_valid_until() {
1268 let yaml = r#"
1269roles:
1270 - name: bad-date
1271 login: true
1272 password_valid_until: "not-a-date"
1273"#;
1274 let manifest = parse_manifest(yaml).unwrap();
1275 let result = expand_manifest(&manifest);
1276 assert!(result.is_err());
1277 assert!(
1278 result
1279 .unwrap_err()
1280 .to_string()
1281 .contains("invalid password_valid_until")
1282 );
1283 }
1284
1285 #[test]
1286 fn reject_date_only_valid_until() {
1287 let yaml = r#"
1288roles:
1289 - name: bad-date
1290 login: true
1291 password_valid_until: "2025-12-31"
1292"#;
1293 let manifest = parse_manifest(yaml).unwrap();
1294 let result = expand_manifest(&manifest);
1295 assert!(result.is_err());
1296 }
1297
1298 #[test]
1299 fn accept_valid_iso8601_timestamps() {
1300 assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1302 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1304 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1305 assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1307 }
1308
1309 #[test]
1310 fn reject_invalid_iso8601_timestamps() {
1311 assert!(!is_valid_iso8601_timestamp("not-a-date"));
1312 assert!(!is_valid_iso8601_timestamp("2025-12-31")); assert!(!is_valid_iso8601_timestamp("2025-13-31T00:00:00Z")); assert!(!is_valid_iso8601_timestamp("2025-12-31T25:00:00Z")); assert!(!is_valid_iso8601_timestamp("2025-12-31T00:00:00")); assert!(!is_valid_iso8601_timestamp("")); }
1318}