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
36 changes: 35 additions & 1 deletion apps/docs/openapi-v2-files-audit.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -747,7 +747,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/V2FileResponse"
"$ref": "#/components/schemas/V2FileMetadataResponse"
}
}
}
Expand DownExpand Up@@ -2196,6 +2196,40 @@
}
}
},
"V2FileMetadata": {
"allOf": [
{
"$ref": "#/components/schemas/V2File"
},
{
"type": "object",
"required": ["share"],
"properties": {
"share": {
"oneOf": [
{
"$ref": "#/components/schemas/V2FileShare"
},
{
"type": "null"
}
],
"description": "The file's public share state, or null when the file has never been shared."
}
}
}
]
},
"V2FileMetadataResponse": {
"type": "object",
"description": "A single file resource with its public share state.",
"required": ["data"],
"properties": {
"data": {
"$ref": "#/components/schemas/V2FileMetadata"
}
}
},
"V2DeleteFileResponse": {
"type": "object",
"description": "The result of archiving a file.",
Expand Down
69 changes: 69 additions & 0 deletions apps/sim/executor/utils/http.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
generateInternalDelegationToken: vi.fn(),
generateInternalToken: vi.fn(),
}))

vi.mock('@/lib/auth/internal', () => ({
generateInternalDelegationToken: mocks.generateInternalDelegationToken,
generateInternalToken: mocks.generateInternalToken,
}))

import { buildAuthHeaders, buildExecutorDelegationHeaders } from '@/executor/utils/http'

