Skip to content

Commit 972422f

Browse files
committed
feat(knowledge): mirror Confluence space permissions and page restrictions
Confluence joins Drive as a source an administrator crawl can mirror, and it is the case that shaped the contract. A page's restrictions come back only when that page is asked for, so they cannot ride along with the listing the way Drive's permissions do. `getDocumentAcls` resolves them for the whole listing at once, after it — round trips bounded by the corpus rather than by the page size, with the space's principals, each page's restriction, and every address resolved once per run and reused. Only an unrestricted page pays for its ancestry, which is the expensive lookup. A restriction replaces the space's permissions rather than narrowing them. Real Confluence access is the intersection, so this over-grants in exactly one case: somebody named on a page restriction who cannot view the space at all. That is a misconfiguration in the source, it errs toward a page they were deliberately named on, and it is what Onyx does. Representing the true intersection would mean expanding both principal sets to member addresses, which our group tables could do — but it emits one token per member, and a five-thousand-person space would carry five-thousand-token ACLs on every restricted page. `null` and `[]` are different answers throughout: no restriction means inherit from the nearest restricted ancestor, then the space; a restriction naming nobody means readable by nobody. Confluence itself only ever produces the first, but collapsing them would publish every deliberately locked page. One departure from the plan, which said to identify groups by name as Onyx does. Onyx uses names because its membership sync is keyed by name; ours is keyed by whatever the permissions API returns, and that is the id. Using it costs no lookup per group and survives a rename, which a name-keyed ACL would not. Directory enumeration moves behind one connector hook. The tenant is baked into every stored group token, and only the connector knows what a tenant is for its source — a Workspace domain for Drive, a site's cloud id for Confluence. The engine previously derived it from the impersonation subject, which Confluence does not have: its service account authenticates with an API token and impersonates nobody, so directory refresh would have silently skipped. The limit worth knowing: Confluence Cloud withholds an address whose owner's profile hides it, and a person we cannot name cannot be granted access. Those grants are dropped and counted rather than guessed at, and a group with a withheld member is reported incomplete so it never replaces a stored membership with a subset.
1 parent faab96f commit 972422f

11 files changed

Lines changed: 1099 additions & 56 deletions

File tree

‎apps/sim/connectors/confluence/confluence.ts‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,20 @@ import {
55
AtlassianSiteNotAccessibleError,
66
AtlassianSiteNotMatchedError,
77
} from '@/lib/atlassian/discovery'
8+
import {
9+
type ConfluencePrincipal,
10+
type ConfluenceRestriction,
11+
confluencePageAcl,
12+
} from '@/lib/knowledge/access/confluence-permissions'
813
import { fetchWithRetry, VALIDATE_RETRY_OPTIONS } from '@/lib/knowledge/documents/utils'
914
import { confluenceConnectorMeta } from '@/connectors/confluence/meta'
15+
import {
16+
getReadRestriction,
17+
listAncestorIds,
18+
listSpaceReadPrincipals,
19+
openConfluenceDirectory,
20+
resolveUserEmails,
21+
} from '@/connectors/confluence/permissions'
1022
import type { ConnectorConfig, ExternalDocument, ExternalDocumentList } from '@/connectors/types'
1123
import { htmlToPlainText, joinTagArray, parseMultiValue, parseTagDate } from '@/connectors/utils'
1224
import { getConfluenceCloudId, normalizeConfluenceDomainHost } from '@/tools/confluence/utils'
@@ -298,6 +310,137 @@ function cqlResultToStub(item: Record<string, unknown>, domain: string): Externa
298310
})
299311
}
300312

