-
Notifications
You must be signed in to change notification settings - Fork 3.8k
fix(atlassian): resolve Jira and Confluence selector contexts server-side #7122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
BillLeoutsakosvl346
wants to merge
1
commit into
fix/server-resolved-selector-context
from
fix/server-resolved-atlassian-selectors
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
182 changes: 182 additions & 0 deletions
182
apps/sim/app/api/tools/atlassian-server-resolved-selectors.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,182 @@ | ||
| /** | ||
| * @vitest-environment node | ||
| */ | ||
| import { createMockRequest } from '@sim/testing' | ||
| import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
|
|
||
| const mocks = vi.hoisted(() => ({ | ||
| authenticate: vi.fn(), | ||
| resolveContext: vi.fn(), | ||
| resolveAtlassianCredential: vi.fn(), | ||
| })) | ||
|
|
||
| vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({ | ||
| authenticateSelectorRequest: mocks.authenticate, | ||
| resolveAuthorizedSelectorContext: mocks.resolveContext, | ||
| })) | ||
| vi.mock('@/lib/selectors/application/atlassian-credential', () => ({ | ||
| resolveAtlassianSelectorCredential: mocks.resolveAtlassianCredential, | ||
| })) | ||
|
|
||
| import { POST as confluencePages } from '@/app/api/tools/confluence/selector-pages/route' | ||
| import { POST as jiraProject } from '@/app/api/tools/jira/projects/route' | ||
|
|
||
| const principal = { | ||
| kind: 'session', | ||
| userId: 'viewer-1', | ||
| sessionId: 'session-1', | ||
| } as const | ||
|
|
||
| function request(path: string, body: unknown) { | ||
| return createMockRequest( | ||
| 'POST', | ||
| body, | ||
| { 'content-type': 'application/json' }, | ||
| `http://localhost:3000${path}` | ||
| ) | ||
| } | ||
|
|
||
| describe('server-resolved Atlassian selector routes', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks() | ||
| vi.unstubAllGlobals() | ||
| mocks.authenticate.mockResolvedValue({ ok: true, principal }) | ||
| mocks.resolveContext.mockImplementation( | ||
| async (_principal: unknown, input: { context: Record<string, unknown> }) => ({ | ||
| ok: true, | ||
| context: { ...input.context, domain: 'resolved-secret.example.com' }, | ||
| requesterUserId: 'viewer-1', | ||
| workspaceId: 'workspace-1', | ||
| credentialAccess: { credentialOwnerUserId: 'owner-1' }, | ||
| }) | ||
| ) | ||
| mocks.resolveAtlassianCredential.mockResolvedValue({ | ||
| accessToken: 'atlassian-token', | ||
| cloudId: 'cloud-id-1', | ||
| }) | ||
| }) | ||
|
|
||
| it('authenticates before parsing a malformed request', async () => { | ||
| mocks.authenticate.mockResolvedValue({ ok: false, status: 401, error: 'Unauthorized' }) | ||
|
|
||
| const response = await jiraProject( | ||
| request('/api/tools/jira/projects', { definitely: 'not a Jira selector request' }) | ||
| ) | ||
|
|
||
| expect(response.status).toBe(401) | ||
| expect(await response.json()).toEqual({ error: 'Unauthorized' }) | ||
| expect(mocks.resolveContext).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it.each([ | ||
| { | ||
| name: 'Jira Project', | ||
| route: jiraProject, | ||
| path: '/api/tools/jira/projects', | ||
| body: { | ||
| credential: 'credential-1', | ||
| workflowId: 'workflow-1', | ||
| domain: '{{INACCESSIBLE_SECRET}}', | ||
| projectId: 'SIM', | ||
| }, | ||
| }, | ||
| { | ||
| name: 'Confluence Page', | ||
| route: confluencePages, | ||
| path: '/api/tools/confluence/selector-pages', | ||
| body: { | ||
| credential: 'credential-1', | ||
| workflowId: 'workflow-1', | ||
| domain: '{{INACCESSIBLE_SECRET}}', | ||
| }, | ||
| }, | ||
| ])('$name stops inaccessible references before provider access', async (testCase) => { | ||
| mocks.resolveContext.mockResolvedValue({ | ||
| ok: false, | ||
| status: 400, | ||
| error: 'Unable to resolve selector configuration', | ||
| }) | ||
| const providerFetch = vi.fn() | ||
| vi.stubGlobal('fetch', providerFetch) | ||
|
|
||
| const response = await testCase.route(request(testCase.path, testCase.body)) | ||
|
|
||
| expect(response.status).toBe(400) | ||
| expect(await response.json()).toEqual({ error: 'Unable to resolve selector configuration' }) | ||
| expect(mocks.resolveAtlassianCredential).not.toHaveBeenCalled() | ||
| expect(providerFetch).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it('maps Jira projects without exposing resolved provider data', async () => { | ||
| vi.stubGlobal( | ||
| 'fetch', | ||
| vi | ||
| .fn() | ||
| .mockResolvedValue( | ||
| Response.json({ id: '10001', name: 'Sim', self: 'https://resolved-secret.example.com' }) | ||
| ) | ||
| ) | ||
|
|
||
| const response = await jiraProject( | ||
| request('/api/tools/jira/projects', { | ||
| credential: 'credential-1', | ||
| workflowId: 'workflow-1', | ||
| domain: '{{DOMAIN}}', | ||
| projectId: 'SIM', | ||
| }) | ||
| ) | ||
|
|
||
| expect(await response.json()).toEqual({ project: { id: '10001', name: 'Sim' } }) | ||
| }) | ||
|
|
||
| it('maps Confluence pages without exposing resolved provider data', async () => { | ||
| vi.stubGlobal( | ||
| 'fetch', | ||
| vi.fn().mockResolvedValue( | ||
| Response.json({ | ||
| results: [ | ||
| { | ||
| id: '20001', | ||
| title: 'Runbook', | ||
| _links: { webui: 'https://resolved-secret.example.com/wiki/runbook' }, | ||
| }, | ||
| ], | ||
| }) | ||
| ) | ||
| ) | ||
|
|
||
| const response = await confluencePages( | ||
| request('/api/tools/confluence/selector-pages', { | ||
| credential: 'credential-1', | ||
| workflowId: 'workflow-1', | ||
| domain: '{{DOMAIN}}', | ||
| }) | ||
| ) | ||
|
|
||
| expect(await response.json()).toEqual({ files: [{ id: '20001', name: 'Runbook' }] }) | ||
| }) | ||
|
|
||
| it('maps provider failures to a stable public response without reading their body', async () => { | ||
| const providerText = vi.fn().mockResolvedValue('provider-body-secret-marker') | ||
| vi.stubGlobal( | ||
| 'fetch', | ||
| vi.fn().mockResolvedValue({ ok: false, status: 418, text: providerText }) | ||
| ) | ||
|
|
||
| const response = await jiraProject( | ||
| request('/api/tools/jira/projects', { | ||
| credential: 'credential-1', | ||
| workflowId: 'workflow-1', | ||
| domain: '{{DOMAIN}}', | ||
| projectId: 'SIM', | ||
| }) | ||
| ) | ||
|
|
||
| expect(response.status).toBe(502) | ||
| expect(await response.json()).toEqual({ | ||
| error: 'Jira selector discovery failed.', | ||
| status: 502, | ||
| }) | ||
| expect(providerText).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { confluenceSelectorPageContract } from '@/lib/api/contracts/selectors/confluence' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { validateJiraCloudId } from '@/lib/core/security/input-validation' | ||
| import { generateRequestId } from '@/lib/core/utils/request' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential' | ||
| import { | ||
| resolveSelectorProviderValue, | ||
| SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS, | ||
| selectorProviderFailure, | ||
| } from '@/lib/selectors/server/provider-errors' | ||
| import { | ||
| authenticateSelectorRequest, | ||
| resolveAuthorizedSelectorContext, | ||
| } from '@/lib/selectors/server/resolve-authorized-context' | ||
| import { getConfluenceCloudId } from '@/tools/confluence/utils' | ||
|
|
||
| const logger = createLogger('ConfluenceSelectorPageAPI') | ||
|
|
||
| export const dynamic = 'force-dynamic' | ||
|
|
||
| interface ConfluencePageResponse { | ||
| id: string | ||
| title: string | ||
| } | ||
|
|
||
| export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| try { | ||
| const authentication = await authenticateSelectorRequest(request) | ||
| if (!authentication.ok) { | ||
| return NextResponse.json({ error: authentication.error }, { status: authentication.status }) | ||
| } | ||
| const parsed = await parseRequest(confluenceSelectorPageContract, request, {}) | ||
| if (!parsed.success) return parsed.response | ||
|
|
||
| const { credential, workflowId, domain: domainReference, pageId } = parsed.data.body | ||
| const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { | ||
| workflowId, | ||
| credentialId: credential, | ||
| context: { domain: domainReference }, | ||
| }) | ||
| if (!resolution.ok) { | ||
| return NextResponse.json({ error: resolution.error }, { status: resolution.status }) | ||
| } | ||
|
|
||
| const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId | ||
| if (!credentialOwnerUserId) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) | ||
| } | ||
| const bundle = await resolveAtlassianSelectorCredential({ | ||
| credentialId: credential, | ||
| credentialOwnerUserId, | ||
| requestId: generateRequestId(), | ||
| serviceId: 'confluence', | ||
| }) | ||
| if (!bundle) { | ||
| return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) | ||
| } | ||
|
|
||
| const domain = resolution.context.domain as string | ||
| const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () => | ||
| bundle.cloudId | ||
| ? bundle.cloudId | ||
| : getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) | ||
| ) | ||
| if (!cloudIdResolution.ok) { | ||
| logger.warn('Confluence selector discovery failed', { | ||
| status: cloudIdResolution.upstreamStatus ?? 'unknown', | ||
| }) | ||
| return NextResponse.json(cloudIdResolution.failure, { | ||
| status: cloudIdResolution.failure.status, | ||
| }) | ||
| } | ||
| const cloudId = cloudIdResolution.value | ||
| const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') | ||
| if (!cloudIdValidation.isValid) { | ||
| return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) | ||
| } | ||
|
|
||
| const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages/${pageId}` | ||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` }, | ||
| }) | ||
| if (!response.ok) { | ||
| logger.warn('Confluence selector page request failed', { status: response.status }) | ||
| const failure = selectorProviderFailure('Confluence', response.status) | ||
| return NextResponse.json(failure, { status: failure.status }) | ||
| } | ||
|
|
||
| const page = (await response.json()) as ConfluencePageResponse | ||
| return NextResponse.json({ id: page.id, title: page.title }) | ||
| } catch { | ||
| logger.error('Error retrieving Confluence selector page') | ||
| return NextResponse.json({ error: 'Failed to retrieve Confluence page' }, { status: 500 }) | ||
| } | ||
| }) | ||
110 changes: 110 additions & 0 deletions
110
apps/sim/app/api/tools/confluence/selector-pages/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { confluenceSelectorPagesContract } from '@/lib/api/contracts/selectors/confluence' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { validateJiraCloudId } from '@/lib/core/security/input-validation' | ||
| import { generateRequestId } from '@/lib/core/utils/request' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { resolveAtlassianSelectorCredential } from '@/lib/selectors/application/atlassian-credential' | ||
| import { | ||
| resolveSelectorProviderValue, | ||
| SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS, | ||
| selectorProviderFailure, | ||
| } from '@/lib/selectors/server/provider-errors' | ||
| import { | ||
| authenticateSelectorRequest, | ||
| resolveAuthorizedSelectorContext, | ||
| } from '@/lib/selectors/server/resolve-authorized-context' | ||
| import { getConfluenceCloudId } from '@/tools/confluence/utils' | ||
|
|
||
| const logger = createLogger('ConfluenceSelectorPagesAPI') | ||
|
|
||
| export const dynamic = 'force-dynamic' | ||
|
|
||
| interface ConfluencePageRow { | ||
| id: string | ||
| title: string | ||
| } | ||
|
|
||
| interface ConfluencePagesResponse { | ||
| results?: ConfluencePageRow[] | ||
| } | ||
|
|
||
| export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| try { | ||
| const authentication = await authenticateSelectorRequest(request) | ||
| if (!authentication.ok) { | ||
| return NextResponse.json({ error: authentication.error }, { status: authentication.status }) | ||
| } | ||
| const parsed = await parseRequest(confluenceSelectorPagesContract, request, {}) | ||
| if (!parsed.success) return parsed.response | ||
|
|
||
| const { credential, workflowId, domain: domainReference, title, limit } = parsed.data.body | ||
| const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { | ||
| workflowId, | ||
| credentialId: credential, | ||
| context: { domain: domainReference }, | ||
| }) | ||
| if (!resolution.ok) { | ||
| return NextResponse.json({ error: resolution.error }, { status: resolution.status }) | ||
| } | ||
|
|
||
| const credentialOwnerUserId = resolution.credentialAccess?.credentialOwnerUserId | ||
| if (!credentialOwnerUserId) { | ||
| return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) | ||
| } | ||
| const bundle = await resolveAtlassianSelectorCredential({ | ||
| credentialId: credential, | ||
| credentialOwnerUserId, | ||
| requestId: generateRequestId(), | ||
| serviceId: 'confluence', | ||
| }) | ||
| if (!bundle) { | ||
| return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 }) | ||
| } | ||
|
|
||
| const domain = resolution.context.domain as string | ||
| const cloudIdResolution = await resolveSelectorProviderValue('Confluence', async () => | ||
| bundle.cloudId | ||
| ? bundle.cloudId | ||
| : getConfluenceCloudId(domain, bundle.accessToken, SELECTOR_ATLASSIAN_DISCOVERY_OPTIONS) | ||
| ) | ||
| if (!cloudIdResolution.ok) { | ||
| logger.warn('Confluence selector discovery failed', { | ||
| status: cloudIdResolution.upstreamStatus ?? 'unknown', | ||
| }) | ||
| return NextResponse.json(cloudIdResolution.failure, { | ||
| status: cloudIdResolution.failure.status, | ||
| }) | ||
| } | ||
| const cloudId = cloudIdResolution.value | ||
| const cloudIdValidation = validateJiraCloudId(cloudId, 'cloudId') | ||
| if (!cloudIdValidation.isValid) { | ||
| return NextResponse.json({ error: cloudIdValidation.error }, { status: 400 }) | ||
| } | ||
|
|
||
| const search = new URLSearchParams({ limit: String(limit) }) | ||
| if (title) search.set('title', title) | ||
| const url = `https://api.atlassian.com/ex/confluence/${cloudIdValidation.sanitized}/wiki/api/v2/pages?${search.toString()}` | ||
| const response = await fetch(url, { | ||
| method: 'GET', | ||
| headers: { Accept: 'application/json', Authorization: `Bearer ${bundle.accessToken}` }, | ||
| }) | ||
| if (!response.ok) { | ||
| logger.warn('Confluence selector pages request failed', { status: response.status }) | ||
| const failure = selectorProviderFailure('Confluence', response.status) | ||
| return NextResponse.json(failure, { status: failure.status }) | ||
| } | ||
|
|
||
| const data = (await response.json()) as ConfluencePagesResponse | ||
| return NextResponse.json({ | ||
| files: (data.results ?? []).map((page) => ({ | ||
| id: page.id, | ||
| name: page.title, | ||
| })), | ||
| }) | ||
| } catch { | ||
| logger.error('Error listing Confluence selector pages') | ||
| return NextResponse.json({ error: 'Failed to retrieve Confluence pages' }, { status: 500 }) | ||
| } | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.