Compare commits

..

10 Commits

Author SHA1 Message Date
waleed
cb58ccbffe fix(audit-log): resolve userName/userEmail for JWT and API key auth paths 2026-02-19 16:02:53 -08:00
waleed
aeeead0b44 improvement(audit-log): add resourceName to credential set invitation accept 2026-02-18 11:50:29 -08:00
waleed
767ba42625 fix(audit-log): add missing actorName/actorEmail to workflow duplicate 2026-02-18 11:45:47 -08:00
waleed
affcdfb126 improvement(audit-log): use better-auth callback for password reset audit, remove cast
- Move password reset audit to onPasswordReset callback in auth config
  instead of coupling to better-auth's verification table internals
- Remove ugly double-cast on workflowData.workspaceId in deployment activation
2026-02-18 11:38:49 -08:00
waleed
5b88698561 fix(audit-log): add workspaceId to deployment activation audit 2026-02-18 11:35:59 -08:00
waleed
b0aca7cd80 fix(audit-log): resolve user for password reset, add CREDENTIAL_SET_INVITATION_RESENT action 2026-02-18 11:12:51 -08:00
waleed
241e56e00c improvement(audit-log): add actorName/actorEmail to all recordAudit calls 2026-02-18 11:08:45 -08:00
waleed
acd5d9d7e1 feat(audit-log): add audit events for templates, billing, credentials, env, deployments, passwords 2026-02-18 10:55:59 -08:00
Waleed
e37b4a926d feat(audit-log): add persistent audit log system with comprehensive route instrumentation (#3242)
* feat(audit-log): add persistent audit log system with comprehensive route instrumentation

* fix(audit-log): address PR review — nullable workspaceId, enum usage, remove redundant queries

- Make audit_log.workspace_id nullable with ON DELETE SET NULL (logs survive workspace/user deletion)
- Make audit_log.actor_id nullable with ON DELETE SET NULL
- Replace all 53 routes' string literal action/resourceType with AuditAction.X and AuditResourceType.X enums
- Fix empty workspaceId ('') → null for OAuth, form, and org routes to avoid FK violations
- Remove redundant DB queries in chat manage route (use checkChatAccess return data)
- Fix organization routes to pass workspaceId: null instead of organizationId

* fix(audit-log): replace remaining workspaceId '' fallbacks with null

* fix(audit-log): credential-set org IDs, workspace deletion FK, actorId fallback, string literal action

* reran migrations

* fix(mcp,audit): tighten env var domain bypass, add post-resolution check, form workspaceId

- Only bypass MCP domain check when env var is in hostname/authority, not path/query
- Add post-resolution validateMcpDomain call in test-connection endpoint
- Match client-side isDomainAllowed to same hostname-only bypass logic
- Return workspaceId from checkFormAccess, use in form audit logs
- Add 49 comprehensive domain-check tests covering all edge cases

* fix(mcp): stateful regex lastIndex bug, RFC 3986 authority parsing

- Remove /g flag from module-level ENV_VAR_PATTERN to avoid lastIndex state
- Create fresh regex instances per call in server-side hasEnvVarInHostname
- Fix authority extraction to terminate at /, ?, or # per RFC 3986
- Prevents bypass via https://evil.com?token={{SECRET}} (no path)
- Add test cases for query-only and fragment-only env var URLs (53 total)

* fix(audit-log): try/catch for never-throw contract, accept null actorName/Email, fix misleading action

- Wrap recordAudit body in try/catch so nanoid() or header extraction can't throw
- Accept string | null for actorName and actorEmail (session.user.name can be null)
- Normalize null -> undefined before insert to match DB column types
- Fix org members route: ORG_MEMBER_ADDED -> ORG_INVITATION_CREATED (sends invite, not adds member)

* improvement(audit-log): add resource names and specific invitation actions

* fix(audit-log): use validated chat record, add mock sync tests
2026-02-18 00:54:52 -08:00
Waleed
11f3a14c02 fix(lock): prevent socket crash when locking agent blocks (#3245) 2026-02-18 00:32:09 -08:00
38 changed files with 228 additions and 54 deletions

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
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 { getCreditBalance } from '@/lib/billing/credits/balance'
import { purchaseCredits } from '@/lib/billing/credits/purchase'
@@ -57,6 +58,17 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: result.error }, { status: 400 })
}
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDIT_PURCHASED,
resourceType: AuditResourceType.BILLING,
description: `Purchased $${validation.data.amount} in credits`,
metadata: { amount: validation.data.amount, requestId: validation.data.requestId },
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to purchase credits', { error, userId: session.user.id })

