1use std::collections::{BTreeMap, BTreeSet};
31
32use crate::diff::{Change, diff};
33use crate::manifest::{
34 DefaultPrivilege, DefaultPrivilegeGrant, Grant, ObjectTarget, ObjectType, PolicyManifest,
35 Privilege, Profile, ProfileGrant, ProfileObjectTarget, RoleDefinition, SchemaBinding,
36 expand_manifest,
37};
38use crate::model::RoleGraph;
39
40#[derive(Debug, Clone)]
42pub struct SuggestOptions {
43 pub min_schemas: usize,
47
48 pub full_inventory: Option<Inventory>,
68}
69
70impl Default for SuggestOptions {
71 fn default() -> Self {
72 Self {
73 min_schemas: 2,
74 full_inventory: None,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct SuggestedProfile {
82 pub profile_name: String,
83 pub role_pattern: String,
84 pub schema_to_role: BTreeMap<String, String>,
86}
87
88#[derive(Debug, Clone)]
90pub enum SkipReason {
91 MultiSchema { role: String, schemas: Vec<String> },
93 SchemaNotDeclared { role: String, schema: String },
95 OwnerMismatch { role: String, schema: String },
97 UniqueAttributes { role: String },
100 UnrepresentableGrant { role: String },
103 SoleSchema { role: String, schema: String },
105 NoUniformPattern { roles: Vec<String> },
107 SchemaPatternConflict {
109 schema: String,
110 winning_pattern: String,
111 dropped_roles: Vec<String>,
112 },
113 RoundTripFailure { reason: String },
115 IncompleteFullInventory { reason: String },
120}
121
122#[derive(Debug, Clone)]
125pub struct SuggestReport {
126 pub manifest: PolicyManifest,
127 pub profiles: Vec<SuggestedProfile>,
128 pub skipped: Vec<SkipReason>,
129 pub round_trip_ok: bool,
132}
133
134pub fn suggest_profiles(input: &PolicyManifest, opts: &SuggestOptions) -> SuggestReport {
137 if !input.profiles.is_empty() {
140 return SuggestReport {
141 manifest: input.clone(),
142 profiles: vec![],
143 skipped: vec![],
144 round_trip_ok: true,
145 };
146 }
147
148 let mut skipped: Vec<SkipReason> = Vec::new();
149
150 let grant_inventory = build_inventory(input);
164 let collapse_inventory: Option<&Inventory> = match opts.full_inventory.as_ref() {
170 None => None,
171 Some(full) => match validate_full_inventory(&grant_inventory, full) {
172 Ok(()) => Some(full),
173 Err(reason) => {
174 skipped.push(SkipReason::IncompleteFullInventory { reason });
175 None
176 }
177 },
178 };
179
180 let mut role_grants: BTreeMap<String, Vec<Grant>> = BTreeMap::new();
183 for grant in &input.grants {
184 role_grants
185 .entry(grant.role.clone())
186 .or_default()
187 .push(grant.clone());
188 }
189
190 if let Some(inv) = collapse_inventory {
194 for grants in role_grants.values_mut() {
195 collapse_full_coverage_grants(grants, inv);
196 }
197 }
198
199 let mut role_dps: BTreeMap<String, Vec<(String, String, DefaultPrivilegeGrant)>> =
202 BTreeMap::new();
203 for dp in &input.default_privileges {
204 let owner = dp
205 .owner
206 .clone()
207 .or_else(|| input.default_owner.clone())
208 .unwrap_or_default();
209 for grant in &dp.grant {
210 if let Some(role) = &grant.role {
211 role_dps.entry(role.clone()).or_default().push((
212 owner.clone(),
213 dp.schema.clone(),
214 grant.clone(),
215 ));
216 }
217 }
218 }
219
220 let schema_owner: BTreeMap<String, Option<String>> = input
223 .schemas
224 .iter()
225 .map(|s| {
226 (
227 s.name.clone(),
228 s.owner.clone().or_else(|| input.default_owner.clone()),
229 )
230 })
231 .collect();
232
233 struct Eligible {
237 role_name: String,
238 schema: String,
239 signature: RoleSignature,
240 login: Option<bool>,
241 inherit: Option<bool>,
242 }
243
244 let mut eligible: Vec<Eligible> = Vec::new();
245 let mut clustered_role_names: BTreeSet<String> = BTreeSet::new();
246
247 for role_def in &input.roles {
248 let role_name = &role_def.name;
249
250 let has_user_comment = role_def
268 .comment
269 .as_deref()
270 .is_some_and(|c| !is_auto_profile_comment(c));
271 if role_def.superuser.is_some()
272 || role_def.createdb.is_some()
273 || role_def.createrole.is_some()
274 || role_def.replication.is_some()
275 || role_def.bypassrls.is_some()
276 || role_def.connection_limit.is_some()
277 || role_def.password.is_some()
278 || role_def.password_valid_until.is_some()
279 || !role_def.config.is_empty()
280 || has_user_comment
281 {
282 skipped.push(SkipReason::UniqueAttributes {
283 role: role_name.clone(),
284 });
285 continue;
286 }
287
288 let mut schemas_seen: BTreeSet<String> = BTreeSet::new();
293 let mut has_unrepresentable_grant = false;
294 let role_grants_vec = role_grants.get(role_name).cloned().unwrap_or_default();
295 for g in &role_grants_vec {
296 match g.object.object_type {
297 ObjectType::Schema => match &g.object.name {
298 Some(name) => {
299 schemas_seen.insert(name.clone());
300 }
301 None => has_unrepresentable_grant = true,
302 },
303 ObjectType::Database => has_unrepresentable_grant = true,
304 _ => match &g.object.schema {
305 Some(s) => {
306 schemas_seen.insert(s.clone());
307 }
308 None => has_unrepresentable_grant = true,
309 },
310 }
311 }
312 if has_unrepresentable_grant {
313 skipped.push(SkipReason::UnrepresentableGrant {
314 role: role_name.clone(),
315 });
316 continue;
317 }
318 let role_dp_vec = role_dps.get(role_name).cloned().unwrap_or_default();
319 for (_, schema, _) in &role_dp_vec {
320 schemas_seen.insert(schema.clone());
321 }
322
323 if schemas_seen.is_empty() {
325 continue;
326 }
327
328 if schemas_seen.len() > 1 {
329 skipped.push(SkipReason::MultiSchema {
330 role: role_name.clone(),
331 schemas: schemas_seen.into_iter().collect(),
332 });
333 continue;
334 }
335
336 let schema = schemas_seen.into_iter().next().unwrap();
337
338 let Some(owner_for_schema) = schema_owner.get(&schema) else {
341 skipped.push(SkipReason::SchemaNotDeclared {
342 role: role_name.clone(),
343 schema,
344 });
345 continue;
346 };
347
348 let mut owner_mismatch = false;
350 for (owner, _, _) in &role_dp_vec {
351 if Some(owner.as_str()) != owner_for_schema.as_deref() {
352 owner_mismatch = true;
353 break;
354 }
355 }
356 if owner_mismatch {
357 skipped.push(SkipReason::OwnerMismatch {
358 role: role_name.clone(),
359 schema,
360 });
361 continue;
362 }
363
364 let signature = compute_signature(&role_grants_vec, &role_dp_vec, &schema);
365
366 eligible.push(Eligible {
367 role_name: role_name.clone(),
368 schema,
369 signature,
370 login: role_def.login,
371 inherit: role_def.inherit,
372 });
373 }
374
375 type ClusterKey = (RoleSignature, Option<bool>, Option<bool>);
379 let mut clusters: BTreeMap<ClusterKey, Vec<&Eligible>> = BTreeMap::new();
380 for el in &eligible {
381 clusters
382 .entry((el.signature.clone(), el.login, el.inherit))
383 .or_default()
384 .push(el);
385 }
386
387 let mut cluster_entries: Vec<_> = clusters.into_iter().collect();
392 cluster_entries.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(&b.0)));
393
394 let pattern_priority = [
395 "{schema}-{profile}",
396 "{schema}_{profile}",
397 "{profile}-{schema}",
398 "{profile}_{schema}",
399 ];
400
401 let mut schema_pattern: BTreeMap<String, String> = BTreeMap::new();
403 let mut schema_profiles: BTreeMap<String, Vec<String>> = BTreeMap::new();
405 let mut profiles_out: BTreeMap<String, Profile> = BTreeMap::new();
407 let mut suggested: Vec<SuggestedProfile> = Vec::new();
409 let mut taken_profile_names: BTreeSet<String> = BTreeSet::new();
411
412 for ((_signature, login, inherit), members) in cluster_entries {
413 let distinct_schemas: BTreeSet<&str> = members.iter().map(|m| m.schema.as_str()).collect();
415 if distinct_schemas.len() < opts.min_schemas {
416 for m in &members {
417 skipped.push(SkipReason::SoleSchema {
418 role: m.role_name.clone(),
419 schema: m.schema.clone(),
420 });
421 }
422 continue;
423 }
424
425 let mut seen_schemas: BTreeSet<&str> = BTreeSet::new();
429 let unique_members: Vec<&Eligible> = members
430 .iter()
431 .filter(|m| seen_schemas.insert(m.schema.as_str()))
432 .copied()
433 .collect();
434
435 let mut chosen: Option<(String, String)> = None;
443 let mut schema_conflict_blocking: Option<(String, String)> = None;
446 for pat in pattern_priority {
447 let viable_name: Option<String> = {
451 let mut names: BTreeSet<String> = BTreeSet::new();
452 let mut ok = true;
453 for m in &unique_members {
454 if let Some(prof) = match_pattern(pat, &m.role_name, &m.schema) {
455 names.insert(prof);
456 } else {
457 ok = false;
458 break;
459 }
460 }
461 if !ok || names.len() != 1 {
462 None
463 } else {
464 let n = names.into_iter().next().unwrap();
465 if !is_valid_identifier(&n)
466 || taken_profile_names.contains(&n)
467 || input.profiles.contains_key(&n)
468 {
469 None
470 } else {
471 Some(n)
472 }
473 }
474 };
475
476 let blocked_by_schema = unique_members.iter().find_map(|m| {
479 schema_pattern
480 .get(&m.schema)
481 .filter(|committed| *committed != pat)
482 .map(|committed| (m.schema.clone(), committed.clone()))
483 });
484
485 match (viable_name, blocked_by_schema) {
486 (Some(name), None) => {
487 chosen = Some((pat.to_string(), name));
488 break;
489 }
490 (Some(_), Some(conflict)) if schema_conflict_blocking.is_none() => {
491 schema_conflict_blocking = Some(conflict);
492 }
493 _ => {}
494 }
495 }
496
497 let Some((pattern, profile_name)) = chosen else {
498 if let Some((schema, winning_pattern)) = schema_conflict_blocking {
499 skipped.push(SkipReason::SchemaPatternConflict {
500 schema,
501 winning_pattern,
502 dropped_roles: unique_members.iter().map(|m| m.role_name.clone()).collect(),
503 });
504 } else {
505 skipped.push(SkipReason::NoUniformPattern {
506 roles: unique_members.iter().map(|m| m.role_name.clone()).collect(),
507 });
508 }
509 continue;
510 };
511
512 for m in &unique_members {
514 schema_pattern.insert(m.schema.clone(), pattern.clone());
515 schema_profiles
516 .entry(m.schema.clone())
517 .or_default()
518 .push(profile_name.clone());
519 clustered_role_names.insert(m.role_name.clone());
520 }
521
522 let representative = unique_members[0];
524 let rep_grants = role_grants
525 .get(&representative.role_name)
526 .cloned()
527 .unwrap_or_default();
528 let rep_dps = role_dps
529 .get(&representative.role_name)
530 .cloned()
531 .unwrap_or_default();
532
533 let profile = build_profile(
534 login,
535 inherit,
536 &rep_grants,
537 &rep_dps,
538 &representative.schema,
539 );
540
541 profiles_out.insert(profile_name.clone(), profile);
542 taken_profile_names.insert(profile_name.clone());
543
544 let schema_to_role: BTreeMap<String, String> = unique_members
545 .iter()
546 .map(|m| (m.schema.clone(), m.role_name.clone()))
547 .collect();
548 suggested.push(SuggestedProfile {
549 profile_name,
550 role_pattern: pattern,
551 schema_to_role,
552 });
553 }
554
555 let mut new_schemas: Vec<SchemaBinding> = input
558 .schemas
559 .iter()
560 .map(|s| {
561 let mut bound_profiles = schema_profiles.get(&s.name).cloned().unwrap_or_default();
562 bound_profiles.sort();
563 let pattern = schema_pattern
564 .get(&s.name)
565 .cloned()
566 .unwrap_or_else(|| s.role_pattern.clone());
567 SchemaBinding {
568 name: s.name.clone(),
569 profiles: bound_profiles,
570 role_pattern: pattern,
571 owner: s.owner.clone(),
572 }
573 })
574 .collect();
575 new_schemas.sort_by(|a, b| a.name.cmp(&b.name));
576
577 let new_roles: Vec<RoleDefinition> = input
578 .roles
579 .iter()
580 .filter(|r| !clustered_role_names.contains(&r.name))
581 .cloned()
582 .collect();
583
584 let new_grants: Vec<Grant> = input
585 .grants
586 .iter()
587 .filter(|g| !clustered_role_names.contains(&g.role))
588 .cloned()
589 .collect();
590
591 let new_default_privileges: Vec<DefaultPrivilege> = input
592 .default_privileges
593 .iter()
594 .filter_map(|dp| {
595 let kept: Vec<DefaultPrivilegeGrant> = dp
596 .grant
597 .iter()
598 .filter(|g| match &g.role {
599 Some(r) => !clustered_role_names.contains(r),
600 None => true,
601 })
602 .cloned()
603 .collect();
604 if kept.is_empty() {
605 None
606 } else {
607 Some(DefaultPrivilege {
608 owner: dp.owner.clone(),
609 schema: dp.schema.clone(),
610 grant: kept,
611 })
612 }
613 })
614 .collect();
615
616 let candidate = PolicyManifest {
617 default_owner: input.default_owner.clone(),
618 auth_providers: input.auth_providers.clone(),
619 profiles: profiles_out,
620 schemas: new_schemas,
621 roles: new_roles,
622 grants: new_grants,
623 default_privileges: new_default_privileges,
624 memberships: input.memberships.clone(),
625 retirements: input.retirements.clone(),
626 };
627
628 let round_trip_inventory = collapse_inventory.cloned().unwrap_or(grant_inventory);
635 let round_trip_ok = match check_round_trip(input, &candidate, &round_trip_inventory) {
636 Ok(()) => true,
637 Err(reason) => {
638 skipped.push(SkipReason::RoundTripFailure {
639 reason: reason.clone(),
640 });
641 false
642 }
643 };
644
645 let manifest = if round_trip_ok {
646 candidate
647 } else {
648 input.clone()
649 };
650
651 SuggestReport {
652 manifest,
653 profiles: if round_trip_ok { suggested } else { vec![] },
654 skipped,
655 round_trip_ok,
656 }
657}
658
659pub type Inventory = BTreeMap<(String, ObjectType), BTreeSet<String>>;
667
668pub fn inventory_from_manifest_grants(m: &PolicyManifest) -> Inventory {
680 build_inventory(m)
681}
682
683#[deprecated(
685 note = "renamed to `inventory_from_manifest_grants` — must NOT be used as full_inventory"
686)]
687pub fn build_inventory_pub(m: &PolicyManifest) -> Inventory {
688 build_inventory(m)
689}
690
691pub fn expand_wildcard_grants(grants: &mut Vec<Grant>, inventory: &Inventory) {
695 expand_wildcards_in_place(grants, inventory)
696}
697
698fn validate_full_inventory(
704 grant_inventory: &Inventory,
705 full_inventory: &Inventory,
706) -> Result<(), String> {
707 for (key, granted_names) in grant_inventory {
708 let Some(full_names) = full_inventory.get(key) else {
709 return Err(format!(
710 "full_inventory missing entry for (schema={}, type={:?}) — but {} object name(s) are referenced in input grants",
711 key.0,
712 key.1,
713 granted_names.len()
714 ));
715 };
716 if let Some(missing) = granted_names.iter().find(|n| !full_names.contains(*n)) {
717 return Err(format!(
718 "full_inventory[(schema={}, type={:?})] does not contain {missing:?} but it appears in input grants",
719 key.0, key.1
720 ));
721 }
722 }
723 Ok(())
724}
725
726fn build_inventory(m: &PolicyManifest) -> Inventory {
727 let mut inv: Inventory = BTreeMap::new();
728 for g in &m.grants {
729 match g.object.object_type {
730 ObjectType::Schema | ObjectType::Database => continue,
731 _ => {}
732 }
733 let Some(name) = g.object.name.as_ref() else {
734 continue;
735 };
736 if name == "*" {
737 continue;
738 }
739 let Some(schema) = g.object.schema.as_ref() else {
740 continue;
741 };
742 inv.entry((schema.clone(), g.object.object_type))
743 .or_default()
744 .insert(name.clone());
745 }
746 inv
747}
748
749fn collapse_full_coverage_grants(grants: &mut Vec<Grant>, inventory: &Inventory) {
753 let mut buckets: BTreeMap<(String, ObjectType), Vec<usize>> = BTreeMap::new();
759 let mut has_wildcard: BTreeSet<(String, ObjectType)> = BTreeSet::new();
760 for (i, g) in grants.iter().enumerate() {
761 match g.object.object_type {
762 ObjectType::Schema | ObjectType::Database => continue,
763 _ => {}
764 }
765 let Some(schema) = g.object.schema.as_ref() else {
766 continue;
767 };
768 let Some(name) = g.object.name.as_ref() else {
769 continue;
770 };
771 if name == "*" {
772 has_wildcard.insert((schema.clone(), g.object.object_type));
773 continue;
774 }
775 buckets
776 .entry((schema.clone(), g.object.object_type))
777 .or_default()
778 .push(i);
779 }
780 buckets.retain(|key, _| !has_wildcard.contains(key));
781
782 let mut to_remove: BTreeSet<usize> = BTreeSet::new();
783 let mut to_add: Vec<Grant> = Vec::new();
784
785 for ((schema, object_type), idxs) in buckets {
786 let first_privs = canonical_privs(&grants[idxs[0]].privileges);
788 let all_same = idxs
789 .iter()
790 .all(|&i| canonical_privs(&grants[i].privileges) == first_privs);
791 if !all_same {
792 continue;
793 }
794 let mut covered: BTreeSet<String> = BTreeSet::new();
796 for &i in &idxs {
797 if let Some(name) = grants[i].object.name.as_ref() {
798 covered.insert(name.clone());
799 }
800 }
801 let inv_names = inventory.get(&(schema.clone(), object_type));
802 let full_coverage = match inv_names {
803 Some(names) => &covered == names,
804 None => false,
805 };
806 if !full_coverage {
807 continue;
808 }
809 for &i in &idxs {
811 to_remove.insert(i);
812 }
813 let role = grants[idxs[0]].role.clone();
814 to_add.push(Grant {
815 role,
816 privileges: first_privs.into_iter().collect(),
817 object: ObjectTarget {
818 object_type,
819 schema: Some(schema),
820 name: Some("*".to_string()),
821 },
822 });
823 }
824
825 let mut remaining = Vec::with_capacity(grants.len() - to_remove.len() + to_add.len());
827 for (i, g) in grants.drain(..).enumerate() {
828 if !to_remove.contains(&i) {
829 remaining.push(g);
830 }
831 }
832 remaining.extend(to_add);
833 *grants = remaining;
834}
835
836fn canonical_privs(privs: &[Privilege]) -> Vec<Privilege> {
837 let mut out = privs.to_vec();
838 out.sort_by_key(|p| privilege_sort_key(*p));
839 out.dedup();
840 out
841}
842
843#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
851struct RoleSignature {
852 grants: Vec<SignatureGrant>,
853 defaults: Vec<SignatureDefault>,
854}
855
856#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
857struct SignatureGrant {
858 object_type: ObjectType,
859 name: Option<String>,
861 privileges: Vec<Privilege>,
862}
863
864#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
865struct SignatureDefault {
866 on_type: ObjectType,
867 privileges: Vec<Privilege>,
868}
869
870fn compute_signature(
871 grants: &[Grant],
872 dps: &[(String, String, DefaultPrivilegeGrant)],
873 schema: &str,
874) -> RoleSignature {
875 let mut sig_grants: Vec<SignatureGrant> = grants
876 .iter()
877 .map(|g| {
878 let name = match g.object.object_type {
879 ObjectType::Schema => {
882 if g.object.name.as_deref() == Some(schema) {
883 None
884 } else {
885 g.object.name.clone()
888 }
889 }
890 _ => g.object.name.clone(),
891 };
892 let mut privs = g.privileges.clone();
893 privs.sort_by_key(|p| privilege_sort_key(*p));
894 privs.dedup();
895 SignatureGrant {
896 object_type: g.object.object_type,
897 name,
898 privileges: privs,
899 }
900 })
901 .collect();
902 sig_grants.sort();
903 sig_grants.dedup();
904
905 let mut sig_defaults: Vec<SignatureDefault> = dps
906 .iter()
907 .map(|(_, _, dpg)| {
908 let mut privs = dpg.privileges.clone();
909 privs.sort_by_key(|p| privilege_sort_key(*p));
910 privs.dedup();
911 SignatureDefault {
912 on_type: dpg.on_type,
913 privileges: privs,
914 }
915 })
916 .collect();
917 sig_defaults.sort();
918 sig_defaults.dedup();
919
920 RoleSignature {
921 grants: sig_grants,
922 defaults: sig_defaults,
923 }
924}
925
926fn privilege_sort_key(p: Privilege) -> u8 {
927 match p {
928 Privilege::Select => 0,
929 Privilege::Insert => 1,
930 Privilege::Update => 2,
931 Privilege::Delete => 3,
932 Privilege::Truncate => 4,
933 Privilege::References => 5,
934 Privilege::Trigger => 6,
935 Privilege::Execute => 7,
936 Privilege::Usage => 8,
937 Privilege::Create => 9,
938 Privilege::Connect => 10,
939 Privilege::Temporary => 11,
940 }
941}
942
943fn match_pattern(pattern: &str, role_name: &str, schema: &str) -> Option<String> {
944 match pattern {
945 "{schema}-{profile}" => role_name
946 .strip_prefix(schema)
947 .and_then(|r| r.strip_prefix('-'))
948 .filter(|p| !p.is_empty())
949 .map(|p| p.to_string()),
950 "{schema}_{profile}" => role_name
951 .strip_prefix(schema)
952 .and_then(|r| r.strip_prefix('_'))
953 .filter(|p| !p.is_empty())
954 .map(|p| p.to_string()),
955 "{profile}-{schema}" => role_name
956 .strip_suffix(schema)
957 .and_then(|r| r.strip_suffix('-'))
958 .filter(|p| !p.is_empty())
959 .map(|p| p.to_string()),
960 "{profile}_{schema}" => role_name
961 .strip_suffix(schema)
962 .and_then(|r| r.strip_suffix('_'))
963 .filter(|p| !p.is_empty())
964 .map(|p| p.to_string()),
965 _ => None,
966 }
967}
968
969fn is_auto_profile_comment(c: &str) -> bool {
973 c.starts_with("Generated from profile '") && c.contains("' for schema '") && c.ends_with('\'')
974}
975
976fn is_valid_identifier(s: &str) -> bool {
977 !s.is_empty()
978 && s.chars()
979 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
980 && !s.starts_with('-')
981 && !s.starts_with('_')
982}
983
984fn build_profile(
985 login: Option<bool>,
986 inherit: Option<bool>,
987 grants: &[Grant],
988 dps: &[(String, String, DefaultPrivilegeGrant)],
989 #[cfg_attr(not(debug_assertions), allow(unused_variables))] schema: &str,
990) -> Profile {
991 let mut profile_grants: Vec<ProfileGrant> = grants
993 .iter()
994 .map(|g| {
995 let object = match g.object.object_type {
996 ObjectType::Schema => ProfileObjectTarget {
997 object_type: ObjectType::Schema,
998 name: None,
1002 },
1003 _ => {
1004 debug_assert_eq!(g.object.schema.as_deref(), Some(schema));
1008 ProfileObjectTarget {
1009 object_type: g.object.object_type,
1010 name: g.object.name.clone(),
1011 }
1012 }
1013 };
1014 let mut privs = g.privileges.clone();
1015 privs.sort_by_key(|p| privilege_sort_key(*p));
1016 privs.dedup();
1017 ProfileGrant {
1018 privileges: privs,
1019 object,
1020 }
1021 })
1022 .collect();
1023 profile_grants.sort_by(|a, b| {
1024 let key_a = (a.object.object_type, a.object.name.clone());
1025 let key_b = (b.object.object_type, b.object.name.clone());
1026 key_a.cmp(&key_b)
1027 });
1028
1029 let mut profile_defaults: Vec<DefaultPrivilegeGrant> = dps
1030 .iter()
1031 .map(|(_, _, dpg)| {
1032 let mut privs = dpg.privileges.clone();
1033 privs.sort_by_key(|p| privilege_sort_key(*p));
1034 privs.dedup();
1035 DefaultPrivilegeGrant {
1036 role: None, privileges: privs,
1038 on_type: dpg.on_type,
1039 }
1040 })
1041 .collect();
1042 profile_defaults.sort_by_key(|d| d.on_type);
1043
1044 Profile {
1045 login,
1046 inherit,
1047 grants: profile_grants,
1048 default_privileges: profile_defaults,
1049 config: BTreeMap::new(),
1053 }
1054}
1055
1056fn check_round_trip(
1057 original: &PolicyManifest,
1058 candidate: &PolicyManifest,
1059 inventory: &Inventory,
1060) -> Result<(), String> {
1061 let mut original_expanded =
1062 expand_manifest(original).map_err(|e| format!("original expand: {e}"))?;
1063 expand_wildcards_in_place(&mut original_expanded.grants, inventory);
1064 let original_graph =
1065 RoleGraph::from_expanded(&original_expanded, original.default_owner.as_deref())
1066 .map_err(|e| format!("original graph: {e}"))?;
1067
1068 let mut candidate_expanded =
1069 expand_manifest(candidate).map_err(|e| format!("candidate expand: {e}"))?;
1070 expand_wildcards_in_place(&mut candidate_expanded.grants, inventory);
1071 let candidate_graph =
1072 RoleGraph::from_expanded(&candidate_expanded, candidate.default_owner.as_deref())
1073 .map_err(|e| format!("candidate graph: {e}"))?;
1074
1075 let changes = diff(&original_graph, &candidate_graph);
1076 let unacceptable: Vec<&Change> = changes
1077 .iter()
1078 .filter(|c| !matches!(c, Change::SetComment { .. }))
1079 .collect();
1080 if !unacceptable.is_empty() {
1081 return Err(format!(
1082 "{} structural change(s) after suggestion (sample: {:?})",
1083 unacceptable.len(),
1084 unacceptable.first()
1085 ));
1086 }
1087 Ok(())
1088}
1089
1090fn expand_wildcards_in_place(grants: &mut Vec<Grant>, inventory: &Inventory) {
1093 let mut out: Vec<Grant> = Vec::with_capacity(grants.len());
1094 for g in grants.drain(..) {
1095 let is_wildcard = matches!(
1096 g.object.object_type,
1097 ObjectType::Table
1098 | ObjectType::View
1099 | ObjectType::MaterializedView
1100 | ObjectType::Sequence
1101 | ObjectType::Function
1102 | ObjectType::Type
1103 ) && g.object.name.as_deref() == Some("*");
1104 if !is_wildcard {
1105 out.push(g);
1106 continue;
1107 }
1108 let Some(schema) = g.object.schema.as_ref() else {
1109 out.push(g);
1110 continue;
1111 };
1112 let key = (schema.clone(), g.object.object_type);
1113 if let Some(names) = inventory.get(&key) {
1114 for name in names {
1115 out.push(Grant {
1116 role: g.role.clone(),
1117 privileges: g.privileges.clone(),
1118 object: ObjectTarget {
1119 object_type: g.object.object_type,
1120 schema: g.object.schema.clone(),
1121 name: Some(name.clone()),
1122 },
1123 });
1124 }
1125 } else {
1126 out.push(g);
1129 }
1130 }
1131 *grants = out;
1132}
1133
1134#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use crate::manifest::parse_manifest;
1142
1143 fn parse(yaml: &str) -> PolicyManifest {
1144 parse_manifest(yaml).expect("parse")
1145 }
1146
1147 #[test]
1148 fn no_input_profiles_no_clusters_returns_unchanged() {
1149 let m = parse(
1150 r#"
1151roles:
1152 - name: alice
1153 login: true
1154"#,
1155 );
1156 let report = suggest_profiles(&m, &SuggestOptions::default());
1157 assert!(report.profiles.is_empty());
1158 assert!(report.round_trip_ok);
1159 }
1160
1161 #[test]
1162 fn input_with_existing_profiles_is_left_alone() {
1163 let m = parse(
1164 r#"
1165profiles:
1166 reader:
1167 grants:
1168 - privileges: [USAGE]
1169 object: { type: schema }
1170schemas:
1171 - name: x
1172 profiles: [reader]
1173"#,
1174 );
1175 let report = suggest_profiles(&m, &SuggestOptions::default());
1176 assert!(report.profiles.is_empty());
1177 assert_eq!(report.manifest.profiles.len(), 1);
1178 }
1179
1180 #[test]
1181 fn clusters_two_schemas_with_dash_pattern() {
1182 let m = parse(
1184 r#"
1185default_owner: app_owner
1186schemas:
1187 - name: inventory
1188 owner: app_owner
1189 - name: checkout
1190 owner: app_owner
1191 - name: analytics
1192 owner: app_owner
1193
1194roles:
1195 - name: inventory-reader
1196 - name: checkout-reader
1197 - name: analytics-reader
1198
1199grants:
1200 - role: inventory-reader
1201 privileges: [USAGE]
1202 object: { type: schema, name: inventory }
1203 - role: inventory-reader
1204 privileges: [SELECT]
1205 object: { type: table, schema: inventory, name: "*" }
1206 - role: checkout-reader
1207 privileges: [USAGE]
1208 object: { type: schema, name: checkout }
1209 - role: checkout-reader
1210 privileges: [SELECT]
1211 object: { type: table, schema: checkout, name: "*" }
1212 - role: analytics-reader
1213 privileges: [USAGE]
1214 object: { type: schema, name: analytics }
1215 - role: analytics-reader
1216 privileges: [SELECT]
1217 object: { type: table, schema: analytics, name: "*" }
1218"#,
1219 );
1220 let report = suggest_profiles(&m, &SuggestOptions::default());
1221 assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
1222 assert_eq!(report.profiles.len(), 1);
1223 let p = &report.profiles[0];
1224 assert_eq!(p.profile_name, "reader");
1225 assert_eq!(p.role_pattern, "{schema}-{profile}");
1226 assert_eq!(p.schema_to_role.len(), 3);
1227 assert!(report.manifest.profiles.contains_key("reader"));
1228 assert!(
1230 report
1231 .manifest
1232 .roles
1233 .iter()
1234 .all(|r| !r.name.ends_with("-reader"))
1235 );
1236 for s in &report.manifest.schemas {
1238 assert_eq!(s.profiles, vec!["reader"]);
1239 assert_eq!(s.role_pattern, "{schema}-{profile}");
1240 }
1241 }
1242
1243 #[test]
1244 fn clusters_with_underscore_pattern() {
1245 let m = parse(
1246 r#"
1247default_owner: app_owner
1248schemas:
1249 - name: inventory
1250 owner: app_owner
1251 - name: checkout
1252 owner: app_owner
1253roles:
1254 - name: inventory_app
1255 login: true
1256 - name: checkout_app
1257 login: true
1258grants:
1259 - role: inventory_app
1260 privileges: [USAGE]
1261 object: { type: schema, name: inventory }
1262 - role: inventory_app
1263 privileges: [SELECT, INSERT, UPDATE, DELETE]
1264 object: { type: table, schema: inventory, name: "*" }
1265 - role: checkout_app
1266 privileges: [USAGE]
1267 object: { type: schema, name: checkout }
1268 - role: checkout_app
1269 privileges: [SELECT, INSERT, UPDATE, DELETE]
1270 object: { type: table, schema: checkout, name: "*" }
1271"#,
1272 );
1273 let report = suggest_profiles(&m, &SuggestOptions::default());
1274 assert!(report.round_trip_ok);
1275 assert_eq!(report.profiles.len(), 1);
1276 let p = &report.profiles[0];
1277 assert_eq!(p.profile_name, "app");
1278 assert_eq!(p.role_pattern, "{schema}_{profile}");
1279 let prof = report.manifest.profiles.get("app").unwrap();
1281 assert_eq!(prof.login, Some(true));
1282 }
1283
1284 #[test]
1285 fn does_not_cluster_single_schema_role() {
1286 let m = parse(
1287 r#"
1288schemas:
1289 - name: inventory
1290 owner: app_owner
1291roles:
1292 - name: inventory-reader
1293grants:
1294 - role: inventory-reader
1295 privileges: [SELECT]
1296 object: { type: table, schema: inventory, name: "*" }
1297"#,
1298 );
1299 let report = suggest_profiles(&m, &SuggestOptions::default());
1300 assert!(report.profiles.is_empty());
1301 assert!(matches!(
1302 report.skipped.first(),
1303 Some(SkipReason::SoleSchema { .. })
1304 ));
1305 }
1306
1307 #[test]
1308 fn min_schemas_one_promotes_single_schema_role() {
1309 let m = parse(
1310 r#"
1311schemas:
1312 - name: inventory
1313 owner: app_owner
1314roles:
1315 - name: inventory-reader
1316grants:
1317 - role: inventory-reader
1318 privileges: [SELECT]
1319 object: { type: table, schema: inventory, name: "*" }
1320"#,
1321 );
1322 let report = suggest_profiles(
1323 &m,
1324 &SuggestOptions {
1325 min_schemas: 1,
1326 ..Default::default()
1327 },
1328 );
1329 assert!(report.round_trip_ok);
1330 assert_eq!(report.profiles.len(), 1);
1331 }
1332
1333 #[test]
1334 fn role_with_unique_attributes_stays_flat() {
1335 let m = parse(
1336 r#"
1337schemas:
1338 - name: inventory
1339 owner: app_owner
1340 - name: checkout
1341 owner: app_owner
1342roles:
1343 - name: inventory-reader
1344 connection_limit: 5
1345 - name: checkout-reader
1346grants:
1347 - role: inventory-reader
1348 privileges: [SELECT]
1349 object: { type: table, schema: inventory, name: "*" }
1350 - role: checkout-reader
1351 privileges: [SELECT]
1352 object: { type: table, schema: checkout, name: "*" }
1353"#,
1354 );
1355 let report = suggest_profiles(&m, &SuggestOptions::default());
1356 assert!(report.profiles.is_empty());
1358 assert!(report.skipped.iter().any(
1359 |s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
1360 ));
1361 }
1362
1363 #[test]
1364 fn multi_schema_role_skipped() {
1365 let m = parse(
1366 r#"
1367schemas:
1368 - name: inventory
1369 owner: app_owner
1370 - name: checkout
1371 owner: app_owner
1372roles:
1373 - name: cross
1374grants:
1375 - role: cross
1376 privileges: [SELECT]
1377 object: { type: table, schema: inventory, name: "*" }
1378 - role: cross
1379 privileges: [SELECT]
1380 object: { type: table, schema: checkout, name: "*" }
1381"#,
1382 );
1383 let report = suggest_profiles(&m, &SuggestOptions::default());
1384 assert!(report.profiles.is_empty());
1385 assert!(
1386 report
1387 .skipped
1388 .iter()
1389 .any(|s| matches!(s, SkipReason::MultiSchema { role, .. } if role == "cross"))
1390 );
1391 }
1392
1393 #[test]
1394 fn non_uniform_pattern_skipped() {
1395 let m = parse(
1396 r#"
1397schemas:
1398 - name: inventory
1399 owner: app_owner
1400 - name: checkout
1401 owner: app_owner
1402roles:
1403 - name: inventory-reader
1404 - name: checkout_reader
1405grants:
1406 - role: inventory-reader
1407 privileges: [SELECT]
1408 object: { type: table, schema: inventory, name: "*" }
1409 - role: checkout_reader
1410 privileges: [SELECT]
1411 object: { type: table, schema: checkout, name: "*" }
1412"#,
1413 );
1414 let report = suggest_profiles(&m, &SuggestOptions::default());
1420 assert!(report.profiles.is_empty());
1421 assert!(
1422 report
1423 .skipped
1424 .iter()
1425 .any(|s| matches!(s, SkipReason::NoUniformPattern { .. }))
1426 );
1427 }
1428
1429 #[test]
1430 fn different_login_split_into_separate_clusters() {
1431 let m = parse(
1432 r#"
1433schemas:
1434 - name: a
1435 owner: o
1436 - name: b
1437 owner: o
1438 - name: c
1439 owner: o
1440 - name: d
1441 owner: o
1442roles:
1443 - name: a-svc
1444 login: true
1445 - name: b-svc
1446 login: true
1447 - name: c-svc
1448 - name: d-svc
1449grants:
1450 - role: a-svc
1451 privileges: [SELECT]
1452 object: { type: table, schema: a, name: "*" }
1453 - role: b-svc
1454 privileges: [SELECT]
1455 object: { type: table, schema: b, name: "*" }
1456 - role: c-svc
1457 privileges: [SELECT]
1458 object: { type: table, schema: c, name: "*" }
1459 - role: d-svc
1460 privileges: [SELECT]
1461 object: { type: table, schema: d, name: "*" }
1462"#,
1463 );
1464 let report = suggest_profiles(&m, &SuggestOptions::default());
1465 assert!(report.round_trip_ok);
1466 assert_eq!(report.profiles.len(), 1);
1470 assert_eq!(report.profiles[0].profile_name, "svc");
1471 let kept_role_names: BTreeSet<&str> = report
1473 .manifest
1474 .roles
1475 .iter()
1476 .map(|r| r.name.as_str())
1477 .collect();
1478 assert_eq!(kept_role_names.len(), 2);
1479 }
1480
1481 #[test]
1482 fn round_trip_zero_diff() {
1483 let m = parse(
1485 r#"
1486default_owner: app_owner
1487schemas:
1488 - name: inventory
1489 owner: app_owner
1490 - name: checkout
1491 owner: app_owner
1492
1493roles:
1494 - name: inventory-rw
1495 - name: checkout-rw
1496
1497grants:
1498 - role: inventory-rw
1499 privileges: [USAGE]
1500 object: { type: schema, name: inventory }
1501 - role: inventory-rw
1502 privileges: [SELECT, INSERT, UPDATE, DELETE]
1503 object: { type: table, schema: inventory, name: "*" }
1504 - role: inventory-rw
1505 privileges: [USAGE, SELECT]
1506 object: { type: sequence, schema: inventory, name: "*" }
1507 - role: checkout-rw
1508 privileges: [USAGE]
1509 object: { type: schema, name: checkout }
1510 - role: checkout-rw
1511 privileges: [SELECT, INSERT, UPDATE, DELETE]
1512 object: { type: table, schema: checkout, name: "*" }
1513 - role: checkout-rw
1514 privileges: [USAGE, SELECT]
1515 object: { type: sequence, schema: checkout, name: "*" }
1516
1517default_privileges:
1518 - owner: app_owner
1519 schema: inventory
1520 grant:
1521 - role: inventory-rw
1522 privileges: [SELECT, INSERT, UPDATE, DELETE]
1523 on_type: table
1524 - owner: app_owner
1525 schema: checkout
1526 grant:
1527 - role: checkout-rw
1528 privileges: [SELECT, INSERT, UPDATE, DELETE]
1529 on_type: table
1530"#,
1531 );
1532
1533 let report = suggest_profiles(&m, &SuggestOptions::default());
1534 assert!(report.round_trip_ok);
1535 assert_eq!(report.profiles.len(), 1);
1536 let prof = report.manifest.profiles.get("rw").unwrap();
1537 assert_eq!(prof.grants.len(), 3);
1538 assert_eq!(prof.default_privileges.len(), 1);
1539
1540 let original_expanded = expand_manifest(&m).unwrap();
1542 let original_graph =
1543 RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
1544 let new_expanded = expand_manifest(&report.manifest).unwrap();
1545 let new_graph =
1546 RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
1547 .unwrap();
1548 let changes = diff(&original_graph, &new_graph);
1549 let bad: Vec<_> = changes
1552 .iter()
1553 .filter(|c| !matches!(c, Change::SetComment { .. }))
1554 .collect();
1555 assert!(bad.is_empty(), "unexpected diff: {bad:?}");
1556 }
1557
1558 #[test]
1559 fn schema_pattern_conflict_drops_smaller_cluster() {
1560 let m = parse(
1570 r#"
1571default_owner: o
1572schemas:
1573 - name: inventory
1574 owner: o
1575 - name: checkout
1576 owner: o
1577
1578roles:
1579 - name: inventory-reader
1580 - name: checkout-reader
1581 - name: inventory_writer
1582 - name: checkout_writer
1583
1584grants:
1585 - role: inventory-reader
1586 privileges: [SELECT]
1587 object: { type: table, schema: inventory, name: "*" }
1588 - role: checkout-reader
1589 privileges: [SELECT]
1590 object: { type: table, schema: checkout, name: "*" }
1591 - role: inventory_writer
1592 privileges: [INSERT]
1593 object: { type: table, schema: inventory, name: "*" }
1594 - role: checkout_writer
1595 privileges: [INSERT]
1596 object: { type: table, schema: checkout, name: "*" }
1597"#,
1598 );
1599
1600 let report = suggest_profiles(&m, &SuggestOptions::default());
1601 assert!(report.round_trip_ok);
1606 assert_eq!(
1607 report.profiles.len(),
1608 1,
1609 "exactly one profile should win: {:?}",
1610 report.profiles
1611 );
1612 let conflicts: Vec<_> = report
1615 .skipped
1616 .iter()
1617 .filter_map(|s| match s {
1618 SkipReason::SchemaPatternConflict {
1619 schema,
1620 winning_pattern,
1621 dropped_roles,
1622 } => Some((schema, winning_pattern, dropped_roles)),
1623 _ => None,
1624 })
1625 .collect();
1626 assert_eq!(
1627 conflicts.len(),
1628 1,
1629 "expected one SchemaPatternConflict skip, got: {:?}",
1630 report.skipped
1631 );
1632 let (_, winning, dropped) = conflicts[0];
1633 assert!(
1636 winning == "{schema}-{profile}" || winning == "{schema}_{profile}",
1637 "unexpected winning_pattern: {winning}"
1638 );
1639 assert_eq!(dropped.len(), 2);
1640 }
1641
1642 #[test]
1643 fn match_pattern_basic() {
1644 assert_eq!(
1645 match_pattern("{schema}-{profile}", "inventory-reader", "inventory"),
1646 Some("reader".into())
1647 );
1648 assert_eq!(
1649 match_pattern("{schema}_{profile}", "inventory_app", "inventory"),
1650 Some("app".into())
1651 );
1652 assert_eq!(
1653 match_pattern("{profile}-{schema}", "ro-inventory", "inventory"),
1654 Some("ro".into())
1655 );
1656 assert_eq!(
1657 match_pattern("{profile}_{schema}", "ro_inventory", "inventory"),
1658 Some("ro".into())
1659 );
1660 assert_eq!(
1662 match_pattern("{schema}-{profile}", "checkout-reader", "inventory"),
1663 None
1664 );
1665 assert_eq!(
1667 match_pattern("{schema}-{profile}", "inventory-", "inventory"),
1668 None
1669 );
1670 assert_eq!(
1672 match_pattern("{schema}-{profile}", "inventoryreader", "inventory"),
1673 None
1674 );
1675 }
1676
1677 #[test]
1678 fn database_grants_excluded_from_clustering() {
1679 let m = parse(
1683 r#"
1684schemas:
1685 - name: a
1686 owner: o
1687 - name: b
1688 owner: o
1689roles:
1690 - name: a-svc
1691 - name: b-svc
1692grants:
1693 - role: a-svc
1694 privileges: [CONNECT]
1695 object: { type: database, name: mydb }
1696 - role: a-svc
1697 privileges: [SELECT]
1698 object: { type: table, schema: a, name: "*" }
1699 - role: b-svc
1700 privileges: [SELECT]
1701 object: { type: table, schema: b, name: "*" }
1702"#,
1703 );
1704 let report = suggest_profiles(&m, &SuggestOptions::default());
1705 assert!(report.profiles.is_empty());
1707 assert!(
1708 report
1709 .skipped
1710 .iter()
1711 .any(|s| matches!(s, SkipReason::UnrepresentableGrant { role } if role == "a-svc"))
1712 );
1713 }
1714
1715 #[test]
1716 fn membership_targets_clustered_role_still_resolve_after_suggestion() {
1717 let m = parse(
1720 r#"
1721schemas:
1722 - name: inventory
1723 owner: o
1724 - name: checkout
1725 owner: o
1726roles:
1727 - name: inventory-reader
1728 - name: checkout-reader
1729 - name: alice
1730 login: true
1731grants:
1732 - role: inventory-reader
1733 privileges: [SELECT]
1734 object: { type: table, schema: inventory, name: "*" }
1735 - role: checkout-reader
1736 privileges: [SELECT]
1737 object: { type: table, schema: checkout, name: "*" }
1738memberships:
1739 - role: inventory-reader
1740 members:
1741 - name: alice
1742"#,
1743 );
1744 let report = suggest_profiles(&m, &SuggestOptions::default());
1745 assert!(report.round_trip_ok);
1746 assert_eq!(report.manifest.memberships.len(), 1);
1748 assert_eq!(report.manifest.memberships[0].role, "inventory-reader");
1749 let expanded = expand_manifest(&report.manifest).unwrap();
1751 assert!(expanded.roles.iter().any(|r| r.name == "inventory-reader"));
1752 assert!(expanded.roles.iter().any(|r| r.name == "checkout-reader"));
1753 }
1754
1755 #[test]
1756 fn wildcard_object_names_preserved_in_profile() {
1757 let m = parse(
1758 r#"
1759schemas:
1760 - name: a
1761 owner: o
1762 - name: b
1763 owner: o
1764roles:
1765 - name: a-rw
1766 - name: b-rw
1767grants:
1768 - role: a-rw
1769 privileges: [SELECT, INSERT]
1770 object: { type: table, schema: a, name: "*" }
1771 - role: a-rw
1772 privileges: [USAGE]
1773 object: { type: sequence, schema: a, name: orders_id_seq }
1774 - role: b-rw
1775 privileges: [SELECT, INSERT]
1776 object: { type: table, schema: b, name: "*" }
1777 - role: b-rw
1778 privileges: [USAGE]
1779 object: { type: sequence, schema: b, name: orders_id_seq }
1780"#,
1781 );
1782
1783 let report = suggest_profiles(&m, &SuggestOptions::default());
1786 assert!(report.round_trip_ok);
1787 assert_eq!(report.profiles.len(), 1);
1788 let prof = report.manifest.profiles.get("rw").unwrap();
1789 let seq_grant = prof
1790 .grants
1791 .iter()
1792 .find(|g| g.object.object_type == ObjectType::Sequence)
1793 .unwrap();
1794 assert_eq!(seq_grant.object.name.as_deref(), Some("orders_id_seq"));
1795
1796 let inv = inventory_from_manifest_grants(&m);
1799 let report = suggest_profiles(
1800 &m,
1801 &SuggestOptions {
1802 full_inventory: Some(inv),
1803 ..Default::default()
1804 },
1805 );
1806 assert!(report.round_trip_ok);
1807 let prof = report.manifest.profiles.get("rw").unwrap();
1808 let seq_grant = prof
1809 .grants
1810 .iter()
1811 .find(|g| g.object.object_type == ObjectType::Sequence)
1812 .unwrap();
1813 assert_eq!(
1814 seq_grant.object.name.as_deref(),
1815 Some("*"),
1816 "single-object full coverage should collapse to wildcard"
1817 );
1818 }
1819
1820 #[test]
1821 fn collapse_clusters_roles_with_different_object_names() {
1822 let m = parse(
1827 r#"
1828schemas:
1829 - name: inventory
1830 owner: o
1831 - name: checkout
1832 owner: o
1833roles:
1834 - name: inventory-reader
1835 - name: checkout-reader
1836grants:
1837 - role: inventory-reader
1838 privileges: [USAGE]
1839 object: { type: schema, name: inventory }
1840 - role: inventory-reader
1841 privileges: [SELECT]
1842 object: { type: table, schema: inventory, name: products }
1843 - role: inventory-reader
1844 privileges: [SELECT]
1845 object: { type: table, schema: inventory, name: stock_levels }
1846 - role: checkout-reader
1847 privileges: [USAGE]
1848 object: { type: schema, name: checkout }
1849 - role: checkout-reader
1850 privileges: [SELECT]
1851 object: { type: table, schema: checkout, name: orders }
1852 - role: checkout-reader
1853 privileges: [SELECT]
1854 object: { type: table, schema: checkout, name: order_items }
1855"#,
1856 );
1857 let inv = inventory_from_manifest_grants(&m);
1860 let report = suggest_profiles(
1861 &m,
1862 &SuggestOptions {
1863 full_inventory: Some(inv),
1864 ..Default::default()
1865 },
1866 );
1867 assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
1868 assert_eq!(report.profiles.len(), 1);
1869 let prof = report.manifest.profiles.get("reader").unwrap();
1870 let table_grant = prof
1872 .grants
1873 .iter()
1874 .find(|g| g.object.object_type == ObjectType::Table)
1875 .unwrap();
1876 assert_eq!(table_grant.object.name.as_deref(), Some("*"));
1877 }
1878
1879 #[test]
1880 fn no_full_inventory_prevents_clustering_across_different_names() {
1881 let m = parse(
1885 r#"
1886schemas:
1887 - name: inventory
1888 owner: o
1889 - name: checkout
1890 owner: o
1891roles:
1892 - name: inventory-reader
1893 - name: checkout-reader
1894grants:
1895 - role: inventory-reader
1896 privileges: [SELECT]
1897 object: { type: table, schema: inventory, name: products }
1898 - role: checkout-reader
1899 privileges: [SELECT]
1900 object: { type: table, schema: checkout, name: orders }
1901"#,
1902 );
1903 let report = suggest_profiles(&m, &SuggestOptions::default());
1906 assert!(report.profiles.is_empty());
1907 }
1908
1909 #[test]
1910 fn collapse_partial_coverage_preserves_per_name_grants() {
1911 let m = parse(
1915 r#"
1916schemas:
1917 - name: a
1918 owner: o
1919 - name: b
1920 owner: o
1921roles:
1922 - name: a-ro
1923 - name: b-ro
1924grants:
1925 - role: a-ro
1926 privileges: [SELECT]
1927 object: { type: table, schema: a, name: t1 }
1928 # a-ro has no grant on a.t2 (which exists, evidenced by another role)
1929 - role: filler
1930 privileges: [SELECT]
1931 object: { type: table, schema: a, name: t2 }
1932 - role: b-ro
1933 privileges: [SELECT]
1934 object: { type: table, schema: b, name: only_one }
1935"#,
1936 );
1937 let inv = inventory_from_manifest_grants(&m);
1942 let report = suggest_profiles(
1943 &m,
1944 &SuggestOptions {
1945 full_inventory: Some(inv),
1946 ..Default::default()
1947 },
1948 );
1949 assert!(report.profiles.is_empty());
1950 }
1951
1952 #[test]
1953 fn incomplete_full_inventory_disables_collapse_with_skip_reason() {
1954 let m = parse(
1960 r#"
1961schemas:
1962 - name: a
1963 owner: o
1964 - name: b
1965 owner: o
1966roles:
1967 - name: a-rw
1968 - name: b-rw
1969grants:
1970 - role: a-rw
1971 privileges: [SELECT]
1972 object: { type: table, schema: a, name: products }
1973 - role: b-rw
1974 privileges: [SELECT]
1975 object: { type: table, schema: b, name: orders }
1976"#,
1977 );
1978 let mut bad: Inventory = BTreeMap::new();
1981 bad.entry(("a".to_string(), ObjectType::Table)).or_default(); bad.entry(("b".to_string(), ObjectType::Table))
1983 .or_default()
1984 .insert("orders".to_string());
1985 let report = suggest_profiles(
1986 &m,
1987 &SuggestOptions {
1988 full_inventory: Some(bad),
1989 ..Default::default()
1990 },
1991 );
1992 assert!(report.profiles.is_empty());
1995 assert!(
1996 report
1997 .skipped
1998 .iter()
1999 .any(|s| matches!(s, SkipReason::IncompleteFullInventory { .. })),
2000 "expected IncompleteFullInventory skip; got: {:?}",
2001 report.skipped
2002 );
2003 }
2004
2005 #[test]
2006 fn full_inventory_with_ungranted_objects_blocks_unsafe_collapse() {
2007 let m = parse(
2014 r#"
2015schemas:
2016 - name: a
2017 owner: o
2018 - name: b
2019 owner: o
2020roles:
2021 - name: a-ro
2022 - name: b-ro
2023grants:
2024 - role: a-ro
2025 privileges: [SELECT]
2026 object: { type: table, schema: a, name: t1 }
2027 - role: b-ro
2028 privileges: [SELECT]
2029 object: { type: table, schema: b, name: only_one }
2030"#,
2031 );
2032 let mut inv = inventory_from_manifest_grants(&m);
2034 inv.entry(("a".to_string(), ObjectType::Table))
2035 .or_default()
2036 .insert("t2_ungranted".to_string());
2037 let report = suggest_profiles(
2038 &m,
2039 &SuggestOptions {
2040 full_inventory: Some(inv),
2041 ..Default::default()
2042 },
2043 );
2044 assert!(report.profiles.is_empty());
2046 }
2047
2048 #[test]
2049 fn auto_generated_profile_comments_dont_block_resuggestion() {
2050 let m = parse(
2055 r#"
2056schemas:
2057 - name: inventory
2058 owner: o
2059 - name: checkout
2060 owner: o
2061roles:
2062 - name: inventory-reader
2063 comment: "Generated from profile 'reader' for schema 'inventory'"
2064 - name: checkout-reader
2065 comment: "Generated from profile 'reader' for schema 'checkout'"
2066grants:
2067 - role: inventory-reader
2068 privileges: [SELECT]
2069 object: { type: table, schema: inventory, name: "*" }
2070 - role: checkout-reader
2071 privileges: [SELECT]
2072 object: { type: table, schema: checkout, name: "*" }
2073"#,
2074 );
2075 let report = suggest_profiles(&m, &SuggestOptions::default());
2076 assert!(report.round_trip_ok);
2077 assert_eq!(report.profiles.len(), 1);
2078 assert_eq!(report.profiles[0].profile_name, "reader");
2079 }
2080
2081 #[test]
2082 fn user_set_comments_still_block_clustering() {
2083 let m = parse(
2086 r#"
2087schemas:
2088 - name: inventory
2089 owner: o
2090 - name: checkout
2091 owner: o
2092roles:
2093 - name: inventory-reader
2094 comment: "Owned by data team — Q3 access only"
2095 - name: checkout-reader
2096grants:
2097 - role: inventory-reader
2098 privileges: [SELECT]
2099 object: { type: table, schema: inventory, name: "*" }
2100 - role: checkout-reader
2101 privileges: [SELECT]
2102 object: { type: table, schema: checkout, name: "*" }
2103"#,
2104 );
2105 let report = suggest_profiles(&m, &SuggestOptions::default());
2106 assert!(report.profiles.is_empty());
2109 assert!(report.skipped.iter().any(
2110 |s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
2111 ));
2112 }
2113
2114 #[test]
2115 fn is_auto_profile_comment_basic() {
2116 assert!(is_auto_profile_comment(
2117 "Generated from profile 'reader' for schema 'inventory'"
2118 ));
2119 assert!(is_auto_profile_comment(
2120 "Generated from profile 'app-rw' for schema 'app_v2'"
2121 ));
2122 assert!(!is_auto_profile_comment("Random user note"));
2123 assert!(!is_auto_profile_comment(
2124 "Generated from profile 'reader' for schema 'inventory"
2125 )); assert!(!is_auto_profile_comment("Generated from profile 'reader'")); }
2128
2129 #[test]
2130 fn function_grants_with_signature_in_name_round_trip() {
2131 let m = parse(
2135 r#"
2136schemas:
2137 - name: a
2138 owner: o
2139 - name: b
2140 owner: o
2141roles:
2142 - name: a-rw
2143 - name: b-rw
2144grants:
2145 - role: a-rw
2146 privileges: [EXECUTE]
2147 object: { type: function, schema: a, name: "order_total(bigint)" }
2148 - role: b-rw
2149 privileges: [EXECUTE]
2150 object: { type: function, schema: b, name: "order_total(bigint)" }
2151"#,
2152 );
2153 let report = suggest_profiles(&m, &SuggestOptions::default());
2154 assert!(report.round_trip_ok);
2155 assert_eq!(report.profiles.len(), 1);
2156 }
2157
2158 #[test]
2159 fn default_privilege_owner_mismatch_excludes_role() {
2160 let m = parse(
2161 r#"
2162schemas:
2163 - name: a
2164 owner: app_owner
2165 - name: b
2166 owner: app_owner
2167roles:
2168 - name: a-rw
2169 - name: b-rw
2170grants:
2171 - role: a-rw
2172 privileges: [SELECT]
2173 object: { type: table, schema: a, name: "*" }
2174 - role: b-rw
2175 privileges: [SELECT]
2176 object: { type: table, schema: b, name: "*" }
2177default_privileges:
2178 - owner: a_different_owner # mismatch — schema "a" is owned by app_owner
2179 schema: a
2180 grant:
2181 - role: a-rw
2182 privileges: [SELECT]
2183 on_type: table
2184 - owner: app_owner
2185 schema: b
2186 grant:
2187 - role: b-rw
2188 privileges: [SELECT]
2189 on_type: table
2190"#,
2191 );
2192 let report = suggest_profiles(&m, &SuggestOptions::default());
2193 assert!(report.profiles.is_empty());
2195 assert!(
2196 report
2197 .skipped
2198 .iter()
2199 .any(|s| matches!(s, SkipReason::OwnerMismatch { role, .. } if role == "a-rw"))
2200 );
2201 }
2202
2203 #[test]
2204 fn role_with_zero_grants_is_left_flat() {
2205 let m = parse(
2206 r#"
2207schemas:
2208 - name: a
2209 owner: o
2210roles:
2211 - name: lonely
2212 login: true
2213"#,
2214 );
2215 let report = suggest_profiles(&m, &SuggestOptions::default());
2216 assert!(report.profiles.is_empty());
2217 assert!(report.round_trip_ok);
2218 assert!(report.manifest.roles.iter().any(|r| r.name == "lonely"));
2219 }
2220
2221 #[test]
2222 fn schema_typed_grant_pointing_to_unrelated_schema_excludes_role() {
2223 let m = parse(
2226 r#"
2227schemas:
2228 - name: a
2229 owner: o
2230 - name: b
2231 owner: o
2232roles:
2233 - name: a-rw
2234 - name: b-rw
2235grants:
2236 - role: a-rw
2237 privileges: [USAGE]
2238 object: { type: schema, name: a }
2239 - role: a-rw
2240 privileges: [USAGE]
2241 object: { type: schema, name: b } # surprise: also touches b
2242 - role: b-rw
2243 privileges: [USAGE]
2244 object: { type: schema, name: b }
2245"#,
2246 );
2247 let report = suggest_profiles(&m, &SuggestOptions::default());
2248 assert!(report.profiles.is_empty());
2249 assert!(
2250 report
2251 .skipped
2252 .iter()
2253 .any(|s| matches!(s, SkipReason::MultiSchema { role, .. } if role == "a-rw"))
2254 );
2255 }
2256
2257 #[test]
2258 fn determinism_same_input_same_output() {
2259 let yaml = r#"
2261default_owner: app_owner
2262schemas:
2263 - name: inventory
2264 owner: app_owner
2265 - name: checkout
2266 owner: app_owner
2267 - name: analytics
2268 owner: app_owner
2269roles:
2270 - name: inventory-reader
2271 - name: checkout-reader
2272 - name: analytics-reader
2273 - name: inventory-rw
2274 - name: checkout-rw
2275 - name: analytics-rw
2276grants:
2277 - role: inventory-reader
2278 privileges: [SELECT]
2279 object: { type: table, schema: inventory, name: "*" }
2280 - role: checkout-reader
2281 privileges: [SELECT]
2282 object: { type: table, schema: checkout, name: "*" }
2283 - role: analytics-reader
2284 privileges: [SELECT]
2285 object: { type: table, schema: analytics, name: "*" }
2286 - role: inventory-rw
2287 privileges: [SELECT, INSERT]
2288 object: { type: table, schema: inventory, name: "*" }
2289 - role: checkout-rw
2290 privileges: [SELECT, INSERT]
2291 object: { type: table, schema: checkout, name: "*" }
2292 - role: analytics-rw
2293 privileges: [SELECT, INSERT]
2294 object: { type: table, schema: analytics, name: "*" }
2295"#;
2296 let m1 = parse(yaml);
2297 let m2 = parse(yaml);
2298 let r1 = suggest_profiles(&m1, &SuggestOptions::default());
2299 let r2 = suggest_profiles(&m2, &SuggestOptions::default());
2300
2301 assert_eq!(r1.profiles.len(), 2);
2304 assert_eq!(r2.profiles.len(), 2);
2305 assert_eq!(
2306 serde_yaml::to_string(&r1.manifest).unwrap(),
2307 serde_yaml::to_string(&r2.manifest).unwrap()
2308 );
2309 }
2310
2311 #[test]
2312 fn realistic_scenario_full_round_trip() {
2313 let yaml = r#"
2316default_owner: app_owner
2317schemas:
2318 - name: inventory
2319 owner: app_owner
2320 - name: checkout
2321 owner: app_owner
2322 - name: analytics
2323 owner: analytics_owner
2324roles:
2325 - name: app_owner
2326 - name: analytics_owner
2327 - name: inventory-editor
2328 - name: checkout-editor
2329 - name: inventory-viewer
2330 - name: checkout-viewer
2331 - name: analytics-viewer
2332 - name: data_analyst
2333
2334grants:
2335 - role: inventory-editor
2336 privileges: [USAGE]
2337 object: { type: schema, name: inventory }
2338 - role: inventory-editor
2339 privileges: [SELECT, INSERT, UPDATE, DELETE]
2340 object: { type: table, schema: inventory, name: "*" }
2341 - role: inventory-editor
2342 privileges: [USAGE, SELECT]
2343 object: { type: sequence, schema: inventory, name: "*" }
2344
2345 - role: checkout-editor
2346 privileges: [USAGE]
2347 object: { type: schema, name: checkout }
2348 - role: checkout-editor
2349 privileges: [SELECT, INSERT, UPDATE, DELETE]
2350 object: { type: table, schema: checkout, name: "*" }
2351 - role: checkout-editor
2352 privileges: [USAGE, SELECT]
2353 object: { type: sequence, schema: checkout, name: "*" }
2354
2355 - role: inventory-viewer
2356 privileges: [USAGE]
2357 object: { type: schema, name: inventory }
2358 - role: inventory-viewer
2359 privileges: [SELECT]
2360 object: { type: table, schema: inventory, name: "*" }
2361
2362 - role: checkout-viewer
2363 privileges: [USAGE]
2364 object: { type: schema, name: checkout }
2365 - role: checkout-viewer
2366 privileges: [SELECT]
2367 object: { type: table, schema: checkout, name: "*" }
2368
2369 - role: analytics-viewer
2370 privileges: [USAGE]
2371 object: { type: schema, name: analytics }
2372 - role: analytics-viewer
2373 privileges: [SELECT]
2374 object: { type: table, schema: analytics, name: "*" }
2375
2376default_privileges:
2377 - owner: app_owner
2378 schema: inventory
2379 grant:
2380 - role: inventory-editor
2381 privileges: [SELECT, INSERT, UPDATE, DELETE]
2382 on_type: table
2383 - owner: app_owner
2384 schema: checkout
2385 grant:
2386 - role: checkout-editor
2387 privileges: [SELECT, INSERT, UPDATE, DELETE]
2388 on_type: table
2389
2390memberships:
2391 - role: inventory-editor
2392 members:
2393 - name: data_analyst
2394 - role: analytics-viewer
2395 members:
2396 - name: data_analyst
2397"#;
2398 let m = parse(yaml);
2399 let report = suggest_profiles(&m, &SuggestOptions::default());
2400 assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
2401
2402 let names: BTreeSet<String> = report
2405 .profiles
2406 .iter()
2407 .map(|p| p.profile_name.clone())
2408 .collect();
2409 assert!(names.contains("editor"), "got: {names:?}");
2410 assert!(names.contains("viewer"), "got: {names:?}");
2411
2412 assert_eq!(report.manifest.memberships.len(), 2);
2414
2415 let expanded = expand_manifest(&report.manifest).unwrap();
2417 let role_names: BTreeSet<String> = expanded.roles.iter().map(|r| r.name.clone()).collect();
2418 for orig in [
2419 "inventory-editor",
2420 "checkout-editor",
2421 "inventory-viewer",
2422 "checkout-viewer",
2423 "analytics-viewer",
2424 "data_analyst",
2425 "app_owner",
2426 "analytics_owner",
2427 ] {
2428 assert!(
2429 role_names.contains(orig),
2430 "missing role {orig} in re-expanded manifest"
2431 );
2432 }
2433
2434 let viewer = report
2436 .profiles
2437 .iter()
2438 .find(|p| p.profile_name == "viewer")
2439 .unwrap();
2440 assert_eq!(viewer.schema_to_role.len(), 3);
2441 let editor = report
2442 .profiles
2443 .iter()
2444 .find(|p| p.profile_name == "editor")
2445 .unwrap();
2446 assert_eq!(editor.schema_to_role.len(), 2);
2447 }
2448
2449 #[test]
2450 fn round_trip_diff_engine_finds_no_structural_changes() {
2451 let yaml = r#"
2455default_owner: o
2456schemas:
2457 - name: s1
2458 owner: o
2459 - name: s2
2460 owner: o
2461 - name: s3
2462 owner: o
2463roles:
2464 - name: s1-rw
2465 - name: s2-rw
2466 - name: s3-rw
2467 - name: s1-ro
2468 - name: s2-ro
2469 - name: s3-ro
2470 - name: alice
2471 login: true
2472grants:
2473 - role: s1-rw
2474 privileges: [USAGE]
2475 object: { type: schema, name: s1 }
2476 - role: s1-rw
2477 privileges: [SELECT, INSERT, UPDATE, DELETE]
2478 object: { type: table, schema: s1, name: "*" }
2479 - role: s2-rw
2480 privileges: [USAGE]
2481 object: { type: schema, name: s2 }
2482 - role: s2-rw
2483 privileges: [SELECT, INSERT, UPDATE, DELETE]
2484 object: { type: table, schema: s2, name: "*" }
2485 - role: s3-rw
2486 privileges: [USAGE]
2487 object: { type: schema, name: s3 }
2488 - role: s3-rw
2489 privileges: [SELECT, INSERT, UPDATE, DELETE]
2490 object: { type: table, schema: s3, name: "*" }
2491 - role: s1-ro
2492 privileges: [USAGE]
2493 object: { type: schema, name: s1 }
2494 - role: s1-ro
2495 privileges: [SELECT]
2496 object: { type: table, schema: s1, name: "*" }
2497 - role: s2-ro
2498 privileges: [USAGE]
2499 object: { type: schema, name: s2 }
2500 - role: s2-ro
2501 privileges: [SELECT]
2502 object: { type: table, schema: s2, name: "*" }
2503 - role: s3-ro
2504 privileges: [USAGE]
2505 object: { type: schema, name: s3 }
2506 - role: s3-ro
2507 privileges: [SELECT]
2508 object: { type: table, schema: s3, name: "*" }
2509default_privileges:
2510 - owner: o
2511 schema: s1
2512 grant:
2513 - role: s1-rw
2514 privileges: [SELECT, INSERT, UPDATE, DELETE]
2515 on_type: table
2516 - role: s1-ro
2517 privileges: [SELECT]
2518 on_type: table
2519 - owner: o
2520 schema: s2
2521 grant:
2522 - role: s2-rw
2523 privileges: [SELECT, INSERT, UPDATE, DELETE]
2524 on_type: table
2525 - role: s2-ro
2526 privileges: [SELECT]
2527 on_type: table
2528 - owner: o
2529 schema: s3
2530 grant:
2531 - role: s3-rw
2532 privileges: [SELECT, INSERT, UPDATE, DELETE]
2533 on_type: table
2534 - role: s3-ro
2535 privileges: [SELECT]
2536 on_type: table
2537memberships:
2538 - role: s1-rw
2539 members:
2540 - name: alice
2541"#;
2542 let m = parse(yaml);
2543 let report = suggest_profiles(&m, &SuggestOptions::default());
2544 assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
2545 assert_eq!(report.profiles.len(), 2);
2546
2547 let original_expanded = expand_manifest(&m).unwrap();
2549 let original_graph =
2550 RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
2551 let new_expanded = expand_manifest(&report.manifest).unwrap();
2552 let new_graph =
2553 RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
2554 .unwrap();
2555 let changes = diff(&original_graph, &new_graph);
2556 let bad: Vec<_> = changes
2557 .iter()
2558 .filter(|c| !matches!(c, Change::SetComment { .. }))
2559 .collect();
2560 assert!(bad.is_empty(), "structural drift: {bad:?}");
2561 }
2562
2563 #[test]
2564 fn empty_manifest_is_idempotent() {
2565 let m = parse("");
2566 let report = suggest_profiles(&m, &SuggestOptions::default());
2567 assert!(report.profiles.is_empty());
2568 assert!(report.round_trip_ok);
2569 }
2570
2571 #[test]
2572 fn schema_with_special_chars_in_name() {
2573 let m = parse(
2575 r#"
2576schemas:
2577 - name: app_v2
2578 owner: o
2579 - name: app_v3
2580 owner: o
2581roles:
2582 - name: app_v2-rw
2583 - name: app_v3-rw
2584grants:
2585 - role: app_v2-rw
2586 privileges: [SELECT]
2587 object: { type: table, schema: app_v2, name: "*" }
2588 - role: app_v3-rw
2589 privileges: [SELECT]
2590 object: { type: table, schema: app_v3, name: "*" }
2591"#,
2592 );
2593 let report = suggest_profiles(&m, &SuggestOptions::default());
2594 assert!(report.round_trip_ok);
2595 assert_eq!(report.profiles.len(), 1);
2596 assert_eq!(report.profiles[0].profile_name, "rw");
2597 }
2598
2599 #[test]
2600 fn schema_name_is_substring_of_role_name() {
2601 let m = parse(
2606 r#"
2607schemas:
2608 - name: app
2609 owner: o
2610 - name: api
2611 owner: o
2612roles:
2613 - name: app-rw
2614 - name: api-rw
2615grants:
2616 - role: app-rw
2617 privileges: [SELECT]
2618 object: { type: table, schema: app, name: "*" }
2619 - role: api-rw
2620 privileges: [SELECT]
2621 object: { type: table, schema: api, name: "*" }
2622"#,
2623 );
2624 let report = suggest_profiles(&m, &SuggestOptions::default());
2625 assert!(report.round_trip_ok);
2626 assert_eq!(report.profiles.len(), 1);
2627 assert_eq!(report.profiles[0].profile_name, "rw");
2628 }
2629
2630 #[test]
2631 fn is_valid_identifier_basic() {
2632 assert!(is_valid_identifier("reader"));
2633 assert!(is_valid_identifier("read-only"));
2634 assert!(is_valid_identifier("read_only"));
2635 assert!(is_valid_identifier("rw2"));
2636 assert!(!is_valid_identifier(""));
2637 assert!(!is_valid_identifier("-reader"));
2638 assert!(!is_valid_identifier("_reader"));
2639 assert!(!is_valid_identifier("read.only"));
2640 assert!(!is_valid_identifier("read only"));
2641 }
2642
2643 #[test]
2644 fn config_carrying_role_stays_flat_and_keeps_its_config() {
2645 let m = parse(
2654 r#"
2655schemas:
2656 - name: inventory
2657 owner: o
2658 - name: checkout
2659 owner: o
2660roles:
2661 - name: inventory-reader
2662 login: true
2663 config:
2664 search_path: inventory
2665 statement_timeout: "30s"
2666 - name: checkout-reader
2667 login: true
2668grants:
2669 - role: inventory-reader
2670 privileges: [SELECT]
2671 object: { type: table, schema: inventory, name: "*" }
2672 - role: checkout-reader
2673 privileges: [SELECT]
2674 object: { type: table, schema: checkout, name: "*" }
2675"#,
2676 );
2677
2678 let report = suggest_profiles(&m, &SuggestOptions::default());
2679 assert!(report.round_trip_ok, "skipped: {:?}", report.skipped);
2680
2681 assert!(
2684 report.profiles.is_empty(),
2685 "expected no profiles, got: {:?}",
2686 report.profiles
2687 );
2688 assert!(report.skipped.iter().any(
2689 |s| matches!(s, SkipReason::UniqueAttributes { role } if role == "inventory-reader")
2690 ));
2691
2692 let role = report
2694 .manifest
2695 .roles
2696 .iter()
2697 .find(|r| r.name == "inventory-reader")
2698 .expect("inventory-reader should remain a flat role");
2699 assert_eq!(role.config["search_path"].0, "inventory");
2700 assert_eq!(role.config["statement_timeout"].0, "30s");
2701
2702 let original_expanded = expand_manifest(&m).unwrap();
2705 let original_graph =
2706 RoleGraph::from_expanded(&original_expanded, m.default_owner.as_deref()).unwrap();
2707 let new_expanded = expand_manifest(&report.manifest).unwrap();
2708 let new_graph =
2709 RoleGraph::from_expanded(&new_expanded, report.manifest.default_owner.as_deref())
2710 .unwrap();
2711 assert_eq!(
2712 original_graph.roles["inventory-reader"].config,
2713 new_graph.roles["inventory-reader"].config
2714 );
2715 assert_eq!(
2716 new_graph.roles["inventory-reader"].config["search_path"],
2717 "inventory"
2718 );
2719 }
2720}