From fda09f224c4ed52ec9bfc6de57bd9773cf758cba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 12:09:45 -0800 Subject: [PATCH 1/3] feat(api): audit log read endpoints for admin and enterprise --- .../app/api/v1/admin/audit-logs/[id]/route.ts | 44 ++++ apps/sim/app/api/v1/admin/audit-logs/route.ts | 96 +++++++++ apps/sim/app/api/v1/admin/types.ts | 43 ++++ apps/sim/app/api/v1/audit-logs/[id]/route.ts | 70 +++++++ apps/sim/app/api/v1/audit-logs/auth.ts | 106 ++++++++++ apps/sim/app/api/v1/audit-logs/format.ts | 43 ++++ apps/sim/app/api/v1/audit-logs/route.ts | 191 ++++++++++++++++++ apps/sim/app/api/v1/middleware.ts | 2 +- 8 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v1/admin/audit-logs/route.ts create mode 100644 apps/sim/app/api/v1/audit-logs/[id]/route.ts create mode 100644 apps/sim/app/api/v1/audit-logs/auth.ts create mode 100644 apps/sim/app/api/v1/audit-logs/format.ts create mode 100644 apps/sim/app/api/v1/audit-logs/route.ts diff --git a/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts new file mode 100644 index 0000000000..848fbc8b31 --- /dev/null +++ b/apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts @@ -0,0 +1,44 @@ +/** + * GET /api/v1/admin/audit-logs/[id] + * + * Get a single audit log entry by ID. + * + * Response: AdminSingleResponse + */ + +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' +import { + internalErrorResponse, + notFoundResponse, + singleResponse, +} from '@/app/api/v1/admin/responses' +import { toAdminAuditLog } from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminAuditLogDetailAPI') + +interface RouteParams { + id: string +} + +export const GET = withAdminAuthParams(async (request, context) => { + const { id } = await context.params + + try { + const [log] = await db.select().from(auditLog).where(eq(auditLog.id, id)).limit(1) + + if (!log) { + return notFoundResponse('AuditLog') + } + + logger.info(`Admin API: Retrieved audit log ${id}`) + + return singleResponse(toAdminAuditLog(log)) + } catch (error) { + logger.error('Admin API: Failed to get audit log', { error, id }) + return internalErrorResponse('Failed to get audit log') + } +}) diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts new file mode 100644 index 0000000000..1cce568b2e --- /dev/null +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -0,0 +1,96 @@ +/** + * GET /api/v1/admin/audit-logs + * + * List all audit logs with pagination and filtering. + * + * Query Parameters: + * - limit: number (default: 50, max: 250) + * - offset: number (default: 0) + * - action: string (optional) - Filter by action (e.g., "workflow.created") + * - resourceType: string (optional) - Filter by resource type (e.g., "workflow") + * - resourceId: string (optional) - Filter by resource ID + * - workspaceId: string (optional) - Filter by workspace ID + * - actorId: string (optional) - Filter by actor user ID + * - actorEmail: string (optional) - Filter by actor email + * - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate + * - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate + * + * Response: AdminListResponse + */ + +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, count, desc, eq, gte, lte, type SQL } from 'drizzle-orm' +import { withAdminAuth } from '@/app/api/v1/admin/middleware' +import { + badRequestResponse, + internalErrorResponse, + listResponse, +} from '@/app/api/v1/admin/responses' +import { + type AdminAuditLog, + createPaginationMeta, + parsePaginationParams, + toAdminAuditLog, +} from '@/app/api/v1/admin/types' + +const logger = createLogger('AdminAuditLogsAPI') + +export const GET = withAdminAuth(async (request) => { + const url = new URL(request.url) + const { limit, offset } = parsePaginationParams(url) + + const actionFilter = url.searchParams.get('action') + const resourceTypeFilter = url.searchParams.get('resourceType') + const resourceIdFilter = url.searchParams.get('resourceId') + const workspaceIdFilter = url.searchParams.get('workspaceId') + const actorIdFilter = url.searchParams.get('actorId') + const actorEmailFilter = url.searchParams.get('actorEmail') + const startDateFilter = url.searchParams.get('startDate') + const endDateFilter = url.searchParams.get('endDate') + + if (startDateFilter && isNaN(Date.parse(startDateFilter))) { + return badRequestResponse('Invalid startDate format. Use ISO 8601.') + } + if (endDateFilter && isNaN(Date.parse(endDateFilter))) { + return badRequestResponse('Invalid endDate format. Use ISO 8601.') + } + + try { + const conditions: SQL[] = [] + + if (actionFilter) conditions.push(eq(auditLog.action, actionFilter)) + if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter)) + if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter)) + if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter)) + if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter)) + if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter)) + if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter))) + if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter))) + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const [countResult, logs] = await Promise.all([ + db.select({ total: count() }).from(auditLog).where(whereClause), + db + .select() + .from(auditLog) + .where(whereClause) + .orderBy(desc(auditLog.createdAt)) + .limit(limit) + .offset(offset), + ]) + + const total = countResult[0].total + const data: AdminAuditLog[] = logs.map(toAdminAuditLog) + const pagination = createPaginationMeta(total, limit, offset) + + logger.info(`Admin API: Listed ${data.length} audit logs (total: ${total})`) + + return listResponse(data, pagination) + } catch (error) { + logger.error('Admin API: Failed to list audit logs', { error }) + return internalErrorResponse('Failed to list audit logs') + } +}) diff --git a/apps/sim/app/api/v1/admin/types.ts b/apps/sim/app/api/v1/admin/types.ts index d7ec4f5c3c..3cfd515b25 100644 --- a/apps/sim/app/api/v1/admin/types.ts +++ b/apps/sim/app/api/v1/admin/types.ts @@ -6,6 +6,7 @@ */ import type { + auditLog, member, organization, referralCampaigns, @@ -694,3 +695,45 @@ export function toAdminReferralCampaign( updatedAt: dbCampaign.updatedAt.toISOString(), } } + +// ============================================================================= +// Audit Log Types +// ============================================================================= + +export type DbAuditLog = InferSelectModel + +export interface AdminAuditLog { + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + ipAddress: string | null + userAgent: string | null + createdAt: string +} + +export function toAdminAuditLog(dbLog: DbAuditLog): AdminAuditLog { + return { + id: dbLog.id, + workspaceId: dbLog.workspaceId, + actorId: dbLog.actorId, + actorName: dbLog.actorName, + actorEmail: dbLog.actorEmail, + action: dbLog.action, + resourceType: dbLog.resourceType, + resourceId: dbLog.resourceId, + resourceName: dbLog.resourceName, + description: dbLog.description, + metadata: dbLog.metadata, + ipAddress: dbLog.ipAddress, + userAgent: dbLog.userAgent, + createdAt: dbLog.createdAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts new file mode 100644 index 0000000000..01519d9c4e --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -0,0 +1,70 @@ +/** + * GET /api/v1/audit-logs/[id] + * + * Get a single audit log entry by ID, scoped to the authenticated user's organization. + * Requires enterprise subscription and org admin/owner role. + * + * Response: { data: AuditLogEntry, limits: UserLimits } + */ + +import { db } from '@sim/db' +import { auditLog } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, inArray } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +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, createRateLimitResponse } from '@/app/api/v1/middleware' + +const logger = createLogger('V1AuditLogDetailAPI') + +export const revalidate = 0 + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + const { id } = await params + + const authResult = await validateEnterpriseAuditAccess(userId) + if (!authResult.success) { + return authResult.response + } + + const { orgMemberIds } = authResult.context + + const [log] = await db + .select() + .from(auditLog) + .where( + and( + eq(auditLog.id, id), + inArray(auditLog.actorId, orgMemberIds) + ) + ) + .limit(1) + + if (!log) { + return NextResponse.json({ error: 'Audit log not found' }, { status: 404 }) + } + + const limits = await getUserLimits(userId) + const response = createApiResponse({ data: formatAuditLogEntry(log) }, limits, rateLimit) + + return NextResponse.json(response.body, { headers: response.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Audit log detail fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts new file mode 100644 index 0000000000..17e2bf956c --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -0,0 +1,106 @@ +/** + * 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 } from 'drizzle-orm' +import { NextResponse } from 'next/server' + +const logger = createLogger('V1AuditLogsAuth') + +export interface EnterpriseAuditContext { + organizationId: string + orgMemberIds: string[] +} + +type AuthResult = + | { success: true; context: EnterpriseAuditContext } + | { success: false; response: NextResponse } + +/** + * Validates enterprise audit log access for the given user. + * + * Checks: + * 1. User belongs to an organization + * 2. User has admin or owner role + * 3. Organization has an active enterprise subscription + * + * Returns the organization ID and all member user IDs on success, + * or an error response on failure. + */ +export async function validateEnterpriseAuditAccess(userId: string): Promise { + const [membership] = await db + .select({ organizationId: member.organizationId, role: member.role }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1) + + if (!membership) { + return { + success: false, + response: NextResponse.json( + { error: 'Not a member of any organization' }, + { status: 403 } + ), + } + } + + if (membership.role !== 'admin' && membership.role !== 'owner') { + return { + success: false, + response: NextResponse.json( + { error: 'Organization admin or owner role required' }, + { status: 403 } + ), + } + } + + const [orgSub, orgMembers] = await Promise.all([ + db + .select({ id: subscription.id }) + .from(subscription) + .where( + and( + eq(subscription.referenceId, membership.organizationId), + eq(subscription.plan, 'enterprise'), + eq(subscription.status, 'active') + ) + ) + .limit(1), + db + .select({ userId: member.userId }) + .from(member) + .where(eq(member.organizationId, membership.organizationId)), + ]) + + if (orgSub.length === 0) { + return { + success: false, + response: NextResponse.json( + { error: 'Active enterprise subscription required' }, + { status: 403 } + ), + } + } + + 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, + }, + } +} diff --git a/apps/sim/app/api/v1/audit-logs/format.ts b/apps/sim/app/api/v1/audit-logs/format.ts new file mode 100644 index 0000000000..5591f4f6f8 --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/format.ts @@ -0,0 +1,43 @@ +/** + * Enterprise audit log response formatting. + * + * Defines the shape returned by the enterprise audit log API. + * Excludes `ipAddress` and `userAgent` for privacy. + */ + +import type { auditLog } from '@sim/db/schema' +import type { InferSelectModel } from 'drizzle-orm' + +type DbAuditLog = InferSelectModel + +export interface EnterpriseAuditLogEntry { + id: string + workspaceId: string | null + actorId: string | null + actorName: string | null + actorEmail: string | null + action: string + resourceType: string + resourceId: string | null + resourceName: string | null + description: string | null + metadata: unknown + createdAt: string +} + +export function formatAuditLogEntry(log: DbAuditLog): EnterpriseAuditLogEntry { + return { + id: log.id, + workspaceId: log.workspaceId, + actorId: log.actorId, + actorName: log.actorName, + actorEmail: log.actorEmail, + action: log.action, + resourceType: log.resourceType, + resourceId: log.resourceId, + resourceName: log.resourceName, + description: log.description, + metadata: log.metadata, + createdAt: log.createdAt.toISOString(), + } +} diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts new file mode 100644 index 0000000000..313c3fc109 --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -0,0 +1,191 @@ +/** + * GET /api/v1/audit-logs + * + * List audit logs scoped to the authenticated user's organization. + * Requires enterprise subscription and org admin/owner role. + * + * Query Parameters: + * - action: string (optional) - Filter by action (e.g., "workflow.created") + * - resourceType: string (optional) - Filter by resource type (e.g., "workflow") + * - resourceId: string (optional) - Filter by resource ID + * - workspaceId: string (optional) - Filter by workspace ID + * - actorId: string (optional) - Filter by actor user ID (must be an org member) + * - startDate: string (optional) - ISO 8601 date, filter createdAt >= startDate + * - endDate: string (optional) - ISO 8601 date, filter createdAt <= endDate + * - includeDeparted: boolean (optional, default: false) - Include logs from departed members + * - limit: number (optional, default: 50, max: 100) + * - cursor: string (optional) - Opaque cursor for pagination + * + * Response: { data: AuditLogEntry[], nextCursor?: string, limits: UserLimits } + */ + +import { db } from '@sim/db' +import { auditLog, workspace } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, desc, eq, gte, inArray, lt, lte, or, type SQL } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +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, createRateLimitResponse } from '@/app/api/v1/middleware' + +const logger = createLogger('V1AuditLogsAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +const isoDateString = z.string().refine((val) => !isNaN(Date.parse(val)), { + message: 'Invalid date format. Use ISO 8601.', +}) + +const QueryParamsSchema = z.object({ + action: z.string().optional(), + resourceType: z.string().optional(), + resourceId: z.string().optional(), + workspaceId: z.string().optional(), + actorId: z.string().optional(), + startDate: isoDateString.optional(), + endDate: isoDateString.optional(), + includeDeparted: z.coerce.boolean().optional().default(false), + limit: z.coerce.number().min(1).max(100).optional().default(50), + cursor: z.string().optional(), +}) + +interface CursorData { + createdAt: string + id: string +} + +function encodeCursor(data: CursorData): string { + return Buffer.from(JSON.stringify(data)).toString('base64') +} + +function decodeCursor(cursor: string): CursorData | null { + try { + return JSON.parse(Buffer.from(cursor, 'base64').toString()) + } catch { + return null + } +} + +export async function GET(request: NextRequest) { + const requestId = crypto.randomUUID().slice(0, 8) + + try { + const rateLimit = await checkRateLimit(request, 'audit-logs') + if (!rateLimit.allowed) { + return createRateLimitResponse(rateLimit) + } + + const userId = rateLimit.userId! + + const authResult = await validateEnterpriseAuditAccess(userId) + if (!authResult.success) { + return authResult.response + } + + const { orgMemberIds } = authResult.context + + const { searchParams } = new URL(request.url) + const rawParams = Object.fromEntries(searchParams.entries()) + const validationResult = QueryParamsSchema.safeParse(rawParams) + + if (!validationResult.success) { + return NextResponse.json( + { error: 'Invalid parameters', details: validationResult.error.errors }, + { status: 400 } + ) + } + + const params = validationResult.data + + if (params.actorId && !orgMemberIds.includes(params.actorId)) { + return NextResponse.json( + { error: 'actorId is not a member of your organization' }, + { status: 400 } + ) + } + + let scopeCondition: SQL + + if (params.includeDeparted) { + const orgWorkspaces = await db + .select({ id: workspace.id }) + .from(workspace) + .where(inArray(workspace.ownerId, orgMemberIds)) + + const orgWorkspaceIds = orgWorkspaces.map((w) => w.id) + + if (orgWorkspaceIds.length > 0) { + scopeCondition = or( + inArray(auditLog.actorId, orgMemberIds), + inArray(auditLog.workspaceId, orgWorkspaceIds) + )! + } else { + scopeCondition = inArray(auditLog.actorId, orgMemberIds) + } + } else { + scopeCondition = inArray(auditLog.actorId, orgMemberIds) + } + + const conditions: SQL[] = [scopeCondition] + + if (params.action) conditions.push(eq(auditLog.action, params.action)) + if (params.resourceType) conditions.push(eq(auditLog.resourceType, params.resourceType)) + if (params.resourceId) conditions.push(eq(auditLog.resourceId, params.resourceId)) + if (params.workspaceId) conditions.push(eq(auditLog.workspaceId, params.workspaceId)) + if (params.actorId) conditions.push(eq(auditLog.actorId, params.actorId)) + if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate))) + if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate))) + + if (params.cursor) { + const cursorData = decodeCursor(params.cursor) + if (cursorData) { + conditions.push( + or( + lt(auditLog.createdAt, new Date(cursorData.createdAt)), + and( + eq(auditLog.createdAt, new Date(cursorData.createdAt)), + lt(auditLog.id, cursorData.id) + ) + )! + ) + } + } + + const rows = await db + .select() + .from(auditLog) + .where(and(...conditions)) + .orderBy(desc(auditLog.createdAt), desc(auditLog.id)) + .limit(params.limit + 1) + + const hasMore = rows.length > params.limit + const data = rows.slice(0, params.limit) + + let nextCursor: string | undefined + if (hasMore && data.length > 0) { + const last = data[data.length - 1] + nextCursor = encodeCursor({ + createdAt: last.createdAt.toISOString(), + id: last.id, + }) + } + + const formattedLogs = data.map(formatAuditLogEntry) + + const limits = await getUserLimits(userId) + const response = createApiResponse( + { data: formattedLogs, nextCursor }, + limits, + rateLimit + ) + + return NextResponse.json(response.body, { headers: response.headers }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error(`[${requestId}] Audit logs fetch error`, { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 06b4109433..60a7b93474 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -19,7 +19,7 @@ export interface RateLimitResult { export async function checkRateLimit( request: NextRequest, - endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs' + endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' | 'audit-logs' = 'logs' ): Promise { try { const auth = await authenticateV1Request(request) From 2f08f6e7efac3e9af55c59bce4fc1e0929904028 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 25 Feb 2026 13:04:41 -0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(api):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20boolean=20coercion,=20cursor=20validation,=20detail?= =?UTF-8?q?=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/sim/app/api/v1/audit-logs/[id]/route.ts | 17 ++++++++++-- apps/sim/app/api/v1/audit-logs/route.ts | 29 ++++++++++++-------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 01519d9c4e..80018a3b69 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -4,13 +4,16 @@ * Get a single audit log entry by ID, scoped to the authenticated user's organization. * Requires enterprise subscription and org admin/owner role. * + * Scope includes logs from current org members AND logs within org workspaces + * (including those from departed members or system actions with null actorId). + * * Response: { data: AuditLogEntry, limits: UserLimits } */ import { db } from '@sim/db' -import { auditLog } from '@sim/db/schema' +import { auditLog, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' @@ -43,13 +46,21 @@ export async function GET( const { orgMemberIds } = authResult.context + const orgWorkspaceIds = db + .select({ id: workspace.id }) + .from(workspace) + .where(inArray(workspace.ownerId, orgMemberIds)) + const [log] = await db .select() .from(auditLog) .where( and( eq(auditLog.id, id), - inArray(auditLog.actorId, orgMemberIds) + or( + inArray(auditLog.actorId, orgMemberIds), + inArray(auditLog.workspaceId, orgWorkspaceIds) + ) ) ) .limit(1) diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 313c3fc109..99a525c99f 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -47,7 +47,11 @@ const QueryParamsSchema = z.object({ actorId: z.string().optional(), startDate: isoDateString.optional(), endDate: isoDateString.optional(), - includeDeparted: z.coerce.boolean().optional().default(false), + includeDeparted: z + .enum(['true', 'false']) + .transform((val) => val === 'true') + .optional() + .default('false'), limit: z.coerce.number().min(1).max(100).optional().default(50), cursor: z.string().optional(), }) @@ -141,16 +145,19 @@ export async function GET(request: NextRequest) { if (params.cursor) { const cursorData = decodeCursor(params.cursor) - if (cursorData) { - conditions.push( - or( - lt(auditLog.createdAt, new Date(cursorData.createdAt)), - and( - eq(auditLog.createdAt, new Date(cursorData.createdAt)), - lt(auditLog.id, cursorData.id) - ) - )! - ) + if (cursorData && cursorData.createdAt && cursorData.id) { + const cursorDate = new Date(cursorData.createdAt) + if (!isNaN(cursorDate.getTime())) { + conditions.push( + or( + lt(auditLog.createdAt, cursorDate), + and( + eq(auditLog.createdAt, cursorDate), + lt(auditLog.id, cursorData.id) + ) + )! + ) + } } } From b537f9228be96dd5d82223ec57d64058b4ba3b8d Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 25 Feb 2026 13:23:35 -0800 Subject: [PATCH 3/3] ran lint --- apps/sim/app/api/v1/admin/audit-logs/route.ts | 4 ++-- apps/sim/app/api/v1/audit-logs/[id]/route.ts | 5 +---- apps/sim/app/api/v1/audit-logs/auth.ts | 5 +---- apps/sim/app/api/v1/audit-logs/route.ts | 17 +++++------------ 4 files changed, 9 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/api/v1/admin/audit-logs/route.ts b/apps/sim/app/api/v1/admin/audit-logs/route.ts index 1cce568b2e..895ac1ff3e 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -50,10 +50,10 @@ export const GET = withAdminAuth(async (request) => { const startDateFilter = url.searchParams.get('startDate') const endDateFilter = url.searchParams.get('endDate') - if (startDateFilter && isNaN(Date.parse(startDateFilter))) { + if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) { return badRequestResponse('Invalid startDate format. Use ISO 8601.') } - if (endDateFilter && isNaN(Date.parse(endDateFilter))) { + if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) { return badRequestResponse('Invalid endDate format. Use ISO 8601.') } diff --git a/apps/sim/app/api/v1/audit-logs/[id]/route.ts b/apps/sim/app/api/v1/audit-logs/[id]/route.ts index 80018a3b69..3cf6351d2b 100644 --- a/apps/sim/app/api/v1/audit-logs/[id]/route.ts +++ b/apps/sim/app/api/v1/audit-logs/[id]/route.ts @@ -24,10 +24,7 @@ const logger = createLogger('V1AuditLogDetailAPI') export const revalidate = 0 -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { +export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { const requestId = crypto.randomUUID().slice(0, 8) try { diff --git a/apps/sim/app/api/v1/audit-logs/auth.ts b/apps/sim/app/api/v1/audit-logs/auth.ts index 17e2bf956c..085884488e 100644 --- a/apps/sim/app/api/v1/audit-logs/auth.ts +++ b/apps/sim/app/api/v1/audit-logs/auth.ts @@ -43,10 +43,7 @@ export async function validateEnterpriseAuditAccess(userId: string): Promise !isNaN(Date.parse(val)), { +const isoDateString = z.string().refine((val) => !Number.isNaN(Date.parse(val)), { message: 'Invalid date format. Use ISO 8601.', }) @@ -145,16 +145,13 @@ export async function GET(request: NextRequest) { if (params.cursor) { const cursorData = decodeCursor(params.cursor) - if (cursorData && cursorData.createdAt && cursorData.id) { + if (cursorData?.createdAt && cursorData.id) { const cursorDate = new Date(cursorData.createdAt) - if (!isNaN(cursorDate.getTime())) { + if (!Number.isNaN(cursorDate.getTime())) { conditions.push( or( lt(auditLog.createdAt, cursorDate), - and( - eq(auditLog.createdAt, cursorDate), - lt(auditLog.id, cursorData.id) - ) + and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id)) )! ) } @@ -183,11 +180,7 @@ export async function GET(request: NextRequest) { const formattedLogs = data.map(formatAuditLogEntry) const limits = await getUserLimits(userId) - const response = createApiResponse( - { data: formattedLogs, nextCursor }, - limits, - rateLimit - ) + const response = createApiResponse({ data: formattedLogs, nextCursor }, limits, rateLimit) return NextResponse.json(response.body, { headers: response.headers }) } catch (error: unknown) {