Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 202 additions & 0 deletions apps/sim/app/api/workflows/[id]/deployed/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/**
* Tests for the workflow deployed-state API route.
* Covers internal-JWT authorization (acting user required + workspace read
* permission) and the unchanged session path.
*
* @vitest-environment node
*/

import {
workflowAuthzMockFns,
workflowsPersistenceUtilsMock,
workflowsPersistenceUtilsMockFns,
workflowsUtilsMock,
workflowsUtilsMockFns,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockVerifyInternalToken } = vi.hoisted(() => ({
mockVerifyInternalToken: vi.fn(),
}))

vi.mock('@/lib/auth/internal', () => ({
verifyInternalToken: mockVerifyInternalToken,
}))

vi.mock('@/lib/workflows/persistence/utils', () => workflowsPersistenceUtilsMock)

vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock)

import { GET } from './route'

const mockAuthorizeWorkflowByWorkspacePermission =
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission
const mockLoadDeployedWorkflowState = workflowsPersistenceUtilsMockFns.mockLoadDeployedWorkflowState
const mockValidateWorkflowPermissions = workflowsUtilsMockFns.mockValidateWorkflowPermissions

const DEPLOYED_STATE = {
blocks: { 'block-1': { id: 'block-1', type: 'starter' } },
edges: [],
loops: {},
parallels: {},
variables: {},
}

function createRequest(options?: { bearerToken?: string }) {
const headers: Record<string, string> = {}
if (options?.bearerToken) {
headers.Authorization = `Bearer ${options.bearerToken}`
}
return new NextRequest('http://localhost:3000/api/workflows/workflow-123/deployed', { headers })
}

const routeParams = () => ({ params: Promise.resolve({ id: 'workflow-123' }) })

describe('GET /api/workflows/[id]/deployed', () => {
beforeEach(() => {
vi.clearAllMocks()
mockVerifyInternalToken.mockResolvedValue({ valid: false })
mockLoadDeployedWorkflowState.mockResolvedValue(DEPLOYED_STATE)
})

describe('internal JWT path', () => {
it('returns 200 when the token carries a user with read permission', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
workspacePermission: 'read',
})

const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())

expect(response.status).toBe(200)
const data = await response.json()
expect(data.deployedState).toEqual(DEPLOYED_STATE)
expect(mockAuthorizeWorkflowByWorkspacePermission).toHaveBeenCalledWith({
workflowId: 'workflow-123',
userId: 'user-123',
action: 'read',
})
expect(mockValidateWorkflowPermissions).not.toHaveBeenCalled()
})

it('returns 403 when the acting user lacks read permission', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
status: 403,
message: 'Unauthorized: Access denied to read this workflow',
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
workspacePermission: null,
})

const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())

expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Unauthorized: Access denied to read this workflow')
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
})

it('returns 403 when the token carries no acting user (fail closed)', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: undefined })

const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())

expect(response.status).toBe(403)
const data = await response.json()
expect(data.error).toBe('Forbidden')
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
})

it('returns 404 when the workflow does not exist', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: false,
status: 404,
message: 'Workflow not found',
workflow: null,
workspacePermission: null,
})

const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())

expect(response.status).toBe(404)
const data = await response.json()
expect(data.error).toBe('Workflow not found')
expect(mockLoadDeployedWorkflowState).not.toHaveBeenCalled()
})
})

describe('session path', () => {
it('returns 200 when session permissions validate', async () => {
mockValidateWorkflowPermissions.mockResolvedValue({
error: null,
session: { user: { id: 'user-123' } },
workflow: { id: 'workflow-123' },
})

const response = await GET(createRequest(), routeParams())

expect(response.status).toBe(200)
const data = await response.json()
expect(data.deployedState).toEqual(DEPLOYED_STATE)
expect(mockValidateWorkflowPermissions).toHaveBeenCalledWith(
'workflow-123',
expect.any(String),
'read'
)
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
})

it('propagates validateWorkflowPermissions errors unchanged', async () => {
mockValidateWorkflowPermissions.mockResolvedValue({
error: { message: 'Unauthorized', status: 401 },
session: null,
workflow: null,
})

const response = await GET(createRequest(), routeParams())

expect(response.status).toBe(401)
const data = await response.json()
expect(data.error).toBe('Unauthorized')
})

it('falls back to session validation when the bearer token is not a valid internal token', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: false })
mockValidateWorkflowPermissions.mockResolvedValue({
error: { message: 'Unauthorized', status: 401 },
session: null,
workflow: null,
})

const response = await GET(createRequest({ bearerToken: 'not-internal' }), routeParams())

expect(response.status).toBe(401)
expect(mockValidateWorkflowPermissions).toHaveBeenCalled()
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
})
})

it('returns null deployedState when loading the snapshot fails', async () => {
mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-123' })
mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
allowed: true,
status: 200,
workflow: { id: 'workflow-123', workspaceId: 'workspace-456' },
workspacePermission: 'admin',
})
mockLoadDeployedWorkflowState.mockRejectedValue(new Error('no active deployment'))

const response = await GET(createRequest({ bearerToken: 'internal-token' }), routeParams())

expect(response.status).toBe(200)
const data = await response.json()
expect(data.deployedState).toBeNull()
})
})
40 changes: 39 additions & 1 deletion apps/sim/app/api/workflows/[id]/deployed/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import type { NextRequest, NextResponse } from 'next/server'
import { getDeployedWorkflowStateContract } from '@/lib/api/contracts/deployments'
import { parseRequest } from '@/lib/api/server'
Expand All @@ -19,6 +20,18 @@ function addNoCacheHeaders(response: NextResponse): NextResponse {
return response
}

