Skip to content

Commit ba8c7d5

Browse files
committed
fix(search): read Confluence pages through the v2 API the Search grant allows
1 parent 2b01631 commit ba8c7d5

6 files changed

Lines changed: 205 additions & 19 deletions

File tree

‎apps/sim/lib/sim-search/live/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Self-managed GitLab is resolved from the saved source's validated host/project i
123123
| Calendar | CalendarList then `/calendars/{id}/events` | `/calendars/{id}/events/{eventId}` | Same-user delegation, selected calendars, event window/query |
124124
| Slack | `POST /api/assistant.search.context` | `conversations.replies` or `files.info` preview | Member only; Slack enforces the connected user's grant |
125125
| Jira | `POST /ex/jira/{cloudId}/rest/api/3/search/jql` | `/rest/api/3/issue/{key}` under that cloud site | Member only |
126-
| Confluence | `/ex/confluence/{cloudId}/wiki/rest/api/search` with CQL | `/wiki/rest/api/content/{id}` | Same site, spaces, current type/status/labels, source readability |
126+
| Confluence | `/ex/confluence/{cloudId}/wiki/rest/api/search` with CQL | v2 `/wiki/api/v2/pages/{id}` or `/blogposts/{id}` (`body-format=view`); a space reads as its homepage | Same site, spaces, current type/status/labels, source readability |
127127
| GitHub | `/search/issues`, `/search/code`, `/search/repositories`, `/search/commits` | Issue, repository, commit, or contents endpoint for returned kind | Added repositories; installation coverage/stable IDs and code filters |
128128
| GitLab | Configured `/api/v4/projects/{project}/search`, or supported date listing | Project issue/MR/wiki/file endpoint | Current request-local admin ACL evidence or saved CSV grants, plus content filters |
129129
| Coda | Personal MCP `search`; REST `/apis/v1/docs` title-search compatibility | MCP read allowlist; REST compatibility document/page reads | Selected parent doc and current source-token visibility; optional Enterprise org membership |
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import { readAtlassian, searchAtlassian } from '@/lib/sim-search/live/atlassian'
3+
import type { NativeClient } from '@/lib/sim-search/live/types'
4+
5+
const SITE = { id: 'cloud', url: 'https://acme.atlassian.net' }
6+
7+
/** Answers only the paths a test names, so a read through the v1 content API fails loudly. */
8+
function client(rows: Record<string, unknown>): NativeClient & { json: ReturnType<typeof vi.fn> } {
9+
return {
10+
json: vi.fn(async (path: string) => {
11+
if (path === '/oauth/token/accessible-resources') return [SITE]
12+
if (!(path in rows)) throw new Error(`Unexpected request: ${path}`)
13+
return rows[path]
14+
}),
15+
text: vi.fn(),
16+
}
17+
}
18+
19+
const v2 = '/ex/confluence/cloud/wiki/api/v2'
20+
21+
describe('Confluence live documents', () => {
22+
it('reads pages and blog posts through v2, which needs only the granular read scopes', async () => {
23+
const api = client({
24+
[`${v2}/pages/123`]: {
25+
id: '123',
26+
title: 'Runbook',
27+
body: { view: { value: '<p>Restart the <b>ingest</b> worker.</p>' } },
28+
version: { createdAt: '2026-09-18T04:50:29.778Z' },
29+
_links: { webui: '/spaces/ENG/pages/123/Runbook' },
30+
},
31+
[`${v2}/blogposts/9`]: {
32+
id: '9',
33+
title: 'Release notes',
34+
body: { view: { value: '<p>Shipped search.</p>' } },
35+
version: { createdAt: '2026-09-20T00:00:00.000Z' },
36+
_links: { webui: '/spaces/ENG/blog/9' },
37+
},
38+
})
39+
await expect(readAtlassian(api, 'confluence', '123', 'cloud', 'page')).resolves.toMatchObject({
40+
id: '123',
41+
kind: 'page',
42+
title: 'Runbook',
43+
content: expect.stringContaining('Restart the ingest worker.'),
44+
url: 'https://acme.atlassian.net/wiki/spaces/ENG/pages/123/Runbook',
45+
modifiedAt: '2026-09-18T04:50:29.778Z',
46+
})
47+
await expect(readAtlassian(api, 'confluence', '9', 'cloud', 'blogpost')).resolves.toMatchObject(
48+
{
49+
kind: 'blogpost',
50+
content: expect.stringContaining('Shipped search.'),
51+
}
52+
)
53+
expect(api.json).toHaveBeenCalledWith(`${v2}/pages/123`, { query: { 'body-format': 'view' } })
54+
})
55+
56+
it('reads a space result as its homepage, keeping the space as the document', async () => {
57+
const api = client({
58+
[`${v2}/spaces`]: {
59+
results: [{ id: '7', key: 'ENG', name: 'Engineering', homepageId: '55' }],
60+
},
61+
[`${v2}/pages/55`]: {
62+
id: '55',
63+
title: 'Engineering Home',
64+
body: { view: { value: '<p>Team charter.</p>' } },
65+
_links: { webui: '/spaces/ENG/overview' },
66+
},
67+
})
68+
await expect(readAtlassian(api, 'confluence', 'ENG', 'cloud', 'space')).resolves.toMatchObject({
69+
id: 'ENG',
70+
kind: 'space',
71+
title: 'Engineering',
72+
content: expect.stringContaining('Team charter.'),
73+
})
74+
expect(api.json).toHaveBeenCalledWith(`${v2}/spaces`, { query: { keys: 'ENG' } })
75+
})
76+
77+
it('records whether a search result is a page, blog post, or space so its read picks the endpoint', async () => {
78+
const api = client({
79+
'/ex/confluence/cloud/wiki/rest/api/search': {
80+
results: [
81+
{
82+
content: {
83+
id: '123',
84+
type: 'page',
85+
title: 'Runbook',
86+
_links: { webui: '/spaces/ENG/pages/123' },
87+
},
88+
},
89+
{
90+
content: {
91+
id: '9',
92+
type: 'blogpost',
93+
title: 'Release notes',
94+
_links: { webui: '/spaces/ENG/blog/9' },
95+
},
96+
},
97+
{
98+
entityType: 'space',
99+
title: 'Engineering',
100+
url: '/spaces/ENG',
101+
space: { key: 'ENG', name: 'Engineering' },
102+
},
103+
],
104+
_links: {},
105+
},
106+
})
107+
const page = await searchAtlassian(api, 'confluence', {
108+
query: 'runbook',
109+
limit: 10,
110+
scopes: [],
111+
})
112+
expect(page.documents.map(({ id, kind }) => ({ id, kind }))).toEqual([
113+
{ id: '123', kind: 'page' },
114+
{ id: '9', kind: 'blogpost' },
115+
{ id: 'ENG', kind: 'space' },
116+
])
117+
expect(page.documents[2]?.url).toBe('https://acme.atlassian.net/wiki/spaces/ENG')
118+
})
119+
})

