From 773ffcc831347d01ad85ac12cf2648c7e552f329 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Fri, 16 Oct 2026 18:16:21 -0700 Subject: [PATCH 1/2] 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/2] 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, } }