/**
* GET /api/workflows/[id]/deployed
* Returns the active deployed state snapshot for a workflow.
*
* Internal (server-to-server) calls must carry the acting user in the internal
* JWT payload (`generateInternalToken(userId)` — the executor's
* `buildAuthHeaders(ctx.userId)` always embeds it) and are authorized as that
* user with the same workspace-read semantics as the sibling
* `/api/workflows/[id]` route. Internal calls without a user id are rejected
* (fail closed). Session calls are authorized via
* `validateWorkflowPermissions` as before.
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ id: string }> }) => {
const requestId = generateRequestId()
Expand All @@ -29,14 +42,39 @@ export const GET = withRouteHandler(
try {
const authHeader = request.headers.get('authorization')
let isInternalCall = false
let internalCallUserId: string | undefined

if (authHeader?.startsWith('Bearer ')) {
const token = authHeader.split(' ')[1]
const verification = await verifyInternalToken(token)
isInternalCall = verification.valid
internalCallUserId = verification.userId
}

if (!isInternalCall) {
if (isInternalCall) {
if (!internalCallUserId) {
logger.warn(`[${requestId}] Internal call without acting user denied for workflow ${id}`)
return addNoCacheHeaders(createErrorResponse('Forbidden', 403))
}

const authorization = await authorizeWorkflowByWorkspacePermission({
workflowId: id,
userId: internalCallUserId,
action: 'read',
})
if (!authorization.workflow) {
logger.warn(`[${requestId}] Workflow ${id} not found for internal call`)
return addNoCacheHeaders(createErrorResponse('Workflow not found', 404))
}
if (!authorization.allowed) {
logger.warn(
`[${requestId}] Internal call user ${internalCallUserId} denied read access to workflow ${id}`
)
return addNoCacheHeaders(
createErrorResponse(authorization.message || 'Access denied', authorization.status)
)
}
} else {
const { error } = await validateWorkflowPermissions(id, requestId, 'read')
if (error) {
const response = createErrorResponse(error.message, error.status)
Expand Down
21 changes: 21 additions & 0 deletions apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ async function handleExecutePost(
startBlockId,
stopAfterBlockId,
runFromBlock: rawRunFromBlock,
parentWorkspaceId,
} = validation.data
const triggerBlockId = parsedTriggerBlockId ?? startBlockId

Expand Down Expand Up @@ -642,6 +643,7 @@ async function handleExecutePost(
stopAfterBlockId: _stopAfterBlockId,
runFromBlock: _runFromBlock,
workflowId: _workflowId, // Also exclude workflowId used for internal JWT auth
parentWorkspaceId: _parentWorkspaceId,
...rest
} = body
return Object.keys(rest).length > 0 ? rest : validatedInput
Expand Down Expand Up @@ -729,6 +731,25 @@ async function handleExecutePost(
)
}

/**
* Workflow-in-workflow invocations (e.g. the agent `workflow_executor`
* tool) declare the parent execution's workspace. Reject execution when
* the target workflow lives in a different workspace so a stale or
* foreign workflow id cannot silently execute with the parent's context.
* The error intentionally omits the target's workspace id.
*/
if (parentWorkspaceId && workflowAuthorization.workflow?.workspaceId !== parentWorkspaceId) {
reqLogger.warn('Blocked cross-workspace child workflow execution', {
parentWorkspaceId,
})
return NextResponse.json(
{
error: `Child workflow ${workflowId} belongs to a different workspace and cannot be executed`,
},
{ status: 403 }
)
}

if (req.signal.aborted) {
return clientCancelledResponse()
}
Expand Down
29 changes: 19 additions & 10 deletions apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
loadForkDependentValues,
} from '@/lib/workspaces/fork/mapping/dependent-value-store'
import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
import { collectForkClearedRefCandidates } from '@/lib/workspaces/fork/promote/cleared-refs'
import {
annotateForkClearedRefSourceLiveness,
collectForkClearedRefCandidates,
} from '@/lib/workspaces/fork/promote/cleared-refs'
import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
Expand Down Expand Up @@ -127,15 +130,21 @@ export const GET = withRouteHandler(
sourceLabels.set(`${kind}:${candidate.id}`, candidate.label)
}
const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name]))
const clearedRefs = collectForkClearedRefCandidates({
items: plan.items,
sourceStates,
resolver: plan.resolver,
workflowIdMap: plan.workflowIdMap,
resolveBlockId,
sourceLabels,
sourceWorkflowNames,
})
// Annotate each reference-cause entry's source liveness so the client can phrase the blocker
// reason (a deleted source can't be copied - it must be mapped to a live target resource).
const clearedRefs = await annotateForkClearedRefSourceLiveness(
db,
auth.sourceWorkspaceId,
collectForkClearedRefCandidates({
items: plan.items,
sourceStates,
resolver: plan.resolver,
workflowIdMap: plan.workflowIdMap,
resolveBlockId,
sourceLabels,
sourceWorkflowNames,
})
)

const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({
kind: reference.kind,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const POST = withRouteHandler(
redeployed: result.redeployed,
deployFailed: result.deployFailed,
unmappedRequired: result.unmappedRequired,
blockers: result.blockers,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
}
Expand Down
Loading
Loading