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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalFileErrorPolicies, internalSessionOrServiceAuth } from '@/lib/workspace-files/api'
import { internalFileErrorPolicies, internalSessionOrExecutorAuth } from '@/lib/workspace-files/api'
import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file'

const logger = createLogger('WorkspaceCsvPreviewAPI')
Expand All @@ -11,7 +11,7 @@ export const dynamic = 'force-dynamic'

export const GET = defineInternalJsonRoute({
contract: getWorkspaceCsvPreviewContract,
auth: internalSessionOrServiceAuth,
auth: internalSessionOrExecutorAuth,
operation: csvPreviewWorkspaceFile.operation,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal CSV preview behavior' }),
errorPolicy: internalFileErrorPolicies.plain,
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/server/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route'
export {
createInternalSessionOrServiceAuth,
createInternalSessionOrExecutorAuth,
defineInternalJsonRoute,
extendInternalErrorPolicy,
type InternalAuthPolicy,
Expand Down
55 changes: 44 additions & 11 deletions apps/sim/lib/api/server/routes/internal-json-route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { DelegatedPrincipal, Principal, SessionPrincipal } from '@sim/auth/principal'
import type {
DelegatedPrincipal,
Principal,
SessionPrincipal,
WorkflowExecutionDelegatedPrincipal,
} from '@sim/auth/principal'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import type { ContractJsonResponse } from '@/lib/api/contracts'
Expand All @@ -15,7 +20,14 @@ import {
parseRequest,
} from '@/lib/api/server/validation'
import { getSession } from '@/lib/auth'
import { verifyInternalToken } from '@/lib/auth/internal'
import {
InvalidInternalDelegationTokenError,
verifyInternalDelegationToken,
} from '@/lib/auth/internal'
import {
bindInternalExecutorDelegation,
InvalidInternalDelegationBindingError,
} from '@/lib/auth/internal-delegation'
import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand All @@ -37,26 +49,47 @@ export const internalSessionAuth = {
},
} as const

export function createInternalSessionOrServiceAuth<P extends DelegatedPrincipal>(
bindDelegation: (args: {
subjectUserId: string
export interface InternalSessionOrExecutorAuthOptions {
audience: string
resourceScope?(
params: Record<string, string | string[] | undefined>
}) => P
): InternalAuthPolicy<SessionPrincipal | P> {
): DelegatedPrincipal['resourceScope']
}

export function createInternalSessionOrExecutorAuth(
options: InternalSessionOrExecutorAuthOptions
): InternalAuthPolicy<SessionPrincipal | WorkflowExecutionDelegatedPrincipal> {
if (!options.audience.trim()) throw new Error('Internal executor auth audience must not be empty')

return {
async authenticate(request, params) {
if (request.headers.has('x-api-key')) {
throw new InternalUnauthenticatedError('Authentication required')
}

const authorization = request.headers.get('authorization')
if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate()
if (!authorization) return internalSessionAuth.authenticate()
if (!authorization.startsWith('Bearer ')) {
throw new InternalUnauthenticatedError('Authentication required')
}

const verification = await verifyInternalToken(authorization.slice('Bearer '.length))
if (!verification.valid || !verification.userId) {
let delegation
try {
delegation = await verifyInternalDelegationToken(authorization.slice('Bearer '.length))
} catch (error) {
if (!(error instanceof InvalidInternalDelegationTokenError)) throw error
throw new InternalUnauthenticatedError('Authentication required')
}

try {
return await bindInternalExecutorDelegation(delegation, {
audience: options.audience,
resourceScope: options.resourceScope?.(params),
})
} catch (error) {
if (!(error instanceof InvalidInternalDelegationBindingError)) throw error
throw new InternalUnauthenticatedError('Authentication required')
}
return bindDelegation({ subjectUserId: verification.userId, params })
},
}
}
Expand Down
115 changes: 115 additions & 0 deletions apps/sim/lib/auth/internal-delegation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@/lib/workflows/application/context', () => ({
resolveActiveWorkflowApplicationContext: mockResolveWorkflow,
resolveActiveWorkflowRunApplicationContext: mockResolveRun,
}))

import {
bindInternalExecutorDelegation,
InvalidInternalDelegationBindingError,
} from '@/lib/auth/internal-delegation'
import { OrchestrationError } from '@/lib/core/orchestration/types'

const claims = {
serviceId: 'executor' as const,
subjectUserId: 'user-1',
workflowId: 'workflow-1',
delegationId: 'delegation-1',
issuedAt: new Date('2026-08-08T12:00:00.000Z'),
expiresAt: new Date('2026-08-08T12:05:00.000Z'),
}

describe('bindInternalExecutorDelegation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockResolveWorkflow.mockResolvedValue({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
})
mockResolveRun.mockResolvedValue({
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
runId: 'execution-1',
})
})

