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
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct RoleDefinition {
296 pub name: String,
297
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub login: Option<bool>,
300
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub superuser: Option<bool>,
303
304 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub createdb: Option<bool>,
306
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub createrole: Option<bool>,
309
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub inherit: Option<bool>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub replication: Option<bool>,
315
316 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub bypassrls: Option<bool>,
318
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub connection_limit: Option<i32>,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub comment: Option<String>,
324
325 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub password: Option<PasswordSource>,
329
330 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub password_valid_until: Option<String>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
341pub struct PasswordSource {
342 pub from_env: String,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
348pub struct Grant {
349 pub role: String,
350 pub privileges: Vec<Privilege>,
351 #[serde(alias = "on")]
352 pub object: ObjectTarget,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
357pub struct ObjectTarget {
358 #[serde(rename = "type")]
359 pub object_type: ObjectType,
360
361 #[serde(default, skip_serializing_if = "Option::is_none")]
363 pub schema: Option<String>,
364
365 #[serde(default, skip_serializing_if = "Option::is_none")]
367 pub name: Option<String>,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
372pub struct DefaultPrivilege {
373 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub owner: Option<String>,
376
377 pub schema: String,
378
379 pub grant: Vec<DefaultPrivilegeGrant>,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
384pub struct DefaultPrivilegeGrant {
385 #[serde(default, skip_serializing_if = "Option::is_none")]
388 pub role: Option<String>,
389
390 pub privileges: Vec<Privilege>,
391 pub on_type: ObjectType,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
396pub struct Membership {
397 pub role: String,
398 pub members: Vec<MemberSpec>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
409pub struct MemberSpec {
410 pub name: String,
411
412 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub inherit: Option<bool>,
415
416 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub admin: Option<bool>,
419}
420
421impl MemberSpec {
422 pub fn inherit(&self) -> bool {
424 self.inherit.unwrap_or(true)
425 }
426
427 pub fn admin(&self) -> bool {
429 self.admin.unwrap_or(false)
430 }
431}
432
433#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
435pub struct RoleRetirement {
436 pub role: String,
438
439 #[serde(default)]
441 pub reassign_owned_to: Option<String>,
442
443 #[serde(default)]
445 pub drop_owned: bool,
446
447 #[serde(default)]
449 pub terminate_sessions: bool,
450}
451
452#[derive(Debug, Clone)]
459pub struct ExpandedManifest {
460 pub schemas: Vec<ExpandedSchema>,
461 pub roles: Vec<RoleDefinition>,
462 pub grants: Vec<Grant>,
463 pub default_privileges: Vec<DefaultPrivilege>,
464 pub memberships: Vec<Membership>,
465}
466
467#[derive(Debug, Clone, PartialEq, Eq)]
468pub struct ExpandedSchema {
469 pub name: String,
470 pub owner: Option<String>,
471}
472
473pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
483 let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
485 if let serde_yaml::Value::Mapping(ref map) = value {
486 let api_version_key = serde_yaml::Value::String("apiVersion".into());
487 let spec_key = serde_yaml::Value::String("spec".into());
488 if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
489 let spec = map.get(&spec_key).ok_or_else(|| {
490 ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
491 })?;
492 let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
493 return Ok(manifest);
494 }
495 }
496 let manifest: PolicyManifest = serde_yaml::from_value(value)?;
497 Ok(manifest)
498}
499
500pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
504 let mut seen_schemas: HashSet<String> = HashSet::new();
505 for schema_binding in &manifest.schemas {
506 if !seen_schemas.insert(schema_binding.name.clone()) {
507 return Err(ManifestError::DuplicateSchema(schema_binding.name.clone()));
508 }
509 }
510
511 let schemas: Vec<ExpandedSchema> = manifest
512 .schemas
513 .iter()
514 .map(|schema_binding| ExpandedSchema {
515 name: schema_binding.name.clone(),
516 owner: schema_binding
517 .owner
518 .clone()
519 .or(manifest.default_owner.clone()),
520 })
521 .collect();
522 let mut roles: Vec<RoleDefinition> = Vec::new();
523 let mut grants: Vec<Grant> = Vec::new();
524 let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
525
526 for schema_binding in &manifest.schemas {
528 for profile_name in &schema_binding.profiles {
529 let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
530 ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
531 })?;
532
533 if !schema_binding.role_pattern.contains("{profile}") {
535 return Err(ManifestError::InvalidRolePattern(
536 schema_binding.role_pattern.clone(),
537 ));
538 }
539
540 let role_name = schema_binding
542 .role_pattern
543 .replace("{schema}", &schema_binding.name)
544 .replace("{profile}", profile_name);
545
546 roles.push(RoleDefinition {
548 name: role_name.clone(),
549 login: profile.login,
550 superuser: None,
551 createdb: None,
552 createrole: None,
553 inherit: profile.inherit,
554 replication: None,
555 bypassrls: None,
556 connection_limit: None,
557 comment: Some(format!(
558 "Generated from profile '{profile_name}' for schema '{}'",
559 schema_binding.name
560 )),
561 password: None,
562 password_valid_until: None,
563 });
564
565 for profile_grant in &profile.grants {
567 let object_target = match profile_grant.object.object_type {
568 ObjectType::Schema => ObjectTarget {
569 object_type: ObjectType::Schema,
570 schema: None,
571 name: Some(schema_binding.name.clone()),
572 },
573 _ => ObjectTarget {
574 object_type: profile_grant.object.object_type,
575 schema: Some(schema_binding.name.clone()),
576 name: profile_grant.object.name.clone(),
577 },
578 };
579
580 grants.push(Grant {
581 role: role_name.clone(),
582 privileges: profile_grant.privileges.clone(),
583 object: object_target,
584 });
585 }
586
587 if !profile.default_privileges.is_empty() {
589 let owner = schema_binding
590 .owner
591 .clone()
592 .or(manifest.default_owner.clone());
593
594 let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
595 .default_privileges
596 .iter()
597 .map(|dp| DefaultPrivilegeGrant {
598 role: Some(role_name.clone()),
599 privileges: dp.privileges.clone(),
600 on_type: dp.on_type,
601 })
602 .collect();
603
604 default_privileges.push(DefaultPrivilege {
605 owner,
606 schema: schema_binding.name.clone(),
607 grant: expanded_grants,
608 });
609 }
610 }
611 }
612
613 for default_priv in &manifest.default_privileges {
615 for grant in &default_priv.grant {
616 if grant.role.is_none() {
617 return Err(ManifestError::MissingDefaultPrivilegeRole {
618 schema: default_priv.schema.clone(),
619 });
620 }
621 }
622 }
623
624 roles.extend(manifest.roles.clone());
626 grants.extend(manifest.grants.clone());
627 default_privileges.extend(manifest.default_privileges.clone());
628 let memberships = manifest.memberships.clone();
629
630 let mut seen_roles: HashSet<String> = HashSet::new();
632 for role in &roles {
633 if seen_roles.contains(&role.name) {
634 return Err(ManifestError::DuplicateRole(role.name.clone()));
635 }
636 seen_roles.insert(role.name.clone());
637 }
638
639 let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
640 let mut seen_retirements: HashSet<String> = HashSet::new();
641 for retirement in &manifest.retirements {
642 if seen_retirements.contains(&retirement.role) {
643 return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
644 }
645 if desired_role_names.contains(&retirement.role) {
646 return Err(ManifestError::RetirementRoleStillDesired(
647 retirement.role.clone(),
648 ));
649 }
650 if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
651 return Err(ManifestError::RetirementSelfReassign {
652 role: retirement.role.clone(),
653 });
654 }
655 seen_retirements.insert(retirement.role.clone());
656 }
657
658 for role in &roles {
662 if role.password.is_some() && role.login != Some(true) {
663 return Err(ManifestError::PasswordWithoutLogin {
664 role: role.name.clone(),
665 });
666 }
667 }
668
669 for role in &roles {
671 if let Some(value) = &role.password_valid_until
672 && !is_valid_iso8601_timestamp(value)
673 {
674 return Err(ManifestError::InvalidValidUntil {
675 role: role.name.clone(),
676 value: value.clone(),
677 });
678 }
679 }
680
681 Ok(ExpandedManifest {
682 schemas,
683 roles,
684 grants,
685 default_privileges,
686 memberships,
687 })
688}
689
690fn is_valid_iso8601_timestamp(value: &str) -> bool {
706 if value.len() < 20 {
708 return false;
709 }
710
711 let bytes = value.as_bytes();
712
713 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
715 return false;
716 }
717
718 let year = &value[0..4];
719 let month = &value[5..7];
720 let day = &value[8..10];
721
722 let Ok(y) = year.parse::<u16>() else {
723 return false;
724 };
725 let Ok(m) = month.parse::<u8>() else {
726 return false;
727 };
728 let Ok(d) = day.parse::<u8>() else {
729 return false;
730 };
731
732 if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
733 return false;
734 }
735
736 if bytes[13] != b':' || bytes[16] != b':' {
738 return false;
739 }
740
741 let hour = &value[11..13];
742 let minute = &value[14..16];
743 let second = &value[17..19];
744
745 let Ok(h) = hour.parse::<u8>() else {
746 return false;
747 };
748 let Ok(min) = minute.parse::<u8>() else {
749 return false;
750 };
751 let Ok(sec) = second.parse::<u8>() else {
752 return false;
753 };
754
755 if h > 23 || min > 59 || sec > 59 {
756 return false;
757 }
758
759 let suffix = &value[19..];
761
762 let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
764 let frac_end = rest
766 .find(|c: char| !c.is_ascii_digit())
767 .unwrap_or(rest.len());
768 if frac_end == 0 {
769 return false; }
771 &rest[frac_end..]
772 } else {
773 suffix
774 };
775
776 match tz_part {
778 "Z" => true,
779 s if (s.starts_with('+') || s.starts_with('-'))
780 && s.len() == 6
781 && s.as_bytes()[3] == b':' =>
782 {
783 let Ok(tz_h) = s[1..3].parse::<u8>() else {
784 return false;
785 };
786 let Ok(tz_m) = s[4..6].parse::<u8>() else {
787 return false;
788 };
789 tz_h <= 14 && tz_m <= 59
790 }
791 _ => false,
792 }
793}
794
795#[cfg(test)]
800mod tests {
801 use super::*;
802
803 #[test]
804 fn parse_minimal_role() {
805 let yaml = r#"
806roles:
807 - name: test-role
808"#;
809 let manifest = parse_manifest(yaml).unwrap();
810 assert_eq!(manifest.roles.len(), 1);
811 assert_eq!(manifest.roles[0].name, "test-role");
812 assert!(manifest.roles[0].login.is_none());
813 }
814
815 #[test]
816 fn parse_full_policy() {
817 let yaml = r#"
818default_owner: app_owner
819
820profiles:
821 editor:
822 login: false
823 grants:
824 - privileges: [USAGE]
825 object: { type: schema }
826 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
827 object: { type: table, name: "*" }
828 - privileges: [USAGE, SELECT, UPDATE]
829 object: { type: sequence, name: "*" }
830 - privileges: [EXECUTE]
831 object: { type: function, name: "*" }
832 default_privileges:
833 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
834 on_type: table
835 - privileges: [USAGE, SELECT, UPDATE]
836 on_type: sequence
837 - privileges: [EXECUTE]
838 on_type: function
839
840schemas:
841 - name: inventory
842 profiles: [editor]
843 - name: catalog
844 profiles: [editor]
845
846roles:
847 - name: analytics-readonly
848 login: true
849
850memberships:
851 - role: inventory-editor
852 members:
853 - name: "alice@example.com"
854 inherit: true
855"#;
856 let manifest = parse_manifest(yaml).unwrap();
857 assert_eq!(manifest.profiles.len(), 1);
858 assert_eq!(manifest.schemas.len(), 2);
859 assert_eq!(manifest.roles.len(), 1);
860 assert_eq!(manifest.memberships.len(), 1);
861 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
862 }
863
864 #[test]
865 fn reject_invalid_yaml() {
866 let yaml = "not: [valid: yaml: {{";
867 assert!(parse_manifest(yaml).is_err());
868 }
869
870 #[test]
871 fn expand_profiles_basic() {
872 let yaml = r#"
873profiles:
874 editor:
875 login: false
876 grants:
877 - privileges: [USAGE]
878 object: { type: schema }
879 - privileges: [SELECT, INSERT]
880 object: { type: table, name: "*" }
881
882schemas:
883 - name: myschema
884 profiles: [editor]
885"#;
886 let manifest = parse_manifest(yaml).unwrap();
887 let expanded = expand_manifest(&manifest).unwrap();
888
889 assert_eq!(expanded.roles.len(), 1);
890 assert_eq!(expanded.roles[0].name, "myschema-editor");
891 assert_eq!(expanded.roles[0].login, Some(false));
892 assert_eq!(expanded.roles[0].inherit, None);
893
894 assert_eq!(expanded.grants.len(), 2);
896 assert_eq!(expanded.grants[0].role, "myschema-editor");
897 assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
898 assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
899
900 assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
901 assert_eq!(
902 expanded.grants[1].object.schema,
903 Some("myschema".to_string())
904 );
905 assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
906 }
907
908 #[test]
909 fn expand_schema_owner_overrides_default_owner() {
910 let yaml = r#"
911default_owner: app_owner
912
913profiles:
914 editor:
915 default_privileges:
916 - privileges: [SELECT]
917 on_type: table
918
919schemas:
920 - name: inventory
921 owner: inventory_owner
922 profiles: [editor]
923 - name: catalog
924 profiles: [editor]
925"#;
926
927 let manifest = parse_manifest(yaml).unwrap();
928 let expanded = expand_manifest(&manifest).unwrap();
929
930 assert_eq!(
931 expanded.schemas,
932 vec![
933 ExpandedSchema {
934 name: "inventory".to_string(),
935 owner: Some("inventory_owner".to_string()),
936 },
937 ExpandedSchema {
938 name: "catalog".to_string(),
939 owner: Some("app_owner".to_string()),
940 },
941 ]
942 );
943 }
944
945 #[test]
946 fn expand_profiles_preserves_generated_role_inherit() {
947 let yaml = r#"
948profiles:
949 editor:
950 login: false
951 inherit: false
952 grants:
953 - privileges: [USAGE]
954 object: { type: schema }
955
956schemas:
957 - name: myschema
958 profiles: [editor]
959"#;
960
961 let manifest = parse_manifest(yaml).unwrap();
962 let expanded = expand_manifest(&manifest).unwrap();
963
964 assert_eq!(expanded.roles.len(), 1);
965 assert_eq!(expanded.roles[0].name, "myschema-editor");
966 assert_eq!(expanded.roles[0].login, Some(false));
967 assert_eq!(expanded.roles[0].inherit, Some(false));
968 }
969
970 #[test]
971 fn expand_declared_schema_with_no_profiles() {
972 let yaml = r#"
973schemas:
974 - name: cdc
975 owner: cdc_owner
976 profiles: []
977"#;
978
979 let manifest = parse_manifest(yaml).unwrap();
980 let expanded = expand_manifest(&manifest).unwrap();
981
982 assert_eq!(expanded.schemas.len(), 1);
983 assert_eq!(expanded.schemas[0].name, "cdc");
984 assert_eq!(expanded.schemas[0].owner.as_deref(), Some("cdc_owner"));
985 assert!(expanded.roles.is_empty());
986 assert!(expanded.grants.is_empty());
987 assert!(expanded.default_privileges.is_empty());
988 }
989
990 #[test]
991 fn expand_profiles_multi_schema() {
992 let yaml = r#"
993profiles:
994 editor:
995 grants:
996 - privileges: [SELECT]
997 object: { type: table, name: "*" }
998 viewer:
999 grants:
1000 - privileges: [SELECT]
1001 object: { type: table, name: "*" }
1002
1003schemas:
1004 - name: alpha
1005 profiles: [editor, viewer]
1006 - name: beta
1007 profiles: [editor, viewer]
1008 - name: gamma
1009 profiles: [editor]
1010"#;
1011 let manifest = parse_manifest(yaml).unwrap();
1012 let expanded = expand_manifest(&manifest).unwrap();
1013
1014 assert_eq!(expanded.roles.len(), 5);
1016 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
1017 assert!(role_names.contains(&"alpha-editor"));
1018 assert!(role_names.contains(&"alpha-viewer"));
1019 assert!(role_names.contains(&"beta-editor"));
1020 assert!(role_names.contains(&"beta-viewer"));
1021 assert!(role_names.contains(&"gamma-editor"));
1022 }
1023
1024 #[test]
1025 fn expand_custom_role_pattern() {
1026 let yaml = r#"
1027profiles:
1028 viewer:
1029 grants:
1030 - privileges: [SELECT]
1031 object: { type: table, name: "*" }
1032
1033schemas:
1034 - name: legacy_data
1035 profiles: [viewer]
1036 role_pattern: "legacy-{profile}"
1037"#;
1038 let manifest = parse_manifest(yaml).unwrap();
1039 let expanded = expand_manifest(&manifest).unwrap();
1040
1041 assert_eq!(expanded.roles.len(), 1);
1042 assert_eq!(expanded.roles[0].name, "legacy-viewer");
1043 }
1044
1045 #[test]
1046 fn expand_rejects_duplicate_role_name() {
1047 let yaml = r#"
1048profiles:
1049 editor:
1050 grants: []
1051
1052schemas:
1053 - name: inventory
1054 profiles: [editor]
1055
1056roles:
1057 - name: inventory-editor
1058"#;
1059 let manifest = parse_manifest(yaml).unwrap();
1060 let result = expand_manifest(&manifest);
1061 assert!(result.is_err());
1062 assert!(
1063 result
1064 .unwrap_err()
1065 .to_string()
1066 .contains("duplicate role name")
1067 );
1068 }
1069
1070 #[test]
1071 fn expand_rejects_duplicate_schema_name() {
1072 let yaml = r#"
1073schemas:
1074 - name: inventory
1075 profiles: []
1076 - name: inventory
1077 owner: inventory_owner
1078 profiles: []
1079"#;
1080
1081 let manifest = parse_manifest(yaml).unwrap();
1082 let error = expand_manifest(&manifest).unwrap_err();
1083 assert!(error.to_string().contains("duplicate schema name"));
1084 }
1085
1086 #[test]
1087 fn expand_rejects_undefined_profile() {
1088 let yaml = r#"
1089profiles: {}
1090
1091schemas:
1092 - name: inventory
1093 profiles: [nonexistent]
1094"#;
1095 let manifest = parse_manifest(yaml).unwrap();
1096 let result = expand_manifest(&manifest);
1097 assert!(result.is_err());
1098 assert!(result.unwrap_err().to_string().contains("not defined"));
1099 }
1100
1101 #[test]
1102 fn expand_rejects_invalid_pattern() {
1103 let yaml = r#"
1104profiles:
1105 editor:
1106 grants: []
1107
1108schemas:
1109 - name: inventory
1110 profiles: [editor]
1111 role_pattern: "static-name"
1112"#;
1113 let manifest = parse_manifest(yaml).unwrap();
1114 let result = expand_manifest(&manifest);
1115 assert!(result.is_err());
1116 assert!(
1117 result
1118 .unwrap_err()
1119 .to_string()
1120 .contains("{profile} placeholder")
1121 );
1122 }
1123
1124 #[test]
1125 fn expand_rejects_top_level_default_privilege_without_role() {
1126 let yaml = r#"
1127default_privileges:
1128 - schema: public
1129 grant:
1130 - privileges: [SELECT]
1131 on_type: table
1132"#;
1133 let manifest = parse_manifest(yaml).unwrap();
1134 let result = expand_manifest(&manifest);
1135 assert!(result.is_err());
1136 assert!(
1137 result
1138 .unwrap_err()
1139 .to_string()
1140 .contains("must specify grant.role")
1141 );
1142 }
1143
1144 #[test]
1145 fn expand_default_privileges_with_owner_override() {
1146 let yaml = r#"
1147default_owner: app_owner
1148
1149profiles:
1150 editor:
1151 grants: []
1152 default_privileges:
1153 - privileges: [SELECT]
1154 on_type: table
1155
1156schemas:
1157 - name: inventory
1158 profiles: [editor]
1159 - name: legacy
1160 profiles: [editor]
1161 owner: legacy_admin
1162"#;
1163 let manifest = parse_manifest(yaml).unwrap();
1164 let expanded = expand_manifest(&manifest).unwrap();
1165
1166 assert_eq!(expanded.default_privileges.len(), 2);
1167
1168 assert_eq!(
1170 expanded.default_privileges[0].owner,
1171 Some("app_owner".to_string())
1172 );
1173 assert_eq!(expanded.default_privileges[0].schema, "inventory");
1174
1175 assert_eq!(
1177 expanded.default_privileges[1].owner,
1178 Some("legacy_admin".to_string())
1179 );
1180 assert_eq!(expanded.default_privileges[1].schema, "legacy");
1181 }
1182
1183 #[test]
1184 fn expand_merges_oneoff_roles_and_grants() {
1185 let yaml = r#"
1186profiles:
1187 editor:
1188 grants:
1189 - privileges: [SELECT]
1190 object: { type: table, name: "*" }
1191
1192schemas:
1193 - name: inventory
1194 profiles: [editor]
1195
1196roles:
1197 - name: analytics
1198 login: true
1199
1200grants:
1201 - role: analytics
1202 privileges: [SELECT]
1203 on:
1204 type: table
1205 schema: inventory
1206 name: "*"
1207"#;
1208 let manifest = parse_manifest(yaml).unwrap();
1209 let expanded = expand_manifest(&manifest).unwrap();
1210
1211 assert_eq!(expanded.roles.len(), 2);
1212 assert_eq!(expanded.grants.len(), 2); }
1214
1215 #[test]
1216 fn parse_manifest_accepts_legacy_on_alias() {
1217 let yaml = r#"
1218grants:
1219 - role: analytics
1220 privileges: [SELECT]
1221 on:
1222 type: table
1223 schema: public
1224 name: "*"
1225"#;
1226 let manifest = parse_manifest(yaml).unwrap();
1227 assert_eq!(manifest.grants.len(), 1);
1228 assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1229 assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1230 assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1231 }
1232
1233 #[test]
1234 fn parse_membership_with_email_roles() {
1235 let yaml = r#"
1236memberships:
1237 - role: inventory-editor
1238 members:
1239 - name: "alice@example.com"
1240 inherit: true
1241 - name: "engineering@example.com"
1242 admin: true
1243"#;
1244 let manifest = parse_manifest(yaml).unwrap();
1245 assert_eq!(manifest.memberships.len(), 1);
1246 assert_eq!(manifest.memberships[0].members.len(), 2);
1247 assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1248 assert_eq!(manifest.memberships[0].members[0].inherit, Some(true));
1249 assert_eq!(manifest.memberships[0].members[1].admin, Some(true));
1250 }
1251
1252 #[test]
1253 fn member_spec_defaults() {
1254 let yaml = r#"
1255memberships:
1256 - role: some-role
1257 members:
1258 - name: user1
1259"#;
1260 let manifest = parse_manifest(yaml).unwrap();
1261 assert_eq!(manifest.memberships[0].members[0].inherit, None);
1263 assert_eq!(manifest.memberships[0].members[0].admin, None);
1264 assert!(manifest.memberships[0].members[0].inherit());
1266 assert!(!manifest.memberships[0].members[0].admin());
1267 }
1268
1269 #[test]
1270 fn expand_rejects_duplicate_retirements() {
1271 let yaml = r#"
1272retirements:
1273 - role: old-app
1274 - role: old-app
1275"#;
1276 let manifest = parse_manifest(yaml).unwrap();
1277 let result = expand_manifest(&manifest);
1278 assert!(matches!(
1279 result,
1280 Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1281 ));
1282 }
1283
1284 #[test]
1285 fn expand_rejects_retirement_for_desired_role() {
1286 let yaml = r#"
1287roles:
1288 - name: old-app
1289
1290retirements:
1291 - role: old-app
1292"#;
1293 let manifest = parse_manifest(yaml).unwrap();
1294 let result = expand_manifest(&manifest);
1295 assert!(matches!(
1296 result,
1297 Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1298 ));
1299 }
1300
1301 #[test]
1302 fn expand_rejects_self_reassign_retirement() {
1303 let yaml = r#"
1304retirements:
1305 - role: old-app
1306 reassign_owned_to: old-app
1307"#;
1308 let manifest = parse_manifest(yaml).unwrap();
1309 let result = expand_manifest(&manifest);
1310 assert!(matches!(
1311 result,
1312 Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1313 ));
1314 }
1315
1316 #[test]
1317 fn parse_auth_providers() {
1318 let yaml = r#"
1319auth_providers:
1320 - type: cloud_sql_iam
1321 project: my-gcp-project
1322 - type: alloydb_iam
1323 project: my-gcp-project
1324 cluster: analytics-prod
1325 - type: rds_iam
1326 region: us-east-1
1327 - type: azure_ad
1328 tenant_id: "abc-123"
1329 - type: supabase
1330 project_ref: myprojref
1331 - type: planet_scale
1332 organization: my-org
1333
1334roles:
1335 - name: app-service
1336"#;
1337 let manifest = parse_manifest(yaml).unwrap();
1338 assert_eq!(manifest.auth_providers.len(), 6);
1339 assert!(matches!(
1340 &manifest.auth_providers[0],
1341 AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1342 ));
1343 assert!(matches!(
1344 &manifest.auth_providers[1],
1345 AuthProvider::AlloyDbIam {
1346 project: Some(p),
1347 cluster: Some(c)
1348 } if p == "my-gcp-project" && c == "analytics-prod"
1349 ));
1350 assert!(matches!(
1351 &manifest.auth_providers[2],
1352 AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1353 ));
1354 assert!(matches!(
1355 &manifest.auth_providers[3],
1356 AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1357 ));
1358 assert!(matches!(
1359 &manifest.auth_providers[4],
1360 AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1361 ));
1362 assert!(matches!(
1363 &manifest.auth_providers[5],
1364 AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1365 ));
1366 }
1367
1368 #[test]
1369 fn parse_manifest_without_auth_providers() {
1370 let yaml = r#"
1371roles:
1372 - name: test-role
1373"#;
1374 let manifest = parse_manifest(yaml).unwrap();
1375 assert!(manifest.auth_providers.is_empty());
1376 }
1377
1378 #[test]
1379 fn parse_role_with_password_source() {
1380 let yaml = r#"
1381roles:
1382 - name: app-service
1383 login: true
1384 password:
1385 from_env: APP_SERVICE_PASSWORD
1386 password_valid_until: "2025-12-31T00:00:00Z"
1387"#;
1388 let manifest = parse_manifest(yaml).unwrap();
1389 assert_eq!(manifest.roles.len(), 1);
1390 let role = &manifest.roles[0];
1391 assert!(role.password.is_some());
1392 assert_eq!(
1393 role.password.as_ref().unwrap().from_env,
1394 "APP_SERVICE_PASSWORD"
1395 );
1396 assert_eq!(
1397 role.password_valid_until,
1398 Some("2025-12-31T00:00:00Z".to_string())
1399 );
1400 }
1401
1402 #[test]
1403 fn parse_role_without_password() {
1404 let yaml = r#"
1405roles:
1406 - name: app-service
1407 login: true
1408"#;
1409 let manifest = parse_manifest(yaml).unwrap();
1410 assert!(manifest.roles[0].password.is_none());
1411 assert!(manifest.roles[0].password_valid_until.is_none());
1412 }
1413
1414 #[test]
1415 fn reject_password_on_nologin_role() {
1416 let yaml = r#"
1417roles:
1418 - name: nologin-role
1419 login: false
1420 password:
1421 from_env: SOME_PASSWORD
1422"#;
1423 let manifest = parse_manifest(yaml).unwrap();
1424 let result = expand_manifest(&manifest);
1425 assert!(result.is_err());
1426 assert!(
1427 result
1428 .unwrap_err()
1429 .to_string()
1430 .contains("login is not enabled")
1431 );
1432 }
1433
1434 #[test]
1435 fn reject_password_on_default_login_role() {
1436 let yaml = r#"
1438roles:
1439 - name: implicit-nologin-role
1440 password:
1441 from_env: SOME_PASSWORD
1442"#;
1443 let manifest = parse_manifest(yaml).unwrap();
1444 let result = expand_manifest(&manifest);
1445 assert!(result.is_err());
1446 assert!(
1447 result
1448 .unwrap_err()
1449 .to_string()
1450 .contains("login is not enabled")
1451 );
1452 }
1453
1454 #[test]
1455 fn reject_invalid_password_valid_until() {
1456 let yaml = r#"
1457roles:
1458 - name: bad-date
1459 login: true
1460 password_valid_until: "not-a-date"
1461"#;
1462 let manifest = parse_manifest(yaml).unwrap();
1463 let result = expand_manifest(&manifest);
1464 assert!(result.is_err());
1465 assert!(
1466 result
1467 .unwrap_err()
1468 .to_string()
1469 .contains("invalid password_valid_until")
1470 );
1471 }
1472
1473 #[test]
1474 fn reject_date_only_valid_until() {
1475 let yaml = r#"
1476roles:
1477 - name: bad-date
1478 login: true
1479 password_valid_until: "2025-12-31"
1480"#;
1481 let manifest = parse_manifest(yaml).unwrap();
1482 let result = expand_manifest(&manifest);
1483 assert!(result.is_err());
1484 }
1485
1486 #[test]
1487 fn accept_valid_iso8601_timestamps() {
1488 assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1490 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1492 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1493 assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1495 }
1496
1497 #[test]
1498 fn reject_invalid_iso8601_timestamps() {
1499 assert!(!is_valid_iso8601_timestamp("not-a-date"));
1500 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("")); }
1506
1507 #[test]
1508 fn parse_manifest_from_kubernetes_cr() {
1509 let yaml = r#"
1510apiVersion: pgroles.io/v1alpha1
1511kind: PostgresPolicy
1512metadata:
1513 name: staging-policy
1514 namespace: pgroles-system
1515spec:
1516 connection:
1517 secretRef:
1518 name: pgroles-db-credentials
1519 interval: "5m"
1520 mode: plan
1521 roles:
1522 - name: app_analytics
1523 login: true
1524 - name: app_billing
1525 login: true
1526 schemas:
1527 - name: analytics
1528 profiles: [editor, viewer]
1529 profiles:
1530 editor:
1531 grants:
1532 - object: { type: schema }
1533 privileges: [USAGE]
1534 - object: { type: table, name: "*" }
1535 privileges: [SELECT, INSERT, UPDATE, DELETE]
1536 viewer:
1537 grants:
1538 - object: { type: schema }
1539 privileges: [USAGE]
1540 - object: { type: table, name: "*" }
1541 privileges: [SELECT]
1542 memberships:
1543 - role: analytics-editor
1544 members:
1545 - { name: app_analytics }
1546 - role: analytics-viewer
1547 members:
1548 - { name: app_billing }
1549"#;
1550 let manifest = parse_manifest(yaml).unwrap();
1551 assert_eq!(manifest.roles.len(), 2);
1552 assert_eq!(manifest.roles[0].name, "app_analytics");
1553 assert_eq!(manifest.schemas.len(), 1);
1554 assert_eq!(manifest.memberships.len(), 2);
1555 assert_eq!(manifest.profiles.len(), 2);
1556 }
1557
1558 #[test]
1559 fn parse_manifest_bare_and_cr_produce_same_result() {
1560 let bare = r#"
1561roles:
1562 - name: test_role
1563 login: true
1564schemas:
1565 - name: public
1566 profiles: [viewer]
1567profiles:
1568 viewer:
1569 grants:
1570 - object: { type: schema }
1571 privileges: [USAGE]
1572"#;
1573 let cr = r#"
1574apiVersion: pgroles.io/v1alpha1
1575kind: PostgresPolicy
1576metadata:
1577 name: test
1578spec:
1579 roles:
1580 - name: test_role
1581 login: true
1582 schemas:
1583 - name: public
1584 profiles: [viewer]
1585 profiles:
1586 viewer:
1587 grants:
1588 - object: { type: schema }
1589 privileges: [USAGE]
1590"#;
1591 let from_bare = parse_manifest(bare).unwrap();
1592 let from_cr = parse_manifest(cr).unwrap();
1593 assert_eq!(from_bare.roles.len(), from_cr.roles.len());
1594 assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
1595 assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
1596 }
1597}