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, serde::Serialize)]
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    /// Terminate other active sessions before dropping a role.
103    TerminateSessions { role: String },
104
105    /// Drop a role.
106    DropRole { name: String },
107}
108
109// ---------------------------------------------------------------------------
110// Diff function
111// ---------------------------------------------------------------------------
112
113/// Compute the list of changes needed to bring `current` to `desired`.
114///
115/// Changes are ordered so that dependencies are respected:
116/// creates before grants, revokes before drops, etc.
117pub 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    // ----- Roles -----
129
130    // Roles in desired but not in current → CREATE
131    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                // Role exists — check for attribute changes
141                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                // Check comment change
149                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    // Roles in current but not in desired → DROP
160    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    // ----- Grants -----
167
168    diff_grants(current, desired, &mut grants, &mut revokes);
169
170    // ----- Default privileges -----
171
172    diff_default_privileges(current, desired, &mut set_defaults, &mut revoke_defaults);
173
174    // ----- Memberships -----
175
176    diff_memberships(current, desired, &mut add_members, &mut remove_members);
177
178    // ----- Assemble in dependency order -----
179    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
192/// Augment a diff plan with explicit role-retirement actions.
193///
194/// Retirement steps are inserted immediately before the matching `DropRole`
195/// so the final plan remains dependency-safe:
196/// `TERMINATE SESSIONS` → `REASSIGN OWNED` → `DROP OWNED` → `DROP ROLE`.
197pub 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
231// ---------------------------------------------------------------------------
232// Grant diffing
233// ---------------------------------------------------------------------------
234
235fn diff_grants(
236    current: &RoleGraph,
237    desired: &RoleGraph,
238    grants_out: &mut Vec<Change>,
239    revokes_out: &mut Vec<Change>,
240) {
241    // Grants in desired but not in current → GRANT (full set)
242    // Grants in both → diff the privilege sets
243    for (key, desired_state) in &desired.grants {
244        match current.grants.get(key) {
245            None => {
246                // Entirely new grant target — grant the full set
247                grants_out.push(change_grant(key, &desired_state.privileges));
248            }
249            Some(current_state) => {
250                // Grant target exists — find privileges to add/remove
251                let to_add: BTreeSet<Privilege> = desired_state
252                    .privileges
253                    .difference(&current_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    // Grant targets in current but not in desired → REVOKE all
273    for (key, current_state) in &current.grants {
274        if !desired.grants.contains_key(key) {
275            revokes_out.push(change_revoke(key, &current_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
300// ---------------------------------------------------------------------------
301// Default privilege diffing
302// ---------------------------------------------------------------------------
303
304fn 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(&current_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 &current.default_privileges {
338        if !desired.default_privileges.contains_key(key) {
339            revoke_out.push(change_revoke_default(key, &current_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
364// ---------------------------------------------------------------------------
365// Membership diffing
366// ---------------------------------------------------------------------------
367
368fn diff_memberships(
369    current: &RoleGraph,
370    desired: &RoleGraph,
371    add_out: &mut Vec<Change>,
372    remove_out: &mut Vec<Change>,
373) {
374    // We compare memberships by (role, member) as the key.
375    // If inherit/admin flags changed, we remove and re-add.
376
377    // Build lookup maps: (role, member) → MembershipEdge
378    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    // Desired but not current → add
390    // Desired and current but different flags → remove + add
391    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                    // Flags changed — revoke and re-grant
406                    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    // Current but not desired → remove
422    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// ---------------------------------------------------------------------------
433// Tests
434// ---------------------------------------------------------------------------
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use crate::model::{DefaultPrivState, GrantState};
440
441    /// Helper: build an empty graph.
442    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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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(&current, &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, // changed!
659        });
660
661        let changes = diff(&current, &desired);
662        // Should produce remove + add
663        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(&current, &desired);
705        // Should add INSERT and revoke DELETE
706        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(&current, &desired);
730        assert_eq!(changes.len(), 2);
731
732        // Creates should come before drops
733        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    /// Integration test: round-trip from manifest → expand → model → diff
776    #[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        // Current state is empty — everything should be created
810        let current = RoleGraph::default();
811        let changes = diff(&current, &desired);
812
813        // Should have: 1 CreateRole, 2 Grants, 1 SetDefaultPrivilege, 1 AddMember
814        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); // schema USAGE + table *
833        assert_eq!(dp_count, 1);
834        assert_eq!(member_count, 1);
835
836        // Diffing desired against itself should produce no changes
837        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}