feat(ee): add enterprise audit logs settings page (#4111)

* feat(ee): add enterprise audit logs settings page with server-side search

Add a new audit logs page under enterprise settings that displays all
actions captured via recordAudit. Includes server-side search, resource
type filtering, date range selection, and cursor-based pagination.

- Add internal API route (app/api/audit-logs) with session auth
- Extract shared query logic (buildFilterConditions, buildOrgScopeCondition,
  queryAuditLogs) into app/api/v1/audit-logs/query.ts
- Refactor v1 and admin audit log routes to use shared query module
- Add React Query hook with useInfiniteQuery and cursor pagination
- Add audit logs UI with debounced search, combobox filters, expandable rows
- Gate behind requiresHosted + requiresEnterprise navigation flags
- Place all enterprise audit log code in ee/audit-logs/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(ee): fix build error and address PR review comments

- Fix import path: @/lib/utils → @/lib/core/utils/cn
- Guard against empty orgMemberIds array in buildOrgScopeCondition
- Skip debounce effect on mount when search is already synced

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* fix(ee): fix type error with unknown metadata in JSX expression

Use ternary instead of && chain to prevent unknown type from being
returned as ReactNode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ee): align skeleton filter width with actual component layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* lint

* feat(audit): add audit logging for passwords, credentials, and schedules

- Add PASSWORD_RESET_REQUESTED audit on forget-password with user lookup
- Add CREDENTIAL_CREATED/UPDATED/DELETED audit on credential CRUD routes
  with metadata (credentialType, providerId, updatedFields, envKey)
- Add SCHEDULE_CREATED audit on schedule creation with cron/timezone metadata
- Fix SCHEDULE_DELETED (was incorrectly using SCHEDULE_UPDATED for deletes)
- Enhance existing schedule update/disable/reactivate audit with structured
  metadata (operation, updatedFields, sourceType, previousStatus)
- Add CREDENTIAL resource type and Credential filter option to audit logs UI
- Enhance password reset completed description with user email

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(audit): align metadata with established recordAudit patterns

- Add actorName/actorEmail to all new credential and schedule audit calls
  to match the established pattern (e.g., api-keys, byok-keys, knowledge)
- Add resourceId and resourceName to forget-password audit call
- Enhance forget-password description with user email

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(testing): sync audit mock with new AuditAction and AuditResourceType entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(audit-logs): derive resource type filter from AuditResourceType

Instead of maintaining a separate hardcoded list, the filter dropdown
now derives its options directly from the AuditResourceType const object.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(audit): enrich all recordAudit calls with structured metadata

- Move resource type filter options to ee/audit-logs/constants.ts
  (derived from AuditResourceType, no separate list to maintain)
- Remove export from internal cursor helpers in query.ts
- Add 5 new AuditAction entries: BYOK_KEY_UPDATED, ENVIRONMENT_DELETED,
  INVITATION_RESENT, WORKSPACE_UPDATED, ORG_INVITATION_RESENT
- Enrich ~80 recordAudit calls across the codebase with structured
  metadata (knowledge bases, connectors, documents, workspaces, members,
  invitations, workflows, deployments, templates, MCP servers, credential
  sets, organizations, permission groups, files, tables, notifications,
  copilot operations)
- Sync audit mock with all new entries

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(audit): remove redundant metadata fields duplicating top-level audit fields

Remove metadata entries that duplicate resourceName, workspaceId, or
other top-level recordAudit fields. Also remove noisy fileNames arrays
from bulk document upload audits (kept fileCount).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(audit): split audit types from server-only log module

Extract AuditAction, AuditResourceType, and their types into
lib/audit/types.ts (client-safe, no @sim/db dependency). The
server-only recordAudit stays in log.ts and re-exports the types
for backwards compatibility. constants.ts now imports from types.ts
directly, breaking the postgres -> tls client bundle chain.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(audit): escape LIKE wildcards in audit log search query

Escape %, _, and \ characters in the search parameter before embedding
in the LIKE pattern to prevent unintended broad matches.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(audit): use actual deletedCount in bulk API key revoke description

The description was using keys.length (requested count) instead of
deletedCount (actual count), which could differ if some keys didn't
exist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(audit-logs): fix OAuth label displaying as "Oauth" in filter dropdown

