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 pub on: ProfileObjectTarget,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ProfileObjectTarget {
237 #[serde(rename = "type")]
238 pub object_type: ObjectType,
239 #[serde(default)]
241 pub name: Option<String>,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
246pub struct SchemaBinding {
247 pub name: String,
248
249 pub profiles: Vec<String>,
250
251 #[serde(default = "default_role_pattern")]
254 pub role_pattern: String,
255
256 #[serde(default)]
258 pub owner: Option<String>,
259}
260
261fn default_role_pattern() -> String {
262 "{schema}-{profile}".to_string()
263}
264
265#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub password: Option<PasswordSource>,
301
302 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub password_valid_until: Option<String>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
313pub struct PasswordSource {
314 pub from_env: String,
316}
317
318#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
328pub struct ObjectTarget {
329 #[serde(rename = "type")]
330 pub object_type: ObjectType,
331
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub schema: Option<String>,
335
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub name: Option<String>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
343pub struct DefaultPrivilege {
344 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
355pub struct DefaultPrivilegeGrant {
356 #[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
367pub struct Membership {
368 pub role: String,
369 pub members: Vec<MemberSpec>,
370}
371
372#[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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
390pub struct RoleRetirement {
391 pub role: String,
393
394 #[serde(default)]
396 pub reassign_owned_to: Option<String>,
397
398 #[serde(default)]
400 pub drop_owned: bool,
401
402 #[serde(default)]
404 pub terminate_sessions: bool,
405}
406
407#[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
421pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
427 let manifest: PolicyManifest = serde_yaml::from_str(yaml)?;
428 Ok(manifest)
429}
430
431pub 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 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 if !schema_binding.role_pattern.contains("{profile}") {
448 return Err(ManifestError::InvalidRolePattern(
449 schema_binding.role_pattern.clone(),
450 ));
451 }
452
453 let role_name = schema_binding
455 .role_pattern
456 .replace("{schema}", &schema_binding.name)
457 .replace("{profile}", profile_name);
458
459 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 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 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 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 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 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 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 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
602fn is_valid_iso8601_timestamp(value: &str) -> bool {
618 if value.len() < 20 {
620 return false;
621 }
622
623 let bytes = value.as_bytes();
624
625 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 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 let suffix = &value[19..];
673
674 let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
676 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; }
683 &rest[frac_end..]
684 } else {
685 suffix
686 };
687
688 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#[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 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 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 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 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); }
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 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 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 assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1279 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 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")); 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("")); }
1295}