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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/api/audit-logs/export/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))

vi.mock('@/app/api/v1/audit-logs/query', () => ({
vi.mock('@/lib/audit-logs/query', () => ({
buildFilterConditions: mockBuildFilterConditions,
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
Expand Down
12 changes: 6 additions & 6 deletions apps/sim/app/api/audit-logs/export/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { exportAuditLogsContract } from '@/lib/api/contracts/audit-logs'
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/app/api/v1/audit-logs/query'
} from '@/lib/audit-logs/query'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'

const logger = createLogger('AuditLogsExportAPI')

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/v1/admin/audit-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { createLogger } from '@sim/logger'
import { and, count, desc } from 'drizzle-orm'
import { v1AdminListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
import { buildFilterConditions } from '@/lib/audit-logs/query'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { withAdminAuth } from '@/app/api/v1/admin/middleware'
import {
Expand All @@ -32,7 +33,6 @@ import {
listResponse,
} from '@/app/api/v1/admin/responses'
import { type AdminAuditLog, createPaginationMeta, toAdminAuditLog } from '@/app/api/v1/admin/types'
import { buildFilterConditions } from '@/app/api/v1/audit-logs/query'

const logger = createLogger('AdminAuditLogsAPI')

Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/v1/audit-logs/[id]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))

vi.mock('@/app/api/v1/audit-logs/query', () => ({
vi.mock('@/lib/audit-logs/query', () => ({
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
}))
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/v1/audit-logs/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v1GetAuditLogContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/lib/audit-logs/query'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { buildOrgScopeCondition, getOrgWorkspaceIds } from '@/app/api/v1/audit-logs/query'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'

Expand Down
130 changes: 4 additions & 126 deletions apps/sim/app/api/v1/audit-logs/auth.ts
Original file line number Diff line number Diff line change
@@ -1,135 +1,13 @@
/**
* Enterprise audit log authorization.
*
* Validates that the authenticated user is an admin/owner of an enterprise organization
* and returns the organization context needed for scoped queries.
*/

import { db } from '@sim/db'
import { member, subscription } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { isOrganizationBillingBlocked } from '@/lib/billing/core/access'
import { USABLE_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils'
import { isAuditLogsEnabled, isBillingEnabled } from '@/lib/core/config/env-flags'

const logger = createLogger('V1AuditLogsAuth')

interface EnterpriseAuditContext {
organizationId: string
orgMemberIds: string[]
}
import {
type EnterpriseAuditContext,
resolveEnterpriseAuditAccess,
} from '@/lib/audit-logs/authorization'

type AuthResult =
| { success: true; context: EnterpriseAuditContext }
| { success: false; response: NextResponse }

/**
* Structured enterprise audit-access result shared by the v1 and v2 surfaces so
* each version can render the failure in its own response envelope.
*/
export type EnterpriseAuditAccessResult =
| { success: true; context: EnterpriseAuditContext }
| { success: false; status: number; message: string }

/**
* Core enterprise audit-access check (no response rendering).
*
* Checks:
* 1. User belongs to an organization (the target one when
* `targetOrganizationId` is given)
* 2. User has admin or owner role
* 3. The organization is entitled to audit logs — an active enterprise
* subscription when billing runs, otherwise the deployment's audit-logs
* entitlement
*
* The subscription query is skipped entirely with billing off. Requiring it
* there made audit logs unreachable on every self-hosted deployment, since no
* subscription row is ever written without billing.
*
* Returns the organization ID and all member user IDs on success.
*/
export async function resolveEnterpriseAuditAccess(
userId: string,
targetOrganizationId?: string
): Promise<EnterpriseAuditAccessResult> {
const [membership] = await db
.select({ organizationId: member.organizationId, role: member.role })
.from(member)
.where(
targetOrganizationId
? and(eq(member.userId, userId), eq(member.organizationId, targetOrganizationId))
: eq(member.userId, userId)
)
.limit(1)

if (!membership) {
return {
success: false,
status: 403,
message: targetOrganizationId
? 'Not a member of the requested organization'
: 'Not a member of any organization',
}
}

if (membership.role !== 'admin' && membership.role !== 'owner') {
return { success: false, status: 403, message: 'Organization admin or owner role required' }
}

if (isBillingEnabled) {
const billingBlocked = await isOrganizationBillingBlocked(membership.organizationId)
if (billingBlocked) {
return { success: false, status: 403, message: 'Active enterprise subscription required' }
}
} else if (!isAuditLogsEnabled) {
return {
success: false,
status: 403,
message:
'Audit logs are disabled. Set ENTERPRISE_ENABLED or AUDIT_LOGS_ENABLED to enable them.',
}
}

const [orgSub, orgMembers] = await Promise.all([
isBillingEnabled
? db
.select({ id: subscription.id })
.from(subscription)
.where(
and(
eq(subscription.referenceId, membership.organizationId),
eq(subscription.plan, 'enterprise'),
inArray(subscription.status, USABLE_SUBSCRIPTION_STATUSES)
)
)
.limit(1)
: Promise.resolve([]),
db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, membership.organizationId)),
])

if (isBillingEnabled && orgSub.length === 0) {
return { success: false, status: 403, message: 'Active enterprise subscription required' }
}

const orgMemberIds = orgMembers.map((m) => m.userId)

logger.info('Enterprise audit access validated', {
userId,
organizationId: membership.organizationId,
memberCount: orgMemberIds.length,
})

return {
success: true,
context: { organizationId: membership.organizationId, orgMemberIds },
}
}

/**
* v1 wrapper: renders {@link resolveEnterpriseAuditAccess} as the v1 `{ error }`
* response body.
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/app/api/v1/audit-logs/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ vi.mock('@/app/api/v1/audit-logs/auth', () => ({
validateEnterpriseAuditAccess: mockValidateEnterpriseAuditAccess,
}))

vi.mock('@/app/api/v1/audit-logs/query', () => ({
vi.mock('@/lib/audit-logs/query', () => ({
buildFilterConditions: mockBuildFilterConditions,
buildOrgScopeCondition: mockBuildOrgScopeCondition,
getOrgWorkspaceIds: mockGetOrgWorkspaceIds,
Expand Down
8 changes: 4 additions & 4 deletions apps/sim/app/api/v1/audit-logs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ import { generateId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { v1ListAuditLogsContract } from '@/lib/api/contracts/v1/audit-logs'
import { parseRequest } from '@/lib/api/server'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import {
buildFilterConditions,
buildOrgScopeCondition,
getOrgWorkspaceIds,
queryAuditLogs,
} from '@/app/api/v1/audit-logs/query'
} from '@/lib/audit-logs/query'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
import {
checkRateLimit,
Expand Down
49 changes: 17 additions & 32 deletions apps/sim/app/api/v2/audit-logs/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
/**
* @vitest-environment node
*/
import {
V2_OPERATION_RATE_LIMIT_ALLOWED,
V2_PREAUTH_RATE_LIMIT_ALLOWED,
v2ApiKeyAuthModuleMock,
v2GateModuleMock,
v2RateLimiterModuleMock,
v2RouteMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
checkPreauth: vi.fn(),
checkOperationRate: vi.fn(),
gate: vi.fn(),
list: vi.fn(),
get: vi.fn(),
}))

vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({
authenticateV2ApiKey: mocks.authenticate,
V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {},
}))

vi.mock('@/lib/core/rate-limiter', () => ({
getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }),
RateLimiter: class RateLimiter {
checkRateLimitDirect = mocks.checkPreauth
checkRateLimitDirectOrThrow = mocks.checkOperationRate
},
}))

vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate }))
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock)

