mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-22 13:30:01 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,5 @@
|
||||
export {
|
||||
internalTableErrorPolicies,
|
||||
internalTableSessionOrExecutorAuth,
|
||||
v2TableErrorPolicies,
|
||||
} from '@/lib/table/api/route-policies'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
createInternalWorkflowErrorPolicy,
|
||||
internalWorkflowErrorPolicies,
|
||||
internalWorkflowReadAuth,
|
||||
internalWorkflowSessionOrExecutorAuth,
|
||||
v2WorkflowErrorPolicies,
|
||||
WORKFLOW_NOT_FOUND_MESSAGE,
|
||||
} from '@/lib/workflows/api/route-policies'
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user