Skip to main content

omni_dev/drive/
visibility.rs

1//! Drive move visibility-diff algorithm — the security-critical core of
2//! `drive move`'s safety gate ([ADR-0070](../../docs/adrs/adr-0070.md)).
3//!
4//! Deliberately pure: zero `DriveClient`/network dependency, and zero
5//! dependency on `crate::drive::file_move` (or any other engine module) —
6//! this module classifies an already-fetched set of permission snapshots,
7//! so the whole file is unit-testable with no wiremock at all.
8//! `DrivePermission` fetching lives in `crate::drive::permissions_api`;
9//! orchestration (which files to fetch, calling [`classify`], gating the
10//! move) lives in `crate::drive::file_move`.
11//!
12//! # The algorithm
13//!
14//! Drive's `permissions.list(fileId)` returns a file's full *effective*
15//! permission set (direct + inherited, merged) — but the
16//! `permissionDetails[].inherited` flag that would let us split "direct"
17//! from "inherited" is only populated for Shared Drive items, not My Drive
18//! files. So instead of reading that split off the file directly, it's
19//! derived by subtraction, from three snapshots the caller fetches:
20//!
21//! ```text
22//! before          = principal_set(permissions.list(file_id))
23//! current_parent  = principal_set(permissions.list(union of file's current parent(s)))  // ∅ if none
24//! dest            = principal_set(permissions.list(dest_folder_id))
25//!
26//! direct_on_file  = before − current_parent      // what's granted on the file, not inherited
27//! after           = direct_on_file ∪ dest
28//!
29//! added   = after − before     // visibility increase: new principals gain access
30//! removed = before − after     // visibility decrease: principals lose access
31//! ```
32//!
33//! Multi-parent legacy files: the caller unions permissions across *every*
34//! current parent, not just the first, before calling [`diff_visibility`].
35//! An orphan/root file with no current parent passes an empty
36//! `current_parent_perms` slice, which degenerates correctly to "everything
37//! on the file counts as direct."
38//!
39//! # Known limitation: shadowed grants
40//!
41//! If a principal has *both* a direct grant on the file and inherited
42//! access via the current parent (a "shadowed" grant), the subtraction
43//! can't distinguish them: `direct_on_file` won't include that principal
44//! (it's in `current_parent` too), so if `dest` also doesn't grant it,
45//! `after` won't include it either — [`diff_visibility`] reports it as
46//! losing access even though it actually keeps it via the shadowed direct
47//! grant, invisible to this subtraction.
48//!
49//! This is the **safe failure direction**: it can only produce a **false
50//! positive** on `removed` (an unnecessary `--allow-visibility-decrease`
51//! requirement), **never a false negative** on `added` — `direct_on_file ⊆
52//! before` always holds, and `dest` enters `after` unfiltered, so a real
53//! visibility increase is always caught. Accepted, not a bug to fix later.
54
55use std::collections::BTreeSet;
56
57use crate::drive::types::DrivePermission;
58
59/// A Drive permission's identity, independent of `role`.
60///
61/// `role` is deliberately excluded from this key: a role change for an
62/// already-visible principal (e.g. `reader` → `writer`) doesn't gate a move
63/// in v1 — only set membership (added/removed) does — but is still visible
64/// informationally via `DrivePermission::role` on the raw snapshots, for
65/// logging that wants it.
66#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
67pub enum Principal {
68    /// A specific Google account, keyed by email.
69    User(String),
70    /// A Google Group, keyed by email.
71    Group(String),
72    /// Every account in a Google Workspace domain.
73    Domain(String),
74    /// "Anyone with the link" — public.
75    Anyone,
76}
77
78impl std::fmt::Display for Principal {
79    /// A stable, log/report-friendly rendering — used by
80    /// `crate::drive::file_move`'s `VisibilityDiffReport` and the request
81    /// log's `added_principals`/`removed_principals` fields.
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            Self::User(email) => write!(f, "user:{email}"),
85            Self::Group(email) => write!(f, "group:{email}"),
86            Self::Domain(domain) => write!(f, "domain:{domain}"),
87            Self::Anyone => write!(f, "anyone"),
88        }
89    }
90}
91
92/// Builds the set of principals a permission list grants access to.
93///
94/// Unrecognised `DrivePermission::permission_type` values are skipped
95/// rather than erroring — forward-compatible with a Drive permission type
96/// this module doesn't yet model, at the cost of that permission never
97/// contributing to a diff (fail-safe: an unmodelled *grant* is simply
98/// invisible to `added`/`removed`, never miscounted as either).
99#[must_use]
100pub fn principal_set(perms: &[DrivePermission]) -> BTreeSet<Principal> {
101    perms.iter().filter_map(principal_of).collect()
102}
103
104fn principal_of(perm: &DrivePermission) -> Option<Principal> {
105    match perm.permission_type.as_str() {
106        "user" => perm.email_address.clone().map(Principal::User),
107        "group" => perm.email_address.clone().map(Principal::Group),
108        "domain" => perm.domain.clone().map(Principal::Domain),
109        "anyone" => Some(Principal::Anyone),
110        _ => None,
111    }
112}
113
114/// The result of diffing a file's visibility before/after a hypothetical
115/// move — see the module doc for the algorithm and its known limitation.
116#[derive(Debug, Clone, Default, PartialEq, Eq)]
117pub struct VisibilityDiff {
118    /// Principals gaining access.
119    pub added: BTreeSet<Principal>,
120    /// Principals losing access (may include false positives from a
121    /// shadowed direct+inherited grant — see the module doc).
122    pub removed: BTreeSet<Principal>,
123}
124
125/// Computes the visibility diff a move from the file's current parent(s) to
126/// a destination folder would produce, given three already-fetched
127/// permission snapshots (see the module doc's algorithm).
128#[must_use]
129pub fn diff_visibility(
130    file_perms: &[DrivePermission],
131    current_parent_perms: &[DrivePermission],
132    dest_folder_perms: &[DrivePermission],
133) -> VisibilityDiff {
134    let before = principal_set(file_perms);
135    let current_parent = principal_set(current_parent_perms);
136    let dest = principal_set(dest_folder_perms);
137
138    let direct_on_file: BTreeSet<Principal> = before.difference(&current_parent).cloned().collect();
139    let after: BTreeSet<Principal> = direct_on_file.union(&dest).cloned().collect();
140
141    let added = after.difference(&before).cloned().collect();
142    let removed = before.difference(&after).cloned().collect();
143
144    VisibilityDiff { added, removed }
145}
146
147/// Which safety gate(s) block a move.
148///
149/// A struct of bools, not a single-variant enum: a move can simultaneously
150/// fail more than one gate, and the audit log should say so precisely
151/// rather than reporting only the first match.
152#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
153pub struct BlockReasons {
154    /// `diff.added` was non-empty and `allow_visibility_increase` wasn't set.
155    pub visibility_increase: bool,
156    /// `diff.removed` was non-empty and `allow_visibility_decrease` wasn't
157    /// set.
158    pub visibility_decrease: bool,
159    /// The move crosses a My Drive / Shared Drive boundary and
160    /// `allow_drive_boundary_crossing` wasn't set.
161    pub drive_boundary_crossing: bool,
162}
163
164impl BlockReasons {
165    /// Whether any gate is blocking.
166    #[must_use]
167    pub fn any(self) -> bool {
168        self.visibility_increase || self.visibility_decrease || self.drive_boundary_crossing
169    }
170}
171
172/// The three independent, per-move opt-ins [`classify`] gates on.
173///
174/// A small struct local to this module (not `crate::drive::file_move`'s
175/// eventual `MoveOptions`, which also carries the destination folder id and
176/// other orchestration-only fields) — keeps this module's public API free
177/// of more than clippy's bool-parameter limit and free of any dependency on
178/// `file_move`, consistent with staying pure and independently testable.
179#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
180pub struct MoveGateFlags {
181    /// Allows a move that would grant new principals access.
182    pub allow_visibility_increase: bool,
183    /// Allows a move that would revoke existing principals' access.
184    pub allow_visibility_decrease: bool,
185    /// Allows a move across a My Drive / Shared Drive boundary.
186    pub allow_drive_boundary_crossing: bool,
187}
188
189/// Classifies whether a move is clear to proceed.
190///
191/// Returns `None` when clear; `Some(reasons)` naming every gate that blocks
192/// it.
193#[must_use]
194pub fn classify(
195    diff: &VisibilityDiff,
196    crosses_boundary: bool,
197    flags: MoveGateFlags,
198) -> Option<BlockReasons> {
199    let reasons = BlockReasons {
200        visibility_increase: !diff.added.is_empty() && !flags.allow_visibility_increase,
201        visibility_decrease: !diff.removed.is_empty() && !flags.allow_visibility_decrease,
202        drive_boundary_crossing: crosses_boundary && !flags.allow_drive_boundary_crossing,
203    };
204    reasons.any().then_some(reasons)
205}
206
207#[cfg(test)]
208#[allow(clippy::unwrap_used, clippy::expect_used)]
209mod tests {
210    use super::*;
211
212    fn user(email: &str) -> DrivePermission {
213        DrivePermission {
214            id: format!("perm-{email}"),
215            permission_type: "user".to_string(),
216            role: "reader".to_string(),
217            email_address: Some(email.to_string()),
218            domain: None,
219        }
220    }
221
222    fn group(email: &str) -> DrivePermission {
223        DrivePermission {
224            permission_type: "group".to_string(),
225            email_address: Some(email.to_string()),
226            ..user(email)
227        }
228    }
229
230    fn domain(name: &str) -> DrivePermission {
231        DrivePermission {
232            id: format!("perm-{name}"),
233            permission_type: "domain".to_string(),
234            role: "reader".to_string(),
235            email_address: None,
236            domain: Some(name.to_string()),
237        }
238    }
239
240    fn anyone() -> DrivePermission {
241        DrivePermission {
242            id: "perm-anyone".to_string(),
243            permission_type: "anyone".to_string(),
244            role: "reader".to_string(),
245            email_address: None,
246            domain: None,
247        }
248    }
249
250    // ── principal_set ────────────────────────────────────────────────
251
252    #[test]
253    fn principal_set_maps_every_recognized_type() {
254        let perms = vec![
255            user("alice@example.com"),
256            group("team@example.com"),
257            domain("example.com"),
258            anyone(),
259        ];
260        let set = principal_set(&perms);
261        assert!(set.contains(&Principal::User("alice@example.com".to_string())));
262        assert!(set.contains(&Principal::Group("team@example.com".to_string())));
263        assert!(set.contains(&Principal::Domain("example.com".to_string())));
264        assert!(set.contains(&Principal::Anyone));
265        assert_eq!(set.len(), 4);
266    }
267
268    #[test]
269    fn principal_set_skips_unrecognized_types() {
270        let mut weird = user("alice@example.com");
271        weird.permission_type = "somethingNew".to_string();
272        let set = principal_set(&[weird]);
273        assert!(set.is_empty());
274    }
275
276    #[test]
277    fn principal_set_skips_user_with_no_email_address() {
278        let mut malformed = user("alice@example.com");
279        malformed.email_address = None;
280        let set = principal_set(&[malformed]);
281        assert!(set.is_empty());
282    }
283
284    #[test]
285    fn principal_set_is_empty_for_no_permissions() {
286        assert!(principal_set(&[]).is_empty());
287    }
288
289    // ── diff_visibility ──────────────────────────────────────────────
290
291    // `file_perms` (the `before` snapshot) is `permissions.list(file_id)`'s
292    // full *merged* effective set — direct-on-file ∪ inherited-from-parent
293    // — not "direct grants only". When a test file has no extra direct
294    // grant beyond what it inherits, `file_perms` equals `current_parent`
295    // exactly (that equality is what lets the subtraction find nothing
296    // "direct" to preserve).
297
298    #[test]
299    fn diff_visibility_no_change_when_dest_grants_same_as_current_parent() {
300        let current_parent = vec![user("alice@example.com")];
301        let file = current_parent.clone(); // nothing direct beyond inheritance
302        let dest = vec![user("alice@example.com")];
303        let diff = diff_visibility(&file, &current_parent, &dest);
304        assert!(diff.added.is_empty());
305        assert!(diff.removed.is_empty());
306    }
307
308    #[test]
309    fn diff_visibility_detects_a_pure_increase() {
310        let current_parent = vec![user("alice@example.com")];
311        let file = current_parent.clone();
312        let dest = vec![user("alice@example.com"), user("bob@example.com")];
313        let diff = diff_visibility(&file, &current_parent, &dest);
314        assert_eq!(
315            diff.added,
316            BTreeSet::from([Principal::User("bob@example.com".to_string())])
317        );
318        assert!(diff.removed.is_empty());
319    }
320
321    #[test]
322    fn diff_visibility_detects_a_pure_decrease() {
323        let current_parent = vec![user("alice@example.com"), user("bob@example.com")];
324        let file = current_parent.clone();
325        let dest = vec![user("alice@example.com")];
326        let diff = diff_visibility(&file, &current_parent, &dest);
327        assert!(diff.added.is_empty());
328        assert_eq!(
329            diff.removed,
330            BTreeSet::from([Principal::User("bob@example.com".to_string())])
331        );
332    }
333
334    #[test]
335    fn diff_visibility_detects_both_an_increase_and_a_decrease() {
336        let current_parent = vec![user("alice@example.com")];
337        let file = current_parent.clone();
338        let dest = vec![user("bob@example.com")];
339        let diff = diff_visibility(&file, &current_parent, &dest);
340        assert_eq!(
341            diff.added,
342            BTreeSet::from([Principal::User("bob@example.com".to_string())])
343        );
344        assert_eq!(
345            diff.removed,
346            BTreeSet::from([Principal::User("alice@example.com".to_string())])
347        );
348    }
349
350    #[test]
351    fn diff_visibility_preserves_a_direct_grant_on_the_file_across_the_move() {
352        // alice has a direct grant on the file itself, not inherited from
353        // the current parent — moving to a dest that grants nobody must
354        // not report her as losing access.
355        let file = vec![user("alice@example.com")];
356        let current_parent = vec![];
357        let dest = vec![];
358        let diff = diff_visibility(&file, &current_parent, &dest);
359        assert!(diff.added.is_empty());
360        assert!(diff.removed.is_empty());
361    }
362
363    #[test]
364    fn diff_visibility_orphan_file_with_no_current_parent_treats_everything_as_direct() {
365        let file = vec![user("alice@example.com")];
366        let current_parent = vec![]; // no current parent at all
367        let dest = vec![];
368        let diff = diff_visibility(&file, &current_parent, &dest);
369        // alice's grant is on the file itself (nothing to subtract), so it
370        // survives the move regardless of what dest grants.
371        assert!(diff.removed.is_empty());
372    }
373
374    #[test]
375    fn diff_visibility_unions_permissions_across_multiple_current_parents() {
376        // A multi-parent legacy file: alice is only visible via the SECOND
377        // parent. Passing only the first parent's permissions would
378        // wrongly treat alice's access as direct-on-file and report a
379        // spurious `removed` when it's actually inherited and about to be
380        // lost. The caller is responsible for unioning both parents'
381        // permissions before calling this function — this test documents
382        // that contract by doing the union inline.
383        let parent_a_perms = vec![user("carol@example.com")];
384        let parent_b_perms = vec![user("alice@example.com")];
385        let current_parent: Vec<DrivePermission> =
386            parent_a_perms.into_iter().chain(parent_b_perms).collect();
387        let file = current_parent.clone(); // nothing direct beyond inheritance
388        let dest = vec![user("carol@example.com")]; // drops alice
389        let diff = diff_visibility(&file, &current_parent, &dest);
390        assert_eq!(
391            diff.removed,
392            BTreeSet::from([Principal::User("alice@example.com".to_string())])
393        );
394    }
395
396    #[test]
397    fn diff_visibility_shadowed_grant_only_ever_produces_a_false_positive_on_removed() {
398        // alice has BOTH a direct grant on the file AND inherited access
399        // via the current parent (the shadowed case the module doc
400        // describes). dest grants nobody. The subtraction can't see the
401        // direct grant (it's masked by current_parent), so this
402        // over-reports her as losing access — a false positive on
403        // `removed` — but critically never produces a false negative on
404        // `added` for anyone else in the same scenario.
405        let file = vec![user("alice@example.com")]; // direct grant
406        let current_parent = vec![user("alice@example.com")]; // same principal, inherited
407        let dest = vec![user("bob@example.com")]; // a real, independent increase
408        let diff = diff_visibility(&file, &current_parent, &dest);
409        // False positive: alice is reported as removed even though her
410        // direct grant would actually survive the move.
411        assert!(diff
412            .removed
413            .contains(&Principal::User("alice@example.com".to_string())));
414        // No false negative: the real increase (bob) is still caught.
415        assert_eq!(
416            diff.added,
417            BTreeSet::from([Principal::User("bob@example.com".to_string())])
418        );
419    }
420
421    #[test]
422    fn diff_visibility_no_op_move_reports_no_change() {
423        let perms = vec![user("alice@example.com"), domain("example.com")];
424        let diff = diff_visibility(&perms, &perms, &perms);
425        assert!(diff.added.is_empty());
426        assert!(diff.removed.is_empty());
427    }
428
429    // ── classify ─────────────────────────────────────────────────────
430
431    fn diff_with(added: &[&str], removed: &[&str]) -> VisibilityDiff {
432        VisibilityDiff {
433            added: added
434                .iter()
435                .map(|e| Principal::User((*e).to_string()))
436                .collect(),
437            removed: removed
438                .iter()
439                .map(|e| Principal::User((*e).to_string()))
440                .collect(),
441        }
442    }
443
444    fn flags(increase: bool, decrease: bool, boundary: bool) -> MoveGateFlags {
445        MoveGateFlags {
446            allow_visibility_increase: increase,
447            allow_visibility_decrease: decrease,
448            allow_drive_boundary_crossing: boundary,
449        }
450    }
451
452    #[test]
453    fn classify_is_clear_when_nothing_changes_and_no_boundary_crossing() {
454        let diff = diff_with(&[], &[]);
455        assert_eq!(classify(&diff, false, flags(false, false, false)), None);
456    }
457
458    #[test]
459    fn classify_blocks_an_unallowed_increase() {
460        let diff = diff_with(&["bob@example.com"], &[]);
461        let reasons = classify(&diff, false, flags(false, false, false)).unwrap();
462        assert!(reasons.visibility_increase);
463        assert!(!reasons.visibility_decrease);
464        assert!(!reasons.drive_boundary_crossing);
465    }
466
467    #[test]
468    fn classify_allows_an_increase_when_opted_in() {
469        let diff = diff_with(&["bob@example.com"], &[]);
470        assert_eq!(classify(&diff, false, flags(true, false, false)), None);
471    }
472
473    #[test]
474    fn classify_blocks_an_unallowed_decrease() {
475        let diff = diff_with(&[], &["alice@example.com"]);
476        let reasons = classify(&diff, false, flags(false, false, false)).unwrap();
477        assert!(!reasons.visibility_increase);
478        assert!(reasons.visibility_decrease);
479        assert!(!reasons.drive_boundary_crossing);
480    }
481
482    #[test]
483    fn classify_allows_a_decrease_when_opted_in() {
484        let diff = diff_with(&[], &["alice@example.com"]);
485        assert_eq!(classify(&diff, false, flags(false, true, false)), None);
486    }
487
488    #[test]
489    fn classify_blocks_an_unallowed_boundary_crossing_even_with_no_visibility_change() {
490        let diff = diff_with(&[], &[]);
491        let reasons = classify(&diff, true, flags(false, false, false)).unwrap();
492        assert!(!reasons.visibility_increase);
493        assert!(!reasons.visibility_decrease);
494        assert!(reasons.drive_boundary_crossing);
495    }
496
497    #[test]
498    fn classify_allows_a_boundary_crossing_when_opted_in() {
499        let diff = diff_with(&[], &[]);
500        assert_eq!(classify(&diff, true, flags(false, false, true)), None);
501    }
502
503    #[test]
504    fn classify_reports_every_simultaneously_failing_gate() {
505        let diff = diff_with(&["bob@example.com"], &["alice@example.com"]);
506        let reasons = classify(&diff, true, flags(false, false, false)).unwrap();
507        assert!(reasons.visibility_increase);
508        assert!(reasons.visibility_decrease);
509        assert!(reasons.drive_boundary_crossing);
510    }
511
512    #[test]
513    fn classify_allows_when_every_relevant_flag_is_opted_in() {
514        let diff = diff_with(&["bob@example.com"], &["alice@example.com"]);
515        assert_eq!(classify(&diff, true, flags(true, true, true)), None);
516    }
517
518    #[test]
519    fn block_reasons_any_is_false_by_default() {
520        assert!(!BlockReasons::default().any());
521    }
522}