View File

@@ -292,8 +292,8 @@ export async function DELETE(
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord?.title,
description: `Deleted chat deployment "${chatRecord?.title}"`,
resourceName: chatRecord?.title || chatId,
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
request: _request,
})

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 { getEmailSubject, renderPollingGroupInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -148,6 +149,19 @@ export async function POST(
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_SET_INVITATION_RESENT,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: id,
resourceName: result.set.name,
description: `Resent credential set invitation to ${invitation.email}`,
metadata: { invitationId, email: invitation.email },
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error resending invitation', error)

View File

@@ -258,7 +258,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Revoked an invitation for credential set "${result.set.name}"`,
description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`,
request: req,
})

View File

@@ -8,6 +8,7 @@ import {
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -78,6 +79,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
status: credentialSetInvitation.status,
expiresAt: credentialSetInvitation.expiresAt,
invitedBy: credentialSetInvitation.invitedBy,
credentialSetName: credentialSet.name,
providerId: credentialSet.providerId,
})
.from(credentialSetInvitation)
@@ -125,7 +127,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
const now = new Date()
const requestId = crypto.randomUUID().slice(0, 8)
// Use transaction to ensure membership + invitation update + webhook sync are atomic
await db.transaction(async (tx) => {
await tx.insert(credentialSetMember).values({
id: crypto.randomUUID(),
@@ -147,8 +148,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
})
.where(eq(credentialSetInvitation.id, invitation.id))
// Clean up all other pending invitations for the same credential set and email
// This prevents duplicate invites from showing up after accepting one
if (invitation.email) {
await tx
.update(credentialSetInvitation)
@@ -166,7 +165,6 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
)
}
// Sync webhooks within the transaction
const syncResult = await syncAllWebhooksForCredentialSet(
invitation.credentialSetId,
requestId,
@@ -184,6 +182,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_SET_INVITATION_ACCEPTED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: invitation.credentialSetId,
resourceName: invitation.credentialSetName,
description: `Accepted credential set invitation`,
metadata: { invitationId: invitation.id },
request: req,
})
return NextResponse.json({
success: true,
credentialSetId: invitation.credentialSetId,

View File

@@ -3,6 +3,7 @@ import { credentialSet, credentialSetMember, organization } from '@sim/db/schema
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -106,6 +107,17 @@ export async function DELETE(req: NextRequest) {
userId: session.user.id,
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CREDENTIAL_SET_MEMBER_LEFT,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: credentialSetId,
description: `Left credential set`,
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to leave credential set'

View File

@@ -4,6 +4,7 @@ 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 { getSession } from '@/lib/auth'
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -53,6 +54,17 @@ export async function POST(req: NextRequest) {
},
})
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.ENVIRONMENT_UPDATED,
resourceType: AuditResourceType.ENVIRONMENT,
description: 'Updated global environment variables',
metadata: { variableCount: Object.keys(variables).length },
request: req,
})
return NextResponse.json({ success: true })
} catch (validationError) {
if (validationError instanceof z.ZodError) {

View File

@@ -205,7 +205,7 @@ export async function POST(request: NextRequest) {
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: title,
description: `Created form "${title}" for workflow "${workflowRecord.name}"`,
description: `Created form "${title}" for workflow ${workflowId}`,
request,
})

View File

@@ -207,7 +207,7 @@ export async function PUT(
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: validatedData.filename ?? accessCheck.document?.filename,
description: `Updated document "${validatedData.filename ?? accessCheck.document?.filename}" in knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`,
request: req,
})
@@ -280,7 +280,7 @@ export async function DELETE(
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: accessCheck.document?.filename,
description: `Deleted document "${accessCheck.document?.filename}" from knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`,
request: req,
})

