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