it('derives workspace authority from the canonical workflow', async () => {
await expect(
bindInternalExecutorDelegation(claims, { audience: 'sim:knowledge' })
).resolves.toEqual({
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: 'sim:knowledge',
issuedAt: claims.issuedAt,
expiresAt: claims.expiresAt,
delegationContext: {
kind: 'workflow_execution',
workflowId: 'workflow-1',
},
})
expect(mockResolveWorkflow).toHaveBeenCalledWith({ workflowId: 'workflow-1' })
expect(mockResolveRun).not.toHaveBeenCalled()
})

it('canonically binds an execution to its signed workflow', async () => {
const executionClaims = { ...claims, executionId: 'execution-1' }

const principal = await bindInternalExecutorDelegation(executionClaims, {
audience: 'sim:workspace-files',
resourceScope: { fileId: 'file-1' },
})

expect(mockResolveRun).toHaveBeenCalledWith({
runId: 'execution-1',
assertedWorkflowId: 'workflow-1',
})
expect(principal).toMatchObject({
workspaceId: 'workspace-1',
resourceScope: { fileId: 'file-1' },
delegationContext: {
kind: 'workflow_execution',
workflowId: 'workflow-1',
executionId: 'execution-1',
},
})
})

it('fails before canonical loading when the domain audience is missing', async () => {
await expect(bindInternalExecutorDelegation(claims, { audience: ' ' })).rejects.toThrow(
'Internal delegation audience must not be empty'
)
expect(mockResolveWorkflow).not.toHaveBeenCalled()
})

it('classifies a missing canonical execution as an invalid delegation binding', async () => {
mockResolveRun.mockRejectedValue(new OrchestrationError('not_found', 'Workflow run not found'))

await expect(
bindInternalExecutorDelegation(
{ ...claims, executionId: 'execution-1' },
{ audience: 'sim:workspace-files' }
)
).rejects.toBeInstanceOf(InvalidInternalDelegationBindingError)
})