‎apps/sim/lib/sim-search/live/atlassian.ts‎

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,24 @@ function issue(row: Record<string, unknown>, cloudId: string, site: string): Nat
6666
}
6767
}
6868
function page(row: Record<string, unknown>, cloudId: string, site: string): NativeDocument {
69+
if (string(row.entityType) === 'space') {
70+
const space = object(row.space)
71+
return {
72+
id: string(space.key),
73+
kind: 'space',
74+
container: cloudId,
75+
title: string(row.title) || string(space.name),
76+
url: `${site}/wiki${string(row.url) || `/spaces/${segment(string(space.key))}`}`,
77+
content: providerText(string(row.excerpt), 'html') || string(row.title),
78+
modifiedAt: string(row.lastModified),
79+
}
80+
}
6981
const content = Object.keys(object(row.content)).length ? object(row.content) : row
7082
const links = object(content._links)
7183
const version = object(content.version)
7284
return {
7385
id: string(content.id),
86+
kind: string(content.type) === 'blogpost' ? 'blogpost' : 'page',
7487
container: cloudId,
7588
title: string(content.title) || string(row.title),
7689
url: `${site}/wiki${string(links.webui) || `/pages/${segment(string(content.id))}`}`,
@@ -190,7 +203,8 @@ export async function readAtlassian(
190203
client: NativeClient,
191204
provider: 'jira' | 'confluence',
192205
id: string,
193-
cloudId?: string
206+
cloudId?: string,
207+
kind?: string
194208
): Promise<NativeDocument> {
195209
const site = (await sites(client)).find((row) => string(row.id) === cloudId)
196210
if (!site || !cloudId)
@@ -205,13 +219,50 @@ export async function readAtlassian(
205219
cloudId,
206220
string(site.url)
207221
)
208-
return page(
209-
object(
210-
await client.json(`/ex/confluence/${segment(cloudId)}/wiki/rest/api/content/${segment(id)}`, {
211-
query: { expand: 'body.view,version' },
212-
})
213-
),
214-
cloudId,
215-
string(site.url)
222+
return readConfluence(client, cloudId, string(site.url), id, kind)
223+
}
224+
225+
/**
226+
* Reads through the v2 API, whose page, blog post, and space endpoints need only the granular
227+
* read scopes a Search connection grants; v1 content reads also need read:content-details.
228+
* A space is read as its homepage.
229+
*/
230+
async function readConfluence(
231+
client: NativeClient,
232+
cloudId: string,
233+
site: string,
234+
id: string,
235+
kind?: string
236+
): Promise<NativeDocument> {
237+
const api = `/ex/confluence/${segment(cloudId)}/wiki/api/v2`
238+
let title: string | undefined
239+
let contentId = id
240+
let contentKind = kind === 'blogpost' ? 'blogpost' : 'page'
241+
if (kind === 'space') {
242+
const space = object(
243+
array(object(await client.json(`${api}/spaces`, { query: { keys: id } })).results)[0]
244+
)
245+
if (!string(space.homepageId))
246+
throw new NativeSearchError('unavailable', 'The Confluence space has no readable homepage.')
247+
title = string(space.name)
248+
contentId = string(space.homepageId)
249+
contentKind = 'page'
250+
}
251+
const row = object(
252+
await client.json(
253+
`${api}/${contentKind === 'blogpost' ? 'blogposts' : 'pages'}/${segment(contentId)}`,
254+
{ query: { 'body-format': 'view' } }
255+
)
216256
)
257+
const pageTitle = string(row.title)
258+
return {
259+
id,
260+
kind: kind === 'space' ? 'space' : contentKind,
261+
container: cloudId,
262+
title: title || pageTitle,
263+
url: `${site}/wiki${string(object(row._links).webui) || `/pages/${segment(contentId)}`}`,
264+
content:
265+
providerText(string(object(object(row.body).view).value), 'html') || title || pageTitle,
266+
modifiedAt: string(object(row.version).createdAt) || string(row.createdAt),
267+
}
217268
}

‎apps/sim/lib/sim-search/live/policy.test.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ describe('organization search scope enforcement', () => {
183183
async (provider) => {
184184
const api = client({
185185
'/ex/jira/site/rest/api/3/issue/ENG-2': { fields: { project: { key: 'ENG' } } },
186-
'/ex/confluence/site/wiki/rest/api/content/ENG-2': { space: { key: 'ENG' } },
186+
'/ex/confluence/site/wiki/api/v2/pages/ENG-2': { id: 'ENG-2', spaceId: '7' },
187+
'/ex/confluence/site/wiki/api/v2/spaces/7': { id: '7', key: 'ENG' },
187188
})
188189
expect(
189190
await createPolicyVerifier(
@@ -203,6 +204,16 @@ describe('organization search scope enforcement', () => {
203204
).toBe(false)
204205
}
205206
)
207+
it('checks Confluence spaces by key and blog posts through their own endpoint', async () => {
208+
const api = client({
209+
'/ex/confluence/site/wiki/api/v2/blogposts/9': { id: '9', spaceId: '7' },
210+
'/ex/confluence/site/wiki/api/v2/spaces/7': { id: '7', key: 'ENG' },
211+
})
212+
const verify = createPolicyVerifier('confluence', selected(['ENG']), api, '')
213+
expect(await verify({ id: '9', container: 'site', kind: 'blogpost' })).toBe(true)
214+
expect(await verify({ id: 'ENG', container: 'site', kind: 'space' })).toBe(true)
215+
expect(await verify({ id: 'HR', container: 'site', kind: 'space' })).toBe(false)
216+
})
206217
it('checks Coda page and row document IDs, including converted URLs', async () => {
207218
const mcp = { call: vi.fn(async () => ({ docUri: 'coda://docs/allowed' })) }
208219
const verify = createPolicyVerifier('coda', selected(['allowed']), null, '', mcp)

‎apps/sim/lib/sim-search/live/policy.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,19 @@ export function createPolicyVerifier(
200200
const project = object(object(row.fields).project)
201201
return Boolean(project.key) && permitsResources(policy, [string(project.key)])
202202
}
203-
const row = object(
204-
await json(
205-
`/ex/confluence/${segment(document.container)}/wiki/rest/api/content/${segment(document.id)}`,
206-
{ expand: 'space' }
203+
/** v2 reads, like document reads, so the check needs only the granular read scopes. */
204+
const api = `/ex/confluence/${segment(document.container)}/wiki/api/v2`
205+
let spaceKey = document.kind === 'space' ? document.id : ''
206+
if (!spaceKey) {
207+
const row = object(
208+
await json(
209+
`${api}/${document.kind === 'blogpost' ? 'blogposts' : 'pages'}/${segment(document.id)}`
210+
)
207211
)
208-
)
209-
const space = object(row.space)
210-
return Boolean(space.key) && permitsResources(policy, [string(space.key)])
212+
if (!string(row.spaceId)) return false
213+
spaceKey = string(object(await json(`${api}/spaces/${segment(string(row.spaceId))}`)).key)
214+
}
215+
return Boolean(spaceKey) && permitsResources(policy, [spaceKey])
211216
}
212217
if (provider === 'coda') {
213218
if (!restricted) return true

‎apps/sim/lib/sim-search/live/providers.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ export const LIVE_SEARCH_PROVIDERS = {
139139
},
140140
search: (client, input) => searchAtlassian(client, 'confluence', input),
141141
read: (client, reference) =>
142-
readAtlassian(client, 'confluence', reference.id, reference.container),
142+
readAtlassian(client, 'confluence', reference.id, reference.container, reference.kind),
143143
},
144144
github: {
145145
guide: {

0 commit comments

Comments
 (0)