From 773ffcc831347d01ad85ac12cf2648c7e552f329 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 16 Oct 2026 18:16:21 -0700 Subject: [PATCH 1/5] improvement(auth): unify resource-scoped human delegation --- apps/sim/app/api/files/uploads/purposes.ts | 11 ++- .../lib/application/operations.ts | 4 +- .../copilot-project-file-request.test.ts | 2 +- .../lib/api/server/routes/copilot-request.ts | 6 +- .../api/server/routes/internal-json-route.ts | 4 +- apps/sim/lib/auth/internal-delegation.ts | 4 +- apps/sim/lib/auth/realtime-file-delegation.ts | 6 +- .../lib/auth/realtime-file-list-delegation.ts | 6 +- apps/sim/lib/core/application/audit-source.ts | 5 +- .../authorized-workspace-use-case.test.ts | 15 +-- .../core/application/content-delivery.test.ts | 2 +- .../lib/core/application/content-delivery.ts | 4 +- .../copilot-workspace-invocation.ts | 21 +++-- apps/sim/lib/core/application/operation.ts | 2 +- .../application/resource-delegation.test.ts | 28 +++--- .../core/application/resource-delegation.ts | 11 ++- .../workspace-authorization.test.ts | 8 +- .../application/workspace-authorization.ts | 11 ++- .../core/application/workspace-operation.ts | 12 ++- .../application/authorization.test.ts | 12 ++- .../application/manage-groups.test.ts | 9 +- .../copilot-managed-oauth-delegation.ts | 4 +- .../application/personal-credentials.test.ts | 6 +- .../application/use-cases.test.ts | 6 +- .../payloads/materialization.server.ts | 30 +++--- .../application/authorization.ts | 4 +- .../lib/function-execution/execute-request.ts | 6 +- .../lib/internal/deployments/client.test.ts | 4 +- apps/sim/lib/internal/deployments/client.ts | 4 +- apps/sim/lib/internal/function/execute.ts | 4 +- apps/sim/lib/internal/principals/executor.ts | 6 +- apps/sim/lib/knowledge/api/internal-route.ts | 3 + .../application/authorization.test.ts | 8 +- .../lib/mcp/application/execute-tool.test.ts | 4 +- .../csv-workflow-execution.live.test.ts | 4 +- .../agent-cli/file-copy-transport.ts | 4 +- .../agent-cli/saved-run-read.live.test.ts | 4 +- .../mothership/application/adapter.test.ts | 4 +- .../sim/lib/mothership/application/adapter.ts | 16 ++-- .../application/authorize-chat-callback.ts | 4 +- .../application/execute-knowledge-use-case.ts | 9 +- .../application/resource-adapter.test.ts | 10 +- .../application/settings-context.ts | 7 +- .../mothership/auth/application-delegation.ts | 16 ++-- .../lib/mothership/auth/file-delegation.ts | 8 +- apps/sim/lib/mothership/auth/internal.ts | 7 +- .../chat/application/workspace-context.ts | 7 +- .../integrations/application/catalog.ts | 7 +- .../memories/application/resolve-access.ts | 7 +- .../memory/application/read-scope.ts | 7 +- .../request/application/read-control.ts | 4 +- .../request/application/recover-stream.ts | 4 +- .../tools/sandbox-resource-transport.test.ts | 2 +- .../settings-management-boundary.test.ts | 7 +- apps/sim/lib/oauth/token-resolution.ts | 6 +- .../__integration__/foundation.integration.ts | 8 +- .../lib/projects/application/authorization.ts | 18 ++-- .../sim/lib/projects/application/discovery.ts | 4 +- .../lib/projects/application/operations.ts | 12 ++- .../sim/lib/projects/application/use-cases.ts | 11 +-- .../authorization-bulk.integration.ts | 10 +- .../authorization.integration.ts | 12 +-- .../content-bulk.integration.ts | 4 +- .../content-rendered.integration.ts | 6 +- .../__integration__/content.integration.ts | 2 +- .../__integration__/documents.integration.ts | 8 +- .../__integration__/uploads.integration.ts | 6 +- .../files/application/authorization.ts | 17 ++-- .../lib/projects/files/application/content.ts | 8 +- .../projects/files/application/downloads.ts | 10 +- .../projects/files/application/operations.ts | 28 +++--- apps/sim/lib/settings/application/context.ts | 4 +- .../upload-session/project-file-binding.ts | 11 ++- .../sim/lib/uploads/upload-session/service.ts | 13 +-- .../application/preferences-authorization.ts | 3 +- .../lib/users/application/preferences.test.ts | 4 +- .../application/authorization.test.ts | 6 +- .../inspect-workflow-tools.test.ts | 4 +- .../application/run-workflow-for-test.ts | 3 +- .../application/run-workflow-from-copilot.ts | 8 +- .../application/copy-authorization.ts | 24 +++-- .../application/copy-operation.ts | 3 +- .../application/delegated-principal.ts | 10 +- .../application/file-context.ts | 5 +- .../application/file-version-write.ts | 1 - ...tored-workspace-file-record-by-key.test.ts | 8 +- packages/auth/src/principal.ts | 93 +++++++++++++------ .../src/factories/principal.factory.ts | 6 +- .../src/mocks/workspace-authorization.mock.ts | 1 - 89 files changed, 458 insertions(+), 329 deletions(-) diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 831ad619e32..f31f78877a0 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { isWorkspaceDelegation } from '@sim/auth/principal' import { db } from '@sim/db' import { workspace } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' @@ -250,11 +251,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') @@ -274,6 +270,11 @@ 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 && 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/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..36dc5c7d371 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,7 +48,7 @@ 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' }, diff --git a/apps/sim/lib/api/server/routes/copilot-request.ts b/apps/sim/lib/api/server/routes/copilot-request.ts index 4a6045618dd..c5e6ea16868 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 { ResourceScopedDelegation, WorkspaceDelegatedPrincipal } 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' @@ -15,7 +15,7 @@ import { type CopilotResourceRequestGrant = ( operation: ApplicationOperation, audience: string -) => ResourceDelegatedPrincipal | undefined +) => ResourceScopedDelegation | undefined type CopilotRequestInvocation = | { kind: 'resource'; grant: CopilotResourceRequestGrant } @@ -71,7 +71,7 @@ export function copilotRequestPrincipal( request: Request, operation: ApplicationOperation, useCase?: CopilotRouteUseCase -): DelegatedPrincipal | ResourceDelegatedPrincipal | undefined { +): WorkspaceDelegatedPrincipal | ResourceScopedDelegation | undefined { const invocation = INVOCATIONS.get(request) if (!invocation) return undefined const audience = copilotRouteAudience(operation, useCase) 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..cd5020e0741 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 WorkspaceDelegatedPrincipal, } 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'] + ): WorkspaceDelegatedPrincipal['resourceScope'] } export function createInternalSessionOrExecutorAuth( diff --git a/apps/sim/lib/auth/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts index 57d8338a9b2..cdcce014cf7 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, + WorkspaceDelegatedPrincipal, } 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?: WorkspaceDelegatedPrincipal['resourceScope'] compatibilityActorUserId?: string } diff --git a/apps/sim/lib/auth/realtime-file-delegation.ts b/apps/sim/lib/auth/realtime-file-delegation.ts index 51f4b5ffa56..c84c32eba4d 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 { ResourceScopedDelegation } 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,7 +28,7 @@ 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,7 +25,7 @@ export const realtimeProjectFileListAuth: InternalAuthPolicy { it('resolves domain-specific delegation options against canonical context', async () => { const scopeCheck = vi.fn( - (principal: DelegatedPrincipal, context: TestContext) => + (principal: WorkspaceDelegatedPrincipal, context: TestContext) => principal.resourceScope?.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 +335,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 +367,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, diff --git a/apps/sim/lib/core/application/content-delivery.test.ts b/apps/sim/lib/core/application/content-delivery.test.ts index 037f25c7057..89f94d3b9cf 100644 --- a/apps/sim/lib/core/application/content-delivery.test.ts +++ b/apps/sim/lib/core/application/content-delivery.test.ts @@ -40,7 +40,7 @@ function delegated(serviceId: DelegatedServiceId): Principal { const CHAT = delegated('copilot') const PROJECT_CHAT: Principal = { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', audience: 'sim:project-files', diff --git a/apps/sim/lib/core/application/content-delivery.ts b/apps/sim/lib/core/application/content-delivery.ts index bc5f3d52259..5cbab489c6d 100644 --- a/apps/sim/lib/core/application/content-delivery.ts +++ b/apps/sim/lib/core/application/content-delivery.ts @@ -158,9 +158,7 @@ class ContentDeliveryUndeclaredError extends Error { /** Chat's own workspace, organization, and resource delegations. */ 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' ) } diff --git a/apps/sim/lib/core/application/copilot-workspace-invocation.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.ts index 8d023187323..7d00b9f9293 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.ts @@ -1,11 +1,12 @@ -import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { Principal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { isWorkspaceDelegation } 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: WorkspaceDelegatedPrincipal): void { if ( principal.serviceId !== 'copilot' || !principal.subjectUserId || @@ -19,9 +20,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 +39,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: WorkspaceDelegatedPrincipal['resourceScope'], target: NestedResourceScope -): NonNullable { +): NonNullable { const scope = { ...granted } for (const key of NESTED_RESOURCE_SCOPE_KEYS) { const value = target[key] @@ -65,6 +71,7 @@ export function bindCopilotWorkspaceOperation

( ): P { if (principal.kind !== 'delegated' || principal.serviceId !== 'copilot') return principal if ( + !isWorkspaceDelegation(principal) || !isCopilotWorkspaceInvocation(principal) || principal.workspaceId !== workspaceId || !sourceAudiences.includes(principal.audience) || 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..1eb9395d730 100644 --- a/apps/sim/lib/core/application/resource-delegation.test.ts +++ b/apps/sim/lib/core/application/resource-delegation.test.ts @@ -7,11 +7,12 @@ 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', @@ -213,21 +214,22 @@ describe('resource delegation authorization', () => { }) 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', () => { diff --git a/apps/sim/lib/core/application/resource-delegation.ts b/apps/sim/lib/core/application/resource-delegation.ts index 4956229fda6..bdf07e01eee 100644 --- a/apps/sim/lib/core/application/resource-delegation.ts +++ b/apps/sim/lib/core/application/resource-delegation.ts @@ -1,15 +1,16 @@ import type { Principal, - ResourceDelegatedPrincipal, ResourceDelegationScope, ResourceFileCopyScope, + ResourceScopedDelegation, } from '@sim/auth/principal' +import { isResourceScopedDelegation } 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 ResourceScopedDelegation['serviceId'][] scope: ResourceDelegationScope maxTtlMs: number } @@ -83,7 +84,7 @@ function isValidScope(scope: unknown): scope is ResourceDelegationScope { ) } -function isValidInvocation(principal: ResourceDelegatedPrincipal): boolean { +function isValidInvocation(principal: ResourceScopedDelegation): boolean { const invocation = principal.invocation if (!isRecordLike(invocation) || Object.keys(invocation).length !== 2) return false if (principal.serviceId === 'copilot') { @@ -157,7 +158,9 @@ export function requireResourceDelegation( const now = Date.now() if ( - principal.kind !== 'resource_delegated' || + !isResourceScopedDelegation(principal) || + principal.workspaceId !== undefined || + principal.resourceScope !== undefined || !policy.services.includes(principal.serviceId) || principal.audience !== policy.audience || !isIdentity(principal.subjectUserId) || diff --git a/apps/sim/lib/core/application/workspace-authorization.test.ts b/apps/sim/lib/core/application/workspace-authorization.test.ts index 0a906477d2e..0bdd2749a56 100644 --- a/apps/sim/lib/core/application/workspace-authorization.test.ts +++ b/apps/sim/lib/core/application/workspace-authorization.test.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal, OAuthAccessTokenPrincipal } from '@sim/auth/principal' +import type { OAuthAccessTokenPrincipal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { createPersonalApiKeyPrincipal, createSessionPrincipal, @@ -63,9 +63,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', diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 67de9320502..1226e27f90f 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -1,9 +1,10 @@ import { - type DelegatedPrincipal, + isWorkspaceDelegation, type OAuthAccessTokenPrincipal, type PersonalApiKeyPrincipal, type Principal, resolvePrincipalSubject, + type WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { type PermissionType, @@ -55,7 +56,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 +74,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 +152,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 +181,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) { @@ -430,7 +432,6 @@ 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..88165c1f8d8 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,7 +105,7 @@ function executorPrincipal(): WorkflowExecutionDelegatedPrincipal { } } -function copilotPrincipal(subjectUserId: string | null = 'user-1'): DelegatedPrincipal { +function copilotPrincipal(subjectUserId: string | null = 'user-1'): WorkspaceDelegatedPrincipal { return { kind: 'delegated', serviceId: 'copilot', @@ -116,7 +119,10 @@ function copilotPrincipal(subjectUserId: string | null = 'user-1'): DelegatedPri } } -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/manage-groups.test.ts b/apps/sim/lib/credential-groups/application/manage-groups.test.ts index 75cdcccb137..b77bc3e62e3 100644 --- a/apps/sim/lib/credential-groups/application/manage-groups.test.ts +++ b/apps/sim/lib/credential-groups/application/manage-groups.test.ts @@ -1,4 +1,7 @@ -import type { CredentialGroupEnrollmentPrincipal, DelegatedPrincipal } from '@sim/auth/principal' +import type { + CredentialGroupEnrollmentPrincipal, + WorkspaceDelegatedPrincipal, +} from '@sim/auth/principal' import { auditMock, auditMockFns } from '@sim/testing' import { createDelegatedPrincipal, @@ -68,7 +71,9 @@ const enrollmentPrincipal: CredentialGroupEnrollmentPrincipal = { invitationTokenHash: 'hash-1', } -function copilotPrincipal(overrides: Partial = {}): DelegatedPrincipal { +function copilotPrincipal( + overrides: Partial = {} +): WorkspaceDelegatedPrincipal { return { ...createDelegatedPrincipal({ subjectUserId: 'admin-1', 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..c57ef1b2819 100644 --- a/apps/sim/lib/credentials/application/personal-credentials.test.ts +++ b/apps/sim/lib/credentials/application/personal-credentials.test.ts @@ -1,4 +1,4 @@ -import type { SubjectDelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceSubjectDelegation } from '@sim/auth/principal' import { resetDbChainMock } from '@sim/testing' import { createDelegatedPrincipal, @@ -58,7 +58,7 @@ const authorizationInput = { expectedProviderId: 'google-drive', } -function delegatedPrincipal(overrides: Partial = {}) { +function delegatedPrincipal(overrides: Partial = {}) { return createDelegatedPrincipal({ delegationId: 'assistant-turn-1', audience: 'sim:credentials', @@ -147,7 +147,7 @@ describe('personal credential application access', () => { it.each([ ['another workspace', { workspaceId: 'workspace-2' }], ['no person', { subjectUserId: undefined }], - ] satisfies Array<[string, Partial]>)( + ] satisfies Array<[string, Partial]>)( 'refuses %s delegations', async (_, overrides) => { await expect( 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..2e5cd577a8e 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 { WorkspaceDelegatedPrincipal } 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 = {} + ): WorkspaceDelegatedPrincipal { return { kind: 'delegated' as const, serviceId: 'executor' as const, diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index 1fc17b1f446..e954eb4b483 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 } 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'}`, + ...(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 try { await readStoredWorkspaceFileRecordByKey.execute({ principal, diff --git a/apps/sim/lib/function-execution/application/authorization.ts b/apps/sim/lib/function-execution/application/authorization.ts index 8a8775e04cc..a9bfbbdc556 100644 --- a/apps/sim/lib/function-execution/application/authorization.ts +++ b/apps/sim/lib/function-execution/application/authorization.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import type { ActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' export const FUNCTION_EXECUTION_DELEGATION_AUDIENCE = 'sim:function-executions' @@ -10,7 +10,7 @@ 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 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..bee615a5fb8 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,7 +78,7 @@ 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 = { diff --git a/apps/sim/lib/internal/principals/executor.ts b/apps/sim/lib/internal/principals/executor.ts index 7170ee0688e..9f46c3d6fe1 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 WorkspaceDelegatedPrincipal } 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?: WorkspaceDelegatedPrincipal['resourceScope'], expiresAt?: Date, compatibilityActorUserId?: string ) { @@ -70,7 +70,7 @@ export async function createExecutorPrincipalFromDelegationOrigin( export interface CreateExecutorPrincipalFromExecutionContextInput { context: InternalToolOperationContext audience: string - resourceScope?: DelegatedPrincipal['resourceScope'] + resourceScope?: WorkspaceDelegatedPrincipal['resourceScope'] expiresAt?: Date } diff --git a/apps/sim/lib/knowledge/api/internal-route.ts b/apps/sim/lib/knowledge/api/internal-route.ts index 26c3ca2863f..11ba3f1e85a 100644 --- a/apps/sim/lib/knowledge/api/internal-route.ts +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -1,4 +1,5 @@ import { + isWorkspaceDelegation, type Principal, resolvePrincipalSubject, resolvePrincipalSubjectUserId, @@ -49,6 +50,8 @@ 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, }).actorUserId diff --git a/apps/sim/lib/knowledge/application/authorization.test.ts b/apps/sim/lib/knowledge/application/authorization.test.ts index 3f32bd96bca..b5c78c745a1 100644 --- a/apps/sim/lib/knowledge/application/authorization.test.ts +++ b/apps/sim/lib/knowledge/application/authorization.test.ts @@ -1,11 +1,13 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { KNOWLEDGE_DELEGATION_AUDIENCE, knowledgeDelegationPolicy, } from '@/lib/knowledge/application/authorization' -function createKnowledgePrincipal(overrides: Partial = {}): DelegatedPrincipal { +function createKnowledgePrincipal( + overrides: Partial = {} +): WorkspaceDelegatedPrincipal { return { kind: 'delegated', serviceId: 'copilot', @@ -49,7 +51,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/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/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/file-copy-transport.ts b/apps/sim/lib/mothership/agent-cli/file-copy-transport.ts index 563f383316a..3f5bdd88f4f 100644 --- a/apps/sim/lib/mothership/agent-cli/file-copy-transport.ts +++ b/apps/sim/lib/mothership/agent-cli/file-copy-transport.ts @@ -1,4 +1,4 @@ -import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import type { ResourceScopedDelegation } 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' @@ -25,7 +25,7 @@ function markCopilotFileCopyRequest( request: Request, context: CopilotExecutionContext, target: CopyFileItemsInput -): ResourceDelegatedPrincipal { +): ResourceScopedDelegation { const principal = createCopilotResourceApplicationPrincipal( requireTrustedCopilotResourceExecutionContext(context), { 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/application/adapter.test.ts b/apps/sim/lib/mothership/application/adapter.test.ts index a6f032d13cb..c05a4b82ad5 100644 --- a/apps/sim/lib/mothership/application/adapter.test.ts +++ b/apps/sim/lib/mothership/application/adapter.test.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } 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 +42,7 @@ function createAdapter( createPrincipal?: (args: { context: TrustedCopilotExecutionContext resourceScope: CopilotResourceScope - }) => DelegatedPrincipal + }) => WorkspaceDelegatedPrincipal ) { return createCopilotApplicationAdapter({ domain: 'file', diff --git a/apps/sim/lib/mothership/application/adapter.ts b/apps/sim/lib/mothership/application/adapter.ts index 15e0d683336..55c19d55627 100644 --- a/apps/sim/lib/mothership/application/adapter.ts +++ b/apps/sim/lib/mothership/application/adapter.ts @@ -1,8 +1,8 @@ import type { - DelegatedPrincipal, Principal, - ResourceDelegatedPrincipal, ResourceDelegationScope, + ResourceScopedDelegation, + WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { type ApplicationOperation, @@ -26,7 +26,7 @@ import { type CopilotApplicationPrincipalFactory = (args: { context: TrustedCopilotExecutionContext resourceScope: CopilotResourceScope -}) => DelegatedPrincipal +}) => WorkspaceDelegatedPrincipal interface CopilotApplicationAdapterOptions { mode?: 'workspace' @@ -41,8 +41,9 @@ interface CopilotApplicationAdapterOptions { +): NonNullable { return { ...resourceScope, ...(context.chatId ? { chatId: context.chatId } : {}), @@ -101,7 +102,7 @@ function expectedResourceScope( } function requireMatchingPrincipal( - principal: DelegatedPrincipal, + principal: WorkspaceDelegatedPrincipal, context: TrustedCopilotExecutionContext, delegation: CopilotDelegationConfiguration, resourceScope: CopilotResourceScope @@ -210,7 +211,8 @@ function createWorkspaceApplicationAdapter Boolean(principal.subjectUserId), + isWithinScope: (principal: WorkspaceDelegatedPrincipal) => Boolean(principal.subjectUserId), }, }, async execute() {}, 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/resource-adapter.test.ts b/apps/sim/lib/mothership/application/resource-adapter.test.ts index c2f036bf041..202263c5d16 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 { isResourceScopedDelegation } 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) @@ -60,7 +62,7 @@ describe('Copilot resource authoring admission', () => { scope: { kind: 'entity', entityType: 'organization', entityId: 'other' }, }) expect(result).toMatchObject({ - kind: 'resource_delegated', + kind: 'delegated', subjectUserId: 'real-user', serviceId: 'copilot', delegationId: 'real-tool-call', @@ -76,7 +78,7 @@ describe('Copilot resource authoring admission', () => { {} ) expect(result).toMatchObject({ - kind: 'resource_delegated', + kind: 'delegated', invocation: { kind: 'chat', chatId: 'real-chat' }, scope: { kind: 'project_discovery' }, }) @@ -231,7 +233,7 @@ describe('Copilot resource authoring admission', () => { scope, }) ).toThrow('Resource delegation is no longer valid') - if (result.kind !== 'resource_delegated' || result.scope.kind !== 'file_copy') + if (!isResourceScopedDelegation(result) || result.scope.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) 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.ts b/apps/sim/lib/mothership/auth/application-delegation.ts index 1471787c42a..e69c4944485 100644 --- a/apps/sim/lib/mothership/auth/application-delegation.ts +++ b/apps/sim/lib/mothership/auth/application-delegation.ts @@ -1,8 +1,8 @@ import type { - DelegatedPrincipal, OrganizationDelegatedPrincipal, - ResourceDelegatedPrincipal, ResourceDelegationScope, + ResourceScopedDelegation, + WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' import { TOOL_WATCHDOG_LONG_RUNNING_MS } from '@/lib/mothership/constants' @@ -31,7 +31,7 @@ export interface CopilotExecutionContext { copilotResourceAdmission?: CopilotResourceAdmission } -type CopilotResourcePrincipal = Extract +type CopilotResourcePrincipal = Extract const RESOURCE_ADMISSION = Symbol('copilot-resource-admission') @@ -160,7 +160,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), @@ -198,7 +198,7 @@ export class InteractiveCopilotExecutionRequiredError extends Error { } export type CopilotResourceScope = Pick< - NonNullable, + NonNullable, 'fileId' | 'tableId' | 'credentialId' | 'mcpServerId' > @@ -237,7 +237,7 @@ export function createCopilotChatPrincipal( context: CopilotChatDelegationContext, audience: string, resourceScope?: CopilotResourceScope -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { return createTrustedCopilotPrincipal( { ...context, delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}` }, { audience, ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, resourceScope } @@ -293,7 +293,7 @@ export function requireInteractiveCopilotExecutionContext( export function createTrustedCopilotPrincipal( input: CreateTrustedCopilotPrincipalInput, options: CreateTrustedCopilotPrincipalOptions -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { requireNonEmpty(input.userId, 'an authenticated user ID') requireNonEmpty(input.workspaceId, 'a workspace ID') requireNonEmpty(input.delegationId, 'a delegation ID') @@ -348,7 +348,7 @@ export function createTrustedCopilotPrincipal( export function createCopilotApplicationPrincipal( trustedContext: TrustedCopilotExecutionContext, options: CreateCopilotApplicationPrincipalOptions -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { const delegationId = options.createDelegationId(trustedContext) return createTrustedCopilotPrincipal( { 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/chat/application/workspace-context.ts b/apps/sim/lib/mothership/chat/application/workspace-context.ts index 035b27a48e6..7502f2f279f 100644 --- a/apps/sim/lib/mothership/chat/application/workspace-context.ts +++ b/apps/sim/lib/mothership/chat/application/workspace-context.ts @@ -1,4 +1,7 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} 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,7 +34,7 @@ export const readWorkspaceContext = defineAuthorizedChatUseCase({ async resolveContext({ principal, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: { workspaceId: string } }) { const chatId = diff --git a/apps/sim/lib/mothership/integrations/application/catalog.ts b/apps/sim/lib/mothership/integrations/application/catalog.ts index 491c86a6407..92734ee166c 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.ts @@ -1,10 +1,11 @@ import { - type DelegatedPrincipal, + isWorkspaceDelegation, type OrganizationDelegatedPrincipal, type Principal, parsePrincipal, resolvePrincipalExecutionActorUserId, resolvePrincipalSubjectUserId, + type WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata' import { stripVersionSuffix } from '@sim/utils/string' @@ -142,7 +143,7 @@ const catalogUseCase = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: IntegrationCatalogRequest }) { if (principal.kind === 'organization_delegated') { @@ -280,7 +281,7 @@ async function catalogPrincipal( return principal } if ( - principal.kind !== 'delegated' || + !isWorkspaceDelegation(principal) || principal.serviceId !== 'copilot' || principal.audience !== INTEGRATION_CATALOG_AUDIENCE || principal.expiresAt.getTime() <= Date.now() || diff --git a/apps/sim/lib/mothership/memories/application/resolve-access.ts b/apps/sim/lib/mothership/memories/application/resolve-access.ts index a52ba63bc29..acef10e65ba 100644 --- a/apps/sim/lib/mothership/memories/application/resolve-access.ts +++ b/apps/sim/lib/mothership/memories/application/resolve-access.ts @@ -1,4 +1,7 @@ -import type { OrganizationDelegatedPrincipal, SubjectDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceSubjectDelegation, +} 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 +49,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 diff --git a/apps/sim/lib/mothership/memory/application/read-scope.ts b/apps/sim/lib/mothership/memory/application/read-scope.ts index 57cd3791f82..7953bcb6719 100644 --- a/apps/sim/lib/mothership/memory/application/read-scope.ts +++ b/apps/sim/lib/mothership/memory/application/read-scope.ts @@ -1,4 +1,7 @@ -import type { DelegatedPrincipal, OrganizationDelegatedPrincipal } from '@sim/auth/principal' +import type { + OrganizationDelegatedPrincipal, + WorkspaceDelegatedPrincipal, +} 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 +38,7 @@ export const readMemoryScope = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: MemoryScopeRequest }) => resolveOwnedChatContext(principal, input.chatId), authorizationOptions: { diff --git a/apps/sim/lib/mothership/request/application/read-control.ts b/apps/sim/lib/mothership/request/application/read-control.ts index 44b909bd767..d9ec0eda96b 100644 --- a/apps/sim/lib/mothership/request/application/read-control.ts +++ b/apps/sim/lib/mothership/request/application/read-control.ts @@ -1,7 +1,7 @@ import type { - DelegatedPrincipal, OrganizationDelegatedPrincipal, Principal, + WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { type AuthorizingUseCase, @@ -47,7 +47,7 @@ const readLiveRunControl = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: RunControlRequest }) => resolveOwnedChatContext(principal, input.chatId), authorizationOptions: { diff --git a/apps/sim/lib/mothership/request/application/recover-stream.ts b/apps/sim/lib/mothership/request/application/recover-stream.ts index 62125711caf..9f3756cff48 100644 --- a/apps/sim/lib/mothership/request/application/recover-stream.ts +++ b/apps/sim/lib/mothership/request/application/recover-stream.ts @@ -1,9 +1,9 @@ import { - type DelegatedPrincipal, type OrganizationDelegatedPrincipal, type Principal, resolvePrincipalSubjectUserId, type SessionPrincipal, + type WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' @@ -129,7 +129,7 @@ export const takeOverStreamRun = defineAuthorizedChatUseCase({ principal, input, }: { - principal: DelegatedPrincipal | OrganizationDelegatedPrincipal + principal: WorkspaceDelegatedPrincipal | OrganizationDelegatedPrincipal input: { streamId: string } }) => resolveStreamRunContext({ principal, input }), authorizationOptions: { 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 67b8f649dc0..e6f7e4431e1 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts @@ -384,7 +384,7 @@ 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' }, 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..debc7ea4b68 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,7 +53,7 @@ const organizationPrincipal: OrganizationDelegatedPrincipal = { kind: 'organization_delegated', organizationId: 'trusted-org', } -const workspacePrincipal: DelegatedPrincipal = { +const workspacePrincipal: WorkspaceDelegatedPrincipal = { ...base, kind: 'delegated', workspaceId: 'trusted-workspace', diff --git a/apps/sim/lib/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index db86b35f544..69241d57208 100644 --- a/apps/sim/lib/oauth/token-resolution.ts +++ b/apps/sim/lib/oauth/token-resolution.ts @@ -1,5 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type DelegatedPrincipal, resolvePrincipalSubject } from '@sim/auth/principal' +import { resolvePrincipalSubject, type WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { @@ -388,7 +388,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 } /** @@ -432,7 +432,7 @@ export async function resolveCredentialAccessToken( } } - let principal: DelegatedPrincipal + let principal: WorkspaceDelegatedPrincipal try { principal = await input.resolveManagedPrincipal(resolved.credentialId) } catch (error) { diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts index 5887c6dd848..bfd23bd330a 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 { ResourceScopedDelegation } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, @@ -161,9 +161,9 @@ 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(), @@ -1582,7 +1582,7 @@ describe('Project discovery delegation', () => { userId: first.ownerId, permissionType: 'admin', }) - const principal: ResourceDelegatedPrincipal = { + const principal: ResourceScopedDelegation = { ...discoveryPrincipal(first.ownerId, first.ids[0]), serviceId: 'copilot', invocation: { kind: 'chat', chatId }, diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts index f91e22c75a5..d7a7b98d8ff 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -1,6 +1,7 @@ import { + isResourceScopedDelegation, type Principal, - type ResourceDelegatedPrincipal, + type ResourceScopedDelegation, 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 | ResourceScopedDelegation, + { 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' && !isResourceScopedDelegation(principal)) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + if (isResourceScopedDelegation(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 | ResourceScopedDelegation, 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 | ResourceScopedDelegation, 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 | ResourceScopedDelegation, 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..53248843e6a 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 { ResourceScopedDelegation } 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: ResourceScopedDelegation, 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..b7ccc80c5be 100644 --- a/apps/sim/lib/projects/application/operations.ts +++ b/apps/sim/lib/projects/application/operations.ts @@ -2,7 +2,7 @@ import type { ApplicationOperation } from '@/lib/core/application/operation' import { 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,10 +11,12 @@ 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) @@ -22,7 +24,7 @@ function defineProjectOperation(operation: O): throw new Error('Resource delegation is available only for bounded Project discovery') 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,7 +36,7 @@ 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', delegationAudience: 'sim:projects:discovery', diff --git a/apps/sim/lib/projects/application/use-cases.ts b/apps/sim/lib/projects/application/use-cases.ts index a073ac43ae2..94bbdd1fd6b 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 { isResourceScopedDelegation, 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 = isResourceScopedDelegation(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 (!isResourceScopedDelegation(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..0a19e5e17ea 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, + ResourceScopedDelegation, } from '@sim/auth/principal' import { db } from '@sim/db' import { @@ -159,9 +159,9 @@ function delegated( userId: string, projectId: string, workspaceId: string -): Extract { +): Extract { return { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: userId, audience: 'sim:project-files', @@ -262,8 +262,8 @@ describe('compound copy owner authority', () => { code: 'forbidden', }) } - const realtime: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const realtime: ResourceScopedDelegation = { + kind: 'delegated', serviceId: 'realtime', subjectUserId: f.readerId, audience: 'sim:files:copy', diff --git a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts index ad6bf49b792..08f0c0423d4 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, ResourceScopedDelegation } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, @@ -205,9 +205,9 @@ function delegated( userId: string, projectId: string, workspaceId: string -): Extract { +): Extract { return { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: userId, audience: 'sim:project-files', @@ -953,7 +953,7 @@ describe('Project file authority at the database boundary', () => { config: { conversationMode: 'agent' }, title: 'Project context', }) - const principal: ResourceDelegatedPrincipal = { + const principal: ResourceScopedDelegation = { ...delegated(f.readerId, f.projectId, f.workspaces[0]), serviceId: 'copilot', invocation: { kind: 'chat', chatId }, @@ -1100,8 +1100,8 @@ describe('Project file authority at the database boundary', () => { contentType: 'text/markdown', sizeBytes: 0, }) - const principal: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const principal: ResourceScopedDelegation = { + kind: 'delegated', serviceId: 'realtime', subjectUserId: f.readerId, delegationId: generateId(), 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..a8bc77dfd5a 100644 --- a/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts @@ -593,7 +593,7 @@ 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', @@ -638,7 +638,7 @@ 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', 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..19098aec27d 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 { ResourceScopedDelegation } from '@sim/auth/principal' import { db } from '@sim/db' import { fileSearchDispatchQueue, @@ -204,8 +204,8 @@ describe('Project delivery admission', () => { }, }) const issuedAt = new Date() - const principal: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const principal: ResourceScopedDelegation = { + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, delegationId: generateId(), diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts index ba3d3c89ccc..cf16e3327f4 100644 --- a/apps/sim/lib/projects/files/__integration__/content.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -1598,7 +1598,7 @@ describe('Project Copilot content admission', () => { }, }) const principal: Principal = { - kind: 'resource_delegated', + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, audience: 'sim:project-files', diff --git a/apps/sim/lib/projects/files/__integration__/documents.integration.ts b/apps/sim/lib/projects/files/__integration__/documents.integration.ts index 5bcfc09569d..07c4860ed2b 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 { ResourceScopedDelegation } from '@sim/auth/principal' import { db } from '@sim/db' import { member, @@ -137,8 +137,8 @@ async function fixture(permissionType: 'read' | 'write' = 'write') { }, }) const target = { projectId: ids.projectId, fileId: created.file.id } - const principal: ResourceDelegatedPrincipal = { - kind: 'resource_delegated', + const principal: ResourceScopedDelegation = { + kind: 'delegated', serviceId: 'realtime', subjectUserId: editorId, audience: 'sim:project-files', @@ -375,7 +375,7 @@ describe('Project collaborative documents against current policy and durable sto }, }) const target = { projectId: f.projectId, fileId: created.file.id } - const principal: ResourceDelegatedPrincipal = { + const principal: ResourceScopedDelegation = { ...f.principal, scope: { kind: 'entity', diff --git a/apps/sim/lib/projects/files/__integration__/uploads.integration.ts b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts index 99757c40fc1..2c4f7d43a34 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 { ResourceScopedDelegation } from '@sim/auth/principal' import { db } from '@sim/db' import { folder, @@ -466,8 +466,8 @@ 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: ResourceScopedDelegation = { + kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, delegationId: generateId(), diff --git a/apps/sim/lib/projects/files/application/authorization.ts b/apps/sim/lib/projects/files/application/authorization.ts index fb6c8a7c6b6..ee7f0dc12d3 100644 --- a/apps/sim/lib/projects/files/application/authorization.ts +++ b/apps/sim/lib/projects/files/application/authorization.ts @@ -1,8 +1,9 @@ import { + isResourceScopedDelegation, 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' && !isResourceScopedDelegation(principal)) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + if (isResourceScopedDelegation(principal)) { requireResourceDelegation(principal, { audience: operation.delegationAudience, services: operation.delegatedServices, @@ -101,7 +104,7 @@ function requirePrincipal( export async function requireCurrentCopilotProjectInvocation( tx: DbTransaction, principal: Pick< - Extract, + Extract, 'subjectUserId' | 'invocation' >, access: Awaited>, @@ -197,7 +200,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 +231,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 +267,7 @@ export async function createProjectFileAuthorizer( await requireProjectApiEnabled() const userId = await requireFileSubject(principal) const invocationScope = - principal.kind === 'resource_delegated' && principal.serviceId === 'copilot' + isResourceScopedDelegation(principal) && principal.serviceId === 'copilot' ? await resolveCopilotProjectScope(principal) : undefined @@ -300,7 +303,7 @@ export async function createProjectFileAuthorizer( } if (access.record.organizationId) await acquirePermissionGroupOrgReadLock(tx, access.record.organizationId) - if (principal.kind === 'resource_delegated' && principal.serviceId === 'copilot') { + if (isResourceScopedDelegation(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..8016abb4de4 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 { + isResourceScopedDelegation, + 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' + (isResourceScopedDelegation(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..845e3c93cac 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 { + isResourceScopedDelegation, + 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' + (isResourceScopedDelegation(principal) ? { status: 'unknown' } : { status: 'exact', entries: [] }) ), @@ -462,7 +466,7 @@ export const exportProjectFileSnapshot = defineAuthorizedProjectFileUseCase< const secretProvenance = mergeWorkspaceFileSecretProvenance( evidence, input.secretProvenance ?? - (principal.kind === 'resource_delegated' + (isResourceScopedDelegation(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..b8306ddcdcd 100644 --- a/apps/sim/lib/projects/files/application/operations.ts +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -1,7 +1,7 @@ import type { Principal, - ResourceDelegatedPrincipal, ResourceDelegationScope, + ResourceScopedDelegation, } from '@sim/auth/principal' import { type ApplicationOperation, @@ -9,10 +9,9 @@ import { 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 + | ResourceScopedDelegation interface ProjectFilePolicy extends ApplicationOperation { readonly access: 'read' | 'write' @@ -25,7 +24,7 @@ interface ProjectFilePolicy extends ApplicationOperation { */ readonly chargesStorage?: true readonly principalKinds: readonly ProjectFilePrincipal['kind'][] - readonly delegatedServices: readonly ResourceDelegatedPrincipal['serviceId'][] + readonly delegatedServices: readonly ResourceScopedDelegation['serviceId'][] readonly delegationAudience: 'sim:project-files' | 'sim:file-list-observation' } @@ -44,7 +43,9 @@ export function projectFileDelegationScope(target: { } } -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') ) { @@ -68,15 +69,10 @@ function defineProjectFileOperation(operation } 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 const POLICY = { principalKinds: PRINCIPALS, delegationAudience: 'sim:project-files', @@ -95,7 +91,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..6ccf0dc8943 100644 --- a/apps/sim/lib/settings/application/context.ts +++ b/apps/sim/lib/settings/application/context.ts @@ -1,4 +1,4 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' import { resolveActiveWorkspaceApplicationContext } from '@/lib/workspaces/application/context' @@ -18,7 +18,7 @@ export const readSettingsWorkspaceContext = defineAuthorizedWorkspaceUseCase({ resolveContext: ({ principal, }: { - principal: DelegatedPrincipal + principal: WorkspaceDelegatedPrincipal input: Record }) => resolveActiveWorkspaceApplicationContext(principal.workspaceId), authorizationOptions: { delegation: { audience: 'sim:settings', isWithinScope: () => true } }, 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..8be24be3a76 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 { + isResourceScopedDelegation, + 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,7 +17,8 @@ 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 (!isResourceScopedDelegation(principal)) break requireResourceDelegation(principal, { audience: 'sim:project-files', services: ['copilot'], @@ -22,7 +27,7 @@ function credential(principal: Principal, projectId: string) { }) 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..fd4113702e8 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,5 +1,6 @@ import { type BoundWorkflowExecutionDelegatedPrincipal, + isResourceScopedDelegation, isUserCredentialPrincipal, type Principal, requirePrincipalSubjectUserId, @@ -369,7 +370,7 @@ export async function createUploadSession( metadata[PROJECT_FILE_UPLOAD_PROVENANCE_KEY] = bindProjectFileUploadProvenance( params.projectId, params.secretProvenance ?? - (params.principal.kind === 'resource_delegated' + (isResourceScopedDelegation(params.principal) ? { status: 'unknown' } : { status: 'exact', entries: [] }) ) @@ -627,11 +628,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') @@ -667,6 +663,11 @@ export function createUploadSessionAuthBinding( principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, } case 'delegated': { + if (isResourceScopedDelegation(principal)) + throw new UploadSessionError( + 'forbidden', + 'Resource delegation cannot create workspace uploads' + ) if (principal.serviceId === 'copilot') { if ( principal.workspaceId !== workspaceId || diff --git a/apps/sim/lib/users/application/preferences-authorization.ts b/apps/sim/lib/users/application/preferences-authorization.ts index ef1b2750560..44f156e7d1c 100644 --- a/apps/sim/lib/users/application/preferences-authorization.ts +++ b/apps/sim/lib/users/application/preferences-authorization.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import { isWorkspaceDelegation } 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 +19,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' diff --git a/apps/sim/lib/users/application/preferences.test.ts b/apps/sim/lib/users/application/preferences.test.ts index b7d60abfd9f..7a967522b1d 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,7 +14,7 @@ import { updateCurrentUserPreferences, } from '@/lib/users/application/preferences' -const delegated: SubjectDelegatedPrincipal = { +const delegated: WorkspaceSubjectDelegation = { kind: 'delegated', serviceId: 'copilot', subjectUserId: 'actor', diff --git a/apps/sim/lib/workflows/application/authorization.test.ts b/apps/sim/lib/workflows/application/authorization.test.ts index 6a1a630efb2..b3cfcc130e6 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 { WorkspaceDelegatedPrincipal } 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 = {} +): WorkspaceDelegatedPrincipal { return { kind: 'delegated', serviceId: 'executor', 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..a39037135e0 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 = [], 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.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 3a1fc488114..7d563989068 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 { + isResourceScopedDelegation, + 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' || + isResourceScopedDelegation(params.principal) || params.principal.kind === 'organization_delegated' || params.principal.kind === 'slack_app' || params.principal.kind === 'slack_installation' || diff --git a/apps/sim/lib/workspace-files/application/copy-authorization.ts b/apps/sim/lib/workspace-files/application/copy-authorization.ts index a7a185bb07b..a522629862d 100644 --- a/apps/sim/lib/workspace-files/application/copy-authorization.ts +++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts @@ -1,4 +1,9 @@ -import type { Principal, ResourceFileCopyScope } from '@sim/auth/principal' +import type { + Principal, + ResourceFileCopyScope, + ResourceScopedDelegation, +} from '@sim/auth/principal' +import { isResourceScopedDelegation } from '@sim/auth/principal' import { workspace } from '@sim/db/schema' import { compareStrings } from '@sim/utils/string' import { and, asc, inArray, isNull } from 'drizzle-orm' @@ -44,7 +49,7 @@ export type CopyFileItemsInput = Omit type CopyPrincipal = | Extract - | Extract + | Extract type ProjectAccess = Awaited> @@ -91,7 +96,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 (isResourceScopedDelegation(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 +130,8 @@ function requireCopyPrincipal( if (!fileCopyOperation.principalKinds.some((kind) => kind === principal.kind)) { throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) } + if (principal.kind === 'delegated' && !isResourceScopedDelegation(principal)) + throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) const scope = { kind: 'file_copy', ...input } if (!isResourceFileCopyScope(scope)) { throw new OrchestrationError( @@ -133,7 +140,7 @@ function requireCopyPrincipal( ) } requireOAuthOperationScope(principal, fileCopyOperation) - if (principal.kind === 'resource_delegated') { + if (isResourceScopedDelegation(principal)) { requireResourceDelegation(principal, { audience: fileCopyOperation.delegationAudience, services: fileCopyOperation.delegatedServices, @@ -152,10 +159,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 = isResourceScopedDelegation(principal) + ? await resolveCopilotProjectScope(principal) + : undefined return async (tx) => { requireCopyPrincipal(principal, input) @@ -221,7 +227,7 @@ export async function createFileCopyAuthorizer( for (const organizationId of organizationIds) await acquirePermissionGroupOrgLock(tx, organizationId) await requireCurrentFileSubject(tx, principal) - if (principal.kind === 'resource_delegated') { + if (isResourceScopedDelegation(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..56736de275b 100644 --- a/apps/sim/lib/workspace-files/application/copy-operation.ts +++ b/apps/sim/lib/workspace-files/application/copy-operation.ts @@ -9,8 +9,9 @@ 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', } as const diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.ts b/apps/sim/lib/workspace-files/application/delegated-principal.ts index 664fde027d0..32d7ef8d7cc 100644 --- a/apps/sim/lib/workspace-files/application/delegated-principal.ts +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -1,10 +1,10 @@ -import type { DelegatedPrincipal } from '@sim/auth/principal' +import type { WorkspaceDelegatedPrincipal } 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,7 +16,7 @@ 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') } @@ -39,7 +39,7 @@ export function createWorkspaceFileDelegatedPrincipal( } export interface RebindWorkspaceFileDelegationInput { - principal: DelegatedPrincipal + principal: WorkspaceDelegatedPrincipal workspaceId: string delegationId: string fileId?: string @@ -50,7 +50,7 @@ export interface RebindWorkspaceFileDelegationInput { /** Rebinds an already-authorized service principal without changing its workflow actor. */ export function rebindWorkspaceFileDelegatedPrincipal( input: RebindWorkspaceFileDelegationInput -): DelegatedPrincipal { +): WorkspaceDelegatedPrincipal { if (input.principal.workspaceId !== input.workspaceId || !input.delegationId) { throw new Error('Workspace file delegation does not match its authorized workspace') } 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/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index ccd1973e3a9..bb646d96789 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -4,7 +4,6 @@ export type Principal = | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal | DelegatedPrincipal - | ResourceDelegatedPrincipal | OrganizationDelegatedPrincipal | SystemPrincipal | CredentialGroupEnrollmentPrincipal @@ -129,11 +128,16 @@ type SystemPrincipal = ActorlessSystemPrincipal | ChatSystemPrincipal | WebhookS interface DelegatedPrincipalBase { kind: 'delegated' - workspaceId: string delegationId: string audience: string issuedAt: Date expiresAt: Date +} + +interface WorkspaceDelegationFields { + workspaceId: string + scope?: never + invocation?: never resourceScope?: { fileId?: string tableId?: string @@ -146,7 +150,9 @@ interface DelegatedPrincipalBase { } } -export interface SubjectDelegatedPrincipal extends DelegatedPrincipalBase { +export interface WorkspaceSubjectDelegation + extends DelegatedPrincipalBase, + WorkspaceDelegationFields { serviceId: 'copilot' | 'realtime' subjectUserId: string } @@ -173,7 +179,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,6 +191,12 @@ export type BoundWorkflowExecutionDelegatedPrincipal = WorkflowExecutionDelegate delegationContext: WorkflowExecutionDelegationContext } +export type SubjectDelegatedPrincipal = WorkspaceSubjectDelegation | ResourceScopedDelegation + +export type WorkspaceDelegatedPrincipal = + | WorkspaceSubjectDelegation + | WorkflowExecutionDelegatedPrincipal + export type DelegatedPrincipal = SubjectDelegatedPrincipal | WorkflowExecutionDelegatedPrincipal export type ResourceEntityType = 'workspace' | 'project' | 'organization' | 'user' @@ -222,18 +236,12 @@ export type ResourceDelegationScope = | ResourceFileCopyScope | ResourceFileCollectionObservationScope -interface ResourceDelegatedPrincipalBase { - kind: 'resource_delegated' - 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 & - ( +export type ResourceScopedDelegation = DelegatedPrincipalBase & { + subjectUserId: string + workspaceId?: never + resourceScope?: never +} & ( | { serviceId: 'copilot' scope: ResourceDelegationScope @@ -246,6 +254,30 @@ export type ResourceDelegatedPrincipal = ResourceDelegatedPrincipalBase & } ) +/** Identifies the resource grant family; domain authorization validates its contents. */ +export function isResourceScopedDelegation( + principal: Principal +): principal is ResourceScopedDelegation { + return ( + principal.kind === 'delegated' && + principal.serviceId !== 'executor' && + principal.scope !== undefined + ) +} + +/** Keeps resource grants out of workspace and workflow authority, including malformed mixed grants. */ +export function isWorkspaceDelegation( + principal: Principal +): principal is WorkspaceDelegatedPrincipal { + return ( + principal.kind === 'delegated' && + principal.scope === undefined && + principal.invocation === undefined && + typeof principal.workspaceId === 'string' && + principal.workspaceId.length > 0 + ) +} + /** Search-only authority delegated by a current organization member. */ interface OrganizationDelegatedPrincipalBase { kind: 'organization_delegated' @@ -346,7 +378,7 @@ export type WorkflowExecutionPrincipal = | PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal - | SubjectDelegatedPrincipal + | WorkspaceSubjectDelegation | SystemPrincipal type SerializedWorkflowExecutionPrincipal = @@ -355,7 +387,7 @@ type SerializedWorkflowExecutionPrincipal = | (Omit & { expiresAt: string }) | WorkspaceApiKeyPrincipal | SystemPrincipal - | (Omit & { + | (Omit & { issuedAt: string expiresAt: string }) @@ -402,7 +434,7 @@ function requireDate(value: unknown, field: string): Date { return date } -function parseResourceScope(value: unknown): DelegatedPrincipal['resourceScope'] { +function parseResourceScope(value: unknown): WorkspaceDelegatedPrincipal['resourceScope'] { const scope = requireRecord(value, 'Serialized principal resourceScope') const keys = [ 'fileId', @@ -415,7 +447,7 @@ function parseResourceScope(value: unknown): DelegatedPrincipal['resourceScope'] 'mcpBlockId', ] as const requireExactKeys(scope, [], keys) - const parsed: NonNullable = {} + const parsed: NonNullable = {} for (const key of keys) { if (scope[key] !== undefined) parsed[key] = requireString(scope[key], key) } @@ -475,6 +507,8 @@ 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: { @@ -616,7 +650,7 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { } return { kind, - serviceId: serviceId as SubjectDelegatedPrincipal['serviceId'], + serviceId: serviceId as WorkspaceSubjectDelegation['serviceId'], subjectUserId: requireString(principal.subjectUserId, 'subjectUserId'), workspaceId: requireString(principal.workspaceId, 'workspaceId'), delegationId: requireString(principal.delegationId, 'delegationId'), @@ -641,7 +675,7 @@ export type PrincipalActor = | Omit | { kind: 'resource_delegated' - serviceId: ResourceDelegatedPrincipal['serviceId'] + serviceId: ResourceScopedDelegation['serviceId'] subjectUserId: string delegationId: string } @@ -722,7 +756,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 +825,19 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { : {}), } case 'delegated': + if (isResourceScopedDelegation(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 +873,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..72224103c7a 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,8 +57,8 @@ export function createWorkspaceApiKeyPrincipal( * ``` */ export function createDelegatedPrincipal( - overrides: Partial = {} -): SubjectDelegatedPrincipal { + overrides: Partial = {} +): WorkspaceSubjectDelegation { return { kind: 'delegated', serviceId: 'copilot', 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': From aed367e14fe59618d5e10937345948c3b8e80e5e Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 16 Oct 2026 19:56:31 -0700 Subject: [PATCH 2/5] refactor(auth): unify delegated grants around entity owners --- apps/sim/app/api/files/uploads/purposes.ts | 12 +- apps/sim/app/api/table/route.ts | 8 +- .../deployments/chat/route.test.ts | 10 +- .../application/authorized-fork-use-case.ts | 17 +- apps/sim/executor/execution/snapshot.test.ts | 6 +- .../lib/api-key/application/create-api-key.ts | 5 +- .../copilot-project-file-request.test.ts | 16 +- .../api/server/routes/copilot-request.test.ts | 21 +- .../lib/api/server/routes/copilot-request.ts | 50 ++--- .../api/server/routes/internal-json-route.ts | 4 +- .../authorized-audit-log-use-case.ts | 12 +- .../audit-logs/application/use-cases.test.ts | 16 +- apps/sim/lib/auth/internal-delegation.ts | 4 +- apps/sim/lib/auth/internal.test.ts | 6 +- apps/sim/lib/auth/principal.test.ts | 34 +++- apps/sim/lib/auth/realtime-file-delegation.ts | 6 +- .../lib/auth/realtime-file-list-delegation.ts | 9 +- .../authorized-billing-read-use-case.ts | 16 +- .../billing/application/list-billing-logs.ts | 6 +- .../lib/billing/application/use-cases.test.ts | 6 +- .../lib/core/application/audit-source.test.ts | 6 +- .../authorized-workspace-use-case.test.ts | 9 +- .../core/application/content-delivery.test.ts | 17 +- .../copilot-workspace-invocation.ts | 24 ++- .../application/resource-delegation.test.ts | 84 +++++--- .../core/application/resource-delegation.ts | 54 ++--- .../workspace-authorization.test.ts | 15 +- .../application/workspace-authorization.ts | 5 +- .../application/authorization.test.ts | 8 +- .../application/authorization.ts | 5 +- .../application/manage-groups.test.ts | 29 ++- .../credentials/application/authorization.ts | 10 +- .../copilot-managed-oauth-delegation.test.ts | 8 +- .../application/personal-credentials.test.ts | 5 +- .../resolve-managed-oauth-token.ts | 3 +- .../resolve-workflow-credentials.ts | 3 +- .../application/use-cases.test.ts | 6 +- .../payloads/materialization.server.ts | 14 +- .../application/authorization.ts | 3 +- apps/sim/lib/internal/function/execute.ts | 8 +- apps/sim/lib/internal/knowledge/operations.ts | 29 +-- apps/sim/lib/internal/logs/operations.ts | 11 +- .../sim/lib/internal/mcp/execute-tool.test.ts | 9 +- apps/sim/lib/internal/memory/operations.ts | 9 +- apps/sim/lib/internal/principals/executor.ts | 6 +- apps/sim/lib/internal/table/operations.ts | 29 +-- apps/sim/lib/internal/windchill/operations.ts | 3 +- .../application/manage-invitation.test.ts | 3 +- .../application/manage-invitation.ts | 3 +- .../application/send-invitation-batch.test.ts | 3 +- .../application/send-invitation-batch.ts | 3 +- .../gitlab-workspace.integration.ts | 8 +- .../search-index-policy.integration.ts | 6 +- apps/sim/lib/knowledge/api/internal-route.ts | 3 +- .../application/add-workspace-files.test.ts | 3 +- .../application/authorization.test.ts | 19 +- .../knowledge/application/authorization.ts | 3 +- .../knowledge/application/connectors.test.ts | 8 +- .../knowledge/application/documents.test.ts | 16 +- .../application/knowledge-bases.test.ts | 12 +- .../sim/lib/logs/application/authorization.ts | 5 +- apps/sim/lib/mcp/application/authorization.ts | 9 +- .../lib/mcp/application/operation-access.ts | 3 +- .../agent-cli/engines/lint-scope.test.ts | 13 +- .../agent-cli/file-copy-transport.ts | 8 +- .../file-round-trip-provenance.test.ts | 8 +- .../agent-cli/file-upload-transport.test.ts | 8 +- .../mothership/agent-cli/scoped-transport.ts | 7 +- .../mothership/application/adapter.test.ts | 21 +- .../sim/lib/mothership/application/adapter.ts | 15 +- .../execute-custom-tool-use-case.test.ts | 11 +- .../application/execute-file-use-case.test.ts | 12 +- .../execute-project-file-use-case.ts | 4 +- .../execute-table-use-case.test.ts | 19 +- .../execute-workflow-use-case.test.ts | 19 +- .../application/resource-adapter.test.ts | 29 +-- .../auth/application-delegation.test.ts | 16 +- .../mothership/auth/application-delegation.ts | 33 ++-- .../mothership/auth/file-delegation.test.ts | 23 ++- .../chat/application/inline-images.ts | 5 +- .../chat/application/read-attachment.ts | 4 +- .../chat/application/read-sandbox-file.ts | 5 +- .../mothership/chat/application/use-cases.ts | 10 +- .../chat/application/workspace-context.ts | 13 +- .../chat/application/workspace-target.ts | 4 +- apps/sim/lib/mothership/chat/lifecycle.ts | 9 +- .../mothership/chat/process-contents.test.ts | 85 ++++++-- .../content-delivery/dispatch.test.ts | 3 +- .../integrations/application/catalog.ts | 14 +- .../memories/application/resolve-access.ts | 13 +- .../memory/application/read-scope.ts | 5 +- .../request/application/read-control.ts | 8 +- .../request/application/recover-stream.ts | 6 +- .../lib/mothership/request/go/stream.test.ts | 10 +- .../mothership/tasks/application/context.ts | 6 +- .../function-execute-table-mounts.test.ts | 14 +- .../tools/sandbox-resource-transport.test.ts | 4 +- .../tools/sandbox-resource-transport.ts | 18 +- .../settings-management-boundary.test.ts | 10 +- apps/sim/lib/oauth/token-resolution.ts | 10 +- .../__integration__/foundation.integration.ts | 15 +- .../lib/projects/application/authorization.ts | 16 +- .../sim/lib/projects/application/discovery.ts | 4 +- .../sim/lib/projects/application/use-cases.ts | 6 +- .../authorization-bulk.integration.ts | 25 ++- .../authorization.integration.ts | 28 ++- .../content-bulk.integration.ts | 8 +- .../content-rendered.integration.ts | 11 +- .../__integration__/content.integration.ts | 7 +- .../__integration__/documents.integration.ts | 20 +- .../__integration__/uploads.integration.ts | 8 +- .../files/application/authorization.ts | 13 +- .../lib/projects/files/application/content.ts | 4 +- .../projects/files/application/downloads.ts | 6 +- .../projects/files/application/operations.ts | 9 +- apps/sim/lib/settings/application/context.ts | 3 +- .../table/application/authorization.test.ts | 19 +- .../lib/table/application/authorization.ts | 9 +- .../upload-session/project-file-binding.ts | 6 +- .../sim/lib/uploads/upload-session/service.ts | 29 +-- .../application/preferences-authorization.ts | 10 +- .../lib/users/application/preferences.test.ts | 8 +- apps/sim/lib/webhooks/execution-principal.ts | 3 +- .../apply-workflow-operations.test.ts | 3 +- .../application/authorization.test.ts | 6 +- .../workflows/application/authorization.ts | 8 +- .../custom-block-authoring.test.ts | 6 +- .../workflows/application/deployments.test.ts | 6 +- .../download-workflow-run-file.test.ts | 3 +- .../execute-manual-workflow.test.ts | 18 +- .../application/execute-workflow.test.ts | 6 +- .../lib/workflows/application/folders.test.ts | 3 +- .../inspect-workflow-tools.test.ts | 5 +- .../application/move-workflows-bulk.test.ts | 3 +- .../read-paused-workflow-execution.test.ts | 3 +- .../replace-workflow-state.test.ts | 6 +- .../resolve-workflow-outputs.test.ts | 3 +- .../workflows/application/run-control.test.ts | 6 +- .../run-workflow-from-copilot.test.ts | 3 +- .../application/run-workflow-from-copilot.ts | 4 +- .../lib/workflows/application/runs.test.ts | 3 +- .../update-workflow-content.test.ts | 3 +- .../application/settings.test.ts | 3 +- .../workflows/references/binding-targets.ts | 8 +- .../application/authorization.test.ts | 18 +- .../application/authorization.ts | 5 +- .../application/copy-authorization.ts | 16 +- .../application/delegated-principal.test.ts | 6 +- .../application/delegated-principal.ts | 36 ++-- ...ead-stored-workspace-file-record-by-key.ts | 5 +- .../resolve-workspace-file-reference.ts | 3 +- apps/sim/lib/workspace-files/ownership.ts | 12 +- apps/sim/tools/index.test.ts | 15 +- packages/auth/src/principal.ts | 184 +++++++++++++----- .../src/factories/principal.factory.ts | 16 +- 155 files changed, 1278 insertions(+), 699 deletions(-) diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index f31f78877a0..bfd97cd4164 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -1,5 +1,9 @@ import type { Principal } from '@sim/auth/principal' -import { isWorkspaceDelegation } 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' @@ -259,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') } { @@ -277,10 +281,10 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom ) 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/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 36dc5c7d371..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 @@ -50,15 +50,23 @@ describe('private v2 Project file authority', () => { expect(principal).toMatchObject({ 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 c5e6ea16868..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 { ResourceScopedDelegation, WorkspaceDelegatedPrincipal } 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 -) => ResourceScopedDelegation | 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 -): WorkspaceDelegatedPrincipal | ResourceScopedDelegation | 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 cd5020e0741..d9abcce376d 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -4,7 +4,7 @@ import { resolvePrincipalSubjectUserId, type SessionPrincipal, type WorkflowExecutionDelegatedPrincipal, - type WorkspaceDelegatedPrincipal, + 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 - ): WorkspaceDelegatedPrincipal['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/internal-delegation.ts b/apps/sim/lib/auth/internal-delegation.ts index cdcce014cf7..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, - WorkspaceDelegatedPrincipal, + 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?: WorkspaceDelegatedPrincipal['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..3a654bec404 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -56,11 +56,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') }) @@ -213,12 +217,16 @@ describe('principal persistence', () => { 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 +260,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 +450,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 +504,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 +550,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 c84c32eba4d..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 { ResourceScopedDelegation } 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) @@ -32,7 +32,7 @@ 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) @@ -29,7 +29,10 @@ 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/authorized-workspace-use-case.test.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.test.ts index 3f59154599f..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 @@ -3,6 +3,7 @@ import type { WorkspaceApiKeyPrincipal, WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' +import { workspaceDelegationRestrictions } from '@sim/auth/principal' import { createPersonalApiKeyPrincipal, createSessionPrincipal, @@ -318,7 +319,7 @@ describe('defineAuthorizedWorkspaceUseCase', () => { it('resolves domain-specific delegation options against canonical context', async () => { const scopeCheck = vi.fn( (principal: WorkspaceDelegatedPrincipal, context: TestContext) => - principal.resourceScope?.fileId === context.canonicalResourceId + workspaceDelegationRestrictions(principal)?.fileId === context.canonicalResourceId ) const useCase = defineAuthorizedWorkspaceUseCase({ operation: delegatedOperation, @@ -387,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 89f94d3b9cf..dc79a184a0c 100644 --- a/apps/sim/lib/core/application/content-delivery.test.ts +++ b/apps/sim/lib/core/application/content-delivery.test.ts @@ -26,16 +26,21 @@ 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') @@ -47,8 +52,8 @@ const PROJECT_CHAT: Principal = { 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/copilot-workspace-invocation.ts b/apps/sim/lib/core/application/copilot-workspace-invocation.ts index 7d00b9f9293..bfceeae4c9c 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.ts @@ -1,5 +1,9 @@ import type { Principal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' -import { isWorkspaceDelegation } 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' @@ -10,7 +14,7 @@ export function markCopilotWorkspaceInvocation(principal: WorkspaceDelegatedPrin if ( principal.serviceId !== 'copilot' || !principal.subjectUserId || - !principal.workspaceId || + !principalWorkspaceId(principal) || principal.expiresAt <= new Date() ) { throw new Error( @@ -39,9 +43,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: WorkspaceDelegatedPrincipal['resourceScope'], + granted: ReturnType, target: NestedResourceScope -): NonNullable { +): NonNullable> { const scope = { ...granted } for (const key of NESTED_RESOURCE_SCOPE_KEYS) { const value = target[key] @@ -73,7 +77,7 @@ export function bindCopilotWorkspaceOperation

( if ( !isWorkspaceDelegation(principal) || !isCopilotWorkspaceInvocation(principal) || - principal.workspaceId !== workspaceId || + principalWorkspaceId(principal) !== workspaceId || !sourceAudiences.includes(principal.audience) || !useCase.delegationAudience ) { @@ -86,7 +90,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/resource-delegation.test.ts b/apps/sim/lib/core/application/resource-delegation.test.ts index 1eb9395d730..7ebcb723404 100644 --- a/apps/sim/lib/core/application/resource-delegation.test.ts +++ b/apps/sim/lib/core/application/resource-delegation.test.ts @@ -19,14 +19,14 @@ const principal = { 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', () => { @@ -40,7 +40,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') @@ -57,7 +57,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') @@ -85,15 +85,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' } }, + }, + ], + [ + 'missing owner ID', + { + ...principal, + grant: { ...principal.grant, owner: { ...principal.grant.owner, entityId: '' } }, + }, ], - ['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' } }], + ['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( @@ -110,16 +125,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() }) @@ -134,21 +152,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') } @@ -157,15 +175,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, @@ -175,10 +192,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) @@ -188,20 +205,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') @@ -245,7 +265,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( @@ -287,7 +307,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 bdf07e01eee..b62a305777d 100644 --- a/apps/sim/lib/core/application/resource-delegation.ts +++ b/apps/sim/lib/core/application/resource-delegation.ts @@ -2,15 +2,15 @@ import type { Principal, ResourceDelegationScope, ResourceFileCopyScope, - ResourceScopedDelegation, + ResourceOperationPrincipal, } from '@sim/auth/principal' -import { isResourceScopedDelegation } from '@sim/auth/principal' +import { hasResourceOperationGrant } from '@sim/auth/principal' import { isRecordLike } from '@sim/utils/object' import { OrchestrationError } from '@/lib/core/orchestration/types' export interface ResourceDelegationPolicy { audience: string - services: readonly ResourceScopedDelegation['serviceId'][] + services: readonly ResourceOperationPrincipal['serviceId'][] scope: ResourceDelegationScope maxTtlMs: number } @@ -64,32 +64,34 @@ 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 ( + !isRecordLike(scope.owner) || + Object.keys(scope.owner).length !== 2 || + !Object.keys(scope.owner).every((key) => key === 'entityType' || key === 'entityId') || + !isIdentity(scope.owner.entityId) + ) + return false 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)) + scope.owner.entityType === 'project' && + Object.keys(scope).length === 2 && + Object.keys(scope).every((key) => key === 'kind' || key === 'owner') ) } return ( scope.kind === 'entity' && - (scope.entityType === 'workspace' || - scope.entityType === 'project' || - scope.entityType === 'organization' || - scope.entityType === 'user') && - isIdentity(scope.entityId) && + ['workspace', 'project', 'organization', 'user'].includes(String(scope.owner.entityType)) && (scope.fileId === undefined || isIdentity(scope.fileId)) && - Object.keys(scope).every((key) => ['kind', 'entityType', 'entityId', 'fileId'].includes(key)) + Object.keys(scope).every((key) => ['kind', 'owner', 'fileId'].includes(key)) ) } -function isValidInvocation(principal: ResourceScopedDelegation): 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))) ) @@ -98,8 +100,8 @@ function isValidInvocation(principal: ResourceScopedDelegation): 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') ) } @@ -112,8 +114,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') { @@ -134,8 +136,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) ) } @@ -158,9 +160,9 @@ export function requireResourceDelegation( const now = Date.now() if ( - !isResourceScopedDelegation(principal) || - principal.workspaceId !== undefined || - principal.resourceScope !== undefined || + !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) || @@ -172,9 +174,9 @@ export function requireResourceDelegation( principal.issuedAt.getTime() > now || principal.expiresAt.getTime() <= now || principal.expiresAt.getTime() - principal.issuedAt.getTime() > policy.maxTtlMs || - !isValidScope(principal.scope) || + !isValidScope(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 0bdd2749a56..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 { OAuthAccessTokenPrincipal, WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import { createDelegatedPrincipal } from '@sim/testing/factories' import { createPersonalApiKeyPrincipal, createSessionPrincipal, @@ -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 1226e27f90f..ed0ad831fff 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -3,6 +3,7 @@ import { type OAuthAccessTokenPrincipal, type PersonalApiKeyPrincipal, type Principal, + principalWorkspaceId, resolvePrincipalSubject, type WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' @@ -422,7 +423,7 @@ export async function authorizeWorkspaceOperation) => 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 b77bc3e62e3..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,10 +1,8 @@ -import type { - CredentialGroupEnrollmentPrincipal, - WorkspaceDelegatedPrincipal, -} 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 { @@ -71,18 +69,14 @@ const enrollmentPrincipal: CredentialGroupEnrollmentPrincipal = { invitationTokenHash: 'hash-1', } -function copilotPrincipal( - overrides: Partial = {} -): WorkspaceDelegatedPrincipal { - 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', () => { @@ -138,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/personal-credentials.test.ts b/apps/sim/lib/credentials/application/personal-credentials.test.ts index c57ef1b2819..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 { WorkspaceSubjectDelegation } 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 2e5cd577a8e..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 { WorkspaceDelegatedPrincipal } 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' @@ -121,8 +121,8 @@ describe('custom tool application use cases', () => { describe('delegated custom-tool resolution', () => { function executorPrincipal( - overrides: Partial = {} - ): WorkspaceDelegatedPrincipal { + overrides: Partial = {} + ): WorkflowExecutionDelegatedPrincipal { return { kind: 'delegated' as const, serviceId: 'executor' as const, diff --git a/apps/sim/lib/execution/payloads/materialization.server.ts b/apps/sim/lib/execution/payloads/materialization.server.ts index e954eb4b483..d02e626d100 100644 --- a/apps/sim/lib/execution/payloads/materialization.server.ts +++ b/apps/sim/lib/execution/payloads/materialization.server.ts @@ -1,5 +1,5 @@ import type { Principal } from '@sim/auth/principal' -import { isWorkspaceDelegation } 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' @@ -293,11 +293,11 @@ export async function assertUserFileContentAccess( principal: options.principal, workspaceId: options.workspaceId, delegationId: `execution-file-read:${options.requestId ?? 'unknown'}`, - ...(options.principal.resourceScope?.fileId - ? { fileId: options.principal.resourceScope.fileId } + ...(workspaceDelegationRestrictions(options.principal)?.fileId + ? { fileId: workspaceDelegationRestrictions(options.principal)?.fileId } : {}), - ...(options.principal.resourceScope?.chatId - ? { chatId: options.principal.resourceScope.chatId } + ...(workspaceDelegationRestrictions(options.principal)?.chatId + ? { chatId: workspaceDelegationRestrictions(options.principal)?.chatId } : {}), ...(options.executionId ? { executionId: options.executionId } : {}), }) @@ -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 a9bfbbdc556..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 { 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' @@ -13,7 +14,7 @@ export const functionExecutionDelegationPolicy = { 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/internal/function/execute.ts b/apps/sim/lib/internal/function/execute.ts index bee615a5fb8..2b1e528acb8 100644 --- a/apps/sim/lib/internal/function/execute.ts +++ b/apps/sim/lib/internal/function/execute.ts @@ -85,12 +85,16 @@ export async function executeFunctionTool(input: ExecuteFunctionToolInput): Prom 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 9f46c3d6fe1..c1edf5db6d0 100644 --- a/apps/sim/lib/internal/principals/executor.ts +++ b/apps/sim/lib/internal/principals/executor.ts @@ -1,4 +1,4 @@ -import { resolvePrincipalSubject, type WorkspaceDelegatedPrincipal } 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?: WorkspaceDelegatedPrincipal['resourceScope'], + resourceScope?: ReturnType, expiresAt?: Date, compatibilityActorUserId?: string ) { @@ -70,7 +70,7 @@ export async function createExecutorPrincipalFromDelegationOrigin( export interface CreateExecutorPrincipalFromExecutionContextInput { context: InternalToolOperationContext audience: string - resourceScope?: WorkspaceDelegatedPrincipal['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 11ba3f1e85a..1a09a03819d 100644 --- a/apps/sim/lib/knowledge/api/internal-route.ts +++ b/apps/sim/lib/knowledge/api/internal-route.ts @@ -1,6 +1,7 @@ import { isWorkspaceDelegation, type Principal, + principalWorkspaceId, resolvePrincipalSubject, resolvePrincipalSubjectUserId, type SessionPrincipal, @@ -53,7 +54,7 @@ export function internalKnowledgeProvenanceUserId( 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 b5c78c745a1..fc74563a201 100644 --- a/apps/sim/lib/knowledge/application/authorization.test.ts +++ b/apps/sim/lib/knowledge/application/authorization.test.ts @@ -1,25 +1,18 @@ 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 = {} -): WorkspaceDelegatedPrincipal { - 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', () => { @@ -27,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', 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/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/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/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 3f5bdd88f4f..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 { ResourceScopedDelegation } 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 -): ResourceScopedDelegation { +): 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-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/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 c05a4b82ad5..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 { 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' @@ -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 55c19d55627..b8e3964b841 100644 --- a/apps/sim/lib/mothership/application/adapter.ts +++ b/apps/sim/lib/mothership/application/adapter.ts @@ -1,9 +1,10 @@ import type { Principal, ResourceDelegationScope, - ResourceScopedDelegation, + ResourceOperationPrincipal, WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' +import { principalWorkspaceId, workspaceDelegationRestrictions } from '@sim/auth/principal' import { type ApplicationOperation, type OperationUseCase, @@ -43,7 +44,7 @@ interface CopilotApplicationAdapterOptions { +): NonNullable> { return { ...resourceScope, ...(context.chatId ? { chatId: context.chatId } : {}), @@ -112,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 @@ -133,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') } } 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-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 202263c5d16..44822796e53 100644 --- a/apps/sim/lib/mothership/application/resource-adapter.test.ts +++ b/apps/sim/lib/mothership/application/resource-adapter.test.ts @@ -1,5 +1,5 @@ import type { Principal } from '@sim/auth/principal' -import { isResourceScopedDelegation } 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' @@ -59,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: '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, }) }) @@ -79,8 +79,8 @@ describe('Copilot resource authoring admission', () => { ) expect(result).toMatchObject({ kind: 'delegated', - invocation: { kind: 'chat', chatId: 'real-chat' }, - scope: { kind: 'project_discovery' }, + invocation: { kind: 'chat', chatId: 'real-chat' } as const, + grant: { kind: 'project_discovery' } as const, }) expect(result).not.toHaveProperty('workflowId') }) @@ -171,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( @@ -183,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' }, + }, }) }) @@ -233,10 +236,10 @@ describe('Copilot resource authoring admission', () => { scope, }) ).toThrow('Resource delegation is no longer valid') - if (!isResourceScopedDelegation(result) || 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/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 e69c4944485..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 { OrganizationDelegatedPrincipal, ResourceDelegationScope, - ResourceScopedDelegation, - WorkspaceDelegatedPrincipal, + 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) @@ -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 -): WorkspaceDelegatedPrincipal { +): 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 -): WorkspaceDelegatedPrincipal { +): 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 -): WorkspaceDelegatedPrincipal { +): 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/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 7502f2f279f..bb1da1a9762 100644 --- a/apps/sim/lib/mothership/chat/application/workspace-context.ts +++ b/apps/sim/lib/mothership/chat/application/workspace-context.ts @@ -2,6 +2,7 @@ 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' @@ -37,17 +38,19 @@ export const readWorkspaceContext = defineAuthorizedChatUseCase({ 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/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.ts b/apps/sim/lib/mothership/integrations/application/catalog.ts index 92734ee166c..6751e941b13 100644 --- a/apps/sim/lib/mothership/integrations/application/catalog.ts +++ b/apps/sim/lib/mothership/integrations/application/catalog.ts @@ -3,9 +3,11 @@ import { 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' @@ -154,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, } }, @@ -286,17 +288,17 @@ async function catalogPrincipal( 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 acef10e65ba..407b20e6b55 100644 --- a/apps/sim/lib/mothership/memories/application/resolve-access.ts +++ b/apps/sim/lib/mothership/memories/application/resolve-access.ts @@ -2,6 +2,7 @@ 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' @@ -143,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' || @@ -164,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.ts b/apps/sim/lib/mothership/memory/application/read-scope.ts index 7953bcb6719..2b5b0680161 100644 --- a/apps/sim/lib/mothership/memory/application/read-scope.ts +++ b/apps/sim/lib/mothership/memory/application/read-scope.ts @@ -2,6 +2,7 @@ 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' @@ -46,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 d9ec0eda96b..b39b8508d5b 100644 --- a/apps/sim/lib/mothership/request/application/read-control.ts +++ b/apps/sim/lib/mothership/request/application/read-control.ts @@ -3,6 +3,7 @@ import type { Principal, WorkspaceDelegatedPrincipal, } from '@sim/auth/principal' +import { principalWorkspaceId, workspaceDelegationRestrictions } from '@sim/auth/principal' import { type AuthorizingUseCase, DelegatedServiceAuthorizationError, @@ -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 9f3756cff48..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 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' @@ -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 e6f7e4431e1..068820a65c0 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts @@ -386,8 +386,8 @@ it('reconstructs Project authority from the admitted lease without borrowing its expect(principal).toMatchObject({ 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 ea22ac4e8f7..e6450eba1d3 100644 --- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts +++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts @@ -3,7 +3,10 @@ import { generateId } from '@sim/utils/id' import { NextRequest } from 'next/server' import { fileCopyInputSchema } from '@/lib/api/contracts/file-copy-input' 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 { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' @@ -130,11 +133,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/settings-management-boundary.test.ts b/apps/sim/lib/mothership/tools/server/settings-management-boundary.test.ts index debc7ea4b68..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 @@ -53,10 +53,16 @@ const organizationPrincipal: OrganizationDelegatedPrincipal = { kind: 'organization_delegated', organizationId: 'trusted-org', } +const { resourceScope, ...workspaceBase } = base const workspacePrincipal: WorkspaceDelegatedPrincipal = { - ...base, + ...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/oauth/token-resolution.ts b/apps/sim/lib/oauth/token-resolution.ts index 69241d57208..f9e5f1127d2 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 { resolvePrincipalSubject, type WorkspaceDelegatedPrincipal } 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 { @@ -499,9 +503,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 bfd23bd330a..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 { ResourceScopedDelegation } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, @@ -161,7 +161,7 @@ function check(name: string, run: () => Promise) { function discoveryPrincipal( userId: string, workspaceId: string -): Extract { +): Extract { return { kind: 'delegated', serviceId: 'copilot', @@ -170,8 +170,8 @@ function discoveryPrincipal( 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: ResourceScopedDelegation = { + 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 d7a7b98d8ff..a3dc212dd00 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -1,7 +1,7 @@ import { - isResourceScopedDelegation, + hasResourceOperationGrant, type Principal, - type ResourceScopedDelegation, + type ResourceOperationPrincipal, requirePrincipalSubjectUserId, type SessionPrincipal, } from '@sim/auth/principal' @@ -32,14 +32,14 @@ export function requireProjectPrincipal< principal: Principal, operation: O ): asserts principal is Extract< - SessionPrincipal | ResourceScopedDelegation, + SessionPrincipal | ResourceOperationPrincipal, { kind: O['principalKinds'][number] } > { if (!operation.principalKinds.some((kind) => kind === principal.kind)) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) - if (principal.kind === 'delegated' && !isResourceScopedDelegation(principal)) + if (principal.kind === 'delegated' && !hasResourceOperationGrant(principal)) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) - if (isResourceScopedDelegation(principal)) { + if (hasResourceOperationGrant(principal)) { if (operation.id !== 'projects.list' || !operation.delegationAudience) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) requireResourceDelegation(principal, { @@ -225,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 | ResourceScopedDelegation, + principal: SessionPrincipal | ResourceOperationPrincipal, operation: ProjectOperation, record: ProjectRecord, access: ProjectAccess, @@ -283,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 | ResourceScopedDelegation, + principal: SessionPrincipal | ResourceOperationPrincipal, operation: ProjectOperation, input: ProjectAuthorizationInput & { projectId: string }, mode: ProjectAccessMode @@ -309,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 | ResourceScopedDelegation, + 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 53248843e6a..93745189b76 100644 --- a/apps/sim/lib/projects/application/discovery.ts +++ b/apps/sim/lib/projects/application/discovery.ts @@ -1,4 +1,4 @@ -import type { ResourceScopedDelegation } 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: ResourceScopedDelegation, + principal: ResourceOperationPrincipal, assertedOrganizationId?: string ): Promise<{ organizationId: string | null; workspaceId?: string }> { if (principal.serviceId !== 'copilot') diff --git a/apps/sim/lib/projects/application/use-cases.ts b/apps/sim/lib/projects/application/use-cases.ts index 94bbdd1fd6b..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 { isResourceScopedDelegation, 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,7 +96,7 @@ 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 = isResourceScopedDelegation(principal) + const scope = hasResourceOperationGrant(principal) ? await resolveCopilotProjectScope(principal, input.organizationId) : { organizationId: input.organizationId } return db.transaction(async (tx) => { @@ -261,7 +261,7 @@ export const getCurrentWorkspaceProject: OperationUseCase< async execute({ principal }) { requireProjectPrincipal(principal, projectOperations.list) await requireProjectApiEnabled() - if (!isResourceScopedDelegation(principal)) + 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 0a19e5e17ea..26f3771d2b3 100644 --- a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts @@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path' import type { OAuthAccessTokenPrincipal, ResourceFileCopyScope, - ResourceScopedDelegation, + ResourceOperationPrincipal, } from '@sim/auth/principal' import { db } from '@sim/db' import { @@ -159,7 +159,7 @@ function delegated( userId: string, projectId: string, workspaceId: string -): Extract { +): Extract { return { kind: 'delegated', serviceId: 'copilot', @@ -168,8 +168,8 @@ function delegated( 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,7 +262,7 @@ describe('compound copy owner authority', () => { code: 'forbidden', }) } - const realtime: ResourceScopedDelegation = { + const realtime: ResourceOperationPrincipal = { kind: 'delegated', serviceId: 'realtime', subjectUserId: f.readerId, @@ -270,13 +270,12 @@ describe('compound copy owner authority', () => { 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 08f0c0423d4..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, ResourceScopedDelegation } from '@sim/auth/principal' +import type { OAuthAccessTokenPrincipal, ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { copilotChats, @@ -205,7 +205,7 @@ function delegated( userId: string, projectId: string, workspaceId: string -): Extract { +): Extract { return { kind: 'delegated', serviceId: 'copilot', @@ -214,8 +214,8 @@ function delegated( 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: ResourceScopedDelegation = { + const principal: ResourceOperationPrincipal = { ...delegated(f.readerId, f.projectId, f.workspaces[0]), serviceId: 'copilot', invocation: { kind: 'chat', chatId }, @@ -1100,7 +1104,7 @@ describe('Project file authority at the database boundary', () => { contentType: 'text/markdown', sizeBytes: 0, }) - const principal: ResourceScopedDelegation = { + const principal: ResourceOperationPrincipal = { kind: 'delegated', serviceId: 'realtime', subjectUserId: f.readerId, @@ -1108,8 +1112,12 @@ describe('Project file authority at the database boundary', () => { 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 a8bc77dfd5a..e957345322f 100644 --- a/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content-bulk.integration.ts @@ -600,8 +600,8 @@ describe('Project archive download and Markdown snapshot export', () => { 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( @@ -645,8 +645,8 @@ describe('Project archive download and Markdown snapshot export', () => { 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 19098aec27d..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 { ResourceScopedDelegation } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { fileSearchDispatchQueue, @@ -204,7 +204,7 @@ describe('Project delivery admission', () => { }, }) const issuedAt = new Date() - const principal: ResourceScopedDelegation = { + const principal: ResourceOperationPrincipal = { kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, @@ -212,8 +212,11 @@ describe('Project delivery admission', () => { 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 cf16e3327f4..7c0defb2211 100644 --- a/apps/sim/lib/projects/files/__integration__/content.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -1605,8 +1605,11 @@ describe('Project Copilot content admission', () => { 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 07c4860ed2b..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 { ResourceScopedDelegation } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { member, @@ -137,7 +137,7 @@ async function fixture(permissionType: 'read' | 'write' = 'write') { }, }) const target = { projectId: ids.projectId, fileId: created.file.id } - const principal: ResourceScopedDelegation = { + const principal: ResourceOperationPrincipal = { kind: 'delegated', serviceId: 'realtime', subjectUserId: editorId, @@ -145,13 +145,12 @@ async function fixture(permissionType: 'read' | 'write' = 'write') { 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: ResourceScopedDelegation = { + 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 2c4f7d43a34..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 { ResourceScopedDelegation } from '@sim/auth/principal' +import type { ResourceOperationPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { folder, @@ -466,7 +466,7 @@ describe('Project upload sessions with real leases, storage, and accounting', () async () => { const f = await fixture() const issuedAt = new Date() - const principal: ResourceScopedDelegation = { + const principal: ResourceOperationPrincipal = { kind: 'delegated', serviceId: 'copilot', subjectUserId: f.editorId, @@ -474,8 +474,8 @@ describe('Project upload sessions with real leases, storage, and accounting', () 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 ee7f0dc12d3..a117251949d 100644 --- a/apps/sim/lib/projects/files/application/authorization.ts +++ b/apps/sim/lib/projects/files/application/authorization.ts @@ -1,5 +1,5 @@ import { - isResourceScopedDelegation, + hasResourceOperationGrant, type OrganizationDelegatedPrincipal, type Principal, requirePrincipalSubjectUserId, @@ -78,9 +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 === 'delegated' && !isResourceScopedDelegation(principal)) + if (principal.kind === 'delegated' && !hasResourceOperationGrant(principal)) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) - if (isResourceScopedDelegation(principal)) { + if (hasResourceOperationGrant(principal)) { requireResourceDelegation(principal, { audience: operation.delegationAudience, services: operation.delegatedServices, @@ -88,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, @@ -267,7 +266,7 @@ export async function createProjectFileAuthorizer( await requireProjectApiEnabled() const userId = await requireFileSubject(principal) const invocationScope = - isResourceScopedDelegation(principal) && principal.serviceId === 'copilot' + hasResourceOperationGrant(principal) && principal.serviceId === 'copilot' ? await resolveCopilotProjectScope(principal) : undefined @@ -303,7 +302,7 @@ export async function createProjectFileAuthorizer( } if (access.record.organizationId) await acquirePermissionGroupOrgReadLock(tx, access.record.organizationId) - if (isResourceScopedDelegation(principal) && 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 8016abb4de4..6b44943a18b 100644 --- a/apps/sim/lib/projects/files/application/content.ts +++ b/apps/sim/lib/projects/files/application/content.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { - isResourceScopedDelegation, + hasResourceOperationGrant, type Principal, requirePrincipalSubjectUserId, } from '@sim/auth/principal' @@ -110,7 +110,7 @@ function replacementProvenance( ): WorkspaceFileSecretProvenance { return ( provenance ?? - (isResourceScopedDelegation(principal) + (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 845e3c93cac..34e478825ad 100644 --- a/apps/sim/lib/projects/files/application/downloads.ts +++ b/apps/sim/lib/projects/files/application/downloads.ts @@ -1,6 +1,6 @@ import { AuditAction, AuditResourceType } from '@sim/audit' import { - isResourceScopedDelegation, + hasResourceOperationGrant, type Principal, requirePrincipalSubjectUserId, } from '@sim/auth/principal' @@ -398,7 +398,7 @@ export const exportProjectFileSnapshot = defineAuthorizedProjectFileUseCase< provenance: mergeWorkspaceFileSecretProvenance( snapshot.provenance.get(snapshot.source.id) ?? { status: 'unknown' }, input.secretProvenance ?? - (isResourceScopedDelegation(principal) + (hasResourceOperationGrant(principal) ? { status: 'unknown' } : { status: 'exact', entries: [] }) ), @@ -466,7 +466,7 @@ export const exportProjectFileSnapshot = defineAuthorizedProjectFileUseCase< const secretProvenance = mergeWorkspaceFileSecretProvenance( evidence, input.secretProvenance ?? - (isResourceScopedDelegation(principal) + (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 b8306ddcdcd..707d94b820b 100644 --- a/apps/sim/lib/projects/files/application/operations.ts +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -1,7 +1,7 @@ import type { Principal, ResourceDelegationScope, - ResourceScopedDelegation, + ResourceOperationPrincipal, } from '@sim/auth/principal' import { type ApplicationOperation, @@ -11,7 +11,7 @@ import { export type ProjectFilePrincipal = | Extract - | ResourceScopedDelegation + | ResourceOperationPrincipal interface ProjectFilePolicy extends ApplicationOperation { readonly access: 'read' | 'write' @@ -24,7 +24,7 @@ interface ProjectFilePolicy extends ApplicationOperation { */ readonly chargesStorage?: true readonly principalKinds: readonly ProjectFilePrincipal['kind'][] - readonly delegatedServices: readonly ResourceScopedDelegation['serviceId'][] + readonly delegatedServices: readonly ResourceOperationPrincipal['serviceId'][] readonly delegationAudience: 'sim:project-files' | 'sim:file-list-observation' } @@ -37,9 +37,8 @@ export function projectFileDelegationScope(target: { }): ResourceDelegationScope { return { kind: 'entity', - entityType: 'project', - entityId: target.projectId, ...(target.fileId ? { fileId: target.fileId } : {}), + owner: { entityType: 'project', entityId: target.projectId }, } } diff --git a/apps/sim/lib/settings/application/context.ts b/apps/sim/lib/settings/application/context.ts index 6ccf0dc8943..08b8f654cad 100644 --- a/apps/sim/lib/settings/application/context.ts +++ b/apps/sim/lib/settings/application/context.ts @@ -1,4 +1,5 @@ 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' @@ -20,7 +21,7 @@ export const readSettingsWorkspaceContext = defineAuthorizedWorkspaceUseCase({ }: { 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 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 && @@ -805,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.ts b/apps/sim/lib/users/application/preferences-authorization.ts index 44f156e7d1c..7978bc6c2e7 100644 --- a/apps/sim/lib/users/application/preferences-authorization.ts +++ b/apps/sim/lib/users/application/preferences-authorization.ts @@ -1,5 +1,9 @@ import type { Principal } from '@sim/auth/principal' -import { isWorkspaceDelegation } 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' @@ -33,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') } @@ -51,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 7a967522b1d..4128d2d4ff0 100644 --- a/apps/sim/lib/users/application/preferences.test.ts +++ b/apps/sim/lib/users/application/preferences.test.ts @@ -18,12 +18,16 @@ 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 b3cfcc130e6..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 { WorkspaceDelegatedPrincipal } from '@sim/auth/principal' +import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal' import { describe, expect, it } from 'vitest' import { requireWorkflowExecutionUserId, @@ -8,8 +8,8 @@ import { import { workflowOperations } from '@/lib/workflows/application/operations' function createExecutorPrincipal( - overrides: Partial = {} -): WorkspaceDelegatedPrincipal { + 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 a39037135e0..e3fea83a05b 100644 --- a/apps/sim/lib/workflows/application/inspect-workflow-tools.test.ts +++ b/apps/sim/lib/workflows/application/inspect-workflow-tools.test.ts @@ -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 7240f302615..0a5799fd9eb 100644 --- a/apps/sim/lib/workflows/application/run-control.test.ts +++ b/apps/sim/lib/workflows/application/run-control.test.ts @@ -78,11 +78,15 @@ const principals: 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/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 7d563989068..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,5 +1,5 @@ import { - isResourceScopedDelegation, + isWorkspaceDelegation, type Principal, requirePrincipalSubjectUserId, } from '@sim/auth/principal' @@ -258,7 +258,7 @@ async function executeCopilotRun(params: { } }): Promise { if ( - isResourceScopedDelegation(params.principal) || + (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 a522629862d..a7797215fcc 100644 --- a/apps/sim/lib/workspace-files/application/copy-authorization.ts +++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts @@ -1,9 +1,9 @@ import type { Principal, ResourceFileCopyScope, - ResourceScopedDelegation, + ResourceOperationPrincipal, } from '@sim/auth/principal' -import { isResourceScopedDelegation } from '@sim/auth/principal' +import { hasResourceOperationGrant } from '@sim/auth/principal' import { workspace } from '@sim/db/schema' import { compareStrings } from '@sim/utils/string' import { and, asc, inArray, isNull } from 'drizzle-orm' @@ -49,7 +49,7 @@ export type CopyFileItemsInput = Omit type CopyPrincipal = | Extract - | Extract + | Extract type ProjectAccess = Awaited> @@ -96,7 +96,7 @@ const COPY_OWNER_POLICIES: FileOwnerAdapters = { allowPersonalApiKeys: row.allowPersonalApiKeys, } // Project access already holds membership and permission SHARE locks; upgrading can deadlock other Projects. - if (isResourceScopedDelegation(principal)) { + 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( @@ -130,7 +130,7 @@ function requireCopyPrincipal( if (!fileCopyOperation.principalKinds.some((kind) => kind === principal.kind)) { throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) } - if (principal.kind === 'delegated' && !isResourceScopedDelegation(principal)) + if (principal.kind === 'delegated' && !hasResourceOperationGrant(principal)) throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) const scope = { kind: 'file_copy', ...input } if (!isResourceFileCopyScope(scope)) { @@ -140,7 +140,7 @@ function requireCopyPrincipal( ) } requireOAuthOperationScope(principal, fileCopyOperation) - if (isResourceScopedDelegation(principal)) { + if (hasResourceOperationGrant(principal)) { requireResourceDelegation(principal, { audience: fileCopyOperation.delegationAudience, services: fileCopyOperation.delegatedServices, @@ -159,7 +159,7 @@ 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 = isResourceScopedDelegation(principal) + const invocationScope = hasResourceOperationGrant(principal) ? await resolveCopilotProjectScope(principal) : undefined @@ -227,7 +227,7 @@ export async function createFileCopyAuthorizer( for (const organizationId of organizationIds) await acquirePermissionGroupOrgLock(tx, organizationId) await requireCurrentFileSubject(tx, principal) - if (isResourceScopedDelegation(principal)) { + 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/delegated-principal.test.ts b/apps/sim/lib/workspace-files/application/delegated-principal.test.ts index 7271eea77e0..3c80c1d1dfd 100644 --- a/apps/sim/lib/workspace-files/application/delegated-principal.test.ts +++ b/apps/sim/lib/workspace-files/application/delegated-principal.test.ts @@ -57,11 +57,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 32d7ef8d7cc..98b06afed9e 100644 --- a/apps/sim/lib/workspace-files/application/delegated-principal.ts +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -1,4 +1,5 @@ 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 @@ -21,8 +22,8 @@ export function createWorkspaceFileDelegatedPrincipal( 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,6 +37,18 @@ export function createWorkspaceFileDelegatedPrincipal( ...(input.executionId ? { executionId: input.executionId } : {}), }, } + if (input.serviceId === 'executor') return { ...principal, serviceId: 'executor' } + const { workspaceId, resourceScope, ...envelope } = principal + return { + ...envelope, + serviceId: input.serviceId, + invocation: { kind: 'workspace', workspaceId }, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: workspaceId }, + restrictions: resourceScope, + }, + } } export interface RebindWorkspaceFileDelegationInput { @@ -51,13 +64,11 @@ export interface RebindWorkspaceFileDelegationInput { export function rebindWorkspaceFileDelegatedPrincipal( input: RebindWorkspaceFileDelegationInput ): WorkspaceDelegatedPrincipal { - if (input.principal.workspaceId !== input.workspaceId || !input.delegationId) { + 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 +78,13 @@ 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 } + return { ...input.principal, ...updates, grant: { ...input.principal.grant, restrictions } } } 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/tools/index.test.ts b/apps/sim/tools/index.test.ts index b660d0b39e5..7e02dbec909 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/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index bb646d96789..c446813898a 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -134,27 +134,32 @@ interface DelegatedPrincipalBase { expiresAt: Date } +interface WorkspaceDelegationRestrictions { + fileId?: string + tableId?: string + chatId?: string + executionId?: string + credentialId?: string + credentialGroupId?: string + mcpServerId?: string + mcpBlockId?: string +} + interface WorkspaceDelegationFields { workspaceId: string - scope?: never + resourceScope?: WorkspaceDelegationRestrictions + grant?: never invocation?: never - resourceScope?: { - fileId?: string - tableId?: string - chatId?: string - executionId?: string - credentialId?: string - credentialGroupId?: string - mcpServerId?: string - mcpBlockId?: string - } } -export interface WorkspaceSubjectDelegation - extends DelegatedPrincipalBase, - WorkspaceDelegationFields { - serviceId: 'copilot' | 'realtime' - subjectUserId: string +interface WorkspaceDelegationGrant { + kind: 'entity' + owner: EntityOwner<'workspace'> + restrictions?: WorkspaceDelegationRestrictions +} + +export type WorkspaceSubjectDelegation = SubjectDelegatedPrincipal & { + grant: WorkspaceDelegationGrant } interface WorkflowExecutionDelegationContext { @@ -191,8 +196,6 @@ export type BoundWorkflowExecutionDelegatedPrincipal = WorkflowExecutionDelegate delegationContext: WorkflowExecutionDelegationContext } -export type SubjectDelegatedPrincipal = WorkspaceSubjectDelegation | ResourceScopedDelegation - export type WorkspaceDelegatedPrincipal = | WorkspaceSubjectDelegation | WorkflowExecutionDelegatedPrincipal @@ -201,31 +204,35 @@ export type DelegatedPrincipal = SubjectDelegatedPrincipal | WorkflowExecutionDe export 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 } } @@ -236,48 +243,86 @@ export type ResourceDelegationScope = | ResourceFileCopyScope | ResourceFileCollectionObservationScope -/** Human authority for entity-owned resources; never a workflow execution principal. */ -export type ResourceScopedDelegation = DelegatedPrincipalBase & { +/** Human authority pairs the trusted invocation with a bounded grant. */ +export type SubjectDelegatedPrincipal = DelegatedPrincipalBase & { subjectUserId: string - workspaceId?: never - resourceScope?: never } & ( | { 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 } } ) -/** Identifies the resource grant family; domain authorization validates its contents. */ -export function isResourceScopedDelegation( +export type ResourceOperationPrincipal = SubjectDelegatedPrincipal & { + grant: ResourceDelegationScope +} + +/** Narrows grants handled by resource operations; domain authorization validates their contents. */ +export function hasResourceOperationGrant( principal: Principal -): principal is ResourceScopedDelegation { +): principal is ResourceOperationPrincipal { return ( principal.kind === 'delegated' && principal.serviceId !== 'executor' && - principal.scope !== undefined + (principal.grant?.kind !== 'entity' || principal.grant.owner?.entityType !== 'workspace') && + principal.grant !== undefined ) } -/** Keeps resource grants out of workspace and workflow authority, including malformed mixed grants. */ +/** 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 + ) return ( - principal.kind === 'delegated' && - principal.scope === undefined && - principal.invocation === undefined && - typeof principal.workspaceId === 'string' && - principal.workspaceId.length > 0 + !('workspaceId' in principal) && + !('resourceScope' in principal) && + principal.grant?.kind === 'entity' && + Object.keys(principal.grant).every((key) => ['kind', 'owner', 'restrictions'].includes(key)) && + principal.grant.owner?.entityType === 'workspace' && + typeof principal.grant.owner.entityId === 'string' && + principal.grant.owner.entityId.length > 0 ) } +/** 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 (principal.kind === 'delegated' && principal.serviceId !== 'executor') + return principal.grant.kind === 'entity' && principal.grant.owner?.entityType === 'workspace' + ? principal.grant.owner.entityId + : 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' @@ -387,7 +432,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 }) @@ -434,7 +482,7 @@ function requireDate(value: unknown, field: string): Date { return date } -function parseResourceScope(value: unknown): WorkspaceDelegatedPrincipal['resourceScope'] { +function parseResourceScope(value: unknown): WorkspaceDelegationRestrictions | undefined { const scope = requireRecord(value, 'Serialized principal resourceScope') const keys = [ 'fileId', @@ -447,7 +495,7 @@ function parseResourceScope(value: unknown): WorkspaceDelegatedPrincipal['resour '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) } @@ -512,7 +560,15 @@ export function serializePrincipal(principal: WorkflowExecutionPrincipal): Seria 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(), }, @@ -648,19 +704,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 WorkspaceSubjectDelegation['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') @@ -675,7 +751,7 @@ export type PrincipalActor = | Omit | { kind: 'resource_delegated' - serviceId: ResourceScopedDelegation['serviceId'] + serviceId: ResourceOperationPrincipal['serviceId'] subjectUserId: string delegationId: string } @@ -825,7 +901,7 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { : {}), } case 'delegated': - if (isResourceScopedDelegation(principal)) + if (hasResourceOperationGrant(principal)) return { kind: 'resource_delegated', serviceId: principal.serviceId, diff --git a/packages/testing/src/factories/principal.factory.ts b/packages/testing/src/factories/principal.factory.ts index 72224103c7a..1595f0fc160 100644 --- a/packages/testing/src/factories/principal.factory.ts +++ b/packages/testing/src/factories/principal.factory.ts @@ -57,18 +57,28 @@ export function createWorkspaceApiKeyPrincipal( * ``` */ export function createDelegatedPrincipal( - overrides: Partial = {} + overrides: Omit< + Partial, + 'workspaceId' | 'resourceScope' | 'invocation' + > & + Pick, 'workspaceId' | 'resourceScope'> = {} ): WorkspaceSubjectDelegation { + const { workspaceId = 'workspace-1', resourceScope, ...rest } = overrides 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 }, + grant: { + kind: 'entity', + owner: { entityType: 'workspace', entityId: workspaceId }, + ...(resourceScope ? { restrictions: resourceScope } : {}), + }, + ...rest, } } From 40aa75df8a075a9b23da48765216bbce7961f754 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sat, 17 Oct 2026 15:49:17 -0700 Subject: [PATCH 3/5] fix(auth): close workspace and resource grant admission gaps A workspace entity grant is admitted only when its invocation matches that grant, and a workspace-owned grant with any extra key matches neither classifier. Co-authored-by: Cursor --- .../auth/delegated-grant-admission.test.ts | 167 ++++++++++++++++++ apps/sim/lib/auth/principal.test.ts | 139 +++++++++++++++ .../copilot-workspace-invocation.test.ts | 19 +- .../copilot-workspace-invocation.ts | 3 +- .../application/resource-delegation.test.ts | 33 ++++ .../core/application/resource-delegation.ts | 74 +------- apps/sim/lib/execution/files.test.ts | 15 +- .../payloads/materialization.server.test.ts | 17 +- .../lib/knowledge/application/tags.test.ts | 8 +- .../agent-cli/file-upload-origin.test.ts | 4 +- .../agent-cli/scope-routing.test.ts | 4 +- .../agent-cli/scoped-fork-transport.test.ts | 8 +- .../application/settings-context.test.ts | 5 +- .../mothership/auth/table-delegation.test.ts | 7 +- .../chat/application/inline-images.test.ts | 19 +- .../lib/mothership/chat/title-context.test.ts | 4 +- .../integrations/application/catalog.test.ts | 7 +- .../memory/application/read-scope.test.ts | 9 +- .../server/knowledge/workspace-search.test.ts | 4 +- .../tools/server/open-resource.test.ts | 7 +- .../lib/mothership/transport/control.test.ts | 20 ++- .../lib/projects/application/operations.ts | 8 +- .../projects/files/application/operations.ts | 7 + .../sim/lib/table/application/imports.test.ts | 9 +- .../preferences-authorization.test.ts | 18 +- .../application/copy-authorization.ts | 7 +- .../application/copy-operation.ts | 3 + .../application/delegated-principal.test.ts | 47 ++++- .../application/delegated-principal.ts | 42 ++++- .../download-workspace-file-items.test.ts | 11 +- .../export-workspace-file-snapshot.test.ts | 14 +- .../application/remove-member.test.ts | 12 +- packages/auth/package.json | 1 + packages/auth/src/principal.ts | 153 ++++++++++++++-- .../src/factories/principal.factory.ts | 19 +- 35 files changed, 773 insertions(+), 151 deletions(-) create mode 100644 apps/sim/lib/auth/delegated-grant-admission.test.ts 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/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index 3a654bec404..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, @@ -211,6 +214,142 @@ 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 = { 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 bfceeae4c9c..8712620a05c 100644 --- a/apps/sim/lib/core/application/copilot-workspace-invocation.ts +++ b/apps/sim/lib/core/application/copilot-workspace-invocation.ts @@ -10,8 +10,9 @@ 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() -export function markCopilotWorkspaceInvocation(principal: WorkspaceDelegatedPrincipal): void { +export function markCopilotWorkspaceInvocation(principal: Principal): void { if ( + !isWorkspaceDelegation(principal) || principal.serviceId !== 'copilot' || !principal.subjectUserId || !principalWorkspaceId(principal) || diff --git a/apps/sim/lib/core/application/resource-delegation.test.ts b/apps/sim/lib/core/application/resource-delegation.test.ts index 7ebcb723404..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, @@ -233,6 +236,36 @@ 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', () => { const operation = defineWorkspaceOperation({ id: 'files.read', diff --git a/apps/sim/lib/core/application/resource-delegation.ts b/apps/sim/lib/core/application/resource-delegation.ts index b62a305777d..983d154f010 100644 --- a/apps/sim/lib/core/application/resource-delegation.ts +++ b/apps/sim/lib/core/application/resource-delegation.ts @@ -1,10 +1,9 @@ import type { Principal, ResourceDelegationScope, - ResourceFileCopyScope, ResourceOperationPrincipal, } from '@sim/auth/principal' -import { hasResourceOperationGrant } from '@sim/auth/principal' +import { hasResourceOperationGrant, isResourceDelegationScope } from '@sim/auth/principal' import { isRecordLike } from '@sim/utils/object' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -19,73 +18,6 @@ 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 ( - !isRecordLike(scope.owner) || - Object.keys(scope.owner).length !== 2 || - !Object.keys(scope.owner).every((key) => key === 'entityType' || key === 'entityId') || - !isIdentity(scope.owner.entityId) - ) - return false - if (scope.kind === 'file_collection_observation') { - return ( - scope.owner.entityType === 'project' && - Object.keys(scope).length === 2 && - Object.keys(scope).every((key) => key === 'kind' || key === 'owner') - ) - } - return ( - scope.kind === 'entity' && - ['workspace', 'project', 'organization', 'user'].includes(String(scope.owner.entityType)) && - (scope.fileId === undefined || isIdentity(scope.fileId)) && - Object.keys(scope).every((key) => ['kind', 'owner', 'fileId'].includes(key)) - ) -} - function isValidInvocation(principal: ResourceOperationPrincipal): boolean { const invocation = principal.invocation if (!isRecordLike(invocation) || Object.keys(invocation).length !== 2) return false @@ -153,7 +85,7 @@ 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') } @@ -174,7 +106,7 @@ export function requireResourceDelegation( principal.issuedAt.getTime() > now || principal.expiresAt.getTime() <= now || principal.expiresAt.getTime() - principal.issuedAt.getTime() > policy.maxTtlMs || - !isValidScope(principal.grant) || + !isResourceDelegationScope(principal.grant) || !isValidInvocation(principal) || !matchesScope(principal.grant, policy.scope) ) { 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/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/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/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/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/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/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/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/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/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/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/projects/application/operations.ts b/apps/sim/lib/projects/application/operations.ts index b7ccc80c5be..7f147a93045 100644 --- a/apps/sim/lib/projects/application/operations.ts +++ b/apps/sim/lib/projects/application/operations.ts @@ -1,5 +1,9 @@ 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' | 'delegated')[] @@ -22,6 +26,7 @@ function defineProjectOperation( !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, delegationScope: 'resource' as const }) @@ -39,6 +44,7 @@ export const projectOperations = { principalKinds: ['session', 'delegated'], access: 'read', capability: 'none', + contentDelivery: 'none', delegationAudience: 'sim:projects:discovery', delegatedServices: ['copilot'], }), diff --git a/apps/sim/lib/projects/files/application/operations.ts b/apps/sim/lib/projects/files/application/operations.ts index 707d94b820b..d28253c5a14 100644 --- a/apps/sim/lib/projects/files/application/operations.ts +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -5,6 +5,7 @@ import type { } from '@sim/auth/principal' import { type ApplicationOperation, + assertContentDeliveryDeclaration, assertOperationCapability, assertOperationOAuthPolicy, } from '@/lib/core/application/operation' @@ -66,16 +67,22 @@ function defineProjectFileOperation( ) { 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, delegationScope: 'resource' 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 = { diff --git a/apps/sim/lib/table/application/imports.test.ts b/apps/sim/lib/table/application/imports.test.ts index 6627538a7cb..24feab00379 100644 --- a/apps/sim/lib/table/application/imports.test.ts +++ b/apps/sim/lib/table/application/imports.test.ts @@ -281,7 +281,14 @@ describe('table import application use cases', () => { 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/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/workspace-files/application/copy-authorization.ts b/apps/sim/lib/workspace-files/application/copy-authorization.ts index a7797215fcc..d85ab1bfa57 100644 --- a/apps/sim/lib/workspace-files/application/copy-authorization.ts +++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts @@ -3,15 +3,12 @@ import type { ResourceFileCopyScope, ResourceOperationPrincipal, } from '@sim/auth/principal' -import { hasResourceOperationGrant } 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, diff --git a/apps/sim/lib/workspace-files/application/copy-operation.ts b/apps/sim/lib/workspace-files/application/copy-operation.ts index 56736de275b..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' @@ -13,10 +14,12 @@ const policy = { 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 3c80c1d1dfd..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', () => { diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.ts b/apps/sim/lib/workspace-files/application/delegated-principal.ts index 98b06afed9e..29bf2275c03 100644 --- a/apps/sim/lib/workspace-files/application/delegated-principal.ts +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -39,15 +39,26 @@ export function createWorkspaceFileDelegatedPrincipal( } 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: input.serviceId, - invocation: { kind: 'workspace', workspaceId }, - grant: { - kind: 'entity', - owner: { entityType: 'workspace', entityId: workspaceId }, - restrictions: resourceScope, - }, + serviceId: 'realtime', + invocation: { kind: 'workspace' as const, workspaceId }, + grant, } } @@ -86,5 +97,20 @@ export function rebindWorkspaceFileDelegatedPrincipal( } if (input.principal.serviceId === 'executor') return { ...input.principal, ...updates, resourceScope: restrictions } - return { ...input.principal, ...updates, grant: { ...input.principal.grant, 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/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/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 c446813898a..a093635ccd0 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -1,3 +1,5 @@ +import { isRecordLike } from '@sim/utils/object' + export type Principal = | SessionPrincipal | PersonalApiKeyPrincipal @@ -202,7 +204,7 @@ export type WorkspaceDelegatedPrincipal = 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 { @@ -268,6 +270,132 @@ 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 @@ -275,8 +403,8 @@ export function hasResourceOperationGrant( return ( principal.kind === 'delegated' && principal.serviceId !== 'executor' && - (principal.grant?.kind !== 'entity' || principal.grant.owner?.entityType !== 'workspace') && - principal.grant !== undefined + !isWorkspaceOwnedEntityGrant(principal.grant) && + isResourceDelegationScope(principal.grant) ) } @@ -291,15 +419,9 @@ export function isWorkspaceDelegation( typeof principal.workspaceId === 'string' && principal.workspaceId.length > 0 ) - return ( - !('workspaceId' in principal) && - !('resourceScope' in principal) && - principal.grant?.kind === 'entity' && - Object.keys(principal.grant).every((key) => ['kind', 'owner', 'restrictions'].includes(key)) && - principal.grant.owner?.entityType === 'workspace' && - typeof principal.grant.owner.entityId === 'string' && - principal.grant.owner.entityId.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. */ @@ -308,10 +430,9 @@ export function principalWorkspaceId( ): string export function principalWorkspaceId(principal: Principal): string | undefined export function principalWorkspaceId(principal: Principal): string | undefined { - if (principal.kind === 'delegated' && principal.serviceId !== 'executor') - return principal.grant.kind === 'entity' && principal.grant.owner?.entityType === 'workspace' - ? principal.grant.owner.entityId - : 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 } diff --git a/packages/testing/src/factories/principal.factory.ts b/packages/testing/src/factories/principal.factory.ts index 1595f0fc160..b8d2fd30fc1 100644 --- a/packages/testing/src/factories/principal.factory.ts +++ b/packages/testing/src/factories/principal.factory.ts @@ -63,7 +63,16 @@ export function createDelegatedPrincipal( > & Pick, 'workspaceId' | 'resourceScope'> = {} ): WorkspaceSubjectDelegation { - const { workspaceId = 'workspace-1', resourceScope, ...rest } = overrides + 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', @@ -72,12 +81,8 @@ export function createDelegatedPrincipal( audience: 'sim:test', issuedAt: new Date(TEST_PRINCIPAL_ISSUED_AT), expiresAt: new Date(TEST_PRINCIPAL_EXPIRES_AT), - invocation: { kind: 'workspace', workspaceId }, - grant: { - kind: 'entity', - owner: { entityType: 'workspace', entityId: workspaceId }, - ...(resourceScope ? { restrictions: resourceScope } : {}), - }, + invocation: { kind: 'workspace', workspaceId: boundWorkspaceId }, + grant, ...rest, } } From d55820f3173c6acea3a00dd3a4331b880dce6e05 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sat, 17 Oct 2026 15:49:36 -0700 Subject: [PATCH 4/5] fix(auth): record the auth package utils dependency Co-authored-by: Cursor --- bun.lock | 1 + 1 file changed, 1 insertion(+) 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": { From 0374a900dd22c0b0a1470ed89d4e4eb3a6a4e9f4 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Sun, 18 Oct 2026 15:47:12 -0700 Subject: [PATCH 5/5] test(auth): cover minted delegation through workspace and knowledge use cases Workspace admission requires the same chat id on the invocation and the grant, so the GitLab fixture and the new cases use the principals Copilot actually mints. Co-authored-by: Cursor --- .../gitlab-workspace.integration.ts | 118 ++++++++++++++++- .../copilot-file-versions.integration.ts | 122 +++++++++++++++++- 2 files changed, 234 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts b/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts index ac990239d3c..c779303f29c 100644 --- a/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts +++ b/apps/sim/lib/knowledge/__integration__/gitlab-workspace.integration.ts @@ -17,6 +17,7 @@ import { } from '@sim/db/schema' import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { generateId } from '@sim/utils/id' +import { compareStrings } from '@sim/utils/string' import { and, eq, inArray, isNull } from 'drizzle-orm' import { afterAll, beforeAll, expect, it, vi } from 'vitest' @@ -42,7 +43,10 @@ vi.mock('@/lib/embeddings', async () => ({ }), })) +import { hasResourceOperationGrant } from '@sim/auth/principal' +import { copilotRequestPrincipal } from '@/lib/api/server/routes/copilot-request' import { decryptApiKey } from '@/lib/api-key/crypto' +import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { encryptSecret } from '@/lib/core/security/encryption' import { createKnowledgeAclFixtureIds, @@ -54,6 +58,10 @@ import { } from '@/lib/knowledge/application/connectors' import { readKnowledgeDocument } from '@/lib/knowledge/application/documents' import { searchKnowledge } from '@/lib/knowledge/application/search' +import { executeCopilotKnowledgeUseCase } from '@/lib/mothership/application/execute-knowledge-use-case' +import { markCopilotProjectFileRequest } from '@/lib/mothership/application/execute-project-file-use-case' +import { createCopilotResourceAdmission } from '@/lib/mothership/auth/application-delegation' +import { getProjectFileMetadata, projectFileOperations } from '@/lib/projects/files/application' const ids = createKnowledgeAclFixtureIds() const sourceConfig = { @@ -71,6 +79,34 @@ const input = { syncIntervalMinutes: 1440, } +function admittedProjectFileGrant(userId: string) { + const chatId = generateId() + const request = new Request('http://localhost:3000/api/v2/projects/project/files/file/metadata') + markCopilotProjectFileRequest( + request, + { + userId, + chatId, + toolCallId: generateId(), + copilotToolExecution: true, + copilotResourceAdmission: createCopilotResourceAdmission({ + userId, + invocation: { kind: 'chat', chatId }, + }), + }, + { projectId: generateId(), fileId: generateId() } + ) + const principal = copilotRequestPrincipal( + request, + projectFileOperations.readMetadata, + getProjectFileMetadata + ) + if (!principal || !hasResourceOperationGrant(principal)) { + throw new Error('Project file request did not mint a resource grant') + } + return principal +} + beforeAll(async () => { fixture.root = mkdtempSync(path.join(tmpdir(), 'sim-gitlab-workspace-')) await seedKnowledgeAclFixture(ids) @@ -128,6 +164,7 @@ it.each([ ])( 'creates, syncs, edits, and searches a workspace GitLab source through $surface using $apiKey', async ({ surface, apiKey }) => { + const chatId = generateId() const caller = surface === 'application' ? principal @@ -139,12 +176,12 @@ it.each([ audience: 'sim:knowledge', issuedAt: new Date(), expiresAt: new Date(Date.now() + 60000), - invocation: { kind: 'chat', chatId: generateId() } as const, + invocation: { kind: 'chat' as const, chatId }, grant: { - kind: 'entity', - owner: { entityType: 'workspace', entityId: ids.workspaceId }, - restrictions: { chatId: generateId() }, - } as const, + kind: 'entity' as const, + owner: { entityType: 'workspace' as const, entityId: ids.workspaceId }, + restrictions: { chatId }, + }, } const { connector } = await createKnowledgeConnector.execute({ principal: caller, @@ -265,3 +302,74 @@ it('still refuses workspace access on a Search index before contacting GitLab', ).rejects.toThrow('Search sources must support per-person access or source permissions') expect(fixture.fetch.mock.calls).toHaveLength(requestCount) }) + +it('creates a GitLab connector for a writer through the minted Copilot knowledge principal', async () => { + const chatId = generateId() + const { connector } = await withWorkspaceInvocationScope( + { workspaceId: ids.workspaceId, organizationId: ids.organizationId }, + () => + executeCopilotKnowledgeUseCase( + { + userId: ids.aliceId, + workspaceId: ids.workspaceId, + chatId, + toolCallId: generateId(), + copilotToolExecution: true, + }, + createKnowledgeConnector, + input + ) + ) + + expect(JSON.stringify(connector)).not.toContain(input.apiKey) + const [stored] = await db + .select() + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connector.id)) + if (!stored) throw new Error('Connector was not stored') + const encryptedApiKey = stored.encryptedApiKey + if (!encryptedApiKey) throw new Error('Connector was stored without an API key') + expect(stored.accessMode).toBe('workspace') + expect(encryptedApiKey).not.toBe(input.apiKey) + expect((await decryptApiKey(encryptedApiKey)).decrypted).toBe(input.apiKey) + await expect + .poll( + async () => { + const [row] = await db + .select({ status: knowledgeConnector.status, error: knowledgeConnector.lastSyncError }) + .from(knowledgeConnector) + .where(eq(knowledgeConnector.id, connector.id)) + return row + }, + { timeout: 15000 } + ) + .toEqual({ status: 'active', error: null }) +}, 30000) + +it('refuses knowledge connector create for a project file grant', async () => { + const connectorIds = async () => { + const rows = await db + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .where(eq(knowledgeConnector.knowledgeBaseId, ids.knowledgeBaseId)) + return rows.map((row) => row.id).sort(compareStrings) + } + const before = await connectorIds() + const requestCount = fixture.fetch.mock.calls.length + + await expect( + withWorkspaceInvocationScope( + { workspaceId: ids.workspaceId, organizationId: ids.organizationId }, + () => + createKnowledgeConnector.execute({ + principal: admittedProjectFileGrant(ids.aliceId), + input, + }) + ) + ).rejects.toMatchObject({ + detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED', + message: 'Principal kind delegated cannot perform operation knowledge.connectors.create', + }) + expect(fixture.fetch.mock.calls).toHaveLength(requestCount) + expect(await connectorIds()).toEqual(before) +}) diff --git a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts index db69adc32f0..bc7d6c87c18 100644 --- a/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts +++ b/apps/sim/lib/workspace-files/__integration__/copilot-file-versions.integration.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' +import { hasResourceOperationGrant, parsePrincipal, serializePrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { auditLog, @@ -30,6 +31,7 @@ vi.mock('@/lib/uploads/core/setup.server', () => ({ }, })) +import { copilotRequestPrincipal } from '@/lib/api/server/routes/copilot-request' import { ContentDeliveryUnobservedError } from '@/lib/core/application' import { withWorkspaceInvocationScope } from '@/lib/core/application/workspace-invocation-scope' import { encryptSecret } from '@/lib/core/security/encryption' @@ -41,7 +43,14 @@ import { runCli } from '@/lib/mothership/agent-cli/run-cli' import { createScopedCliTransport } from '@/lib/mothership/agent-cli/scoped-transport' import { createAgentCliTransport } from '@/lib/mothership/agent-cli/transport' import { createWorkbenchFileProvenance } from '@/lib/mothership/agent-cli/workbench-file-provenance' -import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation' +import { markCopilotProjectFileRequest } from '@/lib/mothership/application/execute-project-file-use-case' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + createCopilotChatPrincipal, + createCopilotResourceAdmission, + createTrustedCopilotPrincipal, +} from '@/lib/mothership/auth/application-delegation' +import { getProjectFileMetadata, projectFileOperations } from '@/lib/projects/files/application' import { updateWorkspaceFileContent, uploadWorkspaceFile, @@ -52,6 +61,7 @@ import { } from '@/lib/uploads/contexts/workspace/file-secret-provenance' import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' +import { revertWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import '@/app/api/v2/files/[fileId]/text/route' @@ -182,6 +192,34 @@ describe('chat-delegated file version history', () => { return new ResolvedSecretTraceRegistry([], { userId, workspaceId: fixture.workspaceId }) } + function admittedProjectFileGrant(userId: string) { + const chatId = generateId() + const request = new Request('http://localhost:3000/api/v2/projects/project/files/file/metadata') + markCopilotProjectFileRequest( + request, + { + userId, + chatId, + toolCallId: generateId(), + copilotToolExecution: true, + copilotResourceAdmission: createCopilotResourceAdmission({ + userId, + invocation: { kind: 'chat', chatId }, + }), + }, + { projectId: generateId(), fileId: generateId() } + ) + const principal = copilotRequestPrincipal( + request, + projectFileOperations.readMetadata, + getProjectFileMetadata + ) + if (!principal || !hasResourceOperationGrant(principal)) { + throw new Error('Project file request did not mint a resource grant') + } + return principal + } + function versionRows(fileId: string) { return db .select({ version: workspaceFileVersion.version, source: workspaceFileVersion.source }) @@ -285,6 +323,88 @@ describe('chat-delegated file version history', () => { expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3]) }) + it('admits a file revert after the minted Copilot principal is serialized and restored', async () => { + const fixture = await seedVersionedFile() + const chatId = generateId() + const restored = parsePrincipal( + serializePrincipal( + createTrustedCopilotPrincipal( + { + userId: fixture.aliceId, + workspaceId: fixture.workspaceId, + chatId, + delegationId: `copilot-chat:${chatId}`, + }, + { + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS, + } + ) + ) + ) + + const result = await withWorkspaceInvocationScope( + { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, + () => + revertWorkspaceFileVersion.execute({ + principal: restored, + input: { + fileId: fixture.fileId, + assertedWorkspaceId: fixture.workspaceId, + version: 1, + expectedCurrentVersion: 3, + }, + }) + ) + + expect(result).toMatchObject({ + reverted: true, + version: { version: 4, source: 'revert', restoredFromVersion: 1 }, + }) + expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3, 4]) + await vi.waitFor(async () => { + const [audit] = await db + .select({ actorId: auditLog.actorId, metadata: auditLog.metadata }) + .from(auditLog) + .where( + and( + eq(auditLog.workspaceId, fixture.workspaceId), + eq(auditLog.resourceId, fixture.fileId) + ) + ) + expect(audit).toMatchObject({ + actorId: fixture.aliceId, + metadata: { + operation: 'files.versions.revert', + actor: { kind: 'delegated', serviceId: 'copilot' }, + }, + }) + }) + }) + + it('refuses a workspace file revert for a project file grant', async () => { + const fixture = await seedVersionedFile() + + await expect( + withWorkspaceInvocationScope( + { workspaceId: fixture.workspaceId, organizationId: fixture.organizationId }, + () => + revertWorkspaceFileVersion.execute({ + principal: admittedProjectFileGrant(fixture.aliceId), + input: { + fileId: fixture.fileId, + assertedWorkspaceId: fixture.workspaceId, + version: 1, + }, + }) + ) + ).rejects.toMatchObject({ + detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED', + message: 'Principal kind delegated cannot perform operation files.versions.revert', + }) + expect((await versionRows(fixture.fileId)).map((row) => row.version)).toEqual([1, 2, 3]) + }) + it('keeps version delete and download direct-only', async () => { const fixture = await seedVersionedFile() const chat = chatTransport(fixture, fixture.aliceId)