Skip to content

Commit 9e68ad3

Browse files
committed
fix(knowledge): close two admin-mode gaps found in an architecture audit
Two real bugs, then the duplication and drift a full re-read of the branch turned up. Administrator mode was unreachable for Confluence. Entering the mode required an impersonation subject on every connector, but a Confluence service account holds an API token that already speaks for the site and impersonates nobody, so the check refused every attempt. A subject is now required only of a connector whose auth declares a subject field, and a test pins the token-backed case. An incremental listing could not carry a revoked grant. A permission change moves no content — re-sharing a file does not touch its modified time in Drive, restricting a page does not touch its version in Confluence — so an incremental run listed only edited documents and the ACL pass refreshed only those. A grant revoked on an unchanged document stood until the next full sync happened to run, which is the over-grant direction. Administrator mode now always lists the whole corpus; content is still hydrated by hash, so unchanged documents are never re-fetched or re-embedded, and the cost is metadata pages only. Configuration validation minted without impersonation. The shared token resolver took the source config as optional, and the validate path did not pass it, so a Drive service account checked its configuration against an empty domain. The config is required now, and every caller — sync, validation, mode switch, creation, and the directory scheduler — passes it. The Workspace domain was derived in two places with a comment saying they must agree. It is one function now, beside the Google directory adapter, which moves from the knowledge library to the Drive connector where Confluence's equivalent already lives; provider-specific API clients belong with their connector, and the orchestration that calls them stays provider-agnostic. The Confluence connector memoised its cloud id in four separate copies, which became one. A group identifier is canonicalised once, in `canonicalGroupId`, by the crawl that writes a token and the directory sync that stores the membership it resolves against. Both already lower-cased by different routes; now they cannot drift. Confluence identifies groups by id, so the docs claiming "never an opaque id" were wrong and are corrected in the token vocabulary and the schema. Removed what nothing read: the impersonation subject the token resolver returned, a `force` flag no caller passed, two vestigial type aliases, and a nesting-depth constant that had one consumer and now lives with it. The directory recency check is one aggregate query rather than two probes; the Confluence ACL resolver enriches principals once rather than once per page; the mode picker validates its value instead of casting it; and the admin-mode hint no longer describes a field only Drive has. The create path had two copies of the admin-role gate, one per permission-scoped mode, and has one.
1 parent de993af commit 9e68ad3

18 files changed

Lines changed: 377 additions & 205 deletions

File tree

apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/connector-access-field/connector-access-field.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { ReactNode } from 'react'
44
import { ButtonGroup, ButtonGroupItem, ChipCombobox, ChipModalField } from '@sim/emcn'
55
import type { ConnectorAccessMode } from '@/lib/api/contracts/knowledge/connectors'
6+
import { isConnectorAccessMode } from '@/lib/knowledge/connectors/access-modes'
67
import {
78
type ConnectorMemberGroupOptions,
89
decodeConnectorMemberGroupOption,
@@ -51,7 +52,7 @@ function accessHint(input: {
5152
return `Everyone in the workspace is invited by email to connect their ${input.connectorConfig.name} account when the first sync starts. Each member sees only the documents their own account can open; scheduled, API, and chat runs see workspace-visible documents only.`
5253
}
5354
if (input.mode === 'admin') {
54-
return `Indexed once as the ${input.connectorConfig.name} administrator you name below, keeping each document's own permissions. People see only what ${input.connectorConfig.name} already lets them open; scheduled, API, and chat runs see workspace-visible documents only.`
55+
return `Indexed once as an administrator, keeping each document's own permissions. People see only what ${input.connectorConfig.name} already lets them open; scheduled, API, and chat runs see workspace-visible documents only.`
5556
}
5657
return input.allowMembers ? undefined : 'Per-member access is turned off for this workspace.'
5758
}
@@ -117,7 +118,9 @@ export function ConnectorAccessField({
117118
<div className='flex flex-col gap-2'>
118119
<ButtonGroup
119120
value={value.accessMode}
120-
onValueChange={(mode) => onChange({ accessMode: mode as ConnectorAccessMode })}
121+
onValueChange={(mode) => {
122+
if (isConnectorAccessMode(mode)) onChange({ accessMode: mode })
123+
}}
121124
>
122125
<ButtonGroupItem value='workspace' disabled={disabled}>
123126
Workspace

apps/sim/connectors/confluence/confluence.ts

Lines changed: 43 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,25 @@ function cqlResultToStub(item: Record<string, unknown>, domain: string): Externa
311311
})
312312
}
313313