ACRONYMS set stored 'OAuth' but lookup used toUpperCase() producing
'OAUTH' which never matched. Now store all acronyms uppercase and use
a display override map for special casing like OAuth.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Waleed
2026-04-11 16:15:48 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6a4f5f2074
commit 30c5e82ab0
94 changed files with 1592 additions and 386 deletions
+71
View File
@@ -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 })
}
}
@@ -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 })
+5 -1
View File
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -116,6 +116,7 @@ export async function DELETE(req: NextRequest) {
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: credentialSetId,
description: `Left credential set`,
metadata: { credentialSetId },
request: req,
})
@@ -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,
})
@@ -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)
+18
View File
@@ -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') {
+7 -2
View File
@@ -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,
})
+7 -1
View File
@@ -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,
})
+9 -2
View File
@@ -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,
})
+1
View File
@@ -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,
})
@@ -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,
})
@@ -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,
},
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
@@ -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,
},
@@ -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,
})
+17
View File
@@ -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,
})
+10 -1
View File
@@ -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,
})
@@ -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,
})
+14 -1
View File
@@ -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,
})
@@ -135,6 +135,11 @@ export const PATCH = withMcpAuth<RouteParams>('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<RouteParams>('admin')(
resourceId: serverId,
resourceName: deletedServer.name,
description: `Unpublished workflow MCP server "${deletedServer.name}"`,
metadata: { serverName: deletedServer.name },
request,
})
@@ -152,7 +152,12 @@ export const PATCH = withMcpAuth<RouteParams>('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<RouteParams>('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,
})
@@ -224,7 +224,13 @@ export const POST = withMcpAuth<RouteParams>('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,
})
@@ -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,
})
@@ -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',
@@ -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,
})
@@ -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,
+1
View File
@@ -126,6 +126,7 @@ export async function POST(request: Request) {
actorEmail: user.email ?? undefined,
resourceName: organizationName ?? undefined,
description: `Created organization "${organizationName}"`,
metadata: { organizationSlug },
request,
})
@@ -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,
})
@@ -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,
})
+39 -17
View File
@@ -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,
})
+20
View File
@@ -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',
+6
View File
@@ -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(
@@ -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,
})
+16
View File
@@ -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,
})
+8
View File
@@ -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,
})
+8 -1
View File
@@ -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}`)
+16 -21
View File
@@ -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<unknown>[] = []
if (actionFilter) conditions.push(eq(auditLog.action, actionFilter))
if (resourceTypeFilter) conditions.push(eq(auditLog.resourceType, resourceTypeFilter))
if (resourceIdFilter) conditions.push(eq(auditLog.resourceId, resourceIdFilter))
if (workspaceIdFilter) conditions.push(eq(auditLog.workspaceId, workspaceIdFilter))
if (actorIdFilter) conditions.push(eq(auditLog.actorId, actorIdFilter))
if (actorEmailFilter) conditions.push(eq(auditLog.actorEmail, actorEmailFilter))
if (startDateFilter) conditions.push(gte(auditLog.createdAt, new Date(startDateFilter)))
if (endDateFilter) conditions.push(lte(auditLog.createdAt, new Date(endDateFilter)))
const 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
+146
View File
@@ -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<typeof auditLog>
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<unknown>[] {
const conditions: SQL<unknown>[] = []
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<SQL<unknown>> {
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<unknown> | 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<unknown>[],
limit: number,
cursor?: string
): Promise<CursorPaginatedResult> {
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 }
}
+20 -84
View File
@@ -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<unknown>
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,
})
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<unknown>[] = [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 { data, nextCursor } = await queryAuditLogs(
[scopeCondition, ...filterConditions],
params.limit,
params.cursor
)
const formattedLogs = data.map(formatAuditLogEntry)
@@ -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,
})
+1
View File
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
})
+1
View File
@@ -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,
})
+1
View File
@@ -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,
})
+8 -2
View File
@@ -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,
})
+6 -1
View File
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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,
})
+8 -1
View File
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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: {
@@ -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)
@@ -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,
})
@@ -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,
})
@@ -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,
})
@@ -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<string, unknown>)[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,
})
@@ -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,
})
@@ -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,
})
+31
View File
@@ -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,
@@ -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)
@@ -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,
})
@@ -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,
})
+1 -1
View File
@@ -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,
})
@@ -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: () => <SettingsSectionSkeleton /> }
)
const AuditLogs = dynamic(
() => import('@/ee/audit-logs/components/audit-logs').then((m) => m.AuditLogs),
{ loading: () => <AuditLogsSkeleton /> }
)
const SSO = dynamic(() => import('@/ee/sso/components/sso-settings').then((m) => m.SSO), {
loading: () => <SettingsSectionSkeleton />,
})
@@ -201,6 +206,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{/* {effectiveSection === 'template-profile' && <TemplateProfile />} */}
{effectiveSection === 'credential-sets' && <CredentialSets />}
{effectiveSection === 'access-control' && <AccessControl />}
{effectiveSection === 'audit-logs' && <AuditLogs />}
{effectiveSection === 'apikeys' && <ApiKeys />}
{isBillingEnabled && effectiveSection === 'subscription' && <Subscription />}
{isBillingEnabled && effectiveSection === 'team' && <TeamManagement />}
@@ -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',
@@ -0,0 +1,27 @@
import { Skeleton } from '@/components/emcn'
export function AuditLogsSkeleton() {
return (
<div className='flex h-full flex-col gap-4.5'>
<div className='flex items-center gap-2'>
<Skeleton className='h-[38px] flex-1 rounded-lg' />
<Skeleton className='h-[38px] w-[160px] rounded-lg' />
<Skeleton className='h-[38px] w-[140px] rounded-lg' />
</div>
<div className='flex items-center gap-4 border-[var(--border)] border-b pb-2'>
<Skeleton className='h-4 w-[140px]' />
<Skeleton className='h-4 w-[120px]' />
<Skeleton className='h-4 flex-1' />
<Skeleton className='h-4 w-[140px]' />
</div>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className='flex items-center gap-4'>
<Skeleton className='h-4 w-[140px]' />
<Skeleton className='h-4 w-[120px]' />
<Skeleton className='h-4 flex-1' />
<Skeleton className='h-4 w-[140px]' />
</div>
))}
</div>
)
}
@@ -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 (
<Badge variant={variant} size='sm'>
{formatAction(action)}
</Badge>
)
}
interface AuditLogRowProps {
entry: EnterpriseAuditLogEntry
}
function AuditLogRow({ entry }: AuditLogRowProps) {
const [expanded, setExpanded] = useState(false)
const timestamp = formatDateTime(new Date(entry.createdAt))
return (
<div className='border-[var(--border)] border-b last:border-b-0'>
<button
type='button'
className='flex w-full items-center gap-4 px-0 py-2.5 text-left transition-colors hover-hover:bg-[var(--surface-4)]'
onClick={() => setExpanded(!expanded)}
>
<span className='w-[160px] flex-shrink-0 text-[var(--text-secondary)] text-sm'>
{timestamp}
</span>
<span className='w-[180px] flex-shrink-0'>
<ActionBadge action={entry.action} />
</span>
<span className='min-w-0 flex-1 truncate text-[var(--text-primary)] text-sm'>
{entry.description || entry.resourceName || entry.resourceId || '-'}
</span>
<span className='w-[160px] flex-shrink-0 truncate text-right text-[var(--text-secondary)] text-sm'>
{entry.actorEmail || entry.actorName || 'System'}
</span>
</button>
{expanded && (
<div className='mb-2 ml-0 flex flex-col gap-1.5 rounded-md bg-[var(--surface-4)] p-3 text-sm'>
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Resource</span>
<span className='text-[var(--text-primary)]'>
{formatResourceType(entry.resourceType)}
{entry.resourceId && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.resourceId})</span>
)}
</span>
</div>
{entry.resourceName && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Name</span>
<span className='text-[var(--text-primary)]'>{entry.resourceName}</span>
</div>
)}
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Actor</span>
<span className='text-[var(--text-primary)]'>
{entry.actorName || 'Unknown'}
{entry.actorEmail && (
<span className='ml-1 text-[var(--text-muted)]'>({entry.actorEmail})</span>
)}
</span>
</div>
{entry.description && (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Description</span>
<span className='text-[var(--text-primary)]'>{entry.description}</span>
</div>
)}
{entry.metadata != null &&
Object.keys(entry.metadata as Record<string, unknown>).length > 0 ? (
<div className='flex gap-2'>
<span className='w-[100px] flex-shrink-0 text-[var(--text-muted)]'>Details</span>
<pre className='min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-[var(--text-secondary)] text-xs'>
{JSON.stringify(entry.metadata, null, 2)}
</pre>
</div>
) : null}
</div>
)}
</div>
)
}
export function AuditLogs() {
const [resourceType, setResourceType] = useState('')
const [dateRange, setDateRange] = useState('30')
const [searchTerm, setSearchTerm] = useState('')
const [debouncedSearch, setDebouncedSearch] = useState('')
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<AuditLogFilters>(() => {
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 (
<div className='flex h-full flex-col gap-4.5'>
<div className='flex items-center gap-2'>
<div className='flex flex-1 items-center gap-2 rounded-lg border border-[var(--border)] bg-transparent px-2 py-2 transition-colors duration-100 dark:bg-[var(--surface-4)] dark:hover-hover:border-[var(--border-1)] dark:hover-hover:bg-[var(--surface-5)]'>
<Search
className='h-[14px] w-[14px] flex-shrink-0 text-[var(--text-tertiary)]'
strokeWidth={2}
/>
<Input
placeholder='Search audit logs...'
value={searchTerm}
onChange={(e) => 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'
/>
</div>
<div className='w-[160px]'>
<Combobox
options={RESOURCE_TYPE_OPTIONS}
value={resourceType}
onChange={setResourceType}
placeholder='Resource type'
size='md'
/>
</div>
<div className='w-[140px]'>
<Combobox
options={DATE_RANGE_OPTIONS}
value={dateRange}
onChange={setDateRange}
placeholder='Date range'
size='md'
/>
</div>
<Button variant='ghost' onClick={handleRefresh} disabled={isRefetching}>
<RefreshCw
className={cn('h-[14px] w-[14px]', isRefetching && 'animate-spin')}
strokeWidth={2}
/>
</Button>
</div>
<div className='flex items-center gap-4 border-[var(--border)] border-b pb-2'>
<span className='w-[160px] flex-shrink-0 font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Timestamp
</span>
<span className='w-[180px] flex-shrink-0 font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Event
</span>
<span className='min-w-0 flex-1 font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Description
</span>
<span className='w-[160px] flex-shrink-0 text-right font-medium text-[var(--text-muted)] text-xs uppercase tracking-wide'>
Actor
</span>
</div>
<div className='min-h-0 flex-1 overflow-y-auto'>
{isLoading ? (
<div className='flex flex-col gap-3'>
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className='flex items-center gap-4 py-2.5'>
<Skeleton className='h-4 w-[140px]' />
<Skeleton className='h-5 w-[120px] rounded-full' />
<Skeleton className='h-4 flex-1' />
<Skeleton className='h-4 w-[140px]' />
</div>
))}
</div>
) : allEntries.length === 0 ? (
<div className='flex h-full items-center justify-center py-12 text-[var(--text-muted)] text-sm'>
{debouncedSearch ? `No results for "${debouncedSearch}"` : 'No audit logs found'}
</div>
) : (
<div className='flex flex-col'>
{allEntries.map((entry) => (
<AuditLogRow key={entry.id} entry={entry} />
))}
{hasNextPage && (
<div className='flex justify-center py-4'>
<Button variant='ghost' onClick={handleLoadMore} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading...' : 'Load more'}
</Button>
</div>
)}
</div>
)}
</div>
</div>
)
}
+24
View File
@@ -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<string, string> = { 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)),
]
@@ -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<AuditLogPage> {
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,
})
}
+4 -204
View File
@@ -2,215 +2,15 @@ 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'
export type { AuditActionType, AuditResourceTypeValue } from '@/lib/audit/types'
export { AuditAction, AuditResourceType } from '@/lib/audit/types'
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',
// 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]
interface AuditLogParams {
workspaceId?: string | null
actorId: string
+214
View File
@@ -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]
+2 -1
View File
@@ -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}`,
})
},
},
@@ -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 {
@@ -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 } }
@@ -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 } }
}
@@ -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 {
@@ -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 }
+12 -2
View File
@@ -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 {
+11
View File
@@ -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',