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