314+
/**
315+
* The site's cloud id, memoised on the run so it is discovered once per sync
316+
* rather than once per call — and taken from the credential where a service
317+
* account already carries it, since its API token cannot call
318+
* `accessible-resources` to discover one.
319+
*/
320+
async function resolveCloudId(
321+
accessToken: string,
322+
sourceConfig: Record<string, unknown>,
323+
syncContext?: Record<string, unknown>
324+
): Promise<string> {
325+
const cached = syncContext?.cloudId
326+
if (typeof cached === 'string' && cached) return cached
327+
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
328+
const cloudId = await getConfluenceCloudId(domain, accessToken)
329+
if (syncContext) syncContext.cloudId = cloudId
330+
return cloudId
331+
}
332+
314333
/**
315334
* The provider segment of every Confluence group token. Fixed, and baked into
316335
* stored ACLs, so it must never change.
@@ -336,12 +355,7 @@ async function resolveConfluenceAcls(
336355
externalIds: string[],
337356
syncContext?: Record<string, unknown>
338357
): Promise<Record<string, string[]>> {
339-
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
340-
let cloudId = syncContext?.cloudId as string | undefined
341-
if (!cloudId) {
342-
cloudId = await getConfluenceCloudId(domain, accessToken)
343-
if (syncContext) syncContext.cloudId = cloudId
344-
}
358+
const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)
345359

346360
const spaceKeys = parseMultiValue(sourceConfig.spaceKey)
347361
const spacePrincipals: ConfluencePrincipal[] = []
@@ -382,8 +396,10 @@ async function resolveConfluenceAcls(
382396
})
383397

384398
/**
385-
* Addresses are resolved once for every account named anywhere, rather than
386-
* per page: a space's own principals appear on every page that inherits them.
399+
* Addresses are resolved once for every account named anywhere and written
400+
* onto the principals in place: a space's own principals appear on every page
401+
* that inherits them, and each restriction object is shared by every chain
402+
* that walked through it.
387403
*/
388404
const accountIds = new Set<string>()
389405
for (const principal of spacePrincipals) {
@@ -395,10 +411,16 @@ async function resolveConfluenceAcls(
395411
}
396412
}
397413
const emails = await resolveUserEmails(cloudId, accessToken, [...accountIds])
398-
const withEmail = (principals: readonly ConfluencePrincipal[]): ConfluencePrincipal[] =>
399-
principals.map((principal) =>
400-
principal.kind === 'user' ? { ...principal, email: emails.get(principal.id) } : principal
401-
)
414+
const withEmail = (principals: ConfluencePrincipal[]): void => {
415+
for (const principal of principals) {
416+
/** A restriction sometimes discloses the address itself; a lookup miss must not erase it. */
417+
if (principal.kind === 'user') principal.email = emails.get(principal.id) ?? principal.email
418+
}
419+
}
420+
withEmail(spacePrincipals)
421+
for (const restriction of restrictions.values()) {
422+
if (restriction !== null) withEmail(restriction)
423+
}
402424

403425
const acls: Record<string, string[]> = {}
404426
let unattributed = 0
@@ -407,8 +429,8 @@ async function resolveConfluenceAcls(
407429
/** A page whose restrictions could not be read is readable by nobody, not by everyone. */
408430
if (!chain) continue
409431
const result = confluencePageAcl({
410-
spacePrincipals: withEmail(spacePrincipals),
411-
restrictionChain: chain.map((entry) => (entry === null ? null : withEmail(entry))),
432+
spacePrincipals,
433+
restrictionChain: chain,
412434
providerId: CONFLUENCE_ACL_PROVIDER_ID,
413435
tenantId: cloudId,
414436
})
@@ -445,11 +467,7 @@ export const confluenceConnector: ConnectorConfig = {
445467
throw new Error('At least one space key is required')
446468
}
447469

448-
let cloudId = syncContext?.cloudId as string | undefined
449-
if (!cloudId) {
450-
cloudId = await getConfluenceCloudId(domain, accessToken)
451-
if (syncContext) syncContext.cloudId = cloudId
452-
}
470+
const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)
453471