View File

@@ -254,7 +254,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: `${createdDocuments.length} document(s)`,
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`,
request: req,
})
@@ -315,7 +315,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: validatedData.filename,
description: `Uploaded document "${validatedData.filename}" to knowledge base "${accessCheck.knowledgeBase?.name}"`,
description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`,
request: req,
})

View File

@@ -220,7 +220,7 @@ export async function DELETE(
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: accessCheck.knowledgeBase.name,
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name}"`,
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`,
request: _request,
})

View File

@@ -99,8 +99,8 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedServer.name,
description: `Updated MCP server "${updatedServer.name}"`,
resourceName: updatedServer.name || serverId,
description: `Updated MCP server "${updatedServer.name || serverId}"`,
request,
})

View File

@@ -131,7 +131,6 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedTool.toolName,
description: `Updated tool "${updatedTool.toolName}" in MCP server`,
metadata: { toolId, toolName: updatedTool.toolName },
request,
@@ -196,7 +195,6 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: deletedTool.toolName,
description: `Removed tool "${deletedTool.toolName}" from MCP server`,
metadata: { toolId, toolName: deletedTool.toolName },
request,

View File

@@ -210,7 +210,6 @@ export const POST = withMcpAuth<RouteParams>('write')(
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: toolName,
description: `Added tool "${toolName}" to MCP server`,
metadata: { toolId, toolName, workflowId: body.workflowId },
request,

View File

@@ -567,7 +567,6 @@ export async function PUT(
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: orgInvitation.email,
description: `Organization invitation ${status} for ${orgInvitation.email}`,
metadata: { invitationId, email: orgInvitation.email, status },
request: req,

View File

@@ -557,7 +557,6 @@ export async function DELETE(
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result[0].email,
description: `Revoked organization invitation for ${result[0].email}`,
metadata: { invitationId, email: result[0].email },
request,

View File

@@ -222,7 +222,7 @@ export async function PUT(
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed member role to ${role}`,
description: `Changed role for member ${memberId} to ${role}`,
metadata: { targetUserId: memberId, newRole: role },
request,
})
@@ -330,7 +330,7 @@ export async function DELETE(
description:
session.user.id === targetUserId
? 'Left the organization'
: 'Removed a member from the organization',
: `Removed member ${targetUserId} from organization`,
metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId },
request,
})

View File

@@ -294,7 +294,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: normalizedEmail,
description: `Invited ${normalizedEmail} to organization as ${role}`,
metadata: { invitationId, email: normalizedEmail, role },
request,

View File

