Skip to content

Commit 8932ca2

Browse files
committed
fix(knowledge): read shared-drive permissions, and finish a pending switch before mirroring
Two mechanics found by tracing the admin-mode path end to end rather than reading it. Every file on a shared drive was invisible. Drive does not populate a file's `permissions` when it lives on a shared drive — the docs say so outright, and the field must come from `permissions.list` instead. The listing left those files without an ACL, and the pass treated "no ACL" as "readable by nobody". A whole shared drive indexed to nothing is the failure the plan's own note about Onyx's `permissionIds` comment was warning about, and it was never built. The contract now says what it should have: `getDocumentAcls` is called with exactly the ids the listing could not answer for, and the engine merges the two sources with the listing's answer winning where it exists. Drive implements it by paging `permissions.list` per file under bounded concurrency; a file whose inline entries were incomplete goes the same way instead of being hidden. Confluence carries nothing inline, so it is unchanged. The merge is a pure function with its own tests, and the shared-drive tests fail if the hook is removed. A switch into administrator mode whose hide outgrew its request budget was never finished. The completion write cleared `accessRewritePending`, but the only thing the content engine did with the flag was restore workspace ACLs, which admin mode's SQL guard correctly ignored — so documents still carrying `{ws}` from before the switch kept it until the pass overwrote them, and any the listing missed kept it indefinitely. The pending hide now runs under the lease before the pass writes real ACLs, mirroring how the member engine finishes its own; the flag is then cleared on the strength of that. The shared-drive group token is gone. Nothing resolved it — the directory sync enumerates groups, not drives — and Drive already reports a drive's members as ordinary inherited permissions on each file, so the token was both redundant and a grant nobody could hold.
1 parent 9e68ad3 commit 8932ca2

8 files changed

Lines changed: 313 additions & 72 deletions

File tree

apps/sim/connectors/google-drive/google-drive.test.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -649,7 +649,6 @@ describe('mirroring Drive permissions onto listed documents', () => {
649649
const url = String(mockFetch.mock.calls[0][0])
650650
expect(decodeURIComponent(url)).toContain('permissions(id,type,emailAddress,domain,role,')
651651
expect(decodeURIComponent(url)).toContain('permissionIds')
652-
expect(decodeURIComponent(url)).toContain('driveId')
653652
})
654653