454472
/**
455473
* Route through CQL when a label filter is set, when multiple spaces are
@@ -508,15 +526,11 @@ export const confluenceConnector: ConnectorConfig = {
508526

509527
getDocumentAcls: resolveConfluenceAcls,
510528

511-
openDirectory: async (accessToken, sourceConfig, syncContext) => {
512-
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
513-
let cloudId = syncContext?.cloudId as string | undefined
514-
if (!cloudId) {
515-
cloudId = await getConfluenceCloudId(domain, accessToken)
516-
if (syncContext) syncContext.cloudId = cloudId
517-
}
518-
return openConfluenceDirectory(cloudId, accessToken)
519-
},
529+
openDirectory: async (accessToken, sourceConfig, syncContext) =>
530+
openConfluenceDirectory(
531+
await resolveCloudId(accessToken, sourceConfig, syncContext),
532+
accessToken
533+
),
520534

521535
getDocument: async (
522536
accessToken: string,
@@ -525,11 +539,7 @@ export const confluenceConnector: ConnectorConfig = {
525539
syncContext?: Record<string, unknown>
526540
): Promise<ExternalDocument | null> => {
527541
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
528-
let cloudId = syncContext?.cloudId as string | undefined
529-
if (!cloudId) {
530-
cloudId = await getConfluenceCloudId(domain, accessToken)
531-
if (syncContext) syncContext.cloudId = cloudId
532-
}
542+
const cloudId = await resolveCloudId(accessToken, sourceConfig, syncContext)
533543

534544
/**
535545
* Fetch the `view` representation rather than `storage`. Storage format only

apps/sim/lib/knowledge/connectors/google-directory.test.ts renamed to apps/sim/connectors/google-drive/directory.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
5-
import { MAX_GROUP_NESTING_DEPTH } from '@/lib/knowledge/access/external-groups'
6-
import { listDomainGroups, listGroupMembers } from '@/lib/knowledge/connectors/google-directory'
5+
import { listDomainGroups, listGroupMembers } from '@/connectors/google-drive/directory'
76

87
const mockFetch = vi.fn()
98

@@ -115,7 +114,7 @@ describe('listGroupMembers', () => {
115114

116115
it('reports an incomplete walk rather than a truncated membership', async () => {
117116
const members: Record<string, unknown[]> = {}
118-
for (let depth = 0; depth <= MAX_GROUP_NESTING_DEPTH + 1; depth += 1) {
117+
for (let depth = 0; depth <= 32; depth += 1) {
119118
members[`g${depth}@corp.com`] = [USER(`u${depth}@corp.com`), NESTED(`g${depth + 1}@corp.com`)]
120119
}
121120
directory(members)

apps/sim/lib/knowledge/connectors/google-directory.ts renamed to apps/sim/connectors/google-drive/directory.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createLogger } from '@sim/logger'
2-
import { MAX_GROUP_NESTING_DEPTH } from '@/lib/knowledge/access/external-groups'
2+
import { canonicalGroupId } from '@/lib/knowledge/access/tokens'
33
import type {
44
ConnectorDirectory,
55
ConnectorDirectoryGroup,
@@ -14,8 +14,30 @@ const PAGE_SIZE = 200
1414
/** Guards against a directory that keeps paginating; far above any real domain. */
1515
const MAX_PAGES = 200
1616

17-
export type DirectoryGroup = ConnectorDirectoryGroup
18-
export type DirectoryGroupMembership = ConnectorDirectoryMembership
17+
/**
18+
* How deep nested groups are followed when flattening membership.
19+
*
20+
* A directory can nest groups arbitrarily and can contain cycles, so the walk
21+
* needs both a visited set and a depth bound. Onyx does not recurse at all,
22+
* which silently drops everyone who is a member only through a subgroup; a
23+
* bounded walk covers every real directory while still terminating.
24+
*/
25+
const MAX_GROUP_NESTING_DEPTH = 10
26+
27+
/**
28+
* The Workspace domain an administrator's address belongs to, or undefined
29+
* when the address is blank.
30+
*
31+
* This is the tenant of every group token a Drive crawl writes and of every
32+
* group the directory sync stores, so it is derived in exactly one place: a
33+
* crawl and a directory that spelled it differently would produce grants
34+
* nothing ever resolves.
35+
*/
36+
export function googleWorkspaceDomain(adminEmail: unknown): string | undefined {
37+
if (typeof adminEmail !== 'string') return undefined
38+
const domain = adminEmail.trim().toLowerCase().split('@')[1]
39+
return domain || undefined
40+
}
1941

