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