655654
it('tags each document with who may read it', async () => {
@@ -735,15 +734,63 @@ describe('mirroring Drive permissions onto listed documents', () => {
735734
expect(doc.acl).toEqual(['link'])
736735
})
737736

738-
it('records the shared drive a file lives on', async () => {
739-
const doc = await listWith(
740-
driveFile({
741-
driveId: 'shared-drive-1',
742-
permissions: [{ id: 'p1', type: 'user', emailAddress: 'alice@corp.com' }],
743-
}),
744-
ADMIN
737+
/**
738+
* Drive does not populate `permissions` for a file on a shared drive; the
739+
* only source is `permissions.list`. A listing that left the ACL unset must
740+
* therefore be answered by the fallback, not treated as readable by nobody.
741+
*/
742+
it('resolves a file the listing could not describe through permissions.list', async () => {
743+
const doc = await listWith(driveFile({}), ADMIN)
744+
expect(doc.acl).toBeUndefined()
745+
746+
mockFetch.mockResolvedValueOnce(
747+
jsonResponse({
748+
permissions: [
749+
{ id: 'p1', type: 'user', emailAddress: 'alice@corp.com' },
750+
{ id: 'p2', type: 'group', emailAddress: 'eng@corp.com' },
751+
],
752+
})
745753
)
746754

747-
expect(doc.acl).toEqual(['g:google-drive:corp.com:shared-drive-1', 'u:alice@corp.com'])
755+
await expect(
756+
googleDriveConnector.getDocumentAcls?.('token', ADMIN, [FILE_ID], {})
757+
).resolves.toEqual({
758+
[FILE_ID]: ['g:google-drive:corp.com:eng@corp.com', 'u:alice@corp.com'],
759+
})
760+
const url = String(mockFetch.mock.calls[1][0])
761+
expect(url).toContain(`/files/${FILE_ID}/permissions`)
762+
expect(url).toContain('supportsAllDrives=true')
763+
})
764+
765+
it('follows the permission list across pages', async () => {
766+
mockFetch
767+
.mockResolvedValueOnce(
768+
jsonResponse({
769+
permissions: [{ id: 'p1', type: 'user', emailAddress: 'alice@corp.com' }],
770+
nextPageToken: 'p2',
771+
})
772+
)
773+
.mockResolvedValueOnce(
774+
jsonResponse({ permissions: [{ id: 'p2', type: 'user', emailAddress: 'bob@corp.com' }] })
775+
)
776+
777+
await expect(
778+
googleDriveConnector.getDocumentAcls?.('token', ADMIN, [FILE_ID], {})
779+
).resolves.toEqual({ [FILE_ID]: ['u:alice@corp.com', 'u:bob@corp.com'] })
780+
})
781+
782+
it('omits a file whose permissions could not be read, so it stays hidden', async () => {
783+
mockFetch.mockResolvedValueOnce(jsonResponse({ error: 'nope' }, 403))
784+
785+
await expect(
786+
googleDriveConnector.getDocumentAcls?.('token', ADMIN, [FILE_ID], {})
787+
).resolves.toEqual({})
788+
})
789+
790+
it('answers nothing for a crawl that mirrors no permissions', async () => {
791+
await expect(
792+
googleDriveConnector.getDocumentAcls?.('token', {}, [FILE_ID], {})
793+
).resolves.toEqual({})
794+
expect(mockFetch).not.toHaveBeenCalled()
748795
})
749796
})

apps/sim/connectors/google-drive/google-drive.ts

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage, toError } from '@sim/utils/errors'
33
import { isPlainRecord } from '@sim/utils/object'
4+
import { mapWithConcurrency } from '@/lib/core/utils/concurrency'
45
import {
56
type DrivePermission,
67
driveFileAcl,
@@ -225,7 +226,6 @@ interface DriveFile {
225226
* its real grants.
226227
*/
227228
permissionIds?: string[]
228-
driveId?: string
229229
}
230230

231231
interface DriveChange {
@@ -485,14 +485,15 @@ function driveAclContext(sourceConfig: Record<string, unknown>): DriveAclContext
485485
}
486486

487487
/**
488-
* The file's mirrored ACL, or undefined when this crawl cannot speak for it.
488+
* The file's mirrored ACL from its listing, or undefined when the listing
489+
* cannot speak for it and {@link resolveDriveAcls} must.
489490
*
490-
* Drive sometimes returns fewer expanded permissions than it reports ids for,
491-
* which is why both are requested. Mirroring the subset that did arrive would
492-
* store the file under narrower grants than it really has — which sounds like
493-
* the safe direction but is not, because the grants that went missing are
494-
* exactly the ones nobody verified. Undefined leaves the file readable by
495-
* nobody until a crawl sees the whole set.
491+
* Drive does not populate `permissions` for a file on a shared drive at all,
492+
* and sometimes returns fewer expanded entries than it reports ids for — which
493+
* is why both are requested. Mirroring the subset that did arrive would store
494+
* the file under narrower grants than it really has, which sounds like the
495+
* safe direction but is not, because the grants that went missing are exactly
496+
* the ones nobody verified. Undefined sends the file to `permissions.list`.
496497
*/
497498
function fileAcl(file: DriveFile, context: DriveAclContext | null): string[] | undefined {
498499
if (!context || !file.permissions) return undefined
@@ -502,10 +503,94 @@ function fileAcl(file: DriveFile, context: DriveAclContext | null): string[] | u
502503
providerId: context.providerId,
503504
tenantId: context.tenantId,
504505
policy: context.policy,
505-
driveId: file.driveId,
506506
})
507507
}
508508

509+
/** Files whose permissions are fetched at once. Bounded to keep a crawl responsive. */
510+
const PERMISSION_FETCH_CONCURRENCY = 8
511+
512+
/** Guards against a file that keeps paginating; far above any real permission list. */
513+
const MAX_PERMISSION_PAGES = 50
514+
515+
/**
516+
* A file's full permission list, from the one endpoint that serves it for every
517+
* file — including those on a shared drive, whose listing carries none.
518+
*
519+
* Throws rather than returning a partial list: a file mirrored under the
520+
* permissions that happened to arrive is a file whose missing grants nobody
521+
* verified.
522+
*/
523+
async function listFilePermissions(
524+
accessToken: string,
525+
fileId: string
526+
): Promise<DrivePermission[]> {
527+
const permissions: DrivePermission[] = []
528+
let pageToken: string | undefined
529+
for (let page = 0; page < MAX_PERMISSION_PAGES; page += 1) {
530+
const query = new URLSearchParams({
531+
fields: 'nextPageToken,permissions(id,type,emailAddress,domain,role,allowFileDiscovery)',
532+
pageSize: '100',
533+
supportsAllDrives: 'true',
534+
})
535+
if (pageToken) query.set('pageToken', pageToken)
536+
537+
const response = await fetchGoogleDriveWithRetry(
538+
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(fileId)}/permissions?${query.toString()}`,
539+
{
540+
method: 'GET',
541+
headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
542+
}
543+
)
544+
if (!response.ok) {
545+
throw new Error(`Google Drive permissions request failed: ${response.status}`)
546+
}
547+
const body = (await response.json()) as {
548+
permissions?: DrivePermission[]
549+
nextPageToken?: string
550+
}
551+
permissions.push(...(body.permissions ?? []))
552+
pageToken = body.nextPageToken
553+
if (!pageToken) return permissions
554+
}
555+
throw new Error(`Google Drive permissions exceeded ${MAX_PERMISSION_PAGES} pages`)
556+
}
557+
558+
/**
559+
* The ACLs of files whose listing could not describe them — every file on a
560+
* shared drive, and any whose inline permissions were incomplete.
561+
*
562+
* A file whose permissions cannot be read is omitted, which leaves it readable
563+
* by nobody until a run can read them: the failure is logged per file and the
564+
* rest of the batch still resolves.
565+
*/
566+
async function resolveDriveAcls(
567+
accessToken: string,
568+
sourceConfig: Record<string, unknown>,
569+
externalIds: string[]
570+
): Promise<Record<string, string[]>> {
571+
const context = driveAclContext(sourceConfig)
572+
if (!context) return {}
573+
574+
const acls: Record<string, string[]> = {}
575+
await mapWithConcurrency(externalIds, PERMISSION_FETCH_CONCURRENCY, async (fileId) => {
576+
try {
577+
const permissions = await listFilePermissions(accessToken, fileId)
578+
acls[fileId] = driveFileAcl({
579+
permissions,
580+
providerId: context.providerId,
581+
tenantId: context.tenantId,
582+
policy: context.policy,
583+
})
584+
} catch (error) {
585+
logger.warn("Could not read a file's permissions; it stays readable by nobody", {
586+
fileId,
587+
...googleDriveErrorLogFields(error),
588+
})
589+
}
590+
})
591+
return acls
592+
}
593+
509594
function fileToStub(file: DriveFile, acl?: string[]): ExternalDocument {
510595
/**
511596
* Sheets moved from a first-sheet-only CSV export to the complete XLSX source.
@@ -563,7 +648,7 @@ export const googleDriveConnector: ConnectorConfig = {
563648
pageSize: String(effectivePageSize),
564649
orderBy: 'modifiedTime desc',
565650
fields:
566-
'kind,nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,parents,driveId,permissionIds,permissions(id,type,emailAddress,domain,role,allowFileDiscovery,permittedBy))',
651+
'kind,nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,createdTime,webViewLink,owners,size,starred,parents,permissionIds,permissions(id,type,emailAddress,domain,role,allowFileDiscovery,permittedBy))',
567652
supportsAllDrives: 'true',
568653
includeItemsFromAllDrives: 'true',
569654
})
@@ -646,6 +731,9 @@ export const googleDriveConnector: ConnectorConfig = {
646731
openDirectory: async (accessToken, sourceConfig) =>
647732
openGoogleDirectory(accessToken, sourceConfig.adminEmail),
648733

734+
getDocumentAcls: (accessToken, sourceConfig, externalIds) =>
735+
resolveDriveAcls(accessToken, sourceConfig, externalIds),
736+
649737
getDocument: async (
650738
accessToken: string,
651739
sourceConfig: Record<string, unknown>,

apps/sim/connectors/types.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -448,14 +448,15 @@ export interface ConnectorConfig extends ConnectorMeta {
448448
) => Promise<ConnectorDirectory | null>
449449

450450
/**
451-
* The ACLs of a batch of already-listed documents, for a source whose
452-
* permissions do not come back with its listing.
451+
* The ACLs of listed documents the listing itself could not answer for.
453452
*
454-
* Drive reports each file's permissions in the same page that lists it, so it
455-
* fills {@link ExternalDocument.acl} directly and needs none of this.
456-
* Confluence reports a page's restrictions only when asked for that page, so
457-
* it resolves them here — batched, and after the listing, so the round trips
458-
* are bounded by the corpus rather than by the page size.
453+
* Called with exactly the external ids whose {@link ExternalDocument.acl}
454+
* the listing left unset, after the listing and once for all of them, so the
455+
* round trips are bounded by what the listing could not carry rather than by
456+
* the page size. Drive fills the ACL inline for most files and lands here
457+
* only for the ones its listing cannot describe — a shared drive's files,
458+
* whose permissions Drive serves solely through `permissions.list`.
459+
* Confluence carries none inline, so every page lands here.
459460
*
460461
* Returns tokens per external id. An id the connector omits is readable by
461462
* nobody: a connector declaring {@link ConnectorMeta.mirrorsSourceAcls}

apps/sim/lib/knowledge/access/drive-permissions.test.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const PROVIDER = 'google-drive'
1515
const TENANT = 'C01abcdef'
1616
const OPEN: OpenSharingPolicy = { domain: true, anyone: true }
1717

18-
function acl(permissions: DrivePermission[], policy = CLOSED_OPEN_SHARING, driveId?: string) {
19-
return driveFileAcl({ permissions, providerId: PROVIDER, tenantId: TENANT, policy, driveId })
18+
function acl(permissions: DrivePermission[], policy = CLOSED_OPEN_SHARING) {
19+
return driveFileAcl({ permissions, providerId: PROVIDER, tenantId: TENANT, policy })
2020
}
2121

2222
describe('driveFileAcl', () => {
@@ -46,12 +46,6 @@ describe('driveFileAcl', () => {
4646
).toEqual([`g:${PROVIDER}:${TENANT}:eng@corp.com`])
4747
})
4848

49-
it('records the shared drive as a group rather than expanding its membership', () => {
50-
expect(
51-
acl([{ type: 'user', emailAddress: 'alice@corp.com' }], CLOSED_OPEN_SHARING, 'drive-1')
52-
).toEqual([`g:${PROVIDER}:${TENANT}:drive-1`, 'u:alice@corp.com'])
53-
})
54-
5549
describe('open sharing is closed by default', () => {
5650
it('drops a whole-domain share', () => {
5751
expect(acl([{ type: 'domain', domain: 'corp.com' }])).toEqual(['link'])
@@ -138,8 +132,7 @@ describe('driveFileAcl', () => {
138132
{ type: 'domain', domain: 'corp.com' },
139133
{ type: 'anyone' },
140134
],
141-
OPEN,
142-
'drive-1'
135+
OPEN
143136
)
144137
for (const token of tokens) expect(token).toMatch(ACCESS_TOKEN_PATTERN)
145138
})

apps/sim/lib/knowledge/access/drive-permissions.ts

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,21 +60,16 @@ export interface DriveAclInput {
6060
/** The Google Workspace customer the crawl runs against. */
6161
tenantId: string | null
6262
policy: OpenSharingPolicy
63-
/**
64-
* The shared drive the file lives on, if any. Recorded as a group rather than
65-
* expanded here: a shared drive's membership is directory state, and
66-
* resolving it during the crawl would re-read it once per file.
67-
*/
68-
driveId?: string | null
6963
}
7064

7165
/**
7266
* The ACL of one Drive file, from the permissions the listing returned.
7367
*
74-
* Inheritance is deliberately not resolved here. A grant that descends from a
75-
* folder still arrives in `permissions[]` with `permittedBy` naming its source,
76-
* so it maps like any other; what is *not* resolved is group membership, which
77-
* belongs to the directory sync. That keeps the crawl one pass over files.
68+
* Inheritance needs no resolving here. A grant that descends from a folder or
69+
* from shared-drive membership arrives in `permissions[]` as an ordinary
70+
* principal, so it maps like any other; what is *not* resolved is group
71+
* membership, which belongs to the directory sync. That keeps the crawl one
72+
* pass over files.
7873
*
7974
* A file whose every grant is unrepresentable — an `anyone` share that is
8075
* link-only, a principal with no email — resolves to a single `link` token
@@ -131,11 +126,6 @@ export function driveFileAcl(input: DriveAclInput): string[] {
131126
}
132127
}
133128

134-
if (input.driveId) {
135-
const token = groupToken({ providerId, tenantId, groupId: input.driveId })
136-
if (token) tokens.add(token)
137-
}
138-
139129
if (tokens.size === 0) return [LINK_ACCESS_TOKEN]
140130
return sortAccessTokens(tokens)
141131
}

0 commit comments

Comments
 (0)