Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions apps/sim/app/api/files/uploads/purposes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Principal } from '@sim/auth/principal'
import {
isWorkspaceDelegation,
principalWorkspaceId,
workspaceDelegationRestrictions,
} from '@sim/auth/principal'
import { db } from '@sim/db'
import { workspace } from '@sim/db/schema'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
Expand Down Expand Up @@ -250,11 +255,6 @@ function requireSessionScope(value: string | null, label = 'scope'): string {

async function principalUserId(principal: Principal, workspaceId?: string): Promise<string> {
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')
Expand All @@ -263,7 +263,7 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom
case 'oauth_access_token':
return principal.userId
case 'workspace_api_key':
if (!workspaceId || principal.workspaceId !== workspaceId) {
if (!workspaceId || principalWorkspaceId(principal) !== workspaceId) {
throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace')
}
{
Expand All @@ -274,12 +274,17 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom
return context.billedAccountUserId
}
case 'delegated':
if (!isWorkspaceDelegation(principal))
throw new UploadSessionError(
'forbidden',
'Resource delegation cannot create workspace uploads'
)
if (
principal.serviceId === 'copilot' &&
principal.workspaceId === workspaceId &&
principalWorkspaceId(principal) === workspaceId &&
principal.audience === WORKSPACE_FILES_DELEGATION_AUDIENCE &&
principal.subjectUserId &&
principal.resourceScope?.chatId &&
workspaceDelegationRestrictions(principal)?.chatId &&
principal.expiresAt > new Date()
)
return principal.subjectUserId
Expand Down
8 changes: 5 additions & 3 deletions apps/sim/app/api/table/route.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) },
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down Expand Up @@ -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)
}

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/ee/access-requests/lib/application/operations.ts
Original file line number Diff line number Diff line change
@@ -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<Principal, { kind: 'session' | 'personal_api_key' | 'oauth_access_token' }>
| (DelegatedPrincipal & { serviceId: 'copilot' })
| (WorkspaceDelegatedPrincipal & { serviceId: 'copilot' })

export const ACCESS_REQUEST_DELEGATION_AUDIENCE = 'sim:settings'

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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, {
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/executor/execution/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] }, {}, {}, [])

Expand Down
5 changes: 3 additions & 2 deletions apps/sim/lib/api-key/application/create-api-key.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 }) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,25 @@ describe('private v2 Project file authority', () => {
)
if (!principal) throw new Error('The admitted Project file request lost its principal')
expect(principal).toMatchObject({
kind: 'resource_delegated',
kind: 'delegated',
subjectUserId: 'actor',
invocation: { kind: 'chat', chatId: 'private-chat' },
scope: { kind: 'entity', entityType: 'project', entityId: 'project', fileId: 'file' },
invocation: { kind: 'chat', chatId: 'private-chat' } as const,
grant: {
kind: 'entity',
fileId: 'file',
owner: { entityType: 'project', entityId: 'project' },
} as const,
})
expect(principal).not.toHaveProperty('workspaceId')
expect(
requireResourceDelegation(principal, {
audience: 'sim:project-files',
services: ['copilot'],
scope: { kind: 'entity', entityType: 'project', entityId: 'project', fileId: 'file' },
scope: {
kind: 'entity',
fileId: 'file',
owner: { entityType: 'project', entityId: 'project' },
},
maxTtlMs: 60_000,
})
).toBe('actor')
Expand Down Expand Up @@ -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(
Expand Down
21 changes: 17 additions & 4 deletions apps/sim/lib/api/server/routes/copilot-request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand Down
50 changes: 21 additions & 29 deletions apps/sim/lib/api/server/routes/copilot-request.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DelegatedPrincipal, ResourceDelegatedPrincipal } from '@sim/auth/principal'
import type { SubjectDelegatedPrincipal, WorkspaceSubjectDelegation } from '@sim/auth/principal'
import { noteContentDeliveryOperation } from '@/lib/core/application'
import { markCopilotWorkspaceInvocation } from '@/lib/core/application/copilot-workspace-invocation'
import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application/operation'
Expand All @@ -12,31 +12,29 @@ import {
* the given audience, or none when that operation is outside the grant. Owned by the domain
* adapter that bound the request, so this route layer stays free of domain policy.
*/
type CopilotResourceRequestGrant = (
type CopilotRequestGrant = (
operation: ApplicationOperation,
audience: string
) => ResourceDelegatedPrincipal | undefined
) => SubjectDelegatedPrincipal | undefined

type CopilotRequestInvocation =
| { kind: 'resource'; grant: CopilotResourceRequestGrant }
| { kind: 'workspace'; context: Readonly<CopilotChatDelegationContext> }
/** In-process authority factory installed after current invocation and target authorization. */
const INVOCATIONS = new WeakMap<Request, CopilotRequestGrant>()

/** A private in-process invocation, installed only after current chat/target authorization. Never a wire header. */
const INVOCATIONS = new WeakMap<Request, CopilotRequestInvocation>()

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 {
Expand Down Expand Up @@ -71,18 +69,12 @@ export function copilotRequestPrincipal(
request: Request,
operation: ApplicationOperation,
useCase?: CopilotRouteUseCase
): DelegatedPrincipal | ResourceDelegatedPrincipal | undefined {
): SubjectDelegatedPrincipal | undefined {
const invocation = INVOCATIONS.get(request)
if (!invocation) return undefined
const audience = copilotRouteAudience(operation, useCase)
if (!audience) return undefined
if (invocation.kind === 'resource') {
const principal = invocation.grant(operation, audience)
if (principal) noteContentDeliveryOperation(operation, principal)
return principal
}
const principal = createCopilotChatPrincipal(invocation.context, audience)
markCopilotWorkspaceInvocation(principal)
noteContentDeliveryOperation(operation, principal)
const principal = invocation(operation, audience)
if (principal) noteContentDeliveryOperation(operation, principal)
return principal
}
4 changes: 2 additions & 2 deletions apps/sim/lib/api/server/routes/internal-json-route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {
type DelegatedPrincipal,
describePrincipalAuth,
type Principal,
resolvePrincipalSubjectUserId,
type SessionPrincipal,
type WorkflowExecutionDelegatedPrincipal,
type workspaceDelegationRestrictions,
} from '@sim/auth/principal'
import { setRequestAuth } from '@sim/logger'
import type { NextRequest } from 'next/server'
Expand Down Expand Up @@ -66,7 +66,7 @@ interface InternalSessionOrExecutorAuthOptions {
audience: string
resourceScope?(
params: Record<string, string | string[] | undefined>
): DelegatedPrincipal['resourceScope']
): ReturnType<typeof workspaceDelegationRestrictions>
}

export function createInternalSessionOrExecutorAuth(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -114,11 +118,13 @@ export function defineAuthorizedAuditLogUseCase<const O extends AuditLogOperatio
(candidate) => 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 (
Expand Down
Loading
Loading