1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, 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("duplicate schema name: \"{0}\"")]
19 DuplicateSchema(String),
20
21 #[error("profile \"{0}\" referenced by schema \"{1}\" is not defined")]
22 UndefinedProfile(String, String),
23
24 #[error("role_pattern must contain {{profile}} placeholder, got: \"{0}\"")]
25 InvalidRolePattern(String),
26
27 #[error("top-level default privilege for schema \"{schema}\" must specify grant.role")]
28 MissingDefaultPrivilegeRole { schema: String },
29
30 #[error("duplicate retirement entry for role: \"{0}\"")]
31 DuplicateRetirement(String),
32
33 #[error("retirement entry for role \"{0}\" conflicts with a desired role of the same name")]
34 RetirementRoleStillDesired(String),
35
36 #[error("retirement entry for role \"{role}\" cannot reassign ownership to itself")]
37 RetirementSelfReassign { role: String },
38
39 #[error(
40 "role \"{role}\" has a password but login is not enabled — password will have no effect"
41 )]
42 PasswordWithoutLogin { role: String },
43
44 #[error(
45 "role \"{role}\" has an invalid password_valid_until value \"{value}\": expected ISO 8601 timestamp (e.g. \"2025-12-31T00:00:00Z\")"
46 )]
47 InvalidValidUntil { role: String, value: String },
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
56#[serde(rename_all = "snake_case")]
57pub enum ObjectType {
58 Table,
59 View,
60 #[serde(alias = "materialized_view")]
61 MaterializedView,
62 Sequence,
63 Function,
64 Schema,
65 Database,
66 Type,
67}
68
69impl std::fmt::Display for ObjectType {
70 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71 match self {
72 ObjectType::Table => write!(f, "table"),
73 ObjectType::View => write!(f, "view"),
74 ObjectType::MaterializedView => write!(f, "materialized_view"),
75 ObjectType::Sequence => write!(f, "sequence"),
76 ObjectType::Function => write!(f, "function"),
77 ObjectType::Schema => write!(f, "schema"),
78 ObjectType::Database => write!(f, "database"),
79 ObjectType::Type => write!(f, "type"),
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "UPPERCASE")]
87pub enum Privilege {
88 Select,
89 Insert,
90 Update,
91 Delete,
92 Truncate,
93 References,
94 Trigger,
95 Execute,
96 Usage,
97 Create,
98 Connect,
99 Temporary,
100}
101
102impl std::fmt::Display for Privilege {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 match self {
105 Privilege::Select => write!(f, "SELECT"),
106 Privilege::Insert => write!(f, "INSERT"),
107 Privilege::Update => write!(f, "UPDATE"),
108 Privilege::Delete => write!(f, "DELETE"),
109 Privilege::Truncate => write!(f, "TRUNCATE"),
110 Privilege::References => write!(f, "REFERENCES"),
111 Privilege::Trigger => write!(f, "TRIGGER"),
112 Privilege::Execute => write!(f, "EXECUTE"),
113 Privilege::Usage => write!(f, "USAGE"),
114 Privilege::Create => write!(f, "CREATE"),
115 Privilege::Connect => write!(f, "CONNECT"),
116 Privilege::Temporary => write!(f, "TEMPORARY"),
117 }
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct PolicyManifest {
128 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub default_owner: Option<String>,
131
132 #[serde(default)]
134 pub auth_providers: Vec<AuthProvider>,
135
136 #[serde(default)]
140 pub profiles: BTreeMap<String, Profile>,
141
142 #[serde(default)]
144 pub schemas: Vec<SchemaBinding>,
145
146 #[serde(default)]
148 pub roles: Vec<RoleDefinition>,
149
150 #[serde(default)]
152 pub grants: Vec<Grant>,
153
154 #[serde(default)]
156 pub default_privileges: Vec<DefaultPrivilege>,
157
158 #[serde(default)]
160 pub memberships: Vec<Membership>,
161
162 #[serde(default)]
164 pub retirements: Vec<RoleRetirement>,
165}
166
167#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
172#[serde(tag = "type", rename_all = "snake_case")]
173pub enum AuthProvider {
174 CloudSqlIam {
177 #[serde(default)]
179 project: Option<String>,
180 },
181 #[serde(rename = "alloydb_iam")]
184 AlloyDbIam {
185 #[serde(default)]
187 project: Option<String>,
188 #[serde(default)]
190 cluster: Option<String>,
191 },
192 RdsIam {
195 #[serde(default)]
197 region: Option<String>,
198 },
199 AzureAd {
201 #[serde(default)]
203 tenant_id: Option<String>,
204 },
205 Supabase {
207 #[serde(default)]
209 project_ref: Option<String>,
210 },
211 PlanetScale {
213 #[serde(default)]
215 organization: Option<String>,
216 },
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct Profile {
222 #[serde(default)]
223 pub login: Option<bool>,
224
225 #[serde(default)]
226 pub inherit: Option<bool>,
227
228 #[serde(default)]
229 pub grants: Vec<ProfileGrant>,
230
231 #[serde(default)]
232 pub default_privileges: Vec<DefaultPrivilegeGrant>,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ProfileGrant {
238 pub privileges: Vec<Privilege>,
239 #[serde(alias = "on")]
240 pub object: ProfileObjectTarget,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct ProfileObjectTarget {
246 #[serde(rename = "type")]
247 pub object_type: ObjectType,
248 #[serde(default)]
250 pub name: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
255pub struct SchemaBinding {
256 pub name: String,
257
258 #[serde(default)]
259 pub profiles: Vec<String>,
260
261 #[serde(default = "default_role_pattern")]
264 pub role_pattern: String,
265
266 #[serde(default)]
268 pub owner: Option<String>,
269}
270
271#[derive(
272 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
273)]
274#[serde(rename_all = "snake_case")]
275pub enum SchemaBindingFacet {
276 Owner,
277 Bindings,
278}
279
280impl std::fmt::Display for SchemaBindingFacet {
281 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282 match self {
283 SchemaBindingFacet::Owner => write!(f, "owner"),
284 SchemaBindingFacet::Bindings => write!(f, "bindings"),
285 }
286 }
287}
288
289pub(crate) fn default_role_pattern() -> String {
290 "{schema}-{profile}".to_string()
291}
292
293fn is_false(value: &bool) -> bool {
294 !*value
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct RoleDefinition {
300 pub name: String,
301
302 #[serde(default, skip_serializing_if = "is_false")]
306 pub external: bool,
307
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub login: Option<bool>,
310
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub superuser: Option<bool>,
313
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub createdb: Option<bool>,
316
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub createrole: Option<bool>,
319
320 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub inherit: Option<bool>,
322
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub replication: Option<bool>,
325
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub bypassrls: Option<bool>,
328
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub connection_limit: Option<i32>,
331
332 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub comment: Option<String>,
334
335 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub password: Option<PasswordSource>,
339
340 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub password_valid_until: Option<String>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
351pub struct PasswordSource {
352 pub from_env: String,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
358pub struct Grant {
359 pub role: String,
360 pub privileges: Vec<Privilege>,
361 #[serde(alias = "on")]
362 pub object: ObjectTarget,
363}
364
365#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
367pub struct ObjectTarget {
368 #[serde(rename = "type")]
369 pub object_type: ObjectType,
370
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub schema: Option<String>,
374
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub name: Option<String>,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
382pub struct DefaultPrivilege {
383 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub owner: Option<String>,
386
387 pub schema: String,
388
389 pub grant: Vec<DefaultPrivilegeGrant>,
390}
391
392#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
394pub struct DefaultPrivilegeGrant {
395 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub role: Option<String>,
399
400 pub privileges: Vec<Privilege>,
401 pub on_type: ObjectType,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
406pub struct Membership {
407 pub role: String,
408 pub members: Vec<MemberSpec>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
419pub struct MemberSpec {
420 pub name: String,
421
422 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub inherit: Option<bool>,
425
426 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub admin: Option<bool>,
429}
430
431impl MemberSpec {
432 pub fn inherit(&self) -> bool {
434 self.inherit.unwrap_or(true)
435 }
436
437 pub fn admin(&self) -> bool {
439 self.admin.unwrap_or(false)
440 }
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
445pub struct RoleRetirement {
446 pub role: String,
448
449 #[serde(default)]
451 pub reassign_owned_to: Option<String>,
452
453 #[serde(default)]
455 pub drop_owned: bool,
456
457 #[serde(default)]
459 pub terminate_sessions: bool,
460}
461
462#[derive(Debug, Clone)]
469pub struct ExpandedManifest {
470 pub schemas: Vec<ExpandedSchema>,
471 pub roles: Vec<RoleDefinition>,
472 pub grants: Vec<Grant>,
473 pub default_privileges: Vec<DefaultPrivilege>,
474 pub memberships: Vec<Membership>,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct ExpandedSchema {
479 pub name: String,
480 pub owner: Option<String>,
481}
482
483pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
493 let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
495 if let serde_yaml::Value::Mapping(ref map) = value {
496 let api_version_key = serde_yaml::Value::String("apiVersion".into());
497 let spec_key = serde_yaml::Value::String("spec".into());
498 if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
499 let spec = map.get(&spec_key).ok_or_else(|| {
500 ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
501 })?;
502 let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
503 return Ok(manifest);
504 }
505 }
506 let manifest: PolicyManifest = serde_yaml::from_value(value)?;
507 Ok(manifest)
508}
509
510pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
514 let mut seen_schemas: HashSet<String> = HashSet::new();
515 for schema_binding in &manifest.schemas {
516 if !seen_schemas.insert(schema_binding.name.clone()) {
517 return Err(ManifestError::DuplicateSchema(schema_binding.name.clone()));
518 }
519 }
520
521 let schemas: Vec<ExpandedSchema> = manifest
522 .schemas
523 .iter()
524 .map(|schema_binding| ExpandedSchema {
525 name: schema_binding.name.clone(),
526 owner: schema_binding
527 .owner
528 .clone()
529 .or(manifest.default_owner.clone()),
530 })
531 .collect();
532 let mut roles: Vec<RoleDefinition> = Vec::new();
533 let mut grants: Vec<Grant> = Vec::new();
534 let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
535
536 for schema_binding in &manifest.schemas {
538 for profile_name in &schema_binding.profiles {
539 let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
540 ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
541 })?;
542
543 if !schema_binding.role_pattern.contains("{profile}") {
545 return Err(ManifestError::InvalidRolePattern(
546 schema_binding.role_pattern.clone(),
547 ));
548 }
549
550 let role_name = schema_binding
552 .role_pattern
553 .replace("{schema}", &schema_binding.name)
554 .replace("{profile}", profile_name);
555
556 roles.push(RoleDefinition {
558 name: role_name.clone(),
559 external: false,
560 login: profile.login,
561 superuser: None,
562 createdb: None,
563 createrole: None,
564 inherit: profile.inherit,
565 replication: None,
566 bypassrls: None,
567 connection_limit: None,
568 comment: Some(format!(
569 "Generated from profile '{profile_name}' for schema '{}'",
570 schema_binding.name
571 )),
572 password: None,
573 password_valid_until: None,
574 });
575
576 for profile_grant in &profile.grants {
578 let object_target = match profile_grant.object.object_type {
579 ObjectType::Schema => ObjectTarget {
580 object_type: ObjectType::Schema,
581 schema: None,
582 name: Some(schema_binding.name.clone()),
583 },
584 _ => ObjectTarget {
585 object_type: profile_grant.object.object_type,
586 schema: Some(schema_binding.name.clone()),
587 name: profile_grant.object.name.clone(),
588 },
589 };
590
591 grants.push(Grant {
592 role: role_name.clone(),
593 privileges: profile_grant.privileges.clone(),
594 object: object_target,
595 });
596 }
597
598 if !profile.default_privileges.is_empty() {
600 let owner = schema_binding
601 .owner
602 .clone()
603 .or(manifest.default_owner.clone());
604
605 let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
606 .default_privileges
607 .iter()
608 .map(|dp| DefaultPrivilegeGrant {
609 role: Some(role_name.clone()),
610 privileges: dp.privileges.clone(),
611 on_type: dp.on_type,
612 })
613 .collect();
614
615 default_privileges.push(DefaultPrivilege {
616 owner,
617 schema: schema_binding.name.clone(),
618 grant: expanded_grants,
619 });
620 }
621 }
622 }
623
624 for default_priv in &manifest.default_privileges {
626 for grant in &default_priv.grant {
627 if grant.role.is_none() {
628 return Err(ManifestError::MissingDefaultPrivilegeRole {
629 schema: default_priv.schema.clone(),
630 });
631 }
632 }
633 }
634
635 roles.extend(manifest.roles.clone());
637 grants.extend(manifest.grants.clone());
638 default_privileges.extend(manifest.default_privileges.clone());
639 let memberships = manifest.memberships.clone();
640
641 let mut seen_roles: HashSet<String> = HashSet::new();
643 for role in &roles {
644 if seen_roles.contains(&role.name) {
645 return Err(ManifestError::DuplicateRole(role.name.clone()));
646 }
647 seen_roles.insert(role.name.clone());
648 }
649
650 let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
651 let mut seen_retirements: HashSet<String> = HashSet::new();
652 for retirement in &manifest.retirements {
653 if seen_retirements.contains(&retirement.role) {
654 return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
655 }
656 if desired_role_names.contains(&retirement.role) {
657 return Err(ManifestError::RetirementRoleStillDesired(
658 retirement.role.clone(),
659 ));
660 }
661 if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
662 return Err(ManifestError::RetirementSelfReassign {
663 role: retirement.role.clone(),
664 });
665 }
666 seen_retirements.insert(retirement.role.clone());
667 }
668
669 for role in &roles {
673 if role.password.is_some() && role.login != Some(true) {
674 return Err(ManifestError::PasswordWithoutLogin {
675 role: role.name.clone(),
676 });
677 }
678 }
679
680 for role in &roles {
682 if let Some(value) = &role.password_valid_until
683 && !is_valid_iso8601_timestamp(value)
684 {
685 return Err(ManifestError::InvalidValidUntil {
686 role: role.name.clone(),
687 value: value.clone(),
688 });
689 }
690 }
691
692 Ok(ExpandedManifest {
693 schemas,
694 roles,
695 grants,
696 default_privileges,
697 memberships,
698 })
699}
700
701fn is_valid_iso8601_timestamp(value: &str) -> bool {
717 if value.len() < 20 {
719 return false;
720 }
721
722 let bytes = value.as_bytes();
723
724 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
726 return false;
727 }
728
729 let year = &value[0..4];
730 let month = &value[5..7];
731 let day = &value[8..10];
732
733 let Ok(y) = year.parse::<u16>() else {
734 return false;
735 };
736 let Ok(m) = month.parse::<u8>() else {
737 return false;
738 };
739 let Ok(d) = day.parse::<u8>() else {
740 return false;
741 };
742
743 if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
744 return false;
745 }
746
747 if bytes[13] != b':' || bytes[16] != b':' {
749 return false;
750 }
751
752 let hour = &value[11..13];
753 let minute = &value[14..16];
754 let second = &value[17..19];
755
756 let Ok(h) = hour.parse::<u8>() else {
757 return false;
758 };
759 let Ok(min) = minute.parse::<u8>() else {
760 return false;
761 };
762 let Ok(sec) = second.parse::<u8>() else {
763 return false;
764 };
765
766 if h > 23 || min > 59 || sec > 59 {
767 return false;
768 }
769
770 let suffix = &value[19..];
772
773 let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
775 let frac_end = rest
777 .find(|c: char| !c.is_ascii_digit())
778 .unwrap_or(rest.len());
779 if frac_end == 0 {
780 return false; }
782 &rest[frac_end..]
783 } else {
784 suffix
785 };
786
787 match tz_part {
789 "Z" => true,
790 s if (s.starts_with('+') || s.starts_with('-'))
791 && s.len() == 6
792 && s.as_bytes()[3] == b':' =>
793 {
794 let Ok(tz_h) = s[1..3].parse::<u8>() else {
795 return false;
796 };
797 let Ok(tz_m) = s[4..6].parse::<u8>() else {
798 return false;
799 };
800 tz_h <= 14 && tz_m <= 59
801 }
802 _ => false,
803 }
804}
805
806#[cfg(test)]
811mod tests {
812 use super::*;
813
814 #[test]
815 fn parse_minimal_role() {
816 let yaml = r#"
817roles:
818 - name: test-role
819"#;
820 let manifest = parse_manifest(yaml).unwrap();
821 assert_eq!(manifest.roles.len(), 1);
822 assert_eq!(manifest.roles[0].name, "test-role");
823 assert!(manifest.roles[0].login.is_none());
824 }
825
826 #[test]
827 fn parse_full_policy() {
828 let yaml = r#"
829default_owner: app_owner
830
831profiles:
832 editor:
833 login: false
834 grants:
835 - privileges: [USAGE]
836 object: { type: schema }
837 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
838 object: { type: table, name: "*" }
839 - privileges: [USAGE, SELECT, UPDATE]
840 object: { type: sequence, name: "*" }
841 - privileges: [EXECUTE]
842 object: { type: function, name: "*" }
843 default_privileges:
844 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
845 on_type: table
846 - privileges: [USAGE, SELECT, UPDATE]
847 on_type: sequence
848 - privileges: [EXECUTE]
849 on_type: function
850
851schemas:
852 - name: inventory
853 profiles: [editor]
854 - name: catalog
855 profiles: [editor]
856
857roles:
858 - name: analytics-readonly
859 login: true
860
861memberships:
862 - role: inventory-editor
863 members:
864 - name: "alice@example.com"
865 inherit: true
866"#;
867 let manifest = parse_manifest(yaml).unwrap();
868 assert_eq!(manifest.profiles.len(), 1);
869 assert_eq!(manifest.schemas.len(), 2);
870 assert_eq!(manifest.roles.len(), 1);
871 assert_eq!(manifest.memberships.len(), 1);
872 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
873 }
874
875 #[test]
876 fn reject_invalid_yaml() {
877 let yaml = "not: [valid: yaml: {{";
878 assert!(parse_manifest(yaml).is_err());
879 }
880
881 #[test]
882 fn expand_profiles_basic() {
883 let yaml = r#"
884profiles:
885 editor:
886 login: false
887 grants:
888 - privileges: [USAGE]
889 object: { type: schema }
890 - privileges: [SELECT, INSERT]
891 object: { type: table, name: "*" }
892
893schemas:
894 - name: myschema
895 profiles: [editor]
896"#;
897 let manifest = parse_manifest(yaml).unwrap();
898 let expanded = expand_manifest(&manifest).unwrap();
899
900 assert_eq!(expanded.roles.len(), 1);
901 assert_eq!(expanded.roles[0].name, "myschema-editor");
902 assert_eq!(expanded.roles[0].login, Some(false));
903 assert_eq!(expanded.roles[0].inherit, None);
904
905 assert_eq!(expanded.grants.len(), 2);
907 assert_eq!(expanded.grants[0].role, "myschema-editor");
908 assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
909 assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
910
911 assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
912 assert_eq!(
913 expanded.grants[1].object.schema,
914 Some("myschema".to_string())
915 );
916 assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
917 }
918
919 #[test]
920 fn expand_schema_owner_overrides_default_owner() {
921 let yaml = r#"
922default_owner: app_owner
923
924profiles:
925 editor:
926 default_privileges:
927 - privileges: [SELECT]
928 on_type: table
929
930schemas:
931 - name: inventory
932 owner: inventory_owner
933 profiles: [editor]
934 - name: catalog
935 profiles: [editor]
936"#;
937
938 let manifest = parse_manifest(yaml).unwrap();
939 let expanded = expand_manifest(&manifest).unwrap();
940
941 assert_eq!(
942 expanded.schemas,
943 vec![
944 ExpandedSchema {
945 name: "inventory".to_string(),
946 owner: Some("inventory_owner".to_string()),
947 },
948 ExpandedSchema {
949 name: "catalog".to_string(),
950 owner: Some("app_owner".to_string()),
951 },
952 ]
953 );
954 }
955
956 #[test]
957 fn expand_profiles_preserves_generated_role_inherit() {
958 let yaml = r#"
959profiles:
960 editor:
961 login: false
962 inherit: false
963 grants:
964 - privileges: [USAGE]
965 object: { type: schema }
966
967schemas:
968 - name: myschema
969 profiles: [editor]
970"#;
971
972 let manifest = parse_manifest(yaml).unwrap();
973 let expanded = expand_manifest(&manifest).unwrap();
974
975 assert_eq!(expanded.roles.len(), 1);
976 assert_eq!(expanded.roles[0].name, "myschema-editor");
977 assert_eq!(expanded.roles[0].login, Some(false));
978 assert_eq!(expanded.roles[0].inherit, Some(false));
979 }
980
981 #[test]
982 fn expand_declared_schema_with_no_profiles() {
983 let yaml = r#"
984schemas:
985 - name: cdc
986 owner: cdc_owner
987 profiles: []
988"#;
989
990 let manifest = parse_manifest(yaml).unwrap();
991 let expanded = expand_manifest(&manifest).unwrap();
992
993 assert_eq!(expanded.schemas.len(), 1);
994 assert_eq!(expanded.schemas[0].name, "cdc");
995 assert_eq!(expanded.schemas[0].owner.as_deref(), Some("cdc_owner"));
996 assert!(expanded.roles.is_empty());
997 assert!(expanded.grants.is_empty());
998 assert!(expanded.default_privileges.is_empty());
999 }
1000
1001 #[test]
1002 fn expand_profiles_multi_schema() {
1003 let yaml = r#"
1004profiles:
1005 editor:
1006 grants:
1007 - privileges: [SELECT]
1008 object: { type: table, name: "*" }
1009 viewer:
1010 grants:
1011 - privileges: [SELECT]
1012 object: { type: table, name: "*" }
1013
1014schemas:
1015 - name: alpha
1016 profiles: [editor, viewer]
1017 - name: beta
1018 profiles: [editor, viewer]
1019 - name: gamma
1020 profiles: [editor]
1021"#;
1022 let manifest = parse_manifest(yaml).unwrap();
1023 let expanded = expand_manifest(&manifest).unwrap();
1024
1025 assert_eq!(expanded.roles.len(), 5);
1027 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
1028 assert!(role_names.contains(&"alpha-editor"));
1029 assert!(role_names.contains(&"alpha-viewer"));
1030 assert!(role_names.contains(&"beta-editor"));
1031 assert!(role_names.contains(&"beta-viewer"));
1032 assert!(role_names.contains(&"gamma-editor"));
1033 }
1034
1035 #[test]
1036 fn expand_custom_role_pattern() {
1037 let yaml = r#"
1038profiles:
1039 viewer:
1040 grants:
1041 - privileges: [SELECT]
1042 object: { type: table, name: "*" }
1043
1044schemas:
1045 - name: legacy_data
1046 profiles: [viewer]
1047 role_pattern: "legacy-{profile}"
1048"#;
1049 let manifest = parse_manifest(yaml).unwrap();
1050 let expanded = expand_manifest(&manifest).unwrap();
1051
1052 assert_eq!(expanded.roles.len(), 1);
1053 assert_eq!(expanded.roles[0].name, "legacy-viewer");
1054 }
1055
1056 #[test]
1057 fn expand_rejects_duplicate_role_name() {
1058 let yaml = r#"
1059profiles:
1060 editor:
1061 grants: []
1062
1063schemas:
1064 - name: inventory
1065 profiles: [editor]
1066
1067roles:
1068 - name: inventory-editor
1069"#;
1070 let manifest = parse_manifest(yaml).unwrap();
1071 let result = expand_manifest(&manifest);
1072 assert!(result.is_err());
1073 assert!(
1074 result
1075 .unwrap_err()
1076 .to_string()
1077 .contains("duplicate role name")
1078 );
1079 }
1080
1081 #[test]
1082 fn expand_rejects_duplicate_schema_name() {
1083 let yaml = r#"
1084schemas:
1085 - name: inventory
1086 profiles: []
1087 - name: inventory
1088 owner: inventory_owner
1089 profiles: []
1090"#;
1091
1092 let manifest = parse_manifest(yaml).unwrap();
1093 let error = expand_manifest(&manifest).unwrap_err();
1094 assert!(error.to_string().contains("duplicate schema name"));
1095 }
1096
1097 #[test]
1098 fn expand_rejects_undefined_profile() {
1099 let yaml = r#"
1100profiles: {}
1101
1102schemas:
1103 - name: inventory
1104 profiles: [nonexistent]
1105"#;
1106 let manifest = parse_manifest(yaml).unwrap();
1107 let result = expand_manifest(&manifest);
1108 assert!(result.is_err());
1109 assert!(result.unwrap_err().to_string().contains("not defined"));
1110 }
1111
1112 #[test]
1113 fn expand_rejects_invalid_pattern() {
1114 let yaml = r#"
1115profiles:
1116 editor:
1117 grants: []
1118
1119schemas:
1120 - name: inventory
1121 profiles: [editor]
1122 role_pattern: "static-name"
1123"#;
1124 let manifest = parse_manifest(yaml).unwrap();
1125 let result = expand_manifest(&manifest);
1126 assert!(result.is_err());
1127 assert!(
1128 result
1129 .unwrap_err()
1130 .to_string()
1131 .contains("{profile} placeholder")
1132 );
1133 }
1134
1135 #[test]
1136 fn expand_rejects_top_level_default_privilege_without_role() {
1137 let yaml = r#"
1138default_privileges:
1139 - schema: public
1140 grant:
1141 - privileges: [SELECT]
1142 on_type: table
1143"#;
1144 let manifest = parse_manifest(yaml).unwrap();
1145 let result = expand_manifest(&manifest);
1146 assert!(result.is_err());
1147 assert!(
1148 result
1149 .unwrap_err()
1150 .to_string()
1151 .contains("must specify grant.role")
1152 );
1153 }
1154
1155 #[test]
1156 fn expand_default_privileges_with_owner_override() {
1157 let yaml = r#"
1158default_owner: app_owner
1159
1160profiles:
1161 editor:
1162 grants: []
1163 default_privileges:
1164 - privileges: [SELECT]
1165 on_type: table
1166
1167schemas:
1168 - name: inventory
1169 profiles: [editor]
1170 - name: legacy
1171 profiles: [editor]
1172 owner: legacy_admin
1173"#;
1174 let manifest = parse_manifest(yaml).unwrap();
1175 let expanded = expand_manifest(&manifest).unwrap();
1176
1177 assert_eq!(expanded.default_privileges.len(), 2);
1178
1179 assert_eq!(
1181 expanded.default_privileges[0].owner,
1182 Some("app_owner".to_string())
1183 );
1184 assert_eq!(expanded.default_privileges[0].schema, "inventory");
1185
1186 assert_eq!(
1188 expanded.default_privileges[1].owner,
1189 Some("legacy_admin".to_string())
1190 );
1191 assert_eq!(expanded.default_privileges[1].schema, "legacy");
1192 }
1193
1194 #[test]
1195 fn expand_merges_oneoff_roles_and_grants() {
1196 let yaml = r#"
1197profiles:
1198 editor:
1199 grants:
1200 - privileges: [SELECT]
1201 object: { type: table, name: "*" }
1202
1203schemas:
1204 - name: inventory
1205 profiles: [editor]
1206
1207roles:
1208 - name: analytics
1209 login: true
1210
1211grants:
1212 - role: analytics
1213 privileges: [SELECT]
1214 on:
1215 type: table
1216 schema: inventory
1217 name: "*"
1218"#;
1219 let manifest = parse_manifest(yaml).unwrap();
1220 let expanded = expand_manifest(&manifest).unwrap();
1221
1222 assert_eq!(expanded.roles.len(), 2);
1223 assert_eq!(expanded.grants.len(), 2); }
1225
1226 #[test]
1227 fn parse_manifest_accepts_legacy_on_alias() {
1228 let yaml = r#"
1229grants:
1230 - role: analytics
1231 privileges: [SELECT]
1232 on:
1233 type: table
1234 schema: public
1235 name: "*"
1236"#;
1237 let manifest = parse_manifest(yaml).unwrap();
1238 assert_eq!(manifest.grants.len(), 1);
1239 assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1240 assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1241 assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1242 }
1243
1244 #[test]
1245 fn parse_membership_with_email_roles() {
1246 let yaml = r#"
1247memberships:
1248 - role: inventory-editor
1249 members:
1250 - name: "alice@example.com"
1251 inherit: true
1252 - name: "engineering@example.com"
1253 admin: true
1254"#;
1255 let manifest = parse_manifest(yaml).unwrap();
1256 assert_eq!(manifest.memberships.len(), 1);
1257 assert_eq!(manifest.memberships[0].members.len(), 2);
1258 assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1259 assert_eq!(manifest.memberships[0].members[0].inherit, Some(true));
1260 assert_eq!(manifest.memberships[0].members[1].admin, Some(true));
1261 }
1262
1263 #[test]
1264 fn member_spec_defaults() {
1265 let yaml = r#"
1266memberships:
1267 - role: some-role
1268 members:
1269 - name: user1
1270"#;
1271 let manifest = parse_manifest(yaml).unwrap();
1272 assert_eq!(manifest.memberships[0].members[0].inherit, None);
1274 assert_eq!(manifest.memberships[0].members[0].admin, None);
1275 assert!(manifest.memberships[0].members[0].inherit());
1277 assert!(!manifest.memberships[0].members[0].admin());
1278 }
1279
1280 #[test]
1281 fn expand_rejects_duplicate_retirements() {
1282 let yaml = r#"
1283retirements:
1284 - role: old-app
1285 - role: old-app
1286"#;
1287 let manifest = parse_manifest(yaml).unwrap();
1288 let result = expand_manifest(&manifest);
1289 assert!(matches!(
1290 result,
1291 Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1292 ));
1293 }
1294
1295 #[test]
1296 fn expand_rejects_retirement_for_desired_role() {
1297 let yaml = r#"
1298roles:
1299 - name: old-app
1300
1301retirements:
1302 - role: old-app
1303"#;
1304 let manifest = parse_manifest(yaml).unwrap();
1305 let result = expand_manifest(&manifest);
1306 assert!(matches!(
1307 result,
1308 Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1309 ));
1310 }
1311
1312 #[test]
1313 fn expand_rejects_self_reassign_retirement() {
1314 let yaml = r#"
1315retirements:
1316 - role: old-app
1317 reassign_owned_to: old-app
1318"#;
1319 let manifest = parse_manifest(yaml).unwrap();
1320 let result = expand_manifest(&manifest);
1321 assert!(matches!(
1322 result,
1323 Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1324 ));
1325 }
1326
1327 #[test]
1328 fn parse_auth_providers() {
1329 let yaml = r#"
1330auth_providers:
1331 - type: cloud_sql_iam
1332 project: my-gcp-project
1333 - type: alloydb_iam
1334 project: my-gcp-project
1335 cluster: analytics-prod
1336 - type: rds_iam
1337 region: us-east-1
1338 - type: azure_ad
1339 tenant_id: "abc-123"
1340 - type: supabase
1341 project_ref: myprojref
1342 - type: planet_scale
1343 organization: my-org
1344
1345roles:
1346 - name: app-service
1347"#;
1348 let manifest = parse_manifest(yaml).unwrap();
1349 assert_eq!(manifest.auth_providers.len(), 6);
1350 assert!(matches!(
1351 &manifest.auth_providers[0],
1352 AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1353 ));
1354 assert!(matches!(
1355 &manifest.auth_providers[1],
1356 AuthProvider::AlloyDbIam {
1357 project: Some(p),
1358 cluster: Some(c)
1359 } if p == "my-gcp-project" && c == "analytics-prod"
1360 ));
1361 assert!(matches!(
1362 &manifest.auth_providers[2],
1363 AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1364 ));
1365 assert!(matches!(
1366 &manifest.auth_providers[3],
1367 AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1368 ));
1369 assert!(matches!(
1370 &manifest.auth_providers[4],
1371 AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1372 ));
1373 assert!(matches!(
1374 &manifest.auth_providers[5],
1375 AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1376 ));
1377 }
1378
1379 #[test]
1380 fn parse_manifest_without_auth_providers() {
1381 let yaml = r#"
1382roles:
1383 - name: test-role
1384"#;
1385 let manifest = parse_manifest(yaml).unwrap();
1386 assert!(manifest.auth_providers.is_empty());
1387 }
1388
1389 #[test]
1390 fn parse_role_with_password_source() {
1391 let yaml = r#"
1392roles:
1393 - name: app-service
1394 login: true
1395 password:
1396 from_env: APP_SERVICE_PASSWORD
1397 password_valid_until: "2025-12-31T00:00:00Z"
1398"#;
1399 let manifest = parse_manifest(yaml).unwrap();
1400 assert_eq!(manifest.roles.len(), 1);
1401 let role = &manifest.roles[0];
1402 assert!(role.password.is_some());
1403 assert_eq!(
1404 role.password.as_ref().unwrap().from_env,
1405 "APP_SERVICE_PASSWORD"
1406 );
1407 assert_eq!(
1408 role.password_valid_until,
1409 Some("2025-12-31T00:00:00Z".to_string())
1410 );
1411 }
1412
1413 #[test]
1414 fn parse_role_without_password() {
1415 let yaml = r#"
1416roles:
1417 - name: app-service
1418 login: true
1419"#;
1420 let manifest = parse_manifest(yaml).unwrap();
1421 assert!(manifest.roles[0].password.is_none());
1422 assert!(manifest.roles[0].password_valid_until.is_none());
1423 }
1424
1425 #[test]
1426 fn reject_password_on_nologin_role() {
1427 let yaml = r#"
1428roles:
1429 - name: nologin-role
1430 login: false
1431 password:
1432 from_env: SOME_PASSWORD
1433"#;
1434 let manifest = parse_manifest(yaml).unwrap();
1435 let result = expand_manifest(&manifest);
1436 assert!(result.is_err());
1437 assert!(
1438 result
1439 .unwrap_err()
1440 .to_string()
1441 .contains("login is not enabled")
1442 );
1443 }
1444
1445 #[test]
1446 fn reject_password_on_default_login_role() {
1447 let yaml = r#"
1449roles:
1450 - name: implicit-nologin-role
1451 password:
1452 from_env: SOME_PASSWORD
1453"#;
1454 let manifest = parse_manifest(yaml).unwrap();
1455 let result = expand_manifest(&manifest);
1456 assert!(result.is_err());
1457 assert!(
1458 result
1459 .unwrap_err()
1460 .to_string()
1461 .contains("login is not enabled")
1462 );
1463 }
1464
1465 #[test]
1466 fn reject_invalid_password_valid_until() {
1467 let yaml = r#"
1468roles:
1469 - name: bad-date
1470 login: true
1471 password_valid_until: "not-a-date"
1472"#;
1473 let manifest = parse_manifest(yaml).unwrap();
1474 let result = expand_manifest(&manifest);
1475 assert!(result.is_err());
1476 assert!(
1477 result
1478 .unwrap_err()
1479 .to_string()
1480 .contains("invalid password_valid_until")
1481 );
1482 }
1483
1484 #[test]
1485 fn reject_date_only_valid_until() {
1486 let yaml = r#"
1487roles:
1488 - name: bad-date
1489 login: true
1490 password_valid_until: "2025-12-31"
1491"#;
1492 let manifest = parse_manifest(yaml).unwrap();
1493 let result = expand_manifest(&manifest);
1494 assert!(result.is_err());
1495 }
1496
1497 #[test]
1498 fn accept_valid_iso8601_timestamps() {
1499 assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1501 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1503 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1504 assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1506 }
1507
1508 #[test]
1509 fn reject_invalid_iso8601_timestamps() {
1510 assert!(!is_valid_iso8601_timestamp("not-a-date"));
1511 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("")); }
1517
1518 #[test]
1519 fn parse_manifest_from_kubernetes_cr() {
1520 let yaml = r#"
1521apiVersion: pgroles.io/v1alpha1
1522kind: PostgresPolicy
1523metadata:
1524 name: staging-policy
1525 namespace: pgroles-system
1526spec:
1527 connection:
1528 secretRef:
1529 name: pgroles-db-credentials
1530 interval: "5m"
1531 mode: plan
1532 roles:
1533 - name: app_analytics
1534 login: true
1535 - name: app_billing
1536 login: true
1537 schemas:
1538 - name: analytics
1539 profiles: [editor, viewer]
1540 profiles:
1541 editor:
1542 grants:
1543 - object: { type: schema }
1544 privileges: [USAGE]
1545 - object: { type: table, name: "*" }
1546 privileges: [SELECT, INSERT, UPDATE, DELETE]
1547 viewer:
1548 grants:
1549 - object: { type: schema }
1550 privileges: [USAGE]
1551 - object: { type: table, name: "*" }
1552 privileges: [SELECT]
1553 memberships:
1554 - role: analytics-editor
1555 members:
1556 - { name: app_analytics }
1557 - role: analytics-viewer
1558 members:
1559 - { name: app_billing }
1560"#;
1561 let manifest = parse_manifest(yaml).unwrap();
1562 assert_eq!(manifest.roles.len(), 2);
1563 assert_eq!(manifest.roles[0].name, "app_analytics");
1564 assert_eq!(manifest.schemas.len(), 1);
1565 assert_eq!(manifest.memberships.len(), 2);
1566 assert_eq!(manifest.profiles.len(), 2);
1567 }
1568
1569 #[test]
1570 fn parse_manifest_bare_and_cr_produce_same_result() {
1571 let bare = r#"
1572roles:
1573 - name: test_role
1574 login: true
1575schemas:
1576 - name: public
1577 profiles: [viewer]
1578profiles:
1579 viewer:
1580 grants:
1581 - object: { type: schema }
1582 privileges: [USAGE]
1583"#;
1584 let cr = r#"
1585apiVersion: pgroles.io/v1alpha1
1586kind: PostgresPolicy
1587metadata:
1588 name: test
1589spec:
1590 roles:
1591 - name: test_role
1592 login: true
1593 schemas:
1594 - name: public
1595 profiles: [viewer]
1596 profiles:
1597 viewer:
1598 grants:
1599 - object: { type: schema }
1600 privileges: [USAGE]
1601"#;
1602 let from_bare = parse_manifest(bare).unwrap();
1603 let from_cr = parse_manifest(cr).unwrap();
1604 assert_eq!(from_bare.roles.len(), from_cr.roles.len());
1605 assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
1606 assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
1607 }
1608}