describe('executor HTTP authentication headers', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.generateInternalDelegationToken.mockResolvedValue('delegation-token')
mocks.generateInternalToken.mockResolvedValue('legacy-token')
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('issues a workflow-scoped executor delegation', async () => {
await expect(
buildExecutorDelegationHeaders({
subjectUserId: 'user-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
).resolves.toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer delegation-token',
})

expect(mocks.generateInternalDelegationToken).toHaveBeenCalledWith({
subjectUserId: 'user-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
})
expect(mocks.generateInternalToken).not.toHaveBeenCalled()
})

it('fails instead of issuing trusted delegation headers in a browser', async () => {
vi.stubGlobal('window', {})

await expect(
buildExecutorDelegationHeaders({
subjectUserId: 'user-1',
workflowId: 'workflow-1',
})
).rejects.toThrow('Executor delegation headers can only be created on the server')
expect(mocks.generateInternalDelegationToken).not.toHaveBeenCalled()
})

it('keeps the legacy helper separate during endpoint migration', async () => {
await expect(buildAuthHeaders('user-1')).resolves.toEqual({
'Content-Type': 'application/json',
Authorization: 'Bearer legacy-token',
})
expect(mocks.generateInternalToken).toHaveBeenCalledWith('user-1')
expect(mocks.generateInternalDelegationToken).not.toHaveBeenCalled()
})
})
22 changes: 21 additions & 1 deletion apps/sim/executor/utils/http.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
import { generateInternalToken } from '@/lib/auth/internal'
import {
type GenerateInternalDelegationTokenInput,
generateInternalDelegationToken,
generateInternalToken,
} from '@/lib/auth/internal'
import { getBaseUrl, getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { HTTP } from '@/executor/constants'

/** @deprecated Use `buildExecutorDelegationHeaders` for protected application routes. */
export async function buildAuthHeaders(userId?: string): Promise<Record<string, string>> {
const headers: Record<string, string> = {
'Content-Type': HTTP.CONTENT_TYPE.JSON,
Expand All@@ -15,6 +20,21 @@ export async function buildAuthHeaders(userId?: string): Promise<Record<string,
return headers
}

/** Builds server-only headers for an executor call bound to its workflow execution origin. */
export async function buildExecutorDelegationHeaders(
input: GenerateInternalDelegationTokenInput
): Promise<Record<string, string>> {
if (typeof window !== 'undefined') {
throw new Error('Executor delegation headers can only be created on the server')
}

const token = await generateInternalDelegationToken(input)
return {
'Content-Type': HTTP.CONTENT_TYPE.JSON,
Authorization: `Bearer ${token}`,
}
}

export function buildAPIUrl(path: string, params?: Record<string, string>): URL {
const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl()
const url = new URL(path, baseUrl)
Expand Down
43 changes: 43 additions & 0 deletions apps/sim/lib/auth/principal.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,12 +2,55 @@
* @vitest-environment node
*/
import {
PrincipalSubjectUserRequiredError,
requirePrincipalSubjectUserId,
resolvePrincipalAttribution,
resolvePrincipalAuditAttribution,
toPrincipalActor,
} from '@sim/auth/principal'
import { describe, expect, it } from 'vitest'

describe('principal subject users', () => {
it('resolves the human subject represented by user-backed principals', () => {
expect(
requirePrincipalSubjectUserId({
kind: 'session',
userId: 'session-user',
sessionId: 'session-1',
})
).toBe('session-user')
expect(
requirePrincipalSubjectUserId({
kind: 'personal_api_key',
userId: 'key-user',
keyId: 'key-1',
})
).toBe('key-user')
expect(
requirePrincipalSubjectUserId({
kind: 'delegated',
serviceId: 'copilot',
subjectUserId: 'delegated-user',
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: 'sim:test',
issuedAt: new Date('2026-01-01T00:00:00Z'),
expiresAt: new Date('2026-01-01T00:05:00Z'),
})
).toBe('delegated-user')
})

it('fails fast instead of fabricating a workspace-key subject', () => {
expect(() =>
requirePrincipalSubjectUserId({
kind: 'workspace_api_key',
keyId: 'key-1',
workspaceId: 'workspace-1',
})
).toThrow(PrincipalSubjectUserRequiredError)
})
})

describe('principal actors', () => {
it('maps every principal to an audit actor without billing-owner substitution', () => {
expect(
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -9,6 +9,7 @@ const operation = {
minimumRole: 'read' as const,
workspaceApiKey: 'deny' as const,
principalKinds: ['delegated'] as const,
delegatedServices: ['copilot'] as const,
}

describe('Copilot workspace application delegation', () => {
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -47,6 +47,7 @@ const delegatedOperation = defineWorkspaceOperation({
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
delegatedServices: ['executor'],
})

const workspaceKeyOperation = defineWorkspaceOperation({
Expand DownExpand Up@@ -222,7 +223,8 @@ describe('defineAuthorizedWorkspaceUseCase', () => {
resolveContext: async (_args: { principal: DelegatedPrincipal; input: TestInput }) =>
canonicalContext,
authorizationOptions: ({ principal }) => {
expectTypeOf(principal).toEqualTypeOf<DelegatedPrincipal>()
expectTypeOf(principal).toMatchTypeOf<DelegatedPrincipal>()
expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>()
return {
delegation: {
audience: 'test:files',
Expand All@@ -231,7 +233,8 @@ describe('defineAuthorizedWorkspaceUseCase', () => {
}
},
async execute({ principal }) {
expectTypeOf(principal).toEqualTypeOf<DelegatedPrincipal>()
expectTypeOf(principal).toMatchTypeOf<DelegatedPrincipal>()
expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>()
return { ok: true as const }
},
})
Expand DownExpand Up@@ -260,6 +263,43 @@ describe('defineAuthorizedWorkspaceUseCase', () => {
)
})

it('rejects a disallowed delegated service before canonical loading', async () => {
const resolveContext = vi.fn(
async (_args: { principal: DelegatedPrincipal; input: TestInput }) => canonicalContext
)
const useCase = defineAuthorizedWorkspaceUseCase({
operation: delegatedOperation,
resolveContext,
authorizationOptions: {
delegation: { audience: 'test:files', isWithinScope: () => true },
},
async execute() {
return { ok: true as const }
},
})

await expect(
useCase.execute({
principal: {
kind: 'delegated',
serviceId: 'copilot',
subjectUserId: 'user-1',
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: 'test:files',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
},
input: { resourceId: 'resource-1' },
})
).rejects.toMatchObject<Partial<OrchestrationError>>({
code: 'forbidden',
message: 'Delegated service copilot cannot perform operation test.delegated_read',
})
expect(resolveContext).not.toHaveBeenCalled()
expect(mocks.resolvePermission).not.toHaveBeenCalled()
})

it('records workspace API keys as non-human audit actors', async () => {
const useCase = defineAuthorizedWorkspaceUseCase({
operation: workspaceKeyOperation,
Expand Down
1 change: 1 addition & 0 deletions apps/sim/lib/core/application/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,6 +16,7 @@ export type {
} from '@/lib/core/application/workspace-authorization'
export {
authorizeWorkspaceOperation,
DelegatedServiceAuthorizationError,
DelegatedWorkspaceAuthorizationError,
InsufficientWorkspacePermissionsError,
PersonalApiKeysDisabledError,
Expand Down
16 changes: 16 additions & 0 deletions apps/sim/lib/core/application/workspace-authorization.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -63,13 +63,29 @@ export class PrincipalKindAuthorizationError extends OrchestrationError {
}
}

export class DelegatedServiceAuthorizationError extends OrchestrationError {
constructor(serviceId: DelegatedPrincipal['serviceId'], operationId: string) {
super('forbidden', `Delegated service ${serviceId} cannot perform operation ${operationId}`)
this.name = 'DelegatedServiceAuthorizationError'
}
}

export function requireAllowedWorkspacePrincipal<O extends WorkspaceOperation>(
principal: Principal,
operation: O
): asserts principal is PrincipalForOperation<O> {
if (!operation.principalKinds.some((kind) => kind === principal.kind)) {
throw new PrincipalKindAuthorizationError(principal.kind, operation.id)
}
if (principal.kind !== 'delegated') return

const delegatedServices = operation.delegatedServices
if (!delegatedServices?.length) {
throw new Error(`Operation ${operation.id} is missing its delegated service policy`)
}
if (!delegatedServices.some((serviceId) => serviceId === principal.serviceId)) {
throw new DelegatedServiceAuthorizationError(principal.serviceId, operation.id)
}
}

function requirePermission(permission: PermissionType | null, required: PermissionType): void {
Expand Down
55 changes: 55 additions & 0 deletions apps/sim/lib/core/application/workspace-operation.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation'

describe('defineWorkspaceOperation delegated service policy', () => {
it('preserves and freezes an explicit delegated service allowlist', () => {
const operation = defineWorkspaceOperation({
id: 'test.read',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
delegatedServices: ['copilot', 'executor'],
})

expect(operation.delegatedServices).toEqual(['copilot', 'executor'])
expect(Object.isFrozen(operation.delegatedServices)).toBe(true)
})

it('fails fast when delegated principals have no service policy', () => {
expect(() =>
defineWorkspaceOperation({
id: 'test.missing_service_policy',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
} as never)
).toThrow('Operation test.missing_service_policy has inconsistent delegated service policy')
})

it('fails fast when a non-delegated operation declares delegated services', () => {
expect(() =>
defineWorkspaceOperation({
id: 'test.unused_service_policy',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['session'],
delegatedServices: ['copilot'],
} as never)
).toThrow('Operation test.unused_service_policy has inconsistent delegated service policy')
})

it('fails fast for duplicate delegated services', () => {
expect(() =>
defineWorkspaceOperation({
id: 'test.duplicate_service_policy',
minimumRole: 'read',
workspaceApiKey: 'deny',
principalKinds: ['delegated'],
delegatedServices: ['copilot', 'copilot'],
} as never)
).toThrow('Operation test.duplicate_service_policy declares duplicate delegated services')
})
})
Loading
Loading