-
Notifications
You must be signed in to change notification settings - Fork 3.4k
feat(api): audit log read endpoints for admin and enterprise #3343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| } | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| } | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
cursor[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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 }) | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.