@@ -162,7 +162,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceName: result.group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Added a member to permission group "${result.group.name}"`,
description: `Added member ${userId} to permission group "${result.group.name}"`,
metadata: { targetUserId: userId, permissionGroupId: id },
request: req,
})
@@ -246,7 +246,7 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
resourceName: result.group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Removed a member from permission group "${result.group.name}"`,
description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`,
metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id },
request: req,
})

View File

@@ -115,8 +115,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
resourceId: scheduleId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: authorization.workflow?.name,
description: `Reactivated schedule for workflow "${authorization.workflow?.name}"`,
description: `Reactivated schedule for workflow ${schedule.workflowId}`,
request,
})

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq, sql } 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 { generateRequestId } from '@/lib/core/utils/request'
import {
@@ -247,6 +248,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Successfully updated template: ${id}`)
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.TEMPLATE_UPDATED,
resourceType: AuditResourceType.TEMPLATE,
resourceId: id,
resourceName: name ?? template.name,
description: `Updated template "${name ?? template.name}"`,
request,
})
return NextResponse.json({
data: updatedTemplate[0],
message: 'Template updated successfully',
@@ -300,6 +313,19 @@ export async function DELETE(
await db.delete(templates).where(eq(templates.id, id))
logger.info(`[${requestId}] Deleted template: ${id}`)
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.TEMPLATE_DELETED,
resourceType: AuditResourceType.TEMPLATE,
resourceId: id,
resourceName: template.name,
description: `Deleted template "${template.name}"`,
request,
})
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting template: ${id}`, error)

View File

@@ -11,6 +11,7 @@ import { and, desc, eq, ilike, or, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { verifyEffectiveSuperUser } from '@/lib/templates/permissions'
@@ -285,6 +286,18 @@ export async function POST(request: NextRequest) {
logger.info(`[${requestId}] Successfully created template: ${templateId}`)
recordAudit({
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.TEMPLATE_CREATED,
resourceType: AuditResourceType.TEMPLATE,
resourceId: templateId,
resourceName: data.name,
description: `Created template "${data.name}"`,
request,
})
return NextResponse.json(
{
id: templateId,

View File

@@ -684,8 +684,8 @@ export async function POST(request: NextRequest) {
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
actorName: session?.user?.name ?? undefined,
actorEmail: session?.user?.email ?? undefined,
action: AuditAction.WEBHOOK_CREATED,
resourceType: AuditResourceType.WEBHOOK,
resourceId: savedWebhook.id,

View File

@@ -268,7 +268,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Deployed workflow "${workflowData.name}"`,
description: `Deployed workflow "${workflowData?.name || id}"`,
request,
})
@@ -348,7 +348,7 @@ export async function DELETE(
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Undeployed workflow "${workflowData.name}"`,
description: `Undeployed workflow "${workflowData?.name || id}"`,
request,
})

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import { restorePreviousVersionWebhooks, saveTriggerWebhooksForDeploy } from '@/lib/webhooks/deploy'
@@ -297,6 +298,19 @@ export async function PATCH(
}
}
recordAudit({
workspaceId: workflowData?.workspaceId,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYMENT_ACTIVATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
description: `Activated deployment version ${versionNum}`,
metadata: { version: versionNum },
request,
})
return createSuccessResponse({
success: true,
deployedAt: result.deployedAt,

View File

@@ -71,7 +71,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
resourceType: AuditResourceType.WORKFLOW,
resourceId: result.id,
resourceName: result.name,
description: `Duplicated workflow as "${result.name}"`,
description: `Duplicated workflow from ${sourceWorkflowId}`,
metadata: { sourceWorkflowId },
request: req,
})

View File

@@ -423,20 +423,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
updates: updateData,
})
recordAudit({
workspaceId: workflowData.workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_UPDATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: updatedWorkflow?.name ?? workflowData.name,
description: `Updated workflow "${updatedWorkflow?.name ?? workflowData.name}"`,
metadata: updates,
request,
})
return NextResponse.json({ workflow: updatedWorkflow }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime

View File

@@ -264,7 +264,6 @@ export async function DELETE(
actorEmail: session?.user?.email,
action: AuditAction.BYOK_KEY_DELETED,
resourceType: AuditResourceType.BYOK_KEY,
resourceId: providerId,
resourceName: providerId,
description: `Removed BYOK key for ${providerId}`,
metadata: { providerId },

View File

@@ -165,7 +165,6 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
action: AuditAction.ENVIRONMENT_UPDATED,
resourceType: AuditResourceType.ENVIRONMENT,
resourceId: workspaceId,
resourceName: 'Environment Variables',
description: `Updated environment variables`,
metadata: { keysUpdated: Object.keys(variables) },
request,

View File

@@ -166,7 +166,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
resourceId: workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed workspace permissions to ${update.permissions}`,
description: `Changed permissions for user ${update.userId} to ${update.permissions}`,
metadata: { targetUserId: update.userId, newPermissions: update.permissions },
request,
})

View File

