Skip to main content

pgevolve_core/diff/
grants.rs

1//! Shared grant-list diffing with lenient drift policy.
2//!
3//! The central rule: never silently REVOKE a grant to a role that is not
4//! mentioned anywhere in the source catalog. Surface unmanaged grants in the
5//! third return slot instead so downstream lint rules (Stage 11) can decide
6//! what to do with them.
7
8use std::collections::BTreeSet;
9
10use crate::identifier::Identifier;
11use crate::ir::catalog::Catalog;
12use crate::ir::grant::{Grant, GrantTarget};
13
14/// Compute additions and removals between target (catalog) and source (desired).
15///
16/// `managed_roles`: the set of role names mentioned anywhere in the source
17/// catalog. Grants whose grantee is not in this set are considered unmanaged
18/// and excluded from the revoke side (lenient policy). `PUBLIC` is always
19/// considered managed.
20///
21/// Returns `(to_add, to_revoke, unmanaged_observed)`.
22///
23/// # Caller contract — emit order
24///
25/// Callers **must** push `to_revoke` entries into the change-set *before*
26/// `to_add` entries. This ensures that when a grant's `with_grant_option` flag
27/// changes (same grantee + privilege + columns, different WGO value), the plan
28/// executes `REVOKE … FROM …` before `GRANT … TO … [WITH GRANT OPTION]`.
29/// Reversing the order causes the GRANT to be immediately undone by the
30/// following REVOKE, leaving the live database without the privilege entirely.
31#[must_use]
32pub fn diff_grants(
33    target: &[Grant],
34    source: &[Grant],
35    managed_roles: &BTreeSet<Identifier>,
36) -> (Vec<Grant>, Vec<Grant>, Vec<Grant>) {
37    let target_set: BTreeSet<&Grant> = target.iter().collect();
38    let source_set: BTreeSet<&Grant> = source.iter().collect();
39
40    let to_add: Vec<Grant> = source_set
41        .difference(&target_set)
42        .map(|g| (*g).clone())
43        .collect();
44
45    let mut to_revoke = Vec::new();
46    let mut unmanaged_observed = Vec::new();
47    for g in target_set.difference(&source_set) {
48        if grantee_is_managed(&g.grantee, managed_roles) {
49            to_revoke.push((*g).clone());
50        } else {
51            unmanaged_observed.push((*g).clone());
52        }
53    }
54    (to_add, to_revoke, unmanaged_observed)
55}
56
57fn grantee_is_managed(target: &GrantTarget, managed_roles: &BTreeSet<Identifier>) -> bool {
58    match target {
59        GrantTarget::Public => true,
60        GrantTarget::Role(name) => managed_roles.contains(name),
61    }
62}
63
64/// Collect every role name referenced anywhere in the source catalog —
65/// in grants, owners, and default-privilege rules. Used as input to
66/// [`diff_grants`].
67#[must_use]
68pub fn collect_managed_roles(cat: &Catalog) -> BTreeSet<Identifier> {
69    let mut out = BTreeSet::new();
70
71    for s in &cat.schemas {
72        collect_grants_into(&s.grants, &mut out);
73        if let Some(o) = &s.owner {
74            out.insert(o.clone());
75        }
76    }
77    for s in &cat.sequences {
78        collect_grants_into(&s.grants, &mut out);
79        if let Some(o) = &s.owner {
80            out.insert(o.clone());
81        }
82    }
83    for t in &cat.tables {
84        collect_grants_into(&t.grants, &mut out);
85        if let Some(o) = &t.owner {
86            out.insert(o.clone());
87        }
88    }
89    for v in &cat.views {
90        collect_grants_into(&v.grants, &mut out);
91        if let Some(o) = &v.owner {
92            out.insert(o.clone());
93        }
94    }
95    for m in &cat.materialized_views {
96        collect_grants_into(&m.grants, &mut out);
97        if let Some(o) = &m.owner {
98            out.insert(o.clone());
99        }
100    }
101    for f in &cat.functions {
102        collect_grants_into(&f.grants, &mut out);
103        if let Some(o) = &f.owner {
104            out.insert(o.clone());
105        }
106    }
107    for p in &cat.procedures {
108        collect_grants_into(&p.grants, &mut out);
109        if let Some(o) = &p.owner {
110            out.insert(o.clone());
111        }
112    }
113    for t in &cat.types {
114        collect_grants_into(&t.grants, &mut out);
115        if let Some(o) = &t.owner {
116            out.insert(o.clone());
117        }
118    }
119    for r in &cat.default_privileges {
120        out.insert(r.target_role.clone());
121        collect_grants_into(&r.grants, &mut out);
122    }
123
124    out
125}
126
127/// Insert the role name of every named-role grantee from `grants` into `set`.
128fn collect_grants_into(grants: &[Grant], set: &mut BTreeSet<Identifier>) {
129    for g in grants {
130        if let GrantTarget::Role(name) = &g.grantee {
131            set.insert(name.clone());
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::ir::grant::{Grant, GrantTarget, Privilege};
140
141    fn id(s: &str) -> Identifier {
142        Identifier::from_unquoted(s).unwrap()
143    }
144
145    fn grant_to_role(role: &str, priv_: Privilege) -> Grant {
146        Grant {
147            grantee: GrantTarget::Role(id(role)),
148            privilege: priv_,
149            with_grant_option: false,
150            columns: None,
151        }
152    }
153
154    fn grant_public(priv_: Privilege) -> Grant {
155        Grant {
156            grantee: GrantTarget::Public,
157            privilege: priv_,
158            with_grant_option: false,
159            columns: None,
160        }
161    }
162
163    fn managed(roles: &[&str]) -> BTreeSet<Identifier> {
164        roles.iter().map(|r| id(r)).collect()
165    }
166
167    #[test]
168    fn empty_lists_yield_empty_diff() {
169        let (add, rev, unmanaged) = diff_grants(&[], &[], &managed(&[]));
170        assert!(add.is_empty());
171        assert!(rev.is_empty());
172        assert!(unmanaged.is_empty());
173    }
174
175    #[test]
176    fn add_only_grant_in_source_not_in_target() {
177        let g = grant_to_role("alice", Privilege::Select);
178        let (add, rev, unmanaged) =
179            diff_grants(&[], std::slice::from_ref(&g), &managed(&["alice"]));
180        assert_eq!(add, vec![g]);
181        assert!(rev.is_empty());
182        assert!(unmanaged.is_empty());
183    }
184
185    #[test]
186    fn revoke_managed_grantee() {
187        // grant in target not in source, grantee is managed → to_revoke
188        let g = grant_to_role("alice", Privilege::Select);
189        let (add, rev, unmanaged) =
190            diff_grants(std::slice::from_ref(&g), &[], &managed(&["alice"]));
191        assert!(add.is_empty());
192        assert_eq!(rev, vec![g]);
193        assert!(unmanaged.is_empty());
194    }
195
196    #[test]
197    fn ignore_unmanaged_grantee() {
198        // grant in target not in source, grantee not in managed_roles → unmanaged_observed
199        let g = grant_to_role("external_role", Privilege::Select);
200        let (add, rev, unmanaged) = diff_grants(std::slice::from_ref(&g), &[], &managed(&[]));
201        assert!(add.is_empty());
202        assert!(rev.is_empty());
203        assert_eq!(unmanaged, vec![g]);
204    }
205
206    #[test]
207    fn public_always_managed() {
208        // PUBLIC grantee is never unmanaged — even when managed_roles is empty
209        let g = grant_public(Privilege::Usage);
210        let (add, rev, unmanaged) = diff_grants(std::slice::from_ref(&g), &[], &managed(&[]));
211        assert!(add.is_empty());
212        assert_eq!(rev, vec![g]);
213        assert!(unmanaged.is_empty());
214    }
215
216    /// Regression for issue #33: when `with_grant_option` changes on an
217    /// existing grant (same grantee + privilege, different WGO flag), the diff
218    /// produces both a `to_revoke` (old WGO value) and a `to_add` (new WGO
219    /// value). Callers **must** emit the REVOKE before the GRANT so that the
220    /// GRANT is not immediately cancelled by the subsequent REVOKE.
221    ///
222    /// This test documents the rule: `to_add` and `to_revoke` for a WGO change
223    /// are distinct elements (different `with_grant_option` value) so both
224    /// appear in the output; the caller must respect the emit order described
225    /// in `diff_grants`'s doc comment.
226    #[test]
227    fn wgo_upgrade_produces_both_add_and_revoke() {
228        // target: readers/Select WITHOUT grant option
229        // source: readers/Select WITH grant option
230        let target = Grant {
231            grantee: GrantTarget::Role(id("readers")),
232            privilege: Privilege::Select,
233            with_grant_option: false,
234            columns: None,
235        };
236        let source = Grant {
237            grantee: GrantTarget::Role(id("readers")),
238            privilege: Privilege::Select,
239            with_grant_option: true,
240            columns: None,
241        };
242        let (add, rev, unmanaged) = diff_grants(
243            std::slice::from_ref(&target),
244            std::slice::from_ref(&source),
245            &managed(&["readers"]),
246        );
247        // One add: readers/Select wgo=true (source wants it)
248        assert_eq!(add.len(), 1, "expected exactly one grant to add: {add:?}");
249        assert!(add[0].with_grant_option, "added grant must have wgo=true");
250        // One revoke: readers/Select wgo=false (target has it, source does not)
251        assert_eq!(
252            rev.len(),
253            1,
254            "expected exactly one grant to revoke: {rev:?}"
255        );
256        assert!(
257            !rev[0].with_grant_option,
258            "revoked grant must have wgo=false"
259        );
260        assert!(unmanaged.is_empty());
261        // Caller contract: emit `rev` BEFORE `add`. If the caller emits
262        // `add` first then `rev`, the executed SQL would be:
263        //   GRANT SELECT TO readers WITH GRANT OPTION;  -- adds WGO
264        //   REVOKE SELECT FROM readers;                 -- removes the grant entirely!
265        // Emitting `rev` first then `add` gives the correct result:
266        //   REVOKE SELECT FROM readers;                 -- removes old grant (no WGO)
267        //   GRANT SELECT TO readers WITH GRANT OPTION;  -- grants with WGO
268    }
269
270    #[test]
271    fn wgo_downgrade_produces_both_add_and_revoke() {
272        // target: readers/Select WITH grant option
273        // source: readers/Select WITHOUT grant option
274        let target = Grant {
275            grantee: GrantTarget::Role(id("readers")),
276            privilege: Privilege::Select,
277            with_grant_option: true,
278            columns: None,
279        };
280        let source = Grant {
281            grantee: GrantTarget::Role(id("readers")),
282            privilege: Privilege::Select,
283            with_grant_option: false,
284            columns: None,
285        };
286        let (add, rev, unmanaged) = diff_grants(
287            std::slice::from_ref(&target),
288            std::slice::from_ref(&source),
289            &managed(&["readers"]),
290        );
291        assert_eq!(add.len(), 1, "expected one add (wgo=false)");
292        assert!(!add[0].with_grant_option, "added grant must have wgo=false");
293        assert_eq!(rev.len(), 1, "expected one revoke (wgo=true)");
294        assert!(rev[0].with_grant_option, "revoked grant must have wgo=true");
295        assert!(unmanaged.is_empty());
296    }
297}