Skip to main content

pgroles_core/
diff.rs

1//! Convergent diff engine.
2//!
3//! Compares two [`RoleGraph`] instances (current vs desired) and produces an
4//! ordered list of [`Change`] operations needed to bring the database from
5//! its current state to the desired state.
6//!
7//! The model is convergent: anything present in the current state but absent
8//! from the desired state is revoked/dropped. This is the Terraform-style
9//! "manifest is the entire truth" approach.
10
11use std::collections::BTreeSet;
12
13use crate::manifest::{ObjectType, Privilege, RoleRetirement};
14use crate::model::{DefaultPrivKey, GrantKey, MembershipEdge, RoleAttribute, RoleGraph, RoleState};
15
16// ---------------------------------------------------------------------------
17// Change enum
18// ---------------------------------------------------------------------------
19
20/// A single change to be applied to the database.
21///
22/// Changes are produced in dependency order by [`diff`]:
23/// 1. Create roles (before granting anything to them)
24/// 2. Alter roles (attribute changes)
25/// 3. Grant privileges
26/// 4. Set default privileges
27/// 5. Remove memberships
28/// 6. Add memberships
29/// 7. Revoke default privileges
30/// 8. Revoke privileges
31/// 9. Drop roles (after revoking everything from them)
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum Change {
34    /// Create a new role with the given attributes.
35    CreateRole { name: String, state: RoleState },
36
37    /// Alter an existing role's attributes.
38    AlterRole {
39        name: String,
40        attributes: Vec<RoleAttribute>,
41    },
42
43    /// Update a role's comment (via COMMENT ON ROLE).
44    SetComment {
45        name: String,
46        comment: Option<String>,
47    },
48
49    /// Grant privileges on an object to a role.
50    Grant {
51        role: String,
52        privileges: BTreeSet<Privilege>,
53        object_type: ObjectType,
54        schema: Option<String>,
55        name: Option<String>,
56    },
57
58    /// Revoke privileges on an object from a role.
59    Revoke {
60        role: String,
61        privileges: BTreeSet<Privilege>,
62        object_type: ObjectType,
63        schema: Option<String>,
64        name: Option<String>,
65    },
66
67    /// Set default privileges (ALTER DEFAULT PRIVILEGES ... GRANT ...).
68    SetDefaultPrivilege {
69        owner: String,
70        schema: String,
71        on_type: ObjectType,
72        grantee: String,
73        privileges: BTreeSet<Privilege>,
74    },
75
76    /// Revoke default privileges (ALTER DEFAULT PRIVILEGES ... REVOKE ...).
77    RevokeDefaultPrivilege {
78        owner: String,
79        schema: String,
80        on_type: ObjectType,
81        grantee: String,
82        privileges: BTreeSet<Privilege>,
83    },
84
85    /// Grant membership (GRANT role TO member).
86    AddMember {
87        role: String,
88        member: String,
89        inherit: bool,
90        admin: bool,
91    },
92
93    /// Revoke membership (REVOKE role FROM member).
94    RemoveMember { role: String, member: String },
95
96    /// Reassign owned objects to a successor role before drop.
97    ReassignOwned { from_role: String, to_role: String },
98
99    /// Drop owned objects and revoke remaining privileges before drop.
100    DropOwned { role: String },
101
102    /// Drop a role.
103    DropRole { name: String },
104}
105
106// ---------------------------------------------------------------------------
107// Diff function
108// ---------------------------------------------------------------------------
109
110/// Compute the list of changes needed to bring `current` to `desired`.
111///
112/// Changes are ordered so that dependencies are respected:
113/// creates before grants, revokes before drops, etc.
114pub 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    // ----- Roles -----
126
127    // Roles in desired but not in current → CREATE
128    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                // Role exists — check for attribute changes
138                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                // Check comment change
146                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    // Roles in current but not in desired → DROP
157    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    // ----- Grants -----
164
165    diff_grants(current, desired, &mut grants, &mut revokes);
166
167    // ----- Default privileges -----
168
169    diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
170
171    // ----- Memberships -----
172
173    diff_memberships(current, desired, &mut add_members, &mut remove_members);
174
175    // ----- Assemble in dependency order -----
176    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
189/// Augment a diff plan with explicit role-retirement actions.
190///
191/// Retirement steps are inserted immediately before the matching `DropRole`
192/// so the final plan remains dependency-safe:
193/// `REASSIGN OWNED` → `DROP OWNED` → `DROP ROLE`.
194pub 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
225// ---------------------------------------------------------------------------
226// Grant diffing
227// ---------------------------------------------------------------------------
228
229fn diff_grants(
230    current: &RoleGraph,
231    desired: &RoleGraph,
232    grants_out: &mut Vec<Change>,
233    revokes_out: &mut Vec<Change>,
234) {
235    // Grants in desired but not in current → GRANT (full set)
236    // Grants in both → diff the privilege sets
237    for (key, desired_state) in &desired.grants {
238        match current.grants.get(key) {
239            None => {
240                // Entirely new grant target — grant the full set
241                grants_out.push(change_grant(key, &desired_state.privileges));
242            }
243            Some(current_state) => {
244                // Grant target exists — find privileges to add/remove
245                let to_add: BTreeSet<Privilege> = desired_state
246                    .privileges
247                    .difference(&current_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    // Grant targets in current but not in desired → REVOKE all
267    for (key, current_state) in &current.grants {
268        if !desired.grants.contains_key(key) {
269            revokes_out.push(change_revoke(key, &current_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
294// ---------------------------------------------------------------------------
295// Default privilege diffing
296// ---------------------------------------------------------------------------
297
298fn 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(&current_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 &current.default_privileges {
332        if !desired.default_privileges.contains_key(key) {
333            revoke_out.push(change_revoke_default(key, &current_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
358// ---------------------------------------------------------------------------
359// Membership diffing
360// ---------------------------------------------------------------------------
361
362fn diff_memberships(
363    current: &RoleGraph,
364    desired: &RoleGraph,
365    add_out: &mut Vec<Change>,
366    remove_out: &mut Vec<Change>,
367) {
368    // We compare memberships by (role, member) as the key.
369    // If inherit/admin flags changed, we remove and re-add.
370
371    // Build lookup maps: (role, member) → MembershipEdge
372    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    // Desired but not current → add
384    // Desired and current but different flags → remove + add
385    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                    // Flags changed — revoke and re-grant
400                    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    // Current but not desired → remove
416    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// ---------------------------------------------------------------------------
427// Tests
428// ---------------------------------------------------------------------------
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433    use crate::model::{DefaultPrivState, GrantState};
434
435    /// Helper: build an empty graph.
436    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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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, // changed!
653        });
654
655        let changes = diff(&current, &desired);
656        // Should produce remove + add
657        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(&current, &desired);
699        // Should add INSERT and revoke DELETE
700        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(&current, &desired);
724        assert_eq!(changes.len(), 2);
725
726        // Creates should come before drops
727        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    /// Integration test: round-trip from manifest → expand → model → diff
770    #[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        // Current state is empty — everything should be created
804        let current = RoleGraph::default();
805        let changes = diff(&current, &desired);
806
807        // Should have: 1 CreateRole, 2 Grants, 1 SetDefaultPrivilege, 1 AddMember
808        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); // schema USAGE + table *
827        assert_eq!(dp_count, 1);
828        assert_eq!(member_count, 1);
829
830        // Diffing desired against itself should produce no changes
831        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}