Skip to main content

pgevolve_core/diff/
default_privileges.rs

1//! Diff `ALTER DEFAULT PRIVILEGES` rules.
2//!
3//! Rules are paired by `(target_role, schema, object_type)`. Within each pair
4//! the grant list is diffed with the standard lenient policy from
5//! [`super::grants::diff_grants`].
6//!
7//! ## Managed-roles extension for owned rules
8//!
9//! When the source catalog explicitly declares a rule (same key present in
10//! `source`), we treat every grantee that appears in the target's grant list
11//! for that rule as "managed" — even if that role does not appear elsewhere in
12//! the source catalog. Rationale: pgevolve previously applied the GRANTs via
13//! that same rule, so the grantees are our responsibility to revoke when the
14//! desired grant list shrinks. The lenient unmanaged-drift policy is reserved
15//! for rules that exist **only** in the live database and have no corresponding
16//! source declaration.
17
18use std::collections::{BTreeMap, BTreeSet};
19
20use crate::identifier::Identifier;
21use crate::ir::default_privileges::{DefaultPrivObjectType, DefaultPrivilegeRule};
22use crate::ir::grant::{Grant, GrantTarget};
23
24/// One ADD or REVOKE step for a default-privilege rule.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct DefaultPrivilegeChange {
27    /// `FOR ROLE x` — the grantor role this rule targets.
28    pub target_role: Identifier,
29    /// `IN SCHEMA y` — schema scope. `None` = global scope.
30    pub schema: Option<Identifier>,
31    /// Object-type discriminant.
32    pub object_type: DefaultPrivObjectType,
33    /// `true` = GRANT, `false` = REVOKE.
34    pub is_grant: bool,
35    /// The specific grant being added or revoked.
36    pub grant: Grant,
37}
38
39type RuleKey = (Identifier, Option<Identifier>, DefaultPrivObjectType);
40
41/// Compute `ALTER DEFAULT PRIVILEGES` changes needed to converge `target`
42/// (live database) toward `source` (desired state).
43///
44/// `managed_roles` is passed to the inner [`super::grants::diff_grants`] call
45/// so that the lenient policy applies: unmanaged grantees in the live catalog
46/// never produce REVOKE steps.
47#[must_use]
48pub fn diff_default_privileges(
49    target: &[DefaultPrivilegeRule],
50    source: &[DefaultPrivilegeRule],
51    managed_roles: &BTreeSet<Identifier>,
52) -> Vec<DefaultPrivilegeChange> {
53    let key = |r: &DefaultPrivilegeRule| -> RuleKey {
54        (r.target_role.clone(), r.schema.clone(), r.object_type)
55    };
56
57    let target_map: BTreeMap<RuleKey, &DefaultPrivilegeRule> =
58        target.iter().map(|r| (key(r), r)).collect();
59    let source_map: BTreeMap<RuleKey, &DefaultPrivilegeRule> =
60        source.iter().map(|r| (key(r), r)).collect();
61
62    let mut all_keys: BTreeSet<RuleKey> = target_map.keys().cloned().collect();
63    all_keys.extend(source_map.keys().cloned());
64
65    let mut out = Vec::new();
66    for k in all_keys {
67        let target_grants = target_map
68            .get(&k)
69            .map_or(&[] as &[Grant], |r| r.grants.as_slice());
70        let source_grants = source_map
71            .get(&k)
72            .map_or(&[] as &[Grant], |r| r.grants.as_slice());
73
74        // When the source explicitly declares this rule, any grantee present in
75        // the target's grant list was placed there by a previous pgevolve apply
76        // and must be revocable. Extend the managed-roles set with those
77        // grantees so that `diff_grants` does not suppress them as "unmanaged".
78        let extended;
79        let effective_managed: &BTreeSet<Identifier> = if source_map.contains_key(&k) {
80            extended = target_grants
81                .iter()
82                .filter_map(|g| {
83                    if let GrantTarget::Role(name) = &g.grantee {
84                        Some(name.clone())
85                    } else {
86                        None
87                    }
88                })
89                .fold(managed_roles.clone(), |mut acc, name| {
90                    acc.insert(name);
91                    acc
92                });
93            &extended
94        } else {
95            managed_roles
96        };
97
98        let (to_add, to_revoke, _unmanaged) =
99            super::grants::diff_grants(target_grants, source_grants, effective_managed);
100
101        // Emit REVOKEs before GRANTs (issue #33): revokes must precede grants so
102        // that WGO-change pairs (same grantee+privilege, different wgo) don't
103        // self-cancel when the changes are applied to the live database.
104        for g in to_revoke {
105            out.push(DefaultPrivilegeChange {
106                target_role: k.0.clone(),
107                schema: k.1.clone(),
108                object_type: k.2,
109                is_grant: false,
110                grant: g,
111            });
112        }
113        for g in to_add {
114            out.push(DefaultPrivilegeChange {
115                target_role: k.0.clone(),
116                schema: k.1.clone(),
117                object_type: k.2,
118                is_grant: true,
119                grant: g,
120            });
121        }
122    }
123    out
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::identifier::Identifier;
130    use crate::ir::default_privileges::{DefaultPrivObjectType, DefaultPrivilegeRule};
131    use crate::ir::grant::{Grant, GrantTarget, Privilege};
132
133    fn id(s: &str) -> Identifier {
134        Identifier::from_unquoted(s).unwrap()
135    }
136
137    fn grant_to(role: &str, priv_: Privilege) -> Grant {
138        Grant {
139            grantee: GrantTarget::Role(id(role)),
140            privilege: priv_,
141            with_grant_option: false,
142            columns: None,
143        }
144    }
145
146    fn rule(
147        target_role: &str,
148        schema: Option<&str>,
149        object_type: DefaultPrivObjectType,
150        grants: Vec<Grant>,
151    ) -> DefaultPrivilegeRule {
152        DefaultPrivilegeRule {
153            target_role: id(target_role),
154            schema: schema.map(id),
155            object_type,
156            grants,
157        }
158    }
159
160    fn managed(roles: &[&str]) -> BTreeSet<Identifier> {
161        roles.iter().map(|r| id(r)).collect()
162    }
163
164    #[test]
165    fn add_only() {
166        // Source has a rule; target does not → all grants in source become ADDs.
167        let src = rule(
168            "app_owner",
169            None,
170            DefaultPrivObjectType::Tables,
171            vec![grant_to("web_anon", Privilege::Select)],
172        );
173        let changes = diff_default_privileges(&[], &[src], &managed(&["app_owner", "web_anon"]));
174        assert_eq!(changes.len(), 1);
175        assert!(changes[0].is_grant, "expected is_grant=true");
176        assert_eq!(changes[0].grant.privilege, Privilege::Select);
177    }
178
179    #[test]
180    fn revoke_only() {
181        // Target has a rule; source does not → grants in target become REVOKEs
182        // (for managed grantees).
183        let tgt = rule(
184            "app_owner",
185            None,
186            DefaultPrivObjectType::Tables,
187            vec![grant_to("web_anon", Privilege::Select)],
188        );
189        let changes = diff_default_privileges(&[tgt], &[], &managed(&["app_owner", "web_anon"]));
190        assert_eq!(changes.len(), 1);
191        assert!(!changes[0].is_grant, "expected is_grant=false");
192        assert_eq!(changes[0].grant.privilege, Privilege::Select);
193    }
194
195    #[test]
196    fn mixed_add_and_revoke() {
197        // Target has SELECT; source has INSERT.
198        // SELECT → REVOKE; INSERT → ADD.
199        let tgt = rule(
200            "app_owner",
201            Some("app"),
202            DefaultPrivObjectType::Sequences,
203            vec![grant_to("alice", Privilege::Select)],
204        );
205        let src = rule(
206            "app_owner",
207            Some("app"),
208            DefaultPrivObjectType::Sequences,
209            vec![grant_to("alice", Privilege::Usage)],
210        );
211        let changes = diff_default_privileges(&[tgt], &[src], &managed(&["app_owner", "alice"]));
212        assert_eq!(changes.len(), 2, "expected 2 changes, got: {changes:?}");
213        let adds: Vec<_> = changes.iter().filter(|c| c.is_grant).collect();
214        let revs: Vec<_> = changes.iter().filter(|c| !c.is_grant).collect();
215        assert_eq!(adds.len(), 1);
216        assert_eq!(revs.len(), 1);
217        assert_eq!(adds[0].grant.privilege, Privilege::Usage);
218        assert_eq!(revs[0].grant.privilege, Privilege::Select);
219    }
220
221    /// Regression for issue #23: when the mutated (source) catalog removes
222    /// grantees from a default-privilege rule but those grantees are not
223    /// mentioned elsewhere in the source catalog, they were silently dropped
224    /// from the REVOKE side (classified as "unmanaged"). The fix: when the
225    /// source catalog explicitly declares the rule (same key exists in source),
226    /// treat target grantees for that rule as managed regardless of the
227    /// catalog-wide `managed_roles` set.
228    #[test]
229    fn revoke_unmanaged_grantee_when_source_owns_the_rule() {
230        use crate::ir::grant::GrantTarget;
231
232        // Target has 3 grants including Role("app") as grantee.
233        let tgt = rule(
234            "app",
235            None,
236            DefaultPrivObjectType::Schemas,
237            vec![
238                Grant {
239                    grantee: GrantTarget::Public,
240                    privilege: Privilege::Usage,
241                    with_grant_option: false,
242                    columns: None,
243                },
244                Grant {
245                    grantee: GrantTarget::Role(id("app")),
246                    privilege: Privilege::Usage,
247                    with_grant_option: false,
248                    columns: None,
249                },
250                Grant {
251                    grantee: GrantTarget::Role(id("app")),
252                    privilege: Privilege::Create,
253                    with_grant_option: false,
254                    columns: None,
255                },
256            ],
257        );
258
259        // Source has the same rule key but only 1 grant (Public/Usage).
260        // "app" does NOT appear in managed_roles.
261        let src = rule(
262            "app",
263            None,
264            DefaultPrivObjectType::Schemas,
265            vec![Grant {
266                grantee: GrantTarget::Public,
267                privilege: Privilege::Usage,
268                with_grant_option: false,
269                columns: None,
270            }],
271        );
272
273        // "app" is not in managed_roles — only "app_owner" is.
274        let changes = diff_default_privileges(&[tgt], &[src], &managed(&["app_owner"]));
275
276        // Must emit 2 REVOKEs for Role("app")/Usage and Role("app")/Create.
277        let revokes: Vec<_> = changes.iter().filter(|c| !c.is_grant).collect();
278        assert_eq!(
279            revokes.len(),
280            2,
281            "expected 2 revokes for Role(app) grants, got: {changes:?}"
282        );
283        let priv_set: std::collections::BTreeSet<Privilege> =
284            revokes.iter().map(|c| c.grant.privilege).collect();
285        assert!(
286            priv_set.contains(&Privilege::Usage),
287            "expected REVOKE Usage"
288        );
289        assert!(
290            priv_set.contains(&Privilege::Create),
291            "expected REVOKE Create"
292        );
293        // No spurious GRANTs.
294        let grants_count = changes.iter().filter(|c| c.is_grant).count();
295        assert_eq!(grants_count, 0, "expected no new grants, got: {changes:?}");
296    }
297
298    /// Regression for issue #30: same shape as #23 but the `target_role` of the
299    /// rule (`ops`) also appears as a grantee in the live (target) grants.
300    /// If `ops` is not in `managed_roles` and does not appear in the source's
301    /// grant list (only `readers` does), then prior to the fix the extended
302    /// managed set would not include `ops` and the two REVOKE steps would be
303    /// suppressed.
304    ///
305    /// Scenario from the bug report:
306    ///   target: `[Grant{ops, Usage}, Grant{ops, Create}, Grant{readers, Create}]`
307    ///   source: `[Grant{readers, Create}]`
308    ///   `managed_roles`: `{readers}` (tight — `ops` is absent)
309    ///
310    /// Expected: 2 REVOKE steps (for the two `ops` grants).
311    #[test]
312    fn revoke_target_role_as_grantee_not_in_source_grants() {
313        // target (live database state)
314        let tgt = rule(
315            "ops",
316            None,
317            DefaultPrivObjectType::Schemas,
318            vec![
319                grant_to("ops", Privilege::Usage),
320                grant_to("ops", Privilege::Create),
321                grant_to("readers", Privilege::Create),
322            ],
323        );
324
325        // source (desired state): only readers/Create remains
326        let src = rule(
327            "ops",
328            None,
329            DefaultPrivObjectType::Schemas,
330            vec![grant_to("readers", Privilege::Create)],
331        );
332
333        // managed_roles contains only `readers`; `ops` is deliberately absent
334        let changes = diff_default_privileges(&[tgt], &[src], &managed(&["readers"]));
335
336        let revokes: Vec<_> = changes.iter().filter(|c| !c.is_grant).collect();
337        assert_eq!(
338            revokes.len(),
339            2,
340            "expected 2 REVOKEs for the ops grants, got: {changes:?}"
341        );
342        let priv_set: std::collections::BTreeSet<Privilege> =
343            revokes.iter().map(|c| c.grant.privilege).collect();
344        assert!(
345            priv_set.contains(&Privilege::Usage),
346            "expected REVOKE Usage for ops"
347        );
348        assert!(
349            priv_set.contains(&Privilege::Create),
350            "expected REVOKE Create for ops"
351        );
352        // No spurious GRANTs.
353        let grants_count = changes.iter().filter(|c| c.is_grant).count();
354        assert_eq!(grants_count, 0, "expected no new grants, got: {changes:?}");
355    }
356}