it('does not disguise canonical-load infrastructure failures as invalid credentials', async () => {
const infrastructureError = new Error('database unavailable')
mockResolveWorkflow.mockRejectedValue(infrastructureError)

await expect(
bindInternalExecutorDelegation(claims, { audience: 'sim:workspace-files' })
).rejects.toBe(infrastructureError)
})
})
59 changes: 59 additions & 0 deletions apps/sim/lib/auth/internal-delegation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { DelegatedPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import type { VerifiedInternalDelegation } from '@/lib/auth/internal'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import {
resolveActiveWorkflowApplicationContext,
resolveActiveWorkflowRunApplicationContext,
} from '@/lib/workflows/application/context'

export interface BindInternalExecutorDelegationOptions {
audience: string
resourceScope?: DelegatedPrincipal['resourceScope']
}

export class InvalidInternalDelegationBindingError extends Error {
constructor() {
super('Internal delegation no longer resolves to an active workflow execution')
this.name = 'InvalidInternalDelegationBindingError'
}
}

/** Binds signed executor claims to the workflow's canonical active workspace. */
export async function bindInternalExecutorDelegation(
claims: VerifiedInternalDelegation,
options: BindInternalExecutorDelegationOptions
): Promise<WorkflowExecutionDelegatedPrincipal> {
if (!options.audience.trim()) throw new Error('Internal delegation audience must not be empty')

let context
try {
context = claims.executionId
? await resolveActiveWorkflowRunApplicationContext({
runId: claims.executionId,
assertedWorkflowId: claims.workflowId,
})
: await resolveActiveWorkflowApplicationContext({ workflowId: claims.workflowId })
} catch (error) {
if (asOrchestrationError(error)?.code === 'not_found') {
throw new InvalidInternalDelegationBindingError()
}
throw error
}

return {
kind: 'delegated',
serviceId: 'executor',
subjectUserId: claims.subjectUserId,
workspaceId: context.workspaceId,
delegationId: claims.delegationId,
audience: options.audience,
issuedAt: claims.issuedAt,
expiresAt: claims.expiresAt,
...(options.resourceScope ? { resourceScope: options.resourceScope } : {}),
delegationContext: {
kind: 'workflow_execution',
workflowId: context.workflowId,
...(claims.executionId ? { executionId: claims.executionId } : {}),
},
}
}
65 changes: 64 additions & 1 deletion apps/sim/lib/auth/internal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@
*/

import { resetEnvMock } from '@sim/testing'
import { decodeJwt } from 'jose'
import { afterAll, describe, expect, it, vi } from 'vitest'

vi.unmock('@/lib/auth/internal')

import { generateInternalToken, verifyInternalToken } from '@/lib/auth/internal'
import {
generateInternalDelegationToken,
generateInternalToken,
InvalidInternalDelegationTokenError,
verifyInternalDelegationToken,
verifyInternalToken,
} from '@/lib/auth/internal'

afterAll(resetEnvMock)

Expand Down Expand Up @@ -39,3 +46,59 @@ describe('internal JWT claims', () => {
await expect(verifyInternalToken(token)).resolves.toEqual({ valid: false })
})
})

describe('internal executor delegation claims', () => {
it('round-trips a subject-bearing workflow execution delegation', async () => {
const token = await generateInternalDelegationToken({
subjectUserId: 'user-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})

const delegation = await verifyInternalDelegationToken(token)

expect(delegation).toMatchObject({
serviceId: 'executor',
subjectUserId: 'user-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
expect(delegation.delegationId).toBeTruthy()
expect(delegation.issuedAt).toBeInstanceOf(Date)
expect(delegation.expiresAt.getTime()).toBeGreaterThan(delegation.issuedAt.getTime())
})

it('derives issued-at and expiry from one timestamp', async () => {
const token = await generateInternalDelegationToken({
subjectUserId: 'user-1',
workflowId: 'workflow-1',
})
const payload = decodeJwt(token)

if (typeof payload.exp !== 'number' || typeof payload.iat !== 'number') {
throw new Error('Generated delegation token is missing numeric lifetime claims')
}
expect(payload.exp - payload.iat).toBe(5 * 60)
})

it('rejects missing delegation scope at issuance', async () => {
await expect(
generateInternalDelegationToken({
subjectUserId: 'user-1',
workflowId: ' ',
})
).rejects.toThrow('Internal delegation workflowId must not be empty')
})

it('does not accept legacy subject or actorless tokens as executor delegations', async () => {
const legacySubjectToken = await generateInternalToken('user-1')
const actorlessToken = await generateInternalToken()

await expect(verifyInternalDelegationToken(legacySubjectToken)).rejects.toBeInstanceOf(
InvalidInternalDelegationTokenError
)
await expect(verifyInternalDelegationToken(actorlessToken)).rejects.toBeInstanceOf(
InvalidInternalDelegationTokenError
)
})
})
Loading
Loading