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)]
382pub struct MemberSpec {
383 pub name: String,
384
385 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub inherit: Option<bool>,
388
389 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub admin: Option<bool>,
392}
393
394impl MemberSpec {
395 pub fn inherit(&self) -> bool {
397 self.inherit.unwrap_or(true)
398 }
399
400 pub fn admin(&self) -> bool {
402 self.admin.unwrap_or(false)
403 }
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
408pub struct RoleRetirement {
409 pub role: String,
411
412 #[serde(default)]
414 pub reassign_owned_to: Option<String>,
415
416 #[serde(default)]
418 pub drop_owned: bool,
419
420 #[serde(default)]
422 pub terminate_sessions: bool,
423}
424
425#[derive(Debug, Clone)]
432pub struct ExpandedManifest {
433 pub roles: Vec<RoleDefinition>,
434 pub grants: Vec<Grant>,
435 pub default_privileges: Vec<DefaultPrivilege>,
436 pub memberships: Vec<Membership>,
437}
438
439pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
449 let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
451 if let serde_yaml::Value::Mapping(ref map) = value {
452 let api_version_key = serde_yaml::Value::String("apiVersion".into());
453 let spec_key = serde_yaml::Value::String("spec".into());
454 if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
455 let spec = map.get(&spec_key).ok_or_else(|| {
456 ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
457 })?;
458 let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
459 return Ok(manifest);
460 }
461 }
462 let manifest: PolicyManifest = serde_yaml::from_value(value)?;
463 Ok(manifest)
464}
465
466pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
470 let mut roles: Vec<RoleDefinition> = Vec::new();
471 let mut grants: Vec<Grant> = Vec::new();
472 let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
473
474 for schema_binding in &manifest.schemas {
476 for profile_name in &schema_binding.profiles {
477 let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
478 ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
479 })?;
480
481 if !schema_binding.role_pattern.contains("{profile}") {
483 return Err(ManifestError::InvalidRolePattern(
484 schema_binding.role_pattern.clone(),
485 ));
486 }
487
488 let role_name = schema_binding
490 .role_pattern
491 .replace("{schema}", &schema_binding.name)
492 .replace("{profile}", profile_name);
493
494 roles.push(RoleDefinition {
496 name: role_name.clone(),
497 login: profile.login,
498 superuser: None,
499 createdb: None,
500 createrole: None,
501 inherit: None,
502 replication: None,
503 bypassrls: None,
504 connection_limit: None,
505 comment: Some(format!(
506 "Generated from profile '{profile_name}' for schema '{}'",
507 schema_binding.name
508 )),
509 password: None,
510 password_valid_until: None,
511 });
512
513 for profile_grant in &profile.grants {
515 let object_target = match profile_grant.object.object_type {
516 ObjectType::Schema => ObjectTarget {
517 object_type: ObjectType::Schema,
518 schema: None,
519 name: Some(schema_binding.name.clone()),
520 },
521 _ => ObjectTarget {
522 object_type: profile_grant.object.object_type,
523 schema: Some(schema_binding.name.clone()),
524 name: profile_grant.object.name.clone(),
525 },
526 };
527
528 grants.push(Grant {
529 role: role_name.clone(),
530 privileges: profile_grant.privileges.clone(),
531 object: object_target,
532 });
533 }
534
535 if !profile.default_privileges.is_empty() {
537 let owner = schema_binding
538 .owner
539 .clone()
540 .or(manifest.default_owner.clone());
541
542 let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
543 .default_privileges
544 .iter()
545 .map(|dp| DefaultPrivilegeGrant {
546 role: Some(role_name.clone()),
547 privileges: dp.privileges.clone(),
548 on_type: dp.on_type,
549 })
550 .collect();
551
552 default_privileges.push(DefaultPrivilege {
553 owner,
554 schema: schema_binding.name.clone(),
555 grant: expanded_grants,
556 });
557 }
558 }
559 }
560
561 for default_priv in &manifest.default_privileges {
563 for grant in &default_priv.grant {
564 if grant.role.is_none() {
565 return Err(ManifestError::MissingDefaultPrivilegeRole {
566 schema: default_priv.schema.clone(),
567 });
568 }
569 }
570 }
571
572 roles.extend(manifest.roles.clone());
574 grants.extend(manifest.grants.clone());
575 default_privileges.extend(manifest.default_privileges.clone());
576 let memberships = manifest.memberships.clone();
577
578 let mut seen_roles: HashSet<String> = HashSet::new();
580 for role in &roles {
581 if seen_roles.contains(&role.name) {
582 return Err(ManifestError::DuplicateRole(role.name.clone()));
583 }
584 seen_roles.insert(role.name.clone());
585 }
586
587 let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
588 let mut seen_retirements: HashSet<String> = HashSet::new();
589 for retirement in &manifest.retirements {
590 if seen_retirements.contains(&retirement.role) {
591 return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
592 }
593 if desired_role_names.contains(&retirement.role) {
594 return Err(ManifestError::RetirementRoleStillDesired(
595 retirement.role.clone(),
596 ));
597 }
598 if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
599 return Err(ManifestError::RetirementSelfReassign {
600 role: retirement.role.clone(),
601 });
602 }
603 seen_retirements.insert(retirement.role.clone());
604 }
605
606 for role in &roles {
610 if role.password.is_some() && role.login != Some(true) {
611 return Err(ManifestError::PasswordWithoutLogin {
612 role: role.name.clone(),
613 });
614 }
615 }
616
617 for role in &roles {
619 if let Some(value) = &role.password_valid_until
620 && !is_valid_iso8601_timestamp(value)
621 {
622 return Err(ManifestError::InvalidValidUntil {
623 role: role.name.clone(),
624 value: value.clone(),
625 });
626 }
627 }
628
629 Ok(ExpandedManifest {
630 roles,
631 grants,
632 default_privileges,
633 memberships,
634 })
635}
636
637fn is_valid_iso8601_timestamp(value: &str) -> bool {
653 if value.len() < 20 {
655 return false;
656 }
657
658 let bytes = value.as_bytes();
659
660 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
662 return false;
663 }
664
665 let year = &value[0..4];
666 let month = &value[5..7];
667 let day = &value[8..10];
668
669 let Ok(y) = year.parse::<u16>() else {
670 return false;
671 };
672 let Ok(m) = month.parse::<u8>() else {
673 return false;
674 };
675 let Ok(d) = day.parse::<u8>() else {
676 return false;
677 };
678
679 if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
680 return false;
681 }
682
683 if bytes[13] != b':' || bytes[16] != b':' {
685 return false;
686 }
687
688 let hour = &value[11..13];
689 let minute = &value[14..16];
690 let second = &value[17..19];
691
692 let Ok(h) = hour.parse::<u8>() else {
693 return false;
694 };
695 let Ok(min) = minute.parse::<u8>() else {
696 return false;
697 };
698 let Ok(sec) = second.parse::<u8>() else {
699 return false;
700 };
701
702 if h > 23 || min > 59 || sec > 59 {
703 return false;
704 }
705
706 let suffix = &value[19..];
708
709 let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
711 let frac_end = rest
713 .find(|c: char| !c.is_ascii_digit())
714 .unwrap_or(rest.len());
715 if frac_end == 0 {
716 return false; }
718 &rest[frac_end..]
719 } else {
720 suffix
721 };
722
723 match tz_part {
725 "Z" => true,
726 s if (s.starts_with('+') || s.starts_with('-'))
727 && s.len() == 6
728 && s.as_bytes()[3] == b':' =>
729 {
730 let Ok(tz_h) = s[1..3].parse::<u8>() else {
731 return false;
732 };
733 let Ok(tz_m) = s[4..6].parse::<u8>() else {
734 return false;
735 };
736 tz_h <= 14 && tz_m <= 59
737 }
738 _ => false,
739 }
740}
741
742#[cfg(test)]
747mod tests {
748 use super::*;
749
750 #[test]
751 fn parse_minimal_role() {
752 let yaml = r#"
753roles:
754 - name: test-role
755"#;
756 let manifest = parse_manifest(yaml).unwrap();
757 assert_eq!(manifest.roles.len(), 1);
758 assert_eq!(manifest.roles[0].name, "test-role");
759 assert!(manifest.roles[0].login.is_none());
760 }
761
762 #[test]
763 fn parse_full_policy() {
764 let yaml = r#"
765default_owner: app_owner
766
767profiles:
768 editor:
769 login: false
770 grants:
771 - privileges: [USAGE]
772 object: { type: schema }
773 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
774 object: { type: table, name: "*" }
775 - privileges: [USAGE, SELECT, UPDATE]
776 object: { type: sequence, name: "*" }
777 - privileges: [EXECUTE]
778 object: { type: function, name: "*" }
779 default_privileges:
780 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
781 on_type: table
782 - privileges: [USAGE, SELECT, UPDATE]
783 on_type: sequence
784 - privileges: [EXECUTE]
785 on_type: function
786
787schemas:
788 - name: inventory
789 profiles: [editor]
790 - name: catalog
791 profiles: [editor]
792
793roles:
794 - name: analytics-readonly
795 login: true
796
797memberships:
798 - role: inventory-editor
799 members:
800 - name: "alice@example.com"
801 inherit: true
802"#;
803 let manifest = parse_manifest(yaml).unwrap();
804 assert_eq!(manifest.profiles.len(), 1);
805 assert_eq!(manifest.schemas.len(), 2);
806 assert_eq!(manifest.roles.len(), 1);
807 assert_eq!(manifest.memberships.len(), 1);
808 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
809 }
810
811 #[test]
812 fn reject_invalid_yaml() {
813 let yaml = "not: [valid: yaml: {{";
814 assert!(parse_manifest(yaml).is_err());
815 }
816
817 #[test]
818 fn expand_profiles_basic() {
819 let yaml = r#"
820profiles:
821 editor:
822 login: false
823 grants:
824 - privileges: [USAGE]
825 object: { type: schema }
826 - privileges: [SELECT, INSERT]
827 object: { type: table, name: "*" }
828
829schemas:
830 - name: myschema
831 profiles: [editor]
832"#;
833 let manifest = parse_manifest(yaml).unwrap();
834 let expanded = expand_manifest(&manifest).unwrap();
835
836 assert_eq!(expanded.roles.len(), 1);
837 assert_eq!(expanded.roles[0].name, "myschema-editor");
838 assert_eq!(expanded.roles[0].login, Some(false));
839
840 assert_eq!(expanded.grants.len(), 2);
842 assert_eq!(expanded.grants[0].role, "myschema-editor");
843 assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
844 assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
845
846 assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
847 assert_eq!(
848 expanded.grants[1].object.schema,
849 Some("myschema".to_string())
850 );
851 assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
852 }
853
854 #[test]
855 fn expand_profiles_multi_schema() {
856 let yaml = r#"
857profiles:
858 editor:
859 grants:
860 - privileges: [SELECT]
861 object: { type: table, name: "*" }
862 viewer:
863 grants:
864 - privileges: [SELECT]
865 object: { type: table, name: "*" }
866
867schemas:
868 - name: alpha
869 profiles: [editor, viewer]
870 - name: beta
871 profiles: [editor, viewer]
872 - name: gamma
873 profiles: [editor]
874"#;
875 let manifest = parse_manifest(yaml).unwrap();
876 let expanded = expand_manifest(&manifest).unwrap();
877
878 assert_eq!(expanded.roles.len(), 5);
880 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
881 assert!(role_names.contains(&"alpha-editor"));
882 assert!(role_names.contains(&"alpha-viewer"));
883 assert!(role_names.contains(&"beta-editor"));
884 assert!(role_names.contains(&"beta-viewer"));
885 assert!(role_names.contains(&"gamma-editor"));
886 }
887
888 #[test]
889 fn expand_custom_role_pattern() {
890 let yaml = r#"
891profiles:
892 viewer:
893 grants:
894 - privileges: [SELECT]
895 object: { type: table, name: "*" }
896
897schemas:
898 - name: legacy_data
899 profiles: [viewer]
900 role_pattern: "legacy-{profile}"
901"#;
902 let manifest = parse_manifest(yaml).unwrap();
903 let expanded = expand_manifest(&manifest).unwrap();
904
905 assert_eq!(expanded.roles.len(), 1);
906 assert_eq!(expanded.roles[0].name, "legacy-viewer");
907 }
908
909 #[test]
910 fn expand_rejects_duplicate_role_name() {
911 let yaml = r#"
912profiles:
913 editor:
914 grants: []
915
916schemas:
917 - name: inventory
918 profiles: [editor]
919
920roles:
921 - name: inventory-editor
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("duplicate role name")
931 );
932 }
933
934 #[test]
935 fn expand_rejects_undefined_profile() {
936 let yaml = r#"
937profiles: {}
938
939schemas:
940 - name: inventory
941 profiles: [nonexistent]
942"#;
943 let manifest = parse_manifest(yaml).unwrap();
944 let result = expand_manifest(&manifest);
945 assert!(result.is_err());
946 assert!(result.unwrap_err().to_string().contains("not defined"));
947 }
948
949 #[test]
950 fn expand_rejects_invalid_pattern() {
951 let yaml = r#"
952profiles:
953 editor:
954 grants: []
955
956schemas:
957 - name: inventory
958 profiles: [editor]
959 role_pattern: "static-name"
960"#;
961 let manifest = parse_manifest(yaml).unwrap();
962 let result = expand_manifest(&manifest);
963 assert!(result.is_err());
964 assert!(
965 result
966 .unwrap_err()
967 .to_string()
968 .contains("{profile} placeholder")
969 );
970 }
971
972 #[test]
973 fn expand_rejects_top_level_default_privilege_without_role() {
974 let yaml = r#"
975default_privileges:
976 - schema: public
977 grant:
978 - privileges: [SELECT]
979 on_type: table
980"#;
981 let manifest = parse_manifest(yaml).unwrap();
982 let result = expand_manifest(&manifest);
983 assert!(result.is_err());
984 assert!(
985 result
986 .unwrap_err()
987 .to_string()
988 .contains("must specify grant.role")
989 );
990 }
991
992 #[test]
993 fn expand_default_privileges_with_owner_override() {
994 let yaml = r#"
995default_owner: app_owner
996
997profiles:
998 editor:
999 grants: []
1000 default_privileges:
1001 - privileges: [SELECT]
1002 on_type: table
1003
1004schemas:
1005 - name: inventory
1006 profiles: [editor]
1007 - name: legacy
1008 profiles: [editor]
1009 owner: legacy_admin
1010"#;
1011 let manifest = parse_manifest(yaml).unwrap();
1012 let expanded = expand_manifest(&manifest).unwrap();
1013
1014 assert_eq!(expanded.default_privileges.len(), 2);
1015
1016 assert_eq!(
1018 expanded.default_privileges[0].owner,
1019 Some("app_owner".to_string())
1020 );
1021 assert_eq!(expanded.default_privileges[0].schema, "inventory");
1022
1023 assert_eq!(
1025 expanded.default_privileges[1].owner,
1026 Some("legacy_admin".to_string())
1027 );
1028 assert_eq!(expanded.default_privileges[1].schema, "legacy");
1029 }
1030
1031 #[test]
1032 fn expand_merges_oneoff_roles_and_grants() {
1033 let yaml = r#"
1034profiles:
1035 editor:
1036 grants:
1037 - privileges: [SELECT]
1038 object: { type: table, name: "*" }
1039
1040schemas:
1041 - name: inventory
1042 profiles: [editor]
1043
1044roles:
1045 - name: analytics
1046 login: true
1047
1048grants:
1049 - role: analytics
1050 privileges: [SELECT]
1051 on:
1052 type: table
1053 schema: inventory
1054 name: "*"
1055"#;
1056 let manifest = parse_manifest(yaml).unwrap();
1057 let expanded = expand_manifest(&manifest).unwrap();
1058
1059 assert_eq!(expanded.roles.len(), 2);
1060 assert_eq!(expanded.grants.len(), 2); }
1062
1063 #[test]
1064 fn parse_manifest_accepts_legacy_on_alias() {
1065 let yaml = r#"
1066grants:
1067 - role: analytics
1068 privileges: [SELECT]
1069 on:
1070 type: table
1071 schema: public
1072 name: "*"
1073"#;
1074 let manifest = parse_manifest(yaml).unwrap();
1075 assert_eq!(manifest.grants.len(), 1);
1076 assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1077 assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1078 assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1079 }
1080
1081 #[test]
1082 fn parse_membership_with_email_roles() {
1083 let yaml = r#"
1084memberships:
1085 - role: inventory-editor
1086 members:
1087 - name: "alice@example.com"
1088 inherit: true
1089 - name: "engineering@example.com"
1090 admin: true
1091"#;
1092 let manifest = parse_manifest(yaml).unwrap();
1093 assert_eq!(manifest.memberships.len(), 1);
1094 assert_eq!(manifest.memberships[0].members.len(), 2);
1095 assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1096 assert_eq!(manifest.memberships[0].members[0].inherit, Some(true));
1097 assert_eq!(manifest.memberships[0].members[1].admin, Some(true));
1098 }
1099
1100 #[test]
1101 fn member_spec_defaults() {
1102 let yaml = r#"
1103memberships:
1104 - role: some-role
1105 members:
1106 - name: user1
1107"#;
1108 let manifest = parse_manifest(yaml).unwrap();
1109 assert_eq!(manifest.memberships[0].members[0].inherit, None);
1111 assert_eq!(manifest.memberships[0].members[0].admin, None);
1112 assert!(manifest.memberships[0].members[0].inherit());
1114 assert!(!manifest.memberships[0].members[0].admin());
1115 }
1116
1117 #[test]
1118 fn expand_rejects_duplicate_retirements() {
1119 let yaml = r#"
1120retirements:
1121 - role: old-app
1122 - role: old-app
1123"#;
1124 let manifest = parse_manifest(yaml).unwrap();
1125 let result = expand_manifest(&manifest);
1126 assert!(matches!(
1127 result,
1128 Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1129 ));
1130 }
1131
1132 #[test]
1133 fn expand_rejects_retirement_for_desired_role() {
1134 let yaml = r#"
1135roles:
1136 - name: old-app
1137
1138retirements:
1139 - role: old-app
1140"#;
1141 let manifest = parse_manifest(yaml).unwrap();
1142 let result = expand_manifest(&manifest);
1143 assert!(matches!(
1144 result,
1145 Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1146 ));
1147 }
1148
1149 #[test]
1150 fn expand_rejects_self_reassign_retirement() {
1151 let yaml = r#"
1152retirements:
1153 - role: old-app
1154 reassign_owned_to: old-app
1155"#;
1156 let manifest = parse_manifest(yaml).unwrap();
1157 let result = expand_manifest(&manifest);
1158 assert!(matches!(
1159 result,
1160 Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1161 ));
1162 }
1163
1164 #[test]
1165 fn parse_auth_providers() {
1166 let yaml = r#"
1167auth_providers:
1168 - type: cloud_sql_iam
1169 project: my-gcp-project
1170 - type: alloydb_iam
1171 project: my-gcp-project
1172 cluster: analytics-prod
1173 - type: rds_iam
1174 region: us-east-1
1175 - type: azure_ad
1176 tenant_id: "abc-123"
1177 - type: supabase
1178 project_ref: myprojref
1179 - type: planet_scale
1180 organization: my-org
1181
1182roles:
1183 - name: app-service
1184"#;
1185 let manifest = parse_manifest(yaml).unwrap();
1186 assert_eq!(manifest.auth_providers.len(), 6);
1187 assert!(matches!(
1188 &manifest.auth_providers[0],
1189 AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1190 ));
1191 assert!(matches!(
1192 &manifest.auth_providers[1],
1193 AuthProvider::AlloyDbIam {
1194 project: Some(p),
1195 cluster: Some(c)
1196 } if p == "my-gcp-project" && c == "analytics-prod"
1197 ));
1198 assert!(matches!(
1199 &manifest.auth_providers[2],
1200 AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1201 ));
1202 assert!(matches!(
1203 &manifest.auth_providers[3],
1204 AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1205 ));
1206 assert!(matches!(
1207 &manifest.auth_providers[4],
1208 AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1209 ));
1210 assert!(matches!(
1211 &manifest.auth_providers[5],
1212 AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1213 ));
1214 }
1215
1216 #[test]
1217 fn parse_manifest_without_auth_providers() {
1218 let yaml = r#"
1219roles:
1220 - name: test-role
1221"#;
1222 let manifest = parse_manifest(yaml).unwrap();
1223 assert!(manifest.auth_providers.is_empty());
1224 }
1225
1226 #[test]
1227 fn parse_role_with_password_source() {
1228 let yaml = r#"
1229roles:
1230 - name: app-service
1231 login: true
1232 password:
1233 from_env: APP_SERVICE_PASSWORD
1234 password_valid_until: "2025-12-31T00:00:00Z"
1235"#;
1236 let manifest = parse_manifest(yaml).unwrap();
1237 assert_eq!(manifest.roles.len(), 1);
1238 let role = &manifest.roles[0];
1239 assert!(role.password.is_some());
1240 assert_eq!(
1241 role.password.as_ref().unwrap().from_env,
1242 "APP_SERVICE_PASSWORD"
1243 );
1244 assert_eq!(
1245 role.password_valid_until,
1246 Some("2025-12-31T00:00:00Z".to_string())
1247 );
1248 }
1249
1250 #[test]
1251 fn parse_role_without_password() {
1252 let yaml = r#"
1253roles:
1254 - name: app-service
1255 login: true
1256"#;
1257 let manifest = parse_manifest(yaml).unwrap();
1258 assert!(manifest.roles[0].password.is_none());
1259 assert!(manifest.roles[0].password_valid_until.is_none());
1260 }
1261
1262 #[test]
1263 fn reject_password_on_nologin_role() {
1264 let yaml = r#"
1265roles:
1266 - name: nologin-role
1267 login: false
1268 password:
1269 from_env: SOME_PASSWORD
1270"#;
1271 let manifest = parse_manifest(yaml).unwrap();
1272 let result = expand_manifest(&manifest);
1273 assert!(result.is_err());
1274 assert!(
1275 result
1276 .unwrap_err()
1277 .to_string()
1278 .contains("login is not enabled")
1279 );
1280 }
1281
1282 #[test]
1283 fn reject_password_on_default_login_role() {
1284 let yaml = r#"
1286roles:
1287 - name: implicit-nologin-role
1288 password:
1289 from_env: SOME_PASSWORD
1290"#;
1291 let manifest = parse_manifest(yaml).unwrap();
1292 let result = expand_manifest(&manifest);
1293 assert!(result.is_err());
1294 assert!(
1295 result
1296 .unwrap_err()
1297 .to_string()
1298 .contains("login is not enabled")
1299 );
1300 }
1301
1302 #[test]
1303 fn reject_invalid_password_valid_until() {
1304 let yaml = r#"
1305roles:
1306 - name: bad-date
1307 login: true
1308 password_valid_until: "not-a-date"
1309"#;
1310 let manifest = parse_manifest(yaml).unwrap();
1311 let result = expand_manifest(&manifest);
1312 assert!(result.is_err());
1313 assert!(
1314 result
1315 .unwrap_err()
1316 .to_string()
1317 .contains("invalid password_valid_until")
1318 );
1319 }
1320
1321 #[test]
1322 fn reject_date_only_valid_until() {
1323 let yaml = r#"
1324roles:
1325 - name: bad-date
1326 login: true
1327 password_valid_until: "2025-12-31"
1328"#;
1329 let manifest = parse_manifest(yaml).unwrap();
1330 let result = expand_manifest(&manifest);
1331 assert!(result.is_err());
1332 }
1333
1334 #[test]
1335 fn accept_valid_iso8601_timestamps() {
1336 assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1338 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1340 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1341 assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1343 }
1344
1345 #[test]
1346 fn reject_invalid_iso8601_timestamps() {
1347 assert!(!is_valid_iso8601_timestamp("not-a-date"));
1348 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("")); }
1354
1355 #[test]
1356 fn parse_manifest_from_kubernetes_cr() {
1357 let yaml = r#"
1358apiVersion: pgroles.io/v1alpha1
1359kind: PostgresPolicy
1360metadata:
1361 name: staging-policy
1362 namespace: pgroles-system
1363spec:
1364 connection:
1365 secretRef:
1366 name: pgroles-db-credentials
1367 interval: "5m"
1368 mode: plan
1369 roles:
1370 - name: app_analytics
1371 login: true
1372 - name: app_billing
1373 login: true
1374 schemas:
1375 - name: analytics
1376 profiles: [editor, viewer]
1377 profiles:
1378 editor:
1379 grants:
1380 - object: { type: schema }
1381 privileges: [USAGE]
1382 - object: { type: table, name: "*" }
1383 privileges: [SELECT, INSERT, UPDATE, DELETE]
1384 viewer:
1385 grants:
1386 - object: { type: schema }
1387 privileges: [USAGE]
1388 - object: { type: table, name: "*" }
1389 privileges: [SELECT]
1390 memberships:
1391 - role: analytics-editor
1392 members:
1393 - { name: app_analytics }
1394 - role: analytics-viewer
1395 members:
1396 - { name: app_billing }
1397"#;
1398 let manifest = parse_manifest(yaml).unwrap();
1399 assert_eq!(manifest.roles.len(), 2);
1400 assert_eq!(manifest.roles[0].name, "app_analytics");
1401 assert_eq!(manifest.schemas.len(), 1);
1402 assert_eq!(manifest.memberships.len(), 2);
1403 assert_eq!(manifest.profiles.len(), 2);
1404 }
1405
1406 #[test]
1407 fn parse_manifest_bare_and_cr_produce_same_result() {
1408 let bare = r#"
1409roles:
1410 - name: test_role
1411 login: true
1412schemas:
1413 - name: public
1414 profiles: [viewer]
1415profiles:
1416 viewer:
1417 grants:
1418 - object: { type: schema }
1419 privileges: [USAGE]
1420"#;
1421 let cr = r#"
1422apiVersion: pgroles.io/v1alpha1
1423kind: PostgresPolicy
1424metadata:
1425 name: test
1426spec:
1427 roles:
1428 - name: test_role
1429 login: true
1430 schemas:
1431 - name: public
1432 profiles: [viewer]
1433 profiles:
1434 viewer:
1435 grants:
1436 - object: { type: schema }
1437 privileges: [USAGE]
1438"#;
1439 let from_bare = parse_manifest(bare).unwrap();
1440 let from_cr = parse_manifest(cr).unwrap();
1441 assert_eq!(from_bare.roles.len(), from_cr.roles.len());
1442 assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
1443 assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
1444 }
1445}