313+
/**
314+
* The provider segment of every Confluence group token. Fixed, and baked into
315+
* stored ACLs, so it must never change.
316+
*/
317+
const CONFLUENCE_ACL_PROVIDER_ID = 'confluence'
318+
319+
/** Pages whose restrictions are resolved at once. Bounded to keep a crawl responsive. */
320+
const ACL_CONCURRENCY = 8
321+
322+
/**
323+
* Resolves who may read each listed page.
324+
*
325+
* Confluence reports a page's restrictions only when asked for that page, so
326+
* unlike Drive this cannot ride along with the listing. Three things are cached
327+
* for the run: the space's read principals (one call per space), each page's
328+
* restriction, and every account id's address — an ancestor's restriction is
329+
* consulted by many of its descendants, and one person is usually named on
330+
* several pages.
331+
*/
332+
async function resolveConfluenceAcls(
333+
accessToken: string,
334+
sourceConfig: Record<string, unknown>,
335+
externalIds: string[],
336+
syncContext?: Record<string, unknown>
337+
): Promise<Record<string, string[]>> {
338+
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
339+
let cloudId = syncContext?.cloudId as string | undefined
340+
if (!cloudId) {
341+
cloudId = await getConfluenceCloudId(domain, accessToken)
342+
if (syncContext) syncContext.cloudId = cloudId
343+
}
344+
345+
const spaceKeys = parseMultiValue(sourceConfig.spaceKey)
346+
const spacePrincipals: ConfluencePrincipal[] = []
347+
for (const spaceKey of spaceKeys) {
348+
const spaceId = await resolveSpaceId(cloudId, accessToken, spaceKey)
349+
spacePrincipals.push(...(await listSpaceReadPrincipals(cloudId, accessToken, spaceId)))
350+
}
351+
352+
const restrictions = new Map<string, ConfluenceRestriction>()
353+
const chains = new Map<string, ConfluenceRestriction[]>()
354+
355+
const readRestriction = async (contentId: string): Promise<ConfluenceRestriction> => {
356+
const cached = restrictions.get(contentId)
357+
if (cached !== undefined) return cached
358+
const restriction = await getReadRestriction(cloudId, accessToken, contentId)
359+
restrictions.set(contentId, restriction)
360+
return restriction
361+
}
362+
363+
await mapWithConcurrency(externalIds, ACL_CONCURRENCY, async (externalId) => {
364+
const own = await readRestriction(externalId)
365+
/**
366+
* A page carrying its own restriction decides on the spot; only an
367+
* unrestricted page needs its ancestry, which is the expensive lookup.
368+
*/
369+
if (own !== null) {
370+
chains.set(externalId, [own])
371+
return
372+
}
373+
const ancestorIds = await listAncestorIds(cloudId, accessToken, externalId)
374+
const chain: ConfluenceRestriction[] = [null]
375+
for (const ancestorId of ancestorIds) {
376+
const restriction = await readRestriction(ancestorId)
377+
chain.push(restriction)
378+
if (restriction !== null) break
379+
}
380+
chains.set(externalId, chain)
381+
})
382+
383+
/**
384+
* Addresses are resolved once for every account named anywhere, rather than
385+
* per page: a space's own principals appear on every page that inherits them.
386+
*/
387+
const accountIds = new Set<string>()
388+
for (const principal of spacePrincipals) {
389+
if (principal.kind === 'user') accountIds.add(principal.id)
390+
}
391+
for (const restriction of restrictions.values()) {
392+
for (const principal of restriction ?? []) {
393+
if (principal.kind === 'user') accountIds.add(principal.id)
394+
}
395+
}
396+
const emails = await resolveUserEmails(cloudId, accessToken, [...accountIds])
397+
const withEmail = (principals: readonly ConfluencePrincipal[]): ConfluencePrincipal[] =>
398+
principals.map((principal) =>
399+
principal.kind === 'user' ? { ...principal, email: emails.get(principal.id) } : principal
400+
)
401+
402+
const acls: Record<string, string[]> = {}
403+
let unattributed = 0
404+
for (const externalId of externalIds) {
405+
const chain = chains.get(externalId)
406+
/** A page whose restrictions could not be read is readable by nobody, not by everyone. */
407+
if (!chain) continue
408+
const result = confluencePageAcl({
409+
spacePrincipals: withEmail(spacePrincipals),
410+
restrictionChain: chain.map((entry) => (entry === null ? null : withEmail(entry))),
411+
providerId: CONFLUENCE_ACL_PROVIDER_ID,
412+
tenantId: cloudId,
413+
})
414+
acls[externalId] = result.acl
415+
unattributed += result.unattributedUsers
416+
}
417+
418+
if (unattributed > 0) {
419+
logger.warn('Confluence withheld addresses for some granted users; those grants were dropped', {
420+
cloudId,
421+
unattributed,
422+
})
423+
}
424+
return acls
425+
}
426+
427+
/** Runs `worker` over `items`, at most `limit` at a time, preserving no order. */
428+
async function mapWithConcurrency<T>(
429+
items: readonly T[],
430+
limit: number,
431+
worker: (item: T) => Promise<void>
432+
): Promise<void> {
433+
let cursor = 0
434+
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
435+
for (;;) {
436+
const index = cursor++
437+
if (index >= items.length) return
438+
await worker(items[index])
439+
}
440+
})
441+
await Promise.all(runners)
442+
}
443+
301444
export const confluenceConnector: ConnectorConfig = {
302445
...confluenceConnectorMeta,
303446

@@ -379,6 +522,18 @@ export const confluenceConnector: ConnectorConfig = {
379522
)
380523
},
381524

525+
getDocumentAcls: resolveConfluenceAcls,
526+
527+
openDirectory: async (accessToken, sourceConfig, syncContext) => {
528+
const domain = normalizeConfluenceDomainHost(sourceConfig.domain as string)
529+
let cloudId = syncContext?.cloudId as string | undefined
530+
if (!cloudId) {
531+
cloudId = await getConfluenceCloudId(domain, accessToken)
532+
if (syncContext) syncContext.cloudId = cloudId
533+
}
534+
return openConfluenceDirectory(cloudId, accessToken)
535+
},
536+
382537
getDocument: async (
383538
accessToken: string,
384539
sourceConfig: Record<string, unknown>,

‎apps/sim/connectors/confluence/meta.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ export const confluenceConnectorMeta: ConnectorMeta = {
2020
'search:confluence',
2121
'offline_access',
2222
],
23+
/**
24+
* Mirroring adds the three reads an ACL needs and nothing else: a space's
25+
* permissions, a page's restrictions, and the addresses behind the account
26+
* ids both return. `read:confluence-user` is what decides whether a person
27+
* can be granted access individually at all — without it every principal is
28+
* an opaque account id that no Sim reader can be matched to.
29+
*/
30+
serviceAccountScopes: [
31+
'read:confluence-content.all',
32+
'read:page:confluence',
33+
'read:blogpost:confluence',
34+
'read:space:confluence',
35+
'read:label:confluence',
36+
'search:confluence',
37+
'read:confluence-space.summary',
38+
'read:confluence-user',
39+
'read:group:confluence',
40+
],
2341
},
2442

2543
/**
@@ -34,6 +52,14 @@ export const confluenceConnectorMeta: ConnectorMeta = {
3452
/** CQL search under a member's token returns only content that member may view. */
3553
permissionScopedListing: { capFieldIds: ['maxPages'] },
3654

55+
/**
56+
* Space permissions and page restrictions are both readable, so one crawl
57+
* under an administrative credential can mirror them. Unlike Drive they come
58+
* back per page rather than with the listing, which is what
59+
* `getDocumentAcls` exists for.
60+
*/
61+
mirrorsSourceAcls: true,
62+
3763
configFields: [
3864
{
3965
id: 'domain',

0 commit comments

Comments
 (0)