2042
interface DirectoryListResponse<T> {
2143
nextPageToken?: string
@@ -75,11 +97,11 @@ interface RawMember {
7597
export async function listDomainGroups(
7698
accessToken: string,
7799
domain: string
78-
): Promise<DirectoryGroup[]> {
100+
): Promise<ConnectorDirectoryGroup[]> {
79101
const raw = await listAll<RawGroup>(`${DIRECTORY_BASE}/groups`, accessToken, 'groups', { domain })
80-
const groups: DirectoryGroup[] = []
102+
const groups: ConnectorDirectoryGroup[] = []
81103
for (const group of raw) {
82-
const id = group.email?.trim().toLowerCase()
104+
const id = group.email ? canonicalGroupId(group.email) : ''
83105
if (!id) continue
84106
groups.push({ id })
85107
}
@@ -100,8 +122,8 @@ export async function listDomainGroups(
100122
*/
101123
export async function listGroupMembers(
102124
accessToken: string,
103-
group: DirectoryGroup
104-
): Promise<DirectoryGroupMembership> {
125+
group: ConnectorDirectoryGroup
126+
): Promise<ConnectorDirectoryMembership> {
105127
const memberEmails = new Set<string>()
106128
const visited = new Set<string>([group.id])
107129
let complete = true
@@ -139,18 +161,12 @@ export async function listGroupMembers(
139161
return { group, memberEmails: [...memberEmails], complete }
140162
}
141163

142-
/**
143-
* The Workspace domain the crawl is looking at, as a directory.
144-
*
145-
* The tenant is the impersonated administrator's email domain, which is what
146-
* `driveAclContext` writes into every group token, so the two must derive it the
147-
* same way or the tokens a crawl writes name a directory nothing resolves.
148-
*/
164+
/** The Workspace domain the crawl is looking at, as a directory. */
149165
export function openGoogleDirectory(
150166
accessToken: string,
151-
adminEmail: string | undefined
167+
adminEmail: unknown
152168
): ConnectorDirectory | null {
153-
const tenantId = adminEmail?.trim().toLowerCase().split('@')[1]
169+
const tenantId = googleWorkspaceDomain(adminEmail)
154170
if (!tenantId) return null
155171
return {
156172
tenantId,

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

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
driveFileAcl,
77
type OpenSharingPolicy,
88
} from '@/lib/knowledge/access/drive-permissions'
9-
import { openGoogleDirectory } from '@/lib/knowledge/connectors/google-directory'
109
import {
1110
attachRetryHeaders,
1211
isRetryableError,
@@ -15,6 +14,7 @@ import {
1514
retryWithExponentialBackoff,
1615
VALIDATE_RETRY_OPTIONS,
1716
} from '@/lib/knowledge/documents/utils'
17+
import { googleWorkspaceDomain, openGoogleDirectory } from '@/connectors/google-drive/directory'
1818
import {
1919
GoogleDriveApiError,
2020
readGoogleDriveApiError,
@@ -465,15 +465,13 @@ interface DriveAclContext {
465465
* directory a group belongs to, and how far the admin has opted into open
466466
* sharing being searchable.
467467
*
468-
* The tenant is the impersonated administrator's email domain, which is the
469-
* Workspace domain the crawl is looking at. It has to be decided once and kept:
470-
* it is baked into every stored `g:` token, so deriving it differently later
471-
* would orphan every ACL already written. Null when no administrator is
472-
* configured, which is every crawl that is not mirroring permissions.
468+
* The tenant is the impersonated administrator's Workspace domain, derived by
469+
* the same function the directory sync uses so the two can never disagree.
470+
* Null when no administrator is configured, which is every crawl that is not
471+
* mirroring permissions.
473472
*/
474473
function driveAclContext(sourceConfig: Record<string, unknown>): DriveAclContext | null {
475-
const admin = typeof sourceConfig.adminEmail === 'string' ? sourceConfig.adminEmail : ''
476-
const domain = admin.trim().toLowerCase().split('@')[1]
474+
const domain = googleWorkspaceDomain(sourceConfig.adminEmail)
477475
if (!domain) return null
478476
const openSharing = sourceConfig.openSharing
479477
return {
@@ -646,7 +644,7 @@ export const googleDriveConnector: ConnectorConfig = {
646644
},
647645

648646
openDirectory: async (accessToken, sourceConfig) =>
649-
openGoogleDirectory(accessToken, sourceConfig.adminEmail as string | undefined),
647+
openGoogleDirectory(accessToken, sourceConfig.adminEmail),
650648

651649
getDocument: async (
652650
accessToken: string,

apps/sim/lib/knowledge/access/external-groups.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,3 @@ export const EXTERNAL_GROUP_STALE_AFTER_MS = 24 * 60 * 60 * 1000
2323
* thirty for the heavier APIs.
2424
*/
2525
export const EXTERNAL_GROUP_SYNC_INTERVAL_MS = 5 * 60 * 1000
26-
27-
/**
28-
* How deep nested groups are followed when flattening membership.
29-
*
30-
* A directory can nest groups arbitrarily and can contain cycles, so the walk
31-
* needs both a visited set and a depth bound. Onyx does not recurse at all,
32-
* which silently drops everyone who is a member only through a subgroup; a
33-
* bounded walk covers every real directory while still terminating.
34-
*/
35-
export const MAX_GROUP_NESTING_DEPTH = 10

apps/sim/lib/knowledge/access/scope.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ const emailHeldByAnotherAccount = sql<boolean>`EXISTS (
7676
* is survivable, an abandoned sync is not.
7777
*/
7878
async function loadExternalGroupTokens(email: string, workspaceId: string): Promise<string[]> {
79+
/**
80+
* A query of its own rather than a fourth join on the credential query above:
81+
* that one already fans out per managed credential, and joining groups onto
82+
* it would multiply the two — every credential row repeated for every group.
83+
* Two indexed reads cost less than one cross product.
84+
*/
7985
const freshEnough = new Date(Date.now() - EXTERNAL_GROUP_STALE_AFTER_MS)
8086
const rows = await db
8187
.select({
@@ -114,12 +120,13 @@ export interface KnowledgeAccessScopeContext {
114120
}
115121

116122
/**
117-
* The tokens a person holds in a workspace: the workspace pair plus one `s:`
118-
* token per active managed credential bound to them through a credential-group
119-
* enrollment. The person must be email-verified — the enrollment binding is by
123+
* The tokens a person holds in a workspace: the workspace pair, one `s:` token
124+
* per active managed credential bound to them through a credential-group
125+
* enrollment, their own `u:` address, and a `g:` token per directory group it
126+
* belongs to. The person must be email-verified — every binding here is by
120127
* email, and an unverified address must not inherit grants made to whoever
121-
* really owns it. Nothing here is cached: revoking or suspending a credential
122-
* is visible on the next read.
128+
* really owns it. Nothing here is cached: revoking a credential or leaving a
129+
* group is visible on the next read.
123130
*/
124131
async function loadUserAccessTokens(
125132
userId: string,

apps/sim/lib/knowledge/access/tokens.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,22 +112,31 @@ export interface GroupIdentity {
112112
/** The provider's tenant, or {@link NO_TENANT_SEGMENT} where it reports none. */
113113
tenantId: string | null
114114
/**
115-
* The group as the provider names it, in whichever identifier both the
116-
* crawl and the directory sync can see: Drive and Confluence both hand back
117-
* an email or a name, never an opaque id.
115+
* The group in whichever identifier both the crawl and the directory sync
116+
* can see — a group email on Drive, a group id on Confluence. Whatever the
117+
* source's permissions API returns is what the directory is keyed by, so no
118+
* lookup ever stands between a grant and the membership that resolves it.
118119
*/
119120
groupId: string
120121
}
121122

122123
/**
123-
* The token of a group grant. Case-folded like {@link userToken}, since group
124-
* identifiers are emails on Drive and names on Confluence, and neither source
125-
* is consistent about case.
124+
* A group identifier in the one form every writer and reader agrees on.
125+
*
126+
* Both the crawl that writes a `g:` token and the directory sync that stores
127+
* the group's membership pass through this, so the two can never disagree about
128+
* case or whitespace — Drive spells a group email however it was typed, and a
129+
* grant that folds differently from its membership row grants nobody.
126130
*/
131+
export function canonicalGroupId(groupId: string): string {
132+
return groupId.trim().toLowerCase()
133+
}
134+
135+
/** The token of a group grant. */
127136
export function groupToken(group: GroupIdentity): string | null {
128137
const { providerId } = group
129138
const tenant = group.tenantId || NO_TENANT_SEGMENT
130-
const groupId = group.groupId?.trim().toLowerCase()
139+
const groupId = canonicalGroupId(group.groupId ?? '')
131140
if (!providerId || !groupId) return null
132141
if (providerId.includes(':') || tenant.includes(':')) return null
133142
const token = `g:${providerId}:${tenant}:${groupId}`

0 commit comments

Comments
 (0)