@@ -298,7 +298,7 @@ export async function DELETE(
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
resourceName: workspaceRecord?.name,
description: `Deleted workspace "${workspaceRecord?.name}"`,
description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`,
request,
})

View File

@@ -238,7 +238,6 @@ export async function DELETE(
resourceId: invitation.workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: invitation.email,
description: `Revoked workspace invitation for ${invitation.email}`,
metadata: { invitationId, email: invitation.email },
request: _request,

View File

@@ -618,6 +618,15 @@ export function Editor() {
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
)}
{hasAdvancedOnlyFields && !canEditBlock && displayAdvancedOptions && (
<div className='flex items-center gap-[10px] px-[2px] pt-[14px] pb-[12px]'>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
<span className='whitespace-nowrap font-medium text-[13px] text-[var(--text-secondary)]'>
Additional fields
</span>
<div className='h-[1.25px] flex-1' style={DASHED_DIVIDER_STYLE} />
</div>
)}
{advancedOnlySubBlocks.map((subBlock, index) => {
const stableKey = getSubBlockStableKey(

View File

@@ -24,12 +24,18 @@ export const AuditAction = {
CHAT_UPDATED: 'chat.updated',
CHAT_DELETED: 'chat.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',
// Documents
@@ -81,6 +87,9 @@ export const AuditAction = {
// OAuth
OAUTH_DISCONNECTED: 'oauth.disconnected',
// Password
PASSWORD_RESET: 'password.reset',
// Organizations
ORGANIZATION_CREATED: 'organization.created',
ORGANIZATION_UPDATED: 'organization.updated',
@@ -103,17 +112,22 @@ export const AuditAction = {
// Schedules
SCHEDULE_UPDATED: 'schedule.updated',
// 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_UPDATED: 'workflow.updated',
WORKFLOW_DELETED: 'workflow.deleted',
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_VARIABLES_UPDATED: 'workflow.variables_updated',
@@ -130,6 +144,7 @@ export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction]
*/
export const AuditResourceType = {
API_KEY: 'api_key',
BILLING: 'billing',
BYOK_KEY: 'byok_key',
CHAT: 'chat',
CREDENTIAL_SET: 'credential_set',
@@ -143,8 +158,10 @@ export const AuditResourceType = {
NOTIFICATION: 'notification',
OAUTH: 'oauth',
ORGANIZATION: 'organization',
PASSWORD: 'password',
PERMISSION_GROUP: 'permission_group',
SCHEDULE: 'schedule',
TEMPLATE: 'template',
WEBHOOK: 'webhook',
WORKFLOW: 'workflow',
WORKSPACE: 'workspace',

View File

@@ -483,6 +483,17 @@ export const auth = betterAuth({
throw new Error(`Failed to send reset password email: ${result.message}`)
}
},
onPasswordReset: async ({ user: resetUser }) => {
const { AuditAction, AuditResourceType, recordAudit } = await import('@/lib/audit/log')
recordAudit({
actorId: resetUser.id,
actorName: resetUser.name,
actorEmail: resetUser.email,
action: AuditAction.PASSWORD_RESET,
resourceType: AuditResourceType.PASSWORD,
description: 'Password reset completed',
})
},
},
hooks: {
before: createAuthMiddleware(async (ctx) => {

View File

@@ -1,4 +1,7 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
import { getSession } from '@/lib/auth'
@@ -16,6 +19,25 @@ export interface AuthResult {
error?: string
}
/**
* Looks up a user's name and email by ID. Returns empty values on failure
* so auth is never blocked by a lookup error.
*/
async function lookupUserInfo(
userId: string
): Promise<{ userName: string | null; userEmail: string | null }> {
try {
const [row] = await db
.select({ name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
return { userName: row?.name ?? null, userEmail: row?.email ?? null }
} catch {
return { userName: null, userEmail: null }
}
}
/**
* Resolves userId from a verified internal JWT token.
* Extracts userId from the JWT payload, URL search params, or POST body.
@@ -46,7 +68,8 @@ async function resolveUserFromJwt(
}
if (userId) {
return { success: true, userId, authType: 'internal_jwt' }
const { userName, userEmail } = await lookupUserInfo(userId)
return { success: true, userId, userName, userEmail, authType: 'internal_jwt' }
}
if (options.requireWorkflowId !== false) {
@@ -205,9 +228,12 @@ export async function checkHybridAuth(
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
if (result.success) {
await updateApiKeyLastUsed(result.keyId!)
const { userName, userEmail } = await lookupUserInfo(result.userId!)
return {
success: true,
userId: result.userId!,
userName,
userEmail,
authType: 'api_key',
apiKeyType: result.keyType,
}

View File

@@ -232,6 +232,7 @@ async function flushSubblockUpdate(
}
let updateSuccessful = false
let blockLocked = false
await db.transaction(async (tx) => {
const [block] = await tx
.select({
@@ -250,6 +251,7 @@ async function flushSubblockUpdate(
// Check if block is locked directly
if (block.locked) {
logger.info(`Skipping subblock update - block ${blockId} is locked`)
blockLocked = true
return
}
@@ -266,6 +268,7 @@ async function flushSubblockUpdate(
if (parentBlock?.locked) {
logger.info(`Skipping subblock update - parent ${parentId} is locked`)
blockLocked = true
return
}
}
@@ -308,6 +311,13 @@ async function flushSubblockUpdate(
serverTimestamp: Date.now(),
})
})
} else if (blockLocked) {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-confirmed', {
operationId: opId,
serverTimestamp: Date.now(),
})
})
} else {
pending.opToSocket.forEach((socketId, opId) => {
io.to(socketId).emit('operation-failed', {

View File

@@ -22,11 +22,15 @@ export const auditMock = {
CHAT_DEPLOYED: 'chat.deployed',
CHAT_UPDATED: 'chat.updated',
CHAT_DELETED: 'chat.deleted',
CREDIT_PURCHASED: 'credit.purchased',
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',
DOCUMENT_UPLOADED: 'document.uploaded',
DOCUMENT_UPDATED: 'document.updated',
@@ -55,6 +59,7 @@ export const auditMock = {
NOTIFICATION_UPDATED: 'notification.updated',
NOTIFICATION_DELETED: 'notification.deleted',
OAUTH_DISCONNECTED: 'oauth.disconnected',
PASSWORD_RESET: 'password.reset',
ORGANIZATION_CREATED: 'organization.created',
ORGANIZATION_UPDATED: 'organization.updated',
ORG_MEMBER_ADDED: 'org_member.added',
@@ -71,14 +76,17 @@ export const auditMock = {
PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added',
PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed',
SCHEDULE_UPDATED: 'schedule.updated',
TEMPLATE_CREATED: 'template.created',
TEMPLATE_UPDATED: 'template.updated',
TEMPLATE_DELETED: 'template.deleted',
WEBHOOK_CREATED: 'webhook.created',
WEBHOOK_DELETED: 'webhook.deleted',
WORKFLOW_CREATED: 'workflow.created',
WORKFLOW_UPDATED: 'workflow.updated',
WORKFLOW_DELETED: 'workflow.deleted',
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_VARIABLES_UPDATED: 'workflow.variables_updated',
WORKSPACE_CREATED: 'workspace.created',
@@ -87,6 +95,7 @@ export const auditMock = {
},
AuditResourceType: {
API_KEY: 'api_key',
BILLING: 'billing',
BYOK_KEY: 'byok_key',
CHAT: 'chat',
CREDENTIAL_SET: 'credential_set',
@@ -100,8 +109,10 @@ export const auditMock = {
NOTIFICATION: 'notification',
OAUTH: 'oauth',
ORGANIZATION: 'organization',
PASSWORD: 'password',
PERMISSION_GROUP: 'permission_group',
SCHEDULE: 'schedule',
TEMPLATE: 'template',
WEBHOOK: 'webhook',
WORKFLOW: 'workflow',
WORKSPACE: 'workspace',