diff --git a/apps/sim/app/api/audit-logs/route.ts b/apps/sim/app/api/audit-logs/route.ts new file mode 100644 index 00000000000..3be8c2dc3b6 --- /dev/null +++ b/apps/sim/app/api/audit-logs/route.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { getSession } from '@/lib/auth' +import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' +import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' + +const logger = createLogger('AuditLogsAPI') + +export const dynamic = 'force-dynamic' + +export async function GET(request: Request) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const authResult = await validateEnterpriseAuditAccess(session.user.id) + if (!authResult.success) { + return authResult.response + } + + const { orgMemberIds } = authResult.context + + const { searchParams } = new URL(request.url) + const search = searchParams.get('search')?.trim() || undefined + const startDate = searchParams.get('startDate') || undefined + const endDate = searchParams.get('endDate') || undefined + const includeDeparted = searchParams.get('includeDeparted') === 'true' + const limit = Math.min(Math.max(Number(searchParams.get('limit')) || 50, 1), 100) + const cursor = searchParams.get('cursor') || undefined + + if (startDate && Number.isNaN(Date.parse(startDate))) { + return NextResponse.json({ error: 'Invalid startDate format' }, { status: 400 }) + } + if (endDate && Number.isNaN(Date.parse(endDate))) { + return NextResponse.json({ error: 'Invalid endDate format' }, { status: 400 }) + } + + const scopeCondition = await buildOrgScopeCondition(orgMemberIds, includeDeparted) + const filterConditions = buildFilterConditions({ + action: searchParams.get('action') || undefined, + resourceType: searchParams.get('resourceType') || undefined, + actorId: searchParams.get('actorId') || undefined, + search, + startDate, + endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + limit, + cursor + ) + + return NextResponse.json({ + success: true, + data: data.map(formatAuditLogEntry), + nextCursor, + }) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Unknown error' + logger.error('Audit logs fetch error', { error: message }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/auth/forget-password/route.ts b/apps/sim/app/api/auth/forget-password/route.ts index e8f05ecfcf1..9db6eef95c9 100644 --- a/apps/sim/app/api/auth/forget-password/route.ts +++ b/apps/sim/app/api/auth/forget-password/route.ts @@ -1,6 +1,10 @@ +import { db } from '@sim/db' +import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { auth } from '@/lib/auth' import { isSameOrigin } from '@/lib/core/utils/validation' @@ -51,6 +55,26 @@ export async function POST(request: NextRequest) { method: 'POST', }) + const [existingUser] = await db + .select({ id: user.id, name: user.name, email: user.email }) + .from(user) + .where(eq(user.email, email)) + .limit(1) + + if (existingUser) { + recordAudit({ + actorId: existingUser.id, + actorName: existingUser.name, + actorEmail: existingUser.email, + action: AuditAction.PASSWORD_RESET_REQUESTED, + resourceType: AuditResourceType.PASSWORD, + resourceId: existingUser.id, + resourceName: existingUser.email ?? undefined, + description: `Password reset requested for ${existingUser.email}`, + request, + }) + } + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error requesting password reset:', { error }) diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts index 7dfeafb2efe..070b3893133 100644 --- a/apps/sim/app/api/billing/credits/route.ts +++ b/apps/sim/app/api/billing/credits/route.ts @@ -64,8 +64,12 @@ export async function POST(request: NextRequest) { actorEmail: session.user.email, action: AuditAction.CREDIT_PURCHASED, resourceType: AuditResourceType.BILLING, + resourceId: validation.data.requestId, description: `Purchased $${validation.data.amount} in credits`, - metadata: { amount: validation.data.amount, requestId: validation.data.requestId }, + metadata: { + amountDollars: validation.data.amount, + requestId: validation.data.requestId, + }, request, }) diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts index c09688c99d6..8cf37410ae0 100644 --- a/apps/sim/app/api/chat/manage/[id]/route.ts +++ b/apps/sim/app/api/chat/manage/[id]/route.ts @@ -233,6 +233,12 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< resourceId: chatId, resourceName: title || existingChatRecord.title, description: `Updated chat deployment "${title || existingChatRecord.title}"`, + metadata: { + identifier: updatedIdentifier, + authType: updateData.authType || existingChatRecord.authType, + workflowId: workflowId || existingChatRecord.workflowId, + chatUrl, + }, request, }) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts index 9a91b86b8e2..752ebc1a9e7 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/[invitationId]/route.ts @@ -159,7 +159,12 @@ export async function POST( resourceId: id, resourceName: result.set.name, description: `Resent credential set invitation to ${invitation.email}`, - metadata: { invitationId, targetEmail: invitation.email }, + metadata: { + invitationId, + targetEmail: invitation.email, + providerId: result.set.providerId, + credentialSetName: result.set.name, + }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/[id]/invite/route.ts b/apps/sim/app/api/credential-sets/[id]/invite/route.ts index cd5ebb53015..b9b0ccc4a95 100644 --- a/apps/sim/app/api/credential-sets/[id]/invite/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/invite/route.ts @@ -187,7 +187,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: actorEmail: session.user.email ?? undefined, resourceName: result.set.name, description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`, - metadata: { targetEmail: email || undefined }, + metadata: { + invitationId: invitation.id, + targetEmail: email || undefined, + providerId: result.set.providerId, + credentialSetName: result.set.name, + }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/[id]/members/route.ts b/apps/sim/app/api/credential-sets/[id]/members/route.ts index e6ffbaa6262..8ec89923bbe 100644 --- a/apps/sim/app/api/credential-sets/[id]/members/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/members/route.ts @@ -197,7 +197,12 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i actorEmail: session.user.email ?? undefined, resourceName: result.set.name, description: `Removed member from credential set "${result.set.name}"`, - metadata: { targetEmail: memberToRemove.email ?? undefined }, + metadata: { + memberId, + memberUserId: memberToRemove.userId, + targetEmail: memberToRemove.email ?? undefined, + providerId: result.set.providerId, + }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/[id]/route.ts b/apps/sim/app/api/credential-sets/[id]/route.ts index 51110916e93..d522cf9c3df 100644 --- a/apps/sim/app/api/credential-sets/[id]/route.ts +++ b/apps/sim/app/api/credential-sets/[id]/route.ts @@ -142,6 +142,13 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: actorEmail: session.user.email ?? undefined, resourceName: updated?.name ?? result.set.name, description: `Updated credential set "${updated?.name ?? result.set.name}"`, + metadata: { + organizationId: result.set.organizationId, + providerId: result.set.providerId, + updatedFields: Object.keys(updates).filter( + (k) => updates[k as keyof typeof updates] !== undefined + ), + }, request: req, }) @@ -199,6 +206,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i actorEmail: session.user.email ?? undefined, resourceName: result.set.name, description: `Deleted credential set "${result.set.name}"`, + metadata: { organizationId: result.set.organizationId, providerId: result.set.providerId }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/invite/[token]/route.ts b/apps/sim/app/api/credential-sets/invite/[token]/route.ts index 656d39fdde1..fc3759b0e27 100644 --- a/apps/sim/app/api/credential-sets/invite/[token]/route.ts +++ b/apps/sim/app/api/credential-sets/invite/[token]/route.ts @@ -192,7 +192,12 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok resourceId: invitation.credentialSetId, resourceName: invitation.credentialSetName, description: `Accepted credential set invitation`, - metadata: { invitationId: invitation.id }, + metadata: { + invitationId: invitation.id, + credentialSetId: invitation.credentialSetId, + providerId: invitation.providerId, + credentialSetName: invitation.credentialSetName, + }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/memberships/route.ts b/apps/sim/app/api/credential-sets/memberships/route.ts index aef704f7b9c..926714b98f9 100644 --- a/apps/sim/app/api/credential-sets/memberships/route.ts +++ b/apps/sim/app/api/credential-sets/memberships/route.ts @@ -116,6 +116,7 @@ export async function DELETE(req: NextRequest) { resourceType: AuditResourceType.CREDENTIAL_SET, resourceId: credentialSetId, description: `Left credential set`, + metadata: { credentialSetId }, request: req, }) diff --git a/apps/sim/app/api/credential-sets/route.ts b/apps/sim/app/api/credential-sets/route.ts index b5166630af9..c120e84b421 100644 --- a/apps/sim/app/api/credential-sets/route.ts +++ b/apps/sim/app/api/credential-sets/route.ts @@ -179,6 +179,7 @@ export async function POST(req: Request) { actorEmail: session.user.email ?? undefined, resourceName: name, description: `Created credential set "${name}"`, + metadata: { organizationId, providerId, credentialSetName: name }, request: req, }) diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 14f2e73142b..c3a61569051 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' @@ -166,6 +167,23 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ updates.updatedAt = new Date() await db.update(credential).set(updates).where(eq(credential.id, id)) + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_UPDATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: id, + resourceName: access.credential.displayName, + description: `Updated ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + credentialType: access.credential.type, + updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'), + }, + request, + }) + const row = await getCredentialResponse(id, session.user.id) return NextResponse.json({ credential: row }, { status: 200 }) } catch (error) { @@ -249,6 +267,20 @@ export async function DELETE( { groups: { workspace: access.credential.workspaceId } } ) + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: id, + resourceName: access.credential.displayName, + description: `Deleted personal env credential "${access.credential.envKey}"`, + metadata: { credentialType: 'env_personal', envKey: access.credential.envKey }, + request, + }) + return NextResponse.json({ success: true }, { status: 200 }) } @@ -302,6 +334,20 @@ export async function DELETE( { groups: { workspace: access.credential.workspaceId } } ) + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: id, + resourceName: access.credential.displayName, + description: `Deleted workspace env credential "${access.credential.envKey}"`, + metadata: { credentialType: 'env_workspace', envKey: access.credential.envKey }, + request, + }) + return NextResponse.json({ success: true }, { status: 200 }) } @@ -318,6 +364,23 @@ export async function DELETE( { groups: { workspace: access.credential.workspaceId } } ) + recordAudit({ + workspaceId: access.credential.workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_DELETED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: id, + resourceName: access.credential.displayName, + description: `Deleted ${access.credential.type} credential "${access.credential.displayName}"`, + metadata: { + credentialType: access.credential.type, + providerId: access.credential.providerId, + }, + request, + }) + return NextResponse.json({ success: true }, { status: 200 }) } catch (error) { logger.error('Failed to delete credential', error) diff --git a/apps/sim/app/api/credentials/route.ts b/apps/sim/app/api/credentials/route.ts index 7d30b63d7b4..0b210325064 100644 --- a/apps/sim/app/api/credentials/route.ts +++ b/apps/sim/app/api/credentials/route.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { encryptSecret } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' @@ -612,6 +613,23 @@ export async function POST(request: NextRequest) { } ) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CREDENTIAL_CREATED, + resourceType: AuditResourceType.CREDENTIAL, + resourceId: credentialId, + resourceName: resolvedDisplayName, + description: `Created ${type} credential "${resolvedDisplayName}"`, + metadata: { + credentialType: type, + providerId: resolvedProviderId, + }, + request, + }) + return NextResponse.json({ credential: created }, { status: 201 }) } catch (error: any) { if (error?.code === '23505') { diff --git a/apps/sim/app/api/environment/route.ts b/apps/sim/app/api/environment/route.ts index 229ba26382f..f8167e92ac2 100644 --- a/apps/sim/app/api/environment/route.ts +++ b/apps/sim/app/api/environment/route.ts @@ -67,8 +67,13 @@ export async function POST(req: NextRequest) { actorEmail: session.user.email, action: AuditAction.ENVIRONMENT_UPDATED, resourceType: AuditResourceType.ENVIRONMENT, - description: 'Updated global environment variables', - metadata: { variableCount: Object.keys(variables).length }, + resourceId: session.user.id, + description: `Updated ${Object.keys(variables).length} personal environment variable(s)`, + metadata: { + variableCount: Object.keys(variables).length, + updatedKeys: Object.keys(variables), + scope: 'personal', + }, request: req, }) diff --git a/apps/sim/app/api/folders/route.ts b/apps/sim/app/api/folders/route.ts index 98e80f5aa3d..37e0ae8d1d8 100644 --- a/apps/sim/app/api/folders/route.ts +++ b/apps/sim/app/api/folders/route.ts @@ -168,7 +168,13 @@ export async function POST(request: NextRequest) { resourceId: id, resourceName: name.trim(), description: `Created folder "${name.trim()}"`, - metadata: { name: name.trim() }, + metadata: { + name: name.trim(), + workspaceId, + parentId: parentId || undefined, + color: color || '#6B7280', + sortOrder: newFolder.sortOrder, + }, request, }) diff --git a/apps/sim/app/api/form/manage/[id]/route.ts b/apps/sim/app/api/form/manage/[id]/route.ts index 577363b8d9c..a57a7c937bb 100644 --- a/apps/sim/app/api/form/manage/[id]/route.ts +++ b/apps/sim/app/api/form/manage/[id]/route.ts @@ -197,8 +197,14 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< resourceId: id, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, - resourceName: formRecord.title ?? undefined, - description: `Updated form "${formRecord.title}"`, + resourceName: (title || formRecord.title) ?? undefined, + description: `Updated form "${title || formRecord.title}"`, + metadata: { + identifier: identifier || formRecord.identifier, + workflowId: formRecord.workflowId, + authType: authType || formRecord.authType, + updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }, request, }) @@ -255,6 +261,7 @@ export async function DELETE( actorEmail: session.user.email ?? undefined, resourceName: formRecord.title ?? undefined, description: `Deleted form "${formRecord.title}"`, + metadata: { identifier: formRecord.identifier, workflowId: formRecord.workflowId }, request, }) diff --git a/apps/sim/app/api/form/route.ts b/apps/sim/app/api/form/route.ts index 6512ba95808..db29c2759de 100644 --- a/apps/sim/app/api/form/route.ts +++ b/apps/sim/app/api/form/route.ts @@ -208,6 +208,7 @@ export async function POST(request: NextRequest) { actorEmail: session.user.email ?? undefined, resourceName: title, description: `Created form "${title}" for workflow ${workflowId}`, + metadata: { identifier, workflowId, authType, formUrl, showBranding }, request, }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts index 48e0d0deb2d..c5e7878fc69 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/documents/route.ts @@ -194,7 +194,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { resourceType: AuditResourceType.CONNECTOR, resourceId: connectorId, description: `Restored ${updated.length} excluded document(s) for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { knowledgeBaseId, documentCount: updated.length }, + metadata: { + knowledgeBaseId, + knowledgeBaseName: writeCheck.knowledgeBase.name, + operation: 'restore', + documentCount: updated.length, + documentIds: updated.map((d) => d.id), + }, request, }) @@ -229,7 +235,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { resourceType: AuditResourceType.CONNECTOR, resourceId: connectorId, description: `Excluded ${updated.length} document(s) from knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { knowledgeBaseId, documentCount: updated.length }, + metadata: { + knowledgeBaseId, + knowledgeBaseName: writeCheck.knowledgeBase.name, + operation: 'exclude', + documentCount: updated.length, + documentIds: updated.map((d) => d.id), + }, request, }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts index 87cdb51a737..6ffee2355a1 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts @@ -268,7 +268,16 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) { resourceId: connectorId, resourceName: updatedData.connectorType, description: `Updated connector for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { knowledgeBaseId, updatedFields: Object.keys(parsed.data) }, + metadata: { + knowledgeBaseId, + knowledgeBaseName: writeCheck.knowledgeBase.name, + connectorType: updatedData.connectorType, + updatedFields: Object.keys(parsed.data), + ...(parsed.data.syncIntervalMinutes !== undefined && { + syncIntervalMinutes: parsed.data.syncIntervalMinutes, + }), + ...(parsed.data.status !== undefined && { newStatus: parsed.data.status }), + }, request, }) @@ -399,6 +408,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { description: `Deleted connector from knowledge base "${writeCheck.knowledgeBase.name}"`, metadata: { knowledgeBaseId, + knowledgeBaseName: writeCheck.knowledgeBase.name, + connectorType: existingConnector[0].connectorType, + deleteDocuments, documentsDeleted: deleteDocuments ? docCount : 0, documentsKept: deleteDocuments ? 0 : docCount, }, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts index df7057fc904..1ace24c886b 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/sync/route.ts @@ -78,7 +78,13 @@ export async function POST(request: NextRequest, { params }: RouteParams) { resourceId: connectorId, resourceName: connectorRows[0].connectorType, description: `Triggered manual sync for connector on knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { knowledgeBaseId }, + metadata: { + knowledgeBaseId, + knowledgeBaseName: writeCheck.knowledgeBase.name, + connectorType: connectorRows[0].connectorType, + connectorStatus: connectorRows[0].status, + syncType: 'manual', + }, request, }) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index b5e2cb86f46..34da8e03276 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -286,7 +286,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: connectorId, resourceName: connectorType, description: `Created ${connectorType} connector for knowledge base "${writeCheck.knowledgeBase.name}"`, - metadata: { knowledgeBaseId, connectorType, syncIntervalMinutes }, + metadata: { + knowledgeBaseId, + knowledgeBaseName: writeCheck.knowledgeBase.name, + connectorType, + syncIntervalMinutes, + authMode: connectorConfig.auth.mode, + }, request, }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts index 4f8735826b1..f238ac4f978 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/[documentId]/route.ts @@ -208,7 +208,16 @@ export async function PUT( resourceType: AuditResourceType.DOCUMENT, resourceId: documentId, resourceName: validatedData.filename ?? accessCheck.document?.filename, - description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`, + description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${knowledgeBaseId}"`, + metadata: { + knowledgeBaseId, + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileName: validatedData.filename ?? accessCheck.document?.filename, + updatedFields: Object.keys(validatedData).filter( + (k) => validatedData[k as keyof typeof validatedData] !== undefined + ), + ...(validatedData.enabled !== undefined && { enabled: validatedData.enabled }), + }, request: req, }) @@ -281,8 +290,14 @@ export async function DELETE( resourceType: AuditResourceType.DOCUMENT, resourceId: documentId, resourceName: accessCheck.document?.filename, - description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`, - metadata: { fileName: accessCheck.document?.filename }, + description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${knowledgeBaseId}"`, + metadata: { + knowledgeBaseId, + knowledgeBaseName: accessCheck.knowledgeBase?.name, + fileName: accessCheck.document?.filename, + fileSize: accessCheck.document?.fileSize, + mimeType: accessCheck.document?.mimeType, + }, request: req, }) diff --git a/apps/sim/app/api/knowledge/[id]/documents/route.ts b/apps/sim/app/api/knowledge/[id]/documents/route.ts index 83056e8f486..b5614aec41d 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/route.ts @@ -278,8 +278,8 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceName: `${createdDocuments.length} document(s)`, description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`, metadata: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, fileCount: createdDocuments.length, - fileNames: createdDocuments.map((doc) => doc.filename), }, request: req, }) @@ -358,6 +358,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceName: validatedData.filename, description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`, metadata: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, fileName: validatedData.filename, fileType: validatedData.mimeType, fileSize: validatedData.fileSize, diff --git a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts index 59be57cd610..8d5ee153918 100644 --- a/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts +++ b/apps/sim/app/api/knowledge/[id]/documents/upsert/route.ts @@ -196,7 +196,10 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: ? `Upserted (replaced) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"` : `Upserted (created) document "${validatedData.filename}" in knowledge base "${knowledgeBaseId}"`, metadata: { + knowledgeBaseName: accessCheck.knowledgeBase?.name, fileName: validatedData.filename, + fileType: validatedData.mimeType, + fileSize: validatedData.fileSize, previousDocumentId: existingDocumentId, isUpdate, }, diff --git a/apps/sim/app/api/knowledge/[id]/restore/route.ts b/apps/sim/app/api/knowledge/[id]/restore/route.ts index 1d37f664ab7..02d8b3e5afd 100644 --- a/apps/sim/app/api/knowledge/[id]/restore/route.ts +++ b/apps/sim/app/api/knowledge/[id]/restore/route.ts @@ -59,6 +59,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: id, resourceName: kb.name, description: `Restored knowledge base "${kb.name}"`, + metadata: { + knowledgeBaseName: kb.name, + }, request, }) diff --git a/apps/sim/app/api/knowledge/[id]/route.ts b/apps/sim/app/api/knowledge/[id]/route.ts index 2dcf53701da..5da7026a454 100644 --- a/apps/sim/app/api/knowledge/[id]/route.ts +++ b/apps/sim/app/api/knowledge/[id]/route.ts @@ -147,6 +147,20 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: resourceId: id, resourceName: validatedData.name ?? updatedKnowledgeBase.name, description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`, + metadata: { + updatedFields: Object.keys(validatedData).filter( + (k) => validatedData[k as keyof typeof validatedData] !== undefined + ), + ...(validatedData.name && { newName: validatedData.name }), + ...(validatedData.description !== undefined && { + description: validatedData.description, + }), + ...(validatedData.chunkingConfig && { + chunkMaxSize: validatedData.chunkingConfig.maxSize, + chunkMinSize: validatedData.chunkingConfig.minSize, + chunkOverlap: validatedData.chunkingConfig.overlap, + }), + }, request: req, }) @@ -226,6 +240,9 @@ export async function DELETE( resourceId: id, resourceName: accessCheck.knowledgeBase.name, description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`, + metadata: { + knowledgeBaseName: accessCheck.knowledgeBase.name, + }, request: _request, }) diff --git a/apps/sim/app/api/knowledge/route.ts b/apps/sim/app/api/knowledge/route.ts index 20499ce8fce..9641f3a4539 100644 --- a/apps/sim/app/api/knowledge/route.ts +++ b/apps/sim/app/api/knowledge/route.ts @@ -162,7 +162,16 @@ export async function POST(req: NextRequest) { resourceId: newKnowledgeBase.id, resourceName: validatedData.name, description: `Created knowledge base "${validatedData.name}"`, - metadata: { name: validatedData.name }, + metadata: { + name: validatedData.name, + description: validatedData.description, + embeddingModel: validatedData.embeddingModel, + embeddingDimension: validatedData.embeddingDimension, + chunkingStrategy: validatedData.chunkingConfig.strategy, + chunkMaxSize: validatedData.chunkingConfig.maxSize, + chunkMinSize: validatedData.chunkingConfig.minSize, + chunkOverlap: validatedData.chunkingConfig.overlap, + }, request: req, }) diff --git a/apps/sim/app/api/mcp/servers/[id]/route.ts b/apps/sim/app/api/mcp/servers/[id]/route.ts index 54265bb687c..67c893fc754 100644 --- a/apps/sim/app/api/mcp/servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/servers/[id]/route.ts @@ -124,6 +124,14 @@ export const PATCH = withMcpAuth<{ id: string }>('write')( resourceId: serverId, resourceName: updatedServer.name || serverId, description: `Updated MCP server "${updatedServer.name || serverId}"`, + metadata: { + serverName: updatedServer.name, + transport: updatedServer.transport, + url: updatedServer.url, + updatedFields: Object.keys(updateData).filter( + (k) => k !== 'workspaceId' && k !== 'updatedAt' + ), + }, request, }) diff --git a/apps/sim/app/api/mcp/servers/route.ts b/apps/sim/app/api/mcp/servers/route.ts index 054c7a3a2ca..5d5c1d8b6fe 100644 --- a/apps/sim/app/api/mcp/servers/route.ts +++ b/apps/sim/app/api/mcp/servers/route.ts @@ -206,7 +206,14 @@ export const POST = withMcpAuth('write')( resourceId: serverId, resourceName: body.name, description: `Added MCP server "${body.name}"`, - metadata: { serverName: body.name, transport: body.transport }, + metadata: { + serverName: body.name, + transport: body.transport, + url: body.url, + timeout: body.timeout || 30000, + retries: body.retries || 3, + source: source, + }, request, }) @@ -278,6 +285,12 @@ export const DELETE = withMcpAuth('admin')( resourceId: serverId!, resourceName: deletedServer.name, description: `Removed MCP server "${deletedServer.name}"`, + metadata: { + serverName: deletedServer.name, + transport: deletedServer.transport, + url: deletedServer.url, + source, + }, request, }) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts index f5ed5371e19..6ed1bb2e0c6 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/route.ts @@ -135,6 +135,11 @@ export const PATCH = withMcpAuth('write')( resourceId: serverId, resourceName: updatedServer.name, description: `Updated workflow MCP server "${updatedServer.name}"`, + metadata: { + serverName: updatedServer.name, + isPublic: updatedServer.isPublic, + updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }, request, }) @@ -189,6 +194,7 @@ export const DELETE = withMcpAuth('admin')( resourceId: serverId, resourceName: deletedServer.name, description: `Unpublished workflow MCP server "${deletedServer.name}"`, + metadata: { serverName: deletedServer.name }, request, }) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts index f54caf4703e..60791a36bcf 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/[toolId]/route.ts @@ -152,7 +152,12 @@ export const PATCH = withMcpAuth('write')( resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, description: `Updated tool "${updatedTool.toolName}" in MCP server`, - metadata: { toolId, toolName: updatedTool.toolName }, + metadata: { + toolId, + toolName: updatedTool.toolName, + workflowId: updatedTool.workflowId, + updatedFields: Object.keys(updateData).filter((k) => k !== 'updatedAt'), + }, request, }) @@ -220,7 +225,7 @@ export const DELETE = withMcpAuth('write')( resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, description: `Removed tool "${deletedTool.toolName}" from MCP server`, - metadata: { toolId, toolName: deletedTool.toolName }, + metadata: { toolId, toolName: deletedTool.toolName, workflowId: deletedTool.workflowId }, request, }) diff --git a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts index 1a4687b44fc..396cfe92468 100644 --- a/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/[id]/tools/route.ts @@ -224,7 +224,13 @@ export const POST = withMcpAuth('write')( resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, description: `Added tool "${toolName}" to MCP server`, - metadata: { toolId, toolName, workflowId: body.workflowId }, + metadata: { + toolId, + toolName, + toolDescription, + workflowId: body.workflowId, + workflowName: workflowRecord.name, + }, request, }) diff --git a/apps/sim/app/api/mcp/workflow-servers/route.ts b/apps/sim/app/api/mcp/workflow-servers/route.ts index 84d431fa423..807df769673 100644 --- a/apps/sim/app/api/mcp/workflow-servers/route.ts +++ b/apps/sim/app/api/mcp/workflow-servers/route.ts @@ -208,6 +208,13 @@ export const POST = withMcpAuth('write')( resourceId: serverId, resourceName: body.name.trim(), description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`, + metadata: { + serverName: body.name.trim(), + isPublic: body.isPublic ?? false, + toolCount: addedTools.length, + toolNames: addedTools.map((t) => t.toolName), + workflowIds: addedTools.map((t) => t.workflowId), + }, request, }) diff --git a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts index f54e72b2701..7f4f7d8004c 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts @@ -182,6 +182,20 @@ export async function POST( email: orgInvitation.email, }) + recordAudit({ + workspaceId: null, + actorId: session.user.id, + action: AuditAction.ORG_INVITATION_RESENT, + resourceType: AuditResourceType.ORGANIZATION, + resourceId: organizationId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: org?.name ?? undefined, + description: `Resent organization invitation to ${orgInvitation.email}`, + metadata: { invitationId, targetEmail: orgInvitation.email, targetRole: orgInvitation.role }, + request: _request, + }) + return NextResponse.json({ success: true, message: 'Invitation resent successfully', diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts index 001184d98e7..5a85cfbb4f0 100644 --- a/apps/sim/app/api/organizations/[id]/invitations/route.ts +++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts @@ -423,7 +423,13 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ actorEmail: session.user.email ?? undefined, resourceName: organizationEntry[0]?.name, description: `Invited ${inv.email} to organization as ${role}`, - metadata: { invitationId: inv.id, targetEmail: inv.email, targetRole: role }, + metadata: { + invitationId: inv.id, + targetEmail: inv.email, + targetRole: role, + isBatch, + workspaceInvitationCount: validWorkspaceInvitations.length, + }, request, }) } @@ -558,7 +564,7 @@ export async function DELETE( actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Revoked organization invitation for ${result[0].email}`, - metadata: { invitationId, targetEmail: result[0].email }, + metadata: { invitationId, targetEmail: result[0].email, targetRole: result[0].role }, request, }) diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts index 3b15d34848e..989d792b6fd 100644 --- a/apps/sim/app/api/organizations/[id]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/route.ts @@ -294,6 +294,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: organizationId, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, + resourceName: organizationEntry[0]?.name ?? undefined, description: `Invited ${normalizedEmail} to organization as ${role}`, metadata: { invitationId, targetEmail: normalizedEmail, targetRole: role }, request, diff --git a/apps/sim/app/api/organizations/route.ts b/apps/sim/app/api/organizations/route.ts index 5803f85dc25..6185f120f46 100644 --- a/apps/sim/app/api/organizations/route.ts +++ b/apps/sim/app/api/organizations/route.ts @@ -126,6 +126,7 @@ export async function POST(request: Request) { actorEmail: user.email ?? undefined, resourceName: organizationName ?? undefined, description: `Created organization "${organizationName}"`, + metadata: { organizationSlug }, request, }) diff --git a/apps/sim/app/api/permission-groups/[id]/route.ts b/apps/sim/app/api/permission-groups/[id]/route.ts index 9391b67d826..51cbe1222b6 100644 --- a/apps/sim/app/api/permission-groups/[id]/route.ts +++ b/apps/sim/app/api/permission-groups/[id]/route.ts @@ -193,6 +193,12 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: actorEmail: session.user.email ?? undefined, resourceName: updated.name, description: `Updated permission group "${updated.name}"`, + metadata: { + organizationId: result.group.organizationId, + updatedFields: Object.keys(updates).filter( + (k) => updates[k as keyof typeof updates] !== undefined + ), + }, request: req, }) @@ -254,6 +260,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i actorEmail: session.user.email ?? undefined, resourceName: result.group.name, description: `Deleted permission group "${result.group.name}"`, + metadata: { organizationId: result.group.organizationId }, request: req, }) diff --git a/apps/sim/app/api/permission-groups/route.ts b/apps/sim/app/api/permission-groups/route.ts index b79f01ebc5f..9b88d482617 100644 --- a/apps/sim/app/api/permission-groups/route.ts +++ b/apps/sim/app/api/permission-groups/route.ts @@ -211,6 +211,7 @@ export async function POST(req: Request) { actorEmail: session.user.email ?? undefined, resourceName: name, description: `Created permission group "${name}"`, + metadata: { organizationId, autoAddNewMembers: autoAddNewMembers || false }, request: req, }) diff --git a/apps/sim/app/api/schedules/[id]/route.ts b/apps/sim/app/api/schedules/[id]/route.ts index 597634aeb9d..d05514a8837 100644 --- a/apps/sim/app/api/schedules/[id]/route.ts +++ b/apps/sim/app/api/schedules/[id]/route.ts @@ -38,6 +38,7 @@ type ScheduleRow = { timezone: string | null sourceType: string | null sourceWorkspaceId: string | null + jobTitle: string | null } async function fetchAndAuthorize( @@ -55,6 +56,7 @@ async function fetchAndAuthorize( timezone: workflowSchedule.timezone, sourceType: workflowSchedule.sourceType, sourceWorkspaceId: workflowSchedule.sourceWorkspaceId, + jobTitle: workflowSchedule.jobTitle, }) .from(workflowSchedule) .where(and(eq(workflowSchedule.id, scheduleId), isNull(workflowSchedule.archivedAt))) @@ -144,13 +146,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ recordAudit({ workspaceId, actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, action: AuditAction.SCHEDULE_UPDATED, resourceType: AuditResourceType.SCHEDULE, resourceId: scheduleId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Disabled schedule ${scheduleId}`, - metadata: {}, + resourceName: schedule.jobTitle ?? undefined, + description: `Disabled schedule "${schedule.jobTitle ?? scheduleId}"`, + metadata: { + operation: 'disable', + sourceType: schedule.sourceType, + previousStatus: schedule.status, + }, request, }) @@ -204,13 +211,17 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ recordAudit({ workspaceId, actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, action: AuditAction.SCHEDULE_UPDATED, resourceType: AuditResourceType.SCHEDULE, resourceId: scheduleId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Updated job schedule ${scheduleId}`, - metadata: {}, + resourceName: schedule.jobTitle ?? undefined, + description: `Updated job schedule "${schedule.jobTitle ?? scheduleId}"`, + metadata: { + operation: 'update', + updatedFields: Object.keys(setFields).filter((k) => k !== 'updatedAt'), + }, request, }) @@ -246,13 +257,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ recordAudit({ workspaceId, actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, action: AuditAction.SCHEDULE_UPDATED, resourceType: AuditResourceType.SCHEDULE, resourceId: scheduleId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Reactivated schedule ${scheduleId}`, - metadata: { cronExpression: schedule.cronExpression, timezone: schedule.timezone }, + resourceName: schedule.jobTitle ?? undefined, + description: `Reactivated schedule "${schedule.jobTitle ?? scheduleId}"`, + metadata: { + operation: 'reactivate', + sourceType: schedule.sourceType, + cronExpression: schedule.cronExpression, + timezone: schedule.timezone, + }, request, }) @@ -289,13 +306,18 @@ export async function DELETE( recordAudit({ workspaceId, actorId: session.user.id, - action: AuditAction.SCHEDULE_UPDATED, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SCHEDULE_DELETED, resourceType: AuditResourceType.SCHEDULE, resourceId: scheduleId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} ${scheduleId}`, - metadata: {}, + resourceName: schedule.jobTitle ?? undefined, + description: `Deleted ${schedule.sourceType === 'job' ? 'job' : 'schedule'} "${schedule.jobTitle ?? scheduleId}"`, + metadata: { + sourceType: schedule.sourceType, + cronExpression: schedule.cronExpression, + timezone: schedule.timezone, + }, request, }) diff --git a/apps/sim/app/api/schedules/route.ts b/apps/sim/app/api/schedules/route.ts index da291cdcccc..a2f14f109a8 100644 --- a/apps/sim/app/api/schedules/route.ts +++ b/apps/sim/app/api/schedules/route.ts @@ -3,6 +3,7 @@ import { workflow, workflowDeploymentVersion, workflowSchedule } from '@sim/db/s import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' +import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { generateId } from '@/lib/core/utils/uuid' @@ -279,6 +280,25 @@ export async function POST(req: NextRequest) { lifecycle, }) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.SCHEDULE_CREATED, + resourceType: AuditResourceType.SCHEDULE, + resourceId: id, + resourceName: title.trim(), + description: `Created job schedule "${title.trim()}"`, + metadata: { + cronExpression, + timezone, + lifecycle, + maxRuns: maxRuns ?? null, + }, + request: req, + }) + captureServerEvent( session.user.id, 'scheduled_task_created', diff --git a/apps/sim/app/api/skills/route.ts b/apps/sim/app/api/skills/route.ts index 41173c13188..f1db74a3cce 100644 --- a/apps/sim/app/api/skills/route.ts +++ b/apps/sim/app/api/skills/route.ts @@ -103,11 +103,14 @@ export async function POST(req: NextRequest) { recordAudit({ workspaceId, actorId: userId, + actorName: authResult.userName ?? undefined, + actorEmail: authResult.userEmail ?? undefined, action: AuditAction.SKILL_CREATED, resourceType: AuditResourceType.SKILL, resourceId: skill.id, resourceName: skill.name, description: `Created/updated skill "${skill.name}"`, + metadata: { source }, }) captureServerEvent( userId, @@ -185,10 +188,13 @@ export async function DELETE(request: NextRequest) { recordAudit({ workspaceId, actorId: authResult.userId, + actorName: authResult.userName ?? undefined, + actorEmail: authResult.userEmail ?? undefined, action: AuditAction.SKILL_DELETED, resourceType: AuditResourceType.SKILL, resourceId: skillId, description: `Deleted skill`, + metadata: { source }, }) captureServerEvent( diff --git a/apps/sim/app/api/table/[tableId]/restore/route.ts b/apps/sim/app/api/table/[tableId]/restore/route.ts index 9175de0b661..fca864c8753 100644 --- a/apps/sim/app/api/table/[tableId]/restore/route.ts +++ b/apps/sim/app/api/table/[tableId]/restore/route.ts @@ -45,6 +45,10 @@ export async function POST( resourceId: tableId, resourceName: table.name, description: `Restored table "${table.name}"`, + metadata: { + tableName: table.name, + workspaceId: table.workspaceId, + }, request, }) diff --git a/apps/sim/app/api/templates/[id]/route.ts b/apps/sim/app/api/templates/[id]/route.ts index 260b64f582b..82c73fffa0f 100644 --- a/apps/sim/app/api/templates/[id]/route.ts +++ b/apps/sim/app/api/templates/[id]/route.ts @@ -251,6 +251,15 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ resourceId: id, resourceName: name ?? template.name, description: `Updated template "${name ?? template.name}"`, + metadata: { + templateName: name ?? template.name, + updatedFields: Object.keys(validationResult.data).filter( + (k) => validationResult.data[k as keyof typeof validationResult.data] !== undefined + ), + statusChange: status !== undefined ? { from: template.status, to: status } : undefined, + stateUpdated: updateState || false, + workflowId: template.workflowId || undefined, + }, request, }) @@ -317,6 +326,13 @@ export async function DELETE( resourceId: id, resourceName: template.name, description: `Deleted template "${template.name}"`, + metadata: { + templateName: template.name, + workflowId: template.workflowId || undefined, + creatorId: template.creatorId || undefined, + status: template.status, + tags: template.tags, + }, request, }) diff --git a/apps/sim/app/api/templates/route.ts b/apps/sim/app/api/templates/route.ts index c6424865c82..0a9f9d02b72 100644 --- a/apps/sim/app/api/templates/route.ts +++ b/apps/sim/app/api/templates/route.ts @@ -346,6 +346,14 @@ export async function POST(request: NextRequest) { resourceId: templateId, resourceName: data.name, description: `Created template "${data.name}"`, + metadata: { + templateName: data.name, + workflowId: data.workflowId, + creatorId: data.creatorId, + tags: data.tags, + tagline: data.details?.tagline || undefined, + status: 'pending', + }, request, }) diff --git a/apps/sim/app/api/tools/custom/route.ts b/apps/sim/app/api/tools/custom/route.ts index 7d45353e609..426da0273ce 100644 --- a/apps/sim/app/api/tools/custom/route.ts +++ b/apps/sim/app/api/tools/custom/route.ts @@ -183,11 +183,14 @@ export async function POST(req: NextRequest) { recordAudit({ workspaceId, actorId: userId, + actorName: authResult.userName ?? undefined, + actorEmail: authResult.userEmail ?? undefined, action: AuditAction.CUSTOM_TOOL_CREATED, resourceType: AuditResourceType.CUSTOM_TOOL, resourceId: tool.id, resourceName: tool.title, description: `Created/updated custom tool "${tool.title}"`, + metadata: { source }, }) } @@ -304,10 +307,14 @@ export async function DELETE(request: NextRequest) { recordAudit({ workspaceId: tool.workspaceId || undefined, actorId: userId, + actorName: authResult.userName ?? undefined, + actorEmail: authResult.userEmail ?? undefined, action: AuditAction.CUSTOM_TOOL_DELETED, resourceType: AuditResourceType.CUSTOM_TOOL, resourceId: toolId, - description: `Deleted custom tool`, + resourceName: tool.title, + description: `Deleted custom tool "${tool.title}"`, + metadata: { source }, }) logger.info(`[${requestId}] Deleted tool: ${toolId}`) 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 895ac1ff3e2..f97c755da33 100644 --- a/apps/sim/app/api/v1/admin/audit-logs/route.ts +++ b/apps/sim/app/api/v1/admin/audit-logs/route.ts @@ -21,7 +21,7 @@ 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 { and, count, desc } from 'drizzle-orm' import { withAdminAuth } from '@/app/api/v1/admin/middleware' import { badRequestResponse, @@ -34,6 +34,7 @@ import { parsePaginationParams, toAdminAuditLog, } from '@/app/api/v1/admin/types' +import { buildFilterConditions } from '@/app/api/v1/audit-logs/query' const logger = createLogger('AdminAuditLogsAPI') @@ -41,33 +42,27 @@ 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') + const startDate = url.searchParams.get('startDate') || undefined + const endDate = url.searchParams.get('endDate') || undefined - if (startDateFilter && Number.isNaN(Date.parse(startDateFilter))) { + if (startDate && Number.isNaN(Date.parse(startDate))) { return badRequestResponse('Invalid startDate format. Use ISO 8601.') } - if (endDateFilter && Number.isNaN(Date.parse(endDateFilter))) { + if (endDate && Number.isNaN(Date.parse(endDate))) { 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 conditions = buildFilterConditions({ + action: url.searchParams.get('action') || undefined, + resourceType: url.searchParams.get('resourceType') || undefined, + resourceId: url.searchParams.get('resourceId') || undefined, + workspaceId: url.searchParams.get('workspaceId') || undefined, + actorId: url.searchParams.get('actorId') || undefined, + actorEmail: url.searchParams.get('actorEmail') || undefined, + startDate, + endDate, + }) const whereClause = conditions.length > 0 ? and(...conditions) : undefined diff --git a/apps/sim/app/api/v1/audit-logs/query.ts b/apps/sim/app/api/v1/audit-logs/query.ts new file mode 100644 index 00000000000..14e24c65427 --- /dev/null +++ b/apps/sim/app/api/v1/audit-logs/query.ts @@ -0,0 +1,146 @@ +import { db } from '@sim/db' +import { auditLog, workspace } from '@sim/db/schema' +import type { InferSelectModel } from 'drizzle-orm' +import { and, desc, eq, gte, ilike, inArray, lt, lte, or, type SQL, sql } from 'drizzle-orm' + +type DbAuditLog = InferSelectModel + +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 interface AuditLogFilterParams { + action?: string + resourceType?: string + resourceId?: string + workspaceId?: string + actorId?: string + actorEmail?: string + search?: string + startDate?: string + endDate?: string +} + +export function buildFilterConditions(params: AuditLogFilterParams): SQL[] { + const conditions: SQL[] = [] + + 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.actorEmail) conditions.push(eq(auditLog.actorEmail, params.actorEmail)) + + if (params.search) { + const escaped = params.search.replace(/[%_\\]/g, '\\$&') + const searchTerm = `%${escaped}%` + conditions.push( + or( + ilike(auditLog.action, searchTerm), + ilike(auditLog.actorEmail, searchTerm), + ilike(auditLog.actorName, searchTerm), + ilike(auditLog.resourceName, searchTerm), + ilike(auditLog.description, searchTerm) + )! + ) + } + + if (params.startDate) conditions.push(gte(auditLog.createdAt, new Date(params.startDate))) + if (params.endDate) conditions.push(lte(auditLog.createdAt, new Date(params.endDate))) + + return conditions +} + +export async function buildOrgScopeCondition( + orgMemberIds: string[], + includeDeparted: boolean +): Promise> { + if (orgMemberIds.length === 0) { + return sql`1 = 0` + } + + if (!includeDeparted) { + return inArray(auditLog.actorId, orgMemberIds) + } + + 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) { + return or( + inArray(auditLog.actorId, orgMemberIds), + inArray(auditLog.workspaceId, orgWorkspaceIds) + )! + } + + return inArray(auditLog.actorId, orgMemberIds) +} + +function buildCursorCondition(cursor: string): SQL | null { + const cursorData = decodeCursor(cursor) + if (!cursorData?.createdAt || !cursorData.id) return null + + const cursorDate = new Date(cursorData.createdAt) + if (Number.isNaN(cursorDate.getTime())) return null + + return or( + lt(auditLog.createdAt, cursorDate), + and(eq(auditLog.createdAt, cursorDate), lt(auditLog.id, cursorData.id)) + )! +} + +interface CursorPaginatedResult { + data: DbAuditLog[] + nextCursor?: string +} + +export async function queryAuditLogs( + conditions: SQL[], + limit: number, + cursor?: string +): Promise { + const allConditions = [...conditions] + + if (cursor) { + const cursorCondition = buildCursorCondition(cursor) + if (cursorCondition) allConditions.push(cursorCondition) + } + + const rows = await db + .select() + .from(auditLog) + .where(allConditions.length > 0 ? and(...allConditions) : undefined) + .orderBy(desc(auditLog.createdAt), desc(auditLog.id)) + .limit(limit + 1) + + const hasMore = rows.length > limit + const data = rows.slice(0, 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, + }) + } + + return { data, nextCursor } +} diff --git a/apps/sim/app/api/v1/audit-logs/route.ts b/apps/sim/app/api/v1/audit-logs/route.ts index 5a090391da4..046680bde44 100644 --- a/apps/sim/app/api/v1/audit-logs/route.ts +++ b/apps/sim/app/api/v1/audit-logs/route.ts @@ -19,15 +19,17 @@ * 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 { generateId } from '@/lib/core/utils/uuid' import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth' import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { + buildFilterConditions, + buildOrgScopeCondition, + queryAuditLogs, +} from '@/app/api/v1/audit-logs/query' import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta' import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware' @@ -57,23 +59,6 @@ const QueryParamsSchema = z.object({ 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 = generateId().slice(0, 8) @@ -112,71 +97,22 @@ export async function GET(request: NextRequest) { ) } - 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?.createdAt && cursorData.id) { - const cursorDate = new Date(cursorData.createdAt) - if (!Number.isNaN(cursorDate.getTime())) { - conditions.push( - or( - lt(auditLog.createdAt, cursorDate), - and(eq(auditLog.createdAt, cursorDate), 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 scopeCondition = await buildOrgScopeCondition(orgMemberIds, params.includeDeparted) + const filterConditions = buildFilterConditions({ + action: params.action, + resourceType: params.resourceType, + resourceId: params.resourceId, + workspaceId: params.workspaceId, + actorId: params.actorId, + startDate: params.startDate, + endDate: params.endDate, + }) + + const { data, nextCursor } = await queryAuditLogs( + [scopeCondition, ...filterConditions], + params.limit, + params.cursor + ) const formattedLogs = data.map(formatAuditLogEntry) diff --git a/apps/sim/app/api/v1/files/[fileId]/route.ts b/apps/sim/app/api/v1/files/[fileId]/route.ts index 7007053681b..b3d3db8ceb8 100644 --- a/apps/sim/app/api/v1/files/[fileId]/route.ts +++ b/apps/sim/app/api/v1/files/[fileId]/route.ts @@ -142,6 +142,7 @@ export async function DELETE(request: NextRequest, { params }: FileRouteParams) resourceId: fileId, resourceName: fileRecord.name, description: `Archived file "${fileRecord.name}" via API`, + metadata: { fileSize: fileRecord.size, fileType: fileRecord.type }, request, }) diff --git a/apps/sim/app/api/v1/files/route.ts b/apps/sim/app/api/v1/files/route.ts index 8c344c1575d..2781fe164a2 100644 --- a/apps/sim/app/api/v1/files/route.ts +++ b/apps/sim/app/api/v1/files/route.ts @@ -155,6 +155,7 @@ export async function POST(request: NextRequest) { resourceId: userFile.id, resourceName: file.name, description: `Uploaded file "${file.name}" via API`, + metadata: { fileSize: file.size, fileType: file.type || 'application/octet-stream' }, request, }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts index b69721329a4..22d40d979f1 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/[documentId]/route.ts @@ -167,6 +167,7 @@ export async function DELETE(request: NextRequest, { params }: DocumentDetailRou resourceId: documentId, resourceName: docs[0].filename, description: `Deleted document "${docs[0].filename}" from knowledge base via API`, + metadata: { knowledgeBaseId }, request, }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts index 7310a4eca98..6eb61e22614 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.ts @@ -207,6 +207,7 @@ export async function POST(request: NextRequest, { params }: DocumentsRouteParam resourceId: newDocument.id, resourceName: file.name, description: `Uploaded document "${file.name}" to knowledge base via API`, + metadata: { knowledgeBaseId, fileSize: file.size, mimeType: contentType }, request, }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/route.ts b/apps/sim/app/api/v1/knowledge/[id]/route.ts index 0b7012c8770..b6b5ed3c8ac 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/route.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/route.ts @@ -111,6 +111,7 @@ export async function PUT(request: NextRequest, { params }: KnowledgeRouteParams resourceId: id, resourceName: updatedKb.name, description: `Updated knowledge base "${updatedKb.name}" via API`, + metadata: { updatedFields: Object.keys(updates) }, request, }) diff --git a/apps/sim/app/api/v1/knowledge/route.ts b/apps/sim/app/api/v1/knowledge/route.ts index 9d45e677bd3..61741d3c59e 100644 --- a/apps/sim/app/api/v1/knowledge/route.ts +++ b/apps/sim/app/api/v1/knowledge/route.ts @@ -106,6 +106,7 @@ export async function POST(request: NextRequest) { resourceId: kb.id, resourceName: kb.name, description: `Created knowledge base "${kb.name}" via API`, + metadata: { chunkingConfig }, request, }) diff --git a/apps/sim/app/api/v1/tables/route.ts b/apps/sim/app/api/v1/tables/route.ts index 09ff717f9cd..d0c0ad3e64d 100644 --- a/apps/sim/app/api/v1/tables/route.ts +++ b/apps/sim/app/api/v1/tables/route.ts @@ -206,6 +206,7 @@ export async function POST(request: NextRequest) { resourceId: table.id, resourceName: table.name, description: `Created table "${table.name}" via API`, + metadata: { columnCount: params.schema.columns.length }, request, }) diff --git a/apps/sim/app/api/webhooks/[id]/route.ts b/apps/sim/app/api/webhooks/[id]/route.ts index 24d93fc0609..e146c939507 100644 --- a/apps/sim/app/api/webhooks/[id]/route.ts +++ b/apps/sim/app/api/webhooks/[id]/route.ts @@ -270,8 +270,14 @@ export async function DELETE( resourceType: AuditResourceType.WEBHOOK, resourceId: id, resourceName: foundWebhook.provider || 'generic', - description: 'Deleted webhook', - metadata: { workflowId: webhookData.workflow.id }, + description: `Deleted ${foundWebhook.provider || 'generic'} webhook`, + metadata: { + provider: foundWebhook.provider || 'generic', + workflowId: webhookData.workflow.id, + webhookPath: foundWebhook.path || undefined, + blockId: foundWebhook.blockId || undefined, + credentialSetId: credentialSetId || undefined, + }, request, }) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index c6ef9e992e1..0c7174f4294 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -687,7 +687,12 @@ export async function POST(request: NextRequest) { resourceId: savedWebhook.id, resourceName: provider || 'generic', description: `Created ${provider || 'generic'} webhook`, - metadata: { provider, workflowId }, + metadata: { + provider: provider || 'generic', + workflowId, + webhookPath: finalPath, + blockId: blockId || undefined, + }, request, }) diff --git a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts index a209db29eb4..a2fb4fe4ba4 100644 --- a/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts +++ b/apps/sim/app/api/workflows/[id]/deployments/[version]/revert/route.ts @@ -127,6 +127,9 @@ export async function POST( actorEmail: session!.user.email ?? undefined, resourceName: workflowRecord?.name ?? undefined, description: `Reverted workflow to deployment version ${version}`, + metadata: { + targetVersion: version, + }, request, }) diff --git a/apps/sim/app/api/workflows/[id]/duplicate/route.ts b/apps/sim/app/api/workflows/[id]/duplicate/route.ts index 63c230f686b..0af8a82bae0 100644 --- a/apps/sim/app/api/workflows/[id]/duplicate/route.ts +++ b/apps/sim/app/api/workflows/[id]/duplicate/route.ts @@ -87,7 +87,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceId: result.id, resourceName: result.name, description: `Duplicated workflow from ${sourceWorkflowId}`, - metadata: { sourceWorkflowId }, + metadata: { + sourceWorkflowId, + newWorkflowId: result.id, + folderId: folderId || undefined, + }, request: req, }) diff --git a/apps/sim/app/api/workflows/[id]/restore/route.ts b/apps/sim/app/api/workflows/[id]/restore/route.ts index a9d6b6ba1a5..c0b4d3d535f 100644 --- a/apps/sim/app/api/workflows/[id]/restore/route.ts +++ b/apps/sim/app/api/workflows/[id]/restore/route.ts @@ -56,6 +56,10 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: workflowId, resourceName: workflowData.name, description: `Restored workflow "${workflowData.name}"`, + metadata: { + workflowName: workflowData.name, + workspaceId: workflowData.workspaceId || undefined, + }, request, }) diff --git a/apps/sim/app/api/workflows/[id]/variables/route.ts b/apps/sim/app/api/workflows/[id]/variables/route.ts index 1b4cd8ab3b9..064669c9b8f 100644 --- a/apps/sim/app/api/workflows/[id]/variables/route.ts +++ b/apps/sim/app/api/workflows/[id]/variables/route.ts @@ -90,7 +90,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id: resourceId: workflowId, resourceName: workflowData.name ?? undefined, description: `Updated workflow variables`, - metadata: { variableCount: Object.keys(variables).length }, + metadata: { + variableCount: Object.keys(variables).length, + variableNames: Object.values(variables).map((v) => v.name), + workflowName: workflowData.name ?? undefined, + }, request: req, }) diff --git a/apps/sim/app/api/workflows/route.ts b/apps/sim/app/api/workflows/route.ts index 3615afd0890..f96bd6d352f 100644 --- a/apps/sim/app/api/workflows/route.ts +++ b/apps/sim/app/api/workflows/route.ts @@ -296,7 +296,14 @@ export async function POST(req: NextRequest) { resourceId: workflowId, resourceName: name, description: `Created workflow "${name}"`, - metadata: { name }, + metadata: { + name, + description: description || undefined, + color, + workspaceId, + folderId: folderId || undefined, + sortOrder, + }, request: req, }) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts index 42711f1fa8c..3345888a6f7 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/[keyId]/route.ts @@ -97,7 +97,12 @@ export async function PUT( actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, resourceName: name, - description: `Updated workspace API key: ${name}`, + description: `Renamed workspace API key from "${existingKey[0].name}" to "${name}"`, + metadata: { + keyType: 'workspace', + previousName: existingKey[0].name, + newName: name, + }, request, }) @@ -163,7 +168,11 @@ export async function DELETE( actorEmail: session.user.email ?? undefined, resourceName: deletedKey.name, description: `Revoked workspace API key: ${deletedKey.name}`, - metadata: { lastUsed: deletedKey.lastUsed?.toISOString() ?? null }, + metadata: { + keyType: 'workspace', + keyName: deletedKey.name, + lastUsed: deletedKey.lastUsed?.toISOString() ?? null, + }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts index a6a15bb52f2..4c156d06f94 100644 --- a/apps/sim/app/api/workspaces/[id]/api-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/api-keys/route.ts @@ -182,7 +182,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: newKey.id, resourceName: name, description: `Created API key "${name}"`, - metadata: { keyName: name }, + metadata: { keyName: name, keyType: 'workspace', source: source ?? 'settings' }, request, }) @@ -257,8 +257,8 @@ export async function DELETE( actorEmail: session?.user?.email, action: AuditAction.API_KEY_REVOKED, resourceType: AuditResourceType.API_KEY, - description: `Revoked ${deletedCount} API key(s)`, - metadata: { keyIds: keys, deletedCount }, + description: `Revoked ${deletedCount} workspace API key(s)`, + metadata: { keyIds: keys, deletedCount, keyType: 'workspace' }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts index 65f177b1c55..5ccda1fae77 100644 --- a/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts +++ b/apps/sim/app/api/workspaces/[id]/byok-keys/route.ts @@ -172,6 +172,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ logger.info(`[${requestId}] Updated BYOK key for ${providerId} in workspace ${workspaceId}`) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.BYOK_KEY_UPDATED, + resourceType: AuditResourceType.BYOK_KEY, + resourceId: existingKey[0].id, + resourceName: providerId, + description: `Updated BYOK key for ${providerId}`, + metadata: { providerId }, + request, + }) + return NextResponse.json({ success: true, key: { diff --git a/apps/sim/app/api/workspaces/[id]/environment/route.ts b/apps/sim/app/api/workspaces/[id]/environment/route.ts index 2e118b628d7..67b1eddeb7a 100644 --- a/apps/sim/app/api/workspaces/[id]/environment/route.ts +++ b/apps/sim/app/api/workspaces/[id]/environment/route.ts @@ -140,8 +140,12 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ action: AuditAction.ENVIRONMENT_UPDATED, resourceType: AuditResourceType.ENVIRONMENT, resourceId: workspaceId, - description: `Updated environment variables`, - metadata: { variableCount: Object.keys(variables).length }, + description: `Updated ${Object.keys(variables).length} workspace environment variable(s)`, + metadata: { + variableCount: Object.keys(variables).length, + updatedKeys: Object.keys(variables), + totalKeysAfterUpdate: Object.keys(merged).length, + }, request, }) @@ -217,6 +221,22 @@ export async function DELETE( actingUserId: userId, }) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session?.user?.name, + actorEmail: session?.user?.email, + action: AuditAction.ENVIRONMENT_DELETED, + resourceType: AuditResourceType.ENVIRONMENT, + resourceId: workspaceId, + description: `Removed ${keys.length} workspace environment variable(s)`, + metadata: { + removedKeys: keys, + remainingKeysCount: Object.keys(current).length, + }, + request, + }) + return NextResponse.json({ success: true }) } catch (error: any) { logger.error(`[${requestId}] Workspace env DELETE error`, error) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 24b5eb56cf0..179efc41d3f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -69,7 +69,9 @@ export async function PUT( action: AuditAction.FILE_UPDATED, resourceType: AuditResourceType.FILE, resourceId: fileId, + resourceName: updatedFile.name, description: `Updated content of file "${updatedFile.name}"`, + metadata: { contentSize: buffer.length }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index c440618863e..34cacc6808d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -58,6 +58,7 @@ export async function PATCH( action: AuditAction.FILE_UPDATED, resourceType: AuditResourceType.FILE, resourceId: fileId, + resourceName: updatedFile.name, description: `Renamed file to "${updatedFile.name}"`, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 5c887442796..41bdf82569f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -134,6 +134,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ resourceId: userFile.id, resourceName: fileName, description: `Uploaded file "${fileName}"`, + metadata: { fileSize: rawFile.size, fileType: rawFile.type || 'application/octet-stream' }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts index 08d3f5802d2..ae5ae96c3e6 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/[notificationId]/route.ts @@ -262,6 +262,14 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Updated ${subscription.notificationType} notification subscription`, + metadata: { + notificationType: subscription.notificationType, + updatedFields: Object.keys(data).filter( + (k) => (data as Record)[k] !== undefined + ), + ...(data.active !== undefined && { active: data.active }), + ...(data.alertConfig !== undefined && { alertRule: data.alertConfig?.rule ?? null }), + }, request, }) @@ -340,6 +348,9 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) { actorEmail: session.user.email ?? undefined, resourceName: deletedSubscription.notificationType, description: `Deleted ${deletedSubscription.notificationType} notification subscription`, + metadata: { + notificationType: deletedSubscription.notificationType, + }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/notifications/route.ts b/apps/sim/app/api/workspaces/[id]/notifications/route.ts index 1a18f8d2386..3ad7532f8e8 100644 --- a/apps/sim/app/api/workspaces/[id]/notifications/route.ts +++ b/apps/sim/app/api/workspaces/[id]/notifications/route.ts @@ -278,6 +278,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Created ${data.notificationType} notification subscription`, + metadata: { + notificationType: data.notificationType, + allWorkflows: data.allWorkflows, + workflowCount: data.workflowIds.length, + levelFilter: data.levelFilter, + alertRule: data.alertConfig?.rule ?? null, + ...(data.notificationType === 'email' && { + recipientCount: data.emailRecipients?.length ?? 0, + }), + ...(data.notificationType === 'slack' && { channelName: data.slackConfig?.channelName }), + }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index f31bce34ba6..e7ee5385597 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -202,19 +202,15 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< action: AuditAction.MEMBER_ROLE_CHANGED, resourceType: AuditResourceType.WORKSPACE, resourceId: workspaceId, + resourceName: permLookup.get(update.userId)?.email ?? update.userId, actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, - description: `Changed permissions for user ${update.userId} to ${update.permissions}`, + description: `Changed permissions for ${permLookup.get(update.userId)?.email ?? update.userId} from ${permLookup.get(update.userId)?.permission ?? 'none'} to ${update.permissions}`, metadata: { targetUserId: update.userId, targetEmail: permLookup.get(update.userId)?.email ?? undefined, - changes: [ - { - field: 'permissions', - from: permLookup.get(update.userId)?.permission ?? null, - to: update.permissions, - }, - ], + previousRole: permLookup.get(update.userId)?.permission ?? null, + newRole: update.permissions, }, request, }) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 375e0879b8b..ca4e9408fcf 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -202,6 +202,37 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise< .where(eq(workspace.id, workspaceId)) .then((rows) => rows[0]) + recordAudit({ + workspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.WORKSPACE_UPDATED, + resourceType: AuditResourceType.WORKSPACE, + resourceId: workspaceId, + resourceName: updatedWorkspace?.name ?? existingWorkspace.name, + description: `Updated workspace "${updatedWorkspace?.name ?? existingWorkspace.name}"`, + metadata: { + changes: { + ...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }), + ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }), + ...(allowPersonalApiKeys !== undefined && { + allowPersonalApiKeys: { + from: existingWorkspace.allowPersonalApiKeys, + to: allowPersonalApiKeys, + }, + }), + ...(billedAccountUserId !== undefined && { + billedAccountUserId: { + from: existingWorkspace.billedAccountUserId, + to: billedAccountUserId, + }, + }), + }, + }, + request, + }) + return NextResponse.json({ workspace: { ...updatedWorkspace, diff --git a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts index 602b60e88cd..d76322d4e5f 100644 --- a/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts +++ b/apps/sim/app/api/workspaces/invitations/[invitationId]/route.ts @@ -189,7 +189,13 @@ export async function GET( actorEmail: session.user.email ?? undefined, resourceName: workspaceDetails.name, description: `Accepted workspace invitation to "${workspaceDetails.name}"`, - metadata: { targetEmail: invitation.email }, + metadata: { + targetEmail: invitation.email, + workspaceName: workspaceDetails.name, + assignedPermission: invitation.permissions || 'read', + invitationId: invitation.id, + inviterId: invitation.inviterId, + }, request: req, }) @@ -272,7 +278,11 @@ export async function DELETE( actorName: session.user.name ?? undefined, actorEmail: session.user.email ?? undefined, description: `Revoked workspace invitation for ${invitation.email}`, - metadata: { invitationId, targetEmail: invitation.email }, + metadata: { + invitationId, + targetEmail: invitation.email, + invitationStatus: invitation.status, + }, request: _request, }) @@ -360,6 +370,24 @@ export async function POST( ) } + recordAudit({ + workspaceId: invitation.workspaceId, + actorId: session.user.id, + action: AuditAction.INVITATION_RESENT, + resourceType: AuditResourceType.WORKSPACE, + resourceId: invitation.workspaceId, + actorName: session.user.name ?? undefined, + actorEmail: session.user.email ?? undefined, + resourceName: ws.name, + description: `Resent workspace invitation to ${invitation.email}`, + metadata: { + invitationId, + targetEmail: invitation.email, + workspaceName: ws.name, + }, + request: _request, + }) + return NextResponse.json({ success: true }) } catch (error) { logger.error('Error resending workspace invitation:', error) diff --git a/apps/sim/app/api/workspaces/invitations/route.ts b/apps/sim/app/api/workspaces/invitations/route.ts index 30c91acd22f..020e350dbb2 100644 --- a/apps/sim/app/api/workspaces/invitations/route.ts +++ b/apps/sim/app/api/workspaces/invitations/route.ts @@ -243,7 +243,12 @@ export async function POST(req: NextRequest) { resourceId: workspaceId, resourceName: email, description: `Invited ${email} as ${permission}`, - metadata: { targetEmail: email, targetRole: permission }, + metadata: { + targetEmail: email, + targetRole: permission, + workspaceName: workspaceDetails.name, + invitationId: invitationData.id, + }, request: req, }) diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index ca918712946..e4a507c5a78 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -121,8 +121,12 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i action: AuditAction.MEMBER_REMOVED, resourceType: AuditResourceType.WORKSPACE, resourceId: workspaceId, - description: isSelf ? 'Left the workspace' : 'Removed a member from the workspace', - metadata: { removedUserId: userId, selfRemoval: isSelf }, + description: isSelf ? 'Left the workspace' : `Removed member ${userId} from the workspace`, + metadata: { + removedUserId: userId, + removedUserRole: userPermission.permissionType, + selfRemoval: isSelf, + }, request: req, }) diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index d64fd05f758..1fdc15f94df 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -118,7 +118,7 @@ export async function POST(req: Request) { resourceId: newWorkspace.id, resourceName: newWorkspace.name, description: `Created workspace "${newWorkspace.name}"`, - metadata: { name: newWorkspace.name }, + metadata: { name: newWorkspace.name, color: newWorkspace.color }, request: req, }) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 4642fc9e843..b6f439635b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -27,6 +27,7 @@ import { isBillingEnabled, isCredentialSetsEnabled, } from '@/app/workspace/[workspaceId]/settings/navigation' +import { AuditLogsSkeleton } from '@/ee/audit-logs/components/audit-logs-skeleton' /** * Generic skeleton fallback for sections without a dedicated skeleton. @@ -153,6 +154,10 @@ const AccessControl = dynamic( () => import('@/ee/access-control/components/access-control').then((m) => m.AccessControl), { loading: () => } ) +const AuditLogs = dynamic( + () => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs), + { loading: () => } +) const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), { loading: () => , }) @@ -201,6 +206,7 @@ export function SettingsPage({ section }: SettingsPageProps) { {/* {effectiveSection === 'template-profile' && } */} {effectiveSection === 'credential-sets' && } {effectiveSection === 'access-control' && } + {effectiveSection === 'audit-logs' && } {effectiveSection === 'apikeys' && } {isBillingEnabled && effectiveSection === 'subscription' && } {isBillingEnabled && effectiveSection === 'team' && } diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts index eb6941cb10b..ff25389fc0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.ts @@ -1,5 +1,6 @@ import { Card, + ClipboardList, Connections, HexSimple, Key, @@ -27,6 +28,7 @@ export type SettingsSection = | 'template-profile' | 'credential-sets' | 'access-control' + | 'audit-logs' | 'apikeys' | 'byok' | 'subscription' @@ -97,6 +99,14 @@ export const allNavigationItems: NavigationItem[] = [ requiresEnterprise: true, selfHostedOverride: isAccessControlEnabled, }, + { + id: 'audit-logs', + label: 'Audit Logs', + icon: ClipboardList, + section: 'enterprise', + requiresHosted: true, + requiresEnterprise: true, + }, { id: 'subscription', label: 'Subscription', diff --git a/apps/sim/ee/audit-logs/components/audit-logs-skeleton.tsx b/apps/sim/ee/audit-logs/components/audit-logs-skeleton.tsx new file mode 100644 index 00000000000..ae5504c7ffc --- /dev/null +++ b/apps/sim/ee/audit-logs/components/audit-logs-skeleton.tsx @@ -0,0 +1,27 @@ +import { Skeleton } from '@/components/emcn' + +export function AuditLogsSkeleton() { + return ( +
+
+ + + +
+
+ + + + +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ) +} diff --git a/apps/sim/ee/audit-logs/components/audit-logs.tsx b/apps/sim/ee/audit-logs/components/audit-logs.tsx new file mode 100644 index 00000000000..7aef8bd7946 --- /dev/null +++ b/apps/sim/ee/audit-logs/components/audit-logs.tsx @@ -0,0 +1,267 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { RefreshCw, Search } from 'lucide-react' +import { Badge, Button, Combobox, type ComboboxOption, Skeleton } from '@/components/emcn' +import { Input } from '@/components/ui' +import { cn } from '@/lib/core/utils/cn' +import { formatDateTime } from '@/lib/core/utils/formatting' +import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format' +import { RESOURCE_TYPE_OPTIONS } from '@/ee/audit-logs/constants' +import { type AuditLogFilters, useAuditLogs } from '@/ee/audit-logs/hooks/audit-logs' + +const logger = createLogger('AuditLogs') + +const DATE_RANGE_OPTIONS: ComboboxOption[] = [ + { label: 'Last 7 days', value: '7' }, + { label: 'Last 30 days', value: '30' }, + { label: 'Last 90 days', value: '90' }, + { label: 'All time', value: '' }, +] + +function formatResourceType(type: string): string { + return type + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') +} + +function getStartOfDay(daysAgo: number): string { + const start = new Date() + start.setDate(start.getDate() - daysAgo) + start.setHours(0, 0, 0, 0) + return start.toISOString() +} + +function formatAction(action: string): string { + return action.replace(/[._]/g, ' ') +} + +interface ActionBadgeProps { + action: string +} + +function ActionBadge({ action }: ActionBadgeProps) { + const [, verb] = action.split('.') + const variant = verb === 'deleted' || verb === 'removed' || verb === 'revoked' ? 'red' : 'default' + return ( + + {formatAction(action)} + + ) +} + +interface AuditLogRowProps { + entry: EnterpriseAuditLogEntry +} + +function AuditLogRow({ entry }: AuditLogRowProps) { + const [expanded, setExpanded] = useState(false) + const timestamp = formatDateTime(new Date(entry.createdAt)) + + return ( +
+ + {expanded && ( +
+
+ Resource + + {formatResourceType(entry.resourceType)} + {entry.resourceId && ( + ({entry.resourceId}) + )} + +
+ {entry.resourceName && ( +
+ Name + {entry.resourceName} +
+ )} +
+ Actor + + {entry.actorName || 'Unknown'} + {entry.actorEmail && ( + ({entry.actorEmail}) + )} + +
+ {entry.description && ( +
+ Description + {entry.description} +
+ )} + {entry.metadata != null && + Object.keys(entry.metadata as Record).length > 0 ? ( +
+ Details +
+                {JSON.stringify(entry.metadata, null, 2)}
+              
+
+ ) : null} +
+ )} +
+ ) +} + +export function AuditLogs() { + const [resourceType, setResourceType] = useState('') + const [dateRange, setDateRange] = useState('30') + const [searchTerm, setSearchTerm] = useState('') + const [debouncedSearch, setDebouncedSearch] = useState('') + const debounceRef = useRef | null>(null) + + useEffect(() => { + const trimmed = searchTerm.trim() + if (trimmed === debouncedSearch) return + debounceRef.current = setTimeout(() => { + setDebouncedSearch(trimmed) + }, 300) + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + } + }, [searchTerm, debouncedSearch]) + + const filters = useMemo(() => { + return { + search: debouncedSearch || undefined, + resourceType: resourceType || undefined, + startDate: dateRange ? getStartOfDay(Number(dateRange)) : undefined, + } + }, [debouncedSearch, resourceType, dateRange]) + + const { data, isLoading, isFetchingNextPage, hasNextPage, fetchNextPage, refetch, isRefetching } = + useAuditLogs(filters) + + const allEntries = useMemo(() => { + if (!data?.pages) return [] + return data.pages.flatMap((page) => page.data) + }, [data]) + + const handleRefresh = useCallback(() => { + refetch().catch((error: unknown) => { + logger.error('Failed to refresh audit logs', { error }) + }) + }, [refetch]) + + const handleLoadMore = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage().catch((error: unknown) => { + logger.error('Failed to load more audit logs', { error }) + }) + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]) + + return ( +
+
+
+ + setSearchTerm(e.target.value)} + className='h-auto flex-1 border-0 bg-transparent p-0 font-base leading-none placeholder:text-[var(--text-tertiary)] focus-visible:ring-0 focus-visible:ring-offset-0' + /> +
+
+ +
+
+ +
+ +
+ +
+ + Timestamp + + + Event + + + Description + + + Actor + +
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ) : allEntries.length === 0 ? ( +
+ {debouncedSearch ? `No results for "${debouncedSearch}"` : 'No audit logs found'} +
+ ) : ( +
+ {allEntries.map((entry) => ( + + ))} + {hasNextPage && ( +
+ +
+ )} +
+ )} +
+
+ ) +} diff --git a/apps/sim/ee/audit-logs/constants.ts b/apps/sim/ee/audit-logs/constants.ts new file mode 100644 index 00000000000..445265f4f81 --- /dev/null +++ b/apps/sim/ee/audit-logs/constants.ts @@ -0,0 +1,24 @@ +import type { ComboboxOption } from '@/components/emcn' +import { AuditResourceType } from '@/lib/audit/types' + +const ACRONYMS = new Set(['API', 'BYOK', 'MCP', 'OAUTH']) + +const DISPLAY_OVERRIDES: Record = { OAUTH: 'OAuth' } + +function formatResourceLabel(key: string): string { + return key + .split('_') + .map((w) => { + const upper = w.toUpperCase() + if (ACRONYMS.has(upper)) return DISPLAY_OVERRIDES[upper] ?? upper + return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase() + }) + .join(' ') +} + +export const RESOURCE_TYPE_OPTIONS: ComboboxOption[] = [ + { label: 'All Types', value: '' }, + ...(Object.entries(AuditResourceType) as [string, string][]) + .map(([key, value]) => ({ label: formatResourceLabel(key), value })) + .sort((a, b) => a.label.localeCompare(b.label)), +] diff --git a/apps/sim/ee/audit-logs/hooks/audit-logs.ts b/apps/sim/ee/audit-logs/hooks/audit-logs.ts new file mode 100644 index 00000000000..259d6094c0d --- /dev/null +++ b/apps/sim/ee/audit-logs/hooks/audit-logs.ts @@ -0,0 +1,58 @@ +import { keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' +import type { EnterpriseAuditLogEntry } from '@/app/api/v1/audit-logs/format' + +export const auditLogKeys = { + all: ['audit-logs'] as const, + lists: () => [...auditLogKeys.all, 'list'] as const, + list: (filters: AuditLogFilters) => [...auditLogKeys.lists(), filters] as const, +} + +export interface AuditLogFilters { + search?: string + action?: string + resourceType?: string + actorId?: string + startDate?: string + endDate?: string +} + +interface AuditLogPage { + success: boolean + data: EnterpriseAuditLogEntry[] + nextCursor?: string +} + +async function fetchAuditLogs( + filters: AuditLogFilters, + cursor?: string, + signal?: AbortSignal +): Promise { + const params = new URLSearchParams() + params.set('limit', '50') + if (filters.search) params.set('search', filters.search) + if (filters.action) params.set('action', filters.action) + if (filters.resourceType) params.set('resourceType', filters.resourceType) + if (filters.actorId) params.set('actorId', filters.actorId) + if (filters.startDate) params.set('startDate', filters.startDate) + if (filters.endDate) params.set('endDate', filters.endDate) + if (cursor) params.set('cursor', cursor) + + const response = await fetch(`/api/audit-logs?${params.toString()}`, { signal }) + if (!response.ok) { + const body = await response.json().catch(() => ({})) + throw new Error(body.error || `Failed to fetch audit logs: ${response.status}`) + } + return response.json() +} + +export function useAuditLogs(filters: AuditLogFilters, enabled = true) { + return useInfiniteQuery({ + queryKey: auditLogKeys.list(filters), + queryFn: ({ pageParam, signal }) => fetchAuditLogs(filters, pageParam, signal), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, + enabled, + staleTime: 30 * 1000, + placeholderData: keepPreviousData, + }) +} diff --git a/apps/sim/lib/audit/log.ts b/apps/sim/lib/audit/log.ts index fc0e8ba3fc6..ea7783aba78 100644 --- a/apps/sim/lib/audit/log.ts +++ b/apps/sim/lib/audit/log.ts @@ -2,214 +2,14 @@ import { auditLog, db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import type { AuditActionType, AuditResourceTypeValue } from '@/lib/audit/types' import { getClientIp } from '@/lib/core/utils/request' import { generateShortId } from '@/lib/core/utils/uuid' -const logger = createLogger('AuditLog') - -/** - * All auditable actions in the platform, grouped by resource type. - */ -export const AuditAction = { - // API Keys - API_KEY_CREATED: 'api_key.created', - API_KEY_UPDATED: 'api_key.updated', - API_KEY_REVOKED: 'api_key.revoked', - PERSONAL_API_KEY_CREATED: 'personal_api_key.created', - PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked', - - // BYOK Keys - BYOK_KEY_CREATED: 'byok_key.created', - BYOK_KEY_DELETED: 'byok_key.deleted', - - // Chat - CHAT_DEPLOYED: 'chat.deployed', - CHAT_UPDATED: 'chat.updated', - CHAT_DELETED: 'chat.deleted', - - // Custom Tools - CUSTOM_TOOL_CREATED: 'custom_tool.created', - CUSTOM_TOOL_UPDATED: 'custom_tool.updated', - CUSTOM_TOOL_DELETED: 'custom_tool.deleted', - - // Billing - CREDIT_PURCHASED: 'credit.purchased', - - // Credential Sets - CREDENTIAL_SET_CREATED: 'credential_set.created', - CREDENTIAL_SET_UPDATED: 'credential_set.updated', - CREDENTIAL_SET_DELETED: 'credential_set.deleted', - CREDENTIAL_SET_MEMBER_REMOVED: 'credential_set_member.removed', - CREDENTIAL_SET_MEMBER_LEFT: 'credential_set_member.left', - CREDENTIAL_SET_INVITATION_CREATED: 'credential_set_invitation.created', - CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted', - CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent', - CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', - - // Connector Documents - CONNECTOR_DOCUMENT_RESTORED: 'connector_document.restored', - CONNECTOR_DOCUMENT_EXCLUDED: 'connector_document.excluded', - - // Documents - DOCUMENT_UPLOADED: 'document.uploaded', - DOCUMENT_UPDATED: 'document.updated', - DOCUMENT_DELETED: 'document.deleted', - - // Environment - ENVIRONMENT_UPDATED: 'environment.updated', - - // Files - FILE_UPLOADED: 'file.uploaded', - FILE_UPDATED: 'file.updated', - FILE_DELETED: 'file.deleted', - FILE_RESTORED: 'file.restored', - - // Folders - FOLDER_CREATED: 'folder.created', - FOLDER_DELETED: 'folder.deleted', - FOLDER_DUPLICATED: 'folder.duplicated', - FOLDER_RESTORED: 'folder.restored', - - // Forms - FORM_CREATED: 'form.created', - FORM_UPDATED: 'form.updated', - FORM_DELETED: 'form.deleted', - - // Invitations - INVITATION_ACCEPTED: 'invitation.accepted', - INVITATION_REVOKED: 'invitation.revoked', - - // Knowledge Base Connectors - CONNECTOR_CREATED: 'connector.created', - CONNECTOR_UPDATED: 'connector.updated', - CONNECTOR_DELETED: 'connector.deleted', - CONNECTOR_SYNCED: 'connector.synced', - - // Knowledge Bases - KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', - KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', - KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', - KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored', - - // MCP Servers - MCP_SERVER_ADDED: 'mcp_server.added', - MCP_SERVER_UPDATED: 'mcp_server.updated', - MCP_SERVER_REMOVED: 'mcp_server.removed', +export type { AuditActionType, AuditResourceTypeValue } from '@/lib/audit/types' +export { AuditAction, AuditResourceType } from '@/lib/audit/types' - // Members - MEMBER_INVITED: 'member.invited', - MEMBER_REMOVED: 'member.removed', - MEMBER_ROLE_CHANGED: 'member.role_changed', - - // Notifications - NOTIFICATION_CREATED: 'notification.created', - NOTIFICATION_UPDATED: 'notification.updated', - NOTIFICATION_DELETED: 'notification.deleted', - - // OAuth / Credentials - OAUTH_DISCONNECTED: 'oauth.disconnected', - CREDENTIAL_RENAMED: 'credential.renamed', - CREDENTIAL_DELETED: 'credential.deleted', - - // Password - PASSWORD_RESET: 'password.reset', - - // Organizations - ORGANIZATION_CREATED: 'organization.created', - ORGANIZATION_UPDATED: 'organization.updated', - ORG_MEMBER_ADDED: 'org_member.added', - ORG_MEMBER_REMOVED: 'org_member.removed', - ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', - ORG_INVITATION_CREATED: 'org_invitation.created', - ORG_INVITATION_ACCEPTED: 'org_invitation.accepted', - ORG_INVITATION_REJECTED: 'org_invitation.rejected', - ORG_INVITATION_CANCELLED: 'org_invitation.cancelled', - ORG_INVITATION_REVOKED: 'org_invitation.revoked', - - // Permission Groups - PERMISSION_GROUP_CREATED: 'permission_group.created', - PERMISSION_GROUP_UPDATED: 'permission_group.updated', - PERMISSION_GROUP_DELETED: 'permission_group.deleted', - PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', - PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', - - // Skills - SKILL_CREATED: 'skill.created', - SKILL_UPDATED: 'skill.updated', - SKILL_DELETED: 'skill.deleted', - - // Schedules - SCHEDULE_UPDATED: 'schedule.updated', - - // Tables - TABLE_CREATED: 'table.created', - TABLE_UPDATED: 'table.updated', - TABLE_DELETED: 'table.deleted', - TABLE_RESTORED: 'table.restored', - - // Templates - TEMPLATE_CREATED: 'template.created', - TEMPLATE_UPDATED: 'template.updated', - TEMPLATE_DELETED: 'template.deleted', - - // Webhooks - WEBHOOK_CREATED: 'webhook.created', - WEBHOOK_DELETED: 'webhook.deleted', - - // Workflows - WORKFLOW_CREATED: 'workflow.created', - WORKFLOW_DELETED: 'workflow.deleted', - WORKFLOW_RESTORED: 'workflow.restored', - WORKFLOW_DEPLOYED: 'workflow.deployed', - WORKFLOW_UNDEPLOYED: 'workflow.undeployed', - WORKFLOW_DUPLICATED: 'workflow.duplicated', - WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated', - WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', - WORKFLOW_LOCKED: 'workflow.locked', - WORKFLOW_UNLOCKED: 'workflow.unlocked', - WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', - - // Workspaces - WORKSPACE_CREATED: 'workspace.created', - WORKSPACE_DELETED: 'workspace.deleted', - WORKSPACE_DUPLICATED: 'workspace.duplicated', -} as const - -export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction] - -/** - * All resource types that can appear in audit log entries. - */ -export const AuditResourceType = { - API_KEY: 'api_key', - BILLING: 'billing', - BYOK_KEY: 'byok_key', - CHAT: 'chat', - CONNECTOR: 'connector', - CREDENTIAL_SET: 'credential_set', - CUSTOM_TOOL: 'custom_tool', - DOCUMENT: 'document', - ENVIRONMENT: 'environment', - FILE: 'file', - FOLDER: 'folder', - FORM: 'form', - KNOWLEDGE_BASE: 'knowledge_base', - MCP_SERVER: 'mcp_server', - NOTIFICATION: 'notification', - OAUTH: 'oauth', - ORGANIZATION: 'organization', - PASSWORD: 'password', - PERMISSION_GROUP: 'permission_group', - SCHEDULE: 'schedule', - SKILL: 'skill', - TABLE: 'table', - TEMPLATE: 'template', - WEBHOOK: 'webhook', - WORKFLOW: 'workflow', - WORKSPACE: 'workspace', -} as const - -export type AuditResourceTypeValue = (typeof AuditResourceType)[keyof typeof AuditResourceType] +const logger = createLogger('AuditLog') interface AuditLogParams { workspaceId?: string | null diff --git a/apps/sim/lib/audit/types.ts b/apps/sim/lib/audit/types.ts new file mode 100644 index 00000000000..bc1f857f469 --- /dev/null +++ b/apps/sim/lib/audit/types.ts @@ -0,0 +1,214 @@ +/** + * All auditable actions in the platform, grouped by resource type. + */ +export const AuditAction = { + // API Keys + API_KEY_CREATED: 'api_key.created', + API_KEY_UPDATED: 'api_key.updated', + API_KEY_REVOKED: 'api_key.revoked', + PERSONAL_API_KEY_CREATED: 'personal_api_key.created', + PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked', + + // BYOK Keys + BYOK_KEY_CREATED: 'byok_key.created', + BYOK_KEY_UPDATED: 'byok_key.updated', + BYOK_KEY_DELETED: 'byok_key.deleted', + + // Chat + CHAT_DEPLOYED: 'chat.deployed', + CHAT_UPDATED: 'chat.updated', + CHAT_DELETED: 'chat.deleted', + + // Custom Tools + CUSTOM_TOOL_CREATED: 'custom_tool.created', + CUSTOM_TOOL_UPDATED: 'custom_tool.updated', + CUSTOM_TOOL_DELETED: 'custom_tool.deleted', + + // Billing + CREDIT_PURCHASED: 'credit.purchased', + + // Credential Sets + CREDENTIAL_SET_CREATED: 'credential_set.created', + CREDENTIAL_SET_UPDATED: 'credential_set.updated', + CREDENTIAL_SET_DELETED: 'credential_set.deleted', + CREDENTIAL_SET_MEMBER_REMOVED: 'credential_set_member.removed', + CREDENTIAL_SET_MEMBER_LEFT: 'credential_set_member.left', + CREDENTIAL_SET_INVITATION_CREATED: 'credential_set_invitation.created', + CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted', + CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent', + CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked', + + // Connector Documents + CONNECTOR_DOCUMENT_RESTORED: 'connector_document.restored', + CONNECTOR_DOCUMENT_EXCLUDED: 'connector_document.excluded', + + // Documents + DOCUMENT_UPLOADED: 'document.uploaded', + DOCUMENT_UPDATED: 'document.updated', + DOCUMENT_DELETED: 'document.deleted', + + // Environment + ENVIRONMENT_UPDATED: 'environment.updated', + ENVIRONMENT_DELETED: 'environment.deleted', + + // Files + FILE_UPLOADED: 'file.uploaded', + FILE_UPDATED: 'file.updated', + FILE_DELETED: 'file.deleted', + FILE_RESTORED: 'file.restored', + + // Folders + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_DUPLICATED: 'folder.duplicated', + FOLDER_RESTORED: 'folder.restored', + + // Forms + FORM_CREATED: 'form.created', + FORM_UPDATED: 'form.updated', + FORM_DELETED: 'form.deleted', + + // Invitations + INVITATION_ACCEPTED: 'invitation.accepted', + INVITATION_RESENT: 'invitation.resent', + INVITATION_REVOKED: 'invitation.revoked', + + // Knowledge Base Connectors + CONNECTOR_CREATED: 'connector.created', + CONNECTOR_UPDATED: 'connector.updated', + CONNECTOR_DELETED: 'connector.deleted', + CONNECTOR_SYNCED: 'connector.synced', + + // Knowledge Bases + KNOWLEDGE_BASE_CREATED: 'knowledge_base.created', + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + KNOWLEDGE_BASE_RESTORED: 'knowledge_base.restored', + + // MCP Servers + MCP_SERVER_ADDED: 'mcp_server.added', + MCP_SERVER_UPDATED: 'mcp_server.updated', + MCP_SERVER_REMOVED: 'mcp_server.removed', + + // Members + MEMBER_INVITED: 'member.invited', + MEMBER_REMOVED: 'member.removed', + MEMBER_ROLE_CHANGED: 'member.role_changed', + + // Notifications + NOTIFICATION_CREATED: 'notification.created', + NOTIFICATION_UPDATED: 'notification.updated', + NOTIFICATION_DELETED: 'notification.deleted', + + // OAuth / Credentials + OAUTH_DISCONNECTED: 'oauth.disconnected', + CREDENTIAL_CREATED: 'credential.created', + CREDENTIAL_UPDATED: 'credential.updated', + CREDENTIAL_RENAMED: 'credential.renamed', + CREDENTIAL_DELETED: 'credential.deleted', + + // Password + PASSWORD_RESET_REQUESTED: 'password.reset_requested', + PASSWORD_RESET: 'password.reset', + + // Organizations + ORGANIZATION_CREATED: 'organization.created', + ORGANIZATION_UPDATED: 'organization.updated', + ORG_MEMBER_ADDED: 'org_member.added', + ORG_MEMBER_REMOVED: 'org_member.removed', + ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed', + ORG_INVITATION_CREATED: 'org_invitation.created', + ORG_INVITATION_ACCEPTED: 'org_invitation.accepted', + ORG_INVITATION_REJECTED: 'org_invitation.rejected', + ORG_INVITATION_CANCELLED: 'org_invitation.cancelled', + ORG_INVITATION_REVOKED: 'org_invitation.revoked', + ORG_INVITATION_RESENT: 'org_invitation.resent', + + // Permission Groups + PERMISSION_GROUP_CREATED: 'permission_group.created', + PERMISSION_GROUP_UPDATED: 'permission_group.updated', + PERMISSION_GROUP_DELETED: 'permission_group.deleted', + PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', + PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', + + // Skills + SKILL_CREATED: 'skill.created', + SKILL_UPDATED: 'skill.updated', + SKILL_DELETED: 'skill.deleted', + + // Schedules + SCHEDULE_CREATED: 'schedule.created', + SCHEDULE_UPDATED: 'schedule.updated', + SCHEDULE_DELETED: 'schedule.deleted', + + // Tables + TABLE_CREATED: 'table.created', + TABLE_UPDATED: 'table.updated', + TABLE_DELETED: 'table.deleted', + TABLE_RESTORED: 'table.restored', + + // Templates + TEMPLATE_CREATED: 'template.created', + TEMPLATE_UPDATED: 'template.updated', + TEMPLATE_DELETED: 'template.deleted', + + // Webhooks + WEBHOOK_CREATED: 'webhook.created', + WEBHOOK_DELETED: 'webhook.deleted', + + // Workflows + WORKFLOW_CREATED: 'workflow.created', + WORKFLOW_DELETED: 'workflow.deleted', + WORKFLOW_RESTORED: 'workflow.restored', + WORKFLOW_DEPLOYED: 'workflow.deployed', + WORKFLOW_UNDEPLOYED: 'workflow.undeployed', + WORKFLOW_DUPLICATED: 'workflow.duplicated', + WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated', + WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', + WORKFLOW_LOCKED: 'workflow.locked', + WORKFLOW_UNLOCKED: 'workflow.unlocked', + WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', + + // Workspaces + WORKSPACE_CREATED: 'workspace.created', + WORKSPACE_UPDATED: 'workspace.updated', + WORKSPACE_DELETED: 'workspace.deleted', + WORKSPACE_DUPLICATED: 'workspace.duplicated', +} as const + +export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction] + +/** + * All resource types that can appear in audit log entries. + */ +export const AuditResourceType = { + API_KEY: 'api_key', + BILLING: 'billing', + BYOK_KEY: 'byok_key', + CHAT: 'chat', + CONNECTOR: 'connector', + CREDENTIAL: 'credential', + CREDENTIAL_SET: 'credential_set', + CUSTOM_TOOL: 'custom_tool', + DOCUMENT: 'document', + ENVIRONMENT: 'environment', + FILE: 'file', + FOLDER: 'folder', + FORM: 'form', + KNOWLEDGE_BASE: 'knowledge_base', + MCP_SERVER: 'mcp_server', + NOTIFICATION: 'notification', + OAUTH: 'oauth', + ORGANIZATION: 'organization', + PASSWORD: 'password', + PERMISSION_GROUP: 'permission_group', + SCHEDULE: 'schedule', + SKILL: 'skill', + TABLE: 'table', + TEMPLATE: 'template', + WEBHOOK: 'webhook', + WORKFLOW: 'workflow', + WORKSPACE: 'workspace', +} as const + +export type AuditResourceTypeValue = (typeof AuditResourceType)[keyof typeof AuditResourceType] diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 98e48bd7803..b08d15d7431 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -707,7 +707,8 @@ export const auth = betterAuth({ actorEmail: resetUser.email, action: AuditAction.PASSWORD_RESET, resourceType: AuditResourceType.PASSWORD, - description: 'Password reset completed', + resourceId: resetUser.id, + description: `Password reset completed for ${resetUser.email}`, }) }, }, diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts index eb89c876cee..f75d6d7fbca 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts @@ -261,6 +261,7 @@ export async function executeDeployMcp( resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, description: `Undeployed workflow "${workflowId}" from MCP server`, + metadata: { workflowId, source: 'copilot' }, }) return { @@ -324,6 +325,7 @@ export async function executeDeployMcp( resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, description: `Updated MCP tool "${toolName}" on server`, + metadata: { workflowId, toolName, source: 'copilot' }, }) return { @@ -353,6 +355,7 @@ export async function executeDeployMcp( resourceType: AuditResourceType.MCP_SERVER, resourceId: serverId, description: `Deployed workflow as MCP tool "${toolName}"`, + metadata: { workflowId, toolName, toolId, source: 'copilot' }, }) return { diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts index 233b1cbfe25..00ecfce4dce 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts @@ -255,6 +255,11 @@ export async function executeCreateWorkspaceMcpServer( resourceId: serverId, resourceName: name, description: `Created MCP server "${name}"`, + metadata: { + isPublic: params.isPublic ?? false, + toolCount: addedTools.length, + source: 'copilot', + }, }) return { success: true, output: { server, addedTools } } @@ -314,6 +319,10 @@ export async function executeUpdateWorkspaceMcpServer( resourceType: AuditResourceType.MCP_SERVER, resourceId: params.serverId, description: `Updated MCP server`, + metadata: { + updatedFields: Object.keys(updates).filter((k) => k !== 'updatedAt'), + source: 'copilot', + }, }) return { success: true, output: { serverId, ...updates, updatedAt: undefined } } @@ -357,7 +366,9 @@ export async function executeDeleteWorkspaceMcpServer( action: AuditAction.MCP_SERVER_REMOVED, resourceType: AuditResourceType.MCP_SERVER, resourceId: params.serverId, - description: `Deleted MCP server`, + resourceName: existing.name, + description: `Deleted MCP server "${existing.name}"`, + metadata: { source: 'copilot' }, }) return { success: true, output: { serverId, name: existing.name, deleted: true } } diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts index 5cbbcdd5730..ac48a677081 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts @@ -241,6 +241,7 @@ async function executeManageCustomTool( resourceId: created?.id, resourceName: title, description: `Created custom tool "${title}"`, + metadata: { source: 'copilot' }, }) return { @@ -299,6 +300,7 @@ async function executeManageCustomTool( resourceId: params.toolId, resourceName: title, description: `Updated custom tool "${title}"`, + metadata: { source: 'copilot' }, }) return { @@ -334,6 +336,7 @@ async function executeManageCustomTool( resourceType: AuditResourceType.CUSTOM_TOOL, resourceId: params.toolId, description: 'Deleted custom tool', + metadata: { source: 'copilot' }, }) return { @@ -502,6 +505,7 @@ async function executeManageMcpTool( description: existing ? `Updated existing MCP server "${config.name}"` : `Added MCP server "${config.name}"`, + metadata: { transport: config.transport, url: config.url, source: 'copilot' }, }) return { @@ -563,7 +567,9 @@ async function executeManageMcpTool( action: AuditAction.MCP_SERVER_UPDATED, resourceType: AuditResourceType.MCP_SERVER, resourceId: params.serverId, + resourceName: updated.name, description: `Updated MCP server "${updated.name}"`, + metadata: { source: 'copilot' }, }) return { @@ -607,7 +613,9 @@ async function executeManageMcpTool( action: AuditAction.MCP_SERVER_REMOVED, resourceType: AuditResourceType.MCP_SERVER, resourceId: params.serverId, + resourceName: deleted.name, description: `Deleted MCP server "${deleted.name}"`, + metadata: { source: 'copilot' }, }) return { @@ -719,6 +727,7 @@ async function executeManageSkill( resourceId: created?.id, resourceName: params.name, description: `Created skill "${params.name}"`, + metadata: { source: 'copilot' }, }) return { @@ -773,6 +782,7 @@ async function executeManageSkill( resourceId: params.skillId, resourceName: updatedName, description: `Updated skill "${updatedName}"`, + metadata: { source: 'copilot' }, }) return { @@ -804,6 +814,7 @@ async function executeManageSkill( resourceType: AuditResourceType.SKILL, resourceId: params.skillId, description: 'Deleted skill', + metadata: { source: 'copilot' }, }) return { @@ -1055,7 +1066,9 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record< action: AuditAction.CREDENTIAL_RENAMED, resourceType: AuditResourceType.OAUTH, resourceId: credentialId, + resourceName: displayName, description: `Renamed credential to "${displayName}"`, + metadata: { source: 'copilot' }, }) return { success: true, output: { credentialId, displayName } } } @@ -1067,6 +1080,7 @@ const SIM_WORKFLOW_TOOL_HANDLERS: Record< resourceType: AuditResourceType.OAUTH, resourceId: credentialId, description: `Deleted credential`, + metadata: { source: 'copilot' }, }) return { success: true, output: { credentialId, deleted: true } } } diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts index c2a378cb768..615fcdee647 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts +++ b/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts @@ -141,6 +141,7 @@ export async function executeCreateWorkflow( resourceId: result.workflowId, resourceName: name, description: `Created workflow "${name}"`, + metadata: { folderId, source: 'copilot' }, }) try { @@ -216,6 +217,7 @@ export async function executeCreateFolder( resourceId: result.folderId, resourceName: name, description: `Created folder "${name}"`, + metadata: { parentId, source: 'copilot' }, }) return { success: true, output: result } @@ -372,6 +374,7 @@ export async function executeSetGlobalWorkflowVariables( resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, description: `Updated workflow variables`, + metadata: { operationCount: operations.length, source: 'copilot' }, }) return { success: true, output: { updated: Object.values(byName).length } } @@ -536,7 +539,10 @@ export async function executeGenerateApiKey( actorId: context.userId, action: AuditAction.API_KEY_CREATED, resourceType: AuditResourceType.API_KEY, - description: `Generated API key for workspace`, + resourceId: newKey.id, + resourceName: name, + description: `Generated API key "${name}" for workspace`, + metadata: { source: 'copilot' }, }) return { diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 1a00e325d29..ada57d50641 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -155,7 +155,19 @@ export async function performChatDeploy( resourceId: chatId, resourceName: title, description: `Deployed chat "${title}"`, - metadata: { workflowId, identifier, authType }, + metadata: { + workflowId, + identifier, + authType, + chatUrl, + isUpdate: !!existingDeployment, + hasOutputConfigs: outputConfigs.length > 0, + hasCustomizations: !!( + params.customizations?.primaryColor || + params.customizations?.welcomeMessage || + params.customizations?.imageUrl + ), + }, }) return { success: true, chatId, chatUrl } @@ -200,6 +212,11 @@ export async function performChatUndeploy( resourceId: chatId, resourceName: chatRecord.title || chatId, description: `Deleted chat deployment "${chatRecord.title || chatId}"`, + metadata: { + workflowId: chatRecord.workflowId || undefined, + identifier: chatRecord.identifier || undefined, + authType: chatRecord.authType || undefined, + }, }) return { success: true } diff --git a/apps/sim/lib/workflows/orchestration/deploy.ts b/apps/sim/lib/workflows/orchestration/deploy.ts index 5e8863ccb0b..d8709e47d50 100644 --- a/apps/sim/lib/workflows/orchestration/deploy.ts +++ b/apps/sim/lib/workflows/orchestration/deploy.ts @@ -209,7 +209,12 @@ export async function performFullDeploy( resourceId: workflowId, resourceName: (workflowData.name as string) || undefined, description: `Deployed workflow "${(workflowData.name as string) || workflowId}"`, - metadata: { version: deploymentVersionId }, + metadata: { + deploymentVersionId, + version: deployResult.version, + previousVersionId: previousVersionId || undefined, + triggerWarnings: triggerSaveResult.warnings?.length ? triggerSaveResult.warnings : undefined, + }, request, }) @@ -473,7 +478,12 @@ export async function performActivateVersion( resourceType: AuditResourceType.WORKFLOW, resourceId: workflowId, description: `Activated deployment version ${version}`, - metadata: { version }, + resourceName: (workflow.name as string) || undefined, + metadata: { + version, + deploymentVersionId: versionRow.id, + previousVersionId: previousVersionId || undefined, + }, }) return { diff --git a/bun.lock b/bun.lock index f8bde9a6cf3..e05bc532f5e 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "simstudio", diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 0b4a46d9deb..a36f182738c 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -18,10 +18,13 @@ export const auditMock = { PERSONAL_API_KEY_CREATED: 'personal_api_key.created', PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked', BYOK_KEY_CREATED: 'byok_key.created', + BYOK_KEY_UPDATED: 'byok_key.updated', BYOK_KEY_DELETED: 'byok_key.deleted', CHAT_DEPLOYED: 'chat.deployed', CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + CREDENTIAL_CREATED: 'credential.created', + CREDENTIAL_UPDATED: 'credential.updated', CREDENTIAL_DELETED: 'credential.deleted', CREDENTIAL_RENAMED: 'credential.renamed', CREDIT_PURCHASED: 'credit.purchased', @@ -43,6 +46,7 @@ export const auditMock = { DOCUMENT_UPDATED: 'document.updated', DOCUMENT_DELETED: 'document.deleted', ENVIRONMENT_UPDATED: 'environment.updated', + ENVIRONMENT_DELETED: 'environment.deleted', FILE_UPLOADED: 'file.uploaded', FILE_UPDATED: 'file.updated', FILE_DELETED: 'file.deleted', @@ -55,6 +59,7 @@ export const auditMock = { FORM_UPDATED: 'form.updated', FORM_DELETED: 'form.deleted', INVITATION_ACCEPTED: 'invitation.accepted', + INVITATION_RESENT: 'invitation.resent', INVITATION_REVOKED: 'invitation.revoked', CONNECTOR_CREATED: 'connector.created', CONNECTOR_UPDATED: 'connector.updated', @@ -75,6 +80,7 @@ export const auditMock = { NOTIFICATION_DELETED: 'notification.deleted', OAUTH_DISCONNECTED: 'oauth.disconnected', PASSWORD_RESET: 'password.reset', + PASSWORD_RESET_REQUESTED: 'password.reset_requested', ORGANIZATION_CREATED: 'organization.created', ORGANIZATION_UPDATED: 'organization.updated', ORG_MEMBER_ADDED: 'org_member.added', @@ -85,12 +91,15 @@ export const auditMock = { ORG_INVITATION_REJECTED: 'org_invitation.rejected', ORG_INVITATION_CANCELLED: 'org_invitation.cancelled', ORG_INVITATION_REVOKED: 'org_invitation.revoked', + ORG_INVITATION_RESENT: 'org_invitation.resent', PERMISSION_GROUP_CREATED: 'permission_group.created', PERMISSION_GROUP_UPDATED: 'permission_group.updated', PERMISSION_GROUP_DELETED: 'permission_group.deleted', PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added', PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed', + SCHEDULE_CREATED: 'schedule.created', SCHEDULE_UPDATED: 'schedule.updated', + SCHEDULE_DELETED: 'schedule.deleted', SKILL_CREATED: 'skill.created', SKILL_UPDATED: 'skill.updated', SKILL_DELETED: 'skill.deleted', @@ -115,6 +124,7 @@ export const auditMock = { WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted', WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated', WORKSPACE_CREATED: 'workspace.created', + WORKSPACE_UPDATED: 'workspace.updated', WORKSPACE_DELETED: 'workspace.deleted', WORKSPACE_DUPLICATED: 'workspace.duplicated', }, @@ -124,6 +134,7 @@ export const auditMock = { BYOK_KEY: 'byok_key', CHAT: 'chat', CONNECTOR: 'connector', + CREDENTIAL: 'credential', CREDENTIAL_SET: 'credential_set', CUSTOM_TOOL: 'custom_tool', DOCUMENT: 'document',