diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts
index 100a7fadca0..de4b8c6104f 100644
--- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts
@@ -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')
@@ -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,
diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts
index 3eacbed3f3e..408ecff04d0 100644
--- a/apps/sim/lib/api/server/routes/index.ts
+++ b/apps/sim/lib/api/server/routes/index.ts
@@ -1,6 +1,6 @@
export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route'
export {
- createInternalSessionOrServiceAuth,
+ createInternalSessionOrExecutorAuth,
defineInternalJsonRoute,
extendInternalErrorPolicy,
type InternalAuthPolicy,
diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts
index 8191e353794..6563d5e7cdb 100644
--- a/apps/sim/lib/api/server/routes/internal-json-route.ts
+++ b/apps/sim/lib/api/server/routes/internal-json-route.ts
@@ -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'
@@ -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'
@@ -37,12 +49,18 @@ export const internalSessionAuth = {
},
} as const
-export function createInternalSessionOrServiceAuth
(
- bindDelegation: (args: {
- subjectUserId: string
+export interface InternalSessionOrExecutorAuthOptions {
+ audience: string
+ resourceScope?(
params: Record
- }) => P
-): InternalAuthPolicy {
+ ): DelegatedPrincipal['resourceScope']
+}
+
+export function createInternalSessionOrExecutorAuth(
+ options: InternalSessionOrExecutorAuthOptions
+): InternalAuthPolicy {
+ 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')) {
@@ -50,13 +68,28 @@ export function createInternalSessionOrServiceAuth
}
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 })
},
}
}
diff --git a/apps/sim/lib/auth/internal-delegation.test.ts b/apps/sim/lib/auth/internal-delegation.test.ts
new file mode 100644
index 00000000000..a041ec712a8
--- /dev/null
+++ b/apps/sim/lib/auth/internal-delegation.test.ts
@@ -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)
+ })
+})
diff --git a/apps/sim/lib/auth/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts
new file mode 100644
index 00000000000..b37b4418c8a
--- /dev/null
+++ b/apps/sim/lib/auth/internal-delegation.ts
@@ -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 {
+ 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 } : {}),
+ },
+ }
+}
diff --git a/apps/sim/lib/auth/internal.test.ts b/apps/sim/lib/auth/internal.test.ts
index fcabbb47c01..1f51376c7fc 100644
--- a/apps/sim/lib/auth/internal.test.ts
+++ b/apps/sim/lib/auth/internal.test.ts
@@ -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)
@@ -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
+ )
+ })
+})
diff --git a/apps/sim/lib/auth/internal.ts b/apps/sim/lib/auth/internal.ts
index 1880efee202..eb28a0cc645 100644
--- a/apps/sim/lib/auth/internal.ts
+++ b/apps/sim/lib/auth/internal.ts
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
-import { jwtVerify, SignJWT } from 'jose'
+import { generateId } from '@sim/utils/id'
+import { type JWTPayload, jwtVerify, SignJWT } from 'jose'
import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import { getClientIp } from '@/lib/core/utils/request'
@@ -14,6 +15,34 @@ export interface InternalTokenClaims {
sandboxProfile?: InternalSandboxProfile
}
+export interface GenerateInternalDelegationTokenInput {
+ subjectUserId: string
+ workflowId: string
+ executionId?: string
+}
+
+export interface VerifiedInternalDelegation {
+ serviceId: 'executor'
+ subjectUserId: string
+ workflowId: string
+ executionId?: string
+ delegationId: string
+ issuedAt: Date
+ expiresAt: Date
+}
+
+export class InvalidInternalDelegationTokenError extends Error {
+ constructor(message = 'Invalid internal delegation token') {
+ super(message)
+ this.name = 'InvalidInternalDelegationTokenError'
+ }
+}
+
+const INTERNAL_DELEGATION_ISSUER = 'sim-internal'
+const INTERNAL_DELEGATION_AUDIENCE = 'sim-api'
+const INTERNAL_DELEGATION_TTL_SECONDS = 5 * 60
+const INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS = 5
+
const getJwtSecret = () => {
// Prefer a dedicated JWT signing key so the internal-JWT trust domain is
// separable from the raw INTERNAL_API_SECRET shared-bearer secret: leaking one
@@ -57,6 +86,94 @@ export async function generateInternalToken(
return token
}
+function requireNonEmptyDelegationClaim(value: string, name: string): string {
+ if (!value.trim()) throw new Error(`Internal delegation ${name} must not be empty`)
+ return value
+}
+
+/** Generates a subject-bearing executor token bound to a workflow and optional execution origin. */
+export async function generateInternalDelegationToken(
+ input: GenerateInternalDelegationTokenInput
+): Promise {
+ const subjectUserId = requireNonEmptyDelegationClaim(input.subjectUserId, 'subjectUserId')
+ const workflowId = requireNonEmptyDelegationClaim(input.workflowId, 'workflowId')
+ const issuedAtSeconds = Math.floor(Date.now() / 1000)
+ const executionId = input.executionId
+ ? requireNonEmptyDelegationClaim(input.executionId, 'executionId')
+ : undefined
+
+ return new SignJWT({
+ type: 'internal_delegation',
+ serviceId: 'executor',
+ workflowId,
+ ...(executionId ? { executionId } : {}),
+ })
+ .setProtectedHeader({ alg: 'HS256' })
+ .setSubject(subjectUserId)
+ .setJti(generateId())
+ .setIssuedAt(issuedAtSeconds)
+ .setExpirationTime(issuedAtSeconds + INTERNAL_DELEGATION_TTL_SECONDS)
+ .setIssuer(INTERNAL_DELEGATION_ISSUER)
+ .setAudience(INTERNAL_DELEGATION_AUDIENCE)
+ .sign(getJwtSecret())
+}
+
+function readVerifiedDelegationClaim(value: unknown): string | null {
+ return typeof value === 'string' && value.trim() ? value : null
+}
+
+/** Verifies a scoped executor delegation without accepting legacy or actorless tokens. */
+export async function verifyInternalDelegationToken(
+ token: string
+): Promise {
+ const secret = getJwtSecret()
+ let payload: JWTPayload
+ try {
+ const verification = await jwtVerify(token, secret, {
+ issuer: INTERNAL_DELEGATION_ISSUER,
+ audience: INTERNAL_DELEGATION_AUDIENCE,
+ algorithms: ['HS256'],
+ clockTolerance: INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS,
+ })
+ payload = verification.payload
+ } catch {
+ throw new InvalidInternalDelegationTokenError()
+ }
+
+ const subjectUserId = readVerifiedDelegationClaim(payload.sub)
+ const workflowId = readVerifiedDelegationClaim(payload.workflowId)
+ const executionId =
+ payload.executionId === undefined ? undefined : readVerifiedDelegationClaim(payload.executionId)
+ const delegationId = readVerifiedDelegationClaim(payload.jti)
+ const nowSeconds = Math.floor(Date.now() / 1000)
+
+ if (
+ payload.type !== 'internal_delegation' ||
+ payload.serviceId !== 'executor' ||
+ !subjectUserId ||
+ !workflowId ||
+ executionId === null ||
+ !delegationId ||
+ typeof payload.iat !== 'number' ||
+ typeof payload.exp !== 'number' ||
+ payload.iat > nowSeconds + INTERNAL_DELEGATION_CLOCK_TOLERANCE_SECONDS ||
+ payload.exp <= payload.iat ||
+ payload.exp - payload.iat > INTERNAL_DELEGATION_TTL_SECONDS
+ ) {
+ throw new InvalidInternalDelegationTokenError()
+ }
+
+ return {
+ serviceId: 'executor',
+ subjectUserId,
+ workflowId,
+ ...(executionId ? { executionId } : {}),
+ delegationId,
+ issuedAt: new Date(payload.iat * 1000),
+ expiresAt: new Date(payload.exp * 1000),
+ }
+}
+
/**
* Verify an internal JWT token
* Returns verification result with userId if present in token
diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts
index 2b23ed91382..dd0b8f04359 100644
--- a/apps/sim/lib/workspace-files/api/index.ts
+++ b/apps/sim/lib/workspace-files/api/index.ts
@@ -2,6 +2,6 @@ export { internalFileAnalytics } from '@/lib/workspace-files/api/internal-analyt
export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies'
export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters'
export {
- internalSessionOrServiceAuth,
+ internalSessionOrExecutorAuth,
v2FileErrorPolicies,
} from '@/lib/workspace-files/api/route-policies'
diff --git a/apps/sim/lib/workspace-files/api/route-policies.test.ts b/apps/sim/lib/workspace-files/api/route-policies.test.ts
index 3fd91c612bb..3ab9860d5f5 100644
--- a/apps/sim/lib/workspace-files/api/route-policies.test.ts
+++ b/apps/sim/lib/workspace-files/api/route-policies.test.ts
@@ -1,32 +1,65 @@
/**
* @vitest-environment node
*/
+
+import { resetEnvMock } from '@sim/testing'
import { NextRequest } from 'next/server'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
-const { mockGetSession, mockVerifyInternalToken } = vi.hoisted(() => ({
- mockGetSession: vi.fn(),
- mockVerifyInternalToken: vi.fn(),
-}))
+const { MockInvalidBindingError, mockBindDelegation, mockGetSession } = vi.hoisted(() => {
+ class MockInvalidBindingError extends Error {}
+ return {
+ MockInvalidBindingError,
+ mockBindDelegation: vi.fn(),
+ mockGetSession: vi.fn(),
+ }
+})
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
-vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken }))
+vi.mock('@/lib/auth/internal-delegation', () => ({
+ bindInternalExecutorDelegation: mockBindDelegation,
+ InvalidInternalDelegationBindingError: MockInvalidBindingError,
+}))
+vi.unmock('@/lib/auth/internal')
import { InternalUnauthenticatedError } from '@/lib/api/server/routes'
-import { internalSessionOrServiceAuth } from '@/lib/workspace-files/api'
+import { generateInternalDelegationToken, generateInternalToken } from '@/lib/auth/internal'
+import { internalSessionOrExecutorAuth } from '@/lib/workspace-files/api'
+
+afterAll(resetEnvMock)
describe('internal file route authentication', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetSession.mockResolvedValue(null)
+ mockBindDelegation.mockImplementation(async (delegation, options) => ({
+ kind: 'delegated',
+ serviceId: 'executor',
+ subjectUserId: delegation.subjectUserId,
+ workspaceId: 'canonical-workspace',
+ delegationId: delegation.delegationId,
+ audience: options.audience,
+ issuedAt: delegation.issuedAt,
+ expiresAt: delegation.expiresAt,
+ resourceScope: options.resourceScope,
+ delegationContext: {
+ kind: 'workflow_execution',
+ workflowId: delegation.workflowId,
+ executionId: delegation.executionId,
+ },
+ }))
})
- it('binds a verified internal user to an executor file principal', async () => {
- mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-1' })
+ it('binds a scoped executor token without trusting the workspace route parameter', async () => {
+ const token = await generateInternalDelegationToken({
+ subjectUserId: 'user-1',
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ })
- const principal = await internalSessionOrServiceAuth.authenticate(
+ const principal = await internalSessionOrExecutorAuth.authenticate(
new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', {
- headers: { authorization: 'Bearer signed-token' },
+ headers: { authorization: `Bearer ${token}` },
}),
{ id: 'ws-1', fileId: 'file-1' }
)
@@ -35,26 +68,72 @@ describe('internal file route authentication', () => {
kind: 'delegated',
serviceId: 'executor',
subjectUserId: 'user-1',
- workspaceId: 'ws-1',
+ workspaceId: 'canonical-workspace',
audience: 'sim:workspace-files',
resourceScope: { fileId: 'file-1' },
})
+ expect(mockBindDelegation).toHaveBeenCalledWith(
+ expect.objectContaining({
+ workflowId: 'workflow-1',
+ executionId: 'execution-1',
+ }),
+ {
+ audience: 'sim:workspace-files',
+ resourceScope: { fileId: 'file-1' },
+ }
+ )
expect(mockGetSession).not.toHaveBeenCalled()
})
- it('rejects internal tokens that do not carry a human subject', async () => {
- mockVerifyInternalToken.mockResolvedValue({ valid: true })
+ it('rejects legacy actorless internal tokens before canonical binding', async () => {
+ const token = await generateInternalToken()
+
+ await expect(
+ internalSessionOrExecutorAuth.authenticate(
+ new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', {
+ headers: { authorization: `Bearer ${token}` },
+ }),
+ { id: 'ws-1', fileId: 'file-1' }
+ )
+ ).rejects.toBeInstanceOf(InternalUnauthenticatedError)
+ expect(mockBindDelegation).not.toHaveBeenCalled()
+ })
+
+ it('rejects a scoped token whose canonical workflow binding no longer exists', async () => {
+ const token = await generateInternalDelegationToken({
+ subjectUserId: 'user-1',
+ workflowId: 'workflow-1',
+ })
+ mockBindDelegation.mockRejectedValue(new MockInvalidBindingError())
await expect(
- internalSessionOrServiceAuth.authenticate(
+ internalSessionOrExecutorAuth.authenticate(
new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', {
- headers: { authorization: 'Bearer signed-token' },
+ headers: { authorization: `Bearer ${token}` },
}),
{ id: 'ws-1', fileId: 'file-1' }
)
).rejects.toBeInstanceOf(InternalUnauthenticatedError)
})
+ it('does not render canonical-binding infrastructure failures as bad credentials', async () => {
+ const token = await generateInternalDelegationToken({
+ subjectUserId: 'user-1',
+ workflowId: 'workflow-1',
+ })
+ const infrastructureError = new Error('database unavailable')
+ mockBindDelegation.mockRejectedValue(infrastructureError)
+
+ await expect(
+ internalSessionOrExecutorAuth.authenticate(
+ new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', {
+ headers: { authorization: `Bearer ${token}` },
+ }),
+ { id: 'ws-1', fileId: 'file-1' }
+ )
+ ).rejects.toBe(infrastructureError)
+ })
+
it('preserves browser session principals when no service token is supplied', async () => {
mockGetSession.mockResolvedValue({
user: { id: 'user-1' },
@@ -62,7 +141,7 @@ describe('internal file route authentication', () => {
})
await expect(
- internalSessionOrServiceAuth.authenticate(
+ internalSessionOrExecutorAuth.authenticate(
new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1'),
{ id: 'ws-1', fileId: 'file-1' }
)
diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts
index c287586ddd2..6ec9c725b04 100644
--- a/apps/sim/lib/workspace-files/api/route-policies.ts
+++ b/apps/sim/lib/workspace-files/api/route-policies.ts
@@ -1,26 +1,18 @@
import {
- createInternalSessionOrServiceAuth,
+ createInternalSessionOrExecutorAuth,
type V2ErrorPolicy,
v2OrchestrationErrorPolicy,
} from '@/lib/api/server/routes'
-import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
+import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
-export const internalSessionOrServiceAuth = createInternalSessionOrServiceAuth(
- ({ subjectUserId, params }) => {
- const workspaceId = params.id
- if (typeof workspaceId !== 'string' || !workspaceId) {
- throw new Error('Internal file delegation requires a workspace route parameter')
- }
- return createWorkspaceFileDelegatedPrincipal({
- serviceId: 'executor',
- subjectUserId,
- workspaceId,
- delegationId: `internal-file:${subjectUserId}`,
- fileId: typeof params.fileId === 'string' ? params.fileId : undefined,
- })
- }
-)
+export const internalSessionOrExecutorAuth = createInternalSessionOrExecutorAuth({
+ audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,
+ resourceScope: (params) => {
+ const fileId = typeof params.fileId === 'string' ? params.fileId : undefined
+ return fileId ? { fileId } : undefined
+ },
+})
export const v2FileErrorPolicies = {
default: v2OrchestrationErrorPolicy,
diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts
index 62136ce8635..d87626272bd 100644
--- a/packages/auth/src/principal.ts
+++ b/packages/auth/src/principal.ts
@@ -39,6 +39,17 @@ export interface DelegatedPrincipal {
}
}
+export interface WorkflowExecutionDelegationContext {
+ kind: 'workflow_execution'
+ workflowId: string
+ executionId?: string
+}
+
+export type WorkflowExecutionDelegatedPrincipal = DelegatedPrincipal & {
+ serviceId: 'executor'
+ delegationContext: WorkflowExecutionDelegationContext
+}
+
export type PrincipalActor =
| { kind: 'session'; userId: string }
| { kind: 'personal_api_key'; keyId: string; userId: string }