Skip to main content

omni_dev/drive/
write_gate.rs

1//! Folder-scoped write-permission gate (issue #1574).
2//!
3//! `omni-dev`'s own local policy layer bounding `drive create`/`upload`/
4//! `edit`, independent of and enforced *in addition to* whatever the OAuth
5//! scope (`crate::drive::auth::DriveGrantedScopes`) would technically
6//! allow. Google's Drive scopes are all-or-nothing across a user's whole
7//! Drive — there is no Google-side way to say "this credential may only
8//! write inside folder X" — so this module fills that gap.
9//!
10//! Deliberately pure: zero `DriveClient`/network dependency, mirroring
11//! `crate::drive::visibility`'s contract exactly. Fetching the ancestor
12//! folder chain a target lives in is `crate::drive::folder_ancestry`'s job;
13//! this module only classifies an already-resolved chain.
14//!
15//! Named `write_gate`, not `permission(s)`, to avoid any confusion with
16//! `crate::drive::permissions_api` — Google's own sharing/ACL wrapper,
17//! a completely unrelated concept.
18//!
19//! # The algorithm
20//!
21//! A target (the `--parent` folder for `create`/`upload`, or a file's
22//! current parent folder(s) for `edit`) is identified by its **ancestor
23//! chain**: `chain[0]` is the target folder itself, `chain[1]` its parent,
24//! `chain[2]` its grandparent, and so on up to Drive's root. [`resolve`]
25//! walks that chain looking for the closest (lowest-depth) rule naming any
26//! folder in it — a non-recursive rule only ever matches at depth 0, its
27//! own folder; a recursive rule matches at any depth. Two tie-breaks, both
28//! security-relevant and each covered by a dedicated test:
29//!
30//! - **Closest ancestor wins**: a rule on a subfolder overrides a broader
31//!   rule on its parent — the more specific grant/restriction is assumed
32//!   the more deliberate one.
33//! - **Deny beats allow at equal depth**: if two rules at the same depth
34//!   disagree, the safe direction wins.
35//!
36//! When no rule anywhere in the chain names `op`, [`DriveOperation::default_policy`]
37//! decides it: `Read` defaults to [`Verdict::Allow`], every write operation
38//! defaults to [`Verdict::Deny`]. This is where "disabled by default" for
39//! writes actually lives — there is deliberately no separate enabled/
40//! disabled toggle; an absent or empty rule list already means "deny
41//! everywhere" via this table alone.
42
43use std::collections::HashSet;
44
45use serde::{Deserialize, Serialize};
46
47/// A Drive operation this gate can permit or refuse. Reused directly as the
48/// settings-file rule shape (`crate::utils::settings::WritePermissionsSettings`)
49/// — no separate wire type.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
51#[serde(rename_all = "lowercase")]
52pub enum DriveOperation {
53    /// List/read/export/download.
54    Read,
55    /// Create a new file or folder.
56    Create,
57    /// Upload local content into a new file.
58    Upload,
59    /// Replace an existing file's content.
60    Edit,
61}
62
63impl std::fmt::Display for DriveOperation {
64    /// Lowercase, matching the `#[serde(rename_all = "lowercase")]` wire
65    /// form — used by `drive permissions show`/`check`'s rendering.
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        let s = match self {
68            Self::Read => "read",
69            Self::Create => "create",
70            Self::Upload => "upload",
71            Self::Edit => "edit",
72        };
73        write!(f, "{s}")
74    }
75}
76
77impl DriveOperation {
78    /// The verdict when no configured rule names this operation anywhere in
79    /// a target's ancestor chain. `Read` stays open by default (unchanged
80    /// from today's behavior); every write defaults closed — the whole
81    /// "disabled by default" requirement lives in this one match, not a
82    /// separate on/off flag.
83    fn default_policy(self) -> Verdict {
84        match self {
85            Self::Read => Verdict::Allow,
86            Self::Create | Self::Upload | Self::Edit => Verdict::Deny,
87        }
88    }
89}
90
91/// One configured folder rule.
92///
93/// `folder_id` is Drive's own canonical id, not a path — Drive folders
94/// have no stable, unique path (names collide, files can have multiple
95/// legacy parents), so identity is the id, exactly as the browser
96/// bridge's `OriginAllowlist` matches exact origin strings rather than
97/// URL patterns.
98#[derive(Debug, Clone, Deserialize, Serialize)]
99pub struct FolderPermissionRule {
100    /// The Drive folder id this rule matches.
101    pub folder_id: String,
102    /// When `true`, this rule also matches every descendant of `folder_id`,
103    /// not just the folder itself (depth > 0 in the ancestor chain). A
104    /// non-recursive rule only ever matches at depth 0.
105    #[serde(default)]
106    pub recursive: bool,
107    /// Operations explicitly permitted at this folder.
108    #[serde(default)]
109    pub allow: HashSet<DriveOperation>,
110    /// Operations explicitly refused at this folder.
111    #[serde(default)]
112    pub deny: HashSet<DriveOperation>,
113}
114
115/// The result of resolving a single operation against a rule set.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum Verdict {
118    /// The operation is permitted.
119    Allow,
120    /// The operation is refused.
121    Deny,
122}
123
124/// Which configured rule (if any) decided a [`Decision`].
125///
126/// `None` means no rule matched anywhere in the chain and the bare
127/// default policy decided it instead. Carried into the request log's
128/// `decided_by_folder_id`/`decided_by_depth` context fields so a refusal
129/// is exactly as auditable as a success.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
131pub struct DecidingRule {
132    /// The folder id of the rule that decided the verdict.
133    pub folder_id: String,
134    /// How many levels above the target this rule's folder sits (0 = the
135    /// target itself).
136    pub depth: usize,
137}
138
139/// The outcome of [`resolve`]: whether an operation is permitted, and which
140/// rule (if any) decided it.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct Decision {
143    /// Whether the operation is permitted.
144    pub verdict: Verdict,
145    /// The configured rule that decided this, or `None` when the bare
146    /// default policy decided it instead.
147    pub decided_by: Option<DecidingRule>,
148}
149
150/// Resolves whether `op` is permitted against `chain` (depth 0 = the
151/// target folder itself, then parent, grandparent, ...) under `rules`. See
152/// the module doc for the full algorithm and its tie-breaks.
153#[must_use]
154pub fn resolve(chain: &[String], op: DriveOperation, rules: &[FolderPermissionRule]) -> Decision {
155    let mut best: Option<(usize, Verdict)> = None;
156    for (depth, folder_id) in chain.iter().enumerate() {
157        let mut deny_here = false;
158        let mut allow_here = false;
159        for rule in rules {
160            if rule.folder_id != *folder_id {
161                continue;
162            }
163            if depth > 0 && !rule.recursive {
164                continue;
165            }
166            deny_here |= rule.deny.contains(&op);
167            allow_here |= rule.allow.contains(&op);
168        }
169        if !deny_here && !allow_here {
170            continue;
171        }
172        let verdict = if deny_here {
173            Verdict::Deny
174        } else {
175            Verdict::Allow
176        };
177        let is_closer = match best {
178            Some((best_depth, _)) => depth < best_depth,
179            None => true,
180        };
181        if is_closer {
182            best = Some((depth, verdict));
183        }
184    }
185    match best {
186        Some((depth, verdict)) => Decision {
187            verdict,
188            decided_by: Some(DecidingRule {
189                folder_id: chain[depth].clone(),
190                depth,
191            }),
192        },
193        None => Decision {
194            verdict: op.default_policy(),
195            decided_by: None,
196        },
197    }
198}
199
200/// Combines per-parent [`Decision`]s into one, for a target with more than
201/// one current parent (a legacy multi-parent file — Drive no longer
202/// permits creating new ones).
203///
204/// Deny wins across parents, the same fail-closed direction every other
205/// tie-break in this module takes. Callers with a single parent (the
206/// common case) can call [`resolve`] directly instead; an *orphan* target
207/// (zero parents) should call `resolve(&[], op, rules)` directly too,
208/// rather than calling this with zero decisions — a target's chain
209/// degenerates to "only the default policy applies," not to a policy
210/// about *no* chain, so `first` requires at least one decision by
211/// construction.
212///
213/// Shared by `drive edit` (whose target's chain starts at its *current*
214/// parents, unioned) and `drive permissions check` (whose target may
215/// itself be a file).
216#[must_use]
217pub fn combine_across_parents(
218    first: Decision,
219    rest: impl IntoIterator<Item = Decision>,
220) -> Decision {
221    rest.into_iter().fold(first, |acc, next| {
222        if acc.verdict == Verdict::Deny {
223            acc
224        } else {
225            next
226        }
227    })
228}
229
230/// Splits an optional [`DecidingRule`] into the `(folder_id, depth)` pair
231/// `crate::request_log::DriveMutationOutcome::decided_by_folder_id`/
232/// `decided_by_depth` expect.
233///
234/// Shared by `create`/`upload`/`edit`'s otherwise near-identical
235/// `record_attempt` functions — each still builds its own
236/// `DriveMutationOutcome` (the verb-specific fields genuinely differ), but
237/// this was the one piece of extraction logic that was byte-for-byte the
238/// same in all three. Kept here (rather than in `crate::request_log`,
239/// which stays decoupled from any one integration's internal types) and
240/// pure, matching this module's zero-I/O contract — it does not itself
241/// call `crate::request_log::record_drive_mutation`.
242#[must_use]
243pub fn decided_by_log_fields(decided_by: Option<&DecidingRule>) -> (Option<String>, Option<usize>) {
244    match decided_by {
245        Some(rule) => (Some(rule.folder_id.clone()), Some(rule.depth)),
246        None => (None, None),
247    }
248}
249
250#[cfg(test)]
251#[allow(clippy::unwrap_used, clippy::expect_used)]
252mod tests {
253    use super::*;
254
255    fn rule(
256        folder_id: &str,
257        recursive: bool,
258        allow: &[DriveOperation],
259        deny: &[DriveOperation],
260    ) -> FolderPermissionRule {
261        FolderPermissionRule {
262            folder_id: folder_id.to_string(),
263            recursive,
264            allow: allow.iter().copied().collect(),
265            deny: deny.iter().copied().collect(),
266        }
267    }
268
269    fn chain(ids: &[&str]) -> Vec<String> {
270        ids.iter().copied().map(ToString::to_string).collect()
271    }
272
273    #[test]
274    fn default_policy_allows_read_with_no_rules() {
275        let decision = resolve(&chain(&["a"]), DriveOperation::Read, &[]);
276        assert_eq!(decision.verdict, Verdict::Allow);
277        assert_eq!(decision.decided_by, None);
278    }
279
280    #[test]
281    fn default_policy_denies_create_upload_edit_with_no_rules() {
282        for op in [
283            DriveOperation::Create,
284            DriveOperation::Upload,
285            DriveOperation::Edit,
286        ] {
287            let decision = resolve(&chain(&["a"]), op, &[]);
288            assert_eq!(
289                decision.verdict,
290                Verdict::Deny,
291                "{op:?} should default-deny"
292            );
293            assert_eq!(decision.decided_by, None);
294        }
295    }
296
297    #[test]
298    fn recursive_rule_matches_deep_descendant() {
299        let rules = [rule("root", true, &[DriveOperation::Create], &[])];
300        let decision = resolve(
301            &chain(&["child", "grandchild", "root"]),
302            DriveOperation::Create,
303            &rules,
304        );
305        assert_eq!(decision.verdict, Verdict::Allow);
306        assert_eq!(decision.decided_by.unwrap().folder_id, "root");
307    }
308
309    #[test]
310    fn non_recursive_rule_matches_own_folder_only() {
311        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
312        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
313        assert_eq!(decision.verdict, Verdict::Allow);
314    }
315
316    #[test]
317    fn non_recursive_rule_does_not_match_child() {
318        let rules = [rule("parent", false, &[DriveOperation::Create], &[])];
319        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
320        // Falls through to the default policy since the non-recursive rule
321        // never matches at depth > 0.
322        assert_eq!(decision.verdict, Verdict::Deny);
323        assert_eq!(decision.decided_by, None);
324    }
325
326    #[test]
327    fn closest_ancestor_wins_deny_over_broader_allow() {
328        let rules = [
329            rule("child", true, &[], &[DriveOperation::Create]),
330            rule("parent", true, &[DriveOperation::Create], &[]),
331        ];
332        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
333        assert_eq!(decision.verdict, Verdict::Deny);
334        assert_eq!(decision.decided_by.unwrap().folder_id, "child");
335    }
336
337    #[test]
338    fn closest_ancestor_wins_allow_over_broader_deny() {
339        // The inverse case — proves "closest wins" isn't secretly "deny
340        // always wins": a closer *allow* beats a farther *deny*.
341        let rules = [
342            rule("child", true, &[DriveOperation::Create], &[]),
343            rule("parent", true, &[], &[DriveOperation::Create]),
344        ];
345        let decision = resolve(&chain(&["child", "parent"]), DriveOperation::Create, &rules);
346        assert_eq!(decision.verdict, Verdict::Allow);
347        assert_eq!(decision.decided_by.unwrap().folder_id, "child");
348    }
349
350    #[test]
351    fn deny_beats_allow_at_equal_depth() {
352        let rules = [
353            rule("target", false, &[DriveOperation::Create], &[]),
354            rule("target", false, &[], &[DriveOperation::Create]),
355        ];
356        let decision = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
357        assert_eq!(decision.verdict, Verdict::Deny);
358    }
359
360    #[test]
361    fn rule_on_unrelated_folder_does_not_apply() {
362        let rules = [rule("unrelated", true, &[DriveOperation::Create], &[])];
363        let decision = resolve(
364            &chain(&["target", "parent"]),
365            DriveOperation::Create,
366            &rules,
367        );
368        assert_eq!(decision.verdict, Verdict::Deny);
369        assert_eq!(decision.decided_by, None);
370    }
371
372    #[test]
373    fn empty_chain_orphan_file_uses_default_policy_only() {
374        let rules = [rule("some-folder", true, &[DriveOperation::Create], &[])];
375        let decision = resolve(&[], DriveOperation::Create, &rules);
376        assert_eq!(decision.verdict, Verdict::Deny);
377        assert_eq!(decision.decided_by, None);
378    }
379
380    #[test]
381    fn display_matches_the_serde_lowercase_wire_form() {
382        assert_eq!(DriveOperation::Read.to_string(), "read");
383        assert_eq!(DriveOperation::Create.to_string(), "create");
384        assert_eq!(DriveOperation::Upload.to_string(), "upload");
385        assert_eq!(DriveOperation::Edit.to_string(), "edit");
386    }
387
388    #[test]
389    fn operations_on_one_rule_are_independent() {
390        let rules = [rule("target", false, &[DriveOperation::Create], &[])];
391        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
392        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
393        assert_eq!(create.verdict, Verdict::Allow);
394        assert_eq!(
395            upload.verdict,
396            Verdict::Deny,
397            "an allow:[create] rule must not leak into upload"
398        );
399    }
400
401    #[test]
402    fn deny_list_and_allow_list_on_same_rule_apply_to_different_ops_independently() {
403        let rules = [rule(
404            "target",
405            false,
406            &[DriveOperation::Create],
407            &[DriveOperation::Edit],
408        )];
409        let create = resolve(&chain(&["target"]), DriveOperation::Create, &rules);
410        let edit = resolve(&chain(&["target"]), DriveOperation::Edit, &rules);
411        let upload = resolve(&chain(&["target"]), DriveOperation::Upload, &rules);
412        assert_eq!(create.verdict, Verdict::Allow);
413        assert_eq!(edit.verdict, Verdict::Deny);
414        assert_eq!(
415            upload.verdict,
416            Verdict::Deny,
417            "no rule named upload; falls to default policy"
418        );
419    }
420
421    // ── combine_across_parents ────────────────────────────────────────
422
423    fn decision(verdict: Verdict) -> Decision {
424        Decision {
425            verdict,
426            decided_by: None,
427        }
428    }
429
430    #[test]
431    fn combine_across_parents_single_decision_returns_it_unchanged() {
432        let combined = combine_across_parents(decision(Verdict::Allow), []);
433        assert_eq!(combined.verdict, Verdict::Allow);
434    }
435
436    #[test]
437    fn combine_across_parents_deny_beats_allow_deny_first() {
438        let combined = combine_across_parents(decision(Verdict::Deny), [decision(Verdict::Allow)]);
439        assert_eq!(combined.verdict, Verdict::Deny);
440    }
441
442    #[test]
443    fn combine_across_parents_deny_beats_allow_allow_first() {
444        let combined = combine_across_parents(decision(Verdict::Allow), [decision(Verdict::Deny)]);
445        assert_eq!(combined.verdict, Verdict::Deny);
446    }
447
448    // ── decided_by_log_fields ────────────────────────────────────────
449
450    #[test]
451    fn decided_by_log_fields_none_yields_none_pair() {
452        assert_eq!(decided_by_log_fields(None), (None, None));
453    }
454
455    #[test]
456    fn decided_by_log_fields_some_extracts_folder_id_and_depth() {
457        let rule = DecidingRule {
458            folder_id: "folder-1".to_string(),
459            depth: 2,
460        };
461        assert_eq!(
462            decided_by_log_fields(Some(&rule)),
463            (Some("folder-1".to_string()), Some(2))
464        );
465    }
466
467    #[test]
468    fn combine_across_parents_all_allow_returns_allow() {
469        let combined = combine_across_parents(
470            decision(Verdict::Allow),
471            [decision(Verdict::Allow), decision(Verdict::Allow)],
472        );
473        assert_eq!(combined.verdict, Verdict::Allow);
474    }
475}