From 2e55a45a5d8939c90bb2232e1b4702c494f427c5 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Wed, 26 Aug 2026 12:15:09 -0700 Subject: [PATCH] fix(cloudwatch): resolve selector credentials server-side --- .../cloudwatch/describe-log-groups/route.ts | 63 +----- .../cloudwatch/selector-log-groups/route.ts | 63 ++++++ .../cloudwatch/selector-log-streams/route.ts | 67 ++++++ .../server-resolved-selectors.test.ts | 197 ++++++++++++++++++ .../app/api/tools/cloudwatch/utils.test.ts | 85 ++++++++ apps/sim/app/api/tools/cloudwatch/utils.ts | 67 +++++- .../providers/cloudwatch/selectors.ts | 30 +-- .../server-resolved-context.test.ts | 71 +++++++ .../cloudwatch.server-resolved.test.ts | 49 +++++ .../lib/api/contracts/selectors/cloudwatch.ts | 31 +++ 10 files changed, 653 insertions(+), 70 deletions(-) create mode 100644 apps/sim/app/api/tools/cloudwatch/selector-log-groups/route.ts create mode 100644 apps/sim/app/api/tools/cloudwatch/selector-log-streams/route.ts create mode 100644 apps/sim/app/api/tools/cloudwatch/server-resolved-selectors.test.ts create mode 100644 apps/sim/app/api/tools/cloudwatch/utils.test.ts create mode 100644 apps/sim/hooks/selectors/providers/cloudwatch/server-resolved-context.test.ts create mode 100644 apps/sim/lib/api/contracts/selectors/cloudwatch.server-resolved.test.ts diff --git a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts index 4a2aabea1c9..fe5c101116a 100644 --- a/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts @@ -1,4 +1,3 @@ -import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' @@ -6,16 +5,10 @@ import { cloudwatchLogGroupsSelectorContract } from '@/lib/api/contracts/selecto import { parseToolRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils' +import { createCloudWatchLogsClient, describeLogGroups } from '@/app/api/tools/cloudwatch/utils' const logger = createLogger('CloudWatchDescribeLogGroups') -/** AWS DescribeLogGroups caps `limit` at 50 items per page. */ -const LOG_GROUPS_PAGE_SIZE = 50 - -/** Upper bound on pages drained to avoid unbounded loops on very large accounts. */ -const MAX_LOG_GROUPS_PAGES = 20 - export const POST = withRouteHandler(async (request: NextRequest) => { try { const auth = await checkSessionOrInternalAuth(request) @@ -39,58 +32,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) try { - const totalLimit = validatedData.limit - const logGroups: { - logGroupName: string - arn: string - storedBytes: number - retentionInDays: number | undefined - creationTime: number | undefined - }[] = [] - let nextToken: string | undefined - - for (let page = 0; page < MAX_LOG_GROUPS_PAGES; page++) { - const pageLimit = - totalLimit !== undefined - ? Math.min(LOG_GROUPS_PAGE_SIZE, totalLimit - logGroups.length) - : LOG_GROUPS_PAGE_SIZE - - const command = new DescribeLogGroupsCommand({ - ...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }), - limit: pageLimit, - ...(nextToken && { nextToken }), - }) - - const response = await client.send(command) - - for (const lg of response.logGroups ?? []) { - logGroups.push({ - logGroupName: lg.logGroupName ?? '', - arn: lg.arn ?? '', - storedBytes: lg.storedBytes ?? 0, - retentionInDays: lg.retentionInDays, - creationTime: lg.creationTime, - }) - } - - nextToken = response.nextToken - if (!nextToken) break - if (totalLimit !== undefined && logGroups.length >= totalLimit) break - - if (page === MAX_LOG_GROUPS_PAGES - 1) { - logger.warn( - `DescribeLogGroups hit pagination cap of ${MAX_LOG_GROUPS_PAGES} pages; log group list may be incomplete` - ) - } - } - - const cappedLogGroups = totalLimit !== undefined ? logGroups.slice(0, totalLimit) : logGroups + const result = await describeLogGroups(client, { + prefix: validatedData.prefix, + limit: validatedData.limit, + }) - logger.info(`Successfully described ${cappedLogGroups.length} log groups`) + logger.info(`Successfully described ${result.logGroups.length} log groups`) return NextResponse.json({ success: true, - output: { logGroups: cappedLogGroups }, + output: { logGroups: result.logGroups }, }) } finally { client.destroy() diff --git a/apps/sim/app/api/tools/cloudwatch/selector-log-groups/route.ts b/apps/sim/app/api/tools/cloudwatch/selector-log-groups/route.ts new file mode 100644 index 00000000000..33e9a2a3fd4 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/selector-log-groups/route.ts @@ -0,0 +1,63 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + cloudwatchLogGroupsBodySchema, + cloudwatchSelectorLogGroupsContract, +} from '@/lib/api/contracts/selectors/cloudwatch' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authenticateSelectorRequest, + resolveAuthorizedSelectorContext, +} from '@/lib/selectors/server/resolve-authorized-context' +import { createCloudWatchLogsClient, describeLogGroups } from '@/app/api/tools/cloudwatch/utils' + +const logger = createLogger('CloudWatchSelectorLogGroupsAPI') + +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(cloudwatchSelectorLogGroupsContract, request, {}) + if (!parsed.success) return parsed.response + + const { workflowId, prefix, limit, ...context } = parsed.data.body + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + context, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) + } + + const validated = cloudwatchLogGroupsBodySchema.safeParse({ + ...resolution.context, + prefix, + limit, + }) + if (!validated.success) { + return NextResponse.json( + { error: 'Invalid CloudWatch selector configuration' }, + { status: 400 } + ) + } + + const client = createCloudWatchLogsClient(validated.data) + try { + const result = await describeLogGroups(client, { + prefix: validated.data.prefix, + limit: validated.data.limit, + }) + return NextResponse.json({ + logGroups: result.logGroups.map(({ logGroupName }) => ({ logGroupName })), + }) + } finally { + client.destroy() + } + } catch { + logger.error('CloudWatch selector log-group request failed') + return NextResponse.json({ error: 'Failed to retrieve CloudWatch log groups' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/tools/cloudwatch/selector-log-streams/route.ts b/apps/sim/app/api/tools/cloudwatch/selector-log-streams/route.ts new file mode 100644 index 00000000000..02ec8ec9214 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/selector-log-streams/route.ts @@ -0,0 +1,67 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { + cloudwatchLogStreamsBodySchema, + cloudwatchSelectorLogStreamsContract, +} from '@/lib/api/contracts/selectors/cloudwatch' +import { parseRequest } from '@/lib/api/server' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + authenticateSelectorRequest, + resolveAuthorizedSelectorContext, +} from '@/lib/selectors/server/resolve-authorized-context' +import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils' + +const logger = createLogger('CloudWatchSelectorLogStreamsAPI') + +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(cloudwatchSelectorLogStreamsContract, request, {}) + if (!parsed.success) return parsed.response + + const { workflowId, prefix, limit, logGroupName, ...context } = parsed.data.body + const resolution = await resolveAuthorizedSelectorContext(authentication.principal, { + workflowId, + context, + }) + if (!resolution.ok) { + return NextResponse.json({ error: resolution.error }, { status: resolution.status }) + } + + const validated = cloudwatchLogStreamsBodySchema.safeParse({ + ...resolution.context, + prefix, + limit, + logGroupName, + }) + if (!validated.success) { + return NextResponse.json( + { error: 'Invalid CloudWatch selector configuration' }, + { status: 400 } + ) + } + + const client = createCloudWatchLogsClient(validated.data) + try { + const result = await describeLogStreams(client, validated.data.logGroupName, { + prefix: validated.data.prefix, + limit: validated.data.limit, + }) + return NextResponse.json({ + logStreams: result.logStreams.map(({ logStreamName }) => ({ logStreamName })), + }) + } finally { + client.destroy() + } + } catch { + logger.error('CloudWatch selector log-stream request failed') + return NextResponse.json( + { error: 'Failed to retrieve CloudWatch log streams' }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/tools/cloudwatch/server-resolved-selectors.test.ts b/apps/sim/app/api/tools/cloudwatch/server-resolved-selectors.test.ts new file mode 100644 index 00000000000..f8027039492 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/server-resolved-selectors.test.ts @@ -0,0 +1,197 @@ +/** + * @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(), + hybridAuth: vi.fn(), + createClient: vi.fn(), + destroyClient: vi.fn(), + describeLogGroups: vi.fn(), + describeLogStreams: vi.fn(), +})) + +vi.mock('@/lib/selectors/server/resolve-authorized-context', () => ({ + authenticateSelectorRequest: mocks.authenticate, + resolveAuthorizedSelectorContext: mocks.resolveContext, +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + checkSessionOrInternalAuth: mocks.hybridAuth, +})) + +vi.mock('@/app/api/tools/cloudwatch/utils', () => ({ + createCloudWatchLogsClient: mocks.createClient, + describeLogGroups: mocks.describeLogGroups, + describeLogStreams: mocks.describeLogStreams, +})) + +import { POST as runtimeLogGroups } from '@/app/api/tools/cloudwatch/describe-log-groups/route' +import { POST as selectorLogGroups } from '@/app/api/tools/cloudwatch/selector-log-groups/route' +import { POST as selectorLogStreams } from '@/app/api/tools/cloudwatch/selector-log-streams/route' + +function request(path: string, body: unknown) { + return createMockRequest('POST', body, {}, `http://localhost:3000${path}`) +} + +const wireBody = { + workflowId: 'workflow-1', + accessKeyId: '{{AWS_ACCESS_KEY_ID}}', + secretAccessKey: '{{AWS_SECRET_ACCESS_KEY}}', + region: '{{AWS_REGION}}', +} + +describe('server-resolved CloudWatch selectors', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue({ + ok: true, + principal: { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + }) + mocks.hybridAuth.mockResolvedValue({ success: true, userId: 'viewer-1' }) + mocks.resolveContext.mockResolvedValue({ + ok: true, + context: { + accessKeyId: 'AKIARESOLVED', + secretAccessKey: 'resolved-secret', + region: 'us-east-1', + }, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + }) + mocks.createClient.mockReturnValue({ destroy: mocks.destroyClient }) + mocks.describeLogGroups.mockResolvedValue({ + logGroups: [ + { + logGroupName: '/aws/lambda/example', + arn: 'arn:aws:logs:example', + storedBytes: 42, + }, + ], + }) + mocks.describeLogStreams.mockResolvedValue({ + logStreams: [ + { + logStreamName: '2026/08/26/stream', + storedBytes: 42, + creationTime: 123, + }, + ], + }) + }) + + it('authenticates before parsing malformed requests', async () => { + mocks.authenticate.mockResolvedValue({ ok: false, status: 401, error: 'Unauthorized' }) + + const response = await selectorLogGroups( + request('/api/tools/cloudwatch/selector-log-groups', {}) + ) + + expect(response.status).toBe(401) + expect(mocks.resolveContext).not.toHaveBeenCalled() + }) + + it('short-circuits inaccessible references before creating a provider client', async () => { + mocks.resolveContext.mockResolvedValue({ + ok: false, + status: 400, + error: 'Unable to resolve selector configuration', + }) + + const response = await selectorLogGroups( + request('/api/tools/cloudwatch/selector-log-groups', wireBody) + ) + + expect(response.status).toBe(400) + expect(mocks.createClient).not.toHaveBeenCalled() + }) + + it('validates resolved credentials, exposes names only, and destroys the client', async () => { + const response = await selectorLogGroups( + request('/api/tools/cloudwatch/selector-log-groups', wireBody) + ) + + expect(await response.json()).toEqual({ + logGroups: [{ logGroupName: '/aws/lambda/example' }], + }) + expect(mocks.resolveContext).toHaveBeenCalledWith(expect.anything(), { + workflowId: 'workflow-1', + context: { + accessKeyId: '{{AWS_ACCESS_KEY_ID}}', + secretAccessKey: '{{AWS_SECRET_ACCESS_KEY}}', + region: '{{AWS_REGION}}', + }, + }) + expect(mocks.createClient).toHaveBeenCalledWith({ + accessKeyId: 'AKIARESOLVED', + secretAccessKey: 'resolved-secret', + region: 'us-east-1', + }) + expect(mocks.destroyClient).toHaveBeenCalledOnce() + }) + + it('maps log streams to strict name-only responses', async () => { + const response = await selectorLogStreams( + request('/api/tools/cloudwatch/selector-log-streams', { + ...wireBody, + logGroupName: '/aws/lambda/example', + }) + ) + + expect(await response.json()).toEqual({ + logStreams: [{ logStreamName: '2026/08/26/stream' }], + }) + expect(mocks.describeLogStreams).toHaveBeenCalledWith( + expect.anything(), + '/aws/lambda/example', + { prefix: undefined, limit: undefined } + ) + expect(mocks.destroyClient).toHaveBeenCalledOnce() + }) + + it('rejects an invalid resolved region before provider access', async () => { + mocks.resolveContext.mockResolvedValue({ + ok: true, + context: { + accessKeyId: 'AKIARESOLVED', + secretAccessKey: 'resolved-secret', + region: 'invalid-region', + }, + requesterUserId: 'viewer-1', + workspaceId: 'workspace-1', + }) + + const response = await selectorLogGroups( + request('/api/tools/cloudwatch/selector-log-groups', wireBody) + ) + + expect(response.status).toBe(400) + expect(mocks.createClient).not.toHaveBeenCalled() + }) + + it('retains runtime-route metadata compatibility', async () => { + const response = await runtimeLogGroups( + request('/api/tools/cloudwatch/describe-log-groups', { + accessKeyId: 'AKIA-LITERAL', + secretAccessKey: 'literal-secret', + region: 'us-east-1', + }) + ) + + expect(await response.json()).toEqual({ + success: true, + output: { + logGroups: [ + { + logGroupName: '/aws/lambda/example', + arn: 'arn:aws:logs:example', + storedBytes: 42, + }, + ], + }, + }) + }) +}) diff --git a/apps/sim/app/api/tools/cloudwatch/utils.test.ts b/apps/sim/app/api/tools/cloudwatch/utils.test.ts new file mode 100644 index 00000000000..19883a2b7c0 --- /dev/null +++ b/apps/sim/app/api/tools/cloudwatch/utils.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { describeLogGroups, describeLogStreams } from '@/app/api/tools/cloudwatch/utils' + +describe('describeLogGroups', () => { + it('preserves bounded pagination and normalized provider mapping', async () => { + const send = vi + .fn() + .mockResolvedValueOnce({ + logGroups: [{ logGroupName: 'api', arn: 'arn:api', storedBytes: 12 }], + nextToken: 'next-page', + }) + .mockResolvedValueOnce({ + logGroups: [{ logGroupName: 'worker' }], + }) + + const result = await describeLogGroups({ send } as never, { prefix: 'a' }) + + expect(result).toEqual({ + logGroups: [ + { + logGroupName: 'api', + arn: 'arn:api', + storedBytes: 12, + retentionInDays: undefined, + creationTime: undefined, + }, + { + logGroupName: 'worker', + arn: '', + storedBytes: 0, + retentionInDays: undefined, + creationTime: undefined, + }, + ], + }) + expect(send).toHaveBeenCalledTimes(2) + expect(send.mock.calls[0][0].input).toMatchObject({ + logGroupNamePrefix: 'a', + limit: 50, + }) + expect(send.mock.calls[1][0].input).toMatchObject({ nextToken: 'next-page' }) + }) + + it('treats limit as a total cap', async () => { + const send = vi.fn().mockResolvedValue({ + logGroups: [{ logGroupName: 'one' }, { logGroupName: 'two' }], + nextToken: 'unused', + }) + + const result = await describeLogGroups({ send } as never, { limit: 1 }) + + expect(result.logGroups).toHaveLength(1) + expect(send).toHaveBeenCalledOnce() + expect(send.mock.calls[0][0].input.limit).toBe(1) + }) +}) + +describe('describeLogStreams', () => { + it('preserves bounded pagination and prefix ordering', async () => { + const send = vi + .fn() + .mockResolvedValueOnce({ + logStreams: [{ logStreamName: 'api/one', storedBytes: 12 }], + nextToken: 'next-page', + }) + .mockResolvedValueOnce({ logStreams: [{ logStreamName: 'api/two' }] }) + + const result = await describeLogStreams({ send } as never, 'group', { prefix: 'api/' }) + + expect(result.logStreams.map(({ logStreamName }) => logStreamName)).toEqual([ + 'api/one', + 'api/two', + ]) + expect(send.mock.calls[0][0].input).toMatchObject({ + logGroupName: 'group', + logStreamNamePrefix: 'api/', + orderBy: 'LogStreamName', + limit: 50, + }) + expect(send.mock.calls[1][0].input).toMatchObject({ nextToken: 'next-page' }) + }) +}) diff --git a/apps/sim/app/api/tools/cloudwatch/utils.ts b/apps/sim/app/api/tools/cloudwatch/utils.ts index a477fda9e9f..ca8eca0b073 100644 --- a/apps/sim/app/api/tools/cloudwatch/utils.ts +++ b/apps/sim/app/api/tools/cloudwatch/utils.ts @@ -1,5 +1,6 @@ import { CloudWatchLogsClient, + DescribeLogGroupsCommand, DescribeLogStreamsCommand, FilterLogEventsCommand, GetLogEventsCommand, @@ -10,12 +11,22 @@ import { createLogger } from '@sim/logger' import { sleep } from '@sim/utils/helpers' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' +const logger = createLogger('CloudWatchUtils') + interface AwsCredentials { region: string accessKeyId: string secretAccessKey: string } +interface DescribedLogGroup { + logGroupName: string + arn: string + storedBytes: number + retentionInDays: number | undefined + creationTime: number | undefined +} + export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient { return new CloudWatchLogsClient({ region: config.region, @@ -26,6 +37,60 @@ export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLo }) } +/** AWS DescribeLogGroups caps `limit` at 50 items per page. */ +const LOG_GROUPS_PAGE_SIZE = 50 + +/** Upper bound on pages drained to avoid unbounded loops on very large accounts. */ +const MAX_LOG_GROUPS_PAGES = 20 + +/** Lists CloudWatch log groups with the same bounded pagination used by tool and selector routes. */ +export async function describeLogGroups( + client: CloudWatchLogsClient, + options?: { prefix?: string; limit?: number } +): Promise<{ logGroups: DescribedLogGroup[] }> { + const totalLimit = options?.limit + const logGroups: DescribedLogGroup[] = [] + let nextToken: string | undefined + + for (let page = 0; page < MAX_LOG_GROUPS_PAGES; page++) { + const pageLimit = + totalLimit !== undefined + ? Math.min(LOG_GROUPS_PAGE_SIZE, totalLimit - logGroups.length) + : LOG_GROUPS_PAGE_SIZE + const response = await client.send( + new DescribeLogGroupsCommand({ + ...(options?.prefix && { logGroupNamePrefix: options.prefix }), + limit: pageLimit, + ...(nextToken && { nextToken }), + }) + ) + + for (const logGroup of response.logGroups ?? []) { + logGroups.push({ + logGroupName: logGroup.logGroupName ?? '', + arn: logGroup.arn ?? '', + storedBytes: logGroup.storedBytes ?? 0, + retentionInDays: logGroup.retentionInDays, + creationTime: logGroup.creationTime, + }) + } + + nextToken = response.nextToken + if (!nextToken) break + if (totalLimit !== undefined && logGroups.length >= totalLimit) break + + if (page === MAX_LOG_GROUPS_PAGES - 1) { + logger.warn( + `DescribeLogGroups hit pagination cap of ${MAX_LOG_GROUPS_PAGES} pages; log group list may be incomplete` + ) + } + } + + return { + logGroups: totalLimit !== undefined ? logGroups.slice(0, totalLimit) : logGroups, + } +} + interface PollOptions { maxWaitMs?: number pollIntervalMs?: number @@ -104,8 +169,6 @@ const LOG_STREAMS_PAGE_SIZE = 50 /** Upper bound on pages drained to avoid unbounded loops on log groups with many streams. */ const MAX_LOG_STREAMS_PAGES = 20 -const logger = createLogger('CloudWatchUtils') - interface DescribedLogStream { logStreamName: string lastEventTimestamp: number | undefined diff --git a/apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts b/apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts index 6d7b7480183..0869bf89736 100644 --- a/apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts +++ b/apps/sim/hooks/selectors/providers/cloudwatch/selectors.ts @@ -18,27 +18,32 @@ function ensureAwsSelectorCredentials(context: SelectorQueryArgs['context'], key export const cloudwatchSelectors = { 'cloudwatch.logGroups': { key: 'cloudwatch.logGroups', - contracts: [selectorContracts.cloudwatchLogGroupsSelectorContract], + contracts: [selectorContracts.cloudwatchSelectorLogGroupsContract], + serverResolvedContextFields: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'], staleTime: SELECTOR_STALE, getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'cloudwatch.logGroups', - context.awsAccessKeyId ?? 'none', - context.awsRegion ?? 'none', search ?? '', ], enabled: ({ context }) => - Boolean(context.awsAccessKeyId && context.awsSecretAccessKey && context.awsRegion), + Boolean( + context.awsAccessKeyId && + context.awsSecretAccessKey && + context.awsRegion && + context.workflowId + ), fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { const awsCredentials = ensureAwsSelectorCredentials(context, 'cloudwatch.logGroups') - const data = await requestJson(selectorContracts.cloudwatchLogGroupsSelectorContract, { + const data = await requestJson(selectorContracts.cloudwatchSelectorLogGroupsContract, { body: { + workflowId: context.workflowId!, ...awsCredentials, prefix: search, }, signal, }) - return (data.output?.logGroups || []).map((lg) => ({ + return data.logGroups.map((lg) => ({ id: lg.logGroupName, label: lg.logGroupName, })) @@ -50,13 +55,12 @@ export const cloudwatchSelectors = { }, 'cloudwatch.logStreams': { key: 'cloudwatch.logStreams', - contracts: [selectorContracts.cloudwatchLogStreamsSelectorContract], + contracts: [selectorContracts.cloudwatchSelectorLogStreamsContract], + serverResolvedContextFields: ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'], staleTime: SELECTOR_STALE, getQueryKey: ({ context, search }: SelectorQueryArgs) => [ 'selectors', 'cloudwatch.logStreams', - context.awsAccessKeyId ?? 'none', - context.awsRegion ?? 'none', context.logGroupName ?? 'none', search ?? '', ], @@ -65,22 +69,24 @@ export const cloudwatchSelectors = { context.awsAccessKeyId && context.awsSecretAccessKey && context.awsRegion && - context.logGroupName + context.logGroupName && + context.workflowId ), fetchList: async ({ context, search, signal }: SelectorQueryArgs) => { const awsCredentials = ensureAwsSelectorCredentials(context, 'cloudwatch.logStreams') if (!context.logGroupName) { throw new Error('Missing log group name for cloudwatch.logStreams selector') } - const data = await requestJson(selectorContracts.cloudwatchLogStreamsSelectorContract, { + const data = await requestJson(selectorContracts.cloudwatchSelectorLogStreamsContract, { body: { + workflowId: context.workflowId!, ...awsCredentials, logGroupName: context.logGroupName, prefix: search, }, signal, }) - return (data.output?.logStreams || []).map((ls) => ({ + return data.logStreams.map((ls) => ({ id: ls.logStreamName, label: ls.logStreamName, })) diff --git a/apps/sim/hooks/selectors/providers/cloudwatch/server-resolved-context.test.ts b/apps/sim/hooks/selectors/providers/cloudwatch/server-resolved-context.test.ts new file mode 100644 index 00000000000..d3e681c5db9 --- /dev/null +++ b/apps/sim/hooks/selectors/providers/cloudwatch/server-resolved-context.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ requestJson: vi.fn() })) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson })) + +import { cloudwatchSelectors } from '@/hooks/selectors/providers/cloudwatch/selectors' + +describe('CloudWatch server-resolved selector context', () => { + beforeEach(() => vi.clearAllMocks()) + + it('opts both selectors into all AWS credential fields', () => { + const fields = ['awsAccessKeyId', 'awsSecretAccessKey', 'awsRegion'] + expect(cloudwatchSelectors['cloudwatch.logGroups'].serverResolvedContextFields).toEqual(fields) + expect(cloudwatchSelectors['cloudwatch.logStreams'].serverResolvedContextFields).toEqual(fields) + }) + + it('forwards raw references and maps group names', async () => { + mocks.requestJson.mockResolvedValue({ + logGroups: [{ logGroupName: '/aws/lambda/example' }], + }) + const context = { + workflowId: 'workflow-1', + awsAccessKeyId: '{{AWS_ACCESS_KEY_ID}}', + awsSecretAccessKey: '{{AWS_SECRET_ACCESS_KEY}}', + awsRegion: '{{AWS_REGION}}', + } + + const options = await cloudwatchSelectors['cloudwatch.logGroups'].fetchList!({ + key: 'cloudwatch.logGroups', + context, + }) + + expect(options).toEqual([{ id: '/aws/lambda/example', label: '/aws/lambda/example' }]) + expect(mocks.requestJson.mock.calls[0][1].body).toEqual({ + workflowId: 'workflow-1', + accessKeyId: '{{AWS_ACCESS_KEY_ID}}', + secretAccessKey: '{{AWS_SECRET_ACCESS_KEY}}', + region: '{{AWS_REGION}}', + }) + }) + + it('requires workflow scope and keeps literal AWS values out of base query keys', () => { + const definition = cloudwatchSelectors['cloudwatch.logGroups'] + const context = { + workspaceId: 'workspace-1', + workflowId: 'workflow-1', + awsAccessKeyId: 'AKIA-LITERAL-SECRET', + awsSecretAccessKey: 'aws-literal-secret', + awsRegion: 'us-secret-1', + } + const key = definition.getQueryKey!({ + key: 'cloudwatch.logGroups', + context, + }) + + expect(definition.enabled?.({ key: 'cloudwatch.logGroups', context })).toBe(true) + expect( + definition.enabled?.({ + key: 'cloudwatch.logGroups', + context: { ...context, workflowId: undefined }, + }) + ).toBe(false) + expect(JSON.stringify(key)).not.toContain('AKIA-LITERAL-SECRET') + expect(JSON.stringify(key)).not.toContain('aws-literal-secret') + expect(JSON.stringify(key)).not.toContain('us-secret-1') + }) +}) diff --git a/apps/sim/lib/api/contracts/selectors/cloudwatch.server-resolved.test.ts b/apps/sim/lib/api/contracts/selectors/cloudwatch.server-resolved.test.ts new file mode 100644 index 00000000000..61d005e051a --- /dev/null +++ b/apps/sim/lib/api/contracts/selectors/cloudwatch.server-resolved.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + cloudwatchLogGroupsBodySchema, + cloudwatchSelectorLogGroupsBodySchema, + cloudwatchSelectorLogGroupsContract, + cloudwatchSelectorLogStreamsContract, +} from '@/lib/api/contracts/selectors/cloudwatch' + +describe('CloudWatch selector contracts', () => { + it('separates reference-friendly wire validation from resolved region validation', () => { + const wire = { + workflowId: 'workflow-1', + accessKeyId: '{{AWS_ACCESS_KEY_ID}}', + secretAccessKey: '{{AWS_SECRET_ACCESS_KEY}}', + region: '{{AWS_REGION}}', + } + + expect(cloudwatchSelectorLogGroupsBodySchema.safeParse(wire).success).toBe(true) + expect(cloudwatchLogGroupsBodySchema.safeParse(wire).success).toBe(false) + expect( + cloudwatchLogGroupsBodySchema.safeParse({ + accessKeyId: 'AKIAEXAMPLE', + secretAccessKey: 'resolved-secret', + region: 'us-east-1', + }).success + ).toBe(true) + }) + + it('keeps selector responses strict and name-only', () => { + expect( + cloudwatchSelectorLogGroupsContract.response.schema.safeParse({ + logGroups: [{ logGroupName: 'group' }], + }).success + ).toBe(true) + expect( + cloudwatchSelectorLogGroupsContract.response.schema.safeParse({ + logGroups: [{ logGroupName: 'group', arn: 'secret-metadata' }], + }).success + ).toBe(false) + expect( + cloudwatchSelectorLogStreamsContract.response.schema.safeParse({ + logStreams: [{ logStreamName: 'stream', storedBytes: 42 }], + }).success + ).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/selectors/cloudwatch.ts b/apps/sim/lib/api/contracts/selectors/cloudwatch.ts index b3e1740d3fa..244ba81ae73 100644 --- a/apps/sim/lib/api/contracts/selectors/cloudwatch.ts +++ b/apps/sim/lib/api/contracts/selectors/cloudwatch.ts @@ -9,6 +9,8 @@ import { validateAwsRegion } from '@/lib/core/security/input-validation' const cloudwatchLogGroupSchema = z.object({ logGroupName: z.string() }).passthrough() const cloudwatchLogStreamSchema = z.object({ logStreamName: z.string() }).passthrough() +const cloudwatchSelectorLogGroupSchema = z.object({ logGroupName: z.string() }).strict() +const cloudwatchSelectorLogStreamSchema = z.object({ logStreamName: z.string() }).strict() /** * AWS region with format validation. Matches the route-level check via @@ -30,6 +32,15 @@ const optionalLimitSchema = z.preprocess( z.coerce.number().int().positive().optional() ) +const cloudwatchSelectorBaseBodySchema = z.object({ + workflowId: z.string().min(1, 'Workflow ID is required'), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + region: z.string().min(1, 'AWS region is required'), + prefix: optionalString, + limit: optionalLimitSchema.optional(), +}) + export const cloudwatchLogGroupsBodySchema = z.object({ accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), @@ -42,6 +53,12 @@ export const cloudwatchLogStreamsBodySchema = cloudwatchLogGroupsBodySchema.exte logGroupName: z.string().min(1, 'Log group name is required'), }) +export const cloudwatchSelectorLogGroupsBodySchema = cloudwatchSelectorBaseBodySchema + +export const cloudwatchSelectorLogStreamsBodySchema = cloudwatchSelectorBaseBodySchema.extend({ + logGroupName: z.string().min(1, 'Log group name is required'), +}) + export const cloudwatchLogGroupsSelectorContract = definePostSelector( '/api/tools/cloudwatch/describe-log-groups', cloudwatchLogGroupsBodySchema, @@ -64,9 +81,23 @@ export const cloudwatchLogStreamsSelectorContract = definePostSelector( .passthrough() ) +export const cloudwatchSelectorLogGroupsContract = definePostSelector( + '/api/tools/cloudwatch/selector-log-groups', + cloudwatchSelectorLogGroupsBodySchema, + z.object({ logGroups: z.array(cloudwatchSelectorLogGroupSchema) }).strict() +) + +export const cloudwatchSelectorLogStreamsContract = definePostSelector( + '/api/tools/cloudwatch/selector-log-streams', + cloudwatchSelectorLogStreamsBodySchema, + z.object({ logStreams: z.array(cloudwatchSelectorLogStreamSchema) }).strict() +) + export const cloudwatchSelectorContractsByPath = { '/api/tools/cloudwatch/describe-log-groups': cloudwatchLogGroupsSelectorContract, '/api/tools/cloudwatch/describe-log-streams': cloudwatchLogStreamsSelectorContract, + '/api/tools/cloudwatch/selector-log-groups': cloudwatchSelectorLogGroupsContract, + '/api/tools/cloudwatch/selector-log-streams': cloudwatchSelectorLogStreamsContract, } as const export type CloudwatchLogGroupsSelectorResponse = ContractJsonResponse<