diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 831ad619e32..bfd97cd4164 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -1,4 +1,9 @@ import type { Principal } from '@sim/auth/principal' +import { + isWorkspaceDelegation, + principalWorkspaceId, + workspaceDelegationRestrictions, +} from '@sim/auth/principal' import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' @@ -250,11 +255,6 @@ function requireSessionScope(value: string | null, label = 'scope'): string { async function principalUserId(principal: Principal, workspaceId?: string): Promise { switch (principal.kind) { - case 'resource_delegated': - throw new UploadSessionError( - 'forbidden', - 'Resource delegation cannot create workspace uploads' - ) case 'slack_app': case 'slack_installation': throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads') @@ -263,7 +263,7 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom case 'oauth_access_token': return principal.userId case 'workspace_api_key': - if (!workspaceId || principal.workspaceId !== workspaceId) { + if (!workspaceId || principalWorkspaceId(principal) !== workspaceId) { throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') } { @@ -274,12 +274,17 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom return context.billedAccountUserId } case 'delegated': + if (!isWorkspaceDelegation(principal)) + throw new UploadSessionError( + 'forbidden', + 'Resource delegation cannot create workspace uploads' + ) if ( principal.serviceId === 'copilot' && - principal.workspaceId === workspaceId && + principalWorkspaceId(principal) === workspaceId && principal.audience === WORKSPACE_FILES_DELEGATION_AUDIENCE && principal.subjectUserId && - principal.resourceScope?.chatId && + workspaceDelegationRestrictions(principal)?.chatId && principal.expiresAt > new Date() ) return principal.subjectUserId diff --git a/apps/sim/app/api/table/route.ts b/apps/sim/app/api/table/route.ts index b7ba0880be1..5c41b8cb64b 100644 --- a/apps/sim/app/api/table/route.ts +++ b/apps/sim/app/api/table/route.ts @@ -1,4 +1,4 @@ -import { resolvePrincipalSubject } from '@sim/auth/principal' +import { principalWorkspaceId, resolvePrincipalSubject } from '@sim/auth/principal' import { createTableContract, listTablesContract } from '@/lib/api/contracts/tables' import { defineInternalJsonRoute, @@ -33,7 +33,8 @@ export const POST = defineInternalJsonRoute({ rateLimit, errorPolicy: createErrorPolicy, mapInput: ({ body }, { principal }) => ({ - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : body.workspaceId, + workspaceId: + principal.kind === 'delegated' ? principalWorkspaceId(principal) : body.workspaceId, name: body.name, description: body.description, schema: { columns: body.schema.columns.map(normalizeColumn) }, @@ -87,7 +88,8 @@ export const GET = defineInternalJsonRoute({ rateLimit, errorPolicy: listErrorPolicy, mapInput: ({ query }, { principal }) => ({ - workspaceId: principal.kind === 'delegated' ? principal.workspaceId : query.workspaceId, + workspaceId: + principal.kind === 'delegated' ? principalWorkspaceId(principal) : query.workspaceId, scope: query.scope, }), useCase: listTableDefinitionsUseCase, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts index e5ce031b2bf..c7b24f8bcb9 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.test.ts @@ -35,6 +35,7 @@ import { } from '@sim/testing/mocks/workspace-context.mock' import { NextRequest } from 'next/server' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createWorkspaceRequestGrant } from '@/lib/api/server/routes/copilot-request' import { PermissionGroupCapabilityError } from '@/lib/permission-groups/capability-error' const mocks = vi.hoisted(() => ({ @@ -465,7 +466,14 @@ describe('/api/v2/workflows/[workflowId]/deployments/chat', () => { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) - markCopilotRequest(request, { userId: 'user-1', workspaceId: WORKSPACE_ID, chatId: 'c-1' }) + markCopilotRequest( + request, + createWorkspaceRequestGrant({ + userId: 'user-1', + workspaceId: WORKSPACE_ID, + chatId: 'c-1', + }) + ) return PUT(request, routeContext) } diff --git a/apps/sim/ee/access-requests/lib/application/operations.ts b/apps/sim/ee/access-requests/lib/application/operations.ts index 13a313be0cc..2db8342a580 100644 --- a/apps/sim/ee/access-requests/lib/application/operations.ts +++ b/apps/sim/ee/access-requests/lib/application/operations.ts @@ -1,11 +1,11 @@ -import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { Principal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' /** Chat acting for the requester joins the direct callers on the workspace routes only. */ export type AccessRequestPrincipal = | Extract - | (DelegatedPrincipal & { serviceId: 'copilot' }) + | (WorkspaceDelegatedPrincipal & { serviceId: 'copilot' }) export const ACCESS_REQUEST_DELEGATION_AUDIENCE = 'sim:settings' diff --git a/apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts b/apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts index 79c9dcb4010..c23528b3c9b 100644 --- a/apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts +++ b/apps/sim/ee/workspace-forking/application/authorized-fork-use-case.ts @@ -1,4 +1,8 @@ -import { type Principal, resolvePrincipalSubject } from '@sim/auth/principal' +import { + type Principal, + resolvePrincipalSubject, + workspaceDelegationRestrictions, +} from '@sim/auth/principal' import { type AuthorizedWorkspaceUseCaseDefinition, defineAuthorizedWorkspaceUseCase, @@ -108,14 +112,19 @@ async function authorizeOtherForkWorkspace( ) { throw new OrchestrationError('not_found', 'Workspace not found in this organization') } - otherPrincipal = Object.freeze({ + const delegated = Object.freeze({ ...createCopilotChatPrincipal( - { userId: context.userId, workspaceId: other.id, chatId: principal.resourceScope?.chatId }, + { + userId: context.userId, + workspaceId: other.id, + chatId: workspaceDelegationRestrictions(principal)?.chatId, + }, principal.audience ), expiresAt: principal.expiresAt, }) - markCopilotWorkspaceInvocation(otherPrincipal) + markCopilotWorkspaceInvocation(delegated) + otherPrincipal = delegated } await withinAuthorizedWorkspaceOperation(() => authorizeWorkspaceOperation(otherPrincipal, operation, otherContext, { diff --git a/apps/sim/executor/execution/snapshot.test.ts b/apps/sim/executor/execution/snapshot.test.ts index 028c005adeb..beb6e80f8d3 100644 --- a/apps/sim/executor/execution/snapshot.test.ts +++ b/apps/sim/executor/execution/snapshot.test.ts @@ -19,11 +19,15 @@ describe('ExecutionSnapshot', () => { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-05-06T00:00:00.000Z'), expiresAt: new Date('2026-05-06T00:05:00.000Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, } const snapshot = new ExecutionSnapshot({ ...metadata, principal }, { blocks: [] }, {}, {}, []) diff --git a/apps/sim/lib/api-key/application/create-api-key.ts b/apps/sim/lib/api-key/application/create-api-key.ts index 4ea3c77aaf5..7f58244abd3 100644 --- a/apps/sim/lib/api-key/application/create-api-key.ts +++ b/apps/sim/lib/api-key/application/create-api-key.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { principalWorkspaceId, resolvePrincipalAttribution } from '@sim/auth/principal' import { apiKeyOperations } from '@/lib/api-key/application/operations' import { performCreateWorkspaceApiKey } from '@/lib/api-key/orchestration' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' @@ -21,7 +21,8 @@ export const createCopilotWorkspaceApiKey = defineAuthorizedWorkspaceUseCase({ authorizationOptions: { delegation: { audience: 'sim:api-keys', - isWithinScope: (principal, context) => principal.workspaceId === context.workspaceId, + isWithinScope: (principal, context) => + principalWorkspaceId(principal) === context.workspaceId, }, }, async execute({ principal, input, context }) { diff --git a/apps/sim/lib/api/server/routes/copilot-project-file-request.test.ts b/apps/sim/lib/api/server/routes/copilot-project-file-request.test.ts index 3aed26faf89..3b1bc58c688 100644 --- a/apps/sim/lib/api/server/routes/copilot-project-file-request.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-project-file-request.test.ts @@ -48,17 +48,25 @@ describe('private v2 Project file authority', () => { ) if (!principal) throw new Error('The admitted Project file request lost its principal') expect(principal).toMatchObject({ - kind: 'resource_delegated', + kind: 'delegated', subjectUserId: 'actor', - invocation: { kind: 'chat', chatId: 'private-chat' }, - scope: { kind: 'entity', entityType: 'project', entityId: 'project', fileId: 'file' }, + invocation: { kind: 'chat', chatId: 'private-chat' } as const, + grant: { + kind: 'entity', + fileId: 'file', + owner: { entityType: 'project', entityId: 'project' }, + } as const, }) expect(principal).not.toHaveProperty('workspaceId') expect( requireResourceDelegation(principal, { audience: 'sim:project-files', services: ['copilot'], - scope: { kind: 'entity', entityType: 'project', entityId: 'project', fileId: 'file' }, + scope: { + kind: 'entity', + fileId: 'file', + owner: { entityType: 'project', entityId: 'project' }, + }, maxTtlMs: 60_000, }) ).toBe('actor') @@ -102,7 +110,7 @@ describe('private v2 Project file authority', () => { expect( copilotRequestPrincipal(collection, projectFileOperations.list, listProjectFiles) ).toMatchObject({ - scope: { kind: 'entity', entityType: 'project', entityId: 'project' }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: 'project' } }, }) expect( copilotRequestPrincipal( diff --git a/apps/sim/lib/api/server/routes/copilot-request.test.ts b/apps/sim/lib/api/server/routes/copilot-request.test.ts index 2212c1d8387..f008def2cbd 100644 --- a/apps/sim/lib/api/server/routes/copilot-request.test.ts +++ b/apps/sim/lib/api/server/routes/copilot-request.test.ts @@ -10,6 +10,7 @@ import { createMockRequest } from '@sim/testing/mocks/request.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' +import { createWorkspaceRequestGrant } from '@/lib/api/server/routes/copilot-request' const { execute } = vi.hoisted(() => ({ execute: vi.fn() })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) @@ -78,7 +79,10 @@ function request(workspaceId = 'target', privateCall = true) { headers: { 'x-api-key': 'opaque', 'x-mothership-workspace-id': 'target' }, }) if (privateCall) - markCopilotRequest(req, { userId: 'actor', workspaceId: 'target', chatId: 'owned-chat' }) + markCopilotRequest( + req, + createWorkspaceRequestGrant({ userId: 'actor', workspaceId: 'target', chatId: 'owned-chat' }) + ) return req } beforeEach(() => { @@ -112,9 +116,12 @@ describe('private Copilot route admission', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'actor', - workspaceId: 'target', audience: 'sim:widgets', - resourceScope: { chatId: 'owned-chat' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'target' }, + restrictions: { chatId: 'owned-chat' }, + }), }) ) expect(v2RouteMocks.authenticate).not.toHaveBeenCalled() @@ -136,7 +143,13 @@ describe('private Copilot route admission', () => { ) expect(admission).toMatchObject({ success: true, - auth: { principal: { kind: 'delegated', workspaceId: 'target', subjectUserId: 'actor' } }, + auth: { + principal: { + kind: 'delegated', + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'target' } }, + subjectUserId: 'actor', + }, + }, }) expect(v2RouteMocks.preauthRate).not.toHaveBeenCalled() expect(v2RouteMocks.authenticate).not.toHaveBeenCalled() diff --git a/apps/sim/lib/api/server/routes/copilot-request.ts b/apps/sim/lib/api/server/routes/copilot-request.ts index 4a6045618dd..f5918ca8f74 100644 --- a/apps/sim/lib/api/server/routes/copilot-request.ts +++ b/apps/sim/lib/api/server/routes/copilot-request.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal, ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { SubjectDelegatedPrincipal, WorkspaceSubjectDelegation } from '@sim/auth/principal' import { noteContentDeliveryOperation } from '@/lib/core/application' import { markCopilotWorkspaceInvocation } from '@/lib/core/application/copilot-workspace-invocation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application/operation' @@ -12,31 +12,29 @@ import { * the given audience, or none when that operation is outside the grant. Owned by the domain * adapter that bound the request, so this route layer stays free of domain policy. */ -type CopilotResourceRequestGrant = ( +type CopilotRequestGrant = ( operation: ApplicationOperation, audience: string -) => ResourceDelegatedPrincipal | undefined +) => SubjectDelegatedPrincipal | undefined -type CopilotRequestInvocation = - | { kind: 'resource'; grant: CopilotResourceRequestGrant } - | { kind: 'workspace'; context: Readonly } +/** In-process authority factory installed after current invocation and target authorization. */ +const INVOCATIONS = new WeakMap() -/** A private in-process invocation, installed only after current chat/target authorization. Never a wire header. */ -const INVOCATIONS = new WeakMap() - -export function markCopilotRequest( - request: Request, - invocation: CopilotChatDelegationContext -): void { - INVOCATIONS.set(request, { kind: 'workspace', context: Object.freeze({ ...invocation }) }) +/** Binds one operation-specific grant factory; application authorization still checks current access. */ +export function markCopilotRequest(request: Request, grant: CopilotRequestGrant): void { + INVOCATIONS.set(request, grant) } -/** Binds one request to a domain adapter's resource grant; the use case still reauthorizes current access. */ -export function markCopilotResourceRequest( - request: Request, - grant: CopilotResourceRequestGrant -): void { - INVOCATIONS.set(request, { kind: 'resource', grant }) +/** Adapts the existing trusted workspace invocation to the common request-grant contract. */ +export function createWorkspaceRequestGrant( + context: CopilotChatDelegationContext +): CopilotRequestGrant { + const invocation = Object.freeze({ ...context }) + return (_operation, audience): WorkspaceSubjectDelegation => { + const principal = createCopilotChatPrincipal(invocation, audience) + markCopilotWorkspaceInvocation(principal) + return principal + } } export function isCopilotRequest(request: Request): boolean { @@ -71,18 +69,12 @@ export function copilotRequestPrincipal( request: Request, operation: ApplicationOperation, useCase?: CopilotRouteUseCase -): DelegatedPrincipal | ResourceDelegatedPrincipal | undefined { +): SubjectDelegatedPrincipal | undefined { const invocation = INVOCATIONS.get(request) if (!invocation) return undefined const audience = copilotRouteAudience(operation, useCase) if (!audience) return undefined - if (invocation.kind === 'resource') { - const principal = invocation.grant(operation, audience) - if (principal) noteContentDeliveryOperation(operation, principal) - return principal - } - const principal = createCopilotChatPrincipal(invocation.context, audience) - markCopilotWorkspaceInvocation(principal) - noteContentDeliveryOperation(operation, principal) + const principal = invocation(operation, audience) + if (principal) noteContentDeliveryOperation(operation, principal) return principal } 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 eb60596d658..d9abcce376d 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,10 +1,10 @@ import { - type DelegatedPrincipal, describePrincipalAuth, type Principal, resolvePrincipalSubjectUserId, type SessionPrincipal, type WorkflowExecutionDelegatedPrincipal, + type workspaceDelegationRestrictions, } from '@sim/auth/principal' import { setRequestAuth } from '@sim/logger' import type { NextRequest } from 'next/server' @@ -66,7 +66,7 @@ interface InternalSessionOrExecutorAuthOptions { audience: string resourceScope?( params: Record - ): DelegatedPrincipal['resourceScope'] + ): ReturnType } export function createInternalSessionOrExecutorAuth( diff --git a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts index e1e20bc1610..d33ef036ce2 100644 --- a/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts +++ b/apps/sim/lib/audit-logs/application/authorized-audit-log-use-case.ts @@ -1,4 +1,8 @@ -import { isUserCredentialPrincipal, type Principal } from '@sim/auth/principal' +import { + isUserCredentialPrincipal, + type Principal, + principalWorkspaceId, +} from '@sim/auth/principal' import type { AuditLogOperation, AuditLogPrincipal } from '@/lib/audit-logs/application/operations' import { copilotAuditLogOperations } from '@/lib/audit-logs/application/operations' import { @@ -114,11 +118,13 @@ export function defineAuthorizedAuditLogUseCase candidate.id === definition.operation.id ) if (!workspaceOperation) throw new Error('Missing private workspace read policy') - const workspace = await resolveActiveWorkspaceApplicationContext(principal.workspaceId) + const workspace = await resolveActiveWorkspaceApplicationContext( + principalWorkspaceId(principal) + ) await authorizeWorkspaceOperation(principal, workspaceOperation, workspace, { delegation: { audience: 'sim:audit-logs', - isWithinScope: (actor, target) => actor.workspaceId === target.workspaceId, + isWithinScope: (actor, target) => principalWorkspaceId(actor) === target.workspaceId, }, }) if ( diff --git a/apps/sim/lib/audit-logs/application/use-cases.test.ts b/apps/sim/lib/audit-logs/application/use-cases.test.ts index d54ffc5f026..0c737f62844 100644 --- a/apps/sim/lib/audit-logs/application/use-cases.test.ts +++ b/apps/sim/lib/audit-logs/application/use-cases.test.ts @@ -16,6 +16,7 @@ import { PgDialect } from 'drizzle-orm/pg-core' import { beforeEach, describe, expect, it, vi } from 'vitest' import { copilotRequestPrincipal, + createWorkspaceRequestGrant, markCopilotRequest, } from '@/lib/api/server/routes/copilot-request' import { auditLogOperations } from '@/lib/audit-logs/application/operations' @@ -106,7 +107,10 @@ describe('audit-log application use cases', () => { it('preserves enterprise admin authority and pins private audit listing to the selected workspace', async () => { const request = createMockRequest({ url: 'https://sim.invalid/api/v2/audit-logs' }) - markCopilotRequest(request, { userId: 'admin-1', workspaceId: 'workspace-1', chatId: 'chat' }) + markCopilotRequest( + request, + createWorkspaceRequestGrant({ userId: 'admin-1', workspaceId: 'workspace-1', chatId: 'chat' }) + ) const principal = copilotRequestPrincipal(request, auditLogOperations.list, listAuditLogs)! await listAuditLogs.execute({ principal, input: { ...listInput, organizationId: undefined } }) expect(mocks.resolveAccess).toHaveBeenCalledWith('admin-1', 'organization-1') @@ -131,7 +135,10 @@ describe('audit-log application use cases', () => { listAuditLogs.execute({ principal: forged, input: listInput }) ).rejects.toMatchObject({ code: 'forbidden' }) const request = createMockRequest({ url: 'https://sim.invalid/api/v2/audit-logs' }) - markCopilotRequest(request, { userId: 'admin-1', workspaceId: 'workspace-1', chatId: 'chat' }) + markCopilotRequest( + request, + createWorkspaceRequestGrant({ userId: 'admin-1', workspaceId: 'workspace-1', chatId: 'chat' }) + ) const principal = copilotRequestPrincipal(request, auditLogOperations.list, listAuditLogs)! await expect( listAuditLogs.execute({ principal, input: { ...listInput, organizationId: 'other' } }) @@ -141,7 +148,10 @@ describe('audit-log application use cases', () => { }) it('binds an ID-only private detail read to the selected workspace and rechecks access', async () => { const request = createMockRequest({ url: 'https://sim.invalid/api/v2/audit-logs/audit-1' }) - markCopilotRequest(request, { userId: 'admin-1', workspaceId: 'workspace-1', chatId: 'chat' }) + markCopilotRequest( + request, + createWorkspaceRequestGrant({ userId: 'admin-1', workspaceId: 'workspace-1', chatId: 'chat' }) + ) const principal = copilotRequestPrincipal(request, auditLogOperations.readDetail, getAuditLog)! mocks.buildOrgScopeCondition.mockReturnValue(sql`true`) dbChainMockFns.limit.mockResolvedValueOnce([]) diff --git a/apps/sim/lib/auth/delegated-grant-admission.test.ts b/apps/sim/lib/auth/delegated-grant-admission.test.ts new file mode 100644 index 00000000000..b2efdf24a7a --- /dev/null +++ b/apps/sim/lib/auth/delegated-grant-admission.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment node + */ +import { + hasResourceOperationGrant, + isWorkspaceDelegation, + parsePrincipal, + principalWorkspaceId, + type ResourceDelegationScope, + serializePrincipal, + workspaceDelegationRestrictions, +} from '@sim/auth/principal' +import { createDelegatedPrincipal } from '@sim/testing/factories/principal.factory' +import { describe, expect, it } from 'vitest' +import { markCopilotWorkspaceInvocation } from '@/lib/core/application/copilot-workspace-invocation' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' + +const issuedAt = new Date('2026-01-01T00:00:00.000Z') +const expiresAt = new Date('2099-01-01T00:00:00.000Z') +const workspaceOperation = defineWorkspaceOperation({ + id: 'test.workspace-grant', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + contentDelivery: 'none', + capability: 'none', +}) +const resourcePolicy = { + audience: 'sim:files', + services: ['copilot'], + maxTtlMs: 60_000, + scope: { kind: 'entity', owner: { entityType: 'project', entityId: 'project-1' } }, +} as const + +function copilotEnvelope( + grant: ResourceDelegationScope, + invocation: { kind: 'chat'; chatId: string } | { kind: 'workspace'; workspaceId: string } +) { + return { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + delegationId: 'delegation-1', + audience: 'sim:test', + issuedAt, + expiresAt, + invocation, + grant, + } +} + +const workspaceGrant = { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, +} +const illegalFileGrant = { ...workspaceGrant, fileId: 'file-1' } + +describe('delegated grant admission', () => { + it('rejects a workspace entity grant that also names a top-level file', () => { + const principal = copilotEnvelope(illegalFileGrant, { + kind: 'workspace', + workspaceId: 'workspace-1', + }) + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(hasResourceOperationGrant(principal)).toBe(false) + expect(principalWorkspaceId(principal)).toBeUndefined() + expect(() => requireAllowedWorkspacePrincipal(principal, workspaceOperation)).toThrow( + expect.objectContaining({ detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' }) + ) + expect(() => requireResourceDelegation(principal, resourcePolicy)).toThrow( + 'Resource delegation is no longer valid' + ) + expect(() => markCopilotWorkspaceInvocation(principal)).toThrow(/current/) + }) + + it('rejects a workspace invocation that names a different workspace than the grant', () => { + const principal = { + ...createDelegatedPrincipal({ workspaceId: 'workspace-1' }), + invocation: { kind: 'workspace' as const, workspaceId: 'other' }, + } + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(principalWorkspaceId(principal)).toBeUndefined() + }) + + it('rejects a chat invocation whose chat id is absent from restrictions', () => { + const principal = { + ...createDelegatedPrincipal({ workspaceId: 'workspace-1', resourceScope: {} }), + invocation: { kind: 'chat' as const, chatId: 'chat-1' }, + } + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(workspaceDelegationRestrictions(principal)?.chatId).toBeUndefined() + }) + + it('accepts a chat invocation that matches restrictions and reads the grant owner', () => { + const principal = { + ...createDelegatedPrincipal({ + workspaceId: 'workspace-1', + resourceScope: { chatId: 'chat-1' }, + }), + invocation: { kind: 'chat' as const, chatId: 'chat-1' }, + } + expect(isWorkspaceDelegation(principal)).toBe(true) + expect(principalWorkspaceId(principal)).toBe('workspace-1') + }) + + it('keeps an empty restriction object from bypassing a required chat id', () => { + const principal = createDelegatedPrincipal({ + workspaceId: 'workspace-1', + resourceScope: {}, + }) + expect(isWorkspaceDelegation(principal)).toBe(true) + expect(workspaceDelegationRestrictions(principal)?.chatId).toBeUndefined() + }) + + it('keeps a project entity grant on the resource side of the partition', () => { + const principal = copilotEnvelope( + { kind: 'entity', owner: { entityType: 'project', entityId: 'project-1' } }, + { kind: 'chat', chatId: 'chat-1' } + ) + expect(hasResourceOperationGrant(principal)).toBe(true) + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(principalWorkspaceId(principal)).toBeUndefined() + }) + + it('keeps a copilot workspace-file chat invocation across a snapshot round-trip', () => { + const minted = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + fileId: 'file-1', + chatId: 'chat-1', + }) + if (minted.serviceId !== 'copilot') throw new Error('Expected a copilot file principal') + expect(minted.invocation).toEqual({ kind: 'chat', chatId: 'chat-1' }) + expect(minted.grant.restrictions).toMatchObject({ fileId: 'file-1', chatId: 'chat-1' }) + const parsed = parsePrincipal(serializePrincipal(minted)) + if (parsed.kind !== 'delegated' || parsed.serviceId === 'executor') + throw new Error('Expected a subject delegation') + expect(parsed.invocation).toEqual({ kind: 'chat', chatId: 'chat-1' }) + expect(parsed.grant.restrictions).toMatchObject({ fileId: 'file-1', chatId: 'chat-1' }) + }) + + it('parses a realtime workspace payload as a workspace invocation', () => { + const parsed = parsePrincipal({ + version: 1, + principal: { + kind: 'delegated', + serviceId: 'realtime', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:test', + issuedAt: issuedAt.toISOString(), + expiresAt: new Date('2026-01-01T00:05:00.000Z').toISOString(), + }, + }) + if (parsed.kind !== 'delegated' || parsed.serviceId !== 'realtime') + throw new Error('Expected a realtime delegation') + expect(parsed.invocation).toEqual({ kind: 'workspace', workspaceId: 'workspace-1' }) + expect(parsed).not.toHaveProperty('connectionId') + }) +}) diff --git a/apps/sim/lib/auth/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts index 57d8338a9b2..bfd6470dce0 100644 --- a/apps/sim/lib/auth/internal-delegation.ts +++ b/apps/sim/lib/auth/internal-delegation.ts @@ -1,6 +1,6 @@ import type { BoundWorkflowExecutionDelegatedPrincipal, - DelegatedPrincipal, + workspaceDelegationRestrictions, } from '@sim/auth/principal' import type { VerifiedInternalDelegation } from '@/lib/auth/internal' import { asOrchestrationError } from '@/lib/core/orchestration/types' @@ -15,7 +15,7 @@ import { export interface BindInternalExecutorDelegationOptions { audience: string - resourceScope?: DelegatedPrincipal['resourceScope'] + resourceScope?: ReturnType compatibilityActorUserId?: string } diff --git a/apps/sim/lib/auth/internal.test.ts b/apps/sim/lib/auth/internal.test.ts index a1136f0617a..d56485e23ae 100644 --- a/apps/sim/lib/auth/internal.test.ts +++ b/apps/sim/lib/auth/internal.test.ts @@ -99,11 +99,15 @@ describe('internal executor delegation claims', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00.000Z'), expiresAt: new Date('2026-01-01T00:05:00.000Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }, }) diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index e3e79def78b..259555e5f35 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -1,6 +1,9 @@ import { + hasResourceOperationGrant, + isWorkspaceDelegation, PrincipalSubjectUserRequiredError, parsePrincipal, + principalWorkspaceId, requirePrincipalSubjectUserId, resolvePrincipalAttribution, resolvePrincipalAuditAttribution, @@ -56,11 +59,15 @@ describe('principal subject users', () => { 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'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }) ).toBe('delegated-user') }) @@ -207,18 +214,158 @@ describe('principal subject users', () => { }) }) +describe('delegated grant partition', () => { + const issuedAt = new Date('2026-01-01T00:00:00.000Z') + const expiresAt = new Date('2026-01-01T00:05:00.000Z') + const base = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + delegationId: 'delegation-1', + audience: 'sim:files', + issuedAt, + expiresAt, + } + + it('rejects a workspace entity grant that also carries a top-level file id', () => { + const illegal = { + ...base, + invocation: { kind: 'workspace' as const, workspaceId: 'workspace-1' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + fileId: 'file-1', + }, + } + const extraKey = { + ...base, + invocation: { kind: 'workspace' as const, workspaceId: 'workspace-1' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + note: 'extra', + }, + } + for (const principal of [illegal, extraKey]) { + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(hasResourceOperationGrant(principal)).toBe(false) + expect(principalWorkspaceId(principal)).toBeUndefined() + } + }) + + it('rejects a workspace invocation that names a different workspace than the grant', () => { + const principal = { + ...base, + invocation: { kind: 'workspace' as const, workspaceId: 'other' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + }, + } + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(principalWorkspaceId(principal)).toBeUndefined() + }) + + it('rejects a chat invocation whose chat id is absent from restrictions', () => { + const principal = { + ...base, + invocation: { kind: 'chat' as const, chatId: 'chat-1' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + }, + } + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(principalWorkspaceId(principal)).toBeUndefined() + }) + + it('accepts a chat invocation that matches restrictions and reads the grant owner', () => { + const principal = { + ...base, + invocation: { kind: 'chat' as const, chatId: 'chat-1' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + restrictions: { chatId: 'chat-1' }, + }, + } + expect(isWorkspaceDelegation(principal)).toBe(true) + expect(principalWorkspaceId(principal)).toBe('workspace-1') + expect(hasResourceOperationGrant(principal)).toBe(false) + }) + + it('keeps empty restrictions a workspace delegation when the workspace invocation matches', () => { + const principal = { + ...base, + invocation: { kind: 'workspace' as const, workspaceId: 'workspace-1' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + restrictions: {}, + }, + } + expect(isWorkspaceDelegation(principal)).toBe(true) + expect(principalWorkspaceId(principal)).toBe('workspace-1') + }) + + it('keeps a project entity grant a resource grant', () => { + const principal = { + ...base, + invocation: { kind: 'chat' as const, chatId: 'chat-1' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'project' as const, entityId: 'project-1' }, + }, + } + expect(hasResourceOperationGrant(principal)).toBe(true) + expect(isWorkspaceDelegation(principal)).toBe(false) + expect(principalWorkspaceId(principal)).toBeUndefined() + }) + + it('restores a realtime workspace payload as a workspace invocation', () => { + const restored = parsePrincipal({ + version: 1, + principal: { + kind: 'delegated', + serviceId: 'realtime', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workflows', + issuedAt: issuedAt.toISOString(), + expiresAt: expiresAt.toISOString(), + }, + }) + expect(restored).toMatchObject({ + serviceId: 'realtime', + invocation: { kind: 'workspace', workspaceId: 'workspace-1' }, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, + }) + expect(restored).not.toHaveProperty('connectionId') + if (restored.kind === 'delegated') + expect(restored.invocation).not.toHaveProperty('connectionId') + }) +}) + describe('principal persistence', () => { it('round trips delegated principals and restores dates', () => { const principal = { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00.000Z'), expiresAt: new Date('2026-01-01T00:05:00.000Z'), - resourceScope: { executionId: 'execution-1' }, + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { executionId: 'execution-1' }, + } as const, } const restored = parsePrincipal(structuredClone(serializePrincipal(principal))) @@ -252,11 +399,11 @@ describe('principal persistence', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: 'not-a-date', expiresAt: '2026-01-01T00:05:00.000Z', + workspaceId: 'workspace-1', }, }) ).toThrow('issuedAt must be an ISO timestamp') @@ -442,11 +589,15 @@ describe('principal actors', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-2', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workspace-files', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2026-01-01T00:05:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }) ).toMatchObject({ actorId: 'user-2' }) expect( @@ -492,11 +643,15 @@ describe('principal actors', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workspace-files', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2026-01-01T00:05:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }) ).toEqual({ kind: 'delegated', @@ -534,11 +689,15 @@ describe('principal actors', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-3', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workspace-files', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2026-01-01T00:05:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }) ).toMatchObject({ attributedUserId: 'user-3' }) }) diff --git a/apps/sim/lib/auth/realtime-file-delegation.ts b/apps/sim/lib/auth/realtime-file-delegation.ts index 51f4b5ffa56..c99df639c36 100644 --- a/apps/sim/lib/auth/realtime-file-delegation.ts +++ b/apps/sim/lib/auth/realtime-file-delegation.ts @@ -1,4 +1,4 @@ -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { FILE_DOC_INTERNAL_HEADERS } from '@sim/realtime-protocol/file-doc' import { generateId } from '@sim/utils/id' import { @@ -9,7 +9,7 @@ import { checkInternalApiKey } from '@/lib/mothership/request/http' import { PROJECT_FILE_DELEGATION_TTL_MS } from '@/lib/projects/files/application/operations' /** Internal relay requests carry the authenticated socket actor, bounded to exactly one Project file. */ -export const realtimeProjectFileAuth: InternalAuthPolicy = { +export const realtimeProjectFileAuth: InternalAuthPolicy = { async authenticate(request, params) { if (!checkInternalApiKey(request).success) throw new InternalUnauthenticatedError() const subjectUserId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.userId) @@ -28,11 +28,11 @@ export const realtimeProjectFileAuth: InternalAuthPolicy = { +export const realtimeProjectFileListAuth: InternalAuthPolicy = { async authenticate(request, params) { if (!checkInternalApiKey(request).success) throw new InternalUnauthenticatedError() const subjectUserId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.userId) @@ -25,11 +25,14 @@ export const realtimeProjectFileListAuth: InternalAuthPolicy candidate.id === operation.id ) if (!workspaceOperation) throw new Error('Missing private workspace read policy') - const workspace = await loadActiveWorkspaceApplicationContext(principal.workspaceId) + const workspace = await loadActiveWorkspaceApplicationContext(principalWorkspaceId(principal)) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') await authorizeWorkspaceOperation(principal, workspaceOperation, workspace, { delegation: { audience: 'sim:billing', - isWithinScope: (actor, target) => actor.workspaceId === target.workspaceId, + isWithinScope: (actor, target) => principalWorkspaceId(actor) === target.workspaceId, }, }) return { kind: 'workspace', workspace } } if (principal.kind === 'workspace_api_key') { - if (requestedWorkspaceId && requestedWorkspaceId !== principal.workspaceId) { + if (requestedWorkspaceId && requestedWorkspaceId !== principalWorkspaceId(principal)) { /** * A cross-tenant refusal, so it must not explain itself: naming the cause * would confirm the named workspace exists to a key that was never scoped @@ -137,7 +141,7 @@ async function resolveBillingReadScope( } const workspaceId = - principal.kind === 'workspace_api_key' ? principal.workspaceId : requestedWorkspaceId + principal.kind === 'workspace_api_key' ? principalWorkspaceId(principal) : requestedWorkspaceId if (!workspaceId) throw new Error(`Billing operation ${operation.id} lost its workspace scope`) const workspace = await loadActiveWorkspaceApplicationContext(workspaceId) diff --git a/apps/sim/lib/billing/application/list-billing-logs.ts b/apps/sim/lib/billing/application/list-billing-logs.ts index c25c7966dd6..9bb92384ecf 100644 --- a/apps/sim/lib/billing/application/list-billing-logs.ts +++ b/apps/sim/lib/billing/application/list-billing-logs.ts @@ -1,4 +1,4 @@ -import { resolvePrincipalSubjectUserId } from '@sim/auth/principal' +import { principalWorkspaceId, resolvePrincipalSubjectUserId } from '@sim/auth/principal' import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' import { billingOperations } from '@/lib/billing/application/operations' import { @@ -69,13 +69,13 @@ export const listBillingLogs = defineAuthorizedBillingReadUseCase({ return { usage, creditsByLogId: apportionLogCredits(usage), scope: 'user' } } /** - * `resolveBillingReadScope` pins a workspace API key to `principal.workspaceId` + * `resolveBillingReadScope` pins a workspace API key to `principalWorkspaceId(principal)` * whatever the query asked for, and has already loaded and validated that * workspace, so this is the same id the resolved scope carries. */ if (principal.kind !== 'workspace_api_key') throw new Error('Billing read lost its acting subject') - const usage = await getWorkspaceUsageLogs(principal.workspaceId, query) + const usage = await getWorkspaceUsageLogs(principalWorkspaceId(principal), query) return { usage, creditsByLogId: apportionLogCredits(usage), scope: 'workspace' } }, }) diff --git a/apps/sim/lib/billing/application/use-cases.test.ts b/apps/sim/lib/billing/application/use-cases.test.ts index 214ef0edd30..1b592a7f13d 100644 --- a/apps/sim/lib/billing/application/use-cases.test.ts +++ b/apps/sim/lib/billing/application/use-cases.test.ts @@ -33,6 +33,7 @@ import { import { beforeEach, describe, expect, it, vi } from 'vitest' import { copilotRequestPrincipal, + createWorkspaceRequestGrant, markCopilotRequest, } from '@/lib/api/server/routes/copilot-request' import { billingOperations } from '@/lib/billing/application/operations' @@ -186,7 +187,10 @@ describe('billing application use cases', () => { }) expect(mocks.loadWorkspace).not.toHaveBeenCalled() const request = new Request('https://sim.invalid/api/v2/billing/status') - markCopilotRequest(request, { userId: 'user-1', workspaceId: 'workspace-1', chatId: 'chat' }) + markCopilotRequest( + request, + createWorkspaceRequestGrant({ userId: 'user-1', workspaceId: 'workspace-1', chatId: 'chat' }) + ) const principal = copilotRequestPrincipal( request, billingOperations.readStatus, diff --git a/apps/sim/lib/core/application/audit-source.test.ts b/apps/sim/lib/core/application/audit-source.test.ts index 6a5b0c9e6d2..0a4adaad3f0 100644 --- a/apps/sim/lib/core/application/audit-source.test.ts +++ b/apps/sim/lib/core/application/audit-source.test.ts @@ -8,11 +8,15 @@ describe('principalAuditSource', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:workflows', issuedAt: new Date(), expiresAt: new Date(), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }) ).toBe('copilot') }) diff --git a/apps/sim/lib/core/application/audit-source.ts b/apps/sim/lib/core/application/audit-source.ts index 3835902a75d..807bf51f5ff 100644 --- a/apps/sim/lib/core/application/audit-source.ts +++ b/apps/sim/lib/core/application/audit-source.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import { type Principal, toPrincipalActor } from '@sim/auth/principal' /** * The surface a semantic audit row attributes a change to. @@ -13,5 +13,6 @@ import type { Principal } from '@sim/auth/principal' * names its own credential class, which is already what `actor` records. */ export function principalAuditSource(principal: Principal): string { - return principal.kind === 'delegated' ? principal.serviceId : principal.kind + const actor = toPrincipalActor(principal) + return actor.kind === 'delegated' ? actor.serviceId : actor.kind } 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 dea8341c579..a5d98dcae99 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 @@ -1,8 +1,9 @@ import type { - DelegatedPrincipal, SessionPrincipal, WorkspaceApiKeyPrincipal, + WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { createPersonalApiKeyPrincipal, createSessionPrincipal, @@ -317,15 +318,15 @@ describe('defineAuthorizedWorkspaceUseCase', () => { it('resolves domain-specific delegation options against canonical context', async () => { const scopeCheck = vi.fn( - (principal: DelegatedPrincipal, context: TestContext) => - principal.resourceScope?.fileId === context.canonicalResourceId + (principal: WorkspaceDelegatedPrincipal, context: TestContext) => + workspaceDelegationRestrictions(principal)?.fileId === context.canonicalResourceId ) const useCase = defineAuthorizedWorkspaceUseCase({ operation: delegatedOperation, - resolveContext: async (_args: { principal: DelegatedPrincipal; input: TestInput }) => + resolveContext: async (_args: { principal: WorkspaceDelegatedPrincipal; input: TestInput }) => canonicalContext, authorizationOptions: ({ principal }) => { - expectTypeOf(principal).toMatchTypeOf() + expectTypeOf(principal).toMatchTypeOf() expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>() return { delegation: { @@ -335,12 +336,12 @@ describe('defineAuthorizedWorkspaceUseCase', () => { } }, async execute({ principal }) { - expectTypeOf(principal).toMatchTypeOf() + expectTypeOf(principal).toMatchTypeOf() expectTypeOf(principal.serviceId).toEqualTypeOf<'executor'>() return { ok: true as const } }, }) - const principal: DelegatedPrincipal = { + const principal: WorkspaceDelegatedPrincipal = { kind: 'delegated', serviceId: 'executor', subjectUserId: 'user-1', @@ -367,7 +368,8 @@ describe('defineAuthorizedWorkspaceUseCase', () => { it('rejects a disallowed delegated service before canonical loading', async () => { const resolveContext = vi.fn( - async (_args: { principal: DelegatedPrincipal; input: TestInput }) => canonicalContext + async (_args: { principal: WorkspaceDelegatedPrincipal; input: TestInput }) => + canonicalContext ) const useCase = defineAuthorizedWorkspaceUseCase({ operation: delegatedOperation, @@ -386,11 +388,15 @@ describe('defineAuthorizedWorkspaceUseCase', () => { 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), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }, input: { resourceId: 'resource-1' }, }) diff --git a/apps/sim/lib/core/application/content-delivery.test.ts b/apps/sim/lib/core/application/content-delivery.test.ts index 173335c32fe..b41b33468e9 100644 --- a/apps/sim/lib/core/application/content-delivery.test.ts +++ b/apps/sim/lib/core/application/content-delivery.test.ts @@ -27,29 +27,34 @@ import { PROVENANCE_MAX_ENTRIES } from '@/lib/execution/provenance-limits' function delegated(serviceId: DelegatedServiceId): Principal { const now = Date.now() - return { - kind: 'delegated', - serviceId, + const envelope = { + kind: 'delegated' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:test', issuedAt: new Date(now), expiresAt: new Date(now + 60_000), } + if (serviceId === 'executor') return { ...envelope, serviceId, workspaceId: 'workspace-1' } + return { + ...envelope, + serviceId, + invocation: { kind: 'workspace', workspaceId: 'workspace-1' }, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } }, + } } const CHAT = delegated('copilot') const PROJECT_CHAT: Principal = { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', audience: 'sim:project-files', delegationId: 'delegation-1', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), - scope: { kind: 'entity', entityType: 'project', entityId: 'project-1' }, - invocation: { kind: 'workspace', workspaceId: 'workspace-1' }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: 'project-1' } } as const, + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, } const EXECUTOR = delegated('executor') const SESSION: Principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } diff --git a/apps/sim/lib/core/application/content-delivery.ts b/apps/sim/lib/core/application/content-delivery.ts index f3d745b8479..613d1fe339e 100644 --- a/apps/sim/lib/core/application/content-delivery.ts +++ b/apps/sim/lib/core/application/content-delivery.ts @@ -163,9 +163,7 @@ class ContentDeliveryUndeclaredError extends Error { */ export function isCopilotPrincipal(principal: Principal): boolean { return ( - (principal.kind === 'delegated' || - principal.kind === 'organization_delegated' || - principal.kind === 'resource_delegated') && + (principal.kind === 'delegated' || principal.kind === 'organization_delegated') && principal.serviceId === 'copilot' && workflowRuns.getStore() === undefined ) diff --git a/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts index 81c615dd918..316b722bde3 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.test.ts @@ -1,3 +1,4 @@ +import { createExecutorPrincipal } from '@sim/testing/factories/principal.factory' import { describe, expect, it } from 'vitest' import { bindCopilotWorkspaceOperation, @@ -44,9 +45,21 @@ describe('private table workspace invocation', () => { expect(() => markCopilotWorkspaceInvocation({ ...principal(), expiresAt: new Date(0) }) ).toThrow(/current/) - expect(() => markCopilotWorkspaceInvocation({ ...principal(), serviceId: 'executor' })).toThrow( - /current/ - ) + expect(() => + markCopilotWorkspaceInvocation( + createExecutorPrincipal({ subjectUserId: 'actor', workspaceId: 'workspace' }) + ) + ).toThrow(/current/) + const illegal = { + ...principal(), + invocation: { kind: 'workspace' as const, workspaceId: 'workspace' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace' }, + fileId: 'file-1', + }, + } + expect(() => markCopilotWorkspaceInvocation(illegal)).toThrow(/current/) }) }) describe('nested workspace operation binding', () => { diff --git a/apps/sim/lib/core/application/copilot-workspace-invocation.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.ts index 8d023187323..8712620a05c 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.ts @@ -1,15 +1,21 @@ -import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { Principal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { + isWorkspaceDelegation, + principalWorkspaceId, + workspaceDelegationRestrictions, +} from '@sim/auth/principal' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application/operation' import { OrchestrationError } from '@/lib/core/orchestration/types' /** In-process admission evidence, never serialized into a job, token, checkpoint, or wire field. */ -const ADMITTED = new WeakSet() +const ADMITTED = new WeakSet() -export function markCopilotWorkspaceInvocation(principal: DelegatedPrincipal): void { +export function markCopilotWorkspaceInvocation(principal: Principal): void { if ( + !isWorkspaceDelegation(principal) || principal.serviceId !== 'copilot' || !principal.subjectUserId || - !principal.workspaceId || + !principalWorkspaceId(principal) || principal.expiresAt <= new Date() ) { throw new Error( @@ -19,9 +25,14 @@ export function markCopilotWorkspaceInvocation(principal: DelegatedPrincipal): v ADMITTED.add(principal) } -export function isCopilotWorkspaceInvocation(principal: DelegatedPrincipal): boolean { +export function isCopilotWorkspaceInvocation( + principal: Principal +): principal is WorkspaceDelegatedPrincipal { return ( - ADMITTED.has(principal) && principal.serviceId === 'copilot' && principal.expiresAt > new Date() + isWorkspaceDelegation(principal) && + ADMITTED.has(principal) && + principal.serviceId === 'copilot' && + principal.expiresAt > new Date() ) } @@ -33,9 +44,9 @@ export type NestedResourceScope = Partial< /** Adds the nested target to the admitted scope; a key already granted never moves to another resource. */ function narrowResourceScope( - granted: DelegatedPrincipal['resourceScope'], + granted: ReturnType, target: NestedResourceScope -): NonNullable { +): NonNullable> { const scope = { ...granted } for (const key of NESTED_RESOURCE_SCOPE_KEYS) { const value = target[key] @@ -65,8 +76,9 @@ export function bindCopilotWorkspaceOperation

( ): P { if (principal.kind !== 'delegated' || principal.serviceId !== 'copilot') return principal if ( + !isWorkspaceDelegation(principal) || !isCopilotWorkspaceInvocation(principal) || - principal.workspaceId !== workspaceId || + principalWorkspaceId(principal) !== workspaceId || !sourceAudiences.includes(principal.audience) || !useCase.delegationAudience ) { @@ -79,7 +91,15 @@ export function bindCopilotWorkspaceOperation

( ...principal, audience: useCase.delegationAudience, ...(resourceScope - ? { resourceScope: narrowResourceScope(principal.resourceScope, resourceScope) } + ? { + grant: { + ...principal.grant, + restrictions: narrowResourceScope( + workspaceDelegationRestrictions(principal), + resourceScope + ), + }, + } : {}), } if (derived.kind === 'delegated') markCopilotWorkspaceInvocation(derived) diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index 85aa56e1315..57f012770cc 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -127,7 +127,7 @@ export type PrincipalKind = Exclude< */ export type UndelegatedPrincipalKind = Exclude< PrincipalKind, - 'delegated' | 'organization_delegated' | 'resource_delegated' + 'delegated' | 'organization_delegated' > /** diff --git a/apps/sim/lib/core/application/resource-delegation.test.ts b/apps/sim/lib/core/application/resource-delegation.test.ts index 1f8714464ff..49c63e36a80 100644 --- a/apps/sim/lib/core/application/resource-delegation.test.ts +++ b/apps/sim/lib/core/application/resource-delegation.test.ts @@ -1,5 +1,8 @@ import { + hasResourceOperationGrant, + isWorkspaceDelegation, parsePrincipal, + principalWorkspaceId, resolvePrincipalAttribution, resolvePrincipalAuditAttribution, resolvePrincipalSubjectUserId, @@ -7,25 +10,26 @@ import { } from '@sim/auth/principal' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { requireAllowedWorkspacePrincipal } from '@/lib/core/application/workspace-authorization' import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' const NOW = new Date('2026-10-03T12:00:00Z') const principal = { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: 'acting-user', delegationId: 'tool-call', audience: 'sim:files', issuedAt: NOW, expiresAt: new Date('2026-10-03T12:01:00Z'), - invocation: { kind: 'chat', chatId: 'chat' }, - scope: { kind: 'entity', entityType: 'project', entityId: 'owner' }, + invocation: { kind: 'chat', chatId: 'chat' } as const, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: 'owner' } } as const, } as const const policy = { audience: 'sim:files', services: ['copilot', 'realtime'], maxTtlMs: 60_000, - scope: principal.scope, + scope: principal.grant, } as const describe('resource delegation authorization', () => { @@ -39,7 +43,7 @@ describe('resource delegation authorization', () => { expect( requireResourceDelegation(principal, { ...policy, - scope: { ...principal.scope, fileId: 'file' }, + scope: { ...principal.grant, fileId: 'file' }, }) ).toBe('acting-user') expect(resolvePrincipalSubjectUserId(principal)).toBe('acting-user') @@ -56,7 +60,7 @@ describe('resource delegation authorization', () => { it('allows discovery without a selected workspace', () => { expect( requireResourceDelegation( - { ...principal, scope: { kind: 'project_discovery' } }, + { ...principal, grant: { kind: 'project_discovery' } }, { ...policy, scope: { kind: 'project_discovery' } } ) ).toBe('acting-user') @@ -84,15 +88,30 @@ describe('resource delegation authorization', () => { ], [ 'another owner type', - { ...principal, scope: { ...principal.scope, entityType: 'workspace' } }, + { + ...principal, + grant: { ...principal.grant, owner: { ...principal.grant.owner, entityType: 'workspace' } }, + }, + ], + [ + 'another owner ID', + { + ...principal, + grant: { ...principal.grant, owner: { ...principal.grant.owner, entityId: 'other' } }, + }, ], - ['another owner ID', { ...principal, scope: { ...principal.scope, entityId: 'other' } }], - ['missing owner ID', { ...principal, scope: { ...principal.scope, entityId: '' } }], - ['empty file bound', { ...principal, scope: { ...principal.scope, fileId: '' } }], - ['discovery used for files', { ...principal, scope: { kind: 'project_discovery' } }], + [ + 'missing owner ID', + { + ...principal, + grant: { ...principal.grant, owner: { ...principal.grant.owner, entityId: '' } }, + }, + ], + ['empty file bound', { ...principal, grant: { ...principal.grant, fileId: '' } }], + ['discovery used for files', { ...principal, grant: { kind: 'project_discovery' } }], [ 'file bound widened to owner', - { ...principal, scope: { ...principal.scope, fileId: 'file' } }, + { ...principal, grant: { ...principal.grant, fileId: 'file' } }, ], ])('refuses %s before granting a subject', (_name, candidate) => { expect(() => requireResourceDelegation(candidate as never, policy)).toThrow( @@ -109,16 +128,19 @@ describe('resource delegation authorization', () => { it('does not let a file grant move to a sibling file', () => { expect(() => requireResourceDelegation( - { ...principal, scope: { ...principal.scope, fileId: 'first' } }, - { ...policy, scope: { ...principal.scope, fileId: 'second' } } + { ...principal, grant: { ...principal.grant, fileId: 'first' } }, + { ...policy, scope: { ...principal.grant, fileId: 'second' } } ) ).toThrow('Resource delegation is no longer valid') }) it('does not treat matching unknown owner types as authority', () => { - const scope = { kind: 'entity', entityType: 'unknown', entityId: 'owner' } + const scope = { kind: 'entity', owner: { entityType: 'unknown', entityId: 'owner' } } expect(() => - requireResourceDelegation({ ...principal, scope } as never, { ...policy, scope } as never) + requireResourceDelegation( + { ...principal, grant: scope } as never, + { ...policy, scope } as never + ) ).toThrow() }) @@ -133,21 +155,21 @@ describe('resource delegation authorization', () => { ...principal, serviceId: 'realtime', invocation: { kind: 'realtime', connectionId: 'connection' }, - scope: { ...principal.scope, fileId: 'file' }, + grant: { ...principal.grant, fileId: 'file' }, } as const - expect(requireResourceDelegation(realtime, { ...policy, scope: realtime.scope })).toBe( + expect(requireResourceDelegation(realtime, { ...policy, scope: realtime.grant })).toBe( 'acting-user' ) for (const candidate of [ - { ...realtime, scope: principal.scope }, - { ...realtime, scope: { kind: 'project_discovery' } }, + { ...realtime, grant: principal.grant }, + { ...realtime, grant: { kind: 'project_discovery' } }, { ...realtime, invocation: principal.invocation }, { ...realtime, invocation: { kind: 'realtime', connectionId: '' } }, ]) { expect(() => requireResourceDelegation( candidate as never, - { ...policy, scope: candidate.scope } as never + { ...policy, scope: candidate.grant } as never ) ).toThrow('Resource delegation is no longer valid') } @@ -156,15 +178,14 @@ describe('resource delegation authorization', () => { it('keeps realtime collection observation separate from entity and file authority', () => { const scope = { kind: 'file_collection_observation', - entityType: 'project', - entityId: 'owner', + owner: { entityType: 'project', entityId: 'owner' }, } as const const observer = { ...principal, audience: 'sim:file-list-observation', serviceId: 'realtime', invocation: { kind: 'realtime', connectionId: 'socket' }, - scope, + grant: scope, } as const const observation = { ...policy, @@ -174,10 +195,10 @@ describe('resource delegation authorization', () => { } as const expect(requireResourceDelegation(observer, observation)).toBe('acting-user') for (const required of [ - { kind: 'entity', entityType: 'project', entityId: 'owner' }, - { kind: 'entity', entityType: 'project', entityId: 'owner', fileId: 'file' }, - { ...scope, entityId: 'another-owner' }, - { kind: 'entity', entityType: 'workspace', entityId: 'owner' }, + { kind: 'entity', owner: { entityType: 'project', entityId: 'owner' } }, + { kind: 'entity', fileId: 'file', owner: { entityType: 'project', entityId: 'owner' } }, + { ...scope, owner: { ...scope.owner, entityId: 'another-owner' } }, + { kind: 'entity', owner: { entityType: 'workspace', entityId: 'owner' } }, ]) { expect(() => requireResourceDelegation(observer, { ...observation, scope: required } as never) @@ -187,20 +208,23 @@ describe('resource delegation authorization', () => { requireResourceDelegation( { ...observer, - scope: { kind: 'entity', entityType: 'project', entityId: 'owner' }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: 'owner' } }, } as never, observation ) ).toThrow('Resource delegation is no longer valid') expect(() => requireResourceDelegation( - { ...observer, scope: { ...scope, entityType: 'workspace' } } as never, + { + ...observer, + grant: { ...scope, owner: { ...scope.owner, entityType: 'workspace' } }, + } as never, observation ) ).toThrow('Resource delegation is no longer valid') expect(() => requireResourceDelegation( - { ...observer, scope: { ...scope, fileId: 'invented-file' } } as never, + { ...observer, grant: { ...scope, fileId: 'invented-file' } } as never, observation ) ).toThrow('Resource delegation is no longer valid') @@ -212,22 +236,53 @@ describe('resource delegation authorization', () => { ).toThrow('Resource delegation is no longer valid') }) + it('admits neither workspace nor resource operations for a workspace grant with a top-level file id', () => { + const illegal = { + ...principal, + invocation: { kind: 'workspace' as const, workspaceId: 'workspace-1' }, + grant: { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + fileId: 'file-1', + }, + } + const operation = defineWorkspaceOperation({ + id: 'files.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + contentDelivery: 'none', + capability: 'none', + }) + expect(isWorkspaceDelegation(illegal)).toBe(false) + expect(hasResourceOperationGrant(illegal)).toBe(false) + expect(principalWorkspaceId(illegal)).toBeUndefined() + expect(() => requireAllowedWorkspacePrincipal(illegal, operation)).toThrow( + expect.objectContaining({ detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED' }) + ) + expect(() => requireResourceDelegation(illegal, policy)).toThrow( + 'Resource delegation is no longer valid' + ) + }) + it('cannot be admitted to workspace operations or persisted as workflow authority', () => { - expect(() => - defineWorkspaceOperation({ - id: 'files.read', - minimumRole: 'read', - workspaceApiKey: 'deny', - principalKinds: ['resource_delegated'], - capability: 'none', - }) - ).toThrow('cannot accept resource delegation') + const operation = defineWorkspaceOperation({ + id: 'files.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + contentDelivery: 'none', + capability: 'none', + }) + expect(() => requireAllowedWorkspacePrincipal(principal, operation)).toThrow( + 'cannot perform operation' + ) expect(() => serializePrincipal(principal as never)).toThrow( 'Principal cannot be persisted for workflow execution' ) - expect(() => parsePrincipal({ version: 1, principal })).toThrow( - 'Resource delegation cannot be persisted for workflow execution' - ) + expect(() => parsePrincipal({ version: 1, principal })).toThrow() }) it('binds copy to both owners, the exact requested selection, and one destination folder', () => { @@ -243,7 +298,7 @@ describe('resource delegation authorization', () => { folderId: null, }, } as const - const copy = { ...principal, audience: 'sim:files:copy', scope } + const copy = { ...principal, audience: 'sim:files:copy', grant: scope } const required = { ...policy, audience: copy.audience, services: ['copilot'], scope } as const expect(requireResourceDelegation(copy, required)).toBe('acting-user') expect( @@ -285,7 +340,7 @@ describe('resource delegation authorization', () => { { ...scope, destination: { ...scope.destination, folderPath: 'unresolved' } }, ]) expect(() => - requireResourceDelegation({ ...copy, scope: malformed } as never, required) + requireResourceDelegation({ ...copy, grant: malformed } as never, required) ).toThrow('Resource delegation is no longer valid') for (const candidate of [ { ...principal, audience: copy.audience }, diff --git a/apps/sim/lib/core/application/resource-delegation.ts b/apps/sim/lib/core/application/resource-delegation.ts index 4956229fda6..983d154f010 100644 --- a/apps/sim/lib/core/application/resource-delegation.ts +++ b/apps/sim/lib/core/application/resource-delegation.ts @@ -1,15 +1,15 @@ import type { Principal, - ResourceDelegatedPrincipal, ResourceDelegationScope, - ResourceFileCopyScope, + ResourceOperationPrincipal, } from '@sim/auth/principal' +import { hasResourceOperationGrant, isResourceDelegationScope } from '@sim/auth/principal' import { isRecordLike } from '@sim/utils/object' import { OrchestrationError } from '@/lib/core/orchestration/types' export interface ResourceDelegationPolicy { audience: string - services: readonly ResourceDelegatedPrincipal['serviceId'][] + services: readonly ResourceOperationPrincipal['serviceId'][] scope: ResourceDelegationScope maxTtlMs: number } @@ -18,77 +18,12 @@ function isIdentity(value: unknown): value is string { return typeof value === 'string' && value.length > 0 && value === value.trim() } -function isCopyOwner(value: unknown): boolean { - return ( - isRecordLike(value) && - (value.entityType === 'workspace' || value.entityType === 'project') && - isIdentity(value.entityId) && - Object.keys(value).length === 2 && - Object.keys(value).every((key) => key === 'entityType' || key === 'entityId') - ) -} - -function isSelectionIds(value: unknown): value is string[] { - return ( - Array.isArray(value) && - value.length <= 1_000 && - Array.from(value).every(isIdentity) && - new Set(value).size === value.length - ) -} - -/** Validates the paired scope shared by delegation admission and the copy application boundary. */ -export function isResourceFileCopyScope(scope: unknown): scope is ResourceFileCopyScope { - if (!isRecordLike(scope) || scope.kind !== 'file_copy') return false - const { source, destination } = scope - return ( - Object.keys(scope).length === 3 && - Object.keys(scope).every((key) => ['kind', 'source', 'destination'].includes(key)) && - isRecordLike(source) && - Object.keys(source).length === 3 && - Object.keys(source).every((key) => ['owner', 'fileIds', 'folderIds'].includes(key)) && - isCopyOwner(source.owner) && - isSelectionIds(source.fileIds) && - isSelectionIds(source.folderIds) && - source.fileIds.length + source.folderIds.length > 0 && - isRecordLike(destination) && - Object.keys(destination).length === 2 && - Object.keys(destination).every((key) => key === 'owner' || key === 'folderId') && - isCopyOwner(destination.owner) && - (destination.folderId === null || isIdentity(destination.folderId)) - ) -} - -function isValidScope(scope: unknown): scope is ResourceDelegationScope { - if (!isRecordLike(scope)) return false - if (scope.kind === 'project_discovery') return Object.keys(scope).length === 1 - if (scope.kind === 'file_copy') return isResourceFileCopyScope(scope) - if (scope.kind === 'file_collection_observation') { - return ( - scope.entityType === 'project' && - isIdentity(scope.entityId) && - Object.keys(scope).length === 3 && - Object.keys(scope).every((key) => ['kind', 'entityType', 'entityId'].includes(key)) - ) - } - return ( - scope.kind === 'entity' && - (scope.entityType === 'workspace' || - scope.entityType === 'project' || - scope.entityType === 'organization' || - scope.entityType === 'user') && - isIdentity(scope.entityId) && - (scope.fileId === undefined || isIdentity(scope.fileId)) && - Object.keys(scope).every((key) => ['kind', 'entityType', 'entityId', 'fileId'].includes(key)) - ) -} - -function isValidInvocation(principal: ResourceDelegatedPrincipal): boolean { +function isValidInvocation(principal: ResourceOperationPrincipal): boolean { const invocation = principal.invocation if (!isRecordLike(invocation) || Object.keys(invocation).length !== 2) return false if (principal.serviceId === 'copilot') { return ( - principal.scope.kind !== 'file_collection_observation' && + principal.grant.kind !== 'file_collection_observation' && ((invocation.kind === 'chat' && isIdentity(invocation.chatId)) || (invocation.kind === 'workspace' && isIdentity(invocation.workspaceId))) ) @@ -97,8 +32,8 @@ function isValidInvocation(principal: ResourceDelegatedPrincipal): boolean { principal.serviceId === 'realtime' && invocation.kind === 'realtime' && isIdentity(invocation.connectionId) && - ((principal.scope.kind === 'entity' && isIdentity(principal.scope.fileId)) || - principal.scope.kind === 'file_collection_observation') + ((principal.grant.kind === 'entity' && isIdentity(principal.grant.fileId)) || + principal.grant.kind === 'file_collection_observation') ) } @@ -111,8 +46,8 @@ function matchesScope( if (granted.kind === 'file_collection_observation') { return ( required.kind === 'file_collection_observation' && - granted.entityType === required.entityType && - granted.entityId === required.entityId + granted.owner.entityType === required.owner.entityType && + granted.owner.entityId === required.owner.entityId ) } if (granted.kind === 'file_copy') { @@ -133,8 +68,8 @@ function matchesScope( } return ( required.kind === 'entity' && - granted.entityType === required.entityType && - granted.entityId === required.entityId && + granted.owner.entityType === required.owner.entityType && + granted.owner.entityId === required.owner.entityId && (granted.fileId === undefined || granted.fileId === required.fileId) ) } @@ -150,14 +85,16 @@ export function requireResourceDelegation( policy.maxTtlMs <= 0 || policy.services.length === 0 || !policy.services.every((service) => service === 'copilot' || service === 'realtime') || - !isValidScope(policy.scope) + !isResourceDelegationScope(policy.scope) ) { throw new Error('Resource delegation requires a bounded operation policy') } const now = Date.now() if ( - principal.kind !== 'resource_delegated' || + !hasResourceOperationGrant(principal) || + ('workspaceId' in principal && principal.workspaceId !== undefined) || + ('resourceScope' in principal && principal.resourceScope !== undefined) || !policy.services.includes(principal.serviceId) || principal.audience !== policy.audience || !isIdentity(principal.subjectUserId) || @@ -169,9 +106,9 @@ export function requireResourceDelegation( principal.issuedAt.getTime() > now || principal.expiresAt.getTime() <= now || principal.expiresAt.getTime() - principal.issuedAt.getTime() > policy.maxTtlMs || - !isValidScope(principal.scope) || + !isResourceDelegationScope(principal.grant) || !isValidInvocation(principal) || - !matchesScope(principal.scope, policy.scope) + !matchesScope(principal.grant, policy.scope) ) { throw new OrchestrationError('forbidden', 'Resource delegation is no longer valid') } diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index 0a906477d2e..123f1aa67e6 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -1,4 +1,5 @@ -import type { DelegatedPrincipal, OAuthAccessTokenPrincipal } from '@sim/auth/principal' +import type { OAuthAccessTokenPrincipal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { createDelegatedPrincipal } from '@sim/testing/factories' import { createPersonalApiKeyPrincipal, createSessionPrincipal, @@ -63,9 +64,9 @@ const executorOperation = defineWorkspaceOperation({ }) function executorPrincipal( - originalPrincipal: NonNullable['principal'], - currentWorkflow?: NonNullable['currentWorkflow'] -): DelegatedPrincipal { + originalPrincipal: NonNullable['principal'], + currentWorkflow?: NonNullable['currentWorkflow'] +): WorkspaceDelegatedPrincipal { return { kind: 'delegated', serviceId: 'executor', @@ -360,12 +361,10 @@ describe('authorizeWorkspaceOperation permission-group capability', () => { await expect( authorizeWorkspaceOperation( { - ...executorPrincipal(createSessionPrincipal(), { - workflowId: 'current-workflow-1', - mode: 'draft', + ...createDelegatedPrincipal({ + audience: 'sim:test', + subjectUserId: 'user-1', }), - serviceId: 'copilot', - subjectUserId: 'user-1', }, copilotCapabilityOperation, context, @@ -540,11 +539,7 @@ describe('capabilityGovernedPrincipalUserId', () => { it('names the person a non-executor delegation acts as', () => { expect( - capabilityGovernedPrincipalUserId({ - ...executorPrincipal(undefined), - serviceId: 'copilot', - subjectUserId: 'user-3', - }) + capabilityGovernedPrincipalUserId(createDelegatedPrincipal({ subjectUserId: 'user-3' })) ).toBe('user-3') }) }) diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 67de9320502..ed0ad831fff 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -1,9 +1,11 @@ import { - type DelegatedPrincipal, + isWorkspaceDelegation, type OAuthAccessTokenPrincipal, type PersonalApiKeyPrincipal, type Principal, + principalWorkspaceId, resolvePrincipalSubject, + type WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { type PermissionType, @@ -55,7 +57,6 @@ export function capabilityGovernedPrincipalUserId(principal: Principal): string case 'slack_installation': case 'slack_app': return null - case 'resource_delegated': case 'organization_delegated': return principal.subjectUserId case 'delegated': { @@ -74,7 +75,7 @@ export interface WorkspaceAuthorizationContext { export interface WorkspaceDelegationPolicy { audience: string - isWithinScope(principal: DelegatedPrincipal, context: C): boolean + isWithinScope(principal: WorkspaceDelegatedPrincipal, context: C): boolean } export interface WorkspaceAuthorizationOptions { @@ -152,7 +153,7 @@ export class PrincipalKindAuthorizationError extends ForbiddenOperationError { * `detailCode`. */ export class DelegatedServiceAuthorizationError extends OrchestrationError { - constructor(serviceId: DelegatedPrincipal['serviceId'], operationId: string) { + constructor(serviceId: WorkspaceDelegatedPrincipal['serviceId'], operationId: string) { super('forbidden', `Delegated service ${serviceId} cannot perform operation ${operationId}`) this.name = 'DelegatedServiceAuthorizationError' } @@ -181,6 +182,8 @@ export function requireAllowedWorkspacePrincipal( } requireOAuthOperationScope(principal, operation) if (principal.kind !== 'delegated') return + if (!isWorkspaceDelegation(principal)) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) const delegatedServices = operation.delegatedServices if (!delegatedServices?.length) { @@ -420,7 +423,7 @@ export async function authorizeWorkspaceOperation = 'delegated' extends O['principalKinds'][number] - ? DelegatedPrincipal & { serviceId: NonNullable[number] } + ? WorkspaceDelegatedPrincipal & { serviceId: NonNullable[number] } : never export type PrincipalForOperation< @@ -116,9 +121,6 @@ export function defineWorkspaceOperation< if (operation.principalKinds.includes('organization_delegated')) { throw new Error(`Workspace operation ${operation.id} cannot accept organization delegation`) } - if (operation.principalKinds.includes('resource_delegated')) { - throw new Error(`Workspace operation ${operation.id} cannot accept resource delegation`) - } const allowsWorkspaceApiKey = operation.principalKinds.includes('workspace_api_key') if (allowsWorkspaceApiKey !== (operation.workspaceApiKey === 'allow')) { diff --git a/apps/sim/lib/credential-groups/application/authorization.test.ts b/apps/sim/lib/credential-groups/application/authorization.test.ts index d24291330e4..8a546818454 100644 --- a/apps/sim/lib/credential-groups/application/authorization.test.ts +++ b/apps/sim/lib/credential-groups/application/authorization.test.ts @@ -1,4 +1,7 @@ -import type { DelegatedPrincipal, WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import type { + WorkflowExecutionDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' import { credentialGroupsAvailabilityMock, credentialGroupsAvailabilityMockFns, @@ -102,21 +105,28 @@ function executorPrincipal(): WorkflowExecutionDelegatedPrincipal { } } -function copilotPrincipal(subjectUserId: string | null = 'user-1'): DelegatedPrincipal { +function copilotPrincipal(subjectUserId: string | null = 'user-1'): WorkspaceDelegatedPrincipal { return { kind: 'delegated', serviceId: 'copilot', ...(subjectUserId ? { subjectUserId } : {}), - workspaceId: 'workspace-1', delegationId: 'copilot-tool:call-1', audience: 'sim:managed-oauth-credentials', issuedAt: new Date(Date.now() - 1_000), expiresAt: new Date(Date.now() + 60_000), - resourceScope: { credentialId: 'credential-1', chatId: 'chat-1' }, + invocation: { kind: 'chat', chatId: 'chat-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { credentialId: 'credential-1', chatId: 'chat-1' }, + } as const, } } -function requireAccess(principal: DelegatedPrincipal, accessContext = context): Promise { +function requireAccess( + principal: WorkspaceDelegatedPrincipal, + accessContext = context +): Promise { return requireCredentialGroupCredentialAccess( principal, accessContext, diff --git a/apps/sim/lib/credential-groups/application/authorization.ts b/apps/sim/lib/credential-groups/application/authorization.ts index 2d6b92d3379..957325b9d6b 100644 --- a/apps/sim/lib/credential-groups/application/authorization.ts +++ b/apps/sim/lib/credential-groups/application/authorization.ts @@ -4,6 +4,7 @@ import { resolvePrincipalSubject, type WorkflowExecutionAuthority, type WorkflowExecutionPrincipal, + workspaceDelegationRestrictions, } from '@sim/auth/principal' import type { WorkspaceAuthorizationContext, @@ -38,7 +39,7 @@ export const workspaceAccountsSettingsDelegationPolicy = { isWithinScope: (principal: Extract) => typeof principal.subjectUserId === 'string' && principal.subjectUserId.trim().length > 0 && - Object.keys(principal.resourceScope ?? {}).every( + Object.keys(workspaceDelegationRestrictions(principal) ?? {}).every( (key) => key === 'chatId' || key === 'executionId' ), } satisfies WorkspaceDelegationPolicy @@ -214,5 +215,5 @@ export async function requireCredentialGroupCredentialAccess( export const credentialGroupDelegationPolicy = { audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, isWithinScope: (principal: Extract) => - principal.resourceScope === undefined, + workspaceDelegationRestrictions(principal) === undefined, } satisfies WorkspaceDelegationPolicy diff --git a/apps/sim/lib/credential-groups/application/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index 75cdcccb137..1c93a879f85 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -1,7 +1,8 @@ -import type { CredentialGroupEnrollmentPrincipal, DelegatedPrincipal } from '@sim/auth/principal' +import type { CredentialGroupEnrollmentPrincipal } from '@sim/auth/principal' import { auditMock, auditMockFns } from '@sim/testing' import { createDelegatedPrincipal, + createExecutorPrincipal, createSessionPrincipal, } from '@sim/testing/factories/principal.factory' import { @@ -68,16 +69,14 @@ const enrollmentPrincipal: CredentialGroupEnrollmentPrincipal = { invitationTokenHash: 'hash-1', } -function copilotPrincipal(overrides: Partial = {}): DelegatedPrincipal { - return { - ...createDelegatedPrincipal({ - subjectUserId: 'admin-1', - delegationId: 'copilot-tool:tool-1', - audience: 'sim:credential-groups', - resourceScope: { chatId: 'chat-1' }, - }), +function copilotPrincipal(overrides: Parameters[0] = {}) { + return createDelegatedPrincipal({ + subjectUserId: 'admin-1', + delegationId: 'copilot-tool:tool-1', + audience: 'sim:credential-groups', + resourceScope: { chatId: 'chat-1' }, ...overrides, - } + }) } describe('Credential Group Settings application operations', () => { @@ -133,7 +132,10 @@ describe('Credential Group Settings application operations', () => { ])('rejects invalid account-read delegation %j', async (overrides) => { await expect( getWorkspaceAccountsSettings.execute({ - principal: copilotPrincipal(overrides), + principal: + overrides.serviceId === 'executor' + ? createExecutorPrincipal({ audience: 'sim:credential-groups' }) + : copilotPrincipal(overrides), input: { workspaceId: 'workspace-1' }, }) ).rejects.toMatchObject({ code: 'forbidden' }) diff --git a/apps/sim/lib/credentials/application/authorization.ts b/apps/sim/lib/credentials/application/authorization.ts index acee7eea975..592b955a9cd 100644 --- a/apps/sim/lib/credentials/application/authorization.ts +++ b/apps/sim/lib/credentials/application/authorization.ts @@ -1,4 +1,8 @@ -import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' +import { + type Principal, + resolvePrincipalExecutionActorUserId, + workspaceDelegationRestrictions, +} from '@sim/auth/principal' import type { WorkspaceDelegationPolicy } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { ManagedMcpCredentialApplicationContext } from '@/lib/credentials/managed-mcp' @@ -22,7 +26,7 @@ export const managedOAuthCredentialDelegationPolicy = { isWithinScope: ( principal: Extract, context: ManagedOAuthCredentialApplicationContext - ) => principal.resourceScope?.credentialId === context.credentialId, + ) => workspaceDelegationRestrictions(principal)?.credentialId === context.credentialId, } satisfies WorkspaceDelegationPolicy export const managedMcpCredentialDelegationPolicy = { @@ -30,7 +34,7 @@ export const managedMcpCredentialDelegationPolicy = { isWithinScope: ( principal: Extract, context: ManagedMcpCredentialApplicationContext - ) => principal.resourceScope?.credentialId === context.credentialId, + ) => workspaceDelegationRestrictions(principal)?.credentialId === context.credentialId, } satisfies WorkspaceDelegationPolicy /** diff --git a/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.test.ts b/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.test.ts index d7632f2db9e..612b1273959 100644 --- a/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.test.ts +++ b/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.test.ts @@ -17,10 +17,14 @@ describe('createCopilotManagedOAuthPrincipal', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-tool:call-1', audience: 'sim:managed-oauth-credentials', - resourceScope: { credentialId: 'credential-1', chatId: 'chat-1' }, + invocation: { kind: 'chat', chatId: 'chat-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { credentialId: 'credential-1', chatId: 'chat-1' }, + } as const, }) expect(principal.expiresAt.getTime()).toBeGreaterThan(principal.issuedAt.getTime()) }) diff --git a/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.ts b/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.ts index e053b43befd..3d39e54b9cd 100644 --- a/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.ts +++ b/apps/sim/lib/credentials/application/copilot-managed-oauth-delegation.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { MANAGED_OAUTH_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, @@ -17,7 +17,7 @@ import { export function createCopilotManagedOAuthPrincipal( context: CopilotExecutionContext | undefined, credentialId: string -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { const trustedContext = requireTrustedCopilotExecutionContext(context) return createCopilotApplicationPrincipal(trustedContext, { audience: MANAGED_OAUTH_DELEGATION_AUDIENCE, diff --git a/apps/sim/lib/credentials/application/personal-credentials.test.ts b/apps/sim/lib/credentials/application/personal-credentials.test.ts index 625a4b33b39..b24818bc23f 100644 --- a/apps/sim/lib/credentials/application/personal-credentials.test.ts +++ b/apps/sim/lib/credentials/application/personal-credentials.test.ts @@ -1,4 +1,3 @@ -import type { SubjectDelegatedPrincipal } from '@sim/auth/principal' import { resetDbChainMock } from '@sim/testing' import { createDelegatedPrincipal, @@ -58,7 +57,7 @@ const authorizationInput = { expectedProviderId: 'google-drive', } -function delegatedPrincipal(overrides: Partial = {}) { +function delegatedPrincipal(overrides: Parameters[0] = {}) { return createDelegatedPrincipal({ delegationId: 'assistant-turn-1', audience: 'sim:credentials', @@ -147,7 +146,7 @@ describe('personal credential application access', () => { it.each([ ['another workspace', { workspaceId: 'workspace-2' }], ['no person', { subjectUserId: undefined }], - ] satisfies Array<[string, Partial]>)( + ] satisfies Array<[string, Parameters[0]]>)( 'refuses %s delegations', async (_, overrides) => { await expect( diff --git a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts index f614c563a9d..3f8220fc6d2 100644 --- a/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts +++ b/apps/sim/lib/credentials/application/resolve-managed-oauth-token.ts @@ -1,5 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import type { Principal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { requireCredentialGroupCredentialAccess } from '@/lib/credential-groups/application/authorization' @@ -31,7 +32,7 @@ export const resolveManagedOAuthCredentialToken = defineAuthorizedWorkspaceUseCa throw new OrchestrationError('forbidden', 'Managed credentials require delegated execution') const context = await loadManagedOAuthCredentialApplicationContext( input.credentialId, - principal.workspaceId + principalWorkspaceId(principal) ) if (!context) throw new OrchestrationError('not_found', 'Managed credential not found') return context diff --git a/apps/sim/lib/credentials/application/resolve-workflow-credentials.ts b/apps/sim/lib/credentials/application/resolve-workflow-credentials.ts index d771a44b67f..a72d774e750 100644 --- a/apps/sim/lib/credentials/application/resolve-workflow-credentials.ts +++ b/apps/sim/lib/credentials/application/resolve-workflow-credentials.ts @@ -1,3 +1,4 @@ +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { db } from '@sim/db' import { credential } from '@sim/db/schema' import { and, asc, eq, inArray } from 'drizzle-orm' @@ -33,7 +34,7 @@ export const resolveWorkflowCredentials = defineAuthorizedWorkspaceUseCase({ authorizationOptions: { delegation: { audience: CREDENTIAL_GROUP_DELEGATION_AUDIENCE, - isWithinScope: (principal) => principal.resourceScope === undefined, + isWithinScope: (principal) => workspaceDelegationRestrictions(principal) === undefined, }, }, authorizeResource: ({ principal }) => { diff --git a/apps/sim/lib/custom-tools/application/use-cases.test.ts b/apps/sim/lib/custom-tools/application/use-cases.test.ts index 9eb3d14594c..e13718287c7 100644 --- a/apps/sim/lib/custom-tools/application/use-cases.test.ts +++ b/apps/sim/lib/custom-tools/application/use-cases.test.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' @@ -120,7 +120,9 @@ describe('custom tool application use cases', () => { }) describe('delegated custom-tool resolution', () => { - function executorPrincipal(overrides: Partial = {}): DelegatedPrincipal { + function executorPrincipal( + overrides: Partial = {} + ): WorkflowExecutionDelegatedPrincipal { return { kind: 'delegated' as const, serviceId: 'executor' as const, diff --git a/apps/sim/lib/execution/files.test.ts b/apps/sim/lib/execution/files.test.ts index d743ef80799..bbbc4d92847 100644 --- a/apps/sim/lib/execution/files.test.ts +++ b/apps/sim/lib/execution/files.test.ts @@ -1,4 +1,8 @@ import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' +import { + createDelegatedPrincipal, + createSessionPrincipal, +} from '@sim/testing/factories/principal.factory' import { fileUtilsServerMock, fileUtilsServerMockFns, @@ -305,7 +309,7 @@ describe('workflow input files', () => { expect(mockGeneratePresignedDownloadUrl).toHaveBeenCalledWith(stored.key, 'workspace', 300) await assertUserFileContentAccess(file, { ...scope, - principal: { kind: 'session', userId: 'actor' }, + principal: createSessionPrincipal({ userId: 'actor' }), }) expect(mocks.readWorkspace).toHaveBeenCalledWith( expect.objectContaining({ @@ -545,7 +549,14 @@ describe('workflow input files', () => { expiresAt: new Date(), }, { kind: 'workspace_api_key', workspaceId, keyId: 'key' }, - { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user', ...delegation }, + createDelegatedPrincipal({ + subjectUserId: 'user', + workspaceId, + delegationId: delegation.delegationId, + audience: delegation.audience, + issuedAt: delegation.issuedAt, + expiresAt: delegation.expiresAt, + }), ])('lets authorized member principal $kind reference workspace files', (principal) => { expect(getStoredFileReferenceScope(principal)).toBe('workspace') }) diff --git a/apps/sim/lib/execution/payloads/materialization.server.test.ts b/apps/sim/lib/execution/payloads/materialization.server.test.ts index 53145592dad..5ee06315101 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.test.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.test.ts @@ -365,7 +365,10 @@ describe('readUserFileContent', () => { )( 'preserves delegated file/chat limits after canonical rejection: %j', async ({ context, code, resourceScope }) => { - const principal = { ...delegatedReader, resourceScope } + const principal = { + ...delegatedReader, + grant: { ...delegatedReader.grant, restrictions: resourceScope }, + } mockReadWorkspaceFileByKey.mockRejectedValue(new OrchestrationError(code, 'Denied')) await expect( @@ -376,7 +379,9 @@ describe('readUserFileContent', () => { ).rejects.toMatchObject({ code }) expect(mockReadWorkspaceFileByKey).toHaveBeenCalledWith( expect.objectContaining({ - principal: expect.objectContaining({ resourceScope: principal.resourceScope }), + principal: expect.objectContaining({ + grant: expect.objectContaining({ restrictions: resourceScope }), + }), }) ) expect(mockVerifyFileAccess).not.toHaveBeenCalled() @@ -417,7 +422,13 @@ describe('readUserFileContent', () => { [ createSessionPrincipal({ userId: 'reader' }), delegatedReader, - { ...delegatedReader, resourceScope: { fileId: 'file-1', chatId: 'chat-1' } }, + { + ...delegatedReader, + grant: { + ...delegatedReader.grant, + restrictions: { fileId: 'file-1', chatId: 'chat-1' }, + }, + }, ].map((principal) => ({ context, principal })) ) )( diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index 1fc17b1f446..d02e626d100 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { isWorkspaceDelegation, workspaceDelegationRestrictions } from '@sim/auth/principal' import { createLogger, type Logger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -287,21 +288,20 @@ export async function assertUserFileContentAccess( } if (context === 'workspace' && options.principal && options.workspaceId) { - const principal = - options.principal.kind === 'delegated' - ? rebindWorkspaceFileDelegatedPrincipal({ - principal: options.principal, - workspaceId: options.workspaceId, - delegationId: `execution-file-read:${options.requestId ?? 'unknown'}`, - ...(options.principal.resourceScope?.fileId - ? { fileId: options.principal.resourceScope.fileId } - : {}), - ...(options.principal.resourceScope?.chatId - ? { chatId: options.principal.resourceScope.chatId } - : {}), - ...(options.executionId ? { executionId: options.executionId } : {}), - }) - : options.principal + const principal = isWorkspaceDelegation(options.principal) + ? rebindWorkspaceFileDelegatedPrincipal({ + principal: options.principal, + workspaceId: options.workspaceId, + delegationId: `execution-file-read:${options.requestId ?? 'unknown'}`, + ...(workspaceDelegationRestrictions(options.principal)?.fileId + ? { fileId: workspaceDelegationRestrictions(options.principal)?.fileId } + : {}), + ...(workspaceDelegationRestrictions(options.principal)?.chatId + ? { chatId: workspaceDelegationRestrictions(options.principal)?.chatId } + : {}), + ...(options.executionId ? { executionId: options.executionId } : {}), + }) + : options.principal try { await readStoredWorkspaceFileRecordByKey.execute({ principal, @@ -317,8 +317,8 @@ export async function assertUserFileContentAccess( /** Legacy storage metadata cannot prove a delegated file or chat identity. */ if ( options.principal.kind === 'delegated' && - (options.principal.resourceScope?.fileId !== undefined || - options.principal.resourceScope?.chatId !== undefined) + (workspaceDelegationRestrictions(options.principal)?.fileId !== undefined || + workspaceDelegationRestrictions(options.principal)?.chatId !== undefined) ) { throw error } diff --git a/apps/sim/lib/function-execution/application/authorization.ts b/apps/sim/lib/function-execution/application/authorization.ts index 8a8775e04cc..387055d419c 100644 --- a/apps/sim/lib/function-execution/application/authorization.ts +++ b/apps/sim/lib/function-execution/application/authorization.ts @@ -1,4 +1,5 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' export const FUNCTION_EXECUTION_DELEGATION_AUDIENCE = 'sim:function-executions' @@ -10,10 +11,10 @@ export interface FunctionExecutionAuthorizationContext extends ActiveWorkspaceAp export const functionExecutionDelegationPolicy = { audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, isWithinScope: ( - principal: DelegatedPrincipal, + principal: WorkspaceDelegatedPrincipal, context: FunctionExecutionAuthorizationContext ): boolean => { - const scopedExecutionId = principal.resourceScope?.executionId + const scopedExecutionId = workspaceDelegationRestrictions(principal)?.executionId return context.executionId ? scopedExecutionId === context.executionId : scopedExecutionId === undefined diff --git a/apps/sim/lib/function-execution/execute-request.ts b/apps/sim/lib/function-execution/execute-request.ts index cc99363f6e3..259b4fd13b7 100644 --- a/apps/sim/lib/function-execution/execute-request.ts +++ b/apps/sim/lib/function-execution/execute-request.ts @@ -1,7 +1,7 @@ import type { - DelegatedPrincipal, OrganizationDelegatedPrincipal, Principal, + WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' @@ -1004,7 +1004,7 @@ function serializeForShellEnv(value: unknown, nullValue = ''): string { } interface FunctionRouteExecutionContext { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal workflowId?: string workspaceId?: string executionId?: string @@ -2243,7 +2243,7 @@ async function collectSandboxOutputFiles(args: { export interface TrustedFunctionExecutionAuth { attributedUserId: string fileAccessUserId?: string - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal sandboxProfile?: 'mothership' resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry } diff --git a/apps/sim/lib/internal/deployments/client.test.ts b/apps/sim/lib/internal/deployments/client.test.ts index 7c120cf577a..4a0bd9daeeb 100644 --- a/apps/sim/lib/internal/deployments/client.test.ts +++ b/apps/sim/lib/internal/deployments/client.test.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ @@ -25,7 +25,7 @@ vi.mock('@/lib/workflows/application/read-workflow-version', () => ({ import { deployWorkflowDeployment } from '@/lib/internal/deployments/client' -const principal: DelegatedPrincipal = { +const principal: WorkspaceDelegatedPrincipal = { kind: 'delegated', serviceId: 'executor', subjectUserId: 'user-1', diff --git a/apps/sim/lib/internal/deployments/client.ts b/apps/sim/lib/internal/deployments/client.ts index 8051584b0c3..4fdd75dd9cd 100644 --- a/apps/sim/lib/internal/deployments/client.ts +++ b/apps/sim/lib/internal/deployments/client.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import type { DeploymentsDeployBody, DeploymentsGetVersionQuery, @@ -15,7 +15,7 @@ import { listWorkflowVersions } from '@/lib/workflows/application/list-workflow- import { readWorkflowVersion } from '@/lib/workflows/application/read-workflow-version' export interface DeploymentApplicationClientContext { - principal: DelegatedPrincipal + principal: WorkspaceDelegatedPrincipal requestId: string signal?: AbortSignal } diff --git a/apps/sim/lib/internal/function/execute.ts b/apps/sim/lib/internal/function/execute.ts index 81000f13976..2b1e528acb8 100644 --- a/apps/sim/lib/internal/function/execute.ts +++ b/apps/sim/lib/internal/function/execute.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import type { FunctionExecuteBody } from '@/lib/api/contracts' import type { InternalSandboxProfile } from '@/lib/auth/internal' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits' @@ -78,19 +78,23 @@ export async function executeFunctionTool(input: ExecuteFunctionToolInput): Prom }, }) } - let principal: DelegatedPrincipal + let principal: WorkspaceDelegatedPrincipal if (context.copilotToolExecution === true) { if (!context.userId) throw new Error('Copilot Function execution requires a user') principal = { kind: 'delegated', serviceId: 'copilot', subjectUserId: context.userId, - workspaceId: context.workspaceId, delegationId: `function-execute:${requestId}`, audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE, issuedAt, expiresAt, - ...(context.executionId ? { resourceScope: { executionId: context.executionId } } : {}), + invocation: { kind: 'workspace', workspaceId: context.workspaceId }, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: context.workspaceId }, + ...(context.executionId ? { restrictions: { executionId: context.executionId } } : {}), + }, } } else { principal = await createExecutorPrincipalFromExecutionContext({ diff --git a/apps/sim/lib/internal/knowledge/operations.ts b/apps/sim/lib/internal/knowledge/operations.ts index d2bdd25c6f4..812b8eec60a 100644 --- a/apps/sim/lib/internal/knowledge/operations.ts +++ b/apps/sim/lib/internal/knowledge/operations.ts @@ -1,4 +1,5 @@ import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import type { z } from 'zod' import { type createChunkBodySchema, @@ -115,7 +116,7 @@ export async function listDocumentsOperation( principal: context.principal, input: { knowledgeBaseId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), enabledFilter: query.enabledFilter, search: query.search, limit: query.limit, @@ -162,7 +163,7 @@ export async function createDocumentsOperation( const documents = bodyInput.bulk ? bodyInput.documents : [bodyInput] const input = { knowledgeBaseId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), documents, bulk: bodyInput.bulk, processingOptions: bodyInput.bulk ? bodyInput.processingOptions : undefined, @@ -227,7 +228,7 @@ export async function readDocumentOperation( input: { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), }, request: { headers: context.headers }, }) @@ -263,7 +264,7 @@ export async function deleteDocumentOperation( const input = { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), source: 'ui', } const result = await deleteKnowledgeDocument.execute({ @@ -289,7 +290,7 @@ export async function upsertDocumentOperation( throwIfAborted(context) const input = { knowledgeBaseId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), documentId: bodyInput.documentId, filename: bodyInput.filename, fileUrl: bodyInput.fileUrl, @@ -370,7 +371,7 @@ export async function listChunksOperation( input: { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), ...query, }, request: { headers: context.headers }, @@ -413,7 +414,7 @@ export async function createChunkOperation( input: { knowledgeBaseId, documentId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), content: bodyInput.content, enabled: bodyInput.enabled, resolveContentProvenance: ({ workspaceId }) => @@ -448,7 +449,7 @@ export async function updateChunkOperation( knowledgeBaseId, documentId, chunkId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), content: bodyInput.content, enabled: bodyInput.enabled, resolveContentProvenance: ({ workspaceId }) => @@ -498,7 +499,7 @@ export async function deleteChunkOperation( knowledgeBaseId, documentId, chunkId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), }, request: { headers: context.headers }, }) @@ -513,7 +514,7 @@ export async function listConnectorsOperation( throwIfAborted(context) const result = await listKnowledgeConnectors.execute({ principal: context.principal, - input: { knowledgeBaseId, assertedWorkspaceId: context.principal.workspaceId }, + input: { knowledgeBaseId, assertedWorkspaceId: principalWorkspaceId(context.principal) }, request: { headers: context.headers }, }) throwIfAborted(context) @@ -533,7 +534,7 @@ export async function readConnectorOperation( input: { knowledgeBaseId, connectorId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), }, request: { headers: context.headers }, }) @@ -551,7 +552,7 @@ export async function syncConnectorOperation( const input = { knowledgeBaseId, connectorId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), rehydrate, resolveBillingAttribution: (workspaceId: string) => Promise.resolve(billingAttribution(context, workspaceId)), @@ -574,7 +575,7 @@ export async function listTagsOperation( throwIfAborted(context) const result = await listKnowledgeTags.execute({ principal: context.principal, - input: { knowledgeBaseId, assertedWorkspaceId: context.principal.workspaceId }, + input: { knowledgeBaseId, assertedWorkspaceId: principalWorkspaceId(context.principal) }, request: { headers: context.headers }, }) throwIfAborted(context) @@ -591,7 +592,7 @@ export async function searchOperation( const result = await searchKnowledge.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), knowledgeBaseIds: Array.isArray(bodyInput.knowledgeBaseIds) ? bodyInput.knowledgeBaseIds : [bodyInput.knowledgeBaseIds], diff --git a/apps/sim/lib/internal/logs/operations.ts b/apps/sim/lib/internal/logs/operations.ts index f860a7f22b9..5d74a01f0f9 100644 --- a/apps/sim/lib/internal/logs/operations.ts +++ b/apps/sim/lib/internal/logs/operations.ts @@ -1,4 +1,5 @@ import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import type { ContractQuery } from '@/lib/api/contracts' import type { listLogsContract } from '@/lib/api/contracts/logs' import { listLogsUseCase } from '@/lib/logs/application/list-logs' @@ -22,7 +23,11 @@ export async function executeLogsList( context.signal?.throwIfAborted() const result = await listLogsUseCase.execute({ principal: context.principal, - input: { ...query, workspaceId: context.principal.workspaceId, signal: context.signal }, + input: { + ...query, + workspaceId: principalWorkspaceId(context.principal), + signal: context.signal, + }, }) return complete(context, result) } @@ -31,7 +36,7 @@ export async function executeLogsGet(id: string, context: LogsToolOperationConte const result = await readLogDetailUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), lookupColumn: 'id', lookupValue: id, signal: context.signal, @@ -47,7 +52,7 @@ export async function executeLogsGetRunDetails( const result = await readLogDetailUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), lookupColumn: 'executionId', lookupValue: executionId, signal: context.signal, diff --git a/apps/sim/lib/internal/mcp/execute-tool.test.ts b/apps/sim/lib/internal/mcp/execute-tool.test.ts index 0265e2243d6..31bbaa2721f 100644 --- a/apps/sim/lib/internal/mcp/execute-tool.test.ts +++ b/apps/sim/lib/internal/mcp/execute-tool.test.ts @@ -129,10 +129,13 @@ describe('executeMcpTool', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', audience: 'sim:mcp-servers', delegationId: 'copilot-tool:call-1', - resourceScope: { chatId: 'chat-1', mcpServerId: 'mcp-server' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { chatId: 'chat-1', mcpServerId: 'mcp-server' }, + }), }), input: expect.objectContaining({ arguments: { query: 'sim' } }), }) @@ -164,7 +167,7 @@ describe('executeMcpTool', () => { serviceId: 'copilot', subjectUserId: 'user-1', audience: 'sim:managed-mcp-credentials', - resourceScope: { chatId: 'chat-1', credentialId }, + grant: expect.objectContaining({ restrictions: { chatId: 'chat-1', credentialId } }), }), input: expect.objectContaining({ credentialId, diff --git a/apps/sim/lib/internal/memory/operations.ts b/apps/sim/lib/internal/memory/operations.ts index 5bc74c8fe27..c8d03fabbf4 100644 --- a/apps/sim/lib/internal/memory/operations.ts +++ b/apps/sim/lib/internal/memory/operations.ts @@ -1,4 +1,5 @@ import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import type { ContractBody, ContractQuery } from '@/lib/api/contracts' import type { createMemoryContract, @@ -49,7 +50,7 @@ export async function executeMemoryAdd( const result = await appendMemoryUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), key: body.key ?? '', data: body.data, ...(resolveWriteProvenance ? { resolveWriteProvenance } : {}), @@ -76,7 +77,7 @@ export async function executeMemoryList( const result = await listMemoriesUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), query: query.query, limit: query.limit, includePersistedSecretProvenance: memoryToolRequestsProvenance(context.headers), @@ -107,7 +108,7 @@ export async function executeMemoryGet( const result = await readMemoryUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), key, includePersistedSecretProvenance: memoryToolRequestsProvenance(context.headers), resolveBillingAttribution: async (workspaceId) => @@ -132,7 +133,7 @@ export async function executeMemoryDelete( const result = await deleteMemoryUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), key: query.conversationId ?? '', signal: context.signal, }, diff --git a/apps/sim/lib/internal/principals/executor.ts b/apps/sim/lib/internal/principals/executor.ts index 7170ee0688e..c1edf5db6d0 100644 --- a/apps/sim/lib/internal/principals/executor.ts +++ b/apps/sim/lib/internal/principals/executor.ts @@ -1,4 +1,4 @@ -import { type DelegatedPrincipal, resolvePrincipalSubject } from '@sim/auth/principal' +import { resolvePrincipalSubject, type workspaceDelegationRestrictions } from '@sim/auth/principal' import { generateId } from '@sim/utils/id' import { bindInternalExecutorDelegation } from '@/lib/auth/internal-delegation' import { MANAGED_MCP_DELEGATION_AUDIENCE } from '@/lib/credentials/application/authorization' @@ -40,7 +40,7 @@ export function resolveExecutorOriginSubject(origin: ExecutorDelegationOrigin): export async function createExecutorPrincipalFromDelegationOrigin( origin: ExecutorDelegationOrigin, audience: string, - resourceScope?: DelegatedPrincipal['resourceScope'], + resourceScope?: ReturnType, expiresAt?: Date, compatibilityActorUserId?: string ) { @@ -70,7 +70,7 @@ export async function createExecutorPrincipalFromDelegationOrigin( export interface CreateExecutorPrincipalFromExecutionContextInput { context: InternalToolOperationContext audience: string - resourceScope?: DelegatedPrincipal['resourceScope'] + resourceScope?: ReturnType expiresAt?: Date } diff --git a/apps/sim/lib/internal/table/operations.ts b/apps/sim/lib/internal/table/operations.ts index 9b95031290d..81c133164fb 100644 --- a/apps/sim/lib/internal/table/operations.ts +++ b/apps/sim/lib/internal/table/operations.ts @@ -1,4 +1,5 @@ import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import type { ContractBody, ContractQuery } from '@/lib/api/contracts' import type { createTableContract, @@ -68,7 +69,7 @@ export async function executeTableCreate( const result = await createTableUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), name: body.name, description: body.description, schema: { @@ -95,7 +96,7 @@ export async function executeTableList( const result = await listTableDefinitionsUseCase.execute({ principal: context.principal, input: { - workspaceId: context.principal.workspaceId, + workspaceId: principalWorkspaceId(context.principal), }, }) const tables = result.tables.map(presentTableListItem) @@ -110,7 +111,7 @@ export async function executeTableGetSchema( ): Promise { const result = await readTableDetailsUseCase.execute({ principal: context.principal, - input: { tableId, workspaceId: context.principal.workspaceId }, + input: { tableId, workspaceId: principalWorkspaceId(context.principal) }, }) return complete(context, { body: { @@ -132,7 +133,7 @@ export async function executeTableGetRow( input: { tableId, rowId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), includePersistedSecretProvenance, requestId: context.requestId, }, @@ -160,7 +161,7 @@ export async function executeTableInsertRows( ? { kind: 'batch', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), rows: body.rows as RowData[], orderKeys: body.orderKeys, strictWrite: false, @@ -172,7 +173,7 @@ export async function executeTableInsertRows( : { kind: 'single', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), data: body.data as RowData, position: body.position, afterRowId: body.afterRowId, @@ -223,7 +224,7 @@ export async function executeTableQueryRows( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), ...(filter && isTablePredicate(filter) ? { predicate: filter } : { legacyFilter: filter as Filter | undefined }), @@ -266,7 +267,7 @@ export async function executeTableQueryRowsV2( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), predicate: body.predicate, sort: body.sort, columns: body.columns, @@ -306,7 +307,7 @@ export async function executeTableUpdateRow( input: { tableId, rowId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), data: body.data as RowData, dataKeying: 'names', strictWrite: false, @@ -336,7 +337,7 @@ export async function executeTableUpdateRowsByFilter( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), filter: body.filter, filterKeying: 'names', data: body.data as RowData, @@ -371,7 +372,7 @@ export async function executeTableDeleteRow( input: { tableId, rowId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), requestId: context.requestId, }, }) @@ -394,14 +395,14 @@ export async function executeTableDeleteRows( ? { kind: 'ids', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), rowIds: body.rowIds, requestId: context.requestId, } : { kind: 'filter', tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), filter: body.filter!, filterKeying: 'names', limit: body.limit, @@ -452,7 +453,7 @@ export async function executeTableUpsertRow( principal: context.principal, input: { tableId, - assertedWorkspaceId: context.principal.workspaceId, + assertedWorkspaceId: principalWorkspaceId(context.principal), data: body.data as RowData, dataKeying: 'names', strictWrite: false, diff --git a/apps/sim/lib/internal/windchill/operations.ts b/apps/sim/lib/internal/windchill/operations.ts index 94e7183de73..237c05ed934 100644 --- a/apps/sim/lib/internal/windchill/operations.ts +++ b/apps/sim/lib/internal/windchill/operations.ts @@ -1,5 +1,6 @@ import { type BoundWorkflowExecutionDelegatedPrincipal, + principalWorkspaceId, resolvePrincipalExecutionActorUserId, } from '@sim/auth/principal' import { createLogger } from '@sim/logger' @@ -486,7 +487,7 @@ async function storeDownloadedFile({ if (executionId) { const file = await uploadExecutionFile( { - workspaceId: principal.workspaceId, + workspaceId: principalWorkspaceId(principal), workflowId, executionId, }, diff --git a/apps/sim/lib/invitations/application/manage-invitation.test.ts b/apps/sim/lib/invitations/application/manage-invitation.test.ts index ba553cfcfaa..487d53ee01c 100644 --- a/apps/sim/lib/invitations/application/manage-invitation.test.ts +++ b/apps/sim/lib/invitations/application/manage-invitation.test.ts @@ -115,11 +115,12 @@ describe('workspace invitation management authority', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'actor', - workspaceId: 'workspace', delegationId: 'call', audience: 'sim:settings', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace' } } as const, } as const const target = { invitationId: input.invitationId, workspaceId: 'workspace' } diff --git a/apps/sim/lib/invitations/application/manage-invitation.ts b/apps/sim/lib/invitations/application/manage-invitation.ts index a451131483e..459d4410733 100644 --- a/apps/sim/lib/invitations/application/manage-invitation.ts +++ b/apps/sim/lib/invitations/application/manage-invitation.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { type Principal, + principalWorkspaceId, requirePrincipalSubjectUserId, resolvePrincipalAuditAttribution, } from '@sim/auth/principal' @@ -206,7 +207,7 @@ async function resendInvitationWithActor({ throw new InvitationManagementError(404, 'Invitation not found in this workspace') if ( principal.kind === 'delegated' && - inv.grants.some((grant) => grant.workspaceId !== principal.workspaceId) + inv.grants.some((grant) => grant.workspaceId !== principalWorkspaceId(principal)) ) throw new InvitationManagementError( 403, diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.test.ts b/apps/sim/lib/invitations/application/send-invitation-batch.test.ts index ab95519db6a..bd1d52bdcf6 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.test.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.test.ts @@ -399,11 +399,12 @@ describe('workspace invitation delegation', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'admin-user', - workspaceId: 'workspace', delegationId: 'workspace-invites', audience: 'sim:settings', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace' } } as const, } as const const input = { ...orgInput, workspaceIds: ['workspace'] } diff --git a/apps/sim/lib/invitations/application/send-invitation-batch.ts b/apps/sim/lib/invitations/application/send-invitation-batch.ts index 9bb034e11bb..4f1b85c8b22 100644 --- a/apps/sim/lib/invitations/application/send-invitation-batch.ts +++ b/apps/sim/lib/invitations/application/send-invitation-batch.ts @@ -1,6 +1,7 @@ import { isUserCredentialPrincipal, type Principal, + principalWorkspaceId, requirePrincipalSubjectUserId, toPrincipalActor, } from '@sim/auth/principal' @@ -326,7 +327,7 @@ export const sendWorkspaceInvitationBatch = defineAuthorizedWorkspaceUseCase({ throw new WorkspaceInvitationError({ message: 'Invalid invitation batch size.', status: 400 }) if ( principal.kind === 'delegated' && - input.workspaceIds.some((id) => id !== principal.workspaceId) + input.workspaceIds.some((id) => id !== principalWorkspaceId(principal)) ) throw new OrchestrationError( 'forbidden', diff --git a/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts b/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts index ab90381eafe..ac990239d3c 100644 --- a/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts @@ -135,12 +135,16 @@ it.each([ kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: ids.aliceId, - workspaceId: ids.workspaceId, delegationId: generateId(), audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60000), - resourceScope: { chatId: generateId() }, + invocation: { kind: 'chat', chatId: generateId() } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: ids.workspaceId }, + restrictions: { chatId: generateId() }, + } as const, } const { connector } = await createKnowledgeConnector.execute({ principal: caller, diff --git a/apps/sim/lib/knowledge/__integration__/search-index-policy.integration.ts b/apps/sim/lib/knowledge/__integration__/search-index-policy.integration.ts index 0a286b3d43d..ef934cd1de8 100644 --- a/apps/sim/lib/knowledge/__integration__/search-index-policy.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/search-index-policy.integration.ts @@ -48,11 +48,15 @@ function copilot(userId: string): Principal { kind: 'delegated', serviceId: 'copilot', subjectUserId: userId, - workspaceId: ids.workspaceId, delegationId: 'fixture-delegation', audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: ids.workspaceId } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: ids.workspaceId }, + } as const, } } diff --git a/apps/sim/lib/knowledge/api/internal-route.ts b/apps/sim/lib/knowledge/api/internal-route.ts index 26c3ca2863f..1a09a03819d 100644 --- a/apps/sim/lib/knowledge/api/internal-route.ts +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -1,5 +1,7 @@ import { + isWorkspaceDelegation, type Principal, + principalWorkspaceId, resolvePrincipalSubject, resolvePrincipalSubjectUserId, type SessionPrincipal, @@ -49,8 +51,10 @@ export function internalKnowledgeProvenanceUserId( if (principal.kind !== 'delegated') return internalKnowledgeActorUserId(principal) const subject = resolvePrincipalSubject(principal) if (subject?.kind === 'sim_user') return subject.userId + if (!isWorkspaceDelegation(principal)) + throw new OrchestrationError('forbidden', 'Workspace authority required') return requireWorkspaceBillingAttributionHeader(headers, { - workspaceId: workspaceId ?? principal.workspaceId, + workspaceId: workspaceId ?? principalWorkspaceId(principal), }).actorUserId } diff --git a/apps/sim/lib/knowledge/application/add-workspace-files.test.ts b/apps/sim/lib/knowledge/application/add-workspace-files.test.ts index 9b9ece88cbf..70059bc8b16 100644 --- a/apps/sim/lib/knowledge/application/add-workspace-files.test.ts +++ b/apps/sim/lib/knowledge/application/add-workspace-files.test.ts @@ -94,11 +94,12 @@ const delegatedPrincipal = { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'dual-workspace-user', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, } as const describe('add workspace files to knowledge base application command', () => { diff --git a/apps/sim/lib/knowledge/application/authorization.test.ts b/apps/sim/lib/knowledge/application/authorization.test.ts index 3f32bd96bca..fc74563a201 100644 --- a/apps/sim/lib/knowledge/application/authorization.test.ts +++ b/apps/sim/lib/knowledge/application/authorization.test.ts @@ -1,23 +1,18 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' +import { createDelegatedPrincipal } from '@sim/testing/factories' import { describe, expect, it } from 'vitest' import { KNOWLEDGE_DELEGATION_AUDIENCE, knowledgeDelegationPolicy, } from '@/lib/knowledge/application/authorization' -function createKnowledgePrincipal(overrides: Partial = {}): DelegatedPrincipal { - return { - kind: 'delegated', - serviceId: 'copilot', - subjectUserId: 'user-1', - workspaceId: 'workspace-1', - delegationId: 'tool-call-1', +function createKnowledgePrincipal(overrides: Parameters[0] = {}) { + return createDelegatedPrincipal({ audience: KNOWLEDGE_DELEGATION_AUDIENCE, - issuedAt: new Date(), - expiresAt: new Date(Date.now() + 60_000), resourceScope: { chatId: 'chat-1' }, ...overrides, - } + }) } describe('knowledge delegation policy', () => { @@ -25,7 +20,7 @@ describe('knowledge delegation policy', () => { const principal = createKnowledgePrincipal() expect(principal.audience).toBe(KNOWLEDGE_DELEGATION_AUDIENCE) - expect(principal.resourceScope).toEqual({ chatId: 'chat-1' }) + expect(workspaceDelegationRestrictions(principal)).toEqual({ chatId: 'chat-1' }) expect( knowledgeDelegationPolicy.isWithinScope(principal, { workspaceId: 'workspace-1', @@ -49,7 +44,7 @@ describe('knowledge delegation policy', () => { }) it('accepts a correctly scoped executor delegation for executor-enabled operations', () => { - const principal: DelegatedPrincipal = { + const principal: WorkspaceDelegatedPrincipal = { kind: 'delegated', serviceId: 'executor', subjectUserId: 'user-1', diff --git a/apps/sim/lib/knowledge/application/authorization.ts b/apps/sim/lib/knowledge/application/authorization.ts index a25ac5b5602..07cbc2e7926 100644 --- a/apps/sim/lib/knowledge/application/authorization.ts +++ b/apps/sim/lib/knowledge/application/authorization.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import type { WorkspaceAuthorizationContext, WorkspaceAuthorizationOptions, @@ -40,6 +41,6 @@ export const knowledgeDelegationPolicy = { delegated: Extract, canonicalContext: KnowledgeAuthorizationContext ) { - return delegated.workspaceId === canonicalContext.workspaceId + return principalWorkspaceId(delegated) === canonicalContext.workspaceId }, } as const diff --git a/apps/sim/lib/knowledge/application/connectors.test.ts b/apps/sim/lib/knowledge/application/connectors.test.ts index 630893c5e51..ac15a3aea35 100644 --- a/apps/sim/lib/knowledge/application/connectors.test.ts +++ b/apps/sim/lib/knowledge/application/connectors.test.ts @@ -177,12 +177,16 @@ const delegatedPrincipal = { kind: 'delegated' as const, serviceId: 'copilot', subjectUserId: 'shared-user', - workspaceId: 'workspace-a', delegationId: 'tool-call-1', audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), - resourceScope: {}, + invocation: { kind: 'workspace', workspaceId: 'workspace-a' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-a' }, + restrictions: {}, + } as const, } const BILLING = { actorUserId: 'shared-user', workspaceId: 'workspace-a' } as never diff --git a/apps/sim/lib/knowledge/application/documents.test.ts b/apps/sim/lib/knowledge/application/documents.test.ts index 92fe406159a..969c66006cf 100644 --- a/apps/sim/lib/knowledge/application/documents.test.ts +++ b/apps/sim/lib/knowledge/application/documents.test.ts @@ -449,12 +449,16 @@ describe('knowledge document application use cases', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'shared-user', - workspaceId: 'workspace-a', delegationId: 'tool-call-1', audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), - resourceScope: {}, + invocation: { kind: 'workspace', workspaceId: 'workspace-a' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-a' }, + restrictions: {}, + } as const, }, input: { knowledgeBaseId: 'knowledge-b', @@ -492,12 +496,16 @@ describe('knowledge document application use cases', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'shared-user', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), - resourceScope: {}, + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: {}, + } as const, }, input: { knowledgeBaseId: 'knowledge-1', diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts index 31c8267a3e0..319a861c240 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.test.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.test.ts @@ -177,11 +177,15 @@ describe('knowledge base application use cases', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'dual-workspace-user', - workspaceId: 'workspace-b', delegationId: 'vfs-1', audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace-b' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-b' }, + } as const, }, input: { workspaceId: 'workspace-1', scope: 'archived' }, }) @@ -338,11 +342,15 @@ describe('knowledge base application use cases', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'dual-workspace-user', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }, input: { assertedWorkspaceId: 'workspace-1', diff --git a/apps/sim/lib/knowledge/application/tags.test.ts b/apps/sim/lib/knowledge/application/tags.test.ts index 59e17fe3316..03bc45f9185 100644 --- a/apps/sim/lib/knowledge/application/tags.test.ts +++ b/apps/sim/lib/knowledge/application/tags.test.ts @@ -370,7 +370,13 @@ describe('knowledge tag application use cases', () => { ) describe('tag usage Chat may see', () => { - const chat = { ...delegatedPrincipal, workspaceId: 'workspace-b' } + const chat = createDelegatedPrincipal({ + subjectUserId: 'shared-user', + workspaceId: 'workspace-b', + delegationId: 'tool-call-1', + audience: 'sim:knowledge', + resourceScope: {}, + }) /** One document under two definitions: its value is refused under one and admitted under the other. */ const detailed = [ { diff --git a/apps/sim/lib/logs/application/authorization.ts b/apps/sim/lib/logs/application/authorization.ts index 81f587bb07e..4a54dd7bf8a 100644 --- a/apps/sim/lib/logs/application/authorization.ts +++ b/apps/sim/lib/logs/application/authorization.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { DelegatedWorkspaceAuthorizationError, NoWorkspaceAccessError, @@ -19,9 +20,9 @@ export const logDelegationPolicy: WorkspaceDelegationPolicy, context: McpServerContext ) => - principal.serviceId === 'copilot' || principal.resourceScope?.mcpServerId === context.server.id, + principal.serviceId === 'copilot' || + workspaceDelegationRestrictions(principal)?.mcpServerId === context.server.id, } satisfies WorkspaceDelegationPolicy /** diff --git a/apps/sim/lib/mcp/application/execute-tool.test.ts b/apps/sim/lib/mcp/application/execute-tool.test.ts index 4de6ad29275..0aef79df674 100644 --- a/apps/sim/lib/mcp/application/execute-tool.test.ts +++ b/apps/sim/lib/mcp/application/execute-tool.test.ts @@ -1,6 +1,6 @@ import type { BoundWorkflowExecutionDelegatedPrincipal, - SubjectDelegatedPrincipal, + WorkspaceSubjectDelegation, } from '@sim/auth/principal' import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' import { @@ -91,7 +91,7 @@ const ACTORLESS_PRINCIPAL: BoundWorkflowExecutionDelegatedPrincipal = { }, resourceScope: { mcpServerId: SERVER.id, mcpBlockId: 'block-1' }, } -const COPILOT_PRINCIPAL: SubjectDelegatedPrincipal = createDelegatedPrincipal({ +const COPILOT_PRINCIPAL: WorkspaceSubjectDelegation = createDelegatedPrincipal({ subjectUserId: 'chat-user', workspaceId: WORKSPACE.workspaceId, delegationId: 'copilot-tool:call-1', diff --git a/apps/sim/lib/mcp/application/operation-access.ts b/apps/sim/lib/mcp/application/operation-access.ts index c495724468c..39b1f9e0290 100644 --- a/apps/sim/lib/mcp/application/operation-access.ts +++ b/apps/sim/lib/mcp/application/operation-access.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { db } from '@sim/db' import { workflowDeploymentVersion } from '@sim/db/schema' import { isPlainRecord } from '@sim/utils/object' @@ -50,7 +51,7 @@ export async function loadMcpOperationAccess( argumentsMode: 'json', } } - const blockId = principal.resourceScope?.mcpBlockId + const blockId = workspaceDelegationRestrictions(principal)?.mcpBlockId if (!blockId) throw new OrchestrationError( 'forbidden', diff --git a/apps/sim/lib/mothership/agent-cli/csv-workflow-execution.live.test.ts b/apps/sim/lib/mothership/agent-cli/csv-workflow-execution.live.test.ts index b1acc836069..c36b7f871ac 100644 --- a/apps/sim/lib/mothership/agent-cli/csv-workflow-execution.live.test.ts +++ b/apps/sim/lib/mothership/agent-cli/csv-workflow-execution.live.test.ts @@ -9,7 +9,7 @@ import { createReadStream } from 'node:fs' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import { generateId } from '@sim/utils/id' import { NextRequest } from 'next/server' @@ -232,7 +232,7 @@ vi.mock('@/lib/internal/principals/executor', async (importOriginal) => ({ createExecutorPrincipalFromExecutionContext: async ({ audience, context, - }: CreateExecutorPrincipalFromExecutionContextInput): Promise => { + }: CreateExecutorPrincipalFromExecutionContextInput): Promise => { if (!context.workspaceId) throw new Error('Fixture execution requires a workspace') fixtures.audiences.push(audience) return { diff --git a/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts b/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts index 9affedd2e0b..32952577dab 100644 --- a/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts +++ b/apps/sim/lib/mothership/agent-cli/engines/lint-scope.test.ts @@ -242,12 +242,16 @@ describe('Mothership workflow lint scope', () => { principal: { kind: 'delegated', serviceId: 'copilot', - workspaceId: 'workflow-workspace', subjectUserId: 'authenticated-user', delegationId: 'delegation-1', audience: 'copilot', issuedAt: new Date(0), expiresAt: new Date(8640000000000000), + invocation: { kind: 'workspace', workspaceId: 'workflow-workspace' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workflow-workspace' }, + } as const, }, input: { workflowId: 'wf-1' }, }) @@ -279,9 +283,12 @@ describe('Mothership workflow lint scope', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'chat-actor', - workspaceId: 'workflow-workspace', audience: 'sim:secrets', - resourceScope: { chatId: 'org-chat' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workflow-workspace' }, + restrictions: { chatId: 'org-chat' }, + }), }), }) ) diff --git a/apps/sim/lib/mothership/agent-cli/file-copy-transport.ts b/apps/sim/lib/mothership/agent-cli/file-copy-transport.ts index 563f383316a..83110c8a825 100644 --- a/apps/sim/lib/mothership/agent-cli/file-copy-transport.ts +++ b/apps/sim/lib/mothership/agent-cli/file-copy-transport.ts @@ -1,8 +1,8 @@ -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { NextRequest } from 'next/server' import { fileCopyInputSchema } from '@/lib/api/contracts/file-copy-input' import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy' -import { markCopilotResourceRequest } from '@/lib/api/server/routes/copilot-request' +import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request' import { V2_PARSE_DEFAULTS, v2InvalidBodyResponse } from '@/lib/api/server/routes/v2-json-route' import { isV2ContractRoute } from '@/lib/api/server/routes/v2-route-pattern' import { parseRequest } from '@/lib/api/server/validation' @@ -25,7 +25,7 @@ function markCopilotFileCopyRequest( request: Request, context: CopilotExecutionContext, target: CopyFileItemsInput -): ResourceDelegatedPrincipal { +): ResourceOperationPrincipal { const principal = createCopilotResourceApplicationPrincipal( requireTrustedCopilotResourceExecutionContext(context), { @@ -35,7 +35,7 @@ function markCopilotFileCopyRequest( scope: { kind: 'file_copy', ...target }, } ) - markCopilotResourceRequest(request, (operation, audience) => + markCopilotRequest(request, (operation, audience) => operation === fileCopyOperation && audience === fileCopyOperation.delegationAudience ? principal : undefined diff --git a/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts b/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts index 98a664531b3..41c56de9a4a 100644 --- a/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts @@ -129,9 +129,13 @@ const PRINCIPAL = { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'reader', - workspaceId: WORKSPACE, audience: 'sim:workspace-files', - resourceScope: { chatId: 'chat' }, + invocation: { kind: 'chat', chatId: 'chat' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: WORKSPACE }, + restrictions: { chatId: 'chat' }, + } as const, } as const const SOURCE = { id: 'source', diff --git a/apps/sim/lib/mothership/agent-cli/file-upload-origin.test.ts b/apps/sim/lib/mothership/agent-cli/file-upload-origin.test.ts index 11af2168891..4bc219bc5d3 100644 --- a/apps/sim/lib/mothership/agent-cli/file-upload-origin.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-upload-origin.test.ts @@ -122,8 +122,10 @@ describe('embedded upload application request context', () => { expect.objectContaining({ kind: 'delegated', subjectUserId: 'reader', - workspaceId, audience: 'sim:workspace-files', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: workspaceId }, + }), }), expect.objectContaining({ id: 'files.upload.create', minimumRole: 'write' }), workspaceId diff --git a/apps/sim/lib/mothership/agent-cli/file-upload-transport.test.ts b/apps/sim/lib/mothership/agent-cli/file-upload-transport.test.ts index 8a76ff8be91..5f138cb4b23 100644 --- a/apps/sim/lib/mothership/agent-cli/file-upload-transport.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-upload-transport.test.ts @@ -89,9 +89,13 @@ describe('private embedded upload control', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'reader', - workspaceId, audience: 'sim:workspace-files', - resourceScope: { chatId: 'chat' }, + invocation: { kind: 'chat', chatId: 'chat' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: workspaceId }, + restrictions: { chatId: 'chat' }, + } as const, }) expect(v2RouteMocks.authenticate).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts b/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts index f9588dd395a..7a96ccaf5fd 100644 --- a/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts +++ b/apps/sim/lib/mothership/agent-cli/saved-run-read.live.test.ts @@ -23,7 +23,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { createInterface } from 'node:readline' import { promisify } from 'node:util' -import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { Principal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { auditLog, @@ -344,7 +344,7 @@ vi.mock('@/lib/internal/principals/executor', async (importOriginal) => ({ audience, context, resourceScope, - }: CreateExecutorPrincipalFromExecutionContextInput): Promise => ({ + }: CreateExecutorPrincipalFromExecutionContextInput): Promise => ({ kind: 'delegated', serviceId: 'executor', subjectUserId: 'run-reader', diff --git a/apps/sim/lib/mothership/agent-cli/scope-routing.test.ts b/apps/sim/lib/mothership/agent-cli/scope-routing.test.ts index 9a54be32cfe..14820191183 100644 --- a/apps/sim/lib/mothership/agent-cli/scope-routing.test.ts +++ b/apps/sim/lib/mothership/agent-cli/scope-routing.test.ts @@ -90,8 +90,10 @@ describe('worker-local CLI authorization through the real handler and scope reso expect(boundary.target).toHaveBeenCalledWith({ principal: expect.objectContaining({ kind: 'delegated', - workspaceId: selected, subjectUserId: 'actor', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: selected }, + }), }), input: { chatId: 'owned-chat', workspaceId: selected }, }) diff --git a/apps/sim/lib/mothership/agent-cli/scoped-fork-transport.test.ts b/apps/sim/lib/mothership/agent-cli/scoped-fork-transport.test.ts index 59436d148e9..f560993cdc7 100644 --- a/apps/sim/lib/mothership/agent-cli/scoped-fork-transport.test.ts +++ b/apps/sim/lib/mothership/agent-cli/scoped-fork-transport.test.ts @@ -163,8 +163,12 @@ describe('native fork commands through private Copilot transport', () => { if (!principal) throw new Error('Native command lost private admission') expect(principal).toMatchObject({ subjectUserId: 'actor', - workspaceId, - resourceScope: { chatId: 'chat' }, + invocation: { kind: 'chat', chatId: 'chat' }, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: workspaceId }, + restrictions: expect.objectContaining({ chatId: 'chat' }), + }, }) const result = await useCase.execute({ principal, diff --git a/apps/sim/lib/mothership/agent-cli/scoped-transport.ts b/apps/sim/lib/mothership/agent-cli/scoped-transport.ts index 2ed54c2a981..af748816e8b 100644 --- a/apps/sim/lib/mothership/agent-cli/scoped-transport.ts +++ b/apps/sim/lib/mothership/agent-cli/scoped-transport.ts @@ -1,5 +1,8 @@ import { NextRequest } from 'next/server' -import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request' +import { + createWorkspaceRequestGrant, + markCopilotRequest, +} from '@/lib/api/server/routes/copilot-request' import { dispatchInProcessV2Request, type MatchedRoute, @@ -33,7 +36,7 @@ export function createScopedCliTransport( const denied = await scope.admitRequest(forwarded, matched) if (denied) return denied } else { - markCopilotRequest(forwarded, scope) + markCopilotRequest(forwarded, createWorkspaceRequestGrant(scope)) } return ( (await dispatchInProcessV2Request(forwarded)) ?? diff --git a/apps/sim/lib/mothership/application/adapter.test.ts b/apps/sim/lib/mothership/application/adapter.test.ts index a6f032d13cb..e18b0f0ac07 100644 --- a/apps/sim/lib/mothership/application/adapter.test.ts +++ b/apps/sim/lib/mothership/application/adapter.test.ts @@ -1,4 +1,5 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { describe, expect, it, vi } from 'vitest' import { defineWorkspaceOperation, type WorkspaceOperation } from '@/lib/core/application' import { createCopilotApplicationAdapter } from '@/lib/mothership/application/adapter' @@ -42,7 +43,7 @@ function createAdapter( createPrincipal?: (args: { context: TrustedCopilotExecutionContext resourceScope: CopilotResourceScope - }) => DelegatedPrincipal + }) => WorkspaceDelegatedPrincipal ) { return createCopilotApplicationAdapter({ domain: 'file', @@ -67,12 +68,15 @@ describe('Copilot application adapter', () => { expect(execute).toHaveBeenCalledWith({ principal: expect.objectContaining({ subjectUserId: 'trusted-user', - workspaceId: 'workspace-1', - resourceScope: { - fileId: 'trusted-file', - chatId: 'chat-1', - executionId: 'execution-1', - }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { + fileId: 'trusted-file', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }), }), input: { fileId: 'model-forged-file' }, }) @@ -184,7 +188,10 @@ describe('Copilot application adapter', () => { ...delegation, resourceScope, }) - return { ...principal, resourceScope: { ...principal.resourceScope, fileId: 'file-2' } } + return { + ...principal, + resourceScope: { ...workspaceDelegationRestrictions(principal), fileId: 'file-2' }, + } }) expect(() => diff --git a/apps/sim/lib/mothership/application/adapter.ts b/apps/sim/lib/mothership/application/adapter.ts index 15e0d683336..b8e3964b841 100644 --- a/apps/sim/lib/mothership/application/adapter.ts +++ b/apps/sim/lib/mothership/application/adapter.ts @@ -1,9 +1,10 @@ import type { - DelegatedPrincipal, Principal, - ResourceDelegatedPrincipal, ResourceDelegationScope, + ResourceOperationPrincipal, + WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' +import { principalWorkspaceId, workspaceDelegationRestrictions } from '@sim/auth/principal' import { type ApplicationOperation, type OperationUseCase, @@ -26,7 +27,7 @@ import { type CopilotApplicationPrincipalFactory = (args: { context: TrustedCopilotExecutionContext resourceScope: CopilotResourceScope -}) => DelegatedPrincipal +}) => WorkspaceDelegatedPrincipal interface CopilotApplicationAdapterOptions { mode?: 'workspace' @@ -41,8 +42,9 @@ interface CopilotApplicationAdapterOptions { +): NonNullable> { return { ...resourceScope, ...(context.chatId ? { chatId: context.chatId } : {}), @@ -101,7 +103,7 @@ function expectedResourceScope( } function requireMatchingPrincipal( - principal: DelegatedPrincipal, + principal: WorkspaceDelegatedPrincipal, context: TrustedCopilotExecutionContext, delegation: CopilotDelegationConfiguration, resourceScope: CopilotResourceScope @@ -111,7 +113,7 @@ function requireMatchingPrincipal( principal.kind !== 'delegated' || principal.serviceId !== 'copilot' || principal.subjectUserId !== context.userId || - principal.workspaceId !== context.workspaceId || + principalWorkspaceId(principal) !== context.workspaceId || !delegationId.trim() || principal.delegationId !== delegationId || principal.audience !== delegation.audience @@ -132,7 +134,11 @@ function requireMatchingPrincipal( } const expectedScope = expectedResourceScope(context, resourceScope) - if (RESOURCE_SCOPE_KEYS.some((key) => principal.resourceScope?.[key] !== expectedScope[key])) { + if ( + RESOURCE_SCOPE_KEYS.some( + (key) => workspaceDelegationRestrictions(principal)?.[key] !== expectedScope[key] + ) + ) { throw new Error('Copilot principal factory violated the configured resource scope') } } @@ -210,7 +216,8 @@ function createWorkspaceApplicationAdapter Boolean(principal.subjectUserId), + isWithinScope: (principal: WorkspaceDelegatedPrincipal) => Boolean(principal.subjectUserId), }, }, async execute() {}, diff --git a/apps/sim/lib/mothership/application/execute-custom-tool-use-case.test.ts b/apps/sim/lib/mothership/application/execute-custom-tool-use-case.test.ts index ace9eb696b7..4e2efa326ff 100644 --- a/apps/sim/lib/mothership/application/execute-custom-tool-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-custom-tool-use-case.test.ts @@ -32,12 +32,15 @@ describe('executeCopilotCustomToolUseCase', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: trustedContext.userId, - workspaceId: trustedContext.workspaceId, delegationId: `copilot-tool:${trustedContext.toolCallId}`, audience: 'sim:custom-tools', - resourceScope: expect.objectContaining({ - chatId: trustedContext.chatId, - executionId: trustedContext.executionId, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: trustedContext.workspaceId }, + restrictions: expect.objectContaining({ + chatId: trustedContext.chatId, + executionId: trustedContext.executionId, + }), }), }), input, diff --git a/apps/sim/lib/mothership/application/execute-file-use-case.test.ts b/apps/sim/lib/mothership/application/execute-file-use-case.test.ts index 7ab8525c3e8..4cf0fa18b86 100644 --- a/apps/sim/lib/mothership/application/execute-file-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-file-use-case.test.ts @@ -45,9 +45,12 @@ describe('executeCopilotFileUseCase', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-call-1', - resourceScope: expect.objectContaining({ fileId: 'file-1' }), + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: expect.objectContaining({ fileId: 'file-1' }), + }), }), input: { fileId: 'file-1', name: 'renamed.txt' }, }) @@ -77,7 +80,10 @@ describe('executeCopilotFileUseCase', () => { expect(resolveWorkspaceFileReference).toHaveBeenCalledWith({ principal: expect.objectContaining({ kind: 'delegated', - workspaceId: 'workspace-1', + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }), delegationId: 'copilot-tool:tool-call-1', }), operation: fileOperations.readContent, diff --git a/apps/sim/lib/mothership/application/execute-knowledge-use-case.ts b/apps/sim/lib/mothership/application/execute-knowledge-use-case.ts index 501bc76ba09..f9f2e24a9e7 100644 --- a/apps/sim/lib/mothership/application/execute-knowledge-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-knowledge-use-case.ts @@ -1,4 +1,7 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' import type { OperationUseCase } from '@/lib/core/application' import type { ResourceScope } from '@/lib/core/resource-scope' import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' @@ -50,7 +53,7 @@ export function requireCopilotKnowledgeWorkspaceId( /** Normalizes immutable Copilot execution identity into a knowledge delegation. */ export function resolveCopilotKnowledgePrincipal( context: CopilotKnowledgeDelegationContext | undefined -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { return createCopilotApplicationPrincipal( requireTrustedCopilotExecutionContext(context), knowledgeDelegation @@ -60,7 +63,7 @@ export function resolveCopilotKnowledgePrincipal( /** Creates the trusted Knowledge principal used while resolving Copilot chat context. */ export function createCopilotChatKnowledgePrincipal( context: CopilotChatKnowledgeDelegationContext -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { return createCopilotChatPrincipal(context, knowledgeDelegationPolicy.audience) } diff --git a/apps/sim/lib/mothership/application/execute-project-file-use-case.ts b/apps/sim/lib/mothership/application/execute-project-file-use-case.ts index 84e23cf5e09..93396200233 100644 --- a/apps/sim/lib/mothership/application/execute-project-file-use-case.ts +++ b/apps/sim/lib/mothership/application/execute-project-file-use-case.ts @@ -1,4 +1,4 @@ -import { markCopilotResourceRequest } from '@/lib/api/server/routes/copilot-request' +import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request' import type { ApplicationOperation } from '@/lib/core/application/operation' import { admitsCopilotResourceDelegation, @@ -47,7 +47,7 @@ export function markCopilotProjectFileRequest( throw new Error('Copilot Project file requests require a valid resource target') } const bound = Object.freeze({ ...target }) - markCopilotResourceRequest(request, (operation, audience) => { + markCopilotRequest(request, (operation, audience) => { const registered = REGISTERED_OPERATIONS.get(operation) if ( !registered || diff --git a/apps/sim/lib/mothership/application/execute-table-use-case.test.ts b/apps/sim/lib/mothership/application/execute-table-use-case.test.ts index 331a4c573e3..e6adc8fc9ca 100644 --- a/apps/sim/lib/mothership/application/execute-table-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-table-use-case.test.ts @@ -32,14 +32,17 @@ describe('executeCopilotTableUseCase', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', audience: 'sim:tables', delegationId: 'copilot-tool:tool-call-1', - resourceScope: { - tableId: 'table-1', - chatId: 'chat-1', - executionId: 'execution-1', - }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { + tableId: 'table-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }), }), input: { tableId: 'model-table' }, }) @@ -57,7 +60,9 @@ describe('executeCopilotTableUseCase', () => { expect(execute).toHaveBeenCalledWith({ principal: expect.objectContaining({ serviceId: 'copilot', - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + grant: expect.objectContaining({ + restrictions: { chatId: 'chat-1', executionId: 'execution-1' }, + }), }), input: { workspaceId: 'workspace-1' }, }) diff --git a/apps/sim/lib/mothership/application/execute-workflow-use-case.test.ts b/apps/sim/lib/mothership/application/execute-workflow-use-case.test.ts index 4a7e13dc775..ec509812441 100644 --- a/apps/sim/lib/mothership/application/execute-workflow-use-case.test.ts +++ b/apps/sim/lib/mothership/application/execute-workflow-use-case.test.ts @@ -50,12 +50,16 @@ describe('Copilot Workflow application adapter', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'trusted-user', - workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-call-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date(Date.now() + COPILOT_APPLICATION_DELEGATION_TTL_MS), - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + invocation: { kind: 'chat', chatId: 'chat-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { chatId: 'chat-1', executionId: 'execution-1' }, + } as const, }, input: { workflowId: 'workflow-1', assertedWorkspaceId: 'workspace-1' }, }) @@ -76,9 +80,12 @@ describe('Copilot Workflow application adapter', () => { principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'trusted-user', - workspaceId: 'workspace-1', audience: 'sim:workflows', - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { chatId: 'chat-1', executionId: 'execution-1' }, + }), }), input: { workflowId: 'workflow-1', userId: 'forged-user' }, }) @@ -94,7 +101,9 @@ describe('Copilot Workflow application adapter', () => { expect(execute).toHaveBeenCalledWith({ principal: expect.objectContaining({ - resourceScope: { chatId: 'chat-1', executionId: 'execution-1' }, + grant: expect.objectContaining({ + restrictions: { chatId: 'chat-1', executionId: 'execution-1' }, + }), }), input: { workspaceId: 'workspace-1', name: 'New workflow' }, }) diff --git a/apps/sim/lib/mothership/application/resource-adapter.test.ts b/apps/sim/lib/mothership/application/resource-adapter.test.ts index c2f036bf041..44822796e53 100644 --- a/apps/sim/lib/mothership/application/resource-adapter.test.ts +++ b/apps/sim/lib/mothership/application/resource-adapter.test.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { hasResourceOperationGrant } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' import { createCopilotApplicationAdapter } from '@/lib/mothership/application/adapter' @@ -11,7 +12,8 @@ import { const operation = Object.freeze({ id: 'projects.list', capability: 'none', - principalKinds: Object.freeze(['resource_delegated'] as const), + principalKinds: Object.freeze(['delegated'] as const), + delegationScope: 'resource' as const, delegatedServices: Object.freeze(['copilot'] as const), delegationAudience: 'sim:projects:discovery', } as const) @@ -57,15 +59,15 @@ describe('Copilot resource authoring admission', () => { const result = await adapter()(admittedContext(), useCase, { userId: 'forged-user', chatId: 'forged-chat', - scope: { kind: 'entity', entityType: 'organization', entityId: 'other' }, + scope: { kind: 'entity', owner: { entityType: 'organization', entityId: 'other' } }, }) expect(result).toMatchObject({ - kind: 'resource_delegated', + kind: 'delegated', subjectUserId: 'real-user', serviceId: 'copilot', delegationId: 'real-tool-call', - invocation: { kind: 'chat', chatId: 'real-chat' }, - scope: { kind: 'project_discovery' }, + invocation: { kind: 'chat', chatId: 'real-chat' } as const, + grant: { kind: 'project_discovery' } as const, }) }) @@ -76,9 +78,9 @@ describe('Copilot resource authoring admission', () => { {} ) expect(result).toMatchObject({ - kind: 'resource_delegated', - invocation: { kind: 'chat', chatId: 'real-chat' }, - scope: { kind: 'project_discovery' }, + kind: 'delegated', + invocation: { kind: 'chat', chatId: 'real-chat' } as const, + grant: { kind: 'project_discovery' } as const, }) expect(result).not.toHaveProperty('workflowId') }) @@ -169,9 +171,8 @@ describe('Copilot resource authoring admission', () => { operations: { list: operation }, projectResourceScope: ({ fileId }) => ({ kind: 'entity', - entityType: 'project', - entityId: 'project', fileId, + owner: { entityType: 'project', entityId: 'project' }, }), }) const result = await execute( @@ -181,7 +182,11 @@ describe('Copilot resource authoring admission', () => { { fileId: 'file' } ) expect(result).toMatchObject({ - scope: { kind: 'entity', entityType: 'project', entityId: 'project', fileId: 'file' }, + grant: { + kind: 'entity', + fileId: 'file', + owner: { entityType: 'project', entityId: 'project' }, + }, }) }) @@ -231,10 +236,10 @@ describe('Copilot resource authoring admission', () => { scope, }) ).toThrow('Resource delegation is no longer valid') - if (result.kind !== 'resource_delegated' || result.scope.kind !== 'file_copy') + if (!hasResourceOperationGrant(result) || result.grant.kind !== 'file_copy') throw new Error('Missing copy grant') - expect(Reflect.set(result.scope.source.owner, 'entityId', 'forged')).toBe(false) - expect(Reflect.set(result.scope.source.fileIds, '1', 'injected')).toBe(false) - expect(Reflect.set(result.scope.destination, 'folderId', 'forged')).toBe(false) + expect(Reflect.set(result.grant.source.owner, 'entityId', 'forged')).toBe(false) + expect(Reflect.set(result.grant.source.fileIds, '1', 'injected')).toBe(false) + expect(Reflect.set(result.grant.destination, 'folderId', 'forged')).toBe(false) }) }) diff --git a/apps/sim/lib/mothership/application/settings-context.test.ts b/apps/sim/lib/mothership/application/settings-context.test.ts index 35cf1c01bde..8596cb37050 100644 --- a/apps/sim/lib/mothership/application/settings-context.test.ts +++ b/apps/sim/lib/mothership/application/settings-context.test.ts @@ -87,7 +87,10 @@ describe('settings conversation scope', () => { expect(result.principal).toMatchObject({ kind: 'delegated', subjectUserId: 'actor', - workspaceId: 'workspace-a', + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-a' }, + }, }) }) diff --git a/apps/sim/lib/mothership/application/settings-context.ts b/apps/sim/lib/mothership/application/settings-context.ts index 7884efdc09f..fdc4537a91c 100644 --- a/apps/sim/lib/mothership/application/settings-context.ts +++ b/apps/sim/lib/mothership/application/settings-context.ts @@ -1,4 +1,7 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' import type { MothershipSettingsScope } from '@/lib/api/contracts/mothership-settings' import { OrchestrationError } from '@/lib/core/orchestration/types' import { resolveInvocationWorkspace } from '@/lib/mothership/application/workspace-target' @@ -14,7 +17,7 @@ import type { ServerToolContext } from '@/lib/mothership/tools/server/base-tool' import { readSettingsWorkspaceContext } from '@/lib/settings/application/context' export interface SettingsContext { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal scope: MothershipSettingsScope workspaceId?: string organizationId?: string diff --git a/apps/sim/lib/mothership/auth/application-delegation.test.ts b/apps/sim/lib/mothership/auth/application-delegation.test.ts index cd1d5562ea6..b9a04f29da6 100644 --- a/apps/sim/lib/mothership/auth/application-delegation.test.ts +++ b/apps/sim/lib/mothership/auth/application-delegation.test.ts @@ -47,16 +47,20 @@ describe('Copilot application delegation', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-call-1', audience: 'sim:files', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2026-01-01T00:05:00Z'), - resourceScope: { - fileId: 'file-1', - chatId: 'chat-1', - executionId: 'execution-1', - }, + invocation: { kind: 'chat', chatId: 'chat-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { + fileId: 'file-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + } as const, }) }) diff --git a/apps/sim/lib/mothership/auth/application-delegation.ts b/apps/sim/lib/mothership/auth/application-delegation.ts index 1471787c42a..1d0b6b59942 100644 --- a/apps/sim/lib/mothership/auth/application-delegation.ts +++ b/apps/sim/lib/mothership/auth/application-delegation.ts @@ -1,8 +1,9 @@ import type { - DelegatedPrincipal, OrganizationDelegatedPrincipal, - ResourceDelegatedPrincipal, ResourceDelegationScope, + ResourceOperationPrincipal, + WorkspaceSubjectDelegation, + workspaceDelegationRestrictions, } from '@sim/auth/principal' import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' import { TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/mothership/constants' @@ -31,7 +32,7 @@ export interface CopilotExecutionContext { copilotResourceAdmission?: CopilotResourceAdmission } -type CopilotResourcePrincipal = Extract +type CopilotResourcePrincipal = Extract const RESOURCE_ADMISSION = Symbol('copilot-resource-admission') @@ -151,6 +152,8 @@ export function createCopilotResourceApplicationPrincipal( const trustedContext = requireTrustedCopilotResourceExecutionContext(context) const issuedAt = new Date() const scope = structuredClone(options.scope) + if (scope.kind === 'entity' || scope.kind === 'file_collection_observation') + Object.freeze(scope.owner) if (scope.kind === 'file_copy') { Object.freeze(scope.source.owner) Object.freeze(scope.source.fileIds) @@ -160,7 +163,7 @@ export function createCopilotResourceApplicationPrincipal( Object.freeze(scope.destination) } const principal: CopilotResourcePrincipal = Object.freeze({ - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: trustedContext.userId, delegationId: options.createDelegationId(trustedContext), @@ -168,7 +171,7 @@ export function createCopilotResourceApplicationPrincipal( issuedAt, expiresAt: new Date(issuedAt.getTime() + options.ttlMs), invocation: Object.freeze({ ...trustedContext.invocation }), - scope: Object.freeze(scope), + grant: Object.freeze(scope), }) requireResourceDelegation(principal, { audience: options.audience, @@ -198,7 +201,7 @@ export class InteractiveCopilotExecutionRequiredError extends Error { } export type CopilotResourceScope = Pick< - NonNullable, + NonNullable>, 'fileId' | 'tableId' | 'credentialId' | 'mcpServerId' > @@ -237,7 +240,7 @@ export function createCopilotChatPrincipal( context: CopilotChatDelegationContext, audience: string, resourceScope?: CopilotResourceScope -): DelegatedPrincipal { +): WorkspaceSubjectDelegation { return createTrustedCopilotPrincipal( { ...context, delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}` }, { audience, ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, resourceScope } @@ -293,7 +296,7 @@ export function requireInteractiveCopilotExecutionContext( export function createTrustedCopilotPrincipal( input: CreateTrustedCopilotPrincipalInput, options: CreateTrustedCopilotPrincipalOptions -): DelegatedPrincipal { +): WorkspaceSubjectDelegation { requireNonEmpty(input.userId, 'an authenticated user ID') requireNonEmpty(input.workspaceId, 'a workspace ID') requireNonEmpty(input.delegationId, 'a delegation ID') @@ -331,16 +334,24 @@ export function createTrustedCopilotPrincipal( ...(input.executionId ? { executionId: input.executionId } : {}), }) - return Object.freeze({ + return Object.freeze({ kind: 'delegated', serviceId: 'copilot', subjectUserId: input.userId, - workspaceId: input.workspaceId, delegationId: input.delegationId, audience: options.audience, issuedAt, expiresAt: new Date(issuedAt.getTime() + options.ttlMs), - resourceScope, + invocation: Object.freeze( + input.chatId + ? { kind: 'chat' as const, chatId: input.chatId } + : { kind: 'workspace' as const, workspaceId: input.workspaceId } + ), + grant: Object.freeze({ + kind: 'entity' as const, + owner: Object.freeze({ entityType: 'workspace' as const, entityId: input.workspaceId }), + restrictions: resourceScope, + }), }) } @@ -348,7 +359,7 @@ export function createTrustedCopilotPrincipal( export function createCopilotApplicationPrincipal( trustedContext: TrustedCopilotExecutionContext, options: CreateCopilotApplicationPrincipalOptions -): DelegatedPrincipal { +): WorkspaceSubjectDelegation { const delegationId = options.createDelegationId(trustedContext) return createTrustedCopilotPrincipal( { diff --git a/apps/sim/lib/mothership/auth/file-delegation.test.ts b/apps/sim/lib/mothership/auth/file-delegation.test.ts index 9810c493aee..2ee8f3bc31b 100644 --- a/apps/sim/lib/mothership/auth/file-delegation.test.ts +++ b/apps/sim/lib/mothership/auth/file-delegation.test.ts @@ -1,3 +1,4 @@ +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { OrchestrationError } from '@/lib/core/orchestration/types' import { COPILOT_APPLICATION_DELEGATION_TTL_MS } from '@/lib/mothership/auth/application-delegation' @@ -25,14 +26,18 @@ describe('Copilot file delegation', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-call-1', audience: 'sim:workspace-files', - resourceScope: { - fileId: 'file-1', - chatId: 'chat-1', - executionId: 'execution-1', - }, + invocation: { kind: 'chat', chatId: 'chat-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { + fileId: 'file-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + } as const, }) expect(principal.expiresAt.getTime() - principal.issuedAt.getTime()).toBe( COPILOT_APPLICATION_DELEGATION_TTL_MS @@ -42,7 +47,7 @@ describe('Copilot file delegation', () => { it('creates a workspace-scoped principal for file creation', () => { const principal = resolveCopilotFilePrincipal(trustedContext) - expect(principal.resourceScope).toEqual({ + expect(workspaceDelegationRestrictions(principal)).toEqual({ chatId: 'chat-1', executionId: 'execution-1', }) @@ -72,7 +77,7 @@ describe('Copilot file delegation', () => { }) ).toMatchObject({ delegationId: 'copilot-chat:chat-1', - resourceScope: { chatId: 'chat-1' }, + grant: { restrictions: { chatId: 'chat-1' } }, }) expect( createCopilotWorkspaceContextFilePrincipal({ @@ -82,7 +87,7 @@ describe('Copilot file delegation', () => { }) ).toMatchObject({ delegationId: 'copilot-workspace-context:execution-1', - resourceScope: { executionId: 'execution-1' }, + grant: { restrictions: { executionId: 'execution-1' } }, }) }) diff --git a/apps/sim/lib/mothership/auth/file-delegation.ts b/apps/sim/lib/mothership/auth/file-delegation.ts index 8143400b037..7bebd00dc37 100644 --- a/apps/sim/lib/mothership/auth/file-delegation.ts +++ b/apps/sim/lib/mothership/auth/file-delegation.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { messageForCopilotApplicationError } from '@/lib/mothership/application/errors' import { COPILOT_APPLICATION_DELEGATION_TTL_MS, @@ -34,7 +34,7 @@ const fileDelegation = { export function resolveCopilotFilePrincipal( context: CopilotFileDelegationContext | undefined, fileId?: string -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { return createCopilotApplicationPrincipal(requireTrustedCopilotExecutionContext(context), { ...fileDelegation, resourceScope: fileId ? { fileId } : undefined, @@ -44,14 +44,14 @@ export function resolveCopilotFilePrincipal( /** Creates the principal used while resolving user-supplied chat file context. */ export function createCopilotChatFilePrincipal( context: CopilotChatFileDelegationContext -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { return createCopilotChatPrincipal(context, workspaceFileDelegationPolicy.audience) } /** Creates the principal used while materializing the Copilot workspace index. */ export function createCopilotWorkspaceContextFilePrincipal( context: CopilotWorkspaceContextFileDelegationContext -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { return createTrustedCopilotPrincipal( { userId: context.userId, diff --git a/apps/sim/lib/mothership/auth/internal.ts b/apps/sim/lib/mothership/auth/internal.ts index f6e8a49e97d..2230d484141 100644 --- a/apps/sim/lib/mothership/auth/internal.ts +++ b/apps/sim/lib/mothership/auth/internal.ts @@ -1,4 +1,7 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' import { generateId } from '@sim/utils/id' import type { NextRequest } from 'next/server' import { InternalUnauthenticatedError } from '@/lib/api/server/routes/internal-json-route' @@ -13,7 +16,7 @@ export function internalCopilotAuth(audience: string, options: { organization?: return { async authenticate( request: NextRequest - ): Promise { + ): Promise { if (!checkInternalApiKey(request).success) throw new InternalUnauthenticatedError() const userId = request.headers.get('x-mothership-user-id') const workspaceId = request.headers.get('x-mothership-workspace-id') diff --git a/apps/sim/lib/mothership/auth/table-delegation.test.ts b/apps/sim/lib/mothership/auth/table-delegation.test.ts index dc94340b004..2cbf6591fe6 100644 --- a/apps/sim/lib/mothership/auth/table-delegation.test.ts +++ b/apps/sim/lib/mothership/auth/table-delegation.test.ts @@ -17,7 +17,12 @@ describe('chat table delegation', () => { expect(principal).toMatchObject({ subjectUserId: 'user-1', audience: tableDelegationPolicy.audience, - resourceScope: { tableId: 'table-1', chatId: 'chat-1' }, + invocation: { kind: 'chat', chatId: 'chat-1' }, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { tableId: 'table-1', chatId: 'chat-1' }, + }, }) expect(tableDelegationPolicy.isWithinScope(principal, { ...context, tableId: 'table-1' })).toBe( true diff --git a/apps/sim/lib/mothership/chat/application/inline-images.test.ts b/apps/sim/lib/mothership/chat/application/inline-images.test.ts index aeb3735ef8a..c2a341f2711 100644 --- a/apps/sim/lib/mothership/chat/application/inline-images.test.ts +++ b/apps/sim/lib/mothership/chat/application/inline-images.test.ts @@ -185,7 +185,13 @@ describe('private chat image application boundary', () => { const invalid = { ...delegated, ...(kind === 'wrong-chat' - ? { resourceScope: { chatId: 'other' } } + ? { + invocation: { kind: 'chat' as const, chatId: 'other' }, + grant: { + ...delegated.grant, + restrictions: { ...delegated.grant.restrictions, chatId: 'other' }, + }, + } : kind === 'wrong-audience' ? { audience: 'other' } : { expiresAt: new Date(0) }), @@ -207,7 +213,10 @@ describe('private chat image application boundary', () => { kind: 'delegated', serviceId: 'copilot', audience: 'sim:workspace-files', - resourceScope: { chatId: 'chat' }, + invocation: { kind: 'chat', chatId: 'chat' }, + grant: expect.objectContaining({ + restrictions: expect.objectContaining({ chatId: 'chat' }), + }), }) }) it('refuses to stream an image whose source holds a recorded secret', async () => { @@ -339,7 +348,11 @@ describe('organization inline images', () => { ) expect(mocks.artifact).toHaveBeenCalledWith( expect.objectContaining({ - principal: expect.objectContaining({ workspaceId: 'target' }), + principal: expect.objectContaining({ + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'target' }, + }), + }), input: expect.objectContaining({ workspaceId: 'target', reference }), }) ) diff --git a/apps/sim/lib/mothership/chat/application/inline-images.ts b/apps/sim/lib/mothership/chat/application/inline-images.ts index 2c1dd3f388d..45739b188d6 100644 --- a/apps/sim/lib/mothership/chat/application/inline-images.ts +++ b/apps/sim/lib/mothership/chat/application/inline-images.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { admitContent, defineWorkspaceOperation, @@ -108,8 +109,8 @@ export const materializeInlineChatImage = defineAuthorizedChatUseCase({ delegation: { audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, isWithinScope: (principal, context) => - principal.resourceScope?.chatId === context.chatId && - principal.resourceScope?.fileId === undefined, + workspaceDelegationRestrictions(principal)?.chatId === context.chatId && + workspaceDelegationRestrictions(principal)?.fileId === undefined, }, }, async execute({ principal, context, input, request }) { diff --git a/apps/sim/lib/mothership/chat/application/read-attachment.ts b/apps/sim/lib/mothership/chat/application/read-attachment.ts index f53e65d7883..1befd4f607d 100644 --- a/apps/sim/lib/mothership/chat/application/read-attachment.ts +++ b/apps/sim/lib/mothership/chat/application/read-attachment.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { defineWorkspaceOperation, requireAdmittedContent } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -44,7 +45,8 @@ export const readChatAttachment = defineAuthorizedChatUseCase({ authorizationOptions: { delegation: { audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, - isWithinScope: (principal, context) => principal.resourceScope?.chatId === context.chatId, + isWithinScope: (principal, context) => + workspaceDelegationRestrictions(principal)?.chatId === context.chatId, }, }, async execute({ principal, context, input }) { diff --git a/apps/sim/lib/mothership/chat/application/read-sandbox-file.ts b/apps/sim/lib/mothership/chat/application/read-sandbox-file.ts index f31b9a86c1b..3a4c96b6a8e 100644 --- a/apps/sim/lib/mothership/chat/application/read-sandbox-file.ts +++ b/apps/sim/lib/mothership/chat/application/read-sandbox-file.ts @@ -1,5 +1,6 @@ import { posix } from 'node:path' import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { defineWorkspaceOperation, requireAdmittedContent } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -74,8 +75,8 @@ export const readChatSandboxFile = defineAuthorizedChatUseCase({ delegation: { audience: workspaceFileDelegationPolicy.audience, isWithinScope: (principal, context) => - principal.resourceScope?.chatId === context.chatId && - principal.resourceScope?.fileId === undefined, + workspaceDelegationRestrictions(principal)?.chatId === context.chatId && + workspaceDelegationRestrictions(principal)?.fileId === undefined, }, }, async execute({ context, input, principal }) { diff --git a/apps/sim/lib/mothership/chat/application/use-cases.ts b/apps/sim/lib/mothership/chat/application/use-cases.ts index 6bcb0554bd4..e6b9928115c 100644 --- a/apps/sim/lib/mothership/chat/application/use-cases.ts +++ b/apps/sim/lib/mothership/chat/application/use-cases.ts @@ -1,4 +1,8 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type Principal, + principalWorkspaceId, + requirePrincipalSubjectUserId, +} from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { truncate } from '@sim/utils/string' @@ -22,7 +26,7 @@ const chatDelegationPolicy = { isWithinScope: ( principal: Extract, context: { workspaceId: string } - ) => principal.workspaceId === context.workspaceId, + ) => principalWorkspaceId(principal) === context.workspaceId, } as const export class RestoreChatNotFoundError extends OrchestrationError { @@ -106,7 +110,7 @@ export const restoreMothershipChat: OperationUseCase< (input.assertedOrganizationId && input.assertedOrganizationId !== chat.organizationId) || (principal.kind === 'organization_delegated' && principal.organizationId !== chat.organizationId) || - (principal.kind === 'delegated' && principal.workspaceId !== chat.workspaceId) + (principal.kind === 'delegated' && principalWorkspaceId(principal) !== chat.workspaceId) ) throw new RestoreChatNotFoundError() if (chat.organizationId) { diff --git a/apps/sim/lib/mothership/chat/application/workspace-context.ts b/apps/sim/lib/mothership/chat/application/workspace-context.ts index 035b27a48e6..bb1da1a9762 100644 --- a/apps/sim/lib/mothership/chat/application/workspace-context.ts +++ b/apps/sim/lib/mothership/chat/application/workspace-context.ts @@ -1,4 +1,8 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { defineWorkspaceOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -31,20 +35,22 @@ export const readWorkspaceContext = defineAuthorizedChatUseCase({ async resolveContext({ principal, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: { workspaceId: string } }) { - const chatId = - principal.resourceScope && 'chatId' in principal.resourceScope - ? principal.resourceScope.chatId - : undefined + const restrictions = + principal.kind === 'organization_delegated' + ? principal.resourceScope + : workspaceDelegationRestrictions(principal) + const chatId = restrictions && 'chatId' in restrictions ? restrictions.chatId : undefined if (!chatId) throw new OrchestrationError('not_found', 'Conversation not found') return resolveOwnedChatContext(principal, chatId) }, authorizationOptions: { delegation: { audience: WORKSPACE_TARGET_AUDIENCE, - isWithinScope: (principal, context) => principal.resourceScope?.chatId === context.chatId, + isWithinScope: (principal, context) => + workspaceDelegationRestrictions(principal)?.chatId === context.chatId, }, }, async execute({ principal, input, context }) { diff --git a/apps/sim/lib/mothership/chat/application/workspace-target.ts b/apps/sim/lib/mothership/chat/application/workspace-target.ts index dcaf947d1cb..cd873bdf8ac 100644 --- a/apps/sim/lib/mothership/chat/application/workspace-target.ts +++ b/apps/sim/lib/mothership/chat/application/workspace-target.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { resolveEffectiveWorkspacePermission } from '@sim/platform-authz/workspace' import { authorizeWorkspaceOperation, defineWorkspaceOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' @@ -41,7 +42,8 @@ export const authorizeChatWorkspaceTarget = defineAuthorizedChatUseCase({ authorizationOptions: { delegation: { audience: WORKSPACE_TARGET_AUDIENCE, - isWithinScope: (principal, context) => principal.resourceScope?.chatId === context.chatId, + isWithinScope: (principal, context) => + workspaceDelegationRestrictions(principal)?.chatId === context.chatId, }, }, async execute({ context, input }) { diff --git a/apps/sim/lib/mothership/chat/lifecycle.ts b/apps/sim/lib/mothership/chat/lifecycle.ts index bc596ab99b5..9296221525f 100644 --- a/apps/sim/lib/mothership/chat/lifecycle.ts +++ b/apps/sim/lib/mothership/chat/lifecycle.ts @@ -1,4 +1,8 @@ -import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type Principal, + resolvePrincipalSubjectUserId, + workspaceDelegationRestrictions, +} from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, copilotMessages } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -361,7 +365,8 @@ async function authorizeCopilotChatRow( if (!principal || resolvePrincipalSubjectUserId(principal) !== userId) return null if ( principal.kind === 'organization_delegated' && - (principal.serviceId !== 'copilot' || principal.resourceScope.chatId !== chat.id) + (principal.serviceId !== 'copilot' || + workspaceDelegationRestrictions(principal)?.chatId !== chat.id) ) return null try { diff --git a/apps/sim/lib/mothership/chat/process-contents.test.ts b/apps/sim/lib/mothership/chat/process-contents.test.ts index 511f6be2a16..80275d89d7a 100644 --- a/apps/sim/lib/mothership/chat/process-contents.test.ts +++ b/apps/sim/lib/mothership/chat/process-contents.test.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import { dbChainMockFns, workflowAuthzMockFns } from '@sim/testing' import { encryptionMock, encryptionMockFns } from '@sim/testing/mocks/encryption.mock' import { @@ -237,7 +238,13 @@ describe('processContextsServer - workflow references', () => { discovery: expect.stringContaining('including descendants'), }) expect(listWorkflowFolders).toHaveBeenCalledWith({ - principal: expect.objectContaining({ subjectUserId: 'reader', workspaceId: 'workspace-1' }), + principal: expect.objectContaining({ + subjectUserId: 'reader', + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }), + }), input: expect.objectContaining({ workspaceId: 'workspace-1' }), }) }) @@ -265,8 +272,11 @@ describe('processContextsServer - knowledge contexts', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'dual-workspace-user', - workspaceId: 'workspace-a', audience: 'sim:knowledge', + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-a' }, + }), }), input: { knowledgeBaseId: 'knowledge-1', @@ -368,8 +378,11 @@ describe('processContextsServer - skill contexts', () => { expect(getSkillUseCase).toHaveBeenCalledWith({ principal: expect.objectContaining({ subjectUserId: 'user-1', - workspaceId: 'ws-1', audience: 'sim:skills', + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + }), }), input: { skillId: 'sk-1', workspaceId: 'ws-1' }, }) @@ -401,7 +414,13 @@ describe('processContextsServer - skill contexts', () => { ) expect(getSkillUseCase).toHaveBeenCalledWith({ - principal: expect.objectContaining({ subjectUserId: 'user-1', workspaceId: 'ws-1' }), + principal: expect.objectContaining({ + subjectUserId: 'user-1', + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + }), + }), input: { skillId, workspaceId: 'ws-1' }, }) expect(result).toEqual([ @@ -643,7 +662,10 @@ describe('processContextsServer - MCP contexts', () => { serviceId: 'copilot', audience: 'sim:mcp-servers', subjectUserId: 'user-1', - workspaceId: 'ws-1', + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + }), }), input: { workspaceId: 'ws-1', serverId: 'mcp-server-1', requireComplete: true }, }) @@ -967,7 +989,13 @@ describe('file folder context', () => { discovery: expect.stringContaining('including descendants'), }) expect(resolveFileFolderPath).toHaveBeenCalledWith({ - principal: expect.objectContaining({ subjectUserId: 'reader', workspaceId: 'ws-1' }), + principal: expect.objectContaining({ + subjectUserId: 'reader', + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + }), + }), input: { workspaceId: 'ws-1', folderId: 'folder-1' }, }) }) @@ -1181,8 +1209,11 @@ describe('processContextsServer - table_selection contexts', () => { expect(queryTableRows).toHaveBeenCalledWith({ principal: expect.objectContaining({ subjectUserId: 'user-1', - workspaceId: 'ws-1', - resourceScope: { tableId: 'tbl-1' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + restrictions: { tableId: 'tbl-1' }, + }), }), input: { tableId: 'tbl-1', @@ -1659,9 +1690,12 @@ describe('workflow resource context consistency', () => { expect(readWorkflowMetadata).toHaveBeenCalledWith({ principal: expect.objectContaining({ subjectUserId: 'user-1', - workspaceId: 'ws-1', audience: 'sim:workflows', - resourceScope: { chatId: 'chat-1' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + restrictions: { chatId: 'chat-1' }, + }), }), input: { workflowId: 'flow', assertedWorkspaceId: 'ws-1' }, }) @@ -1735,8 +1769,11 @@ describe('folder and foldered-resource chat pointers', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'ws-1', - resourceScope: { chatId: 'chat-1' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + restrictions: { chatId: 'chat-1' }, + }), }), input: expect.objectContaining({ workspaceId: 'ws-1' }), }) @@ -1772,9 +1809,12 @@ describe('folder and foldered-resource chat pointers', () => { expect(resolveFileFolderPath).toHaveBeenCalledWith({ principal: expect.objectContaining({ subjectUserId: 'user-1', - workspaceId: 'ws-1', audience: 'sim:workspace-files', - resourceScope: { chatId: 'chat-1' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + restrictions: { chatId: 'chat-1' }, + }), }), input: { workspaceId: 'ws-1', folderId: 'child' }, }) @@ -1907,8 +1947,11 @@ describe('folder and foldered-resource chat pointers', () => { principal: expect.objectContaining({ audience: 'sim:tables', subjectUserId: 'user-1', - workspaceId: 'ws-1', - resourceScope: { tableId: 'hidden' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'ws-1' }, + restrictions: { tableId: 'hidden' }, + }), }), input: { tableId: 'hidden', workspaceId: 'ws-1' }, }) @@ -1948,9 +1991,12 @@ describe('table view context', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'reader', - workspaceId: 'workspace-1', audience: 'sim:tables', - resourceScope: { tableId: 'table-1', chatId: 'chat-1' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { tableId: 'table-1', chatId: 'chat-1' }, + }), }), input: { tableId: 'table-1', workspaceId: 'workspace-1', viewId: 'view-1' }, }) @@ -2164,8 +2210,7 @@ describe('organization resource mention targets', () => { input: { workspaceId?: string; assertedWorkspaceId?: string } }) => { if ( - !('workspaceId' in principal) || - principal.workspaceId !== workspaceId || + principalWorkspaceId(principal) !== workspaceId || (input.workspaceId ?? input.assertedWorkspaceId) !== workspaceId ) { throw new DelegatedWorkspaceAuthorizationError() diff --git a/apps/sim/lib/mothership/chat/title-context.test.ts b/apps/sim/lib/mothership/chat/title-context.test.ts index 7b45e63cbf7..736fee382b5 100644 --- a/apps/sim/lib/mothership/chat/title-context.test.ts +++ b/apps/sim/lib/mothership/chat/title-context.test.ts @@ -45,8 +45,10 @@ describe('buildChatTitleContext', () => { expect(getWorkspace).toHaveBeenCalledWith({ principal: expect.objectContaining({ subjectUserId: 'user', - workspaceId: 'workspace', audience: 'sim:workspaces', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'workspace' }, + }), }), input: { workspaceId: 'workspace' }, }) diff --git a/apps/sim/lib/mothership/content-delivery/dispatch.test.ts b/apps/sim/lib/mothership/content-delivery/dispatch.test.ts index b03ab096ed5..ac5ce4bb246 100644 --- a/apps/sim/lib/mothership/content-delivery/dispatch.test.ts +++ b/apps/sim/lib/mothership/content-delivery/dispatch.test.ts @@ -23,11 +23,12 @@ const CHAT: Principal = { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:test', issuedAt: new Date(now), expiresAt: new Date(now + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, } const SOURCED = defineWorkspaceOperation({ diff --git a/apps/sim/lib/mothership/integrations/application/catalog.test.ts b/apps/sim/lib/mothership/integrations/application/catalog.test.ts index 78ef337b5c8..9c31a3dbf69 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.test.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.test.ts @@ -378,7 +378,12 @@ it('filters organization enabled servers to the authorized target before broad M }, }) expect(mocks.listServers).toHaveBeenCalledWith({ - principal: expect.objectContaining({ workspaceId: 'workspace-1', subjectUserId: 'actor' }), + principal: expect.objectContaining({ + subjectUserId: 'actor', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }), + }), input: { workspaceId: 'workspace-1' }, }) expect(mocks.mcp).toHaveBeenCalledWith('actor', 'workspace-1', ['mcp-abc'], undefined) diff --git a/apps/sim/lib/mothership/integrations/application/catalog.ts b/apps/sim/lib/mothership/integrations/application/catalog.ts index 491c86a6407..6751e941b13 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.ts @@ -1,10 +1,13 @@ import { - type DelegatedPrincipal, + isWorkspaceDelegation, type OrganizationDelegatedPrincipal, type Principal, parsePrincipal, + principalWorkspaceId, resolvePrincipalExecutionActorUserId, resolvePrincipalSubjectUserId, + type WorkspaceDelegatedPrincipal, + workspaceDelegationRestrictions, } from '@sim/auth/principal' import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata' import { stripVersionSuffix } from '@sim/utils/string' @@ -142,7 +145,7 @@ const catalogUseCase = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: IntegrationCatalogRequest }) { if (principal.kind === 'organization_delegated') { @@ -153,15 +156,15 @@ const catalogUseCase = defineAuthorizedChatUseCase({ throw new OrchestrationError('forbidden', 'Conversation mode mismatch') return context } - if (input.workspaceId && input.workspaceId !== principal.workspaceId) + if (input.workspaceId && input.workspaceId !== principalWorkspaceId(principal)) throw new OrchestrationError('not_found', 'Workspace not found in this conversation') const userId = resolvePrincipalExecutionActorUserId(principal) if (!userId) throw new OrchestrationError('forbidden', 'Integration discovery requires a user') return { - ...(await resolveActiveWorkspaceApplicationContext(principal.workspaceId)), + ...(await resolveActiveWorkspaceApplicationContext(principalWorkspaceId(principal))), organizationId: undefined, userId, - chatId: principal.resourceScope?.chatId, + chatId: workspaceDelegationRestrictions(principal)?.chatId, mode: input.mode, } }, @@ -280,22 +283,22 @@ async function catalogPrincipal( return principal } if ( - principal.kind !== 'delegated' || + !isWorkspaceDelegation(principal) || principal.serviceId !== 'copilot' || principal.audience !== INTEGRATION_CATALOG_AUDIENCE || principal.expiresAt.getTime() <= Date.now() || input.mode !== 'agent' || - (input.workspaceId && input.workspaceId !== principal.workspaceId) + (input.workspaceId && input.workspaceId !== principalWorkspaceId(principal)) ) throw new OrchestrationError('forbidden', 'Invalid executor catalog delegation') const userId = resolvePrincipalSubjectUserId(principal) if (!userId) throw new OrchestrationError('forbidden', 'Executor catalog actor required') const executor = await createExecutorPrincipalFromExecutionContext({ - context: catalogExecutorContext(input, userId, principal.workspaceId), + context: catalogExecutorContext(input, userId, principalWorkspaceId(principal)), audience: INTEGRATION_CATALOG_AUDIENCE, expiresAt: principal.expiresAt, }) - if (executor.workspaceId !== principal.workspaceId) + if (executor.workspaceId !== principalWorkspaceId(principal)) throw new OrchestrationError('not_found', 'Executor catalog workspace does not match') return executor } diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.ts b/apps/sim/lib/mothership/memories/application/resolve-access.ts index a52ba63bc29..407b20e6b55 100644 --- a/apps/sim/lib/mothership/memories/application/resolve-access.ts +++ b/apps/sim/lib/mothership/memories/application/resolve-access.ts @@ -1,4 +1,8 @@ -import type { OrganizationDelegatedPrincipal, SubjectDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceSubjectDelegation, +} from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, project, workspace } from '@sim/db/schema' import { permissionSatisfies } from '@sim/platform-authz/workspace' @@ -46,7 +50,7 @@ const organizationOperation = defineOrganizationOperation({ delegatedServices: ['copilot'], delegationAudience: MEMORY_NOTES_ACCESS_AUDIENCE, }) -type MemoryPrincipal = SubjectDelegatedPrincipal | OrganizationDelegatedPrincipal +type MemoryPrincipal = WorkspaceSubjectDelegation | OrganizationDelegatedPrincipal /** The worker acts on the answer after commit, so a consistent unlocked snapshot is enough. */ const READ_SNAPSHOT = { isolationLevel: 'repeatable read', accessMode: 'read only' } as const @@ -140,10 +144,11 @@ const resolveAccess = defineAuthorizedChatUseCase({ principal: MemoryPrincipal input: MemoryNotesAccessInput }) { - const chatId = - principal.resourceScope && 'chatId' in principal.resourceScope - ? principal.resourceScope.chatId - : undefined + const restrictions = + principal.kind === 'organization_delegated' + ? principal.resourceScope + : workspaceDelegationRestrictions(principal) + const chatId = restrictions && 'chatId' in restrictions ? restrictions.chatId : undefined const now = Date.now() if ( principal.serviceId !== 'copilot' || @@ -161,7 +166,8 @@ const resolveAccess = defineAuthorizedChatUseCase({ authorizationOptions: { delegation: { audience: MEMORY_NOTES_ACCESS_AUDIENCE, - isWithinScope: (principal, context) => principal.resourceScope?.chatId === context.chatId, + isWithinScope: (principal, context) => + workspaceDelegationRestrictions(principal)?.chatId === context.chatId, }, }, async execute({ principal, context, input }): Promise { diff --git a/apps/sim/lib/mothership/memory/application/read-scope.test.ts b/apps/sim/lib/mothership/memory/application/read-scope.test.ts index b2e5194e54e..fe7c8170921 100644 --- a/apps/sim/lib/mothership/memory/application/read-scope.test.ts +++ b/apps/sim/lib/mothership/memory/application/read-scope.test.ts @@ -156,7 +156,14 @@ describe('private memory scope', () => { workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission.mockResolvedValue('read') await expect( readMemoryScope.execute({ - principal: { ...caller, resourceScope: { chatId: 'other' } }, + principal: { + ...caller, + invocation: { kind: 'chat', chatId: 'other' }, + grant: { + ...caller.grant, + restrictions: { ...caller.grant.restrictions, chatId: 'other' }, + }, + }, input, }) ).rejects.toThrow() diff --git a/apps/sim/lib/mothership/memory/application/read-scope.ts b/apps/sim/lib/mothership/memory/application/read-scope.ts index 57cd3791f82..2b5b0680161 100644 --- a/apps/sim/lib/mothership/memory/application/read-scope.ts +++ b/apps/sim/lib/mothership/memory/application/read-scope.ts @@ -1,4 +1,8 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' +import { principalWorkspaceId, workspaceDelegationRestrictions } from '@sim/auth/principal' import { defineWorkspaceOperation } from '@/lib/core/application' import { defineOrganizationOperation } from '@/lib/core/application/organization-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -35,7 +39,7 @@ export const readMemoryScope = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: MemoryScopeRequest }) => resolveOwnedChatContext(principal, input.chatId), authorizationOptions: { @@ -43,8 +47,8 @@ export const readMemoryScope = defineAuthorizedChatUseCase({ audience: MEMORY_SCOPE_AUDIENCE, isWithinScope: (principal, context) => principal.serviceId === 'copilot' && - principal.workspaceId === context.workspaceId && - principal.resourceScope?.chatId === context.chatId, + principalWorkspaceId(principal) === context.workspaceId && + workspaceDelegationRestrictions(principal)?.chatId === context.chatId, }, }, async execute({ context }) { diff --git a/apps/sim/lib/mothership/request/application/read-control.ts b/apps/sim/lib/mothership/request/application/read-control.ts index 44b909bd767..b39b8508d5b 100644 --- a/apps/sim/lib/mothership/request/application/read-control.ts +++ b/apps/sim/lib/mothership/request/application/read-control.ts @@ -1,8 +1,9 @@ import type { - DelegatedPrincipal, OrganizationDelegatedPrincipal, Principal, + WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' +import { principalWorkspaceId, workspaceDelegationRestrictions } from '@sim/auth/principal' import { type AuthorizingUseCase, DelegatedServiceAuthorizationError, @@ -47,7 +48,7 @@ const readLiveRunControl = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: RunControlRequest }) => resolveOwnedChatContext(principal, input.chatId), authorizationOptions: { @@ -55,8 +56,9 @@ const readLiveRunControl = defineAuthorizedChatUseCase({ audience: RUN_CONTROL_AUDIENCE, isWithinScope: (principal, context) => principal.serviceId === 'copilot' && - principal.workspaceId === context.workspaceId && - (!principal.resourceScope?.chatId || principal.resourceScope.chatId === context.chatId), + principalWorkspaceId(principal) === context.workspaceId && + (!workspaceDelegationRestrictions(principal)?.chatId || + workspaceDelegationRestrictions(principal)?.chatId === context.chatId), }, }, async execute({ input, context }) { @@ -93,7 +95,7 @@ function requireRunControlCaller(principal: Principal, input: RunControlRequest) /** A workspace delegation may leave the chat unscoped; an organization one always names it. */ const chatScope = principal.kind === 'delegated' - ? (principal.resourceScope?.chatId ?? input.chatId) + ? (workspaceDelegationRestrictions(principal)?.chatId ?? input.chatId) : principal.serviceId === 'copilot' ? principal.resourceScope.chatId : undefined diff --git a/apps/sim/lib/mothership/request/application/recover-stream.ts b/apps/sim/lib/mothership/request/application/recover-stream.ts index 62125711caf..ec0035d13c7 100644 --- a/apps/sim/lib/mothership/request/application/recover-stream.ts +++ b/apps/sim/lib/mothership/request/application/recover-stream.ts @@ -1,9 +1,11 @@ import { - type DelegatedPrincipal, type OrganizationDelegatedPrincipal, type Principal, + principalWorkspaceId, resolvePrincipalSubjectUserId, type SessionPrincipal, + type WorkspaceDelegatedPrincipal, + workspaceDelegationRestrictions, } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -129,7 +131,7 @@ export const takeOverStreamRun = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: { streamId: string } }) => resolveStreamRunContext({ principal, input }), authorizationOptions: { @@ -137,8 +139,8 @@ export const takeOverStreamRun = defineAuthorizedChatUseCase({ audience: RUN_TAKEOVER_AUDIENCE, isWithinScope: (principal, context) => principal.serviceId === 'copilot' && - principal.workspaceId === context.workspaceId && - principal.resourceScope?.chatId === context.chatId, + principalWorkspaceId(principal) === context.workspaceId && + workspaceDelegationRestrictions(principal)?.chatId === context.chatId, }, }, execute: ({ context, request }) => recoverStreamRun(context, request?.signal), diff --git a/apps/sim/lib/mothership/request/go/stream.test.ts b/apps/sim/lib/mothership/request/go/stream.test.ts index 48431df2ca3..f7f037509d5 100644 --- a/apps/sim/lib/mothership/request/go/stream.test.ts +++ b/apps/sim/lib/mothership/request/go/stream.test.ts @@ -58,8 +58,12 @@ vi.mock('@/lib/mothership/application/execute-file-use-case', () => ({ kind: 'delegated', serviceId: 'copilot', subjectUserId: context.userId, - workspaceId: context.workspaceId, delegationId: context.toolCallId, + invocation: { kind: 'workspace', workspaceId: context.workspaceId } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: context.workspaceId }, + } as const, }, input, }), @@ -685,7 +689,9 @@ describe('copilot go stream helpers', () => { expect(mockFindFilesByReference).toHaveBeenCalledWith({ principal: expect.objectContaining({ kind: 'delegated', - workspaceId: 'workspace-1', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }), }), input: { workspaceId: 'workspace-1', diff --git a/apps/sim/lib/mothership/tasks/application/context.ts b/apps/sim/lib/mothership/tasks/application/context.ts index a08fa9d5f8a..d58f0b701cb 100644 --- a/apps/sim/lib/mothership/tasks/application/context.ts +++ b/apps/sim/lib/mothership/tasks/application/context.ts @@ -1,3 +1,4 @@ +import { principalWorkspaceId, workspaceDelegationRestrictions } from '@sim/auth/principal' import type { WorkspaceAuthorizationContext, WorkspaceDelegationPolicy, @@ -14,6 +15,7 @@ export const taskDelegationPolicy: WorkspaceDelegationPolicy< audience: TASK_DELEGATION_AUDIENCE, isWithinScope: (principal, context) => principal.serviceId === 'copilot' && - principal.workspaceId === context.workspaceId && - (!principal.resourceScope?.chatId || principal.resourceScope.chatId === context.chatId), + principalWorkspaceId(principal) === context.workspaceId && + (!workspaceDelegationRestrictions(principal)?.chatId || + workspaceDelegationRestrictions(principal)?.chatId === context.chatId), } diff --git a/apps/sim/lib/mothership/tools/handlers/function-execute-table-mounts.test.ts b/apps/sim/lib/mothership/tools/handlers/function-execute-table-mounts.test.ts index 19a460bad5f..a55cb16f4e3 100644 --- a/apps/sim/lib/mothership/tools/handlers/function-execute-table-mounts.test.ts +++ b/apps/sim/lib/mothership/tools/handlers/function-execute-table-mounts.test.ts @@ -249,12 +249,22 @@ describe('Mothership table mounts through code calls', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'actor', - workspaceId: mode === 'workspace' ? 'other' : 'workspace', delegationId: 'call', audience: mode === 'audience' ? 'sim:files' : 'sim:tables', issuedAt: new Date(Date.now() - 1000), expiresAt: new Date(Date.now() + (mode === 'expired' ? -1 : 60000)), - resourceScope: mode === 'scope' ? { tableId: 'other' } : {}, + invocation: { + kind: 'workspace', + workspaceId: mode === 'workspace' ? 'other' : 'workspace', + } as const, + grant: { + kind: 'entity', + owner: { + entityType: 'workspace', + entityId: mode === 'workspace' ? 'other' : 'workspace', + }, + restrictions: mode === 'scope' ? { tableId: 'other' } : {}, + } as const, }, input: { workspaceId: 'workspace', diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts index 6c0f18210db..203fb5e6baa 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts @@ -385,10 +385,10 @@ it('reconstructs Project authority from the admitted lease without borrowing its delegationAudience: 'sim:project-files', }) expect(principal).toMatchObject({ - kind: 'resource_delegated', + kind: 'delegated', subjectUserId: scope.userId, - invocation: { kind: 'chat', chatId: scope.chatId }, - scope: { kind: 'entity', entityType: 'project', entityId: 'project' }, + invocation: { kind: 'chat', chatId: scope.chatId } as const, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: 'project' } } as const, }) expect(principal).not.toHaveProperty('workspaceId') return Response.json({ data: [] }) diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts index 3e0daa2a5c8..27b3c224a18 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts @@ -2,7 +2,10 @@ import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' import { NextRequest } from 'next/server' import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy' -import { markCopilotRequest } from '@/lib/api/server/routes/copilot-request' +import { + createWorkspaceRequestGrant, + markCopilotRequest, +} from '@/lib/api/server/routes/copilot-request' import { matchV2Route } from '@/lib/api/server/routes/in-process-transport' import { V2_PARSE_DEFAULTS } from '@/lib/api/server/routes/v2-json-route' import { DEFAULT_MAX_JSON_BODY_BYTES, parseRequest } from '@/lib/api/server/validation' @@ -139,11 +142,14 @@ async function proxyAuthorizedSandboxRequest( return Response.json({ error: 'Method not allowed' }, { status: 405 }) const dispatch = async () => { const privateRequest = new NextRequest(forwarded) - markCopilotRequest(privateRequest, { - userId: scope.userId, - workspaceId: targetWorkspaceId, - chatId: scope.chatId, - }) + markCopilotRequest( + privateRequest, + createWorkspaceRequestGrant({ + userId: scope.userId, + workspaceId: targetWorkspaceId, + chatId: scope.chatId, + }) + ) const result = await handler(privateRequest, { params: Promise.resolve(matched.params), }) diff --git a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts index 8ae1042bd73..18167a9a10d 100644 --- a/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts +++ b/apps/sim/lib/mothership/tools/server/knowledge/workspace-search.test.ts @@ -221,7 +221,9 @@ describe('Assistant retrieval tools', () => { principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'reader', - workspaceId: 'workspace', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'workspace' }, + }), }), input: expect.objectContaining({ workspaceId: 'workspace', diff --git a/apps/sim/lib/mothership/tools/server/open-resource.test.ts b/apps/sim/lib/mothership/tools/server/open-resource.test.ts index e09e77f6aa6..a8f8b339cc3 100644 --- a/apps/sim/lib/mothership/tools/server/open-resource.test.ts +++ b/apps/sim/lib/mothership/tools/server/open-resource.test.ts @@ -93,7 +93,12 @@ describe('resource opening authorization boundary', () => { for (const read of Object.values(mocks)) expect(read).toHaveBeenCalledWith( expect.objectContaining({ - principal: expect.objectContaining({ subjectUserId: 'actor', workspaceId: 'ws-a' }), + principal: expect.objectContaining({ + subjectUserId: 'actor', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'ws-a' }, + }), + }), }) ) expect(mocks.workflow.mock.calls[0][0].input).toEqual({ diff --git a/apps/sim/lib/mothership/tools/server/settings-management-boundary.test.ts b/apps/sim/lib/mothership/tools/server/settings-management-boundary.test.ts index 7cd7052b52c..fa5243d523a 100644 --- a/apps/sim/lib/mothership/tools/server/settings-management-boundary.test.ts +++ b/apps/sim/lib/mothership/tools/server/settings-management-boundary.test.ts @@ -1,4 +1,7 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' import { beforeEach, describe, expect, it, vi } from 'vitest' const { resolve, sectionAccess } = vi.hoisted(() => ({ resolve: vi.fn(), sectionAccess: vi.fn() })) @@ -50,10 +53,16 @@ const organizationPrincipal: OrganizationDelegatedPrincipal = { kind: 'organization_delegated', organizationId: 'trusted-org', } -const workspacePrincipal: DelegatedPrincipal = { - ...base, +const { resourceScope, ...workspaceBase } = base +const workspacePrincipal: WorkspaceDelegatedPrincipal = { + ...workspaceBase, kind: 'delegated', - workspaceId: 'trusted-workspace', + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'trusted-workspace' }, + restrictions: resourceScope, + }, + invocation: { kind: 'chat', chatId: 'current-chat' }, } function target(scope: 'account' | 'organization' | 'workspace') { resolve.mockResolvedValue({ diff --git a/apps/sim/lib/mothership/transport/control.test.ts b/apps/sim/lib/mothership/transport/control.test.ts index 14e34392109..c3df1be7511 100644 --- a/apps/sim/lib/mothership/transport/control.test.ts +++ b/apps/sim/lib/mothership/transport/control.test.ts @@ -76,9 +76,12 @@ describe('outbound control delivery uses the existing authorized operations', () input: operation.input, principal: expect.objectContaining({ subjectUserId: 'user', - workspaceId: 'workspace', audience: 'control', - resourceScope: { chatId: 'chat' }, + invocation: { kind: 'chat', chatId: 'chat' }, + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'workspace' }, + restrictions: expect.objectContaining({ chatId: 'chat' }), + }), }), }) }) @@ -223,8 +226,10 @@ it('routes catalog reads through catalog-specific delegated authority', async () expect(handlers.catalog).toHaveBeenCalledWith({ principal: expect.objectContaining({ audience: 'catalog', - workspaceId: 'workspace', subjectUserId: 'user', + grant: expect.objectContaining({ + owner: { entityType: 'workspace', entityId: 'workspace' }, + }), }), input, }) @@ -246,7 +251,14 @@ it('routes memory through the same use case for both delivery scopes', async () principal: expect.objectContaining({ subjectUserId: 'user', audience: 'memory', - resourceScope: { chatId: 'chat' }, + ...('organizationId' in owner + ? { resourceScope: { chatId: 'chat' } } + : { + invocation: { kind: 'chat', chatId: 'chat' }, + grant: expect.objectContaining({ + restrictions: expect.objectContaining({ chatId: 'chat' }), + }), + }), }), }) } diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 78a5baf89fe..ea667f5da25 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -1,5 +1,9 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type DelegatedPrincipal, resolvePrincipalSubject } from '@sim/auth/principal' +import { + principalWorkspaceId, + resolvePrincipalSubject, + type WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { @@ -441,7 +445,7 @@ export interface ResolveCredentialAccessTokenInput * managed credentials are rejected with `MANAGED_CREDENTIAL_DELEGATION_REQUIRED`. * Must throw {@link InvalidManagedOAuthDelegationError} on an invalid delegation. */ - resolveManagedPrincipal?: (credentialId: string) => Promise + resolveManagedPrincipal?: (credentialId: string) => Promise } /** @@ -486,7 +490,7 @@ export async function resolveCredentialAccessToken( } } - let principal: DelegatedPrincipal + let principal: WorkspaceDelegatedPrincipal try { principal = await input.resolveManagedPrincipal(resolved.credentialId) } catch (error) { @@ -556,9 +560,9 @@ export async function resolveCredentialAccessToken( { credential_type: 'managed_oauth', provider_id: toolMetadata.oauth.provider, - workspace_id: principal.workspaceId, + workspace_id: principalWorkspaceId(principal), }, - { groups: { workspace: principal.workspaceId } } + { groups: { workspace: principalWorkspaceId(principal) } } ) } diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts index 5887c6dd848..b1e6b9a27aa 100644 --- a/apps/sim/lib/projects/__integration__/foundation.integration.ts +++ b/apps/sim/lib/projects/__integration__/foundation.integration.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, @@ -161,17 +161,17 @@ function check(name: string, run: () => Promise) { function discoveryPrincipal( userId: string, workspaceId: string -): Extract { +): Extract { return { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: userId, delegationId: generateId(), audience: 'sim:projects:discovery', issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - invocation: { kind: 'workspace', workspaceId }, - scope: { kind: 'project_discovery' }, + invocation: { kind: 'workspace', workspaceId } as const, + grant: { kind: 'project_discovery' } as const, } } @@ -1582,7 +1582,7 @@ describe('Project discovery delegation', () => { userId: first.ownerId, permissionType: 'admin', }) - const principal: ResourceDelegatedPrincipal = { + const principal: ResourceOperationPrincipal = { ...discoveryPrincipal(first.ownerId, first.ids[0]), serviceId: 'copilot', invocation: { kind: 'chat', chatId }, @@ -1664,10 +1664,9 @@ describe('Project discovery delegation', () => { { issuedAt: new Date('invalid') }, { audience: 'sim:workspace-files' }, { - scope: { + grant: { kind: 'entity' as const, - entityType: 'project' as const, - entityId: state.projectId, + owner: { entityType: 'project' as const, entityId: state.projectId }, }, }, ]) { diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts index f91e22c75a5..a3dc212dd00 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -1,6 +1,7 @@ import { + hasResourceOperationGrant, type Principal, - type ResourceDelegatedPrincipal, + type ResourceOperationPrincipal, requirePrincipalSubjectUserId, type SessionPrincipal, } from '@sim/auth/principal' @@ -30,10 +31,15 @@ export function requireProjectPrincipal< >( principal: Principal, operation: O -): asserts principal is Extract { +): asserts principal is Extract< + SessionPrincipal | ResourceOperationPrincipal, + { kind: O['principalKinds'][number] } +> { if (!operation.principalKinds.some((kind) => kind === principal.kind)) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) - if (principal.kind === 'resource_delegated') { + if (principal.kind === 'delegated' && !hasResourceOperationGrant(principal)) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + if (hasResourceOperationGrant(principal)) { if (operation.id !== 'projects.list' || !operation.delegationAudience) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) requireResourceDelegation(principal, { @@ -219,7 +225,7 @@ export async function loadProjectAccess( /** Applies the access rules to one loaded Project; hidden environments never enter the result. */ async function evaluateProjectAccess( tx: DbTransaction, - principal: SessionPrincipal | ResourceDelegatedPrincipal, + principal: SessionPrincipal | ResourceOperationPrincipal, operation: ProjectOperation, record: ProjectRecord, access: ProjectAccess, @@ -277,7 +283,7 @@ export type AuthorizedProject = Awaited /** Authorizes one Project for `operation`; see {@link ProjectAccessMode} for locking. */ export async function authorizeProject( tx: DbTransaction, - principal: SessionPrincipal | ResourceDelegatedPrincipal, + principal: SessionPrincipal | ResourceOperationPrincipal, operation: ProjectOperation, input: ProjectAuthorizationInput & { projectId: string }, mode: ProjectAccessMode @@ -303,7 +309,7 @@ export async function authorizeProject( /** Authorizes a listed page of Projects in id order inside the caller's read-only snapshot. */ export async function authorizeProjectsForRead( tx: DbTransaction, - principal: SessionPrincipal | ResourceDelegatedPrincipal, + principal: SessionPrincipal | ResourceOperationPrincipal, projectIds: string[] ): Promise { requireProjectPrincipal(principal, projectOperations.list) diff --git a/apps/sim/lib/projects/application/discovery.ts b/apps/sim/lib/projects/application/discovery.ts index d8ec0a1773d..93745189b76 100644 --- a/apps/sim/lib/projects/application/discovery.ts +++ b/apps/sim/lib/projects/application/discovery.ts @@ -1,4 +1,4 @@ -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' @@ -11,7 +11,7 @@ import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-as /** Project access stays inside the current, server-resolved conversation owner. */ export async function resolveCopilotProjectScope( - principal: ResourceDelegatedPrincipal, + principal: ResourceOperationPrincipal, assertedOrganizationId?: string ): Promise<{ organizationId: string | null; workspaceId?: string }> { if (principal.serviceId !== 'copilot') diff --git a/apps/sim/lib/projects/application/operations.ts b/apps/sim/lib/projects/application/operations.ts index ba73e90e5e7..7f147a93045 100644 --- a/apps/sim/lib/projects/application/operations.ts +++ b/apps/sim/lib/projects/application/operations.ts @@ -1,8 +1,12 @@ import type { ApplicationOperation } from '@/lib/core/application/operation' -import { assertOperationCapability, defineOperation } from '@/lib/core/application/operation' +import { + assertContentDeliveryDeclaration, + assertOperationCapability, + defineOperation, +} from '@/lib/core/application/operation' export interface ProjectOperation extends ApplicationOperation { - readonly principalKinds: readonly ('session' | 'resource_delegated')[] + readonly principalKinds: readonly ('session' | 'delegated')[] readonly access: 'read' | 'admin' | 'issues' readonly delegationAudience?: string readonly delegatedServices?: readonly ['copilot'] @@ -11,18 +15,21 @@ export interface ProjectOperation extends ApplicationOperation { export const PROJECT_DISCOVERY_DELEGATION_TTL_MS = 60_000 /** Project operations cannot borrow authority from workspace keys or an arbitrary root. */ -function defineProjectOperation(operation: O): O { +function defineProjectOperation( + operation: O +): O & { readonly delegationScope: 'resource' } { assertOperationCapability(operation) if ( - operation.principalKinds.includes('resource_delegated') && + operation.principalKinds.includes('delegated') && (operation.id !== 'projects.list' || operation.access !== 'read' || !operation.delegationAudience) ) throw new Error('Resource delegation is available only for bounded Project discovery') + assertContentDeliveryDeclaration(operation, operation.delegatedServices ?? []) Object.freeze(operation.principalKinds) if (operation.delegatedServices) Object.freeze(operation.delegatedServices) - return Object.freeze(operation) + return Object.freeze({ ...operation, delegationScope: 'resource' as const }) } export const projectOperations = { @@ -34,9 +41,10 @@ export const projectOperations = { // permission-group-exempt: navigation exposes only Projects containing accessible environments. list: defineProjectOperation({ id: 'projects.list', - principalKinds: ['session', 'resource_delegated'], + principalKinds: ['session', 'delegated'], access: 'read', capability: 'none', + contentDelivery: 'none', delegationAudience: 'sim:projects:discovery', delegatedServices: ['copilot'], }), diff --git a/apps/sim/lib/projects/application/use-cases.ts b/apps/sim/lib/projects/application/use-cases.ts index a073ac43ae2..66383f2546c 100644 --- a/apps/sim/lib/projects/application/use-cases.ts +++ b/apps/sim/lib/projects/application/use-cases.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { hasResourceOperationGrant, requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { member, permissions, project, workspace } from '@sim/db/schema' import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace' @@ -96,10 +96,9 @@ export const listProjects: OperationUseCase< if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) throw new OrchestrationError('validation', 'Limit must be between 1 and 100') const userId = requirePrincipalSubjectUserId(principal) - const scope = - principal.kind === 'resource_delegated' - ? await resolveCopilotProjectScope(principal, input.organizationId) - : { organizationId: input.organizationId } + const scope = hasResourceOperationGrant(principal) + ? await resolveCopilotProjectScope(principal, input.organizationId) + : { organizationId: input.organizationId } return db.transaction(async (tx) => { /** Driven from the caller's grants and admin organization, so cost tracks their reach. */ const candidates = await tx.execute<{ id: string }>(sql` @@ -262,7 +261,7 @@ export const getCurrentWorkspaceProject: OperationUseCase< async execute({ principal }) { requireProjectPrincipal(principal, projectOperations.list) await requireProjectApiEnabled() - if (principal.kind !== 'resource_delegated') + if (!hasResourceOperationGrant(principal)) throw new OrchestrationError('forbidden', 'Current Project discovery requires an invocation') const scope = await resolveCopilotProjectScope(principal) const workspaceId = scope.workspaceId diff --git a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts index a9c4e7c77e1..26f3771d2b3 100644 --- a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts @@ -2,8 +2,8 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import type { OAuthAccessTokenPrincipal, - ResourceDelegatedPrincipal, ResourceFileCopyScope, + ResourceOperationPrincipal, } from '@sim/auth/principal' import { db } from '@sim/db' import { @@ -159,17 +159,17 @@ function delegated( userId: string, projectId: string, workspaceId: string -): Extract { +): Extract { return { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: userId, audience: 'sim:project-files', delegationId: generateId(), issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - scope: { kind: 'entity', entityType: 'project', entityId: projectId }, - invocation: { kind: 'workspace', workspaceId }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: projectId } } as const, + invocation: { kind: 'workspace', workspaceId } as const, } } @@ -216,7 +216,7 @@ describe('compound copy owner authority', () => { return { ...delegated(f.readerId, f.projectId, f.workspaces[0]), audience: 'sim:files:copy', - scope: { kind: 'file_copy' as const, ...input }, + grant: { kind: 'file_copy' as const, ...input }, } } @@ -262,21 +262,20 @@ describe('compound copy owner authority', () => { code: 'forbidden', }) } - const realtime: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const realtime: ResourceOperationPrincipal = { + kind: 'delegated', serviceId: 'realtime', subjectUserId: f.readerId, audience: 'sim:files:copy', delegationId: generateId(), issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - scope: { + grant: { kind: 'entity', - entityType: 'project', - entityId: f.projectId, fileId: input.source.fileIds[0], - }, - invocation: { kind: 'realtime', connectionId: 'copy-fixture' }, + owner: { entityType: 'project', entityId: f.projectId }, + } as const, + invocation: { kind: 'realtime', connectionId: 'copy-fixture' } as const, } for (const rejected of [ realtime, @@ -539,7 +538,7 @@ describe('Mothership origin navigation and independent Project authority', () => const copy = { ...principal, audience: 'sim:files:copy', - scope: { kind: 'file_copy' as const, ...input }, + grant: { kind: 'file_copy' as const, ...input }, } const copied = await copyFileItems.execute({ principal: copy, input }) expect(copied.folders).toHaveLength(1) @@ -584,7 +583,7 @@ describe('Mothership origin navigation and independent Project authority', () => const principal = { ...delegated(f.readerId, b.projectId, f.workspaces[0]), audience: 'sim:files:copy', - scope: { kind: 'file_copy' as const, ...input }, + grant: { kind: 'file_copy' as const, ...input }, } const authorize = await createFileCopyAuthorizer(principal, input) await db.transaction(authorize) diff --git a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts index ad6bf49b792..9123096b900 100644 --- a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts @@ -1,6 +1,6 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' -import type { OAuthAccessTokenPrincipal, ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { OAuthAccessTokenPrincipal, ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, @@ -205,17 +205,17 @@ function delegated( userId: string, projectId: string, workspaceId: string -): Extract { +): Extract { return { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: userId, audience: 'sim:project-files', delegationId: generateId(), issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - scope: { kind: 'entity', entityType: 'project', entityId: projectId }, - invocation: { kind: 'workspace', workspaceId }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: projectId } } as const, + invocation: { kind: 'workspace', workspaceId } as const, } } @@ -918,7 +918,11 @@ describe('Project file authority at the database boundary', () => { readAccess.execute({ principal: { ...principal, - scope: { kind: 'entity', entityType: 'project', entityId: f.projectId, fileId }, + grant: { + kind: 'entity', + fileId, + owner: { entityType: 'project', entityId: f.projectId }, + }, }, input: { ...input, fileId }, }) @@ -927,7 +931,7 @@ describe('Project file authority at the database boundary', () => { readAccess.execute({ principal: { ...principal, - scope: { kind: 'entity', entityType: 'project', entityId: generateId() }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: generateId() } }, }, input, }) @@ -953,7 +957,7 @@ describe('Project file authority at the database boundary', () => { config: { conversationMode: 'agent' }, title: 'Project context', }) - const principal: ResourceDelegatedPrincipal = { + const principal: ResourceOperationPrincipal = { ...delegated(f.readerId, f.projectId, f.workspaces[0]), serviceId: 'copilot', invocation: { kind: 'chat', chatId }, @@ -1100,16 +1104,20 @@ describe('Project file authority at the database boundary', () => { contentType: 'text/markdown', sizeBytes: 0, }) - const principal: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const principal: ResourceOperationPrincipal = { + kind: 'delegated', serviceId: 'realtime', subjectUserId: f.readerId, delegationId: generateId(), audience: 'sim:project-files', issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - scope: { kind: 'entity', entityType: 'project', entityId: f.projectId, fileId }, - invocation: { kind: 'realtime', connectionId: 'fixture-connection' }, + grant: { + kind: 'entity', + fileId, + owner: { entityType: 'project', entityId: f.projectId }, + } as const, + invocation: { kind: 'realtime', connectionId: 'fixture-connection' } as const, } expect( await readFile.execute({ principal, input: { projectId: f.projectId, fileId } }) diff --git a/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts b/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts index 9f2f940e04e..e957345322f 100644 --- a/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts @@ -593,15 +593,15 @@ describe('Project archive download and Markdown snapshot export', () => { }, }) const principal: Principal = { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, audience: 'sim:project-files', delegationId: generateId(), issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, - invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: f.projectId } } as const, + invocation: { kind: 'workspace', workspaceId: f.workspaceId } as const, } const read = vi.spyOn(storage, 'downloadFile') await expect( @@ -638,15 +638,15 @@ describe('Project archive download and Markdown snapshot export', () => { input: createInput(f.projectId, 'private bytes'), }) const principal: Principal = { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, audience: 'sim:project-files', delegationId: generateId(), issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, - invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: f.projectId } } as const, + invocation: { kind: 'workspace', workspaceId: f.workspaceId } as const, } const read = vi.spyOn(storage, 'downloadFile') let admissions = 0 diff --git a/apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts b/apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts index 10298483fdf..c9e43b6e4fb 100644 --- a/apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content-rendered.integration.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs' import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { fileSearchDispatchQueue, @@ -204,16 +204,19 @@ describe('Project delivery admission', () => { }, }) const issuedAt = new Date() - const principal: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const principal: ResourceOperationPrincipal = { + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, delegationId: generateId(), audience: 'sim:project-files', issuedAt, expiresAt: new Date(issuedAt.getTime() + 60_000), - invocation: { kind: 'workspace', workspaceId: f.workspaceId }, - scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + invocation: { kind: 'workspace', workspaceId: f.workspaceId } as const, + grant: { + kind: 'entity', + owner: { entityType: 'project', entityId: f.projectId }, + } as const, } const download = vi.spyOn(storage, 'downloadFile') const stream = vi.spyOn(storage, 'downloadFileStream') diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts index ba3d3c89ccc..7c0defb2211 100644 --- a/apps/sim/lib/projects/files/__integration__/content.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -1598,15 +1598,18 @@ describe('Project Copilot content admission', () => { }, }) const principal: Principal = { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, audience: 'sim:project-files', delegationId: generateId(), issuedAt: new Date(), expiresAt: new Date(Date.now() + 30_000), - scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, - invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + grant: { + kind: 'entity', + owner: { entityType: 'project', entityId: f.projectId }, + } as const, + invocation: { kind: 'workspace', workspaceId: f.workspaceId } as const, } const buffered = vi.spyOn(storage, 'downloadFile') const streamed = vi.spyOn(storage, 'downloadFileStream') diff --git a/apps/sim/lib/projects/files/__integration__/documents.integration.ts b/apps/sim/lib/projects/files/__integration__/documents.integration.ts index 5bcfc09569d..348037c1e04 100644 --- a/apps/sim/lib/projects/files/__integration__/documents.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/documents.integration.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs' import { mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { member, @@ -137,21 +137,20 @@ async function fixture(permissionType: 'read' | 'write' = 'write') { }, }) const target = { projectId: ids.projectId, fileId: created.file.id } - const principal: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const principal: ResourceOperationPrincipal = { + kind: 'delegated', serviceId: 'realtime', subjectUserId: editorId, audience: 'sim:project-files', delegationId: generateId(), issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), - scope: { + grant: { kind: 'entity', - entityType: 'project', - entityId: ids.projectId, fileId: created.file.id, - }, - invocation: { kind: 'realtime', connectionId: `socket-${generateId()}` }, + owner: { entityType: 'project', entityId: ids.projectId }, + } as const, + invocation: { kind: 'realtime', connectionId: `socket-${generateId()}` } as const, } return { ...ids, owner, target, principal } } @@ -375,13 +374,12 @@ describe('Project collaborative documents against current policy and durable sto }, }) const target = { projectId: f.projectId, fileId: created.file.id } - const principal: ResourceDelegatedPrincipal = { + const principal: ResourceOperationPrincipal = { ...f.principal, - scope: { + grant: { kind: 'entity', - entityType: 'project', - entityId: f.projectId, fileId: created.file.id, + owner: { entityType: 'project', entityId: f.projectId }, }, } await expect( diff --git a/apps/sim/lib/projects/files/__integration__/uploads.integration.ts b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts index 99757c40fc1..3bb60648a46 100644 --- a/apps/sim/lib/projects/files/__integration__/uploads.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts @@ -3,7 +3,7 @@ import { mkdtempSync } from 'node:fs' import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { folder, @@ -466,16 +466,16 @@ describe('Project upload sessions with real leases, storage, and accounting', () async () => { const f = await fixture() const issuedAt = new Date() - const principal: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const principal: ResourceOperationPrincipal = { + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, delegationId: generateId(), audience: 'sim:project-files', issuedAt, expiresAt: new Date(issuedAt.getTime() + 60_000), - invocation: { kind: 'workspace', workspaceId: f.workspaceId }, - scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + invocation: { kind: 'workspace', workspaceId: f.workspaceId } as const, + grant: { kind: 'entity', owner: { entityType: 'project', entityId: f.projectId } } as const, } const session = await application.createProjectFileUploadSession.execute({ principal, diff --git a/apps/sim/lib/projects/files/application/authorization.ts b/apps/sim/lib/projects/files/application/authorization.ts index fb6c8a7c6b6..a117251949d 100644 --- a/apps/sim/lib/projects/files/application/authorization.ts +++ b/apps/sim/lib/projects/files/application/authorization.ts @@ -1,8 +1,9 @@ import { + hasResourceOperationGrant, type OrganizationDelegatedPrincipal, type Principal, requirePrincipalSubjectUserId, - type SubjectDelegatedPrincipal, + type WorkspaceSubjectDelegation, } from '@sim/auth/principal' import { copilotChats, @@ -77,7 +78,9 @@ function requirePrincipal( if (operation.target !== 'file' && input.fileId !== undefined) { throw new OrchestrationError('validation', 'This operation targets the Project file collection') } - if (principal.kind === 'resource_delegated') { + if (principal.kind === 'delegated' && !hasResourceOperationGrant(principal)) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + if (hasResourceOperationGrant(principal)) { requireResourceDelegation(principal, { audience: operation.delegationAudience, services: operation.delegatedServices, @@ -85,8 +88,7 @@ function requirePrincipal( operation.target === 'collection_observation' ? { kind: 'file_collection_observation', - entityType: 'project', - entityId: input.projectId, + owner: { entityType: 'project', entityId: input.projectId }, } : projectFileDelegationScope(input), maxTtlMs: PROJECT_FILE_DELEGATION_TTL_MS, @@ -101,7 +103,7 @@ function requirePrincipal( export async function requireCurrentCopilotProjectInvocation( tx: DbTransaction, principal: Pick< - Extract, + Extract, 'subjectUserId' | 'invocation' >, access: Awaited>, @@ -197,7 +199,7 @@ export async function requireCurrentCopilotProjectInvocation( /** Shared owner policy keeps compound copies subject to the same live Project edit rule. */ export async function requireProjectFileOwnerRole( tx: DbTransaction, - principal: ProjectFilePrincipal | SubjectDelegatedPrincipal | OrganizationDelegatedPrincipal, + principal: ProjectFilePrincipal | WorkspaceSubjectDelegation | OrganizationDelegatedPrincipal, access: Awaited>, accessMode: 'read' | 'write', mode: 'hold' | 'snapshot' = 'hold' @@ -228,7 +230,7 @@ export async function requireProjectFileOwnerRole( /** Credential and Files capability checks apply to every currently visible environment. */ export async function requireProjectFileOwnerCapabilities( tx: DbTransaction, - principal: ProjectFilePrincipal | SubjectDelegatedPrincipal | OrganizationDelegatedPrincipal, + principal: ProjectFilePrincipal | WorkspaceSubjectDelegation | OrganizationDelegatedPrincipal, access: Awaited>, capability: ProjectFileOperation['capability'] ): Promise { @@ -264,7 +266,7 @@ export async function createProjectFileAuthorizer( await requireProjectApiEnabled() const userId = await requireFileSubject(principal) const invocationScope = - principal.kind === 'resource_delegated' && principal.serviceId === 'copilot' + hasResourceOperationGrant(principal) && principal.serviceId === 'copilot' ? await resolveCopilotProjectScope(principal) : undefined @@ -300,7 +302,7 @@ export async function createProjectFileAuthorizer( } if (access.record.organizationId) await acquirePermissionGroupOrgReadLock(tx, access.record.organizationId) - if (principal.kind === 'resource_delegated' && principal.serviceId === 'copilot') { + if (hasResourceOperationGrant(principal) && principal.serviceId === 'copilot') { if (!invocationScope) throw new Error('Copilot Project invocation was not prepared') await requireCurrentCopilotProjectInvocation(tx, principal, access, invocationScope) } diff --git a/apps/sim/lib/projects/files/application/content.ts b/apps/sim/lib/projects/files/application/content.ts index 73b4005d11d..6b44943a18b 100644 --- a/apps/sim/lib/projects/files/application/content.ts +++ b/apps/sim/lib/projects/files/application/content.ts @@ -1,5 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + hasResourceOperationGrant, + type Principal, + requirePrincipalSubjectUserId, +} from '@sim/auth/principal' import type { PreparedCollabDocState } from '@/lib/collab-doc/state' import { isCopilotPrincipal, requireAdmittedContent } from '@/lib/core/application/content-delivery' import { runWithOutboundOrganization } from '@/lib/core/network/context.server' @@ -106,7 +110,7 @@ function replacementProvenance( ): WorkspaceFileSecretProvenance { return ( provenance ?? - (principal.kind === 'resource_delegated' + (hasResourceOperationGrant(principal) ? { status: 'unknown' } : EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE) ) diff --git a/apps/sim/lib/projects/files/application/downloads.ts b/apps/sim/lib/projects/files/application/downloads.ts index 9c9042e5085..34e478825ad 100644 --- a/apps/sim/lib/projects/files/application/downloads.ts +++ b/apps/sim/lib/projects/files/application/downloads.ts @@ -1,5 +1,9 @@ import { AuditAction, AuditResourceType } from '@sim/audit' -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + hasResourceOperationGrant, + type Principal, + requirePrincipalSubjectUserId, +} from '@sim/auth/principal' import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' @@ -394,7 +398,7 @@ export const exportProjectFileSnapshot = defineAuthorizedProjectFileUseCase< provenance: mergeWorkspaceFileSecretProvenance( snapshot.provenance.get(snapshot.source.id) ?? { status: 'unknown' }, input.secretProvenance ?? - (principal.kind === 'resource_delegated' + (hasResourceOperationGrant(principal) ? { status: 'unknown' } : { status: 'exact', entries: [] }) ), @@ -462,7 +466,7 @@ export const exportProjectFileSnapshot = defineAuthorizedProjectFileUseCase< const secretProvenance = mergeWorkspaceFileSecretProvenance( evidence, input.secretProvenance ?? - (principal.kind === 'resource_delegated' + (hasResourceOperationGrant(principal) ? { status: 'unknown' } : { status: 'exact', entries: [] }) ) diff --git a/apps/sim/lib/projects/files/application/operations.ts b/apps/sim/lib/projects/files/application/operations.ts index c9497e8aed4..d28253c5a14 100644 --- a/apps/sim/lib/projects/files/application/operations.ts +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -1,18 +1,18 @@ import type { Principal, - ResourceDelegatedPrincipal, ResourceDelegationScope, + ResourceOperationPrincipal, } from '@sim/auth/principal' import { type ApplicationOperation, + assertContentDeliveryDeclaration, assertOperationCapability, assertOperationOAuthPolicy, } from '@/lib/core/application/operation' -export type ProjectFilePrincipal = Extract< - Principal, - { kind: 'session' | 'personal_api_key' | 'oauth_access_token' | 'resource_delegated' } -> +export type ProjectFilePrincipal = + | Extract + | ResourceOperationPrincipal interface ProjectFilePolicy extends ApplicationOperation { readonly access: 'read' | 'write' @@ -25,7 +25,7 @@ interface ProjectFilePolicy extends ApplicationOperation { */ readonly chargesStorage?: true readonly principalKinds: readonly ProjectFilePrincipal['kind'][] - readonly delegatedServices: readonly ResourceDelegatedPrincipal['serviceId'][] + readonly delegatedServices: readonly ResourceOperationPrincipal['serviceId'][] readonly delegationAudience: 'sim:project-files' | 'sim:file-list-observation' } @@ -38,13 +38,14 @@ export function projectFileDelegationScope(target: { }): ResourceDelegationScope { return { kind: 'entity', - entityType: 'project', - entityId: target.projectId, ...(target.fileId ? { fileId: target.fileId } : {}), + owner: { entityType: 'project', entityId: target.projectId }, } } -function defineProjectFileOperation(operation: O): O { +function defineProjectFileOperation( + operation: O +): O & { readonly delegationScope: 'resource' } { assertOperationCapability(operation) assertOperationOAuthPolicy(operation) const observesCollection = operation.target === 'collection_observation' @@ -53,7 +54,7 @@ function defineProjectFileOperation(operation (operation.access !== 'read' || operation.delegationAudience !== 'sim:file-list-observation' || operation.principalKinds.length !== 1 || - operation.principalKinds[0] !== 'resource_delegated' || + operation.principalKinds[0] !== 'delegated' || operation.delegatedServices.length !== 1 || operation.delegatedServices[0] !== 'realtime') ) { @@ -66,21 +67,22 @@ function defineProjectFileOperation(operation ) { throw new Error('Realtime Project authority must target an existing file') } + assertContentDeliveryDeclaration(operation, operation.delegatedServices) Object.freeze(operation.principalKinds) Object.freeze(operation.delegatedServices) - return Object.freeze(operation) + return Object.freeze({ ...operation, delegationScope: 'resource' as const }) } -const PRINCIPALS = [ - 'session', - 'personal_api_key', - 'oauth_access_token', - 'resource_delegated', -] as const +const PRINCIPALS = ['session', 'personal_api_key', 'oauth_access_token', 'delegated'] as const +/** + * Copilot reaches these operations through a resource grant. They are outside the + * workspace-file content ledger, so the reviewed answer is `none`. + */ const POLICY = { principalKinds: PRINCIPALS, delegationAudience: 'sim:project-files', delegatedServices: ['copilot'], + contentDelivery: 'none', } as const const HISTORY_POLICY = { @@ -95,7 +97,7 @@ export const projectFileOperations = { capability: 'files.use', access: 'read', target: 'collection_observation', - principalKinds: ['resource_delegated'], + principalKinds: ['delegated'], delegatedServices: ['realtime'], delegationAudience: 'sim:file-list-observation', }), diff --git a/apps/sim/lib/settings/application/context.ts b/apps/sim/lib/settings/application/context.ts index 56891e793b7..08b8f654cad 100644 --- a/apps/sim/lib/settings/application/context.ts +++ b/apps/sim/lib/settings/application/context.ts @@ -1,4 +1,5 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' @@ -18,9 +19,9 @@ export const readSettingsWorkspaceContext = defineAuthorizedWorkspaceUseCase({ resolveContext: ({ principal, }: { - principal: DelegatedPrincipal + principal: WorkspaceDelegatedPrincipal input: Record - }) => resolveActiveWorkspaceApplicationContext(principal.workspaceId), + }) => resolveActiveWorkspaceApplicationContext(principalWorkspaceId(principal)), authorizationOptions: { delegation: { audience: 'sim:settings', isWithinScope: () => true } }, async execute({ context }) { return { workspaceId: context.workspaceId, organizationId: context.workspaceOrganizationId } diff --git a/apps/sim/lib/table/application/authorization.test.ts b/apps/sim/lib/table/application/authorization.test.ts index 45f9d725cec..1503570ba39 100644 --- a/apps/sim/lib/table/application/authorization.test.ts +++ b/apps/sim/lib/table/application/authorization.test.ts @@ -136,28 +136,35 @@ describe('table operation authorization', () => { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:tables', issuedAt: new Date(Date.now() - 10_000), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, } await expectForbidden({ ...base, audience: 'sim:files', expiresAt: new Date(Date.now() + 60_000), - resourceScope: { tableId: 'table-1' }, + grant: { ...base.grant, restrictions: { tableId: 'table-1' } }, }) await expectForbidden({ ...base, expiresAt: new Date(Date.now() - 1), - resourceScope: { tableId: 'table-1' }, + grant: { ...base.grant, restrictions: { tableId: 'table-1' } }, }) await expectForbidden({ ...base, - workspaceId: 'workspace-2', expiresAt: new Date(Date.now() + 60_000), - resourceScope: { tableId: 'table-1' }, + grant: { + ...base.grant, + owner: { entityType: 'workspace', entityId: 'workspace-2' }, + restrictions: { tableId: 'table-1' }, + }, }) await expectForbidden({ ...base, @@ -166,7 +173,7 @@ describe('table operation authorization', () => { await expectForbidden({ ...base, expiresAt: new Date(Date.now() + 60_000), - resourceScope: { tableId: 'table-2' }, + grant: { ...base.grant, restrictions: { tableId: 'table-2' } }, }) expect(resolvePermission).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/table/application/authorization.ts b/apps/sim/lib/table/application/authorization.ts index 864a875dcfd..0eda4fb16e2 100644 --- a/apps/sim/lib/table/application/authorization.ts +++ b/apps/sim/lib/table/application/authorization.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { principalWorkspaceId, workspaceDelegationRestrictions } from '@sim/auth/principal' import { authorizeWorkspaceOperation, type WorkspaceAuthorizationContext, @@ -27,13 +28,13 @@ export const tableDelegationPolicy: WorkspaceDelegationPolicy { mocks.createResource.mockClear() await expect( createTableImportUseCase.execute({ - principal: { ...delegated, workspaceId: 'other' }, + principal: { + ...delegated, + invocation: { kind: 'workspace', workspaceId: 'other' }, + grant: { + ...delegated.grant, + owner: { entityType: 'workspace', entityId: 'other' }, + }, + }, input, request, }) diff --git a/apps/sim/lib/uploads/upload-session/project-file-binding.ts b/apps/sim/lib/uploads/upload-session/project-file-binding.ts index 747015a1912..bec09affa8b 100644 --- a/apps/sim/lib/uploads/upload-session/project-file-binding.ts +++ b/apps/sim/lib/uploads/upload-session/project-file-binding.ts @@ -1,4 +1,8 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + hasResourceOperationGrant, + type Principal, + requirePrincipalSubjectUserId, +} from '@sim/auth/principal' import { isRecordLike } from '@sim/utils/object' import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -13,16 +17,17 @@ function credential(principal: Principal, projectId: string) { return { kind: principal.kind, userId: principal.userId, keyId: principal.keyId } case 'oauth_access_token': return { kind: principal.kind, userId: principal.userId, clientId: principal.clientId } - case 'resource_delegated': { + case 'delegated': { + if (!hasResourceOperationGrant(principal)) break requireResourceDelegation(principal, { audience: 'sim:project-files', services: ['copilot'], - scope: { kind: 'entity', entityType: 'project', entityId: projectId }, + scope: { kind: 'entity', owner: { entityType: 'project', entityId: projectId } }, maxTtlMs: PROJECT_FILE_DELEGATION_TTL_MS, }) if (principal.serviceId !== 'copilot') break return { - kind: principal.kind, + kind: 'resource_delegated' as const, serviceId: principal.serviceId, subjectUserId: principal.subjectUserId, audience: principal.audience, diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index cdc758c58a7..1d2eb96e28a 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,8 +1,11 @@ import { type BoundWorkflowExecutionDelegatedPrincipal, + hasResourceOperationGrant, isUserCredentialPrincipal, type Principal, + principalWorkspaceId, requirePrincipalSubjectUserId, + workspaceDelegationRestrictions, } from '@sim/auth/principal' import { db, dbFor } from '@sim/db' import { organization, uploadSession, user } from '@sim/db/schema' @@ -369,7 +372,7 @@ export async function createUploadSession( metadata[PROJECT_FILE_UPLOAD_PROVENANCE_KEY] = bindProjectFileUploadProvenance( params.projectId, params.secretProvenance ?? - (params.principal.kind === 'resource_delegated' + (hasResourceOperationGrant(params.principal) ? { status: 'unknown' } : { status: 'exact', entries: [] }) ) @@ -627,11 +630,6 @@ export function createUploadSessionAuthBinding( options: { executorDelegationAudience?: string; copilotDelegationAudience?: string } = {} ): UploadSessionAuthBinding { switch (principal.kind) { - case 'resource_delegated': - throw new UploadSessionError( - 'forbidden', - 'Resource delegation cannot create workspace uploads' - ) case 'slack_app': case 'slack_installation': throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads') @@ -658,7 +656,7 @@ export function createUploadSessionAuthBinding( principal: { kind: principal.kind, userId: principal.userId, clientId: principal.clientId }, } case 'workspace_api_key': - if (principal.workspaceId !== workspaceId) { + if (principalWorkspaceId(principal) !== workspaceId) { throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') } return { @@ -667,12 +665,18 @@ export function createUploadSessionAuthBinding( principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, } case 'delegated': { + if (hasResourceOperationGrant(principal)) + throw new UploadSessionError( + 'forbidden', + 'Resource delegation cannot create workspace uploads' + ) if (principal.serviceId === 'copilot') { + const chatId = workspaceDelegationRestrictions(principal)?.chatId if ( - principal.workspaceId !== workspaceId || + principalWorkspaceId(principal) !== workspaceId || principal.audience !== (options.copilotDelegationAudience ?? WORKSPACE_FILES_DELEGATION_AUDIENCE) || - !principal.resourceScope?.chatId || + !chatId || principal.expiresAt <= new Date() ) { throw new UploadSessionError( @@ -688,7 +692,7 @@ export function createUploadSessionAuthBinding( serviceId: 'copilot', subjectUserId: requirePrincipalSubjectUserId(principal), audience: principal.audience, - chatId: principal.resourceScope.chatId, + chatId, }, } } @@ -696,7 +700,7 @@ export function createUploadSessionAuthBinding( options.executorDelegationAudience === undefined || !isExecutorWorkflowExecutionPrincipal(principal) || principal.audience !== options.executorDelegationAudience || - principal.workspaceId !== workspaceId + principalWorkspaceId(principal) !== workspaceId ) { throw new UploadSessionError('forbidden', 'Delegated principal cannot create this upload') } @@ -774,19 +778,19 @@ export function assertUploadSessionAuthBinding( bound.clientId === principal.clientId : bound.kind === 'workspace_api_key' ? principal.kind === 'workspace_api_key' && - bound.workspaceId === principal.workspaceId && + bound.workspaceId === principalWorkspaceId(principal) && bound.keyId === principal.keyId : bound.serviceId === 'copilot' ? principal.kind === 'delegated' && principal.serviceId === 'copilot' && - principal.workspaceId === session.workspaceId && + principalWorkspaceId(principal) === session.workspaceId && principal.subjectUserId === bound.subjectUserId && principal.audience === bound.audience && principal.audience === copilotUploadAudience(session.purpose) && - principal.resourceScope?.chatId === bound.chatId && + workspaceDelegationRestrictions(principal)?.chatId === bound.chatId && principal.expiresAt > new Date() : isExecutorWorkflowExecutionPrincipal(principal) && - principal.workspaceId === session.workspaceId && + principalWorkspaceId(principal) === session.workspaceId && principal.subjectUserId === bound.subjectUserId && principal.audience === bound.audience && principal.delegationContext.workflowId === bound.workflowId && @@ -804,7 +808,7 @@ export function assertUploadSessionAuthBinding( function assertLegacyUploadSessionOwner(session: UploadSessionRecord, principal: Principal): void { const matches = principal.kind === 'workspace_api_key' - ? principal.workspaceId === session.workspaceId + ? principalWorkspaceId(principal) === session.workspaceId : (principal.kind === 'session' || isUserCredentialPrincipal(principal)) && principal.userId === session.userId if (!matches) throw uploadNotFound() diff --git a/apps/sim/lib/users/application/preferences-authorization.test.ts b/apps/sim/lib/users/application/preferences-authorization.test.ts index 142c4add4f4..58e41ebbaaf 100644 --- a/apps/sim/lib/users/application/preferences-authorization.test.ts +++ b/apps/sim/lib/users/application/preferences-authorization.test.ts @@ -1,6 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { createDelegatedPrincipal, + createExecutorPrincipal, createPersonalApiKeyPrincipal, createWorkspaceApiKeyPrincipal, } from '@sim/testing/factories/principal.factory' @@ -64,7 +65,6 @@ describe('account preferences authority', () => { { ...delegated, expiresAt: new Date(0) }, { ...delegated, issuedAt: new Date(Date.now() + 60_000) }, { ...delegated, expiresAt: new Date(Number.NaN) }, - { ...delegated, resourceScope: {} }, ])('refuses invalid delegation before protected lookups', async (principal) => { await expect( authorizeAccountPreferences(principal, userAccountOperations.updateSettings) @@ -72,11 +72,25 @@ describe('account preferences authority', () => { expect(mocks.workspace).not.toHaveBeenCalled() expect(mocks.organization).not.toHaveBeenCalled() }) + it('refuses a delegation whose restrictions omit the required chat id', async () => { + await expect( + authorizeAccountPreferences( + { ...delegated, grant: { ...delegated.grant, restrictions: {} } }, + userAccountOperations.updateSettings + ) + ).rejects.toThrow('Account settings delegation is no longer valid') + expect(mocks.workspace).not.toHaveBeenCalled() + expect(mocks.organization).not.toHaveBeenCalled() + }) it('does not grant self-account operations to API keys or executors', async () => { const principals: Principal[] = [ createWorkspaceApiKeyPrincipal({ workspaceId: 'workspace', keyId: 'key' }), createPersonalApiKeyPrincipal({ userId: 'actor', keyId: 'key' }), - { ...delegated, serviceId: 'executor' }, + createExecutorPrincipal({ + subjectUserId: 'actor', + workspaceId: 'workspace', + audience: 'sim:settings', + }), ] for (const principal of principals) { await expect( diff --git a/apps/sim/lib/users/application/preferences-authorization.ts b/apps/sim/lib/users/application/preferences-authorization.ts index ef1b2750560..7978bc6c2e7 100644 --- a/apps/sim/lib/users/application/preferences-authorization.ts +++ b/apps/sim/lib/users/application/preferences-authorization.ts @@ -1,4 +1,9 @@ import type { Principal } from '@sim/auth/principal' +import { + isWorkspaceDelegation, + principalWorkspaceId, + workspaceDelegationRestrictions, +} from '@sim/auth/principal' import { ForbiddenOperationError } from '@/lib/core/application' import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' import { requireCurrentHumanRole } from '@/lib/core/application/workspace-authorization' @@ -18,7 +23,7 @@ export async function authorizeAccountPreferences( ) } if (principal.kind === 'session') return principal.userId - if (principal.kind !== 'delegated' && principal.kind !== 'organization_delegated') { + if (!isWorkspaceDelegation(principal) && principal.kind !== 'organization_delegated') { throw new ForbiddenOperationError( 'PRINCIPAL_KIND_NOT_PERMITTED', 'This account operation requires its authenticated owner' @@ -32,7 +37,7 @@ export async function authorizeAccountPreferences( !Number.isFinite(principal.expiresAt.getTime()) || principal.issuedAt.getTime() > now || principal.expiresAt.getTime() <= now || - !principal.resourceScope?.chatId + !workspaceDelegationRestrictions(principal)?.chatId ) { throw new OrchestrationError('forbidden', 'Account settings delegation is no longer valid') } @@ -50,7 +55,7 @@ export async function authorizeAccountPreferences( ) return context.userId } - const context = await resolveActiveWorkspaceApplicationContext(principal.workspaceId) + const context = await resolveActiveWorkspaceApplicationContext(principalWorkspaceId(principal)) await requireCurrentHumanRole(principal.subjectUserId, context, 'read') return principal.subjectUserId } diff --git a/apps/sim/lib/users/application/preferences.test.ts b/apps/sim/lib/users/application/preferences.test.ts index b7d60abfd9f..4128d2d4ff0 100644 --- a/apps/sim/lib/users/application/preferences.test.ts +++ b/apps/sim/lib/users/application/preferences.test.ts @@ -1,4 +1,4 @@ -import type { SubjectDelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceSubjectDelegation } from '@sim/auth/principal' import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { mothershipAgentUrlMock } from '@sim/testing/mocks/mothership-agent-url.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -14,16 +14,20 @@ import { updateCurrentUserPreferences, } from '@/lib/users/application/preferences' -const delegated: SubjectDelegatedPrincipal = { +const delegated: WorkspaceSubjectDelegation = { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'actor', - workspaceId: 'workspace', delegationId: 'call', audience: 'sim:settings', issuedAt: new Date(), expiresAt: new Date(Date.now() + 1000), - resourceScope: { chatId: 'chat' }, + invocation: { kind: 'chat', chatId: 'chat' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace' }, + restrictions: { chatId: 'chat' }, + } as const, } describe('account preference mutations', () => { diff --git a/apps/sim/lib/webhooks/execution-principal.ts b/apps/sim/lib/webhooks/execution-principal.ts index 209979e82ba..7016f0e38d6 100644 --- a/apps/sim/lib/webhooks/execution-principal.ts +++ b/apps/sim/lib/webhooks/execution-principal.ts @@ -3,6 +3,7 @@ import type { WebhookSystemPrincipal, WorkflowExecutionPrincipal, } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' export function normalizeWebhookPrincipalProvider(provider: string): string { if (!provider.trim()) throw new Error('Webhook execution provider must not be empty') @@ -48,7 +49,7 @@ export function assertWebhookExecutionPrincipal( principal.serviceId !== 'webhook' || principal.webhookId !== expected.webhookId || principal.workflowId !== expected.workflowId || - principal.workspaceId !== expected.workspaceId || + principalWorkspaceId(principal) !== expected.workspaceId || principal.provider !== normalizeWebhookPrincipalProvider(expected.provider) ) { throw new Error('Webhook job principal does not match its canonical execution scope') diff --git a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts index 490cf941a9a..088e3ae3af5 100644 --- a/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts +++ b/apps/sim/lib/workflows/application/apply-workflow-operations.test.ts @@ -167,11 +167,12 @@ const copilotPrincipal = { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2099-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, } const operations = [ diff --git a/apps/sim/lib/workflows/application/authorization.test.ts b/apps/sim/lib/workflows/application/authorization.test.ts index 6a1a630efb2..622764941af 100644 --- a/apps/sim/lib/workflows/application/authorization.test.ts +++ b/apps/sim/lib/workflows/application/authorization.test.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { requireWorkflowExecutionUserId, @@ -7,7 +7,9 @@ import { } from '@/lib/workflows/application/authorization' import { workflowOperations } from '@/lib/workflows/application/operations' -function createExecutorPrincipal(overrides: Partial = {}): DelegatedPrincipal { +function createExecutorPrincipal( + overrides: Partial = {} +): WorkflowExecutionDelegatedPrincipal { return { kind: 'delegated', serviceId: 'executor', diff --git a/apps/sim/lib/workflows/application/authorization.ts b/apps/sim/lib/workflows/application/authorization.ts index 1448e30a837..587115678c5 100644 --- a/apps/sim/lib/workflows/application/authorization.ts +++ b/apps/sim/lib/workflows/application/authorization.ts @@ -1,4 +1,8 @@ -import { type Principal, resolvePrincipalExecutionActorUserId } from '@sim/auth/principal' +import { + type Principal, + principalWorkspaceId, + resolvePrincipalExecutionActorUserId, +} from '@sim/auth/principal' import type { WorkspaceAuthorizationContext, WorkspaceDelegationPolicy, @@ -19,7 +23,7 @@ export const workflowDelegationPolicy: WorkspaceDelegationPolicy, context: WorkflowAuthorizationContext ) { - if (principal.workspaceId !== context.workspaceId) return false + if (principalWorkspaceId(principal) !== context.workspaceId) return false if (principal.serviceId === 'copilot') return true if (principal.serviceId !== 'executor') return false const delegationContext = (principal as { delegationContext?: unknown }).delegationContext diff --git a/apps/sim/lib/workflows/application/custom-block-authoring.test.ts b/apps/sim/lib/workflows/application/custom-block-authoring.test.ts index 6b6be9f2201..501c026c3b5 100644 --- a/apps/sim/lib/workflows/application/custom-block-authoring.test.ts +++ b/apps/sim/lib/workflows/application/custom-block-authoring.test.ts @@ -94,11 +94,15 @@ const copilot = { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: context.workspaceId, delegationId: 'tool-call-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01'), expiresAt: new Date('2099-01-01'), + invocation: { kind: 'workspace', workspaceId: context.workspaceId } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: context.workspaceId }, + } as const, } const customBlock: CustomBlockWithInputs = { id: 'custom-1', diff --git a/apps/sim/lib/workflows/application/deployments.test.ts b/apps/sim/lib/workflows/application/deployments.test.ts index e5c01ba73b5..21967fac05a 100644 --- a/apps/sim/lib/workflows/application/deployments.test.ts +++ b/apps/sim/lib/workflows/application/deployments.test.ts @@ -110,11 +110,15 @@ const adminPrincipals: Array<{ principal: Principal; actorUserId: string }> = [ kind: 'delegated', serviceId: 'copilot', subjectUserId: 'delegated-user', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2999-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }, actorUserId: 'delegated-user', }, diff --git a/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts b/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts index 33d52c2ed94..91b40119a86 100644 --- a/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts +++ b/apps/sim/lib/workflows/application/download-workflow-run-file.test.ts @@ -87,11 +87,12 @@ const principals: Principal[] = [ kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2999-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, }, ] diff --git a/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts b/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts index 200241c8656..8294f0cc95f 100644 --- a/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts +++ b/apps/sim/lib/workflows/application/execute-manual-workflow.test.ts @@ -111,11 +111,15 @@ describe('manual workflow execution application operations', () => { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'copilot-actor', - workspaceId: context.workspaceId, audience: 'sim:workflows', delegationId: 'cli-call', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: context.workspaceId } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: context.workspaceId }, + } as const, } await executeManualWorkflowOperation.execute({ principal: delegated, @@ -158,11 +162,15 @@ describe('manual workflow execution application operations', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'actor', - workspaceId: context.workspaceId, audience: 'sim:workflows', delegationId: 'call', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: context.workspaceId } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: context.workspaceId }, + } as const, } as const for (const principal of [ { ...base, workspaceId: 'foreign' }, @@ -192,11 +200,15 @@ describe('manual workflow execution application operations', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'actor', - workspaceId: context.workspaceId, audience: 'sim:workflows', delegationId: 'call', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: context.workspaceId } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: context.workspaceId }, + } as const, } as const mockPermission.mockResolvedValue('read') await expect( diff --git a/apps/sim/lib/workflows/application/execute-workflow.test.ts b/apps/sim/lib/workflows/application/execute-workflow.test.ts index 84711abd79e..0aaaae63ef1 100644 --- a/apps/sim/lib/workflows/application/execute-workflow.test.ts +++ b/apps/sim/lib/workflows/application/execute-workflow.test.ts @@ -85,11 +85,15 @@ describe('executeWorkflowOperation', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2999-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, } as Principal, actorUserId: 'user-1', authenticatesCredentials: true, diff --git a/apps/sim/lib/workflows/application/folders.test.ts b/apps/sim/lib/workflows/application/folders.test.ts index 4d8a9d78346..069e8d0e471 100644 --- a/apps/sim/lib/workflows/application/folders.test.ts +++ b/apps/sim/lib/workflows/application/folders.test.ts @@ -72,11 +72,12 @@ const principals: Principal[] = [ kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'ws-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2099-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'ws-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'ws-1' } } as const, }, ] diff --git a/apps/sim/lib/workflows/application/inspect-workflow-tools.test.ts b/apps/sim/lib/workflows/application/inspect-workflow-tools.test.ts index 1d20a529c9b..e3fea83a05b 100644 --- a/apps/sim/lib/workflows/application/inspect-workflow-tools.test.ts +++ b/apps/sim/lib/workflows/application/inspect-workflow-tools.test.ts @@ -1,4 +1,4 @@ -import type { SubjectDelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceSubjectDelegation } from '@sim/auth/principal' import { createWorkspaceApiKeyPrincipal } from '@sim/testing/factories/principal.factory' import { mcpUseCasesMock, mcpUseCasesMockFns } from '@sim/testing/mocks/mcp-use-cases.mock' import { @@ -73,7 +73,7 @@ mockGetBlock.mockReturnValue(undefined) const mockPermission = workspaceAuthzMockFns.mockResolveEffectiveWorkspacePermission const mockContext = workflowContextMockFns.mockResolveActiveWorkflowApplicationContext -let principal: SubjectDelegatedPrincipal +let principal: WorkspaceSubjectDelegation function save( type = 'agent', tools: unknown = [], @@ -117,11 +117,12 @@ describe('configured workflow tool inspection', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'reader', - workspaceId: 'workspace', audience: 'sim:workflows', delegationId: 'chat-tool', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace' } } as const, } markCopilotWorkspaceInvocation(principal) mockContext.mockResolvedValue({ @@ -336,8 +337,8 @@ describe('configured workflow tool inspection', () => { }) expect(mocks.mcp.mock.calls[0][0].principal).toMatchObject({ subjectUserId: 'reader', - workspaceId: 'workspace', audience: 'sim:mcp-servers', + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace' } }, }) expect(runtime.client.request).not.toHaveBeenCalled() expect(result.stdout).not.toContain('input_schema') diff --git a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts index feb12deefff..a28df7a92a6 100644 --- a/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts +++ b/apps/sim/lib/workflows/application/move-workflows-bulk.test.ts @@ -47,11 +47,12 @@ const principal = { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2099-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, } describe('moveWorkflowsBulk', () => { diff --git a/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts b/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts index 749750f37b6..3c9cd4e9208 100644 --- a/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts +++ b/apps/sim/lib/workflows/application/read-paused-workflow-execution.test.ts @@ -52,11 +52,12 @@ const allowedPrincipals: Principal[] = [ kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00.000Z'), expiresAt: new Date('2999-01-01T00:00:00.000Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, }, ] diff --git a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts index 354b0a96dca..009a08d2dbb 100644 --- a/apps/sim/lib/workflows/application/replace-workflow-state.test.ts +++ b/apps/sim/lib/workflows/application/replace-workflow-state.test.ts @@ -349,11 +349,15 @@ describe('replaceWorkflowState', () => { kind: 'delegated' as const, serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2099-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, } const block = { ...BLOCK, diff --git a/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts b/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts index f67f8336996..25b7c6a771e 100644 --- a/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts +++ b/apps/sim/lib/workflows/application/resolve-workflow-outputs.test.ts @@ -42,11 +42,12 @@ const principal = { kind: 'delegated' as const, serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-tool:tool-1', audience: 'sim:workflows', issuedAt: new Date('2026-08-01T00:00:00.000Z'), expiresAt: new Date('2099-08-01T00:00:00.000Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, } describe('resolveWorkflowOutputs', () => { diff --git a/apps/sim/lib/workflows/application/run-control.test.ts b/apps/sim/lib/workflows/application/run-control.test.ts index 5679b20aca2..deee292d5bc 100644 --- a/apps/sim/lib/workflows/application/run-control.test.ts +++ b/apps/sim/lib/workflows/application/run-control.test.ts @@ -82,11 +82,15 @@ const principals: Array<{ principal: Principal; actorUserId: string; isApiCaller kind: 'delegated', serviceId: 'copilot', subjectUserId: 'delegated-user', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2999-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }, actorUserId: 'delegated-user', isApiCaller: true, diff --git a/apps/sim/lib/workflows/application/run-workflow-for-test.ts b/apps/sim/lib/workflows/application/run-workflow-for-test.ts index 448eb42bce9..324b3530c1a 100644 --- a/apps/sim/lib/workflows/application/run-workflow-for-test.ts +++ b/apps/sim/lib/workflows/application/run-workflow-for-test.ts @@ -1,4 +1,5 @@ import { + isWorkspaceDelegation, type Principal, requirePrincipalSubjectUserId, type WorkflowExecutionPrincipal, @@ -157,6 +158,6 @@ function requireTestPrincipal(principal: Principal): WorkflowExecutionPrincipal ) { return principal } - if (principal.kind === 'delegated' && principal.serviceId === 'copilot') return principal + if (isWorkspaceDelegation(principal) && principal.serviceId === 'copilot') return principal throw new Error(`A ${principal.kind} principal cannot run workflow tests`) } diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts index f985d210379..c2bba497bb9 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.test.ts @@ -75,11 +75,12 @@ const principal = { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2099-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, } const context = { diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 3a1fc488114..0d4f460275b 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -1,4 +1,8 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + isWorkspaceDelegation, + type Principal, + requirePrincipalSubjectUserId, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -254,7 +258,7 @@ async function executeCopilotRun(params: { } }): Promise { if ( - params.principal.kind === 'resource_delegated' || + (params.principal.kind === 'delegated' && !isWorkspaceDelegation(params.principal)) || params.principal.kind === 'organization_delegated' || params.principal.kind === 'slack_app' || params.principal.kind === 'slack_installation' || diff --git a/apps/sim/lib/workflows/application/runs.test.ts b/apps/sim/lib/workflows/application/runs.test.ts index 94eabc12bd0..44612ac1390 100644 --- a/apps/sim/lib/workflows/application/runs.test.ts +++ b/apps/sim/lib/workflows/application/runs.test.ts @@ -63,11 +63,12 @@ const principals: Principal[] = [ kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2999-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, }, ] diff --git a/apps/sim/lib/workflows/application/update-workflow-content.test.ts b/apps/sim/lib/workflows/application/update-workflow-content.test.ts index cd853b4e8b0..d4b07dcd5db 100644 --- a/apps/sim/lib/workflows/application/update-workflow-content.test.ts +++ b/apps/sim/lib/workflows/application/update-workflow-content.test.ts @@ -42,11 +42,12 @@ const principal = { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'tool-call-1', audience: 'sim:workflows', issuedAt: new Date('2026-01-01T00:00:00Z'), expiresAt: new Date('2099-01-01T00:00:00Z'), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'workspace-1' } } as const, } describe('applyWorkflowVariableOperations', () => { diff --git a/apps/sim/lib/workflows/custom-blocks/application/settings.test.ts b/apps/sim/lib/workflows/custom-blocks/application/settings.test.ts index 2097b82bedf..f01fec56392 100644 --- a/apps/sim/lib/workflows/custom-blocks/application/settings.test.ts +++ b/apps/sim/lib/workflows/custom-blocks/application/settings.test.ts @@ -44,11 +44,12 @@ const principal = { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'real-actor', - workspaceId: 'source', audience: 'sim:settings', delegationId: 'call', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'source' } as const, + grant: { kind: 'entity', owner: { entityType: 'workspace', entityId: 'source' } } as const, } as const const target = { workspaceId: 'source', id: 'block' } const publishInput = { diff --git a/apps/sim/lib/workflows/references/binding-targets.ts b/apps/sim/lib/workflows/references/binding-targets.ts index a17f1de61f4..f9f2ccd6452 100644 --- a/apps/sim/lib/workflows/references/binding-targets.ts +++ b/apps/sim/lib/workflows/references/binding-targets.ts @@ -1,4 +1,8 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + type Principal, + principalWorkspaceId, + requirePrincipalSubjectUserId, +} from '@sim/auth/principal' import { credential, customBlock, @@ -211,7 +215,7 @@ export async function authorizeWorkflowBindingCredentials( !( principal.kind === 'delegated' && principal.serviceId === 'copilot' && - principal.workspaceId === workspaceId && + principalWorkspaceId(principal) === workspaceId && isCopilotWorkspaceInvocation(principal) && ['sim:workflows', 'sim:workspaces'].includes(principal.audience) ) diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts index 6e23be54992..3900b6ef08c 100644 --- a/apps/sim/lib/workspace-files/application/authorization.test.ts +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -82,12 +82,16 @@ describe('file operation authorization', () => { kind: 'delegated', serviceId: 'copilot', 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', chatId: 'chat-1' }, + invocation: { kind: 'chat', chatId: 'chat-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { fileId: 'file-1', chatId: 'chat-1' }, + } as const, }, fileOperations.rename, authorizationContext @@ -137,21 +141,25 @@ describe('file operation authorization', () => { kind: 'delegated' as const, serviceId: 'copilot' as const, subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:workspace-files', issuedAt: new Date(Date.now() - 10_000), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, } await expectForbidden({ ...base, expiresAt: new Date(Date.now() - 1), - resourceScope: { fileId: 'file-1' }, + grant: { ...base.grant, restrictions: { fileId: 'file-1' } }, }) await expectForbidden({ ...base, expiresAt: new Date(Date.now() + 60_000), - resourceScope: { fileId: 'file-2' }, + grant: { ...base.grant, restrictions: { fileId: 'file-2' } }, }) expect(resolvePermission).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workspace-files/application/authorization.ts b/apps/sim/lib/workspace-files/application/authorization.ts index dca73ebab1e..f7f1d52b860 100644 --- a/apps/sim/lib/workspace-files/application/authorization.ts +++ b/apps/sim/lib/workspace-files/application/authorization.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import type { WorkspaceOperation } from '@/lib/core/application' import { authorizeWorkspaceOperation, @@ -23,8 +24,8 @@ export const workspaceFileDelegationPolicy = { delegated: Extract, canonicalContext: WorkspaceFileAuthorizationContext ) => - delegated.resourceScope?.fileId === undefined || - delegated.resourceScope.fileId === canonicalContext.fileId, + workspaceDelegationRestrictions(delegated)?.fileId === undefined || + workspaceDelegationRestrictions(delegated)?.fileId === canonicalContext.fileId, } as const export async function authorizeWorkspaceFileAccess( diff --git a/apps/sim/lib/workspace-files/application/copy-authorization.ts b/apps/sim/lib/workspace-files/application/copy-authorization.ts index a7a185bb07b..d85ab1bfa57 100644 --- a/apps/sim/lib/workspace-files/application/copy-authorization.ts +++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts @@ -1,12 +1,14 @@ -import type { Principal, ResourceFileCopyScope } from '@sim/auth/principal' +import type { + Principal, + ResourceFileCopyScope, + ResourceOperationPrincipal, +} from '@sim/auth/principal' +import { hasResourceOperationGrant, isResourceFileCopyScope } from '@sim/auth/principal' import { workspace } from '@sim/db/schema' import { compareStrings } from '@sim/utils/string' import { and, asc, inArray, isNull } from 'drizzle-orm' import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' -import { - isResourceFileCopyScope, - requireResourceDelegation, -} from '@/lib/core/application/resource-delegation' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' import { authorizeWorkspaceOperation, PrincipalKindAuthorizationError, @@ -44,7 +46,7 @@ export type CopyFileItemsInput = Omit type CopyPrincipal = | Extract - | Extract + | Extract type ProjectAccess = Awaited> @@ -91,7 +93,7 @@ const COPY_OWNER_POLICIES: FileOwnerAdapters = { allowPersonalApiKeys: row.allowPersonalApiKeys, } // Project access already holds membership and permission SHARE locks; upgrading can deadlock other Projects. - if (principal.kind === 'resource_delegated') { + if (hasResourceOperationGrant(principal)) { await requireCurrentHumanRole(userId, context, mode, { executor: tx }) // permission-group-enforced: files.use — copy delegation retains the subject's workspace file policy. await assertWorkspaceCapability( @@ -125,6 +127,8 @@ function requireCopyPrincipal( if (!fileCopyOperation.principalKinds.some((kind) => kind === principal.kind)) { throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) } + if (principal.kind === 'delegated' && !hasResourceOperationGrant(principal)) + throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) const scope = { kind: 'file_copy', ...input } if (!isResourceFileCopyScope(scope)) { throw new OrchestrationError( @@ -133,7 +137,7 @@ function requireCopyPrincipal( ) } requireOAuthOperationScope(principal, fileCopyOperation) - if (principal.kind === 'resource_delegated') { + if (hasResourceOperationGrant(principal)) { requireResourceDelegation(principal, { audience: fileCopyOperation.delegationAudience, services: fileCopyOperation.delegatedServices, @@ -152,10 +156,9 @@ export async function createFileCopyAuthorizer( await requireProjectApiEnabled() // actorless-unsupported: files.copy rejects executors and workspace keys; both owner policies require the acting human. const userId = await requireFileSubject(principal) - const invocationScope = - principal.kind === 'resource_delegated' - ? await resolveCopilotProjectScope(principal) - : undefined + const invocationScope = hasResourceOperationGrant(principal) + ? await resolveCopilotProjectScope(principal) + : undefined return async (tx) => { requireCopyPrincipal(principal, input) @@ -221,7 +224,7 @@ export async function createFileCopyAuthorizer( for (const organizationId of organizationIds) await acquirePermissionGroupOrgLock(tx, organizationId) await requireCurrentFileSubject(tx, principal) - if (principal.kind === 'resource_delegated') { + if (hasResourceOperationGrant(principal)) { if (!invocationScope) throw new Error('Copilot copy invocation was not prepared') for (const access of projects.values()) { await requireCurrentCopilotProjectInvocation(tx, principal, access, invocationScope) diff --git a/apps/sim/lib/workspace-files/application/copy-operation.ts b/apps/sim/lib/workspace-files/application/copy-operation.ts index 9e70495fe8f..d75a39220b1 100644 --- a/apps/sim/lib/workspace-files/application/copy-operation.ts +++ b/apps/sim/lib/workspace-files/application/copy-operation.ts @@ -1,4 +1,5 @@ import { + assertContentDeliveryDeclaration, assertOperationCapability, assertOperationOAuthPolicy, } from '@/lib/core/application/operation' @@ -9,13 +10,16 @@ const policy = { id: 'files.copy', capability: 'files.use', oauthScope: 'api:write', - principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'resource_delegated'], + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'delegated'], delegatedServices: ['copilot'], + delegationScope: 'resource', delegationAudience: 'sim:files:copy', + contentDelivery: 'none', } as const assertOperationCapability(policy) assertOperationOAuthPolicy(policy) +assertContentDeliveryDeclaration(policy, policy.delegatedServices) Object.freeze(policy.principalKinds) Object.freeze(policy.delegatedServices) diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.test.ts b/apps/sim/lib/workspace-files/application/delegated-principal.test.ts index 7271eea77e0..24f8ca7f47e 100644 --- a/apps/sim/lib/workspace-files/application/delegated-principal.test.ts +++ b/apps/sim/lib/workspace-files/application/delegated-principal.test.ts @@ -1,6 +1,51 @@ +import { parsePrincipal, serializePrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' -import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { + createWorkspaceFileDelegatedPrincipal, + rebindWorkspaceFileDelegatedPrincipal, +} from '@/lib/workspace-files/application/delegated-principal' + +describe('createWorkspaceFileDelegatedPrincipal', () => { + it('keeps a copilot file principal on its chat invocation through a snapshot round trip', () => { + const minted = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + fileId: 'file-1', + chatId: 'chat-1', + }) + expect(minted).toMatchObject({ + serviceId: 'copilot', + invocation: { kind: 'chat', chatId: 'chat-1' }, + grant: { restrictions: { fileId: 'file-1', chatId: 'chat-1' } }, + }) + expect(parsePrincipal(structuredClone(serializePrincipal(minted)))).toMatchObject({ + serviceId: 'copilot', + invocation: { kind: 'chat', chatId: 'chat-1' }, + grant: { + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + restrictions: { fileId: 'file-1', chatId: 'chat-1' }, + }, + }) + }) + + it('keeps realtime workspace-file authority on a workspace invocation', () => { + expect( + createWorkspaceFileDelegatedPrincipal({ + serviceId: 'realtime', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + chatId: 'chat-1', + }) + ).toMatchObject({ + serviceId: 'realtime', + invocation: { kind: 'workspace', workspaceId: 'workspace-1' }, + }) + }) +}) describe('rebindWorkspaceFileDelegatedPrincipal', () => { it('preserves actorless workflow identity and deployment authority', () => { @@ -57,11 +102,15 @@ describe('rebindWorkspaceFileDelegatedPrincipal', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'copilot-1', audience: 'sim:function-executions', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), + invocation: { kind: 'workspace', workspaceId: 'workspace-1' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + } as const, }, workspaceId: 'workspace-2', delegationId: 'file-1', diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.ts b/apps/sim/lib/workspace-files/application/delegated-principal.ts index 664fde027d0..29bf2275c03 100644 --- a/apps/sim/lib/workspace-files/application/delegated-principal.ts +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -1,10 +1,11 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { principalWorkspaceId } from '@sim/auth/principal' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' const WORKSPACE_FILE_DELEGATION_TTL_MS = 5 * 60 * 1000 export interface WorkspaceFileDelegationInput { - serviceId: DelegatedPrincipal['serviceId'] + serviceId: WorkspaceDelegatedPrincipal['serviceId'] subjectUserId: string workspaceId: string delegationId: string @@ -16,13 +17,13 @@ export interface WorkspaceFileDelegationInput { /** Binds a trusted service execution to the shared workspace-file principal shape. */ export function createWorkspaceFileDelegatedPrincipal( input: WorkspaceFileDelegationInput -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { throw new Error('Workspace file delegation requires subject, workspace, and delegation IDs') } const issuedAt = new Date() - return { - kind: 'delegated', + const principal = { + kind: 'delegated' as const, serviceId: input.serviceId, subjectUserId: input.subjectUserId, workspaceId: input.workspaceId, @@ -36,10 +37,33 @@ export function createWorkspaceFileDelegatedPrincipal( ...(input.executionId ? { executionId: input.executionId } : {}), }, } + if (input.serviceId === 'executor') return { ...principal, serviceId: 'executor' } + const { workspaceId, resourceScope, ...envelope } = principal + const grant = { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: workspaceId }, + restrictions: resourceScope, + } + if (input.serviceId === 'copilot') { + return { + ...envelope, + serviceId: 'copilot', + invocation: input.chatId + ? { kind: 'chat' as const, chatId: input.chatId } + : { kind: 'workspace' as const, workspaceId }, + grant, + } + } + return { + ...envelope, + serviceId: 'realtime', + invocation: { kind: 'workspace' as const, workspaceId }, + grant, + } } export interface RebindWorkspaceFileDelegationInput { - principal: DelegatedPrincipal + principal: WorkspaceDelegatedPrincipal workspaceId: string delegationId: string fileId?: string @@ -50,14 +74,12 @@ export interface RebindWorkspaceFileDelegationInput { /** Rebinds an already-authorized service principal without changing its workflow actor. */ export function rebindWorkspaceFileDelegatedPrincipal( input: RebindWorkspaceFileDelegationInput -): DelegatedPrincipal { - if (input.principal.workspaceId !== input.workspaceId || !input.delegationId) { +): WorkspaceDelegatedPrincipal { + if (principalWorkspaceId(input.principal) !== input.workspaceId || !input.delegationId) { throw new Error('Workspace file delegation does not match its authorized workspace') } const issuedAt = new Date() - return { - ...input.principal, - workspaceId: input.workspaceId, + const updates = { delegationId: input.delegationId, audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, issuedAt, @@ -67,10 +89,28 @@ export function rebindWorkspaceFileDelegatedPrincipal( issuedAt.getTime() + WORKSPACE_FILE_DELEGATION_TTL_MS ) ), - resourceScope: { - ...(input.fileId ? { fileId: input.fileId } : {}), - ...(input.chatId ? { chatId: input.chatId } : {}), - ...(input.executionId ? { executionId: input.executionId } : {}), - }, + } + const restrictions = { + ...(input.fileId ? { fileId: input.fileId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + } + if (input.principal.serviceId === 'executor') + return { ...input.principal, ...updates, resourceScope: restrictions } + if (input.principal.serviceId === 'copilot') { + return { + ...input.principal, + ...updates, + invocation: restrictions.chatId + ? { kind: 'chat' as const, chatId: restrictions.chatId } + : { kind: 'workspace' as const, workspaceId: input.workspaceId }, + grant: { ...input.principal.grant, restrictions }, + } + } + return { + ...input.principal, + ...updates, + invocation: { kind: 'workspace' as const, workspaceId: input.workspaceId }, + grant: { ...input.principal.grant, restrictions }, } } diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index 6864a907e6d..0edd3062211 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -1,5 +1,6 @@ import { createDelegatedPrincipal, + createExecutorPrincipal, createSessionPrincipal, } from '@sim/testing/factories/principal.factory' import { auditMock } from '@sim/testing/mocks/audit.mock' @@ -388,11 +389,11 @@ describe('downloadWorkspaceFileItems', () => { it('does not apply the capability to a delegated executor carrying a subject', async () => { await expect( downloadWorkspaceFileItems.execute({ - principal: { - ...delegatedPrincipal, - serviceId: 'executor' as const, - resourceScope: {}, - }, + principal: createExecutorPrincipal({ + subjectUserId: 'u1', + workspaceId: 'ws-1', + audience: 'sim:workspace-files', + }), input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, }) ).resolves.toMatchObject({ filesToZip: [expect.objectContaining({ id: 'f2' })] }) diff --git a/apps/sim/lib/workspace-files/application/export-workspace-file-snapshot.test.ts b/apps/sim/lib/workspace-files/application/export-workspace-file-snapshot.test.ts index 61db913afd5..1298fafde3d 100644 --- a/apps/sim/lib/workspace-files/application/export-workspace-file-snapshot.test.ts +++ b/apps/sim/lib/workspace-files/application/export-workspace-file-snapshot.test.ts @@ -1,6 +1,7 @@ import type { Principal } from '@sim/auth/principal' import { createDelegatedPrincipal, + createExecutorPrincipal, createPersonalApiKeyPrincipal, createSessionPrincipal, createWorkspaceApiKeyPrincipal, @@ -199,16 +200,19 @@ describe('exportWorkspaceFileSnapshot', () => { scopes: ['api:read'], expiresAt: new Date(Date.now() + 60_000), }, - ...(['copilot', 'executor'] as const).map((serviceId) => ({ - kind: 'delegated' as const, - serviceId, + createDelegatedPrincipal({ + workspaceId: WORKSPACE_ID, + audience: 'sim:workspace-files', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + }), + createExecutorPrincipal({ subjectUserId: 'user-1', workspaceId: WORKSPACE_ID, - delegationId: 'delegation-1', audience: 'sim:workspace-files', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60_000), - })), + }), ])('uses current read authorization for registered principal %j', async (principal) => { expect((await execute('snapshot', principal)).buffer.toString()).toBe('snapshot') expect(mocks.resolvePermission).toHaveBeenCalled() diff --git a/apps/sim/lib/workspace-files/application/file-context.ts b/apps/sim/lib/workspace-files/application/file-context.ts index a9e46b5a492..1d8b25c67cd 100644 --- a/apps/sim/lib/workspace-files/application/file-context.ts +++ b/apps/sim/lib/workspace-files/application/file-context.ts @@ -1,4 +1,4 @@ -import type { Principal } from '@sim/auth/principal' +import { isWorkspaceDelegation, type Principal } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { @@ -32,7 +32,8 @@ export interface WorkspaceFileContextInput { * keys, system callers, and the executor's delegation never reach an owned file. */ function isOwnedFilePrincipal(principal: Principal): boolean { - if (principal.kind === 'delegated') return principal.serviceId === 'copilot' + if (principal.kind === 'delegated') + return isWorkspaceDelegation(principal) && principal.serviceId === 'copilot' return ( principal.kind === 'session' || principal.kind === 'personal_api_key' || diff --git a/apps/sim/lib/workspace-files/application/file-version-write.ts b/apps/sim/lib/workspace-files/application/file-version-write.ts index 801afbea100..a44bb4ce4b1 100644 --- a/apps/sim/lib/workspace-files/application/file-version-write.ts +++ b/apps/sim/lib/workspace-files/application/file-version-write.ts @@ -10,7 +10,6 @@ function versionSourceForPrincipal(principal: Principal): WorkspaceFileVersionSo case 'oauth_access_token': case 'workspace_api_key': return 'api' - case 'resource_delegated': case 'delegated': if (principal.serviceId === 'copilot') return 'copilot' if (principal.serviceId === 'executor') return 'workflow' diff --git a/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.test.ts b/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.test.ts index 55a7a45fed9..8d13d94a508 100644 --- a/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.test.ts +++ b/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.test.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { Principal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { createPersonalApiKeyPrincipal, createSessionPrincipal, @@ -52,7 +52,9 @@ const file = { } const session = createSessionPrincipal({ userId: 'reader' }) -function executor(overrides: Partial = {}): DelegatedPrincipal { +function executor( + overrides: Partial = {} +): WorkspaceDelegatedPrincipal { return { kind: 'delegated', serviceId: 'executor', @@ -146,7 +148,7 @@ describe('readStoredWorkspaceFileRecordByKey', () => { ).resolves.toMatchObject({ file: { context: 'workspace' } }) }) - it.each>([ + it.each>([ { resourceScope: { fileId: 'other-file' } }, { resourceScope: { chatId: 'other-chat' } }, { resourceScope: { fileId: 'file-1', chatId: 'other-chat' } }, diff --git a/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.ts b/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.ts index f7a443bc947..b1efab20fce 100644 --- a/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.ts +++ b/apps/sim/lib/workspace-files/application/read-stored-workspace-file-record-by-key.ts @@ -1,3 +1,4 @@ +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { @@ -67,8 +68,8 @@ export const readStoredWorkspaceFileRecordByKey = defineAuthorizedWorkspaceUseCa isWithinScope: (principal, context) => workspaceFileDelegationPolicy.isWithinScope(principal, context) && (context.file.context !== 'mothership' || - principal.resourceScope?.chatId === undefined || - principal.resourceScope.chatId === context.file.chatId), + workspaceDelegationRestrictions(principal)?.chatId === undefined || + workspaceDelegationRestrictions(principal)?.chatId === context.file.chatId), }, }, async execute({ input, context, principal }): Promise<{ file: FileMetadataRecord }> { diff --git a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts index 38dfd293ace..904b0830f90 100644 --- a/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' import { admitContent } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -69,7 +70,7 @@ export async function resolveReferencedWorkspaceFileContext( ): Promise { const chatId = (principal.kind === 'delegated' && principal.serviceId === 'copilot' - ? principal.resourceScope?.chatId + ? workspaceDelegationRestrictions(principal)?.chatId : undefined) ?? input.chatId const lookup = chatId === undefined ? options : { ...options, chatId } const file = diff --git a/apps/sim/lib/workspace-files/ownership.ts b/apps/sim/lib/workspace-files/ownership.ts index 97aea752618..4d00b6bb9ad 100644 --- a/apps/sim/lib/workspace-files/ownership.ts +++ b/apps/sim/lib/workspace-files/ownership.ts @@ -1,11 +1,8 @@ -import type { ResourceEntityType } from '@sim/auth/principal' +import type { EntityOwner } from '@sim/auth/principal' import type { WorkspaceFileRow } from '@sim/db/schema' /** Persisted ownership is independent of creator attribution and caller authority. */ -export interface FileOwner { - entityType: ResourceEntityType - entityId: string -} +export type FileOwner = EntityOwner /** An asserted file target; application authorization verifies its persisted owner. */ export interface OwnedFileTarget { @@ -14,10 +11,7 @@ export interface OwnedFileTarget { } /** Owners whose durable files support the shared Files editor and folder lifecycle. */ -export interface EditableFileOwner { - entityType: 'workspace' | 'project' - entityId: string -} +export type EditableFileOwner = EntityOwner<'workspace' | 'project'> type PersistedFileOwnership = Pick< WorkspaceFileRow, diff --git a/apps/sim/lib/workspaces/application/remove-member.test.ts b/apps/sim/lib/workspaces/application/remove-member.test.ts index 2ba5a95a979..7486e4133bc 100644 --- a/apps/sim/lib/workspaces/application/remove-member.test.ts +++ b/apps/sim/lib/workspaces/application/remove-member.test.ts @@ -191,7 +191,17 @@ describe('workspace member removal', () => { ) it('rejects wrong-workspace and expired delegation before removing access', async () => { - await expect(remove('target', { ...delegated, workspaceId: 'foreign' })).rejects.toThrow() + await expect( + remove( + 'target', + createDelegatedPrincipal({ + subjectUserId: 'actor', + workspaceId: 'foreign', + delegationId: 'call', + audience: 'sim:settings', + }) + ) + ).rejects.toThrow() await expect(remove('target', { ...delegated, expiresAt: new Date(0) })).rejects.toThrow() expect(mocks.revoke).not.toHaveBeenCalled() }) diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index ca457cd1713..eb2049eb654 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -956,8 +956,11 @@ describe('executeTool Function', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-456', - resourceScope: { executionId: 'execution-1' }, + grant: expect.objectContaining({ + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-456' }, + restrictions: { executionId: 'execution-1' }, + }), }), input: expect.objectContaining({ workspaceId: 'workspace-456', @@ -1052,8 +1055,12 @@ describe('executeTool Function', () => { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-456', - resourceScope: { executionId: 'execution-1' }, + invocation: { kind: 'workspace', workspaceId: 'workspace-456' } as const, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: 'workspace-456' }, + restrictions: { executionId: 'execution-1' }, + } as const, }, input: { workspaceId: 'workspace-456', diff --git a/bun.lock b/bun.lock index 7f0e555445e..4be61ecb39e 100644 --- a/bun.lock +++ b/bun.lock @@ -458,6 +458,7 @@ "version": "0.1.0", "dependencies": { "@sim/db": "workspace:*", + "@sim/utils": "workspace:*", "better-auth": "1.6.27", }, "devDependencies": { diff --git a/packages/auth/package.json b/packages/auth/package.json index 567462999c8..b72e381bb69 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@sim/db": "workspace:*", + "@sim/utils": "workspace:*", "better-auth": "1.6.27" }, "devDependencies": { diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index ccd1973e3a9..a093635ccd0 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -1,10 +1,11 @@ +import { isRecordLike } from '@sim/utils/object' + export type Principal = | SessionPrincipal | PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal | DelegatedPrincipal - | ResourceDelegatedPrincipal | OrganizationDelegatedPrincipal | SystemPrincipal | CredentialGroupEnrollmentPrincipal @@ -129,26 +130,38 @@ type SystemPrincipal = ActorlessSystemPrincipal | ChatSystemPrincipal | WebhookS interface DelegatedPrincipalBase { kind: 'delegated' - workspaceId: string delegationId: string audience: string issuedAt: Date expiresAt: Date - resourceScope?: { - fileId?: string - tableId?: string - chatId?: string - executionId?: string - credentialId?: string - credentialGroupId?: string - mcpServerId?: string - mcpBlockId?: string - } } -export interface SubjectDelegatedPrincipal extends DelegatedPrincipalBase { - serviceId: 'copilot' | 'realtime' - subjectUserId: string +interface WorkspaceDelegationRestrictions { + fileId?: string + tableId?: string + chatId?: string + executionId?: string + credentialId?: string + credentialGroupId?: string + mcpServerId?: string + mcpBlockId?: string +} + +interface WorkspaceDelegationFields { + workspaceId: string + resourceScope?: WorkspaceDelegationRestrictions + grant?: never + invocation?: never +} + +interface WorkspaceDelegationGrant { + kind: 'entity' + owner: EntityOwner<'workspace'> + restrictions?: WorkspaceDelegationRestrictions +} + +export type WorkspaceSubjectDelegation = SubjectDelegatedPrincipal & { + grant: WorkspaceDelegationGrant } interface WorkflowExecutionDelegationContext { @@ -173,7 +186,9 @@ export type WorkflowExecutionAuthority = | { workflowId: string; mode: 'draft' } | { workflowId: string; mode: 'deployment'; deploymentVersionId: string } -export interface WorkflowExecutionDelegatedPrincipal extends DelegatedPrincipalBase { +export interface WorkflowExecutionDelegatedPrincipal + extends DelegatedPrincipalBase, + WorkspaceDelegationFields { serviceId: 'executor' subjectUserId?: string delegationContext?: WorkflowExecutionDelegationContext @@ -183,35 +198,43 @@ export type BoundWorkflowExecutionDelegatedPrincipal = WorkflowExecutionDelegate delegationContext: WorkflowExecutionDelegationContext } +export type WorkspaceDelegatedPrincipal = + | WorkspaceSubjectDelegation + | WorkflowExecutionDelegatedPrincipal + export type DelegatedPrincipal = SubjectDelegatedPrincipal | WorkflowExecutionDelegatedPrincipal -export type ResourceEntityType = 'workspace' | 'project' | 'organization' | 'user' +type ResourceEntityType = 'workspace' | 'project' | 'organization' | 'user' + +/** Canonical owner identity shared by resource targets and authority restrictions. */ +export interface EntityOwner { + entityType: T + entityId: string +} /** An owner grant may be narrowed to a single file, but never widened to another owner. */ interface ResourceEntityScope { kind: 'entity' - entityType: ResourceEntityType - entityId: string + owner: EntityOwner fileId?: string } /** Observing a Project collection grants no authority to address or mutate an individual file. */ interface ResourceFileCollectionObservationScope { kind: 'file_collection_observation' - entityType: 'project' - entityId: string + owner: EntityOwner<'project'> } /** One compound copy grant binds the exact requested source selection and destination. */ export interface ResourceFileCopyScope { kind: 'file_copy' source: { - owner: { entityType: 'workspace' | 'project'; entityId: string } + owner: EntityOwner<'workspace' | 'project'> fileIds: readonly string[] folderIds: readonly string[] } destination: { - owner: { entityType: 'workspace' | 'project'; entityId: string } + owner: EntityOwner<'workspace' | 'project'> folderId: string | null } } @@ -222,30 +245,205 @@ export type ResourceDelegationScope = | ResourceFileCopyScope | ResourceFileCollectionObservationScope -interface ResourceDelegatedPrincipalBase { - kind: 'resource_delegated' +/** Human authority pairs the trusted invocation with a bounded grant. */ +export type SubjectDelegatedPrincipal = DelegatedPrincipalBase & { subjectUserId: string - delegationId: string - audience: string - issuedAt: Date - expiresAt: Date -} - -/** Human authority for entity-owned resources; never a workflow execution principal. */ -export type ResourceDelegatedPrincipal = ResourceDelegatedPrincipalBase & - ( +} & ( | { serviceId: 'copilot' - scope: ResourceDelegationScope + grant: WorkspaceDelegationGrant | ResourceDelegationScope invocation: { kind: 'chat'; chatId: string } | { kind: 'workspace'; workspaceId: string } } | { serviceId: 'realtime' - scope: (ResourceEntityScope & { fileId: string }) | ResourceFileCollectionObservationScope - invocation: { kind: 'realtime'; connectionId: string } + grant: + | WorkspaceDelegationGrant + | (ResourceEntityScope & { fileId: string }) + | ResourceFileCollectionObservationScope + invocation: + | { kind: 'realtime'; connectionId: string } + | { kind: 'workspace'; workspaceId: string } } ) +export type ResourceOperationPrincipal = SubjectDelegatedPrincipal & { + grant: ResourceDelegationScope +} + +function isRecord(value: unknown): value is Record { + return isRecordLike(value) +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function isIdentity(value: unknown): value is string { + return isNonEmptyString(value) && value === value.trim() +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)) +} + +function isResourceOwner( + value: unknown, + entityTypes: readonly string[] +): value is { entityType: string; entityId: string } { + return ( + isRecord(value) && + Object.keys(value).length === 2 && + hasOnlyKeys(value, ['entityType', 'entityId']) && + entityTypes.includes(String(value.entityType)) && + isIdentity(value.entityId) + ) +} + +function isSelectionIds(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length <= 1_000 && + value.every(isIdentity) && + new Set(value).size === value.length + ) +} + +/** Validates the paired scope shared by delegation admission and the copy application boundary. */ +export function isResourceFileCopyScope(scope: unknown): scope is ResourceFileCopyScope { + if (!isRecord(scope) || scope.kind !== 'file_copy') return false + const { source, destination } = scope + return ( + Object.keys(scope).length === 3 && + hasOnlyKeys(scope, ['kind', 'source', 'destination']) && + isRecord(source) && + Object.keys(source).length === 3 && + hasOnlyKeys(source, ['owner', 'fileIds', 'folderIds']) && + isResourceOwner(source.owner, ['workspace', 'project']) && + isSelectionIds(source.fileIds) && + isSelectionIds(source.folderIds) && + source.fileIds.length + source.folderIds.length > 0 && + isRecord(destination) && + Object.keys(destination).length === 2 && + hasOnlyKeys(destination, ['owner', 'folderId']) && + isResourceOwner(destination.owner, ['workspace', 'project']) && + (destination.folderId === null || isIdentity(destination.folderId)) + ) +} + +/** Structural resource-delegation scope. Resource admission still rejects a workspace-owned entity grant. */ +export function isResourceDelegationScope(scope: unknown): scope is ResourceDelegationScope { + if (!isRecord(scope)) return false + if (scope.kind === 'project_discovery') return Object.keys(scope).length === 1 + if (scope.kind === 'file_copy') return isResourceFileCopyScope(scope) + if (!isResourceOwner(scope.owner, ['workspace', 'project', 'organization', 'user'])) return false + if (scope.kind === 'file_collection_observation') { + return ( + scope.owner.entityType === 'project' && + Object.keys(scope).length === 2 && + hasOnlyKeys(scope, ['kind', 'owner']) + ) + } + return ( + scope.kind === 'entity' && + (scope.fileId === undefined || isIdentity(scope.fileId)) && + hasOnlyKeys(scope, ['kind', 'owner', 'fileId']) + ) +} + +/** + * Any entity grant whose owner is a workspace, including shapes with extra keys. + * Those are neither workspace delegation nor resource grants. + */ +function isWorkspaceOwnedEntityGrant(grant: unknown): boolean { + return ( + isRecord(grant) && + grant.kind === 'entity' && + isRecord(grant.owner) && + grant.owner.entityType === 'workspace' + ) +} + +function isWorkspaceDelegationGrant(grant: unknown): grant is WorkspaceDelegationGrant { + if ( + !isRecord(grant) || + grant.kind !== 'entity' || + !hasOnlyKeys(grant, ['kind', 'owner', 'restrictions']) + ) + return false + if (!isResourceOwner(grant.owner, ['workspace'])) return false + return grant.restrictions === undefined || isRecord(grant.restrictions) +} + +function workspaceInvocationAgrees( + principal: SubjectDelegatedPrincipal, + grant: WorkspaceDelegationGrant +): boolean { + const invocation = principal.invocation + if (!isRecord(invocation)) return false + if (invocation.kind === 'workspace') { + return ( + hasOnlyKeys(invocation, ['kind', 'workspaceId']) && + invocation.workspaceId === grant.owner.entityId + ) + } + if (invocation.kind !== 'chat' || principal.serviceId !== 'copilot') return false + const chatId = grant.restrictions?.chatId + return ( + hasOnlyKeys(invocation, ['kind', 'chatId']) && + isIdentity(invocation.chatId) && + isIdentity(chatId) && + invocation.chatId === chatId + ) +} + +/** Narrows grants handled by resource operations; domain authorization validates their contents. */ +export function hasResourceOperationGrant( + principal: Principal +): principal is ResourceOperationPrincipal { + return ( + principal.kind === 'delegated' && + principal.serviceId !== 'executor' && + !isWorkspaceOwnedEntityGrant(principal.grant) && + isResourceDelegationScope(principal.grant) + ) +} + +/** Identifies workspace authority without granting admission to any operation. */ +export function isWorkspaceDelegation( + principal: Principal +): principal is WorkspaceDelegatedPrincipal { + if (principal.kind !== 'delegated') return false + if (principal.serviceId === 'executor') + return ( + principal.grant === undefined && + typeof principal.workspaceId === 'string' && + principal.workspaceId.length > 0 + ) + if ('workspaceId' in principal || 'resourceScope' in principal) return false + if (!isWorkspaceDelegationGrant(principal.grant)) return false + return workspaceInvocationAgrees(principal, principal.grant) +} + +/** Reads the principal's authority-bound workspace, never UI selection. */ +export function principalWorkspaceId( + principal: WorkspaceDelegatedPrincipal | WorkspaceApiKeyPrincipal +): string +export function principalWorkspaceId(principal: Principal): string | undefined +export function principalWorkspaceId(principal: Principal): string | undefined { + if (isWorkspaceDelegation(principal) && principal.serviceId !== 'executor') + return principal.grant.owner.entityId + if (principal.kind === 'delegated' && principal.serviceId !== 'executor') return undefined + return 'workspaceId' in principal ? principal.workspaceId : undefined +} + +/** Reads existing workspace restrictions for human or executor delegation. */ +export function workspaceDelegationRestrictions( + principal: Principal +): WorkspaceDelegationRestrictions | undefined { + if (!isWorkspaceDelegation(principal)) return undefined + return principal.serviceId === 'executor' ? principal.resourceScope : principal.grant.restrictions +} + /** Search-only authority delegated by a current organization member. */ interface OrganizationDelegatedPrincipalBase { kind: 'organization_delegated' @@ -346,7 +544,7 @@ export type WorkflowExecutionPrincipal = | PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal - | SubjectDelegatedPrincipal + | WorkspaceSubjectDelegation | SystemPrincipal type SerializedWorkflowExecutionPrincipal = @@ -355,7 +553,10 @@ type SerializedWorkflowExecutionPrincipal = | (Omit & { expiresAt: string }) | WorkspaceApiKeyPrincipal | SystemPrincipal - | (Omit & { + | (Omit< + WorkspaceSubjectDelegation, + 'issuedAt' | 'expiresAt' | 'grant' | 'invocation' | 'workspaceId' | 'resourceScope' + > & { workspaceId: string; resourceScope?: WorkspaceDelegationRestrictions } & { issuedAt: string expiresAt: string }) @@ -402,7 +603,7 @@ function requireDate(value: unknown, field: string): Date { return date } -function parseResourceScope(value: unknown): DelegatedPrincipal['resourceScope'] { +function parseResourceScope(value: unknown): WorkspaceDelegationRestrictions | undefined { const scope = requireRecord(value, 'Serialized principal resourceScope') const keys = [ 'fileId', @@ -415,7 +616,7 @@ function parseResourceScope(value: unknown): DelegatedPrincipal['resourceScope'] 'mcpBlockId', ] as const requireExactKeys(scope, [], keys) - const parsed: NonNullable = {} + const parsed: WorkspaceDelegationRestrictions = {} for (const key of keys) { if (scope[key] !== undefined) parsed[key] = requireString(scope[key], key) } @@ -475,10 +676,20 @@ export function serializePrincipal(principal: WorkflowExecutionPrincipal): Seria } return { version: 1, principal: { ...principal } } case 'delegated': + if (!isWorkspaceDelegation(principal)) + throw new Error('Principal cannot be persisted for workflow execution') return { version: 1, principal: { - ...principal, + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + delegationId: principal.delegationId, + audience: principal.audience, + workspaceId: principal.grant.owner.entityId, + ...(principal.grant.restrictions === undefined + ? {} + : { resourceScope: principal.grant.restrictions }), issuedAt: principal.issuedAt.toISOString(), expiresAt: principal.expiresAt.toISOString(), }, @@ -614,19 +825,39 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { if (!['copilot', 'realtime'].includes(serviceId)) { throw new Error(`Unsupported delegated principal service ${serviceId}`) } - return { - kind, - serviceId: serviceId as SubjectDelegatedPrincipal['serviceId'], + const restrictions = + principal.resourceScope === undefined + ? undefined + : parseResourceScope(principal.resourceScope) + const delegated = { + kind: 'delegated' as const, subjectUserId: requireString(principal.subjectUserId, 'subjectUserId'), - workspaceId: requireString(principal.workspaceId, 'workspaceId'), + grant: { + kind: 'entity' as const, + owner: { + entityType: 'workspace' as const, + entityId: requireString(principal.workspaceId, 'workspaceId'), + }, + ...(restrictions === undefined ? {} : { restrictions }), + }, delegationId: requireString(principal.delegationId, 'delegationId'), audience: requireString(principal.audience, 'audience'), issuedAt: requireDate(principal.issuedAt, 'issuedAt'), expiresAt: requireDate(principal.expiresAt, 'expiresAt'), - ...(principal.resourceScope === undefined - ? {} - : { resourceScope: parseResourceScope(principal.resourceScope) }), } + const workspaceInvocation = { + kind: 'workspace' as const, + workspaceId: delegated.grant.owner.entityId, + } + return serviceId === 'copilot' + ? { + ...delegated, + serviceId, + invocation: restrictions?.chatId + ? { kind: 'chat', chatId: restrictions.chatId } + : workspaceInvocation, + } + : { ...delegated, serviceId: 'realtime', invocation: workspaceInvocation } } case 'resource_delegated': throw new Error('Resource delegation cannot be persisted for workflow execution') @@ -641,7 +872,7 @@ export type PrincipalActor = | Omit | { kind: 'resource_delegated' - serviceId: ResourceDelegatedPrincipal['serviceId'] + serviceId: ResourceOperationPrincipal['serviceId'] subjectUserId: string delegationId: string } @@ -722,7 +953,6 @@ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject case 'personal_api_key': case 'oauth_access_token': return { kind: 'sim_user', userId: principal.userId } - case 'resource_delegated': case 'organization_delegated': return { kind: 'sim_user', userId: principal.subjectUserId } case 'delegated': @@ -792,19 +1022,19 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { : {}), } case 'delegated': + if (hasResourceOperationGrant(principal)) + return { + kind: 'resource_delegated', + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + delegationId: principal.delegationId, + } return { kind: principal.kind, serviceId: principal.serviceId, ...(principal.subjectUserId ? { subjectUserId: principal.subjectUserId } : {}), delegationId: principal.delegationId, } - case 'resource_delegated': - return { - kind: principal.kind, - serviceId: principal.serviceId, - subjectUserId: principal.subjectUserId, - delegationId: principal.delegationId, - } case 'organization_delegated': return { kind: principal.kind, @@ -840,7 +1070,7 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { * deliberately left out — this describes the credential's kind, not the actor. */ export interface PrincipalAuthDescriptor { - kind: Principal['kind'] + kind: PrincipalActor['kind'] service?: string clientId?: string } diff --git a/packages/testing/src/factories/principal.factory.ts b/packages/testing/src/factories/principal.factory.ts index 98b90edef4e..b8d2fd30fc1 100644 --- a/packages/testing/src/factories/principal.factory.ts +++ b/packages/testing/src/factories/principal.factory.ts @@ -1,9 +1,9 @@ import type { PersonalApiKeyPrincipal, SessionPrincipal, - SubjectDelegatedPrincipal, WorkflowExecutionDelegatedPrincipal, WorkspaceApiKeyPrincipal, + WorkspaceSubjectDelegation, } from '@sim/auth/principal' /** @@ -57,18 +57,33 @@ export function createWorkspaceApiKeyPrincipal( * ``` */ export function createDelegatedPrincipal( - overrides: Partial = {} -): SubjectDelegatedPrincipal { + overrides: Omit< + Partial, + 'workspaceId' | 'resourceScope' | 'invocation' + > & + Pick, 'workspaceId' | 'resourceScope'> = {} +): WorkspaceSubjectDelegation { + const { workspaceId = 'workspace-1', resourceScope, grant: grantOverride, ...rest } = overrides + const grant = grantOverride ?? { + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: workspaceId }, + ...(resourceScope ? { restrictions: resourceScope } : {}), + } + const boundWorkspaceId = + grant.kind === 'entity' && grant.owner.entityType === 'workspace' + ? grant.owner.entityId + : workspaceId return { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', - workspaceId: 'workspace-1', delegationId: 'delegation-1', audience: 'sim:test', issuedAt: new Date(TEST_PRINCIPAL_ISSUED_AT), expiresAt: new Date(TEST_PRINCIPAL_EXPIRES_AT), - ...overrides, + invocation: { kind: 'workspace', workspaceId: boundWorkspaceId }, + grant, + ...rest, } } diff --git a/packages/testing/src/mocks/workspace-authorization.mock.ts b/packages/testing/src/mocks/workspace-authorization.mock.ts index c94f99e1f67..5b067eb371d 100644 --- a/packages/testing/src/mocks/workspace-authorization.mock.ts +++ b/packages/testing/src/mocks/workspace-authorization.mock.ts @@ -120,7 +120,6 @@ export const workspaceAuthorizationMockFns = { case 'personal_api_key': case 'oauth_access_token': return principal.userId ?? null - case 'resource_delegated': case 'organization_delegated': return principal.subjectUserId ?? null case 'delegated':