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
44 changes: 44 additions & 0 deletions apps/sim/app/api/v1/admin/audit-logs/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* GET /api/v1/admin/audit-logs/[id]
*
* Get a single audit log entry by ID.
*
* Response: AdminSingleResponse<AdminAuditLog>
*/

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<RouteParams>(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')
}
})
96 changes: 96 additions & 0 deletions apps/sim/app/api/v1/admin/audit-logs/route.ts
Original file line number Diff line number Diff line change
@@ -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<AdminAuditLog>
*/

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 && Number.isNaN(Date.parse(startDateFilter))) {
return badRequestResponse('Invalid startDate format. Use ISO 8601.')
}
if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) {
return badRequestResponse('Invalid endDate format. Use ISO 8601.')
}

try {
const conditions: SQL<unknown>[] = []

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')
}
})
43 changes: 43 additions & 0 deletions apps/sim/app/api/v1/admin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import type {
auditLog,
member,
organization,
referralCampaigns,
Expand Down Expand Up @@ -694,3 +695,45 @@ export function toAdminReferralCampaign(
updatedAt: dbCampaign.updatedAt.toISOString(),
}
}

// =============================================================================
// Audit Log Types
// =============================================================================

export type DbAuditLog = InferSelectModel<typeof auditLog>

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(),
}
}
78 changes: 78 additions & 0 deletions apps/sim/app/api/v1/audit-logs/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* 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.
*
* 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, workspace } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
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'
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 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),
or(
inArray(auditLog.actorId, orgMemberIds),
inArray(auditLog.workspaceId, orgWorkspaceIds)
)
)
)
.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 })
}
}
103 changes: 103 additions & 0 deletions apps/sim/app/api/v1/audit-logs/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* 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<AuthResult> {
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,
},
}
}
Loading