v0.5.93: NextJS config changes, MCP and Blocks whitelisting, copilot keyboard shortcuts, audit logs

This commit is contained in:
Waleed
2026-02-18 12:10:05 -08:00
committed by GitHub
126 changed files with 14416 additions and 240 deletions

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { createMockLogger, createMockRequest } from '@sim/testing'
import { auditMock, createMockLogger, createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('OAuth Disconnect API Route', () => {
@@ -67,6 +67,8 @@ describe('OAuth Disconnect API Route', () => {
vi.doMock('@/lib/webhooks/utils.server', () => ({
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
}))
vi.doMock('@/lib/audit/log', () => auditMock)
})
afterEach(() => {

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, like, or } 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 { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -118,6 +119,20 @@ export async function POST(request: NextRequest) {
}
}
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.OAUTH_DISCONNECTED,
resourceType: AuditResourceType.OAUTH,
resourceId: providerId ?? provider,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: provider,
description: `Disconnected OAuth provider: ${provider}`,
metadata: { provider, providerId },
request,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error) {
logger.error(`[${requestId}] Error disconnecting OAuth provider`, error)

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

@@ -3,10 +3,12 @@
*
* @vitest-environment node
*/
import { loggerMock } from '@sim/testing'
import { auditMock, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/core/config/feature-flags', () => ({
isDev: true,
isHosted: false,
@@ -216,8 +218,11 @@ describe('Chat Edit API Route', () => {
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
@@ -311,8 +316,11 @@ describe('Chat Edit API Route', () => {
workflowId: 'workflow-123',
}
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
mockLimit.mockResolvedValueOnce([])
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: mockChat,
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'PATCH',
@@ -371,8 +379,11 @@ describe('Chat Edit API Route', () => {
}),
}))
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',
@@ -393,8 +404,11 @@ describe('Chat Edit API Route', () => {
}),
}))
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
mockWhere.mockResolvedValue(undefined)
mockCheckChatAccess.mockResolvedValue({
hasAccess: true,
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
workspaceId: 'workspace-123',
})
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
method: 'DELETE',

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
@@ -103,7 +104,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
try {
const validatedData = chatUpdateSchema.parse(body)
const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
const {
hasAccess,
chat: existingChatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
if (!hasAccess || !existingChatRecord) {
return createErrorResponse('Chat not found or access denied', 404)
@@ -217,6 +222,19 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
logger.info(`Chat "${chatId}" updated successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_UPDATED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: title || existingChatRecord.title,
description: `Updated chat deployment "${title || existingChatRecord.title}"`,
request,
})
return createSuccessResponse({
id: chatId,
chatUrl,
@@ -252,7 +270,11 @@ export async function DELETE(
return createErrorResponse('Unauthorized', 401)
}
const { hasAccess } = await checkChatAccess(chatId, session.user.id)
const {
hasAccess,
chat: chatRecord,
workspaceId: chatWorkspaceId,
} = await checkChatAccess(chatId, session.user.id)
if (!hasAccess) {
return createErrorResponse('Chat not found or access denied', 404)
@@ -262,6 +284,19 @@ export async function DELETE(
logger.info(`Chat "${chatId}" deleted successfully`)
recordAudit({
workspaceId: chatWorkspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DELETED,
resourceType: AuditResourceType.CHAT,
resourceId: chatId,
resourceName: chatRecord?.title || chatId,
description: `Deleted chat deployment "${chatRecord?.title || chatId}"`,
request: _request,
})
return createSuccessResponse({
message: 'Chat deployment deleted successfully',
})

View File

@@ -1,9 +1,10 @@
import { NextRequest } from 'next/server'
/**
* Tests for chat API route
*
* @vitest-environment node
*/
import { auditMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('Chat API Route', () => {
@@ -30,6 +31,8 @@ describe('Chat API Route', () => {
mockInsert.mockReturnValue({ values: mockValues })
mockValues.mockReturnValue({ returning: mockReturning })
vi.doMock('@/lib/audit/log', () => auditMock)
vi.doMock('@sim/db', () => ({
db: {
select: mockSelect,

View File

@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
import type { NextRequest } 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 { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
@@ -42,7 +43,7 @@ const chatSchema = z.object({
.default([]),
})
export async function GET(request: NextRequest) {
export async function GET(_request: NextRequest) {
try {
const session = await getSession()
@@ -174,7 +175,7 @@ export async function POST(request: NextRequest) {
userId: session.user.id,
identifier,
title,
description: description || '',
description: description || null,
customizations: mergedCustomizations,
isActive: true,
authType,
@@ -224,6 +225,20 @@ export async function POST(request: NextRequest) {
// Silently fail
}
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.CHAT_DEPLOYED,
resourceType: AuditResourceType.CHAT,
resourceId: id,
resourceName: title,
description: `Deployed chat "${title}"`,
metadata: { workflowId, identifier, authType },
request,
})
return createSuccessResponse({
id,
chatUrl,

View File

@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForChatCreation(
export async function checkChatAccess(
chatId: string,
userId: string
): Promise<{ hasAccess: boolean; chat?: any }> {
): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> {
const chatData = await db
.select({
chat: chat,
@@ -78,7 +78,9 @@ export async function checkChatAccess(
action: 'admin',
})
return authorization.allowed ? { hasAccess: true, chat: chatRecord } : { hasAccess: false }
return authorization.allowed
? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId }
: { hasAccess: false }
}
export async function validateChatAuth(

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

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
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'
@@ -175,6 +176,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
emailSent: !!email,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.CREDENTIAL_SET_INVITATION_CREATED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Created invitation for credential set "${result.set.name}"${email ? ` to ${email}` : ''}`,
request: req,
})
return NextResponse.json({
invitation: {
...invitation,
@@ -235,6 +249,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
)
)
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.CREDENTIAL_SET_INVITATION_REVOKED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Revoked invitation "${invitationId}" for credential set "${result.set.name}"`,
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error cancelling invitation', error)

View File

@@ -3,6 +3,7 @@ import { account, credentialSet, credentialSetMember, member, user } from '@sim/
import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
@@ -13,6 +14,7 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
const [set] = await db
.select({
id: credentialSet.id,
name: credentialSet.name,
organizationId: credentialSet.organizationId,
providerId: credentialSet.providerId,
})
@@ -177,6 +179,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
userId: session.user.id,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.CREDENTIAL_SET_MEMBER_REMOVED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Removed member from credential set "${result.set.name}"`,
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error removing member from credential set', error)

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 { hasCredentialSetsAccess } from '@/lib/billing'
@@ -131,6 +132,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
const [updated] = await db.select().from(credentialSet).where(eq(credentialSet.id, id)).limit(1)
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.CREDENTIAL_SET_UPDATED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: updated?.name ?? result.set.name,
description: `Updated credential set "${updated?.name ?? result.set.name}"`,
request: req,
})
return NextResponse.json({ credentialSet: updated })
} catch (error) {
if (error instanceof z.ZodError) {
@@ -175,6 +189,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
logger.info('Deleted credential set', { credentialSetId: id, userId: session.user.id })
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.CREDENTIAL_SET_DELETED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.set.name,
description: `Deleted credential set "${result.set.name}"`,
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting credential set', error)

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 { and, count, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasCredentialSetsAccess } from '@/lib/billing'
@@ -165,6 +166,19 @@ export async function POST(req: Request) {
userId: session.user.id,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.CREDENTIAL_SET_CREATED,
resourceType: AuditResourceType.CREDENTIAL_SET,
resourceId: newCredentialSet.id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Created credential set "${name}"`,
request: req,
})
return NextResponse.json({ credentialSet: newCredentialSet }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {

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

@@ -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 { generateRequestId } from '@/lib/core/utils/request'
import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate'
@@ -115,6 +116,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
}
)
recordAudit({
workspaceId: targetWorkspaceId,
actorId: session.user.id,
action: AuditAction.FOLDER_DUPLICATED,
resourceType: AuditResourceType.FOLDER,
resourceId: newFolderId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Duplicated folder "${sourceFolder.name}" as "${name}"`,
request: req,
})
return NextResponse.json(
{
id: newFolderId,

View File

@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
type MockUser,
mockAuth,
@@ -12,6 +13,8 @@ import {
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/audit/log', () => auditMock)
/** Type for captured folder values in tests */
interface CapturedFolderValues {
name?: string

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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -167,6 +168,19 @@ export async function DELETE(
deletionStats,
})
recordAudit({
workspaceId: existingFolder.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FOLDER_DELETED,
resourceType: AuditResourceType.FOLDER,
resourceId: id,
resourceName: existingFolder.name,
description: `Deleted folder "${existingFolder.name}"`,
request,
})
return NextResponse.json({
success: true,
deletedItems: deletionStats,

View File

@@ -3,9 +3,17 @@
*
* @vitest-environment node
*/
import { createMockRequest, mockAuth, mockConsoleLogger, setupCommonApiMocks } from '@sim/testing'
import {
auditMock,
createMockRequest,
mockAuth,
mockConsoleLogger,
setupCommonApiMocks,
} from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/audit/log', () => auditMock)
interface CapturedFolderValues {
name?: string
color?: string

View File

@@ -3,6 +3,7 @@ import { workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, desc, eq, isNull } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -119,6 +120,20 @@ export async function POST(request: NextRequest) {
logger.info('Created new folder:', { id, name, workspaceId, parentId })
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FOLDER_CREATED,
resourceType: AuditResourceType.FOLDER,
resourceId: id,
resourceName: name.trim(),
description: `Created folder "${name.trim()}"`,
metadata: { name: name.trim() },
request,
})
return NextResponse.json({ folder: newFolder })
} catch (error) {
logger.error('Error creating folder:', { error })

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } 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 { checkFormAccess, DEFAULT_FORM_CUSTOMIZATIONS } from '@/app/api/form/utils'
@@ -102,7 +103,11 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const { id } = await params
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
const {
hasAccess,
form: formRecord,
workspaceId: formWorkspaceId,
} = await checkFormAccess(id, session.user.id)
if (!hasAccess || !formRecord) {
return createErrorResponse('Form not found or access denied', 404)
@@ -184,6 +189,19 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
logger.info(`Form ${id} updated successfully`)
recordAudit({
workspaceId: formWorkspaceId ?? null,
actorId: session.user.id,
action: AuditAction.FORM_UPDATED,
resourceType: AuditResourceType.FORM,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: formRecord.title ?? undefined,
description: `Updated form "${formRecord.title}"`,
request,
})
return createSuccessResponse({
message: 'Form updated successfully',
})
@@ -213,7 +231,11 @@ export async function DELETE(
const { id } = await params
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
const {
hasAccess,
form: formRecord,
workspaceId: formWorkspaceId,
} = await checkFormAccess(id, session.user.id)
if (!hasAccess || !formRecord) {
return createErrorResponse('Form not found or access denied', 404)
@@ -223,6 +245,19 @@ export async function DELETE(
logger.info(`Form ${id} deleted (soft delete)`)
recordAudit({
workspaceId: formWorkspaceId ?? null,
actorId: session.user.id,
action: AuditAction.FORM_DELETED,
resourceType: AuditResourceType.FORM,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: formRecord.title ?? undefined,
description: `Deleted form "${formRecord.title}"`,
request,
})
return createSuccessResponse({
message: 'Form deleted successfully',
})

View File

@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm'
import type { NextRequest } 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 { isDev } from '@/lib/core/config/feature-flags'
import { encryptSecret } from '@/lib/core/security/encryption'
@@ -178,7 +179,7 @@ export async function POST(request: NextRequest) {
userId: session.user.id,
identifier,
title,
description: description || '',
description: description || null,
customizations: mergedCustomizations,
isActive: true,
authType,
@@ -195,6 +196,19 @@ export async function POST(request: NextRequest) {
logger.info(`Form "${title}" deployed successfully at ${formUrl}`)
recordAudit({
workspaceId: workflowRecord.workspaceId ?? null,
actorId: session.user.id,
action: AuditAction.FORM_CREATED,
resourceType: AuditResourceType.FORM,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: title,
description: `Created form "${title}" for workflow ${workflowId}`,
request,
})
return createSuccessResponse({
id,
formUrl,

View File

@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForFormCreation(
export async function checkFormAccess(
formId: string,
userId: string
): Promise<{ hasAccess: boolean; form?: any }> {
): Promise<{ hasAccess: boolean; form?: any; workspaceId?: string }> {
const formData = await db
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
.from(form)
@@ -75,7 +75,9 @@ export async function checkFormAccess(
action: 'admin',
})
return authorization.allowed ? { hasAccess: true, form: formRecord } : { hasAccess: false }
return authorization.allowed
? { hasAccess: true, form: formRecord, workspaceId: workflowWorkspaceId }
: { hasAccess: false }
}
export async function validateFormAuth(

View File

@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
mockAuth,
mockConsoleLogger,
@@ -35,6 +36,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
mockDrizzleOrm()
mockConsoleLogger()
vi.mock('@/lib/audit/log', () => auditMock)
describe('Document By ID API Route', () => {
const mockAuth$ = mockAuth()

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 { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import {
@@ -197,6 +198,19 @@ export async function PUT(
`[${requestId}] Document updated: ${documentId} in knowledge base ${knowledgeBaseId}`
)
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_UPDATED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: validatedData.filename ?? accessCheck.document?.filename,
description: `Updated document "${documentId}" in knowledge base "${knowledgeBaseId}"`,
request: req,
})
return NextResponse.json({
success: true,
data: updatedDocument,
@@ -257,6 +271,19 @@ export async function DELETE(
`[${requestId}] Document deleted: ${documentId} from knowledge base ${knowledgeBaseId}`
)
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_DELETED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: documentId,
resourceName: accessCheck.document?.filename,
description: `Deleted document "${documentId}" from knowledge base "${knowledgeBaseId}"`,
request: req,
})
return NextResponse.json({
success: true,
data: result,

View File

@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
mockAuth,
mockConsoleLogger,
@@ -40,6 +41,8 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
mockDrizzleOrm()
mockConsoleLogger()
vi.mock('@/lib/audit/log', () => auditMock)
describe('Knowledge Base Documents API Route', () => {
const mockAuth$ = mockAuth()

View File

@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto'
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 { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import {
@@ -244,6 +245,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
logger.error(`[${requestId}] Critical error in document processing pipeline:`, error)
})
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: `${createdDocuments.length} document(s)`,
description: `Uploaded ${createdDocuments.length} document(s) to knowledge base "${knowledgeBaseId}"`,
request: req,
})
return NextResponse.json({
success: true,
data: {
@@ -292,6 +306,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
// Silently fail
}
recordAudit({
workspaceId: accessCheck.knowledgeBase?.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.DOCUMENT_UPLOADED,
resourceType: AuditResourceType.DOCUMENT,
resourceId: knowledgeBaseId,
resourceName: validatedData.filename,
description: `Uploaded document "${validatedData.filename}" to knowledge base "${knowledgeBaseId}"`,
request: req,
})
return NextResponse.json({
success: true,
data: newDocument,

View File

@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
mockAuth,
mockConsoleLogger,
@@ -16,6 +17,8 @@ mockKnowledgeSchemas()
mockDrizzleOrm()
mockConsoleLogger()
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/knowledge/service', () => ({
getKnowledgeBaseById: vi.fn(),
updateKnowledgeBase: vi.fn(),

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 { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -135,6 +136,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
logger.info(`[${requestId}] Knowledge base updated: ${id} for user ${userId}`)
recordAudit({
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.KNOWLEDGE_BASE_UPDATED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: validatedData.name ?? updatedKnowledgeBase.name,
description: `Updated knowledge base "${validatedData.name ?? updatedKnowledgeBase.name}"`,
request: req,
})
return NextResponse.json({
success: true,
data: updatedKnowledgeBase,
@@ -197,6 +211,19 @@ export async function DELETE(
logger.info(`[${requestId}] Knowledge base deleted: ${id} for user ${userId}`)
recordAudit({
workspaceId: accessCheck.knowledgeBase.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.KNOWLEDGE_BASE_DELETED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: id,
resourceName: accessCheck.knowledgeBase.name,
description: `Deleted knowledge base "${accessCheck.knowledgeBase.name || id}"`,
request: _request,
})
return NextResponse.json({
success: true,
data: { message: 'Knowledge base deleted successfully' },

View File

@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import {
auditMock,
createMockRequest,
mockAuth,
mockConsoleLogger,
@@ -16,6 +17,8 @@ mockKnowledgeSchemas()
mockDrizzleOrm()
mockConsoleLogger()
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
}))

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 { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -109,6 +110,20 @@ export async function POST(req: NextRequest) {
`[${requestId}] Knowledge base created: ${newKnowledgeBase.id} for user ${session.user.id}`
)
recordAudit({
workspaceId: validatedData.workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.KNOWLEDGE_BASE_CREATED,
resourceType: AuditResourceType.KNOWLEDGE_BASE,
resourceId: newKnowledgeBase.id,
resourceName: validatedData.name,
description: `Created knowledge base "${validatedData.name}"`,
metadata: { name: validatedData.name },
request: req,
})
return NextResponse.json({
success: true,
data: newKnowledgeBase,

View File

@@ -99,7 +99,7 @@ export interface EmbeddingData {
export interface KnowledgeBaseAccessResult {
hasAccess: true
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
}
export interface KnowledgeBaseAccessDenied {
@@ -113,7 +113,7 @@ export type KnowledgeBaseAccessCheck = KnowledgeBaseAccessResult | KnowledgeBase
export interface DocumentAccessResult {
hasAccess: true
document: DocumentData
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
}
export interface DocumentAccessDenied {
@@ -128,7 +128,7 @@ export interface ChunkAccessResult {
hasAccess: true
chunk: EmbeddingData
document: DocumentData
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
}
export interface ChunkAccessDenied {
@@ -151,6 +151,7 @@ export async function checkKnowledgeBaseAccess(
id: knowledgeBase.id,
userId: knowledgeBase.userId,
workspaceId: knowledgeBase.workspaceId,
name: knowledgeBase.name,
})
.from(knowledgeBase)
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))
@@ -193,6 +194,7 @@ export async function checkKnowledgeBaseWriteAccess(
id: knowledgeBase.id,
userId: knowledgeBase.userId,
workspaceId: knowledgeBase.workspaceId,
name: knowledgeBase.name,
})
.from(knowledgeBase)
.where(and(eq(knowledgeBase.id, knowledgeBaseId), isNull(knowledgeBase.deletedAt)))

View File

@@ -3,6 +3,8 @@ import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
@@ -15,7 +17,11 @@ export const dynamic = 'force-dynamic'
* PATCH - Update an MCP server in the workspace (requires write or admin permission)
*/
export const PATCH = withMcpAuth<{ id: string }>('write')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
const { id: serverId } = await params
try {
@@ -29,6 +35,17 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
// Remove workspaceId from body to prevent it from being updated
const { workspaceId: _, ...updateData } = body
if (updateData.url) {
try {
validateMcpDomain(updateData.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
}
// Get the current server to check if URL is changing
const [currentServer] = await db
.select({ url: mcpServers.url })
@@ -73,6 +90,20 @@ export const PATCH = withMcpAuth<{ id: string }>('write')(
}
logger.info(`[${requestId}] Successfully updated MCP server: ${serverId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedServer.name || serverId,
description: `Updated MCP server "${updatedServer.name || serverId}"`,
request,
})
return createMcpSuccessResponse({ server: updatedServer })
} catch (error) {
logger.error(`[${requestId}] Error updating MCP server:`, error)

View File

@@ -3,6 +3,8 @@ import { mcpServers } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpService } from '@/lib/mcp/service'
import {
@@ -54,7 +56,7 @@ export const GET = withMcpAuth('read')(
* it will be updated instead of creating a duplicate.
*/
export const POST = withMcpAuth('write')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const body = getParsedBody(request) || (await request.json())
@@ -72,6 +74,15 @@ export const POST = withMcpAuth('write')(
)
}
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
const serverId = body.url ? generateMcpServerId(workspaceId, body.url) : crypto.randomUUID()
const [existingServer] = await db
@@ -151,6 +162,20 @@ export const POST = withMcpAuth('write')(
// Silently fail
}
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: body.name,
description: `Added MCP server "${body.name}"`,
metadata: { serverName: body.name, transport: body.transport },
request,
})
return createMcpSuccessResponse({ serverId }, 201)
} catch (error) {
logger.error(`[${requestId}] Error registering MCP server:`, error)
@@ -167,7 +192,7 @@ export const POST = withMcpAuth('write')(
* DELETE - Delete an MCP server from the workspace (requires admin permission)
*/
export const DELETE = withMcpAuth('admin')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const { searchParams } = new URL(request.url)
const serverId = searchParams.get('serverId')
@@ -198,6 +223,20 @@ export const DELETE = withMcpAuth('admin')(
await mcpService.clearCache(workspaceId)
logger.info(`[${requestId}] Successfully deleted MCP server: ${serverId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId!,
resourceName: deletedServer.name,
description: `Removed MCP server "${deletedServer.name}"`,
request,
})
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting MCP server:`, error)

View File

@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import type { NextRequest } from 'next/server'
import { McpClient } from '@/lib/mcp/client'
import { McpDomainNotAllowedError, validateMcpDomain } from '@/lib/mcp/domain-check'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { resolveMcpConfigEnvVars } from '@/lib/mcp/resolve-config'
import type { McpTransport } from '@/lib/mcp/types'
@@ -71,6 +72,15 @@ export const POST = withMcpAuth('write')(
)
}
try {
validateMcpDomain(body.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
// Build initial config for resolution
const initialConfig = {
id: `test-${requestId}`,
@@ -95,6 +105,16 @@ export const POST = withMcpAuth('write')(
logger.warn(`[${requestId}] Some environment variables not found:`, { missingVars })
}
// Re-validate domain after env var resolution
try {
validateMcpDomain(testConfig.url)
} catch (e) {
if (e instanceof McpDomainNotAllowedError) {
return createMcpErrorResponse(e, e.message, 403)
}
throw e
}
const testSecurityPolicy = {
requireConsent: false,
auditLevel: 'none' as const,

View File

@@ -3,6 +3,7 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
@@ -71,7 +72,11 @@ export const GET = withMcpAuth<RouteParams>('read')(
* PATCH - Update a workflow MCP server
*/
export const PATCH = withMcpAuth<RouteParams>('write')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = await params
const body = getParsedBody(request) || (await request.json())
@@ -112,6 +117,19 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
logger.info(`[${requestId}] Successfully updated workflow MCP server: ${serverId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: updatedServer.name,
description: `Updated workflow MCP server "${updatedServer.name}"`,
request,
})
return createMcpSuccessResponse({ server: updatedServer })
} catch (error) {
logger.error(`[${requestId}] Error updating workflow MCP server:`, error)
@@ -128,7 +146,11 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
* DELETE - Delete a workflow MCP server and all its tools
*/
export const DELETE = withMcpAuth<RouteParams>('admin')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = await params
@@ -149,6 +171,19 @@ export const DELETE = withMcpAuth<RouteParams>('admin')(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_REMOVED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: deletedServer.name,
description: `Unpublished workflow MCP server "${deletedServer.name}"`,
request,
})
return createMcpSuccessResponse({ message: `Server ${serverId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting workflow MCP server:`, error)

View File

@@ -3,6 +3,7 @@ import { workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
@@ -65,7 +66,11 @@ export const GET = withMcpAuth<RouteParams>('read')(
* PATCH - Update a tool's configuration
*/
export const PATCH = withMcpAuth<RouteParams>('write')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId, toolId } = await params
const body = getParsedBody(request) || (await request.json())
@@ -118,6 +123,19 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Updated tool "${updatedTool.toolName}" in MCP server`,
metadata: { toolId, toolName: updatedTool.toolName },
request,
})
return createMcpSuccessResponse({ tool: updatedTool })
} catch (error) {
logger.error(`[${requestId}] Error updating tool:`, error)
@@ -134,7 +152,11 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
* DELETE - Remove a tool from an MCP server
*/
export const DELETE = withMcpAuth<RouteParams>('write')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId, toolId } = await params
@@ -165,6 +187,19 @@ export const DELETE = withMcpAuth<RouteParams>('write')(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Removed tool "${deletedTool.toolName}" from MCP server`,
metadata: { toolId, toolName: deletedTool.toolName },
request,
})
return createMcpSuccessResponse({ message: `Tool ${toolId} deleted successfully` })
} catch (error) {
logger.error(`[${requestId}] Error deleting tool:`, error)

View File

@@ -3,6 +3,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
@@ -76,7 +77,11 @@ export const GET = withMcpAuth<RouteParams>('read')(
* POST - Add a workflow as a tool to an MCP server
*/
export const POST = withMcpAuth<RouteParams>('write')(
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
async (
request: NextRequest,
{ userId, userName, userEmail, workspaceId, requestId },
{ params }
) => {
try {
const { id: serverId } = await params
const body = getParsedBody(request) || (await request.json())
@@ -197,6 +202,19 @@ export const POST = withMcpAuth<RouteParams>('write')(
mcpPubSub?.publishWorkflowToolsChanged({ serverId, workspaceId })
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_UPDATED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
description: `Added tool "${toolName}" to MCP server`,
metadata: { toolId, toolName, workflowId: body.workflowId },
request,
})
return createMcpSuccessResponse({ tool }, 201)
} catch (error) {
logger.error(`[${requestId}] Error adding tool:`, error)

View File

@@ -3,6 +3,7 @@ import { workflow, workflowMcpServer, workflowMcpTool } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq, inArray, sql } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getParsedBody, withMcpAuth } from '@/lib/mcp/middleware'
import { mcpPubSub } from '@/lib/mcp/pubsub'
import { createMcpErrorResponse, createMcpSuccessResponse } from '@/lib/mcp/utils'
@@ -85,7 +86,7 @@ export const GET = withMcpAuth('read')(
* POST - Create a new workflow MCP server
*/
export const POST = withMcpAuth('write')(
async (request: NextRequest, { userId, workspaceId, requestId }) => {
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
try {
const body = getParsedBody(request) || (await request.json())
@@ -188,6 +189,19 @@ export const POST = withMcpAuth('write')(
`[${requestId}] Successfully created workflow MCP server: ${body.name} (ID: ${serverId})`
)
recordAudit({
workspaceId,
actorId: userId,
actorName: userName,
actorEmail: userEmail,
action: AuditAction.MCP_SERVER_ADDED,
resourceType: AuditResourceType.MCP_SERVER,
resourceId: serverId,
resourceName: body.name.trim(),
description: `Published workflow MCP server "${body.name.trim()}" with ${addedTools.length} tool(s)`,
request,
})
return createMcpSuccessResponse({ server, addedTools }, 201)
} catch (error) {
logger.error(`[${requestId}] Error creating workflow MCP server:`, error)

View File

@@ -18,6 +18,7 @@ import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import { syncUsageLimitsFromSubscription } from '@/lib/billing/core/usage'
@@ -552,6 +553,25 @@ export async function PUT(
email: orgInvitation.email,
})
const auditActionMap = {
accepted: AuditAction.ORG_INVITATION_ACCEPTED,
rejected: AuditAction.ORG_INVITATION_REJECTED,
cancelled: AuditAction.ORG_INVITATION_CANCELLED,
} as const
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: auditActionMap[status],
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Organization invitation ${status} for ${orgInvitation.email}`,
metadata: { invitationId, email: orgInvitation.email, status },
request: req,
})
return NextResponse.json({
success: true,
message: `Invitation ${status} successfully`,

View File

@@ -17,6 +17,7 @@ import {
renderBatchInvitationEmail,
renderInvitationEmail,
} from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import {
validateBulkInvitations,
@@ -411,6 +412,22 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
workspaceInvitationCount: workspaceInvitationIds.length,
})
for (const inv of invitationsToCreate) {
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORG_INVITATION_CREATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: organizationEntry[0]?.name,
description: `Invited ${inv.email} to organization as ${role}`,
metadata: { invitationId: inv.id, email: inv.email, role },
request,
})
}
return NextResponse.json({
success: true,
message: `${invitationsToCreate.length} invitation(s) sent successfully`,
@@ -532,6 +549,19 @@ export async function DELETE(
email: result[0].email,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORG_INVITATION_REVOKED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Revoked organization invitation for ${result[0].email}`,
metadata: { invitationId, email: result[0].email },
request,
})
return NextResponse.json({
success: true,
message: 'Invitation cancelled successfully',

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 { getUserUsageData } from '@/lib/billing/core/usage'
import { removeUserFromOrganization } from '@/lib/billing/organizations/membership'
@@ -213,6 +214,19 @@ export async function PUT(
updatedBy: session.user.id,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORG_MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed role for member ${memberId} to ${role}`,
metadata: { targetUserId: memberId, newRole: role },
request,
})
return NextResponse.json({
success: true,
message: 'Member role updated successfully',
@@ -305,6 +319,22 @@ export async function DELETE(
billingActions: result.billingActions,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORG_MEMBER_REMOVED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description:
session.user.id === targetUserId
? 'Left the organization'
: `Removed member ${targetUserId} from organization`,
metadata: { targetUserId, wasSelfRemoval: session.user.id === targetUserId },
request,
})
return NextResponse.json({
success: true,
message:

View File

@@ -5,6 +5,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getEmailSubject, renderInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { validateSeatAvailability } from '@/lib/billing/validation/seat-management'
@@ -285,6 +286,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Don't fail the request if email fails
}
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORG_INVITATION_CREATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Invited ${normalizedEmail} to organization as ${role}`,
metadata: { invitationId, email: normalizedEmail, role },
request,
})
return NextResponse.json({
success: true,
message: `Invitation sent to ${normalizedEmail}`,

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, ne } 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 {
getOrganizationSeatAnalytics,
@@ -192,6 +193,20 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
changes: { name, slug, logo },
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: updatedOrg[0].name,
description: `Updated organization settings`,
metadata: { changes: { name, slug, logo } },
request,
})
return NextResponse.json({
success: true,
message: 'Organization updated successfully',

View File

@@ -3,6 +3,7 @@ import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, or } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
@@ -115,6 +116,19 @@ export async function POST(request: Request) {
organizationId,
})
recordAudit({
workspaceId: null,
actorId: user.id,
action: AuditAction.ORGANIZATION_CREATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: user.name ?? undefined,
actorEmail: user.email ?? undefined,
resourceName: organizationName ?? undefined,
description: `Created organization "${organizationName}"`,
request,
})
return NextResponse.json({
success: true,
organizationId,

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 { hasAccessControlAccess } from '@/lib/billing'
@@ -13,6 +14,7 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) {
const [group] = await db
.select({
id: permissionGroup.id,
name: permissionGroup.name,
organizationId: permissionGroup.organizationId,
})
.from(permissionGroup)
@@ -151,6 +153,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
assignedBy: session.user.id,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_MEMBER_ADDED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
resourceName: result.group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Added member ${userId} to permission group "${result.group.name}"`,
metadata: { targetUserId: userId, permissionGroupId: id },
request: req,
})
return NextResponse.json({ member: newMember }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
@@ -221,6 +237,20 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
userId: session.user.id,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_MEMBER_REMOVED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
resourceName: result.group.name,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Removed member ${memberToRemove.userId} from permission group "${result.group.name}"`,
metadata: { targetUserId: memberToRemove.userId, memberId, permissionGroupId: id },
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error removing member from permission group', error)

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 { hasAccessControlAccess } from '@/lib/billing'
import {
@@ -181,6 +182,19 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
.where(eq(permissionGroup.id, id))
.limit(1)
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_UPDATED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: updated.name,
description: `Updated permission group "${updated.name}"`,
request: req,
})
return NextResponse.json({
permissionGroup: {
...updated,
@@ -229,6 +243,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
logger.info('Deleted permission group', { permissionGroupId: id, userId: session.user.id })
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_DELETED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: result.group.name,
description: `Deleted permission group "${result.group.name}"`,
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting permission group', error)

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, count, desc, eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { hasAccessControlAccess } from '@/lib/billing'
import {
@@ -198,6 +199,19 @@ export async function POST(req: Request) {
userId: session.user.id,
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.PERMISSION_GROUP_CREATED,
resourceType: AuditResourceType.PERMISSION_GROUP,
resourceId: newGroup.id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Created permission group "${name}"`,
request: req,
})
return NextResponse.json({ permissionGroup: newGroup }, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {

View File

@@ -3,7 +3,7 @@
*
* @vitest-environment node
*/
import { databaseMock, loggerMock } from '@sim/testing'
import { auditMock, databaseMock, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -37,6 +37,8 @@ vi.mock('@/lib/core/utils/request', () => ({
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/audit/log', () => auditMock)
import { PUT } from './route'
function createRequest(body: Record<string, unknown>): NextRequest {

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 { generateRequestId } from '@/lib/core/utils/request'
import { validateCronExpression } from '@/lib/workflows/schedules/utils'
@@ -106,6 +107,18 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Reactivated schedule: ${scheduleId}`)
recordAudit({
workspaceId: authorization.workflow.workspaceId ?? null,
actorId: session.user.id,
action: AuditAction.SCHEDULE_UPDATED,
resourceType: AuditResourceType.SCHEDULE,
resourceId: scheduleId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Reactivated schedule for workflow ${schedule.workflowId}`,
request,
})
return NextResponse.json({
message: 'Schedule activated successfully',
nextRunAt,

View File

@@ -0,0 +1,14 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/feature-flags'
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return NextResponse.json({
allowedIntegrations: getAllowedIntegrationsFromEnv(),
})
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getAllowedMcpDomainsFromEnv } from '@/lib/core/config/feature-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
export async function GET() {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const configuredDomains = getAllowedMcpDomainsFromEnv()
if (configuredDomains === null) {
return NextResponse.json({ allowedMcpDomains: null })
}
try {
const platformHostname = new URL(getBaseUrl()).hostname.toLowerCase()
if (!configuredDomains.includes(platformHostname)) {
return NextResponse.json({
allowedMcpDomains: [...configuredDomains, platformHostname],
})
}
} catch {}
return NextResponse.json({ allowedMcpDomains: configuredDomains })
}

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

@@ -3,6 +3,7 @@ import { apiKey } 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 { generateRequestId } from '@/lib/core/utils/request'
@@ -34,12 +35,27 @@ export async function DELETE(
const result = await db
.delete(apiKey)
.where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId)))
.returning({ id: apiKey.id })
.returning({ id: apiKey.id, name: apiKey.name })
if (!result.length) {
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
}
const deletedKey = result[0]
recordAudit({
workspaceId: null,
actorId: userId,
action: AuditAction.PERSONAL_API_KEY_REVOKED,
resourceType: AuditResourceType.API_KEY,
resourceId: keyId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: deletedKey.name,
description: `Revoked personal API key: ${deletedKey.name}`,
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Failed to delete API key', { error })

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
const logger = createLogger('ApiKeysAPI')
@@ -110,6 +111,19 @@ export async function POST(request: NextRequest) {
createdAt: apiKey.createdAt,
})
recordAudit({
workspaceId: null,
actorId: userId,
action: AuditAction.PERSONAL_API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
resourceId: newKey.id,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Created personal API key: ${name}`,
request,
})
return NextResponse.json({
key: {
...newKey,

View File

@@ -3,6 +3,7 @@ import { webhook, workflow } 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 { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { validateInteger } from '@/lib/core/security/input-validation'
import { PlatformEvents } from '@/lib/core/telemetry'
@@ -261,6 +262,20 @@ export async function DELETE(
logger.info(`[${requestId}] Successfully deleted webhook: ${id}`)
}
recordAudit({
workspaceId: webhookData.workflow.workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WEBHOOK_DELETED,
resourceType: AuditResourceType.WEBHOOK,
resourceId: id,
resourceName: foundWebhook.provider || 'generic',
description: 'Deleted webhook',
metadata: { workflowId: webhookData.workflow.id },
request,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
logger.error(`[${requestId}] Error deleting webhook`, {

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray, isNull, or } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -145,7 +146,8 @@ export async function GET(request: NextRequest) {
// Create or Update a webhook
export async function POST(request: NextRequest) {
const requestId = generateRequestId()
const userId = (await getSession())?.user?.id
const session = await getSession()
const userId = session?.user?.id
if (!userId) {
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
@@ -678,6 +680,20 @@ export async function POST(request: NextRequest) {
} catch {
// Telemetry should not fail the operation
}
recordAudit({
workspaceId: workflowRecord.workspaceId || null,
actorId: userId,
actorName: session?.user?.name ?? undefined,
actorEmail: session?.user?.email ?? undefined,
action: AuditAction.WEBHOOK_CREATED,
resourceType: AuditResourceType.WEBHOOK,
resourceId: savedWebhook.id,
resourceName: provider || 'generic',
description: `Created ${provider || 'generic'} webhook`,
metadata: { provider, workflowId },
request,
})
}
const status = targetWebhookId ? 200 : 201

View File

@@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { generateRequestId } from '@/lib/core/utils/request'
import { removeMcpToolsForWorkflow, syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
import {
@@ -258,6 +259,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// Sync MCP tools with the latest parameter schema
await syncMcpToolsForWorkflow({ workflowId: id, requestId, context: 'deploy' })
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: actorUserId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_DEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Deployed workflow "${workflowData?.name || id}"`,
request,
})
const responseApiKeyInfo = workflowData!.workspaceId
? 'Workspace API keys'
: 'Personal API keys'
@@ -297,11 +311,11 @@ export async function DELETE(
try {
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
const { error, workflow: workflowData } = await validateWorkflowPermissions(
id,
requestId,
'admin'
)
const {
error,
session,
workflow: workflowData,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
@@ -325,6 +339,19 @@ export async function DELETE(
// Silently fail
}
recordAudit({
workspaceId: workflowData?.workspaceId || null,
actorId: session!.user.id,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.WORKFLOW_UNDEPLOYED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
resourceName: workflowData?.name,
description: `Undeployed workflow "${workflowData?.name || id}"`,
request,
})
return createSuccessResponse({
isDeployed: false,
deployedAt: null,

View File

@@ -2,6 +2,7 @@ import { db, workflow, workflowDeploymentVersion } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { env } from '@/lib/core/config/env'
import { generateRequestId } from '@/lib/core/utils/request'
import { syncMcpToolsForWorkflow } from '@/lib/mcp/workflow-mcp-sync'
@@ -22,7 +23,11 @@ export async function POST(
const { id, version } = await params
try {
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
const {
error,
session,
workflow: workflowRecord,
} = await validateWorkflowPermissions(id, requestId, 'admin')
if (error) {
return createErrorResponse(error.message, error.status)
}
@@ -107,6 +112,19 @@ export async function POST(
logger.error('Error sending workflow reverted event to socket server', e)
}
recordAudit({
workspaceId: workflowRecord?.workspaceId ?? null,
actorId: session!.user.id,
action: AuditAction.WORKFLOW_DEPLOYMENT_REVERTED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: id,
actorName: session!.user.name ?? undefined,
actorEmail: session!.user.email ?? undefined,
resourceName: workflowRecord?.name ?? undefined,
description: `Reverted workflow to deployment version ${version}`,
request,
})
return createSuccessResponse({
message: 'Reverted to deployment version',
lastSaved: Date.now(),

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

@@ -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 { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -61,6 +62,20 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
`[${requestId}] Successfully duplicated workflow ${sourceWorkflowId} to ${result.id} in ${elapsed}ms`
)
recordAudit({
workspaceId: workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_DUPLICATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: result.id,
resourceName: result.name,
description: `Duplicated workflow from ${sourceWorkflowId}`,
metadata: { sourceWorkflowId },
request: req,
})
return NextResponse.json(result, { status: 201 })
} catch (error) {
if (error instanceof Error) {

View File

@@ -5,7 +5,7 @@
* @vitest-environment node
*/
import { loggerMock, setupGlobalFetchMock } from '@sim/testing'
import { auditMock, loggerMock, setupGlobalFetchMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -23,6 +23,8 @@ vi.mock('@/lib/auth', () => ({
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/workflows/persistence/utils', () => ({
loadWorkflowFromNormalizedTables: (workflowId: string) =>
mockLoadWorkflowFromNormalizedTables(workflowId),

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 { checkHybridAuth, checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { env } from '@/lib/core/config/env'
import { PlatformEvents } from '@/lib/core/telemetry'
@@ -336,6 +337,19 @@ export async function DELETE(
// Don't fail the deletion if Socket.IO notification fails
}
recordAudit({
workspaceId: workflowData.workspaceId || null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_DELETED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowData.name,
description: `Deleted workflow "${workflowData.name}"`,
request,
})
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
const elapsed = Date.now() - startTime

View File

@@ -5,6 +5,7 @@
* @vitest-environment node
*/
import {
auditMock,
databaseMock,
defaultMockUser,
mockAuth,
@@ -27,6 +28,8 @@ describe('Workflow Variables API Route', () => {
vi.doMock('@sim/db', () => databaseMock)
vi.doMock('@/lib/audit/log', () => auditMock)
vi.doMock('@/lib/workflows/utils', () => ({
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
}))

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 { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
@@ -79,6 +80,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
})
.where(eq(workflow.id, workflowId))
recordAudit({
workspaceId: workflowData.workspaceId ?? null,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_VARIABLES_UPDATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: workflowData.name ?? undefined,
description: `Updated workflow variables`,
request: req,
})
return NextResponse.json({ success: true })
} catch (validationError) {
if (validationError instanceof z.ZodError) {

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { getUserEntityPermissions, workspaceExists } from '@/lib/workspaces/permissions/utils'
@@ -188,6 +189,20 @@ export async function POST(req: NextRequest) {
logger.info(`[${requestId}] Successfully created empty workflow ${workflowId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: auth.userName,
actorEmail: auth.userEmail,
action: AuditAction.WORKFLOW_CREATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: workflowId,
resourceName: name,
description: `Created workflow "${name}"`,
metadata: { name },
request: req,
})
return NextResponse.json({
id: workflowId,
name,

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, not } 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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -86,6 +87,19 @@ export async function PUT(
updatedAt: apiKey.updatedAt,
})
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.API_KEY_UPDATED,
resourceType: AuditResourceType.API_KEY,
resourceId: keyId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: name,
description: `Updated workspace API key: ${name}`,
request,
})
logger.info(`[${requestId}] Updated workspace API key: ${keyId} in workspace ${workspaceId}`)
return NextResponse.json({ key: updatedKey })
} catch (error: unknown) {
@@ -123,12 +137,27 @@ export async function DELETE(
.where(
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
)
.returning({ id: apiKey.id })
.returning({ id: apiKey.id, name: apiKey.name })
if (deletedRows.length === 0) {
return NextResponse.json({ error: 'API key not found' }, { status: 404 })
}
const deletedKey = deletedRows[0]
recordAudit({
workspaceId,
actorId: userId,
action: AuditAction.API_KEY_REVOKED,
resourceType: AuditResourceType.API_KEY,
resourceId: keyId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: deletedKey.name,
description: `Revoked workspace API key: ${deletedKey.name}`,
request,
})
logger.info(`[${requestId}] Deleted workspace API key: ${keyId} from workspace ${workspaceId}`)
return NextResponse.json({ success: true })
} catch (error: unknown) {

View File

@@ -6,6 +6,7 @@ import { nanoid } from 'nanoid'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { createApiKey, getApiKeyDisplayFormat } from '@/lib/api-key/auth'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -159,6 +160,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Created workspace API key: ${name} in workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
resourceId: newKey.id,
resourceName: name,
description: `Created API key "${name}"`,
metadata: { keyName: name },
request,
})
return NextResponse.json({
key: {
...newKey,
@@ -222,6 +237,19 @@ export async function DELETE(
logger.info(
`[${requestId}] Deleted ${deletedCount} workspace API keys from workspace ${workspaceId}`
)
recordAudit({
workspaceId,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.API_KEY_REVOKED,
resourceType: AuditResourceType.API_KEY,
description: `Revoked ${deletedCount} API key(s)`,
metadata: { keyIds: keys, deletedCount },
request,
})
return NextResponse.json({ success: true, deletedCount })
} catch (error: unknown) {
logger.error(`[${requestId}] Workspace API key DELETE error`, error)

View File

@@ -5,6 +5,7 @@ import { and, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
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'
@@ -185,6 +186,20 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Created BYOK key for ${providerId} in workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.BYOK_KEY_CREATED,
resourceType: AuditResourceType.BYOK_KEY,
resourceId: newKey.id,
resourceName: providerId,
description: `Added BYOK key for ${providerId}`,
metadata: { providerId },
request,
})
return NextResponse.json({
success: true,
key: {
@@ -242,6 +257,19 @@ export async function DELETE(
logger.info(`[${requestId}] Deleted BYOK key for ${providerId} from workspace ${workspaceId}`)
recordAudit({
workspaceId,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.BYOK_KEY_DELETED,
resourceType: AuditResourceType.BYOK_KEY,
resourceName: providerId,
description: `Removed BYOK key for ${providerId}`,
metadata: { providerId },
request,
})
return NextResponse.json({ success: true })
} catch (error: unknown) {
logger.error(`[${requestId}] BYOK key DELETE error`, error)

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 { generateRequestId } from '@/lib/core/utils/request'
import { duplicateWorkspace } from '@/lib/workspaces/duplicate'
@@ -45,6 +46,19 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
`[${requestId}] Successfully duplicated workspace ${sourceWorkspaceId} to ${result.id} in ${elapsed}ms`
)
recordAudit({
workspaceId: sourceWorkspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.WORKSPACE_DUPLICATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: result.id,
resourceName: name,
description: `Duplicated workspace to "${name}"`,
request: req,
})
return NextResponse.json(result, { status: 201 })
} catch (error) {
if (error instanceof Error) {

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'
@@ -156,6 +157,19 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
set: { variables: merged, updatedAt: new Date() },
})
recordAudit({
workspaceId,
actorId: userId,
actorName: session?.user?.name,
actorEmail: session?.user?.email,
action: AuditAction.ENVIRONMENT_UPDATED,
resourceType: AuditResourceType.ENVIRONMENT,
resourceId: workspaceId,
description: `Updated environment variables`,
metadata: { keysUpdated: Object.keys(variables) },
request,
})
return NextResponse.json({ success: true })
} catch (error: any) {
logger.error(`[${requestId}] Workspace env PUT error`, error)

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
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 { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace'
@@ -39,6 +40,18 @@ export async function DELETE(
logger.info(`[${requestId}] Deleted workspace file: ${fileId}`)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_DELETED,
resourceType: AuditResourceType.FILE,
resourceId: fileId,
description: `Deleted file "${fileId}"`,
request,
})
return NextResponse.json({
success: true,
})

View File

@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
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 { listWorkspaceFiles, uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace'
@@ -104,6 +105,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
logger.info(`[${requestId}] Uploaded workspace file: ${file.name}`)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.FILE_UPLOADED,
resourceType: AuditResourceType.FILE,
resourceId: userFile.id,
resourceName: file.name,
description: `Uploaded file "${file.name}"`,
request,
})
return NextResponse.json({
success: true,
file: userFile,

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray } 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 { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -251,6 +252,19 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
subscriptionId: subscription.id,
})
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.NOTIFICATION_UPDATED,
resourceType: AuditResourceType.NOTIFICATION,
resourceId: notificationId,
resourceName: subscription.notificationType,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Updated ${subscription.notificationType} notification subscription`,
request,
})
return NextResponse.json({
data: {
id: subscription.id,
@@ -300,17 +314,35 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
eq(workspaceNotificationSubscription.workspaceId, workspaceId)
)
)
.returning({ id: workspaceNotificationSubscription.id })
.returning({
id: workspaceNotificationSubscription.id,
notificationType: workspaceNotificationSubscription.notificationType,
})
if (deleted.length === 0) {
return NextResponse.json({ error: 'Notification not found' }, { status: 404 })
}
const deletedSubscription = deleted[0]
logger.info('Deleted notification subscription', {
workspaceId,
subscriptionId: notificationId,
})
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.NOTIFICATION_DELETED,
resourceType: AuditResourceType.NOTIFICATION,
resourceId: notificationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: deletedSubscription.notificationType,
description: `Deleted ${deletedSubscription.notificationType} notification subscription`,
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting notification', { error })

View File

@@ -5,6 +5,7 @@ import { and, eq, inArray } 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 { encryptSecret } from '@/lib/core/security/encryption'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
@@ -256,6 +257,19 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
type: data.notificationType,
})
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.NOTIFICATION_CREATED,
resourceType: AuditResourceType.NOTIFICATION,
resourceId: subscription.id,
resourceName: data.notificationType,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Created ${data.notificationType} notification subscription`,
request,
})
return NextResponse.json({
data: {
id: subscription.id,

View File

@@ -5,6 +5,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 {
getUsersWithPermissions,
@@ -156,6 +157,21 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
const updatedUsers = await getUsersWithPermissions(workspaceId)
for (const update of body.updates) {
recordAudit({
workspaceId,
actorId: session.user.id,
action: AuditAction.MEMBER_ROLE_CHANGED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Changed permissions for user ${update.userId} to ${update.permissions}`,
metadata: { targetUserId: update.userId, newPermissions: update.permissions },
request,
})
}
return NextResponse.json({
message: 'Permissions updated successfully',
users: updatedUsers,

View File

@@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray } 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'
const logger = createLogger('WorkspaceByIdAPI')
@@ -228,6 +229,13 @@ export async function DELETE(
`Deleting workspace ${workspaceId} for user ${session.user.id}, deleteTemplates: ${deleteTemplates}`
)
// Fetch workspace name before deletion for audit logging
const [workspaceRecord] = await db
.select({ name: workspace.name })
.from(workspace)
.where(eq(workspace.id, workspaceId))
.limit(1)
// Delete workspace and all related data in a transaction
await db.transaction(async (tx) => {
// Get all workflows in this workspace before deletion
@@ -281,6 +289,19 @@ export async function DELETE(
logger.info(`Successfully deleted workspace ${workspaceId} and all related data`)
})
recordAudit({
workspaceId: null,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.WORKSPACE_DELETED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
resourceName: workspaceRecord?.name,
description: `Deleted workspace "${workspaceRecord?.name || workspaceId}"`,
request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error(`Error deleting workspace ${workspaceId}:`, error)

View File

@@ -1,4 +1,4 @@
import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
import { auditMock, createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -55,6 +55,8 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({
vi.mock('@sim/logger', () => loggerMock)
vi.mock('@/lib/audit/log', () => auditMock)
vi.mock('@/lib/core/utils/urls', () => ({
getBaseUrl: vi.fn().mockReturnValue('https://test.sim.ai'),
}))

View File

@@ -12,6 +12,7 @@ import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
@@ -162,6 +163,19 @@ export async function GET(
.where(eq(workspaceInvitation.id, invitation.id))
})
recordAudit({
workspaceId: invitation.workspaceId,
actorId: session.user.id,
action: AuditAction.INVITATION_ACCEPTED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: invitation.workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: workspaceDetails.name,
description: `Accepted workspace invitation to "${workspaceDetails.name}"`,
request: req,
})
return NextResponse.redirect(new URL(`/workspace/${invitation.workspaceId}/w`, getBaseUrl()))
}
@@ -216,6 +230,19 @@ export async function DELETE(
await db.delete(workspaceInvitation).where(eq(workspaceInvitation.id, invitationId))
recordAudit({
workspaceId: invitation.workspaceId,
actorId: session.user.id,
action: AuditAction.INVITATION_REVOKED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: invitation.workspaceId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
description: `Revoked workspace invitation for ${invitation.email}`,
metadata: { invitationId, email: invitation.email },
request: _request,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error deleting workspace invitation:', error)

View File

@@ -1,4 +1,4 @@
import { createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing'
import { auditMock, createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
describe('Workspace Invitations API Route', () => {
@@ -96,6 +96,8 @@ describe('Workspace Invitations API Route', () => {
getEmailDomain: vi.fn().mockReturnValue('sim.ai'),
}))
vi.doMock('@/lib/audit/log', () => auditMock)
vi.doMock('drizzle-orm', () => ({
and: vi.fn().mockImplementation((...args) => ({ type: 'and', conditions: args })),
eq: vi.fn().mockImplementation((field, value) => ({ type: 'eq', field, value })),

View File

@@ -13,6 +13,7 @@ import { createLogger } from '@sim/logger'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { WorkspaceInvitationEmail } from '@/components/emails'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { getBaseUrl } from '@/lib/core/utils/urls'
@@ -214,6 +215,20 @@ export async function POST(req: NextRequest) {
token: token,
})
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.MEMBER_INVITED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: workspaceId,
resourceName: email,
description: `Invited ${email} as ${permission}`,
metadata: { email, role: permission },
request: req,
})
return NextResponse.json({ success: true, invitation: invitationData })
} catch (error) {
if (error instanceof InvitationsNotAllowedError) {

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 { hasWorkspaceAdminAccess } from '@/lib/workspaces/permissions/utils'
@@ -101,6 +102,19 @@ export async function DELETE(req: NextRequest, { params }: { params: Promise<{ i
)
)
recordAudit({
workspaceId,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
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 },
request: req,
})
return NextResponse.json({ success: true })
} catch (error) {
logger.error('Error removing workspace member:', error)

View File

@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { and, desc, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
import { buildDefaultWorkflowArtifacts } from '@/lib/workflows/defaults'
@@ -68,6 +69,20 @@ export async function POST(req: Request) {
const newWorkspace = await createWorkspace(session.user.id, name, skipDefaultWorkflow)
recordAudit({
workspaceId: newWorkspace.id,
actorId: session.user.id,
actorName: session.user.name,
actorEmail: session.user.email,
action: AuditAction.WORKSPACE_CREATED,
resourceType: AuditResourceType.WORKSPACE,
resourceId: newWorkspace.id,
resourceName: newWorkspace.name,
description: `Created workspace "${newWorkspace.name}"`,
metadata: { name: newWorkspace.name },
request: req,
})
return NextResponse.json({ workspace: newWorkspace })
} catch (error) {
logger.error('Error creating workspace:', error)

View File

@@ -23,7 +23,7 @@ import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
import { getBlock } from '@/blocks/registry'
import type { CopilotToolCall } from '@/stores/panel'
import { useCopilotStore } from '@/stores/panel'
import { useCopilotStore, usePanelStore } from '@/stores/panel'
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
@@ -341,16 +341,20 @@ export function OptionsSelector({
const [hoveredIndex, setHoveredIndex] = useState(-1)
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
const containerRef = useRef<HTMLDivElement>(null)
const activeTab = usePanelStore((s) => s.activeTab)
const isLocked = chosenKey !== null
// Handle keyboard navigation - only for the active options selector
// Handle keyboard navigation - only for the active options selector when copilot is active
useEffect(() => {
if (isInteractionDisabled || !enableKeyboardNav || isLocked) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return
// Only handle keyboard shortcuts when the copilot panel is active
if (activeTab !== 'copilot') return
const activeElement = document.activeElement
const isInputFocused =
activeElement?.tagName === 'INPUT' ||
@@ -387,7 +391,15 @@ export function OptionsSelector({
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect])
}, [
isInteractionDisabled,
enableKeyboardNav,
isLocked,
sortedOptions,
hoveredIndex,
onSelect,
activeTab,
])
if (sortedOptions.length === 0) return null

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

@@ -36,17 +36,18 @@ export function isBlockProtected(blockId: string, blocks: Record<string, BlockSt
/**
* Checks if an edge is protected from modification.
* An edge is protected if either its source or target block is protected.
* An edge is protected only if its target block is protected.
* Outbound connections from locked blocks are allowed to be modified.
*
* @param edge - The edge to check (must have source and target)
* @param blocks - Record of all blocks in the workflow
* @returns True if the edge is protected
* @returns True if the edge is protected (target is locked)
*/
export function isEdgeProtected(
edge: { source: string; target: string },
blocks: Record<string, BlockState>
): boolean {
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
return isBlockProtected(edge.target, blocks)
}
/**

View File

@@ -2523,7 +2523,7 @@ const WorkflowContent = React.memo(() => {
.filter((change: any) => change.type === 'remove')
.map((change: any) => change.id)
.filter((edgeId: string) => {
// Prevent removing edges connected to protected blocks
// Prevent removing edges targeting protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true
return !isEdgeProtected(edge, blocks)
@@ -2595,7 +2595,7 @@ const WorkflowContent = React.memo(() => {
if (!sourceNode || !targetNode) return
// Prevent connections to/from protected blocks
// Prevent connections to protected blocks (outbound from locked blocks is allowed)
if (isEdgeProtected(connection, blocks)) {
addNotification({
level: 'info',
@@ -3357,12 +3357,12 @@ const WorkflowContent = React.memo(() => {
/** Stable delete handler to avoid creating new function references per edge. */
const handleEdgeDelete = useCallback(
(edgeId: string) => {
// Prevent removing edges connected to protected blocks
// Prevent removing edges targeting protected blocks
const edge = edges.find((e) => e.id === edgeId)
if (edge && isEdgeProtected(edge, blocks)) {
addNotification({
level: 'info',
message: 'Cannot remove connections from locked blocks',
message: 'Cannot remove connections to locked blocks',
workflowId: activeWorkflowId || undefined,
})
return
@@ -3420,7 +3420,7 @@ const WorkflowContent = React.memo(() => {
// Handle edge deletion first (edges take priority if selected)
if (selectedEdges.size > 0) {
// Get all selected edge IDs and filter out edges connected to protected blocks
// Get all selected edge IDs and filter out edges targeting protected blocks
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
const edge = edges.find((e) => e.id === edgeId)
if (!edge) return true

View File

@@ -223,13 +223,11 @@ export function Integrations({ onOpenChange, registerCloseHandler }: Integration
}
}
// Group services by provider, filtering by permission config
const groupedServices = services.reduce(
(acc, service) => {
// Filter based on allowedIntegrations
if (
permissionConfig.allowedIntegrations !== null &&
!permissionConfig.allowedIntegrations.includes(service.id)
!permissionConfig.allowedIntegrations.includes(service.id.replace(/-/g, '_').toLowerCase())
) {
return acc
}

View File

@@ -106,6 +106,40 @@ interface McpServer {
const logger = createLogger('McpSettings')
/**
* Checks if a URL's hostname is in the allowed domains list.
* Returns true if no allowlist is configured (null) or the domain matches.
* Env var references in the hostname bypass the check since the domain
* can't be determined until resolution — but env vars only in the path/query
* do NOT bypass the check.
*/
const ENV_VAR_PATTERN = /\{\{[^}]+\}\}/
function hasEnvVarInHostname(url: string): boolean {
// If the entire URL is an env var, hostname is unknown
const globalPattern = new RegExp(ENV_VAR_PATTERN.source, 'g')
if (url.trim().replace(globalPattern, '').trim() === '') return true
const protocolEnd = url.indexOf('://')
if (protocolEnd === -1) return ENV_VAR_PATTERN.test(url)
// Extract authority per RFC 3986 (terminated by /, ?, or #)
const afterProtocol = url.substring(protocolEnd + 3)
const authorityEnd = afterProtocol.search(/[/?#]/)
const authority = authorityEnd === -1 ? afterProtocol : afterProtocol.substring(0, authorityEnd)
return ENV_VAR_PATTERN.test(authority)
}
function isDomainAllowed(url: string | undefined, allowedDomains: string[] | null): boolean {
if (allowedDomains === null) return true
if (!url) return false
if (hasEnvVarInHostname(url)) return true
try {
const hostname = new URL(url).hostname.toLowerCase()
return allowedDomains.includes(hostname)
} catch {
return false
}
}
const DEFAULT_FORM_DATA: McpServerFormData = {
name: '',
transport: 'streamable-http',
@@ -390,6 +424,15 @@ export function MCP({ initialServerId }: MCPProps) {
} = useMcpServerTest()
const availableEnvVars = useAvailableEnvVarKeys(workspaceId)
const [allowedMcpDomains, setAllowedMcpDomains] = useState<string[] | null>(null)
useEffect(() => {
fetch('/api/settings/allowed-mcp-domains')
.then((res) => res.json())
.then((data) => setAllowedMcpDomains(data.allowedMcpDomains ?? null))
.catch(() => setAllowedMcpDomains(null))
}, [])
const urlInputRef = useRef<HTMLInputElement>(null)
const [showAddForm, setShowAddForm] = useState(false)
@@ -1006,10 +1049,14 @@ export function MCP({ initialServerId }: MCPProps) {
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
const isFormValid = formData.name.trim() && formData.url?.trim()
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
const isAddDomainBlocked =
!!formData.url?.trim() && !isDomainAllowed(formData.url, allowedMcpDomains)
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
const testButtonLabel = getTestButtonLabel(testResult, isTestingConnection)
const isEditFormValid = editFormData.name.trim() && editFormData.url?.trim()
const isEditDomainBlocked =
!!editFormData.url?.trim() && !isDomainAllowed(editFormData.url, allowedMcpDomains)
const editTestButtonLabel = getTestButtonLabel(editTestResult, isEditTestingConnection)
const hasEditChanges = useMemo(() => {
if (editFormData.name !== editOriginalData.name) return true
@@ -1299,6 +1346,11 @@ export function MCP({ initialServerId }: MCPProps) {
onChange={(e) => handleEditInputChange('url', e.target.value)}
onScroll={setEditUrlScrollLeft}
/>
{isEditDomainBlocked && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
Domain not permitted by server policy
</p>
)}
</FormField>
<div className='flex flex-col gap-[8px]'>
@@ -1351,7 +1403,7 @@ export function MCP({ initialServerId }: MCPProps) {
<Button
variant='default'
onClick={handleEditTestConnection}
disabled={isEditTestingConnection || !isEditFormValid}
disabled={isEditTestingConnection || !isEditFormValid || isEditDomainBlocked}
>
{editTestButtonLabel}
</Button>
@@ -1361,7 +1413,9 @@ export function MCP({ initialServerId }: MCPProps) {
</Button>
<Button
onClick={handleSaveEdit}
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
disabled={
!hasEditChanges || isUpdatingServer || !isEditFormValid || isEditDomainBlocked
}
variant='tertiary'
>
{isUpdatingServer ? 'Saving...' : 'Save'}
@@ -1434,6 +1488,11 @@ export function MCP({ initialServerId }: MCPProps) {
onChange={(e) => handleInputChange('url', e.target.value)}
onScroll={(scrollLeft) => handleUrlScroll(scrollLeft)}
/>
{isAddDomainBlocked && (
<p className='mt-[4px] text-[12px] text-[var(--text-error)]'>
Domain not permitted by server policy
</p>
)}
</FormField>
<div className='flex flex-col gap-[8px]'>
@@ -1479,7 +1538,7 @@ export function MCP({ initialServerId }: MCPProps) {
<Button
variant='default'
onClick={handleTestConnection}
disabled={isTestingConnection || !isFormValid}
disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
>
{testButtonLabel}
</Button>
@@ -1489,7 +1548,9 @@ export function MCP({ initialServerId }: MCPProps) {
Cancel
</Button>
<Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'>
{isSubmitDisabled && isFormValid ? 'Adding...' : 'Add Server'}
{isSubmitDisabled && isFormValid && !isAddDomainBlocked
? 'Adding...'
: 'Add Server'}
</Button>
</div>
</div>

View File

@@ -7,6 +7,8 @@ export interface SubscriptionPermissions {
canCancelSubscription: boolean
showTeamMemberView: boolean
showUpgradePlans: boolean
isEnterpriseMember: boolean
canViewUsageInfo: boolean
}
export interface SubscriptionState {
@@ -31,6 +33,9 @@ export function getSubscriptionPermissions(
const { isFree, isPro, isTeam, isEnterprise, isPaid } = subscription
const { isTeamAdmin } = userRole
const isEnterpriseMember = isEnterprise && !isTeamAdmin
const canViewUsageInfo = !isEnterpriseMember
return {
canUpgradeToPro: isFree,
canUpgradeToTeam: isFree || (isPro && !isTeam),
@@ -40,6 +45,8 @@ export function getSubscriptionPermissions(
canCancelSubscription: isPaid && !isEnterprise && !(isTeam && !isTeamAdmin), // Team members can't cancel
showTeamMemberView: isTeam && !isTeamAdmin,
showUpgradePlans: isFree || (isPro && !isTeam) || (isTeam && isTeamAdmin), // Free users, Pro users, Team owners see plans
isEnterpriseMember,
canViewUsageInfo,
}
}

View File

@@ -300,12 +300,16 @@ export function Subscription() {
)
const showBadge =
(permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
permissions.showTeamMemberView ||
subscription.isEnterprise ||
isBlocked
!permissions.isEnterpriseMember &&
((permissions.canEditUsageLimit && !permissions.showTeamMemberView) ||
permissions.showTeamMemberView ||
subscription.isEnterprise ||
isBlocked)
const getBadgeConfig = (): { text: string; variant: 'blue-secondary' | 'red' } => {
if (permissions.isEnterpriseMember) {
return { text: '', variant: 'blue-secondary' }
}
if (permissions.showTeamMemberView || subscription.isEnterprise) {
return { text: `${subscription.seats} seats`, variant: 'blue-secondary' }
}
@@ -443,67 +447,75 @@ export function Subscription() {
return (
<div className='flex h-full flex-col gap-[20px]'>
{/* Current Plan & Usage Overview */}
<UsageHeader
title={formatPlanName(subscription.plan)}
showBadge={showBadge}
badgeText={badgeConfig.text}
badgeVariant={badgeConfig.variant}
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
seatsText={
permissions.canManageTeam || subscription.isEnterprise
? `${subscription.seats} seats`
: undefined
}
current={usage.current}
limit={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.data?.totalUsageLimit
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={isBlocked}
progressValue={Math.min(usage.percentUsed, 100)}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
}
context={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? 'organization'
: 'user'
}
organizationId={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? activeOrgId
: undefined
}
onLimitUpdated={() => {
logger.info('Usage limit updated')
}}
/>
) : undefined
}
/>
{/* Current Plan & Usage Overview - hidden from enterprise members (non-admin) */}
{permissions.canViewUsageInfo ? (
<UsageHeader
title={formatPlanName(subscription.plan)}
showBadge={showBadge}
badgeText={badgeConfig.text}
badgeVariant={badgeConfig.variant}
onBadgeClick={permissions.showTeamMemberView ? undefined : handleBadgeClick}
seatsText={
permissions.canManageTeam || subscription.isEnterprise
? `${subscription.seats} seats`
: undefined
}
current={usage.current}
limit={
subscription.isEnterprise || subscription.isTeam
? organizationBillingData?.data?.totalUsageLimit
: !subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
? usage.current // placeholder; rightContent will render UsageLimit
: usage.limit
}
isBlocked={isBlocked}
progressValue={Math.min(usage.percentUsed, 100)}
rightContent={
!subscription.isFree &&
(permissions.canEditUsageLimit || permissions.showTeamMemberView) ? (
<UsageLimit
ref={usageLimitRef}
currentLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.totalUsageLimit
: usageLimitData.currentLimit || usage.limit
}
currentUsage={usage.current}
canEdit={permissions.canEditUsageLimit}
minimumLimit={
(subscription.isTeam || subscription.isEnterprise) &&
isTeamAdmin &&
organizationBillingData?.data
? organizationBillingData.data.minimumBillingAmount
: usageLimitData.minimumLimit || (subscription.isPro ? 20 : 40)
}
context={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? 'organization'
: 'user'
}
organizationId={
(subscription.isTeam || subscription.isEnterprise) && isTeamAdmin
? activeOrgId
: undefined
}
onLimitUpdated={() => {
logger.info('Usage limit updated')
}}
/>
) : undefined
}
/>
) : (
<div className='flex items-center'>
<span className='font-medium text-[14px] text-[var(--text-primary)]'>
{formatPlanName(subscription.plan)}
</span>
</div>
)}
{/* Upgrade Plans */}
{permissions.showUpgradePlans && (
@@ -539,8 +551,8 @@ export function Subscription() {
</div>
)}
{/* Credit Balance */}
{subscription.isPaid && (
{/* Credit Balance - hidden from enterprise members (non-admin) */}
{subscription.isPaid && permissions.canViewUsageInfo && (
<CreditBalance
balance={subscriptionData?.data?.creditBalance ?? 0}
canPurchase={permissions.canEditUsageLimit}
@@ -554,10 +566,11 @@ export function Subscription() {
<ReferralCode onRedeemComplete={() => refetchSubscription()} />
)}
{/* Next Billing Date - hidden from team members */}
{/* Next Billing Date - hidden from team members and enterprise members (non-admin) */}
{subscription.isPaid &&
subscriptionData?.data?.periodEnd &&
!permissions.showTeamMemberView && (
!permissions.showTeamMemberView &&
!permissions.isEnterpriseMember && (
<div className='flex items-center justify-between'>
<Label>Next Billing Date</Label>
<span className='text-[12px] text-[var(--text-secondary)]'>
@@ -566,8 +579,8 @@ export function Subscription() {
</div>
)}
{/* Usage notifications */}
{subscription.isPaid && <BillingUsageNotificationsToggle />}
{/* Usage notifications - hidden from enterprise members (non-admin) */}
{subscription.isPaid && permissions.canViewUsageInfo && <BillingUsageNotificationsToggle />}
{/* Cancel Subscription */}
{permissions.canCancelSubscription && (

View File

@@ -285,6 +285,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
const isPro = planType === 'pro'
const isTeam = planType === 'team'
const isEnterprise = planType === 'enterprise'
const isEnterpriseMember = isEnterprise && !userCanManageBilling
const handleUpgradeToPro = useCallback(async () => {
try {
@@ -463,6 +464,18 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
}
}
if (isEnterpriseMember) {
return (
<div className='flex flex-shrink-0 flex-col border-t px-[13.5px] pt-[8px] pb-[10px]'>
<div className='flex h-[18px] items-center'>
<span className='font-medium text-[12px] text-[var(--text-primary)]'>
{PLAN_NAMES[planType]}
</span>
</div>
</div>
)
}
return (
<>
<div

View File

@@ -0,0 +1,265 @@
/**
* @vitest-environment node
*/
import { databaseMock, drizzleOrmMock, loggerMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
DEFAULT_PERMISSION_GROUP_CONFIG,
mockGetAllowedIntegrationsFromEnv,
mockIsOrganizationOnEnterprisePlan,
mockGetProviderFromModel,
} = vi.hoisted(() => ({
DEFAULT_PERMISSION_GROUP_CONFIG: {
allowedIntegrations: null,
allowedModelProviders: null,
hideTraceSpans: false,
hideKnowledgeBaseTab: false,
hideCopilot: false,
hideApiKeysTab: false,
hideEnvironmentTab: false,
hideFilesTab: false,
disableMcpTools: false,
disableCustomTools: false,
disableSkills: false,
hideTemplates: false,
disableInvitations: false,
hideDeployApi: false,
hideDeployMcp: false,
hideDeployA2a: false,
hideDeployChatbot: false,
hideDeployTemplate: false,
},
mockGetAllowedIntegrationsFromEnv: vi.fn<() => string[] | null>(),
mockIsOrganizationOnEnterprisePlan: vi.fn<() => Promise<boolean>>(),
mockGetProviderFromModel: vi.fn<(model: string) => string>(),
}))
vi.mock('@sim/db', () => databaseMock)
vi.mock('@sim/db/schema', () => ({}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('drizzle-orm', () => drizzleOrmMock)
vi.mock('@/lib/billing', () => ({
isOrganizationOnEnterprisePlan: mockIsOrganizationOnEnterprisePlan,
}))
vi.mock('@/lib/core/config/feature-flags', () => ({
getAllowedIntegrationsFromEnv: mockGetAllowedIntegrationsFromEnv,
isAccessControlEnabled: false,
isHosted: false,
}))
vi.mock('@/lib/permission-groups/types', () => ({
DEFAULT_PERMISSION_GROUP_CONFIG,
parsePermissionGroupConfig: (config: unknown) => {
if (!config || typeof config !== 'object') return DEFAULT_PERMISSION_GROUP_CONFIG
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, ...config }
},
}))
vi.mock('@/providers/utils', () => ({
getProviderFromModel: mockGetProviderFromModel,
}))
import {
getUserPermissionConfig,
IntegrationNotAllowedError,
validateBlockType,
} from './permission-check'
describe('IntegrationNotAllowedError', () => {
it.concurrent('creates error with correct name and message', () => {
const error = new IntegrationNotAllowedError('discord')
expect(error).toBeInstanceOf(Error)
expect(error.name).toBe('IntegrationNotAllowedError')
expect(error.message).toContain('discord')
})
it.concurrent('includes custom reason when provided', () => {
const error = new IntegrationNotAllowedError('discord', 'blocked by server policy')
expect(error.message).toContain('blocked by server policy')
})
})
describe('getUserPermissionConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null when no env allowlist is configured', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
const config = await getUserPermissionConfig('user-123')
expect(config).toBeNull()
})
it('returns config with env allowlist when configured', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
const config = await getUserPermissionConfig('user-123')
expect(config).not.toBeNull()
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
})
it('preserves default values for non-allowlist fields', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack'])
const config = await getUserPermissionConfig('user-123')
expect(config!.disableMcpTools).toBe(false)
expect(config!.allowedModelProviders).toBeNull()
})
})
describe('env allowlist fallback when userId is absent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns null allowlist when no userId and no env allowlist', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
const userId: string | undefined = undefined
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
expect(allowedIntegrations).toBeNull()
})
it('falls back to env allowlist when no userId is provided', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
const userId: string | undefined = undefined
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
expect(allowedIntegrations).toEqual(['slack', 'gmail'])
})
it('env allowlist filters block types when userId is absent', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
const userId: string | undefined = undefined
const permissionConfig = userId ? await getUserPermissionConfig(userId) : null
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? mockGetAllowedIntegrationsFromEnv()
expect(allowedIntegrations).not.toBeNull()
expect(allowedIntegrations!.includes('slack')).toBe(true)
expect(allowedIntegrations!.includes('discord')).toBe(false)
})
it('uses permission config when userId is present, ignoring env fallback', async () => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(['slack', 'gmail'])
const config = await getUserPermissionConfig('user-123')
expect(config).not.toBeNull()
expect(config!.allowedIntegrations).toEqual(['slack', 'gmail'])
})
})
describe('validateBlockType', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('when no env allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue(null)
})
it('allows any block type', async () => {
await validateBlockType(undefined, 'google_drive')
})
it('allows multi-word block types', async () => {
await validateBlockType(undefined, 'microsoft_excel')
})
it('always allows start_trigger', async () => {
await validateBlockType(undefined, 'start_trigger')
})
})
describe('when env allowlist is configured', () => {
beforeEach(() => {
mockGetAllowedIntegrationsFromEnv.mockReturnValue([
'slack',
'google_drive',
'microsoft_excel',
])
})
it('allows block types on the allowlist', async () => {
await validateBlockType(undefined, 'slack')
await validateBlockType(undefined, 'google_drive')
await validateBlockType(undefined, 'microsoft_excel')
})
it('rejects block types not on the allowlist', async () => {
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(
IntegrationNotAllowedError
)
})
it('always allows start_trigger regardless of allowlist', async () => {
await validateBlockType(undefined, 'start_trigger')
})
it('matches case-insensitively', async () => {
await validateBlockType(undefined, 'Slack')
await validateBlockType(undefined, 'GOOGLE_DRIVE')
})
it('includes env reason in error when env allowlist is the source', async () => {
await expect(validateBlockType(undefined, 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
})
it('includes env reason even when userId is present if env is the source', async () => {
await expect(validateBlockType('user-123', 'discord')).rejects.toThrow(/ALLOWED_INTEGRATIONS/)
})
})
})
describe('service ID to block type normalization', () => {
it.concurrent('hyphenated service IDs match underscore block types after normalization', () => {
const allowedBlockTypes = [
'google_drive',
'microsoft_excel',
'microsoft_teams',
'google_sheets',
'google_docs',
'google_calendar',
'google_forms',
'microsoft_planner',
]
const serviceIds = [
'google-drive',
'microsoft-excel',
'microsoft-teams',
'google-sheets',
'google-docs',
'google-calendar',
'google-forms',
'microsoft-planner',
]
for (const serviceId of serviceIds) {
const normalized = serviceId.replace(/-/g, '_')
expect(allowedBlockTypes).toContain(normalized)
}
})
it.concurrent('single-word service IDs are unaffected by normalization', () => {
const serviceIds = ['slack', 'gmail', 'notion', 'discord', 'jira', 'trello']
for (const serviceId of serviceIds) {
const normalized = serviceId.replace(/-/g, '_')
expect(normalized).toBe(serviceId)
}
})
})

View File

@@ -3,8 +3,13 @@ import { member, permissionGroup, permissionGroupMember } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
import {
getAllowedIntegrationsFromEnv,
isAccessControlEnabled,
isHosted,
} from '@/lib/core/config/feature-flags'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
parsePermissionGroupConfig,
} from '@/lib/permission-groups/types'
@@ -23,8 +28,12 @@ export class ProviderNotAllowedError extends Error {
}
export class IntegrationNotAllowedError extends Error {
constructor(blockType: string) {
super(`Integration "${blockType}" is not allowed based on your permission group settings`)
constructor(blockType: string, reason?: string) {
super(
reason
? `Integration "${blockType}" is not allowed: ${reason}`
: `Integration "${blockType}" is not allowed based on your permission group settings`
)
this.name = 'IntegrationNotAllowedError'
}
}
@@ -57,11 +66,38 @@ export class InvitationsNotAllowedError extends Error {
}
}
/**
* Merges the env allowlist into a permission config.
* If `config` is null and no env allowlist is set, returns null.
* If `config` is null but env allowlist is set, returns a default config with only allowedIntegrations set.
* If both are set, intersects the two allowlists.
*/
function mergeEnvAllowlist(config: PermissionGroupConfig | null): PermissionGroupConfig | null {
const envAllowlist = getAllowedIntegrationsFromEnv()
if (envAllowlist === null) {
return config
}
if (config === null) {
return { ...DEFAULT_PERMISSION_GROUP_CONFIG, allowedIntegrations: envAllowlist }
}
const merged =
config.allowedIntegrations === null
? envAllowlist
: config.allowedIntegrations
.map((i) => i.toLowerCase())
.filter((i) => envAllowlist.includes(i))
return { ...config, allowedIntegrations: merged }
}
export async function getUserPermissionConfig(
userId: string
): Promise<PermissionGroupConfig | null> {
if (!isHosted && !isAccessControlEnabled) {
return null
return mergeEnvAllowlist(null)
}
const [membership] = await db
@@ -71,12 +107,12 @@ export async function getUserPermissionConfig(
.limit(1)
if (!membership) {
return null
return mergeEnvAllowlist(null)
}
const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId)
if (!isEnterprise) {
return null
return mergeEnvAllowlist(null)
}
const [groupMembership] = await db
@@ -92,10 +128,10 @@ export async function getUserPermissionConfig(
.limit(1)
if (!groupMembership) {
return null
return mergeEnvAllowlist(null)
}
return parsePermissionGroupConfig(groupMembership.config)
return mergeEnvAllowlist(parsePermissionGroupConfig(groupMembership.config))
}
export async function getPermissionConfig(
@@ -152,19 +188,25 @@ export async function validateBlockType(
return
}
if (!userId) {
return
}
const config = await getPermissionConfig(userId, ctx)
const config = userId ? await getPermissionConfig(userId, ctx) : mergeEnvAllowlist(null)
if (!config || config.allowedIntegrations === null) {
return
}
if (!config.allowedIntegrations.includes(blockType)) {
logger.warn('Integration blocked by permission group', { userId, blockType })
throw new IntegrationNotAllowedError(blockType)
if (!config.allowedIntegrations.includes(blockType.toLowerCase())) {
const envAllowlist = getAllowedIntegrationsFromEnv()
const blockedByEnv = envAllowlist !== null && !envAllowlist.includes(blockType.toLowerCase())
logger.warn(
blockedByEnv
? 'Integration blocked by env allowlist'
: 'Integration blocked by permission group',
{ userId, blockType }
)
throw new IntegrationNotAllowedError(
blockType,
blockedByEnv ? 'blocked by server ALLOWED_INTEGRATIONS policy' : undefined
)
}
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react'
import { createLogger } from '@sim/logger'
import { Check, ChevronDown, Copy, Eye, EyeOff } from 'lucide-react'
import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
import { Button, Combobox, Input, Switch, Textarea } from '@/components/emcn'
import { Skeleton } from '@/components/ui'
import { useSession } from '@/lib/auth/auth-client'
@@ -418,29 +418,29 @@ export function SSO() {
{/* Callback URL */}
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Callback URL
</span>
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{providerCallbackUrl}
</code>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Callback URL
</span>
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(providerCallbackUrl)}
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
{copied ? (
<Check className='h-[14px] w-[14px]' />
<Check className='h-[13px] w-[13px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
<Clipboard className='h-[13px] w-[13px]' />
)}
<span className='sr-only'>Copy callback URL</span>
</Button>
</div>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{providerCallbackUrl}
</code>
</div>
<p className='text-[13px] text-[var(--text-muted)]'>
Configure this in your identity provider
</p>
@@ -852,29 +852,29 @@ export function SSO() {
{/* Callback URL display */}
<div className='flex flex-col gap-[8px]'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Callback URL
</span>
<div className='relative'>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px] pr-[40px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{callbackUrl}
</code>
</div>
<div className='flex items-center justify-between'>
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
Callback URL
</span>
<Button
type='button'
variant='ghost'
onClick={() => copyToClipboard(callbackUrl)}
className='-translate-y-1/2 absolute top-1/2 right-[4px] h-[28px] w-[28px] rounded-[4px] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
>
{copied ? (
<Check className='h-[14px] w-[14px]' />
<Check className='h-[13px] w-[13px]' />
) : (
<Copy className='h-[14px] w-[14px]' />
<Clipboard className='h-[13px] w-[13px]' />
)}
<span className='sr-only'>Copy callback URL</span>
</Button>
</div>
<div className='flex h-9 items-center rounded-[6px] border bg-[var(--surface-1)] px-[10px]'>
<code className='flex-1 truncate font-mono text-[13px] text-[var(--text-primary)]'>
{callbackUrl}
</code>
</div>
<p className='text-[13px] text-[var(--text-muted)]'>
Configure this in your identity provider
</p>

View File

@@ -17,6 +17,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({
isDev: true,
isTest: false,
getCostMultiplier: vi.fn().mockReturnValue(1),
getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null),
isEmailVerificationEnabled: false,
isBillingEnabled: false,
isOrganizationsEnabled: false,

View File

@@ -1,6 +1,7 @@
'use client'
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import { isAccessControlEnabled, isHosted } from '@/lib/core/config/feature-flags'
import {
@@ -21,12 +22,44 @@ export interface PermissionConfigResult {
isInvitationsDisabled: boolean
}
interface AllowedIntegrationsResponse {
allowedIntegrations: string[] | null
}
function useAllowedIntegrationsFromEnv() {
return useQuery<AllowedIntegrationsResponse>({
queryKey: ['allowedIntegrations', 'env'],
queryFn: async () => {
const response = await fetch('/api/settings/allowed-integrations')
if (!response.ok) return { allowedIntegrations: null }
return response.json()
},
staleTime: 5 * 60 * 1000,
})
}
/**
* Intersects two allowlists. If either is null (unrestricted), returns the other.
* If both are set, returns only items present in both.
*/
function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null {
if (a === null) return b
if (b === null) return a.map((i) => i.toLowerCase())
return a.map((i) => i.toLowerCase()).filter((i) => b.includes(i))
}
export function usePermissionConfig(): PermissionConfigResult {
const accessControlDisabled = !isHosted && !isAccessControlEnabled
const { data: organizationsData } = useOrganizations()
const activeOrganization = organizationsData?.activeOrganization
const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id)
const { data: permissionData, isLoading: isPermissionLoading } = useUserPermissionConfig(
activeOrganization?.id
)
const { data: envAllowlistData, isLoading: isEnvAllowlistLoading } =
useAllowedIntegrationsFromEnv()
const isLoading = isPermissionLoading || isEnvAllowlistLoading
const config = useMemo(() => {
if (accessControlDisabled) {
@@ -40,13 +73,18 @@ export function usePermissionConfig(): PermissionConfigResult {
const isInPermissionGroup = !accessControlDisabled && !!permissionData?.permissionGroupId
const mergedAllowedIntegrations = useMemo(() => {
const envAllowlist = envAllowlistData?.allowedIntegrations ?? null
return intersectAllowlists(config.allowedIntegrations, envAllowlist)
}, [config.allowedIntegrations, envAllowlistData])
const isBlockAllowed = useMemo(() => {
return (blockType: string) => {
if (blockType === 'start_trigger') return true
if (config.allowedIntegrations === null) return true
return config.allowedIntegrations.includes(blockType)
if (mergedAllowedIntegrations === null) return true
return mergedAllowedIntegrations.includes(blockType.toLowerCase())
}
}, [config.allowedIntegrations])
}, [mergedAllowedIntegrations])
const isProviderAllowed = useMemo(() => {
return (providerId: string) => {
@@ -57,13 +95,14 @@ export function usePermissionConfig(): PermissionConfigResult {
const filterBlocks = useMemo(() => {
return <T extends { type: string }>(blocks: T[]): T[] => {
if (config.allowedIntegrations === null) return blocks
if (mergedAllowedIntegrations === null) return blocks
return blocks.filter(
(block) =>
block.type === 'start_trigger' || config.allowedIntegrations!.includes(block.type)
block.type === 'start_trigger' ||
mergedAllowedIntegrations.includes(block.type.toLowerCase())
)
}
}, [config.allowedIntegrations])
}, [mergedAllowedIntegrations])
const filterProviders = useMemo(() => {
return (providerIds: string[]): string[] => {
@@ -77,9 +116,14 @@ export function usePermissionConfig(): PermissionConfigResult {
return featureFlagDisabled || config.disableInvitations
}, [config.disableInvitations])
const mergedConfig = useMemo(
() => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }),
[config, mergedAllowedIntegrations]
)
return useMemo(
() => ({
config,
config: mergedConfig,
isLoading,
isInPermissionGroup,
filterBlocks,
@@ -89,7 +133,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isInvitationsDisabled,
}),
[
config,
mergedConfig,
isLoading,
isInPermissionGroup,
filterBlocks,

View File

@@ -0,0 +1,272 @@
/**
* @vitest-environment node
*/
import { auditMock, databaseMock, loggerMock } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => ({
...databaseMock,
auditLog: { id: 'id', workspaceId: 'workspace_id' },
}))
vi.mock('@sim/logger', () => loggerMock)
vi.mock('nanoid', () => ({ nanoid: () => 'test-id-123' }))
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
describe('AuditAction', () => {
it('contains all expected action categories', () => {
expect(AuditAction.WORKFLOW_CREATED).toBe('workflow.created')
expect(AuditAction.MEMBER_INVITED).toBe('member.invited')
expect(AuditAction.API_KEY_CREATED).toBe('api_key.created')
expect(AuditAction.ORGANIZATION_CREATED).toBe('organization.created')
})
it('has unique values for every key', () => {
const values = Object.values(AuditAction)
const unique = new Set(values)
expect(unique.size).toBe(values.length)
})
})
describe('AuditResourceType', () => {
it('contains all expected resource types', () => {
expect(AuditResourceType.WORKFLOW).toBe('workflow')
expect(AuditResourceType.WORKSPACE).toBe('workspace')
expect(AuditResourceType.API_KEY).toBe('api_key')
expect(AuditResourceType.MCP_SERVER).toBe('mcp_server')
})
it('has unique values for every key', () => {
const values = Object.values(AuditResourceType)
const unique = new Set(values)
expect(unique.size).toBe(values.length)
})
})
describe('recordAudit', () => {
const mockInsert = databaseMock.db.insert
let mockValues: ReturnType<typeof vi.fn>
beforeEach(() => {
vi.clearAllMocks()
mockValues = vi.fn(() => Promise.resolve())
mockInsert.mockReturnValue({ values: mockValues })
})
afterEach(() => {
vi.restoreAllMocks()
})
it('inserts an audit log entry with all required fields', async () => {
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.WORKFLOW_CREATED,
resourceType: AuditResourceType.WORKFLOW,
resourceId: 'wf-1',
})
await vi.waitFor(() => {
expect(mockInsert).toHaveBeenCalledTimes(1)
})
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
id: 'test-id-123',
workspaceId: 'ws-1',
actorId: 'user-1',
action: 'workflow.created',
resourceType: 'workflow',
resourceId: 'wf-1',
metadata: {},
})
)
})
it('includes optional denormalized fields when provided', async () => {
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.FOLDER_CREATED,
resourceType: AuditResourceType.FOLDER,
resourceId: 'folder-1',
actorName: 'Waleed',
actorEmail: 'waleed@example.com',
resourceName: 'My Folder',
description: 'Created folder "My Folder"',
})
await vi.waitFor(() => {
expect(mockValues).toHaveBeenCalledTimes(1)
})
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
actorName: 'Waleed',
actorEmail: 'waleed@example.com',
resourceName: 'My Folder',
description: 'Created folder "My Folder"',
})
)
})
it('sets optional fields to undefined when not provided', async () => {
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.WORKSPACE_DELETED,
resourceType: AuditResourceType.WORKSPACE,
})
await vi.waitFor(() => {
expect(mockValues).toHaveBeenCalledTimes(1)
})
const insertedValues = mockValues.mock.calls[0][0]
expect(insertedValues.resourceId).toBeUndefined()
expect(insertedValues.actorName).toBeUndefined()
expect(insertedValues.actorEmail).toBeUndefined()
expect(insertedValues.resourceName).toBeUndefined()
expect(insertedValues.description).toBeUndefined()
})
it('extracts IP address from x-forwarded-for header', async () => {
const request = new Request('https://example.com', {
headers: {
'x-forwarded-for': '1.2.3.4, 5.6.7.8',
'user-agent': 'TestAgent/1.0',
},
})
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.MEMBER_INVITED,
resourceType: AuditResourceType.WORKSPACE,
request,
})
await vi.waitFor(() => {
expect(mockValues).toHaveBeenCalledTimes(1)
})
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
ipAddress: '1.2.3.4',
userAgent: 'TestAgent/1.0',
})
)
})
it('falls back to x-real-ip when x-forwarded-for is absent', async () => {
const request = new Request('https://example.com', {
headers: { 'x-real-ip': '10.0.0.1' },
})
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
request,
})
await vi.waitFor(() => {
expect(mockValues).toHaveBeenCalledTimes(1)
})
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
ipAddress: '10.0.0.1',
userAgent: undefined,
})
)
})
it('defaults metadata to empty object when not provided', async () => {
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.ENVIRONMENT_UPDATED,
resourceType: AuditResourceType.ENVIRONMENT,
})
await vi.waitFor(() => {
expect(mockValues).toHaveBeenCalledTimes(1)
})
expect(mockValues).toHaveBeenCalledWith(expect.objectContaining({ metadata: {} }))
})
it('passes through metadata when provided', async () => {
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.WEBHOOK_CREATED,
resourceType: AuditResourceType.WEBHOOK,
metadata: { provider: 'github', workflowId: 'wf-1' },
})
await vi.waitFor(() => {
expect(mockValues).toHaveBeenCalledTimes(1)
})
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({
metadata: { provider: 'github', workflowId: 'wf-1' },
})
)
})
it('does not throw when the database insert fails', async () => {
mockValues.mockReturnValue(Promise.reject(new Error('DB connection lost')))
expect(() => {
recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.WORKFLOW_DELETED,
resourceType: AuditResourceType.WORKFLOW,
})
}).not.toThrow()
})
it('does not block — returns void synchronously', () => {
const result = recordAudit({
workspaceId: 'ws-1',
actorId: 'user-1',
action: AuditAction.CHAT_DEPLOYED,
resourceType: AuditResourceType.CHAT,
})
expect(result).toBeUndefined()
})
})
describe('auditMock sync', () => {
it('has the same AuditAction keys as the source', () => {
const sourceKeys = Object.keys(AuditAction).sort()
const mockKeys = Object.keys(auditMock.AuditAction).sort()
expect(mockKeys).toEqual(sourceKeys)
})
it('has the same AuditAction values as the source', () => {
for (const key of Object.keys(AuditAction)) {
expect(auditMock.AuditAction[key]).toBe(AuditAction[key as keyof typeof AuditAction])
}
})
it('has the same AuditResourceType keys as the source', () => {
const sourceKeys = Object.keys(AuditResourceType).sort()
const mockKeys = Object.keys(auditMock.AuditResourceType).sort()
expect(mockKeys).toEqual(sourceKeys)
})
it('has the same AuditResourceType values as the source', () => {
for (const key of Object.keys(AuditResourceType)) {
expect(auditMock.AuditResourceType[key]).toBe(
AuditResourceType[key as keyof typeof AuditResourceType]
)
}
})
})

Some files were not shown because too many files have changed in this diff Show More