From 0bc4323b94dfa329733d3343a3ab9dfd05eed7c8 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Thu, 3 Sep 2026 13:35:18 -0500 Subject: [PATCH 1/6] Add a folder-scoped Google Drive resource Confines the existing read-only GoogleDriveSession API to one selected folder's current descendant subtree, for both My Drive folders and ordinary subfolders inside a shared drive. A shared drive's own root stays with the Shared Drive resource. Drive v3 offers no folder corpus, no folder-scoped token, and no recursive ancestor predicate -- `'' in parents` means direct children only -- so membership is proved in the gatekeeper. drive-folder-scope.ts walks each candidate's parent chain upward through freshly batched files.get metadata, level by level, admitting only acyclic same-domain chains of live folders that reach the root within Drive's 101-hop nesting limit, then re-reads every node on the surviving paths immediately before disclosure. Nothing is cached across the operation that proved it: a hierarchy change rotates no credential and bumps no cache generation. The grant identity is an internal `/_resource/folder/:folderId` path rather than the natural browser URL, because `/drive/folders/:driveId` is already the shared drive's permanent identity and its pattern leaves the query component wildcard -- a query-qualified variant would match both resources and make resource selection order-dependent. describe() still reports the natural URL for the UI. Folder-derived Doc and Sheet children now revalidate: DriveSessionCore .nativeRead() re-proves ancestry and the exact native MIME type before the provider is contacted, re-checks the proved chain before any approval, and discards the fetched value if either fails. Drive has no ancestry-plus-content transaction, so a move landing after that final check still returns; the next read denies, and the method says so. CursorPager's authorize() now takes `exhausted` and its page budget is maxProviderPagesPerCall: reaching the bound authorizes the empty nonterminal page and returns [] rather than throwing, so a folder cursor can filter a whole page without claiming there are no results. Only an exhausted cursor reaches the empty-search path, which now fences observer admission through ObserverTracker.prepareWithheld() -- a withheld read registers no tracked set, so addObserver would otherwise verify a candidate against nothing. The Drive observer denial no longer names a file ID. --- packages/gatekeeper-google/README.md | 33 +- .../__tests__/configurator-url.test.ts | 32 +- .../__tests__/cursor.test.ts | 46 +- .../__tests__/drive-api.test.ts | 167 +++- .../__tests__/drive-observers.test.ts | 33 +- .../__tests__/drive-session.test.ts | 733 +++++++++++++++++- .../__tests__/observers.test.ts | 76 ++ .../__tests__/resources.test.ts | 55 +- .../__tests__/workerd/configurators.test.ts | 27 +- .../__tests__/workerd/native-sessions.test.ts | 183 ++++- .../drive-folder-configurator-types.d.ts | 7 + .../drive-folder-configurator-ui.tsx | 25 + packages/gatekeeper-google/src/cursor.ts | 46 +- packages/gatekeeper-google/src/drive-api.ts | 179 ++++- .../src/drive-folder-scope.ts | 196 +++++ .../gatekeeper-google/src/drive-observers.ts | 23 +- .../gatekeeper-google/src/drive-session.ts | 319 ++++++-- .../gatekeeper-google/src/drive-types.d.ts | 46 +- .../src/google-configurators.ts | 31 +- packages/gatekeeper-google/src/google.ts | 216 ++++-- packages/gatekeeper-google/src/observers.ts | 53 +- packages/gatekeeper-google/src/resources.ts | 48 +- 22 files changed, 2311 insertions(+), 263 deletions(-) create mode 100644 packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts create mode 100644 packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx create mode 100644 packages/gatekeeper-google/src/drive-folder-scope.ts diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index 0f93874562..a461d6e5b9 100644 --- a/packages/gatekeeper-google/README.md +++ b/packages/gatekeeper-google/README.md @@ -74,10 +74,10 @@ included). Across all resource types, the gatekeeper can request: - `openid`, `userinfo.profile`, and `userinfo.email` to identify the connected account. - `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending. -- `documents` for direct Google Docs reads and edits; `documents.readonly` for native Docs opened from account-wide or exact-file Drive bindings. -- `drive.metadata.readonly` for the Docs and Sheets pickers, account-wide Drive discovery, exact-file metadata, and native-file scope checks. -- `drive.readonly` for the shared-drive picker and scope lookup, metadata search, and native Docs or Sheets reads within one shared drive. This restricted scope conveys account-wide file content and remains after the account expands consent, but Google accepts nothing narrower for `drives.list`/`drives.get` and accepts this Drive scope for the native APIs. The gatekeeper still enforces the shared-drive binding boundary. -- `spreadsheets.readonly` to read metadata and bounded cell ranges from directly selected spreadsheets or native Sheets opened from account-wide or exact-file Drive bindings. +- `documents` for direct Google Docs reads and edits; `documents.readonly` for native Docs opened from account-wide, folder, or exact-file Drive bindings. +- `drive.metadata.readonly` for the Docs, Sheets, and folder pickers, account-wide Drive discovery, exact-file metadata, folder descendant proofs, and native-file scope checks. Google classifies this as a restricted scope, so every Drive resource here needs restricted-scope verification. +- `drive.readonly` for the shared-drive picker and scope lookup, metadata search, and native Docs or Sheets reads within one shared drive. It is restricted like `drive.metadata.readonly`, but adds account-wide file content download on top, and it remains after the account expands consent. Google accepts nothing narrower for `drives.list`/`drives.get` and accepts this Drive scope for the native APIs. The gatekeeper still enforces the shared-drive binding boundary. +- `spreadsheets.readonly` to read metadata and bounded cell ranges from directly selected spreadsheets or native Sheets opened from account-wide, folder, or exact-file Drive bindings. - `calendar.calendarlist.readonly` so the resource picker can list calendars. - `calendar.events` to manage selected calendar and check calendar availability. - `bigquery` for BigQuery dry-runs and queries. This is intentionally broader than `bigquery.readonly` because dry-runs use `jobs.insert`; the gatekeeper enforces read-only SQL and resource scope checks before running queries. @@ -136,12 +136,12 @@ User — see Step 4.) 2. Create or open a gadget. 3. Navigate to the **Connections** tab. 4. Click **+ New Connection**. -5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Drive Account, Google Workspace Shared Drive, Google Drive File, Google Calendar, or BigQuery. +5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Drive Account, Google Workspace Shared Drive, Google Drive Folder, Google Drive File, Google Calendar, or BigQuery. 6. If prompted, connect a Google account. 7. You should be redirected to Google's consent screen in a new tab. 8. The consent screen acts extra-scary since this is an "unverified" test app. 9. After granting access, the tab closes, and you're back to Gadgets. -10. Use the picker to choose the mailbox scope, document, shared drive, Drive file, project, dataset, or table to connect. (The Google Drive Account resource covers the whole account, so it has no picker.) +10. Use the picker to choose the mailbox scope, document, shared drive, folder, Drive file, project, dataset, or table to connect. (The Google Drive Account resource covers the whole account, so it has no picker.) 11. Create the connection. Ask the agent what it can do, or ask it to write a gadget using the new binding. You can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right). @@ -168,21 +168,30 @@ stores baseline and Preview secrets separately, so provision the same signing va ## Google Drive read-only bindings -Drive exposes three permanent resource URL forms: +Drive exposes four permanent resource URL forms: -- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: listings set `includeItemsFromAllDrives`, so shared-drive items the account has accessed come back too, and reads by ID are not scope-checked at all, so anything the account's token resolves is inside this grant. Listings stay on `corpora=user` rather than `allDrives`, which Google flags as much less efficient and allows to return `incompleteSearch`, so a shared drive the account belongs to but has never touched may be readable by ID without appearing in a listing. It is the broadest of the three by design; bind a shared drive or a file if that is too much. +- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: listings set `includeItemsFromAllDrives`, so shared-drive items the account has accessed come back too, and reads by ID are not scope-checked at all, so anything the account's token resolves is inside this grant. Listings stay on `corpora=user` rather than `allDrives`, which Google flags as much less efficient and allows to return `incompleteSearch`, so a shared drive the account belongs to but has never touched may be readable by ID without appearing in a listing. It is the broadest of the four by design; bind a shared drive, a folder, or a file if that is too much. - `https://drive.google.com/drive/folders/` selects one Google Workspace shared drive, where the organization rather than an individual owns the files. +- `https://drive.google.com/_resource/folder/` selects one folder and everything currently beneath it, at any depth. - `https://drive.google.com/file/d//view` selects one file by its immutable ID. Despite the `/folders/` URL, the second resource is a Google Workspace shared drive, not an individual folder. Google uses a shared drive's ID for its root folder too. The gatekeeper confirms the ID with `drives.get`, so it rejects ordinary folder IDs. -The agent-facing `GoogleDriveSession` reports the binding scope, lists entries, runs structured searches, and fetches one entry by ID. Listing and search return disposable RPC cursors. A parent filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata for any tab count, but Markdown content requires exactly one tab; Sheets expose spreadsheet metadata and bounded A1 range reads. The API does not expose raw Drive `q` strings, file writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. One caveat: the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description, and OCR text. Results carry metadata alone, but repeated queries remain a content oracle over files the agent cannot otherwise read. +The folder resource is the reason the third form is an internal `_resource` path rather than the natural browser URL. `/drive/folders/:driveId` is already the shared drive's permanent identity and its pattern leaves the query wildcard, so a query-qualified variant of it would match both resources and make selection order-dependent. `describe()` still reports the natural `https://drive.google.com/drive/folders/` for the UI to link to, and validates the root through the same check the session uses, so a hand-built resource URL naming a file, a shortcut, trash, or a shared drive's root fails at connect rather than minting a binding that refuses every call. -Every native open re-fetches Drive metadata, enforces the immutable account, shared-drive, or exact-file scope, authorizes the metadata observation, and then checks the exact MIME type. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it. +A folder binding covers both storage domains: a folder in My Drive — including one another person shared from theirs — and an ordinary subfolder inside a shared drive. A shared drive's own root is rejected by the picker and served by the Shared Drive resource. Drive v3 has no folder corpus, no folder-scoped token, and no recursive ancestor predicate (`'' in parents` means direct children only), so membership is proved here: every operation re-reads the root, then walks each candidate's `parents` chain upward through fresh batched `files.get` metadata, admitting only chains that reach the root within the 101-hop nesting limit without a cycle, a trashed or non-folder link, a multi-parent link, or a hop into another storage domain. Nothing is cached across the operation that proved it — a hierarchy change rotates no credential and bumps no cache generation. A cursor pins the root's corpus when it opens and aborts if the root moves between My Drive and a shared drive, since a Drive page token is only valid against the corpus that produced it. Membership is a post-filter, since Drive cannot restrict a listing to a subtree, so a bare `list()` scans the corpus and a small folder in a large drive costs one round trip per page. A folder cursor fetches one provider page of 100 items per `next()` call and returns `[]` when that page filtered down to nothing with results still ahead; `null` still means exhausted, so drain the cursor. A fully-filtered page discloses nothing and is not audited; the terminal one is. `directParentId` is the efficient shape — it compiles to Drive's own `'' in parents` — so prefer it when walking a known folder. The bound folder's own `parentId` is withheld, since its container is outside the binding. -Account-wide and exact-file Drive bindings request `documents.readonly` and `spreadsheets.readonly` in addition to `drive.metadata.readonly`. An older metadata-only connection is therefore prompted to expand consent before it is treated as granting either resource. Shared-drive bindings remain on `drive.readonly`, which Google accepts for native Docs and Sheets reads, so they do not request redundant scopes. +The agent-facing `GoogleDriveSession` reports the binding scope, lists entries, runs structured searches, and fetches one entry by ID. Listing and search return disposable RPC cursors. A folder binding lists and searches its whole subtree; on every binding a `directParentId` filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata for any tab count, but Markdown content requires exactly one tab; Sheets expose spreadsheet metadata and bounded A1 range reads. The API does not expose raw Drive `q` strings, file writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. One caveat: the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description, and OCR text. Results carry metadata alone, but repeated queries remain a content oracle over files the agent cannot otherwise read. -Account and shared-drive bindings use per-file observer tracking because individual shared-drive items can carry narrower ACLs. They remember every file ID whose metadata or native content a workspace has read. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes — and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page or native child capability is disclosed, it checks the file ID against every existing observer and excludes observers who cannot access it. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. There is deliberately no cached access verdict, so revoked access fails closed on the next open. +Every native open re-fetches Drive metadata, enforces the immutable account, shared-drive, folder, or exact-file scope, authorizes the metadata observation, and then checks the exact MIME type. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it. + +A folder-derived Doc or Sheet session goes further, because its authority is derived from a hierarchy Drive can change under it: every method re-proves the file's ancestry and native type *before* contacting the Docs or Sheets API, re-checks the proved chain after the read and before any approval, and discards the fetched value if either fails. Drive offers no ancestry-plus-content transaction, so a move landing after that final check still returns the already-authorized result — the next call is what denies. Bindings with an immutable scope keep the plain read-then-authorize path. + +Account-wide, folder, and exact-file Drive bindings request `documents.readonly` and `spreadsheets.readonly` in addition to `drive.metadata.readonly`. An older metadata-only connection is therefore prompted to expand consent before it is treated as granting any of them. Shared-drive bindings remain on `drive.readonly`, which Google accepts for native Docs and Sheets reads, so they do not request redundant scopes. + +Account, shared-drive, and folder bindings use per-file observer tracking because individual items can carry narrower ACLs. They remember every file ID whose metadata or native content a workspace has read, including one that has since moved out of scope: a later observer must still prove direct access to it. Hidden ancestors traversed by a proof and candidates it rejected are never tracked, since neither is disclosed. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes — and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page or native child capability is disclosed, it checks the file ID against every existing observer and excludes observers who cannot access it. The refusal names no file: a collaborator who cannot reach one must not learn which one this workspace read. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. There is deliberately no cached access verdict, so revoked access fails closed on the next open. + +A search that terminates with no match at all is the one read no observer can be verified against, because it registers no file ID. Such a read is audited, withheld from every current observer, and latched: a durable fence goes down before the approval is requested and closes admission permanently once it succeeds, so the binding becomes unshareable rather than silently sharing an owner-relative negative answer. Only an exhausted cursor can trigger this; an intermediate empty page is not a negative answer, and the bound folder is re-checked against the cursor's pinned corpus before the latch closes, so a folder trashed or moved to another drive mid-scan refuses the read instead of ending sharing for good. ## Troubleshooting diff --git a/packages/gatekeeper-google/__tests__/configurator-url.test.ts b/packages/gatekeeper-google/__tests__/configurator-url.test.ts index e95ad1da3e..2dbe656c2e 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -22,11 +22,12 @@ import driveAccountConfigurator from "../src/configurator/drive-account-configur import driveFileConfigurator from "../src/configurator/drive-file-configurator-ui"; import calendarConfigurator from "../src/configurator/calendar-configurator-ui"; import type { CalendarConfiguratorRpc } from "../src/configurator/calendar-configurator-types"; +import driveFolderConfigurator from "../src/configurator/drive-folder-configurator-ui"; import gmailConfigurator from "../src/configurator/gmail-configurator-ui"; import sharedDriveConfigurator from "../src/configurator/shared-drive-configurator-ui"; import { - GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_RESOURCE, - GOOGLE_SHARED_DRIVE_RESOURCE, parseResourceUrl, + GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, + GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE, parseResourceUrl, } from "../src/resources"; // The configurators never call `ui` from these two methods; it is present only to satisfy the @@ -165,6 +166,9 @@ describe("Drive configurator URLs", () => { expect(renderedCopy(driveFileConfigurator)).toContain( "A selected native Google Doc or Sheet also provides read-only content.", ); + expect(renderedCopy(driveFolderConfigurator)).toContain( + "Search everything currently beneath it and read native Google Docs and Sheets.", + ); }); it("round-trips an encoded shared-drive ID", () => { @@ -185,6 +189,25 @@ describe("Drive configurator URLs", () => { expect(parseResourceUrl(url)).toEqual({ kind: "driveFile", fileId: values.fileId }); }); + it("round-trips an encoded folder ID", () => { + let values = { folderId: "folder/id with spaces" }; + let url = configurableUrl(driveFolderConfigurator, values); + expect(url).toBe( + GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern.replace( + ":folderId", encodeURIComponent(values.folderId)), + ); + expect(parseResourceUrl(url)).toEqual({ kind: "driveFolder", folderId: values.folderId }); + }); + + // The folder picker mints the internal `_resource` selector, never the natural browser URL: that + // one is the shared drive's permanent identity, and a folder minting it would hand a whole + // drive's authority to a binding the user configured as one folder. + it("never mints the shared drive's identity from a folder", () => { + let url = configurableUrl(driveFolderConfigurator, { folderId: "FOLDER123" }); + expect(url).not.toContain("/drive/folders/"); + expect(parseResourceUrl(url)).toEqual({ kind: "driveFolder", folderId: "FOLDER123" }); + }); + // Prefill after deleting the hand-written hooks: the sandbox fallback extracts named groups and // decodeURIComponent's them. A missing decode would leave `%2F`/`%20` in the form values. it("prefills encoded IDs from urlPattern named groups", () => { @@ -197,5 +220,10 @@ describe("Drive configurator URLs", () => { let fileUrl = configurableUrl(driveFileConfigurator, fileValues); expect(valuesFromUrlPattern(fileUrl, GOOGLE_DRIVE_FILE_RESOURCE.urlPattern)) .toEqual(fileValues); + + let folderValues = { folderId: "folder/id with spaces" }; + let folderUrl = configurableUrl(driveFolderConfigurator, folderValues); + expect(valuesFromUrlPattern(folderUrl, GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern)) + .toEqual(folderValues); }); }); diff --git a/packages/gatekeeper-google/__tests__/cursor.test.ts b/packages/gatekeeper-google/__tests__/cursor.test.ts index 4523b427cd..4370d31de9 100644 --- a/packages/gatekeeper-google/__tests__/cursor.test.ts +++ b/packages/gatekeeper-google/__tests__/cursor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { CursorPager, DEFAULT_MAX_EMPTY_PAGES } from "../src/cursor"; +import { CursorPager, DEFAULT_MAX_PROVIDER_PAGES_PER_CALL } from "../src/cursor"; import type { CursorPage, CursorPagerOptions } from "../src/cursor"; /** Serves a fixed script of pages, keyed by the token used to ask for them. */ @@ -110,27 +110,37 @@ describe("pages with no usable results", () => { expect(await pager.next()).toBeNull(); }); - it("gives up after the configured number of fruitless pages", async () => { + it("hands back an empty page once the configured budget runs out", async () => { let empty = Array.from({ length: 10 }, () => [] as string[]); - let { pager } = makePager([...empty, ["a"]], { - maxEmptyPages: 3, + let { pager, authorized } = makePager([...empty, ["a"]], { + maxProviderPagesPerCall: 3, }); - await expect(pager.next()).rejects.toThrow( - "TestProvider returned 3 pages with no usable results."); + + // Not the end of the results: the caller drains, and the next call resumes where this stopped. + expect(await pager.next()).toEqual([]); + expect(authorized).toEqual([[]]); + expect(await pager.next()).toEqual([]); }); - it("defaults the budget to DEFAULT_MAX_EMPTY_PAGES", async () => { - let pages = Array.from({ length: DEFAULT_MAX_EMPTY_PAGES + 1 }, () => [] as string[]); + it("defaults the budget to DEFAULT_MAX_PROVIDER_PAGES_PER_CALL", async () => { + let pages = Array.from({ length: DEFAULT_MAX_PROVIDER_PAGES_PER_CALL + 1 }, () => [] as string[]); let { pager, requested } = makePager(pages.concat([["a"]])); - await expect(pager.next()).rejects.toThrow(`returned ${DEFAULT_MAX_EMPTY_PAGES} pages`); - expect(requested).toHaveLength(DEFAULT_MAX_EMPTY_PAGES); + expect(await pager.next()).toEqual([]); + expect(requested).toHaveLength(DEFAULT_MAX_PROVIDER_PAGES_PER_CALL); }); it("counts the budget per call, not for the cursor's lifetime", async () => { - let { pager } = makePager([[], ["a"], [], ["b"]], { maxEmptyPages: 2 }); + let { pager } = makePager([[], ["a"], [], ["b"]], { maxProviderPagesPerCall: 2 }); expect(await pager.next()).toEqual(["a"]); expect(await pager.next()).toEqual(["b"]); }); + + // The budget bounds work, never the result. A caller that stops at `[]` would silently lose the + // pages behind it, so draining has to reach every entry however many empty slices it takes. + it("still yields every entry when the budget slices the walk", async () => { + let { pager } = makePager([[], [], ["a"], [], ["b"]], { maxProviderPagesPerCall: 1 }); + expect(await drain(pager)).toEqual(["a", "b"]); + }); }); describe("malformed provider responses", () => { @@ -173,6 +183,20 @@ describe("authorization", () => { expect(authorized).toEqual([["a", "b"], ["c"]]); }); + // Only `exhausted` distinguishes "there is nothing" from "this call found nothing yet", and a + // caller that treats the second as the first audits a negative answer nobody established. + it("reports exhaustion only when the provider offered no continuation token", async () => { + let seen: [string[], boolean][] = []; + let { pager } = makePager([[], ["a"]], { + maxProviderPagesPerCall: 1, + authorize: async (entries, exhausted) => { seen.push([entries, exhausted]); }, + }); + + expect(await pager.next()).toEqual([]); + expect(await pager.next()).toEqual(["a"]); + expect(seen).toEqual([[[], false], [["a"], true]]); + }); + it("authorizes the surviving entries, not the raw page", async () => { let { pager, authorized } = makePager([["keep", "skip"]], { buildEntries: async items => items.filter(item => item !== "skip"), diff --git a/packages/gatekeeper-google/__tests__/drive-api.test.ts b/packages/gatekeeper-google/__tests__/drive-api.test.ts index 97f2968016..98aa0f1e28 100644 --- a/packages/gatekeeper-google/__tests__/drive-api.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-api.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { DRIVE_FILE_FIELDS, DRIVE_FILE_ITEM_FIELDS, DriveApi, DriveApiDisabledError, DriveApiRequestError, - buildDriveQuery, escapeDriveQueryLiteral, + FOLDER_MIME_TYPE, buildDriveQuery, escapeDriveQueryLiteral, } from "../src/drive-api"; /** Google's real error envelope for an API that is not enabled on the project. */ @@ -39,6 +39,10 @@ const jsonResponse = (body: unknown, status = 200) => const api = (token = "tok") => new DriveApi(async () => token); +/** + * A batch response shaped like a real one: every part's body is followed by a blank line before the + * next boundary. Omitting that line made the parser's old last-chunk body extraction look correct. + */ function batchResponse(results: { status: number; body?: string; contentId?: string }[]): Response { let boundary = "drive_test_boundary"; let body = results.map((result, index) => [ @@ -50,6 +54,7 @@ function batchResponse(results: { status: number; body?: string; contentId?: str "Content-Type: application/json", "", result.body ?? "{}", + "", ].join("\r\n")).join("\r\n") + `\r\n--${boundary}--\r\n`; return new Response(body, { headers: { "Content-Type": `multipart/mixed; boundary=${boundary}` } }); } @@ -201,6 +206,12 @@ describe("listFiles", () => { expect(await api().listFiles()).toEqual({ files: [] }); }); + it("describes malformed response sizes as UTF-16 code units", async () => { + stubFetch([new Response("é")]); + await expect(api().listFiles()).rejects + .toThrow("Google Drive response was not valid JSON (1 UTF-16 code units)"); + }); + it("omits nextPageToken on the last page rather than reporting it undefined", async () => { stubFetch([jsonResponse({ files: [] })]); expect("nextPageToken" in await api().listFiles()).toBe(false); @@ -305,6 +316,7 @@ describe("metadata lookup", () => { let file = { id: "file/1", name: "Plan", mimeType: "application/pdf", modifiedTime: "2026-01-02T03:04:05Z", trashed: false, + capabilities: { canListChildren: true }, }; let calls = stubFetch([jsonResponse(file)]); expect(await api().getFile("file/1")).toEqual(file); @@ -312,6 +324,7 @@ describe("metadata lookup", () => { expect(calls[0].url.searchParams.get("supportsAllDrives")).toBe("true"); expect(calls[0].url.searchParams.get("fields")).toBe(DRIVE_FILE_ITEM_FIELDS); expect(DRIVE_FILE_ITEM_FIELDS.split(",")).toContain("trashed"); + expect(DRIVE_FILE_ITEM_FIELDS).toContain("capabilities(canListChildren)"); expect(DRIVE_FILE_ITEM_FIELDS).not.toMatch(/createdTime|photoLink|iconLink|thumbnailLink/); }); @@ -398,6 +411,18 @@ describe("bulk access verification", () => { expect(calls[0].body).toContain("GET /drive/v3/files/one?fields=id&supportsAllDrives=true"); }); + it("refuses metadata-only access when the bound folder must be listable", async () => { + stubFetch([batchResponse([{ + status: 200, + body: JSON.stringify({ + id: "folder", capabilities: { canListChildren: false }, + }), + }])]); + + await expect(api().checkFileAccess(["folder"], "folder")) + .resolves.toEqual([false]); + }); + it("concatenates batch outcomes in request order across the 100-file chunk boundary", async () => { let calls = stubFetch([ batchResponse([ @@ -566,6 +591,146 @@ describe("bulk access verification", () => { }); }); +describe("folder scope nodes", () => { + const node = (id: string, extra: Record = {}) => + JSON.stringify({ id, mimeType: FOLDER_MIME_TYPE, parents: ["p"], trashed: false, ...extra }); + + it("parses ancestry facts and asks only for the fields a proof decides from", async () => { + let calls = stubFetch([batchResponse([ + { status: 200, body: node("one", { + driveId: "drive-1", capabilities: { canListChildren: true }, + }) }, + ])]); + + await expect(api().getScopeNodes(["one"])).resolves.toEqual([{ + id: "one", mimeType: FOLDER_MIME_TYPE, parents: ["p"], trashed: false, + driveId: "drive-1", canListChildren: true, + }]); + expect(calls[0].body).toContain(`fields=${encodeURIComponent( + "id,mimeType,parents,driveId,trashed,capabilities(canListChildren)")}`); + expect(calls[0].body).not.toContain("name"); + }); + + it("places nodes by Content-ID rather than positional order", async () => { + stubFetch([batchResponse([ + { status: 200, body: node("two"), contentId: "response-item-1" }, + { status: 200, body: node("one"), contentId: "response-item-0" }, + ])]); + + await expect(api().getScopeNodes(["one", "two"])) + .resolves.toEqual([expect.objectContaining({ id: "one" }), expect.objectContaining({ id: "two" })]); + }); + + it("keeps positions across the 100-file chunk boundary", async () => { + stubFetch([ + batchResponse([ + ...Array.from({ length: 99 }, (_, index) => ({ status: 200, body: node(`file-${index}`) })), + { status: 404 }, + ]), + batchResponse([{ status: 200, body: node("file-100") }]), + ]); + + let nodes = await api().getScopeNodes( + Array.from({ length: 101 }, (_, index) => `file-${index}`)); + expect(nodes).toHaveLength(101); + expect(nodes[98]).toEqual(expect.objectContaining({ id: "file-98" })); + expect(nodes[99]).toBeUndefined(); + expect(nodes[100]).toEqual(expect.objectContaining({ id: "file-100" })); + }); + + it.each([403, 404])("reports only an inaccessible file (%i) as a hole", async status => { + stubFetch([batchResponse([{ status }])]); + await expect(api().getScopeNodes(["one"])).resolves.toEqual([undefined]); + }); + + // A quota, outage, or account-wide block answered as "not a descendant" would silently shrink a + // listing, which is the one failure shape a scope check must never produce. + it.each([ + ["quota", 403, JSON.stringify({ error: { errors: [{ reason: "userRateLimitExceeded" }] } })], + // Google's domainPolicy denies the app every file, so no single file's membership follows. + ["an account-wide policy block", 403, + JSON.stringify({ error: { errors: [{ reason: "domainPolicy" }] } })], + ["rate limiting", 429, "{}"], + ["a server error", 503, "{}"], + ])("throws on %s rather than reporting a hole", async (_label, status, body) => { + stubFetch([batchResponse([{ status, body }])]); + await expect(api().getScopeNodes(["one"])).rejects.toThrow(/batch subrequest failed/); + }); + + it("throws when the API is not enabled for the project", async () => { + stubFetch([batchResponse([{ status: 403, body: API_DISABLED_BODY }])]); + await expect(api().getScopeNodes(["one"])).rejects.toBeInstanceOf(DriveApiDisabledError); + }); + + // The echo is what ties a node's facts to the file whose membership they decide. + it("throws when a part's body answers for another file", async () => { + stubFetch([batchResponse([{ status: 200, body: node("other") }])]); + await expect(api().getScopeNodes(["one"])) + .rejects.toThrow("Google Drive batch response did not echo the requested file ID"); + }); + + // The live failure: a body terminated by a blank line before the boundary made the old parser + // read the empty trailing chunk as the body, so every *successful* subrequest threw. Spelled out + // byte by byte rather than through `batchResponse`, so a fixture that drifts cannot hide it. + it("reads a body that a conforming emitter terminates with a blank line", async () => { + let boundary = "conforming_boundary"; + let text = [ + `--${boundary}`, + "Content-Type: application/http", + "Content-ID: ", + "", + "HTTP/1.1 200 OK", + "Content-Type: application/json; charset=UTF-8", + "", + node("one"), + "", + `--${boundary}--`, + "", + ].join("\r\n"); + stubFetch([new Response(text, { + headers: { "Content-Type": `multipart/mixed; boundary=${boundary}` }, + })]); + + await expect(api().getScopeNodes(["one"])) + .resolves.toEqual([expect.objectContaining({ id: "one", parents: ["p"] })]); + }); + + it.each([ + ["a non-string parent", JSON.stringify({ id: "one", parents: [7] })], + ["a non-boolean trashed", JSON.stringify({ id: "one", trashed: "no" })], + ])("throws on %s", async (_label, body) => { + stubFetch([batchResponse([{ status: 200, body }])]); + await expect(api().getScopeNodes(["one"])).rejects.toThrow(); + }); + + it.each([["malformed", "not json"], ["empty", ""], ["non-ASCII", "é"]])( + "reports %s JSON with its UTF-16 size", async (_label, body) => { + stubFetch([batchResponse([{ status: 200, body }])]); + await expect(api().getScopeNodes(["one"])).rejects.toThrow( + `Google Drive batch response part was not valid JSON (${body.length} UTF-16 code units)`); + }); + + it("replays once after an inner 401, then gives up", async () => { + let tokens = ["stale", "fresh"]; + let drive = new DriveApi(async () => tokens.shift() ?? "fresh"); + let calls = stubFetch([ + batchResponse([{ status: 401 }]), + batchResponse([{ status: 200, body: node("one") }]), + ]); + + await expect(drive.getScopeNodes(["one"])) + .resolves.toEqual([expect.objectContaining({ id: "one" })]); + expect(calls.map(call => call.headers.get("Authorization"))) + .toEqual(["Bearer stale", "Bearer fresh"]); + }); + + it("issues no request for an empty list", async () => { + let calls = stubFetch([]); + await expect(api().getScopeNodes([])).resolves.toEqual([]); + expect(calls).toEqual([]); + }); +}); + describe("error handling", () => { it("distinguishes the API-not-enabled 403 by Google's reason, which the admin must fix", async () => { stubFetch([new Response(API_DISABLED_BODY, { status: 403 })]); diff --git a/packages/gatekeeper-google/__tests__/drive-observers.test.ts b/packages/gatekeeper-google/__tests__/drive-observers.test.ts index e413914c76..61c35b5838 100644 --- a/packages/gatekeeper-google/__tests__/drive-observers.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-observers.test.ts @@ -14,15 +14,20 @@ function deny(ids: readonly string[]): ObserverBatchResult { function tracker( scope: DriveBindingScope, - verdicts: (ids: readonly string[], verifier: string) => ObserverBatchResult | Promise, + verdicts: ( + ids: readonly string[], verifier: string, listableFolderId?: string, + ) => ObserverBatchResult | Promise, ) { let kv = new FakeKv(); let asked: string[][] = []; - let track = driveObserverTracker(kv, scope, async (verifier, fileIds) => { - asked.push([...fileIds]); - return verdicts(fileIds, verifier); - }); - return { kv, asked, track }; + let listableFolders: (string | undefined)[] = []; + let track = driveObserverTracker(kv, scope, + async (verifier, fileIds, listableFolderId) => { + asked.push([...fileIds]); + listableFolders.push(listableFolderId); + return verdicts(fileIds, verifier, listableFolderId); + }); + return { kv, asked, listableFolders, track }; } describe("driveObserverTracker", () => { @@ -42,6 +47,16 @@ describe("driveObserverTracker", () => { expect(asked).toEqual([["drive-1"]]); }); + it("seeds a folder binding with its root, which is durable authority a proof is not", async () => { + let { kv, asked, listableFolders, track } = + tracker({ kind: "folder", folderId: "folder-1" }, allow); + + expect([...kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}folder-1`]); + await track.addObserver("obs", "verifier"); + expect(asked).toEqual([["folder-1"]]); + expect(listableFolders).toEqual(["folder-1"]); + }); + it("seeds an account binding with nothing", async () => { let { kv, asked, track } = tracker({ kind: "account" }, allow); @@ -54,7 +69,7 @@ describe("driveObserverTracker", () => { let { kv, track } = tracker({ kind: "file", fileId: "file-1" }, deny); await expect(track.addObserver("obs", "verifier")) - .rejects.toThrow(/cannot access Drive file file-1/); + .rejects.toThrow("This collaborator cannot access Drive data this workspace has read."); expect([...track.observers()]).toEqual([]); expect([...kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}file-1`]); }); @@ -83,7 +98,7 @@ describe("driveObserverTracker", () => { kv.put(`${DRIVE_OBSERVATION_PREFIX}file-1`, "pending"); release(); - await expect(admission).rejects.toThrow(/cannot access Drive file file-1/); + await expect(admission).rejects.toThrow(/cannot access Drive data this workspace has read/); expect(asked).toEqual([[], ["file-1"]]); }); @@ -95,7 +110,7 @@ describe("driveObserverTracker", () => { await track.addObserver("obs", "old"); await expect(track.addObserver("obs", "new")) - .rejects.toThrow(/cannot access Drive file file-1/); + .rejects.toThrow(/cannot access Drive data this workspace has read/); expect((await track.prepareObservation(["file-2"])).excludeObservers).toBeUndefined(); }); diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index a499495883..fc5155a1be 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -1,12 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; -import { DriveSessionCore, driveFileToEntry } from "../src/drive-session"; -import { DriveApiRequestError, type DriveFile, type DriveListFilesOptions } from "../src/drive-api"; +import { DriveSessionCore, driveFileToEntry, type DriveBindingScope } from "../src/drive-session"; +import { readFolderRoot } from "../src/drive-folder-scope"; +import { + DriveApiRequestError, FOLDER_MIME_TYPE, + type DriveFile, type DriveListFilesOptions, type DriveScopeNode, +} from "../src/drive-api"; import type { ObserverCheck } from "../src/observers"; import { driveObserverTracker } from "../src/drive-observers"; import { FakeKv } from "./fake-kv"; +import type { DriveEntry } from "../src/drive-types"; -const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; +const SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; +const docMime = "application/vnd.google-apps.document"; +const sheetMime = "application/vnd.google-apps.spreadsheet"; const file = (overrides: Partial = {}): DriveFile => ({ id: "file-1", @@ -17,8 +24,7 @@ const file = (overrides: Partial = {}): DriveFile => ({ }); function core(overrides: { - scope?: { kind: "account" } | { kind: "sharedDrive"; driveId: string } | - { kind: "file"; fileId: string }; + scope?: DriveBindingScope; files?: DriveFile[]; getFile?: (id: string) => Promise; getDrive?: (id: string) => Promise<{ id: string; name: string }>; @@ -26,19 +32,22 @@ function core(overrides: { files: DriveFile[]; nextPageToken?: string; }>; + getScopeNodes?: (ids: readonly string[]) => Promise<(DriveScopeNode | undefined)[]>; prepareObservation?: (ids: string[]) => Promise>; + prepareWithheld?: () => ObserverCheck; authorize?: (description: ObservationDescription) => Promise; - observerIds?: () => string[]; } = {}) { let listFiles = vi.fn(overrides.listFiles ?? (async () => ({ files: overrides.files ?? [file()] }))); let getFile = vi.fn(overrides.getFile ?? (async (id: string) => file({ id }))); let getDrive = vi.fn(overrides.getDrive ?? (async (id: string) => ({ id, name: "Current shared drive" }))); + let getScopeNodes = vi.fn(overrides.getScopeNodes ?? + (async (ids: readonly string[]) => ids.map(() => undefined))); let prepared: string[][] = []; let authorizations: ObservationDescription[] = []; let events: string[] = []; let session = new DriveSessionCore({ - api: { listFiles, getFile, getDrive }, + api: { listFiles, getFile, getDrive, getScopeNodes }, scope: overrides.scope ?? { kind: "account" }, prepareObservation: overrides.prepareObservation ?? (async (ids: string[]) => { prepared.push(ids); @@ -48,14 +57,74 @@ function core(overrides: { commit: () => events.push("commit"), }; }), - observerIds: overrides.observerIds ?? (() => ["excluded"]), + prepareWithheld: overrides.prepareWithheld ?? (() => ({ + excludeObservers: ["excluded"], + pendingSets: [], + commit: () => events.push("latch"), + discard: () => events.push("unlatch"), + })), authorize: async (description: ObservationDescription) => { authorizations.push(description); events.push("authorize"); await overrides.authorize?.(description); }, }); - return { session, listFiles, getFile, getDrive, prepared, authorizations, events }; + return { + session, listFiles, getFile, getDrive, getScopeNodes, prepared, authorizations, events, + }; +} + +const FOLDER_ROOT = "folder-root"; + +const folder = (id: string, overrides: Partial = {}): DriveFile => + file({ id, name: id, mimeType: FOLDER_MIME_TYPE, trashed: false, + capabilities: { canListChildren: true }, ...overrides }); + +const child = (id: string, parent: string, overrides: Partial = {}): DriveFile => + file({ id, name: id, parents: [parent], trashed: false, ...overrides }); + +/** + * A provider serving one Drive tree. `parents` is the only edge, exactly as Drive models it, and + * the scope-node view is the narrow projection the real batch returns. + */ +function tree(nodes: DriveFile[]) { + let byId = new Map(nodes.map(node => [node.id, node])); + return { + byId, + getFile: async (id: string) => { + let found = byId.get(id); + if (!found) throw new DriveApiRequestError(404); + return found; + }, + getScopeNodes: async (ids: readonly string[]) => ids.map((id): DriveScopeNode | undefined => { + let found = byId.get(id); + if (!found) return undefined; + return { + id: found.id, + ...(found.mimeType ? { mimeType: found.mimeType } : {}), + ...(found.parents ? { parents: found.parents } : {}), + ...(found.driveId ? { driveId: found.driveId } : {}), + ...(found.trashed === undefined ? {} : { trashed: found.trashed }), + ...(found.capabilities?.canListChildren === undefined ? {} : { + canListChildren: found.capabilities.canListChildren, + }), + }; + }), + }; +} + +/** A folder-scoped core over `nodes`, which must include the root itself. */ +function folderCore(nodes: DriveFile[], overrides: Parameters[0] = {}) { + let provider = tree(nodes); + return { + ...core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: provider.getScopeNodes, + ...overrides, + }), + provider, + }; } describe("Drive metadata mapping", () => { @@ -127,7 +196,20 @@ describe("Drive session scope", () => { })]); expect(authorizations[0]).not.toHaveProperty("prohibitAllSharing"); expect(authorizations[0].description).not.toContain("0"); - expect(events).toEqual(["authorize"]); + // The read registers no file ID, so nothing could ever verify a later observer against it: + // the audit lands, then admission latches closed, and only then is the caller refused. + expect(events).toEqual(["authorize", "latch"]); + }); + + it("leaves admission open when the empty search is itself refused", async () => { + let { session, events } = core({ + files: [], + authorize: async () => { throw new Error("denied"); }, + }); + + await expect((await session.search({ namePrefix: "missing" })).next()) + .rejects.toThrow("denied"); + expect(events).toEqual(["authorize", "unlatch"]); }); it("ends a search cleanly after an earlier page disclosed results", async () => { @@ -378,6 +460,16 @@ describe("Drive parent folder probe", () => { expect(events).toEqual(["authorize", "commit"]); }); + it("rejects a metadata-only parent before listing", async () => { + let { session, listFiles } = core({ + getFile: async id => folder(id, { capabilities: { canListChildren: false } }), + }); + + await expect(session.list({ directParentId: "folder-x" })) + .rejects.toThrow(/children can be listed/); + expect(listFiles).not.toHaveBeenCalled(); + }); + it("does not disclose a readable non-folder parent when observation is denied", async () => { let { session, listFiles, prepared, authorizations, events } = core({ getFile: async id => file({ id, mimeType: "application/pdf" }), @@ -404,7 +496,7 @@ describe("Drive parent folder probe", () => { let { session, authorizations, events } = core({ scope: { kind: "sharedDrive", driveId: "drive-1" }, files: [file({ id: "child-1", driveId: "drive-1", parents: ["folder-1"] })], - getFile: async id => file({ id, driveId: "drive-1", mimeType: FOLDER_MIME_TYPE }), + getFile: async id => folder(id, { driveId: "drive-1" }), }); await (await session.list({ directParentId: "folder-1" })).next(); @@ -428,9 +520,6 @@ describe("Drive parent folder probe", () => { }); describe("Drive native sessions", () => { - const docMime = "application/vnd.google-apps.document"; - const sheetMime = "application/vnd.google-apps.spreadsheet"; - it.each([ ["account Doc", { kind: "account" } as const, docMime, "Google Doc"], ["account Sheet", { kind: "account" } as const, sheetMime, "Google Sheet"], @@ -500,10 +589,11 @@ describe("Drive native sessions", () => { listFiles: async () => ({ files: [] }), getFile: async () => { throw new DriveApiRequestError(404); }, getDrive: async (id: string) => ({ id, name: "Current shared drive" }), + getScopeNodes: async ids => ids.map(() => undefined), }, scope: { kind: "account" }, prepareObservation: fileIds => track.prepareObservation(fileIds), - observerIds: () => [...track.observers()].map(([id]) => id), + prepareWithheld: () => track.prepareWithheld(), authorize: async () => {}, }); @@ -511,7 +601,7 @@ describe("Drive native sessions", () => { .rejects.toBeInstanceOf(DriveApiRequestError); await expect(track.addObserver("late", "verifier")) - .rejects.toThrow(/cannot access Drive file file-1/); + .rejects.toThrow(/cannot access Drive data this workspace has read/); expect([...track.observers()]).toEqual([]); }); @@ -676,3 +766,614 @@ describe("Drive observation authorization", () => { expect(observation.description.length).toBeLessThanOrEqual(240); }); }); + +// Drive has no folder corpus and no recursive ancestor predicate, so every one of these outcomes +// is decided by the gatekeeper's own `parents` walk rather than by anything the provider enforces. +describe("Drive folder scope", () => { + const root = folder(FOLDER_ROOT, { parents: ["outside-folder"] }); + + describe("membership", () => { + it.each([ + ["the root itself", FOLDER_ROOT, [root]], + ["a direct child", "kid", [root, child("kid", FOLDER_ROOT)]], + ["a deep descendant", "deep", + [root, folder("mid", { parents: [FOLDER_ROOT] }), child("deep", "mid")]], + ["a subfolder inside a shared drive", "kid", [ + folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" }), + child("kid", FOLDER_ROOT, { driveId: "drive-1" }), + ]], + ])("admits %s", async (_label, fileId, nodes) => { + let { session } = folderCore(nodes); + expect((await session.getEntry(fileId)).id).toBe(fileId); + }); + + it.each([ + ["a sibling of the root", "sibling", [root, child("sibling", "outside-folder")]], + ["the root's own parent", "outside-folder", + [root, folder("outside-folder", { parents: ["grandparent"] })]], + ["a file whose parent is unreadable", "orphan", [root, child("orphan", "hidden")]], + ["a file with no parents at all", "loose", [root, file({ id: "loose", trashed: false })]], + ["a file with an empty parent array", "loose", + [root, file({ id: "loose", parents: [], trashed: false })]], + // Drive gives a file one current parent; anything else is a shape this cannot decide. + ["a file claiming two parents", "shared", + [root, file({ id: "shared", parents: [FOLDER_ROOT, "elsewhere"], trashed: false })]], + ["a trashed descendant", "gone", + [root, child("gone", FOLDER_ROOT, { trashed: true })]], + ["a descendant behind a trashed folder", "deep", + [root, folder("mid", { parents: [FOLDER_ROOT], trashed: true }), child("deep", "mid")]], + // A shortcut is a file of its own; it is listed, never followed, and cannot carry a chain. + ["a descendant behind a shortcut", "deep", [ + root, + file({ id: "link", mimeType: SHORTCUT_MIME_TYPE, parents: [FOLDER_ROOT], trashed: false }), + child("deep", "link"), + ]], + ["a chain that cycles before reaching the root", "deep", [ + root, + folder("a", { parents: ["b"] }), + folder("b", { parents: ["a"] }), + child("deep", "a"), + ]], + ])("refuses %s", async (_label, fileId, nodes) => { + let { session } = folderCore(nodes); + await expect(session.getEntry(fileId)) + .rejects.toThrow("The requested file is outside this Drive binding."); + }); + + it("closes observer admission after a failed ancestry probe", async () => { + let track = driveObserverTracker(new FakeKv(), + { kind: "folder", folderId: FOLDER_ROOT }, async (_verifier, fileIds) => ({ + baselineAllowed: true, allowed: fileIds.map(() => true), + })); + let { session } = folderCore([root, child("sibling", "outside-folder")], { + prepareObservation: fileIds => track.prepareObservation(fileIds), + prepareWithheld: () => track.prepareWithheld(), + }); + + await expect(session.getEntry("sibling")) + .rejects.toThrow("The requested file is outside this Drive binding."); + await expect(track.addObserver("late", "verifier")) + .rejects.toThrow(/can no longer be observed/); + }); + + // Both storage domains cap nesting at 100 levels, so a chain longer than that never terminates + // at a legal root and must be abandoned rather than walked forever. + it("refuses a chain deeper than Drive's own nesting limit", async () => { + let chain = Array.from({ length: 120 }, + (_, index) => folder(`n${index}`, { parents: [index === 0 ? FOLDER_ROOT : `n${index - 1}`] })); + let { session } = folderCore([root, ...chain, child("deep", "n119")]); + + await expect(session.getEntry("deep")) + .rejects.toThrow("The requested file is outside this Drive binding."); + }); + + it("admits a descendant at the deepest legal nesting", async () => { + let chain = Array.from({ length: 98 }, + (_, index) => folder(`n${index}`, { parents: [index === 0 ? FOLDER_ROOT : `n${index - 1}`] })); + let { session } = folderCore([root, ...chain, child("deep", "n97")]); + + expect((await session.getEntry("deep")).id).toBe("deep"); + }); + + // Membership is same-domain by construction: a chain that crosses between My Drive and a shared + // drive is walking through a hierarchy the binding's corpus never covered. + it("refuses a descendant whose chain changes storage domain", async () => { + let { session } = folderCore([ + root, + folder("mid", { parents: [FOLDER_ROOT], driveId: "drive-1" }), + child("deep", "mid", { driveId: "drive-1" }), + ]); + + await expect(session.getEntry("deep")) + .rejects.toThrow("The requested file is outside this Drive binding."); + }); + + // The walk reads one ancestor level per round trip, so a move landing mid-walk leaves the + // chain that would authorize the read already stale. Both direct operations go through the + // same proof, and neither may disclose or audit anything off it. + it.each([ + ["getEntry", (session: DriveSessionCore) => session.getEntry("deep")], + ["openNativeFile", + (session: DriveSessionCore) => session.openNativeFile("deep", docMime, "Google Doc")], + ])("refuses %s when the chain changed during the ancestry walk", async (_label, operate) => { + let provider = tree([ + root, folder("mid", { parents: [FOLDER_ROOT] }), child("deep", "mid", { mimeType: docMime }), + ]); + let walked = false; + let { session, authorizations } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: async ids => { + let nodes = await provider.getScopeNodes(ids); + // "mid" leaves the subtree right after the walk read it, before the recheck re-reads it. + if (!walked) { + walked = true; + provider.byId.set("mid", folder("mid", { parents: ["elsewhere"] })); + } + return nodes; + }, + }); + + await expect(operate(session)) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(authorizations).toEqual([]); + }); + }); + + describe("root validation", () => { + const badRoots: [string, DriveFile][] = [ + ["a root that is not a folder", file({ id: FOLDER_ROOT, trashed: false })], + ["a shortcut standing in for the root", + file({ id: FOLDER_ROOT, mimeType: SHORTCUT_MIME_TYPE, trashed: false })], + ["a trashed root", folder(FOLDER_ROOT, { trashed: true })], + // A shared drive's root carries the drive's own ID and is the Shared Drive resource. + ["a shared drive's own root", folder(FOLDER_ROOT, { driveId: FOLDER_ROOT })], + ["a metadata-only folder", { + ...folder(FOLDER_ROOT), capabilities: { canListChildren: false }, + } as DriveFile], + // The provider answering for another file would decide membership from the wrong facts. + ["a root the provider echoes as another file", folder("someone-else")], + ]; + + it.each(badRoots)("refuses %s", async (_label, node) => { + let { session } = folderCore([node]); + await expect(session.getScope()) + .rejects.toThrow("The requested file is outside this Drive binding."); + }); + + // `describe()` runs before any session exists, so both entry points share one validator rather + // than letting a hand-built resource URL mint a presentable binding that refuses every call. + it.each(badRoots)("refuses %s through the validator describe() shares", async (_label, node) => { + await expect(readFolderRoot(FOLDER_ROOT, async () => node)) + .rejects.toThrow("The requested file is outside this Drive binding."); + }); + + // The alias resolves per account, so it names no stable authority to confine anything to. + it("refuses the account-relative alias at both entry points, contacting Drive at neither", + async () => { + let { session, getFile } = core({ scope: { kind: "folder", folderId: "root" } }); + await expect(session.getScope()) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(getFile).not.toHaveBeenCalled(); + + let fetch = vi.fn(); + await expect(readFolderRoot("root", fetch)) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("reports the folder's current name against its immutable ID", async () => { + let { session } = folderCore([folder(FOLDER_ROOT, { name: "Renamed", parents: ["above"] })]); + + expect(await session.getScope()) + .toEqual({ kind: "folder", folderId: FOLDER_ROOT, name: "Renamed" }); + }); + + // The folder above the binding is outside it; naming it would disclose one level of hierarchy + // the grant never covered. + it("withholds the root's own parent", async () => { + let { session } = folderCore([root, child("kid", FOLDER_ROOT)]); + + expect(await session.getEntry(FOLDER_ROOT)).not.toHaveProperty("parentId"); + expect(await session.getEntry("kid")).toMatchObject({ parentId: FOLDER_ROOT }); + }); + }); + + describe("listing", () => { + const page = (files: DriveFile[], nextPageToken?: string) => + ({ files, ...(nextPageToken ? { nextPageToken } : {}) }); + + it("selects the corpus the root lives in and asks for a bounded page", async () => { + let nodes = [ + folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" }), + child("kid", FOLDER_ROOT, { driveId: "drive-1" }), + ]; + let { session, listFiles } = folderCore(nodes, { + listFiles: async () => page([nodes[1]]), + }); + + await (await session.list()).next(); + expect(listFiles).toHaveBeenCalledWith(expect.objectContaining({ + corpus: { kind: "drive", driveId: "drive-1" }, pageSize: 100, + })); + }); + + it("uses the user corpus for a folder in My Drive", async () => { + let { session, listFiles } = folderCore([root, child("kid", FOLDER_ROOT)], { + listFiles: async () => page([child("kid", FOLDER_ROOT)]), + }); + + await (await session.list()).next(); + expect(listFiles).toHaveBeenCalledWith(expect.objectContaining({ corpus: { kind: "user" } })); + }); + + it("returns only proven descendants, in the order the provider gave them", async () => { + let nodes = [ + root, + folder("mid", { parents: [FOLDER_ROOT] }), + child("deep", "mid"), + child("kid", FOLDER_ROOT), + child("sibling", "outside-folder"), + ]; + let { session } = folderCore(nodes, { + listFiles: async () => page([nodes[4], nodes[2], nodes[3]]), + }); + + expect((await (await session.list()).next())?.map(entry => entry.id)) + .toEqual(["deep", "kid"]); + }); + + // The corpus scan returns the bound folder like any other row, and it proves as a member so + // `getEntry` can read it. No test had ever put it in a provider page, so a listing disclosed + // the folder as one of its own children and inflated every count by one. + it("omits the bound folder from its own listing", async () => { + let nodes = [root, folder("mid", { parents: [FOLDER_ROOT] }), child("kid", FOLDER_ROOT)]; + let { session } = folderCore(nodes, { listFiles: async () => page(nodes) }); + + expect((await (await session.list()).next())?.map(entry => entry.id)) + .toEqual(["mid", "kid"]); + }); + + it("omits the bound folder from a search that matches folders", async () => { + let nodes = [root, folder("mid", { parents: [FOLDER_ROOT] })]; + let { session } = folderCore(nodes, { listFiles: async () => page(nodes) }); + + let cursor = await session.search({ mimeTypes: [FOLDER_MIME_TYPE] }); + expect((await cursor.next())?.map(entry => entry.id)).toEqual(["mid"]); + }); + + // The counterpart: excluding it from listings must not make the bound folder unreadable, and + // its own parent stays withheld because that folder is outside the binding. + it("still reads the bound folder's own metadata through getEntry", async () => { + let { session } = folderCore([root]); + + let entry = await session.getEntry(FOLDER_ROOT); + expect(entry.id).toBe(FOLDER_ROOT); + expect(entry.parentId).toBeUndefined(); + }); + + // The whole page filtering out is not a negative answer: one provider page per call is the + // subrequest budget, and the results are on the next one. + it("yields an empty page while results remain, then the results, then null", async () => { + let nodes = [root, child("kid", FOLDER_ROOT), child("sibling", "outside-folder")]; + let { session } = folderCore(nodes, { + listFiles: async ({ pageToken }) => + pageToken === "p2" ? page([nodes[1]]) : page([nodes[2]], "p2"), + }); + + let cursor = await session.list(); + expect(await cursor.next()).toEqual([]); + expect((await cursor.next())?.map(entry => entry.id)).toEqual(["kid"]); + expect(await cursor.next()).toBeNull(); + }); + + it("closes observer admission before returning filtered cursor progress", async () => { + let nodes = [root, child("kid", FOLDER_ROOT), child("sibling", "outside-folder")]; + let track = driveObserverTracker(new FakeKv(), + { kind: "folder", folderId: FOLDER_ROOT }, async (_verifier, fileIds) => ({ + baselineAllowed: true, allowed: fileIds.map(() => true), + })); + let { session } = folderCore(nodes, { + listFiles: async ({ pageToken }) => + pageToken === "p2" ? page([nodes[1]]) : page([nodes[2]], "p2"), + prepareObservation: fileIds => track.prepareObservation(fileIds), + prepareWithheld: () => track.prepareWithheld(), + }); + + let cursor = await session.list(); + expect(await cursor.next()).toEqual([]); + await expect(track.addObserver("late", "verifier")) + .rejects.toThrow(/can no longer be observed/); + }); + + // The terminal one is a real answer about the folder, so it still gets a record. + it("audits a listing that ends with nothing in scope", async () => { + let sibling = child("sibling", "outside-folder"); + let { session, authorizations } = folderCore([root, sibling], { + listFiles: async () => page([sibling]), + }); + + expect(await (await session.list()).next()).toBeNull(); + expect(authorizations).toHaveLength(1); + expect(authorizations[0].description).toContain("for 0 Drive"); + }); + + // The withheld latch is permanent, so it must not fire on an emptiness the root's own + // disappearance manufactured. + it("refuses rather than latching when the root vanished during an empty search", async () => { + let provider = tree([root]); + let { session, events, authorizations } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: provider.getScopeNodes, + listFiles: async () => { + provider.byId.set(FOLDER_ROOT, folder(FOLDER_ROOT, { trashed: true })); + return page([]); + }, + }); + + await expect((await session.search({ namePrefix: "anything" })).next()) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(events).not.toContain("latch"); + expect(authorizations).toEqual([]); + }); + + // A root that changed drive is still a valid root, so only the pinned corpus catches it — and + // the negative result was computed against the corpus the folder has left. + it("refuses rather than latching when the root changed drive during an empty search", async () => { + let provider = tree([root]); + let { session, events, authorizations } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: provider.getScopeNodes, + listFiles: async () => { + provider.byId.set(FOLDER_ROOT, + folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" })); + return page([]); + }, + }); + + await expect((await session.search({ namePrefix: "anything" })).next()) + .rejects.toThrow("moved to another drive"); + expect(events).not.toContain("latch"); + expect(authorizations).toEqual([]); + }); + + it("revalidates a direct parent before fetching each page", async () => { + let parent = folder("parent", { parents: [FOLDER_ROOT] }); + let listFiles = vi.fn(async () => page([])); + let { session, provider, events } = folderCore([root, parent], { listFiles }); + let cursor = await session.search({ directParentId: "parent" }); + provider.byId.set("parent", folder("parent", { parents: ["outside-folder"] })); + + await expect(cursor.next()) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(listFiles).not.toHaveBeenCalled(); + expect(events).toEqual(["authorize", "commit"]); + }); + + // A page token is only valid against the corpus that produced it, so a root that changes + // domain mid-pagination has nowhere safe to resume. + it("aborts a cursor whose root moved to another drive", async () => { + let current = folder(FOLDER_ROOT, { parents: ["above"] }); + let provider = tree([current, child("kid", FOLDER_ROOT)]); + let { session } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: provider.getScopeNodes, + listFiles: async () => page([child("kid", FOLDER_ROOT)], "p2"), + }); + + let cursor = await session.list(); + expect((await cursor.next())?.map(entry => entry.id)).toEqual(["kid"]); + provider.byId.set(FOLDER_ROOT, + folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" })); + + await expect(cursor.next()).rejects.toThrow(/moved to another drive/); + }); + + // The earliest hops of a page's proof are the stalest thing authorizing its disclosure, so the + // recheck immediately before disclosure is what catches a move that landed during the walk. + it("discards a page whose chain changed under it, without advancing the cursor", async () => { + let provider = tree([root, folder("mid", { parents: [FOLDER_ROOT] }), child("deep", "mid")]); + let calls = 0; + let requested: (string | undefined)[] = []; + let { session, authorizations } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: async ids => { + // The walk resolves "mid" first; the recheck re-reads the whole path afterwards. + if (++calls === 2) provider.byId.set("mid", folder("mid", { parents: ["elsewhere"] })); + return provider.getScopeNodes(ids); + }, + listFiles: async ({ pageToken }) => { + requested.push(pageToken); + return page([child("deep", "mid")], "p2"); + }, + }); + + let cursor = await session.list(); + await expect(cursor.next()) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(authorizations).toEqual([]); + + provider.byId.set("mid", folder("mid", { parents: [FOLDER_ROOT] })); + expect((await cursor.next())?.map(entry => entry.id)).toEqual(["deep"]); + expect(requested).toEqual([undefined, undefined]); + }); + + it("names the folder and its descendants in the observation", async () => { + let { session, authorizations } = folderCore([root, child("kid", FOLDER_ROOT)], { + listFiles: async () => page([child("kid", FOLDER_ROOT)]), + }); + + await (await session.list()).next(); + expect(authorizations[0].description) + .toContain(`folder ${FOLDER_ROOT} and its descendants`); + }); + }); + + describe("native reads", () => { + const nativeDoc = (parent: string) => + child("doc-1", parent, { mimeType: docMime }); + + it("opens a native descendant and refuses one outside the subtree", async () => { + let inside = folderCore([root, nativeDoc(FOLDER_ROOT)]); + await expect(inside.session.openNativeFile("doc-1", docMime, "Google Doc")) + .resolves.toBe("doc-1"); + + let outside = folderCore([root, nativeDoc("outside-folder")]); + await expect(outside.session.openNativeFile("doc-1", docMime, "Google Doc")) + .rejects.toThrow("The requested file is outside this Drive binding."); + }); + + it("proves the file before the provider is contacted at all", async () => { + let { session } = folderCore([root, nativeDoc("outside-folder")]); + let fetched = vi.fn(async () => "content"); + + await expect(session.nativeRead("doc-1", docMime)(fetched, () => ({ + title: "Read Google Doc content", description: "Read the body.", + }))).rejects.toThrow("The requested file is outside this Drive binding."); + expect(fetched).not.toHaveBeenCalled(); + }); + + it("refuses a file whose native type no longer matches", async () => { + let { session } = folderCore([root, child("doc-1", FOLDER_ROOT, { mimeType: "application/pdf" })]); + let fetched = vi.fn(async () => "content"); + + await expect(session.nativeRead("doc-1", docMime)(fetched, () => ({ + title: "Read Google Doc content", description: "Read the body.", + }))).rejects.toThrow("The requested file is outside this Drive binding."); + expect(fetched).not.toHaveBeenCalled(); + }); + + // The move lands while the Docs API call is in flight, so only a check after the read catches + // it — and the content must not be authorized, let alone returned. + it("discards content when the file left the subtree during the read", async () => { + let provider = tree([root, nativeDoc(FOLDER_ROOT)]); + let { session, authorizations } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: provider.getScopeNodes, + }); + + await expect(session.nativeRead("doc-1", docMime)(async () => { + provider.byId.set("doc-1", nativeDoc("outside-folder")); + return "secret"; + }, () => ({ title: "Read Google Doc content", description: "Read the body." }))) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(authorizations).toEqual([]); + }); + + it("discards content when the root loses child-list access during the read", async () => { + let provider = tree([root, nativeDoc(FOLDER_ROOT)]); + let { session, authorizations } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: provider.getFile, + getScopeNodes: provider.getScopeNodes, + }); + + await expect(session.nativeRead("doc-1", docMime)(async () => { + provider.byId.set(FOLDER_ROOT, { + ...root, capabilities: { canListChildren: false }, + }); + return "secret"; + }, () => ({ title: "Read Google Doc content", description: "Read the body." }))) + .rejects.toThrow("The requested file is outside this Drive binding."); + expect(authorizations).toEqual([]); + }); + + it("authorizes and returns content that survived both checks", async () => { + let { session, authorizations, prepared, events } = + folderCore([root, nativeDoc(FOLDER_ROOT)]); + + await expect(session.nativeRead("doc-1", docMime)(async () => "body", () => ({ + title: "Read Google Doc content", description: "Read the body.", + }))).resolves.toBe("body"); + expect(prepared).toEqual([["doc-1"]]); + expect(authorizations).toEqual([expect.objectContaining({ + title: "Read Google Doc content", excludeObservers: ["excluded"], + })]); + expect(events).toEqual(["authorize", "commit"]); + }); + + // An immutable scope cannot move under the session, so it pays for no revalidation. + it("makes no scope calls for a binding whose scope cannot change", async () => { + let { session, getFile, getScopeNodes } = core({ scope: { kind: "file", fileId: "doc-1" } }); + + await expect(session.nativeRead("doc-1", docMime)(async () => "body", () => ({ + title: "Read Google Doc content", description: "Read the body.", + }))).resolves.toBe("body"); + expect(getFile).not.toHaveBeenCalled(); + expect(getScopeNodes).not.toHaveBeenCalled(); + }); + }); + + describe("failure modes", () => { + // A quota, outage, or account-wide block reported as a scope denial would look like the file + // left the folder, and the caller would go looking for a move that never happened. + it.each([ + ["a quota refusal", new DriveApiRequestError(403, "userRateLimitExceeded")], + // The root read happens on every folder operation, so this is the one users would hit. + ["an account-wide policy block", new DriveApiRequestError(403, "domainPolicy")], + ["a server error", new DriveApiRequestError(500)], + ])("surfaces %s rather than a scope denial", async (_label, error) => { + let { session } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: async () => { throw error; }, + }); + + await expect(session.getEntry("kid")).rejects.toBe(error); + }); + + it("turns an inaccessible root into the generic refusal", async () => { + let { session } = core({ + scope: { kind: "folder", folderId: FOLDER_ROOT }, + getFile: async () => { throw new DriveApiRequestError(404); }, + }); + + await expect(session.getEntry("kid")) + .rejects.toThrow("The requested file is outside this Drive binding."); + }); + + // Hidden ancestors and rejected neighbours are enforcement input, never disclosure: neither may + // consume observer cardinality or appear in anything the caller or the audit trail sees. + it("tracks and names only what it disclosed", async () => { + let nodes = [ + root, + folder("secret-mid", { parents: [FOLDER_ROOT] }), + child("deep", "secret-mid"), + child("private-neighbour", "outside-folder"), + ]; + let { session, prepared, authorizations } = folderCore(nodes, { + listFiles: async () => ({ files: [nodes[3], nodes[2]] }), + }); + + await (await session.list()).next(); + expect(prepared).toEqual([["deep"]]); + let described = JSON.stringify(authorizations); + expect(described).not.toContain("secret-mid"); + expect(described).not.toContain("private-neighbour"); + expect(described).not.toContain("outside-folder"); + }); + }); + + // The end-to-end contract over a realistic fixture: a direct file, a nested one, and a sibling + // outside the root, spread over pages so both cursors have to be drained past an empty slice. + describe("draining a folder subtree", () => { + const nodes = [ + root, + child("direct-file", FOLDER_ROOT), + folder("nested", { parents: [FOLDER_ROOT] }), + child("nested-file", "nested"), + child("sibling", "outside-folder"), + ]; + + /** Serves the sibling alone, then the two in-scope files, then ends. */ + const paged = async ({ pageToken }: DriveListFilesOptions) => { + if (pageToken === undefined) return { files: [nodes[4]], nextPageToken: "p2" }; + if (pageToken === "p2") return { files: [nodes[1], nodes[3]], nextPageToken: "p3" }; + return { files: [] }; + }; + + async function drain(cursor: { next(): Promise }): Promise { + let ids: string[] = []; + for (let call = 0; call < 10; call++) { + let page = await cursor.next(); + if (page === null) return ids; + ids.push(...page.map(entry => entry.id)); + } + throw new Error("cursor did not terminate"); + } + + it.each([ + ["list", async (session: DriveSessionCore) => session.list()], + ["full-text search", + async (session: DriveSessionCore) => session.search({ fullTextContains: "plan" })], + ])("returns every descendant and no neighbour through %s", async (_label, open) => { + let { session } = folderCore(nodes, { listFiles: paged }); + + expect(await drain(await open(session))).toEqual(["direct-file", "nested-file"]); + }); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/observers.test.ts b/packages/gatekeeper-google/__tests__/observers.test.ts index 59b7b9c36b..09304513e0 100644 --- a/packages/gatekeeper-google/__tests__/observers.test.ts +++ b/packages/gatekeeper-google/__tests__/observers.test.ts @@ -357,6 +357,27 @@ describe("bulk verification", () => { expect(nonceKeys()).toEqual([]); }); + it("refuses admission when an owner-only read begins during verification", async () => { + let release!: (result: ObserverBatchResult) => void; + let started!: () => void; + let result = new Promise(resolve => { release = resolve; }); + let seen = new Promise(resolve => { started = resolve; }); + let tracker = makeBulkTracker(async () => { + started(); + return result; + }); + + let admission = tracker.addObserver("reader", allow()); + await seen; + tracker.prepareWithheld().commit(); + release({ baselineAllowed: true, allowed: [] }); + + await expect(admission).rejects.toThrow(/can no longer be observed/); + expect([...tracker.observers()]).toEqual([]); + expect(attemptKeys()).toEqual([]); + expect(nonceKeys()).toEqual([]); + }); + it("keeps a newer same-ID admission authoritative when the older attempt finishes first", async () => { kv.put("set:a", "observed"); let releaseA!: (result: ObserverBatchResult) => void; @@ -625,3 +646,58 @@ describe("concurrency", () => { expect(hasAccess).toHaveBeenCalledTimes(6); }); }); + +// An observation no tracked set describes is one `addObserver` can never verify a candidate +// against: the backward check would pass vacuously over data the candidate was never entitled to. +describe("withheld observations", () => { + const withholdKeys = () => [...kv.list({ prefix: "observer-withhold:" })].map(([key]) => key); + + it("excludes every current observer, including one still being admitted", async () => { + let tracker = makeTracker(); + await tracker.addObserver("settled", allow()); + kv.put("observer-attempt:joining", allow()); + + expect(tracker.prepareWithheld().excludeObservers).toEqual(["settled", "joining"]); + }); + + it("reports no exclusions when nobody is admitted", () => { + expect(makeTracker().prepareWithheld().excludeObservers).toBeUndefined(); + }); + + // The marker goes down before the approval is requested, so an activation that dies awaiting the + // overseer leaves admission closed rather than open over a record the overseer may already hold. + it("closes admission while the read is still in flight", async () => { + let tracker = makeTracker(); + tracker.prepareWithheld(); + + await expect(tracker.addObserver("late", allow())).rejects.toThrow(/can no longer be observed/); + expect(withholdKeys()).toHaveLength(1); + }); + + it("latches admission closed for good once the read is authorized", async () => { + let tracker = makeTracker(); + tracker.prepareWithheld().commit(); + + // No marker survives the latch, and a fresh tracker over the same storage still refuses. + expect(withholdKeys()).toEqual([]); + await expect(makeTracker().addObserver("late", allow())) + .rejects.toThrow(/can no longer be observed/); + }); + + it("reopens admission when the read was refused", async () => { + let tracker = makeTracker(); + tracker.prepareWithheld().discard!(); + + expect(withholdKeys()).toEqual([]); + await expect(tracker.addObserver("late", allow())).resolves.toBeUndefined(); + }); + + it("keeps a concurrent read's fence standing when another is discarded", async () => { + let tracker = makeTracker(); + let refused = tracker.prepareWithheld(); + tracker.prepareWithheld(); + + refused.discard!(); + await expect(tracker.addObserver("late", allow())).rejects.toThrow(/can no longer be observed/); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index 8e7c2d51b1..6bde5b6216 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; import { BIGQUERY_RESOURCE, GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DOC_RESOURCE, - GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE, - GOOGLE_SHEETS_RESOURCE, IDENTITY_SCOPES, LEGACY_GRANTED_RESOURCE_URL_PATTERNS, RESOURCE_BY_KIND, - RESOURCE_SCOPES, SCOPE_DERIVED_RESOURCE_URL_PATTERNS, SUPPORTED_RESOURCES, + GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, GOOGLE_DRIVE_RESOURCE, + GOOGLE_SHARED_DRIVE_RESOURCE, GOOGLE_SHEETS_RESOURCE, IDENTITY_SCOPES, + LEGACY_GRANTED_RESOURCE_URL_PATTERNS, RESOURCE_BY_KIND, RESOURCE_SCOPES, + SCOPE_DERIVED_RESOURCE_URL_PATTERNS, SUPPORTED_RESOURCES, grantedResourceUrlPatterns, hasDriveResourceGrant, parseResourceUrl, recordedResourceUrlPatterns, resourceUrlPatternsToOAuthScopes, resourcesCoveredByScopes, validateResourceUrlPatterns, @@ -30,6 +31,7 @@ describe("resource declarations", () => { "https://calendar.google.com/calendar/:calendarId/*", "https://drive.google.com/drive/my-drive", "https://drive.google.com/drive/folders/:driveId", + "https://drive.google.com/_resource/folder/:folderId", "https://drive.google.com/file/d/:fileId/view", "https://bigquery.googleapis.com/:projectId/*", ]); @@ -91,15 +93,31 @@ describe("resource declarations", () => { expect([ GOOGLE_DRIVE_RESOURCE.description, GOOGLE_SHARED_DRIVE_RESOURCE.description, + GOOGLE_DRIVE_FOLDER_RESOURCE.description, GOOGLE_DRIVE_FILE_RESOURCE.description, ]).toEqual([ "Find files and folders anywhere this Google account can read in Drive, including shared " + "drives. Full-text search examines indexed file content, descriptions, and OCR text; search " + "results contain metadata only, while native Google Docs and Sheets can be opened read-only.", "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", + "Find files and folders, and read native Google Docs and Sheets, within one Drive folder " + + "and its descendants.", "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", ]); }); + + // The shared drive's pattern leaves the query wildcard, so a folder identity built by qualifying + // `/drive/folders/:driveId` would match both and make resource selection order-dependent. These + // two must stay disjoint as *patterns*, not merely distinct strings. + it("keeps the folder selector off every other resource's pattern", () => { + let folderUrl = "https://drive.google.com/_resource/folder/FOLDER123"; + for (let resource of SUPPORTED_RESOURCES) { + let matches = new URLPattern(resource.urlPattern).test(folderUrl); + expect(matches).toBe(resource === GOOGLE_DRIVE_FOLDER_RESOURCE); + } + expect(new URLPattern(GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern) + .test("https://drive.google.com/drive/folders/DRIVE123")).toBe(false); + }); }); describe("resourceUrlPatternsToOAuthScopes", () => { @@ -120,9 +138,9 @@ describe("resourceUrlPatternsToOAuthScopes", () => { ]); }); - // Pins every permanent scope each Drive resource needs. Account and exact-file bindings require - // the metadata scope plus the native Docs and Sheets read scopes. The shared drive needs the wider - // `drive.readonly` scope because `drives.list`/`drives.get` accept nothing narrower. + // Pins every permanent scope each Drive resource needs. Account, folder and exact-file bindings + // require the metadata scope plus the native Docs and Sheets read scopes. The shared drive needs + // the wider `drive.readonly` scope because `drives.list`/`drives.get` accept nothing narrower. it.each([ [GOOGLE_DRIVE_RESOURCE, [ "https://www.googleapis.com/auth/drive.metadata.readonly", @@ -130,6 +148,11 @@ describe("resourceUrlPatternsToOAuthScopes", () => { "https://www.googleapis.com/auth/spreadsheets.readonly", ]], [GOOGLE_SHARED_DRIVE_RESOURCE, ["https://www.googleapis.com/auth/drive.readonly"]], + [GOOGLE_DRIVE_FOLDER_RESOURCE, [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ]], [GOOGLE_DRIVE_FILE_RESOURCE, [ "https://www.googleapis.com/auth/drive.metadata.readonly", "https://www.googleapis.com/auth/documents.readonly", @@ -233,7 +256,8 @@ describe("resourcesCoveredByScopes", () => { describe("hasDriveResourceGrant", () => { it("accepts each explicit Drive resource and rejects historical non-Drive grants", () => { for (let resource of [ - GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, + GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, + GOOGLE_DRIVE_FILE_RESOURCE, ]) { expect(hasDriveResourceGrant([resource.urlPattern])).toBe(true); } @@ -422,12 +446,29 @@ describe("parseResourceUrl", () => { ["account", "https://drive.google.com/drive/my-drive", { kind: "driveAccount" }], ["shared drive", "https://drive.google.com/drive/folders/DRIVE123", { kind: "sharedDrive", driveId: "DRIVE123" }], + ["folder", "https://drive.google.com/_resource/folder/FOLDER123", + { kind: "driveFolder", folderId: "FOLDER123" }], ["file", "https://drive.google.com/file/d/FILE123/view", { kind: "driveFile", fileId: "FILE123" }], ] as const)("scopes to one %s", (_name, url, expected) => { expect(parseResourceUrl(url)).toEqual(expected); }); + // The two share a host and a noun. A folder URL resolving to a shared drive would mint a whole + // drive's authority from a folder's consent, and the reverse would orphan every shared-drive + // binding, so each grammar must stay deaf to the other's shape. + it("keeps the folder and shared-drive grammars from bleeding into each other", () => { + expect(parseResourceUrl("https://drive.google.com/drive/folders/DRIVE123?resource=folder")) + .toEqual({ kind: "sharedDrive", driveId: "DRIVE123" }); + expect(() => parseResourceUrl("https://drive.google.com/_resource/folder/")) + .toThrow(/Unsupported Google Drive resource URL/); + }); + + it("decodes a folder ID that needed escaping", () => { + expect(parseResourceUrl("https://drive.google.com/_resource/folder/a%20b")) + .toEqual({ kind: "driveFolder", folderId: "a b" }); + }); + it("rejects paths outside the permanent Drive grammar", () => { expect(() => parseResourceUrl("https://drive.google.com/drive/u/0/my-drive")) .toThrow(/Unsupported Google Drive resource URL/); diff --git a/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts b/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts index 8486362425..3e0c961487 100644 --- a/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { AccessTokenRequest } from "../../src/auth-retry"; -import { BigQueryConfiguratorUI, CalendarConfiguratorUI } from "../../src/google-configurators"; +import { + BigQueryConfiguratorUI, CalendarConfiguratorUI, DriveFolderConfiguratorUI, +} from "../../src/google-configurators"; import type { GoogleAccessToken } from "../../src/google-api"; const token = (value: string): GoogleAccessToken => ({ @@ -23,6 +25,29 @@ describe("Google resource configurators", () => { .resolves.toBe("person@example.com"); }); + it("omits folders whose children cannot be listed", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ + files: [ + { + id: "metadata-only", name: "Metadata only", + capabilities: { canListChildren: false }, + }, + { + id: "usable", name: "Usable", + capabilities: { canListChildren: true }, + }, + ], + }))); + + await expect(new DriveFolderConfiguratorUI(async () => token("access-token")) + .listDriveFolders("")) + .resolves.toEqual([{ + value: "usable", + title: "Usable", + subtitle: "My Drive", + }]); + }); + it("refreshes a rejected Calendar access token", async () => { let getToken = vi.fn(async (opts?: AccessTokenRequest) => token(opts?.forceRefresh ? "fresh" : "stale")); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index b0336357a1..08ecab0b51 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -11,6 +11,7 @@ import { GoogleSheetsApi } from "../../src/sheets-api"; const DOC_MIME = "application/vnd.google-apps.document"; const SHEET_MIME = "application/vnd.google-apps.spreadsheet"; +const FOLDER_MIME = "application/vnd.google-apps.folder"; let providerUrls: string[]; /** The document the provider currently serves; a test may replace it mid-session. */ let providerTabs: unknown[]; @@ -141,7 +142,7 @@ function newSession() { { kind: "account" }, queueStub, async fileIds => ({ pendingSets: fileIds, commit() {} }), - () => [], + () => ({ pendingSets: [], commit() {} }), )), }; } @@ -335,3 +336,183 @@ describe("Drive Doc tab selection", () => { }); }); }); + +// A folder binding's authority is derived from a hierarchy Drive can change under it, so the +// nested sessions it hands out must re-prove membership on every call rather than once at open. +describe("folder-scoped native sessions", () => { + const ROOT = "folder-root"; + + type Node = { + id: string; + mimeType: string; + parents?: string[]; + trashed: boolean; + capabilities?: { canListChildren: boolean }; + }; + + /** The subtree the provider answers from. Tests move files by rewriting `parents` here. */ + function subtree(): Map { + return new Map([ + [ROOT, { id: ROOT, mimeType: FOLDER_MIME, parents: ["above"], trashed: false, + capabilities: { canListChildren: true } }], + ["doc-1", { id: "doc-1", mimeType: DOC_MIME, parents: [ROOT], trashed: false }], + ["sheet-1", { id: "sheet-1", mimeType: SHEET_MIME, parents: [ROOT], trashed: false }], + ]); + } + + /** One multipart `files.get` batch response, echoing each requested ID by Content-ID position. */ + function batchResponse(body: string, nodes: Map): Response { + const boundary = "folder_batch"; + const ids = [...body.matchAll(/GET \/drive\/v3\/files\/([^?]+)\?/g)] + .map(match => decodeURIComponent(match[1])); + const parts = ids.map((id, index) => { + const node = nodes.get(id); + return [ + `--${boundary}`, + "Content-Type: application/http", + `Content-ID: `, + "", + node ? "HTTP/1.1 200 OK" : "HTTP/1.1 404 Not Found", + "Content-Type: application/json", + "", + node ? JSON.stringify(node) : "{}", + ].join("\r\n"); + }); + return new Response(`${parts.join("\r\n")}\r\n--${boundary}--\r\n`, { + headers: { "Content-Type": `multipart/mixed; boundary=${boundary}` }, + }); + } + + function installFolderProvider(nodes: Map, onNativeRead?: () => void) { + const nativeCalls: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + if (url.pathname === "/batch/drive/v3") { + return batchResponse(String(init?.body ?? ""), nodes); + } + if (url.pathname.includes("/drive/v3/files/")) { + const id = decodeURIComponent(url.pathname.split("/").at(-1)!); + const node = nodes.get(id); + if (!node) return Response.json({}, { status: 404 }); + return Response.json({ ...node, name: id, modifiedTime: "2026-08-20T12:00:00Z" }); + } + nativeCalls.push(url.hostname); + onNativeRead?.(); + if (url.hostname === "docs.googleapis.com") { + return Response.json({ + documentId: "doc-1", + title: "Quarterly plan", + revisionId: "revision-1", + tabs: [docTab("solo", "Solo", "")], + }); + } + if (url.pathname.endsWith("/values:batchGet")) { + return Response.json({ + valueRanges: url.searchParams.getAll("ranges").map(range => ({ range, values: [["x"]] })), + }); + } + return Response.json({ + spreadsheetId: "sheet-1", + properties: { title: "Forecast" }, + sheets: [{ properties: { sheetId: 0, title: "Sheet1", index: 0 } }], + }); + })); + return nativeCalls; + } + + function folderSession(nodes: Map) { + const queue = new TestApprovalQueue(); + return { + queue, + session: new RpcStub(new GoogleDriveSessionImpl( + new DriveApi(getAccessToken), + new GoogleDocsApi(getAccessToken), + new GoogleSheetsApi(getAccessToken), + { kind: "folder", folderId: ROOT }, + new RpcStub(queue), + async fileIds => ({ pendingSets: fileIds, commit() {} }), + () => ({ pendingSets: [], commit() {} }), + )), + }; + } + + const OUTSIDE = "The requested file is outside this Drive binding."; + + it("serves Doc and Sheet reads while the files remain in the subtree", async () => { + const nodes = subtree(); + installFolderProvider(nodes); + using session = folderSession(nodes).session; + + using doc = await session.openGoogleDoc("doc-1"); + expect((await doc.getMetadata()).title).toBe("doc-1"); + expect(await doc.getContent()).toBe(""); + + using sheet = await session.openGoogleSheet("sheet-1"); + expect((await sheet.getSpreadsheet()).title).toBe("Forecast"); + expect((await sheet.readRange("A1:A1")).values).toEqual([["x"]]); + expect((await sheet.readRanges(["A1:A1", "B1:B1"])).map(r => r.range)) + .toEqual(["A1:A1", "B1:B1"]); + }); + + // The capability was minted while the file was inside; the move is what revokes it, and it has to + // revoke an already-open session, not merely the next open. `Promise.resolve` settles each RPC + // promise into a native one, so its rejection gets a handler attached eagerly. + it("refuses every Doc read after the document leaves the subtree", async () => { + const nodes = subtree(); + const nativeCalls = installFolderProvider(nodes); + using session = folderSession(nodes).session; + using doc = await session.openGoogleDoc("doc-1"); + + nodes.set("doc-1", { id: "doc-1", mimeType: DOC_MIME, parents: ["elsewhere"], trashed: false }); + nativeCalls.length = 0; + + await expect(Promise.resolve(doc.getMetadata())).rejects.toThrow(OUTSIDE); + await expect(Promise.resolve(doc.getContent())).rejects.toThrow(OUTSIDE); + // The precheck runs first, so the Docs API is never asked for content we could not disclose. + expect(nativeCalls).toEqual([]); + }); + + it("refuses every Sheet read after the file leaves the subtree", async () => { + const nodes = subtree(); + const nativeCalls = installFolderProvider(nodes); + using session = folderSession(nodes).session; + using sheet = await session.openGoogleSheet("sheet-1"); + + // No parents at all: containment is undecidable, which is not membership. + nodes.set("sheet-1", { id: "sheet-1", mimeType: SHEET_MIME, parents: [], trashed: false }); + nativeCalls.length = 0; + + await expect(Promise.resolve(sheet.getSpreadsheet())).rejects.toThrow(OUTSIDE); + await expect(Promise.resolve(sheet.readRange("A1:A1"))).rejects.toThrow(OUTSIDE); + await expect(Promise.resolve(sheet.readRanges(["A1:A1", "B1:B1"]))).rejects.toThrow(OUTSIDE); + expect(nativeCalls).toEqual([]); + }); + + // The window the postcheck exists for: the move lands while the Docs call is in flight, so only a + // second look after the read can catch it — and the content must reach neither the approval queue + // nor the caller. + it("discards content when the move lands during the provider read", async () => { + const nodes = subtree(); + installFolderProvider(nodes, () => { + nodes.set("doc-1", { id: "doc-1", mimeType: DOC_MIME, parents: ["elsewhere"], trashed: false }); + }); + const { queue, session } = folderSession(nodes); + using scoped = session; + using doc = await scoped.openGoogleDoc("doc-1"); + const authorizedBefore = queue.observations.length; + + await expect(Promise.resolve(doc.getContent())) + .rejects.toThrow(OUTSIDE); + expect(queue.observations).toHaveLength(authorizedBefore); + }); + + it("refuses to open a native file that is already outside the subtree", async () => { + const nodes = subtree(); + nodes.set("doc-1", { id: "doc-1", mimeType: DOC_MIME, parents: ["elsewhere"], trashed: false }); + installFolderProvider(nodes); + using session = folderSession(nodes).session; + + await expect(Promise.resolve(session.openGoogleDoc("doc-1"))) + .rejects.toThrow(OUTSIDE); + }); +}); diff --git a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts new file mode 100644 index 0000000000..703134cf9b --- /dev/null +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts @@ -0,0 +1,7 @@ +import type { ConfiguratorOption } from "./configurator-option"; + +export type DriveFolderConfiguratorValues = { folderId?: string | null }; + +export interface DriveFolderConfiguratorRpc { + listDriveFolders(query: string): Promise; +} diff --git a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx new file mode 100644 index 0000000000..6661bf9272 --- /dev/null +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx @@ -0,0 +1,25 @@ +import { Autocomplete, Field, h, Section, type ConfiguratorUISpec } from "@gadgets/configurator-ui"; +import type { DriveFolderConfiguratorRpc, DriveFolderConfiguratorValues } from "./drive-folder-configurator-types"; + +export default { + initial: {}, + isReady: ({ values }) => typeof values.folderId === "string" && values.folderId.length > 0, + // Must mirror `parseDriveUrl` in resources.ts, which is what actually mints the capability. This + // module is transpiled on its own and cannot import that parser, so `__tests__/configurator-url + // .test.ts` is what keeps the copies honest. + resourceUrl: ({ values }) => + `https://drive.google.com/_resource/folder/${encodeURIComponent(values.folderId ?? "")}`, + render({ values, setValues, ui }) { + return
+ + ui.listDriveFolders(query)} + onChange={folderId => setValues({ folderId })} + /> + +
; + }, +} satisfies ConfiguratorUISpec; diff --git a/packages/gatekeeper-google/src/cursor.ts b/packages/gatekeeper-google/src/cursor.ts index 6f8b0ad299..099a2b95b5 100644 --- a/packages/gatekeeper-google/src/cursor.ts +++ b/packages/gatekeeper-google/src/cursor.ts @@ -33,23 +33,30 @@ export type CursorPagerOptions = { */ buildEntries(items: Item[]): Promise; - /** Authorize a page or terminal empty result before returning it. Throws to deny. */ - authorize(entries: Entry[]): Promise; + /** + * Authorize a page before it is disclosed. Throws to deny. + * + * `exhausted` says the provider handed back no continuation token, so this really is the end of + * the results. An empty page with `exhausted: false` is only this call's budget running out, and + * must not be treated as a negative answer -- there are results ahead of it. + */ + authorize(entries: Entry[], exhausted: boolean): Promise; /** Best-effort cleanup for built entries that authorization prevents from being returned. */ disposeEntries?(entries: Entry[]): void | Promise; - /** How many result-less pages to walk past before giving up. */ - maxEmptyPages?: number; + /** Provider pages one `next()` may fetch before returning what it has. */ + maxProviderPagesPerCall?: number; }; /** - * Pages to walk past before concluding the provider is wasting our time. + * Provider pages one `next()` walks past before handing the caller an empty page. * * Reached either because the provider itself keeps returning empty pages, or because a scope - * filter keeps discarding everything on them. + * filter keeps discarding everything on them. The bound is per call, not per cursor: it caps the + * work and the subrequests one invocation can spend, and the caller drains to `null` regardless. */ -export const DEFAULT_MAX_EMPTY_PAGES = 20; +export const DEFAULT_MAX_PROVIDER_PAGES_PER_CALL = 20; /** The one method a `Cursor` exposes. `CursorPager` implements it; google.ts wraps it for RPC. */ export interface Pager { @@ -58,7 +65,7 @@ export interface Pager { export class CursorPager implements Pager { #options: CursorPagerOptions; - #maxEmptyPages: number; + #maxProviderPages: number; #pageToken: string | undefined; // Every token the provider has handed back. A cursor stops at the first repeat, so this grows // only with genuinely distinct pages. @@ -68,11 +75,13 @@ export class CursorPager implements Pager { constructor(options: CursorPagerOptions) { this.#options = options; - this.#maxEmptyPages = options.maxEmptyPages ?? DEFAULT_MAX_EMPTY_PAGES; + this.#maxProviderPages = + options.maxProviderPagesPerCall ?? DEFAULT_MAX_PROVIDER_PAGES_PER_CALL; } /** - * The next page of entries, or null once there are none left. + * The next page of entries, `[]` when this call's page budget ran out with results still ahead, + * or null once there are none left. * * Calls are serialized: a caller that fires several without awaiting gets successive pages * rather than a race over the cursor's position. @@ -88,7 +97,7 @@ export class CursorPager implements Pager { async #nextPage(): Promise { if (this.#exhausted) return null; - let { provider, fetchPage, buildEntries, authorize, disposeEntries } = this.#options; + let { fetchPage, buildEntries, authorize, disposeEntries, provider } = this.#options; let pageToken = this.#pageToken; // Tokens followed during this call. Merged into the committed set only once the page is // approved: a denied read rewinds the cursor, and the retry re-derives these same tokens. @@ -110,19 +119,16 @@ export class CursorPager implements Pager { } entries = await buildEntries(page.items); - if (entries.length > 0) break; - - if (pageToken === undefined) break; - if (fetched >= this.#maxEmptyPages) { - throw new Error( - `${provider} returned ${fetched} pages with no usable results.`); - } + if (entries.length > 0 || pageToken === undefined) break; + // Out of budget with pages still to come. Hand back an empty page rather than throwing: the + // caller's own retry is the next slice of the same work, and a cursor is drained to `null`. + if (fetched >= this.#maxProviderPages) break; } // Advance only once the page has been approved. A denied read leaves the cursor where it was, // so retrying re-offers the same page instead of silently skipping over it. try { - await authorize(entries); + await authorize(entries, pageToken === undefined); } catch (error) { try { await disposeEntries?.(entries); @@ -134,6 +140,6 @@ export class CursorPager implements Pager { for (let token of followed) this.#seenTokens.add(token); this.#pageToken = pageToken; this.#exhausted = pageToken === undefined; - return entries.length > 0 ? entries : null; + return entries.length === 0 && this.#exhausted ? null : entries; } } diff --git a/packages/gatekeeper-google/src/drive-api.ts b/packages/gatekeeper-google/src/drive-api.ts index b41072b395..488ba57fbf 100644 --- a/packages/gatekeeper-google/src/drive-api.ts +++ b/packages/gatekeeper-google/src/drive-api.ts @@ -8,6 +8,9 @@ const MAX_BATCH_FILES = 100; const MAX_BATCH_RESPONSE_BYTES = 1_000_000; const MAX_JSON_RESPONSE_BYTES = 5_000_000; +/** Exact MIME type Drive gives a native folder. A shortcut to one has its own type, not this. */ +export const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; + /** The subset of Drive's file resource this gatekeeper asks for. */ export type DriveFile = { id: string; @@ -20,16 +23,36 @@ export type DriveFile = { owners?: { displayName?: string; emailAddress?: string }[]; webViewLink?: string; trashed?: boolean; + capabilities?: { canListChildren?: boolean }; shortcutDetails?: { targetId?: string; targetMimeType?: string }; }; /** Current metadata for one shared drive. */ export type DriveInfo = { id: string; name: string }; +/** + * The minimal per-file facts a folder-scope descendant proof rests on. + * + * Deliberately narrower than {@link DriveFile}: an ancestry walk touches folders the caller never + * asked about and must never see, so it fetches only what membership is decided from. + */ +export type DriveScopeNode = { + id: string; + mimeType?: string; + parents?: string[]; + driveId?: string; + trashed?: boolean; + canListChildren?: boolean; +}; + +/** Field mask for the facts {@link DriveApi.getScopeNodes} uses to prove scope. */ +const DRIVE_SCOPE_NODE_FIELDS = + "id,mimeType,parents,driveId,trashed,capabilities(canListChildren)"; + /** The per-file field mask. `getFile` sends this; {@link DRIVE_FILE_FIELDS} wraps it for lists. */ export const DRIVE_FILE_ITEM_FIELDS = [ "id", "name", "mimeType", "modifiedTime", "size", "parents", "driveId", "trashed", - "owners(displayName,emailAddress)", "webViewLink", + "owners(displayName,emailAddress)", "webViewLink", "capabilities(canListChildren)", "shortcutDetails(targetId,targetMimeType)", ].join(","); @@ -85,18 +108,26 @@ export class DriveApiRequestError extends Error { super(`Google Drive API request failed: ${status}${reason ? ` (${reason})` : ""}`); } - /** Whether this failure reports one of Google's documented quota reasons. */ - get isQuotaExceeded(): boolean { - return this.status === 403 && this.reason !== undefined && QUOTA_403_REASONS.has(this.reason); + /** + * Whether this failure describes the account or the app rather than one file. + * + * A file-specific denial is a scope fact a caller may record; these are not, so recording one + * would narrow a listing or deny a binding on an outage. + */ + get isAccountWide(): boolean { + return this.status === 403 && this.reason !== undefined && + ACCOUNT_WIDE_403_REASONS.has(this.reason); } } const MAX_ERROR_BODY_BYTES = 4096; const API_DISABLED_REASON = "accessNotConfigured"; -const QUOTA_403_REASONS = new Set([ +const ACCOUNT_WIDE_403_REASONS = new Set([ "dailyLimitExceeded", "rateLimitExceeded", "userRateLimitExceeded", + // The domain administrator has disabled Drive for this app, for every file it might ask about. + "domainPolicy", ]); function googleErrorReason(value: unknown): string | undefined { if (!isRecord(value) || !isRecord(value.error) || !Array.isArray(value.error.errors)) { @@ -144,6 +175,15 @@ function googleErrorReasonFromText(text: string): string | undefined { } } +/** Parses a Drive JSON body without exposing its metadata in failures. */ +function parseDriveJson(text: string, context: string): unknown { + try { + return JSON.parse(text); + } catch { + throw new Error(`Google Drive ${context} was not valid JSON (${text.length} UTF-16 code units)`); + } +} + async function errorReason(response: Response): Promise { let text = await readBoundedText( response, MAX_ERROR_BODY_BYTES, "Google Drive error response was too large").catch(() => ""); @@ -184,6 +224,20 @@ function optionalFields(value: Record, fields: readonly string[ return result; } +function optionalParents(value: unknown): string[] | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some(parent => typeof parent !== "string")) { + throw new Error("Invalid Google Drive file parents"); + } + return value as string[]; +} + +function optionalCanListChildren(value: unknown): boolean | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw new Error("Invalid Google Drive file capabilities"); + return optionalBoolean(value.canListChildren, "file capabilities.canListChildren"); +} + function parseDriveFile(value: unknown): DriveFile { if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") { throw new Error("Invalid Google Drive file response"); @@ -201,13 +255,12 @@ function parseDriveFile(value: unknown): DriveFile { if (!isRecord(value.shortcutDetails)) throw new Error("Invalid Google Drive shortcut details"); shortcutDetails = optionalFields(value.shortcutDetails, ["targetId", "targetMimeType"]); } - let parents: string[] | undefined; - if (value.parents !== undefined) { - if (!Array.isArray(value.parents) || value.parents.some(parent => typeof parent !== "string")) { - throw new Error("Invalid Google Drive file parents"); - } - parents = value.parents as string[]; + let capabilities: DriveFile["capabilities"]; + if (value.capabilities !== undefined) { + let canListChildren = optionalCanListChildren(value.capabilities); + capabilities = canListChildren === undefined ? {} : { canListChildren }; } + let parents = optionalParents(value.parents); let trashed = optionalBoolean(value.trashed, "file trashed"); return { id: value.id, @@ -218,10 +271,34 @@ function parseDriveFile(value: unknown): DriveFile { ...(parents ? { parents } : {}), ...(owners ? { owners } : {}), ...(trashed === undefined ? {} : { trashed }), + ...(capabilities ? { capabilities } : {}), ...(shortcutDetails ? { shortcutDetails } : {}), }; } +/** + * Parses one batch part's body as the scope node for `fileId`. + * + * The echo check is load-bearing, not defensive noise: these nodes decide whether a file is inside + * the bound folder, and a body answering for some other file would decide it from the wrong facts. + */ +function parseDriveScopeNode(body: string, fileId: string): DriveScopeNode { + let value = parseDriveJson(body, "batch response part"); + if (!isRecord(value) || value.id !== fileId) { + throw new Error("Google Drive batch response did not echo the requested file ID"); + } + let parents = optionalParents(value.parents); + let trashed = optionalBoolean(value.trashed, "file trashed"); + let canListChildren = optionalCanListChildren(value.capabilities); + return { + id: fileId, + ...optionalFields(value, ["mimeType", "driveId"]), + ...(parents ? { parents } : {}), + ...(trashed === undefined ? {} : { trashed }), + ...(canListChildren === undefined ? {} : { canListChildren }), + }; +} + function parseDriveInfo(value: unknown): DriveInfo { if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") { throw new Error("Invalid Google shared-drive response"); @@ -266,6 +343,27 @@ export function buildDriveQuery(query: DriveFileQuery): string { type BatchAccessPart = { status: number; body: string }; +/** + * The inner HTTP response carried by one `multipart/mixed` part: a status line, headers, a blank + * line, then the body. + * + * The body is located forward from the status line rather than taken as the part's last + * blank-line-delimited chunk. A conforming emitter ends the body with a blank line before the next + * boundary, so that chunk is empty, and reading it as the body turns every *successful* subrequest + * into unparseable JSON. The status is read from the same match, so a body quoting a status line + * cannot supply it either. + */ +function parseBatchPart(part: string): BatchAccessPart | undefined { + let statusMatch = /HTTP\/1\.[01] (\d{3})/.exec(part); + if (!statusMatch) return undefined; + let afterStatus = part.slice(statusMatch.index); + let headerEnd = /\r?\n\r?\n/.exec(afterStatus); + return { + status: Number(statusMatch[1]), + body: headerEnd ? afterStatus.slice(headerEnd.index + headerEnd[0].length).trim() : "", + }; +} + /** * Split a Drive batch response and place each part by its echoed Content-ID. * @@ -296,8 +394,9 @@ async function parseBatchAccessParts( if (index < 0 || index >= count || placed[index] !== undefined) { throw new Error("Google Drive batch response part had an unrecognised Content-ID"); } - let status = Number(/HTTP\/1\.[01] (\d{3})/.exec(part)?.[1]); - placed[index] = { status, body: part.split(/\r?\n\r?\n/).at(-1) ?? "" }; + let parsed = parseBatchPart(part); + if (!parsed) throw new Error("Google Drive batch response part was missing a status line"); + placed[index] = parsed; } return placed.map(part => { if (part === undefined) { @@ -314,7 +413,7 @@ function batchPartAllowed(part: BatchAccessPart): boolean { throw new DriveApiDisabledError( "the Google Drive API is not enabled for this OAuth project"); } - if (part.status === 403 && reason !== undefined && QUOTA_403_REASONS.has(reason)) { + if (part.status === 403 && reason !== undefined && ACCOUNT_WIDE_403_REASONS.has(reason)) { throw new Error("Google Drive batch subrequest failed: 403"); } if (part.status === 403 || part.status === 404) return false; @@ -399,23 +498,59 @@ export class DriveApi { return drives; } - /** Fresh access checks, issued as multipart `files.get` batches of at most 100 IDs. */ - async checkFileAccess(fileIds: readonly string[]): Promise { - let result: boolean[] = []; + /** Fresh access checks, optionally requiring one folder's children to be listable. */ + async checkFileAccess( + fileIds: readonly string[], listableFolderId?: string, + ): Promise { + let fields = listableFolderId === undefined + ? "id" + : "id,capabilities(canListChildren)"; + return this.#batchGetFiles(fileIds, fields, (part, fileId) => { + if (!batchPartAllowed(part)) return false; + return fileId !== listableFolderId || + parseDriveScopeNode(part.body, fileId).canListChildren === true; + }); + } + + /** + * Fresh ancestry facts for a folder-scope proof, in the requested order. + * + * `undefined` marks a file-specific denial (403/404). API disabled, quota, an account-wide policy + * block, malformed multipart, a bad Content-ID, and a body answering for another file all throw, + * so none of them can be read as "not a descendant" and quietly narrow a listing. A 403 whose + * reason Google does not document as account-wide still counts as a denial. + */ + async getScopeNodes(fileIds: readonly string[]): Promise<(DriveScopeNode | undefined)[]> { + return this.#batchGetFiles(fileIds, DRIVE_SCOPE_NODE_FIELDS, (part, fileId) => + batchPartAllowed(part) ? parseDriveScopeNode(part.body, fileId) : undefined); + } + + /** Runs `files.get` batches of at most 100 IDs, mapping each placed part back to its ID. */ + async #batchGetFiles( + fileIds: readonly string[], + fields: string, + mapPart: (part: BatchAccessPart, fileId: string) => T, + ): Promise { + let result: T[] = []; for (let offset = 0; offset < fileIds.length; offset += MAX_BATCH_FILES) { - result.push(...await this.#checkFileAccessBatch(fileIds.slice(offset, offset + MAX_BATCH_FILES))); + let chunk = fileIds.slice(offset, offset + MAX_BATCH_FILES); + let parts = await this.#batchGetChunk(chunk, fields); + result.push(...parts.map((part, index) => mapPart(part, chunk[index]))); } return result; } - async #checkFileAccessBatch(fileIds: readonly string[]): Promise { + async #batchGetChunk( + fileIds: readonly string[], fields: string, + ): Promise { let boundary = `gadgets_drive_${crypto.randomUUID()}`; let parts = fileIds.map((fileId, index) => [ `--${boundary}`, "Content-Type: application/http", `Content-ID: `, "", - `GET /drive/v3/files/${encodeURIComponent(fileId)}?fields=id&supportsAllDrives=true HTTP/1.1`, + `GET /drive/v3/files/${encodeURIComponent(fileId)}?fields=${encodeURIComponent(fields)}` + + "&supportsAllDrives=true HTTP/1.1", "Accept: application/json", "", "", @@ -455,7 +590,7 @@ export class DriveApi { continue; } - return placed.map(batchPartAllowed); + return placed; } } @@ -467,6 +602,6 @@ export class DriveApi { if (!response.ok) throw await driveError(response); let text = await readBoundedText( response, MAX_JSON_RESPONSE_BYTES, "Google Drive response was too large"); - return JSON.parse(text); + return parseDriveJson(text, "response"); } } diff --git a/packages/gatekeeper-google/src/drive-folder-scope.ts b/packages/gatekeeper-google/src/drive-folder-scope.ts new file mode 100644 index 0000000000..0ca5199435 --- /dev/null +++ b/packages/gatekeeper-google/src/drive-folder-scope.ts @@ -0,0 +1,196 @@ +/** + * Descendant-membership proofs for a folder-scoped Drive binding. + * + * Drive v3 offers no folder corpus, no folder-scoped token, and no recursive ancestor predicate -- + * `'' in parents` means direct children only. So confinement to a subtree is proved here, from + * freshly fetched metadata, one parent hop at a time, and nothing survives the operation that + * proved it: a hierarchy change rotates no credential and bumps no cache generation, so a + * remembered ancestry would be authority the provider never re-confirmed. + */ + +import { FOLDER_MIME_TYPE, type DriveApi, type DriveFile, type DriveScopeNode } from "./drive-api"; + +/** + * Parent hops one proof may take. + * + * My Drive and shared drives both cap nesting at 100 levels, so one hop past that separates a legal + * maximum-depth descendant from a chain that does not terminate. + */ +const MAX_PARENT_HOPS = 101; + +/** The single refusal every folder-scope failure collapses to. It names nothing it rejected. */ +export function outsideScope(): never { + throw new Error("The requested file is outside this Drive binding."); +} + +/** A candidate proven to be the root or one of its live descendants, with the chain that proved it. */ +export type FolderProof = { + file: DriveFile; + /** The candidate and every ancestor traversed, up to and including the root. */ + path: DriveScopeNode[]; +}; + +/** One candidate's walk toward the root. Absent `parentId` with `proven: false` means rejected. */ +type Walk = FolderProof & { + seen: Set; + parentId?: string; + proven: boolean; +}; + +function scopeNode(file: DriveFile): DriveScopeNode { + return { + id: file.id, + ...(file.mimeType === undefined ? {} : { mimeType: file.mimeType }), + ...(file.parents ? { parents: file.parents } : {}), + ...(file.driveId === undefined ? {} : { driveId: file.driveId }), + ...(file.trashed === undefined ? {} : { trashed: file.trashed }), + ...(file.capabilities?.canListChildren === undefined ? {} : { + canListChildren: file.capabilities.canListChildren, + }), + }; +} + +/** + * Reads the bound folder and confirms it is still usable as a root, or refuses. + * + * The one place a folder ID becomes authority, so `describe()` and the session share it and cannot + * drift. Identity is the immutable ID: a rename or a move does not retarget the capability. What + * would retarget it is accepting something that is no longer an ordinary live folder. + */ +export async function readFolderRoot( + folderId: string, + getFile: (fileId: string) => Promise, +): Promise { + // The account-relative alias resolves per account, so it names no stable authority. Checked + // before the fetch, since no read can make it one. + if (folderId === "root") outsideScope(); + let file = await getFile(folderId); + if (file.id !== folderId || + // A shortcut carries its own MIME type, so this also refuses one aimed at a folder. + file.mimeType !== FOLDER_MIME_TYPE || + file.capabilities?.canListChildren !== true || + file.trashed !== false || + // A shared drive's root shares the drive's own ID and is the Shared Drive resource. Serving it + // here too would make the folder binding a second, weaker name for a whole drive. + file.id === file.driveId) { + outsideScope(); + } + return file; +} + +/** Whether both nodes sit in the same storage domain: the same shared drive, or My Drive. */ +function sameDomain(node: DriveScopeNode, root: DriveScopeNode): boolean { + return node.driveId === root.driveId; +} + +/** Whether an intermediate node can carry a chain: a live folder in the root's own domain. */ +function isTraversableFolder(node: DriveScopeNode, root: DriveScopeNode): boolean { + return node.trashed === false && node.mimeType === FOLDER_MIME_TYPE && sameDomain(node, root); +} + +/** Whether a re-read node still states every fact the proof recorded about it. */ +function unchanged(node: DriveScopeNode, recorded: DriveScopeNode): boolean { + return node.id === recorded.id && node.mimeType === recorded.mimeType && + node.driveId === recorded.driveId && node.trashed === recorded.trashed && + node.canListChildren === recorded.canListChildren && + node.parents?.length === recorded.parents?.length && + (node.parents ?? []).every((parent, index) => parent === recorded.parents?.[index]); +} + +function startWalk(file: DriveFile, root: DriveScopeNode): Walk | undefined { + let node = scopeNode(file); + // The candidate is the one node whose type is unconstrained: a leaf may be any file, a shortcut + // included -- it is disclosed as a shortcut and never followed. + if (node.trashed !== false || !sameDomain(node, root)) return undefined; + if (node.id === root.id) { + return { file, path: [root], seen: new Set([root.id]), proven: true }; + } + // A Drive file has one current parent. `parents` is an array anyway, so anything else is either a + // malformed response or a shape whose containment this cannot decide. + if (node.parents?.length !== 1) return undefined; + return { file, path: [node], seen: new Set([node.id]), parentId: node.parents[0], proven: false }; +} + +/** Follows one walk to the ancestor it was waiting on, or abandons it. */ +function step(walk: Walk, node: DriveScopeNode | undefined, root: DriveScopeNode): void { + let parentId = walk.parentId; + walk.parentId = undefined; + if (parentId === undefined || node === undefined || walk.seen.has(parentId)) return; + walk.seen.add(parentId); + walk.path.push(node); + if (parentId === root.id) { + walk.proven = true; + return; + } + if (node.parents?.length !== 1) return; + walk.parentId = node.parents[0]; +} + +export class FolderScope { + #api: Pick; + + constructor(api: Pick) { + this.#api = api; + } + + /** + * The subset of `files` that is the root or one of its live descendants, in provider order. + * + * Batched by level rather than per candidate: one `files.get` batch resolves the whole frontier's + * parents, so even a full page of maximum-depth candidates costs one subrequest per level. A + * missing or inaccessible parent, several parents, a non-folder or trashed ancestor, a hop into + * another storage domain, a cycle, or depth exhaustion all mean "not a member" rather than an + * error: on a broad page those are ordinary neighbours the binding must not disclose. + */ + async prove(files: readonly DriveFile[], rootFile: DriveFile): Promise { + let root = scopeNode(rootFile); + let walks: Walk[] = []; + for (let file of files) { + let walk = startWalk(file, root); + if (walk) walks.push(walk); + } + + // Ancestors resolved during this proof, and only during it. `undefined` records a parent that + // was fetched and rejected, so a shared subtree costs one lookup however many walks cross it. + let ancestors = new Map([[root.id, root]]); + for (let hop = 0; hop < MAX_PARENT_HOPS; hop++) { + let pending = walks.filter(walk => walk.parentId !== undefined); + if (pending.length === 0) break; + let wanted = [...new Set(pending.map(walk => walk.parentId!))] + .filter(id => !ancestors.has(id)); + if (wanted.length > 0) { + let nodes = await this.#api.getScopeNodes(wanted); + for (let [index, node] of nodes.entries()) { + ancestors.set(wanted[index], node && isTraversableFolder(node, root) ? node : undefined); + } + } + for (let walk of pending) step(walk, ancestors.get(walk.parentId!), root); + } + + return walks.filter(walk => walk.proven).map(({ file, path }) => ({ file, path })); + } + + /** + * Re-reads every node the proofs rested on and refuses if any recorded fact has changed. + * + * A proof spans many round trips, so its earliest hops are the stalest thing authorizing the + * disclosure. This is a second look immediately before that disclosure, not a lock: Drive has no + * ancestry-plus-content transaction, and a move landing after it is caught by the next read. + * + * Moving the bound folder itself also fails here, since its own `parents` is compared like any + * other node's even though the root's container cannot affect membership. The retry succeeds, and + * exempting one field from this comparison would cost more review than it saves. + */ + async recheck(proofs: readonly FolderProof[]): Promise { + let recorded = new Map(); + for (let proof of proofs) { + for (let node of proof.path) recorded.set(node.id, node); + } + if (recorded.size === 0) return; + let ids = [...recorded.keys()]; + let nodes = await this.#api.getScopeNodes(ids); + for (let [index, node] of nodes.entries()) { + if (!node || !unchanged(node, recorded.get(ids[index])!)) outsideScope(); + } + } +} diff --git a/packages/gatekeeper-google/src/drive-observers.ts b/packages/gatekeeper-google/src/drive-observers.ts index 51788c2845..ee2e4face5 100644 --- a/packages/gatekeeper-google/src/drive-observers.ts +++ b/packages/gatekeeper-google/src/drive-observers.ts @@ -12,6 +12,7 @@ function scopeRootId(scope: DriveBindingScope): string | undefined { switch (scope.kind) { case "account": return undefined; case "sharedDrive": return scope.driveId; + case "folder": return scope.folderId; case "file": return scope.fileId; } } @@ -19,11 +20,11 @@ function scopeRootId(scope: DriveBindingScope): string | undefined { /** * The observer tracker for one Drive binding, seeded with the set its scope already names. * - * A shared-drive or single-file binding can always reach its own root, so that ID is recorded up - * front rather than waiting for a read to discover it. A file binding therefore never grows past - * it because its session admits no other ID. This lets all three scopes share one admission path. - * Without the seed a file binding would need a second, hand-rolled verify kept in step by hand with - * this one's staging and rollback. + * A shared-drive, folder, or single-file binding can always reach its own root, so that ID is + * recorded up front rather than waiting for a read to discover it. A file binding therefore never + * grows past it because its session admits no other ID. This lets every scope share one admission + * path. Without the seed a file binding would need a second, hand-rolled verify kept in step by + * hand with this one's staging and rollback. * * `verifyBatch` is passed in rather than a verifier type, so this module stays independent of the * worker entrypoint that owns the RPC interface. @@ -31,9 +32,12 @@ function scopeRootId(scope: DriveBindingScope): string | undefined { export function driveObserverTracker( kv: ObserverKv, scope: DriveBindingScope, - verifyBatch: (verifier: V, fileIds: readonly string[]) => Promise, + verifyBatch: ( + verifier: V, fileIds: readonly string[], listableFolderId?: string, + ) => Promise, ): ObserverTracker { let rootId = scopeRootId(scope); + let listableFolderId = scope.kind === "folder" ? scope.folderId : undefined; if (rootId !== undefined) { let key = `${DRIVE_OBSERVATION_PREFIX}${encodeURIComponent(rootId)}`; if (kv.get(key) === undefined) kv.put(key, "observed"); @@ -42,10 +46,11 @@ export function driveObserverTracker( setPrefix: DRIVE_OBSERVATION_PREFIX, encode: encodeURIComponent, decode: decodeURIComponent, - verifyBatch, + verifyBatch: (verifier, fileIds) => verifyBatch(verifier, fileIds, listableFolderId), baselineDeniedMessage: DRIVE_BASELINE_DENIED_MESSAGE, - deniedMessage: fileId => - `This collaborator cannot access Drive file ${fileId}, whose metadata this workspace has read.`, + // The refusal names no ID: a collaborator who cannot reach a file must not learn that this + // workspace read one, nor which. The reader knows their own access, not this binding's history. + deniedMessage: () => "This collaborator cannot access Drive data this workspace has read.", // checkFileAccess issues ceil(N/100) sequential subrequests. The overseer re-runs addObserver // on every open, per observer, at concurrency 6. 2000 files → 20 subrequests per observer, 120 // if six run together — well inside the 1000-subrequest budget. Uncapped, a whole-account diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index 32d9fb70bf..7263026c09 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -1,18 +1,33 @@ import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; import { CursorPager, type Pager } from "./cursor"; -import { DriveApiRequestError, type DriveApi, type DriveCorpus, type DriveFile, type DriveListFilesOptions } from "./drive-api"; +import { + DriveApiRequestError, FOLDER_MIME_TYPE, + type DriveApi, type DriveCorpus, type DriveFile, type DriveListFilesOptions, +} from "./drive-api"; +import { FolderScope, outsideScope, readFolderRoot, type FolderProof } from "./drive-folder-scope"; import type { ObserverCheck } from "./observers"; import type { DriveEntry, DriveListOptions, DriveOrder, DriveScope, DriveSearchQuery, } from "./drive-types"; -const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; const SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; /** Exact MIME type for native Google Docs files. */ export const GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; /** Exact MIME type for native Google Sheets files. */ export const GOOGLE_SHEET_MIME_TYPE = "application/vnd.google-apps.spreadsheet"; +/** + * Drive items one folder-scoped provider page asks for. + * + * Membership is a post-filter -- Drive cannot restrict a listing to a subtree -- so a bare `list()` + * scans the corpus and a small folder in a large drive costs one round trip per page. Full pages + * keep that count down; the page budget below still caps a call at one of them. Measured worst + * case, 100 candidates each 99 levels deep on distinct chains: 203 subrequests for one `next()`. + */ +const FOLDER_PAGE_SIZE = 100; + +const FOLDER_MOVED = "The connected Drive folder moved to another drive; open a new listing."; + // Agent-supplied query values go in the approval description, so each value and the whole string // are capped. They are not logged and they stay out of the title. const MAX_OBSERVATION_VALUE = 32; @@ -22,22 +37,50 @@ const MAX_OBSERVATION_DESCRIPTION = 240; export type DriveBindingScope = | { kind: "account" } | { kind: "sharedDrive"; driveId: string } + | { kind: "folder"; folderId: string } | { kind: "file"; fileId: string }; -type DriveSessionApi = Pick; +type DriveSessionApi = Pick; + +/** An observation description before scope enforcement supplies the observer exclusions. */ +export type NativeObservation = Omit; + +/** + * Performs one native Docs or Sheets read and authorizes it before the value is disclosed. + * + * The fetch is a thunk rather than a value so a scope check can refuse before the provider is + * contacted at all. + */ +export type NativeRead = ( + fetch: () => Promise, + observe: (value: T) => NativeObservation, +) => Promise; + +/** Reads and authorizes with no live scope check, for a binding whose scope cannot move. */ +export function unguardedNativeRead( + authorize: (description: ObservationDescription) => Promise, +): NativeRead { + return async (fetch: () => Promise, observe: (value: T) => NativeObservation) => { + let value = await fetch(); + await authorize(observe(value)); + return value; + }; +} /** * Everything one Drive session core enforces and reports through. * * `authorize` is part of the construction because it is the one thing that differs between the * cores a session builds: they share its scope and observer tracking, but a capability handed to - * the caller -- a cursor -- authorizes through an approval queue with its own lifetime. + * the caller -- a cursor, a native child -- authorizes through an approval queue with its own + * lifetime. */ export type DriveSessionCoreOptions = { api: DriveSessionApi; scope: DriveBindingScope; prepareObservation(fileIds: string[]): Promise>; - observerIds(): string[]; + /** Fences an owner-only observation: excludes today's observers and closes admission. */ + prepareWithheld(): ObserverCheck; authorize(description: ObservationDescription): Promise; }; @@ -55,8 +98,13 @@ export function driveModifiedTime(file: DriveFile): Date { return modifiedTime; } -/** Maps one validated provider file to the permanent agent-facing declaration. */ -export function driveFileToEntry(file: DriveFile): DriveEntry { +/** + * Maps one validated provider file to the permanent agent-facing declaration. + * + * `rootId` names a scope root whose own `parentId` is withheld: the folder containing the bound + * folder is outside the binding, and naming it would disclose one level above it. + */ +export function driveFileToEntry(file: DriveFile, rootId?: string): DriveEntry { let mimeType = requiredString(file.mimeType, "mimeType"); let isFolder = mimeType === FOLDER_MIME_TYPE; let isShortcut = mimeType === SHORTCUT_MIME_TYPE; @@ -70,6 +118,7 @@ export function driveFileToEntry(file: DriveFile): DriveEntry { } } let owner = file.driveId ? undefined : file.owners?.[0]; + let parentId = file.id === rootId ? undefined : file.parents?.[0]; let shortcut: DriveEntry["shortcut"]; if (isShortcut && file.shortcutDetails) { shortcut = { @@ -91,7 +140,7 @@ export function driveFileToEntry(file: DriveFile): DriveEntry { ...(owner.emailAddress ? { emailAddress: owner.emailAddress } : {}), }, } : {}), - ...(file.parents?.[0] ? { parentId: file.parents[0] } : {}), + ...(parentId ? { parentId } : {}), ...(file.driveId ? { driveId: file.driveId } : {}), ...(file.webViewLink ? { webViewLink: file.webViewLink } : {}), ...(shortcut ? { shortcut } : {}), @@ -163,6 +212,7 @@ function scopePhrase(scope: DriveBindingScope): string { switch (scope.kind) { case "account": return "the connected Drive account"; case "sharedDrive": return `shared drive ${scope.driveId}`; + case "folder": return `folder ${scope.folderId} and its descendants`; case "file": return `file ${scope.fileId}`; } } @@ -209,15 +259,17 @@ function emptySearchDescription(scope: DriveBindingScope, query: DriveListFilesO export class DriveSessionCore { #api: DriveSessionApi; #scope: DriveBindingScope; + #folder: FolderScope; #prepareObservation: (fileIds: string[]) => Promise>; - #observerIds: () => string[]; + #prepareWithheld: () => ObserverCheck; #authorize: (description: ObservationDescription) => Promise; constructor(options: DriveSessionCoreOptions) { this.#api = options.api; this.#scope = options.scope; + this.#folder = new FolderScope(options.api); this.#prepareObservation = options.prepareObservation; - this.#observerIds = options.observerIds; + this.#prepareWithheld = options.prepareWithheld; this.#authorize = options.authorize; } @@ -228,14 +280,20 @@ export class DriveSessionCore { let drive = await this.#api.getDrive(this.#scope.driveId); // Capability identity is the binding, never the provider's echo. A mismatch means the name // describes some other drive, so refuse rather than label the binding with it. - if (drive.id !== this.#scope.driveId) this.#outsideScope(); + if (drive.id !== this.#scope.driveId) outsideScope(); await this.#authorizeIds([this.#scope.driveId], "Read Google Drive scope", "Read the current name of the connected shared drive."); return { kind: "sharedDrive", driveId: this.#scope.driveId, name: drive.name }; } + case "folder": { + let root = await this.#getFolderRoot(); + await this.#authorizeIds([root.id], "Read Google Drive scope", + "Read the current name of the connected Drive folder."); + return { kind: "folder", folderId: root.id, name: root.name }; + } case "file": { let file = await this.#api.getFile(this.#scope.fileId); - if (file.id !== this.#scope.fileId) this.#outsideScope(); + if (file.id !== this.#scope.fileId) outsideScope(); await this.#authorizeIds([this.#scope.fileId], "Read Google Drive scope", "Read the current name of the connected Drive file."); return { kind: "file", fileId: this.#scope.fileId, name: file.name }; @@ -268,9 +326,9 @@ export class DriveSessionCore { } async getEntry(fileId: string): Promise { - if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); + if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) outsideScope(); let file = await this.#getFileInScope(fileId); - let entry = driveFileToEntry(file); + let entry = driveFileToEntry(file, this.#rootId()); await this.#authorizeIds([file.id], "Read Google Drive metadata", `Read metadata for Drive file ${file.id}.`); return entry; @@ -282,7 +340,7 @@ export class DriveSessionCore { expectedMimeType: string, description: string, ): Promise { - if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); + if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) outsideScope(); let file = await this.#getFileInScope(fileId); await this.#authorizeIds( [file.id], @@ -295,41 +353,108 @@ export class DriveSessionCore { return file.id; } - #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Pager { - let hasDisclosedEntries = false; + /** + * Wraps one native Docs or Sheets read in the enforcement its binding needs. + * + * A folder binding's authority is derived from a hierarchy the provider can change under it, so + * every read re-proves the file's ancestry and exact native type before the provider is contacted, + * re-checks the proved chain before the result is authorized, and discards the fetched value if + * either fails. Drive offers no ancestry-plus-content transaction, so a move landing after that + * final check still returns; the next read is what denies. Immutable scopes need none of this: + * the ID they name cannot leave them. + */ + nativeRead(fileId: string, expectedMimeType: string): NativeRead { + if (this.#scope.kind !== "folder") { + return unguardedNativeRead(description => this.#authorize(description)); + } + return async (fetch: () => Promise, observe: (value: T) => NativeObservation) => { + let proof = await this.#proveNativeFile(fileId, expectedMimeType); + let value = await fetch(); + await this.#folder.recheck([proof]); + let check = await this.#prepareObservation([fileId]); + await this.#authorize({ ...observe(value), excludeObservers: check.excludeObservers }); + check.commit(); + return value; + }; + } + + async #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Promise> { + if (this.#scope.kind === "folder") return this.#folderCursor(query, denyEmptySearch); + let corpus: DriveCorpus = this.#scope.kind === "sharedDrive" + ? { kind: "drive", driveId: this.#scope.driveId } + : { kind: "user" }; return new CursorPager({ provider: "Google Drive", fetchPage: async pageToken => { - let page = await this.#api.listFiles({ ...query, corpus: this.#corpus(), pageToken }); + let page = await this.#api.listFiles({ ...query, corpus, pageToken }); return { items: page.files, ...(page.nextPageToken ? { nextPageToken: page.nextPageToken } : {}) }; }, - buildEntries: async files => files.filter(file => this.#inScope(file)).map(driveFileToEntry), - authorize: async entries => { - if (denyEmptySearch && entries.length === 0 && !hasDisclosedEntries) { - await this.#authorize({ - title: "Search Google Drive metadata", - description: emptySearchDescription(this.#scope, query), - excludeObservers: this.#observerIds(), - }); - throw new Error("An empty Drive search cannot be shared safely."); - } - await this.#authorizeIds( - entries.map(entry => entry.id), - "Read Google Drive metadata", - listingDescription(this.#scope, query, entries.length), - ); - if (entries.length > 0) hasDisclosedEntries = true; + buildEntries: async files => + files.filter(file => this.#inScope(file)).map(file => driveFileToEntry(file)), + authorize: this.#pageAuthorizer(query, denyEmptySearch), + }); + } + + /** + * A cursor over one folder subtree, on the corpus the root lives in. + * + * The corpus is pinned when the cursor opens, because a Drive page token is only valid against + * the corpus that produced it: a root that moves between My Drive and a shared drive aborts the + * cursor rather than replaying its token against the other corpus. Every provider page is proved + * before anything derived from it -- entries, descriptions, observer exclusions -- exists. + * + * Bare listings scan the corpus and post-filter, so cost is linear in its size. A BFS from the + * root over `'' in parents` would fetch only in-scope rows, but it trades `DriveOrder`'s + * global ordering for per-level ordering and `fullTextContains` cannot use it, so it is a + * separate change rather than a tweak here. + */ + async #folderCursor( + query: DriveListFilesOptions, + denyEmptySearch: boolean, + ): Promise> { + let root = await this.#getFolderRoot(); + let driveId = root.driveId; + let corpus: DriveCorpus = driveId ? { kind: "drive", driveId } : { kind: "user" }; + // A page token is only valid against the corpus that produced it, and a root that changed drive + // is still a valid root, so the pin is what catches the move rather than the root check. + let requireCurrentScope = async () => { + root = await this.#getFolderRoot(); + if (root.driveId !== driveId) throw new Error(FOLDER_MOVED); + if (query.directParentId) await this.#revalidateParent(query.directParentId, root); + }; + return new CursorPager({ + provider: "Google Drive", + fetchPage: async pageToken => { + await requireCurrentScope(); + let page = await this.#api.listFiles({ + ...query, corpus, pageSize: FOLDER_PAGE_SIZE, pageToken, + }); + return { items: page.files, ...(page.nextPageToken ? { nextPageToken: page.nextPageToken } : {}) }; + }, + buildEntries: async files => { + let proofs = await this.#folder.prove(files, root); + await this.#folder.recheck(proofs); + // The root bounds the listing rather than appearing in it. `prove` admits it so `getEntry` + // can read the bound folder's own metadata, but `'' in parents` never returns it, so + // leaving it here makes a bare listing disclose one entry a narrowed one cannot. + return proofs.filter(proof => proof.file.id !== root.id) + .map(proof => driveFileToEntry(proof.file, root.id)); }, + authorize: this.#pageAuthorizer(query, denyEmptySearch, requireCurrentScope), + // A maximum-depth page costs one ancestry batch per level, so one provider page per call is + // what keeps a single invocation inside the Worker subrequest ceiling. The cursor contract + // already requires draining to `null` rather than stopping at an empty page. + maxProviderPagesPerCall: 1, }); } #exactFileCursor(): Pager { - let fileId = this.#scope.kind === "file" ? this.#scope.fileId : this.#outsideScope(); + let fileId = this.#scope.kind === "file" ? this.#scope.fileId : outsideScope(); return new CursorPager({ provider: "Google Drive", fetchPage: async () => ({ items: [await this.#api.getFile(fileId)] }), buildEntries: async files => { - if (files.length !== 1 || files[0].id !== fileId) this.#outsideScope(); + if (files.length !== 1 || files[0].id !== fileId) outsideScope(); return files[0].trashed === false ? [driveFileToEntry(files[0])] : []; }, authorize: async () => { @@ -339,10 +464,51 @@ export class DriveSessionCore { }); } - #corpus(): DriveCorpus { - return this.#scope.kind === "sharedDrive" - ? { kind: "drive", driveId: this.#scope.driveId } - : { kind: "user" }; + #pageAuthorizer( + query: DriveListFilesOptions, + denyEmptySearch: boolean, + revalidate?: () => Promise, + ): (entries: DriveEntry[], exhausted: boolean) => Promise { + let hasDisclosedEntries = false; + return async (entries, exhausted) => { + if (entries.length === 0) { + await revalidate?.(); + if (!exhausted) { + await this.#authorizeWithheld( + "Scan Google Drive metadata", listingDescription(this.#scope, query, 0)); + return; + } + if (denyEmptySearch && !hasDisclosedEntries) await this.#refuseEmptySearch(query); + } + await this.#authorizeIds( + entries.map(entry => entry.id), + "Read Google Drive metadata", + listingDescription(this.#scope, query, entries.length), + ); + if (entries.length > 0) hasDisclosedEntries = true; + }; + } + + /** Audits an owner-only empty search, closes observer admission, and refuses to share it. */ + async #refuseEmptySearch(query: DriveListFilesOptions): Promise { + await this.#authorizeWithheld( + "Search Google Drive metadata", emptySearchDescription(this.#scope, query)); + throw new Error("An empty Drive search cannot be shared safely."); + } + + async #authorizeWithheld(title: string, description: string): Promise { + let check = this.#prepareWithheld(); + try { + await this.#authorize({ title, description, excludeObservers: check.excludeObservers }); + } catch (error) { + check.discard?.(); + throw error; + } + check.commit(); + } + + #rootId(): string | undefined { + return this.#scope.kind === "folder" ? this.#scope.folderId : undefined; } #inScope(file: DriveFile): boolean { @@ -350,26 +516,80 @@ export class DriveSessionCore { case "account": return true; case "sharedDrive": return file.driveId === this.#scope.driveId || file.id === this.#scope.driveId; + // Membership is a live ancestry proof, not a field comparison, so a folder binding never + // reaches here. + case "folder": return false; case "file": return file.id === this.#scope.fileId; } } + /** The bound folder, re-read and re-validated. Every folder operation starts from this. */ + async #getFolderRoot(): Promise { + if (this.#scope.kind !== "folder") outsideScope(); + return readFolderRoot(this.#scope.folderId, id => this.#fetchFile(id)); + } + + /** One candidate's live membership proof. A direct read admits exactly one result. */ + async #proveFile(file: DriveFile, root: DriveFile): Promise { + let [proof] = await this.#folder.prove([file], root); + if (proof === undefined) { + await this.#authorizeWithheld("Check Google Drive folder", + "Check whether a requested file belongs to this Drive folder binding."); + outsideScope(); + } + return proof; + } + + async #revalidateParent(parentId: string, root: DriveFile): Promise { + let parent = await this.#fetchFile(parentId); + if (parent.id !== parentId) outsideScope(); + let [proof] = await this.#folder.prove([parent], root); + if (!proof) outsideScope(); + await this.#folder.recheck([proof]); + this.#assertListableFolder(parent); + } + + async #proveNativeFile(fileId: string, expectedMimeType: string): Promise { + let root = await this.#getFolderRoot(); + let file = await this.#fetchFile(fileId); + if (file.id !== fileId || file.mimeType !== expectedMimeType) outsideScope(); + return this.#proveFile(file, root); + } + async #assertParent(parentId: string): Promise { - if (this.#scope.kind === "file") this.#outsideScope(); + if (this.#scope.kind === "file") outsideScope(); let parent = await this.#getFileInScope(parentId); await this.#authorizeIds([parent.id], "Check Google Drive folder", "Check that the requested parent folder belongs to this Drive binding."); - if (parent.mimeType !== FOLDER_MIME_TYPE) throw new Error("directParentId must identify a folder"); + this.#assertListableFolder(parent); + } + + #assertListableFolder(file: DriveFile): void { + if (file.mimeType !== FOLDER_MIME_TYPE || file.capabilities?.canListChildren !== true) { + throw new Error("directParentId must identify a folder whose children can be listed"); + } } async #getFileInScope(fileId: string): Promise { - let file: DriveFile; + let file = await this.#fetchFile(fileId); + if (file.id !== fileId) outsideScope(); + if (this.#scope.kind === "folder") { + let proof = await this.#proveFile(file, await this.#getFolderRoot()); + await this.#folder.recheck([proof]); + return file; + } + if (!this.#inScope(file)) outsideScope(); + return file; + } + + /** `files.get`, translating a denial into this binding's refusal where that is what it means. */ + async #fetchFile(fileId: string): Promise { try { - file = await this.#api.getFile(fileId); + return await this.#api.getFile(fileId); } catch (err) { - if (err instanceof DriveApiRequestError && !err.isQuotaExceeded && + if (err instanceof DriveApiRequestError && !err.isAccountWide && (err.status === 403 || err.status === 404)) { - if (this.#scope.kind === "sharedDrive") this.#outsideScope(); + if (this.#scope.kind === "sharedDrive" || this.#scope.kind === "folder") outsideScope(); if (this.#scope.kind === "account") { // Tracked like a successful read rather than merely hidden from today's observers. An // ObservationDescription's exclusion binds only the observers named in it — there is no @@ -383,8 +603,6 @@ export class DriveSessionCore { } throw err; } - if (file.id !== fileId || !this.#inScope(file)) this.#outsideScope(); - return file; } async #authorizeIds(fileIds: string[], title: string, description: string): Promise { @@ -392,9 +610,4 @@ export class DriveSessionCore { await this.#authorize({ title, description, excludeObservers: check.excludeObservers }); check.commit(); } - - #outsideScope(): never { - throw new Error("The requested file is outside this Drive binding."); - } } - diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index a5263363aa..70a4e15a34 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -5,10 +5,11 @@ import type { GoogleSpreadsheetReadSession } from "./sheets-types"; * A pagination cursor. * * This is an RPC object. Call `next()` repeatedly on the same cursor to fetch subsequent batches, - * and dispose the cursor when finished. + * and dispose the cursor when finished. Drain it until `next()` returns `null`: an empty array + * means this call ran out of budget while filtering, not that there is nothing left. */ export interface Cursor { - /** Return the next batch of results, or `null` once the cursor is exhausted. */ + /** The next batch, `[]` when this call found none but more remain, or `null` once exhausted. */ next(): Promise; } @@ -19,12 +20,22 @@ export interface Cursor { * drives. `list()` and `search()` cover My Drive plus shared-drive items the account has accessed; * `getEntry()` resolves any ID the account can read, so a file may be readable by ID without ever * appearing in a listing. Shared-drive scope means a Google Workspace shared drive, not an - * ordinary or shared folder; its files belong to the organization rather than an individual. Names - * are current display metadata; stable IDs are capability identity. + * ordinary or shared folder; its files belong to the organization rather than an individual. + * + * Folder scope is one folder plus every file and folder currently beneath it, at any depth. The + * folder may live in My Drive — including one someone else shared from theirs — or inside a shared + * drive; a shared drive's own root is not a folder binding, it is the shared-drive scope. Every + * operation re-derives membership from live Drive metadata, so an item that moves out stops being + * readable, one that moves in becomes readable, and a shortcut is listed but never followed to its + * target. `parentId` is withheld for the bound folder itself, since its container is outside the + * binding. + * + * Names are current display metadata; stable IDs are capability identity. */ export type DriveScope = | { kind: "account" } | { kind: "sharedDrive"; driveId: string; name: string } + | { kind: "folder"; folderId: string; name: string } | { kind: "file"; fileId: string; name: string }; /** Owner metadata for a Drive entry. Absent for items in shared drives. */ @@ -132,9 +143,11 @@ export interface GoogleDriveReadSession { getScope(): Promise; /** - * List entries in the binding scope, most recently modified first by default. `directParentId` - * limits the result to direct children, never recursive descendants, and throws when the folder - * is outside the immutable binding scope. + * List entries in the binding scope, most recently modified first by default. + * + * A folder binding lists its whole subtree at every depth. `directParentId` narrows any binding + * to one folder's direct children, never recursive descendants, and throws when that folder is + * outside the binding scope. */ list(options?: DriveListOptions): Promise>; @@ -143,6 +156,9 @@ export interface GoogleDriveReadSession { * fields are AND-ed, while values within `mimeTypes` are OR-ed. `order` cannot be combined with * `fullTextContains`; omitting it for full-text search preserves Drive's relevance order. * + * A folder binding searches its whole subtree; matches outside it are discarded before anything + * is disclosed, so a page can come back empty with results still ahead — drain to `null`. + * * Throws on a file-scoped binding; a single file cannot be searched. Use `getEntry()` to read it. * Also throws when no entries match because an owner-relative negative result cannot be shared safely. */ @@ -152,11 +168,13 @@ export interface GoogleDriveReadSession { * Return metadata for one file ID. * * A file binding throws without contacting Drive when the ID is not the bound file. A shared-drive - * binding throws when the file is not in that drive. An account binding returns any file the - * connected account can read, including files in shared drives it is a member of. + * binding throws when the file is not in that drive. A folder binding throws unless the file is + * currently inside its subtree. An account binding returns any file the connected account can + * read, including files in shared drives it is a member of. * * Unlike `list()` and `search()`, this can return a trashed file: those methods always exclude - * trash, while a direct get does not, and {@link DriveEntry} has no `trashed` field. + * trash, while a direct get does not, and {@link DriveEntry} has no `trashed` field. A folder + * binding is the exception — trash is outside its subtree, so it throws instead. */ getEntry(fileId: string): Promise; @@ -165,6 +183,9 @@ export interface GoogleDriveReadSession { * `application/vnd.google-apps.document`. Other MIME types, including folders and shortcuts, are * rejected. The returned RPC capability supports promise pipelining and must be disposed when * finished. + * + * A folder binding re-proves the file's place in the subtree on every call the returned session + * makes, so a document moved out stops answering even through an already-open session. */ openGoogleDoc(fileId: string): Promise; @@ -173,9 +194,12 @@ export interface GoogleDriveReadSession { * `application/vnd.google-apps.spreadsheet`. Other MIME types, including folders and shortcuts, * are rejected. The returned RPC capability supports promise pipelining and must be disposed when * finished. + * + * A folder binding re-proves the file's place in the subtree on every call the returned session + * makes, so a spreadsheet moved out stops answering even through an already-open session. */ openGoogleSheet(fileId: string): Promise; } -/** The access provided by an account or shared-drive binding. */ +/** The access provided by an account, shared-drive, or folder binding. */ export type GoogleDriveSession = GoogleDriveReadSession; diff --git a/packages/gatekeeper-google/src/google-configurators.ts b/packages/gatekeeper-google/src/google-configurators.ts index 345598b3e4..9d4d4f61b3 100644 --- a/packages/gatekeeper-google/src/google-configurators.ts +++ b/packages/gatekeeper-google/src/google-configurators.ts @@ -4,7 +4,7 @@ import { BigQueryApi } from "./bigquery-api"; import { GoogleCalendarApi } from "./calendar-api"; import { GoogleAccessToken } from "./google-api"; import { AccessTokenProvider, AccessTokenRequest } from "./auth-retry"; -import { DriveApi, DriveApiDisabledError } from "./drive-api"; +import { DriveApi, DriveApiDisabledError, FOLDER_MIME_TYPE } from "./drive-api"; import type { BigQueryConfiguratorRpc } from "./configurator/bigquery-configurator-types"; import type { CalendarConfiguratorRpc } from "./configurator/calendar-configurator-types"; import type { GmailConfiguratorRpc } from "./configurator/gmail-configurator-types"; @@ -13,6 +13,7 @@ import type { GoogleSheetsConfiguratorRpc } from "./configurator/google-sheets-c import type { ConfiguratorOption } from "./configurator/configurator-option"; import type { DriveAccountConfiguratorRpc } from "./configurator/drive-account-configurator-types"; import type { DriveFileConfiguratorRpc } from "./configurator/drive-file-configurator-types"; +import type { DriveFolderConfiguratorRpc } from "./configurator/drive-folder-configurator-types"; import type { SharedDriveConfiguratorRpc } from "./configurator/shared-drive-configurator-types"; /** @@ -278,7 +279,7 @@ export class DriveFileConfiguratorUI extends RpcTarget implements DriveFileConfi let { files } = await withDriveApiEnabled( "Drive file search requires the Google Drive API to be enabled for this OAuth project.", () => drive.listFiles({ - namePrefix: query, excludeMimeTypes: ["application/vnd.google-apps.folder"], + namePrefix: query, excludeMimeTypes: [FOLDER_MIME_TYPE], }), ); return files.map(file => ({ @@ -291,3 +292,29 @@ export class DriveFileConfiguratorUI extends RpcTarget implements DriveFileConfi })); } } + +@validateRpc() +export class DriveFolderConfiguratorUI extends RpcTarget implements DriveFolderConfiguratorRpc { + constructor(getToken: () => Promise) { + super(); + googleTokenGetters.set(this, getToken); + } + + async listDriveFolders(query: string): Promise { + let drive = new DriveApi(googleTokenProvider(this)); + let { files } = await withDriveApiEnabled( + "Drive folder search requires the Google Drive API to be enabled for this OAuth project.", + () => drive.listFiles({ mimeType: FOLDER_MIME_TYPE, namePrefix: query }), + ); + // A shared drive's root carries the drive's own ID. It is the Shared Drive resource, and + // offering it here too would make a folder binding a second, weaker name for a whole drive. + return files.filter(file => + file.id !== file.driveId && file.capabilities?.canListChildren === true).map(file => ({ + value: file.id, + title: file.name, + subtitle: file.driveId + ? "In a shared drive" + : file.owners?.[0]?.displayName ?? file.owners?.[0]?.emailAddress ?? "My Drive", + })); + } +} diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 7167e3ab13..bb884ed35d 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -19,10 +19,11 @@ import { } from "./markdown-converter"; import { DriveApi, DriveApiRequestError } from "./drive-api"; import { driveObserverTracker } from "./drive-observers"; +import { readFolderRoot } from "./drive-folder-scope"; import { DriveSessionCore, driveModifiedTime, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, - type DriveBindingScope, - type DriveSessionCoreOptions, + unguardedNativeRead, + type DriveBindingScope, type DriveSessionCoreOptions, type NativeRead, } from "./drive-session"; import type { DriveEntry, DriveListOptions, DriveSearchQuery, GoogleDriveSession } from "./drive-types"; import { BigQueryApi, DEFAULT_MAX_BYTES_BILLED } from "./bigquery-api"; @@ -54,6 +55,7 @@ import { GoogleSheetsConfiguratorUI, DriveAccountConfiguratorUI, DriveFileConfiguratorUI, + DriveFolderConfiguratorUI, SharedDriveConfiguratorUI, } from "./google-configurators"; import BIGQUERY_CONFIGURATOR_HTML from "./generated/bigquery-configurator-ui.txt"; @@ -63,13 +65,15 @@ import GOOGLE_DOC_CONFIGURATOR_HTML from "./generated/google-doc-configurator-ui import GOOGLE_SHEETS_CONFIGURATOR_HTML from "./generated/google-sheets-configurator-ui.txt"; import DRIVE_ACCOUNT_CONFIGURATOR_HTML from "./generated/drive-account-configurator-ui.txt"; import DRIVE_FILE_CONFIGURATOR_HTML from "./generated/drive-file-configurator-ui.txt"; +import DRIVE_FOLDER_CONFIGURATOR_HTML from "./generated/drive-folder-configurator-ui.txt"; import SHARED_DRIVE_CONFIGURATOR_HTML from "./generated/shared-drive-configurator-ui.txt"; import GOOGLE_LOGO_SVG from "./google-logo.svg"; import { obsContext } from "./observability.js"; import { AccessTokenCache, AccessTokenRequest, ACCESS_TOKEN_EXPIRY_SAFETY_MS } from "./auth-retry"; import { BIGQUERY_HOST, BIGQUERY_RESOURCE, GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, - GOOGLE_DOC_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_RESOURCE, + GOOGLE_DOC_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, + GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE, GOOGLE_SHEETS_RESOURCE, RESOURCE_BY_KIND, SUPPORTED_RESOURCES, grantedResourceUrlPatterns, hasDriveResourceGrant, parseResourceUrl, recordedResourceUrlPatterns, type RecordedResourceGrant, @@ -741,14 +745,14 @@ export class GatekeeperUserImpl extends WorkerEntrypoint; hasCalendarFreeBusyAccess(calendarId: string): Promise; hasDatasetAccess(projectId: string, datasetId: string): Promise; - verifyDriveFiles(fileIds: string[]): Promise; + verifyDriveFiles(fileIds: string[], listableFolderId?: string): Promise; } @validateRpc() @@ -980,7 +991,9 @@ export class GoogleVerifier extends WorkerEntrypoint } } - async verifyDriveFiles(fileIds: string[]): Promise { + async verifyDriveFiles( + fileIds: string[], listableFolderId?: string, + ): Promise { let account = this.ctx.exports.UserAccount.get( this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId)); let granted = await account.getGrantedResourceUrlPatterns(); @@ -988,7 +1001,10 @@ export class GoogleVerifier extends WorkerEntrypoint if (!baselineAllowed) return { baselineAllowed, allowed: fileIds.map(() => false) }; let api = new DriveApi(opts => this.#getToken(opts)); - return { baselineAllowed, allowed: await api.checkFileAccess(fileIds) }; + return { + baselineAllowed, + allowed: await api.checkFileAccess(fileIds, listableFolderId), + }; } } @@ -1914,7 +1930,7 @@ class GoogleDocSessionImpl extends RpcTarget implements GoogleDocSession { // or a malformed body are transient or fixable, and dating the document from one would // report a changed document as unchanged for as long as Drive stays unhealthy. let refusedGrant = error instanceof DriveApiRequestError && error.status === 403 && - !error.isQuotaExceeded; + !error.isAccountWide; if (!refusedGrant) throw error; logger.warn("no Drive grant to date a Google Doc that has no revision", { event: "google.doc.metadata.drive.ungranted", error, @@ -2162,16 +2178,20 @@ class GoogleSpreadsheetSessionImpl extends RpcTarget implements GoogleSpreadshee #api: GoogleSheetsApi; #spreadsheetId: string; #approvalQueue: RpcStub; + #read: NativeRead; constructor( api: GoogleSheetsApi, spreadsheetId: string, approvalQueue: RpcStub, + read?: NativeRead, ) { super(); this.#api = api; this.#spreadsheetId = spreadsheetId; this.#approvalQueue = approvalQueue; + this.#read = read ?? unguardedNativeRead( + description => approvalQueue.authorizeObservation(description)); } [Symbol.dispose](): void { @@ -2179,14 +2199,14 @@ class GoogleSpreadsheetSessionImpl extends RpcTarget implements GoogleSpreadshee } async getSpreadsheet(): Promise { - let spreadsheet = await this.#api.getSpreadsheet(this.#spreadsheetId); - await this.#approvalQueue.authorizeObservation({ - title: "Read Google spreadsheet metadata", - description: - `Read metadata for "${spreadsheet.title}", including its ${spreadsheet.sheets.length} ` + - "worksheet(s).", - }); - return spreadsheet; + return this.#read( + () => this.#api.getSpreadsheet(this.#spreadsheetId), + spreadsheet => ({ + title: "Read Google spreadsheet metadata", + description: + `Read metadata for "${spreadsheet.title}", including its ${spreadsheet.sheets.length} ` + + "worksheet(s).", + })); } async readRange( @@ -2207,22 +2227,22 @@ class GoogleSpreadsheetSessionImpl extends RpcTarget implements GoogleSpreadshee ranges: string[], options?: { valueMode?: SpreadsheetValueMode }, ): Promise { - let result = await this.#api.readRanges( - this.#spreadsheetId, ranges, options?.valueMode, - ); - let cellCount = result.reduce( - (total, range) => total + range.values.reduce((sum, row) => sum + row.length, 0), - 0, - ); - await this.#approvalQueue.authorizeObservation({ - title: result.length === 1 - ? `Read Google Sheets range ${result[0].range}` - : `Read ${result.length} Google Sheets ranges`, - description: - `Read ${cellCount.toLocaleString()} cell(s) from ${result.length} bounded range(s) in ` + - "the connected spreadsheet.", - }); - return result; + return this.#read( + () => this.#api.readRanges(this.#spreadsheetId, ranges, options?.valueMode), + result => { + let cellCount = result.reduce( + (total, range) => total + range.values.reduce((sum, row) => sum + row.length, 0), + 0, + ); + return { + title: result.length === 1 + ? `Read Google Sheets range ${result[0].range}` + : `Read ${result.length} Google Sheets ranges`, + description: + `Read ${cellCount.toLocaleString()} cell(s) from ${result.length} bounded range(s) ` + + "in the connected spreadsheet.", + }; + }); } } @@ -2789,6 +2809,19 @@ export class GoogleDriveGatekeeperImpl tsType: "GoogleDriveSession", }; } + if (scope.kind === "folder") { + // Validated here too, so a hand-built resource URL fails at connect rather than minting a + // presentable binding whose every call then refuses. + let folder = await readFolderRoot(scope.folderId, id => api.getFile(id)); + return { + // The natural browser URL, not the internal `_resource` selector the grant is keyed on. + url: `https://drive.google.com/drive/folders/${encodeURIComponent(scope.folderId)}`, + title: folder.name, + snippet: `Find files and folders and read native Google Docs and Sheets in Drive folder "${folder.name}" and everything beneath it`, + suggestedBindingName: "GOOGLE_DRIVE_FOLDER", + tsType: "GoogleDriveSession", + }; + } let file = await api.getFile(scope.fileId); return { url: `https://drive.google.com/file/d/${encodeURIComponent(scope.fileId)}/view`, @@ -2817,7 +2850,7 @@ export class GoogleDriveGatekeeperImpl this.ctx.props.scope, approvalQueue.dup(), fileIds => observerTracker.prepareObservation(fileIds), - () => [...observerTracker.observers()].map(([id]) => id), + () => observerTracker.prepareWithheld(), ); } @@ -2831,7 +2864,8 @@ export class GoogleDriveGatekeeperImpl #observerTracker(): ObserverTracker> { return driveObserverTracker>( this.ctx.storage.kv, this.ctx.props.scope, - (verifier, fileIds) => verifier.verifyDriveFiles([...fileIds])); + (verifier, fileIds, listableFolderId) => + verifier.verifyDriveFiles([...fileIds], listableFolderId)); } async addObserver(id: string, user: Fetcher): Promise { @@ -2851,18 +2885,22 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession #approvalQueue: RpcStub; /** The most recent snapshot request. Chaining onto it serializes concurrent reads. */ #snapshot?: Promise; + #read: NativeRead; constructor( docsApi: GoogleDocsApi, driveApi: DriveApi, documentId: string, approvalQueue: RpcStub, + read?: NativeRead, ) { super(); this.#docsApi = docsApi; this.#driveApi = driveApi; this.#documentId = documentId; this.#approvalQueue = approvalQueue; + this.#read = read ?? unguardedNativeRead( + description => approvalQueue.authorizeObservation(description)); } [Symbol.dispose](): void { @@ -2870,13 +2908,13 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession } async getMetadata(): Promise { - let file = await this.#driveApi.getFile(this.#documentId); - let lastModified = driveModifiedTime(file); - await this.#approvalQueue.authorizeObservation({ + return this.#read(async () => { + let file = await this.#driveApi.getFile(this.#documentId); + return { title: file.name, lastModified: driveModifiedTime(file) }; + }, () => ({ title: "Read Google Doc metadata", description: "Read the current title and modification time of the Drive document.", - }); - return { title: file.name, lastModified }; + })); } // Each call chains onto the previous request, so concurrent reads share one fetch instead of @@ -2899,33 +2937,33 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession } async listTabs(): Promise { - let snapshot = await this.#getSnapshot(); - await this.#approvalQueue.authorizeObservation({ - title: "List Google Doc tabs", - description: "Read the document's tab names and hierarchy.", - }); - return snapshot.tabs.map(googleDocTabMetadata); + return this.#read( + async () => (await this.#getSnapshot()).tabs.map(googleDocTabMetadata), + () => ({ + title: "List Google Doc tabs", + description: "Read the document's tab names and hierarchy.", + })); } async getContent(tabId?: string): Promise { - let snapshot = await this.#getSnapshot(); - let tab: GoogleDocTabSnapshot; - try { - tab = resolveGoogleDocTab(snapshot, tabId, "getContent"); - } catch (error) { - // The selector error says whether a tab exists, so the attempt discloses something too. - await this.#approvalQueue.authorizeObservation({ - title: "Read Google Doc content", - description: "Read the content of one tab of the document.", - }); - throw error; - } - - await this.#approvalQueue.authorizeObservation({ + let selection = await this.#read< + { tab: GoogleDocTabSnapshot } | { error: unknown } + >(async () => { + let snapshot = await this.#getSnapshot(); + try { + return { tab: resolveGoogleDocTab(snapshot, tabId, "getContent") }; + } catch (error) { + return { error }; + } + }, result => "error" in result ? { title: "Read Google Doc content", - description: `Read the current content of tab ${googleDocTabLabel(tab)} as Markdown.`, + description: "Read the content of one tab of the document.", + } : { + title: "Read Google Doc content", + description: `Read the current content of tab ${googleDocTabLabel(result.tab)} as Markdown.`, }); - return tab.markdown; + if ("error" in selection) throw selection.error; + return selection.tab.markdown; } } @@ -2946,14 +2984,14 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess scope: DriveBindingScope, approvalQueue: RpcStub, prepareObservation: (fileIds: string[]) => Promise>, - observerIds: () => string[], + prepareWithheld: () => ObserverCheck, ) { super(); this.#driveApi = driveApi; this.#docsApi = docsApi; this.#sheetsApi = sheetsApi; this.#approvalQueue = approvalQueue; - this.#coreOptions = { api: driveApi, scope, prepareObservation, observerIds }; + this.#coreOptions = { api: driveApi, scope, prepareObservation, prepareWithheld }; this.#core = this.#coreFor(this.#approvalQueue); } @@ -3009,21 +3047,39 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess } async openGoogleDoc(fileId: string): Promise { - let documentId = await this.#core.openNativeFile( - fileId, GOOGLE_DOC_MIME_TYPE, "Google Doc", - ); - return new GoogleDocReadSessionImpl( - this.#docsApi, this.#driveApi, documentId, this.#approvalQueue.dup(), - ); + return this.#openNative(fileId, GOOGLE_DOC_MIME_TYPE, "Google Doc", + (documentId, queue, read) => + new GoogleDocReadSessionImpl(this.#docsApi, this.#driveApi, documentId, queue, read)); } async openGoogleSheet(fileId: string): Promise { - let spreadsheetId = await this.#core.openNativeFile( - fileId, GOOGLE_SHEET_MIME_TYPE, "Google Sheet", - ); - return new GoogleSpreadsheetSessionImpl( - this.#sheetsApi, spreadsheetId, this.#approvalQueue.dup(), - ); + return this.#openNative(fileId, GOOGLE_SHEET_MIME_TYPE, "Google Sheet", + (spreadsheetId, queue, read) => + new GoogleSpreadsheetSessionImpl(this.#sheetsApi, spreadsheetId, queue, read)); + } + + /** + * Opens one native child on an approval queue and a core of its own. + * + * The child outlives this session, so it needs its own queue stub — and the guard that revalidates + * its every read has to authorize through that same stub, which is why the core is built here + * rather than reusing the session's. Ownership passes to the child only once it exists. + */ + async #openNative( + fileId: string, + mimeType: string, + description: string, + build: (id: string, queue: RpcStub, read: NativeRead) => T, + ): Promise { + let queue = this.#approvalQueue.dup(); + try { + let core = this.#coreFor(queue); + let id = await core.openNativeFile(fileId, mimeType, description); + return build(id, queue, core.nativeRead(id, mimeType)); + } catch (error) { + queue[Symbol.dispose](); + throw error; + } } } diff --git a/packages/gatekeeper-google/src/observers.ts b/packages/gatekeeper-google/src/observers.ts index 4012b9b736..32abdea755 100644 --- a/packages/gatekeeper-google/src/observers.ts +++ b/packages/gatekeeper-google/src/observers.ts @@ -20,6 +20,15 @@ const OBSERVER_PREFIX = "observer:"; const OBSERVER_ATTEMPT_PREFIX = "observer-attempt:"; const OBSERVER_NONCE_PREFIX = "observer-nonce:"; +/** Latched once an owner-only observation has been made: admission is closed for good. */ +const OBSERVER_WITHHELD_KEY = "observer-withheld"; +/** One marker per owner-only read still in flight. A marker stranded by a crash fails closed. */ +const OBSERVER_WITHHOLD_PREFIX = "observer-withhold:"; + +/** Refusal when the binding has read something no observer can ever be verified against. */ +export const OBSERVER_WITHHELD_MESSAGE = + "This binding has made an observation no collaborator can be verified against, so it can no " + + "longer be observed."; /** Persisted state of one tracked set. `true` is the pre-"pending" legacy encoding of observed. */ export type ObservedSetState = true | "pending" | "observed"; @@ -35,6 +44,8 @@ export type ObserverCheck = { pendingSets: T[]; /** Promotes the pending sets to observed. Call only after the read is authorized. */ commit(): void; + /** Releases state this call staged. Call when the read was refused. */ + discard?(): void; }; /** The storage surface the tracker needs, satisfied by a Durable Object's `ctx.storage.kv`. */ @@ -118,7 +129,9 @@ export class ObserverTracker { #options: ObserverTrackerOptions; constructor(kv: ObserverKv, options: ObserverTrackerOptions) { - let reserved = [OBSERVER_PREFIX, OBSERVER_ATTEMPT_PREFIX, OBSERVER_NONCE_PREFIX]; + let reserved = [ + OBSERVER_PREFIX, OBSERVER_ATTEMPT_PREFIX, OBSERVER_NONCE_PREFIX, OBSERVER_WITHHOLD_PREFIX, + ]; if (reserved.includes(options.setPrefix)) { throw new Error(`setPrefix must not collide with a reserved prefix (${reserved.join(", ")})`); } @@ -236,11 +249,48 @@ export class ObserverTracker { }; } + /** + * Fences an owner-only observation: nobody currently admitted may see it, and nobody new may be + * admitted after it. + * + * Such a read registers no tracked set, so {@link addObserver} would have nothing to verify a + * later candidate against — the backward check would pass vacuously over data the candidate was + * never entitled to. The durable marker goes down before the caller asks for approval, so an + * activation that dies mid-read leaves admission closed rather than open; `commit` latches and + * then clears it, and `discard` clears it when the read was refused. + */ + prepareWithheld(): ObserverCheck { + // Enumerated before the marker goes down: a throw here must strand nothing. + let excludeObservers = [...this.observers()].map(([id]) => id); + let markerKey = `${OBSERVER_WITHHOLD_PREFIX}${crypto.randomUUID()}`; + this.#kv.put(markerKey, true); + return { + ...(excludeObservers.length > 0 ? { excludeObservers } : {}), + pendingSets: [], + // Latch before the marker goes, so no state has neither fence standing. + commit: () => { + this.#kv.put(OBSERVER_WITHHELD_KEY, true); + this.#kv.delete(markerKey); + }, + discard: () => this.#kv.delete(markerKey), + }; + } + + /** Whether an owner-only read has latched, or is still unsettled. */ + #observationWithheld(): boolean { + if (this.#kv.get(OBSERVER_WITHHELD_KEY)) return true; + for (let _ of this.#kv.list({ prefix: OBSERVER_WITHHOLD_PREFIX })) return true; + return false; + } + /** * Admits `id` as an observer, or throws naming the first set they cannot reach. Bulk verification * stages the candidate, then re-lists until every set has been checked before promotion. */ async addObserver(id: string, verifier: V): Promise { + // A withheld read tracks no set, so nothing here can establish this candidate was entitled to + // it. One still in flight counts: this candidate is absent from the exclusion list it sent. + if (this.#observationWithheld()) throw new Error(OBSERVER_WITHHELD_MESSAGE); let verifyBatch = this.#options.verifyBatch; if (verifyBatch !== undefined) return this.#addBulkObserver(id, verifier, verifyBatch); @@ -275,6 +325,7 @@ export class ObserverTracker { value => !checked.has(this.#options.encode(value))); if (!needsBaselineCheck && pending.length === 0) { this.#assertCurrentAdmission(nonceKey, nonce); + if (this.#observationWithheld()) throw new Error(OBSERVER_WITHHELD_MESSAGE); this.#kv.put(observerKey, verifier); this.#kv.delete(attemptKey); this.#kv.delete(nonceKey); diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index 4c836d697f..eb1a1be4cb 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -93,6 +93,24 @@ export const GOOGLE_SHARED_DRIVE_RESOURCE: SupportedResource = { grantable: true, }; +/** + * Files, folders, and read-only native content within one Drive folder and its descendants. + * + * The `_resource` path is an internal selector rather than a browser URL, because the natural + * `/drive/folders/:id` is already {@link GOOGLE_SHARED_DRIVE_RESOURCE}'s permanent identity and its + * pattern leaves the search component wildcard: a query-qualified variant of it would match both + * resources, making selection order- and filtering-dependent. This path is disjoint from every + * other pattern even when either resource is disabled. + */ +export const GOOGLE_DRIVE_FOLDER_RESOURCE: SupportedResource = { + urlPattern: "https://drive.google.com/_resource/folder/:folderId", + title: "Google Drive Folder", + description: + "Find files and folders, and read native Google Docs and Sheets, within one Drive folder " + + "and its descendants.", + grantable: true, +}; + /** Metadata and, when native, read-only content for one immutable Drive file ID. */ export const GOOGLE_DRIVE_FILE_RESOURCE: SupportedResource = { urlPattern: "https://drive.google.com/file/d/:fileId/view", @@ -177,13 +195,25 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] resource: GOOGLE_SHARED_DRIVE_RESOURCE, // `drive.readonly` (not `drive.metadata.readonly`): the shared-drive picker and the binding's // `getScope` use `drives.list`/`drives.get`, which accept nothing narrower. The same scope already - // authorizes native Docs and Sheets content, so do not add redundant API scopes. It is a - // restricted scope granting account-wide content access, strictly wider than the authority the - // shared-drive binding exercises. Narrowing it means dropping both calls: resolving a shared - // drive's name through `files.get` on the drive root instead, and giving up drive enumeration in - // the configurator. + // authorizes native Docs and Sheets content, so do not add redundant API scopes. Google lists + // `drive.metadata.readonly` as restricted too, so that is not the distinction: what this scope + // adds is account-wide content download, strictly wider than the authority the shared-drive + // binding exercises. Narrowing it means dropping both calls: resolving a shared drive's name + // through `files.get` on the drive root instead, and giving up drive enumeration in the + // configurator. scopes: ["https://www.googleapis.com/auth/drive.readonly"], }, + { + resource: GOOGLE_DRIVE_FOLDER_RESOURCE, + // The same read-only trio the account and exact-file resources take. A folder binding proves + // descendant membership from `files.get` metadata and reads native content through the Docs and + // Sheets APIs, so it needs nothing from the wider `drive.readonly` the shared drive requires. + scopes: [ + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/documents.readonly", + "https://www.googleapis.com/auth/spreadsheets.readonly", + ], + }, { resource: GOOGLE_DRIVE_FILE_RESOURCE, scopes: [ @@ -205,6 +235,7 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] const DRIVE_RESOURCE_PATTERNS = new Set([ GOOGLE_DRIVE_RESOURCE.urlPattern, GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern, + GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern, GOOGLE_DRIVE_FILE_RESOURCE.urlPattern, ]); @@ -310,6 +341,7 @@ export type ResourceTarget = | { kind: "bigquery"; projectId: string; datasetId?: string; tableId?: string } | { kind: "driveAccount" } | { kind: "sharedDrive"; driveId: string } + | { kind: "driveFolder"; folderId: string } | { kind: "driveFile"; fileId: string }; /** The grantable resource each {@link ResourceTarget} kind belongs to. */ @@ -320,6 +352,7 @@ export const RESOURCE_BY_KIND: Record calendar: GOOGLE_CALENDAR_RESOURCE, bigquery: BIGQUERY_RESOURCE, driveAccount: GOOGLE_DRIVE_RESOURCE, + driveFolder: GOOGLE_DRIVE_FOLDER_RESOURCE, sharedDrive: GOOGLE_SHARED_DRIVE_RESOURCE, driveFile: GOOGLE_DRIVE_FILE_RESOURCE, }; @@ -432,6 +465,11 @@ function parseDriveUrl(parsed: URL): ResourceTarget { let sharedDrive = /^\/drive\/folders\/([^/]+)\/?$/.exec(parsed.pathname); if (sharedDrive) return { kind: "sharedDrive", driveId: decodeURIComponent(sharedDrive[1]) }; + // Internal selector, not a browser URL: `/drive/folders/:driveId` above is the shared drive's + // permanent identity and matches any query, so a folder cannot be told apart by qualifying it. + let folder = /^\/_resource\/folder\/([^/]+)\/?$/.exec(parsed.pathname); + if (folder) return { kind: "driveFolder", folderId: decodeURIComponent(folder[1]) }; + let file = /^\/file\/d\/([^/]+)\/view\/?$/.exec(parsed.pathname); if (file) return { kind: "driveFile", fileId: decodeURIComponent(file[1]) }; From 152d506a1f1584a51ee34515bae7950f8f9a1481 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Wed, 16 Sep 2026 11:53:36 -0500 Subject: [PATCH 2/6] Let a resource configurator offer an account authorization startResourceConfigurator() previously returned only iframe HTML plus a gatekeeper-defined capability, so a gatekeeper had no way to say that selecting a resource first needs a wider grant on the connected account. The sandboxed frame cannot ask for one itself: it runs under sandbox="allow-scripts" with no popup or top-navigation privileges, and widening that sandbox would weaken every configurator to serve one. ResourceConfiguratorFrame now carries an optional authorization action: display copy plus a capability that prepares the consent attempt and returns its URL, or omits the URL when the authority is already held. The gatekeeper keeps the policy -- what scope, what nonce, what callback -- and Workshop contributes only the trusted user gesture. No arbitrary scope strings cross the boundary, and the field is optional, so every existing gatekeeper configurator is unaffected. --- packages/workshop-shared/src/gatekeeper.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/workshop-shared/src/gatekeeper.ts b/packages/workshop-shared/src/gatekeeper.ts index 930589c99d..9a274b0894 100644 --- a/packages/workshop-shared/src/gatekeeper.ts +++ b/packages/workshop-shared/src/gatekeeper.ts @@ -420,11 +420,21 @@ export type GatekeeperUiFrame = { ui: RpcStub; } -/** - * Legacy alias for GatekeeperUiFrame: the established return type of startResourceConfigurator, - * referenced by every gatekeeper implementation. Kept to avoid a repo-wide rename. - */ -export type ResourceConfiguratorFrame = GatekeeperUiFrame; +/** An account-authorization action rendered by Workshop outside the iframe. */ +export type ResourceConfiguratorAuthorization = { + /** Label for the user-initiated action. */ + title: string; + /** Explanation of the additional account authority. */ + description: string; + /** Prepare authorization; omit url when access is already available. */ + request: RpcStub<() => Promise<{ url?: string }>>; +}; + +/** A resource selector with an optional user-initiated authorization action. */ +export type ResourceConfiguratorFrame = GatekeeperUiFrame & { + /** Additional account authorization; never invoked automatically. */ + authorization?: ResourceConfiguratorAuthorization; +}; /** * The root interface of an Adapter, as provided to the Gadget Workshop. From 44f7c65edd420b7fe9abaaa6af362585233c7e1e Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Wed, 16 Sep 2026 11:53:46 -0500 Subject: [PATCH 3/6] Render a configurator's authorization action outside its iframe ResourceConfiguratorHost now wraps the sandboxed configurator and, when the frame declares one, renders the authorization control itself. The popup is opened blank during the click and navigated after the gatekeeper answers, because a window opened after an awaited RPC is blocked by default. popup.opener is cleared, and only an http(s) URL with no embedded credentials is navigated to; anything else closes the window and reports a failure rather than following it. Each action is keyed by frameKey, so switching account or resource while a request is in flight invalidates that request generation and closes its blank window -- a late answer cannot navigate a popup that now belongs to a different account or resource. Unmount does the same. A frame now owns two RPC capabilities, so disposeConfiguratorFrame() releases both and is used by both configurator owners on replacement, unmount, and late completion; the authorization stub is released even when disposing the UI stub throws. ResourceConfiguratorHost.test.tsx covers the blocked popup, the valid and credential-bearing URLs, the already-granted answer, the fenced late result, and disposal. --- .../src/BlueprintLandingPage.tsx | 8 +- .../workshop-frontend/src/GatekeeperModal.tsx | 6 +- .../src/ResourceConfiguratorHost.test.tsx | 198 ++++++++++++++++++ .../src/ResourceConfiguratorHost.tsx | 129 +++++++++++- 4 files changed, 318 insertions(+), 23 deletions(-) create mode 100644 packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.tsx index dd967ad2ff..dc9b2c7f35 100644 --- a/packages/workshop-frontend/src/BlueprintLandingPage.tsx +++ b/packages/workshop-frontend/src/BlueprintLandingPage.tsx @@ -16,7 +16,7 @@ import { saveStreamToFile, } from './fileTransfers' import { AccountChooser, AccountOption } from './gatekeeper-modal/AccountChooser' -import ResourceConfiguratorHost from './ResourceConfiguratorHost' +import ResourceConfiguratorHost, { disposeConfiguratorFrame } from './ResourceConfiguratorHost' import { WorkshopButton, WorkshopIconButton } from './components/WorkshopControls' import { MENU_CONTENT, MENU_ITEM, MENU_ITEM_DANGER } from './components/menuStyles' import { useDocumentTitle } from './useDocumentTitle' @@ -1456,12 +1456,6 @@ function BindingField({ return null } -// Dispose the host-side capability bundle returned with a configurator frame, releasing the -// gatekeeper-side resources backing the iframe. -function disposeConfiguratorFrame(frame: ResourceConfiguratorFrame | null) { - const uiDisposable = frame?.ui as any - uiDisposable?.[Symbol.dispose]?.() -} function formatSuggestedResource(resourceUrl: string): string { try { diff --git a/packages/workshop-frontend/src/GatekeeperModal.tsx b/packages/workshop-frontend/src/GatekeeperModal.tsx index e591c4c6c7..fe67b4a726 100644 --- a/packages/workshop-frontend/src/GatekeeperModal.tsx +++ b/packages/workshop-frontend/src/GatekeeperModal.tsx @@ -22,7 +22,7 @@ import { SupportedResource, VendorDescription, matchesResourceUrlPattern } from import { ResourceConfiguratorFrame } from '@gadgets/workshop-shared/gatekeeper' import { useAuthenticatedApi } from './AuthContext' import { WorkshopButton, WorkshopIconButton } from './components/WorkshopControls' -import ResourceConfiguratorHost from './ResourceConfiguratorHost' +import ResourceConfiguratorHost, { disposeConfiguratorFrame } from './ResourceConfiguratorHost' import { AgentSpawnerConfigForm, SpawnerEnvRow, @@ -178,10 +178,6 @@ function accountSupportsConnection(account: AccountOption, connection: Connectio account.supportedResources.some(resource => resource.urlPattern === connection.resourceUrlPattern)) } -function disposeConfiguratorFrame(frame: ResourceConfiguratorFrame | null) { - const uiDisposable = frame?.ui as any - uiDisposable?.[Symbol.dispose]?.() -} export default function GatekeeperModal({ open, onClose, getOverseer, onCreated, spawnerEnvCandidates, diff --git a/packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx b/packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx new file mode 100644 index 0000000000..f971bcc33f --- /dev/null +++ b/packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx @@ -0,0 +1,198 @@ +// @vitest-environment jsdom +/* eslint-disable react/react-in-jsx-scope */ + +import { act, type ComponentProps } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcStub, RpcTarget } from 'capnweb' +import type { + ResourceConfiguratorAuthorization, + ResourceConfiguratorFrame, +} from '@gadgets/workshop-shared/gatekeeper' + +Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, writable: true }) + +vi.mock('./components/WorkshopControls', () => ({ + WorkshopButton: ({ children, ...props }: ComponentProps<'button'>) => ( + + ), +})) + +vi.mock('./SandboxedResourceConfigurator', () => ({ + default: () =>
, +})) + +import ResourceConfiguratorHost, { disposeConfiguratorFrame } from './ResourceConfiguratorHost' + +type Deferred = { + promise: Promise + resolve: (value: T) => void + reject: (error: unknown) => void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function stub unknown>(fn: T): RpcStub { + return fn as unknown as RpcStub +} + +function frame(auth?: ResourceConfiguratorAuthorization): ResourceConfiguratorFrame { + return { + iframeHtml: '', + ui: {} as RpcStub, + authorization: auth, + } +} + +function popup() { + return { + opener: {} as unknown, + close: vi.fn<() => void>(), + location: { replace: vi.fn<(url: string) => void>() }, + } +} + +function renderHost(hostFrame: ResourceConfiguratorFrame, frameKey = 1) { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + act(() => { + root.render( + , + ) + }) + return { container, root } +} + +function authorization(request: ResourceConfiguratorAuthorization['request']): ResourceConfiguratorAuthorization { + return { + title: 'Enable shared-drive discovery', + description: 'Additional authority is required.', + request, + } +} + +const roots: Root[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()) + document.body.textContent = '' + vi.restoreAllMocks() +}) + +describe('ResourceConfiguratorHost authorization', () => { + it('does not request authorization when the popup is blocked', () => { + const request = vi.fn<() => Promise<{ url: string }>>( + async () => ({ url: 'https://accounts.example.test/oauth' }), + ) + vi.spyOn(window, 'open').mockReturnValue(null) + const rendered = renderHost(frame(authorization(stub(request)))) + roots.push(rendered.root) + + act(() => rendered.container.querySelector('button')!.click()) + + expect(request).not.toHaveBeenCalled() + expect(rendered.container.textContent).toContain('Allow popups and try again.') + }) + + it('pre-opens a safe popup and navigates it to a valid authorization URL', async () => { + const opened = popup() + const request = vi.fn<() => Promise<{ url: string }>>(async () => { + expect(opened.opener).toBeNull() + return { url: 'https://accounts.example.test/oauth?state=secret' } + }) + vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) + const rendered = renderHost(frame(authorization(stub(request)))) + roots.push(rendered.root) + + await act(async () => rendered.container.querySelector('button')!.click()) + + expect(window.open).toHaveBeenCalledWith('about:blank', '_blank') + expect(opened.location.replace).toHaveBeenCalledWith('https://accounts.example.test/oauth?state=secret') + expect(opened.close).not.toHaveBeenCalled() + expect(rendered.container.textContent).toContain('Complete authorization in the new tab') + }) + + it('closes the popup and reports an invalid authorization URL', async () => { + const opened = popup() + const request = stub(vi.fn(async () => ({ url: 'https://user:password@accounts.example.test/oauth' }))) + vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) + const rendered = renderHost(frame(authorization(request))) + roots.push(rendered.root) + + await act(async () => rendered.container.querySelector('button')!.click()) + + expect(opened.location.replace).not.toHaveBeenCalled() + expect(opened.close).toHaveBeenCalledOnce() + expect(rendered.container.textContent).toContain('Could not start authorization. Please try again.') + }) + + it('closes an unused popup when access is already available', async () => { + const opened = popup() + const request = stub(vi.fn(async (): Promise<{ url?: string }> => ({}))) + vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) + const rendered = renderHost(frame(authorization(request))) + roots.push(rendered.root) + + await act(async () => rendered.container.querySelector('button')!.click()) + + expect(opened.close).toHaveBeenCalledOnce() + expect(rendered.container.textContent).toContain('Access is already available.') + }) + + it('closes a pending blank popup and ignores its result after frame replacement', async () => { + const opened = popup() + const pending = deferred<{ url?: string }>() + const request = stub(vi.fn(() => pending.promise)) + vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) + const rendered = renderHost(frame(authorization(request)), 1) + roots.push(rendered.root) + + act(() => rendered.container.querySelector('button')!.click()) + act(() => { + rendered.root.render( + , + ) + }) + expect(opened.close).toHaveBeenCalledOnce() + + await act(async () => pending.resolve({ url: 'https://accounts.example.test/oauth' })) + + expect(opened.location.replace).not.toHaveBeenCalled() + }) +}) + +describe('disposeConfiguratorFrame', () => { + it('attempts to dispose both frame capabilities', () => { + const requestDispose = vi.fn<() => void>() + const request = Object.assign(vi.fn<() => void>(), { [Symbol.dispose]: requestDispose }) + const uiError = new Error('ui disposal failed') + const uiDispose = vi.fn<() => void>(() => { throw uiError }) + const hostFrame = frame(authorization(request as unknown as ResourceConfiguratorAuthorization['request'])) + hostFrame.ui = { [Symbol.dispose]: uiDispose } as unknown as RpcStub + + expect(() => disposeConfiguratorFrame(hostFrame)).toThrow(uiError) + expect(uiDispose).toHaveBeenCalledOnce() + expect(requestDispose).toHaveBeenCalledOnce() + }) +}) diff --git a/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx b/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx index 7f9faf853e..c8829543a3 100644 --- a/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx +++ b/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx @@ -1,7 +1,30 @@ -import { ResourceConfiguratorFrame } from '@gadgets/workshop-shared/gatekeeper' +import { useEffect, useRef, useState } from 'react' +import type { + ResourceConfiguratorAuthorization, + ResourceConfiguratorFrame, +} from '@gadgets/workshop-shared/gatekeeper' +import { WorkshopButton } from './components/WorkshopControls' import SandboxedResourceConfigurator from './SandboxedResourceConfigurator' -/** Renders the resource configurator slot inside the gatekeeper modal. */ +/** Releases every capability owned by a resource-configurator frame. */ +export function disposeConfiguratorFrame(frame: ResourceConfiguratorFrame | null): void { + if (!frame) return + try { + disposeRpcStub(frame.ui) + } finally { + disposeRpcStub(frame.authorization?.request) + } +} + +function disposeRpcStub(stub: unknown): void { + const isObject = typeof stub === 'object' && stub !== null + if (!isObject && typeof stub !== 'function') return + if (!(Symbol.dispose in stub)) return + const dispose = stub[Symbol.dispose] + if (typeof dispose === 'function') dispose.call(stub) +} + +/** Renders the trusted controls and sandboxed resource configurator. */ export default function ResourceConfiguratorHost({ frame, frameKey, @@ -30,15 +53,99 @@ export default function ResourceConfiguratorHost({ if (error) return {error} if (!frame) return null - return + return ( + <> + {frame.authorization && ( + + )} + + + ) +} + +function AuthorizationAction({ + authorization, +}: { + authorization: ResourceConfiguratorAuthorization +}) { + const [pending, setPending] = useState(false) + const [message, setMessage] = useState(null) + const requestId = useRef(0) + const blankPopup = useRef(null) + + useEffect(() => () => { + requestId.current++ + blankPopup.current?.close() + blankPopup.current = null + }, []) + + const requestAuthorization = async () => { + const popup = window.open('about:blank', '_blank') + if (!popup) { + setMessage('Allow popups and try again.') + return + } + + popup.opener = null + const currentRequest = ++requestId.current + blankPopup.current = popup + setPending(true) + setMessage(null) + + try { + const result = await authorization.request() + if (currentRequest !== requestId.current) return + + if (!result.url) { + popup.close() + blankPopup.current = null + setMessage('Access is already available. Retry the shared-drive selector below.') + return + } + + const url = new URL(result.url) + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) { + throw new Error('Invalid authorization URL') + } + + popup.location.replace(url.href) + blankPopup.current = null + setMessage('Complete authorization in the new tab, then return and retry the shared-drive selector below.') + } catch { + if (currentRequest !== requestId.current) return + popup.close() + blankPopup.current = null + setMessage('Could not start authorization. Please try again.') + } finally { + if (currentRequest === requestId.current) setPending(false) + } + } + + return ( +
+
{authorization.title}
+

{authorization.description}

+ void requestAuthorization()} + > + {authorization.title} + + {message &&

{message}

} +
+ ) } function Placeholder({ children }: { children: React.ReactNode }) { From 7b78ae3a626b079eecf56a0411642b83d08e8970 Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Wed, 16 Sep 2026 11:54:00 -0500 Subject: [PATCH 4/6] Unify Google Drive folders and Workspace Shared Drives One Google Drive Folder resource now covers both an ordinary folder and a Workspace Shared Drive's root, so the separate Shared Drive resource and its picker are gone. The identity is the natural browser URL, https://drive.google.com/drive/folders/, which is unambiguous once no second resource claims that path, and the folder's grant stays the narrow drive.metadata.readonly, documents.readonly and spreadsheets.readonly set. The capability is positioned rather than recursive. GoogleDriveFolderSession lists and searches only the current folder's direct children, and openFolder() appends one validated direct-child edge to a new, independently disposable capability, leaving the parent untouched. Both list() and search() carry a direct-parent predicate into the provider request -- corpora=drive for a shared-drive root, corpora=user otherwise -- so Google's own full-text index answers within the folder instead of a broad search being filtered afterwards. That replaces the upward ancestry proofs: every operation revalidates the root and the root-to-current path, and observations are typed as files or listable folders so a remembered folder unit must still be a live listable folder. Complete shared-drive discovery cannot be narrowed -- Google accepts nothing smaller than drive.readonly for drives.list -- so it is an optional, user-initiated expansion on the account rather than part of any folder's grant. The stored OAuth flow carries a requestDriveReadonly flag, which adds only that scope and is never recorded as resource consent; a reconnect preserves an expansion the account already made. listSharedDrives() checks the stored scope before calling the provider, and the picker's Workspace Shared Drives source is offered alongside Folders, which lists every folder the account can list children of, including ones another person shared with it. --- packages/gatekeeper-google/README.md | 31 +- .../__tests__/configurator-url.test.ts | 50 +- .../__tests__/drive-api.test.ts | 82 +- .../__tests__/drive-observers.test.ts | 111 +- .../__tests__/drive-session.test.ts | 1045 +++-------------- .../__tests__/oauth-flow.test.ts | 21 + .../__tests__/resources.test.ts | 92 +- .../__tests__/workerd/configurators.test.ts | 57 +- .../__tests__/workerd/drive-discovery.test.ts | 161 +++ .../__tests__/workerd/native-sessions.test.ts | 34 +- .../__tests__/workerd/worker.ts | 68 +- .../drive-folder-configurator-types.d.ts | 6 +- .../drive-folder-configurator-ui.tsx | 50 +- .../shared-drive-configurator-types.d.ts | 7 - .../shared-drive-configurator-ui.tsx | 25 - packages/gatekeeper-google/src/drive-api.ts | 39 +- .../src/drive-folder-scope.ts | 210 +--- .../gatekeeper-google/src/drive-observers.ts | 67 +- .../gatekeeper-google/src/drive-session.ts | 534 +++++---- .../gatekeeper-google/src/drive-types.d.ts | 151 +-- .../src/google-configurators.ts | 47 +- packages/gatekeeper-google/src/google.ts | 215 ++-- packages/gatekeeper-google/src/oauth-flow.ts | 9 +- packages/gatekeeper-google/src/resources.ts | 68 +- .../gatekeeper-google/vitest.worker.config.ts | 1 + 25 files changed, 1321 insertions(+), 1860 deletions(-) create mode 100644 packages/gatekeeper-google/__tests__/workerd/drive-discovery.test.ts delete mode 100644 packages/gatekeeper-google/src/configurator/shared-drive-configurator-types.d.ts delete mode 100644 packages/gatekeeper-google/src/configurator/shared-drive-configurator-ui.tsx diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index a461d6e5b9..f9d24cf9df 100644 --- a/packages/gatekeeper-google/README.md +++ b/packages/gatekeeper-google/README.md @@ -76,7 +76,7 @@ included). Across all resource types, the gatekeeper can request: - `gmail.modify` for Gmail thread reads, organization, replies, forwards, and sending. This single scope already includes label access and sending. - `documents` for direct Google Docs reads and edits; `documents.readonly` for native Docs opened from account-wide, folder, or exact-file Drive bindings. - `drive.metadata.readonly` for the Docs, Sheets, and folder pickers, account-wide Drive discovery, exact-file metadata, folder descendant proofs, and native-file scope checks. Google classifies this as a restricted scope, so every Drive resource here needs restricted-scope verification. -- `drive.readonly` for the shared-drive picker and scope lookup, metadata search, and native Docs or Sheets reads within one shared drive. It is restricted like `drive.metadata.readonly`, but adds account-wide file content download on top, and it remains after the account expands consent. Google accepts nothing narrower for `drives.list`/`drives.get` and accepts this Drive scope for the native APIs. The gatekeeper still enforces the shared-drive binding boundary. +- `drive.readonly` is an optional account-level expansion used only to discover every shared-drive root. It is restricted like `drive.metadata.readonly`, but adds account-wide file content authority. Declining it leaves ordinary folder selection and every existing narrow connection usable. - `spreadsheets.readonly` to read metadata and bounded cell ranges from directly selected spreadsheets or native Sheets opened from account-wide, folder, or exact-file Drive bindings. - `calendar.calendarlist.readonly` so the resource picker can list calendars. - `calendar.events` to manage selected calendar and check calendar availability. @@ -136,12 +136,12 @@ User — see Step 4.) 2. Create or open a gadget. 3. Navigate to the **Connections** tab. 4. Click **+ New Connection**. -5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Drive Account, Google Workspace Shared Drive, Google Drive Folder, Google Drive File, Google Calendar, or BigQuery. +5. Choose a Google resource type: Gmail, Google Doc, Google Spreadsheet, Google Drive Account, Google Drive Folder, Google Drive File, Google Calendar, or BigQuery. 6. If prompted, connect a Google account. 7. You should be redirected to Google's consent screen in a new tab. 8. The consent screen acts extra-scary since this is an "unverified" test app. 9. After granting access, the tab closes, and you're back to Gadgets. -10. Use the picker to choose the mailbox scope, document, shared drive, folder, Drive file, project, dataset, or table to connect. (The Google Drive Account resource covers the whole account, so it has no picker.) +10. Use the picker to choose the mailbox scope, document, folder or shared-drive root, Drive file, project, dataset, or table to connect. (The Google Drive Account resource covers the whole account, so it has no picker.) 11. Create the connection. Ask the agent what it can do, or ask it to write a gadget using the new binding. You can also see your connected accounts and add and remove them in the settings (accessed through the account menu in the upper-right). @@ -168,30 +168,25 @@ stores baseline and Preview secrets separately, so provision the same signing va ## Google Drive read-only bindings -Drive exposes four permanent resource URL forms: +Drive exposes three permanent resource URL forms: -- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: listings set `includeItemsFromAllDrives`, so shared-drive items the account has accessed come back too, and reads by ID are not scope-checked at all, so anything the account's token resolves is inside this grant. Listings stay on `corpora=user` rather than `allDrives`, which Google flags as much less efficient and allows to return `incompleteSearch`, so a shared drive the account belongs to but has never touched may be readable by ID without appearing in a listing. It is the broadest of the four by design; bind a shared drive, a folder, or a file if that is too much. -- `https://drive.google.com/drive/folders/` selects one Google Workspace shared drive, where the organization rather than an individual owns the files. -- `https://drive.google.com/_resource/folder/` selects one folder and everything currently beneath it, at any depth. +- `https://drive.google.com/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: listings include shared-drive items, and any ID the account token resolves is in scope. Listings use `corpora=user`, so a shared drive the account has never touched may be readable by ID without appearing in a listing. Bind a folder or file when this authority is too broad. +- `https://drive.google.com/drive/folders/` selects one folder or shared-drive root. - `https://drive.google.com/file/d//view` selects one file by its immutable ID. -Despite the `/folders/` URL, the second resource is a Google Workspace shared drive, not an individual folder. Google uses a shared drive's ID for its root folder too. The gatekeeper confirms the ID with `drives.get`, so it rejects ordinary folder IDs. +The folder picker has separate **Folders** and **Workspace Shared Drives** sources, but both mint the same folder resource. **Folders** covers every ordinary folder the account can list children of, including folders another person shared with it, so Drive's "Shared with me" items appear there rather than under the Shared Drives source. **Workspace Shared Drives** lists organization-owned drives only. Folder search works with the baseline `drive.metadata.readonly` grant. Complete Shared Drive discovery requires the optional account-level `drive.readonly` expansion because Google offers no narrower scope for `drives.list`; enabling discovery does not broaden any folder binding. Metadata-only folders that cannot list children are not offered. -The folder resource is the reason the third form is an internal `_resource` path rather than the natural browser URL. `/drive/folders/:driveId` is already the shared drive's permanent identity and its pattern leaves the query wildcard, so a query-qualified variant of it would match both resources and make selection order-dependent. `describe()` still reports the natural `https://drive.google.com/drive/folders/` for the UI to link to, and validates the root through the same check the session uses, so a hand-built resource URL naming a file, a shortcut, trash, or a shared drive's root fails at connect rather than minting a binding that refuses every call. +The agent-facing `GoogleDriveReadSession` covers account and exact-file bindings. `GoogleDriveFolderSession` is positioned at the selected root and exposes only its current folder's direct children: `list()`, provider-side structured `search()`, `getEntry()`, native Doc/Sheet opens, and `openFolder()` for one live direct child. Listing and search return disposable RPC cursors; child folders and native content sessions are independently disposable capabilities. There is no built-in recursive folder search, traversal pager, raw Drive `q`, file write, shortcut traversal, arbitrary download/export, or Workers AI extraction. -A folder binding covers both storage domains: a folder in My Drive — including one another person shared from theirs — and an ordinary subfolder inside a shared drive. A shared drive's own root is rejected by the picker and served by the Shared Drive resource. Drive v3 has no folder corpus, no folder-scoped token, and no recursive ancestor predicate (`'' in parents` means direct children only), so membership is proved here: every operation re-reads the root, then walks each candidate's `parents` chain upward through fresh batched `files.get` metadata, admitting only chains that reach the root within the 101-hop nesting limit without a cycle, a trashed or non-folder link, a multi-parent link, or a hop into another storage domain. Nothing is cached across the operation that proved it — a hierarchy change rotates no credential and bumps no cache generation. A cursor pins the root's corpus when it opens and aborts if the root moves between My Drive and a shared drive, since a Drive page token is only valid against the corpus that produced it. Membership is a post-filter, since Drive cannot restrict a listing to a subtree, so a bare `list()` scans the corpus and a small folder in a large drive costs one round trip per page. A folder cursor fetches one provider page of 100 items per `next()` call and returns `[]` when that page filtered down to nothing with results still ahead; `null` still means exhausted, so drain the cursor. A fully-filtered page discloses nothing and is not audited; the terminal one is. `directParentId` is the efficient shape — it compiles to Drive's own `'' in parents` — so prefer it when walking a known folder. The bound folder's own `parentId` is withheld, since its container is outside the binding. +Every folder operation revalidates the selected root and the root-to-current path. A shared-drive root uses `corpora=drive`; other folders use `corpora=user`. Both listing and full-text search include a direct-parent predicate, so indexed content, descriptions, and OCR can match only immediate children. `openFolder()` appends one validated direct-child edge to a new capability without changing the parent capability. -The agent-facing `GoogleDriveSession` reports the binding scope, lists entries, runs structured searches, and fetches one entry by ID. Listing and search return disposable RPC cursors. A folder binding lists and searches its whole subtree; on every binding a `directParentId` filter means direct children only, never recursive descendants. For a native Google Doc or Sheet, `openGoogleDoc()` or `openGoogleSheet()` returns an independently disposable, read-only nested session. Docs expose metadata for any tab count, but Markdown content requires exactly one tab; Sheets expose spreadsheet metadata and bounded A1 range reads. The API does not expose raw Drive `q` strings, file writes, shortcut traversal, arbitrary download or export, or Workers AI extraction. One caveat: the `fullTextContains` search filter compiles to Drive's `fullText contains`, which matches a file's indexed body text, description, and OCR text. Results carry metadata alone, but repeated queries remain a content oracle over files the agent cannot otherwise read. +Every native open re-fetches Drive metadata, enforces the immutable account, folder, or exact-file scope, authorizes the metadata observation, and checks the exact MIME type. A folder-derived Doc or Sheet read revalidates direct membership before contacting the native API and again before approval; if the file moves meanwhile, the fetched value is discarded. Direct Google Doc bindings retain their editing API, while Drive-opened Docs and Sheets are read-only. -Every native open re-fetches Drive metadata, enforces the immutable account, shared-drive, folder, or exact-file scope, authorizes the metadata observation, and then checks the exact MIME type. A folder, shortcut, non-native blob, wrong native type, or out-of-scope file cannot mint a content capability. Direct Google Doc bindings retain their existing editing API; Drive-opened Docs do not expose it. +Account, folder, and exact-file Drive bindings request `drive.metadata.readonly`, `documents.readonly`, and `spreadsheets.readonly`. The broader `drive.readonly` grant covers those requirements when already present, but remains only account-level discovery authority and is never recorded as resource consent. Existing metadata-only connections are prompted to expand before native content reads are considered granted. -A folder-derived Doc or Sheet session goes further, because its authority is derived from a hierarchy Drive can change under it: every method re-proves the file's ancestry and native type *before* contacting the Docs or Sheets API, re-checks the proved chain after the read and before any approval, and discards the fetched value if either fails. Drive offers no ancestry-plus-content transaction, so a move landing after that final check still returns the already-authorized result — the next call is what denies. Bindings with an immutable scope keep the plain read-then-authorize path. +Drive observations are typed as files or listable folders. A folder operation observes its positioned folder path plus each disclosed direct child, and native reads observe the file independently. Before a collaborator opens the workspace, the gatekeeper requires their own explicit Drive resource consent and rechecks remembered units with fresh batched metadata reads; folder units must still be live listable folders. Hidden rejected candidates are not disclosed or remembered. -Account-wide, folder, and exact-file Drive bindings request `documents.readonly` and `spreadsheets.readonly` in addition to `drive.metadata.readonly`. An older metadata-only connection is therefore prompted to expand consent before it is treated as granting any of them. Shared-drive bindings remain on `drive.readonly`, which Google accepts for native Docs and Sheets reads, so they do not request redundant scopes. - -Account, shared-drive, and folder bindings use per-file observer tracking because individual items can carry narrower ACLs. They remember every file ID whose metadata or native content a workspace has read, including one that has since moved out of scope: a later observer must still prove direct access to it. Hidden ancestors traversed by a proof and candidates it rejected are never tracked, since neither is disclosed. Before each collaborator opens the workspace, the gatekeeper requires that their own account explicitly consented to a Drive resource — a Drive grant is never inferred from held OAuth scopes — and rechecks all remembered IDs with fresh batched `files.get` calls. Before a new result page or native child capability is disclosed, it checks the file ID against every existing observer and excludes observers who cannot access it. The refusal names no file: a collaborator who cannot reach one must not learn which one this workspace read. Exact-file bindings perform the same fresh check for their single file on each share attempt. Google batch requests contain at most 100 `files.get` subrequests, and a binding is capped at 2,000 distinct file IDs; attempting to cross the limit refuses the read and asks the user to bind a narrower scope. There is deliberately no cached access verdict, so revoked access fails closed on the next open. - -A search that terminates with no match at all is the one read no observer can be verified against, because it registers no file ID. Such a read is audited, withheld from every current observer, and latched: a durable fence goes down before the approval is requested and closes admission permanently once it succeeds, so the binding becomes unshareable rather than silently sharing an owner-relative negative answer. Only an exhausted cursor can trigger this; an intermediate empty page is not a negative answer, and the bound folder is re-checked against the cursor's pinned corpus before the latch closes, so a folder trashed or moved to another drive mid-scan refuses the read instead of ending sharing for good. +A search that exhausts with no match is owner-relative and cannot be verified against a file. That read is audited, withheld from current observers, and permanently closes later sharing only after the positioned folder path is revalidated. Intermediate empty provider pages do not trigger the restriction. ## Troubleshooting diff --git a/packages/gatekeeper-google/__tests__/configurator-url.test.ts b/packages/gatekeeper-google/__tests__/configurator-url.test.ts index 2dbe656c2e..f7250e6e48 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -24,10 +24,9 @@ import calendarConfigurator from "../src/configurator/calendar-configurator-ui"; import type { CalendarConfiguratorRpc } from "../src/configurator/calendar-configurator-types"; import driveFolderConfigurator from "../src/configurator/drive-folder-configurator-ui"; import gmailConfigurator from "../src/configurator/gmail-configurator-ui"; -import sharedDriveConfigurator from "../src/configurator/shared-drive-configurator-ui"; import { - GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, - GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE, parseResourceUrl, + GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, + GOOGLE_DRIVE_FOLDER_RESOURCE, GOOGLE_DRIVE_RESOURCE, parseResourceUrl, } from "../src/resources"; // The configurators never call `ui` from these two methods; it is present only to satisfy the @@ -156,29 +155,16 @@ describe("Drive configurator URLs", () => { expect(parseResourceUrl(url)).toEqual({ kind: "driveAccount" }); }); - it("explains native Doc and Sheet reads at every Drive scope", () => { + it("explains Drive read behavior", () => { expect(renderedCopy(driveAccountConfigurator)).toContain( "native Google Docs and Sheets can be opened in read-only content sessions.", ); - expect(renderedCopy(sharedDriveConfigurator)).toContain( - "Search its files and read native Google Docs and Sheets.", - ); expect(renderedCopy(driveFileConfigurator)).toContain( "A selected native Google Doc or Sheet also provides read-only content.", ); - expect(renderedCopy(driveFolderConfigurator)).toContain( - "Search everything currently beneath it and read native Google Docs and Sheets.", - ); + expect(renderedCopy(driveFolderConfigurator)).toContain("Workspace Shared Drives"); }); - it("round-trips an encoded shared-drive ID", () => { - let values = { driveId: "shared/id with spaces" }; - let url = configurableUrl(sharedDriveConfigurator, values); - expect(url).toBe( - GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern.replace(":driveId", encodeURIComponent(values.driveId)), - ); - expect(parseResourceUrl(url)).toEqual({ kind: "sharedDrive", driveId: values.driveId }); - }); it("round-trips an encoded file ID", () => { let values = { fileId: "file/id with spaces" }; @@ -199,23 +185,15 @@ describe("Drive configurator URLs", () => { expect(parseResourceUrl(url)).toEqual({ kind: "driveFolder", folderId: values.folderId }); }); - // The folder picker mints the internal `_resource` selector, never the natural browser URL: that - // one is the shared drive's permanent identity, and a folder minting it would hand a whole - // drive's authority to a binding the user configured as one folder. - it("never mints the shared drive's identity from a folder", () => { + it("mints the natural Drive folder URL", () => { let url = configurableUrl(driveFolderConfigurator, { folderId: "FOLDER123" }); - expect(url).not.toContain("/drive/folders/"); + expect(url).toBe("https://drive.google.com/drive/folders/FOLDER123"); expect(parseResourceUrl(url)).toEqual({ kind: "driveFolder", folderId: "FOLDER123" }); }); // Prefill after deleting the hand-written hooks: the sandbox fallback extracts named groups and // decodeURIComponent's them. A missing decode would leave `%2F`/`%20` in the form values. it("prefills encoded IDs from urlPattern named groups", () => { - let driveValues = { driveId: "shared/id with spaces" }; - let driveUrl = configurableUrl(sharedDriveConfigurator, driveValues); - expect(valuesFromUrlPattern(driveUrl, GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern)) - .toEqual(driveValues); - let fileValues = { fileId: "file/id with spaces" }; let fileUrl = configurableUrl(driveFileConfigurator, fileValues); expect(valuesFromUrlPattern(fileUrl, GOOGLE_DRIVE_FILE_RESOURCE.urlPattern)) @@ -226,4 +204,20 @@ describe("Drive configurator URLs", () => { expect(valuesFromUrlPattern(folderUrl, GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern)) .toEqual(folderValues); }); + + it("clears the selected folder when its source changes", () => { + const clearFields = vi.fn(); + const setValues = vi.fn(); + const tree = driveFolderConfigurator.render!({ + values: { source: "folders", folderId: "folder-1" }, + setValues, + clearFields, + ui: noUi, + } as never) as unknown as { + children: Array<{ children: Array<{ props: { onChange(value: string): void } }> }>; + }; + tree.children[0].children[0].props.onChange("sharedDrives"); + expect(clearFields).toHaveBeenCalledWith("folderId"); + expect(setValues).toHaveBeenCalledWith({ source: "sharedDrives", folderId: null }); + }); }); diff --git a/packages/gatekeeper-google/__tests__/drive-api.test.ts b/packages/gatekeeper-google/__tests__/drive-api.test.ts index 98aa0f1e28..dadaca76d7 100644 --- a/packages/gatekeeper-google/__tests__/drive-api.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-api.test.ts @@ -288,6 +288,18 @@ describe("listDrives", () => { expect(calls.map(call => call.url.searchParams.get("q"))) .toEqual(["name contains 'Pro'", "name contains 'Pro'"]); }); + + // Without a budget a cyclic nextPageToken pages until the Worker's subrequest limit. + it("stops shared-drive pagination at the page budget", async () => { + let calls = stubFetch(() => jsonResponse({ + drives: [{ id: "drive-1", name: "Product" }], nextPageToken: "loop", + })); + + await expect(api().listAllDrives()).resolves.toHaveLength(20); + expect(calls).toHaveLength(20); + expect(calls[1].url.searchParams.get("pageToken")).toBe("loop"); + }); + it("rejects malformed shared-drive metadata", async () => { stubFetch([jsonResponse({ drives: [{ id: "drive-1", name: false }] })]); await expect(api().listDrives()).rejects.toThrow("Invalid Google shared-drive response"); @@ -404,11 +416,12 @@ describe("bulk access verification", () => { let calls = stubFetch([batchResponse([ { status: 200 }, { status: 403 }, { status: 404 }, ])]); - await expect(api().checkFileAccess(["one", "two", "three"])) + await expect(api().checkObservations(["one", "two", "three"].map(fileId => ({ kind: "file" as const, fileId })))) .resolves.toEqual([true, false, false]); expect(calls[0].url.href).toBe("https://www.googleapis.com/batch/drive/v3"); expect(calls[0].method).toBe("POST"); - expect(calls[0].body).toContain("GET /drive/v3/files/one?fields=id&supportsAllDrives=true"); + expect(calls[0].body).toContain( + "GET /drive/v3/files/one?fields=id%2CmimeType%2Ctrashed%2Ccapabilities"); }); it("refuses metadata-only access when the bound folder must be listable", async () => { @@ -419,7 +432,7 @@ describe("bulk access verification", () => { }), }])]); - await expect(api().checkFileAccess(["folder"], "folder")) + await expect(api().checkObservations([{kind: "folder", fileId: "folder"}])) .resolves.toEqual([false]); }); @@ -431,9 +444,7 @@ describe("bulk access verification", () => { ]), batchResponse([{ status: 403 }]), ]); - await expect(api().checkFileAccess( - Array.from({ length: 101 }, (_, index) => `file-${index}`), - )).resolves.toEqual([ + await expect(api().checkObservations(Array.from({ length: 101 }, (_, index) => `file-${index}`).map(fileId => ({ kind: "file" as const, fileId })))).resolves.toEqual([ ...Array.from({ length: 99 }, () => true), false, false, @@ -445,13 +456,13 @@ describe("bulk access verification", () => { it("checks no Google endpoint for an empty file set", async () => { let calls = stubFetch([]); - await expect(api().checkFileAccess([])).resolves.toEqual([]); + await expect(api().checkObservations([].map(fileId => ({ kind: "file" as const, fileId })))).resolves.toEqual([]); expect(calls).toEqual([]); }); it("distinguishes an API-disabled inner response", async () => { stubFetch([batchResponse([{ status: 403, body: API_DISABLED_BODY }])]); - await expect(api().checkFileAccess(["one"])) + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toBeInstanceOf(DriveApiDisabledError); }); @@ -460,7 +471,7 @@ describe("bulk access verification", () => { async reason => { let body = JSON.stringify({ error: { errors: [{ reason }] } }); stubFetch([batchResponse([{ status: 403, body }])]); - await expect(api().checkFileAccess(["one"])) + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Google Drive batch subrequest failed: 403"); }, ); @@ -468,7 +479,7 @@ describe("bulk access verification", () => { it("does not infer API disablement from unstructured error text", async () => { let body = JSON.stringify({ error: { message: "accessNotConfigured" } }); stubFetch([batchResponse([{ status: 403, body }])]); - await expect(api().checkFileAccess(["one"])).resolves.toEqual([false]); + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))).resolves.toEqual([false]); }); it("cancels an oversized batch response before reading the remaining stream", async () => { @@ -486,7 +497,7 @@ describe("bulk access verification", () => { headers: { "Content-Type": "multipart/mixed; boundary=response_boundary" }, })]); - await expect(api().checkFileAccess(["one"])) + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Google Drive batch response was too large"); expect(cancelled).toBe(true); expect(pulls).toBeLessThan(4); @@ -494,32 +505,32 @@ describe("bulk access verification", () => { it("fails a transient inner response instead of reporting an access denial", async () => { stubFetch([batchResponse([{ status: 429 }])]); - await expect(api().checkFileAccess(["one"])) + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Google Drive batch subrequest failed: 429"); }); it("rejects a batch response whose Content-Type carries no boundary", async () => { stubFetch([new Response("x", { headers: { "Content-Type": "multipart/mixed" } })]); - await expect(api().checkFileAccess(["one"])) + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Invalid Google Drive batch response boundary"); }); it("rejects a truncated batch with fewer parts than files", async () => { stubFetch([batchResponse([{ status: 200 }])]); - await expect(api().checkFileAccess(["one", "two"])) + await expect(api().checkObservations(["one", "two"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Google Drive batch response did not contain one result per file"); }); it("surfaces an outer non-ok batch POST", async () => { let calls = stubFetch(() => new Response("{}", { status: 500 })); - await expect(api().checkFileAccess(["one"])) + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Google Drive API request failed: 500"); expect(calls).toHaveLength(3); }); it("wraps the batch POST in a multipart envelope matching its Content-Type boundary", async () => { let calls = stubFetch([batchResponse([{ status: 200 }])]); - await api().checkFileAccess(["one"]); + await api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId }))); let contentType = calls[0].headers.get("Content-Type") ?? ""; let boundary = /boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType)?.slice(1).find(Boolean); expect(boundary).toBeTruthy(); @@ -532,7 +543,7 @@ describe("bulk access verification", () => { new Response("slow down", { status: 429, headers: { "Retry-After": "0" } }), batchResponse([{ status: 200 }]), ]); - await expect(api().checkFileAccess(["one"])).resolves.toEqual([true]); + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))).resolves.toEqual([true]); expect(calls).toHaveLength(2); }); @@ -548,7 +559,7 @@ describe("bulk access verification", () => { batchResponse([{ status: 401 }]), batchResponse([{ status: 200 }]), ]); - await expect(drive.checkFileAccess(["one"])).resolves.toEqual([true]); + await expect(drive.checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))).resolves.toEqual([true]); expect(calls).toHaveLength(2); expect(calls.map(call => call.headers.get("Authorization"))) .toEqual(["Bearer stale", "Bearer fresh"]); @@ -569,7 +580,7 @@ describe("bulk access verification", () => { batchResponse([{ status: 401 }]), batchResponse([{ status: 401 }]), ]); - await expect(drive.checkFileAccess(["one"])) + await expect(drive.checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Google Drive batch subrequest failed: 401"); expect(calls).toHaveLength(2); }); @@ -580,15 +591,44 @@ describe("bulk access verification", () => { { status: 200, contentId: "response-item-0" }, { status: 404, contentId: "response-item-2" }, ])]); - await expect(api().checkFileAccess(["one", "two", "three"])) + await expect(api().checkObservations(["one", "two", "three"].map(fileId => ({ kind: "file" as const, fileId })))) .resolves.toEqual([true, false, false]); }); it("rejects a batch part whose Content-ID does not name a requested file", async () => { stubFetch([batchResponse([{ status: 200, contentId: "response-item-7" }])]); - await expect(api().checkFileAccess(["one"])) + await expect(api().checkObservations(["one"].map(fileId => ({ kind: "file" as const, fileId })))) .rejects.toThrow("Google Drive batch response part had an unrecognised Content-ID"); }); + it("distinguishes listable folder units from ordinary file units", async () => { + stubFetch([batchResponse([ + { + status: 200, + body: JSON.stringify({ + id: "folder", mimeType: FOLDER_MIME_TYPE, trashed: false, + capabilities: { canListChildren: true }, + }), + }, + { + status: 200, + body: JSON.stringify({ id: "file", mimeType: "application/pdf", trashed: false }), + }, + { + status: 200, + body: JSON.stringify({ + id: "closed", mimeType: FOLDER_MIME_TYPE, trashed: false, + capabilities: { canListChildren: false }, + }), + }, + ])]); + + await expect(api().checkObservations([ + {kind: "folder", fileId: "folder"}, + {kind: "file", fileId: "file"}, + {kind: "folder", fileId: "closed"}, + ])).resolves.toEqual([true, true, false]); + }); + }); describe("folder scope nodes", () => { diff --git a/packages/gatekeeper-google/__tests__/drive-observers.test.ts b/packages/gatekeeper-google/__tests__/drive-observers.test.ts index 61c35b5838..5a25cc36d2 100644 --- a/packages/gatekeeper-google/__tests__/drive-observers.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-observers.test.ts @@ -1,124 +1,101 @@ import { describe, expect, it } from "vitest"; -import { DRIVE_OBSERVATION_PREFIX, driveObserverTracker } from "../src/drive-observers"; +import { + DRIVE_OBSERVATION_PREFIX, driveObserverTracker, type DriveObservation, +} from "../src/drive-observers"; import type { DriveBindingScope } from "../src/drive-session"; import type { ObserverBatchResult } from "../src/observers"; import { FakeKv } from "./fake-kv"; -function allow(ids: readonly string[]): ObserverBatchResult { - return { baselineAllowed: true, allowed: ids.map(() => true) }; +function allow(units: readonly DriveObservation[]): ObserverBatchResult { + return { baselineAllowed: true, allowed: units.map(() => true) }; } -function deny(ids: readonly string[]): ObserverBatchResult { - return { baselineAllowed: true, allowed: ids.map(() => false) }; +function deny(units: readonly DriveObservation[]): ObserverBatchResult { + return { baselineAllowed: true, allowed: units.map(() => false) }; } function tracker( scope: DriveBindingScope, verdicts: ( - ids: readonly string[], verifier: string, listableFolderId?: string, + units: readonly DriveObservation[], verifier: string, ) => ObserverBatchResult | Promise, ) { let kv = new FakeKv(); - let asked: string[][] = []; - let listableFolders: (string | undefined)[] = []; - let track = driveObserverTracker(kv, scope, - async (verifier, fileIds, listableFolderId) => { - asked.push([...fileIds]); - listableFolders.push(listableFolderId); - return verdicts(fileIds, verifier, listableFolderId); - }); - return { kv, asked, listableFolders, track }; + let asked: DriveObservation[][] = []; + let track = driveObserverTracker(kv, scope, async (verifier, units) => { + asked.push([...units]); + return verdicts(units, verifier); + }); + return { kv, asked, track }; } describe("driveObserverTracker", () => { - - it("seeds a file binding with its bound file, so a joiner is verified against it", async () => { - let { kv, asked, track } = tracker({ kind: "file", fileId: "file-1" }, allow); - - expect([...kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}file-1`]); - await track.addObserver("obs", "verifier"); - expect(asked).toEqual([["file-1"]]); - }); - - it("seeds a shared-drive binding with its root", async () => { - let { asked, track } = tracker({ kind: "sharedDrive", driveId: "drive-1" }, allow); - - await track.addObserver("obs", "verifier"); - expect(asked).toEqual([["drive-1"]]); - }); - - it("seeds a folder binding with its root, which is durable authority a proof is not", async () => { - let { kv, asked, listableFolders, track } = - tracker({ kind: "folder", folderId: "folder-1" }, allow); - - expect([...kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}folder-1`]); - await track.addObserver("obs", "verifier"); - expect(asked).toEqual([["folder-1"]]); - expect(listableFolders).toEqual(["folder-1"]); + it("seeds file and folder bindings with distinct disclosure units", async () => { + let file = tracker({ kind: "file", fileId: "same" }, allow); + let folder = tracker({ kind: "folder", folderId: "same" }, allow); + + expect([...file.kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}same`]); + expect([...folder.kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}folder:same`]); + await file.track.addObserver("obs", "verifier"); + await folder.track.addObserver("obs", "verifier"); + expect(file.asked).toEqual([[{kind: "file", fileId: "same"}]]); + expect(folder.asked).toEqual([[{kind: "folder", fileId: "same"}]]); }); it("seeds an account binding with nothing", async () => { let { kv, asked, track } = tracker({ kind: "account" }, allow); - expect([...kv.entries.keys()]).toEqual([]); await track.addObserver("obs", "verifier"); expect(asked).toEqual([[]]); }); - it("refuses - and records no observer for - a joiner denied the bound file", async () => { - let { kv, track } = tracker({ kind: "file", fileId: "file-1" }, deny); - + it("refuses a joiner denied one tracked unit", async () => { + let { track } = tracker({ kind: "folder", folderId: "folder-1" }, deny); await expect(track.addObserver("obs", "verifier")) .rejects.toThrow("This collaborator cannot access Drive data this workspace has read."); expect([...track.observers()]).toEqual([]); - expect([...kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}file-1`]); }); - it("refuses a joiner holding no Drive grant at all", async () => { + it("refuses a joiner holding no Drive grant", async () => { let { track } = tracker({ kind: "file", fileId: "file-1" }, - ids => ({ baselineAllowed: false, allowed: ids.map(() => false) })); - + units => ({ baselineAllowed: false, allowed: units.map(() => false) })); await expect(track.addObserver("obs", "verifier")) .rejects.toThrow(/has not granted Google Drive access/); }); - it("rechecks a file tracked during account observer admission", async () => { + it("rechecks a unit tracked during account observer admission", async () => { let release!: () => void; let started!: () => void; let opening = new Promise(resolve => { release = resolve; }); let seen = new Promise(resolve => { started = resolve; }); let calls = 0; - let { kv, asked, track } = tracker({ kind: "account" }, async ids => { + let { kv, asked, track } = tracker({ kind: "account" }, async units => { if (calls++ === 0) { started(); await opening; } - return ids.length === 0 ? allow(ids) : deny(ids); + return units.length === 0 ? allow(units) : deny(units); }); let admission = track.addObserver("obs", "verifier"); await seen; - kv.put(`${DRIVE_OBSERVATION_PREFIX}file-1`, "pending"); + kv.put(`${DRIVE_OBSERVATION_PREFIX}folder:child`, "pending"); release(); await expect(admission).rejects.toThrow(/cannot access Drive data this workspace has read/); - expect(asked).toEqual([[], ["file-1"]]); + expect(asked).toEqual([[], [{kind: "folder", fileId: "child"}]]); }); - it("keeps the old Drive verifier after failed same-ID re-verification", async () => { - let { track } = tracker( - { kind: "file", fileId: "file-1" }, - (ids, verifier) => verifier === "old" ? allow(ids) : deny(ids), - ); - await track.addObserver("obs", "old"); - - await expect(track.addObserver("obs", "new")) - .rejects.toThrow(/cannot access Drive data this workspace has read/); - - expect((await track.prepareObservation(["file-2"])).excludeObservers).toBeUndefined(); + it("decodes historical bare keys as file observations", async () => { + let { kv, asked, track } = tracker({ kind: "account" }, allow); + kv.put(`${DRIVE_OBSERVATION_PREFIX}old%2Ffile`, "observed"); + await track.addObserver("obs", "verifier"); + expect(asked).toEqual([[{kind: "file", fileId: "old/file"}]]); }); - it("percent-encodes an ID that would otherwise collide with the key grammar", async () => { - let { kv, asked, track } = tracker({ kind: "file", fileId: "a:b/c" }, allow); - expect([...kv.entries.keys()]).toEqual([`${DRIVE_OBSERVATION_PREFIX}a%3Ab%2Fc`]); + it("percent-encodes IDs without colliding with the typed key grammar", async () => { + let { kv, asked, track } = tracker({ kind: "folder", folderId: "folder:a/b" }, allow); + expect([...kv.entries.keys()]).toEqual([ + `${DRIVE_OBSERVATION_PREFIX}folder:folder%3Aa%2Fb`, + ]); await track.addObserver("obs", "verifier"); - expect(asked).toEqual([["a:b/c"]]); + expect(asked).toEqual([[{kind: "folder", fileId: "folder:a/b"}]]); }); }); diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index fc5155a1be..01db899a06 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper"; -import { DriveSessionCore, driveFileToEntry, type DriveBindingScope } from "../src/drive-session"; -import { readFolderRoot } from "../src/drive-folder-scope"; +import type { DriveObservation } from "../src/drive-observers"; +import { + DriveFolderSessionCore, DriveSessionCore, driveFileToEntry, requireDriveBindingScope, +} from "../src/drive-session"; +import { readFolderRoot, type FolderLocation } from "../src/drive-folder-scope"; import { DriveApiRequestError, FOLDER_MIME_TYPE, type DriveFile, type DriveListFilesOptions, type DriveScopeNode, @@ -9,9 +12,6 @@ import { import type { ObserverCheck } from "../src/observers"; import { driveObserverTracker } from "../src/drive-observers"; import { FakeKv } from "./fake-kv"; -import type { DriveEntry } from "../src/drive-types"; - -const SHORTCUT_MIME_TYPE = "application/vnd.google-apps.shortcut"; const docMime = "application/vnd.google-apps.document"; const sheetMime = "application/vnd.google-apps.spreadsheet"; @@ -24,36 +24,35 @@ const file = (overrides: Partial = {}): DriveFile => ({ }); function core(overrides: { - scope?: DriveBindingScope; + scope?: { kind: "account" } | { kind: "file"; fileId: string }; files?: DriveFile[]; getFile?: (id: string) => Promise; - getDrive?: (id: string) => Promise<{ id: string; name: string }>; listFiles?: (options: DriveListFilesOptions) => Promise<{ files: DriveFile[]; nextPageToken?: string; }>; getScopeNodes?: (ids: readonly string[]) => Promise<(DriveScopeNode | undefined)[]>; - prepareObservation?: (ids: string[]) => Promise>; - prepareWithheld?: () => ObserverCheck; + prepareObservation?: ( + observations: DriveObservation[], + ) => Promise>; + prepareWithheld?: () => ObserverCheck; authorize?: (description: ObservationDescription) => Promise; } = {}) { let listFiles = vi.fn(overrides.listFiles ?? (async () => ({ files: overrides.files ?? [file()] }))); let getFile = vi.fn(overrides.getFile ?? (async (id: string) => file({ id }))); - let getDrive = vi.fn(overrides.getDrive ?? - (async (id: string) => ({ id, name: "Current shared drive" }))); let getScopeNodes = vi.fn(overrides.getScopeNodes ?? (async (ids: readonly string[]) => ids.map(() => undefined))); let prepared: string[][] = []; let authorizations: ObservationDescription[] = []; let events: string[] = []; let session = new DriveSessionCore({ - api: { listFiles, getFile, getDrive, getScopeNodes }, + api: { listFiles, getFile, getScopeNodes }, scope: overrides.scope ?? { kind: "account" }, - prepareObservation: overrides.prepareObservation ?? (async (ids: string[]) => { - prepared.push(ids); + prepareObservation: overrides.prepareObservation ?? (async observations => { + prepared.push(observations.map(observation => observation.fileId)); return { excludeObservers: ["excluded"], - pendingSets: ids, + pendingSets: observations, commit: () => events.push("commit"), }; }), @@ -69,13 +68,9 @@ function core(overrides: { await overrides.authorize?.(description); }, }); - return { - session, listFiles, getFile, getDrive, getScopeNodes, prepared, authorizations, events, - }; + return { session, listFiles, getFile, getScopeNodes, prepared, authorizations, events }; } -const FOLDER_ROOT = "folder-root"; - const folder = (id: string, overrides: Partial = {}): DriveFile => file({ id, name: id, mimeType: FOLDER_MIME_TYPE, trashed: false, capabilities: { canListChildren: true }, ...overrides }); @@ -113,19 +108,6 @@ function tree(nodes: DriveFile[]) { }; } -/** A folder-scoped core over `nodes`, which must include the root itself. */ -function folderCore(nodes: DriveFile[], overrides: Parameters[0] = {}) { - let provider = tree(nodes); - return { - ...core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: provider.getFile, - getScopeNodes: provider.getScopeNodes, - ...overrides, - }), - provider, - }; -} describe("Drive metadata mapping", () => { it("maps the complete declared metadata shape without provider-only fields", () => { @@ -166,6 +148,23 @@ describe("Drive metadata mapping", () => { }); }); +// Persisted props outlive a code deploy, so an unrecognized kind must refuse rather than fall +// through every narrow check and be served as the whole account. +describe("requireDriveBindingScope", () => { + it("refuses a binding scope from an older model", () => { + expect(() => requireDriveBindingScope({ kind: "sharedDrive", driveId: "drive-1" } as never)) + .toThrow(/predates the current folder resource/); + }); + + it("passes each supported scope through", () => { + for (const scope of [ + { kind: "account" }, { kind: "folder", folderId: "f" }, { kind: "file", fileId: "x" }, + ] as const) { + expect(requireDriveBindingScope(scope)).toBe(scope); + } + }); +}); + describe("Drive session scope", () => { it("lists the connected account and authorizes every returned file before committing", async () => { let { session, listFiles, prepared, authorizations, events } = core(); @@ -178,11 +177,8 @@ describe("Drive session scope", () => { expect(events).toEqual(["authorize", "commit"]); }); - it.each([ - ["account", { kind: "account" }], - ["shared drive", { kind: "sharedDrive", driveId: "drive-1" }], - ] as const)("audits and rejects an empty %s search", async (_label, scope) => { - let { session, prepared, authorizations, events } = core({ scope, files: [] }); + it("audits and rejects an empty account search", async () => { + let { session, prepared, authorizations, events } = core({ files: [] }); let cursor = await session.search({ namePrefix: "missing" }); await expect(cursor.next()).rejects @@ -212,6 +208,18 @@ describe("Drive session scope", () => { expect(events).toEqual(["authorize", "unlatch"]); }); + // An empty slice with pages still ahead is this call's budget running out, not a negative + // answer: fencing it would close collaborator admission for good over nothing disclosed. + it("keeps admission open when the page budget slices a listing", async () => { + let page = 0; + let { session, events } = core({ + listFiles: async () => ({ files: [], nextPageToken: `page-${++page}` }), + }); + + await expect((await session.search({ namePrefix: "missing" })).next()).resolves.toEqual([]); + expect(events).toEqual(["authorize", "commit"]); + }); + it("ends a search cleanly after an earlier page disclosed results", async () => { let { session, listFiles } = core({ listFiles: async options => options.pageToken === "page-2" @@ -225,92 +233,6 @@ describe("Drive session scope", () => { expect(listFiles).toHaveBeenCalledTimes(2); }); - it("pins shared-drive reads and drops a foreign result before observation", async () => { - let local = file({ id: "local", driveId: "drive-1" }); - let foreign = file({ id: "foreign", driveId: "drive-2" }); - let { session, listFiles, prepared } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - files: [local, foreign], - }); - - let page = await (await session.list()).next(); - expect(page?.map(entry => entry.id)).toEqual(["local"]); - expect(listFiles).toHaveBeenCalledWith(expect.objectContaining({ - corpus: { kind: "drive", driveId: "drive-1" }, - })); - expect(prepared).toEqual([["local"]]); - }); - - it("re-applies the shared-drive corpus pin on every page", async () => { - let { session, listFiles } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - listFiles: async options => options.pageToken === "page-2" - ? { files: [file({ id: "local-2", driveId: "drive-1" })] } - : { files: [file({ id: "local-1", driveId: "drive-1" })], nextPageToken: "page-2" }, - }); - - let cursor = await session.list(); - expect((await cursor.next())?.map(entry => entry.id)).toEqual(["local-1"]); - expect((await cursor.next())?.map(entry => entry.id)).toEqual(["local-2"]); - expect(listFiles).toHaveBeenNthCalledWith(1, expect.objectContaining({ - corpus: { kind: "drive", driveId: "drive-1" }, - })); - expect(listFiles).toHaveBeenNthCalledWith(2, expect.objectContaining({ - corpus: { kind: "drive", driveId: "drive-1" }, - pageToken: "page-2", - })); - }); - - it("refuses a direct lookup outside a shared drive before authorizing it", async () => { - let { session, prepared, authorizations } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async id => file({ id, driveId: "drive-2" }), - }); - - await expect(session.getEntry("foreign")).rejects.toThrow(/outside this Drive binding/); - expect(prepared).toEqual([]); - expect(authorizations).toEqual([]); - }); - - it.each([403, 404])( - "does not reveal whether the account can read a shared-drive probe rejected with %d", - async status => { - let { session, prepared } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async () => { throw new DriveApiRequestError(status); }, - }); - - let outside = new Error("The requested file is outside this Drive binding."); - await expect(session.getEntry("foreign")).rejects.toThrow(outside); - await expect(session.list({ directParentId: "foreign" })).rejects.toThrow(outside); - expect(prepared).toEqual([]); - }, - ); - - it("preserves a shared-drive provider outage", async () => { - let { session } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async () => { throw new DriveApiRequestError(500); }, - }); - - await expect(session.getEntry("file-1")).rejects - .toThrow("Google Drive API request failed: 500"); - }); - - it.each([ - "dailyLimitExceeded", - "rateLimitExceeded", - "userRateLimitExceeded", - ])("preserves a shared-drive quota failure reported as %s", async reason => { - let error = new DriveApiRequestError(403, reason); - let { session } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async () => { throw error; }, - }); - - await expect(session.getEntry("file-1")).rejects.toThrow(error); - }); - it("refuses another file ID without calling Google for a file-scoped binding", async () => { let { session, getFile } = core({ scope: { kind: "file", fileId: "file-1" } }); await expect(session.getEntry("file-2")).rejects.toThrow(/outside this Drive binding/); @@ -370,29 +292,6 @@ describe("Drive session scope", () => { expect(listFiles).not.toHaveBeenCalled(); }); - it("reads current shared-drive scope metadata and observes its root ID", async () => { - let { session, getDrive, prepared } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - }); - await expect(session.getScope()).resolves.toEqual({ - kind: "sharedDrive", driveId: "drive-1", name: "Current shared drive", - }); - expect(getDrive).toHaveBeenCalledWith("drive-1"); - expect(prepared).toEqual([["drive-1"]]); - }); - - it("refuses a shared-drive scope read when the provider returns another drive", async () => { - let { session, getDrive, prepared } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getDrive: async () => ({ id: "drive-other", name: "Spoofed name" }), - }); - - await expect(session.getScope()).rejects.toThrow(/outside this Drive binding/); - expect(getDrive).toHaveBeenCalledTimes(1); - expect(getDrive).toHaveBeenCalledWith("drive-1"); - expect(prepared).toEqual([]); - }); - it("refuses a file scope read when the provider returns another file", async () => { let { session, getFile, prepared } = core({ scope: { kind: "file", fileId: "file-1" }, @@ -403,130 +302,13 @@ describe("Drive session scope", () => { expect(getFile).toHaveBeenCalledWith("file-1"); expect(prepared).toEqual([]); }); - - it("treats the shared-drive root id as in scope", async () => { - let { session, prepared } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - files: [file({ id: "drive-1", name: "Drive root", mimeType: FOLDER_MIME_TYPE })], - }); - - let page = await (await session.list()).next(); - expect(page?.map(entry => entry.id)).toEqual(["drive-1"]); - expect(prepared).toEqual([["drive-1"]]); - }); - - it("drops a My Drive file when the provider ignores the shared-drive corpus", async () => { - let { session, prepared, authorizations } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - files: [file({ id: "mydrive-file" })], - }); - - await expect((await session.list()).next()).resolves.toBeNull(); - expect(prepared).toEqual([[]]); - expect(authorizations).toHaveLength(1); - }); }); -describe("Drive parent folder probe", () => { - it("rejects a parent from another shared drive before listing", async () => { - let { session, listFiles, getFile, prepared, authorizations } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async id => file({ id, driveId: "drive-2", mimeType: FOLDER_MIME_TYPE }), - }); - - await expect(session.list({ directParentId: "folder-x" })) - .rejects.toThrow(/outside this Drive binding/); - expect(getFile).toHaveBeenCalledWith("folder-x"); - expect(listFiles).not.toHaveBeenCalled(); - expect(prepared).toEqual([]); - expect(authorizations).toEqual([]); - }); - - it("observes a readable non-folder parent before disclosing its type", async () => { - let { session, listFiles, getFile, prepared, authorizations, events } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async id => file({ id, driveId: "drive-1", mimeType: "application/pdf" }), - }); - - await expect(session.list({ directParentId: "file-x" })) - .rejects.toThrow(/must identify a folder/); - expect(getFile).toHaveBeenCalledWith("file-x"); - expect(listFiles).not.toHaveBeenCalled(); - expect(prepared).toEqual([["file-x"]]); - expect(authorizations).toEqual([expect.objectContaining({ - title: "Check Google Drive folder", - excludeObservers: ["excluded"], - })]); - expect(events).toEqual(["authorize", "commit"]); - }); - - it("rejects a metadata-only parent before listing", async () => { - let { session, listFiles } = core({ - getFile: async id => folder(id, { capabilities: { canListChildren: false } }), - }); - - await expect(session.list({ directParentId: "folder-x" })) - .rejects.toThrow(/children can be listed/); - expect(listFiles).not.toHaveBeenCalled(); - }); - - it("does not disclose a readable non-folder parent when observation is denied", async () => { - let { session, listFiles, prepared, authorizations, events } = core({ - getFile: async id => file({ id, mimeType: "application/pdf" }), - authorize: async () => { throw new Error("denied"); }, - }); - - await expect(session.list({ directParentId: "file-x" })).rejects.toThrow("denied"); - expect(listFiles).not.toHaveBeenCalled(); - expect(prepared).toEqual([["file-x"]]); - expect(authorizations).toHaveLength(1); - expect(events).toEqual(["authorize"]); - }); - - it("rejects a parent probe on a file-scoped binding without calling Google", async () => { - let { session, getFile, listFiles } = core({ scope: { kind: "file", fileId: "file-1" } }); - - await expect(session.list({ directParentId: "folder-x" })) - .rejects.toThrow(/outside this Drive binding/); - expect(getFile).not.toHaveBeenCalled(); - expect(listFiles).not.toHaveBeenCalled(); - }); - - it("observes the parent-folder probe before listing its children", async () => { - let { session, authorizations, events } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - files: [file({ id: "child-1", driveId: "drive-1", parents: ["folder-1"] })], - getFile: async id => folder(id, { driveId: "drive-1" }), - }); - - await (await session.list({ directParentId: "folder-1" })).next(); - expect(authorizations[0].title).toBe("Check Google Drive folder"); - expect(authorizations[1].title).toBe("Read Google Drive metadata"); - expect(events).toEqual(["authorize", "commit", "authorize", "commit"]); - }); - - it("rejects search when the parent is outside the shared drive", async () => { - let { session, listFiles, prepared, authorizations } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async id => file({ id, driveId: "drive-2", mimeType: FOLDER_MIME_TYPE }), - }); - - await expect(session.search({ directParentId: "folder-x" })) - .rejects.toThrow(/outside this Drive binding/); - expect(listFiles).not.toHaveBeenCalled(); - expect(prepared).toEqual([]); - expect(authorizations).toEqual([]); - }); -}); describe("Drive native sessions", () => { it.each([ ["account Doc", { kind: "account" } as const, docMime, "Google Doc"], ["account Sheet", { kind: "account" } as const, sheetMime, "Google Sheet"], - ["shared-drive Doc", { kind: "sharedDrive", driveId: "drive-1" } as const, - docMime, "Google Doc"], - ["shared-drive Sheet", { kind: "sharedDrive", driveId: "drive-1" } as const, - sheetMime, "Google Sheet"], ["exact-file Doc", { kind: "file", fileId: "file-1" } as const, docMime, "Google Doc"], ["exact-file Sheet", { kind: "file", fileId: "file-1" } as const, @@ -534,11 +316,7 @@ describe("Drive native sessions", () => { ])("opens an in-scope native %s", async (_name, scope, mimeType, description) => { let { session, getFile } = core({ scope, - getFile: async id => file({ - id, - mimeType, - ...(scope.kind === "sharedDrive" ? { driveId: scope.driveId } : {}), - }), + getFile: async id => file({ id, mimeType }), }); await expect(session.openNativeFile("file-1", mimeType, description)) @@ -588,7 +366,6 @@ describe("Drive native sessions", () => { api: { listFiles: async () => ({ files: [] }), getFile: async () => { throw new DriveApiRequestError(404); }, - getDrive: async (id: string) => ({ id, name: "Current shared drive" }), getScopeNodes: async ids => ids.map(() => undefined), }, scope: { kind: "account" }, @@ -613,32 +390,6 @@ describe("Drive native sessions", () => { expect(getFile).not.toHaveBeenCalled(); }); - it("rejects a foreign shared-drive file without authorizing or tracking it", async () => { - let { session, prepared, authorizations } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async id => file({ id, driveId: "drive-2", mimeType: docMime }), - }); - - await expect(session.openNativeFile("foreign", docMime, "Google Doc")) - .rejects.toThrow(/outside this Drive binding/); - expect(prepared).toEqual([]); - expect(authorizations).toEqual([]); - }); - - it.each([403, 404])( - "normalizes a %s shared-drive probe failure without authorizing or tracking it", - async status => { - let { session, prepared, authorizations } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - getFile: async () => { throw new DriveApiRequestError(status); }, - }); - - await expect(session.openNativeFile("foreign", docMime, "Google Doc")) - .rejects.toThrow(new Error("The requested file is outside this Drive binding.")); - expect(prepared).toEqual([]); - expect(authorizations).toEqual([]); - }, - ); it.each([ ["wrong native type", sheetMime, undefined], ["folder", "application/vnd.google-apps.folder", undefined], @@ -717,14 +468,11 @@ describe("Drive search validation", () => { }); it("uses Drive relevance order only for full-text search", async () => { - let { session, listFiles } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - files: [file({ id: "local", driveId: "drive-1" })], - }); + let { session, listFiles } = core({ files: [file({ id: "local" })] }); await (await session.search({ fullTextContains: "budget" })).next(); expect(listFiles).toHaveBeenCalledWith(expect.objectContaining({ orderBy: null, - corpus: { kind: "drive", driveId: "drive-1" }, + corpus: { kind: "user" }, })); }); @@ -749,17 +497,14 @@ describe("Drive observation authorization", () => { it("includes the binding scope and a truncated query in the description", async () => { let longText = "salary-review-".repeat(8); - let { session, authorizations } = core({ - scope: { kind: "sharedDrive", driveId: "drive-1" }, - files: [file({ id: "local", driveId: "drive-1" })], - }); + let { session, authorizations } = core({ files: [file({ id: "local" })] }); await (await session.search({ namePrefix: "plan", fullTextContains: longText })).next(); let observation = authorizations[0]; expect(observation.title).toBe("Read Google Drive metadata"); expect(observation.title).not.toContain(longText); expect(observation.title).not.toContain("plan"); - expect(observation.description).toContain("shared drive drive-1"); + expect(observation.description).toContain("the connected Drive account"); expect(observation.description).toContain('name starts with "plan"'); expect(observation.description).toContain("salary-review-"); expect(observation.description).not.toContain(longText); @@ -767,613 +512,127 @@ describe("Drive observation authorization", () => { }); }); -// Drive has no folder corpus and no recursive ancestor predicate, so every one of these outcomes -// is decided by the gatekeeper's own `parents` walk rather than by anything the provider enforces. -describe("Drive folder scope", () => { - const root = folder(FOLDER_ROOT, { parents: ["outside-folder"] }); - - describe("membership", () => { - it.each([ - ["the root itself", FOLDER_ROOT, [root]], - ["a direct child", "kid", [root, child("kid", FOLDER_ROOT)]], - ["a deep descendant", "deep", - [root, folder("mid", { parents: [FOLDER_ROOT] }), child("deep", "mid")]], - ["a subfolder inside a shared drive", "kid", [ - folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" }), - child("kid", FOLDER_ROOT, { driveId: "drive-1" }), - ]], - ])("admits %s", async (_label, fileId, nodes) => { - let { session } = folderCore(nodes); - expect((await session.getEntry(fileId)).id).toBe(fileId); - }); - - it.each([ - ["a sibling of the root", "sibling", [root, child("sibling", "outside-folder")]], - ["the root's own parent", "outside-folder", - [root, folder("outside-folder", { parents: ["grandparent"] })]], - ["a file whose parent is unreadable", "orphan", [root, child("orphan", "hidden")]], - ["a file with no parents at all", "loose", [root, file({ id: "loose", trashed: false })]], - ["a file with an empty parent array", "loose", - [root, file({ id: "loose", parents: [], trashed: false })]], - // Drive gives a file one current parent; anything else is a shape this cannot decide. - ["a file claiming two parents", "shared", - [root, file({ id: "shared", parents: [FOLDER_ROOT, "elsewhere"], trashed: false })]], - ["a trashed descendant", "gone", - [root, child("gone", FOLDER_ROOT, { trashed: true })]], - ["a descendant behind a trashed folder", "deep", - [root, folder("mid", { parents: [FOLDER_ROOT], trashed: true }), child("deep", "mid")]], - // A shortcut is a file of its own; it is listed, never followed, and cannot carry a chain. - ["a descendant behind a shortcut", "deep", [ - root, - file({ id: "link", mimeType: SHORTCUT_MIME_TYPE, parents: [FOLDER_ROOT], trashed: false }), - child("deep", "link"), - ]], - ["a chain that cycles before reaching the root", "deep", [ - root, - folder("a", { parents: ["b"] }), - folder("b", { parents: ["a"] }), - child("deep", "a"), - ]], - ])("refuses %s", async (_label, fileId, nodes) => { - let { session } = folderCore(nodes); - await expect(session.getEntry(fileId)) - .rejects.toThrow("The requested file is outside this Drive binding."); - }); - - it("closes observer admission after a failed ancestry probe", async () => { - let track = driveObserverTracker(new FakeKv(), - { kind: "folder", folderId: FOLDER_ROOT }, async (_verifier, fileIds) => ({ - baselineAllowed: true, allowed: fileIds.map(() => true), - })); - let { session } = folderCore([root, child("sibling", "outside-folder")], { - prepareObservation: fileIds => track.prepareObservation(fileIds), - prepareWithheld: () => track.prepareWithheld(), - }); - - await expect(session.getEntry("sibling")) - .rejects.toThrow("The requested file is outside this Drive binding."); - await expect(track.addObserver("late", "verifier")) - .rejects.toThrow(/can no longer be observed/); - }); - - // Both storage domains cap nesting at 100 levels, so a chain longer than that never terminates - // at a legal root and must be abandoned rather than walked forever. - it("refuses a chain deeper than Drive's own nesting limit", async () => { - let chain = Array.from({ length: 120 }, - (_, index) => folder(`n${index}`, { parents: [index === 0 ? FOLDER_ROOT : `n${index - 1}`] })); - let { session } = folderCore([root, ...chain, child("deep", "n119")]); - - await expect(session.getEntry("deep")) - .rejects.toThrow("The requested file is outside this Drive binding."); - }); - - it("admits a descendant at the deepest legal nesting", async () => { - let chain = Array.from({ length: 98 }, - (_, index) => folder(`n${index}`, { parents: [index === 0 ? FOLDER_ROOT : `n${index - 1}`] })); - let { session } = folderCore([root, ...chain, child("deep", "n97")]); - - expect((await session.getEntry("deep")).id).toBe("deep"); - }); - - // Membership is same-domain by construction: a chain that crosses between My Drive and a shared - // drive is walking through a hierarchy the binding's corpus never covered. - it("refuses a descendant whose chain changes storage domain", async () => { - let { session } = folderCore([ - root, - folder("mid", { parents: [FOLDER_ROOT], driveId: "drive-1" }), - child("deep", "mid", { driveId: "drive-1" }), - ]); - - await expect(session.getEntry("deep")) - .rejects.toThrow("The requested file is outside this Drive binding."); - }); - - // The walk reads one ancestor level per round trip, so a move landing mid-walk leaves the - // chain that would authorize the read already stale. Both direct operations go through the - // same proof, and neither may disclose or audit anything off it. - it.each([ - ["getEntry", (session: DriveSessionCore) => session.getEntry("deep")], - ["openNativeFile", - (session: DriveSessionCore) => session.openNativeFile("deep", docMime, "Google Doc")], - ])("refuses %s when the chain changed during the ancestry walk", async (_label, operate) => { - let provider = tree([ - root, folder("mid", { parents: [FOLDER_ROOT] }), child("deep", "mid", { mimeType: docMime }), - ]); - let walked = false; - let { session, authorizations } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: provider.getFile, - getScopeNodes: async ids => { - let nodes = await provider.getScopeNodes(ids); - // "mid" leaves the subtree right after the walk read it, before the recheck re-reads it. - if (!walked) { - walked = true; - provider.byId.set("mid", folder("mid", { parents: ["elsewhere"] })); - } - return nodes; - }, - }); - - await expect(operate(session)) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(authorizations).toEqual([]); - }); - }); - - describe("root validation", () => { - const badRoots: [string, DriveFile][] = [ - ["a root that is not a folder", file({ id: FOLDER_ROOT, trashed: false })], - ["a shortcut standing in for the root", - file({ id: FOLDER_ROOT, mimeType: SHORTCUT_MIME_TYPE, trashed: false })], - ["a trashed root", folder(FOLDER_ROOT, { trashed: true })], - // A shared drive's root carries the drive's own ID and is the Shared Drive resource. - ["a shared drive's own root", folder(FOLDER_ROOT, { driveId: FOLDER_ROOT })], - ["a metadata-only folder", { - ...folder(FOLDER_ROOT), capabilities: { canListChildren: false }, - } as DriveFile], - // The provider answering for another file would decide membership from the wrong facts. - ["a root the provider echoes as another file", folder("someone-else")], - ]; - - it.each(badRoots)("refuses %s", async (_label, node) => { - let { session } = folderCore([node]); - await expect(session.getScope()) - .rejects.toThrow("The requested file is outside this Drive binding."); - }); - - // `describe()` runs before any session exists, so both entry points share one validator rather - // than letting a hand-built resource URL mint a presentable binding that refuses every call. - it.each(badRoots)("refuses %s through the validator describe() shares", async (_label, node) => { - await expect(readFolderRoot(FOLDER_ROOT, async () => node)) - .rejects.toThrow("The requested file is outside this Drive binding."); - }); - - // The alias resolves per account, so it names no stable authority to confine anything to. - it("refuses the account-relative alias at both entry points, contacting Drive at neither", - async () => { - let { session, getFile } = core({ scope: { kind: "folder", folderId: "root" } }); - await expect(session.getScope()) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(getFile).not.toHaveBeenCalled(); - - let fetch = vi.fn(); - await expect(readFolderRoot("root", fetch)) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(fetch).not.toHaveBeenCalled(); - }); - - it("reports the folder's current name against its immutable ID", async () => { - let { session } = folderCore([folder(FOLDER_ROOT, { name: "Renamed", parents: ["above"] })]); - - expect(await session.getScope()) - .toEqual({ kind: "folder", folderId: FOLDER_ROOT, name: "Renamed" }); - }); - - // The folder above the binding is outside it; naming it would disclose one level of hierarchy - // the grant never covered. - it("withholds the root's own parent", async () => { - let { session } = folderCore([root, child("kid", FOLDER_ROOT)]); - - expect(await session.getEntry(FOLDER_ROOT)).not.toHaveProperty("parentId"); - expect(await session.getEntry("kid")).toMatchObject({ parentId: FOLDER_ROOT }); - }); - }); - - describe("listing", () => { - const page = (files: DriveFile[], nextPageToken?: string) => - ({ files, ...(nextPageToken ? { nextPageToken } : {}) }); - - it("selects the corpus the root lives in and asks for a bounded page", async () => { - let nodes = [ - folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" }), - child("kid", FOLDER_ROOT, { driveId: "drive-1" }), - ]; - let { session, listFiles } = folderCore(nodes, { - listFiles: async () => page([nodes[1]]), - }); - - await (await session.list()).next(); - expect(listFiles).toHaveBeenCalledWith(expect.objectContaining({ - corpus: { kind: "drive", driveId: "drive-1" }, pageSize: 100, - })); - }); - - it("uses the user corpus for a folder in My Drive", async () => { - let { session, listFiles } = folderCore([root, child("kid", FOLDER_ROOT)], { - listFiles: async () => page([child("kid", FOLDER_ROOT)]), - }); - - await (await session.list()).next(); - expect(listFiles).toHaveBeenCalledWith(expect.objectContaining({ corpus: { kind: "user" } })); - }); - - it("returns only proven descendants, in the order the provider gave them", async () => { - let nodes = [ - root, - folder("mid", { parents: [FOLDER_ROOT] }), - child("deep", "mid"), - child("kid", FOLDER_ROOT), - child("sibling", "outside-folder"), - ]; - let { session } = folderCore(nodes, { - listFiles: async () => page([nodes[4], nodes[2], nodes[3]]), - }); - - expect((await (await session.list()).next())?.map(entry => entry.id)) - .toEqual(["deep", "kid"]); - }); - - // The corpus scan returns the bound folder like any other row, and it proves as a member so - // `getEntry` can read it. No test had ever put it in a provider page, so a listing disclosed - // the folder as one of its own children and inflated every count by one. - it("omits the bound folder from its own listing", async () => { - let nodes = [root, folder("mid", { parents: [FOLDER_ROOT] }), child("kid", FOLDER_ROOT)]; - let { session } = folderCore(nodes, { listFiles: async () => page(nodes) }); - - expect((await (await session.list()).next())?.map(entry => entry.id)) - .toEqual(["mid", "kid"]); - }); - - it("omits the bound folder from a search that matches folders", async () => { - let nodes = [root, folder("mid", { parents: [FOLDER_ROOT] })]; - let { session } = folderCore(nodes, { listFiles: async () => page(nodes) }); - - let cursor = await session.search({ mimeTypes: [FOLDER_MIME_TYPE] }); - expect((await cursor.next())?.map(entry => entry.id)).toEqual(["mid"]); - }); - - // The counterpart: excluding it from listings must not make the bound folder unreadable, and - // its own parent stays withheld because that folder is outside the binding. - it("still reads the bound folder's own metadata through getEntry", async () => { - let { session } = folderCore([root]); - - let entry = await session.getEntry(FOLDER_ROOT); - expect(entry.id).toBe(FOLDER_ROOT); - expect(entry.parentId).toBeUndefined(); - }); - - // The whole page filtering out is not a negative answer: one provider page per call is the - // subrequest budget, and the results are on the next one. - it("yields an empty page while results remain, then the results, then null", async () => { - let nodes = [root, child("kid", FOLDER_ROOT), child("sibling", "outside-folder")]; - let { session } = folderCore(nodes, { - listFiles: async ({ pageToken }) => - pageToken === "p2" ? page([nodes[1]]) : page([nodes[2]], "p2"), - }); - - let cursor = await session.list(); - expect(await cursor.next()).toEqual([]); - expect((await cursor.next())?.map(entry => entry.id)).toEqual(["kid"]); - expect(await cursor.next()).toBeNull(); - }); - it("closes observer admission before returning filtered cursor progress", async () => { - let nodes = [root, child("kid", FOLDER_ROOT), child("sibling", "outside-folder")]; - let track = driveObserverTracker(new FakeKv(), - { kind: "folder", folderId: FOLDER_ROOT }, async (_verifier, fileIds) => ({ - baselineAllowed: true, allowed: fileIds.map(() => true), - })); - let { session } = folderCore(nodes, { - listFiles: async ({ pageToken }) => - pageToken === "p2" ? page([nodes[1]]) : page([nodes[2]], "p2"), - prepareObservation: fileIds => track.prepareObservation(fileIds), - prepareWithheld: () => track.prepareWithheld(), - }); - - let cursor = await session.list(); - expect(await cursor.next()).toEqual([]); - await expect(track.addObserver("late", "verifier")) - .rejects.toThrow(/can no longer be observed/); - }); - - // The terminal one is a real answer about the folder, so it still gets a record. - it("audits a listing that ends with nothing in scope", async () => { - let sibling = child("sibling", "outside-folder"); - let { session, authorizations } = folderCore([root, sibling], { - listFiles: async () => page([sibling]), - }); - - expect(await (await session.list()).next()).toBeNull(); - expect(authorizations).toHaveLength(1); - expect(authorizations[0].description).toContain("for 0 Drive"); - }); - - // The withheld latch is permanent, so it must not fire on an emptiness the root's own - // disappearance manufactured. - it("refuses rather than latching when the root vanished during an empty search", async () => { - let provider = tree([root]); - let { session, events, authorizations } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: provider.getFile, - getScopeNodes: provider.getScopeNodes, - listFiles: async () => { - provider.byId.set(FOLDER_ROOT, folder(FOLDER_ROOT, { trashed: true })); - return page([]); - }, - }); - - await expect((await session.search({ namePrefix: "anything" })).next()) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(events).not.toContain("latch"); - expect(authorizations).toEqual([]); - }); - - // A root that changed drive is still a valid root, so only the pinned corpus catches it — and - // the negative result was computed against the corpus the folder has left. - it("refuses rather than latching when the root changed drive during an empty search", async () => { - let provider = tree([root]); - let { session, events, authorizations } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: provider.getFile, - getScopeNodes: provider.getScopeNodes, - listFiles: async () => { - provider.byId.set(FOLDER_ROOT, - folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" })); - return page([]); - }, - }); - - await expect((await session.search({ namePrefix: "anything" })).next()) - .rejects.toThrow("moved to another drive"); - expect(events).not.toContain("latch"); - expect(authorizations).toEqual([]); - }); - - it("revalidates a direct parent before fetching each page", async () => { - let parent = folder("parent", { parents: [FOLDER_ROOT] }); - let listFiles = vi.fn(async () => page([])); - let { session, provider, events } = folderCore([root, parent], { listFiles }); - let cursor = await session.search({ directParentId: "parent" }); - provider.byId.set("parent", folder("parent", { parents: ["outside-folder"] })); - - await expect(cursor.next()) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(listFiles).not.toHaveBeenCalled(); - expect(events).toEqual(["authorize", "commit"]); - }); - - // A page token is only valid against the corpus that produced it, so a root that changes - // domain mid-pagination has nowhere safe to resume. - it("aborts a cursor whose root moved to another drive", async () => { - let current = folder(FOLDER_ROOT, { parents: ["above"] }); - let provider = tree([current, child("kid", FOLDER_ROOT)]); - let { session } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, +describe("positioned Drive folder session", () => { + const root = folder("R", { parents: ["above"] }); + const nested = folder("A", { parents: ["R"] }); + const directDoc = child("D0", "R", { mimeType: docMime }); + const nestedDoc = child("D1", "A", { mimeType: docMime }); + const foreignDoc = child("X", "U", { mimeType: docMime }); + + function positioned( + nodes: DriveFile[], + location: FolderLocation = { rootId: "R", folderIds: ["R"] }, + listFiles?: (query: DriveListFilesOptions) => Promise<{ + files: DriveFile[]; + nextPageToken?: string; + }>, + ) { + const provider = tree(nodes); + const queries: DriveListFilesOptions[] = []; + const observations: DriveObservation[][] = []; + const authorizations: ObservationDescription[] = []; + const events: string[] = []; + const session = new DriveFolderSessionCore({ + api: { getFile: provider.getFile, getScopeNodes: provider.getScopeNodes, - listFiles: async () => page([child("kid", FOLDER_ROOT)], "p2"), - }); - - let cursor = await session.list(); - expect((await cursor.next())?.map(entry => entry.id)).toEqual(["kid"]); - provider.byId.set(FOLDER_ROOT, - folder(FOLDER_ROOT, { parents: ["drive-1"], driveId: "drive-1" })); - - await expect(cursor.next()).rejects.toThrow(/moved to another drive/); - }); - - // The earliest hops of a page's proof are the stalest thing authorizing its disclosure, so the - // recheck immediately before disclosure is what catches a move that landed during the walk. - it("discards a page whose chain changed under it, without advancing the cursor", async () => { - let provider = tree([root, folder("mid", { parents: [FOLDER_ROOT] }), child("deep", "mid")]); - let calls = 0; - let requested: (string | undefined)[] = []; - let { session, authorizations } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: provider.getFile, - getScopeNodes: async ids => { - // The walk resolves "mid" first; the recheck re-reads the whole path afterwards. - if (++calls === 2) provider.byId.set("mid", folder("mid", { parents: ["elsewhere"] })); - return provider.getScopeNodes(ids); + listFiles: async options => { + let query = options ?? {}; + queries.push(query); + if (listFiles) return listFiles(query); + return { + files: nodes.filter(node => + node.trashed === false && node.parents?.length === 1 && + node.parents[0] === query.directParentId && node.mimeType !== FOLDER_MIME_TYPE), + }; }, - listFiles: async ({ pageToken }) => { - requested.push(pageToken); - return page([child("deep", "mid")], "p2"); - }, - }); - - let cursor = await session.list(); - await expect(cursor.next()) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(authorizations).toEqual([]); - - provider.byId.set("mid", folder("mid", { parents: [FOLDER_ROOT] })); - expect((await cursor.next())?.map(entry => entry.id)).toEqual(["deep"]); - expect(requested).toEqual([undefined, undefined]); + }, + location, + prepareObservation: async units => { + observations.push([...units]); + return { pendingSets: units, commit: () => events.push("commit") }; + }, + prepareWithheld: () => ({ pendingSets: [], commit: () => events.push("latch") }), + authorize: async description => { + authorizations.push(description); + events.push("authorize"); + }, }); + return { session, provider, queries, observations, authorizations, events }; + } - it("names the folder and its descendants in the observation", async () => { - let { session, authorizations } = folderCore([root, child("kid", FOLDER_ROOT)], { - listFiles: async () => page([child("kid", FOLDER_ROOT)]), - }); + it("lists and searches only the positioned folder's direct children", async () => { + const { session, queries } = positioned([root, nested, directDoc, nestedDoc, foreignDoc]); - await (await session.list()).next(); - expect(authorizations[0].description) - .toContain(`folder ${FOLDER_ROOT} and its descendants`); - }); + await expect((await session.list()).next()).resolves.toEqual([ + expect.objectContaining({ id: "D0", parentId: "R" }), + ]); + await expect((await session.search({ fullTextContains: "invoice" })).next()) + .resolves.toEqual([expect.objectContaining({ id: "D0" })]); + expect(queries).toEqual([ + expect.objectContaining({ directParentId: "R" }), + expect.objectContaining({ directParentId: "R", fullTextContains: "invoice" }), + ]); }); - describe("native reads", () => { - const nativeDoc = (parent: string) => - child("doc-1", parent, { mimeType: docMime }); - - it("opens a native descendant and refuses one outside the subtree", async () => { - let inside = folderCore([root, nativeDoc(FOLDER_ROOT)]); - await expect(inside.session.openNativeFile("doc-1", docMime, "Google Doc")) - .resolves.toBe("doc-1"); + it("navigates one checked child at a time", async () => { + const { session } = positioned([root, nested, directDoc, nestedDoc, foreignDoc]); - let outside = folderCore([root, nativeDoc("outside-folder")]); - await expect(outside.session.openNativeFile("doc-1", docMime, "Google Doc")) - .rejects.toThrow("The requested file is outside this Drive binding."); - }); - - it("proves the file before the provider is contacted at all", async () => { - let { session } = folderCore([root, nativeDoc("outside-folder")]); - let fetched = vi.fn(async () => "content"); - - await expect(session.nativeRead("doc-1", docMime)(fetched, () => ({ - title: "Read Google Doc content", description: "Read the body.", - }))).rejects.toThrow("The requested file is outside this Drive binding."); - expect(fetched).not.toHaveBeenCalled(); - }); - - it("refuses a file whose native type no longer matches", async () => { - let { session } = folderCore([root, child("doc-1", FOLDER_ROOT, { mimeType: "application/pdf" })]); - let fetched = vi.fn(async () => "content"); - - await expect(session.nativeRead("doc-1", docMime)(fetched, () => ({ - title: "Read Google Doc content", description: "Read the body.", - }))).rejects.toThrow("The requested file is outside this Drive binding."); - expect(fetched).not.toHaveBeenCalled(); - }); - - // The move lands while the Docs API call is in flight, so only a check after the read catches - // it — and the content must not be authorized, let alone returned. - it("discards content when the file left the subtree during the read", async () => { - let provider = tree([root, nativeDoc(FOLDER_ROOT)]); - let { session, authorizations } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: provider.getFile, - getScopeNodes: provider.getScopeNodes, - }); - - await expect(session.nativeRead("doc-1", docMime)(async () => { - provider.byId.set("doc-1", nativeDoc("outside-folder")); - return "secret"; - }, () => ({ title: "Read Google Doc content", description: "Read the body." }))) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(authorizations).toEqual([]); - }); - - it("discards content when the root loses child-list access during the read", async () => { - let provider = tree([root, nativeDoc(FOLDER_ROOT)]); - let { session, authorizations } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: provider.getFile, - getScopeNodes: provider.getScopeNodes, - }); - - await expect(session.nativeRead("doc-1", docMime)(async () => { - provider.byId.set(FOLDER_ROOT, { - ...root, capabilities: { canListChildren: false }, - }); - return "secret"; - }, () => ({ title: "Read Google Doc content", description: "Read the body." }))) - .rejects.toThrow("The requested file is outside this Drive binding."); - expect(authorizations).toEqual([]); - }); - - it("authorizes and returns content that survived both checks", async () => { - let { session, authorizations, prepared, events } = - folderCore([root, nativeDoc(FOLDER_ROOT)]); - - await expect(session.nativeRead("doc-1", docMime)(async () => "body", () => ({ - title: "Read Google Doc content", description: "Read the body.", - }))).resolves.toBe("body"); - expect(prepared).toEqual([["doc-1"]]); - expect(authorizations).toEqual([expect.objectContaining({ - title: "Read Google Doc content", excludeObservers: ["excluded"], - })]); - expect(events).toEqual(["authorize", "commit"]); - }); + await expect(session.getEntry("D1")).rejects.toThrow(/outside this Drive binding/); + await expect(session.openNativeFile("D1", docMime, "Google Doc")) + .rejects.toThrow(/outside this Drive binding/); + const location = await session.openFolder("A"); + const childSession = positioned([root, nested, directDoc, nestedDoc, foreignDoc], location).session; + await expect(childSession.openNativeFile("D1", docMime, "Google Doc")).resolves.toBe("D1"); + await expect(childSession.getEntry("X")).rejects.toThrow(/outside this Drive binding/); + }); - // An immutable scope cannot move under the session, so it pays for no revalidation. - it("makes no scope calls for a binding whose scope cannot change", async () => { - let { session, getFile, getScopeNodes } = core({ scope: { kind: "file", fileId: "doc-1" } }); + it("invalidates a saved path when one edge changes", async () => { + const nodes = [root, nested, nestedDoc]; + const { session, provider } = positioned(nodes); + const location = await session.openFolder("A"); + const childSession = positioned(nodes, location); + provider.byId.set("A", folder("A", { parents: ["elsewhere"] })); + childSession.provider.byId.set("A", folder("A", { parents: ["elsewhere"] })); - await expect(session.nativeRead("doc-1", docMime)(async () => "body", () => ({ - title: "Read Google Doc content", description: "Read the body.", - }))).resolves.toBe("body"); - expect(getFile).not.toHaveBeenCalled(); - expect(getScopeNodes).not.toHaveBeenCalled(); - }); + await expect(childSession.session.getScope()).rejects.toThrow(/outside this Drive binding/); }); - describe("failure modes", () => { - // A quota, outage, or account-wide block reported as a scope denial would look like the file - // left the folder, and the caller would go looking for a move that never happened. - it.each([ - ["a quota refusal", new DriveApiRequestError(403, "userRateLimitExceeded")], - // The root read happens on every folder operation, so this is the one users would hit. - ["an account-wide policy block", new DriveApiRequestError(403, "domainPolicy")], - ["a server error", new DriveApiRequestError(500)], - ])("surfaces %s rather than a scope denial", async (_label, error) => { - let { session } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: async () => { throw error; }, - }); - - await expect(session.getEntry("kid")).rejects.toBe(error); - }); + it("accepts a listable shared-drive root through the folder validator", async () => { + const sharedRoot = folder("drive-1", { driveId: "drive-1", parents: undefined }); + await expect(readFolderRoot("drive-1", async () => sharedRoot)).resolves.toBe(sharedRoot); + }); - it("turns an inaccessible root into the generic refusal", async () => { - let { session } = core({ - scope: { kind: "folder", folderId: FOLDER_ROOT }, - getFile: async () => { throw new DriveApiRequestError(404); }, - }); + it("rejects a trashed direct child", async () => { + const trashed = child("T", "R", { mimeType: docMime, trashed: true }); + const { session } = positioned([root, trashed]); - await expect(session.getEntry("kid")) - .rejects.toThrow("The requested file is outside this Drive binding."); - }); + await expect(session.getEntry("T")).rejects.toThrow(/outside this Drive binding/); + await expect(session.openNativeFile("T", docMime, "Google Doc")) + .rejects.toThrow(/outside this Drive binding/); + }); - // Hidden ancestors and rejected neighbours are enforcement input, never disclosure: neither may - // consume observer cardinality or appear in anything the caller or the audit trail sees. - it("tracks and names only what it disclosed", async () => { - let nodes = [ - root, - folder("secret-mid", { parents: [FOLDER_ROOT] }), - child("deep", "secret-mid"), - child("private-neighbour", "outside-folder"), - ]; - let { session, prepared, authorizations } = folderCore(nodes, { - listFiles: async () => ({ files: [nodes[3], nodes[2]] }), - }); + it("audits and rejects an empty folder search", async () => { + const { session, authorizations, events } = + positioned([root], undefined, async () => ({ files: [] })); - await (await session.list()).next(); - expect(prepared).toEqual([["deep"]]); - let described = JSON.stringify(authorizations); - expect(described).not.toContain("secret-mid"); - expect(described).not.toContain("private-neighbour"); - expect(described).not.toContain("outside-folder"); - }); + await expect((await session.search({ namePrefix: "missing" })).next()) + .rejects.toThrow("An empty Drive search cannot be shared safely."); + expect(authorizations).toEqual([expect.objectContaining({ + title: "Search Google Drive metadata", + description: expect.stringContaining('name starts with "missing"'), + })]); + expect(events).toEqual(["authorize", "latch"]); }); - // The end-to-end contract over a realistic fixture: a direct file, a nested one, and a sibling - // outside the root, spread over pages so both cursors have to be drained past an empty slice. - describe("draining a folder subtree", () => { - const nodes = [ - root, - child("direct-file", FOLDER_ROOT), - folder("nested", { parents: [FOLDER_ROOT] }), - child("nested-file", "nested"), - child("sibling", "outside-folder"), - ]; - - /** Serves the sibling alone, then the two in-scope files, then ends. */ - const paged = async ({ pageToken }: DriveListFilesOptions) => { - if (pageToken === undefined) return { files: [nodes[4]], nextPageToken: "p2" }; - if (pageToken === "p2") return { files: [nodes[1], nodes[3]], nextPageToken: "p3" }; - return { files: [] }; - }; - - async function drain(cursor: { next(): Promise }): Promise { - let ids: string[] = []; - for (let call = 0; call < 10; call++) { - let page = await cursor.next(); - if (page === null) return ids; - ids.push(...page.map(entry => entry.id)); - } - throw new Error("cursor did not terminate"); - } - - it.each([ - ["list", async (session: DriveSessionCore) => session.list()], - ["full-text search", - async (session: DriveSessionCore) => session.search({ fullTextContains: "plan" })], - ])("returns every descendant and no neighbour through %s", async (_label, open) => { - let { session } = folderCore(nodes, { listFiles: paged }); + it("keeps admission open when the page budget slices a folder listing", async () => { + let page = 0; + const { session, observations, events } = positioned([root], undefined, + async () => ({ files: [], nextPageToken: `page-${++page}` })); - expect(await drain(await open(session))).toEqual(["direct-file", "nested-file"]); - }); + await expect((await session.search({ namePrefix: "missing" })).next()).resolves.toEqual([]); + expect(observations).toEqual([[{ kind: "folder", fileId: "R" }]]); + expect(events).toEqual(["authorize", "commit"]); }); }); diff --git a/packages/gatekeeper-google/__tests__/oauth-flow.test.ts b/packages/gatekeeper-google/__tests__/oauth-flow.test.ts index 7ed936e29f..e71119a1a1 100644 --- a/packages/gatekeeper-google/__tests__/oauth-flow.test.ts +++ b/packages/gatekeeper-google/__tests__/oauth-flow.test.ts @@ -117,6 +117,27 @@ describe("stored OAuth flow", () => { }, ); + it("adds Drive discovery without changing resource intent", () => { + let kv = new FakeKv(); + prepareOAuthFlow( + kv, "init", [GOOGLE_DOC_RESOURCE.urlPattern], "reconnect", 0, true); + + expect(beginStoredOAuthFlow(kv, "init", "oauth", OAUTH_REDIRECT_URI, 1)).toEqual({ + oauthNonce: "oauth", + scopes: [ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/drive.readonly", + ], + }); + expect(claimStoredOAuthFlow(kv, "oauth", 2)).toEqual({ + mode: "reconnect", + requestedResources: [GOOGLE_DOC_RESOURCE.urlPattern], + oauthRedirectUri: OAUTH_REDIRECT_URI, + }); + }); + it("clears obsolete pending-flow keys when preparing a new flow", () => { let kv = new FakeKv(); for (let key of ["nonce", "requestedScopes", "requestedResources", "reconnecting", "ephemeral"]) { diff --git a/packages/gatekeeper-google/__tests__/resources.test.ts b/packages/gatekeeper-google/__tests__/resources.test.ts index 6bde5b6216..118b6b5738 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -2,9 +2,8 @@ import { describe, expect, it } from "vitest"; import { BIGQUERY_RESOURCE, GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DOC_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, GOOGLE_DRIVE_RESOURCE, - GOOGLE_SHARED_DRIVE_RESOURCE, GOOGLE_SHEETS_RESOURCE, IDENTITY_SCOPES, - LEGACY_GRANTED_RESOURCE_URL_PATTERNS, RESOURCE_BY_KIND, RESOURCE_SCOPES, - SCOPE_DERIVED_RESOURCE_URL_PATTERNS, SUPPORTED_RESOURCES, + GOOGLE_SHEETS_RESOURCE, IDENTITY_SCOPES, LEGACY_GRANTED_RESOURCE_URL_PATTERNS, + RESOURCE_BY_KIND, RESOURCE_SCOPES, SCOPE_DERIVED_RESOURCE_URL_PATTERNS, SUPPORTED_RESOURCES, grantedResourceUrlPatterns, hasDriveResourceGrant, parseResourceUrl, recordedResourceUrlPatterns, resourceUrlPatternsToOAuthScopes, resourcesCoveredByScopes, validateResourceUrlPatterns, @@ -30,8 +29,7 @@ describe("resource declarations", () => { "https://docs.google.com/spreadsheets/d/:spreadsheetId/*", "https://calendar.google.com/calendar/:calendarId/*", "https://drive.google.com/drive/my-drive", - "https://drive.google.com/drive/folders/:driveId", - "https://drive.google.com/_resource/folder/:folderId", + "https://drive.google.com/drive/folders/:folderId", "https://drive.google.com/file/d/:fileId/view", "https://bigquery.googleapis.com/:projectId/*", ]); @@ -92,31 +90,24 @@ describe("resource declarations", () => { it("advertises native Docs and Sheets only on Drive resources", () => { expect([ GOOGLE_DRIVE_RESOURCE.description, - GOOGLE_SHARED_DRIVE_RESOURCE.description, GOOGLE_DRIVE_FOLDER_RESOURCE.description, GOOGLE_DRIVE_FILE_RESOURCE.description, ]).toEqual([ "Find files and folders anywhere this Google account can read in Drive, including shared " + "drives. Full-text search examines indexed file content, descriptions, and OCR text; search " + "results contain metadata only, while native Google Docs and Sheets can be opened read-only.", - "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", - "Find files and folders, and read native Google Docs and Sheets, within one Drive folder " + - "and its descendants.", + "Browse a selected folder or shared drive, search its direct children, and read native " + + "Google Docs and Sheets.", "Read metadata and, for a native Google Doc or Sheet, content from one Drive file.", ]); }); - // The shared drive's pattern leaves the query wildcard, so a folder identity built by qualifying - // `/drive/folders/:driveId` would match both and make resource selection order-dependent. These - // two must stay disjoint as *patterns*, not merely distinct strings. - it("keeps the folder selector off every other resource's pattern", () => { - let folderUrl = "https://drive.google.com/_resource/folder/FOLDER123"; + it("matches the natural folder URL only to the folder resource", () => { + let folderUrl = "https://drive.google.com/drive/folders/FOLDER123"; for (let resource of SUPPORTED_RESOURCES) { let matches = new URLPattern(resource.urlPattern).test(folderUrl); expect(matches).toBe(resource === GOOGLE_DRIVE_FOLDER_RESOURCE); } - expect(new URLPattern(GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern) - .test("https://drive.google.com/drive/folders/DRIVE123")).toBe(false); }); }); @@ -138,16 +129,14 @@ describe("resourceUrlPatternsToOAuthScopes", () => { ]); }); - // Pins every permanent scope each Drive resource needs. Account, folder and exact-file bindings - // require the metadata scope plus the native Docs and Sheets read scopes. The shared drive needs - // the wider `drive.readonly` scope because `drives.list`/`drives.get` accept nothing narrower. + // Pins every permanent scope each Drive resource needs. Complete shared-drive discovery adds + // `drive.readonly` separately; it is not part of the selected folder's grant. it.each([ [GOOGLE_DRIVE_RESOURCE, [ "https://www.googleapis.com/auth/drive.metadata.readonly", "https://www.googleapis.com/auth/documents.readonly", "https://www.googleapis.com/auth/spreadsheets.readonly", ]], - [GOOGLE_SHARED_DRIVE_RESOURCE, ["https://www.googleapis.com/auth/drive.readonly"]], [GOOGLE_DRIVE_FOLDER_RESOURCE, [ "https://www.googleapis.com/auth/drive.metadata.readonly", "https://www.googleapis.com/auth/documents.readonly", @@ -167,21 +156,13 @@ describe("resourceUrlPatternsToOAuthScopes", () => { it("requires account and file grants to expand beyond metadata-only consent", () => { const drivePatterns = [ GOOGLE_DRIVE_RESOURCE.urlPattern, - GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern, GOOGLE_DRIVE_FILE_RESOURCE.urlPattern, ]; - const oldMetadataGrant = [ + const granted = resourcesCoveredByScopes(drivePatterns, [ ...IDENTITY_SCOPES, "https://www.googleapis.com/auth/drive.metadata.readonly", - ]; - const granted = resourcesCoveredByScopes(drivePatterns, oldMetadataGrant); - - expect(granted).not.toContain(GOOGLE_DRIVE_RESOURCE.urlPattern); - expect(granted).not.toContain(GOOGLE_DRIVE_FILE_RESOURCE.urlPattern); - expect(resourcesCoveredByScopes(drivePatterns, [ - ...IDENTITY_SCOPES, - "https://www.googleapis.com/auth/drive.readonly", - ])).toContain(GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern); + ]); + expect(granted).toEqual([]); }); it("deduplicates scopes shared between resources", () => { let scopes = resourceUrlPatternsToOAuthScopes( @@ -224,8 +205,8 @@ describe("resourcesCoveredByScopes", () => { .not.toContain(GOOGLE_CALENDAR_RESOURCE.urlPattern); }); - it("ignores scopes it does not know", () => { - expect(resourcesCoveredByScopes(allPatterns, ["https://www.googleapis.com/auth/drive"])) + it("ignores unrelated scopes", () => { + expect(resourcesCoveredByScopes(allPatterns, ["https://www.googleapis.com/auth/tasks"])) .toEqual([]); }); @@ -251,13 +232,39 @@ describe("resourcesCoveredByScopes", () => { SCOPE_DERIVED_RESOURCE_URL_PATTERNS, scopes))).toBe(false); } }); + + it("uses wider Drive scopes only for explicitly requested resources", () => { + const folderIntent = [GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern]; + for (const scope of [ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive", + ]) { + expect(resourcesCoveredByScopes(folderIntent, [scope])) + .toEqual(folderIntent); + expect(resourcesCoveredByScopes([GOOGLE_DOC_RESOURCE.urlPattern], [scope])) + .toEqual([]); + expect(resourcesCoveredByScopes([GOOGLE_DOC_RESOURCE.urlPattern], [ + scope, + "https://www.googleapis.com/auth/documents.readonly", + ])).toEqual([]); + } + + expect(resourcesCoveredByScopes(folderIntent, [ + "https://www.googleapis.com/auth/drive.metadata", + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/spreadsheets", + ])).toEqual(folderIntent); + expect(resourcesCoveredByScopes( + [GOOGLE_DOC_RESOURCE.urlPattern, GOOGLE_SHEETS_RESOURCE.urlPattern], + ["https://www.googleapis.com/auth/drive.readonly"], + )).toEqual([GOOGLE_SHEETS_RESOURCE.urlPattern]); + }); }); describe("hasDriveResourceGrant", () => { it("accepts each explicit Drive resource and rejects historical non-Drive grants", () => { for (let resource of [ - GOOGLE_DRIVE_RESOURCE, GOOGLE_SHARED_DRIVE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, - GOOGLE_DRIVE_FILE_RESOURCE, + GOOGLE_DRIVE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, ]) { expect(hasDriveResourceGrant([resource.urlPattern])).toBe(true); } @@ -444,9 +451,7 @@ describe("parseResourceUrl", () => { describe("Drive", () => { it.each([ ["account", "https://drive.google.com/drive/my-drive", { kind: "driveAccount" }], - ["shared drive", "https://drive.google.com/drive/folders/DRIVE123", - { kind: "sharedDrive", driveId: "DRIVE123" }], - ["folder", "https://drive.google.com/_resource/folder/FOLDER123", + ["folder", "https://drive.google.com/drive/folders/FOLDER123", { kind: "driveFolder", folderId: "FOLDER123" }], ["file", "https://drive.google.com/file/d/FILE123/view", { kind: "driveFile", fileId: "FILE123" }], @@ -454,18 +459,13 @@ describe("parseResourceUrl", () => { expect(parseResourceUrl(url)).toEqual(expected); }); - // The two share a host and a noun. A folder URL resolving to a shared drive would mint a whole - // drive's authority from a folder's consent, and the reverse would orphan every shared-drive - // binding, so each grammar must stay deaf to the other's shape. - it("keeps the folder and shared-drive grammars from bleeding into each other", () => { - expect(parseResourceUrl("https://drive.google.com/drive/folders/DRIVE123?resource=folder")) - .toEqual({ kind: "sharedDrive", driveId: "DRIVE123" }); - expect(() => parseResourceUrl("https://drive.google.com/_resource/folder/")) + it("rejects a folder route with no ID", () => { + expect(() => parseResourceUrl("https://drive.google.com/drive/folders/")) .toThrow(/Unsupported Google Drive resource URL/); }); it("decodes a folder ID that needed escaping", () => { - expect(parseResourceUrl("https://drive.google.com/_resource/folder/a%20b")) + expect(parseResourceUrl("https://drive.google.com/drive/folders/a%20b")) .toEqual({ kind: "driveFolder", folderId: "a b" }); }); diff --git a/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts b/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts index 3e0c961487..dabc7ccbba 100644 --- a/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/configurators.test.ts @@ -25,7 +25,7 @@ describe("Google resource configurators", () => { .resolves.toBe("person@example.com"); }); - it("omits folders whose children cannot be listed", async () => { + it("includes listable folders and shared-drive roots", async () => { vi.stubGlobal("fetch", vi.fn(async () => Response.json({ files: [ { @@ -36,16 +36,57 @@ describe("Google resource configurators", () => { id: "usable", name: "Usable", capabilities: { canListChildren: true }, }, + { + id: "drive-1", driveId: "drive-1", name: "Team Drive", + capabilities: { canListChildren: true }, + }, ], }))); - await expect(new DriveFolderConfiguratorUI(async () => token("access-token")) - .listDriveFolders("")) - .resolves.toEqual([{ - value: "usable", - title: "Usable", - subtitle: "My Drive", - }]); + await expect(new DriveFolderConfiguratorUI( + async () => token("access-token"), async () => true, + ).listDriveFolders("")) + .resolves.toEqual([ + { value: "usable", title: "Usable", subtitle: "My Drive" }, + { value: "drive-1", title: "Team Drive", subtitle: "In a shared drive" }, + ]); + }); + + it("refuses shared-drive discovery before optional consent", async () => { + const fetch = vi.fn(); + vi.stubGlobal("fetch", fetch); + const ui = new DriveFolderConfiguratorUI( + async () => token("access-token"), async () => false, + ); + + await expect(ui.listSharedDrives("")).rejects.toThrow( + "Enable Workspace Shared Drive discovery above, then try again.", + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("lists every shared-drive page after optional consent", async () => { + const calls: URL[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + const url = new URL(input instanceof Request ? input.url : input.toString()); + calls.push(url); + return url.searchParams.has("pageToken") + ? Response.json({ drives: [{ id: "drive-2", name: "Two" }] }) + : Response.json({ + drives: [{ id: "drive-1", name: "One" }], nextPageToken: "next", + }); + })); + const ui = new DriveFolderConfiguratorUI( + async () => token("access-token"), async () => true, + ); + + await expect(ui.listSharedDrives("team")).resolves.toEqual([ + { value: "drive-1", title: "One", subtitle: "Workspace Shared Drive" }, + { value: "drive-2", title: "Two", subtitle: "Workspace Shared Drive" }, + ]); + expect(calls).toHaveLength(2); + expect(calls[0].searchParams.get("q")).toBe("name contains 'team'"); + expect(calls[1].searchParams.get("pageToken")).toBe("next"); }); it("refreshes a rejected Calendar access token", async () => { diff --git a/packages/gatekeeper-google/__tests__/workerd/drive-discovery.test.ts b/packages/gatekeeper-google/__tests__/workerd/drive-discovery.test.ts new file mode 100644 index 0000000000..22cb62a80f --- /dev/null +++ b/packages/gatekeeper-google/__tests__/workerd/drive-discovery.test.ts @@ -0,0 +1,161 @@ +import { env } from "cloudflare:workers"; +import { runInDurableObject, SELF } from "cloudflare:test"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { UserAccount } from "../../src/google"; +import { + BIGQUERY_RESOURCE, + GOOGLE_DOC_RESOURCE, + IDENTITY_SCOPES, +} from "../../src/resources"; + +type TestUserAccount = UserAccount & { + setTestCallback( + id: string, + initiationNonce: string, + requestedResources: string[], + mode: "connect" | "auth" | "reconnect", + ): Promise; + readTestConnectNotifications(id: string): string[]; +}; + +type TestEnv = { + UserAccount: DurableObjectNamespace; +}; + +const testEnv = env as unknown as TestEnv; +const REDIRECT_URI = "http://localhost:8787/gatekeeper/google/oauth"; + +function runAccount( + account: DurableObjectStub, + callback: (instance: TestUserAccount, state: DurableObjectState) => T | Promise, +): Promise { + return runInDurableObject(account, callback); +} + +async function initializedAccount(scopes: string[], resources: string[]) { + const name = `drive-discovery-${crypto.randomUUID()}`; + const account = testEnv.UserAccount.getByName(name); + await runAccount(account, async (instance, state) => { + state.storage.kv.put("refreshToken", "old-refresh-token"); + state.storage.kv.put("accessToken", { + token: "old-access-token", + expires: new Date(Date.now() + 3600_000), + }); + state.storage.kv.put("grantedScopes", scopes); + state.storage.kv.put("grantedResources", resources); + await instance.setTestCallback(name, "initial", resources, "reconnect"); + }); + return { account, name }; +} + +async function beginDiscovery(account: DurableObjectStub): Promise { + const prepared = await runAccount(account, instance => instance.requestSharedDriveDiscovery()); + if (!prepared.url) throw new Error("Expected a discovery authorization URL."); + const response = await SELF.fetch(prepared.url, { redirect: "manual" }); + expect(response.status).toBe(302); + const location = response.headers.get("location"); + if (!location) throw new Error("Expected a Google authorization redirect."); + const authorizationUrl = new URL(location); + expect(authorizationUrl.searchParams.get("scope")?.split(" ")) + .toContain("https://www.googleapis.com/auth/drive.readonly"); + const state = authorizationUrl.searchParams.get("state"); + if (!state) throw new Error("Expected an OAuth state."); + return state; +} + +async function finishDiscovery(state: string, query: string): Promise { + return SELF.fetch(`http://localhost/gatekeeper/google/oauth?${query}&state=${encodeURIComponent(state)}`); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("shared-drive discovery authorization", () => { + it.each([ + "https://www.googleapis.com/auth/drive.readonly", + "https://www.googleapis.com/auth/drive", + ])("preserves discovery during a normal reconnect from %s", async coveringScope => { + const { account } = await initializedAccount( + [...IDENTITY_SCOPES, coveringScope], + [GOOGLE_DOC_RESOURCE.urlPattern], + ); + + const begun = await runAccount(account, async instance => { + await instance.prepareReconnect("reconnect", [GOOGLE_DOC_RESOURCE.urlPattern]); + return instance.beginOAuthFlow("reconnect", REDIRECT_URI); + }); + + expect(begun?.scopes).toContain("https://www.googleapis.com/auth/drive.readonly"); + expect(begun?.scopes).not.toContain("https://www.googleapis.com/auth/drive"); + }); + + it("leaves credentials and resource intent unchanged when consent is denied", async () => { + const scopes = [...IDENTITY_SCOPES, "https://www.googleapis.com/auth/documents"]; + const resources = [GOOGLE_DOC_RESOURCE.urlPattern]; + const { account, name } = await initializedAccount(scopes, resources); + const state = await beginDiscovery(account); + + const response = await finishDiscovery(state, "error=access_denied"); + + expect(response.status).toBe(400); + await expect(runAccount(account, instance => instance.readTestConnectNotifications(name))) + .resolves.toEqual([]); + await expect(runAccount(account, (_instance, durableState) => ({ + refreshToken: durableState.storage.kv.get("refreshToken"), + scopes: durableState.storage.kv.get("grantedScopes"), + resources: durableState.storage.kv.get("grantedResources"), + }))).resolves.toEqual({ + refreshToken: "old-refresh-token", + scopes, + resources, + }); + }); + + it.each([ + { granted: true, optionalScopes: ["https://www.googleapis.com/auth/drive.readonly"] }, + { granted: false, optionalScopes: [] }, + ])("records actual returned scopes when optional discovery is $granted", async ({ + granted, + optionalScopes, + }) => { + const resources = [GOOGLE_DOC_RESOURCE.urlPattern, BIGQUERY_RESOURCE.urlPattern]; + const { account, name } = await initializedAccount(IDENTITY_SCOPES, resources); + const state = await beginDiscovery(account); + const returnedScopes = [ + ...IDENTITY_SCOPES, + "https://www.googleapis.com/auth/documents", + "https://www.googleapis.com/auth/drive.metadata.readonly", + "https://www.googleapis.com/auth/bigquery", + ...optionalScopes, + ]; + const exchange = vi.fn(async () => Response.json({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + expires_in: 3600, + scope: returnedScopes.join(" "), + })); + vi.stubGlobal("fetch", exchange); + + const response = await finishDiscovery(state, "code=authorization-code"); + + expect(response.status).toBe(200); + await expect(runAccount(account, instance => instance.readTestConnectNotifications(name))) + .resolves.toEqual(["restored"]); + await expect(runAccount(account, instance => instance.hasSharedDriveDiscovery())) + .resolves.toBe(granted); + await expect(runAccount(account, (_instance, durableState) => ({ + refreshToken: durableState.storage.kv.get("refreshToken"), + scopes: durableState.storage.kv.get("grantedScopes"), + resources: durableState.storage.kv.get("grantedResources"), + }))).resolves.toEqual({ + refreshToken: "new-refresh-token", + scopes: returnedScopes, + resources, + }); + + const replay = await finishDiscovery(state, "code=replayed-code"); + expect(replay.status).toBe(200); + expect(exchange).toHaveBeenCalledOnce(); + await expect(runAccount(account, instance => instance.readTestConnectNotifications(name))) + .resolves.toEqual(["restored"]); + }); +}); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index 08ecab0b51..1779718d2d 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -400,7 +400,7 @@ describe("folder-scoped native sessions", () => { onNativeRead?.(); if (url.hostname === "docs.googleapis.com") { return Response.json({ - documentId: "doc-1", + documentId: decodeURIComponent(url.pathname.split("/").at(-1)!), title: "Quarterly plan", revisionId: "revision-1", tabs: [docTab("solo", "Solo", "")], @@ -488,9 +488,8 @@ describe("folder-scoped native sessions", () => { expect(nativeCalls).toEqual([]); }); - // The window the postcheck exists for: the move lands while the Docs call is in flight, so only a - // second look after the read can catch it — and the content must reach neither the approval queue - // nor the caller. + // The move lands while the Docs call is in flight. The content reaches neither the approval + // queue nor caller; only the owner-relative failed membership check is authorized. it("discards content when the move lands during the provider read", async () => { const nodes = subtree(); installFolderProvider(nodes, () => { @@ -501,9 +500,30 @@ describe("folder-scoped native sessions", () => { using doc = await scoped.openGoogleDoc("doc-1"); const authorizedBefore = queue.observations.length; - await expect(Promise.resolve(doc.getContent())) - .rejects.toThrow(OUTSIDE); - expect(queue.observations).toHaveLength(authorizedBefore); + await expect(Promise.resolve(doc.getContent())).rejects.toThrow(OUTSIDE); + expect(queue.observations.slice(authorizedBefore)).toEqual([ + expect.objectContaining({ title: "Check Google Drive folder" }), + ]); + }); + + it("keeps child-folder and native capabilities alive after their parents are disposed", async () => { + const nodes = subtree(); + nodes.set("nested", { + id: "nested", mimeType: FOLDER_MIME, parents: [ROOT], trashed: false, + capabilities: { canListChildren: true }, + }); + nodes.set("nested-doc", { + id: "nested-doc", mimeType: DOC_MIME, parents: ["nested"], trashed: false, + }); + installFolderProvider(nodes); + const parent = folderSession(nodes).session; + const child = await parent.openFolder("nested"); + parent[Symbol.dispose](); + const doc = await child.openGoogleDoc("nested-doc"); + child[Symbol.dispose](); + using ownedDoc = doc; + + await expect(Promise.resolve(ownedDoc.getContent())).resolves.toBe(""); }); it("refuses to open a native file that is already outside the subtree", async () => { diff --git a/packages/gatekeeper-google/__tests__/workerd/worker.ts b/packages/gatekeeper-google/__tests__/workerd/worker.ts index 07a5d93bcf..d40d6e6bbc 100644 --- a/packages/gatekeeper-google/__tests__/workerd/worker.ts +++ b/packages/gatekeeper-google/__tests__/workerd/worker.ts @@ -1,8 +1,11 @@ -import { DurableObject, RpcStub, RpcTarget } from "cloudflare:workers"; +import { DurableObject, RpcStub, RpcTarget, WorkerEntrypoint } from "cloudflare:workers"; import { GmailForwardSnapshotStore } from "../../src/gmail-state"; import { GmailGatekeeperImpl, type GmailGatekeeperImplProps } from "../../src/gmail"; import { UserAccount } from "../../src/google"; -import type {ActionKind} from "@gadgets/workshop-shared/gatekeeper"; +import type { OAuthFlowMode } from "../../src/oauth-flow"; +import type { + ActionKind, GatekeeperConnectCallback, GatekeeperUser, +} from "@gadgets/workshop-shared/gatekeeper"; import {TestGitCache} from "../test-git-cache"; import type { GmailComposeOptions, GmailDraftInput, GmailDraftPatch, GmailMessage, GmailReplyOptions, @@ -12,6 +15,66 @@ import type { export { default } from "../../src/google"; export { GmailGatekeeperImpl, UserAccount }; + +const connectNotifications = new Map(); + +export class TestConnectCallback extends WorkerEntrypoint< + Cloudflare.Env, + {id: string} +> implements GatekeeperConnectCallback { + async complete(_user: Fetcher): Promise { + this.#record("complete"); + } + + async credentialsExpired(): Promise { + this.#record("expired"); + } + + async credentialsRestored(): Promise { + this.#record("restored"); + } + + #record(event: string): void { + let events = connectNotifications.get(this.ctx.props.id) ?? []; + connectNotifications.set(this.ctx.props.id, [...events, event]); + } +} + +type TestUserAccount = UserAccount & { + setTestCallback( + id: string, + initiationNonce: string, + requestedResources: string[], + mode: OAuthFlowMode, + ): Promise; + readTestConnectNotifications(id: string): string[]; +}; + +type UserAccountContext = { + ctx: { + exports: { + TestConnectCallback(options: {props: {id: string}}): Fetcher; + }; + }; +}; + +const testUserAccountPrototype = UserAccount.prototype as TestUserAccount; + +testUserAccountPrototype.setTestCallback = function( + id: string, + initiationNonce: string, + requestedResources: string[], + mode: OAuthFlowMode, +): Promise { + const account = this as unknown as UserAccountContext; + const callback = account.ctx.exports.TestConnectCallback({props: {id}}); + return this.setCallback(callback, initiationNonce, requestedResources, mode); +}; + +testUserAccountPrototype.readTestConnectNotifications = function(id: string): string[] { + return [...(connectNotifications.get(id) ?? [])]; +}; + type StorageOperation = | {kind: "put"; key: string; value: unknown} | {kind: "delete"; key: string}; @@ -144,6 +207,7 @@ class TestApprovalQueue extends RpcTarget { export class TestHooks extends DurableObject { #queues = new Map(); + #gatekeeper( facetName: string, id: string, props: GmailGatekeeperImplProps, ) { diff --git a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts index 703134cf9b..b3ce83c2e1 100644 --- a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts @@ -1,7 +1,11 @@ import type { ConfiguratorOption } from "./configurator-option"; -export type DriveFolderConfiguratorValues = { folderId?: string | null }; +export type DriveFolderConfiguratorValues = { + source?: "folders" | "sharedDrives" | null; + folderId?: string | null; +}; export interface DriveFolderConfiguratorRpc { listDriveFolders(query: string): Promise; + listSharedDrives(query: string): Promise; } diff --git a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx index 6661bf9272..0c701bca22 100644 --- a/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx @@ -1,22 +1,50 @@ -import { Autocomplete, Field, h, Section, type ConfiguratorUISpec } from "@gadgets/configurator-ui"; -import type { DriveFolderConfiguratorRpc, DriveFolderConfiguratorValues } from "./drive-folder-configurator-types"; +import { + Autocomplete, Field, h, RadioCards, Section, type ConfiguratorUISpec, +} from "@gadgets/configurator-ui"; +import type { + DriveFolderConfiguratorRpc, DriveFolderConfiguratorValues, +} from "./drive-folder-configurator-types"; export default { - initial: {}, + initial: { source: "folders" as const }, isReady: ({ values }) => typeof values.folderId === "string" && values.folderId.length > 0, - // Must mirror `parseDriveUrl` in resources.ts, which is what actually mints the capability. This - // module is transpiled on its own and cannot import that parser, so `__tests__/configurator-url - // .test.ts` is what keeps the copies honest. resourceUrl: ({ values }) => - `https://drive.google.com/_resource/folder/${encodeURIComponent(values.folderId ?? "")}`, - render({ values, setValues, ui }) { + `https://drive.google.com/drive/folders/${encodeURIComponent(values.folderId ?? "")}`, + render({ values, setValues, clearFields, ui }) { + let source = values.source === "sharedDrives" ? "sharedDrives" : "folders"; return
- + + { + if (nextSource !== "folders" && nextSource !== "sharedDrives") return; + clearFields("folderId"); + setValues({ source: nextSource, folderId: null }); + }} + /> + + ui.listDriveFolders(query)} + placeholder={source === "sharedDrives" + ? "Search Workspace Shared Drives..." + : "Search Drive folders..."} + loadOptions={query => source === "sharedDrives" + ? ui.listSharedDrives(query) + : ui.listDriveFolders(query)} onChange={folderId => setValues({ folderId })} /> diff --git a/packages/gatekeeper-google/src/configurator/shared-drive-configurator-types.d.ts b/packages/gatekeeper-google/src/configurator/shared-drive-configurator-types.d.ts deleted file mode 100644 index bb0a968e3c..0000000000 --- a/packages/gatekeeper-google/src/configurator/shared-drive-configurator-types.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { ConfiguratorOption } from "./configurator-option"; - -export type SharedDriveConfiguratorValues = { driveId?: string | null }; - -export interface SharedDriveConfiguratorRpc { - listSharedDrives(query: string): Promise; -} diff --git a/packages/gatekeeper-google/src/configurator/shared-drive-configurator-ui.tsx b/packages/gatekeeper-google/src/configurator/shared-drive-configurator-ui.tsx deleted file mode 100644 index 1d35cd50dc..0000000000 --- a/packages/gatekeeper-google/src/configurator/shared-drive-configurator-ui.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Autocomplete, Field, h, Section, type ConfiguratorUISpec } from "@gadgets/configurator-ui"; -import type { SharedDriveConfiguratorRpc, SharedDriveConfiguratorValues } from "./shared-drive-configurator-types"; - -export default { - initial: {}, - isReady: ({ values }) => typeof values.driveId === "string" && values.driveId.length > 0, - // Must mirror `parseDriveUrl` in resources.ts, which is what actually mints the capability. This - // module is transpiled on its own and cannot import that parser, so `__tests__/configurator-url - // .test.ts` is what keeps the copies honest. - resourceUrl: ({ values }) => - `https://drive.google.com/drive/folders/${encodeURIComponent(values.driveId ?? "")}`, - render({ values, setValues, ui }) { - return
- - ui.listSharedDrives(query)} - onChange={driveId => setValues({ driveId })} - /> - -
; - }, -} satisfies ConfiguratorUISpec; diff --git a/packages/gatekeeper-google/src/drive-api.ts b/packages/gatekeeper-google/src/drive-api.ts index 488ba57fbf..a7d5b847b7 100644 --- a/packages/gatekeeper-google/src/drive-api.ts +++ b/packages/gatekeeper-google/src/drive-api.ts @@ -1,5 +1,6 @@ // Structured Google Drive API client shared by configurators, sessions, and observer verification. +import type { DriveObservation } from "./drive-observers"; import { AccessTokenProvider, fetchWithAuthRetry } from "./auth-retry"; const DRIVE_API_BASE = "https://www.googleapis.com/drive/v3"; @@ -7,6 +8,8 @@ const DRIVE_BATCH_URL = "https://www.googleapis.com/batch/drive/v3"; const MAX_BATCH_FILES = 100; const MAX_BATCH_RESPONSE_BYTES = 1_000_000; const MAX_JSON_RESPONSE_BYTES = 5_000_000; +/** Page budget for `listAllDrives`, at 100 shared drives a page. */ +const LIST_DRIVES_MAX_PAGES = 20; /** Exact MIME type Drive gives a native folder. A shortcut to one has its own type, not this. */ export const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; @@ -481,12 +484,13 @@ export class DriveApi { return { drives, ...(nextPageToken ? { nextPageToken } : {}) }; } - /** Every shared drive visible to the connected account. */ + /** Every shared drive visible to the connected account, up to the page budget. */ async listAllDrives( options: Omit = {}, ): Promise { let drives: DriveInfo[] = []; let pageToken: string | undefined; + let pages = 0; do { let page = await this.listDrives({ ...options, @@ -494,22 +498,25 @@ export class DriveApi { }); drives.push(...page.drives); pageToken = page.nextPageToken; - } while (pageToken); + } while (pageToken && ++pages < LIST_DRIVES_MAX_PAGES); return drives; } - /** Fresh access checks, optionally requiring one folder's children to be listable. */ - async checkFileAccess( - fileIds: readonly string[], listableFolderId?: string, - ): Promise { - let fields = listableFolderId === undefined - ? "id" - : "id,capabilities(canListChildren)"; - return this.#batchGetFiles(fileIds, fields, (part, fileId) => { - if (!batchPartAllowed(part)) return false; - return fileId !== listableFolderId || - parseDriveScopeNode(part.body, fileId).canListChildren === true; - }); + + /** Fresh access checks for typed file and folder disclosure units. */ + async checkObservations(observations: readonly DriveObservation[]): Promise { + return this.#batchGetFiles( + observations.map(observation => observation.fileId), + "id,mimeType,trashed,capabilities(canListChildren)", + (part, _fileId, index) => { + if (!batchPartAllowed(part)) return false; + let observation = observations[index]; + if (observation.kind === "file") return true; + let node = parseDriveScopeNode(part.body, observation.fileId); + return node.mimeType === FOLDER_MIME_TYPE && node.trashed === false && + node.canListChildren === true; + }, + ); } /** @@ -529,13 +536,13 @@ export class DriveApi { async #batchGetFiles( fileIds: readonly string[], fields: string, - mapPart: (part: BatchAccessPart, fileId: string) => T, + mapPart: (part: BatchAccessPart, fileId: string, index: number) => T, ): Promise { let result: T[] = []; for (let offset = 0; offset < fileIds.length; offset += MAX_BATCH_FILES) { let chunk = fileIds.slice(offset, offset + MAX_BATCH_FILES); let parts = await this.#batchGetChunk(chunk, fields); - result.push(...parts.map((part, index) => mapPart(part, chunk[index]))); + result.push(...parts.map((part, index) => mapPart(part, chunk[index], offset + index))); } return result; } diff --git a/packages/gatekeeper-google/src/drive-folder-scope.ts b/packages/gatekeeper-google/src/drive-folder-scope.ts index 0ca5199435..7060ce75b6 100644 --- a/packages/gatekeeper-google/src/drive-folder-scope.ts +++ b/packages/gatekeeper-google/src/drive-folder-scope.ts @@ -1,196 +1,64 @@ -/** - * Descendant-membership proofs for a folder-scoped Drive binding. - * - * Drive v3 offers no folder corpus, no folder-scoped token, and no recursive ancestor predicate -- - * `'' in parents` means direct children only. So confinement to a subtree is proved here, from - * freshly fetched metadata, one parent hop at a time, and nothing survives the operation that - * proved it: a hierarchy change rotates no credential and bumps no cache generation, so a - * remembered ancestry would be authority the provider never re-confirmed. - */ +import { FOLDER_MIME_TYPE, type DriveFile, type DriveScopeNode } from "./drive-api"; -import { FOLDER_MIME_TYPE, type DriveApi, type DriveFile, type DriveScopeNode } from "./drive-api"; +const MAX_PATH_NODES = 101; -/** - * Parent hops one proof may take. - * - * My Drive and shared drives both cap nesting at 100 levels, so one hop past that separates a legal - * maximum-depth descendant from a chain that does not terminate. - */ -const MAX_PARENT_HOPS = 101; +/** Internal root-to-position path; never accepted from an agent. */ +export type FolderLocation = { + rootId: string; + folderIds: readonly string[]; +}; -/** The single refusal every folder-scope failure collapses to. It names nothing it rejected. */ +/** The single refusal for folder-scope failures. */ export function outsideScope(): never { throw new Error("The requested file is outside this Drive binding."); } -/** A candidate proven to be the root or one of its live descendants, with the chain that proved it. */ -export type FolderProof = { - file: DriveFile; - /** The candidate and every ancestor traversed, up to and including the root. */ - path: DriveScopeNode[]; -}; - -/** One candidate's walk toward the root. Absent `parentId` with `proven: false` means rejected. */ -type Walk = FolderProof & { - seen: Set; - parentId?: string; - proven: boolean; -}; - -function scopeNode(file: DriveFile): DriveScopeNode { - return { - id: file.id, - ...(file.mimeType === undefined ? {} : { mimeType: file.mimeType }), - ...(file.parents ? { parents: file.parents } : {}), - ...(file.driveId === undefined ? {} : { driveId: file.driveId }), - ...(file.trashed === undefined ? {} : { trashed: file.trashed }), - ...(file.capabilities?.canListChildren === undefined ? {} : { - canListChildren: file.capabilities.canListChildren, - }), - }; -} - -/** - * Reads the bound folder and confirms it is still usable as a root, or refuses. - * - * The one place a folder ID becomes authority, so `describe()` and the session share it and cannot - * drift. Identity is the immutable ID: a rename or a move does not retarget the capability. What - * would retarget it is accepting something that is no longer an ordinary live folder. - */ +/** Reads and validates the selected folder root. */ export async function readFolderRoot( folderId: string, getFile: (fileId: string) => Promise, ): Promise { - // The account-relative alias resolves per account, so it names no stable authority. Checked - // before the fetch, since no read can make it one. if (folderId === "root") outsideScope(); let file = await getFile(folderId); - if (file.id !== folderId || - // A shortcut carries its own MIME type, so this also refuses one aimed at a folder. - file.mimeType !== FOLDER_MIME_TYPE || - file.capabilities?.canListChildren !== true || - file.trashed !== false || - // A shared drive's root shares the drive's own ID and is the Shared Drive resource. Serving it - // here too would make the folder binding a second, weaker name for a whole drive. - file.id === file.driveId) { + if (file.id !== folderId || file.mimeType !== FOLDER_MIME_TYPE || + file.capabilities?.canListChildren !== true || file.trashed !== false) { outsideScope(); } return file; } -/** Whether both nodes sit in the same storage domain: the same shared drive, or My Drive. */ -function sameDomain(node: DriveScopeNode, root: DriveScopeNode): boolean { - return node.driveId === root.driveId; -} - -/** Whether an intermediate node can carry a chain: a live folder in the root's own domain. */ -function isTraversableFolder(node: DriveScopeNode, root: DriveScopeNode): boolean { - return node.trashed === false && node.mimeType === FOLDER_MIME_TYPE && sameDomain(node, root); -} - -/** Whether a re-read node still states every fact the proof recorded about it. */ -function unchanged(node: DriveScopeNode, recorded: DriveScopeNode): boolean { - return node.id === recorded.id && node.mimeType === recorded.mimeType && - node.driveId === recorded.driveId && node.trashed === recorded.trashed && - node.canListChildren === recorded.canListChildren && - node.parents?.length === recorded.parents?.length && - (node.parents ?? []).every((parent, index) => parent === recorded.parents?.[index]); -} - -function startWalk(file: DriveFile, root: DriveScopeNode): Walk | undefined { - let node = scopeNode(file); - // The candidate is the one node whose type is unconstrained: a leaf may be any file, a shortcut - // included -- it is disclosed as a shortcut and never followed. - if (node.trashed !== false || !sameDomain(node, root)) return undefined; - if (node.id === root.id) { - return { file, path: [root], seen: new Set([root.id]), proven: true }; - } - // A Drive file has one current parent. `parents` is an array anyway, so anything else is either a - // malformed response or a shape whose containment this cannot decide. - if (node.parents?.length !== 1) return undefined; - return { file, path: [node], seen: new Set([node.id]), parentId: node.parents[0], proven: false }; -} - -/** Follows one walk to the ancestor it was waiting on, or abandons it. */ -function step(walk: Walk, node: DriveScopeNode | undefined, root: DriveScopeNode): void { - let parentId = walk.parentId; - walk.parentId = undefined; - if (parentId === undefined || node === undefined || walk.seen.has(parentId)) return; - walk.seen.add(parentId); - walk.path.push(node); - if (parentId === root.id) { - walk.proven = true; - return; - } - if (node.parents?.length !== 1) return; - walk.parentId = node.parents[0]; -} - -export class FolderScope { - #api: Pick; - - constructor(api: Pick) { - this.#api = api; +/** Refetches and validates every saved edge from the bound root to the current folder. */ +export async function readFolderLocation( + location: FolderLocation, + getScopeNodes: (fileIds: readonly string[]) => Promise<(DriveScopeNode | undefined)[]>, +): Promise { + let ids = location.folderIds; + if (location.rootId === "root" || ids.length === 0 || ids.length > MAX_PATH_NODES || + ids[0] !== location.rootId || new Set(ids).size !== ids.length) { + outsideScope(); } - /** - * The subset of `files` that is the root or one of its live descendants, in provider order. - * - * Batched by level rather than per candidate: one `files.get` batch resolves the whole frontier's - * parents, so even a full page of maximum-depth candidates costs one subrequest per level. A - * missing or inaccessible parent, several parents, a non-folder or trashed ancestor, a hop into - * another storage domain, a cycle, or depth exhaustion all mean "not a member" rather than an - * error: on a broad page those are ordinary neighbours the binding must not disclose. - */ - async prove(files: readonly DriveFile[], rootFile: DriveFile): Promise { - let root = scopeNode(rootFile); - let walks: Walk[] = []; - for (let file of files) { - let walk = startWalk(file, root); - if (walk) walks.push(walk); + let nodes = await getScopeNodes(ids); + if (nodes.length !== ids.length) outsideScope(); + let root = nodes[0]; + if (!root) outsideScope(); + + for (let index = 0; index < ids.length; index++) { + let node = nodes[index]; + if (!node || node.id !== ids[index] || node.mimeType !== FOLDER_MIME_TYPE || + node.trashed !== false || node.canListChildren !== true || + node.driveId !== root.driveId) { + outsideScope(); } - - // Ancestors resolved during this proof, and only during it. `undefined` records a parent that - // was fetched and rejected, so a shared subtree costs one lookup however many walks cross it. - let ancestors = new Map([[root.id, root]]); - for (let hop = 0; hop < MAX_PARENT_HOPS; hop++) { - let pending = walks.filter(walk => walk.parentId !== undefined); - if (pending.length === 0) break; - let wanted = [...new Set(pending.map(walk => walk.parentId!))] - .filter(id => !ancestors.has(id)); - if (wanted.length > 0) { - let nodes = await this.#api.getScopeNodes(wanted); - for (let [index, node] of nodes.entries()) { - ancestors.set(wanted[index], node && isTraversableFolder(node, root) ? node : undefined); - } - } - for (let walk of pending) step(walk, ancestors.get(walk.parentId!), root); + if (index > 0 && (node.parents?.length !== 1 || node.parents[0] !== ids[index - 1])) { + outsideScope(); } - - return walks.filter(walk => walk.proven).map(({ file, path }) => ({ file, path })); } + return nodes as DriveScopeNode[]; +} - /** - * Re-reads every node the proofs rested on and refuses if any recorded fact has changed. - * - * A proof spans many round trips, so its earliest hops are the stalest thing authorizing the - * disclosure. This is a second look immediately before that disclosure, not a lock: Drive has no - * ancestry-plus-content transaction, and a move landing after it is caught by the next read. - * - * Moving the bound folder itself also fails here, since its own `parents` is compared like any - * other node's even though the root's container cannot affect membership. The retry succeeds, and - * exempting one field from this comparison would cost more review than it saves. - */ - async recheck(proofs: readonly FolderProof[]): Promise { - let recorded = new Map(); - for (let proof of proofs) { - for (let node of proof.path) recorded.set(node.id, node); - } - if (recorded.size === 0) return; - let ids = [...recorded.keys()]; - let nodes = await this.#api.getScopeNodes(ids); - for (let [index, node] of nodes.entries()) { - if (!node || !unchanged(node, recorded.get(ids[index])!)) outsideScope(); - } - } +/** Whether a fresh file is a live direct child in the same Drive storage domain. */ +export function isDirectChild(file: DriveFile, parent: DriveScopeNode): boolean { + return file.trashed === false && file.driveId === parent.driveId && + file.parents?.length === 1 && file.parents[0] === parent.id; } diff --git a/packages/gatekeeper-google/src/drive-observers.ts b/packages/gatekeeper-google/src/drive-observers.ts index ee2e4face5..042fc5310b 100644 --- a/packages/gatekeeper-google/src/drive-observers.ts +++ b/packages/gatekeeper-google/src/drive-observers.ts @@ -1,60 +1,59 @@ import type { DriveBindingScope } from "./drive-session"; import { ObserverTracker, type ObserverBatchResult, type ObserverKv } from "./observers"; -/** Key prefix for the Drive file IDs a binding has disclosed metadata about. */ +/** Key prefix for Drive disclosure units. */ export const DRIVE_OBSERVATION_PREFIX = "observedDriveFile:"; -/** Refusal when a joining collaborator holds no Google Drive grant at all. */ +/** Refusal when a joining collaborator holds no Google Drive grant. */ export const DRIVE_BASELINE_DENIED_MESSAGE = "This collaborator has not granted Google Drive access, so they cannot observe this binding."; -function scopeRootId(scope: DriveBindingScope): string | undefined { +/** Data access needed to observe a Drive disclosure. */ +export type DriveObservation = + | { kind: "file"; fileId: string } + | { kind: "folder"; fileId: string }; + +function encodeObservation(observation: DriveObservation): string { + let id = encodeURIComponent(observation.fileId); + return observation.kind === "folder" ? `folder:${id}` : id; +} + +function decodeObservation(value: string): DriveObservation { + if (value.startsWith("folder:")) { + return {kind: "folder", fileId: decodeURIComponent(value.slice("folder:".length))}; + } + return {kind: "file", fileId: decodeURIComponent(value)}; +} + +function scopeRoot(scope: DriveBindingScope): DriveObservation | undefined { switch (scope.kind) { case "account": return undefined; - case "sharedDrive": return scope.driveId; - case "folder": return scope.folderId; - case "file": return scope.fileId; + case "folder": return {kind: "folder", fileId: scope.folderId}; + case "file": return {kind: "file", fileId: scope.fileId}; } } -/** - * The observer tracker for one Drive binding, seeded with the set its scope already names. - * - * A shared-drive, folder, or single-file binding can always reach its own root, so that ID is - * recorded up front rather than waiting for a read to discover it. A file binding therefore never - * grows past it because its session admits no other ID. This lets every scope share one admission - * path. Without the seed a file binding would need a second, hand-rolled verify kept in step by - * hand with this one's staging and rollback. - * - * `verifyBatch` is passed in rather than a verifier type, so this module stays independent of the - * worker entrypoint that owns the RPC interface. - */ +/** Creates the observer tracker for one Drive binding. */ export function driveObserverTracker( kv: ObserverKv, scope: DriveBindingScope, verifyBatch: ( - verifier: V, fileIds: readonly string[], listableFolderId?: string, + verifier: V, + observations: DriveObservation[], ) => Promise, -): ObserverTracker { - let rootId = scopeRootId(scope); - let listableFolderId = scope.kind === "folder" ? scope.folderId : undefined; - if (rootId !== undefined) { - let key = `${DRIVE_OBSERVATION_PREFIX}${encodeURIComponent(rootId)}`; +): ObserverTracker { + let root = scopeRoot(scope); + if (root) { + let key = `${DRIVE_OBSERVATION_PREFIX}${encodeObservation(root)}`; if (kv.get(key) === undefined) kv.put(key, "observed"); } - return new ObserverTracker(kv, { + return new ObserverTracker(kv, { setPrefix: DRIVE_OBSERVATION_PREFIX, - encode: encodeURIComponent, - decode: decodeURIComponent, - verifyBatch: (verifier, fileIds) => verifyBatch(verifier, fileIds, listableFolderId), + encode: encodeObservation, + decode: decodeObservation, + verifyBatch, baselineDeniedMessage: DRIVE_BASELINE_DENIED_MESSAGE, - // The refusal names no ID: a collaborator who cannot reach a file must not learn that this - // workspace read one, nor which. The reader knows their own access, not this binding's history. deniedMessage: () => "This collaborator cannot access Drive data this workspace has read.", - // checkFileAccess issues ceil(N/100) sequential subrequests. The overseer re-runs addObserver - // on every open, per observer, at concurrency 6. 2000 files → 20 subrequests per observer, 120 - // if six run together — well inside the 1000-subrequest budget. Uncapped, a whole-account - // binding would grow until admission exceeds that budget and locks every collaborator out. maxTrackedSets: 2000, }); } diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index 7263026c09..602bff15f8 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -2,9 +2,13 @@ import type { ObservationDescription } from "@gadgets/workshop-shared/gatekeeper import { CursorPager, type Pager } from "./cursor"; import { DriveApiRequestError, FOLDER_MIME_TYPE, - type DriveApi, type DriveCorpus, type DriveFile, type DriveListFilesOptions, + type DriveApi, type DriveFile, type DriveListFilesOptions, type DriveScopeNode, } from "./drive-api"; -import { FolderScope, outsideScope, readFolderRoot, type FolderProof } from "./drive-folder-scope"; +import { + isDirectChild, outsideScope, readFolderLocation, + type FolderLocation, +} from "./drive-folder-scope"; +import type { DriveObservation } from "./drive-observers"; import type { ObserverCheck } from "./observers"; import type { DriveEntry, DriveListOptions, DriveOrder, DriveScope, DriveSearchQuery, @@ -16,16 +20,6 @@ export const GOOGLE_DOC_MIME_TYPE = "application/vnd.google-apps.document"; /** Exact MIME type for native Google Sheets files. */ export const GOOGLE_SHEET_MIME_TYPE = "application/vnd.google-apps.spreadsheet"; -/** - * Drive items one folder-scoped provider page asks for. - * - * Membership is a post-filter -- Drive cannot restrict a listing to a subtree -- so a bare `list()` - * scans the corpus and a small folder in a large drive costs one round trip per page. Full pages - * keep that count down; the page budget below still caps a call at one of them. Measured worst - * case, 100 candidates each 99 levels deep on distinct chains: 203 subrequests for one `next()`. - */ -const FOLDER_PAGE_SIZE = 100; - const FOLDER_MOVED = "The connected Drive folder moved to another drive; open a new listing."; // Agent-supplied query values go in the approval description, so each value and the whole string @@ -36,11 +30,27 @@ const MAX_OBSERVATION_DESCRIPTION = 240; /** Immutable authority carried by one Drive gatekeeper binding. */ export type DriveBindingScope = | { kind: "account" } - | { kind: "sharedDrive"; driveId: string } | { kind: "folder"; folderId: string } | { kind: "file"; fileId: string }; -type DriveSessionApi = Pick; +/** + * Refuses a binding whose persisted scope predates this model rather than widening it: an + * unrecognized kind would fall through every narrow check and be served as account scope. + */ +export function requireDriveBindingScope(scope: DriveBindingScope): DriveBindingScope { + switch (scope.kind) { + case "account": + case "folder": + case "file": + return scope; + } + throw new Error( + "This Google Drive connection predates the current folder resource. Remove it and connect " + + "the folder or shared drive again."); +} + +type DriveSessionScope = Exclude; +type DriveSessionApi = Pick; /** An observation description before scope enforcement supplies the observer exclusions. */ export type NativeObservation = Omit; @@ -77,13 +87,18 @@ export function unguardedNativeRead( */ export type DriveSessionCoreOptions = { api: DriveSessionApi; - scope: DriveBindingScope; - prepareObservation(fileIds: string[]): Promise>; + scope: DriveSessionScope; + prepareObservation(observations: DriveObservation[]): Promise>; /** Fences an owner-only observation: excludes today's observers and closes admission. */ - prepareWithheld(): ObserverCheck; + prepareWithheld(): ObserverCheck; authorize(description: ObservationDescription): Promise; }; +/** Construction contract for one positioned folder capability core. */ +export type DriveFolderSessionCoreOptions = Omit & { + location: FolderLocation; +}; + function requiredString(value: string | undefined, field: string): string { if (!value) throw new Error(`Google Drive omitted required file ${field}`); return value; @@ -211,8 +226,7 @@ function clip(value: string, max: number): string { function scopePhrase(scope: DriveBindingScope): string { switch (scope.kind) { case "account": return "the connected Drive account"; - case "sharedDrive": return `shared drive ${scope.driveId}`; - case "folder": return `folder ${scope.folderId} and its descendants`; + case "folder": return `folder ${scope.folderId}`; case "file": return `file ${scope.fileId}`; } } @@ -255,64 +269,42 @@ function emptySearchDescription(scope: DriveBindingScope, query: DriveListFilesO return clip(`${text}.`, MAX_OBSERVATION_DESCRIPTION); } -/** Scope enforcement, pagination, mapping, and observation authorization for Drive sessions. */ +/** Scope enforcement, pagination, mapping, and observation authorization for account/file sessions. */ export class DriveSessionCore { #api: DriveSessionApi; - #scope: DriveBindingScope; - #folder: FolderScope; - #prepareObservation: (fileIds: string[]) => Promise>; - #prepareWithheld: () => ObserverCheck; + #scope: DriveSessionScope; + #prepareObservation: (observations: DriveObservation[]) => Promise>; + #prepareWithheld: () => ObserverCheck; #authorize: (description: ObservationDescription) => Promise; constructor(options: DriveSessionCoreOptions) { this.#api = options.api; this.#scope = options.scope; - this.#folder = new FolderScope(options.api); this.#prepareObservation = options.prepareObservation; this.#prepareWithheld = options.prepareWithheld; this.#authorize = options.authorize; } async getScope(): Promise { - switch (this.#scope.kind) { - case "account": return { kind: "account" }; - case "sharedDrive": { - let drive = await this.#api.getDrive(this.#scope.driveId); - // Capability identity is the binding, never the provider's echo. A mismatch means the name - // describes some other drive, so refuse rather than label the binding with it. - if (drive.id !== this.#scope.driveId) outsideScope(); - await this.#authorizeIds([this.#scope.driveId], "Read Google Drive scope", - "Read the current name of the connected shared drive."); - return { kind: "sharedDrive", driveId: this.#scope.driveId, name: drive.name }; - } - case "folder": { - let root = await this.#getFolderRoot(); - await this.#authorizeIds([root.id], "Read Google Drive scope", - "Read the current name of the connected Drive folder."); - return { kind: "folder", folderId: root.id, name: root.name }; - } - case "file": { - let file = await this.#api.getFile(this.#scope.fileId); - if (file.id !== this.#scope.fileId) outsideScope(); - await this.#authorizeIds([this.#scope.fileId], "Read Google Drive scope", - "Read the current name of the connected Drive file."); - return { kind: "file", fileId: this.#scope.fileId, name: file.name }; - } - } + if (this.#scope.kind === "account") return {kind: "account"}; + let file = await this.#fetchFile(this.#scope.fileId); + if (file.id !== this.#scope.fileId) outsideScope(); + await this.#authorizeFiles([file.id], "Read Google Drive scope", + "Read the current name of the connected Drive file."); + return {kind: "file", fileId: file.id, name: file.name}; } async list(options: DriveListOptions = {}): Promise> { - if (options.directParentId) await this.#assertParent(options.directParentId); + let directParentId = options.directParentId?.trim(); + if (directParentId) await this.#assertParent(directParentId); if (this.#scope.kind === "file") return this.#exactFileCursor(); return this.#cursor({ - ...(options.directParentId ? { directParentId: options.directParentId } : {}), + ...(directParentId ? {directParentId} : {}), orderBy: orderBy(options.order), }); } async search(query: DriveSearchQuery): Promise> { - // Drive `q` has no `id =` clause, and returning the bound file unconditionally would claim it - // matched filters we never evaluated. list() already short-circuits to getFile; search cannot. if (this.#scope.kind === "file") { throw new Error( "A single-file Drive binding cannot be searched; use getEntry() to read the bound file."); @@ -327,11 +319,11 @@ export class DriveSessionCore { async getEntry(fileId: string): Promise { if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) outsideScope(); - let file = await this.#getFileInScope(fileId); - let entry = driveFileToEntry(file, this.#rootId()); - await this.#authorizeIds([file.id], "Read Google Drive metadata", + let file = await this.#fetchFile(fileId); + if (file.id !== fileId) outsideScope(); + await this.#authorizeFiles([file.id], "Read Google Drive metadata", `Read metadata for Drive file ${file.id}.`); - return entry; + return driveFileToEntry(file); } /** Validate and authorize one native file before a nested content session is created. */ @@ -341,273 +333,325 @@ export class DriveSessionCore { description: string, ): Promise { if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) outsideScope(); - let file = await this.#getFileInScope(fileId); - await this.#authorizeIds( - [file.id], - `Open ${description} from Google Drive`, - `Check current metadata for Drive file ${file.id} and open it as a ${description}.`, - ); + let file = await this.#fetchFile(fileId); + if (file.id !== fileId) outsideScope(); + await this.#authorizeFiles([file.id], `Open ${description} from Google Drive`, + `Check current metadata for Drive file ${file.id} and open it as a ${description}.`); if (file.mimeType !== expectedMimeType) { throw new Error(`The requested Drive file is not a ${description}.`); } return file.id; } - /** - * Wraps one native Docs or Sheets read in the enforcement its binding needs. - * - * A folder binding's authority is derived from a hierarchy the provider can change under it, so - * every read re-proves the file's ancestry and exact native type before the provider is contacted, - * re-checks the proved chain before the result is authorized, and discards the fetched value if - * either fails. Drive offers no ancestry-plus-content transaction, so a move landing after that - * final check still returns; the next read is what denies. Immutable scopes need none of this: - * the ID they name cannot leave them. - */ - nativeRead(fileId: string, expectedMimeType: string): NativeRead { - if (this.#scope.kind !== "folder") { - return unguardedNativeRead(description => this.#authorize(description)); - } - return async (fetch: () => Promise, observe: (value: T) => NativeObservation) => { - let proof = await this.#proveNativeFile(fileId, expectedMimeType); - let value = await fetch(); - await this.#folder.recheck([proof]); - let check = await this.#prepareObservation([fileId]); - await this.#authorize({ ...observe(value), excludeObservers: check.excludeObservers }); - check.commit(); - return value; - }; + /** Native reads need no moving-scope check for immutable account/file capabilities. */ + nativeRead(_fileId: string, _expectedMimeType: string): NativeRead { + return unguardedNativeRead(description => this.#authorize(description)); } async #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Promise> { - if (this.#scope.kind === "folder") return this.#folderCursor(query, denyEmptySearch); - let corpus: DriveCorpus = this.#scope.kind === "sharedDrive" - ? { kind: "drive", driveId: this.#scope.driveId } - : { kind: "user" }; return new CursorPager({ provider: "Google Drive", fetchPage: async pageToken => { - let page = await this.#api.listFiles({ ...query, corpus, pageToken }); - return { items: page.files, ...(page.nextPageToken ? { nextPageToken: page.nextPageToken } : {}) }; + let page = await this.#api.listFiles({...query, corpus: {kind: "user"}, pageToken}); + return {items: page.files, ...(page.nextPageToken ? {nextPageToken: page.nextPageToken} : {})}; }, - buildEntries: async files => - files.filter(file => this.#inScope(file)).map(file => driveFileToEntry(file)), + buildEntries: async files => files.map(file => driveFileToEntry(file)), authorize: this.#pageAuthorizer(query, denyEmptySearch), }); } - /** - * A cursor over one folder subtree, on the corpus the root lives in. - * - * The corpus is pinned when the cursor opens, because a Drive page token is only valid against - * the corpus that produced it: a root that moves between My Drive and a shared drive aborts the - * cursor rather than replaying its token against the other corpus. Every provider page is proved - * before anything derived from it -- entries, descriptions, observer exclusions -- exists. - * - * Bare listings scan the corpus and post-filter, so cost is linear in its size. A BFS from the - * root over `'' in parents` would fetch only in-scope rows, but it trades `DriveOrder`'s - * global ordering for per-level ordering and `fullTextContains` cannot use it, so it is a - * separate change rather than a tweak here. - */ - async #folderCursor( - query: DriveListFilesOptions, - denyEmptySearch: boolean, - ): Promise> { - let root = await this.#getFolderRoot(); - let driveId = root.driveId; - let corpus: DriveCorpus = driveId ? { kind: "drive", driveId } : { kind: "user" }; - // A page token is only valid against the corpus that produced it, and a root that changed drive - // is still a valid root, so the pin is what catches the move rather than the root check. - let requireCurrentScope = async () => { - root = await this.#getFolderRoot(); - if (root.driveId !== driveId) throw new Error(FOLDER_MOVED); - if (query.directParentId) await this.#revalidateParent(query.directParentId, root); - }; - return new CursorPager({ - provider: "Google Drive", - fetchPage: async pageToken => { - await requireCurrentScope(); - let page = await this.#api.listFiles({ - ...query, corpus, pageSize: FOLDER_PAGE_SIZE, pageToken, - }); - return { items: page.files, ...(page.nextPageToken ? { nextPageToken: page.nextPageToken } : {}) }; - }, - buildEntries: async files => { - let proofs = await this.#folder.prove(files, root); - await this.#folder.recheck(proofs); - // The root bounds the listing rather than appearing in it. `prove` admits it so `getEntry` - // can read the bound folder's own metadata, but `'' in parents` never returns it, so - // leaving it here makes a bare listing disclose one entry a narrowed one cannot. - return proofs.filter(proof => proof.file.id !== root.id) - .map(proof => driveFileToEntry(proof.file, root.id)); - }, - authorize: this.#pageAuthorizer(query, denyEmptySearch, requireCurrentScope), - // A maximum-depth page costs one ancestry batch per level, so one provider page per call is - // what keeps a single invocation inside the Worker subrequest ceiling. The cursor contract - // already requires draining to `null` rather than stopping at an empty page. - maxProviderPagesPerCall: 1, - }); - } - #exactFileCursor(): Pager { let fileId = this.#scope.kind === "file" ? this.#scope.fileId : outsideScope(); return new CursorPager({ provider: "Google Drive", - fetchPage: async () => ({ items: [await this.#api.getFile(fileId)] }), + fetchPage: async () => ({items: [await this.#api.getFile(fileId)]}), buildEntries: async files => { if (files.length !== 1 || files[0].id !== fileId) outsideScope(); return files[0].trashed === false ? [driveFileToEntry(files[0])] : []; }, - authorize: async () => { - await this.#authorizeIds([fileId], "Read Google Drive metadata", - `Read metadata for Drive file ${fileId}.`); - }, + authorize: async () => this.#authorizeFiles([fileId], "Read Google Drive metadata", + `Read metadata for Drive file ${fileId}.`), }); } #pageAuthorizer( query: DriveListFilesOptions, denyEmptySearch: boolean, - revalidate?: () => Promise, ): (entries: DriveEntry[], exhausted: boolean) => Promise { let hasDisclosedEntries = false; return async (entries, exhausted) => { - if (entries.length === 0) { - await revalidate?.(); - if (!exhausted) { - await this.#authorizeWithheld( - "Scan Google Drive metadata", listingDescription(this.#scope, query, 0)); - return; - } - if (denyEmptySearch && !hasDisclosedEntries) await this.#refuseEmptySearch(query); + // An empty nonterminal slice means this call's page budget ran out, not that nothing matches. + if (entries.length === 0 && exhausted && denyEmptySearch && !hasDisclosedEntries) { + await this.#authorizeWithheld( + "Search Google Drive metadata", emptySearchDescription(this.#scope, query)); + throw new Error("An empty Drive search cannot be shared safely."); } - await this.#authorizeIds( - entries.map(entry => entry.id), - "Read Google Drive metadata", - listingDescription(this.#scope, query, entries.length), - ); + await this.#authorizeFiles(entries.map(entry => entry.id), "Read Google Drive metadata", + listingDescription(this.#scope, query, entries.length)); if (entries.length > 0) hasDisclosedEntries = true; }; } - /** Audits an owner-only empty search, closes observer admission, and refuses to share it. */ - async #refuseEmptySearch(query: DriveListFilesOptions): Promise { - await this.#authorizeWithheld( - "Search Google Drive metadata", emptySearchDescription(this.#scope, query)); - throw new Error("An empty Drive search cannot be shared safely."); + async #assertParent(parentId: string): Promise { + if (this.#scope.kind === "file") outsideScope(); + let parent = await this.#fetchFile(parentId); + if (parent.id !== parentId) outsideScope(); + await this.#authorizeFiles([parent.id], "Check Google Drive folder", + "Check that the requested parent folder belongs to this Drive binding."); + if (parent.mimeType !== FOLDER_MIME_TYPE || parent.capabilities?.canListChildren !== true) { + throw new Error("directParentId must identify a folder whose children can be listed"); + } + } + + async #fetchFile(fileId: string): Promise { + try { + return await this.#api.getFile(fileId); + } catch (error) { + if (this.#scope.kind === "account" && error instanceof DriveApiRequestError && + !error.isAccountWide && (error.status === 403 || error.status === 404)) { + await this.#authorizeFiles([fileId], "Check Google Drive file access", + `Check whether the connected account can access Drive file ${fileId}.`); + } + throw error; + } + } + + async #authorizeFiles(fileIds: string[], title: string, description: string): Promise { + let observations: DriveObservation[] = fileIds.map(fileId => ({kind: "file", fileId})); + let check = await this.#prepareObservation(observations); + await this.#authorize({title, description, excludeObservers: check.excludeObservers}); + check.commit(); } async #authorizeWithheld(title: string, description: string): Promise { let check = this.#prepareWithheld(); try { - await this.#authorize({ title, description, excludeObservers: check.excludeObservers }); + await this.#authorize({title, description, excludeObservers: check.excludeObservers}); } catch (error) { check.discard?.(); throw error; } check.commit(); } +} + +/** Direct-child Drive access positioned at one provider-validated folder path. */ +export class DriveFolderSessionCore { + #api: DriveSessionApi; + #location: FolderLocation; + #prepareObservation: (observations: DriveObservation[]) => Promise>; + #prepareWithheld: () => ObserverCheck; + #authorize: (description: ObservationDescription) => Promise; + + constructor(options: DriveFolderSessionCoreOptions) { + this.#api = options.api; + this.#location = {rootId: options.location.rootId, folderIds: [...options.location.folderIds]}; + this.#prepareObservation = options.prepareObservation; + this.#prepareWithheld = options.prepareWithheld; + this.#authorize = options.authorize; + } - #rootId(): string | undefined { - return this.#scope.kind === "folder" ? this.#scope.folderId : undefined; + async getScope(): Promise { + let path = await this.#readLocation(); + let folder = await this.#readCurrentFolder(path); + await this.#readLocation(); + await this.#authorizeUnits([this.#folderObservation()], "Read Google Drive scope", + "Read the current name of the connected Drive folder."); + return { + kind: "folder", folderId: folder.id, rootFolderId: this.#location.rootId, name: folder.name, + }; } - #inScope(file: DriveFile): boolean { - switch (this.#scope.kind) { - case "account": return true; - case "sharedDrive": - return file.driveId === this.#scope.driveId || file.id === this.#scope.driveId; - // Membership is a live ancestry proof, not a field comparison, so a folder binding never - // reaches here. - case "folder": return false; - case "file": return file.id === this.#scope.fileId; - } + async list(options: DriveListOptions = {}): Promise> { + return this.#cursor({orderBy: orderBy(options.order)}); + } + + async search(query: DriveSearchQuery): Promise> { + let normalized = normalizeSearch(query); + return this.#cursor({ + ...normalized, + orderBy: normalized.fullTextContains ? null : orderBy(normalized.order), + }, true); + } + + async getEntry(fileId: string): Promise { + let file = await this.#requireDirectFile(fileId); + let entry = driveFileToEntry(file); + await this.#authorizeUnits( + [this.#folderObservation(), {kind: "file", fileId: file.id}], + "Read Google Drive metadata", `Read metadata for Drive file ${file.id}.`); + return entry; } - /** The bound folder, re-read and re-validated. Every folder operation starts from this. */ - async #getFolderRoot(): Promise { - if (this.#scope.kind !== "folder") outsideScope(); - return readFolderRoot(this.#scope.folderId, id => this.#fetchFile(id)); + /** Validate and authorize one direct native child before its content session is created. */ + async openNativeFile( + fileId: string, + expectedMimeType: string, + description: string, + ): Promise { + let file = await this.#requireDirectFile(fileId); + await this.#authorizeUnits( + [this.#folderObservation(), {kind: "file", fileId: file.id}], + `Open ${description} from Google Drive`, + `Check current metadata for Drive file ${file.id} and open it as a ${description}.`, + ); + if (file.mimeType !== expectedMimeType) { + throw new Error(`The requested Drive file is not a ${description}.`); + } + return file.id; } - /** One candidate's live membership proof. A direct read admits exactly one result. */ - async #proveFile(file: DriveFile, root: DriveFile): Promise { - let [proof] = await this.#folder.prove([file], root); - if (proof === undefined) { - await this.#authorizeWithheld("Check Google Drive folder", - "Check whether a requested file belongs to this Drive folder binding."); + /** Open one live, listable direct child folder and append its checked path edge. */ + async openFolder(folderId: string): Promise { + let folder = await this.#requireDirectFile(folderId); + if (folder.mimeType !== FOLDER_MIME_TYPE || folder.capabilities?.canListChildren !== true) { + await this.#authorizeWithheld( + "Check Google Drive folder", "Check whether a requested folder can be opened here."); outsideScope(); } - return proof; + await this.#authorizeUnits( + [this.#folderObservation(), {kind: "folder", fileId: folder.id}], + "Open Google Drive folder", `Open direct child folder ${folder.id}.`); + return { + rootId: this.#location.rootId, + folderIds: [...this.#location.folderIds, folder.id], + }; } - async #revalidateParent(parentId: string, root: DriveFile): Promise { - let parent = await this.#fetchFile(parentId); - if (parent.id !== parentId) outsideScope(); - let [proof] = await this.#folder.prove([parent], root); - if (!proof) outsideScope(); - await this.#folder.recheck([proof]); - this.#assertListableFolder(parent); + /** Revalidate the saved path and direct child on every native Docs or Sheets read. */ + nativeRead(fileId: string, expectedMimeType: string): NativeRead { + return async (fetch: () => Promise, observe: (value: T) => NativeObservation) => { + let before = await this.#requireDirectFile(fileId); + if (before.mimeType !== expectedMimeType) outsideScope(); + let value = await fetch(); + let after = await this.#requireDirectFile(fileId); + if (after.mimeType !== expectedMimeType) outsideScope(); + let check = await this.#prepareObservation( + [this.#folderObservation(), {kind: "file", fileId}]); + await this.#authorize({...observe(value), excludeObservers: check.excludeObservers}); + check.commit(); + return value; + }; } - async #proveNativeFile(fileId: string, expectedMimeType: string): Promise { - let root = await this.#getFolderRoot(); - let file = await this.#fetchFile(fileId); - if (file.id !== fileId || file.mimeType !== expectedMimeType) outsideScope(); - return this.#proveFile(file, root); + async #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Promise> { + let initial = await this.#readLocation(); + let driveId = initial[0].driveId; + let corpus = driveId ? {kind: "drive" as const, driveId} : {kind: "user" as const}; + let requireCurrentLocation = async () => { + let path = await this.#readLocation(); + if (path[0].driveId !== driveId) throw new Error(FOLDER_MOVED); + return path; + }; + return new CursorPager({ + provider: "Google Drive", + fetchPage: async pageToken => { + await requireCurrentLocation(); + let page = await this.#api.listFiles({ + ...query, directParentId: this.#currentFolderId(), corpus, pageToken, + }); + return {items: page.files, ...(page.nextPageToken ? {nextPageToken: page.nextPageToken} : {})}; + }, + buildEntries: async files => { + let path = await requireCurrentLocation(); + let parent = path[path.length - 1]; + if (files.some(file => !isDirectChild(file, parent))) outsideScope(); + return files.map(file => driveFileToEntry(file)); + }, + authorize: this.#pageAuthorizer(query, denyEmptySearch, requireCurrentLocation), + }); } - async #assertParent(parentId: string): Promise { - if (this.#scope.kind === "file") outsideScope(); - let parent = await this.#getFileInScope(parentId); - await this.#authorizeIds([parent.id], "Check Google Drive folder", - "Check that the requested parent folder belongs to this Drive binding."); - this.#assertListableFolder(parent); + #pageAuthorizer( + query: DriveListFilesOptions, + denyEmptySearch: boolean, + revalidate: () => Promise, + ): (entries: DriveEntry[], exhausted: boolean) => Promise { + let hasDisclosedEntries = false; + let scope: DriveBindingScope = {kind: "folder", folderId: this.#currentFolderId()}; + return async (entries, exhausted) => { + await revalidate(); + // An empty nonterminal slice means this call's page budget ran out, not that nothing matches. + if (entries.length === 0 && exhausted && denyEmptySearch && !hasDisclosedEntries) { + await this.#authorizeWithheld( + "Search Google Drive metadata", emptySearchDescription(scope, query)); + throw new Error("An empty Drive search cannot be shared safely."); + } + let observations: DriveObservation[] = [ + this.#folderObservation(), + ...entries.map(entry => ({kind: "file" as const, fileId: entry.id})), + ]; + await this.#authorizeUnits(observations, "Read Google Drive metadata", + listingDescription(scope, query, entries.length)); + if (entries.length > 0) hasDisclosedEntries = true; + }; } - #assertListableFolder(file: DriveFile): void { - if (file.mimeType !== FOLDER_MIME_TYPE || file.capabilities?.canListChildren !== true) { - throw new Error("directParentId must identify a folder whose children can be listed"); + async #readLocation(): Promise { + return readFolderLocation(this.#location, ids => this.#api.getScopeNodes(ids)); + } + + async #readCurrentFolder(path: DriveScopeNode[]): Promise { + let current = path[path.length - 1]; + let folder = await this.#tryFetchFile(current.id); + if (!folder || folder.id !== current.id || folder.mimeType !== FOLDER_MIME_TYPE || + folder.capabilities?.canListChildren !== true || folder.trashed !== false || + folder.driveId !== path[0].driveId || + (path.length > 1 && !isDirectChild(folder, path[path.length - 2]))) { + outsideScope(); } + return folder; } - async #getFileInScope(fileId: string): Promise { - let file = await this.#fetchFile(fileId); - if (file.id !== fileId) outsideScope(); - if (this.#scope.kind === "folder") { - let proof = await this.#proveFile(file, await this.#getFolderRoot()); - await this.#folder.recheck([proof]); - return file; + async #requireDirectFile(fileId: string): Promise { + let path = await this.#readLocation(); + let file = await this.#tryFetchFile(fileId); + if (!file || file.id !== fileId || !isDirectChild(file, path[path.length - 1])) { + await this.#authorizeWithheld( + "Check Google Drive folder", + "Check whether a requested file is a direct child of this Drive folder."); + outsideScope(); } - if (!this.#inScope(file)) outsideScope(); + await this.#readLocation(); return file; } - /** `files.get`, translating a denial into this binding's refusal where that is what it means. */ - async #fetchFile(fileId: string): Promise { + async #tryFetchFile(fileId: string): Promise { try { return await this.#api.getFile(fileId); - } catch (err) { - if (err instanceof DriveApiRequestError && !err.isAccountWide && - (err.status === 403 || err.status === 404)) { - if (this.#scope.kind === "sharedDrive" || this.#scope.kind === "folder") outsideScope(); - if (this.#scope.kind === "account") { - // Tracked like a successful read rather than merely hidden from today's observers. An - // ObservationDescription's exclusion binds only the observers named in it — there is no - // per-thread hiding — so with none registered the result would be disclosed with nothing - // durable recorded, and a collaborator admitted later would inherit the history unchecked. - // Committing the id makes every future addObserver() verify it, and a file this account - // cannot reach is one no observer can reach either, so that admission fails closed. - await this.#authorizeIds([fileId], "Check Google Drive file access", - `Check whether the connected account can access Drive file ${fileId}.`); - } + } catch (error) { + if (error instanceof DriveApiRequestError && !error.isAccountWide && + (error.status === 403 || error.status === 404)) { + return undefined; } - throw err; + throw error; } } - async #authorizeIds(fileIds: string[], title: string, description: string): Promise { - let check = await this.#prepareObservation(fileIds); - await this.#authorize({ title, description, excludeObservers: check.excludeObservers }); + #currentFolderId(): string { + return this.#location.folderIds[this.#location.folderIds.length - 1]; + } + + #folderObservation(): DriveObservation { + return {kind: "folder", fileId: this.#currentFolderId()}; + } + + async #authorizeUnits( + observations: DriveObservation[], title: string, description: string, + ): Promise { + let check = await this.#prepareObservation(observations); + await this.#authorize({title, description, excludeObservers: check.excludeObservers}); + check.commit(); + } + + async #authorizeWithheld(title: string, description: string): Promise { + let check = this.#prepareWithheld(); + try { + await this.#authorize({title, description, excludeObservers: check.excludeObservers}); + } catch (error) { + check.discard?.(); + throw error; + } check.commit(); } } diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index 70a4e15a34..0fb3717daa 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -4,38 +4,18 @@ import type { GoogleSpreadsheetReadSession } from "./sheets-types"; /** * A pagination cursor. * - * This is an RPC object. Call `next()` repeatedly on the same cursor to fetch subsequent batches, - * and dispose the cursor when finished. Drain it until `next()` returns `null`: an empty array - * means this call ran out of budget while filtering, not that there is nothing left. + * Call `next()` repeatedly on the same RPC object and dispose it when finished. Drain it until + * `next()` returns `null`; an empty array means only that this call made no visible progress. */ export interface Cursor { - /** The next batch, `[]` when this call found none but more remain, or `null` once exhausted. */ + /** The next batch, `[]` when more work remains, or `null` once exhausted. */ next(): Promise; } -/** - * The immutable resource scope of a Google Drive binding. - * - * Account scope is everything the connected account can read in Drive, including files in shared - * drives. `list()` and `search()` cover My Drive plus shared-drive items the account has accessed; - * `getEntry()` resolves any ID the account can read, so a file may be readable by ID without ever - * appearing in a listing. Shared-drive scope means a Google Workspace shared drive, not an - * ordinary or shared folder; its files belong to the organization rather than an individual. - * - * Folder scope is one folder plus every file and folder currently beneath it, at any depth. The - * folder may live in My Drive — including one someone else shared from theirs — or inside a shared - * drive; a shared drive's own root is not a folder binding, it is the shared-drive scope. Every - * operation re-derives membership from live Drive metadata, so an item that moves out stops being - * readable, one that moves in becomes readable, and a shortcut is listed but never followed to its - * target. `parentId` is withheld for the bound folder itself, since its container is outside the - * binding. - * - * Names are current display metadata; stable IDs are capability identity. - */ +/** The immutable resource scope of a Google Drive binding or positioned folder capability. */ export type DriveScope = | { kind: "account" } - | { kind: "sharedDrive"; driveId: string; name: string } - | { kind: "folder"; folderId: string; name: string } + | { kind: "folder"; folderId: string; rootFolderId: string; name: string } | { kind: "file"; fileId: string; name: string }; /** Owner metadata for a Drive entry. Absent for items in shared drives. */ @@ -54,12 +34,7 @@ export type DriveShortcut = { targetMimeType?: string; }; -/** - * Read-only metadata for one entry within the immutable binding scope. - * - * `list()` and `search()` never return trashed items. `getEntry()` can, and this type does not - * say whether they are — there is no `trashed` field. - */ +/** Read-only metadata for one entry within the immutable binding scope. */ export type DriveEntry = { /** Stable Drive file ID. */ id: string; @@ -87,38 +62,24 @@ export type DriveEntry = { /** Supported ordering for Drive listing and structured search. */ export type DriveOrder = - /** Most recently modified entries first. */ | "modifiedTimeDesc" - /** Least recently modified entries first. */ | "modifiedTimeAsc" - /** Names in ascending order. */ | "nameAsc" - /** Names in descending order. */ | "nameDesc"; -/** Options for listing entries within the binding scope. */ +/** Options for listing entries within an account or exact-file binding. */ export type DriveListOptions = { - /** Limit results to direct children of this folder; descendants are not included. */ + /** Limit an account listing to one folder's direct children. */ directParentId?: string; /** Result order. Defaults to most recently modified first. */ order?: DriveOrder; }; -/** - * Structured values for searching Drive metadata. - * - * Callers provide values only, never raw Drive query syntax. Populated filter fields are AND-ed; - * values within `mimeTypes` are OR-ed. - */ +/** Structured, AND-combined values for searching Drive metadata. */ export type DriveSearchQuery = { /** Match entries whose name starts with this value. */ namePrefix?: string; - /** - * Match entries whose indexed text contains this value. - * - * This is the one filter that reaches past metadata: Drive indexes a file's body text, - * description and OCR text. Results still carry metadata alone. - */ + /** Match entries whose indexed body text, description, or OCR text contains this value. */ fullTextContains?: string; /** Match entries having any one of these MIME types. */ mimeTypes?: string[]; @@ -126,80 +87,66 @@ export type DriveSearchQuery = { modifiedAfter?: string; /** Match entries modified before this RFC 3339 timestamp. */ modifiedBefore?: string; - /** Limit matches to direct children of this folder; descendants are not included. */ + /** Limit account matches to one folder's direct children. */ directParentId?: string; /** Result order. Cannot be combined with `fullTextContains`. */ order?: DriveOrder; }; -/** - * Read-only metadata discovery and native Google Docs/Sheets access within the selected Drive scope. - * - * Every Drive binding provides this. Methods do not follow shortcut targets, edit Drive, or read - * non-native file contents, and the native sessions they return are read-only. - */ +/** Listing options for the positioned folder's direct children. */ +export type DriveFolderListOptions = Pick; + +/** Provider search filters for the positioned folder's direct children. */ +export type DriveFolderSearchQuery = Omit; + +/** Read-only Drive metadata discovery and native Google Docs/Sheets access. */ export interface GoogleDriveReadSession { - /** Return the immutable binding scope with current display metadata. */ + /** Return this capability's immutable scope with current display metadata. */ getScope(): Promise; - /** - * List entries in the binding scope, most recently modified first by default. - * - * A folder binding lists its whole subtree at every depth. `directParentId` narrows any binding - * to one folder's direct children, never recursive descendants, and throws when that folder is - * outside the binding scope. - */ + /** List entries in an account binding, or return the one exact-file entry. */ list(options?: DriveListOptions): Promise>; /** - * Search with structured values. At least one filter other than `order` is required. Populated filter - * fields are AND-ed, while values within `mimeTypes` are OR-ed. `order` cannot be combined with - * `fullTextContains`; omitting it for full-text search preserves Drive's relevance order. - * - * A folder binding searches its whole subtree; matches outside it are discarded before anything - * is disclosed, so a page can come back empty with results still ahead — drain to `null`. - * - * Throws on a file-scoped binding; a single file cannot be searched. Use `getEntry()` to read it. - * Also throws when no entries match because an owner-relative negative result cannot be shared safely. + * Search the connected account with structured values. Exact-file bindings cannot be searched. + * An empty result is withheld because it is owner-relative and cannot be shared safely. */ search(query: DriveSearchQuery): Promise>; /** - * Return metadata for one file ID. - * - * A file binding throws without contacting Drive when the ID is not the bound file. A shared-drive - * binding throws when the file is not in that drive. A folder binding throws unless the file is - * currently inside its subtree. An account binding returns any file the connected account can - * read, including files in shared drives it is a member of. - * - * Unlike `list()` and `search()`, this can return a trashed file: those methods always exclude - * trash, while a direct get does not, and {@link DriveEntry} has no `trashed` field. A folder - * binding is the exception — trash is outside its subtree, so it throws instead. + * Return one entry. An account binding accepts any accessible ID; an exact-file binding accepts + * only its bound ID. Either may return trash. */ getEntry(fileId: string): Promise; - /** - * Open an in-scope native Google Doc with MIME type - * `application/vnd.google-apps.document`. Other MIME types, including folders and shortcuts, are - * rejected. The returned RPC capability supports promise pipelining and must be disposed when - * finished. - * - * A folder binding re-proves the file's place in the subtree on every call the returned session - * makes, so a document moved out stops answering even through an already-open session. - */ + + /** Open an in-scope native Google Doc as an independently disposable read capability. */ openGoogleDoc(fileId: string): Promise; - /** - * Open an in-scope native Google Sheet with MIME type - * `application/vnd.google-apps.spreadsheet`. Other MIME types, including folders and shortcuts, - * are rejected. The returned RPC capability supports promise pipelining and must be disposed when - * finished. - * - * A folder binding re-proves the file's place in the subtree on every call the returned session - * makes, so a spreadsheet moved out stops answering even through an already-open session. - */ + /** Open an in-scope native Google Sheet as an independently disposable read capability. */ openGoogleSheet(fileId: string): Promise; } -/** The access provided by an account, shared-drive, or folder binding. */ +/** Read-only navigation within the originally selected folder. */ +export interface GoogleDriveFolderSession extends Pick { + /** List only the positioned folder's direct children. */ + list(options?: DriveFolderListOptions): Promise>; + + /** Search only the positioned folder's direct children using provider-side filters. */ + search(query: DriveFolderSearchQuery): Promise>; + + /** Return one live direct child. A nested descendant or a trashed entry is rejected. */ + getEntry(fileId: string): Promise; + + /** Open a live direct-child native Google Doc as an independently disposable capability. */ + openGoogleDoc(fileId: string): Promise; + + /** Open a live direct-child native Google Sheet as an independently disposable capability. */ + openGoogleSheet(fileId: string): Promise; + + /** Open a live direct child folder as an independently disposable capability. */ + openFolder(folderId: string): Promise; +} + +/** The established account and exact-file Drive read capability. */ export type GoogleDriveSession = GoogleDriveReadSession; diff --git a/packages/gatekeeper-google/src/google-configurators.ts b/packages/gatekeeper-google/src/google-configurators.ts index 9d4d4f61b3..a4bb5491bd 100644 --- a/packages/gatekeeper-google/src/google-configurators.ts +++ b/packages/gatekeeper-google/src/google-configurators.ts @@ -14,7 +14,6 @@ import type { ConfiguratorOption } from "./configurator/configurator-option"; import type { DriveAccountConfiguratorRpc } from "./configurator/drive-account-configurator-types"; import type { DriveFileConfiguratorRpc } from "./configurator/drive-file-configurator-types"; import type { DriveFolderConfiguratorRpc } from "./configurator/drive-folder-configurator-types"; -import type { SharedDriveConfiguratorRpc } from "./configurator/shared-drive-configurator-types"; /** * Mints an access token for a configurator, forwarding `AccessTokenRequest` to the `UserAccount` @@ -247,25 +246,9 @@ export class GoogleSheetsConfiguratorUI extends RpcTarget implements GoogleSheet } } -@validateRpc() -export class DriveAccountConfiguratorUI extends RpcTarget implements DriveAccountConfiguratorRpc {} @validateRpc() -export class SharedDriveConfiguratorUI extends RpcTarget implements SharedDriveConfiguratorRpc { - constructor(getToken: () => Promise) { - super(); - googleTokenGetters.set(this, getToken); - } - - async listSharedDrives(query: string): Promise { - let drive = new DriveApi(googleTokenProvider(this)); - let drives = await withDriveApiEnabled( - "Shared-drive search requires the Google Drive API to be enabled for this OAuth project.", - () => drive.listAllDrives({ namePrefix: query }), - ); - return drives.map(item => ({ value: item.id, title: item.name, subtitle: item.id })); - } -} +export class DriveAccountConfiguratorUI extends RpcTarget implements DriveAccountConfiguratorRpc {} @validateRpc() export class DriveFileConfiguratorUI extends RpcTarget implements DriveFileConfiguratorRpc { @@ -295,9 +278,14 @@ export class DriveFileConfiguratorUI extends RpcTarget implements DriveFileConfi @validateRpc() export class DriveFolderConfiguratorUI extends RpcTarget implements DriveFolderConfiguratorRpc { - constructor(getToken: () => Promise) { + #hasSharedDriveDiscovery: () => Promise; + + constructor( + getToken: () => Promise, + hasSharedDriveDiscovery: () => Promise) { super(); googleTokenGetters.set(this, getToken); + this.#hasSharedDriveDiscovery = hasSharedDriveDiscovery; } async listDriveFolders(query: string): Promise { @@ -306,10 +294,7 @@ export class DriveFolderConfiguratorUI extends RpcTarget implements DriveFolderC "Drive folder search requires the Google Drive API to be enabled for this OAuth project.", () => drive.listFiles({ mimeType: FOLDER_MIME_TYPE, namePrefix: query }), ); - // A shared drive's root carries the drive's own ID. It is the Shared Drive resource, and - // offering it here too would make a folder binding a second, weaker name for a whole drive. - return files.filter(file => - file.id !== file.driveId && file.capabilities?.canListChildren === true).map(file => ({ + return files.filter(file => file.capabilities?.canListChildren === true).map(file => ({ value: file.id, title: file.name, subtitle: file.driveId @@ -317,4 +302,20 @@ export class DriveFolderConfiguratorUI extends RpcTarget implements DriveFolderC : file.owners?.[0]?.displayName ?? file.owners?.[0]?.emailAddress ?? "My Drive", })); } + + async listSharedDrives(query: string): Promise { + if (!await this.#hasSharedDriveDiscovery()) { + throw new Error("Enable Workspace Shared Drive discovery above, then try again."); + } + let drive = new DriveApi(googleTokenProvider(this)); + let drives = await withDriveApiEnabled( + "Shared Drive discovery requires the Google Drive API to be enabled for this OAuth project.", + () => drive.listAllDrives({namePrefix: query}), + ); + return drives.map(sharedDrive => ({ + value: sharedDrive.id, + title: sharedDrive.name, + subtitle: "Workspace Shared Drive", + })); + } } diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index bb884ed35d..7ca10f90dc 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -18,14 +18,17 @@ import { computeReplaceOperations, docTabToMarkdown, markdownToDocRequests, type DocTabSnapshot, } from "./markdown-converter"; import { DriveApi, DriveApiRequestError } from "./drive-api"; -import { driveObserverTracker } from "./drive-observers"; -import { readFolderRoot } from "./drive-folder-scope"; +import { driveObserverTracker, type DriveObservation } from "./drive-observers"; +import { outsideScope, readFolderRoot, type FolderLocation } from "./drive-folder-scope"; import { - DriveSessionCore, driveModifiedTime, GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, - unguardedNativeRead, - type DriveBindingScope, type DriveSessionCoreOptions, type NativeRead, + DriveFolderSessionCore, DriveSessionCore, driveModifiedTime, + GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, requireDriveBindingScope, unguardedNativeRead, + type DriveBindingScope, type NativeRead, } from "./drive-session"; -import type { DriveEntry, DriveListOptions, DriveSearchQuery, GoogleDriveSession } from "./drive-types"; +import type { + DriveEntry, DriveListOptions, DriveSearchQuery, GoogleDriveFolderSession, + GoogleDriveReadSession, GoogleDriveSession, +} from "./drive-types"; import { BigQueryApi, DEFAULT_MAX_BYTES_BILLED } from "./bigquery-api"; import { BigQueryDataset, BigQueryDryRunResult, BigQueryField, BigQueryProject, @@ -56,7 +59,6 @@ import { DriveAccountConfiguratorUI, DriveFileConfiguratorUI, DriveFolderConfiguratorUI, - SharedDriveConfiguratorUI, } from "./google-configurators"; import BIGQUERY_CONFIGURATOR_HTML from "./generated/bigquery-configurator-ui.txt"; import CALENDAR_CONFIGURATOR_HTML from "./generated/calendar-configurator-ui.txt"; @@ -66,15 +68,13 @@ import GOOGLE_SHEETS_CONFIGURATOR_HTML from "./generated/google-sheets-configura import DRIVE_ACCOUNT_CONFIGURATOR_HTML from "./generated/drive-account-configurator-ui.txt"; import DRIVE_FILE_CONFIGURATOR_HTML from "./generated/drive-file-configurator-ui.txt"; import DRIVE_FOLDER_CONFIGURATOR_HTML from "./generated/drive-folder-configurator-ui.txt"; -import SHARED_DRIVE_CONFIGURATOR_HTML from "./generated/shared-drive-configurator-ui.txt"; import GOOGLE_LOGO_SVG from "./google-logo.svg"; import { obsContext } from "./observability.js"; import { AccessTokenCache, AccessTokenRequest, ACCESS_TOKEN_EXPIRY_SAFETY_MS } from "./auth-retry"; import { BIGQUERY_HOST, BIGQUERY_RESOURCE, GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DOC_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, - GOOGLE_DRIVE_RESOURCE, - GOOGLE_SHARED_DRIVE_RESOURCE, GOOGLE_SHEETS_RESOURCE, RESOURCE_BY_KIND, SUPPORTED_RESOURCES, + GOOGLE_DRIVE_RESOURCE, GOOGLE_SHEETS_RESOURCE, RESOURCE_BY_KIND, SUPPORTED_RESOURCES, grantedResourceUrlPatterns, hasDriveResourceGrant, parseResourceUrl, recordedResourceUrlPatterns, type RecordedResourceGrant, } from "./resources"; @@ -425,9 +425,20 @@ export class UserAccount extends DurableObject { } /** Prepare a reconnect or scope-expansion attempt for this account. */ - async prepareReconnect(initiationNonce: string, requestedResources: string[]) { + async prepareReconnect( + initiationNonce: string, requestedResources: string[], requestDriveReadonly = false) { + let grantedScopes = this.ctx.storage.kv.get("grantedScopes") ?? []; + let preserveDriveDiscovery = grantedScopes.includes( + "https://www.googleapis.com/auth/drive.readonly") || + grantedScopes.includes("https://www.googleapis.com/auth/drive"); prepareOAuthFlow( - this.ctx.storage.kv, initiationNonce, requestedResources, "reconnect", Date.now()); + this.ctx.storage.kv, + initiationNonce, + requestedResources, + "reconnect", + Date.now(), + requestDriveReadonly || preserveDriveDiscovery, + ); } /** @@ -447,6 +458,20 @@ export class UserAccount extends DurableObject { return recordedResourceUrlPatterns(this.#recordedGrant()); } + async hasSharedDriveDiscovery(): Promise { + let scopes = this.ctx.storage.kv.get("grantedScopes") ?? []; + return scopes.includes("https://www.googleapis.com/auth/drive.readonly") || + scopes.includes("https://www.googleapis.com/auth/drive"); + } + + async requestSharedDriveDiscovery(): Promise<{url?: string}> { + if (await this.hasSharedDriveDiscovery()) return {}; + let requestedResources = await this.getRequestableResourceUrlPatterns(); + let initiationNonce = generateNonce(); + await this.prepareReconnect(initiationNonce, requestedResources, true); + return {url: `${getBaseUrl(this.env)}/${this.ctx.id.toString()}/${initiationNonce}`}; + } + #recordedGrant(): RecordedResourceGrant { let resourceUrlPatterns = this.ctx.storage.kv.get("grantedResources"); let oauthScopes = this.ctx.storage.kv.get("grantedScopes"); @@ -744,13 +769,11 @@ export class GatekeeperUserImpl extends WorkerEntrypoint { - let getToken = async (opts?: AccessTokenRequest) => { - let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId); - let obj = this.ctx.exports.UserAccount.get(id); - return await obj.getAccessToken(opts); - }; + let id = this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId); + let account = this.ctx.exports.UserAccount.get(id); + let getToken = async (opts?: AccessTokenRequest) => await account.getAccessToken(opts); if (resourceUrlPattern === BIGQUERY_RESOURCE.urlPattern) { return { @@ -810,17 +831,21 @@ export class GatekeeperUserImpl extends WorkerEntrypoint account.hasSharedDriveDiscovery(); return { iframeHtml: DRIVE_FOLDER_CONFIGURATOR_HTML, - ui: new RpcStub(new DriveFolderConfiguratorUI(getToken)), + ui: new RpcStub(new DriveFolderConfiguratorUI(getToken, hasSharedDriveDiscovery)), + ...(!await hasSharedDriveDiscovery() ? { + authorization: { + title: "Enable Workspace Shared Drive discovery", + description: "Google requires permission to read all Drive files your account can " + + "access to list Workspace Shared Drives. This is optional; each connection still " + + "exposes only its selected folder.", + request: new RpcStub(() => account.requestSharedDriveDiscovery()), + }, + } : {}), }; } @@ -925,7 +950,7 @@ export interface GoogleVerifierApi extends GatekeeperUserVerifier { hasCalendarWriterAccess(calendarId: string): Promise; hasCalendarFreeBusyAccess(calendarId: string): Promise; hasDatasetAccess(projectId: string, datasetId: string): Promise; - verifyDriveFiles(fileIds: string[], listableFolderId?: string): Promise; + verifyDriveObservations(observations: DriveObservation[]): Promise; } @validateRpc() @@ -991,19 +1016,19 @@ export class GoogleVerifier extends WorkerEntrypoint } } - async verifyDriveFiles( - fileIds: string[], listableFolderId?: string, + async verifyDriveObservations( + observations: DriveObservation[], ): Promise { let account = this.ctx.exports.UserAccount.get( this.ctx.exports.UserAccount.idFromString(this.ctx.props.userObjectId)); let granted = await account.getGrantedResourceUrlPatterns(); let baselineAllowed = hasDriveResourceGrant(granted); - if (!baselineAllowed) return { baselineAllowed, allowed: fileIds.map(() => false) }; + if (!baselineAllowed) return { baselineAllowed, allowed: observations.map(() => false) }; let api = new DriveApi(opts => this.#getToken(opts)); return { baselineAllowed, - allowed: await api.checkFileAccess(fileIds, listableFolderId), + allowed: await api.checkObservations(observations), }; } } @@ -2788,7 +2813,7 @@ export class GoogleDriveGatekeeperImpl } async describe(): Promise { - let { scope } = this.ctx.props; + let scope = this.#scope; if (scope.kind === "account") { return { url: GOOGLE_DRIVE_RESOURCE.urlPattern, @@ -2799,16 +2824,6 @@ export class GoogleDriveGatekeeperImpl }; } let api = new DriveApi(opts => this.#getAccessToken(opts)); - if (scope.kind === "sharedDrive") { - let drive = await api.getDrive(scope.driveId); - return { - url: `https://drive.google.com/drive/folders/${encodeURIComponent(scope.driveId)}`, - title: drive.name, - snippet: `Find files and folders and read native Google Docs and Sheets in organization-owned shared drive "${drive.name}"`, - suggestedBindingName: "GOOGLE_SHARED_DRIVE", - tsType: "GoogleDriveSession", - }; - } if (scope.kind === "folder") { // Validated here too, so a hand-built resource URL fails at connect rather than minting a // presentable binding whose every call then refuses. @@ -2817,9 +2832,9 @@ export class GoogleDriveGatekeeperImpl // The natural browser URL, not the internal `_resource` selector the grant is keyed on. url: `https://drive.google.com/drive/folders/${encodeURIComponent(scope.folderId)}`, title: folder.name, - snippet: `Find files and folders and read native Google Docs and Sheets in Drive folder "${folder.name}" and everything beneath it`, + snippet: `List and search direct children, navigate child folders, and read native Google Docs and Sheets in Drive folder "${folder.name}"`, suggestedBindingName: "GOOGLE_DRIVE_FOLDER", - tsType: "GoogleDriveSession", + tsType: "GoogleDriveFolderSession", }; } let file = await api.getFile(scope.fileId); @@ -2847,9 +2862,9 @@ export class GoogleDriveGatekeeperImpl new DriveApi(getDriveAccessToken), new GoogleDocsApi(getDriveAccessToken), new GoogleSheetsApi(getDriveAccessToken), - this.ctx.props.scope, + this.#scope, approvalQueue.dup(), - fileIds => observerTracker.prepareObservation(fileIds), + observations => observerTracker.prepareObservation(observations), () => observerTracker.prepareWithheld(), ); } @@ -2861,11 +2876,14 @@ export class GoogleDriveGatekeeperImpl throw new Error("Google Drive gatekeeper has no writable actions to revert"); } - #observerTracker(): ObserverTracker> { + #observerTracker(): ObserverTracker> { return driveObserverTracker>( - this.ctx.storage.kv, this.ctx.props.scope, - (verifier, fileIds, listableFolderId) => - verifier.verifyDriveFiles([...fileIds], listableFolderId)); + this.ctx.storage.kv, this.#scope, + (verifier, observations) => verifier.verifyDriveObservations([...observations])); + } + + get #scope(): DriveBindingScope { + return requireDriveBindingScope(this.ctx.props.scope); } async addObserver(id: string, user: Fetcher): Promise { @@ -2969,13 +2987,19 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession /** Drive RPC session implementation, exported for workerd contract coverage. */ @validateRpc() -export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSession { - #core: DriveSessionCore; - #coreOptions: Omit; +export class GoogleDriveSessionImpl extends RpcTarget + implements GoogleDriveReadSession, GoogleDriveFolderSession { + #core: DriveSessionCore | DriveFolderSessionCore; #driveApi: DriveApi; #docsApi: GoogleDocsApi; #sheetsApi: GoogleSheetsApi; + #scope: DriveBindingScope; + #location?: FolderLocation; #approvalQueue: RpcStub; + #prepareObservation: ( + observations: DriveObservation[], + ) => Promise>; + #prepareWithheld: () => ObserverCheck; constructor( driveApi: DriveApi, @@ -2983,15 +3007,21 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess sheetsApi: GoogleSheetsApi, scope: DriveBindingScope, approvalQueue: RpcStub, - prepareObservation: (fileIds: string[]) => Promise>, - prepareWithheld: () => ObserverCheck, + prepareObservation: ( + observations: DriveObservation[], + ) => Promise>, + prepareWithheld: () => ObserverCheck, + location?: FolderLocation, ) { super(); this.#driveApi = driveApi; this.#docsApi = docsApi; this.#sheetsApi = sheetsApi; + this.#scope = scope; + this.#location = location; this.#approvalQueue = approvalQueue; - this.#coreOptions = { api: driveApi, scope, prepareObservation, prepareWithheld }; + this.#prepareObservation = prepareObservation; + this.#prepareWithheld = prepareWithheld; this.#core = this.#coreFor(this.#approvalQueue); } @@ -3011,41 +3041,26 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess return this.#cursor(core => core.search(query)); } - /** - * A core with this session's authority, authorizing through `queue`. - * - * Scope and observer tracking are identical in every case; only the approval queue differs, - * which is the whole reason a cursor needs a core of its own. - */ - #coreFor(queue: RpcStub): DriveSessionCore { - return new DriveSessionCore({ - ...this.#coreOptions, - authorize: description => queue.authorizeObservation(description), - }); + getEntry(fileId: string): Promise { + return this.#core.getEntry(fileId); } - /** - * A cursor paging through an approval-queue stub of its own, disposed with the cursor. - * - * The caller owns a returned cursor separately from this session and may keep paging it after - * disposing the session, so a cursor sharing the session's stub would fail mid-pagination. - */ - async #cursor( - open: (core: DriveSessionCore) => Promise>, - ): Promise> { + async openFolder(folderId: string): Promise { let queue = this.#approvalQueue.dup(); try { - return new RpcCursor(await open(this.#coreFor(queue)), queue); + let core = this.#coreFor(queue); + if (!(core instanceof DriveFolderSessionCore)) outsideScope(); + let location = await core.openFolder(folderId); + return new GoogleDriveSessionImpl( + this.#driveApi, this.#docsApi, this.#sheetsApi, this.#scope, queue, + this.#prepareObservation, this.#prepareWithheld, location, + ); } catch (error) { queue[Symbol.dispose](); throw error; } } - getEntry(fileId: string): Promise { - return this.#core.getEntry(fileId); - } - async openGoogleDoc(fileId: string): Promise { return this.#openNative(fileId, GOOGLE_DOC_MIME_TYPE, "Google Doc", (documentId, queue, read) => @@ -3058,13 +3073,37 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess new GoogleSpreadsheetSessionImpl(this.#sheetsApi, spreadsheetId, queue, read)); } - /** - * Opens one native child on an approval queue and a core of its own. - * - * The child outlives this session, so it needs its own queue stub — and the guard that revalidates - * its every read has to authorize through that same stub, which is why the core is built here - * rather than reusing the session's. Ownership passes to the child only once it exists. - */ + #coreFor(queue: RpcStub): DriveSessionCore | DriveFolderSessionCore { + let common = { + api: this.#driveApi, + prepareObservation: this.#prepareObservation, + prepareWithheld: this.#prepareWithheld, + authorize: (description: ObservationDescription) => queue.authorizeObservation(description), + }; + if (this.#scope.kind === "folder") { + return new DriveFolderSessionCore({ + ...common, + location: this.#location ?? { + rootId: this.#scope.folderId, + folderIds: [this.#scope.folderId], + }, + }); + } + return new DriveSessionCore({...common, scope: this.#scope}); + } + + async #cursor( + open: (core: DriveSessionCore | DriveFolderSessionCore) => Promise>, + ): Promise> { + let queue = this.#approvalQueue.dup(); + try { + return new RpcCursor(await open(this.#coreFor(queue)), queue); + } catch (error) { + queue[Symbol.dispose](); + throw error; + } + } + async #openNative( fileId: string, mimeType: string, diff --git a/packages/gatekeeper-google/src/oauth-flow.ts b/packages/gatekeeper-google/src/oauth-flow.ts index 850a62d100..eced56a2a3 100644 --- a/packages/gatekeeper-google/src/oauth-flow.ts +++ b/packages/gatekeeper-google/src/oauth-flow.ts @@ -2,6 +2,7 @@ import { resourceUrlPatternsToOAuthScopes, validateResourceUrlPatterns } from ". const FLOW_KEY = "oauthFlow"; const NONCE_LIFETIME_MS = 10 * 60 * 1000; +const DRIVE_READONLY_SCOPE = "https://www.googleapis.com/auth/drive.readonly"; const LEGACY_FLOW_KEYS = [ "nonce", "requestedScopes", "requestedResources", "reconnecting", "ephemeral", ] as const; @@ -14,6 +15,7 @@ export type StoredOAuthFlow = { stage: "initiation" | "oauth"; mode: OAuthFlowMode; requestedResources: string[]; + requestDriveReadonly?: boolean; oauthRedirectUri?: string; }; @@ -44,7 +46,7 @@ function matchesFlow(flow: StoredOAuthFlow | undefined, stage: StoredOAuthFlow[" export function prepareOAuthFlow(kv: SynchronousKv, initiationNonce: string, requestedResources: readonly string[], mode: OAuthFlowMode, - now: number): void { + now: number, requestDriveReadonly = false): void { validateResourceUrlPatterns(requestedResources); for (let key of LEGACY_FLOW_KEYS) kv.delete(key); kv.put(FLOW_KEY, { @@ -53,6 +55,7 @@ export function prepareOAuthFlow(kv: SynchronousKv, initiationNonce: string, stage: "initiation", mode, requestedResources: [...requestedResources], + ...(requestDriveReadonly ? {requestDriveReadonly: true} : {}), }); } @@ -73,7 +76,9 @@ export function beginStoredOAuthFlow(kv: SynchronousKv, initiationNonce: string, ...flow, value: oauthNonce, expiresAt: now + NONCE_LIFETIME_MS, stage: "oauth", oauthRedirectUri, }); - return { oauthNonce, scopes: resourceUrlPatternsToOAuthScopes(flow.requestedResources) }; + let scopes = new Set(resourceUrlPatternsToOAuthScopes(flow.requestedResources)); + if (flow.requestDriveReadonly) scopes.add(DRIVE_READONLY_SCOPE); + return { oauthNonce, scopes: [...scopes] }; } export function claimStoredOAuthFlow(kv: SynchronousKv, oauthNonce: string, now: number) diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index eb1a1be4cb..5ddaaae187 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -85,29 +85,14 @@ export const GOOGLE_DRIVE_RESOURCE: SupportedResource = { grantable: true, }; -/** Files, folders, and read-only native content in one Google Workspace shared drive. */ -export const GOOGLE_SHARED_DRIVE_RESOURCE: SupportedResource = { - urlPattern: "https://drive.google.com/drive/folders/:driveId", - title: "Google Workspace Shared Drive", - description: "Find files and folders, and read native Google Docs and Sheets, in one organization-owned shared drive.", - grantable: true, -}; -/** - * Files, folders, and read-only native content within one Drive folder and its descendants. - * - * The `_resource` path is an internal selector rather than a browser URL, because the natural - * `/drive/folders/:id` is already {@link GOOGLE_SHARED_DRIVE_RESOURCE}'s permanent identity and its - * pattern leaves the search component wildcard: a query-qualified variant of it would match both - * resources, making selection order- and filtering-dependent. This path is disjoint from every - * other pattern even when either resource is disabled. - */ +/** A selected Drive folder or shared-drive root, exposed through direct-child navigation. */ export const GOOGLE_DRIVE_FOLDER_RESOURCE: SupportedResource = { - urlPattern: "https://drive.google.com/_resource/folder/:folderId", + urlPattern: "https://drive.google.com/drive/folders/:folderId", title: "Google Drive Folder", description: - "Find files and folders, and read native Google Docs and Sheets, within one Drive folder " + - "and its descendants.", + "Browse a selected folder or shared drive, search its direct children, and read native " + + "Google Docs and Sheets.", grantable: true, }; @@ -191,23 +176,8 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] "https://www.googleapis.com/auth/spreadsheets.readonly", ], }, - { - resource: GOOGLE_SHARED_DRIVE_RESOURCE, - // `drive.readonly` (not `drive.metadata.readonly`): the shared-drive picker and the binding's - // `getScope` use `drives.list`/`drives.get`, which accept nothing narrower. The same scope already - // authorizes native Docs and Sheets content, so do not add redundant API scopes. Google lists - // `drive.metadata.readonly` as restricted too, so that is not the distinction: what this scope - // adds is account-wide content download, strictly wider than the authority the shared-drive - // binding exercises. Narrowing it means dropping both calls: resolving a shared drive's name - // through `files.get` on the drive root instead, and giving up drive enumeration in the - // configurator. - scopes: ["https://www.googleapis.com/auth/drive.readonly"], - }, { resource: GOOGLE_DRIVE_FOLDER_RESOURCE, - // The same read-only trio the account and exact-file resources take. A folder binding proves - // descendant membership from `files.get` metadata and reads native content through the Docs and - // Sheets APIs, so it needs nothing from the wider `drive.readonly` the shared drive requires. scopes: [ "https://www.googleapis.com/auth/drive.metadata.readonly", "https://www.googleapis.com/auth/documents.readonly", @@ -234,7 +204,6 @@ export const RESOURCE_SCOPES: {resource: SupportedResource, scopes: string[]}[] const DRIVE_RESOURCE_PATTERNS = new Set([ GOOGLE_DRIVE_RESOURCE.urlPattern, - GOOGLE_SHARED_DRIVE_RESOURCE.urlPattern, GOOGLE_DRIVE_FOLDER_RESOURCE.urlPattern, GOOGLE_DRIVE_FILE_RESOURCE.urlPattern, ]); @@ -269,6 +238,23 @@ export function resourceUrlPatternsToOAuthScopes(resourceUrlPatterns: readonly s return [...scopes]; } +function oauthScopeCovers(required: string, granted: ReadonlySet): boolean { + if (granted.has(required)) return true; + const driveRead = granted.has("https://www.googleapis.com/auth/drive.readonly") || + granted.has("https://www.googleapis.com/auth/drive"); + if (driveRead) { + return required === "https://www.googleapis.com/auth/drive.metadata.readonly" || + required === "https://www.googleapis.com/auth/documents.readonly" || + required === "https://www.googleapis.com/auth/spreadsheets.readonly"; + } + return (required === "https://www.googleapis.com/auth/drive.metadata.readonly" && + granted.has("https://www.googleapis.com/auth/drive.metadata")) || + (required === "https://www.googleapis.com/auth/documents.readonly" && + granted.has("https://www.googleapis.com/auth/documents")) || + (required === "https://www.googleapis.com/auth/spreadsheets.readonly" && + granted.has("https://www.googleapis.com/auth/spreadsheets")); +} + /** * The subset of `resourceUrlPatterns` whose every OAuth scope is present in `grantedOAuthScopes`. * @@ -282,7 +268,7 @@ export function resourcesCoveredByScopes( let requested = new Set(resourceUrlPatterns); return RESOURCE_SCOPES .filter(entry => requested.has(entry.resource.urlPattern) && - entry.scopes.every(scope => granted.has(scope))) + entry.scopes.every(scope => oauthScopeCovers(scope, granted))) .map(entry => entry.resource.urlPattern); } @@ -340,7 +326,6 @@ export type ResourceTarget = | { kind: "calendar"; calendarId: string; availabilityMode: CalendarAvailabilityMode } | { kind: "bigquery"; projectId: string; datasetId?: string; tableId?: string } | { kind: "driveAccount" } - | { kind: "sharedDrive"; driveId: string } | { kind: "driveFolder"; folderId: string } | { kind: "driveFile"; fileId: string }; @@ -353,7 +338,6 @@ export const RESOURCE_BY_KIND: Record bigquery: BIGQUERY_RESOURCE, driveAccount: GOOGLE_DRIVE_RESOURCE, driveFolder: GOOGLE_DRIVE_FOLDER_RESOURCE, - sharedDrive: GOOGLE_SHARED_DRIVE_RESOURCE, driveFile: GOOGLE_DRIVE_FILE_RESOURCE, }; @@ -458,16 +442,10 @@ function parseCalendarUrl(parsed: URL): ResourceTarget { parsed.searchParams.get("availability") === "allVisible" ? "allVisible" : "thisCalendar"; return { kind: "calendar", calendarId, availabilityMode }; } - function parseDriveUrl(parsed: URL): ResourceTarget { if (/^\/drive\/my-drive\/?$/.test(parsed.pathname)) return { kind: "driveAccount" }; - let sharedDrive = /^\/drive\/folders\/([^/]+)\/?$/.exec(parsed.pathname); - if (sharedDrive) return { kind: "sharedDrive", driveId: decodeURIComponent(sharedDrive[1]) }; - - // Internal selector, not a browser URL: `/drive/folders/:driveId` above is the shared drive's - // permanent identity and matches any query, so a folder cannot be told apart by qualifying it. - let folder = /^\/_resource\/folder\/([^/]+)\/?$/.exec(parsed.pathname); + let folder = /^\/drive\/folders\/([^/]+)\/?$/.exec(parsed.pathname); if (folder) return { kind: "driveFolder", folderId: decodeURIComponent(folder[1]) }; let file = /^\/file\/d\/([^/]+)\/view\/?$/.exec(parsed.pathname); diff --git a/packages/gatekeeper-google/vitest.worker.config.ts b/packages/gatekeeper-google/vitest.worker.config.ts index 5b9319cb8a..fe13f4972f 100644 --- a/packages/gatekeeper-google/vitest.worker.config.ts +++ b/packages/gatekeeper-google/vitest.worker.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ test: { include: [ "__tests__/workerd/configurators.test.ts", + "__tests__/workerd/drive-discovery.test.ts", "__tests__/workerd/gmail-actions.test.ts", "__tests__/workerd/gmail-state.test.ts", ], From 0f987c136410eadde04e612b31005072779532bd Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Wed, 16 Sep 2026 18:00:55 -0500 Subject: [PATCH 5/6] Fence only owner-relative folder probe failures A folder session refused every out-of-scope direct lookup through #authorizeWithheld, which commits the observer-withheld marker and makes every future addObserver() fail. The trigger was ordinary use rather than probing: a user pastes a link to a doc that lives one subfolder down, the agent opens it by ID, and the binding is silently unshareable from then on with no way to clear it. Only one of the two refusals answers an owner-relative question. #tryFetchFile collapses 403 and 404 to undefined, so an absent file means "this account cannot see it" -- a negative a collaborator might not share, which stays fenced. Whether a visible file is a direct child of the positioned folder is objective, so refusing it discloses nothing and now leaves admission open. Both paths still raise the same outsideScope() error, so the caller cannot tell them apart. The move-during-read test correspondingly asserts the stronger claim it was always about: the moved file stays visible, so the content reaches neither the approval queue nor the caller and nothing is authorized. --- .../gatekeeper-google/__tests__/drive-session.test.ts | 10 ++++++++++ .../__tests__/workerd/native-sessions.test.ts | 6 ++---- packages/gatekeeper-google/src/drive-session.ts | 5 ++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/gatekeeper-google/__tests__/drive-session.test.ts b/packages/gatekeeper-google/__tests__/drive-session.test.ts index 01db899a06..b96edd9988 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -613,6 +613,16 @@ describe("positioned Drive folder session", () => { .rejects.toThrow(/outside this Drive binding/); }); + it("fences an invisible probe but not a visible non-child", async () => { + const visible = positioned([root, nested, nestedDoc]); + await expect(visible.session.getEntry("D1")).rejects.toThrow(/outside this Drive binding/); + expect(visible.events).toEqual([]); + + const invisible = positioned([root]); + await expect(invisible.session.getEntry("gone")).rejects.toThrow(/outside this Drive binding/); + expect(invisible.events).toEqual(["authorize", "latch"]); + }); + it("audits and rejects an empty folder search", async () => { const { session, authorizations, events } = positioned([root], undefined, async () => ({ files: [] })); diff --git a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts index 1779718d2d..880581c67a 100644 --- a/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts +++ b/packages/gatekeeper-google/__tests__/workerd/native-sessions.test.ts @@ -489,7 +489,7 @@ describe("folder-scoped native sessions", () => { }); // The move lands while the Docs call is in flight. The content reaches neither the approval - // queue nor caller; only the owner-relative failed membership check is authorized. + // queue nor the caller, and the moved file stays visible, so nothing is authorized at all. it("discards content when the move lands during the provider read", async () => { const nodes = subtree(); installFolderProvider(nodes, () => { @@ -501,9 +501,7 @@ describe("folder-scoped native sessions", () => { const authorizedBefore = queue.observations.length; await expect(Promise.resolve(doc.getContent())).rejects.toThrow(OUTSIDE); - expect(queue.observations.slice(authorizedBefore)).toEqual([ - expect.objectContaining({ title: "Check Google Drive folder" }), - ]); + expect(queue.observations.slice(authorizedBefore)).toEqual([]); }); it("keeps child-folder and native capabilities alive after their parents are disposed", async () => { diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index 602bff15f8..4b736043ab 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -606,12 +606,15 @@ export class DriveFolderSessionCore { async #requireDirectFile(fileId: string): Promise { let path = await this.#readLocation(); let file = await this.#tryFetchFile(fileId); - if (!file || file.id !== fileId || !isDirectChild(file, path[path.length - 1])) { + if (!file || file.id !== fileId) { + // Invisible to this account, which is an owner-relative answer: fence it. Whether a visible + // file is a direct child is objective, so refusing that discloses nothing and stays open. await this.#authorizeWithheld( "Check Google Drive folder", "Check whether a requested file is a direct child of this Drive folder."); outsideScope(); } + if (!isDirectChild(file, path[path.length - 1])) outsideScope(); await this.#readLocation(); return file; } From 86814d2f6798f22d23eb76b722b363e43a8399bb Mon Sep 17 00:00:00 2001 From: Nathan Disidore Date: Wed, 16 Sep 2026 18:14:49 -0500 Subject: [PATCH 6/6] Own the configurator's authorization action and frame identity The trusted authorization control owns popup state, an unmount cleanup and the whole OAuth hand-off, which the frontend conventions say earns a file of its own; it sat inline in the configurator host. Its tests move with it, leaving the host suite the cases that are about host wiring. Its popup now opens under a stable window name. The account holds a single pending OAuth flow, so a second click used to open a second tab and strand the first on a superseded nonce; naming the window makes the repeat click renavigate the same tab instead. Polling popup.closed to re-enable the button was the alternative, but a background tab left open would then wedge the control disabled long after its flow died. Both hosts clear a superseded frame from an effect, so one committed render could pair a newly chosen account with the previous account's frame, and acting on it would configure or authorize the wrong account. The host now takes the frame state plus the current selection and resolves them through one guard, so no call site can forget the check. GatekeeperModal already made the comparison at submit time in two places; both route through the same helper now, and its duplicate local state type is gone. BlueprintLandingPage records the account and resource pattern its frame state never carried. --- .../__tests__/observers.test.ts | 14 ++ packages/gatekeeper-google/src/google.ts | 12 +- packages/gatekeeper-google/src/oauth-flow.ts | 5 +- packages/gatekeeper-google/src/observers.ts | 1 + packages/gatekeeper-google/src/resources.ts | 15 +- .../src/BlueprintLandingPage.tsx | 24 ++- .../workshop-frontend/src/GatekeeperModal.tsx | 28 ++-- .../src/ResourceAuthorizationAction.test.tsx | 114 +++++++++++++++ .../src/ResourceAuthorizationAction.tsx | 82 +++++++++++ .../src/ResourceConfiguratorHost.test.tsx | 138 ++++++------------ .../src/ResourceConfiguratorHost.tsx | 126 +++++----------- 11 files changed, 340 insertions(+), 219 deletions(-) create mode 100644 packages/workshop-frontend/src/ResourceAuthorizationAction.test.tsx create mode 100644 packages/workshop-frontend/src/ResourceAuthorizationAction.tsx diff --git a/packages/gatekeeper-google/__tests__/observers.test.ts b/packages/gatekeeper-google/__tests__/observers.test.ts index 09304513e0..32d30e38ff 100644 --- a/packages/gatekeeper-google/__tests__/observers.test.ts +++ b/packages/gatekeeper-google/__tests__/observers.test.ts @@ -674,6 +674,20 @@ describe("withheld observations", () => { expect(withholdKeys()).toHaveLength(1); }); + // The fence can land while the candidate's access checks are in flight, after the entry check. + it("refuses a per-set candidate withheld during its access checks", async () => { + let tracker = makeTracker({ + hasAccess: async () => { + tracker.prepareWithheld().commit(); + return true; + }, + }); + (await tracker.prepareObservation(["one"])).commit(); + + await expect(tracker.addObserver("late", allow("one"))) + .rejects.toThrow(/can no longer be observed/); + }); + it("latches admission closed for good once the read is authorized", async () => { let tracker = makeTracker(); tracker.prepareWithheld().commit(); diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 7ca10f90dc..c7686a07f3 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -75,7 +75,7 @@ import { BIGQUERY_HOST, BIGQUERY_RESOURCE, GMAIL_RESOURCE, GOOGLE_CALENDAR_RESOURCE, GOOGLE_DOC_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, GOOGLE_DRIVE_RESOURCE, GOOGLE_SHEETS_RESOURCE, RESOURCE_BY_KIND, SUPPORTED_RESOURCES, - grantedResourceUrlPatterns, hasDriveResourceGrant, parseResourceUrl, + grantedResourceUrlPatterns, grantsDriveDiscovery, hasDriveResourceGrant, parseResourceUrl, recordedResourceUrlPatterns, type RecordedResourceGrant, } from "./resources"; import { @@ -427,10 +427,8 @@ export class UserAccount extends DurableObject { /** Prepare a reconnect or scope-expansion attempt for this account. */ async prepareReconnect( initiationNonce: string, requestedResources: string[], requestDriveReadonly = false) { - let grantedScopes = this.ctx.storage.kv.get("grantedScopes") ?? []; - let preserveDriveDiscovery = grantedScopes.includes( - "https://www.googleapis.com/auth/drive.readonly") || - grantedScopes.includes("https://www.googleapis.com/auth/drive"); + let preserveDriveDiscovery = grantsDriveDiscovery( + this.ctx.storage.kv.get("grantedScopes") ?? []); prepareOAuthFlow( this.ctx.storage.kv, initiationNonce, @@ -459,9 +457,7 @@ export class UserAccount extends DurableObject { } async hasSharedDriveDiscovery(): Promise { - let scopes = this.ctx.storage.kv.get("grantedScopes") ?? []; - return scopes.includes("https://www.googleapis.com/auth/drive.readonly") || - scopes.includes("https://www.googleapis.com/auth/drive"); + return grantsDriveDiscovery(this.ctx.storage.kv.get("grantedScopes") ?? []); } async requestSharedDriveDiscovery(): Promise<{url?: string}> { diff --git a/packages/gatekeeper-google/src/oauth-flow.ts b/packages/gatekeeper-google/src/oauth-flow.ts index eced56a2a3..681d0a1b95 100644 --- a/packages/gatekeeper-google/src/oauth-flow.ts +++ b/packages/gatekeeper-google/src/oauth-flow.ts @@ -1,8 +1,9 @@ -import { resourceUrlPatternsToOAuthScopes, validateResourceUrlPatterns } from "./resources"; +import { + DRIVE_READONLY_SCOPE, resourceUrlPatternsToOAuthScopes, validateResourceUrlPatterns, +} from "./resources"; const FLOW_KEY = "oauthFlow"; const NONCE_LIFETIME_MS = 10 * 60 * 1000; -const DRIVE_READONLY_SCOPE = "https://www.googleapis.com/auth/drive.readonly"; const LEGACY_FLOW_KEYS = [ "nonce", "requestedScopes", "requestedResources", "reconnecting", "ephemeral", ] as const; diff --git a/packages/gatekeeper-google/src/observers.ts b/packages/gatekeeper-google/src/observers.ts index 32abdea755..ccec0f9065 100644 --- a/packages/gatekeeper-google/src/observers.ts +++ b/packages/gatekeeper-google/src/observers.ts @@ -362,6 +362,7 @@ export class ObserverTracker { let tracked = this.listTracked(); let pending = tracked.filter(value => !checked.has(this.#options.encode(value))); if (pending.length === 0) { + if (this.#observationWithheld()) throw new Error(OBSERVER_WITHHELD_MESSAGE); if (recordObservers) this.#kv.put(observerKey, verifier); return; } diff --git a/packages/gatekeeper-google/src/resources.ts b/packages/gatekeeper-google/src/resources.ts index 5ddaaae187..a8119c3b9a 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -217,6 +217,18 @@ export function hasDriveResourceGrant(resourceUrlPatterns: readonly string[]): b return resourceUrlPatterns.some(pattern => DRIVE_RESOURCE_PATTERNS.has(pattern)); } +/** Account-wide Drive read. Granted only by the optional expansion shared-drive discovery needs. */ +export const DRIVE_READONLY_SCOPE = "https://www.googleapis.com/auth/drive.readonly"; +const DRIVE_READWRITE_SCOPE = "https://www.googleapis.com/auth/drive"; + +/** Whether granted scopes carry the account-wide Drive read that shared-drive discovery requires. */ +export function grantsDriveDiscovery(grantedScopes: Iterable): boolean { + for (let scope of grantedScopes) { + if (scope === DRIVE_READONLY_SCOPE || scope === DRIVE_READWRITE_SCOPE) return true; + } + return false; +} + /** Rejects any pattern that is not a known grantable resource. */ export function validateResourceUrlPatterns(resourceUrlPatterns: readonly string[]): void { let unknown = resourceUrlPatterns.filter(pattern => !KNOWN_RESOURCE_PATTERNS.has(pattern)); @@ -240,8 +252,7 @@ export function resourceUrlPatternsToOAuthScopes(resourceUrlPatterns: readonly s function oauthScopeCovers(required: string, granted: ReadonlySet): boolean { if (granted.has(required)) return true; - const driveRead = granted.has("https://www.googleapis.com/auth/drive.readonly") || - granted.has("https://www.googleapis.com/auth/drive"); + const driveRead = grantsDriveDiscovery(granted); if (driveRead) { return required === "https://www.googleapis.com/auth/drive.metadata.readonly" || required === "https://www.googleapis.com/auth/documents.readonly" || diff --git a/packages/workshop-frontend/src/BlueprintLandingPage.tsx b/packages/workshop-frontend/src/BlueprintLandingPage.tsx index dc9b2c7f35..99649bda19 100644 --- a/packages/workshop-frontend/src/BlueprintLandingPage.tsx +++ b/packages/workshop-frontend/src/BlueprintLandingPage.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } fro import { useNavigate, useParams, useRouter } from '@tanstack/react-router' import { RpcStub } from 'capnweb' import { PublicApi, AuthenticatedApi, AdminApi, BlueprintPublicInfo, BlueprintBinding, BlueprintBindingAssignment, BlueprintUserSummary, AiChatAuthorInfo } from '@gadgets/workshop-shared/api' -import { SupportedResource, VendorDescription, ResourceConfiguratorFrame } from '@gadgets/workshop-shared/gatekeeper' +import { SupportedResource, VendorDescription } from '@gadgets/workshop-shared/gatekeeper' import { Button, Dialog, DropdownMenu, Select, Tooltip, useKumoToastManager } from '@cloudflare/kumo' import { ArrowsOutSimple, ArrowLeft, ArrowSquareOut, DotsThree, DownloadSimple, Lightning, Plus, Robot, Sparkle, Star, Trash, X } from '@phosphor-icons/react' @@ -16,7 +16,9 @@ import { saveStreamToFile, } from './fileTransfers' import { AccountChooser, AccountOption } from './gatekeeper-modal/AccountChooser' -import ResourceConfiguratorHost, { disposeConfiguratorFrame } from './ResourceConfiguratorHost' +import ResourceConfiguratorHost, { + disposeConfiguratorFrame, type ConfiguratorFrameState, +} from './ResourceConfiguratorHost' import { WorkshopButton, WorkshopIconButton } from './components/WorkshopControls' import { MENU_CONTENT, MENU_ITEM, MENU_ITEM_DANGER } from './components/menuStyles' import { useDocumentTitle } from './useDocumentTitle' @@ -1541,13 +1543,13 @@ function BlueprintGatekeeperBindingField({ // Configurator iframe state. We re-spin the iframe whenever the (account, resource) pair // changes; each frame is disposed when replaced or when the component unmounts. - const [frameState, setFrameState] = useState<{ key: number, frame: ResourceConfiguratorFrame } | null>(null) + const [frameState, setFrameState] = useState(null) const [frameLoading, setFrameLoading] = useState(false) const [frameError, setFrameError] = useState(null) - const frameRef = useRef<{ key: number, frame: ResourceConfiguratorFrame } | null>(null) + const frameRef = useRef(null) const frameKeyRef = useRef(0) - const replaceFrameState = useCallback((next: { key: number, frame: ResourceConfiguratorFrame } | null) => { + const replaceFrameState = useCallback((next: ConfiguratorFrameState | null) => { const prev = frameRef.current if (prev?.frame !== next?.frame) disposeConfiguratorFrame(prev?.frame ?? null) frameRef.current = next @@ -1597,7 +1599,12 @@ function BlueprintGatekeeperBindingField({ disposeConfiguratorFrame(frame) return } - replaceFrameState({ key: ++frameKeyRef.current, frame }) + replaceFrameState({ + key: ++frameKeyRef.current, + frame, + accountId: selectedAccount.id, + resourceUrlPattern: resource.urlPattern, + }) }) .catch(err => { console.error('Failed to start resource configurator:', err) @@ -1655,8 +1662,9 @@ function BlueprintGatekeeperBindingField({ )} > | null = null let transferred = false try { - if (!configuratorFrameState?.frame || configuratorFrameState.accountId !== selectedAccountId || configuratorFrameState.resourceUrlPattern !== resourceUrlPattern) { - throw new Error('Configurator is not ready.') - } + const current = currentConfiguratorFrame( + configuratorFrameState, selectedAccountId, resourceUrlPattern) + if (!current) throw new Error('Configurator is not ready.') const resourceUrl = await configuratorCollectResourceUrlRef.current?.() if (!resourceUrl) throw new Error('Configurator did not provide a resource URL.') const overseer = await getOverseer() @@ -744,11 +738,7 @@ export default function GatekeeperModal({ if (selectedConnection.resourceUrlPattern) { const resourceUrlPattern = selectedConnection.resourceUrlPattern return Boolean( - selectedAccountId !== null && - resourceUrlPattern && - configuratorFrameState?.frame && - configuratorFrameState.accountId === selectedAccountId && - configuratorFrameState.resourceUrlPattern === resourceUrlPattern && + currentConfiguratorFrame(configuratorFrameState, selectedAccountId, resourceUrlPattern) && configuratorSelectionReady !== false && !hasMissingResourceGrants, ) @@ -836,8 +826,8 @@ export default function GatekeeperModal({ {selectedConnection.resourceUrlPattern && !hasMissingResourceGrants && ( ({ + WorkshopButton: ({ children, ...props }: ComponentProps<'button'>) => ( + + ), +})) + +import { ResourceAuthorizationAction } from './ResourceAuthorizationAction' + +const roots: Root[] = [] + +afterEach(() => { + for (const root of roots.splice(0)) act(() => root.unmount()) + document.body.textContent = '' + vi.restoreAllMocks() +}) + +function popup() { + return { + opener: {} as unknown, + close: vi.fn<() => void>(), + location: { replace: vi.fn<(url: string) => void>() }, + } +} + +function render(request: ResourceConfiguratorAuthorization['request']) { + const container = document.createElement('div') + document.body.append(container) + const root = createRoot(container) + roots.push(root) + act(() => { + root.render( + , + ) + }) + return { container, root, click: () => container.querySelector('button')!.click() } +} + +function stub unknown>(fn: T): RpcStub { + return fn as unknown as RpcStub +} + +describe('ResourceAuthorizationAction', () => { + it('does not request authorization when the popup is blocked', () => { + const request = vi.fn<() => Promise<{ url: string }>>( + async () => ({ url: 'https://accounts.example.test/oauth' }), + ) + vi.spyOn(window, 'open').mockReturnValue(null) + const rendered = render(stub(request)) + + act(() => rendered.click()) + + expect(request).not.toHaveBeenCalled() + expect(rendered.container.textContent).toContain('Allow popups and try again.') + }) + + it('pre-opens a safe named popup and navigates it to a valid authorization URL', async () => { + const opened = popup() + const request = vi.fn<() => Promise<{ url: string }>>(async () => { + expect(opened.opener).toBeNull() + return { url: 'https://accounts.example.test/oauth?state=secret' } + }) + vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) + const rendered = render(stub(request)) + + await act(async () => rendered.click()) + + // A repeat click must reuse this tab: a stranded tab holds a superseded OAuth nonce. + expect(window.open).toHaveBeenCalledWith('about:blank', 'gadgets-gatekeeper-authorization') + expect(opened.location.replace).toHaveBeenCalledWith('https://accounts.example.test/oauth?state=secret') + expect(opened.close).not.toHaveBeenCalled() + expect(rendered.container.textContent).toContain('Complete authorization in the new tab') + }) + + it('closes the popup and reports an invalid authorization URL', async () => { + const opened = popup() + vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) + const rendered = render( + stub(vi.fn(async () => ({ url: 'https://user:password@accounts.example.test/oauth' })))) + + await act(async () => rendered.click()) + + expect(opened.location.replace).not.toHaveBeenCalled() + expect(opened.close).toHaveBeenCalledOnce() + expect(rendered.container.textContent).toContain('Could not start authorization. Please try again.') + }) + + it('closes an unused popup when access is already available', async () => { + const opened = popup() + vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) + const rendered = render(stub(vi.fn(async (): Promise<{ url?: string }> => ({})))) + + await act(async () => rendered.click()) + + expect(opened.close).toHaveBeenCalledOnce() + expect(rendered.container.textContent).toContain('Access is already available.') + }) +}) diff --git a/packages/workshop-frontend/src/ResourceAuthorizationAction.tsx b/packages/workshop-frontend/src/ResourceAuthorizationAction.tsx new file mode 100644 index 0000000000..94912c633f --- /dev/null +++ b/packages/workshop-frontend/src/ResourceAuthorizationAction.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from 'react' +import type { ResourceConfiguratorAuthorization } from '@gadgets/workshop-shared/gatekeeper' +import { WorkshopButton } from './components/WorkshopControls' + +// A stable name so a second click renavigates the same tab: the account holds one pending OAuth +// flow, so a second tab would strand the first on a superseded nonce. +const AUTHORIZATION_WINDOW_NAME = 'gadgets-gatekeeper-authorization' + +/** Trusted control that runs a gatekeeper's account authorization outside the configurator iframe. */ +export const ResourceAuthorizationAction = ({ + authorization, +}: { + authorization: ResourceConfiguratorAuthorization +}) => { + const [pending, setPending] = useState(false) + const [message, setMessage] = useState(null) + const requestId = useRef(0) + const blankPopup = useRef(null) + + useEffect(() => () => { + requestId.current++ + blankPopup.current?.close() + blankPopup.current = null + }, []) + + const requestAuthorization = async () => { + const popup = window.open('about:blank', AUTHORIZATION_WINDOW_NAME) + if (!popup) { + setMessage('Allow popups and try again.') + return + } + + popup.opener = null + const currentRequest = ++requestId.current + blankPopup.current = popup + setPending(true) + setMessage(null) + + try { + const result = await authorization.request() + if (currentRequest !== requestId.current) return + + if (!result.url) { + popup.close() + blankPopup.current = null + setMessage('Access is already available. Retry the shared-drive selector below.') + return + } + + const url = new URL(result.url) + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) { + throw new Error('Invalid authorization URL') + } + + popup.location.replace(url.href) + blankPopup.current = null + setMessage('Complete authorization in the new tab, then return and retry the shared-drive selector below.') + } catch { + if (currentRequest !== requestId.current) return + popup.close() + blankPopup.current = null + setMessage('Could not start authorization. Please try again.') + } finally { + if (currentRequest === requestId.current) setPending(false) + } + } + + return ( +
+
{authorization.title}
+

{authorization.description}

+ void requestAuthorization()} + > + {authorization.title} + + {message &&

{message}

} +
+ ) +} diff --git a/packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx b/packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx index f971bcc33f..fe8e035830 100644 --- a/packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx +++ b/packages/workshop-frontend/src/ResourceConfiguratorHost.test.tsx @@ -5,10 +5,7 @@ import { act, type ComponentProps } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it, vi } from 'vitest' import type { RpcStub, RpcTarget } from 'capnweb' -import type { - ResourceConfiguratorAuthorization, - ResourceConfiguratorFrame, -} from '@gadgets/workshop-shared/gatekeeper' +import type { ResourceConfiguratorAuthorization } from '@gadgets/workshop-shared/gatekeeper' Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, writable: true }) @@ -22,7 +19,9 @@ vi.mock('./SandboxedResourceConfigurator', () => ({ default: () =>
, })) -import ResourceConfiguratorHost, { disposeConfiguratorFrame } from './ResourceConfiguratorHost' +import ResourceConfiguratorHost, { + disposeConfiguratorFrame, type ConfiguratorFrameState, +} from './ResourceConfiguratorHost' type Deferred = { promise: Promise @@ -44,11 +43,19 @@ function stub unknown>(fn: T): RpcStub { return fn as unknown as RpcStub } -function frame(auth?: ResourceConfiguratorAuthorization): ResourceConfiguratorFrame { +const PATTERN = 'https://drive.google.com/drive/folders/:folderId' +const ACCOUNT = 7 + +function state( + auth?: ResourceConfiguratorAuthorization, + overrides: Partial = {}, +): ConfiguratorFrameState { return { - iframeHtml: '', - ui: {} as RpcStub, - authorization: auth, + key: 1, + frame: { iframeHtml: '', ui: {} as RpcStub, authorization: auth }, + accountId: ACCOUNT, + resourceUrlPattern: PATTERN, + ...overrides, } } @@ -60,21 +67,25 @@ function popup() { } } -function renderHost(hostFrame: ResourceConfiguratorFrame, frameKey = 1) { +function host(hostState: ConfiguratorFrameState | null) { + return ( + + ) +} + +function renderHost(hostState: ConfiguratorFrameState | null) { const container = document.createElement('div') document.body.append(container) const root = createRoot(container) - act(() => { - root.render( - , - ) - }) + roots.push(root) + act(() => root.render(host(hostState))) return { container, root } } @@ -94,64 +105,19 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('ResourceConfiguratorHost authorization', () => { - it('does not request authorization when the popup is blocked', () => { - const request = vi.fn<() => Promise<{ url: string }>>( - async () => ({ url: 'https://accounts.example.test/oauth' }), - ) - vi.spyOn(window, 'open').mockReturnValue(null) - const rendered = renderHost(frame(authorization(stub(request)))) - roots.push(rendered.root) - - act(() => rendered.container.querySelector('button')!.click()) - - expect(request).not.toHaveBeenCalled() - expect(rendered.container.textContent).toContain('Allow popups and try again.') - }) - - it('pre-opens a safe popup and navigates it to a valid authorization URL', async () => { - const opened = popup() - const request = vi.fn<() => Promise<{ url: string }>>(async () => { - expect(opened.opener).toBeNull() - return { url: 'https://accounts.example.test/oauth?state=secret' } - }) - vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) - const rendered = renderHost(frame(authorization(stub(request)))) - roots.push(rendered.root) - - await act(async () => rendered.container.querySelector('button')!.click()) - - expect(window.open).toHaveBeenCalledWith('about:blank', '_blank') - expect(opened.location.replace).toHaveBeenCalledWith('https://accounts.example.test/oauth?state=secret') - expect(opened.close).not.toHaveBeenCalled() - expect(rendered.container.textContent).toContain('Complete authorization in the new tab') - }) - - it('closes the popup and reports an invalid authorization URL', async () => { - const opened = popup() - const request = stub(vi.fn(async () => ({ url: 'https://user:password@accounts.example.test/oauth' }))) - vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) - const rendered = renderHost(frame(authorization(request))) - roots.push(rendered.root) +describe('ResourceConfiguratorHost', () => { + // A superseded frame is cleared from an effect, so it outlives the selection by one render. + it('renders nothing for a frame from another account or resource type', () => { + const request = stub(vi.fn(async () => ({ url: 'https://accounts.example.test/oauth' }))) - await act(async () => rendered.container.querySelector('button')!.click()) + for (const stale of [ + state(authorization(request), { accountId: ACCOUNT + 1 }), + state(authorization(request), { resourceUrlPattern: 'https://mail.google.com/*' }), + ]) { + expect(renderHost(stale).container.textContent).toBe('') + } - expect(opened.location.replace).not.toHaveBeenCalled() - expect(opened.close).toHaveBeenCalledOnce() - expect(rendered.container.textContent).toContain('Could not start authorization. Please try again.') - }) - - it('closes an unused popup when access is already available', async () => { - const opened = popup() - const request = stub(vi.fn(async (): Promise<{ url?: string }> => ({}))) - vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) - const rendered = renderHost(frame(authorization(request))) - roots.push(rendered.root) - - await act(async () => rendered.container.querySelector('button')!.click()) - - expect(opened.close).toHaveBeenCalledOnce() - expect(rendered.container.textContent).toContain('Access is already available.') + expect(renderHost(state(authorization(request))).container.querySelector('button')).not.toBeNull() }) it('closes a pending blank popup and ignores its result after frame replacement', async () => { @@ -159,21 +125,10 @@ describe('ResourceConfiguratorHost authorization', () => { const pending = deferred<{ url?: string }>() const request = stub(vi.fn(() => pending.promise)) vi.spyOn(window, 'open').mockReturnValue(opened as unknown as Window) - const rendered = renderHost(frame(authorization(request)), 1) - roots.push(rendered.root) + const rendered = renderHost(state(authorization(request))) act(() => rendered.container.querySelector('button')!.click()) - act(() => { - rendered.root.render( - , - ) - }) + act(() => rendered.root.render(host(state(undefined, { key: 2 })))) expect(opened.close).toHaveBeenCalledOnce() await act(async () => pending.resolve({ url: 'https://accounts.example.test/oauth' })) @@ -188,7 +143,8 @@ describe('disposeConfiguratorFrame', () => { const request = Object.assign(vi.fn<() => void>(), { [Symbol.dispose]: requestDispose }) const uiError = new Error('ui disposal failed') const uiDispose = vi.fn<() => void>(() => { throw uiError }) - const hostFrame = frame(authorization(request as unknown as ResourceConfiguratorAuthorization['request'])) + const hostFrame = state( + authorization(request as unknown as ResourceConfiguratorAuthorization['request'])).frame hostFrame.ui = { [Symbol.dispose]: uiDispose } as unknown as RpcStub expect(() => disposeConfiguratorFrame(hostFrame)).toThrow(uiError) diff --git a/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx b/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx index c8829543a3..81cbf97237 100644 --- a/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx +++ b/packages/workshop-frontend/src/ResourceConfiguratorHost.tsx @@ -1,9 +1,5 @@ -import { useEffect, useRef, useState } from 'react' -import type { - ResourceConfiguratorAuthorization, - ResourceConfiguratorFrame, -} from '@gadgets/workshop-shared/gatekeeper' -import { WorkshopButton } from './components/WorkshopControls' +import type { ResourceConfiguratorFrame } from '@gadgets/workshop-shared/gatekeeper' +import { ResourceAuthorizationAction } from './ResourceAuthorizationAction' import SandboxedResourceConfigurator from './SandboxedResourceConfigurator' /** Releases every capability owned by a resource-configurator frame. */ @@ -24,10 +20,33 @@ function disposeRpcStub(stub: unknown): void { if (typeof dispose === 'function') dispose.call(stub) } +/** A started configurator frame together with the selection it was started for. */ +export type ConfiguratorFrameState = { + key: number + frame: ResourceConfiguratorFrame + accountId: number + resourceUrlPattern: string +} + +/** + * The frame only while it still belongs to the current selection. + * + * Both hosts clear a superseded frame from an effect, so one committed render can pair the new + * account with the old frame; acting on it would configure or authorize the wrong account. + */ +export function currentConfiguratorFrame( + state: ConfiguratorFrameState | null, + accountId: number | null, + resourceUrlPattern: string | null, +): ConfiguratorFrameState | null { + if (!state || state.accountId !== accountId) return null + return state.resourceUrlPattern === resourceUrlPattern ? state : null +} + /** Renders the trusted controls and sandboxed resource configurator. */ export default function ResourceConfiguratorHost({ - frame, - frameKey, + state, + accountId, loading, error, disabled, @@ -37,8 +56,8 @@ export default function ResourceConfiguratorHost({ initialResourceUrl, resourceUrlPattern, }: { - frame: ResourceConfiguratorFrame | null - frameKey: number | null + state: ConfiguratorFrameState | null + accountId: number | null loading: boolean error: string | null disabled: boolean @@ -46,23 +65,26 @@ export default function ResourceConfiguratorHost({ onSelectionReadyChange?: (ready: boolean | null) => void topOffset?: number initialResourceUrl?: string - resourceUrlPattern?: string + resourceUrlPattern: string }) { if (disabled) return Choose an account before selecting a resource. if (loading) return Loading configurator... if (error) return {error} - if (!frame) return null + + const current = currentConfiguratorFrame(state, accountId, resourceUrlPattern) + if (!current) return null + const { frame, key } = current return ( <> {frame.authorization && ( - )} (null) - const requestId = useRef(0) - const blankPopup = useRef(null) - - useEffect(() => () => { - requestId.current++ - blankPopup.current?.close() - blankPopup.current = null - }, []) - - const requestAuthorization = async () => { - const popup = window.open('about:blank', '_blank') - if (!popup) { - setMessage('Allow popups and try again.') - return - } - - popup.opener = null - const currentRequest = ++requestId.current - blankPopup.current = popup - setPending(true) - setMessage(null) - - try { - const result = await authorization.request() - if (currentRequest !== requestId.current) return - - if (!result.url) { - popup.close() - blankPopup.current = null - setMessage('Access is already available. Retry the shared-drive selector below.') - return - } - - const url = new URL(result.url) - if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) { - throw new Error('Invalid authorization URL') - } - - popup.location.replace(url.href) - blankPopup.current = null - setMessage('Complete authorization in the new tab, then return and retry the shared-drive selector below.') - } catch { - if (currentRequest !== requestId.current) return - popup.close() - blankPopup.current = null - setMessage('Could not start authorization. Please try again.') - } finally { - if (currentRequest === requestId.current) setPending(false) - } - } - - return ( -
-
{authorization.title}
-

{authorization.description}

- void requestAuthorization()} - > - {authorization.title} - - {message &&

{message}

} -
- ) -} - function Placeholder({ children }: { children: React.ReactNode }) { return (