vi.mock('@/lib/audit-logs/application/list-audit-logs', () => ({
listAuditLogs: { operation: { id: 'audit_logs.list' }, execute: mocks.list },
Expand Down Expand Up @@ -67,18 +60,10 @@ const log = {
describe('v2 audit-log routes', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.authenticate.mockResolvedValue(auth)
mocks.gate.mockResolvedValue(null)
mocks.checkPreauth.mockResolvedValue({
allowed: true,
remaining: 599,
resetAt: new Date('2026-08-01T01:00:00Z'),
})
mocks.checkOperationRate.mockResolvedValue({
allowed: true,
remaining: 99,
resetAt: new Date('2026-08-01T01:00:00Z'),
})
v2RouteMocks.authenticate.mockResolvedValue(auth)
v2RouteMocks.gate.mockResolvedValue(null)
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
mocks.list.mockResolvedValue({ data: [log], nextCursor: 'next-1' })
mocks.get.mockResolvedValue({ log })
})
Expand All @@ -87,8 +72,8 @@ describe('v2 audit-log routes', () => {
const response = await listLogs(new NextRequest('http://localhost:3000/api/v2/audit-logs'))

expect(response.status).toBe(400)
expect(mocks.authenticate).toHaveBeenCalled()
expect(mocks.checkOperationRate).toHaveBeenCalledTimes(2)
expect(v2RouteMocks.authenticate).toHaveBeenCalled()
expect(v2RouteMocks.operationRate).toHaveBeenCalledTimes(2)
expect(mocks.list).not.toHaveBeenCalled()
})

Expand Down
47 changes: 16 additions & 31 deletions apps/sim/app/api/v2/billing/logs/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,24 @@
/**
* @vitest-environment node
*/
import {
V2_OPERATION_RATE_LIMIT_ALLOWED,
V2_PREAUTH_RATE_LIMIT_ALLOWED,
v2ApiKeyAuthModuleMock,
v2GateModuleMock,
v2RateLimiterModuleMock,
v2RouteMocks,
} from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
authenticate: vi.fn(),
checkPreauth: vi.fn(),
checkOperationRate: vi.fn(),
gate: vi.fn(),
execute: vi.fn(),
}))

vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({
authenticateV2ApiKey: mocks.authenticate,
V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {},
}))

vi.mock('@/lib/core/rate-limiter', () => ({
getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }),
RateLimiter: class RateLimiter {
checkRateLimitDirect = mocks.checkPreauth
checkRateLimitDirectOrThrow = mocks.checkOperationRate
},
}))

vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate }))
vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
vi.mock('@/app/api/v2/lib/gate', () => v2GateModuleMock)

vi.mock('@/lib/billing/application/list-billing-logs', () => ({
listBillingLogs: { operation: { id: 'billing.logs.list' }, execute: mocks.execute },
Expand All @@ -46,18 +39,10 @@ describe('GET /api/v2/billing/logs', () => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-01T00:00:00Z'))
mocks.authenticate.mockResolvedValue(auth)
mocks.gate.mockResolvedValue(null)
mocks.checkPreauth.mockResolvedValue({
allowed: true,
remaining: 599,
resetAt: new Date('2026-08-01T01:00:00Z'),
})
mocks.checkOperationRate.mockResolvedValue({
allowed: true,
remaining: 99,
resetAt: new Date('2026-08-01T01:00:00Z'),
})
v2RouteMocks.authenticate.mockResolvedValue(auth)
v2RouteMocks.gate.mockResolvedValue(null)
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
mocks.execute.mockResolvedValue({
usage: {
logs: [
Expand Down Expand Up @@ -116,7 +101,7 @@ describe('GET /api/v2/billing/logs', () => {
)

expect(response.status).toBe(400)
expect(mocks.authenticate).toHaveBeenCalled()
expect(v2RouteMocks.authenticate).toHaveBeenCalled()
expect(mocks.execute).not.toHaveBeenCalled()
})
})
Loading
Loading