fix(api): conceal cross-tenant resource denials on internal routes (#6586)

The v2 routes rewrite DelegatedWorkspaceAuthorizationError,
NoWorkspaceAccessError, and WorkspaceApiKeyScopeAuthorizationError to a
404 so a caller with no reach into a workspace cannot confirm a
resource exists. The internal routes reach the same application use
cases and still answered 403, so the same probe worked from the other
surface. Same-workspace role denials stay 403 on both.
This commit is contained in:
Waleed
2026-08-11 19:50:04 -07:00
committed by GitHub
parent 5e1862ed20
commit 083319be8b
37 changed files with 778 additions and 278 deletions
@@ -1,10 +1,6 @@
import { createTableExportResourceContract } from '@/lib/api/contracts/table-transfers'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { createTableExportUseCase } from '@/lib/table/application/exports'
import { tableOperations } from '@/lib/table/application/operations'
import { toV2TableExport } from '@/lib/table/orchestration/export-resource'
@@ -16,7 +12,7 @@ export const POST = defineInternalJsonRoute({
rateLimit: internalRateLimits.none({
reason: 'Existing authenticated table export creation has no request-rate policy',
}),
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealTableAuthorization,
mapInput: ({ params, body }) => ({
tableId: params.tableId,
workspaceId: body.workspaceId,
@@ -6,6 +6,7 @@ import { describe, expect, it, vi } from 'vitest'
interface CapturedDefinition {
contract: { method: string; path: string }
auth: unknown
errorPolicy: unknown
operation: { id: string }
useCase: unknown
mapInput(input: {
@@ -21,6 +22,7 @@ interface CapturedDefinition {
const mocks = vi.hoisted(() => ({
auth: { kind: 'session-or-executor' },
concealTableGroupAuthorization: { kind: 'conceal-table-group' },
definitions: [] as CapturedDefinition[],
useCases: {
create: { operation: { id: 'tables.groups.create' } },
@@ -34,15 +36,17 @@ vi.mock('@/lib/api/server/routes', () => ({
mocks.definitions.push(definition)
return vi.fn()
},
extendInternalErrorPolicy: vi.fn(() => ({ kind: 'table' })),
internalErrorResponse: vi.fn(),
internalOrchestrationErrorPolicy: { kind: 'plain' },
internalRateLimits: {
none: ({ reason }: { reason: string }) => ({ kind: 'none', reason }),
},
}))
vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth }))
vi.mock('@/lib/table/api', () => ({
internalTableErrorPolicies: {
concealTableGroupAuthorization: mocks.concealTableGroupAuthorization,
},
internalTableSessionOrExecutorAuth: mocks.auth,
}))
vi.mock('@/lib/table/application/groups', () => ({
createTableGroupUseCase: mocks.useCases.create,
@@ -77,6 +81,7 @@ describe('/api/table/[tableId]/groups', () => {
expect(route.auth).toBe(mocks.auth)
expect(route.useCase).toBe(useCase)
expect(route.operation.id).toBe(useCase.operation.id)
expect(route.errorPolicy).toBe(mocks.concealTableGroupAuthorization)
}
})
@@ -3,30 +3,17 @@ import {
deleteWorkflowGroupContract,
updateWorkflowGroupContract,
} from '@/lib/api/contracts/tables'
import {
defineInternalJsonRoute,
extendInternalErrorPolicy,
internalErrorResponse,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import {
createTableGroupUseCase,
deleteTableGroupUseCase,
updateTableGroupUseCase,
} from '@/lib/table/application/groups'
import { tableOperations } from '@/lib/table/application/operations'
import { TableLockedError } from '@/lib/table/mutation-locks'
import type { TableDefinition } from '@/lib/table/types'
import { normalizeColumn } from '@/app/api/table/utils'
const errorPolicy = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) =>
error instanceof TableLockedError
? internalErrorResponse(423, { error: error.message, lock: error.lock })
: null
)
const rateLimit = internalRateLimits.none({
reason: 'Existing authenticated table group mutations have no request-rate policy',
})
@@ -47,7 +34,7 @@ export const POST = defineInternalJsonRoute({
useCase: createTableGroupUseCase,
auth: internalTableSessionOrExecutorAuth,
rateLimit,
errorPolicy,
errorPolicy: internalTableErrorPolicies.concealTableGroupAuthorization,
mapInput: ({ params, body }) => ({
tableId: params.tableId,
...body,
@@ -62,7 +49,7 @@ export const PATCH = defineInternalJsonRoute({
useCase: updateTableGroupUseCase,
auth: internalTableSessionOrExecutorAuth,
rateLimit,
errorPolicy,
errorPolicy: internalTableErrorPolicies.concealTableGroupAuthorization,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
present: ({ table }) => presentTable(table),
})
@@ -73,7 +60,7 @@ export const DELETE = defineInternalJsonRoute({
useCase: deleteTableGroupUseCase,
auth: internalTableSessionOrExecutorAuth,
rateLimit,
errorPolicy,
errorPolicy: internalTableErrorPolicies.concealTableGroupAuthorization,
mapInput: ({ params, body }) => ({ tableId: params.tableId, ...body }),
present: ({ table }) => presentTable(table),
})
@@ -1,10 +1,6 @@
import { downloadTableExportResourceContract } from '@/lib/api/contracts/table-transfers'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { downloadTableExportUseCase } from '@/lib/table/application/exports'
import { tableOperations } from '@/lib/table/application/operations'
@@ -15,7 +11,7 @@ export const GET = defineInternalJsonRoute({
rateLimit: internalRateLimits.none({
reason: 'Existing authenticated table export download signing has no request-rate policy',
}),
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealExportAuthorization,
mapInput: ({ params, query }) => ({
exportId: params.exportId,
workspaceId: query.workspaceId,
@@ -2,12 +2,8 @@ import {
cancelTableExportResourceContract,
getTableExportResourceContract,
} from '@/lib/api/contracts/table-transfers'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { cancelTableExportUseCase, readTableExportUseCase } from '@/lib/table/application/exports'
import { tableOperations } from '@/lib/table/application/operations'
import { toV2TableExport } from '@/lib/table/orchestration/export-resource'
@@ -21,7 +17,7 @@ export const GET = defineInternalJsonRoute({
auth: internalTableSessionOrExecutorAuth,
operation: tableOperations.readExport,
rateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealExportAuthorization,
mapInput: ({ params, query }) => ({
exportId: params.exportId,
workspaceId: query.workspaceId,
@@ -35,7 +31,7 @@ export const DELETE = defineInternalJsonRoute({
auth: internalTableSessionOrExecutorAuth,
operation: tableOperations.cancelExport,
rateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealExportAuthorization,
mapInput: ({ params, query }) => ({
exportId: params.exportId,
workspaceId: query.workspaceId,
@@ -1,10 +1,6 @@
import { completeTableImportResourceContract } from '@/lib/api/contracts/table-transfers'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { completeTableImportUseCase } from '@/lib/table/application/imports'
import { tableOperations } from '@/lib/table/application/operations'
import { toV2TableImport } from '@/lib/table/orchestration/import-resource'
@@ -16,7 +12,7 @@ export const POST = defineInternalJsonRoute({
rateLimit: internalRateLimits.none({
reason: 'Existing authenticated table import completion has no request-rate policy',
}),
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealImportAuthorization,
mapInput: ({ params, query, headers }) => ({
importId: params.importId,
workspaceId: query.workspaceId,
@@ -1,10 +1,6 @@
import { createTableImportPartUrlsContract } from '@/lib/api/contracts/table-transfers'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { createTableImportPartsUseCase } from '@/lib/table/application/imports'
import { tableOperations } from '@/lib/table/application/operations'
@@ -15,7 +11,7 @@ export const POST = defineInternalJsonRoute({
rateLimit: internalRateLimits.none({
reason: 'Existing authenticated table import part signing has no request-rate policy',
}),
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealImportAuthorization,
mapInput: ({ params, query, headers, body }) => ({
importId: params.importId,
workspaceId: query.workspaceId,
@@ -2,12 +2,8 @@ import {
cancelTableImportResourceContract,
getTableImportResourceContract,
} from '@/lib/api/contracts/table-transfers'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { cancelTableImportUseCase, readTableImportUseCase } from '@/lib/table/application/imports'
import { tableOperations } from '@/lib/table/application/operations'
import { toV2TableImport } from '@/lib/table/orchestration/import-resource'
@@ -21,7 +17,7 @@ export const GET = defineInternalJsonRoute({
auth: internalTableSessionOrExecutorAuth,
operation: tableOperations.readImport,
rateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealImportAuthorization,
mapInput: ({ params, query }) => ({
importId: params.importId,
workspaceId: query.workspaceId,
@@ -35,7 +31,7 @@ export const DELETE = defineInternalJsonRoute({
auth: internalTableSessionOrExecutorAuth,
operation: tableOperations.cancelImport,
rateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealImportAuthorization,
mapInput: ({ params, query, headers }) => ({
importId: params.importId,
workspaceId: query.workspaceId,
+3 -7
View File
@@ -1,10 +1,6 @@
import { createTableImportResourceContract } from '@/lib/api/contracts/table-transfers'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes'
import { internalTableErrorPolicies, internalTableSessionOrExecutorAuth } from '@/lib/table/api'
import { createTableImportUseCase } from '@/lib/table/application/imports'
import { tableOperations } from '@/lib/table/application/operations'
import { toV2CreateTableImport } from '@/lib/table/orchestration/import-resource'
@@ -16,7 +12,7 @@ export const POST = defineInternalJsonRoute({
rateLimit: internalRateLimits.none({
reason: 'Existing authenticated table import creation has no request-rate policy',
}),
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalTableErrorPolicies.concealTableAuthorization,
mapInput: ({ body }) => ({ body }),
useCase: createTableImportUseCase,
present: ({ import: created }) => ({ data: toV2CreateTableImport(created) }),
@@ -10,6 +10,7 @@ interface CapturedDefinition {
response: { status?: number }
}
auth: unknown
errorPolicy: unknown
operation: { id: string }
useCase: unknown
}
@@ -17,6 +18,11 @@ interface CapturedDefinition {
const mocks = vi.hoisted(() => ({
auth: { kind: 'session-or-executor' },
definitions: [] as CapturedDefinition[],
errorPolicies: {
concealTableAuthorization: { kind: 'conceal-table' },
concealImportAuthorization: { kind: 'conceal-import' },
concealExportAuthorization: { kind: 'conceal-export' },
},
useCases: {
cancelExport: { operation: { id: 'tables.exports.cancel' } },
cancelImport: { operation: { id: 'tables.imports.cancel' } },
@@ -41,7 +47,10 @@ vi.mock('@/lib/api/server/routes', () => ({
},
}))
vi.mock('@/lib/table/api', () => ({ internalTableSessionOrExecutorAuth: mocks.auth }))
vi.mock('@/lib/table/api', () => ({
internalTableErrorPolicies: mocks.errorPolicies,
internalTableSessionOrExecutorAuth: mocks.auth,
}))
vi.mock('@/lib/table/application/imports', () => ({
cancelTableImportUseCase: mocks.useCases.cancelImport,
@@ -106,6 +115,36 @@ describe('internal table transfer routes', () => {
}
})
it('conceals cross-tenant authorization on every table transfer control leg', () => {
const expected = [
['POST', '/api/table/imports', mocks.errorPolicies.concealTableAuthorization],
['GET', '/api/table/imports/[importId]', mocks.errorPolicies.concealImportAuthorization],
['DELETE', '/api/table/imports/[importId]', mocks.errorPolicies.concealImportAuthorization],
[
'POST',
'/api/table/imports/[importId]/parts',
mocks.errorPolicies.concealImportAuthorization,
],
[
'POST',
'/api/table/imports/[importId]/complete',
mocks.errorPolicies.concealImportAuthorization,
],
['POST', '/api/table/[tableId]/exports', mocks.errorPolicies.concealTableAuthorization],
['GET', '/api/table/exports/[exportId]', mocks.errorPolicies.concealExportAuthorization],
['DELETE', '/api/table/exports/[exportId]', mocks.errorPolicies.concealExportAuthorization],
[
'GET',
'/api/table/exports/[exportId]/download',
mocks.errorPolicies.concealExportAuthorization,
],
] as const
for (const [method, path, errorPolicy] of expected) {
expect(definition(method, path).errorPolicy).toBe(errorPolicy)
}
})
it('preserves the create response statuses', () => {
expect(definition('POST', '/api/table/imports').contract.response.status).toBe(201)
expect(definition('POST', '/api/table/[tableId]/exports').contract.response.status).toBe(201)
@@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/lib/api/server/routes', () => ({
createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })),
internalOrchestrationErrorPolicy: { kind: 'internal-plain' },
createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
@@ -6,6 +6,8 @@ import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) }))
vi.mock('@/lib/api/server/routes', () => ({
createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })),
internalOrchestrationErrorPolicy: { kind: 'internal-plain' },
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
@@ -8,6 +8,8 @@ const mocks = vi.hoisted(() => ({
}))
vi.mock('@/lib/api/server/routes', () => ({
createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })),
internalOrchestrationErrorPolicy: { kind: 'internal-plain' },
createInternalSessionOrExecutorAuth: vi.fn(() => ({ kind: 'internal-workflow' })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
@@ -34,6 +34,8 @@ vi.mock('@/lib/api/server/routes', () => {
}
return {
admitV2Request: mocks.admit,
createInternalResourceConcealmentPolicy: vi.fn(() => ({ project: () => null })),
internalOrchestrationErrorPolicy: { project: () => null },
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn(
({ render }: { render?: (error: unknown) => Response | null }) => ({
@@ -6,6 +6,8 @@ import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) }))
vi.mock('@/lib/api/server/routes', () => ({
createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })),
internalOrchestrationErrorPolicy: { kind: 'internal-plain' },
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
@@ -6,6 +6,8 @@ import { describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ defineRoute: vi.fn((definition) => definition) }))
vi.mock('@/lib/api/server/routes', () => ({
createInternalResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-internal-resource' })),
internalOrchestrationErrorPolicy: { kind: 'internal-plain' },
createInternalSessionOrExecutorAuth: vi.fn(() => ({ authenticate: vi.fn() })),
createV2ResourceConcealmentPolicy: vi.fn(() => ({ kind: 'conceal-resource' })),
defineV2JsonRoute: mocks.defineRoute,
@@ -17,18 +17,25 @@ vi.mock('@/lib/api/server', () => ({
parseRequest: mocks.parseRequest,
}))
vi.mock('@/lib/api/server/routes', () => ({
defineInternalJsonRoute: vi.fn(() => vi.fn()),
InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {},
internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) },
internalSessionAuth: { authenticate: mocks.session },
}))
vi.mock('@/lib/api/server/routes', async () => {
const { concealCrossTenantResourceError } = await import(
'@/lib/api/server/routes/resource-concealment'
)
return {
concealCrossTenantResourceError,
defineInternalJsonRoute: vi.fn(() => vi.fn()),
InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {},
internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) },
internalSessionAuth: { authenticate: mocks.session },
}
})
vi.mock('@/lib/workflows/api', () => ({
createInternalWorkflowErrorPolicy: vi.fn(() => ({
project: vi.fn(),
unhandled: vi.fn(),
})),
WORKFLOW_NOT_FOUND_MESSAGE: 'Workflow not found',
}))
vi.mock('@/lib/core/utils/with-route-handler', () => ({
@@ -44,6 +51,12 @@ vi.mock('@/lib/workflows/application/read-workflow-version', () => ({
readWorkflowVersion: { execute: mocks.read },
}))
import {
DelegatedWorkspaceAuthorizationError,
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { PATCH } from '@/app/api/workflows/[id]/deployments/[version]/route'
describe('workflow deployment version PATCH', () => {
@@ -122,4 +135,49 @@ describe('workflow deployment version PATCH', () => {
expect(mocks.update).toHaveBeenCalledOnce()
expect(mocks.activate).not.toHaveBeenCalled()
})
it.each([
new NoWorkspaceAccessError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
])('conceals a cross-tenant activation denial as an absent workflow: %s', async (error) => {
mocks.parseRequest.mockResolvedValue({
success: true,
data: { params: { id: 'workflow-1', version: 2 }, body: { isActive: true } },
})
mocks.activate.mockRejectedValueOnce(error)
const response = await PATCH(
createMockRequest(
'PATCH',
undefined,
{},
'http://localhost/api/workflows/workflow-1/deployments/2'
),
{ params: Promise.resolve({ id: 'workflow-1', version: '2' }) }
)
expect(response.status).toBe(404)
expect(await response.json()).toMatchObject({ error: 'Workflow not found' })
})
it('keeps a same-workspace role denial on activation forbidden', async () => {
mocks.parseRequest.mockResolvedValue({
success: true,
data: { params: { id: 'workflow-1', version: 2 }, body: { isActive: true } },
})
mocks.activate.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
const response = await PATCH(
createMockRequest(
'PATCH',
undefined,
{},
'http://localhost/api/workflows/workflow-1/deployments/2'
),
{ params: Promise.resolve({ id: 'workflow-1', version: '2' }) }
)
expect(response.status).toBe(403)
})
})
@@ -6,6 +6,7 @@ import {
} from '@/lib/api/contracts/deployments'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import {
concealCrossTenantResourceError,
defineInternalJsonRoute,
InternalUnauthenticatedError,
internalRateLimits,
@@ -14,7 +15,7 @@ import {
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { createInternalWorkflowErrorPolicy } from '@/lib/workflows/api'
import { createInternalWorkflowErrorPolicy, WORKFLOW_NOT_FOUND_MESSAGE } from '@/lib/workflows/api'
import {
activateWorkflowVersion,
updateWorkflowVersion,
@@ -118,7 +119,9 @@ export const PATCH = withRouteHandler(
if (error instanceof InternalUnauthenticatedError) {
return createErrorResponse(error.message, 401)
}
const orchestrationError = asOrchestrationError(error)
const orchestrationError = asOrchestrationError(
concealCrossTenantResourceError(error, WORKFLOW_NOT_FOUND_MESSAGE)
)
if (orchestrationError) {
return createErrorResponse(
orchestrationError.message,
+62 -6
View File
@@ -17,18 +17,28 @@ const mocks = vi.hoisted(() => ({
vi.mock('@/lib/api/server', () => ({ parseRequest: mocks.parseRequest }))
vi.mock('@/lib/api/server/routes', () => ({
defineInternalJsonRoute: mocks.defineRoute,
InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {},
internalOrchestrationErrorPolicy: { kind: 'plain-orchestration' },
internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) },
}))
vi.mock('@/lib/api/server/routes', async () => {
const { concealCrossTenantResourceError } = await import(
'@/lib/api/server/routes/resource-concealment'
)
return {
concealCrossTenantResourceError,
defineInternalJsonRoute: mocks.defineRoute,
InternalUnauthenticatedError: class InternalUnauthenticatedError extends Error {},
internalOrchestrationErrorPolicy: { kind: 'plain-orchestration' },
internalRateLimits: { none: vi.fn(() => ({ kind: 'none' })) },
}
})
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.capture }))
vi.mock('@/lib/workflows/api', () => ({
internalWorkflowErrorPolicies: {
concealWorkflowAuthorization: { kind: 'conceal-workflow-authorization' },
},
internalWorkflowReadAuth: { authenticate: mocks.auth },
internalWorkflowSessionOrExecutorAuth: { authenticate: mocks.auth },
WORKFLOW_NOT_FOUND_MESSAGE: 'Workflow not found',
}))
vi.mock('@/lib/workflows/application/read-workflow-definition', () => ({
@@ -56,6 +66,12 @@ vi.mock('@/lib/workflows/application/update-workflow', () => ({
},
}))
import {
DelegatedWorkspaceAuthorizationError,
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { DELETE, GET, PUT } from '@/app/api/workflows/[id]/route'
const sessionPrincipal = {
@@ -97,6 +113,12 @@ describe('/api/workflows/[id] application adapters', () => {
expect(Reflect.get(DELETE, 'mapInput')({ params: { id: 'workflow-1' } })).toEqual({
workflowId: 'workflow-1',
})
for (const handler of [GET, DELETE]) {
expect(Reflect.get(handler, 'errorPolicy')).toMatchObject({
kind: 'conceal-workflow-authorization',
})
}
})
it('keeps human delete analytics surface-specific and no-op aware', async () => {
@@ -155,6 +177,40 @@ describe('/api/workflows/[id] application adapters', () => {
)
})
it.each([
new NoWorkspaceAccessError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
])('conceals a cross-tenant update denial as an absent workflow: %s', async (error) => {
mocks.parseRequest.mockResolvedValue({
success: true,
data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } },
})
mocks.updateWorkflow.mockRejectedValueOnce(error)
const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), {
params: Promise.resolve({ id: 'workflow-1' }),
})
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'Workflow not found' })
})
it('keeps a same-workspace role denial on update forbidden', async () => {
mocks.parseRequest.mockResolvedValue({
success: true,
data: { params: { id: 'workflow-1' }, body: { name: 'Renamed' } },
})
mocks.updateWorkflow.mockRejectedValueOnce(new InsufficientWorkspacePermissionsError())
const response = await PUT(createMockRequest('PUT', { name: 'Renamed' }), {
params: Promise.resolve({ id: 'workflow-1' }),
})
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'Insufficient workspace permissions' })
})
it('projects unknown update failures safely', async () => {
mocks.parseRequest.mockResolvedValue({
success: true,
+8 -4
View File
@@ -10,17 +10,19 @@ import {
} from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
import {
concealCrossTenantResourceError,
defineInternalJsonRoute,
InternalUnauthenticatedError,
internalOrchestrationErrorPolicy,
internalRateLimits,
} from '@/lib/api/server/routes'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
internalWorkflowErrorPolicies,
internalWorkflowReadAuth,
internalWorkflowSessionOrExecutorAuth,
WORKFLOW_NOT_FOUND_MESSAGE,
} from '@/lib/workflows/api'
import { deleteWorkflow } from '@/lib/workflows/application/delete-workflow'
import { readWorkflowDefinition } from '@/lib/workflows/application/read-workflow-definition'
@@ -37,7 +39,7 @@ export const GET = defineInternalJsonRoute({
auth: internalWorkflowReadAuth,
operation: readWorkflowDefinition.operation,
rateLimit: workflowInternalRateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization,
mapInput: ({ params }) => ({ workflowId: params.id, state: 'draft' as const }),
useCase: readWorkflowDefinition,
present: ({ workflow: workflowData, state }) => {
@@ -82,7 +84,7 @@ export const DELETE = defineInternalJsonRoute({
auth: internalWorkflowSessionOrExecutorAuth,
operation: deleteWorkflow.operation,
rateLimit: workflowInternalRateLimit,
errorPolicy: internalOrchestrationErrorPolicy,
errorPolicy: internalWorkflowErrorPolicies.concealWorkflowAuthorization,
mapInput: ({ params }) => ({ workflowId: params.id }),
useCase: deleteWorkflow,
present: () => ({ success: true as const }),
@@ -147,7 +149,9 @@ export const PUT = withRouteHandler(
if (error instanceof InternalUnauthenticatedError) {
return NextResponse.json({ error: error.message }, { status: 401 })
}
const orchestrationError = asOrchestrationError(error)
const orchestrationError = asOrchestrationError(
concealCrossTenantResourceError(error, WORKFLOW_NOT_FOUND_MESSAGE)
)
if (orchestrationError) {
return NextResponse.json(
{ error: orchestrationError.message },
@@ -20,6 +20,11 @@ vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () =>
}))
import { StorageLimitExceededError } from '@/lib/billing/storage'
import {
DelegatedWorkspaceAuthorizationError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route'
@@ -88,6 +93,20 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => {
expect(mocks.updateContent).not.toHaveBeenCalled()
})
it.each([
new NoWorkspaceAccessError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
])('conceals a cross-tenant admission denial as an absent file: %s', async (error) => {
mocks.admit.mockRejectedValue(error)
const response = await PUT(createRequest('{not-json'), routeContext)
expect(response.status).toBe(404)
await expect(response.json()).resolves.toMatchObject({ error: 'File not found' })
expect(mocks.updateContent).not.toHaveBeenCalled()
})
it('rejects malformed base64 after admission', async () => {
const response = await PUT(
createRequest({ content: 'not-base64!', encoding: 'base64' }),
@@ -22,7 +22,7 @@ export const PUT = defineInternalJsonRoute({
rateLimit: internalRateLimits.none({
reason: 'Preserve existing internal content-update behavior',
}),
errorPolicy: internalFileErrorPolicies.content,
errorPolicy: internalFileErrorPolicies.concealContentAuthorization,
parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES },
beforeParse: async ({ principal, params }) => {
if (typeof params.fileId === 'string') {
@@ -31,6 +31,12 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent }))
import {
DelegatedWorkspaceAuthorizationError,
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/route'
@@ -131,6 +137,28 @@ describe('PATCH /api/workspaces/[id]/files/[fileId]', () => {
expect(mocks.captureServerEvent).not.toHaveBeenCalled()
})
it.each([
new NoWorkspaceAccessError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
])('conceals a cross-tenant denial as an absent file: %s', async (error) => {
mocks.rename.mockRejectedValue(error)
const response = await callRename({ name: 'renamed.csv' })
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'File not found' })
})
it('keeps a same-workspace role denial forbidden', async () => {
mocks.rename.mockRejectedValue(new InsufficientWorkspacePermissionsError())
const response = await callRename({ name: 'renamed.csv' })
expect(response.status).toBe(403)
expect(await response.json()).toEqual({ error: 'Insufficient workspace permissions' })
})
it('hides unexpected failures behind the internal 500 envelope', async () => {
mocks.rename.mockRejectedValue(new Error('update workspace_files failed'))
@@ -28,7 +28,7 @@ export const PATCH = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: fileOperations.rename,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal rename behavior' }),
errorPolicy: internalFileErrorPolicies.default,
errorPolicy: internalFileErrorPolicies.concealResourceAuthorization,
mapInput: ({ params, body }) => ({
fileId: params.fileId,
assertedWorkspaceId: params.id,
@@ -48,7 +48,7 @@ export const DELETE = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: fileOperations.delete,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal delete behavior' }),
errorPolicy: internalFileErrorPolicies.default,
errorPolicy: internalFileErrorPolicies.concealResourceAuthorization,
mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }),
useCase: deleteWorkspaceFileOperation,
onSuccess: internalFileAnalytics.deleted,
@@ -23,6 +23,11 @@ vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({
},
}))
import {
DelegatedWorkspaceAuthorizationError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
@@ -99,6 +104,19 @@ describe('/api/workspaces/[id]/files/[fileId]/share', () => {
expect(await response.json()).toEqual({ error: 'Access denied' })
})
it.each([
new NoWorkspaceAccessError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
])('conceals a cross-tenant denial as an absent file: %s', async (error) => {
mocks.getShare.mockRejectedValueOnce(error)
const response = await GET(getRequest(), context)
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'File not found' })
})
it('renders resource absence as 404', async () => {
mocks.getShare.mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found'))
@@ -18,7 +18,7 @@ export const GET = defineInternalJsonRoute({
auth: internalSessionAuth,
operation: fileOperations.readShare,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-read behavior' }),
errorPolicy: internalFileErrorPolicies.default,
errorPolicy: internalFileErrorPolicies.concealResourceAuthorization,
mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }),
useCase: getWorkspaceFileShare,
})
@@ -30,7 +30,7 @@ export const PUT = defineInternalJsonRoute({
rateLimit: internalRateLimits.none({
reason: 'Preserve existing internal share-update behavior',
}),
errorPolicy: internalFileErrorPolicies.default,
errorPolicy: internalFileErrorPolicies.concealResourceAuthorization,
mapInput: ({ params, body }) => ({
fileId: params.fileId,
assertedWorkspaceId: params.id,
+5 -1
View File
@@ -12,6 +12,11 @@ export {
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes/internal-json-route'
export {
concealCrossTenantResourceError,
createInternalResourceConcealmentPolicy,
createV2ResourceConcealmentPolicy,
} from '@/lib/api/server/routes/resource-concealment'
export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route'
export { defineV2BodyLifecycleRoute } from '@/lib/api/server/routes/v2-body-lifecycle-route'
export {
@@ -24,4 +29,3 @@ export {
v2OrchestrationErrorPolicy,
v2RateLimits,
} from '@/lib/api/server/routes/v2-json-route'
export { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/v2-resource-concealment'
@@ -0,0 +1,267 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
concealCrossTenantResourceError,
type InternalErrorPolicy,
type V2ErrorPolicy,
} from '@/lib/api/server/routes'
import {
DelegatedWorkspaceAuthorizationError,
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
PersonalApiKeysDisabledError,
PrincipalKindAuthorizationError,
WorkspaceApiKeyAuthorizationError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types'
import {
internalKnowledgeErrorPolicies,
v2KnowledgeErrorPolicies,
} from '@/lib/knowledge/api/route-policies'
import { internalTableErrorPolicies, v2TableErrorPolicies } from '@/lib/table/api/route-policies'
import {
createInternalWorkflowErrorPolicy,
internalWorkflowErrorPolicies,
v2WorkflowErrorPolicies,
} from '@/lib/workflows/api/route-policies'
import { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies'
import { v2FileErrorPolicies } from '@/lib/workspace-files/api/route-policies'
const policies: Array<{
domain: string
policy: V2ErrorPolicy
notFoundMessage: string
}> = [
{
domain: 'file',
policy: v2FileErrorPolicies.concealResourceAuthorization,
notFoundMessage: 'File not found',
},
{
domain: 'workflow',
policy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
notFoundMessage: 'Workflow not found',
},
{
domain: 'workflow run',
policy: v2WorkflowErrorPolicies.concealRunAuthorization,
notFoundMessage: 'Run not found',
},
{
domain: 'table',
policy: v2TableErrorPolicies.concealTableAuthorization,
notFoundMessage: 'Table not found',
},
{
domain: 'table import',
policy: v2TableErrorPolicies.concealImportAuthorization,
notFoundMessage: 'Table import not found',
},
{
domain: 'table export',
policy: v2TableErrorPolicies.concealExportAuthorization,
notFoundMessage: 'Table export not found',
},
{
domain: 'knowledge base',
policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
notFoundMessage: 'Knowledge base not found',
},
]
const crossTenantAuthorizationErrors = [
new NoWorkspaceAccessError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
]
describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => {
it.each(crossTenantAuthorizationErrors)(
'conceals cross-tenant authorization: %s',
async (error) => {
const response = policy.render(error)
expect(response?.status).toBe(404)
await expect(response?.json()).resolves.toEqual({
error: { code: 'NOT_FOUND', message: notFoundMessage },
})
}
)
it('preserves workspace personal-key policy denial as forbidden', async () => {
const response = policy.render(new PersonalApiKeysDisabledError())
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toEqual({
error: {
code: 'FORBIDDEN',
message: 'Personal API keys are not allowed for this workspace',
},
})
})
it('preserves insufficient workspace role as forbidden', async () => {
const response = policy.render(new InsufficientWorkspacePermissionsError())
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toEqual({
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
})
})
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')
)
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toEqual({
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
})
})
})
/**
* Internal routes reach the same application use cases as their v2 twins, so a
* cross-tenant denial must read as absence on both surfaces. Anything narrower
* — a same-workspace role or key-policy denial — stays a 403 here exactly as it
* does on v2.
*/
const internalPolicies: Array<{
route: string
policy: InternalErrorPolicy
notFoundMessage: string
}> = [
{
route: 'PATCH/DELETE /api/workspaces/[id]/files/[fileId]',
policy: internalFileErrorPolicies.concealResourceAuthorization,
notFoundMessage: 'File not found',
},
{
route: 'PUT /api/workspaces/[id]/files/[fileId]/content',
policy: internalFileErrorPolicies.concealContentAuthorization,
notFoundMessage: 'File not found',
},
{
route: 'GET/DELETE /api/workflows/[id]',
policy: internalWorkflowErrorPolicies.concealWorkflowAuthorization,
notFoundMessage: 'Workflow not found',
},
{
route: 'POST/DELETE /api/workflows/[id]/deploy',
policy: createInternalWorkflowErrorPolicy('Failed to deploy workflow'),
notFoundMessage: 'Workflow not found',
},
{
route: 'GET /api/workflows/[id]/deployments',
policy: createInternalWorkflowErrorPolicy('Failed to list deployments'),
notFoundMessage: 'Workflow not found',
},
{
route: 'GET /api/workflows/[id]/deployments/[version]',
policy: createInternalWorkflowErrorPolicy('Failed to fetch deployment version'),
notFoundMessage: 'Workflow not found',
},
{
route: 'POST /api/table/imports, POST /api/table/[tableId]/exports',
policy: internalTableErrorPolicies.concealTableAuthorization,
notFoundMessage: 'Table not found',
},
{
route: 'POST/PATCH/DELETE /api/table/[tableId]/groups',
policy: internalTableErrorPolicies.concealTableGroupAuthorization,
notFoundMessage: 'Table not found',
},
{
route: 'GET/DELETE /api/table/imports/[importId]',
policy: internalTableErrorPolicies.concealImportAuthorization,
notFoundMessage: 'Table import not found',
},
{
route: 'GET/DELETE /api/table/exports/[exportId]',
policy: internalTableErrorPolicies.concealExportAuthorization,
notFoundMessage: 'Table export not found',
},
{
route: 'GET/PUT/DELETE /api/knowledge/[id]',
policy: internalKnowledgeErrorPolicies.read,
notFoundMessage: 'Knowledge base not found',
},
{
route: 'GET /api/knowledge/[id]/documents/[documentId]',
policy: internalKnowledgeErrorPolicies.documents,
notFoundMessage: 'Knowledge base not found',
},
{
route: 'POST /api/knowledge/[id]/documents',
policy: internalKnowledgeErrorPolicies.uploads,
notFoundMessage: 'Knowledge base not found',
},
{
route: 'POST /api/knowledge/search',
policy: internalKnowledgeErrorPolicies.search,
notFoundMessage: 'Knowledge base not found',
},
]
describe.each(internalPolicies)('$route internal concealment', ({ policy, notFoundMessage }) => {
it.each(crossTenantAuthorizationErrors)('conceals cross-tenant authorization: %s', (error) => {
const response = policy.project(error)
expect(response?.status).toBe(404)
expect(response?.body).toMatchObject({ error: notFoundMessage })
})
it('preserves insufficient workspace role as forbidden', () => {
const response = policy.project(new InsufficientWorkspacePermissionsError())
expect(response?.status).toBe(403)
expect(response?.body).toMatchObject({ error: 'Insufficient workspace permissions' })
})
it.each([
new PersonalApiKeysDisabledError(),
new WorkspaceApiKeyAuthorizationError(),
new PrincipalKindAuthorizationError('workspace_api_key', 'resources.read'),
])('preserves same-workspace principal policy denial as forbidden: %s', (error) => {
expect(policy.project(error)?.status).toBe(403)
})
it('does not classify generic forbidden errors by message', () => {
const response = policy.project(
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
)
expect(response?.status).toBe(403)
})
})
describe('internal list surfaces', () => {
it.each(crossTenantAuthorizationErrors)(
'leaves workspace-level knowledge listing forbidden: %s',
(error) => {
expect(internalKnowledgeErrorPolicies.list.project(error)?.status).toBe(403)
expect(internalKnowledgeErrorPolicies.create.project(error)?.status).toBe(403)
}
)
})
describe('concealCrossTenantResourceError', () => {
it.each(crossTenantAuthorizationErrors)('reports absence for %s', (error) => {
const concealed = concealCrossTenantResourceError(error, 'Workflow not found')
expect(asOrchestrationError(concealed)).toMatchObject({
code: 'not_found',
message: 'Workflow not found',
})
})
it('passes every other failure through untouched', () => {
const error = new InsufficientWorkspacePermissionsError()
expect(concealCrossTenantResourceError(error, 'Workflow not found')).toBe(error)
})
})
@@ -0,0 +1,84 @@
import type { InternalErrorPolicy } from '@/lib/api/server/routes/internal-json-route'
import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route'
import {
DelegatedWorkspaceAuthorizationError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
type V2ErrorRenderer = V2ErrorPolicy['render']
/**
* Authorization failures proving the caller has no reach into the resource's
* workspace at all, as opposed to a workspace member whose role is too low.
* Answering these with `403` confirms the resource exists to a caller who was
* never entitled to learn that.
*/
function isCrossTenantAuthorizationError(error: unknown): boolean {
return (
error instanceof DelegatedWorkspaceAuthorizationError ||
error instanceof NoWorkspaceAccessError ||
error instanceof WorkspaceApiKeyScopeAuthorizationError
)
}
/**
* The failure a caller should see in place of `error`: an absent resource when
* the denial was cross-tenant, and `error` itself otherwise.
*
* Routes that classify their own failures instead of delegating to an error
* policy — the raw `withRouteHandler` exceptions — run their caught value
* through this before classifying it, so they conceal the same way the
* policy-driven routes beside them do.
*/
export function concealCrossTenantResourceError(error: unknown, notFoundMessage: string): unknown {
if (!isCrossTenantAuthorizationError(error)) return error
return new OrchestrationError('not_found', notFoundMessage)
}
/**
* Conceals cross-tenant authorization failures while preserving same-workspace
* policy and role denials as 403 responses.
*/
export function createV2ResourceConcealmentPolicy(options: {
notFoundMessage: string
render?: V2ErrorRenderer
}): V2ErrorPolicy {
const render = options.render ?? v2CaughtOrchestrationError
return {
render(error) {
if (isCrossTenantAuthorizationError(error)) {
return v2Error('NOT_FOUND', options.notFoundMessage)
}
return render(error)
},
}
}
/**
* The internal-surface counterpart of {@link createV2ResourceConcealmentPolicy}.
*
* The same application use case is reachable from both `/api/v2/...` and the
* internal `/api/...` routes, so a surface answering `403` where the other
* answers `404` hands back the resource-existence signal the v2 policy exists to
* withhold. The concealed failure is re-projected through `base` as a
* `not_found` orchestration error rather than built directly, so each domain's
* own error body — the legacy workflow `code` field, the shared `requestId`
* stamp — matches what its ordinary 404s already return.
*/
export function createInternalResourceConcealmentPolicy(options: {
base: InternalErrorPolicy
notFoundMessage: string
}): InternalErrorPolicy {
if (!options.notFoundMessage.trim()) {
throw new Error('A concealed internal resource requires a not-found message')
}
return {
project(error) {
return options.base.project(concealCrossTenantResourceError(error, options.notFoundMessage))
},
unhandled: options.base.unhandled,
}
}
@@ -1,118 +0,0 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { V2ErrorPolicy } from '@/lib/api/server/routes'
import {
DelegatedWorkspaceAuthorizationError,
InsufficientWorkspacePermissionsError,
NoWorkspaceAccessError,
PersonalApiKeysDisabledError,
PrincipalKindAuthorizationError,
WorkspaceApiKeyAuthorizationError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { v2KnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies'
import { v2TableErrorPolicies } from '@/lib/table/api/route-policies'
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api/route-policies'
import { v2FileErrorPolicies } from '@/lib/workspace-files/api/route-policies'
const policies: Array<{
domain: string
policy: V2ErrorPolicy
notFoundMessage: string
}> = [
{
domain: 'file',
policy: v2FileErrorPolicies.concealResourceAuthorization,
notFoundMessage: 'File not found',
},
{
domain: 'workflow',
policy: v2WorkflowErrorPolicies.concealWorkflowAuthorization,
notFoundMessage: 'Workflow not found',
},
{
domain: 'workflow run',
policy: v2WorkflowErrorPolicies.concealRunAuthorization,
notFoundMessage: 'Run not found',
},
{
domain: 'table',
policy: v2TableErrorPolicies.concealTableAuthorization,
notFoundMessage: 'Table not found',
},
{
domain: 'table import',
policy: v2TableErrorPolicies.concealImportAuthorization,
notFoundMessage: 'Table import not found',
},
{
domain: 'table export',
policy: v2TableErrorPolicies.concealExportAuthorization,
notFoundMessage: 'Table export not found',
},
{
domain: 'knowledge base',
policy: v2KnowledgeErrorPolicies.concealKnowledgeBaseAuthorization,
notFoundMessage: 'Knowledge base not found',
},
]
const crossTenantAuthorizationErrors = [
new NoWorkspaceAccessError(),
new WorkspaceApiKeyScopeAuthorizationError(),
new DelegatedWorkspaceAuthorizationError(),
]
describe.each(policies)('$domain resource concealment', ({ policy, notFoundMessage }) => {
it.each(crossTenantAuthorizationErrors)(
'conceals cross-tenant authorization: %s',
async (error) => {
const response = policy.render(error)
expect(response?.status).toBe(404)
await expect(response?.json()).resolves.toEqual({
error: { code: 'NOT_FOUND', message: notFoundMessage },
})
}
)
it('preserves workspace personal-key policy denial as forbidden', async () => {
const response = policy.render(new PersonalApiKeysDisabledError())
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toEqual({
error: {
code: 'FORBIDDEN',
message: 'Personal API keys are not allowed for this workspace',
},
})
})
it('preserves insufficient workspace role as forbidden', async () => {
const response = policy.render(new InsufficientWorkspacePermissionsError())
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toEqual({
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
})
})
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')
)
expect(response?.status).toBe(403)
await expect(response?.json()).resolves.toEqual({
error: { code: 'FORBIDDEN', message: 'Insufficient workspace permissions' },
})
})
})
@@ -1,36 +0,0 @@
import type { V2ErrorPolicy } from '@/lib/api/server/routes/v2-json-route'
import {
DelegatedWorkspaceAuthorizationError,
NoWorkspaceAccessError,
WorkspaceApiKeyScopeAuthorizationError,
} from '@/lib/core/application'
import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response'
type V2ErrorRenderer = V2ErrorPolicy['render']
function isCrossTenantAuthorizationError(error: unknown): boolean {
return (
error instanceof DelegatedWorkspaceAuthorizationError ||
error instanceof NoWorkspaceAccessError ||
error instanceof WorkspaceApiKeyScopeAuthorizationError
)
}
/**
* Conceals cross-tenant authorization failures while preserving same-workspace
* policy and role denials as 403 responses.
*/
export function createV2ResourceConcealmentPolicy(options: {
notFoundMessage: string
render?: V2ErrorRenderer
}): V2ErrorPolicy {
const render = options.render ?? v2CaughtOrchestrationError
return {
render(error) {
if (isCrossTenantAuthorizationError(error)) {
return v2Error('NOT_FOUND', options.notFoundMessage)
}
return render(error)
},
}
}
+33 -11
View File
@@ -1,4 +1,5 @@
import {
createInternalResourceConcealmentPolicy,
createInternalSessionOrExecutorAuth,
createV2ResourceConcealmentPolicy,
type InternalErrorPolicy,
@@ -52,21 +53,42 @@ export const internalKnowledgeSessionOrExecutorAuth = createInternalSessionOrExe
audience: KNOWLEDGE_DELEGATION_AUDIENCE,
})
export const KNOWLEDGE_BASE_NOT_FOUND_MESSAGE = 'Knowledge base not found'
/**
* Conceals a knowledge-base-scoped internal policy the way the v2 knowledge
* routes conceal theirs. The workspace-level `list` and `create` policies are
* deliberately left alone: neither names a knowledge base, so there is no
* resource whose existence a 403 could betray.
*/
function concealKnowledgeBase(base: InternalErrorPolicy): InternalErrorPolicy {
return createInternalResourceConcealmentPolicy({
base,
notFoundMessage: KNOWLEDGE_BASE_NOT_FOUND_MESSAGE,
})
}
export const internalKnowledgeErrorPolicies = {
list: internalKnowledgeErrorPolicy('Failed to fetch knowledge bases'),
read: internalKnowledgeErrorPolicy('Failed to fetch knowledge base'),
read: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to fetch knowledge base')),
create: internalKnowledgeErrorPolicy('Failed to create knowledge base'),
update: internalKnowledgeErrorPolicy('Failed to update knowledge base'),
delete: internalKnowledgeErrorPolicy('Failed to delete knowledge base'),
restore: internalKnowledgeErrorPolicy('Internal server error'),
update: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to update knowledge base')),
delete: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to delete knowledge base')),
restore: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')),
default: internalKnowledgeErrorPolicy('Internal server error'),
documents: internalKnowledgeErrorPolicy('Failed to process knowledge document request'),
chunks: internalKnowledgeErrorPolicy('Failed to process knowledge chunk request'),
upsert: internalKnowledgeUploadErrorPolicy,
search: internalKnowledgeSearchErrorPolicy,
tags: internalKnowledgeErrorPolicy('Failed to process knowledge tag request'),
connectors: internalKnowledgeErrorPolicy('Internal server error'),
uploads: internalKnowledgeUploadErrorPolicy,
documents: concealKnowledgeBase(
internalKnowledgeErrorPolicy('Failed to process knowledge document request')
),
chunks: concealKnowledgeBase(
internalKnowledgeErrorPolicy('Failed to process knowledge chunk request')
),
upsert: concealKnowledgeBase(internalKnowledgeUploadErrorPolicy),
search: concealKnowledgeBase(internalKnowledgeSearchErrorPolicy),
tags: concealKnowledgeBase(
internalKnowledgeErrorPolicy('Failed to process knowledge tag request')
),
connectors: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')),
uploads: concealKnowledgeBase(internalKnowledgeUploadErrorPolicy),
} as const
const v2KnowledgeUsageErrorPolicy = {
+1
View File
@@ -1,4 +1,5 @@
export {
internalTableErrorPolicies,
internalTableSessionOrExecutorAuth,
v2TableErrorPolicies,
} from '@/lib/table/api/route-policies'
+36
View File
@@ -1,6 +1,10 @@
import {
createInternalResourceConcealmentPolicy,
createInternalSessionOrExecutorAuth,
createV2ResourceConcealmentPolicy,
extendInternalErrorPolicy,
internalErrorResponse,
internalOrchestrationErrorPolicy,
type V2ErrorPolicy,
} from '@/lib/api/server/routes'
import { TABLE_DELEGATION_AUDIENCE } from '@/lib/table/application/authorization'
@@ -53,3 +57,35 @@ export const v2TableErrorPolicies = {
render: renderTableError,
}),
} as const
const internalTableGroupErrorPolicy = extendInternalErrorPolicy(
internalOrchestrationErrorPolicy,
(error) =>
error instanceof TableLockedError
? internalErrorResponse(423, { error: error.message, lock: error.lock })
: null
)
/**
* Internal-surface counterparts of {@link v2TableErrorPolicies}. The internal
* routes reach the same table use cases, so they conceal the same cross-tenant
* authorization failures behind the same not-found wording.
*/
export const internalTableErrorPolicies = {
concealTableAuthorization: createInternalResourceConcealmentPolicy({
base: internalOrchestrationErrorPolicy,
notFoundMessage: 'Table not found',
}),
concealTableGroupAuthorization: createInternalResourceConcealmentPolicy({
base: internalTableGroupErrorPolicy,
notFoundMessage: 'Table not found',
}),
concealImportAuthorization: createInternalResourceConcealmentPolicy({
base: internalOrchestrationErrorPolicy,
notFoundMessage: 'Table import not found',
}),
concealExportAuthorization: createInternalResourceConcealmentPolicy({
base: internalOrchestrationErrorPolicy,
notFoundMessage: 'Table export not found',
}),
} as const
+2
View File
@@ -1,6 +1,8 @@
export {
createInternalWorkflowErrorPolicy,
internalWorkflowErrorPolicies,
internalWorkflowReadAuth,
internalWorkflowSessionOrExecutorAuth,
v2WorkflowErrorPolicies,
WORKFLOW_NOT_FOUND_MESSAGE,
} from '@/lib/workflows/api/route-policies'
+37 -15
View File
@@ -1,11 +1,13 @@
import type { Principal } from '@sim/auth/principal'
import {
createInternalResourceConcealmentPolicy,
createInternalSessionOrExecutorAuth,
createV2ResourceConcealmentPolicy,
type InternalAuthPolicy,
type InternalErrorPolicy,
InternalUnauthenticatedError,
internalErrorResponse,
internalOrchestrationErrorPolicy,
type V2ErrorPolicy,
v2OrchestrationErrorPolicy,
} from '@/lib/api/server/routes'
@@ -63,22 +65,42 @@ function legacyWorkflowErrorCode(message: string): string {
return message.toUpperCase().replace(/\s+/g, '_')
}
export const WORKFLOW_NOT_FOUND_MESSAGE = 'Workflow not found'
/**
* Every route built on this policy is scoped to a single workflow, so all of
* them conceal cross-tenant authorization the way their v2 counterparts do.
*/
export function createInternalWorkflowErrorPolicy(fallback: string): InternalErrorPolicy {
if (!fallback.trim()) throw new Error('Internal workflow error fallback is required')
return {
project(error) {
const classified = asOrchestrationError(error)
if (!classified) return null
return internalErrorResponse(statusForOrchestrationError(classified.code), {
error: classified.message,
code: legacyWorkflowErrorCode(classified.message),
})
return createInternalResourceConcealmentPolicy({
notFoundMessage: WORKFLOW_NOT_FOUND_MESSAGE,
base: {
project(error) {
const classified = asOrchestrationError(error)
if (!classified) return null
return internalErrorResponse(statusForOrchestrationError(classified.code), {
error: classified.message,
code: legacyWorkflowErrorCode(classified.message),
})
},
unhandled() {
return internalErrorResponse(500, {
error: fallback,
code: legacyWorkflowErrorCode(fallback),
})
},
},
unhandled() {
return internalErrorResponse(500, {
error: fallback,
code: legacyWorkflowErrorCode(fallback),
})
},
}
})
}
/**
* Internal-surface counterparts of {@link v2WorkflowErrorPolicies} for the
* workflow routes that project plain orchestration errors.
*/
export const internalWorkflowErrorPolicies = {
concealWorkflowAuthorization: createInternalResourceConcealmentPolicy({
base: internalOrchestrationErrorPolicy,
notFoundMessage: WORKFLOW_NOT_FOUND_MESSAGE,
}),
} as const
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import {
createInternalResourceConcealmentPolicy,
extendInternalErrorPolicy,
type InternalErrorPolicy,
internalErrorResponse,
@@ -78,9 +79,23 @@ const inline: InternalErrorPolicy = {
},
}
const FILE_NOT_FOUND_MESSAGE = 'File not found'
export const internalFileErrorPolicies = {
default: internalOrchestrationErrorPolicy,
content,
/**
* Single-file internal routes reach the same use cases as the concealing v2
* file routes, so they withhold the same cross-tenant existence signal.
*/
concealResourceAuthorization: createInternalResourceConcealmentPolicy({
base: internalOrchestrationErrorPolicy,
notFoundMessage: FILE_NOT_FOUND_MESSAGE,
}),
concealContentAuthorization: createInternalResourceConcealmentPolicy({
base: content,
notFoundMessage: FILE_NOT_FOUND_MESSAGE,
}),
style,
compiledCheck,
downloadUrl,