From 5c9b105e9f5c7c682e1b9d0d39f832c3813ef6ff Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 16:51:02 -0700 Subject: [PATCH] feat(auth): centralize delegated identity policy --- apps/docs/openapi-v2-files-audit.json | 36 +++++++++- apps/sim/executor/utils/http.test.ts | 69 +++++++++++++++++++ apps/sim/executor/utils/http.ts | 22 +++++- apps/sim/lib/auth/principal.test.ts | 43 ++++++++++++ .../execute-workspace-use-case.test.ts | 1 + .../authorized-workspace-use-case.test.ts | 44 +++++++++++- apps/sim/lib/core/application/index.ts | 1 + .../application/workspace-authorization.ts | 16 +++++ .../application/workspace-operation.test.ts | 55 +++++++++++++++ .../core/application/workspace-operation.ts | 56 +++++++++++++-- .../custom-tools/application/authorization.ts | 3 +- .../custom-tools/application/operations.ts | 33 ++++----- .../lib/custom-tools/application/use-cases.ts | 18 ++--- .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 30 ++++---- apps/sim/lib/mcp/application/authorization.ts | 3 +- apps/sim/lib/mcp/application/operations.ts | 24 +++---- .../lib/skills/application/authorization.ts | 3 +- apps/sim/lib/skills/application/operations.ts | 27 ++++---- apps/sim/lib/skills/application/use-cases.ts | 12 ++-- .../lib/table/application/operations.test.ts | 6 ++ apps/sim/lib/table/application/operations.ts | 14 ++-- .../lib/workflows/application/operations.ts | 57 +++++++-------- .../application/authorization.test.ts | 30 ++++++++ .../application/operations.test.ts | 18 +++++ .../workspace-files/application/operations.ts | 63 +++++++++-------- .../application/share-workspace-file.ts | 4 +- packages/auth/src/principal.ts | 22 ++++++ 28 files changed, 553 insertions(+), 159 deletions(-) create mode 100644 apps/sim/executor/utils/http.test.ts create mode 100644 apps/sim/lib/core/application/workspace-operation.test.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index a2925d24e50..c3cf92abbce 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -747,7 +747,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" + "$ref": "#/components/schemas/V2FileMetadataResponse" } } } @@ -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.", diff --git a/apps/sim/executor/utils/http.test.ts b/apps/sim/executor/utils/http.test.ts new file mode 100644 index 00000000000..811a77e60f3 --- /dev/null +++ b/apps/sim/executor/utils/http.test.ts @@ -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() + }) +}) diff --git a/apps/sim/executor/utils/http.ts b/apps/sim/executor/utils/http.ts index 57ea632a41b..0d74d422268 100644 --- a/apps/sim/executor/utils/http.ts +++ b/apps/sim/executor/utils/http.ts @@ -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> { const headers: Record = { 'Content-Type': HTTP.CONTENT_TYPE.JSON, @@ -15,6 +20,21 @@ export async function buildAuthHeaders(userId?: string): Promise> { + 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): URL { const baseUrl = path.startsWith('/api/') ? getInternalApiBaseUrl() : getBaseUrl() const url = new URL(path, baseUrl) diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index 27248919ee8..d440b02be05 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -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( diff --git a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts index e2aaa045163..5e18cd751a7 100644 --- a/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts +++ b/apps/sim/lib/copilot/application/execute-workspace-use-case.test.ts @@ -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', () => { diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index 6c72bb0d3e5..aa2f4e5e494 100644 --- a/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts @@ -47,6 +47,7 @@ const delegatedOperation = defineWorkspaceOperation({ minimumRole: 'read', workspaceApiKey: 'deny', principalKinds: ['delegated'], + delegatedServices: ['executor'], }) const workspaceKeyOperation = defineWorkspaceOperation({ @@ -222,7 +223,8 @@ describe('defineAuthorizedWorkspaceUseCase', () => { resolveContext: async (_args: { principal: DelegatedPrincipal; input: TestInput }) => canonicalContext, authorizationOptions: ({ principal }) => { - expectTypeOf(principal).toEqualTypeOf() + expectTypeOf(principal).toMatchTypeOf() + expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>() return { delegation: { audience: 'test:files', @@ -231,7 +233,8 @@ describe('defineAuthorizedWorkspaceUseCase', () => { } }, async execute({ principal }) { - expectTypeOf(principal).toEqualTypeOf() + expectTypeOf(principal).toMatchTypeOf() + expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>() return { ok: true as const } }, }) @@ -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>({ + 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, diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index ea8138bf790..9a32e0a7a94 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -16,6 +16,7 @@ export type { } from '@/lib/core/application/workspace-authorization' export { authorizeWorkspaceOperation, + DelegatedServiceAuthorizationError, DelegatedWorkspaceAuthorizationError, InsufficientWorkspacePermissionsError, PersonalApiKeysDisabledError, diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index b511270f1f9..0d12773d3fb 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -63,6 +63,13 @@ 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( principal: Principal, operation: O @@ -70,6 +77,15 @@ export function requireAllowedWorkspacePrincipal( 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 { diff --git a/apps/sim/lib/core/application/workspace-operation.test.ts b/apps/sim/lib/core/application/workspace-operation.test.ts new file mode 100644 index 00000000000..20e5ed02822 --- /dev/null +++ b/apps/sim/lib/core/application/workspace-operation.test.ts @@ -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') + }) +}) diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index ec6d731adfb..aebf094713e 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import type { DelegatedPrincipal, DelegatedServiceId, Principal } from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import type { ApplicationOperation } from '@/lib/core/application/operation' @@ -6,17 +6,36 @@ type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny export type PrincipalKind = Principal['kind'] -export type PrincipalForOperation = - Extract +type NonDelegatedPrincipalForOperation< + O extends { readonly principalKinds: readonly PrincipalKind[] }, +> = Exclude, DelegatedPrincipal> + +type DelegatedPrincipalForOperation< + O extends { + readonly principalKinds: readonly PrincipalKind[] + readonly delegatedServices?: readonly DelegatedServiceId[] + }, +> = 'delegated' extends O['principalKinds'][number] + ? DelegatedPrincipal & { serviceId: NonNullable[number] } + : never + +export type PrincipalForOperation< + O extends { + readonly principalKinds: readonly PrincipalKind[] + readonly delegatedServices?: readonly DelegatedServiceId[] + }, +> = NonDelegatedPrincipalForOperation | DelegatedPrincipalForOperation export interface WorkspaceOperation< Id extends string = string, Role extends PermissionType = PermissionType, PrincipalKinds extends readonly PrincipalKind[] = readonly PrincipalKind[], + DelegatedServices extends readonly DelegatedServiceId[] = readonly DelegatedServiceId[], > extends ApplicationOperation { readonly minimumRole: Role readonly workspaceApiKey: WorkspaceApiKeyPolicy readonly principalKinds: PrincipalKinds + readonly delegatedServices?: DelegatedServices } type WorkspaceApiKeyPrincipalConsistency< @@ -26,14 +45,26 @@ type WorkspaceApiKeyPrincipalConsistency< ? { readonly workspaceApiKey: Role extends 'admin' ? never : 'allow' } : { readonly workspaceApiKey: 'deny' } +type DelegatedPrincipalConsistency< + PrincipalKinds extends readonly PrincipalKind[], + DelegatedServices extends readonly DelegatedServiceId[], +> = 'delegated' extends PrincipalKinds[number] + ? { + readonly delegatedServices: DelegatedServices extends readonly [] ? never : DelegatedServices + } + : { readonly delegatedServices?: never } + export function defineWorkspaceOperation< const Id extends string, const Role extends PermissionType, const PrincipalKinds extends readonly PrincipalKind[], + const DelegatedServices extends readonly DelegatedServiceId[] = readonly [], >( - operation: WorkspaceOperation & - WorkspaceApiKeyPrincipalConsistency -): WorkspaceOperation { + operation: WorkspaceOperation & + WorkspaceApiKeyPrincipalConsistency & + DelegatedPrincipalConsistency +): WorkspaceOperation & + DelegatedPrincipalConsistency { if (operation.principalKinds.length === 0) { throw new Error(`Operation ${operation.id} must allow at least one principal kind`) } @@ -49,6 +80,17 @@ export function defineWorkspaceOperation< throw new Error(`Operation ${operation.id} exceeds the workspace API key write ceiling`) } + const allowsDelegatedPrincipal = operation.principalKinds.includes('delegated') + const delegatedServices = operation.delegatedServices ?? [] + if (allowsDelegatedPrincipal !== delegatedServices.length > 0) { + throw new Error(`Operation ${operation.id} has inconsistent delegated service policy`) + } + if (new Set(delegatedServices).size !== delegatedServices.length) { + throw new Error(`Operation ${operation.id} declares duplicate delegated services`) + } + Object.freeze(operation.principalKinds) - return Object.freeze(operation) + if (operation.delegatedServices) Object.freeze(operation.delegatedServices) + Object.freeze(operation) + return operation } diff --git a/apps/sim/lib/custom-tools/application/authorization.ts b/apps/sim/lib/custom-tools/application/authorization.ts index 84e65bdf921..4dd7b31f2ce 100644 --- a/apps/sim/lib/custom-tools/application/authorization.ts +++ b/apps/sim/lib/custom-tools/application/authorization.ts @@ -1,11 +1,10 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' export const CUSTOM_TOOL_DELEGATION_AUDIENCE = 'sim:custom-tools' export const customToolDelegationPolicy = { audience: CUSTOM_TOOL_DELEGATION_AUDIENCE, - isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', + isWithinScope: () => true, } as const satisfies WorkspaceDelegationPolicy<{ workspaceId: string workspaceOrganizationId: string | null diff --git a/apps/sim/lib/custom-tools/application/operations.ts b/apps/sim/lib/custom-tools/application/operations.ts index eff480f5782..2bee97b7cf8 100644 --- a/apps/sim/lib/custom-tools/application/operations.ts +++ b/apps/sim/lib/custom-tools/application/operations.ts @@ -1,67 +1,68 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const -const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const +const HUMAN_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const customToolOperations = { list: defineWorkspaceOperation({ id: 'custom_tools.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'custom_tools.list_available', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'custom_tools.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'custom_tools.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), save: defineWorkspaceOperation({ id: 'custom_tools.save', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'custom_tools.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), updateAvailable: defineWorkspaceOperation({ id: 'custom_tools.update_available', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'custom_tools.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), deleteAvailable: defineWorkspaceOperation({ id: 'custom_tools.delete_available', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/custom-tools/application/use-cases.ts b/apps/sim/lib/custom-tools/application/use-cases.ts index 71a58139e83..853e7662b4b 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.ts @@ -1,5 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { + type Principal, + requirePrincipalSubjectUserId, + resolvePrincipalAttribution, +} from '@sim/auth/principal' import type { customTools } from '@sim/db/schema' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import type { ListSortOrder } from '@/lib/api/list-query' @@ -52,10 +56,6 @@ async function resolveWorkspaceToolContext( return { ...workspace, tool } } -function humanUserId(principal: Exclude): string { - return principal.kind === 'delegated' ? principal.subjectUserId : principal.userId -} - async function resolveAvailableToolContext(args: { principal: Exclude workspaceId: string @@ -64,7 +64,7 @@ async function resolveAvailableToolContext(args: { const workspace = await resolveWorkspaceContext(args.workspaceId) const tool = await getCustomToolById({ toolId: args.toolId, - userId: humanUserId(args.principal), + userId: requirePrincipalSubjectUserId(args.principal), workspaceId: workspace.workspaceId, }) if (!tool) throw new OrchestrationError('not_found', 'Custom tool not found') @@ -116,7 +116,7 @@ export const listAvailableCustomToolsUseCase = defineAuthorizedWorkspaceUseCase( authorizationOptions, async execute({ principal, context }) { const tools = await listCustomTools({ - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), workspaceId: context.workspaceId, }) return { tools } @@ -300,7 +300,7 @@ export const updateAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase const tool = await updateCustomTool({ workspaceId: context.workspaceId, toolId: context.tool.id, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), title, schema: input.schema ?? context.tool.schema, code: input.code ?? context.tool.code, @@ -369,7 +369,7 @@ export const deleteAvailableCustomToolUseCase = defineAuthorizedWorkspaceUseCase const deleted = await deleteCustomTool({ workspaceId: context.workspaceId, toolId: context.tool.id, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), }) if (!deleted) throw new OrchestrationError('not_found', 'Custom tool not found') return { tool: context.tool } diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 49b12c42476..c8d39ec283b 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -46,5 +46,7 @@ describe('knowledge operation registry', () => { expect(knowledgeOperations.uploadDocument.principalKinds).toContain('delegated') expect(knowledgeOperations.listFolders.principalKinds).not.toContain('delegated') expect(knowledgeOperations.uploadComplete.principalKinds).not.toContain('delegated') + expect(knowledgeOperations.list.delegatedServices).toEqual(['copilot']) + expect(knowledgeOperations.uploadComplete.delegatedServices).toBeUndefined() }) }) diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 199ab6931d1..dda120bb467 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -1,11 +1,9 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const const HTTP_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'workspace_api_key'] as const @@ -14,37 +12,37 @@ export const knowledgeOperations = { id: 'knowledge.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'knowledge.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'knowledge.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'knowledge.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'knowledge.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), search: defineWorkspaceOperation({ id: 'knowledge.search', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'knowledge.folders.list', @@ -74,25 +72,25 @@ export const knowledgeOperations = { id: 'knowledge.documents.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), readDocument: defineWorkspaceOperation({ id: 'knowledge.documents.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), uploadDocument: defineWorkspaceOperation({ id: 'knowledge.documents.upload', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), deleteDocument: defineWorkspaceOperation({ id: 'knowledge.documents.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'knowledge.documents.upload.create', diff --git a/apps/sim/lib/mcp/application/authorization.ts b/apps/sim/lib/mcp/application/authorization.ts index bd3c575a4d1..3e854da883b 100644 --- a/apps/sim/lib/mcp/application/authorization.ts +++ b/apps/sim/lib/mcp/application/authorization.ts @@ -1,11 +1,10 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' export const MCP_SERVER_DELEGATION_AUDIENCE = 'sim:mcp-servers' export const mcpServerDelegationPolicy = { audience: MCP_SERVER_DELEGATION_AUDIENCE, - isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', + isWithinScope: () => true, } as const satisfies WorkspaceDelegationPolicy<{ workspaceId: string workspaceOrganizationId: string | null diff --git a/apps/sim/lib/mcp/application/operations.ts b/apps/sim/lib/mcp/application/operations.ts index b0a6dcbc28f..e615dcfe77a 100644 --- a/apps/sim/lib/mcp/application/operations.ts +++ b/apps/sim/lib/mcp/application/operations.ts @@ -1,54 +1,52 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const mcpServerOperations = { list: defineWorkspaceOperation({ id: 'mcp_servers.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'mcp_servers.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'mcp_servers.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), register: defineWorkspaceOperation({ id: 'mcp_servers.register', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'mcp_servers.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), reconfigure: defineWorkspaceOperation({ id: 'mcp_servers.reconfigure', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'mcp_servers.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/skills/application/authorization.ts b/apps/sim/lib/skills/application/authorization.ts index 97aea84b6da..057af302526 100644 --- a/apps/sim/lib/skills/application/authorization.ts +++ b/apps/sim/lib/skills/application/authorization.ts @@ -1,11 +1,10 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' export const SKILL_DELEGATION_AUDIENCE = 'sim:skills' export const skillDelegationPolicy = { audience: SKILL_DELEGATION_AUDIENCE, - isWithinScope: (principal: DelegatedPrincipal) => principal.serviceId === 'copilot', + isWithinScope: () => true, } as const satisfies WorkspaceDelegationPolicy<{ workspaceId: string workspaceOrganizationId: string | null diff --git a/apps/sim/lib/skills/application/operations.ts b/apps/sim/lib/skills/application/operations.ts index b19065816be..dc0ecbebd90 100644 --- a/apps/sim/lib/skills/application/operations.ts +++ b/apps/sim/lib/skills/application/operations.ts @@ -1,49 +1,50 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const -const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const +const HUMAN_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const skillOperations = { list: defineWorkspaceOperation({ id: 'skills.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), listAvailable: defineWorkspaceOperation({ id: 'skills.list_available', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'skills.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'skills.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'skills.update', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'skills.delete', minimumRole: 'read', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/skills/application/use-cases.ts b/apps/sim/lib/skills/application/use-cases.ts index 89899bb5bcc..73fbf9130dc 100644 --- a/apps/sim/lib/skills/application/use-cases.ts +++ b/apps/sim/lib/skills/application/use-cases.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId, resolvePrincipalAttribution } from '@sim/auth/principal' import type { skill } from '@sim/db/schema' import type { ListSortOrder } from '@/lib/api/list-query' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' @@ -42,10 +42,6 @@ async function resolveSkillContext(workspaceId: string, skillId: string): Promis return { ...workspace, skill: row } } -function humanUserId(principal: Exclude): string { - return principal.kind === 'delegated' ? principal.subjectUserId : principal.userId -} - const authorizationOptions = { delegation: skillDelegationPolicy } export interface ListSkillsInput { @@ -82,7 +78,7 @@ export const listAvailableSkillsUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ principal, context }) { const skills = await listSkillsForUser({ workspaceId: context.workspaceId, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), }) return { skills } }, @@ -156,7 +152,7 @@ export const updateSkillUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ principal, input, context }) { const row = await updateSkill({ workspaceId: context.workspaceId, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), skillId: context.skill.id, name: input.name, description: input.description, @@ -188,7 +184,7 @@ export const deleteSkillUseCase = defineAuthorizedWorkspaceUseCase({ async execute({ principal, context }) { const row = await deleteSkillRecord({ workspaceId: context.workspaceId, - userId: humanUserId(principal), + userId: requirePrincipalSubjectUserId(principal), skillId: context.skill.id, }) return { skill: row } diff --git a/apps/sim/lib/table/application/operations.test.ts b/apps/sim/lib/table/application/operations.test.ts index e792a611fb3..07722033747 100644 --- a/apps/sim/lib/table/application/operations.test.ts +++ b/apps/sim/lib/table/application/operations.test.ts @@ -52,4 +52,10 @@ describe('table operation registry', () => { expect(tableOperations.createExport.minimumRole).toBe('read') expect(tableOperations.cancelExport.minimumRole).toBe('read') }) + + it('keeps delegated table operations Copilot-only', () => { + for (const operation of Object.values(tableOperations)) { + expect(operation.delegatedServices).toEqual(['copilot']) + } + }) }) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index 9a8b30915da..8662437787f 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -1,18 +1,16 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const function readOperation(id: Id) { return defineWorkspaceOperation({ id, minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }) } @@ -21,7 +19,7 @@ function writeOperation(id: Id) { id, minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_PRINCIPAL_POLICY, }) } diff --git a/apps/sim/lib/workflows/application/operations.ts b/apps/sim/lib/workflows/application/operations.ts index 08f6399352e..8150e81a21d 100644 --- a/apps/sim/lib/workflows/application/operations.ts +++ b/apps/sim/lib/workflows/application/operations.ts @@ -1,140 +1,141 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_WORKFLOW_PRINCIPALS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const +const ALL_WORKFLOW_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const -const HUMAN_WORKFLOW_PRINCIPALS = ['session', 'personal_api_key', 'delegated'] as const +const HUMAN_WORKFLOW_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const export const workflowOperations = { list: defineWorkspaceOperation({ id: 'workflows.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), read: defineWorkspaceOperation({ id: 'workflows.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), create: defineWorkspaceOperation({ id: 'workflows.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), update: defineWorkspaceOperation({ id: 'workflows.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'workflows.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'workflows.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), createFolder: defineWorkspaceOperation({ id: 'workflows.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), relocateFolder: defineWorkspaceOperation({ id: 'workflows.folders.relocate', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), deleteFolder: defineWorkspaceOperation({ id: 'workflows.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), deploy: defineWorkspaceOperation({ id: 'workflows.deploy', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), undeploy: defineWorkspaceOperation({ id: 'workflows.undeploy', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), activateVersion: defineWorkspaceOperation({ id: 'workflows.versions.activate', minimumRole: 'admin', workspaceApiKey: 'deny', - principalKinds: HUMAN_WORKFLOW_PRINCIPALS, + ...HUMAN_WORKFLOW_PRINCIPAL_POLICY, }), listVersions: defineWorkspaceOperation({ id: 'workflows.versions.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), readVersion: defineWorkspaceOperation({ id: 'workflows.versions.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), export: defineWorkspaceOperation({ id: 'workflows.export', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), import: defineWorkspaceOperation({ id: 'workflows.import', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), execute: defineWorkspaceOperation({ id: 'workflows.execute', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), listRuns: defineWorkspaceOperation({ id: 'workflows.runs.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), readRun: defineWorkspaceOperation({ id: 'workflows.runs.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), cancelRun: defineWorkspaceOperation({ id: 'workflows.runs.cancel', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), resumeRun: defineWorkspaceOperation({ id: 'workflows.runs.resume', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_WORKFLOW_PRINCIPALS, + ...ALL_WORKFLOW_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts index 524fd4aed21..1ae557d8895 100644 --- a/apps/sim/lib/workspace-files/application/authorization.test.ts +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -126,6 +126,36 @@ describe('file operation authorization', () => { ) }) + it('admits executor delegation only for explicitly declared file-tool operations', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'executor' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-1', executionId: 'execution-1' }, + } + + await authorizeWorkspaceFileAccess( + principal, + fileOperations.updateContent, + authorizationContext + ) + expect(resolvePermission).toHaveBeenCalledTimes(1) + + resolvePermission.mockClear() + await expect( + authorizeWorkspaceFileAccess(principal, fileOperations.rename, authorizationContext) + ).rejects.toMatchObject>({ + code: 'forbidden', + message: 'Delegated service executor cannot perform operation files.rename', + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + it('rejects expired or wrong-file delegations before permission lookup', async () => { const base = { kind: 'delegated' as const, diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index 82b5bdbd55c..858b83c2f63 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -36,6 +36,23 @@ describe('file operation registry', () => { expect(new Set(ids).size).toBe(ids.length) }) + it('allows executor delegation only for operations used by the internal file tool', () => { + const executorOperationIds = Object.values(fileOperations) + .filter((operation) => operation.delegatedServices?.includes('executor')) + .map((operation) => operation.id) + + expect(executorOperationIds).toEqual([ + 'files.read_metadata', + 'files.read_content', + 'files.download', + 'files.create', + 'files.update_content', + 'files.move', + 'files.share.update', + 'files.folders.create', + ]) + }) + it('keeps external sharing policy changes human-delegated', () => { expect(fileOperations.updateShare.workspaceApiKey).toBe('deny') expect(fileOperations.updateShare.principalKinds).toEqual([ @@ -43,6 +60,7 @@ describe('file operation registry', () => { 'personal_api_key', 'delegated', ]) + expect(fileOperations.updateShare.delegatedServices).toEqual(['copilot', 'executor']) }) it('restricts compiled checks to authenticated sessions', () => { diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index cd4f6179b23..dba3e7da931 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -1,37 +1,42 @@ import { defineWorkspaceOperation } from '@/lib/core/application' -const ALL_PRINCIPAL_KINDS = [ - 'session', - 'personal_api_key', - 'workspace_api_key', - 'delegated', -] as const -const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const +const ALL_COPILOT_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot'], +} as const +const ALL_FILE_TOOL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const +const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = { + principalKinds: ['session', 'personal_api_key', 'delegated'], + delegatedServices: ['copilot', 'executor'], +} as const export const fileOperations = { list: defineWorkspaceOperation({ id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), readMetadata: defineWorkspaceOperation({ id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), readContent: defineWorkspaceOperation({ id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), download: defineWorkspaceOperation({ id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), compiledCheck: defineWorkspaceOperation({ id: 'files.compiled_check', @@ -43,109 +48,109 @@ export const fileOperations = { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), rename: defineWorkspaceOperation({ id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), updateContent: defineWorkspaceOperation({ id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateMetadata: defineWorkspaceOperation({ id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), move: defineWorkspaceOperation({ id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), delete: defineWorkspaceOperation({ id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), restore: defineWorkspaceOperation({ id: 'files.restore', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), readShare: defineWorkspaceOperation({ id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), updateShare: defineWorkspaceOperation({ id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'deny', - principalKinds: HUMAN_PRINCIPAL_KINDS, + ...HUMAN_FILE_TOOL_PRINCIPAL_POLICY, }), listFolders: defineWorkspaceOperation({ id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), createFolder: defineWorkspaceOperation({ id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_FILE_TOOL_PRINCIPAL_POLICY, }), updateFolder: defineWorkspaceOperation({ id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), deleteFolder: defineWorkspaceOperation({ id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), restoreFolder: defineWorkspaceOperation({ id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadCreate: defineWorkspaceOperation({ id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadParts: defineWorkspaceOperation({ id: 'files.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadComplete: defineWorkspaceOperation({ id: 'files.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), uploadCancel: defineWorkspaceOperation({ id: 'files.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', - principalKinds: ALL_PRINCIPAL_KINDS, + ...ALL_COPILOT_PRINCIPAL_POLICY, }), } as const diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts index 599d3c3ed09..051957f390c 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -71,7 +71,7 @@ export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ return { ...canonical, file } }, async execute({ principal, input, context }): Promise { - const subjectUserId = resolvePrincipalAttribution(principal).attributedUserId + const subjectUserId = requirePrincipalSubjectUserId(principal) const existingShare = await getShareForResource('file', context.fileId) if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index d87626272bd..114fe2a0663 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -39,6 +39,28 @@ export interface DelegatedPrincipal { } } +export type DelegatedServiceId = DelegatedPrincipal['serviceId'] + +export class PrincipalSubjectUserRequiredError extends Error { + constructor(principalKind: Principal['kind']) { + super(`Principal kind ${principalKind} does not represent a human subject`) + this.name = 'PrincipalSubjectUserRequiredError' + } +} + +/** Resolves the real human subject represented by a principal or fails fast. */ +export function requirePrincipalSubjectUserId(principal: Principal): string { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'delegated': + return principal.subjectUserId + case 'workspace_api_key': + throw new PrincipalSubjectUserRequiredError(principal.kind) + } +} + export interface WorkflowExecutionDelegationContext { kind: 'workflow_execution' workflowId: string