diff --git a/packages/gatekeeper-google/README.md b/packages/gatekeeper-google/README.md index 0f93874562..fd6496a7b3 100644 --- a/packages/gatekeeper-google/README.md +++ b/packages/gatekeeper-google/README.md @@ -74,10 +74,9 @@ 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. +- `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 +135,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 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, 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). @@ -170,19 +169,23 @@ stores baseline and Preview secrets separately, so provision the same signing va 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 three by design; bind a shared drive 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/drive/my-drive` selects everything the connected account can read in Drive. Despite the `my-drive` URL it is not limited to My Drive: any ID the account token resolves is in scope. Listings use `corpora=user`, which Google defines as My Drive items the account created or opened plus items shared directly with it, so a shared drive's contents 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 is one search over `corpora=allDrives`, which spans My Drive, "Shared with me", and every shared drive the account is a member of. It runs on the baseline `drive.metadata.readonly` grant and asks for no broader scope. It returns a single provider page of suggestions, so it is an interactive search rather than an exhaustive enumeration: a known folder or shared-drive root that does not surface can still be connected by supplying its `https://drive.google.com/drive/folders/` URL, which opens the picker prefilled. Folders whose children the account cannot list are not offered, and a search Google reports as incomplete fails rather than presenting partial results as complete. -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. +A folder URL carrying `?resourcekey=` is not supported: the key is dropped, and the binding then fails with a Drive 404 for anyone whose access to that folder comes from the link rather than from a direct grant. Google requires resource keys only for items shared by link before September 2021, and only for link-access users — an owner or anyone granted access directly is unaffected, even when the URL they paste happens to carry a key. Such a folder needs direct access to connect. -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. +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. -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. +Every folder operation revalidates the selected root and the root-to-current path. A root carrying a `driveId` uses `corpora=drive`; other folders use `corpora=user`. That drive corpus requires membership of the shared drive, so a folder shared directly with a non-member connects and then fails every listing with `teamDriveMembershipRequired` — such a folder needs drive membership, not just folder access. 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. -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. +Account, folder, and exact-file Drive bindings request `drive.metadata.readonly`, `documents.readonly`, and `spreadsheets.readonly`. A broader `drive.readonly` or `drive` grant the account already holds covers those requirements, but is never requested here. Existing metadata-only connections are prompted to expand before native content reads are considered granted. + +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. + +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 e95ad1da3e..131b6eebab 100644 --- a/packages/gatekeeper-google/__tests__/configurator-url.test.ts +++ b/packages/gatekeeper-google/__tests__/configurator-url.test.ts @@ -22,11 +22,11 @@ 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, parseResourceUrl, } from "../src/resources"; // The configurators never call `ui` from these two methods; it is present only to satisfy the @@ -155,26 +155,17 @@ 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("My Drive, Shared with me, and 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" }; @@ -185,17 +176,33 @@ 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 }); + }); + + it("mints the natural Drive folder URL", () => { + let url = configurableUrl(driveFolderConfigurator, { folderId: "FOLDER123" }); + 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)) .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..d8a4509a84 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, + DRIVE_FILE_ITEM_FIELDS, DriveApi, DriveApiDisabledError, DriveApiRequestError, + 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}` } }); } @@ -154,7 +159,8 @@ describe("listFiles", () => { it("requests the field mask that DriveFile describes", async () => { let calls = stubFetch([jsonResponse({ files: [] })]); await api().listFiles(); - expect(calls[0].url.searchParams.get("fields")).toBe(DRIVE_FILE_FIELDS); + expect(calls[0].url.searchParams.get("fields")) + .toBe(`incompleteSearch,nextPageToken,files(${DRIVE_FILE_ITEM_FIELDS})`); }); it("sends the bearer token", async () => { @@ -201,6 +207,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); @@ -221,6 +233,34 @@ describe("listFiles", () => { expect(calls[0].url.searchParams.has("driveId")).toBe(false); }); + it("spans every corpus the account can reach without a dangling driveId", async () => { + let calls = stubFetch([jsonResponse({ files: [] })]); + await api().listFiles({ corpus: { kind: "allDrives" } }); + let params = calls[0].url.searchParams; + expect(params.get("corpora")).toBe("allDrives"); + expect(params.get("supportsAllDrives")).toBe("true"); + expect(params.get("includeItemsFromAllDrives")).toBe("true"); + expect(params.has("driveId")).toBe(false); + }); + + // A cross-corpus search Drive declares incomplete is not safely presentable as a complete + // result, so it fails here rather than reaching a caller that cannot tell. + it("refuses a search Drive reports as incomplete", async () => { + stubFetch([jsonResponse({ incompleteSearch: true, files: [{ id: "1", name: "a" }] })]); + await expect(api().listFiles({ corpus: { kind: "allDrives" } })).rejects + .toThrow("Google Drive could not complete this search. Try again."); + }); + + it("accepts a search Drive reports as complete", async () => { + stubFetch([jsonResponse({ incompleteSearch: false, files: [{ id: "1", name: "a" }] })]); + expect(await api().listFiles()).toEqual({ files: [{ id: "1", name: "a" }] }); + }); + + it("rejects a non-boolean completeness claim instead of guessing", async () => { + stubFetch([jsonResponse({ incompleteSearch: "true", files: [] })]); + await expect(api().listFiles()).rejects.toThrow("Invalid Google Drive incompleteSearch"); + }); + it("sends the assembled query as the Drive q parameter", async () => { let calls = stubFetch([jsonResponse({ files: [] })]); await api().listFiles({ @@ -246,65 +286,12 @@ describe("listFiles", () => { }); }); -describe("listDrives", () => { - it("returns shared-drive picker options and forwards pagination", async () => { - let calls = stubFetch([jsonResponse({ - drives: [{ id: "drive-1", name: "Product" }], nextPageToken: "p2", - })]); - - expect(await api().listDrives({ pageToken: "p1" })).toEqual({ - drives: [{ id: "drive-1", name: "Product" }], nextPageToken: "p2", - }); - expect(calls[0].url.pathname).toBe("/drive/v3/drives"); - expect(calls[0].url.searchParams.get("pageSize")).toBe("100"); - expect(calls[0].url.searchParams.get("pageToken")).toBe("p1"); - }); - - - it("collects every shared-drive page", async () => { - let calls = stubFetch([ - jsonResponse({ - drives: [{ id: "drive-1", name: "Product" }], nextPageToken: "p2", - }), - jsonResponse({ drives: [{ id: "drive-2", name: "Production" }] }), - ]); - - await expect(api().listAllDrives({ namePrefix: "Pro" })).resolves.toEqual([ - { id: "drive-1", name: "Product" }, - { id: "drive-2", name: "Production" }, - ]); - expect(calls.map(call => call.url.searchParams.get("pageToken"))).toEqual([null, "p2"]); - expect(calls.map(call => call.url.searchParams.get("q"))) - .toEqual(["name contains 'Pro'", "name contains 'Pro'"]); - }); - 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"); - }); - - it("escapes the name prefix when filtering shared drives", async () => { - let calls = stubFetch([jsonResponse({ drives: [] })]); - await api().listDrives({ namePrefix: "Ada's \\drive" }); - expect(calls[0].url.searchParams.get("q")).toBe("name contains 'Ada\\'s \\\\drive'"); - }); - - it("omits q when the name prefix is whitespace-only", async () => { - let calls = stubFetch([jsonResponse({ drives: [] })]); - await api().listDrives({ namePrefix: " " }); - expect(calls[0].url.searchParams.has("q")).toBe(false); - }); - - it("treats a response with no drives array as an empty page", async () => { - stubFetch([jsonResponse({})]); - expect(await api().listDrives()).toEqual({ drives: [] }); - }); -}); - describe("metadata lookup", () => { it("gets one file with shared-drive support and the public metadata fields", async () => { 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 +299,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/); }); @@ -362,11 +350,6 @@ describe("metadata lookup", () => { }); }); - it("gets current shared-drive metadata by stable ID", async () => { - let calls = stubFetch([jsonResponse({ id: "drive/1", name: "Current name" })]); - expect(await api().getDrive("drive/1")).toEqual({ id: "drive/1", name: "Current name" }); - expect(calls[0].url.pathname).toBe("/drive/v3/drives/drive%2F1"); - }); it("cancels an oversized JSON response before reading the remaining stream", async () => { let pulls = 0; let cancelled = false; @@ -391,11 +374,24 @@ 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%2Cparents%2CdriveId%2Ctrashed%2Ccapabilities"); + }); + + 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().checkObservations([{kind: "folder", fileId: "folder"}])) + .resolves.toEqual([false]); }); it("concatenates batch outcomes in request order across the 100-file chunk boundary", async () => { @@ -406,9 +402,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, @@ -420,13 +414,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); }); @@ -435,7 +429,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"); }, ); @@ -443,7 +437,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 () => { @@ -461,7 +455,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); @@ -469,32 +463,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(); @@ -507,7 +501,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); }); @@ -523,7 +517,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"]); @@ -544,7 +538,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); }); @@ -555,15 +549,184 @@ 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", () => { + 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", () => { @@ -582,6 +745,20 @@ describe("error handling", () => { expect(error.message).toBe("Google Drive API request failed: 403 (insufficientPermissions)"); }); + // A folder inside a shared drive can be shared directly with a non-member, who then holds a + // listable folder in a drive corpus they cannot query. The bare reason code says none of that. + it("explains a shared-drive membership refusal, keeping the reason for callers", async () => { + stubFetch([new Response(JSON.stringify({ + error: { errors: [{ reason: "teamDriveMembershipRequired" }] }, + }), { status: 403 })]); + let error = await api().listFiles({ corpus: { kind: "drive", driveId: "drive-1" } }) + .catch(e => e); + expect(error).toMatchObject({ status: 403, reason: "teamDriveMembershipRequired" }); + expect(error.message).toBe( + "Google Drive API request failed: 403 (the connected account is not a member of the " + + "shared drive this item belongs to)"); + }); + it("preserves an ordinary 404 without a provider reason", async () => { stubFetch([new Response("{}", { status: 404 })]); let error = await api().listFiles().catch(e => e); diff --git a/packages/gatekeeper-google/__tests__/drive-observers.test.ts b/packages/gatekeeper-google/__tests__/drive-observers.test.ts index e413914c76..5a25cc36d2 100644 --- a/packages/gatekeeper-google/__tests__/drive-observers.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-observers.test.ts @@ -1,109 +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) => ObserverBatchResult | Promise, + verdicts: ( + units: readonly DriveObservation[], verifier: 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); + 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 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(/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`]); }); - 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 file file-1/); - expect(asked).toEqual([[], ["file-1"]]); + await expect(admission).rejects.toThrow(/cannot access Drive data this workspace has read/); + 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 file file-1/); - - 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 bb9ae07f9c..7301833874 100644 --- a/packages/gatekeeper-google/__tests__/drive-session.test.ts +++ b/packages/gatekeeper-google/__tests__/drive-session.test.ts @@ -1,12 +1,25 @@ +import { OBSERVATION_REFUSED_CODE } from "@gadgets/gatekeeper-kit/observers"; 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 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, +} from "../src/drive-api"; +import type { DriveListOptions, DriveSearchQuery } from "../src/drive-types"; import type { ObserverCheck } from "../src/observers"; import { driveObserverTracker } from "../src/drive-observers"; import { FakeKv } from "./fake-kv"; -const FOLDER_MIME_TYPE = "application/vnd.google-apps.folder"; +const refusal = () => + Object.assign(new Error("refused"), { code: OBSERVATION_REFUSED_CODE }); + +const docMime = "application/vnd.google-apps.document"; +const sheetMime = "application/vnd.google-apps.spreadsheet"; const file = (overrides: Partial = {}): DriveFile => ({ id: "file-1", @@ -17,47 +30,93 @@ const file = (overrides: Partial = {}): DriveFile => ({ }); function core(overrides: { - scope?: { kind: "account" } | { kind: "sharedDrive"; driveId: string } | - { kind: "file"; fileId: string }; + 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; }>; - prepareObservation?: (ids: string[]) => Promise>; + getScopeNodes?: (ids: readonly string[]) => Promise<(DriveScopeNode | undefined)[]>; + prepareObservation?: ( + observations: DriveObservation[], + ) => 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 units: DriveObservation[][] = []; let authorizations: ObservationDescription[] = []; let events: string[] = []; let session = new DriveSessionCore({ - api: { listFiles, getFile, getDrive }, + 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)); + units.push([...observations]); return { excludeObservers: ["excluded"], - pendingSets: ids, + pendingSets: observations, 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, getScopeNodes, prepared, units, authorizations, events }; } +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, + }), + }; + }), + }; +} + + describe("Drive metadata mapping", () => { it("maps the complete declared metadata shape without provider-only fields", () => { expect(driveFileToEntry(file({ @@ -97,6 +156,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(); @@ -109,11 +185,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 @@ -127,106 +200,106 @@ describe("Drive session scope", () => { })]); expect(authorizations[0]).not.toHaveProperty("containsRestrictedData"); 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("ends a search cleanly after an earlier page disclosed results", async () => { - let { session, listFiles } = core({ - listFiles: async options => options.pageToken === "page-2" - ? { files: [] } - : { files: [file()], nextPageToken: "page-2" }, + // The overseer can record the observation and lose the response, so an unmarked failure leaves + // the outcome unknown and the fence must stand. + it("latches admission when an empty search's audit fails ambiguously", async () => { + let { session, events } = core({ + files: [], + authorize: async () => { throw new Error("connection lost"); }, }); - let cursor = await session.search({ namePrefix: "Quarterly" }); - expect((await cursor.next())?.map(entry => entry.id)).toEqual(["file-1"]); - await expect(cursor.next()).resolves.toBeNull(); - expect(listFiles).toHaveBeenCalledTimes(2); + await expect((await session.search({ namePrefix: "missing" })).next()) + .rejects.toThrow("connection lost"); + expect(events).toEqual(["authorize", "latch"]); }); - 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], + it("leaves admission open when the overseer marks the audit refused", async () => { + let { session, events } = core({ + files: [], + authorize: async () => { throw refusal(); }, }); - 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"]]); + await expect((await session.search({ namePrefix: "missing" })).next()) + .rejects.toThrow("refused"); + expect(events).toEqual(["authorize", "unlatch"]); }); - 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" }, + // 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}` }), }); - 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", - })); + await expect((await session.search({ namePrefix: "missing" })).next()).resolves.toEqual([]); + expect(events).toEqual(["authorize", "commit"]); }); - 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" }), - }); + // A file unit proves only metadata access, so it cannot stand for "this account can list the + // folder": a metadata-only observer would pass it vacuously. + it("records a listable parent folder as a folder observation", async () => { + let { session, units, events } = core({ getFile: async id => folder(id) }); - await expect(session.getEntry("foreign")).rejects.toThrow(/outside this Drive binding/); - expect(prepared).toEqual([]); - expect(authorizations).toEqual([]); + await session.list({ directParentId: "F" }); + expect(units).toEqual([[{ kind: "folder", fileId: "F" }]]); + expect(events).toEqual(["authorize", "commit"]); }); - 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); }, + it("fences a live parent folder this account cannot list", async () => { + let { session, units, events } = core({ + getFile: async id => folder(id, { capabilities: { canListChildren: false } }), }); - await expect(session.getEntry("file-1")).rejects - .toThrow("Google Drive API request failed: 500"); + await expect(session.list({ directParentId: "F" })) + .rejects.toThrow("directParentId must identify a folder whose children can be listed"); + expect(units).toEqual([]); + expect(events).toEqual(["authorize", "latch"]); }); 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; }, + ["a non-folder", (id: string) => file({ id })], + ["a trashed folder", (id: string) => folder(id, { trashed: true })], + ])("refuses %s parent as an objective disclosure", async (_label, getFile) => { + let { session, units, events } = core({ getFile: async id => getFile(id) }); + + await expect(session.list({ directParentId: "F" })) + .rejects.toThrow("directParentId must identify a folder whose children can be listed"); + expect(units).toEqual([[{ kind: "file", fileId: "F" }]]); + expect(events).toEqual(["authorize", "commit"]); + }); + + // Dropping a blank parent as if it were absent turns a folder listing into an account-wide one, + // which is the opposite of the narrowing the caller asked for. + it.each([ + ["list", (session: DriveSessionCore) => session.list({ directParentId: " " })], + ["search", (session: DriveSessionCore) => + session.search({ namePrefix: "plan", directParentId: " " })], + ])("refuses a blank parent on %s", async (_label, read) => { + let { session, listFiles, getFile, events } = core(); + + await expect(read(session)).rejects.toThrow("directParentId must not be blank"); + expect(getFile).not.toHaveBeenCalled(); + expect(listFiles).not.toHaveBeenCalled(); + expect(events).toEqual([]); + }); + + it("ends a search cleanly after an earlier page disclosed results", async () => { + let { session, listFiles } = core({ + listFiles: async options => options.pageToken === "page-2" + ? { files: [] } + : { files: [file()], nextPageToken: "page-2" }, }); - await expect(session.getEntry("file-1")).rejects.toThrow(error); + let cursor = await session.search({ namePrefix: "Quarterly" }); + expect((await cursor.next())?.map(entry => entry.id)).toEqual(["file-1"]); + await expect(cursor.next()).resolves.toBeNull(); + expect(listFiles).toHaveBeenCalledTimes(2); }); it("refuses another file ID without calling Google for a file-scoped binding", async () => { @@ -288,29 +361,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" }, @@ -321,123 +371,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("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 => file({ id, driveId: "drive-1", mimeType: FOLDER_MIME_TYPE }), - }); - - 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", () => { - 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"], - ["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, @@ -445,11 +385,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)) @@ -499,11 +435,11 @@ 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" }, prepareObservation: fileIds => track.prepareObservation(fileIds), - observerIds: () => [...track.observers()].map(([id]) => id), + prepareWithheld: () => track.prepareWithheld(), authorize: async () => {}, }); @@ -511,7 +447,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([]); }); @@ -523,32 +459,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], @@ -627,14 +537,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" }, })); }); @@ -659,20 +566,215 @@ 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); expect(observation.description.length).toBeLessThanOrEqual(240); }); }); + + +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 = { 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 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), + }; + }, + }, + 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("lists and searches only the positioned folder's direct children", async () => { + const { session, queries } = positioned([root, nested, directDoc, nestedDoc, foreignDoc]); + + 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" }), + ]); + }); + + // One class serves both Drive session interfaces, so the RPC boundary validates the account + // shapes and a folder capability can still be handed `directParentId`. + it("refuses a caller-supplied parent on a folder listing", async () => { + const { session, queries, events } = positioned([root, directDoc]); + const options: DriveListOptions = { directParentId: "decoy" }; + + await expect(session.list(options)).rejects.toThrow(/directParentId is not accepted/); + expect(queries).toEqual([]); + expect(events).toEqual([]); + }); + + it("refuses a caller-supplied parent on a folder search", async () => { + const { session, queries, events } = positioned([root, directDoc]); + const query: DriveSearchQuery = { namePrefix: "plan", directParentId: "decoy" }; + + await expect(session.search(query)).rejects.toThrow(/directParentId is not accepted/); + expect(queries).toEqual([]); + expect(events).toEqual([]); + }); + + // An unpaged cursor must not tell the caller whether the saved path is still visible and + // connected: that read is a disclosure, and nothing has authorized one yet. + it("reads nothing about the saved path until the first page", async () => { + const { session, provider, queries, events } = positioned([root, directDoc]); + provider.byId.delete("R"); + + const pager = await session.list(); + expect(queries).toEqual([]); + expect(events).toEqual([]); + await expect(pager.next()).rejects.toThrow(/outside this Drive binding/); + }); + + // A bound shared-drive folder still navigates through its own drive corpus; the picker's + // cross-corpus discovery must not leak in here. + it("lists a shared-drive folder through that drive's corpus alone", async () => { + const sharedRoot = folder("SR", { driveId: "drive-1", parents: undefined }); + const sharedDoc = child("SD", "SR", { mimeType: docMime, driveId: "drive-1" }); + const { session, queries } = positioned([sharedRoot, sharedDoc], { folderIds: ["SR"] }); + + await expect((await session.list()).next()).resolves.toEqual([ + expect.objectContaining({ id: "SD", driveId: "drive-1" }), + ]); + expect(queries).toEqual([expect.objectContaining({ + directParentId: "SR", corpus: { kind: "drive", driveId: "drive-1" }, + })]); + }); + + it("navigates one checked child at a time", async () => { + const { session } = positioned([root, nested, directDoc, nestedDoc, foreignDoc]); + + 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/); + }); + + it("fences an unopenable child only when the refusal is owner-relative", async () => { + const objective = positioned([root, directDoc]); + await expect(objective.session.openFolder("D0")).rejects.toThrow(/outside this Drive binding/); + expect(objective.observations).toEqual([[ + { kind: "folder", fileId: "R" }, { kind: "file", fileId: "D0" }, + ]]); + expect(objective.events).toEqual(["authorize", "commit"]); + + const unlistable = folder("U", { parents: ["R"], capabilities: { canListChildren: false } }); + const ownerRelative = positioned([root, unlistable]); + await expect(ownerRelative.session.openFolder("U")) + .rejects.toThrow(/outside this Drive binding/); + expect(ownerRelative.observations).toEqual([]); + expect(ownerRelative.events).toEqual(["authorize", "latch"]); + }); + + 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(childSession.session.getScope()).rejects.toThrow(/outside this Drive binding/); + }); + + 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("rejects a trashed direct child", async () => { + const trashed = child("T", "R", { mimeType: docMime, trashed: true }); + const { session } = positioned([root, trashed]); + + 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/); + }); + + 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: [] })); + + 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"]); + }); + + 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}` })); + + 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__/observers.test.ts b/packages/gatekeeper-google/__tests__/observers.test.ts index 59b7b9c36b..32d30e38ff 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,72 @@ 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); + }); + + // 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(); + + // 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..58c1be4e4d 100644 --- a/packages/gatekeeper-google/__tests__/resources.test.ts +++ b/packages/gatekeeper-google/__tests__/resources.test.ts @@ -1,9 +1,9 @@ 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_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, @@ -29,7 +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/drive/folders/:folderId", "https://drive.google.com/file/d/:fileId/view", "https://bigquery.googleapis.com/:projectId/*", ]); @@ -90,16 +90,25 @@ 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.", + "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.", ]); }); + + 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); + } + }); }); describe("resourceUrlPatternsToOAuthScopes", () => { @@ -120,16 +129,19 @@ 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. No broader Drive scope is ever + // requested; a wider one only ever arrives from a grant the account already held. 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", + "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", @@ -144,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( @@ -201,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([]); }); @@ -228,12 +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_FILE_RESOURCE, + GOOGLE_DRIVE_RESOURCE, GOOGLE_DRIVE_FOLDER_RESOURCE, GOOGLE_DRIVE_FILE_RESOURCE, ]) { expect(hasDriveResourceGrant([resource.urlPattern])).toBe(true); } @@ -420,14 +451,24 @@ 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/drive/folders/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); }); + 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/drive/folders/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..09c377873c 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,73 @@ describe("Google resource configurators", () => { .resolves.toBe("person@example.com"); }); + // One provider page across every corpus the account reaches. A continuation token is normal for + // an interactive picker, so it must neither be followed nor treated as a failure. + it("offers listable folders from every corpus in one all-drives request", async () => { + const calls: URL[] = []; + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + calls.push(new URL(input instanceof Request ? input.url : input.toString())); + return Response.json({ + nextPageToken: "next", + files: [ + { id: "mine", name: "Team plans", capabilities: { canListChildren: true } }, + { + id: "shared-with-me", name: "Team budget", + owners: [{ displayName: "Ada" }], capabilities: { canListChildren: true }, + }, + { + id: "in-drive", name: "Team drive folder", driveId: "drive-1", + capabilities: { canListChildren: true }, + }, + { id: "metadata-only", name: "Team archive", capabilities: { canListChildren: false } }, + ], + }); + })); + + await expect(new DriveFolderConfiguratorUI(async () => token("access-token")) + .listDriveFolders("Team")) + .resolves.toEqual([ + { value: "mine", title: "Team plans", subtitle: "My Drive", meta: "mine" }, + { value: "shared-with-me", title: "Team budget", subtitle: "Ada", meta: "…-with-me" }, + { + value: "in-drive", title: "Team drive folder", subtitle: "In a shared drive", + meta: "in-drive", + }, + ]); + expect(calls).toHaveLength(1); + expect(calls[0].searchParams.get("corpora")).toBe("allDrives"); + expect(calls[0].searchParams.get("q")).toBe( + "trashed = false and mimeType = 'application/vnd.google-apps.folder' and " + + "name contains 'Team'", + ); + }); + + // Duplicate folder names across shared drives are ordinary, and every other column matches, so + // without a differentiator the user cannot see which capability they are about to grant. + it("tells same-named folders apart", async () => { + vi.stubGlobal("fetch", vi.fn(async () => Response.json({ + files: [ + { + id: "1AbCdEfGhIjKlMnOpQrStUv_platform", name: "Engineering", driveId: "drive-1", + capabilities: { canListChildren: true }, + }, + { + id: "1AbCdEfGhIjKlMnOpQrStUv_marketing", name: "Engineering", driveId: "drive-2", + capabilities: { canListChildren: true }, + }, + ], + }))); + + const options = await new DriveFolderConfiguratorUI(async () => token("access-token")) + .listDriveFolders("Engineering"); + + expect(options.map(option => [option.title, option.subtitle])).toEqual([ + ["Engineering", "In a shared drive"], + ["Engineering", "In a shared drive"], + ]); + expect(options.map(option => option.meta)).toEqual(["…platform", "…arketing"]); + }); + 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..cc9d89d230 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,249 @@ 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: decodeURIComponent(url.pathname.split("/").at(-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 move lands while the Docs call is in flight. The content reaches neither the approval + // 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, () => { + 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.slice(authorizedBefore)).toEqual([]); + }); + + // That refused read captured a revision while the document sat outside the subtree. Serving it + // from the snapshot once the document returns would disclose content the guard rejected, so the + // retry has to go back to the provider. + it("drops the snapshot a refused mid-flight read left behind", async () => { + const nodes = subtree(); + let pendingMoveOut = true; + const nativeCalls = installFolderProvider(nodes, () => { + if (!pendingMoveOut) return; + pendingMoveOut = false; + nodes.set("doc-1", + { id: "doc-1", mimeType: DOC_MIME, parents: ["elsewhere"], trashed: false }); + }); + using session = folderSession(nodes).session; + using doc = await session.openGoogleDoc("doc-1"); + + await expect(Promise.resolve(doc.getContent())).rejects.toThrow(OUTSIDE); + nodes.set("doc-1", { id: "doc-1", mimeType: DOC_MIME, parents: [ROOT], trashed: false }); + nativeCalls.length = 0; + + await expect(Promise.resolve(doc.getContent())).resolves.toBe(""); + expect(nativeCalls).toEqual(["docs.googleapis.com"]); + }); + + // Reads issued together share one fetch, so no refusal among them may leave that revision + // reusable by a later read. + it("leaves nothing reusable when a shared fetch is refused", async () => { + const nodes = subtree(); + let pendingMoveOut = true; + const nativeCalls = installFolderProvider(nodes, () => { + if (!pendingMoveOut) return; + pendingMoveOut = false; + nodes.set("doc-1", + { id: "doc-1", mimeType: DOC_MIME, parents: ["elsewhere"], trashed: false }); + }); + using session = folderSession(nodes).session; + using doc = await session.openGoogleDoc("doc-1"); + + const settled = await Promise.allSettled( + [Promise.resolve(doc.listTabs()), Promise.resolve(doc.getContent())]); + expect(settled.map(result => result.status)).toEqual(["rejected", "rejected"]); + + nodes.set("doc-1", { id: "doc-1", mimeType: DOC_MIME, parents: [ROOT], trashed: false }); + nativeCalls.length = 0; + + await expect(Promise.resolve(doc.getContent())).resolves.toBe(""); + expect(nativeCalls).toEqual(["docs.googleapis.com"]); + }); + + 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 () => { + 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/__tests__/workerd/worker.ts b/packages/gatekeeper-google/__tests__/workerd/worker.ts index 07a5d93bcf..646b19312f 100644 --- a/packages/gatekeeper-google/__tests__/workerd/worker.ts +++ b/packages/gatekeeper-google/__tests__/workerd/worker.ts @@ -2,7 +2,7 @@ import { DurableObject, RpcStub, RpcTarget } 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 { ActionKind } from "@gadgets/workshop-shared/gatekeeper"; import {TestGitCache} from "../test-git-cache"; import type { GmailComposeOptions, GmailDraftInput, GmailDraftPatch, GmailMessage, GmailReplyOptions, @@ -144,6 +144,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 new file mode 100644 index 0000000000..7425a31403 --- /dev/null +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-types.d.ts @@ -0,0 +1,9 @@ +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..2f58ff7e05 --- /dev/null +++ b/packages/gatekeeper-google/src/configurator/drive-folder-configurator-ui.tsx @@ -0,0 +1,27 @@ +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/drive/folders/${encodeURIComponent(values.folderId ?? "")}`, + render({ values, setValues, ui }) { + return
+ + ui.listDriveFolders(query)} + onChange={folderId => setValues({ folderId })} + /> + +
; + }, +} satisfies ConfiguratorUISpec; 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/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..720bf607e9 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"; @@ -8,6 +9,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,21 +24,62 @@ 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)"; + +/** + * Whether these facts describe a folder a binding may stand on: a live native folder whose + * children this account can list. + * + * The one definition of that triple. Both record shapes reach it through the adapters below, so a + * scope check can never accidentally assert two of the three. + */ +function listableFolder( + mimeType: string | undefined, trashed: boolean | undefined, + canListChildren: boolean | undefined, +): boolean { + return mimeType === FOLDER_MIME_TYPE && trashed === false && canListChildren === true; +} + +/** {@link listableFolder} for the narrow ancestry-proof shape. */ +export function isListableFolderNode(node: DriveScopeNode): boolean { + return listableFolder(node.mimeType, node.trashed, node.canListChildren); +} + +/** {@link listableFolder} for a full file resource. */ +export function isListableFolderFile(file: DriveFile): boolean { + return listableFolder(file.mimeType, file.trashed, file.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(","); /** Drive returns only requested fields, so this mask and {@link DriveFile} travel together. */ -export const DRIVE_FILE_FIELDS = `nextPageToken,files(${DRIVE_FILE_ITEM_FIELDS})`; +const DRIVE_FILE_FIELDS = `incompleteSearch,nextPageToken,files(${DRIVE_FILE_ITEM_FIELDS})`; /** Structured Drive search clauses. Every populated field is AND-ed. */ export type DriveFileQuery = { @@ -56,9 +101,13 @@ export type DriveFileQuery = { * * One value rather than the provider's independent `corpora`/`driveId` pair: a shared-drive * binding's whole boundary is those two travelling together, and `driveId` without - * `corpora: "drive"` silently falls back to the user corpus. + * `corpora: "drive"` silently falls back to the user corpus. `allDrives` spans My Drive, "Shared + * with me", and every shared drive this account is a member of. */ -export type DriveCorpus = { kind: "user" } | { kind: "drive"; driveId: string }; +export type DriveCorpus = + | { kind: "user" } + | { kind: "allDrives" } + | { kind: "drive"; driveId: string }; export type DriveListFilesOptions = DriveFileQuery & { pageSize?: number; @@ -70,8 +119,6 @@ export type DriveListFilesOptions = DriveFileQuery & { }; export type DriveFileList = { files: DriveFile[]; nextPageToken?: string }; -export type DriveListDrivesOptions = { pageSize?: number; pageToken?: string; namePrefix?: string }; -export type DriveList = { drives: DriveInfo[]; nextPageToken?: string }; /** Drive refused because the API is not enabled on this OAuth project. */ export class DriveApiDisabledError extends Error {} @@ -82,22 +129,38 @@ export class DriveApiRequestError extends Error { readonly status: number, readonly reason?: string, ) { - super(`Google Drive API request failed: ${status}${reason ? ` (${reason})` : ""}`); + super(`Google Drive API request failed: ${status}${ + reason ? ` (${REASON_EXPLANATIONS[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", ]); + +/** Reasons whose bare code leaves a caller nothing to act on. */ +const REASON_EXPLANATIONS: Record = { + teamDriveMembershipRequired: + "the connected account is not a member of the shared drive this item belongs to", +}; + function googleErrorReason(value: unknown): string | undefined { if (!isRecord(value) || !isRecord(value.error) || !Array.isArray(value.error.errors)) { return undefined; @@ -144,6 +207,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 +256,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 +287,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,15 +303,32 @@ function parseDriveFile(value: unknown): DriveFile { ...(parents ? { parents } : {}), ...(owners ? { owners } : {}), ...(trashed === undefined ? {} : { trashed }), + ...(capabilities ? { capabilities } : {}), ...(shortcutDetails ? { shortcutDetails } : {}), }; } -function parseDriveInfo(value: unknown): DriveInfo { - if (!isRecord(value) || typeof value.id !== "string" || typeof value.name !== "string") { - throw new Error("Invalid Google shared-drive response"); +/** + * 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"); } - return { id: value.id, name: value.name }; + 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 }), + }; } /** Escapes a value for interpolation into a Drive `q` string literal. */ @@ -266,6 +368,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 +419,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 +438,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; @@ -341,6 +465,10 @@ export class DriveApi { if (corpus.kind === "drive") params.set("driveId", corpus.driveId); let body = await this.#getUnknown("/files", params); if (!isRecord(body)) throw new Error("Invalid Google Drive file-list response"); + // A cross-corpus search Drive could not finish is indistinguishable from a complete one. + if (optionalBoolean(body.incompleteSearch, "incompleteSearch")) { + throw new Error("Google Drive could not complete this search. Try again."); + } let files: DriveFile[] = []; if (body.files !== undefined) { if (!Array.isArray(body.files)) throw new Error("Invalid Google Drive file-list response"); @@ -356,66 +484,59 @@ export class DriveApi { return parseDriveFile(await this.#getUnknown(`/files/${encodeURIComponent(fileId)}`, params)); } - /** Current metadata for one shared drive. */ - async getDrive(driveId: string): Promise { - let params = new URLSearchParams({ fields: "id,name" }); - return parseDriveInfo(await this.#getUnknown(`/drives/${encodeURIComponent(driveId)}`, params)); + /** Fresh access checks for typed file and folder disclosure units. */ + async checkObservations(observations: readonly DriveObservation[]): Promise { + return this.#batchGetFiles( + observations.map(observation => observation.fileId), + DRIVE_SCOPE_NODE_FIELDS, + (part, _fileId, index) => { + if (!batchPartAllowed(part)) return false; + let observation = observations[index]; + if (observation.kind === "file") return true; + return isListableFolderNode(parseDriveScopeNode(part.body, observation.fileId)); + }, + ); } - /** One page of shared drives visible to the connected account. */ - async listDrives(options: DriveListDrivesOptions = {}): Promise { - let params = new URLSearchParams({ - pageSize: String(options.pageSize ?? 100), fields: "nextPageToken,drives(id,name)", - }); - if (options.pageToken) params.set("pageToken", options.pageToken); - if (options.namePrefix?.trim()) { - params.set("q", literalClause("name", "contains", options.namePrefix.trim())); - } - let body = await this.#getUnknown("/drives", params); - if (!isRecord(body)) throw new Error("Invalid Google shared-drive list response"); - let drives: DriveInfo[] = []; - if (body.drives !== undefined) { - if (!Array.isArray(body.drives)) throw new Error("Invalid Google shared-drive list response"); - drives = body.drives.map(parseDriveInfo); - } - let nextPageToken = optionalString(body.nextPageToken, "nextPageToken"); - return { drives, ...(nextPageToken ? { nextPageToken } : {}) }; + /** + * 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); } - /** Every shared drive visible to the connected account. */ - async listAllDrives( - options: Omit = {}, - ): Promise { - let drives: DriveInfo[] = []; - let pageToken: string | undefined; - do { - let page = await this.listDrives({ - ...options, - ...(pageToken ? { pageToken } : {}), - }); - drives.push(...page.drives); - pageToken = page.nextPageToken; - } while (pageToken); - 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[] = []; + /** 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, index: number) => 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], offset + 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 +576,7 @@ export class DriveApi { continue; } - return placed.map(batchPartAllowed); + return placed; } } @@ -467,6 +588,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..b50b1734e6 --- /dev/null +++ b/packages/gatekeeper-google/src/drive-folder-scope.ts @@ -0,0 +1,66 @@ +import { + isListableFolderFile, isListableFolderNode, type DriveFile, type DriveScopeNode, +} from "./drive-api"; + +const MAX_PATH_NODES = 101; + +/** + * Internal root-to-position path; never accepted from an agent. + * + * `folderIds[0]` is the bound root, so there is no separate root field to keep in agreement with + * the path it heads. + */ +export type FolderLocation = { + folderIds: readonly string[]; +}; + +/** The single refusal for folder-scope failures. */ +export function outsideScope(): never { + throw new Error("The requested file is outside this Drive binding."); +} + +/** Reads and validates the selected folder root. */ +export async function readFolderRoot( + folderId: string, + getFile: (fileId: string) => Promise, +): Promise { + if (folderId === "root") outsideScope(); + let file = await getFile(folderId); + if (file.id !== folderId || !isListableFolderFile(file)) outsideScope(); + return file; +} + +/** 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 (ids.length === 0 || ids.length > MAX_PATH_NODES || ids[0] === "root" || + new Set(ids).size !== ids.length) { + outsideScope(); + } + + 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] || !isListableFolderNode(node) || + node.driveId !== root.driveId) { + outsideScope(); + } + if (index > 0 && (node.parents?.length !== 1 || node.parents[0] !== ids[index - 1])) { + outsideScope(); + } + } + return nodes as DriveScopeNode[]; +} + +/** 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 51788c2845..042fc5310b 100644 --- a/packages/gatekeeper-google/src/drive-observers.ts +++ b/packages/gatekeeper-google/src/drive-observers.ts @@ -1,55 +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 "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 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. - * - * `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[]) => Promise, -): ObserverTracker { - let rootId = scopeRootId(scope); - if (rootId !== undefined) { - let key = `${DRIVE_OBSERVATION_PREFIX}${encodeURIComponent(rootId)}`; + verifyBatch: ( + verifier: V, + observations: DriveObservation[], + ) => Promise, +): 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, + encode: encodeObservation, + decode: decodeObservation, verifyBatch, baselineDeniedMessage: DRIVE_BASELINE_DENIED_MESSAGE, - deniedMessage: fileId => - `This collaborator cannot access Drive file ${fileId}, whose metadata 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. + deniedMessage: () => "This collaborator cannot access Drive data this workspace has read.", maxTrackedSets: 2000, }); } diff --git a/packages/gatekeeper-google/src/drive-session.ts b/packages/gatekeeper-google/src/drive-session.ts index 32d9fb70bf..e790d7b552 100644 --- a/packages/gatekeeper-google/src/drive-session.ts +++ b/packages/gatekeeper-google/src/drive-session.ts @@ -1,18 +1,29 @@ +import { isObservationRefused } from "@gadgets/gatekeeper-kit/observers"; 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, isListableFolderFile, + type DriveApi, type DriveFile, type DriveListFilesOptions, type DriveScopeNode, +} from "./drive-api"; +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, + DriveEntry, DriveFolderListOptions, DriveFolderSearchQuery, 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"; +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; @@ -21,26 +32,78 @@ 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; + +/** + * 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[]; + scope: DriveSessionScope; + prepareObservation(observations: DriveObservation[]): Promise>; + /** Fences an owner-only observation: excludes today's observers and closes admission. */ + prepareWithheld(): ObserverCheck; authorize(description: ObservationDescription): Promise; }; +/** Construction contract for one positioned folder capability core. */ +export type DriveFolderSessionCoreOptions = Omit & { + location: FolderLocation; +}; + +/** Either core a Drive session can be driving. */ +export type DriveCore = DriveSessionCore | DriveFolderSessionCore; + function requiredString(value: string | undefined, field: string): string { if (!value) throw new Error(`Google Drive omitted required file ${field}`); return value; @@ -122,10 +185,25 @@ function timestamp(value: string | undefined, field: string): string | undefined return value; } +/** + * A supplied parent is a narrowing the caller asked for, so a blank one is refused. + * + * Dropping it as if it were absent widens the read to the whole binding, which is the opposite of + * what was asked; a blank name or text filter only matches more within the same scope. + */ +function narrowingParentId(directParentId?: string): string | undefined { + if (directParentId === undefined) return undefined; + let trimmed = directParentId.trim(); + if (!trimmed) { + throw new Error("directParentId must not be blank; omit it to read the whole binding."); + } + return trimmed; +} + function normalizeSearch(query: DriveSearchQuery): DriveSearchQuery { let namePrefix = query.namePrefix?.trim(); let fullTextContains = query.fullTextContains?.trim(); - let directParentId = query.directParentId?.trim(); + let directParentId = narrowingParentId(query.directParentId); let mimeTypes = query.mimeTypes?.map(value => value.trim()).filter(Boolean); let modifiedAfter = query.modifiedAfter ? timestamp(query.modifiedAfter, "modifiedAfter") @@ -162,7 +240,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}`; case "file": return `file ${scope.fileId}`; } } @@ -205,56 +283,147 @@ function emptySearchDescription(scope: DriveBindingScope, query: DriveListFilesO return clip(`${text}.`, MAX_OBSERVATION_DESCRIPTION); } -/** Scope enforcement, pagination, mapping, and observation authorization for Drive sessions. */ -export class DriveSessionCore { - #api: DriveSessionApi; - #scope: DriveBindingScope; - #prepareObservation: (fileIds: string[]) => Promise>; - #observerIds: () => string[]; - #authorize: (description: ObservationDescription) => Promise; +/** + * Drive's indistinguishable "absent or not permitted" answer about one file. + * + * One definition, because both cores and the batch path must agree on it: a reason Google + * documents as account-wide is an outage, not a scope fact, and recording it would narrow a + * listing or deny a binding for everyone. + */ +function isFileInvisible(error: unknown): boolean { + return error instanceof DriveApiRequestError && !error.isAccountWide && + (error.status === 403 || error.status === 404); +} + +/** + * The disclosure protocol both cores run: observation staging, the withheld fence, and the + * per-page authorization rule. + * + * Shared rather than copied because the fence ordering and the empty-search refusal are + * security-relevant, and two copies of a security rule drift without saying so. + */ +abstract class DriveCoreBase { + protected readonly api: DriveSessionApi; + protected readonly prepareObservation: + (observations: DriveObservation[]) => Promise>; + protected readonly authorize: (description: ObservationDescription) => Promise; + readonly #prepareWithheld: () => ObserverCheck; + + constructor(options: Omit) { + this.api = options.api; + this.prepareObservation = options.prepareObservation; + this.authorize = options.authorize; + this.#prepareWithheld = options.prepareWithheld; + } + + /** Stage, authorize, and commit one disclosure of `observations`. */ + protected 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(); + } + + /** {@link authorizeUnits} for plain file IDs. */ + protected authorizeFiles( + fileIds: readonly string[], title: string, description: string, + ): Promise { + return this.authorizeUnits( + fileIds.map(fileId => ({kind: "file", fileId})), title, description); + } + + /** Authorize a read no observer can be verified against, closing admission for good. */ + protected async authorizeWithheld(title: string, description: string): Promise { + let check = this.#prepareWithheld(); + try { + await this.authorize({title, description, excludeObservers: check.excludeObservers}); + } catch (error) { + // Only a marked refusal proves the overseer recorded nothing. Any other failure leaves the + // outcome unknown, so the fence latches exactly as a commit would. + if (isObservationRefused(error)) check.discard?.(); + else check.commit(); + throw error; + } + check.commit(); + } + + /** Current metadata for one file, or `undefined` when it is invisible to this account. */ + protected async tryFetchFile(fileId: string): Promise { + try { + return await this.api.getFile(fileId); + } catch (error) { + if (isFileInvisible(error)) return undefined; + throw error; + } + } + + /** + * The per-page authorization rule: refuse a terminal empty search, otherwise disclose the page. + * + * `revalidate` runs first where the scope can move under the cursor, and `baseUnits` are the + * units every page of that scope also discloses. + */ + protected pageAuthorizer( + scope: DriveBindingScope, + query: DriveListFilesOptions, + denyEmptySearch: boolean, + options: { + revalidate?: () => Promise; + baseUnits?: readonly DriveObservation[]; + } = {}, + ): (entries: DriveEntry[], exhausted: boolean) => Promise { + let hasDisclosedEntries = false; + return async (entries, exhausted) => { + await options.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."); + } + await this.authorizeUnits( + [ + ...(options.baseUnits ?? []), + ...entries.map(entry => ({kind: "file" as const, fileId: entry.id})), + ], + "Read Google Drive metadata", + listingDescription(scope, query, entries.length), + ); + if (entries.length > 0) hasDisclosedEntries = true; + }; + } +} + +/** Scope enforcement, pagination, mapping, and observation authorization for account/file sessions. */ +export class DriveSessionCore extends DriveCoreBase { + #scope: DriveSessionScope; constructor(options: DriveSessionCoreOptions) { - this.#api = options.api; + super(options); this.#scope = options.scope; - this.#prepareObservation = options.prepareObservation; - this.#observerIds = options.observerIds; - 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) this.#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 "file": { - let file = await this.#api.getFile(this.#scope.fileId); - if (file.id !== this.#scope.fileId) this.#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 = narrowingParentId(options.directParentId); + 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."); @@ -268,12 +437,12 @@ export class DriveSessionCore { } async getEntry(fileId: string): Promise { - if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#outsideScope(); - let file = await this.#getFileInScope(fileId); - let entry = driveFileToEntry(file); - await this.#authorizeIds([file.id], "Read Google Drive metadata", + if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) outsideScope(); + 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. */ @@ -282,119 +451,274 @@ export class DriveSessionCore { expectedMimeType: string, description: string, ): Promise { - if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) this.#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}.`, - ); + if (this.#scope.kind === "file" && fileId !== this.#scope.fileId) outsideScope(); + 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; } - #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Pager { - let hasDisclosedEntries = false; + /** Native reads need no moving-scope check for immutable account/file capabilities. */ + nativeRead(_fileId: string, _expectedMimeType: string): NativeRead { + return unguardedNativeRead(this.authorize); + } + + async #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Promise> { return new CursorPager({ provider: "Google Drive", fetchPage: async pageToken => { - let page = await this.#api.listFiles({ ...query, corpus: this.#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; + let page = await this.api.listFiles({...query, corpus: {kind: "user"}, pageToken}); + return {items: page.files, ...(page.nextPageToken ? {nextPageToken: page.nextPageToken} : {})}; }, + buildEntries: async files => files.map(file => driveFileToEntry(file)), + authorize: this.pageAuthorizer(this.#scope, query, denyEmptySearch), }); } #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)] }), + 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 () => { - 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}.`), }); } - #corpus(): DriveCorpus { - return this.#scope.kind === "sharedDrive" - ? { kind: "drive", driveId: this.#scope.driveId } - : { kind: "user" }; + async #assertParent(parentId: string): Promise { + if (this.#scope.kind === "file") outsideScope(); + let parent = await this.#fetchFile(parentId); + if (parent.id !== parentId) outsideScope(); + let title = "Check Google Drive folder"; + let description = "Check that the requested parent folder belongs to this Drive binding."; + if (!isListableFolderFile(parent)) { + // On a live folder only `canListChildren` is owner-relative and needs the fence; a + // non-folder or a trashed one is an objective refusal a listing would disclose anyway. + if (parent.mimeType === FOLDER_MIME_TYPE && parent.trashed === false) { + await this.authorizeWithheld(title, description); + } else { + await this.authorizeFiles([parent.id], title, description); + } + throw new Error("directParentId must identify a folder whose children can be listed"); + } + // A folder unit, not a file: the disclosure is that this account can list the folder, which a + // metadata-only observer must fail rather than pass vacuously. + await this.authorizeUnits([{kind: "folder", fileId: parent.id}], title, description); } - #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; - case "file": return file.id === this.#scope.fileId; + /** + * Like {@link tryFetchFile}, but an account binding records the invisible answer before + * rethrowing: with nothing durable recorded, a collaborator admitted later would inherit that + * disclosure unchecked. + */ + async #fetchFile(fileId: string): Promise { + try { + return await this.api.getFile(fileId); + } catch (error) { + if (this.#scope.kind === "account" && isFileInvisible(error)) { + await this.authorizeFiles([fileId], "Check Google Drive file access", + `Check whether the connected account can access Drive file ${fileId}.`); + } + throw error; } } +} - async #assertParent(parentId: string): Promise { - if (this.#scope.kind === "file") this.#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"); +/** + * The positioned folder is the only parent a folder binding searches, so a caller-supplied one is + * refused rather than dropped: the provider call overrides it, but it would still reach the + * observation description and report a folder that was never read. + */ +function rejectDirectParent(query: DriveFolderListOptions | DriveFolderSearchQuery): void { + if ((query as DriveSearchQuery).directParentId !== undefined) { + throw new Error("A Drive folder binding is already scoped; directParentId is not accepted."); } +} - async #getFileInScope(fileId: string): Promise { - let file: DriveFile; - try { - file = await this.#api.getFile(fileId); - } catch (err) { - if (err instanceof DriveApiRequestError && !err.isQuotaExceeded && - (err.status === 403 || err.status === 404)) { - if (this.#scope.kind === "sharedDrive") this.#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}.`); - } +/** Direct-child Drive access positioned at one provider-validated folder path. */ +export class DriveFolderSessionCore extends DriveCoreBase { + #location: FolderLocation; + + constructor(options: DriveFolderSessionCoreOptions) { + super(options); + this.#location = {folderIds: [...options.location.folderIds]}; + } + + 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.#rootId(), name: folder.name, + }; + } + + async list(options: DriveFolderListOptions = {}): Promise> { + rejectDirectParent(options); + return this.#cursor({orderBy: orderBy(options.order)}); + } + + async search(query: DriveFolderSearchQuery): Promise> { + rejectDirectParent(query); + 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; + } + + /** 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; + } + + /** Open one live, listable direct child folder and append its checked path edge. */ + async openFolder(folderId: string): Promise { + // `#requireDirectFile` has already established the folder is live, so this is the listable + // half of the same predicate. Only `canListChildren` is owner-relative and needs the fence; + // a direct child that is not a folder at all is an objective refusal `list()` would disclose. + let folder = await this.#requireDirectFile(folderId); + if (!isListableFolderFile(folder)) { + if (folder.mimeType === FOLDER_MIME_TYPE) { + await this.authorizeWithheld( + "Check Google Drive folder", "Check whether a requested folder can be opened here."); + } else { + await this.authorizeUnits([this.#folderObservation(), {kind: "file", fileId: folder.id}], + "Check Google Drive folder", "Check whether a requested folder can be opened here."); } - throw err; + outsideScope(); + } + await this.authorizeUnits( + [this.#folderObservation(), {kind: "folder", fileId: folder.id}], + "Open Google Drive folder", `Open direct child folder ${folder.id}.`); + return {folderIds: [...this.#location.folderIds, folder.id]}; + } + + /** 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; + }; + } + + /** + * The bound drive is learned from the first page's own revalidation rather than a read taken + * before the cursor is returned, so calling `list()` and never paging authorizes nothing and + * discloses nothing about the saved path. + */ + #cursor(query: DriveListFilesOptions, denyEmptySearch = false): Pager { + let bound: {driveId: string | undefined} | undefined; + let requireCurrentLocation = async () => { + let path = await this.#readLocation(); + bound ??= {driveId: path[0].driveId}; + if (bound.driveId !== path[0].driveId) throw new Error(FOLDER_MOVED); + return path; + }; + return new CursorPager({ + provider: "Google Drive", + fetchPage: async pageToken => { + let {driveId} = (await requireCurrentLocation())[0]; + let page = await this.api.listFiles({ + ...query, + directParentId: this.#currentFolderId(), + corpus: driveId ? {kind: "drive", driveId} : {kind: "user"}, + 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( + {kind: "folder", folderId: this.#currentFolderId()}, query, denyEmptySearch, + {revalidate: requireCurrentLocation, baseUnits: [this.#folderObservation()]}), + }); + } + + 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 || !isListableFolderFile(folder) || + folder.driveId !== path[0].driveId || + (path.length > 1 && !isDirectChild(folder, path[path.length - 2]))) { + outsideScope(); + } + return folder; + } + + async #requireDirectFile(fileId: string): Promise { + let path = await this.#readLocation(); + let file = await this.tryFetchFile(fileId); + 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 (file.id !== fileId || !this.#inScope(file)) this.#outsideScope(); + if (!isDirectChild(file, path[path.length - 1])) outsideScope(); + await this.#readLocation(); return file; } - async #authorizeIds(fileIds: string[], title: string, description: string): Promise { - let check = await this.#prepareObservation(fileIds); - await this.#authorize({ title, description, excludeObservers: check.excludeObservers }); - check.commit(); + #rootId(): string { + return this.#location.folderIds[0]; } - #outsideScope(): never { - throw new Error("The requested file is outside this Drive binding."); + #currentFolderId(): string { + return this.#location.folderIds[this.#location.folderIds.length - 1]; } -} + #folderObservation(): DriveObservation { + return {kind: "folder", fileId: this.#currentFolderId()}; + } +} diff --git a/packages/gatekeeper-google/src/drive-types.d.ts b/packages/gatekeeper-google/src/drive-types.d.ts index a5263363aa..1779499794 100644 --- a/packages/gatekeeper-google/src/drive-types.d.ts +++ b/packages/gatekeeper-google/src/drive-types.d.ts @@ -4,27 +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. + * 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 { - /** Return the next batch of results, or `null` once the cursor is 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. 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; rootFolderId: string; name: string } | { kind: "file"; fileId: string; name: string }; /** Owner metadata for a Drive entry. Absent for items in shared drives. */ @@ -46,8 +37,9 @@ export type DriveShortcut = { /** * 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. + * `list()` and `search()` never return trashed items, including the bound file of an exact-file + * binding. `getEntry()` can, and this type does not say whether an entry is trashed — there is no + * `trashed` field. */ export type DriveEntry = { /** Stable Drive file ID. */ @@ -76,38 +68,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[]; @@ -115,67 +93,76 @@ 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. `directParentId` - * limits the result to direct children, never recursive descendants, and throws when the folder - * is outside the immutable binding scope. + * List entries in an account binding, or the one exact-file entry unless it is trashed. + * + * `directParentId` throws unless it names a folder whose children this account can list. */ 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. - * - * 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. At least one filter other than `order` + * is required, and omitting `order` for a full-text search preserves Drive's relevance order. + * 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. 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. + * 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. - */ + /** Open an in-scope native Google Doc as an independently disposable read capability. */ openGoogleDoc(fileId: string): Promise; + /** Open an in-scope native Google Sheet as an independently disposable read capability. */ + openGoogleSheet(fileId: string): Promise; +} + +/** 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>; + /** - * 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. + * Search only the positioned folder's direct children using provider-side filters. At least one + * filter other than `order` is required, and omitting `order` for a full-text search preserves + * Drive's relevance order. An empty result is withheld because it is owner-relative and cannot + * be shared safely. */ + 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 access provided by an account or shared-drive binding. */ +/** 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 345598b3e4..215f0bba2f 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,7 +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 { SharedDriveConfiguratorRpc } from "./configurator/shared-drive-configurator-types"; +import type { DriveFolderConfiguratorRpc } from "./configurator/drive-folder-configurator-types"; /** * Mints an access token for a configurator, forwarding `AccessTokenRequest` to the `UserAccount` @@ -93,6 +93,17 @@ function optionMatches(parts: (string | undefined)[], query: string): boolean { return lowerQuery.split(/\s+/).every(term => corpus.includes(term)); } +/** + * Enough of a Drive ID to tell same-named results apart and to match against a Drive URL. + * + * Duplicate folder and file names are ordinary, and the picker's other columns can be identical + * too, so without this the user cannot see which capability they are granting. A tail rather than + * the whole ID because `meta` does not shrink, and a full one would crowd out the subtitle. + */ +function idTail(id: string): string { + return id.length > 8 ? `…${id.slice(-8)}` : id; +} + async function listDriveFiles( target: object, query: string, @@ -250,44 +261,58 @@ export class GoogleSheetsConfiguratorUI extends RpcTarget implements GoogleSheet export class DriveAccountConfiguratorUI extends RpcTarget implements DriveAccountConfiguratorRpc {} @validateRpc() -export class SharedDriveConfiguratorUI extends RpcTarget implements SharedDriveConfiguratorRpc { +export class DriveFileConfiguratorUI extends RpcTarget implements DriveFileConfiguratorRpc { constructor(getToken: () => Promise) { super(); googleTokenGetters.set(this, getToken); } - async listSharedDrives(query: string): Promise { + async listDriveFiles(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 }), + let { files } = await withDriveApiEnabled( + "Drive file search requires the Google Drive API to be enabled for this OAuth project.", + () => drive.listFiles({ + namePrefix: query, excludeMimeTypes: [FOLDER_MIME_TYPE], + }), ); - return drives.map(item => ({ value: item.id, title: item.name, subtitle: item.id })); + return files.map(file => ({ + value: file.id, + title: file.name, + subtitle: [ + file.mimeType, + file.modifiedTime ? `Modified ${new Date(file.modifiedTime).toLocaleDateString()}` : undefined, + ].filter(Boolean).join(" · ") || undefined, + meta: idTail(file.id), + })); } } @validateRpc() -export class DriveFileConfiguratorUI extends RpcTarget implements DriveFileConfiguratorRpc { +export class DriveFolderConfiguratorUI extends RpcTarget implements DriveFolderConfiguratorRpc { constructor(getToken: () => Promise) { super(); googleTokenGetters.set(this, getToken); } - async listDriveFiles(query: string): Promise { + /** + * One page of folders this account can list children of, across My Drive, "Shared with me", and + * every shared drive it belongs to. An interactive search, not an exhaustive enumeration. + */ + async listDriveFolders(query: string): Promise { let drive = new DriveApi(googleTokenProvider(this)); let { files } = await withDriveApiEnabled( - "Drive file search requires the Google Drive API to be enabled for this OAuth project.", + "Drive folder search requires the Google Drive API to be enabled for this OAuth project.", () => drive.listFiles({ - namePrefix: query, excludeMimeTypes: ["application/vnd.google-apps.folder"], + mimeType: FOLDER_MIME_TYPE, namePrefix: query, corpus: { kind: "allDrives" }, }), ); - return files.map(file => ({ + return files.filter(file => file.capabilities?.canListChildren === true).map(file => ({ value: file.id, title: file.name, - subtitle: [ - file.mimeType, - file.modifiedTime ? `Modified ${new Date(file.modifiedTime).toLocaleDateString()}` : undefined, - ].filter(Boolean).join(" · ") || undefined, + subtitle: file.driveId + ? "In a shared drive" + : file.owners?.[0]?.displayName ?? file.owners?.[0]?.emailAddress ?? "My Drive", + meta: idTail(file.id), })); } } diff --git a/packages/gatekeeper-google/src/google.ts b/packages/gatekeeper-google/src/google.ts index 16d7da88c6..48f866d264 100644 --- a/packages/gatekeeper-google/src/google.ts +++ b/packages/gatekeeper-google/src/google.ts @@ -20,13 +20,17 @@ import { computeReplaceOperations, docTabToMarkdown, markdownToDocRequests, type DocTabSnapshot, } from "./markdown-converter"; import { DriveApi, DriveApiRequestError } from "./drive-api"; -import { driveObserverTracker } from "./drive-observers"; +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, - type DriveBindingScope, - type DriveSessionCoreOptions, + DriveFolderSessionCore, DriveSessionCore, driveModifiedTime, + GOOGLE_DOC_MIME_TYPE, GOOGLE_SHEET_MIME_TYPE, requireDriveBindingScope, unguardedNativeRead, + type DriveBindingScope, type DriveCore, type NativeObservation, 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 +60,7 @@ import { GoogleSheetsConfiguratorUI, DriveAccountConfiguratorUI, DriveFileConfiguratorUI, - SharedDriveConfiguratorUI, + DriveFolderConfiguratorUI, } from "./google-configurators"; import BIGQUERY_CONFIGURATOR_HTML from "./generated/bigquery-configurator-ui.txt"; import CALENDAR_CONFIGURATOR_HTML from "./generated/calendar-configurator-ui.txt"; @@ -65,14 +69,14 @@ 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 SHARED_DRIVE_CONFIGURATOR_HTML from "./generated/shared-drive-configurator-ui.txt"; +import DRIVE_FOLDER_CONFIGURATOR_HTML from "./generated/drive-folder-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_SHARED_DRIVE_RESOURCE, GOOGLE_SHEETS_RESOURCE, RESOURCE_BY_KIND, SUPPORTED_RESOURCES, + 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, recordedResourceUrlPatterns, type RecordedResourceGrant, } from "./resources"; @@ -770,15 +774,13 @@ 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 { @@ -836,10 +836,10 @@ export class GatekeeperUserImpl extends WorkerEntrypoint; hasCalendarFreeBusyAccess(calendarId: string): Promise; hasDatasetAccess(projectId: string, datasetId: string): Promise; - verifyDriveFiles(fileIds: string[]): Promise; + verifyDriveObservations(observations: DriveObservation[]): Promise; } @validateRpc() @@ -1015,15 +1015,20 @@ export class GoogleVerifier extends WorkerEntrypoint } } - async verifyDriveFiles(fileIds: string[]): Promise { + 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) }; + return { + baselineAllowed, + allowed: await api.checkObservations(observations), + }; } } @@ -1949,7 +1954,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, @@ -2158,8 +2163,11 @@ export class GoogleSheetsGatekeeperImpl async startSession(approvalQueue: RpcStub): Promise { let api = new GoogleSheetsApi(opts => this.#getAccessToken(opts)); + let queue = approvalQueue.dup(); + // A spreadsheet binding's scope is the one spreadsheet, so there is nothing to revalidate. return new GoogleSpreadsheetSessionImpl( - api, this.ctx.props.spreadsheetId, approvalQueue.dup(), + api, this.ctx.props.spreadsheetId, queue, + unguardedNativeRead(description => queue.authorizeObservation(description)), ); } @@ -2197,16 +2205,19 @@ 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; } [Symbol.dispose](): void { @@ -2214,14 +2225,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( @@ -2242,22 +2253,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.", + }; + }); } } @@ -2803,7 +2814,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, @@ -2814,14 +2825,17 @@ export class GoogleDriveGatekeeperImpl }; } let api = new DriveApi(opts => this.#getAccessToken(opts)); - if (scope.kind === "sharedDrive") { - let drive = await api.getDrive(scope.driveId); + 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 { - 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", + // 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: `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: "GoogleDriveFolderSession", }; } let file = await api.getFile(scope.fileId); @@ -2849,10 +2863,10 @@ export class GoogleDriveGatekeeperImpl new DriveApi(getDriveAccessToken), new GoogleDocsApi(getDriveAccessToken), new GoogleSheetsApi(getDriveAccessToken), - this.ctx.props.scope, + this.#scope, approvalQueue.dup(), - fileIds => observerTracker.prepareObservation(fileIds), - () => [...observerTracker.observers()].map(([id]) => id), + observations => observerTracker.prepareObservation(observations), + () => observerTracker.prepareWithheld(), ); } @@ -2863,10 +2877,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) => verifier.verifyDriveFiles([...fileIds])); + 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 { @@ -2884,20 +2902,25 @@ class GoogleDocReadSessionImpl extends RpcTarget implements GoogleDocReadSession #driveApi: DriveApi; #documentId: string; #approvalQueue: RpcStub; - /** The most recent snapshot request. Chaining onto it serializes concurrent reads. */ - #snapshot?: Promise; + /** A fetch still in flight, so reads issued together observe one revision. */ + #inFlight?: Promise; + /** The last revision a completed scope check approved, the only one a later read may reuse. */ + #approved?: GoogleDocSnapshot; + #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; } [Symbol.dispose](): void { @@ -2905,24 +2928,44 @@ 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 - // racing to overwrite each other with whichever response lands last. - #getSnapshot(): Promise { - return this.#snapshot = this.#nextSnapshot(this.#snapshot); + /** + * Reads one snapshot under the binding's scope guard. + * + * Only a revision a whole guard cycle approved becomes reusable. The scope check straddles the + * fetch, so a revision fetched while the document was outside must never reach a later read -- + * and rolling it back after the refusal is too late, since a read that chained onto it meanwhile + * already holds it. Reads issued together still share one fetch: each brackets that fetch with + * its own checks, exactly as a lone read does. + */ + async #readSnapshot( + use: (snapshot: GoogleDocSnapshot) => T, + observe: (value: T) => NativeObservation, + ): Promise { + let snapshot: GoogleDocSnapshot | undefined; + let value = await this.#read(async () => { + snapshot = await (this.#inFlight ??= this.#nextSnapshot() + .finally(() => { this.#inFlight = undefined; })); + return use(snapshot); + }, observe); + // A slow guard must not republish its older revision over a newer one already approved. + if (snapshot && snapshot.fetchedAt >= (this.#approved?.fetchedAt ?? 0)) { + this.#approved = snapshot; + } + return value; } /** Reuse one revision for the TTL, then confirm it is still current before reusing it again. */ - async #nextSnapshot(pending?: Promise): Promise { - let cached = await pending?.catch(() => undefined); + async #nextSnapshot(): Promise { + let cached = this.#approved; if (cached) { if (Date.now() - cached.fetchedAt < DOC_SNAPSHOT_TTL_MS) return cached; if (await googleDocRevisionUnchanged(this.#docsApi, this.#documentId, cached)) { @@ -2934,45 +2977,52 @@ 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.#readSnapshot( + snapshot => snapshot.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({ + // The selector error says whether a tab exists, so a failed attempt discloses something too + // and has to be authorized. It rides back as a value so one guarded read covers both outcomes. + let selection = await this.#readSnapshot( + (snapshot): { tab: GoogleDocTabSnapshot } | { error: unknown } => { + try { + return { tab: resolveGoogleDocTab(snapshot, tabId, "getContent") }; + } catch (error) { + return { error }; + } + }, + result => ({ title: "Read Google Doc content", - description: "Read the content of one tab of the document.", - }); - throw error; - } - - await this.#approvalQueue.authorizeObservation({ - title: "Read Google Doc content", - description: `Read the current content of tab ${googleDocTabLabel(tab)} as Markdown.`, - }); - return tab.markdown; + description: "tab" in result + ? `Read the current content of tab ${googleDocTabLabel(result.tab)} as Markdown.` + : "Read the content of one tab of the document.", + })); + if (!("tab" in selection)) throw selection.error; + return selection.tab.markdown; } } /** 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: DriveCore; #driveApi: DriveApi; #docsApi: GoogleDocsApi; #sheetsApi: GoogleSheetsApi; + #scope: DriveBindingScope; + /** Set exactly when the scope is a folder, so no core has to be built to learn which it is. */ + #location?: FolderLocation; #approvalQueue: RpcStub; + #prepareObservation: ( + observations: DriveObservation[], + ) => Promise>; + #prepareWithheld: () => ObserverCheck; constructor( driveApi: DriveApi, @@ -2980,15 +3030,23 @@ export class GoogleDriveSessionImpl extends RpcTarget implements GoogleDriveSess sheetsApi: GoogleSheetsApi, scope: DriveBindingScope, approvalQueue: RpcStub, - prepareObservation: (fileIds: string[]) => Promise>, - observerIds: () => string[], + prepareObservation: ( + observations: DriveObservation[], + ) => Promise>, + prepareWithheld: () => ObserverCheck, + location?: FolderLocation, ) { super(); this.#driveApi = driveApi; this.#docsApi = docsApi; this.#sheetsApi = sheetsApi; + this.#scope = scope; + this.#location = scope.kind === "folder" + ? location ?? {folderIds: [scope.folderId]} + : undefined; this.#approvalQueue = approvalQueue; - this.#coreOptions = { api: driveApi, scope, prepareObservation, observerIds }; + this.#prepareObservation = prepareObservation; + this.#prepareWithheld = prepareWithheld; this.#core = this.#coreFor(this.#approvalQueue); } @@ -3008,57 +3066,78 @@ 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); + } + + async openFolder(folderId: string): Promise { + if (this.#scope.kind !== "folder") outsideScope(); + return this.#withQueue(async (queue, core) => { + let location = await (core as DriveFolderSessionCore).openFolder(folderId); + return new GoogleDriveSessionImpl( + this.#driveApi, this.#docsApi, this.#sheetsApi, this.#scope, queue, + this.#prepareObservation, this.#prepareWithheld, location, + ); }); } + async openGoogleDoc(fileId: string): Promise { + 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 { + return this.#openNative(fileId, GOOGLE_SHEET_MIME_TYPE, "Google Sheet", + (spreadsheetId, queue, read) => + new GoogleSpreadsheetSessionImpl(this.#sheetsApi, spreadsheetId, queue, read)); + } + + #coreFor(queue: RpcStub): DriveCore { + 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 ?? {folderIds: [this.#scope.folderId]}, + }); + } + return new DriveSessionCore({...common, scope: this.#scope}); + } + /** - * A cursor paging through an approval-queue stub of its own, disposed with the cursor. + * Runs `use` against a capability-owned approval queue, disposing it if `use` throws. * - * 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. + * A capability handed to the caller outlives this session, so it pages through a queue of its + * own; the queue is this session's to release until ownership transfers on success. */ - async #cursor( - open: (core: DriveSessionCore) => Promise>, - ): Promise> { + async #withQueue(use: (queue: RpcStub, core: DriveCore) => Promise) { let queue = this.#approvalQueue.dup(); try { - return new RpcCursor(await open(this.#coreFor(queue)), queue); + return await use(queue, this.#coreFor(queue)); } catch (error) { queue[Symbol.dispose](); throw error; } } - getEntry(fileId: string): Promise { - return this.#core.getEntry(fileId); - } - - 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(), - ); + async #cursor(open: (core: DriveCore) => Promise>): Promise> { + return this.#withQueue(async (queue, core) => new RpcCursor(await open(core), queue)); } - 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(), - ); + async #openNative( + fileId: string, + mimeType: string, + description: string, + build: (id: string, queue: RpcStub, read: NativeRead) => T, + ): Promise { + return this.#withQueue(async (queue, core) => { + let id = await core.openNativeFile(fileId, mimeType, description); + return build(id, queue, core.nativeRead(id, mimeType)); + }); } } diff --git a/packages/gatekeeper-google/src/observers.ts b/packages/gatekeeper-google/src/observers.ts index 4012b9b736..d831b4dc56 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,10 @@ 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, + OBSERVER_WITHHELD_KEY, + ]; if (reserved.includes(options.setPrefix)) { throw new Error(`setPrefix must not collide with a reserved prefix (${reserved.join(", ")})`); } @@ -236,11 +250,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 +326,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); @@ -311,6 +363,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 4c836d697f..bffb8665a1 100644 --- a/packages/gatekeeper-google/src/resources.ts +++ b/packages/gatekeeper-google/src/resources.ts @@ -85,11 +85,13 @@ 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.", +/** 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/drive/folders/:folderId", + title: "Google Drive Folder", + description: + "Browse a selected folder or shared drive, search its direct children, and read native " + + "Google Docs and Sheets.", grantable: true, }; @@ -174,15 +176,12 @@ 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. - scopes: ["https://www.googleapis.com/auth/drive.readonly"], + resource: GOOGLE_DRIVE_FOLDER_RESOURCE, + 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, @@ -204,7 +203,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, ]); @@ -217,6 +216,13 @@ export function hasDriveResourceGrant(resourceUrlPatterns: readonly string[]): b return resourceUrlPatterns.some(pattern => DRIVE_RESOURCE_PATTERNS.has(pattern)); } +/** + * Wider Drive grants an account may already hold. Never requested here; they appear only in + * {@link SCOPE_COVERED_BY}, where they truthfully subsume the narrow requirements. + */ +const DRIVE_READONLY_SCOPE = "https://www.googleapis.com/auth/drive.readonly"; +const DRIVE_READWRITE_SCOPE = "https://www.googleapis.com/auth/drive"; + /** 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)); @@ -238,6 +244,30 @@ export function resourceUrlPatternsToOAuthScopes(resourceUrlPatterns: readonly s return [...scopes]; } +/** + * Scopes that subsume each required scope, so a wider grant still covers a resource. + * + * Declared as data beside {@link RESOURCE_SCOPES} rather than as branches: a missing implication + * reads as an ungranted resource and silently hides a configurator, so the next readonly/readwrite + * pair should be a row here and nothing else. + */ +const SCOPE_COVERED_BY: Record = { + "https://www.googleapis.com/auth/drive.metadata.readonly": [ + "https://www.googleapis.com/auth/drive.metadata", DRIVE_READONLY_SCOPE, DRIVE_READWRITE_SCOPE, + ], + "https://www.googleapis.com/auth/documents.readonly": [ + "https://www.googleapis.com/auth/documents", DRIVE_READONLY_SCOPE, DRIVE_READWRITE_SCOPE, + ], + "https://www.googleapis.com/auth/spreadsheets.readonly": [ + "https://www.googleapis.com/auth/spreadsheets", DRIVE_READONLY_SCOPE, DRIVE_READWRITE_SCOPE, + ], +}; + +function oauthScopeCovers(required: string, granted: ReadonlySet): boolean { + return granted.has(required) || + (SCOPE_COVERED_BY[required]?.some(scope => granted.has(scope)) ?? false); +} + /** * The subset of `resourceUrlPatterns` whose every OAuth scope is present in `grantedOAuthScopes`. * @@ -251,7 +281,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); } @@ -309,7 +339,7 @@ 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 }; /** The grantable resource each {@link ResourceTarget} kind belongs to. */ @@ -320,7 +350,7 @@ export const RESOURCE_BY_KIND: Record calendar: GOOGLE_CALENDAR_RESOURCE, bigquery: BIGQUERY_RESOURCE, driveAccount: GOOGLE_DRIVE_RESOURCE, - sharedDrive: GOOGLE_SHARED_DRIVE_RESOURCE, + driveFolder: GOOGLE_DRIVE_FOLDER_RESOURCE, driveFile: GOOGLE_DRIVE_FILE_RESOURCE, }; @@ -429,8 +459,8 @@ function parseCalendarUrl(parsed: URL): ResourceTarget { 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]) }; + let folder = /^\/drive\/folders\/([^/]+)\/?$/.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]) };