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 #[error(
50 "role \"{role}\" has an invalid config parameter name \"{parameter}\": expected a PostgreSQL setting name (letters, digits, underscores, optionally dot-qualified)"
51 )]
52 InvalidConfigParameter { role: String, parameter: String },
53
54 #[error(
55 "role \"{role}\" sets config `role: {target}` but declares no membership in \"{target}\" — the setting would fail at login; add \"{role}\" to the members of \"{target}\""
56 )]
57 SetRoleWithoutMembership { role: String, target: String },
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
66#[serde(rename_all = "snake_case")]
67pub enum ObjectType {
68 Table,
69 View,
70 #[serde(alias = "materialized_view")]
71 MaterializedView,
72 Sequence,
73 Function,
74 Schema,
75 Database,
76 Type,
77}
78
79impl std::fmt::Display for ObjectType {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 ObjectType::Table => write!(f, "table"),
83 ObjectType::View => write!(f, "view"),
84 ObjectType::MaterializedView => write!(f, "materialized_view"),
85 ObjectType::Sequence => write!(f, "sequence"),
86 ObjectType::Function => write!(f, "function"),
87 ObjectType::Schema => write!(f, "schema"),
88 ObjectType::Database => write!(f, "database"),
89 ObjectType::Type => write!(f, "type"),
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
96#[serde(rename_all = "UPPERCASE")]
97pub enum Privilege {
98 Select,
99 Insert,
100 Update,
101 Delete,
102 Truncate,
103 References,
104 Trigger,
105 Execute,
106 Usage,
107 Create,
108 Connect,
109 Temporary,
110}
111
112impl std::fmt::Display for Privilege {
113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114 match self {
115 Privilege::Select => write!(f, "SELECT"),
116 Privilege::Insert => write!(f, "INSERT"),
117 Privilege::Update => write!(f, "UPDATE"),
118 Privilege::Delete => write!(f, "DELETE"),
119 Privilege::Truncate => write!(f, "TRUNCATE"),
120 Privilege::References => write!(f, "REFERENCES"),
121 Privilege::Trigger => write!(f, "TRIGGER"),
122 Privilege::Execute => write!(f, "EXECUTE"),
123 Privilege::Usage => write!(f, "USAGE"),
124 Privilege::Create => write!(f, "CREATE"),
125 Privilege::Connect => write!(f, "CONNECT"),
126 Privilege::Temporary => write!(f, "TEMPORARY"),
127 }
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct PolicyManifest {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub default_owner: Option<String>,
141
142 #[serde(default)]
144 pub auth_providers: Vec<AuthProvider>,
145
146 #[serde(default)]
150 pub profiles: BTreeMap<String, Profile>,
151
152 #[serde(default)]
154 pub schemas: Vec<SchemaBinding>,
155
156 #[serde(default)]
158 pub roles: Vec<RoleDefinition>,
159
160 #[serde(default)]
162 pub grants: Vec<Grant>,
163
164 #[serde(default)]
166 pub default_privileges: Vec<DefaultPrivilege>,
167
168 #[serde(default)]
170 pub memberships: Vec<Membership>,
171
172 #[serde(default)]
174 pub retirements: Vec<RoleRetirement>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
182#[serde(tag = "type", rename_all = "snake_case")]
183pub enum AuthProvider {
184 CloudSqlIam {
187 #[serde(default)]
189 project: Option<String>,
190 },
191 #[serde(rename = "alloydb_iam")]
194 AlloyDbIam {
195 #[serde(default)]
197 project: Option<String>,
198 #[serde(default)]
200 cluster: Option<String>,
201 },
202 RdsIam {
205 #[serde(default)]
207 region: Option<String>,
208 },
209 AzureAd {
211 #[serde(default)]
213 tenant_id: Option<String>,
214 },
215 Supabase {
217 #[serde(default)]
219 project_ref: Option<String>,
220 },
221 PlanetScale {
223 #[serde(default)]
225 organization: Option<String>,
226 },
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Profile {
232 #[serde(default)]
233 pub login: Option<bool>,
234
235 #[serde(default)]
236 pub inherit: Option<bool>,
237
238 #[serde(default)]
239 pub grants: Vec<ProfileGrant>,
240
241 #[serde(default)]
242 pub default_privileges: Vec<DefaultPrivilegeGrant>,
243
244 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
255 pub config: BTreeMap<String, ConfigValue>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct ProfileGrant {
261 pub privileges: Vec<Privilege>,
262 #[serde(alias = "on")]
263 pub object: ProfileObjectTarget,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct ProfileObjectTarget {
269 #[serde(rename = "type")]
270 pub object_type: ObjectType,
271 #[serde(default)]
273 pub name: Option<String>,
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
278pub struct SchemaBinding {
279 pub name: String,
280
281 #[serde(default)]
282 pub profiles: Vec<String>,
283
284 #[serde(default = "default_role_pattern")]
287 pub role_pattern: String,
288
289 #[serde(default)]
291 pub owner: Option<String>,
292}
293
294#[derive(
295 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
296)]
297#[serde(rename_all = "snake_case")]
298pub enum SchemaBindingFacet {
299 Owner,
300 Bindings,
301}
302
303impl std::fmt::Display for SchemaBindingFacet {
304 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
305 match self {
306 SchemaBindingFacet::Owner => write!(f, "owner"),
307 SchemaBindingFacet::Bindings => write!(f, "bindings"),
308 }
309 }
310}
311
312pub(crate) fn default_role_pattern() -> String {
313 "{schema}-{profile}".to_string()
314}
315
316fn substitute_placeholders(value: &str, schema: &str, profile: &str) -> String {
320 value
321 .replace("{schema}", schema)
322 .replace("{profile}", profile)
323}
324
325fn is_false(value: &bool) -> bool {
326 !*value
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct RoleDefinition {
332 pub name: String,
333
334 #[serde(default, skip_serializing_if = "is_false")]
338 pub external: bool,
339
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub login: Option<bool>,
342
343 #[serde(default, skip_serializing_if = "Option::is_none")]
344 pub superuser: Option<bool>,
345
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub createdb: Option<bool>,
348
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub createrole: Option<bool>,
351
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub inherit: Option<bool>,
354
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub replication: Option<bool>,
357
358 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub bypassrls: Option<bool>,
360
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub connection_limit: Option<i32>,
363
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub comment: Option<String>,
366
367 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub password: Option<PasswordSource>,
371
372 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub password_valid_until: Option<String>,
376
377 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
383 pub config: BTreeMap<String, ConfigValue>,
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)]
394#[serde(transparent)]
395pub struct ConfigValue(pub String);
396
397impl<'de> Deserialize<'de> for ConfigValue {
398 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
399 where
400 D: serde::Deserializer<'de>,
401 {
402 struct ConfigValueVisitor;
403
404 impl serde::de::Visitor<'_> for ConfigValueVisitor {
405 type Value = ConfigValue;
406
407 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 f.write_str("a string")
409 }
410
411 fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
412 Ok(ConfigValue(v.to_string()))
413 }
414
415 fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Self::Value, E> {
416 Ok(ConfigValue(v))
417 }
418
419 fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
420 Err(E::custom(format!(
421 "config values must be quoted strings: write \"{v}\" instead of {v}"
422 )))
423 }
424
425 fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
426 Err(E::custom(format!(
427 "config values must be quoted strings: write \"{v}\" instead of {v}"
428 )))
429 }
430
431 fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
432 Err(E::custom(format!(
433 "config values must be quoted strings: write \"{v}\" instead of {v}"
434 )))
435 }
436
437 fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Self::Value, E> {
438 let suggestion = if v { "on" } else { "off" };
439 Err(E::custom(format!(
440 "config values must be quoted strings: write \"{suggestion}\" (or \"{v}\") instead of {v}"
441 )))
442 }
443 }
444
445 deserializer.deserialize_any(ConfigValueVisitor)
446 }
447}
448
449pub fn is_valid_config_parameter_name(name: &str) -> bool {
453 !name.is_empty()
454 && name.split('.').all(|segment| {
455 let mut chars = segment.chars();
456 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
457 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
458 })
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
466pub struct PasswordSource {
467 pub from_env: String,
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
473pub struct Grant {
474 pub role: String,
475 pub privileges: Vec<Privilege>,
476 #[serde(alias = "on")]
477 pub object: ObjectTarget,
478}
479
480#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
482pub struct ObjectTarget {
483 #[serde(rename = "type")]
484 pub object_type: ObjectType,
485
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub schema: Option<String>,
489
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub name: Option<String>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
497pub struct DefaultPrivilege {
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub owner: Option<String>,
501
502 pub schema: String,
503
504 pub grant: Vec<DefaultPrivilegeGrant>,
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
509pub struct DefaultPrivilegeGrant {
510 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub role: Option<String>,
514
515 pub privileges: Vec<Privilege>,
516 pub on_type: ObjectType,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
521pub struct Membership {
522 pub role: String,
523 pub members: Vec<MemberSpec>,
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
534pub struct MemberSpec {
535 pub name: String,
536
537 #[serde(default, skip_serializing_if = "Option::is_none")]
539 pub inherit: Option<bool>,
540
541 #[serde(default, skip_serializing_if = "Option::is_none")]
543 pub admin: Option<bool>,
544}
545
546impl MemberSpec {
547 pub fn inherit(&self) -> bool {
549 self.inherit.unwrap_or(true)
550 }
551
552 pub fn admin(&self) -> bool {
554 self.admin.unwrap_or(false)
555 }
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
560pub struct RoleRetirement {
561 pub role: String,
563
564 #[serde(default)]
566 pub reassign_owned_to: Option<String>,
567
568 #[serde(default)]
570 pub drop_owned: bool,
571
572 #[serde(default)]
574 pub terminate_sessions: bool,
575}
576
577#[derive(Debug, Clone)]
584pub struct ExpandedManifest {
585 pub schemas: Vec<ExpandedSchema>,
586 pub roles: Vec<RoleDefinition>,
587 pub grants: Vec<Grant>,
588 pub default_privileges: Vec<DefaultPrivilege>,
589 pub memberships: Vec<Membership>,
590}
591
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct ExpandedSchema {
594 pub name: String,
595 pub owner: Option<String>,
596}
597
598pub fn parse_manifest(yaml: &str) -> Result<PolicyManifest, ManifestError> {
608 let value: serde_yaml::Value = serde_yaml::from_str(yaml)?;
610 if let serde_yaml::Value::Mapping(ref map) = value {
611 let api_version_key = serde_yaml::Value::String("apiVersion".into());
612 let spec_key = serde_yaml::Value::String("spec".into());
613 if map.contains_key(&api_version_key) && map.contains_key(&spec_key) {
614 let spec = map.get(&spec_key).ok_or_else(|| {
615 ManifestError::Yaml(serde::de::Error::custom("missing spec in CR"))
616 })?;
617 let manifest: PolicyManifest = serde_yaml::from_value(spec.clone())?;
618 return Ok(manifest);
619 }
620 }
621 let manifest: PolicyManifest = serde_yaml::from_value(value)?;
622 Ok(manifest)
623}
624
625pub fn expand_manifest(manifest: &PolicyManifest) -> Result<ExpandedManifest, ManifestError> {
629 let mut seen_schemas: HashSet<String> = HashSet::new();
630 for schema_binding in &manifest.schemas {
631 if !seen_schemas.insert(schema_binding.name.clone()) {
632 return Err(ManifestError::DuplicateSchema(schema_binding.name.clone()));
633 }
634 }
635
636 let schemas: Vec<ExpandedSchema> = manifest
637 .schemas
638 .iter()
639 .map(|schema_binding| ExpandedSchema {
640 name: schema_binding.name.clone(),
641 owner: schema_binding
642 .owner
643 .clone()
644 .or(manifest.default_owner.clone()),
645 })
646 .collect();
647 let mut roles: Vec<RoleDefinition> = Vec::new();
648 let mut grants: Vec<Grant> = Vec::new();
649 let mut default_privileges: Vec<DefaultPrivilege> = Vec::new();
650
651 for schema_binding in &manifest.schemas {
653 for profile_name in &schema_binding.profiles {
654 let profile = manifest.profiles.get(profile_name).ok_or_else(|| {
655 ManifestError::UndefinedProfile(profile_name.clone(), schema_binding.name.clone())
656 })?;
657
658 if !schema_binding.role_pattern.contains("{profile}") {
660 return Err(ManifestError::InvalidRolePattern(
661 schema_binding.role_pattern.clone(),
662 ));
663 }
664
665 let role_name = schema_binding
667 .role_pattern
668 .replace("{schema}", &schema_binding.name)
669 .replace("{profile}", profile_name);
670
671 let config: BTreeMap<String, ConfigValue> = profile
677 .config
678 .iter()
679 .map(|(parameter, value)| {
680 let substituted =
681 substitute_placeholders(&value.0, &schema_binding.name, profile_name);
682 (parameter.clone(), ConfigValue(substituted))
683 })
684 .collect();
685
686 roles.push(RoleDefinition {
688 name: role_name.clone(),
689 external: false,
690 login: profile.login,
691 superuser: None,
692 createdb: None,
693 createrole: None,
694 inherit: profile.inherit,
695 replication: None,
696 bypassrls: None,
697 connection_limit: None,
698 comment: Some(format!(
699 "Generated from profile '{profile_name}' for schema '{}'",
700 schema_binding.name
701 )),
702 password: None,
703 password_valid_until: None,
704 config,
705 });
706
707 for profile_grant in &profile.grants {
709 let object_target = match profile_grant.object.object_type {
710 ObjectType::Schema => ObjectTarget {
711 object_type: ObjectType::Schema,
712 schema: None,
713 name: Some(schema_binding.name.clone()),
714 },
715 _ => ObjectTarget {
716 object_type: profile_grant.object.object_type,
717 schema: Some(schema_binding.name.clone()),
718 name: profile_grant.object.name.clone(),
719 },
720 };
721
722 grants.push(Grant {
723 role: role_name.clone(),
724 privileges: profile_grant.privileges.clone(),
725 object: object_target,
726 });
727 }
728
729 if !profile.default_privileges.is_empty() {
731 let owner = schema_binding
732 .owner
733 .clone()
734 .or(manifest.default_owner.clone());
735
736 let expanded_grants: Vec<DefaultPrivilegeGrant> = profile
737 .default_privileges
738 .iter()
739 .map(|dp| DefaultPrivilegeGrant {
740 role: Some(role_name.clone()),
741 privileges: dp.privileges.clone(),
742 on_type: dp.on_type,
743 })
744 .collect();
745
746 default_privileges.push(DefaultPrivilege {
747 owner,
748 schema: schema_binding.name.clone(),
749 grant: expanded_grants,
750 });
751 }
752 }
753 }
754
755 for default_priv in &manifest.default_privileges {
757 for grant in &default_priv.grant {
758 if grant.role.is_none() {
759 return Err(ManifestError::MissingDefaultPrivilegeRole {
760 schema: default_priv.schema.clone(),
761 });
762 }
763 }
764 }
765
766 roles.extend(manifest.roles.clone());
768 grants.extend(manifest.grants.clone());
769 default_privileges.extend(manifest.default_privileges.clone());
770 let memberships = manifest.memberships.clone();
771
772 let mut seen_roles: HashSet<String> = HashSet::new();
774 for role in &roles {
775 if seen_roles.contains(&role.name) {
776 return Err(ManifestError::DuplicateRole(role.name.clone()));
777 }
778 seen_roles.insert(role.name.clone());
779 }
780
781 let desired_role_names: HashSet<String> = roles.iter().map(|role| role.name.clone()).collect();
782 let mut seen_retirements: HashSet<String> = HashSet::new();
783 for retirement in &manifest.retirements {
784 if seen_retirements.contains(&retirement.role) {
785 return Err(ManifestError::DuplicateRetirement(retirement.role.clone()));
786 }
787 if desired_role_names.contains(&retirement.role) {
788 return Err(ManifestError::RetirementRoleStillDesired(
789 retirement.role.clone(),
790 ));
791 }
792 if retirement.reassign_owned_to.as_deref() == Some(retirement.role.as_str()) {
793 return Err(ManifestError::RetirementSelfReassign {
794 role: retirement.role.clone(),
795 });
796 }
797 seen_retirements.insert(retirement.role.clone());
798 }
799
800 for role in &roles {
804 if role.password.is_some() && role.login != Some(true) {
805 return Err(ManifestError::PasswordWithoutLogin {
806 role: role.name.clone(),
807 });
808 }
809 }
810
811 for role in &roles {
813 if let Some(value) = &role.password_valid_until
814 && !is_valid_iso8601_timestamp(value)
815 {
816 return Err(ManifestError::InvalidValidUntil {
817 role: role.name.clone(),
818 value: value.clone(),
819 });
820 }
821 }
822
823 for role in &roles {
829 for (parameter, value) in &role.config {
830 if !is_valid_config_parameter_name(parameter) {
831 return Err(ManifestError::InvalidConfigParameter {
832 role: role.name.clone(),
833 parameter: parameter.clone(),
834 });
835 }
836 if parameter.eq_ignore_ascii_case("role") {
837 let target = value.0.as_str();
838 let target_declared = desired_role_names.contains(target);
839 let membership_declared = memberships.iter().any(|membership| {
840 membership.role == target
841 && membership
842 .members
843 .iter()
844 .any(|member| member.name == role.name)
845 });
846 if target_declared && !membership_declared {
847 return Err(ManifestError::SetRoleWithoutMembership {
848 role: role.name.clone(),
849 target: target.to_string(),
850 });
851 }
852 }
853 }
854 }
855
856 Ok(ExpandedManifest {
857 schemas,
858 roles,
859 grants,
860 default_privileges,
861 memberships,
862 })
863}
864
865fn is_valid_iso8601_timestamp(value: &str) -> bool {
881 if value.len() < 20 {
883 return false;
884 }
885
886 let bytes = value.as_bytes();
887
888 if bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
890 return false;
891 }
892
893 let year = &value[0..4];
894 let month = &value[5..7];
895 let day = &value[8..10];
896
897 let Ok(y) = year.parse::<u16>() else {
898 return false;
899 };
900 let Ok(m) = month.parse::<u8>() else {
901 return false;
902 };
903 let Ok(d) = day.parse::<u8>() else {
904 return false;
905 };
906
907 if y < 1970 || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
908 return false;
909 }
910
911 if bytes[13] != b':' || bytes[16] != b':' {
913 return false;
914 }
915
916 let hour = &value[11..13];
917 let minute = &value[14..16];
918 let second = &value[17..19];
919
920 let Ok(h) = hour.parse::<u8>() else {
921 return false;
922 };
923 let Ok(min) = minute.parse::<u8>() else {
924 return false;
925 };
926 let Ok(sec) = second.parse::<u8>() else {
927 return false;
928 };
929
930 if h > 23 || min > 59 || sec > 59 {
931 return false;
932 }
933
934 let suffix = &value[19..];
936
937 let tz_part = if let Some(rest) = suffix.strip_prefix('.') {
939 let frac_end = rest
941 .find(|c: char| !c.is_ascii_digit())
942 .unwrap_or(rest.len());
943 if frac_end == 0 {
944 return false; }
946 &rest[frac_end..]
947 } else {
948 suffix
949 };
950
951 match tz_part {
953 "Z" => true,
954 s if (s.starts_with('+') || s.starts_with('-'))
955 && s.len() == 6
956 && s.as_bytes()[3] == b':' =>
957 {
958 let Ok(tz_h) = s[1..3].parse::<u8>() else {
959 return false;
960 };
961 let Ok(tz_m) = s[4..6].parse::<u8>() else {
962 return false;
963 };
964 tz_h <= 14 && tz_m <= 59
965 }
966 _ => false,
967 }
968}
969
970#[cfg(test)]
975mod tests {
976 use super::*;
977
978 #[test]
979 fn parse_minimal_role() {
980 let yaml = r#"
981roles:
982 - name: test-role
983"#;
984 let manifest = parse_manifest(yaml).unwrap();
985 assert_eq!(manifest.roles.len(), 1);
986 assert_eq!(manifest.roles[0].name, "test-role");
987 assert!(manifest.roles[0].login.is_none());
988 }
989
990 #[test]
991 fn parse_role_config_accepts_strings() {
992 let yaml = r#"
993roles:
994 - name: blue
995 login: true
996 config:
997 role: combined
998 statement_timeout: "30000"
999 jit: "off"
1000"#;
1001 let manifest = parse_manifest(yaml).unwrap();
1002 let config = &manifest.roles[0].config;
1003 assert_eq!(config["role"].0, "combined");
1004 assert_eq!(config["statement_timeout"].0, "30000");
1005 assert_eq!(config["jit"].0, "off");
1006 }
1007
1008 #[test]
1009 fn parse_role_config_rejects_unquoted_number() {
1010 let yaml = r#"
1013roles:
1014 - name: blue
1015 config:
1016 statement_timeout: 30000
1017"#;
1018 let err = parse_manifest(yaml).unwrap_err();
1019 assert!(
1020 err.to_string().contains("write \"30000\" instead of 30000"),
1021 "unexpected error: {err}"
1022 );
1023 }
1024
1025 #[test]
1026 fn parse_role_config_rejects_unquoted_boolean() {
1027 let yaml = r#"
1028roles:
1029 - name: blue
1030 config:
1031 jit: false
1032"#;
1033 let err = parse_manifest(yaml).unwrap_err();
1034 assert!(
1035 err.to_string().contains("write \"off\""),
1036 "unexpected error: {err}"
1037 );
1038 }
1039
1040 #[test]
1041 fn expand_rejects_invalid_config_parameter_name() {
1042 let yaml = r#"
1043roles:
1044 - name: blue
1045 config:
1046 "bad name; DROP TABLE": x
1047"#;
1048 let manifest = parse_manifest(yaml).unwrap();
1049 let err = expand_manifest(&manifest).unwrap_err();
1050 assert!(matches!(err, ManifestError::InvalidConfigParameter { .. }));
1051 }
1052
1053 #[test]
1054 fn expand_rejects_set_role_without_declared_membership() {
1055 let yaml = r#"
1056roles:
1057 - name: blue
1058 login: true
1059 config:
1060 role: combined
1061 - name: combined
1062"#;
1063 let manifest = parse_manifest(yaml).unwrap();
1064 let err = expand_manifest(&manifest).unwrap_err();
1065 assert!(matches!(
1066 err,
1067 ManifestError::SetRoleWithoutMembership { role, target }
1068 if role == "blue" && target == "combined"
1069 ));
1070 }
1071
1072 #[test]
1073 fn expand_accepts_set_role_with_declared_membership() {
1074 let yaml = r#"
1075roles:
1076 - name: blue
1077 login: true
1078 config:
1079 role: combined
1080 - name: combined
1081
1082memberships:
1083 - role: combined
1084 members:
1085 - name: blue
1086"#;
1087 let manifest = parse_manifest(yaml).unwrap();
1088 assert!(expand_manifest(&manifest).is_ok());
1089 }
1090
1091 #[test]
1092 fn expand_accepts_set_role_to_undeclared_target() {
1093 let yaml = r#"
1096roles:
1097 - name: blue
1098 login: true
1099 config:
1100 role: external_combined
1101"#;
1102 let manifest = parse_manifest(yaml).unwrap();
1103 assert!(expand_manifest(&manifest).is_ok());
1104 }
1105
1106 #[test]
1107 fn config_parameter_name_validation() {
1108 assert!(is_valid_config_parameter_name("role"));
1109 assert!(is_valid_config_parameter_name("search_path"));
1110 assert!(is_valid_config_parameter_name("app.tenant"));
1111 assert!(is_valid_config_parameter_name("_x.y2"));
1112 assert!(!is_valid_config_parameter_name(""));
1113 assert!(!is_valid_config_parameter_name("2bad"));
1114 assert!(!is_valid_config_parameter_name("bad name"));
1115 assert!(!is_valid_config_parameter_name("bad;name"));
1116 assert!(!is_valid_config_parameter_name("trailing."));
1117 assert!(!is_valid_config_parameter_name(".leading"));
1118 }
1119
1120 #[test]
1121 fn parse_full_policy() {
1122 let yaml = r#"
1123default_owner: app_owner
1124
1125profiles:
1126 editor:
1127 login: false
1128 grants:
1129 - privileges: [USAGE]
1130 object: { type: schema }
1131 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
1132 object: { type: table, name: "*" }
1133 - privileges: [USAGE, SELECT, UPDATE]
1134 object: { type: sequence, name: "*" }
1135 - privileges: [EXECUTE]
1136 object: { type: function, name: "*" }
1137 default_privileges:
1138 - privileges: [SELECT, INSERT, UPDATE, DELETE, REFERENCES, TRIGGER]
1139 on_type: table
1140 - privileges: [USAGE, SELECT, UPDATE]
1141 on_type: sequence
1142 - privileges: [EXECUTE]
1143 on_type: function
1144
1145schemas:
1146 - name: inventory
1147 profiles: [editor]
1148 - name: catalog
1149 profiles: [editor]
1150
1151roles:
1152 - name: analytics-readonly
1153 login: true
1154
1155memberships:
1156 - role: inventory-editor
1157 members:
1158 - name: "alice@example.com"
1159 inherit: true
1160"#;
1161 let manifest = parse_manifest(yaml).unwrap();
1162 assert_eq!(manifest.profiles.len(), 1);
1163 assert_eq!(manifest.schemas.len(), 2);
1164 assert_eq!(manifest.roles.len(), 1);
1165 assert_eq!(manifest.memberships.len(), 1);
1166 assert_eq!(manifest.default_owner, Some("app_owner".to_string()));
1167 }
1168
1169 #[test]
1170 fn reject_invalid_yaml() {
1171 let yaml = "not: [valid: yaml: {{";
1172 assert!(parse_manifest(yaml).is_err());
1173 }
1174
1175 #[test]
1176 fn expand_profiles_basic() {
1177 let yaml = r#"
1178profiles:
1179 editor:
1180 login: false
1181 grants:
1182 - privileges: [USAGE]
1183 object: { type: schema }
1184 - privileges: [SELECT, INSERT]
1185 object: { type: table, name: "*" }
1186
1187schemas:
1188 - name: myschema
1189 profiles: [editor]
1190"#;
1191 let manifest = parse_manifest(yaml).unwrap();
1192 let expanded = expand_manifest(&manifest).unwrap();
1193
1194 assert_eq!(expanded.roles.len(), 1);
1195 assert_eq!(expanded.roles[0].name, "myschema-editor");
1196 assert_eq!(expanded.roles[0].login, Some(false));
1197 assert_eq!(expanded.roles[0].inherit, None);
1198
1199 assert_eq!(expanded.grants.len(), 2);
1201 assert_eq!(expanded.grants[0].role, "myschema-editor");
1202 assert_eq!(expanded.grants[0].object.object_type, ObjectType::Schema);
1203 assert_eq!(expanded.grants[0].object.name, Some("myschema".to_string()));
1204
1205 assert_eq!(expanded.grants[1].object.object_type, ObjectType::Table);
1206 assert_eq!(
1207 expanded.grants[1].object.schema,
1208 Some("myschema".to_string())
1209 );
1210 assert_eq!(expanded.grants[1].object.name, Some("*".to_string()));
1211 }
1212
1213 #[test]
1214 fn expand_schema_owner_overrides_default_owner() {
1215 let yaml = r#"
1216default_owner: app_owner
1217
1218profiles:
1219 editor:
1220 default_privileges:
1221 - privileges: [SELECT]
1222 on_type: table
1223
1224schemas:
1225 - name: inventory
1226 owner: inventory_owner
1227 profiles: [editor]
1228 - name: catalog
1229 profiles: [editor]
1230"#;
1231
1232 let manifest = parse_manifest(yaml).unwrap();
1233 let expanded = expand_manifest(&manifest).unwrap();
1234
1235 assert_eq!(
1236 expanded.schemas,
1237 vec![
1238 ExpandedSchema {
1239 name: "inventory".to_string(),
1240 owner: Some("inventory_owner".to_string()),
1241 },
1242 ExpandedSchema {
1243 name: "catalog".to_string(),
1244 owner: Some("app_owner".to_string()),
1245 },
1246 ]
1247 );
1248 }
1249
1250 #[test]
1251 fn expand_profiles_preserves_generated_role_inherit() {
1252 let yaml = r#"
1253profiles:
1254 editor:
1255 login: false
1256 inherit: false
1257 grants:
1258 - privileges: [USAGE]
1259 object: { type: schema }
1260
1261schemas:
1262 - name: myschema
1263 profiles: [editor]
1264"#;
1265
1266 let manifest = parse_manifest(yaml).unwrap();
1267 let expanded = expand_manifest(&manifest).unwrap();
1268
1269 assert_eq!(expanded.roles.len(), 1);
1270 assert_eq!(expanded.roles[0].name, "myschema-editor");
1271 assert_eq!(expanded.roles[0].login, Some(false));
1272 assert_eq!(expanded.roles[0].inherit, Some(false));
1273 }
1274
1275 #[test]
1276 fn expand_declared_schema_with_no_profiles() {
1277 let yaml = r#"
1278schemas:
1279 - name: cdc
1280 owner: cdc_owner
1281 profiles: []
1282"#;
1283
1284 let manifest = parse_manifest(yaml).unwrap();
1285 let expanded = expand_manifest(&manifest).unwrap();
1286
1287 assert_eq!(expanded.schemas.len(), 1);
1288 assert_eq!(expanded.schemas[0].name, "cdc");
1289 assert_eq!(expanded.schemas[0].owner.as_deref(), Some("cdc_owner"));
1290 assert!(expanded.roles.is_empty());
1291 assert!(expanded.grants.is_empty());
1292 assert!(expanded.default_privileges.is_empty());
1293 }
1294
1295 #[test]
1296 fn expand_profiles_multi_schema() {
1297 let yaml = r#"
1298profiles:
1299 editor:
1300 grants:
1301 - privileges: [SELECT]
1302 object: { type: table, name: "*" }
1303 viewer:
1304 grants:
1305 - privileges: [SELECT]
1306 object: { type: table, name: "*" }
1307
1308schemas:
1309 - name: alpha
1310 profiles: [editor, viewer]
1311 - name: beta
1312 profiles: [editor, viewer]
1313 - name: gamma
1314 profiles: [editor]
1315"#;
1316 let manifest = parse_manifest(yaml).unwrap();
1317 let expanded = expand_manifest(&manifest).unwrap();
1318
1319 assert_eq!(expanded.roles.len(), 5);
1321 let role_names: Vec<&str> = expanded.roles.iter().map(|r| r.name.as_str()).collect();
1322 assert!(role_names.contains(&"alpha-editor"));
1323 assert!(role_names.contains(&"alpha-viewer"));
1324 assert!(role_names.contains(&"beta-editor"));
1325 assert!(role_names.contains(&"beta-viewer"));
1326 assert!(role_names.contains(&"gamma-editor"));
1327 }
1328
1329 #[test]
1330 fn expand_custom_role_pattern() {
1331 let yaml = r#"
1332profiles:
1333 viewer:
1334 grants:
1335 - privileges: [SELECT]
1336 object: { type: table, name: "*" }
1337
1338schemas:
1339 - name: legacy_data
1340 profiles: [viewer]
1341 role_pattern: "legacy-{profile}"
1342"#;
1343 let manifest = parse_manifest(yaml).unwrap();
1344 let expanded = expand_manifest(&manifest).unwrap();
1345
1346 assert_eq!(expanded.roles.len(), 1);
1347 assert_eq!(expanded.roles[0].name, "legacy-viewer");
1348 }
1349
1350 #[test]
1351 fn expand_rejects_duplicate_role_name() {
1352 let yaml = r#"
1353profiles:
1354 editor:
1355 grants: []
1356
1357schemas:
1358 - name: inventory
1359 profiles: [editor]
1360
1361roles:
1362 - name: inventory-editor
1363"#;
1364 let manifest = parse_manifest(yaml).unwrap();
1365 let result = expand_manifest(&manifest);
1366 assert!(result.is_err());
1367 assert!(
1368 result
1369 .unwrap_err()
1370 .to_string()
1371 .contains("duplicate role name")
1372 );
1373 }
1374
1375 #[test]
1376 fn expand_rejects_duplicate_schema_name() {
1377 let yaml = r#"
1378schemas:
1379 - name: inventory
1380 profiles: []
1381 - name: inventory
1382 owner: inventory_owner
1383 profiles: []
1384"#;
1385
1386 let manifest = parse_manifest(yaml).unwrap();
1387 let error = expand_manifest(&manifest).unwrap_err();
1388 assert!(error.to_string().contains("duplicate schema name"));
1389 }
1390
1391 #[test]
1392 fn expand_rejects_undefined_profile() {
1393 let yaml = r#"
1394profiles: {}
1395
1396schemas:
1397 - name: inventory
1398 profiles: [nonexistent]
1399"#;
1400 let manifest = parse_manifest(yaml).unwrap();
1401 let result = expand_manifest(&manifest);
1402 assert!(result.is_err());
1403 assert!(result.unwrap_err().to_string().contains("not defined"));
1404 }
1405
1406 #[test]
1407 fn expand_rejects_invalid_pattern() {
1408 let yaml = r#"
1409profiles:
1410 editor:
1411 grants: []
1412
1413schemas:
1414 - name: inventory
1415 profiles: [editor]
1416 role_pattern: "static-name"
1417"#;
1418 let manifest = parse_manifest(yaml).unwrap();
1419 let result = expand_manifest(&manifest);
1420 assert!(result.is_err());
1421 assert!(
1422 result
1423 .unwrap_err()
1424 .to_string()
1425 .contains("{profile} placeholder")
1426 );
1427 }
1428
1429 #[test]
1430 fn expand_rejects_top_level_default_privilege_without_role() {
1431 let yaml = r#"
1432default_privileges:
1433 - schema: public
1434 grant:
1435 - privileges: [SELECT]
1436 on_type: table
1437"#;
1438 let manifest = parse_manifest(yaml).unwrap();
1439 let result = expand_manifest(&manifest);
1440 assert!(result.is_err());
1441 assert!(
1442 result
1443 .unwrap_err()
1444 .to_string()
1445 .contains("must specify grant.role")
1446 );
1447 }
1448
1449 #[test]
1450 fn expand_default_privileges_with_owner_override() {
1451 let yaml = r#"
1452default_owner: app_owner
1453
1454profiles:
1455 editor:
1456 grants: []
1457 default_privileges:
1458 - privileges: [SELECT]
1459 on_type: table
1460
1461schemas:
1462 - name: inventory
1463 profiles: [editor]
1464 - name: legacy
1465 profiles: [editor]
1466 owner: legacy_admin
1467"#;
1468 let manifest = parse_manifest(yaml).unwrap();
1469 let expanded = expand_manifest(&manifest).unwrap();
1470
1471 assert_eq!(expanded.default_privileges.len(), 2);
1472
1473 assert_eq!(
1475 expanded.default_privileges[0].owner,
1476 Some("app_owner".to_string())
1477 );
1478 assert_eq!(expanded.default_privileges[0].schema, "inventory");
1479
1480 assert_eq!(
1482 expanded.default_privileges[1].owner,
1483 Some("legacy_admin".to_string())
1484 );
1485 assert_eq!(expanded.default_privileges[1].schema, "legacy");
1486 }
1487
1488 #[test]
1489 fn expand_merges_oneoff_roles_and_grants() {
1490 let yaml = r#"
1491profiles:
1492 editor:
1493 grants:
1494 - privileges: [SELECT]
1495 object: { type: table, name: "*" }
1496
1497schemas:
1498 - name: inventory
1499 profiles: [editor]
1500
1501roles:
1502 - name: analytics
1503 login: true
1504
1505grants:
1506 - role: analytics
1507 privileges: [SELECT]
1508 on:
1509 type: table
1510 schema: inventory
1511 name: "*"
1512"#;
1513 let manifest = parse_manifest(yaml).unwrap();
1514 let expanded = expand_manifest(&manifest).unwrap();
1515
1516 assert_eq!(expanded.roles.len(), 2);
1517 assert_eq!(expanded.grants.len(), 2); }
1519
1520 #[test]
1521 fn parse_manifest_accepts_legacy_on_alias() {
1522 let yaml = r#"
1523grants:
1524 - role: analytics
1525 privileges: [SELECT]
1526 on:
1527 type: table
1528 schema: public
1529 name: "*"
1530"#;
1531 let manifest = parse_manifest(yaml).unwrap();
1532 assert_eq!(manifest.grants.len(), 1);
1533 assert_eq!(manifest.grants[0].object.object_type, ObjectType::Table);
1534 assert_eq!(manifest.grants[0].object.schema.as_deref(), Some("public"));
1535 assert_eq!(manifest.grants[0].object.name.as_deref(), Some("*"));
1536 }
1537
1538 #[test]
1539 fn parse_membership_with_email_roles() {
1540 let yaml = r#"
1541memberships:
1542 - role: inventory-editor
1543 members:
1544 - name: "alice@example.com"
1545 inherit: true
1546 - name: "engineering@example.com"
1547 admin: true
1548"#;
1549 let manifest = parse_manifest(yaml).unwrap();
1550 assert_eq!(manifest.memberships.len(), 1);
1551 assert_eq!(manifest.memberships[0].members.len(), 2);
1552 assert_eq!(manifest.memberships[0].members[0].name, "alice@example.com");
1553 assert_eq!(manifest.memberships[0].members[0].inherit, Some(true));
1554 assert_eq!(manifest.memberships[0].members[1].admin, Some(true));
1555 }
1556
1557 #[test]
1558 fn member_spec_defaults() {
1559 let yaml = r#"
1560memberships:
1561 - role: some-role
1562 members:
1563 - name: user1
1564"#;
1565 let manifest = parse_manifest(yaml).unwrap();
1566 assert_eq!(manifest.memberships[0].members[0].inherit, None);
1568 assert_eq!(manifest.memberships[0].members[0].admin, None);
1569 assert!(manifest.memberships[0].members[0].inherit());
1571 assert!(!manifest.memberships[0].members[0].admin());
1572 }
1573
1574 #[test]
1575 fn expand_rejects_duplicate_retirements() {
1576 let yaml = r#"
1577retirements:
1578 - role: old-app
1579 - role: old-app
1580"#;
1581 let manifest = parse_manifest(yaml).unwrap();
1582 let result = expand_manifest(&manifest);
1583 assert!(matches!(
1584 result,
1585 Err(ManifestError::DuplicateRetirement(role)) if role == "old-app"
1586 ));
1587 }
1588
1589 #[test]
1590 fn expand_rejects_retirement_for_desired_role() {
1591 let yaml = r#"
1592roles:
1593 - name: old-app
1594
1595retirements:
1596 - role: old-app
1597"#;
1598 let manifest = parse_manifest(yaml).unwrap();
1599 let result = expand_manifest(&manifest);
1600 assert!(matches!(
1601 result,
1602 Err(ManifestError::RetirementRoleStillDesired(role)) if role == "old-app"
1603 ));
1604 }
1605
1606 #[test]
1607 fn expand_rejects_self_reassign_retirement() {
1608 let yaml = r#"
1609retirements:
1610 - role: old-app
1611 reassign_owned_to: old-app
1612"#;
1613 let manifest = parse_manifest(yaml).unwrap();
1614 let result = expand_manifest(&manifest);
1615 assert!(matches!(
1616 result,
1617 Err(ManifestError::RetirementSelfReassign { role }) if role == "old-app"
1618 ));
1619 }
1620
1621 #[test]
1622 fn parse_auth_providers() {
1623 let yaml = r#"
1624auth_providers:
1625 - type: cloud_sql_iam
1626 project: my-gcp-project
1627 - type: alloydb_iam
1628 project: my-gcp-project
1629 cluster: analytics-prod
1630 - type: rds_iam
1631 region: us-east-1
1632 - type: azure_ad
1633 tenant_id: "abc-123"
1634 - type: supabase
1635 project_ref: myprojref
1636 - type: planet_scale
1637 organization: my-org
1638
1639roles:
1640 - name: app-service
1641"#;
1642 let manifest = parse_manifest(yaml).unwrap();
1643 assert_eq!(manifest.auth_providers.len(), 6);
1644 assert!(matches!(
1645 &manifest.auth_providers[0],
1646 AuthProvider::CloudSqlIam { project: Some(p) } if p == "my-gcp-project"
1647 ));
1648 assert!(matches!(
1649 &manifest.auth_providers[1],
1650 AuthProvider::AlloyDbIam {
1651 project: Some(p),
1652 cluster: Some(c)
1653 } if p == "my-gcp-project" && c == "analytics-prod"
1654 ));
1655 assert!(matches!(
1656 &manifest.auth_providers[2],
1657 AuthProvider::RdsIam { region: Some(r) } if r == "us-east-1"
1658 ));
1659 assert!(matches!(
1660 &manifest.auth_providers[3],
1661 AuthProvider::AzureAd { tenant_id: Some(t) } if t == "abc-123"
1662 ));
1663 assert!(matches!(
1664 &manifest.auth_providers[4],
1665 AuthProvider::Supabase { project_ref: Some(r) } if r == "myprojref"
1666 ));
1667 assert!(matches!(
1668 &manifest.auth_providers[5],
1669 AuthProvider::PlanetScale { organization: Some(o) } if o == "my-org"
1670 ));
1671 }
1672
1673 #[test]
1674 fn parse_manifest_without_auth_providers() {
1675 let yaml = r#"
1676roles:
1677 - name: test-role
1678"#;
1679 let manifest = parse_manifest(yaml).unwrap();
1680 assert!(manifest.auth_providers.is_empty());
1681 }
1682
1683 #[test]
1684 fn parse_role_with_password_source() {
1685 let yaml = r#"
1686roles:
1687 - name: app-service
1688 login: true
1689 password:
1690 from_env: APP_SERVICE_PASSWORD
1691 password_valid_until: "2025-12-31T00:00:00Z"
1692"#;
1693 let manifest = parse_manifest(yaml).unwrap();
1694 assert_eq!(manifest.roles.len(), 1);
1695 let role = &manifest.roles[0];
1696 assert!(role.password.is_some());
1697 assert_eq!(
1698 role.password.as_ref().unwrap().from_env,
1699 "APP_SERVICE_PASSWORD"
1700 );
1701 assert_eq!(
1702 role.password_valid_until,
1703 Some("2025-12-31T00:00:00Z".to_string())
1704 );
1705 }
1706
1707 #[test]
1708 fn parse_role_without_password() {
1709 let yaml = r#"
1710roles:
1711 - name: app-service
1712 login: true
1713"#;
1714 let manifest = parse_manifest(yaml).unwrap();
1715 assert!(manifest.roles[0].password.is_none());
1716 assert!(manifest.roles[0].password_valid_until.is_none());
1717 }
1718
1719 #[test]
1720 fn reject_password_on_nologin_role() {
1721 let yaml = r#"
1722roles:
1723 - name: nologin-role
1724 login: false
1725 password:
1726 from_env: SOME_PASSWORD
1727"#;
1728 let manifest = parse_manifest(yaml).unwrap();
1729 let result = expand_manifest(&manifest);
1730 assert!(result.is_err());
1731 assert!(
1732 result
1733 .unwrap_err()
1734 .to_string()
1735 .contains("login is not enabled")
1736 );
1737 }
1738
1739 #[test]
1740 fn reject_password_on_default_login_role() {
1741 let yaml = r#"
1743roles:
1744 - name: implicit-nologin-role
1745 password:
1746 from_env: SOME_PASSWORD
1747"#;
1748 let manifest = parse_manifest(yaml).unwrap();
1749 let result = expand_manifest(&manifest);
1750 assert!(result.is_err());
1751 assert!(
1752 result
1753 .unwrap_err()
1754 .to_string()
1755 .contains("login is not enabled")
1756 );
1757 }
1758
1759 #[test]
1760 fn reject_invalid_password_valid_until() {
1761 let yaml = r#"
1762roles:
1763 - name: bad-date
1764 login: true
1765 password_valid_until: "not-a-date"
1766"#;
1767 let manifest = parse_manifest(yaml).unwrap();
1768 let result = expand_manifest(&manifest);
1769 assert!(result.is_err());
1770 assert!(
1771 result
1772 .unwrap_err()
1773 .to_string()
1774 .contains("invalid password_valid_until")
1775 );
1776 }
1777
1778 #[test]
1779 fn reject_date_only_valid_until() {
1780 let yaml = r#"
1781roles:
1782 - name: bad-date
1783 login: true
1784 password_valid_until: "2025-12-31"
1785"#;
1786 let manifest = parse_manifest(yaml).unwrap();
1787 let result = expand_manifest(&manifest);
1788 assert!(result.is_err());
1789 }
1790
1791 #[test]
1792 fn accept_valid_iso8601_timestamps() {
1793 assert!(is_valid_iso8601_timestamp("2025-12-31T00:00:00Z"));
1795 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00+05:30"));
1797 assert!(is_valid_iso8601_timestamp("2025-06-15T14:30:00-05:00"));
1798 assert!(is_valid_iso8601_timestamp("2025-12-31T23:59:59.999Z"));
1800 }
1801
1802 #[test]
1803 fn reject_invalid_iso8601_timestamps() {
1804 assert!(!is_valid_iso8601_timestamp("not-a-date"));
1805 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("")); }
1811
1812 #[test]
1813 fn parse_manifest_from_kubernetes_cr() {
1814 let yaml = r#"
1815apiVersion: pgroles.io/v1alpha1
1816kind: PostgresPolicy
1817metadata:
1818 name: staging-policy
1819 namespace: pgroles-system
1820spec:
1821 connection:
1822 secretRef:
1823 name: pgroles-db-credentials
1824 interval: "5m"
1825 mode: plan
1826 roles:
1827 - name: app_analytics
1828 login: true
1829 - name: app_billing
1830 login: true
1831 schemas:
1832 - name: analytics
1833 profiles: [editor, viewer]
1834 profiles:
1835 editor:
1836 grants:
1837 - object: { type: schema }
1838 privileges: [USAGE]
1839 - object: { type: table, name: "*" }
1840 privileges: [SELECT, INSERT, UPDATE, DELETE]
1841 viewer:
1842 grants:
1843 - object: { type: schema }
1844 privileges: [USAGE]
1845 - object: { type: table, name: "*" }
1846 privileges: [SELECT]
1847 memberships:
1848 - role: analytics-editor
1849 members:
1850 - { name: app_analytics }
1851 - role: analytics-viewer
1852 members:
1853 - { name: app_billing }
1854"#;
1855 let manifest = parse_manifest(yaml).unwrap();
1856 assert_eq!(manifest.roles.len(), 2);
1857 assert_eq!(manifest.roles[0].name, "app_analytics");
1858 assert_eq!(manifest.schemas.len(), 1);
1859 assert_eq!(manifest.memberships.len(), 2);
1860 assert_eq!(manifest.profiles.len(), 2);
1861 }
1862
1863 #[test]
1864 fn profile_config_substitutes_schema_and_profile_placeholders_in_values() {
1865 let yaml = r#"
1866profiles:
1867 editor:
1868 login: true
1869 config:
1870 search_path: "{schema}"
1871 statement_timeout: "30s"
1872 app.profile_name: "{profile}"
1873 app.combo: "{schema}-{profile}-{schema}"
1874 app.literal: "no placeholders here"
1875
1876schemas:
1877 - name: inventory
1878 profiles: [editor]
1879"#;
1880 let manifest = parse_manifest(yaml).unwrap();
1881 let expanded = expand_manifest(&manifest).unwrap();
1882
1883 assert_eq!(expanded.roles.len(), 1);
1884 let role = &expanded.roles[0];
1885 assert_eq!(role.name, "inventory-editor");
1886 assert_eq!(role.config["search_path"].0, "inventory");
1887 assert_eq!(role.config["statement_timeout"].0, "30s");
1888 assert_eq!(role.config["app.profile_name"].0, "editor");
1889 assert_eq!(role.config["app.combo"].0, "inventory-editor-inventory");
1890 assert_eq!(role.config["app.literal"].0, "no placeholders here");
1891 }
1892
1893 #[test]
1894 fn profile_config_empty_when_not_declared() {
1895 let yaml = r#"
1896profiles:
1897 editor:
1898 login: true
1899
1900schemas:
1901 - name: inventory
1902 profiles: [editor]
1903"#;
1904 let manifest = parse_manifest(yaml).unwrap();
1905 let expanded = expand_manifest(&manifest).unwrap();
1906 assert!(expanded.roles[0].config.is_empty());
1907 }
1908
1909 #[test]
1910 fn profile_config_rejects_invalid_parameter_name() {
1911 let yaml = r#"
1915profiles:
1916 editor:
1917 config:
1918 "{schema}": inventory
1919
1920schemas:
1921 - name: inventory
1922 profiles: [editor]
1923"#;
1924 let manifest = parse_manifest(yaml).unwrap();
1925 let err = expand_manifest(&manifest).unwrap_err();
1926 assert!(matches!(err, ManifestError::InvalidConfigParameter { .. }));
1927 }
1928
1929 #[test]
1930 fn profile_config_rejects_unquoted_number() {
1931 let yaml = r#"
1932profiles:
1933 editor:
1934 config:
1935 statement_timeout: 30000
1936
1937schemas:
1938 - name: inventory
1939 profiles: [editor]
1940"#;
1941 let err = parse_manifest(yaml).unwrap_err();
1942 assert!(
1943 err.to_string().contains("write \"30000\" instead of 30000"),
1944 "unexpected error: {err}"
1945 );
1946 }
1947
1948 #[test]
1949 fn profile_config_role_membership_validation_fires() {
1950 let yaml = r#"
1955profiles:
1956 editor:
1957 login: true
1958 config:
1959 role: combined
1960
1961schemas:
1962 - name: inventory
1963 profiles: [editor]
1964
1965roles:
1966 - name: combined
1967"#;
1968 let manifest = parse_manifest(yaml).unwrap();
1969 let err = expand_manifest(&manifest).unwrap_err();
1970 assert!(matches!(
1971 err,
1972 ManifestError::SetRoleWithoutMembership { role, target }
1973 if role == "inventory-editor" && target == "combined"
1974 ));
1975 }
1976
1977 #[test]
1978 fn profile_config_role_membership_validation_passes_with_declared_membership() {
1979 let yaml = r#"
1980profiles:
1981 editor:
1982 login: true
1983 config:
1984 role: combined
1985
1986schemas:
1987 - name: inventory
1988 profiles: [editor]
1989
1990roles:
1991 - name: combined
1992
1993memberships:
1994 - role: combined
1995 members:
1996 - name: inventory-editor
1997"#;
1998 let manifest = parse_manifest(yaml).unwrap();
1999 assert!(expand_manifest(&manifest).is_ok());
2000 }
2001
2002 #[test]
2003 fn parse_manifest_bare_and_cr_produce_same_result() {
2004 let bare = r#"
2005roles:
2006 - name: test_role
2007 login: true
2008schemas:
2009 - name: public
2010 profiles: [viewer]
2011profiles:
2012 viewer:
2013 grants:
2014 - object: { type: schema }
2015 privileges: [USAGE]
2016"#;
2017 let cr = r#"
2018apiVersion: pgroles.io/v1alpha1
2019kind: PostgresPolicy
2020metadata:
2021 name: test
2022spec:
2023 roles:
2024 - name: test_role
2025 login: true
2026 schemas:
2027 - name: public
2028 profiles: [viewer]
2029 profiles:
2030 viewer:
2031 grants:
2032 - object: { type: schema }
2033 privileges: [USAGE]
2034"#;
2035 let from_bare = parse_manifest(bare).unwrap();
2036 let from_cr = parse_manifest(cr).unwrap();
2037 assert_eq!(from_bare.roles.len(), from_cr.roles.len());
2038 assert_eq!(from_bare.schemas.len(), from_cr.schemas.len());
2039 assert_eq!(from_bare.profiles.len(), from_cr.profiles.len());
2040 }
2041}