1use std::collections::BTreeSet;
12
13use crate::manifest::{ObjectType, Privilege, RoleRetirement};
14use crate::model::{DefaultPrivKey, GrantKey, MembershipEdge, RoleAttribute, RoleGraph, RoleState};
15
16#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Change {
34 CreateRole { name: String, state: RoleState },
36
37 AlterRole {
39 name: String,
40 attributes: Vec<RoleAttribute>,
41 },
42
43 SetComment {
45 name: String,
46 comment: Option<String>,
47 },
48
49 Grant {
51 role: String,
52 privileges: BTreeSet<Privilege>,
53 object_type: ObjectType,
54 schema: Option<String>,
55 name: Option<String>,
56 },
57
58 Revoke {
60 role: String,
61 privileges: BTreeSet<Privilege>,
62 object_type: ObjectType,
63 schema: Option<String>,
64 name: Option<String>,
65 },
66
67 SetDefaultPrivilege {
69 owner: String,
70 schema: String,
71 on_type: ObjectType,
72 grantee: String,
73 privileges: BTreeSet<Privilege>,
74 },
75
76 RevokeDefaultPrivilege {
78 owner: String,
79 schema: String,
80 on_type: ObjectType,
81 grantee: String,
82 privileges: BTreeSet<Privilege>,
83 },
84
85 AddMember {
87 role: String,
88 member: String,
89 inherit: bool,
90 admin: bool,
91 },
92
93 RemoveMember { role: String, member: String },
95
96 ReassignOwned { from_role: String, to_role: String },
98
99 DropOwned { role: String },
101
102 DropRole { name: String },
104}
105
106pub fn diff(current: &RoleGraph, desired: &RoleGraph) -> Vec<Change> {
115 let mut creates = Vec::new();
116 let mut alters = Vec::new();
117 let mut grants = Vec::new();
118 let mut set_defaults = Vec::new();
119 let mut add_members = Vec::new();
120 let mut remove_members = Vec::new();
121 let mut revoke_defaults = Vec::new();
122 let mut revokes = Vec::new();
123 let mut drops = Vec::new();
124
125 for (name, desired_state) in &desired.roles {
129 match current.roles.get(name) {
130 None => {
131 creates.push(Change::CreateRole {
132 name: name.clone(),
133 state: desired_state.clone(),
134 });
135 }
136 Some(current_state) => {
137 let attribute_changes = current_state.changed_attributes(desired_state);
139 if !attribute_changes.is_empty() {
140 alters.push(Change::AlterRole {
141 name: name.clone(),
142 attributes: attribute_changes,
143 });
144 }
145 if current_state.comment != desired_state.comment {
147 alters.push(Change::SetComment {
148 name: name.clone(),
149 comment: desired_state.comment.clone(),
150 });
151 }
152 }
153 }
154 }
155
156 for name in current.roles.keys() {
158 if !desired.roles.contains_key(name) {
159 drops.push(Change::DropRole { name: name.clone() });
160 }
161 }
162
163 diff_grants(current, desired, &mut grants, &mut revokes);
166
167 diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
170
171 diff_memberships(current, desired, &mut add_members, &mut remove_members);
174
175 let mut changes = Vec::new();
177 changes.extend(creates);
178 changes.extend(alters);
179 changes.extend(grants);
180 changes.extend(set_defaults);
181 changes.extend(remove_members);
182 changes.extend(add_members);
183 changes.extend(revoke_defaults);
184 changes.extend(revokes);
185 changes.extend(drops);
186 changes
187}
188
189pub fn apply_role_retirements(changes: Vec<Change>, retirements: &[RoleRetirement]) -> Vec<Change> {
195 if retirements.is_empty() {
196 return changes;
197 }
198
199 let retirement_by_role: std::collections::BTreeMap<&str, &RoleRetirement> = retirements
200 .iter()
201 .map(|retirement| (retirement.role.as_str(), retirement))
202 .collect();
203
204 let mut planned = Vec::with_capacity(changes.len());
205 for change in changes {
206 if let Change::DropRole { name } = &change
207 && let Some(retirement) = retirement_by_role.get(name.as_str())
208 {
209 if let Some(successor) = &retirement.reassign_owned_to {
210 planned.push(Change::ReassignOwned {
211 from_role: name.clone(),
212 to_role: successor.clone(),
213 });
214 }
215 if retirement.drop_owned {
216 planned.push(Change::DropOwned { role: name.clone() });
217 }
218 }
219 planned.push(change);
220 }
221
222 planned
223}
224
225fn diff_grants(
230 current: &RoleGraph,
231 desired: &RoleGraph,
232 grants_out: &mut Vec<Change>,
233 revokes_out: &mut Vec<Change>,
234) {
235 for (key, desired_state) in &desired.grants {
238 match current.grants.get(key) {
239 None => {
240 grants_out.push(change_grant(key, &desired_state.privileges));
242 }
243 Some(current_state) => {
244 let to_add: BTreeSet<Privilege> = desired_state
246 .privileges
247 .difference(¤t_state.privileges)
248 .copied()
249 .collect();
250 let to_remove: BTreeSet<Privilege> = current_state
251 .privileges
252 .difference(&desired_state.privileges)
253 .copied()
254 .collect();
255
256 if !to_add.is_empty() {
257 grants_out.push(change_grant(key, &to_add));
258 }
259 if !to_remove.is_empty() {
260 revokes_out.push(change_revoke(key, &to_remove));
261 }
262 }
263 }
264 }
265
266 for (key, current_state) in ¤t.grants {
268 if !desired.grants.contains_key(key) {
269 revokes_out.push(change_revoke(key, ¤t_state.privileges));
270 }
271 }
272}
273
274fn change_grant(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
275 Change::Grant {
276 role: key.role.clone(),
277 privileges: privileges.clone(),
278 object_type: key.object_type,
279 schema: key.schema.clone(),
280 name: key.name.clone(),
281 }
282}
283
284fn change_revoke(key: &GrantKey, privileges: &BTreeSet<Privilege>) -> Change {
285 Change::Revoke {
286 role: key.role.clone(),
287 privileges: privileges.clone(),
288 object_type: key.object_type,
289 schema: key.schema.clone(),
290 name: key.name.clone(),
291 }
292}
293
294fn diff_default_privileges(
299 current: &RoleGraph,
300 desired: &RoleGraph,
301 set_out: &mut Vec<Change>,
302 revoke_out: &mut Vec<Change>,
303) {
304 for (key, desired_state) in &desired.default_privileges {
305 match current.default_privileges.get(key) {
306 None => {
307 set_out.push(change_set_default(key, &desired_state.privileges));
308 }
309 Some(current_state) => {
310 let to_add: BTreeSet<Privilege> = desired_state
311 .privileges
312 .difference(¤t_state.privileges)
313 .copied()
314 .collect();
315 let to_remove: BTreeSet<Privilege> = current_state
316 .privileges
317 .difference(&desired_state.privileges)
318 .copied()
319 .collect();
320
321 if !to_add.is_empty() {
322 set_out.push(change_set_default(key, &to_add));
323 }
324 if !to_remove.is_empty() {
325 revoke_out.push(change_revoke_default(key, &to_remove));
326 }
327 }
328 }
329 }
330
331 for (key, current_state) in ¤t.default_privileges {
332 if !desired.default_privileges.contains_key(key) {
333 revoke_out.push(change_revoke_default(key, ¤t_state.privileges));
334 }
335 }
336}
337
338fn change_set_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
339 Change::SetDefaultPrivilege {
340 owner: key.owner.clone(),
341 schema: key.schema.clone(),
342 on_type: key.on_type,
343 grantee: key.grantee.clone(),
344 privileges: privileges.clone(),
345 }
346}
347
348fn change_revoke_default(key: &DefaultPrivKey, privileges: &BTreeSet<Privilege>) -> Change {
349 Change::RevokeDefaultPrivilege {
350 owner: key.owner.clone(),
351 schema: key.schema.clone(),
352 on_type: key.on_type,
353 grantee: key.grantee.clone(),
354 privileges: privileges.clone(),
355 }
356}
357
358fn diff_memberships(
363 current: &RoleGraph,
364 desired: &RoleGraph,
365 add_out: &mut Vec<Change>,
366 remove_out: &mut Vec<Change>,
367) {
368 let current_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = current
373 .memberships
374 .iter()
375 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
376 .collect();
377 let desired_map: std::collections::BTreeMap<(&str, &str), &MembershipEdge> = desired
378 .memberships
379 .iter()
380 .map(|edge| ((edge.role.as_str(), edge.member.as_str()), edge))
381 .collect();
382
383 for (&(role, member), &desired_edge) in &desired_map {
386 match current_map.get(&(role, member)) {
387 None => {
388 add_out.push(Change::AddMember {
389 role: desired_edge.role.clone(),
390 member: desired_edge.member.clone(),
391 inherit: desired_edge.inherit,
392 admin: desired_edge.admin,
393 });
394 }
395 Some(current_edge) => {
396 if current_edge.inherit != desired_edge.inherit
397 || current_edge.admin != desired_edge.admin
398 {
399 remove_out.push(Change::RemoveMember {
401 role: current_edge.role.clone(),
402 member: current_edge.member.clone(),
403 });
404 add_out.push(Change::AddMember {
405 role: desired_edge.role.clone(),
406 member: desired_edge.member.clone(),
407 inherit: desired_edge.inherit,
408 admin: desired_edge.admin,
409 });
410 }
411 }
412 }
413 }
414
415 for &(role, member) in current_map.keys() {
417 if !desired_map.contains_key(&(role, member)) {
418 remove_out.push(Change::RemoveMember {
419 role: role.to_string(),
420 member: member.to_string(),
421 });
422 }
423 }
424}
425
426#[cfg(test)]
431mod tests {
432 use super::*;
433 use crate::model::{DefaultPrivState, GrantState};
434
435 fn empty_graph() -> RoleGraph {
437 RoleGraph::default()
438 }
439
440 #[test]
441 fn diff_empty_to_empty_is_empty() {
442 let changes = diff(&empty_graph(), &empty_graph());
443 assert!(changes.is_empty());
444 }
445
446 #[test]
447 fn diff_creates_new_roles() {
448 let current = empty_graph();
449 let mut desired = empty_graph();
450 desired
451 .roles
452 .insert("new-role".to_string(), RoleState::default());
453
454 let changes = diff(¤t, &desired);
455 assert_eq!(changes.len(), 1);
456 assert!(matches!(&changes[0], Change::CreateRole { name, .. } if name == "new-role"));
457 }
458
459 #[test]
460 fn diff_drops_removed_roles() {
461 let mut current = empty_graph();
462 current
463 .roles
464 .insert("old-role".to_string(), RoleState::default());
465 let desired = empty_graph();
466
467 let changes = diff(¤t, &desired);
468 assert_eq!(changes.len(), 1);
469 assert!(matches!(&changes[0], Change::DropRole { name } if name == "old-role"));
470 }
471
472 #[test]
473 fn diff_alters_changed_role_attributes() {
474 let mut current = empty_graph();
475 current
476 .roles
477 .insert("role1".to_string(), RoleState::default());
478
479 let mut desired = empty_graph();
480 desired.roles.insert(
481 "role1".to_string(),
482 RoleState {
483 login: true,
484 ..RoleState::default()
485 },
486 );
487
488 let changes = diff(¤t, &desired);
489 assert_eq!(changes.len(), 1);
490 match &changes[0] {
491 Change::AlterRole { name, attributes } => {
492 assert_eq!(name, "role1");
493 assert!(attributes.contains(&RoleAttribute::Login(true)));
494 }
495 other => panic!("expected AlterRole, got: {other:?}"),
496 }
497 }
498
499 #[test]
500 fn diff_grants_new_privileges() {
501 let current = empty_graph();
502 let mut desired = empty_graph();
503 let key = GrantKey {
504 role: "r1".to_string(),
505 object_type: ObjectType::Table,
506 schema: Some("public".to_string()),
507 name: Some("*".to_string()),
508 };
509 desired.grants.insert(
510 key,
511 GrantState {
512 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
513 },
514 );
515
516 let changes = diff(¤t, &desired);
517 assert_eq!(changes.len(), 1);
518 match &changes[0] {
519 Change::Grant {
520 role, privileges, ..
521 } => {
522 assert_eq!(role, "r1");
523 assert!(privileges.contains(&Privilege::Select));
524 assert!(privileges.contains(&Privilege::Insert));
525 }
526 other => panic!("expected Grant, got: {other:?}"),
527 }
528 }
529
530 #[test]
531 fn diff_revokes_removed_privileges() {
532 let mut current = empty_graph();
533 let key = GrantKey {
534 role: "r1".to_string(),
535 object_type: ObjectType::Table,
536 schema: Some("public".to_string()),
537 name: Some("*".to_string()),
538 };
539 current.grants.insert(
540 key.clone(),
541 GrantState {
542 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
543 },
544 );
545
546 let mut desired = empty_graph();
547 desired.grants.insert(
548 key,
549 GrantState {
550 privileges: BTreeSet::from([Privilege::Select]),
551 },
552 );
553
554 let changes = diff(¤t, &desired);
555 assert_eq!(changes.len(), 1);
556 match &changes[0] {
557 Change::Revoke {
558 role, privileges, ..
559 } => {
560 assert_eq!(role, "r1");
561 assert!(privileges.contains(&Privilege::Insert));
562 assert!(!privileges.contains(&Privilege::Select));
563 }
564 other => panic!("expected Revoke, got: {other:?}"),
565 }
566 }
567
568 #[test]
569 fn diff_revokes_entire_grant_target_when_absent_from_desired() {
570 let mut current = empty_graph();
571 let key = GrantKey {
572 role: "r1".to_string(),
573 object_type: ObjectType::Schema,
574 schema: None,
575 name: Some("myschema".to_string()),
576 };
577 current.grants.insert(
578 key,
579 GrantState {
580 privileges: BTreeSet::from([Privilege::Usage]),
581 },
582 );
583 let desired = empty_graph();
584
585 let changes = diff(¤t, &desired);
586 assert_eq!(changes.len(), 1);
587 assert!(matches!(&changes[0], Change::Revoke { role, .. } if role == "r1"));
588 }
589
590 #[test]
591 fn diff_adds_memberships() {
592 let current = empty_graph();
593 let mut desired = empty_graph();
594 desired.memberships.insert(MembershipEdge {
595 role: "editors".to_string(),
596 member: "user@example.com".to_string(),
597 inherit: true,
598 admin: false,
599 });
600
601 let changes = diff(¤t, &desired);
602 assert_eq!(changes.len(), 1);
603 match &changes[0] {
604 Change::AddMember {
605 role,
606 member,
607 inherit,
608 admin,
609 } => {
610 assert_eq!(role, "editors");
611 assert_eq!(member, "user@example.com");
612 assert!(*inherit);
613 assert!(!admin);
614 }
615 other => panic!("expected AddMember, got: {other:?}"),
616 }
617 }
618
619 #[test]
620 fn diff_removes_memberships() {
621 let mut current = empty_graph();
622 current.memberships.insert(MembershipEdge {
623 role: "editors".to_string(),
624 member: "old@example.com".to_string(),
625 inherit: true,
626 admin: false,
627 });
628 let desired = empty_graph();
629
630 let changes = diff(¤t, &desired);
631 assert_eq!(changes.len(), 1);
632 assert!(
633 matches!(&changes[0], Change::RemoveMember { role, member } if role == "editors" && member == "old@example.com")
634 );
635 }
636
637 #[test]
638 fn diff_re_grants_membership_when_flags_change() {
639 let mut current = empty_graph();
640 current.memberships.insert(MembershipEdge {
641 role: "editors".to_string(),
642 member: "user@example.com".to_string(),
643 inherit: true,
644 admin: false,
645 });
646
647 let mut desired = empty_graph();
648 desired.memberships.insert(MembershipEdge {
649 role: "editors".to_string(),
650 member: "user@example.com".to_string(),
651 inherit: true,
652 admin: true, });
654
655 let changes = diff(¤t, &desired);
656 assert_eq!(changes.len(), 2);
658 assert!(matches!(
659 &changes[0],
660 Change::RemoveMember { role, member }
661 if role == "editors" && member == "user@example.com"
662 ));
663 assert!(matches!(
664 &changes[1],
665 Change::AddMember {
666 role,
667 member,
668 admin: true,
669 ..
670 } if role == "editors" && member == "user@example.com"
671 ));
672 }
673
674 #[test]
675 fn diff_default_privileges_add_and_revoke() {
676 let mut current = empty_graph();
677 let key = DefaultPrivKey {
678 owner: "app_owner".to_string(),
679 schema: "inventory".to_string(),
680 on_type: ObjectType::Table,
681 grantee: "inventory-editor".to_string(),
682 };
683 current.default_privileges.insert(
684 key.clone(),
685 DefaultPrivState {
686 privileges: BTreeSet::from([Privilege::Select, Privilege::Delete]),
687 },
688 );
689
690 let mut desired = empty_graph();
691 desired.default_privileges.insert(
692 key,
693 DefaultPrivState {
694 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
695 },
696 );
697
698 let changes = diff(¤t, &desired);
699 assert_eq!(changes.len(), 2);
701 assert!(changes.iter().any(|c| matches!(
702 c,
703 Change::SetDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Insert)
704 )));
705 assert!(changes.iter().any(|c| matches!(
706 c,
707 Change::RevokeDefaultPrivilege { privileges, .. } if privileges.contains(&Privilege::Delete)
708 )));
709 }
710
711 #[test]
712 fn diff_ordering_creates_before_drops() {
713 let mut current = empty_graph();
714 current
715 .roles
716 .insert("old-role".to_string(), RoleState::default());
717
718 let mut desired = empty_graph();
719 desired
720 .roles
721 .insert("new-role".to_string(), RoleState::default());
722
723 let changes = diff(¤t, &desired);
724 assert_eq!(changes.len(), 2);
725
726 let create_idx = changes
728 .iter()
729 .position(|c| matches!(c, Change::CreateRole { .. }))
730 .unwrap();
731 let drop_idx = changes
732 .iter()
733 .position(|c| matches!(c, Change::DropRole { .. }))
734 .unwrap();
735 assert!(create_idx < drop_idx);
736 }
737
738 #[test]
739 fn diff_identical_graphs_produce_no_changes() {
740 let mut graph = empty_graph();
741 graph
742 .roles
743 .insert("role1".to_string(), RoleState::default());
744 graph.grants.insert(
745 GrantKey {
746 role: "role1".to_string(),
747 object_type: ObjectType::Table,
748 schema: Some("public".to_string()),
749 name: Some("*".to_string()),
750 },
751 GrantState {
752 privileges: BTreeSet::from([Privilege::Select]),
753 },
754 );
755 graph.memberships.insert(MembershipEdge {
756 role: "role1".to_string(),
757 member: "user@example.com".to_string(),
758 inherit: true,
759 admin: false,
760 });
761
762 let changes = diff(&graph, &graph);
763 assert!(
764 changes.is_empty(),
765 "identical graphs should produce no changes"
766 );
767 }
768
769 #[test]
771 fn manifest_to_diff_integration() {
772 use crate::manifest::{expand_manifest, parse_manifest};
773 use crate::model::RoleGraph;
774
775 let yaml = r#"
776default_owner: app_owner
777
778profiles:
779 editor:
780 grants:
781 - privileges: [USAGE]
782 on: { type: schema }
783 - privileges: [SELECT, INSERT, UPDATE, DELETE]
784 on: { type: table, name: "*" }
785 default_privileges:
786 - privileges: [SELECT, INSERT, UPDATE, DELETE]
787 on_type: table
788
789schemas:
790 - name: inventory
791 profiles: [editor]
792
793memberships:
794 - role: inventory-editor
795 members:
796 - name: "user@example.com"
797"#;
798 let manifest = parse_manifest(yaml).unwrap();
799 let expanded = expand_manifest(&manifest).unwrap();
800 let desired =
801 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
802
803 let current = RoleGraph::default();
805 let changes = diff(¤t, &desired);
806
807 let create_count = changes
809 .iter()
810 .filter(|c| matches!(c, Change::CreateRole { .. }))
811 .count();
812 let grant_count = changes
813 .iter()
814 .filter(|c| matches!(c, Change::Grant { .. }))
815 .count();
816 let dp_count = changes
817 .iter()
818 .filter(|c| matches!(c, Change::SetDefaultPrivilege { .. }))
819 .count();
820 let member_count = changes
821 .iter()
822 .filter(|c| matches!(c, Change::AddMember { .. }))
823 .count();
824
825 assert_eq!(create_count, 1);
826 assert_eq!(grant_count, 2); assert_eq!(dp_count, 1);
828 assert_eq!(member_count, 1);
829
830 let no_changes = diff(&desired, &desired);
832 assert!(no_changes.is_empty());
833 }
834
835 #[test]
836 fn apply_role_retirements_inserts_cleanup_before_drop() {
837 let changes = vec![
838 Change::Grant {
839 role: "analytics".to_string(),
840 privileges: BTreeSet::from([Privilege::Select]),
841 object_type: ObjectType::Table,
842 schema: Some("public".to_string()),
843 name: Some("*".to_string()),
844 },
845 Change::DropRole {
846 name: "old-app".to_string(),
847 },
848 ];
849
850 let planned = apply_role_retirements(
851 changes,
852 &[crate::manifest::RoleRetirement {
853 role: "old-app".to_string(),
854 reassign_owned_to: Some("successor".to_string()),
855 drop_owned: true,
856 }],
857 );
858
859 assert!(matches!(planned[0], Change::Grant { .. }));
860 assert!(matches!(
861 planned[1],
862 Change::ReassignOwned {
863 ref from_role,
864 ref to_role
865 } if from_role == "old-app" && to_role == "successor"
866 ));
867 assert!(matches!(
868 planned[2],
869 Change::DropOwned { ref role } if role == "old-app"
870 ));
871 assert!(matches!(
872 planned[3],
873 Change::DropRole { ref name } if name == "old-app"
874 ));
875 }
876}