fix(api): align v2 permissions and resource behavior (#6557)

* fix(api): align v2 permissions and resource behavior

* fix(api): refine resource authorization boundaries

* fix(tables): restore large durable imports

* fix(files): classify missing archive targets
This commit is contained in:
Theodore Li
2026-08-11 14:31:27 -07:00
committed by GitHub
parent d6505f643d
commit 6d3e484fb3
77 changed files with 685 additions and 264 deletions
+1 -1
View File
@@ -7017,7 +7017,7 @@
"size": {
"type": "integer",
"minimum": 1,
"maximum": 26214400,
"maximum": 5368709120,
"description": "Exact CSV file size in bytes."
}
},
@@ -16,7 +16,7 @@ import { statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CSV_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table'
import { CSV_SYNC_MAX_FILE_SIZE_BYTES, type CsvHeaderMapping } from '@/lib/table'
import { performTableCsvImport } from '@/lib/table/orchestration'
import { getUserSettings } from '@/lib/users/queries'
import {
@@ -53,7 +53,7 @@ export const POST = withRouteHandler(async (request: NextRequest, { params }: Ro
let parsed: Awaited<ReturnType<typeof readMultipart>>
try {
parsed = await readMultipart(request, {
maxFileBytes: CSV_MAX_FILE_SIZE_BYTES,
maxFileBytes: CSV_SYNC_MAX_FILE_SIZE_BYTES,
requiredFieldsBeforeFile: ['workspaceId'],
signal: request.signal,
})
+2 -2
View File
@@ -10,7 +10,7 @@ import { isMultipartError, readMultipart } from '@/lib/core/utils/multipart'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { findActiveFolder } from '@/lib/folders/queries'
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table'
import { CSV_SYNC_MAX_FILE_SIZE_BYTES } from '@/lib/table'
import { performCreateTableFromCsv } from '@/lib/table/orchestration'
import { getUserSettings } from '@/lib/users/queries'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -39,7 +39,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
let parsed: Awaited<ReturnType<typeof readMultipart>>
try {
parsed = await readMultipart(request, {
maxFileBytes: CSV_MAX_FILE_SIZE_BYTES,
maxFileBytes: CSV_SYNC_MAX_FILE_SIZE_BYTES,
requiredFieldsBeforeFile: ['workspaceId'],
signal: request.signal,
})
@@ -3,6 +3,10 @@
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
} from '@/lib/core/application'
const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => {
class MockV2ApiKeyUnauthenticatedError extends Error {}
@@ -160,4 +164,15 @@ describe('/api/v2/custom-tools/[id]', () => {
expect(response.status).toBe(401)
expect(mocks.update).not.toHaveBeenCalled()
})
it('conceals cross-tenant access while preserving same-workspace role denials', async () => {
mocks.get.mockRejectedValueOnce(new NoWorkspaceAccessError())
expect((await GET(request('GET'), context)).status).toBe(404)
mocks.update.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
expect(
(await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID, code: 'return 2' }), context))
.status
).toBe(403)
})
})
@@ -4,9 +4,9 @@ import {
v2UpdateCustomToolContract,
} from '@/lib/api/contracts/v2/custom-tools'
import {
createV2ResourceConcealmentPolicy,
defineV2JsonRoute,
v2ApiKeyAuth,
v2OrchestrationErrorPolicy,
v2RateLimits,
} from '@/lib/api/server/routes'
import { customToolOperations } from '@/lib/custom-tools/application/operations'
@@ -20,13 +20,17 @@ import { toV2CustomTool } from '@/app/api/v2/custom-tools/utils'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const customToolResourceErrorPolicy = createV2ResourceConcealmentPolicy({
notFoundMessage: 'Custom tool not found',
})
/** GET /api/v2/custom-tools/[id] — Fetch a single custom tool. */
export const GET = defineV2JsonRoute({
contract: v2GetCustomToolContract,
operation: customToolOperations.read,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: customToolResourceErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, toolId: params.id }),
useCase: getWorkspaceCustomToolUseCase,
present: ({ tool }) => ({ data: { customTool: toV2CustomTool(tool) } }),
@@ -38,7 +42,7 @@ export const PATCH = defineV2JsonRoute({
operation: customToolOperations.update,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: customToolResourceErrorPolicy,
mapInput: ({ params, body }) => ({
...body,
toolId: params.id,
@@ -54,7 +58,7 @@ export const DELETE = defineV2JsonRoute({
operation: customToolOperations.delete,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: customToolResourceErrorPolicy,
mapInput: ({ params, query }) => ({
workspaceId: query.workspaceId,
toolId: params.id,
@@ -113,7 +113,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
expect(mocks.readMetadata).not.toHaveBeenCalled()
})
it('conceals an authorization failure as not found', async () => {
it('conceals cross-workspace authorization as not found', async () => {
mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError())
const response = await callGet(`workspaceId=${WORKSPACE_ID}`)
@@ -141,7 +141,7 @@ describe('v2 single-file routes', () => {
})
})
it('conceals download authorization failures', async () => {
it('conceals cross-workspace download authorization', async () => {
mocks.download.mockRejectedValue(new NoWorkspaceAccessError())
const response = await GET(
+7 -5
View File
@@ -38,8 +38,12 @@ function uploadStatus(status: string): V2UploadStatus {
import type { Principal } from '@sim/auth/principal'
import type { NextRequest, NextResponse } from 'next/server'
import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes'
import { authenticateV2ApiKey } from '@/lib/api/server/routes/v2-api-key-auth'
import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
const uploadControlErrorPolicy = createV2ResourceConcealmentPolicy({
notFoundMessage: 'Upload session not found',
})
/** Re-authenticates the API key for each upload control leg. */
export async function authenticateUploadPrincipal(request: NextRequest): Promise<Principal> {
@@ -47,9 +51,7 @@ export async function authenticateUploadPrincipal(request: NextRequest): Promise
return auth.principal
}
/** Resource-ID upload controls conceal authorization failures as absence. */
/** Conceals cross-tenant upload-session authorization while preserving same-workspace denials. */
export function v2UploadControlError(error: unknown): NextResponse | null {
const response = v2CaughtOrchestrationError(error)
if (!response) return null
return response.status === 403 ? v2Error('NOT_FOUND', 'Upload session not found') : response
return uploadControlErrorPolicy.render(error)
}
@@ -71,7 +71,7 @@ export const DELETE = defineV2JsonRoute({
auth: v2ApiKeyAuth,
operation: knowledgeOperations.deleteDocument,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.default,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
documentId: params.documentId,
@@ -106,7 +106,7 @@ export const POST = defineV2BodyLifecycleRoute({
auth: v2ApiKeyAuth,
operation: knowledgeOperations.uploadDocument,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.documentUpload,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUploadAuthorization,
admission: {
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
+2 -2
View File
@@ -41,7 +41,7 @@ export const PATCH = defineV2JsonRoute({
auth: v2ApiKeyAuth,
operation: knowledgeOperations.update,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.default,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
parseOptions: {
invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'),
},
@@ -66,7 +66,7 @@ export const DELETE = defineV2JsonRoute({
auth: v2ApiKeyAuth,
operation: knowledgeOperations.delete,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.default,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
mapInput: ({ params, query }) => ({
knowledgeBaseId: params.id,
assertedWorkspaceId: query.workspaceId,
@@ -70,13 +70,14 @@ describe('POST /api/v2/knowledge/search', () => {
})
})
it('delegates normalized IDs through the semantic operation', async () => {
it('delegates normalized IDs and the selected search mode through the semantic operation', async () => {
const request = buildRequest(
JSON.stringify({
workspaceId: WORKSPACE_ID,
knowledgeBaseIds: 'kb-1',
query: 'hello',
topK: 10,
searchMode: 'hybrid',
})
)
@@ -91,6 +92,7 @@ describe('POST /api/v2/knowledge/search', () => {
query: 'hello',
topK: 10,
tagFilters: undefined,
searchMode: 'hybrid',
},
request,
})
@@ -14,7 +14,7 @@ export const POST = defineV2JsonRoute({
auth: v2ApiKeyAuth,
operation: knowledgeOperations.search,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2KnowledgeErrorPolicies.usage,
errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUsageAuthorization,
parseOptions: {
invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'),
},
@@ -26,6 +26,7 @@ export const POST = defineV2JsonRoute({
query: body.query,
topK: body.topK,
tagFilters: body.tagFilters,
searchMode: body.searchMode,
}),
useCase: searchKnowledge,
present: (result) => ({ data: result }),
@@ -24,7 +24,7 @@ vi.mock('@/lib/logs/application/get-public-log', () => ({
getPublicLog: { operation: { id: 'logs.read_detail' }, execute: mocks.execute },
}))
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { NoWorkspaceAccessError } from '@/lib/core/application'
import { GET } from '@/app/api/v2/logs/[runId]/route'
const auth = {
@@ -95,9 +95,7 @@ describe('GET /api/v2/logs/[runId]', () => {
})
it('conceals canonical workspace authorization as log not-found', async () => {
mocks.execute.mockRejectedValueOnce(
new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation')
)
mocks.execute.mockRejectedValueOnce(new NoWorkspaceAccessError())
const response = await GET(new NextRequest('http://localhost:3000/api/v2/logs/run-1'), {
params: Promise.resolve({ runId: 'run-1' }),
@@ -4,6 +4,10 @@
import type { mcpServers } from '@sim/db/schema'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
} from '@/lib/core/application'
const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => {
class MockV2ApiKeyUnauthenticatedError extends Error {}
@@ -178,4 +182,15 @@ describe('/api/v2/mcp-servers/[id]', () => {
expect(response.status).toBe(401)
expect(mocks.update).not.toHaveBeenCalled()
})
it('conceals cross-tenant access while preserving same-workspace role denials', async () => {
mocks.get.mockRejectedValueOnce(new NoWorkspaceAccessError())
expect((await GET(request('GET'), context)).status).toBe(404)
mocks.update.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
expect(
(await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID, name: 'New docs' }), context))
.status
).toBe(403)
})
})
@@ -4,9 +4,9 @@ import {
v2UpdateMcpServerContract,
} from '@/lib/api/contracts/v2/mcp-servers'
import {
createV2ResourceConcealmentPolicy,
defineV2JsonRoute,
v2ApiKeyAuth,
v2OrchestrationErrorPolicy,
v2RateLimits,
} from '@/lib/api/server/routes'
import { mcpServerOperations } from '@/lib/mcp/application/operations'
@@ -21,13 +21,17 @@ import { toV2McpServer } from '@/app/api/v2/mcp-servers/utils'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const mcpServerResourceErrorPolicy = createV2ResourceConcealmentPolicy({
notFoundMessage: 'MCP server not found',
})
/** GET /api/v2/mcp-servers/[id] — Fetch a single MCP server. */
export const GET = defineV2JsonRoute({
contract: v2GetMcpServerContract,
operation: mcpServerOperations.read,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: mcpServerResourceErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, serverId: params.id }),
useCase: getMcpServerUseCase,
present: ({ server }) => ({ data: { mcpServer: toV2McpServer(server) } }),
@@ -39,7 +43,7 @@ export const PATCH = defineV2JsonRoute({
operation: mcpServerOperations.update,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: mcpServerResourceErrorPolicy,
mapInput: ({ params, body }) => ({ ...body, serverId: params.id, source: 'api' as const }),
useCase: updateMcpServerUseCase,
present: ({ server }) => ({ data: { mcpServer: toV2McpServer(server) } }),
@@ -51,7 +55,7 @@ export const DELETE = defineV2JsonRoute({
operation: mcpServerOperations.delete,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: mcpServerResourceErrorPolicy,
mapInput: ({ params, query }) => ({
workspaceId: query.workspaceId,
serverId: params.id,
@@ -3,6 +3,10 @@
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
} from '@/lib/core/application'
const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => {
class MockV2ApiKeyUnauthenticatedError extends Error {}
@@ -165,4 +169,15 @@ describe('/api/v2/skills/[id]', () => {
expect(response.status).toBe(401)
expect(mocks.update).not.toHaveBeenCalled()
})
it('conceals cross-tenant access while preserving same-workspace role denials', async () => {
mocks.get.mockRejectedValueOnce(new NoWorkspaceAccessError())
expect((await GET(request('GET'), context)).status).toBe(404)
mocks.update.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
expect(
(await PATCH(request('PATCH', { workspaceId: WORKSPACE_ID, content: '# Updated' }), context))
.status
).toBe(403)
})
})
+8 -4
View File
@@ -4,9 +4,9 @@ import {
v2UpdateSkillContract,
} from '@/lib/api/contracts/v2/skills'
import {
createV2ResourceConcealmentPolicy,
defineV2JsonRoute,
v2ApiKeyAuth,
v2OrchestrationErrorPolicy,
v2RateLimits,
} from '@/lib/api/server/routes'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -21,13 +21,17 @@ import { toV2Skill } from '@/app/api/v2/skills/utils'
export const dynamic = 'force-dynamic'
export const revalidate = 0
const skillResourceErrorPolicy = createV2ResourceConcealmentPolicy({
notFoundMessage: 'Skill not found',
})
/** GET /api/v2/skills/[id] — Fetch a single skill, including its body. */
export const GET = defineV2JsonRoute({
contract: v2GetSkillContract,
operation: skillOperations.read,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: skillResourceErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: query.workspaceId, skillId: params.id }),
useCase: getSkillUseCase,
present: ({ skill }) => ({ data: { skill: toV2Skill(skill) } }),
@@ -39,7 +43,7 @@ export const PATCH = defineV2JsonRoute({
operation: skillOperations.update,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: skillResourceErrorPolicy,
mapInput: ({ params, body }) => ({
...body,
skillId: params.id,
@@ -69,7 +73,7 @@ export const DELETE = defineV2JsonRoute({
operation: skillOperations.delete,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
errorPolicy: skillResourceErrorPolicy,
mapInput: ({ params, query }) => ({
workspaceId: query.workspaceId,
skillId: params.id,
@@ -27,7 +27,7 @@ export const POST = defineV2JsonRoute({
useCase: addTableColumnUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
present: presentColumns,
})
@@ -38,7 +38,7 @@ export const PATCH = defineV2JsonRoute({
useCase: updateTableColumnUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
present: presentColumns,
})
@@ -49,7 +49,7 @@ export const DELETE = defineV2JsonRoute({
useCase: deleteTableColumnUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
present: presentColumns,
})
@@ -61,7 +61,7 @@ export const PATCH = defineV2JsonRoute({
useCase: updateTableUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
present: async (result) => {
rethrowUpdateFailure(result)
@@ -78,7 +78,7 @@ export const DELETE = defineV2JsonRoute({
useCase: deleteTableUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, query }) => ({ tableId: params.tableId, workspaceId: query.workspaceId }),
onSuccess: ({ result }) => {
captureServerEvent(
@@ -43,7 +43,7 @@ export const PATCH = defineV2JsonRoute({
useCase: updateTableViewUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, body }) => ({ ...params, ...body }),
present: presentView,
})
@@ -54,7 +54,7 @@ export const DELETE = defineV2JsonRoute({
useCase: deleteTableViewUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, query }) => ({ ...params, workspaceId: query.workspaceId }),
present: ({ viewId }) => ({ data: { id: viewId } }),
})
@@ -43,7 +43,7 @@ export const POST = defineV2JsonRoute({
useCase: createTableViewUseCase,
auth: v2ApiKeyAuth,
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2TableErrorPolicies.default,
errorPolicy: v2TableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
present: async ({ view }) => ({
data: {
-11
View File
@@ -191,17 +191,6 @@ export function v2CsvBodyCapError(request: { headers: Headers }): NextResponse |
)
}
/**
* Renders a failed {@link checkAccess} result on a MUTATION path: a missing
* table stays 404, a missing permission stays 403. Read paths instead mask both
* as 404 inline so cross-workspace resource existence is never leaked.
*/
export function v2TableAccessError(result: { ok: false; status: 404 | 403 }): NextResponse {
return result.status === 404
? v2Error('NOT_FOUND', 'Table not found')
: v2Error('FORBIDDEN', 'Access denied')
}
/**
* Maps a delete/write rejected by a table lock to the v2 `LOCKED` envelope,
* mirroring v1's {@link tableLockErrorResponse}. Returns `null` for anything
@@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/lib/api/server/routes', () => ({
createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
@@ -408,7 +408,7 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
expect(mockPreprocessExecution).not.toHaveBeenCalled()
})
it('masks a workspace-key/workflow mismatch as 404', async () => {
it('conceals a workspace-key/workflow mismatch as not found', async () => {
mockAuthenticateV2ApiKey.mockResolvedValue({
principal: {
kind: 'workspace_api_key',
@@ -539,6 +539,28 @@ describe('POST /api/v2/workflows/[id]/execute', () => {
expect(asyncRes.status).toBe(400)
})
it('returns not found when a public workflow disappears before authorization', async () => {
dbChainMockFns.limit.mockReset()
dbChainMockFns.limit.mockResolvedValueOnce([
{ isPublicApi: true, isDeployed: true, userId: 'owner-1', workspaceId: 'workspace-1' },
])
mockAuthorize.mockResolvedValueOnce({
allowed: false,
status: 404,
message: 'Workflow not found',
workflow: null,
workspacePermission: null,
})
const response = await callPublicExecute({ input: {} })
expect(response.status).toBe(404)
expect(await response.json()).toEqual({
error: { code: 'NOT_FOUND', message: 'Workflow not found' },
})
expect(mockPreprocessExecution).not.toHaveBeenCalled()
})
it('rejects anonymous abuse before looking up the workflow', async () => {
mockCheckPreAuthRate.mockResolvedValueOnce({
allowed: false,
@@ -244,9 +244,16 @@ export const POST = withRouteHandler(
userId,
action: 'read',
})
// Mask authorization failures as 404 so cross-workspace existence never leaks.
if (!workflowAuthorization.allowed || !workflowAuthorization.workflow) {
return v2Error('NOT_FOUND', 'Workflow not found')
if (workflowAuthorization.status === 404) {
return v2Error('NOT_FOUND', 'Workflow not found')
}
if (workflowAuthorization.status === 403) {
return v2Error('FORBIDDEN', 'Insufficient workspace permissions')
}
throw new Error(
`Unexpected workflow authorization status: ${workflowAuthorization.status}`
)
}
result = await executeWorkflowService({
workflowId,
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition)
vi.mock('@/lib/api/server/routes', () => ({
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn((options) => options),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
@@ -20,7 +20,7 @@ import { workflowOperations } from '@/lib/workflows/application/operations'
import { GET } from '@/app/api/v2/workflows/[id]/export/route'
describe('/api/v2/workflows/[id]/export route definition', () => {
it('uses canonical workflow authorization with concealment', () => {
it('uses canonical workflow authorization with tenant-boundary concealment', () => {
expect(GET).toMatchObject({
operation: workflowOperations.export,
useCase: exportWorkflow,
@@ -9,7 +9,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/lib/api/server/routes', () => ({
createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
@@ -134,7 +134,7 @@ describe('/api/v2/workflows/[id]', () => {
})
})
it('preserves the personal-key-disabled 403 instead of concealing it', async () => {
it('returns the personal-key-disabled policy failure as forbidden', async () => {
mocks.readWorkflow.mockRejectedValue(new PersonalApiKeysDisabledError())
const response = await GET(
new NextRequest(`http://localhost/api/v2/workflows/${WORKFLOW_ID}`),
@@ -35,7 +35,11 @@ vi.mock('@/lib/api/server/routes', () => {
return {
admitV2Request: mocks.admit,
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ render: renderOrchestrationError })),
createV2ResourceConcealmentPolicy: vi.fn(
({ render }: { render?: (error: unknown) => Response | null }) => ({
render: render ?? renderOrchestrationError,
})
),
V2RouteInfrastructureError,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition)
vi.mock('@/lib/api/server/routes', () => ({
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn((options) => options),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition)
vi.mock('@/lib/api/server/routes', () => ({
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn((options) => options),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
v2ApiKeyAuth: { kind: 'v2-api-key' },
v2RateLimits: { publicApi: { kind: 'public-api' } },
@@ -29,6 +29,7 @@ vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({
},
}))
import { WorkspaceFileItemsNotFoundError } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import { POST as RESTORE } from '@/app/api/workspaces/[id]/files/folders/[folderId]/restore/route'
import { DELETE, PATCH } from '@/app/api/workspaces/[id]/files/folders/[folderId]/route'
@@ -116,6 +117,19 @@ describe('/api/workspaces/[id]/files/folders/[folderId]', () => {
})
})
it('returns not found when deleting an already archived folder', async () => {
mocks.deleteFolder.mockRejectedValueOnce(new WorkspaceFileItemsNotFoundError([], [FOLDER_ID]))
const response = await DELETE(request('DELETE'), context)
expect(response.status).toBe(404)
expect(await response.json()).toEqual({
success: false,
error: `Workspace file items not found (folders: ${FOLDER_ID})`,
})
expect(mocks.captureServerEvent).not.toHaveBeenCalled()
})
it('restores a folder through the shared use case', async () => {
const response = await RESTORE(request('POST'), context)
+3 -3
View File
@@ -1835,7 +1835,7 @@ export function useImportCsv() {
},
onError: (error) => {
logger.error('Failed to start CSV import:', error)
toast.error(error.message, { duration: 5000 })
toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
@@ -1879,7 +1879,7 @@ export function useImportFileAsTable() {
},
onError: (error) => {
logger.error('Failed to start import from file:', error)
toast.error(error.message, { duration: 5000 })
toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 })
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: tableKeys.lists() })
@@ -1936,7 +1936,7 @@ export function useImportCsvIntoTable() {
onError: (error, variables) => {
if (handleTableLockRejection(error, queryClient, variables.tableId)) return
logger.error('Failed to start CSV import:', error)
toast.error(error.message, { duration: 5000 })
toast.error(extractValidationIssues(error)[0]?.message ?? error.message, { duration: 5000 })
},
onSettled: (_data, _error, variables) => {
invalidateRowCount(queryClient, variables.tableId)
+3 -3
View File
@@ -34,7 +34,7 @@ import {
SORT_DIRECTIONS,
TABLE_LIMITS,
} from '@/lib/table/constants'
import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import'
import { CSV_SYNC_MAX_FILE_SIZE_BYTES, CSV_SYNC_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import'
import {
getTablePredicateTreeSizeError,
MAX_PREDICATE_GROUP_SIZE,
@@ -1063,10 +1063,10 @@ export const csvFileSchema = z
ctx.addIssue({ code: 'custom', message: 'CSV file is required' })
return
}
if (value.size > CSV_MAX_FILE_SIZE_BYTES) {
if (value.size > CSV_SYNC_MAX_FILE_SIZE_BYTES) {
ctx.addIssue({
code: 'custom',
message: CSV_MAX_FILE_SIZE_MESSAGE,
message: CSV_SYNC_MAX_FILE_SIZE_MESSAGE,
})
}
})
@@ -11,7 +11,7 @@ import {
v2UpdateTableColumnBodySchema,
} from '@/lib/api/contracts/v2/tables'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
@@ -85,10 +85,12 @@ function existingTableImport(overrides: Record<string, unknown> = {}) {
describe('v2 table import contracts', () => {
it('accepts the exact CSV byte limit and rejects one byte over it', () => {
expect(
v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES)).success
v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_DURABLE_MAX_FILE_SIZE_BYTES))
.success
).toBe(true)
expect(
v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_MAX_FILE_SIZE_BYTES + 1)).success
v2TableUploadImportSourceSchema.safeParse(uploadSource(CSV_DURABLE_MAX_FILE_SIZE_BYTES + 1))
.success
).toBe(false)
})
+5 -2
View File
@@ -62,7 +62,10 @@ import {
v2UploadTransferSchema,
} from '@/lib/api/contracts/v2/uploads'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import'
import {
CSV_DURABLE_MAX_FILE_SIZE_BYTES,
CSV_DURABLE_MAX_FILE_SIZE_MESSAGE,
} from '@/lib/table/import'
import type { RowData } from '@/lib/table/types'
/**
@@ -1405,7 +1408,7 @@ export const v2TableUploadImportSourceSchema = z
.number()
.int()
.min(1)
.max(CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE)
.max(CSV_DURABLE_MAX_FILE_SIZE_BYTES, CSV_DURABLE_MAX_FILE_SIZE_MESSAGE)
.describe('Exact CSV file size in bytes.'),
})
.strict()
@@ -10,6 +10,7 @@ import {
PersonalApiKeysDisabledError,
PrincipalKindAuthorizationError,
WorkspaceApiKeyAuthorizationError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
@@ -59,16 +60,15 @@ const policies: Array<{
},
]
const resourceAuthorizationErrors = [
const crossTenantAuthorizationErrors = [
new NoWorkspaceAccessError(),
new WorkspaceApiKeyAuthorizationError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'),
]
describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => {
it.each(resourceAuthorizationErrors)(
'conceals typed resource authorization: %s',
it.each(crossTenantAuthorizationErrors)(
'conceals cross-tenant authorization: %s',
async (error) => {
const response = policy.render(error)
expect(response?.status).toBe(404)
@@ -97,6 +97,15 @@ describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessa
})
})
it.each([
new WorkspaceApiKeyAuthorizationError(),
new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'),
])('preserves same-workspace principal policy denial as forbidden: %s', async (error) => {
const response = policy.render(error)
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toMatchObject({ error: { code: 'FORBIDDEN' } })
})
it('does not classify generic forbidden errors by message', async () => {
const response = policy.render(
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
@@ -2,23 +2,24 @@ import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route'
import {
DelegatedWorkspaceAuthorizationError,
NoWorkspaceAccessError,
PrincipalKindAuthorizationError,
WorkspaceApiKeyAuthorizationError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
type V2ErrorRenderer = V2ErrorPolicy['render']
function isResourceAuthorizationError(error: unknown): boolean {
function isCrossTenantAuthorizationError(error: unknown): boolean {
return (
error instanceof DelegatedWorkspaceAuthorizationError ||
error instanceof NoWorkspaceAccessError ||
error instanceof PrincipalKindAuthorizationError ||
error instanceof WorkspaceApiKeyAuthorizationError
error instanceof WorkspaceApiKeyScopeAuthorizationError
)
}
/** Conceals only typed resource-authorization failures without hiding workspace policy denials. */
/**
* Conceals cross-tenant authorization failures while preserving same-workspace
* policy and role denials as 403 responses.
*/
export function createV2ResourceConcealmentPolicy(options: {
notFoundMessage: string
render?: V2ErrorRenderer
@@ -26,7 +27,7 @@ export function createV2ResourceConcealmentPolicy(options: {
const render = options.render ?? v2CaughtOrchestrationError
return {
render(error) {
if (isResourceAuthorizationError(error)) {
if (isCrossTenantAuthorizationError(error)) {
return v2Error('NOT_FOUND', options.notFoundMessage)
}
return render(error)
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
getOrgWorkspaceIds: vi.fn(),
buildOrgScopeCondition: vi.fn(),
buildFilterConditions: vi.fn(),
decodeAuditLogCursor: vi.fn(),
queryAuditLogs: vi.fn(),
recordAudit: vi.fn(),
}))
@@ -22,6 +23,7 @@ vi.mock('@/lib/audit-logs/query', () => ({
getOrgWorkspaceIds: mocks.getOrgWorkspaceIds,
buildOrgScopeCondition: mocks.buildOrgScopeCondition,
buildFilterConditions: mocks.buildFilterConditions,
decodeAuditLogCursor: mocks.decodeAuditLogCursor,
queryAuditLogs: mocks.queryAuditLogs,
}))
@@ -58,6 +60,10 @@ describe('audit-log application use cases', () => {
mocks.getOrgWorkspaceIds.mockResolvedValue(['workspace-1'])
mocks.buildOrgScopeCondition.mockReturnValue({ type: 'scope' })
mocks.buildFilterConditions.mockReturnValue([])
mocks.decodeAuditLogCursor.mockReturnValue({
createdAt: '2026-01-01T00:00:00.000Z',
id: 'audit-1',
})
mocks.queryAuditLogs.mockResolvedValue({ data: [], nextCursor: undefined })
})
@@ -96,6 +102,20 @@ describe('audit-log application use cases', () => {
expect(mocks.queryAuditLogs).not.toHaveBeenCalled()
})
it('rejects a malformed cursor instead of restarting at page one', async () => {
mocks.decodeAuditLogCursor.mockReturnValueOnce(null)
await expect(
listAuditLogs.execute({
principal: sessionPrincipal,
input: { ...listInput, cursor: 'not-a-cursor' },
})
).rejects.toMatchObject({ code: 'validation', message: 'Invalid audit-log cursor' })
expect(mocks.getOrgWorkspaceIds).not.toHaveBeenCalled()
expect(mocks.queryAuditLogs).not.toHaveBeenCalled()
})
it('returns a typed not-found only after applying organization scope', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])
@@ -4,6 +4,7 @@ import {
type AuditLogFilterParams,
buildFilterConditions,
buildOrgScopeCondition,
decodeAuditLogCursor,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/lib/audit-logs/query'
@@ -23,6 +24,9 @@ export const listAuditLogs = defineAuthorizedAuditLogUseCase({
operation: auditLogOperations.list,
organizationId: (input: ListAuditLogsInput) => input.organizationId,
execute: async ({ input, context }): Promise<ListAuditLogsResult> => {
if (input.cursor && !decodeAuditLogCursor(input.cursor)) {
throw new OrchestrationError('validation', 'Invalid audit-log cursor')
}
const orgWorkspaceIds = await getOrgWorkspaceIds(context.organizationId)
if (input.filters.workspaceId && !orgWorkspaceIds.includes(input.filters.workspaceId)) {
throw new OrchestrationError('validation', 'workspaceId does not belong to your organization')
+29 -1
View File
@@ -7,7 +7,11 @@
*/
import { dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query'
import {
buildOrgScopeCondition,
decodeAuditLogCursor,
getOrgWorkspaceIds,
} from '@/lib/audit-logs/query'
const ORG_ID = 'org-1'
const MEMBER_IDS = ['user-1', 'user-2']
@@ -167,3 +171,27 @@ describe('getOrgWorkspaceIds', () => {
)
})
})
describe('decodeAuditLogCursor', () => {
it('accepts the exact timestamp and ID cursor shape', () => {
const cursor = Buffer.from(
JSON.stringify({ createdAt: '2026-01-01T00:00:00.000Z', id: 'audit-1' })
).toString('base64')
expect(decodeAuditLogCursor(cursor)).toEqual({
createdAt: '2026-01-01T00:00:00.000Z',
id: 'audit-1',
})
})
it.each([
'not-base64',
Buffer.from('{}').toString('base64'),
Buffer.from(JSON.stringify({ createdAt: 'not-a-date', id: 'audit-1' })).toString('base64'),
Buffer.from(JSON.stringify({ createdAt: '2026-01-01T00:00:00.000Z', id: 1 })).toString(
'base64'
),
])('rejects malformed cursor %s', (cursor) => {
expect(decodeAuditLogCursor(cursor)).toBeNull()
})
})
+15 -6
View File
@@ -15,9 +15,20 @@ function encodeCursor(data: CursorData): string {
return Buffer.from(JSON.stringify(data)).toString('base64')
}
function decodeCursor(cursor: string): CursorData | null {
export function decodeAuditLogCursor(cursor: string): CursorData | null {
try {
return JSON.parse(Buffer.from(cursor, 'base64').toString())
const decoded: unknown = JSON.parse(Buffer.from(cursor, 'base64').toString())
if (!decoded || typeof decoded !== 'object' || Array.isArray(decoded)) return null
const { createdAt, id } = decoded as Partial<CursorData>
if (
typeof createdAt !== 'string' ||
Number.isNaN(new Date(createdAt).getTime()) ||
typeof id !== 'string' ||
!id
) {
return null
}
return { createdAt, id }
} catch {
return null
}
@@ -116,11 +127,9 @@ export function buildOrgScopeCondition(params: OrgScopeParams): SQL<unknown> {
}
function buildCursorCondition(cursor: string): SQL<unknown> | null {
const cursorData = decodeCursor(cursor)
if (!cursorData?.createdAt || !cursorData.id) return null
const cursorData = decodeAuditLogCursor(cursor)
if (!cursorData) return null
const cursorDate = new Date(cursorData.createdAt)
if (Number.isNaN(cursorDate.getTime())) return null
return or(
lt(auditLog.createdAt, cursorDate),
@@ -22,7 +22,7 @@ const mocks = vi.hoisted(() => ({
getUserStorageLimit: vi.fn(),
getUserStorageUsage: vi.fn(),
getUsageLogs: vi.fn(),
getCredits: vi.fn(),
getWorkspaceUsageLogs: vi.fn(),
recordAudit: vi.fn(),
}))
@@ -56,7 +56,7 @@ vi.mock('@/lib/billing/core/subscription', () => ({
vi.mock('@/lib/billing/core/usage-log', () => ({
deriveBillingContext: mocks.deriveBillingContext,
getUserUsageLogs: mocks.getUsageLogs,
getUsageCreditsByLogId: mocks.getCredits,
getWorkspaceUsageLogs: mocks.getWorkspaceUsageLogs,
}))
vi.mock('@/lib/billing/storage', () => ({
@@ -123,7 +123,11 @@ describe('billing application use cases', () => {
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: false },
})
mocks.getCredits.mockResolvedValue({})
mocks.getWorkspaceUsageLogs.mockResolvedValue({
logs: [],
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: false },
})
})
it('rejects unsupported principals before protected loading', async () => {
@@ -186,7 +190,7 @@ describe('billing application use cases', () => {
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
})
it('uses the billing owner only as the workspace ledger attribution', async () => {
it('lists the complete workspace ledger for a workspace key', async () => {
await listBillingLogs.execute({
principal: workspacePrincipal,
input: {
@@ -196,14 +200,55 @@ describe('billing application use cases', () => {
},
})
expect(mocks.getUsageLogs).toHaveBeenCalledWith(
'billing-owner-1',
expect.objectContaining({ workspaceId: 'workspace-1' })
expect(mocks.getWorkspaceUsageLogs).toHaveBeenCalledWith(
'workspace-1',
expect.objectContaining({ includeSummary: false, limit: 50 })
)
expect(mocks.getUsageLogs).not.toHaveBeenCalled()
expect(mocks.resolvePermission).not.toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})
it('keeps personal-key billing logs actor-scoped and workspace-filtered', async () => {
await listBillingLogs.execute({
principal: personalPrincipal,
input: {
workspaceId: 'workspace-1',
startDate: new Date('2026-01-01T00:00:00Z'),
endDate: new Date('2026-02-01T00:00:00Z'),
limit: 50,
},
})
expect(mocks.getUsageLogs).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ workspaceId: 'workspace-1', includeSummary: false, limit: 50 })
)
expect(mocks.getWorkspaceUsageLogs).not.toHaveBeenCalled()
})
it('apportions credits only across the bounded page', async () => {
mocks.getWorkspaceUsageLogs.mockResolvedValueOnce({
logs: [
{ id: 'log-1', cost: 0.003 },
{ id: 'log-2', cost: 0.003 },
],
summary: { totalCost: 0, bySource: {} },
pagination: { hasMore: true, nextCursor: 'log-2' },
})
const result = await listBillingLogs.execute({
principal: workspacePrincipal,
input: {
startDate: new Date('2026-01-01T00:00:00Z'),
endDate: new Date('2026-02-01T00:00:00Z'),
limit: 2,
},
})
expect(result.creditsByLogId).toEqual({ 'log-1': 1, 'log-2': 0 })
})
it('propagates workspace-store failures', async () => {
const failure = new Error('database unavailable')
mocks.loadWorkspace.mockRejectedValueOnce(failure)
@@ -1,11 +1,11 @@
import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case'
import { billingOperations } from '@/lib/billing/application/operations'
import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution'
import {
getUsageCreditsByLogId,
getUserUsageLogs,
getWorkspaceUsageLogs,
type UsageLogSource,
} from '@/lib/billing/core/usage-log'
import { apportionCredits } from '@/lib/billing/credits/conversion'
export interface ListBillingLogsInput {
workspaceId?: string
@@ -26,31 +26,26 @@ export const listBillingLogs = defineAuthorizedBillingReadUseCase({
requestedWorkspaceId: (input: ListBillingLogsInput) => input.workspaceId,
execute: async ({ principal, input, scope }): Promise<ListBillingLogsResult> => {
const workspaceId = scope.kind === 'workspace' ? scope.workspace.workspaceId : undefined
let ledgerUserId: string
const query = {
source: input.source,
startDate: input.startDate,
endDate: input.endDate,
limit: input.limit,
cursor: input.cursor,
includeSummary: false,
}
let usage: ListBillingLogsResult['usage']
if (principal.kind === 'personal_api_key') {
ledgerUserId = principal.userId
usage = await getUserUsageLogs(principal.userId, { ...query, workspaceId })
} else {
if (scope.kind !== 'workspace') {
throw new Error('Workspace API key billing logs require a workspace scope')
}
ledgerUserId = (await resolveSystemBillingAttribution(scope.workspace.workspaceId))
.billedAccountUserId
usage = await getWorkspaceUsageLogs(scope.workspace.workspaceId, query)
}
const filter = {
source: input.source,
workspaceId,
startDate: input.startDate,
endDate: input.endDate,
}
const [usage, creditsByLogId] = await Promise.all([
getUserUsageLogs(ledgerUserId, {
...filter,
limit: input.limit,
cursor: input.cursor,
includeSummary: false,
}),
getUsageCreditsByLogId(ledgerUserId, filter),
])
const creditsByLogId = apportionCredits(
usage.logs.map((log) => ({ key: log.id, dollars: log.cost }))
)
return { usage, creditsByLogId }
},
})
@@ -36,6 +36,8 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({
import {
CUMULATIVE_COST_EPSILON,
CumulativeUsageContextMismatchError,
getUserUsageLogs,
getWorkspaceUsageLogs,
recordCumulativeUsage,
recordUsage,
resolveCumulativeTopUp,
@@ -390,3 +392,49 @@ describe('recordCumulativeUsage', () => {
expect(executedSqlContaining(tx, 'hashtextextended')).toBe(true)
})
})
interface MockCondition {
type?: string
conditions?: MockCondition[]
left?: string
right?: string
}
function latestWhereCondition(): MockCondition {
const condition = dbChainMockFns.where.mock.calls.at(-1)?.[0]
if (!condition) throw new Error('Expected a usage-log where condition')
return condition as MockCondition
}
describe('usage-log query scopes', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})
it('queries a complete workspace ledger without an actor predicate', async () => {
await getWorkspaceUsageLogs('workspace-1', { limit: 25, includeSummary: false })
expect(latestWhereCondition()).toMatchObject({
type: 'and',
conditions: [{ type: 'eq', left: 'workspaceId', right: 'workspace-1' }],
})
expect(dbChainMockFns.limit).toHaveBeenCalledWith(26)
})
it('keeps personal queries actor-scoped with an optional workspace filter', async () => {
await getUserUsageLogs('user-1', {
workspaceId: 'workspace-1',
limit: 25,
includeSummary: false,
})
expect(latestWhereCondition()).toMatchObject({
type: 'and',
conditions: [
{ type: 'eq', left: 'userId', right: 'user-1' },
{ type: 'eq', left: 'workspaceId', right: 'workspace-1' },
],
})
})
})
+31 -9
View File
@@ -613,8 +613,14 @@ interface UsageLogFilter {
endDate?: Date
}
function buildUsageLogConditions(userId: string, filter: UsageLogFilter) {
const conditions = [eq(usageLog.userId, userId)]
type UsageLogScope = { kind: 'user'; userId: string } | { kind: 'workspace'; workspaceId: string }
function buildUsageLogConditions(scope: UsageLogScope, filter: UsageLogFilter) {
const conditions = [
scope.kind === 'user'
? eq(usageLog.userId, scope.userId)
: eq(usageLog.workspaceId, scope.workspaceId),
]
if (filter.source) {
conditions.push(
Array.isArray(filter.source)
@@ -643,7 +649,7 @@ export async function getUsageCreditsByLogId(
const rows = await dbReplica
.select({ id: usageLog.id, cost: usageLog.cost })
.from(usageLog)
.where(and(...buildUsageLogConditions(userId, filter)))
.where(and(...buildUsageLogConditions({ kind: 'user', userId }, filter)))
.orderBy(desc(usageLog.createdAt), desc(usageLog.id))
return apportionCredits(
@@ -718,10 +724,10 @@ export interface UsageLogsResult {
}
/**
* Get usage logs for a user with optional filtering and pagination
* Gets one bounded usage-log page for an explicit actor or workspace scope.
*/
export async function getUserUsageLogs(
userId: string,
async function getUsageLogs(
scope: UsageLogScope,
options: GetUsageLogsOptions = {}
): Promise<UsageLogsResult> {
const {
@@ -736,7 +742,7 @@ export async function getUserUsageLogs(
} = options
try {
const conditions = buildUsageLogConditions(userId, { source, workspaceId, startDate, endDate })
const conditions = buildUsageLogConditions(scope, { source, workspaceId, startDate, endDate })
if (cursor) {
let resolvedCursorCreatedAt = cursorCreatedAt
@@ -803,7 +809,7 @@ export async function getUserUsageLogs(
let totalCost = 0
if (includeSummary) {
const summaryConditions = buildUsageLogConditions(userId, {
const summaryConditions = buildUsageLogConditions(scope, {
source,
workspaceId,
startDate,
@@ -841,9 +847,25 @@ export async function getUserUsageLogs(
} catch (error) {
logger.error('Failed to get usage logs', {
error: toError(error).message,
userId,
scope,
options,
})
throw error
}
}
/** Gets usage logs whose actor is the selected user. */
export function getUserUsageLogs(
userId: string,
options: GetUsageLogsOptions = {}
): Promise<UsageLogsResult> {
return getUsageLogs({ kind: 'user', userId }, options)
}
/** Gets usage logs attributed to the selected workspace, regardless of actor. */
export function getWorkspaceUsageLogs(
workspaceId: string,
options: Omit<GetUsageLogsOptions, 'workspaceId'> = {}
): Promise<UsageLogsResult> {
return getUsageLogs({ kind: 'workspace', workspaceId }, options)
}
+1
View File
@@ -24,6 +24,7 @@ export {
PrincipalKindAuthorizationError,
requireAllowedWorkspacePrincipal,
WorkspaceApiKeyAuthorizationError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application/workspace-authorization'
export {
defineWorkspaceOperation,
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
import type { SessionPrincipal } from '@sim/auth/principal'
import type { SessionPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -21,6 +21,7 @@ import {
defineWorkspaceOperation,
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
const writeOperation = defineWorkspaceOperation({
@@ -36,6 +37,19 @@ const principal: SessionPrincipal = {
sessionId: 'session-1',
}
const workspaceKeyOperation = defineWorkspaceOperation({
id: 'test.workspace-key-write',
minimumRole: 'write',
workspaceApiKey: 'allow',
principalKinds: ['workspace_api_key'],
})
const workspaceKeyPrincipal: WorkspaceApiKeyPrincipal = {
kind: 'workspace_api_key',
workspaceId: 'workspace-other',
keyId: 'key-1',
}
const context = {
workspaceId: 'workspace-1',
workspaceOrganizationId: 'organization-1',
@@ -70,4 +84,10 @@ describe('authorizeWorkspaceOperation', () => {
authorizeWorkspaceOperation(principal, writeOperation, context)
).resolves.toBeUndefined()
})
it('classifies a workspace-key tenant mismatch separately from role denials', async () => {
await expect(
authorizeWorkspaceOperation(workspaceKeyPrincipal, workspaceKeyOperation, context)
).rejects.toBeInstanceOf(WorkspaceApiKeyScopeAuthorizationError)
})
})
@@ -56,6 +56,13 @@ export class WorkspaceApiKeyAuthorizationError extends OrchestrationError {
}
}
export class WorkspaceApiKeyScopeAuthorizationError extends OrchestrationError {
constructor() {
super('forbidden', 'Workspace API key cannot access this workspace')
this.name = 'WorkspaceApiKeyScopeAuthorizationError'
}
}
export class DelegatedWorkspaceAuthorizationError extends OrchestrationError {
constructor() {
super('forbidden', 'Delegated workspace access is no longer valid')
@@ -139,8 +146,10 @@ export async function authorizeWorkspaceOperation<C extends WorkspaceAuthorizati
await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options)
return
case 'workspace_api_key':
if (principal.workspaceId !== context.workspaceId) {
throw new WorkspaceApiKeyScopeAuthorizationError()
}
if (
principal.workspaceId !== context.workspaceId ||
operation.workspaceApiKey !== 'allow' ||
!permissionSatisfies('write', operation.minimumRole)
) {
@@ -9,6 +9,7 @@ import {
PersonalApiKeysDisabledError,
PrincipalKindAuthorizationError,
WorkspaceApiKeyAuthorizationError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
@@ -16,10 +17,9 @@ import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
describe('v2 knowledge error policies', () => {
it.each([
new NoWorkspaceAccessError(),
new WorkspaceApiKeyAuthorizationError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
new PrincipalKindAuthorizationError('workspace_api_key', 'knowledge.read'),
])('conceals canonical resource authorization failures as absence', async (error) => {
])('conceals cross-tenant knowledge authorization failures', async (error) => {
const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(error)
expect(response?.status).toBe(404)
expect(await response?.json()).toEqual({
@@ -27,6 +27,15 @@ describe('v2 knowledge error policies', () => {
})
})
it.each([
new WorkspaceApiKeyAuthorizationError(),
new PrincipalKindAuthorizationError('workspace_api_key', 'knowledge.read'),
])('preserves same-workspace principal policy failures as forbidden', async (error) => {
const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(error)
expect(response?.status).toBe(403)
expect(await response?.json()).toMatchObject({ error: { code: 'FORBIDDEN' } })
})
it('preserves the personal-api-key policy failure as forbidden', async () => {
const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(
new PersonalApiKeysDisabledError()
@@ -49,4 +58,14 @@ describe('v2 knowledge error policies', () => {
error: { code: 'FORBIDDEN', message: 'Knowledge base transition is forbidden' },
})
})
it('preserves genuine not-found failures', async () => {
const response = v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization.render(
new OrchestrationError('not_found', 'Knowledge base not found')
)
expect(response?.status).toBe(404)
expect(await response?.json()).toEqual({
error: { code: 'NOT_FOUND', message: 'Knowledge base not found' },
})
})
})
+21 -11
View File
@@ -78,21 +78,31 @@ const v2KnowledgeUsageErrorPolicy = {
},
} satisfies V2ErrorPolicy
const v2KnowledgeDocumentUploadErrorPolicy = {
render(error) {
if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) {
return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message)
}
if (isPayloadSizeLimitError(error)) {
return v2Error('PAYLOAD_TOO_LARGE', error.message)
}
return v2KnowledgeUsageErrorPolicy.render(error)
},
} satisfies V2ErrorPolicy
export const v2KnowledgeErrorPolicies = {
default: v2OrchestrationErrorPolicy,
usage: v2KnowledgeUsageErrorPolicy,
documentUpload: {
render(error) {
if (error instanceof KnowledgeDocumentUnsupportedMediaTypeError) {
return v2Error('UNSUPPORTED_MEDIA_TYPE', error.message)
}
if (isPayloadSizeLimitError(error)) {
return v2Error('PAYLOAD_TOO_LARGE', error.message)
}
return v2KnowledgeUsageErrorPolicy.render(error)
},
} satisfies V2ErrorPolicy,
documentUpload: v2KnowledgeDocumentUploadErrorPolicy,
concealKnowledgeBaseAuthorization: createV2ResourceConcealmentPolicy({
notFoundMessage: 'Knowledge base not found',
}),
concealKnowledgeBaseUsageAuthorization: createV2ResourceConcealmentPolicy({
notFoundMessage: 'Knowledge base not found',
render: v2KnowledgeUsageErrorPolicy.render,
}),
concealKnowledgeBaseUploadAuthorization: createV2ResourceConcealmentPolicy({
notFoundMessage: 'Knowledge base not found',
render: v2KnowledgeDocumentUploadErrorPolicy.render,
}),
} as const
+7 -10
View File
@@ -1,14 +1,11 @@
import { type V2ErrorPolicy, v2OrchestrationErrorPolicy } from '@/lib/api/server/routes'
import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
import {
createV2ResourceConcealmentPolicy,
v2OrchestrationErrorPolicy,
} from '@/lib/api/server/routes'
export const v2LogErrorPolicies = {
default: v2OrchestrationErrorPolicy,
concealDetailAuthorization: {
render(error) {
const response = v2CaughtOrchestrationError(error)
if (!response) return null
if (response.status === 403) return v2Error('NOT_FOUND', 'Log not found')
return response
},
} satisfies V2ErrorPolicy,
concealDetailAuthorization: createV2ResourceConcealmentPolicy({
notFoundMessage: 'Log not found',
}),
} as const
@@ -0,0 +1,20 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { mcpServerOperations } from '@/lib/mcp/application/operations'
describe('MCP server operation registry', () => {
it('requires a human subject for tool discovery', () => {
expect(mcpServerOperations.discoverTools).toMatchObject({
workspaceApiKey: 'deny',
principalKinds: ['session', 'personal_api_key', 'delegated'],
delegatedServices: ['copilot'],
})
})
it('uses unique stable operation IDs', () => {
const ids = Object.values(mcpServerOperations).map((operation) => operation.id)
expect(new Set(ids).size).toBe(ids.length)
})
})
+6 -2
View File
@@ -4,6 +4,10 @@ const ALL_PRINCIPAL_POLICY = {
principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'],
delegatedServices: ['copilot'],
} as const
const HUMAN_PRINCIPAL_POLICY = {
principalKinds: ['session', 'personal_api_key', 'delegated'],
delegatedServices: ['copilot'],
} as const
export const mcpServerOperations = {
list: defineWorkspaceOperation({
@@ -15,8 +19,8 @@ export const mcpServerOperations = {
discoverTools: defineWorkspaceOperation({
id: 'mcp_servers.tools.discover',
minimumRole: 'read',
workspaceApiKey: 'allow',
...ALL_PRINCIPAL_POLICY,
workspaceApiKey: 'deny',
...HUMAN_PRINCIPAL_POLICY,
}),
listWorkflowDeployments: defineWorkspaceOperation({
id: 'mcp_servers.workflow_deployments.list',
+16 -1
View File
@@ -45,7 +45,7 @@ vi.mock('@/lib/mcp/queries', () => ({
listWorkspaceMcpServers: vi.fn(),
}))
import { createMcpServerUseCase } from '@/lib/mcp/application/use-cases'
import { createMcpServerUseCase, discoverMcpToolsUseCase } from '@/lib/mcp/application/use-cases'
type McpServerRow = typeof mcpServers.$inferSelect
const workspace = {
@@ -148,6 +148,21 @@ describe('MCP server application use cases', () => {
expect(mocks.effects).not.toHaveBeenCalled()
})
it('rejects workspace-key tool discovery before protected loading', async () => {
await expect(
discoverMcpToolsUseCase.execute({
principal: {
kind: 'workspace_api_key',
workspaceId: workspace.workspaceId,
keyId: 'workspace-key-1',
},
input: { workspaceId: workspace.workspaceId },
})
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.loadContext).not.toHaveBeenCalled()
})
it('fails fast when a post-audit domain effect fails', async () => {
mocks.effects.mockRejectedValueOnce(new Error('cache unavailable'))
+9 -3
View File
@@ -18,6 +18,7 @@ import type { ColumnType } from '@/lib/table/column-types'
import { parseCurrencyInput } from '@/lib/table/currency'
import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates'
import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types'
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
/**
* Field separators we sniff for, in tie-break priority order. Semicolon files are
@@ -230,10 +231,15 @@ export const CSV_MAX_BATCH_SIZE = 5000
/** Maximum serialized CSV row data retained before an import batch is flushed. */
export const CSV_MAX_BATCH_SIZE_BYTES = 5 * 1024 * 1024
/** Maximum CSV/TSV file size accepted by import routes (25 MB). */
export const CSV_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024
/** Maximum CSV/TSV size accepted by legacy multipart routes that buffer request bodies. */
export const CSV_SYNC_MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024
export const CSV_MAX_FILE_SIZE_MESSAGE = `File exceeds maximum allowed size of ${CSV_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`
export const CSV_SYNC_MAX_FILE_SIZE_MESSAGE = `File exceeds maximum allowed size of ${CSV_SYNC_MAX_FILE_SIZE_BYTES / (1024 * 1024)} MB`
/** Maximum CSV/TSV size accepted by the bounded streaming import worker. */
export const CSV_DURABLE_MAX_FILE_SIZE_BYTES = MAX_WORKSPACE_FILE_SIZE
export const CSV_DURABLE_MAX_FILE_SIZE_MESSAGE = 'File exceeds maximum allowed size of 5 GB'
/**
* Error thrown when the user-supplied mapping or CSV does not line up with the
@@ -46,7 +46,7 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({
}))
vi.mock('@/lib/users/queries', () => ({ getUserSettings: mockGetUserSettings }))
import { CSV_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
import { CSV_DURABLE_MAX_FILE_SIZE_BYTES } from '@/lib/table/import'
import { createAuthorizedTableImportResource } from '@/lib/table/orchestration/import-resource'
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
@@ -108,7 +108,7 @@ describe('createAuthorizedTableImportResource workspace file size', () => {
})
it('accepts a workspace CSV at the exact byte limit', async () => {
mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES))
mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_DURABLE_MAX_FILE_SIZE_BYTES))
const result = await createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET })
@@ -118,7 +118,7 @@ describe('createAuthorizedTableImportResource workspace file size', () => {
})
it('rejects a workspace CSV one byte over the limit before creating a table', async () => {
mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_MAX_FILE_SIZE_BYTES + 1))
mockGetWorkspaceFile.mockResolvedValue(workspaceFile(CSV_DURABLE_MAX_FILE_SIZE_BYTES + 1))
await expect(
createImport({ workspaceId: WORKSPACE_ID, source: SOURCE, target: TARGET })
@@ -150,13 +150,16 @@ describe('createAuthorizedTableImportResource upload size', () => {
type: 'upload',
name: 'data.csv',
contentType: 'text/csv',
size: CSV_MAX_FILE_SIZE_BYTES,
size: CSV_DURABLE_MAX_FILE_SIZE_BYTES,
},
target: TARGET,
})
expect(mockCreateUploadSession).toHaveBeenCalledWith(
expect.objectContaining({ fileSize: CSV_MAX_FILE_SIZE_BYTES, purpose: 'table_import' })
expect.objectContaining({
fileSize: CSV_DURABLE_MAX_FILE_SIZE_BYTES,
purpose: 'table_import',
})
)
})
@@ -168,7 +171,7 @@ describe('createAuthorizedTableImportResource upload size', () => {
type: 'upload',
name: 'data.csv',
contentType: 'text/csv',
size: CSV_MAX_FILE_SIZE_BYTES + 1,
size: CSV_DURABLE_MAX_FILE_SIZE_BYTES + 1,
},
target: TARGET,
})
@@ -22,7 +22,10 @@ import { runDetached } from '@/lib/core/utils/background'
import { generateRequestId } from '@/lib/core/utils/request'
import { findActiveFolder } from '@/lib/folders/queries'
import { getWorkspaceTableLimits } from '@/lib/table/billing'
import { CSV_MAX_FILE_SIZE_BYTES, CSV_MAX_FILE_SIZE_MESSAGE } from '@/lib/table/import'
import {
CSV_DURABLE_MAX_FILE_SIZE_BYTES,
CSV_DURABLE_MAX_FILE_SIZE_MESSAGE,
} from '@/lib/table/import'
import { runTableImport, type TableImportPayload } from '@/lib/table/import-runner'
import { markTableJobRunningInWorkspace } from '@/lib/table/jobs/service'
import { assertRowDelete, assertRowInsert } from '@/lib/table/mutation-locks'
@@ -85,8 +88,8 @@ async function createTableImportResourceCore(
if (body.source.type === 'upload') {
assertCsvFileName(body.source.name)
if (body.source.size > CSV_MAX_FILE_SIZE_BYTES) {
throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE)
if (body.source.size > CSV_DURABLE_MAX_FILE_SIZE_BYTES) {
throw new OrchestrationError('validation', CSV_DURABLE_MAX_FILE_SIZE_MESSAGE)
}
const upload = await createUploadSession({
id: importId,
@@ -523,8 +526,8 @@ async function requireWorkspaceSource(
if (!resolved || resolved.id !== fileId || resolved.workspaceId !== workspaceId) {
throw new OrchestrationError('not_found', 'Workspace file not found')
}
if (resolved.size > CSV_MAX_FILE_SIZE_BYTES) {
throw new OrchestrationError('validation', CSV_MAX_FILE_SIZE_MESSAGE)
if (resolved.size > CSV_DURABLE_MAX_FILE_SIZE_BYTES) {
throw new OrchestrationError('validation', CSV_DURABLE_MAX_FILE_SIZE_MESSAGE)
}
return resolved
}
@@ -48,15 +48,14 @@ export class WorkspaceFileMoveConflictError extends Error {
}
}
export class WorkspaceFileItemsNotFoundError extends Error {
readonly code = 'WORKSPACE_FILE_ITEMS_NOT_FOUND' as const
export class WorkspaceFileItemsNotFoundError extends OrchestrationError {
constructor(fileIds: string[], folderIds: string[]) {
const parts = [
fileIds.length > 0 ? `files: ${fileIds.join(', ')}` : null,
folderIds.length > 0 ? `folders: ${folderIds.join(', ')}` : null,
].filter(Boolean)
super(`Workspace file items not found (${parts.join('; ')})`)
super('not_found', `Workspace file items not found (${parts.join('; ')})`)
this.name = 'WorkspaceFileItemsNotFoundError'
}
}
@@ -9,6 +9,7 @@ import {
PersonalApiKeysDisabledError,
PrincipalKindAuthorizationError,
WorkspaceApiKeyAuthorizationError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -30,10 +31,9 @@ import {
describe('v2 workflow error policies', () => {
it.each([
new NoWorkspaceAccessError(),
new WorkspaceApiKeyAuthorizationError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'),
])('conceals workflow authorization failures as absence', async (error) => {
])('conceals cross-tenant workflow authorization failures', async (error) => {
const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error)
expect(response?.status).toBe(404)
expect(await response?.json()).toEqual({
@@ -41,6 +41,15 @@ describe('v2 workflow error policies', () => {
})
})
it.each([
new WorkspaceApiKeyAuthorizationError(),
new PrincipalKindAuthorizationError('workspace_api_key', 'workflows.deploy'),
])('preserves same-workspace principal policy failures as forbidden', async (error) => {
const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error)
expect(response?.status).toBe(403)
expect(await response?.json()).toMatchObject({ error: { code: 'FORBIDDEN' } })
})
it('preserves the personal-api-key workspace policy failure as forbidden', async () => {
const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(
new PersonalApiKeysDisabledError()
@@ -58,6 +67,7 @@ describe('v2 workflow error policies', () => {
const response = v2WorkflowErrorPolicies.concealRunAuthorization.render(
new NoWorkspaceAccessError()
)
expect(response?.status).toBe(404)
expect(await response?.json()).toEqual({
error: { code: 'NOT_FOUND', message: 'Run not found' },
})
@@ -72,6 +82,16 @@ describe('v2 workflow error policies', () => {
error: { code: 'FORBIDDEN', message: 'Workflow transition is forbidden' },
})
})
it('preserves genuine not-found failures', async () => {
const response = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(
new OrchestrationError('not_found', 'Workflow not found')
)
expect(response?.status).toBe(404)
expect(await response?.json()).toEqual({
error: { code: 'NOT_FOUND', message: 'Workflow not found' },
})
})
})
describe('internal workflow read auth', () => {
@@ -1,4 +1,3 @@
import type { Principal } from '@sim/auth/principal'
import { resolvePrincipalAttribution } from '@sim/auth/principal'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
@@ -14,19 +13,12 @@ export interface CancelWorkflowRunInput {
runId: string
}
function assertedWorkspaceId(principal: Principal): string | undefined {
return principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? principal.workspaceId
: undefined
}
export const cancelWorkflowRun = defineAuthorizedWorkflowUseCase({
operation: workflowOperations.cancelRun,
resolveContext: ({ principal, input }: { principal: Principal; input: CancelWorkflowRunInput }) =>
resolveContext: ({ input }: { input: CancelWorkflowRunInput }) =>
resolveActiveWorkflowRunApplicationContext({
runId: input.runId,
assertedWorkflowId: input.workflowId,
assertedWorkspaceId: assertedWorkspaceId(principal),
}),
async execute({ principal, context }) {
const attribution = resolvePrincipalAttribution(principal, {
@@ -24,23 +24,14 @@ export interface ExecuteWorkflowInput {
includeToolCalls?: boolean
}
function assertedWorkspaceId(principal: Principal): string | undefined {
return principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? principal.workspaceId
: undefined
}
function authenticatesExecutionCredentials(principal: Principal): boolean {
return principal.kind !== 'workspace_api_key'
}
export const executeWorkflowOperation = defineAuthorizedWorkflowUseCase({
operation: workflowOperations.execute,
resolveContext: ({ principal, input }: { principal: Principal; input: ExecuteWorkflowInput }) =>
resolveActiveWorkflowApplicationContext({
workflowId: input.workflowId,
assertedWorkspaceId: assertedWorkspaceId(principal),
}),
resolveContext: ({ input }: { input: ExecuteWorkflowInput }) =>
resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }),
async execute({ principal, context, input }): Promise<ExecuteWorkflowServiceResult> {
const attribution = resolvePrincipalAttribution(principal, {
workspaceBillingOwnerUserId: context.billedAccountUserId,
@@ -1,4 +1,3 @@
import type { Principal } from '@sim/auth/principal'
import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case'
import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/application/context'
import { workflowOperations } from '@/lib/workflows/application/operations'
@@ -11,19 +10,10 @@ export interface ListWorkflowRunsInput extends Omit<ListWorkflowExecutionsInput,
workflowId: string
}
function assertedWorkspaceId(principal: Principal): string | undefined {
return principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? principal.workspaceId
: undefined
}
export const listWorkflowRuns = defineAuthorizedWorkflowUseCase({
operation: workflowOperations.listRuns,
resolveContext: ({ principal, input }: { principal: Principal; input: ListWorkflowRunsInput }) =>
resolveActiveWorkflowApplicationContext({
workflowId: input.workflowId,
assertedWorkspaceId: assertedWorkspaceId(principal),
}),
resolveContext: ({ input }: { input: ListWorkflowRunsInput }) =>
resolveActiveWorkflowApplicationContext({ workflowId: input.workflowId }),
async execute({ context, input }) {
const result = await listWorkflowExecutions({
workflowId: context.workflowId,
@@ -1,11 +1,12 @@
import type { Principal } from '@sim/auth/principal'
/** Leaves scoped-principal workspace mismatches to canonical authorization so they remain 403s. */
export function assertedWorkflowWorkspaceId(
principal: Principal,
assertedWorkspaceId?: string
): string | undefined {
if (principal.kind === 'workspace_api_key' || principal.kind === 'delegated') {
return principal.workspaceId
return undefined
}
return assertedWorkspaceId
}
@@ -107,7 +107,7 @@ describe('Copilot workflow metadata application queries', () => {
expect(mocks.resolveContext).toHaveBeenCalledWith({
workflowId: 'workflow-1',
assertedWorkspaceId: 'workspace-1',
assertedWorkspaceId: undefined,
})
expect(result).toEqual({
blocks: [
@@ -169,7 +169,7 @@ describe('readWorkflowDeploymentOverview', () => {
expect(result.mcpToolsTruncated).toBe(true)
})
it('rejects a cross-workspace assertion before protected status loads', async () => {
it('returns forbidden for a cross-workspace delegated principal before protected status loads', async () => {
queueTableRows(schemaMock.workflow, [
{
workflowId: workflowRecord.id,
@@ -183,7 +183,7 @@ describe('readWorkflowDeploymentOverview', () => {
principal: { ...principal, workspaceId: 'workspace-2' },
input: { workflowId: workflowRecord.id },
})
).rejects.toMatchObject({ code: 'not_found' })
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.deploymentSummary).not.toHaveBeenCalled()
})
@@ -1,4 +1,3 @@
import type { Principal } from '@sim/auth/principal'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
FUNCTIONAL_OUTPUTS_UNAVAILABLE_MESSAGE,
@@ -16,19 +15,12 @@ export interface ReadWorkflowRunInput {
selectedOutputs: string[]
}
function assertedWorkspaceId(principal: Principal): string | undefined {
return principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? principal.workspaceId
: undefined
}
export const readWorkflowRun = defineAuthorizedWorkflowUseCase({
operation: workflowOperations.readRun,
resolveContext: ({ principal, input }: { principal: Principal; input: ReadWorkflowRunInput }) =>
resolveContext: ({ input }: { input: ReadWorkflowRunInput }) =>
resolveActiveWorkflowRunApplicationContext({
runId: input.runId,
assertedWorkflowId: input.workflowId,
assertedWorkspaceId: assertedWorkspaceId(principal),
}),
async execute({ context, input }) {
try {
@@ -1,4 +1,3 @@
import type { Principal } from '@sim/auth/principal'
import { resolvePrincipalAttribution } from '@sim/auth/principal'
import { defineAuthorizedWorkflowUseCase } from '@/lib/workflows/application/authorized-workflow-use-case'
import { resolveActiveWorkflowRunApplicationContext } from '@/lib/workflows/application/context'
@@ -12,19 +11,12 @@ export interface ResumeWorkflowRunInput {
resumeInput: unknown
}
function assertedWorkspaceId(principal: Principal): string | undefined {
return principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? principal.workspaceId
: undefined
}
export const resumeWorkflowRun = defineAuthorizedWorkflowUseCase({
operation: workflowOperations.resumeRun,
resolveContext: ({ principal, input }: { principal: Principal; input: ResumeWorkflowRunInput }) =>
resolveContext: ({ input }: { input: ResumeWorkflowRunInput }) =>
resolveActiveWorkflowRunApplicationContext({
runId: input.runId,
assertedWorkflowId: input.workflowId,
assertedWorkspaceId: assertedWorkspaceId(principal),
}),
async execute({ principal, input, context }) {
const attribution = resolvePrincipalAttribution(principal, {
@@ -126,7 +126,7 @@ describe('Copilot workflow run application commands', () => {
expect(result).toMatchObject({ success: true, output: { ok: true } })
expect(mocks.resolveContext).toHaveBeenCalledWith({
workflowId: 'workflow-1',
assertedWorkspaceId: 'workspace-1',
assertedWorkspaceId: undefined,
})
expect(mocks.permission).toHaveBeenCalledBefore(mocks.loadDraft)
expect(mocks.admission).toHaveBeenCalledWith(
@@ -276,18 +276,16 @@ describe('authorized workflow CRUD and version reads', () => {
expect(mocks.recordAudit).not.toHaveBeenCalled()
})
it('binds workspace keys to canonical workflow scope before protected reads', async () => {
mocks.resolveWorkflowContext.mockRejectedValue(new Error('canonical mismatch'))
it('returns forbidden when a workspace key does not match canonical workflow scope', async () => {
await expect(
readWorkflow.execute({
principal: { ...workspacePrincipal, workspaceId: 'workspace-other' },
input: { workflowId: WORKFLOW_ID },
})
).rejects.toThrow('canonical mismatch')
).rejects.toMatchObject({ code: 'forbidden' })
expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({
workflowId: WORKFLOW_ID,
assertedWorkspaceId: 'workspace-other',
assertedWorkspaceId: undefined,
})
expect(mocks.loadSnapshot).not.toHaveBeenCalled()
})
@@ -327,7 +325,7 @@ describe('authorized workflow CRUD and version reads', () => {
expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({
workflowId: WORKFLOW_ID,
assertedWorkspaceId: WORKSPACE_ID,
assertedWorkspaceId: undefined,
})
expect(mocks.resolvePermission).toHaveBeenCalledWith('user-1', WORKSPACE_ID, null, undefined, {
forUpdate: undefined,
@@ -121,10 +121,6 @@ describe('workflow run-control application use cases', () => {
expect(mocks.resolveRunContext).toHaveBeenCalledWith({
runId: 'parent-run-1',
assertedWorkflowId: 'workflow-1',
assertedWorkspaceId:
principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? 'workspace-1'
: undefined,
})
expect(mocks.cancel).toHaveBeenCalledWith({
executionId: 'parent-run-1',
@@ -153,10 +149,6 @@ describe('workflow run-control application use cases', () => {
expect(mocks.resolveRunContext).toHaveBeenCalledWith({
runId: 'parent-run-1',
assertedWorkflowId: 'workflow-1',
assertedWorkspaceId:
principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? 'workspace-1'
: undefined,
})
expect(mocks.resume).toHaveBeenCalledWith({
workflowId: 'workflow-1',
@@ -90,10 +90,6 @@ describe('workflow run application use cases', () => {
expect(mocks.resolveWorkflowContext).toHaveBeenCalledWith({
workflowId: 'workflow-1',
assertedWorkspaceId:
principal.kind === 'workspace_api_key' || principal.kind === 'delegated'
? 'workspace-1'
: undefined,
})
expect(mocks.list).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: 'workflow-1', limit: 25, order: 'desc' })
@@ -115,7 +111,6 @@ describe('workflow run application use cases', () => {
expect(mocks.resolveRunContext).toHaveBeenCalledWith({
runId: 'run-1',
assertedWorkflowId: 'workflow-1',
assertedWorkspaceId: 'workspace-1',
})
expect(mocks.getStatus).toHaveBeenCalledWith({
workflowId: 'workflow-1',
@@ -63,6 +63,18 @@ describe('file operation registry', () => {
expect(fileOperations.updateShare.delegatedServices).toEqual(['copilot', 'executor'])
})
it('keeps resumable workspace-file uploads on credential-bound principals', () => {
for (const operation of [
fileOperations.uploadCreate,
fileOperations.uploadParts,
fileOperations.uploadComplete,
fileOperations.uploadCancel,
]) {
expect(operation.principalKinds).toEqual(['session', 'personal_api_key', 'workspace_api_key'])
expect(operation.delegatedServices).toBeUndefined()
}
})
it('restricts compiled checks to authenticated sessions', () => {
expect(fileOperations.compiledCheck).toMatchObject({
id: 'files.compiled_check',
@@ -12,6 +12,9 @@ const HUMAN_FILE_TOOL_PRINCIPAL_POLICY = {
principalKinds: ['session', 'personal_api_key', 'delegated'],
delegatedServices: ['copilot', 'executor'],
} as const
const UPLOAD_PRINCIPAL_POLICY = {
principalKinds: ['session', 'personal_api_key', 'workspace_api_key'],
} as const
export const fileOperations = {
list: defineWorkspaceOperation({
@@ -153,25 +156,25 @@ export const fileOperations = {
id: 'files.upload.create',
minimumRole: 'write',
workspaceApiKey: 'allow',
...ALL_COPILOT_PRINCIPAL_POLICY,
...UPLOAD_PRINCIPAL_POLICY,
}),
uploadParts: defineWorkspaceOperation({
id: 'files.upload.parts',
minimumRole: 'write',
workspaceApiKey: 'allow',
...ALL_COPILOT_PRINCIPAL_POLICY,
...UPLOAD_PRINCIPAL_POLICY,
}),
uploadComplete: defineWorkspaceOperation({
id: 'files.upload.complete',
minimumRole: 'write',
workspaceApiKey: 'allow',
...ALL_COPILOT_PRINCIPAL_POLICY,
...UPLOAD_PRINCIPAL_POLICY,
}),
uploadCancel: defineWorkspaceOperation({
id: 'files.upload.cancel',
minimumRole: 'write',
workspaceApiKey: 'allow',
...ALL_COPILOT_PRINCIPAL_POLICY,
...UPLOAD_PRINCIPAL_POLICY,
}),
} as const
+44 -2
View File
@@ -21,7 +21,7 @@ describe('queryPublicWorkspaceMembers', () => {
image: null,
role: 'write',
joinedAt: new Date('2026-01-01T00:00:00.000Z'),
userOrganizationId: 'org-1',
hasRelevantOrganizationMembership: true,
},
{
userId: 'user-2',
@@ -30,7 +30,7 @@ describe('queryPublicWorkspaceMembers', () => {
image: null,
role: 'read',
joinedAt: new Date('2026-01-02T00:00:00.000Z'),
userOrganizationId: null,
hasRelevantOrganizationMembership: false,
},
])
queueTableRows(schemaMock.member, [
@@ -67,6 +67,48 @@ describe('queryPublicWorkspaceMembers', () => {
])
expect(page?.nextEmail).toBeNull()
expect(dbChainMockFns.limit).toHaveBeenCalledWith(11)
expect(dbChainMockFns.leftJoin).not.toHaveBeenCalled()
})
it('marks organization members as external collaborators on a personal workspace', async () => {
queueTableRows(schemaMock.workspace, [{ ownerId: 'user-1', organizationId: null }])
queueTableRows(schemaMock.permissions, [
{
userId: 'user-1',
email: 'ada@example.com',
name: 'Ada',
image: null,
role: 'admin',
joinedAt: new Date('2026-01-01T00:00:00.000Z'),
hasRelevantOrganizationMembership: true,
},
{
userId: 'user-2',
email: 'grace@example.com',
name: 'Grace',
image: null,
role: 'read',
joinedAt: new Date('2026-01-02T00:00:00.000Z'),
hasRelevantOrganizationMembership: true,
},
{
userId: 'user-3',
email: 'katherine@example.com',
name: 'Katherine',
image: null,
role: 'read',
joinedAt: new Date('2026-01-03T00:00:00.000Z'),
hasRelevantOrganizationMembership: false,
},
])
const page = await queryPublicWorkspaceMembers('workspace-1', { limit: 10 })
expect(page?.members.map(({ email, isExternal }) => ({ email, isExternal }))).toEqual([
{ email: 'ada@example.com', isExternal: false },
{ email: 'grace@example.com', isExternal: true },
{ email: 'katherine@example.com', isExternal: false },
])
})
it('returns null when the workspace is not active', async () => {
+16 -3
View File
@@ -117,6 +117,18 @@ export async function queryPublicWorkspaceMembers(
const emailOrder = sql<string>`${user.email} COLLATE "C"`
const emailCursor = options.afterEmail ? gt(emailOrder, options.afterEmail) : undefined
const sourceLimit = options.limit + 1
const hasRelevantOrganizationMembership = workspaceRow.organizationId
? sql<boolean>`EXISTS (
SELECT 1
FROM ${member}
WHERE ${member.userId} = ${user.id}
AND ${member.organizationId} = ${workspaceRow.organizationId}
)`
: sql<boolean>`EXISTS (
SELECT 1
FROM ${member}
WHERE ${member.userId} = ${user.id}
)`
const explicitPromise = db
.select({
@@ -126,11 +138,10 @@ export async function queryPublicWorkspaceMembers(
image: user.image,
role: permissions.permissionType,
joinedAt: permissions.createdAt,
userOrganizationId: member.organizationId,
hasRelevantOrganizationMembership,
})
.from(permissions)
.innerJoin(user, eq(permissions.userId, user.id))
.leftJoin(member, eq(member.userId, user.id))
.where(
and(
eq(permissions.entityType, 'workspace'),
@@ -175,7 +186,9 @@ export async function queryPublicWorkspaceMembers(
role: row.role,
isExternal:
row.userId !== workspaceRow.ownerId &&
row.userOrganizationId !== workspaceRow.organizationId,
(workspaceRow.organizationId
? !row.hasRelevantOrganizationMembership
: row.hasRelevantOrganizationMembership),
joinedAt: row.joinedAt,
})
}