Skip to content

Commit d692245

Browse files
improvement(api): harden application route boundaries (#6451)
* improvement(api): harden application route boundaries * fix(folders): reject creates at workspace cap
1 parent 8dfc6bb commit d692245

71 files changed

Lines changed: 1509 additions & 664 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/audit-logs/export/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
2222
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
2323
}))
2424

25-
vi.mock('@/app/api/v1/audit-logs/query', () => ({
25+
vi.mock('@/lib/audit-logs/query', () => ({
2626
buildFilterConditions: mockBuildFilterConditions,
2727
buildOrgScopeCondition: mockBuildOrgScopeCondition,
2828
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,

apps/sim/app/api/audit-logs/export/route.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@ import { getErrorMessage } from '@sim/utils/errors'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { exportAuditLogsContract } from '@/lib/api/contracts/audit-logs'
55
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6-
import { getSession } from '@/lib/auth'
7-
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
9-
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
10-
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
116
import {
127
buildFilterConditions,
138
buildOrgScopeCondition,
149
getOrgWorkspaceIds,
1510
queryAuditLogs,
16-
} from '@/app/api/v1/audit-logs/query'
11+
} from '@/lib/audit-logs/query'
12+
import { getSession } from '@/lib/auth'
13+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
14+
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
15+
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
16+
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
1717

1818
const logger = createLogger('AuditLogsExportAPI')
1919

apps/sim/app/api/v1/admin/audit-logs/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger'
2424
import { and, count, desc } from 'drizzle-orm'
2525
import { v1AdminListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
2626
import { parseRequest } from '@/lib/api/server'
27+
import { buildFilterConditions } from '@/lib/audit-logs/query'
2728
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2829
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
2930
import {
@@ -32,7 +33,6 @@ import {
3233
listResponse,
3334
} from '@/app/api/v1/admin/responses'
3435
import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types'
35-
import { buildFilterConditions } from '@/app/api/v1/audit-logs/query'
3636

3737
const logger = createLogger('AdminAuditLogsAPI')
3838

apps/sim/app/api/v1/audit-logs/[id]/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
2828
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
2929
}))
3030

31-
vi.mock('@/app/api/v1/audit-logs/query', () => ({
31+
vi.mock('@/lib/audit-logs/query', () => ({
3232
buildOrgScopeCondition: mockBuildOrgScopeCondition,
3333
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
3434
}))

apps/sim/app/api/v1/audit-logs/[id]/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ import { and, eq } from 'drizzle-orm'
2020
import { type NextRequest, NextResponse } from 'next/server'
2121
import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs'
2222
import { parseRequest } from '@/lib/api/server'
23+
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query'
2324
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
2425
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
2526
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
26-
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
2727
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
2828
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
2929

apps/sim/app/api/v1/audit-logs/auth.ts

Lines changed: 4 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,135 +1,13 @@
1-
/**
2-
* Enterprise audit log authorization.
3-
*
4-
* Validates that the authenticated user is an admin/owner of an enterprise organization
5-
* and returns the organization context needed for scoped queries.
6-
*/
7-
8-
import { db } from '@sim/db'
9-
import { member, subscription } from '@sim/db/schema'
10-
import { createLogger } from '@sim/logger'
11-
import { and, eq, inArray } from 'drizzle-orm'
121
import { NextResponse } from 'next/server'
13-
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
14-
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
15-
import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags'
16-
17-
const logger = createLogger('V1AuditLogsAuth')
18-
19-
interface EnterpriseAuditContext {
20-
organizationId: string
21-
orgMemberIds: string[]
22-
}
2+
import {
3+
type EnterpriseAuditContext,
4+
resolveEnterpriseAuditAccess,
5+
} from '@/lib/audit-logs/authorization'
236

247
type AuthResult =
258
| { success: true; context: EnterpriseAuditContext }
269
| { success: false; response: NextResponse }
2710

28-
/**
29-
* Structured enterprise audit-access result shared by the v1 and v2 surfaces so
30-
* each version can render the failure in its own response envelope.
31-
*/
32-
export type EnterpriseAuditAccessResult =
33-
| { success: true; context: EnterpriseAuditContext }
34-
| { success: false; status: number; message: string }
35-
36-
/**
37-
* Core enterprise audit-access check (no response rendering).
38-
*
39-
* Checks:
40-
* 1. User belongs to an organization (the target one when
41-
* `targetOrganizationId` is given)
42-
* 2. User has admin or owner role
43-
* 3. The organization is entitled to audit logs — an active enterprise
44-
* subscription when billing runs, otherwise the deployment's audit-logs
45-
* entitlement
46-
*
47-
* The subscription query is skipped entirely with billing off. Requiring it
48-
* there made audit logs unreachable on every self-hosted deployment, since no
49-
* subscription row is ever written without billing.
50-
*
51-
* Returns the organization ID and all member user IDs on success.
52-
*/
53-
export async function resolveEnterpriseAuditAccess(
54-
userId: string,
55-
targetOrganizationId?: string
56-
): Promise<EnterpriseAuditAccessResult> {
57-
const [membership] = await db
58-
.select({ organizationId: member.organizationId, role: member.role })
59-
.from(member)
60-
.where(
61-
targetOrganizationId
62-
? and(eq(member.userId, userId), eq(member.organizationId, targetOrganizationId))
63-
: eq(member.userId, userId)
64-
)
65-
.limit(1)
66-
67-
if (!membership) {
68-
return {
69-
success: false,
70-
status: 403,
71-
message: targetOrganizationId
72-
? 'Not a member of the requested organization'
73-
: 'Not a member of any organization',
74-
}
75-
}
76-
77-
if (membership.role !== 'admin' && membership.role !== 'owner') {
78-
return { success: false, status: 403, message: 'Organization admin or owner role required' }
79-
}
80-
81-
if (isBillingEnabled) {
82-
const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId)
83-
if (billingBlocked) {
84-
return { success: false, status: 403, message: 'Active enterprise subscription required' }
85-
}
86-
} else if (!isAuditLogsEnabled) {
87-
return {
88-
success: false,
89-
status: 403,
90-
message:
91-
'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.',
92-
}
93-
}
94-
95-
const [orgSub, orgMembers] = await Promise.all([
96-
isBillingEnabled
97-
? db
98-
.select({ id: subscription.id })
99-
.from(subscription)
100-
.where(
101-
and(
102-
eq(subscription.referenceId, membership.organizationId),
103-
eq(subscription.plan, 'enterprise'),
104-
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
105-
)
106-
)
107-
.limit(1)
108-
: Promise.resolve([]),
109-
db
110-
.select({ userId: member.userId })
111-
.from(member)
112-
.where(eq(member.organizationId, membership.organizationId)),
113-
])
114-
115-
if (isBillingEnabled && orgSub.length === 0) {
116-
return { success: false, status: 403, message: 'Active enterprise subscription required' }
117-
}
118-
119-
const orgMemberIds = orgMembers.map((m) => m.userId)
120-
121-
logger.info('Enterprise audit access validated', {
122-
userId,
123-
organizationId: membership.organizationId,
124-
memberCount: orgMemberIds.length,
125-
})
126-
127-
return {
128-
success: true,
129-
context: { organizationId: membership.organizationId, orgMemberIds },
130-
}
131-
}
132-
13311
/**
13412
* v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }`
13513
* response body.

apps/sim/app/api/v1/audit-logs/route.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
3434
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
3535
}))
3636

37-
vi.mock('@/app/api/v1/audit-logs/query', () => ({
37+
vi.mock('@/lib/audit-logs/query', () => ({
3838
buildFilterConditions: mockBuildFilterConditions,
3939
buildOrgScopeCondition: mockBuildOrgScopeCondition,
4040
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,

apps/sim/app/api/v1/audit-logs/route.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ import { generateId } from '@sim/utils/id'
2525
import { type NextRequest, NextResponse } from 'next/server'
2626
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
2727
import { parseRequest } from '@/lib/api/server'
28-
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
29-
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
30-
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
3128
import {
3229
buildFilterConditions,
3330
buildOrgScopeCondition,
3431
getOrgWorkspaceIds,
3532
queryAuditLogs,
36-
} from '@/app/api/v1/audit-logs/query'
33+
} from '@/lib/audit-logs/query'
34+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
35+
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
36+
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
3737
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
3838
import {
3939
checkRateLimit,

apps/sim/app/api/v2/audit-logs/route.test.ts

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,25 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import {
5+
V2_OPERATION_RATE_LIMIT_ALLOWED,
6+
V2_PREAUTH_RATE_LIMIT_ALLOWED,
7+
v2ApiKeyAuthModuleMock,
8+
v2GateModuleMock,
9+
v2RateLimiterModuleMock,
10+
v2RouteMocks,
11+
} from '@sim/testing'
412
import { NextRequest } from 'next/server'
513
import { beforeEach, describe, expect, it, vi } from 'vitest'
614

715
const mocks = vi.hoisted(() => ({
8-
authenticate: vi.fn(),
9-
checkPreauth: vi.fn(),
10-
checkOperationRate: vi.fn(),
11-
gate: vi.fn(),
1216
list: vi.fn(),
1317
get: vi.fn(),
1418
}))
1519

16-
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({
17-
authenticateV2ApiKey: mocks.authenticate,
18-
V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {},
19-
}))
20-
21-
vi.mock('@/lib/core/rate-limiter', () => ({
22-
getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }),
23-
RateLimiter: class RateLimiter {
24-
checkRateLimitDirect = mocks.checkPreauth
25-
checkRateLimitDirectOrThrow = mocks.checkOperationRate
26-
},
27-
}))
28-
29-
vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate }))
20+
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
21+
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
22+
vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock)
3023

3124
vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({
3225
listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.list },
@@ -67,18 +60,10 @@ const log = {
6760
describe('v2 audit-log routes', () => {
6861
beforeEach(() => {
6962
vi.clearAllMocks()
70-
mocks.authenticate.mockResolvedValue(auth)
71-
mocks.gate.mockResolvedValue(null)
72-
mocks.checkPreauth.mockResolvedValue({
73-
allowed: true,
74-
remaining: 599,
75-
resetAt: new Date('2026-08-01T01:00:00Z'),
76-
})
77-
mocks.checkOperationRate.mockResolvedValue({
78-
allowed: true,
79-
remaining: 99,
80-
resetAt: new Date('2026-08-01T01:00:00Z'),
81-
})
63+
v2RouteMocks.authenticate.mockResolvedValue(auth)
64+
v2RouteMocks.gate.mockResolvedValue(null)
65+
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
66+
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
8267
mocks.list.mockResolvedValue({ data: [log], nextCursor: 'next-1' })
8368
mocks.get.mockResolvedValue({ log })
8469
})
@@ -87,8 +72,8 @@ describe('v2 audit-log routes', () => {
8772
const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs'))
8873

8974
expect(response.status).toBe(400)
90-
expect(mocks.authenticate).toHaveBeenCalled()
91-
expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2)
75+
expect(v2RouteMocks.authenticate).toHaveBeenCalled()
76+
expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2)
9277
expect(mocks.list).not.toHaveBeenCalled()
9378
})
9479

apps/sim/app/api/v2/billing/logs/route.test.ts

Lines changed: 16 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,24 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import {
5+
V2_OPERATION_RATE_LIMIT_ALLOWED,
6+
V2_PREAUTH_RATE_LIMIT_ALLOWED,
7+
v2ApiKeyAuthModuleMock,
8+
v2GateModuleMock,
9+
v2RateLimiterModuleMock,
10+
v2RouteMocks,
11+
} from '@sim/testing'
412
import { NextRequest } from 'next/server'
513
import { beforeEach, describe, expect, it, vi } from 'vitest'
614

715
const mocks = vi.hoisted(() => ({
8-
authenticate: vi.fn(),
9-
checkPreauth: vi.fn(),
10-
checkOperationRate: vi.fn(),
11-
gate: vi.fn(),
1216
execute: vi.fn(),
1317
}))
1418

15-
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({
16-
authenticateV2ApiKey: mocks.authenticate,
17-
V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {},
18-
}))
19-
20-
vi.mock('@/lib/core/rate-limiter', () => ({
21-
getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }),
22-
RateLimiter: class RateLimiter {
23-
checkRateLimitDirect = mocks.checkPreauth
24-
checkRateLimitDirectOrThrow = mocks.checkOperationRate
25-
},
26-
}))
27-
28-
vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate }))
19+
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
20+
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
21+
vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock)
2922

3023
vi.mock('@/lib/billing/application/list-billing-logs', () => ({
3124
listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute },
@@ -46,18 +39,10 @@ describe('GET /api/v2/billing/logs', () => {
4639
vi.clearAllMocks()
4740
vi.useFakeTimers()
4841
vi.setSystemTime(new Date('2026-08-01T00:00:00Z'))
49-
mocks.authenticate.mockResolvedValue(auth)
50-
mocks.gate.mockResolvedValue(null)
51-
mocks.checkPreauth.mockResolvedValue({
52-
allowed: true,
53-
remaining: 599,
54-
resetAt: new Date('2026-08-01T01:00:00Z'),
55-
})
56-
mocks.checkOperationRate.mockResolvedValue({
57-
allowed: true,
58-
remaining: 99,
59-
resetAt: new Date('2026-08-01T01:00:00Z'),
60-
})
42+
v2RouteMocks.authenticate.mockResolvedValue(auth)
43+
v2RouteMocks.gate.mockResolvedValue(null)
44+
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
45+
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
6146
mocks.execute.mockResolvedValue({
6247
usage: {
6348
logs: [
@@ -116,7 +101,7 @@ describe('GET /api/v2/billing/logs', () => {
116101
)
117102

118103
expect(response.status).toBe(400)
119-
expect(mocks.authenticate).toHaveBeenCalled()
104+
expect(v2RouteMocks.authenticate).toHaveBeenCalled()
120105
expect(mocks.execute).not.toHaveBeenCalled()
121106
})
122107
})

0 commit comments

Comments
 (0)