mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-22 03:01:08 -05:00
Compare commits
2 Commits
improvemen
...
v0.5.92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da46a387c9 | ||
|
|
b7e377ec4b |
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, createMockLogger, createMockRequest } from '@sim/testing'
|
||||
import { createMockLogger, createMockRequest } from '@sim/testing'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('OAuth Disconnect API Route', () => {
|
||||
@@ -67,8 +67,6 @@ describe('OAuth Disconnect API Route', () => {
|
||||
vi.doMock('@/lib/webhooks/utils.server', () => ({
|
||||
syncAllWebhooksForCredentialSet: mockSyncAllWebhooksForCredentialSet,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/audit/log', () => auditMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -119,20 +118,6 @@ 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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -58,17 +57,6 @@ 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 })
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, loggerMock } from '@sim/testing'
|
||||
import { 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,
|
||||
@@ -218,11 +216,8 @@ describe('Chat Edit API Route', () => {
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
chat: mockChat,
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
mockLimit.mockResolvedValueOnce([]) // No identifier conflict
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
@@ -316,11 +311,8 @@ describe('Chat Edit API Route', () => {
|
||||
workflowId: 'workflow-123',
|
||||
}
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
chat: mockChat,
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true, chat: mockChat })
|
||||
mockLimit.mockResolvedValueOnce([])
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'PATCH',
|
||||
@@ -379,11 +371,8 @@ describe('Chat Edit API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'DELETE',
|
||||
@@ -404,11 +393,8 @@ describe('Chat Edit API Route', () => {
|
||||
}),
|
||||
}))
|
||||
|
||||
mockCheckChatAccess.mockResolvedValue({
|
||||
hasAccess: true,
|
||||
chat: { title: 'Test Chat', workflowId: 'workflow-123' },
|
||||
workspaceId: 'workspace-123',
|
||||
})
|
||||
mockCheckChatAccess.mockResolvedValue({ hasAccess: true })
|
||||
mockWhere.mockResolvedValue(undefined)
|
||||
|
||||
const req = new NextRequest('http://localhost:3000/api/chat/manage/chat-123', {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -104,11 +103,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
try {
|
||||
const validatedData = chatUpdateSchema.parse(body)
|
||||
|
||||
const {
|
||||
hasAccess,
|
||||
chat: existingChatRecord,
|
||||
workspaceId: chatWorkspaceId,
|
||||
} = await checkChatAccess(chatId, session.user.id)
|
||||
const { hasAccess, chat: existingChatRecord } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (!hasAccess || !existingChatRecord) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
@@ -222,19 +217,6 @@ 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,
|
||||
@@ -270,11 +252,7 @@ export async function DELETE(
|
||||
return createErrorResponse('Unauthorized', 401)
|
||||
}
|
||||
|
||||
const {
|
||||
hasAccess,
|
||||
chat: chatRecord,
|
||||
workspaceId: chatWorkspaceId,
|
||||
} = await checkChatAccess(chatId, session.user.id)
|
||||
const { hasAccess } = await checkChatAccess(chatId, session.user.id)
|
||||
|
||||
if (!hasAccess) {
|
||||
return createErrorResponse('Chat not found or access denied', 404)
|
||||
@@ -284,19 +262,6 @@ 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',
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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', () => {
|
||||
@@ -31,8 +30,6 @@ 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,
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -43,7 +42,7 @@ const chatSchema = z.object({
|
||||
.default([]),
|
||||
})
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
|
||||
@@ -175,7 +174,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: session.user.id,
|
||||
identifier,
|
||||
title,
|
||||
description: description || null,
|
||||
description: description || '',
|
||||
customizations: mergedCustomizations,
|
||||
isActive: true,
|
||||
authType,
|
||||
@@ -225,20 +224,6 @@ 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,
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForChatCreation(
|
||||
export async function checkChatAccess(
|
||||
chatId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> {
|
||||
): Promise<{ hasAccess: boolean; chat?: any }> {
|
||||
const chatData = await db
|
||||
.select({
|
||||
chat: chat,
|
||||
@@ -78,9 +78,7 @@ export async function checkChatAccess(
|
||||
action: 'admin',
|
||||
})
|
||||
|
||||
return authorization.allowed
|
||||
? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId }
|
||||
: { hasAccess: false }
|
||||
return authorization.allowed ? { hasAccess: true, chat: chatRecord } : { hasAccess: false }
|
||||
}
|
||||
|
||||
export async function validateChatAuth(
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -149,19 +148,6 @@ 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)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -176,19 +175,6 @@ 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,
|
||||
@@ -249,19 +235,6 @@ 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)
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -14,7 +13,6 @@ async function getCredentialSetWithAccess(credentialSetId: string, userId: strin
|
||||
const [set] = await db
|
||||
.select({
|
||||
id: credentialSet.id,
|
||||
name: credentialSet.name,
|
||||
organizationId: credentialSet.organizationId,
|
||||
providerId: credentialSet.providerId,
|
||||
})
|
||||
@@ -179,19 +177,6 @@ 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)
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
|
||||
@@ -132,19 +131,6 @@ 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) {
|
||||
@@ -189,19 +175,6 @@ 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)
|
||||
|
||||
@@ -8,7 +8,6 @@ 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'
|
||||
|
||||
@@ -79,7 +78,6 @@ 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)
|
||||
@@ -127,6 +125,7 @@ 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(),
|
||||
@@ -148,6 +147,8 @@ 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)
|
||||
@@ -165,6 +166,7 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ tok
|
||||
)
|
||||
}
|
||||
|
||||
// Sync webhooks within the transaction
|
||||
const syncResult = await syncAllWebhooksForCredentialSet(
|
||||
invitation.credentialSetId,
|
||||
requestId,
|
||||
@@ -182,19 +184,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
|
||||
@@ -107,17 +106,6 @@ 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'
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
|
||||
@@ -166,19 +165,6 @@ 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) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -54,17 +53,6 @@ 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) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -116,19 +115,6 @@ 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,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
createMockRequest,
|
||||
type MockUser,
|
||||
mockAuth,
|
||||
@@ -13,8 +12,6 @@ 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
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
|
||||
@@ -168,19 +167,6 @@ 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,
|
||||
|
||||
@@ -3,17 +3,9 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
createMockRequest,
|
||||
mockAuth,
|
||||
mockConsoleLogger,
|
||||
setupCommonApiMocks,
|
||||
} from '@sim/testing'
|
||||
import { 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
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
|
||||
@@ -120,20 +119,6 @@ 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 })
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -103,11 +102,7 @@ export async function PATCH(request: NextRequest, { params }: { params: Promise<
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
hasAccess,
|
||||
form: formRecord,
|
||||
workspaceId: formWorkspaceId,
|
||||
} = await checkFormAccess(id, session.user.id)
|
||||
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
|
||||
|
||||
if (!hasAccess || !formRecord) {
|
||||
return createErrorResponse('Form not found or access denied', 404)
|
||||
@@ -189,19 +184,6 @@ 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',
|
||||
})
|
||||
@@ -231,11 +213,7 @@ export async function DELETE(
|
||||
|
||||
const { id } = await params
|
||||
|
||||
const {
|
||||
hasAccess,
|
||||
form: formRecord,
|
||||
workspaceId: formWorkspaceId,
|
||||
} = await checkFormAccess(id, session.user.id)
|
||||
const { hasAccess, form: formRecord } = await checkFormAccess(id, session.user.id)
|
||||
|
||||
if (!hasAccess || !formRecord) {
|
||||
return createErrorResponse('Form not found or access denied', 404)
|
||||
@@ -245,19 +223,6 @@ 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',
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -179,7 +178,7 @@ export async function POST(request: NextRequest) {
|
||||
userId: session.user.id,
|
||||
identifier,
|
||||
title,
|
||||
description: description || null,
|
||||
description: description || '',
|
||||
customizations: mergedCustomizations,
|
||||
isActive: true,
|
||||
authType,
|
||||
@@ -196,19 +195,6 @@ 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,
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function checkWorkflowAccessForFormCreation(
|
||||
export async function checkFormAccess(
|
||||
formId: string,
|
||||
userId: string
|
||||
): Promise<{ hasAccess: boolean; form?: any; workspaceId?: string }> {
|
||||
): Promise<{ hasAccess: boolean; form?: any }> {
|
||||
const formData = await db
|
||||
.select({ form: form, workflowWorkspaceId: workflow.workspaceId })
|
||||
.from(form)
|
||||
@@ -75,9 +75,7 @@ export async function checkFormAccess(
|
||||
action: 'admin',
|
||||
})
|
||||
|
||||
return authorization.allowed
|
||||
? { hasAccess: true, form: formRecord, workspaceId: workflowWorkspaceId }
|
||||
: { hasAccess: false }
|
||||
return authorization.allowed ? { hasAccess: true, form: formRecord } : { hasAccess: false }
|
||||
}
|
||||
|
||||
export async function validateFormAuth(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
createMockRequest,
|
||||
mockAuth,
|
||||
mockConsoleLogger,
|
||||
@@ -36,8 +35,6 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
vi.mock('@/lib/audit/log', () => auditMock)
|
||||
|
||||
describe('Document By ID API Route', () => {
|
||||
const mockAuth$ = mockAuth()
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -198,19 +197,6 @@ 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,
|
||||
@@ -271,19 +257,6 @@ 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,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
createMockRequest,
|
||||
mockAuth,
|
||||
mockConsoleLogger,
|
||||
@@ -41,8 +40,6 @@ vi.mock('@/lib/knowledge/documents/service', () => ({
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
vi.mock('@/lib/audit/log', () => auditMock)
|
||||
|
||||
describe('Knowledge Base Documents API Route', () => {
|
||||
const mockAuth$ = mockAuth()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -245,19 +244,6 @@ 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: {
|
||||
@@ -306,19 +292,6 @@ 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,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
createMockRequest,
|
||||
mockAuth,
|
||||
mockConsoleLogger,
|
||||
@@ -17,8 +16,6 @@ mockKnowledgeSchemas()
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
vi.mock('@/lib/audit/log', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/knowledge/service', () => ({
|
||||
getKnowledgeBaseById: vi.fn(),
|
||||
updateKnowledgeBase: vi.fn(),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -136,19 +135,6 @@ 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,
|
||||
@@ -211,19 +197,6 @@ 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' },
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
createMockRequest,
|
||||
mockAuth,
|
||||
mockConsoleLogger,
|
||||
@@ -17,8 +16,6 @@ mockKnowledgeSchemas()
|
||||
mockDrizzleOrm()
|
||||
mockConsoleLogger()
|
||||
|
||||
vi.mock('@/lib/audit/log', () => auditMock)
|
||||
|
||||
vi.mock('@/lib/workspaces/permissions/utils', () => ({
|
||||
getUserEntityPermissions: vi.fn().mockResolvedValue('admin'),
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -110,20 +109,6 @@ 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,
|
||||
|
||||
@@ -99,7 +99,7 @@ export interface EmbeddingData {
|
||||
|
||||
export interface KnowledgeBaseAccessResult {
|
||||
hasAccess: true
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
||||
}
|
||||
|
||||
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' | 'name'>
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
||||
}
|
||||
|
||||
export interface DocumentAccessDenied {
|
||||
@@ -128,7 +128,7 @@ export interface ChunkAccessResult {
|
||||
hasAccess: true
|
||||
chunk: EmbeddingData
|
||||
document: DocumentData
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId' | 'name'>
|
||||
knowledgeBase: Pick<KnowledgeBaseData, 'id' | 'userId' | 'workspaceId'>
|
||||
}
|
||||
|
||||
export interface ChunkAccessDenied {
|
||||
@@ -151,7 +151,6 @@ 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)))
|
||||
@@ -194,7 +193,6 @@ 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)))
|
||||
|
||||
@@ -3,8 +3,6 @@ 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'
|
||||
@@ -17,11 +15,7 @@ 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, userName, userEmail, workspaceId, requestId },
|
||||
{ params }
|
||||
) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
const { id: serverId } = await params
|
||||
|
||||
try {
|
||||
@@ -35,17 +29,6 @@ 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 })
|
||||
@@ -90,20 +73,6 @@ 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)
|
||||
|
||||
@@ -3,8 +3,6 @@ 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 {
|
||||
@@ -56,7 +54,7 @@ export const GET = withMcpAuth('read')(
|
||||
* it will be updated instead of creating a duplicate.
|
||||
*/
|
||||
export const POST = withMcpAuth('write')(
|
||||
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
@@ -74,15 +72,6 @@ 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
|
||||
@@ -162,20 +151,6 @@ 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)
|
||||
@@ -192,7 +167,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, userName, userEmail, workspaceId, requestId }) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const serverId = searchParams.get('serverId')
|
||||
@@ -223,20 +198,6 @@ 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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -72,15 +71,6 @@ 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}`,
|
||||
@@ -105,16 +95,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -72,11 +71,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
||||
* PATCH - Update a workflow MCP server
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ userId, userName, userEmail, workspaceId, requestId },
|
||||
{ params }
|
||||
) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
@@ -117,19 +112,6 @@ 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)
|
||||
@@ -146,11 +128,7 @@ 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, userName, userEmail, workspaceId, requestId },
|
||||
{ params }
|
||||
) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
|
||||
@@ -171,19 +149,6 @@ 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)
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -66,11 +65,7 @@ export const GET = withMcpAuth<RouteParams>('read')(
|
||||
* PATCH - Update a tool's configuration
|
||||
*/
|
||||
export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ userId, userName, userEmail, workspaceId, requestId },
|
||||
{ params }
|
||||
) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
@@ -123,19 +118,6 @@ 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)
|
||||
@@ -152,11 +134,7 @@ export const PATCH = withMcpAuth<RouteParams>('write')(
|
||||
* DELETE - Remove a tool from an MCP server
|
||||
*/
|
||||
export const DELETE = withMcpAuth<RouteParams>('write')(
|
||||
async (
|
||||
request: NextRequest,
|
||||
{ userId, userName, userEmail, workspaceId, requestId },
|
||||
{ params }
|
||||
) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId, toolId } = await params
|
||||
|
||||
@@ -187,19 +165,6 @@ 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)
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -77,11 +76,7 @@ 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, userName, userEmail, workspaceId, requestId },
|
||||
{ params }
|
||||
) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }, { params }) => {
|
||||
try {
|
||||
const { id: serverId } = await params
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
@@ -202,19 +197,6 @@ 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)
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -86,7 +85,7 @@ export const GET = withMcpAuth('read')(
|
||||
* POST - Create a new workflow MCP server
|
||||
*/
|
||||
export const POST = withMcpAuth('write')(
|
||||
async (request: NextRequest, { userId, userName, userEmail, workspaceId, requestId }) => {
|
||||
async (request: NextRequest, { userId, workspaceId, requestId }) => {
|
||||
try {
|
||||
const body = getParsedBody(request) || (await request.json())
|
||||
|
||||
@@ -189,19 +188,6 @@ 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)
|
||||
|
||||
@@ -18,7 +18,6 @@ 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'
|
||||
@@ -553,25 +552,6 @@ 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`,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
renderBatchInvitationEmail,
|
||||
renderInvitationEmail,
|
||||
} from '@/components/emails'
|
||||
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import {
|
||||
validateBulkInvitations,
|
||||
@@ -412,22 +411,6 @@ 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`,
|
||||
@@ -549,19 +532,6 @@ 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',
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -214,19 +213,6 @@ 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',
|
||||
@@ -319,22 +305,6 @@ 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:
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -286,19 +285,6 @@ 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}`,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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,
|
||||
@@ -193,20 +192,6 @@ 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',
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
|
||||
@@ -116,19 +115,6 @@ 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,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
|
||||
@@ -14,7 +13,6 @@ async function getPermissionGroupWithAccess(groupId: string, userId: string) {
|
||||
const [group] = await db
|
||||
.select({
|
||||
id: permissionGroup.id,
|
||||
name: permissionGroup.name,
|
||||
organizationId: permissionGroup.organizationId,
|
||||
})
|
||||
.from(permissionGroup)
|
||||
@@ -153,20 +151,6 @@ 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) {
|
||||
@@ -237,20 +221,6 @@ 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)
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -182,19 +181,6 @@ 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,
|
||||
@@ -243,19 +229,6 @@ 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)
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -199,19 +198,6 @@ 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) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { auditMock, databaseMock, loggerMock } from '@sim/testing'
|
||||
import { databaseMock, loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -37,8 +37,6 @@ 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 {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -107,18 +106,6 @@ 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,
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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(),
|
||||
})
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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 })
|
||||
}
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
@@ -248,18 +247,6 @@ 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',
|
||||
@@ -313,19 +300,6 @@ 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)
|
||||
|
||||
@@ -11,7 +11,6 @@ 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'
|
||||
@@ -286,18 +285,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
|
||||
@@ -35,27 +34,12 @@ export async function DELETE(
|
||||
const result = await db
|
||||
.delete(apiKey)
|
||||
.where(and(eq(apiKey.id, keyId), eq(apiKey.userId, userId)))
|
||||
.returning({ id: apiKey.id, name: apiKey.name })
|
||||
.returning({ id: apiKey.id })
|
||||
|
||||
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 })
|
||||
|
||||
@@ -5,7 +5,6 @@ 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')
|
||||
@@ -111,19 +110,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -262,20 +261,6 @@ 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`, {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -146,8 +145,7 @@ export async function GET(request: NextRequest) {
|
||||
// Create or Update a webhook
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = generateRequestId()
|
||||
const session = await getSession()
|
||||
const userId = session?.user?.id
|
||||
const userId = (await getSession())?.user?.id
|
||||
|
||||
if (!userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized webhook creation attempt`)
|
||||
@@ -680,20 +678,6 @@ 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
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 {
|
||||
@@ -259,19 +258,6 @@ 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'
|
||||
@@ -311,11 +297,11 @@ export async function DELETE(
|
||||
try {
|
||||
logger.debug(`[${requestId}] Undeploying workflow: ${id}`)
|
||||
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowData,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
const { error, workflow: workflowData } = await validateWorkflowPermissions(
|
||||
id,
|
||||
requestId,
|
||||
'admin'
|
||||
)
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
@@ -339,19 +325,6 @@ 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,
|
||||
|
||||
@@ -2,7 +2,6 @@ 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'
|
||||
@@ -23,11 +22,7 @@ export async function POST(
|
||||
const { id, version } = await params
|
||||
|
||||
try {
|
||||
const {
|
||||
error,
|
||||
session,
|
||||
workflow: workflowRecord,
|
||||
} = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
const { error } = await validateWorkflowPermissions(id, requestId, 'admin')
|
||||
if (error) {
|
||||
return createErrorResponse(error.message, error.status)
|
||||
}
|
||||
@@ -112,19 +107,6 @@ 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(),
|
||||
|
||||
@@ -3,7 +3,6 @@ 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'
|
||||
@@ -298,19 +297,6 @@ 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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -62,20 +61,6 @@ 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) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { auditMock, loggerMock, setupGlobalFetchMock } from '@sim/testing'
|
||||
import { loggerMock, setupGlobalFetchMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -23,8 +23,6 @@ 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),
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -337,19 +336,6 @@ 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
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import {
|
||||
auditMock,
|
||||
databaseMock,
|
||||
defaultMockUser,
|
||||
mockAuth,
|
||||
@@ -28,8 +27,6 @@ describe('Workflow Variables API Route', () => {
|
||||
|
||||
vi.doMock('@sim/db', () => databaseMock)
|
||||
|
||||
vi.doMock('@/lib/audit/log', () => auditMock)
|
||||
|
||||
vi.doMock('@/lib/workflows/utils', () => ({
|
||||
authorizeWorkflowByWorkspacePermission: mockAuthorizeWorkflowByWorkspacePermission,
|
||||
}))
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -80,19 +79,6 @@ 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) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -189,20 +188,6 @@ 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,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -87,19 +86,6 @@ 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) {
|
||||
@@ -137,27 +123,12 @@ export async function DELETE(
|
||||
.where(
|
||||
and(eq(apiKey.workspaceId, workspaceId), eq(apiKey.id, keyId), eq(apiKey.type, 'workspace'))
|
||||
)
|
||||
.returning({ id: apiKey.id, name: apiKey.name })
|
||||
.returning({ id: apiKey.id })
|
||||
|
||||
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) {
|
||||
|
||||
@@ -6,7 +6,6 @@ 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'
|
||||
@@ -160,20 +159,6 @@ 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,
|
||||
@@ -237,19 +222,6 @@ 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)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -186,20 +185,6 @@ 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: {
|
||||
@@ -257,19 +242,6 @@ 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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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'
|
||||
@@ -46,19 +45,6 @@ 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) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -157,19 +156,6 @@ 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)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -40,18 +39,6 @@ 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,
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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'
|
||||
@@ -105,19 +104,6 @@ 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,
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -252,19 +251,6 @@ 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,
|
||||
@@ -314,35 +300,17 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||
eq(workspaceNotificationSubscription.workspaceId, workspaceId)
|
||||
)
|
||||
)
|
||||
.returning({
|
||||
id: workspaceNotificationSubscription.id,
|
||||
notificationType: workspaceNotificationSubscription.notificationType,
|
||||
})
|
||||
.returning({ id: workspaceNotificationSubscription.id })
|
||||
|
||||
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 })
|
||||
|
||||
@@ -5,7 +5,6 @@ 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'
|
||||
@@ -257,19 +256,6 @@ 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,
|
||||
|
||||
@@ -5,7 +5,6 @@ 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,
|
||||
@@ -157,21 +156,6 @@ 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,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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')
|
||||
@@ -229,13 +228,6 @@ 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
|
||||
@@ -289,19 +281,6 @@ 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auditMock, createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
|
||||
import { createSession, createWorkspaceRecord, loggerMock } from '@sim/testing'
|
||||
import { NextRequest } from 'next/server'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -55,8 +55,6 @@ 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'),
|
||||
}))
|
||||
|
||||
@@ -12,7 +12,6 @@ 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'
|
||||
@@ -163,19 +162,6 @@ 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()))
|
||||
}
|
||||
|
||||
@@ -230,19 +216,6 @@ 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { auditMock, createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing'
|
||||
import { createMockRequest, mockAuth, mockConsoleLogger } from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('Workspace Invitations API Route', () => {
|
||||
@@ -96,8 +96,6 @@ 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 })),
|
||||
|
||||
@@ -13,7 +13,6 @@ 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'
|
||||
@@ -215,20 +214,6 @@ 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) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
|
||||
@@ -102,19 +101,6 @@ 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)
|
||||
|
||||
@@ -4,7 +4,6 @@ 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'
|
||||
@@ -69,20 +68,6 @@ 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)
|
||||
|
||||
@@ -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, usePanelStore } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||
|
||||
@@ -341,20 +341,16 @@ 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 when copilot is active
|
||||
// Handle keyboard navigation - only for the active options selector
|
||||
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' ||
|
||||
@@ -391,15 +387,7 @@ export function OptionsSelector({
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
isInteractionDisabled,
|
||||
enableKeyboardNav,
|
||||
isLocked,
|
||||
sortedOptions,
|
||||
hoveredIndex,
|
||||
onSelect,
|
||||
activeTab,
|
||||
])
|
||||
}, [isInteractionDisabled, enableKeyboardNav, isLocked, sortedOptions, hoveredIndex, onSelect])
|
||||
|
||||
if (sortedOptions.length === 0) return null
|
||||
|
||||
|
||||
@@ -618,15 +618,6 @@ 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(
|
||||
|
||||
@@ -36,18 +36,17 @@ export function isBlockProtected(blockId: string, blocks: Record<string, BlockSt
|
||||
|
||||
/**
|
||||
* Checks if an edge is protected from modification.
|
||||
* An edge is protected only if its target block is protected.
|
||||
* Outbound connections from locked blocks are allowed to be modified.
|
||||
* An edge is protected if either its source or target block is protected.
|
||||
*
|
||||
* @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 (target is locked)
|
||||
* @returns True if the edge is protected
|
||||
*/
|
||||
export function isEdgeProtected(
|
||||
edge: { source: string; target: string },
|
||||
blocks: Record<string, BlockState>
|
||||
): boolean {
|
||||
return isBlockProtected(edge.target, blocks)
|
||||
return isBlockProtected(edge.source, blocks) || isBlockProtected(edge.target, blocks)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 targeting protected blocks
|
||||
// Prevent removing edges connected to 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 protected blocks (outbound from locked blocks is allowed)
|
||||
// Prevent connections to/from protected blocks
|
||||
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 targeting protected blocks
|
||||
// Prevent removing edges connected to protected blocks
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (edge && isEdgeProtected(edge, blocks)) {
|
||||
addNotification({
|
||||
level: 'info',
|
||||
message: 'Cannot remove connections to locked blocks',
|
||||
message: 'Cannot remove connections from 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 targeting protected blocks
|
||||
// Get all selected edge IDs and filter out edges connected to protected blocks
|
||||
const edgeIds = Array.from(selectedEdges.values()).filter((edgeId) => {
|
||||
const edge = edges.find((e) => e.id === edgeId)
|
||||
if (!edge) return true
|
||||
|
||||
@@ -223,11 +223,13 @@ 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.replace(/-/g, '_'))
|
||||
!permissionConfig.allowedIntegrations.includes(service.id)
|
||||
) {
|
||||
return acc
|
||||
}
|
||||
|
||||
@@ -106,40 +106,6 @@ 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',
|
||||
@@ -424,15 +390,6 @@ 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)
|
||||
@@ -1049,14 +1006,10 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
const showNoResults = searchTerm.trim() && filteredServers.length === 0 && servers.length > 0
|
||||
|
||||
const isFormValid = formData.name.trim() && formData.url?.trim()
|
||||
const isAddDomainBlocked =
|
||||
!!formData.url?.trim() && !isDomainAllowed(formData.url, allowedMcpDomains)
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid || isAddDomainBlocked
|
||||
const isSubmitDisabled = serversLoading || isAddingServer || !isFormValid
|
||||
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
|
||||
@@ -1346,11 +1299,6 @@ 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]'>
|
||||
@@ -1403,7 +1351,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleEditTestConnection}
|
||||
disabled={isEditTestingConnection || !isEditFormValid || isEditDomainBlocked}
|
||||
disabled={isEditTestingConnection || !isEditFormValid}
|
||||
>
|
||||
{editTestButtonLabel}
|
||||
</Button>
|
||||
@@ -1413,9 +1361,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={
|
||||
!hasEditChanges || isUpdatingServer || !isEditFormValid || isEditDomainBlocked
|
||||
}
|
||||
disabled={!hasEditChanges || isUpdatingServer || !isEditFormValid}
|
||||
variant='tertiary'
|
||||
>
|
||||
{isUpdatingServer ? 'Saving...' : 'Save'}
|
||||
@@ -1488,11 +1434,6 @@ 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]'>
|
||||
@@ -1538,7 +1479,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTestingConnection || !isFormValid || isAddDomainBlocked}
|
||||
disabled={isTestingConnection || !isFormValid}
|
||||
>
|
||||
{testButtonLabel}
|
||||
</Button>
|
||||
@@ -1548,9 +1489,7 @@ export function MCP({ initialServerId }: MCPProps) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddServer} disabled={isSubmitDisabled} variant='tertiary'>
|
||||
{isSubmitDisabled && isFormValid && !isAddDomainBlocked
|
||||
? 'Adding...'
|
||||
: 'Add Server'}
|
||||
{isSubmitDisabled && isFormValid ? 'Adding...' : 'Add Server'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -3,13 +3,8 @@ 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'
|
||||
@@ -28,12 +23,8 @@ export class ProviderNotAllowedError extends Error {
|
||||
}
|
||||
|
||||
export class IntegrationNotAllowedError extends Error {
|
||||
constructor(blockType: string, reason?: string) {
|
||||
super(
|
||||
reason
|
||||
? `Integration "${blockType}" is not allowed: ${reason}`
|
||||
: `Integration "${blockType}" is not allowed based on your permission group settings`
|
||||
)
|
||||
constructor(blockType: string) {
|
||||
super(`Integration "${blockType}" is not allowed based on your permission group settings`)
|
||||
this.name = 'IntegrationNotAllowedError'
|
||||
}
|
||||
}
|
||||
@@ -66,38 +57,11 @@ 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 mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
@@ -107,12 +71,12 @@ export async function getUserPermissionConfig(
|
||||
.limit(1)
|
||||
|
||||
if (!membership) {
|
||||
return mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
const isEnterprise = await isOrganizationOnEnterprisePlan(membership.organizationId)
|
||||
if (!isEnterprise) {
|
||||
return mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
const [groupMembership] = await db
|
||||
@@ -128,10 +92,10 @@ export async function getUserPermissionConfig(
|
||||
.limit(1)
|
||||
|
||||
if (!groupMembership) {
|
||||
return mergeEnvAllowlist(null)
|
||||
return null
|
||||
}
|
||||
|
||||
return mergeEnvAllowlist(parsePermissionGroupConfig(groupMembership.config))
|
||||
return parsePermissionGroupConfig(groupMembership.config)
|
||||
}
|
||||
|
||||
export async function getPermissionConfig(
|
||||
@@ -188,25 +152,19 @@ export async function validateBlockType(
|
||||
return
|
||||
}
|
||||
|
||||
const config = userId ? await getPermissionConfig(userId, ctx) : mergeEnvAllowlist(null)
|
||||
if (!userId) {
|
||||
return
|
||||
}
|
||||
|
||||
const config = await getPermissionConfig(userId, ctx)
|
||||
|
||||
if (!config || config.allowedIntegrations === null) {
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
if (!config.allowedIntegrations.includes(blockType)) {
|
||||
logger.warn('Integration blocked by permission group', { userId, blockType })
|
||||
throw new IntegrationNotAllowedError(blockType)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Check, ChevronDown, Clipboard, Eye, EyeOff } from 'lucide-react'
|
||||
import { Check, ChevronDown, Copy, 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]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Callback URL
|
||||
</span>
|
||||
<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>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => copyToClipboard(providerCallbackUrl)}
|
||||
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
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)]'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-[13px] w-[13px]' />
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[13px] w-[13px]' />
|
||||
<Copy className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<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]'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>
|
||||
Callback URL
|
||||
</span>
|
||||
<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>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
onClick={() => copyToClipboard(callbackUrl)}
|
||||
className='h-[22px] w-[22px] rounded-[4px] p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
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)]'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-[13px] w-[13px]' />
|
||||
<Check className='h-[14px] w-[14px]' />
|
||||
) : (
|
||||
<Clipboard className='h-[13px] w-[13px]' />
|
||||
<Copy className='h-[14px] w-[14px]' />
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -17,7 +17,6 @@ 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,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'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 {
|
||||
@@ -22,44 +21,12 @@ 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
|
||||
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: isPermissionLoading } = useUserPermissionConfig(
|
||||
activeOrganization?.id
|
||||
)
|
||||
const { data: envAllowlistData, isLoading: isEnvAllowlistLoading } =
|
||||
useAllowedIntegrationsFromEnv()
|
||||
|
||||
const isLoading = isPermissionLoading || isEnvAllowlistLoading
|
||||
const { data: permissionData, isLoading } = useUserPermissionConfig(activeOrganization?.id)
|
||||
|
||||
const config = useMemo(() => {
|
||||
if (accessControlDisabled) {
|
||||
@@ -73,18 +40,13 @@ 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 (mergedAllowedIntegrations === null) return true
|
||||
return mergedAllowedIntegrations.includes(blockType.toLowerCase())
|
||||
if (config.allowedIntegrations === null) return true
|
||||
return config.allowedIntegrations.includes(blockType)
|
||||
}
|
||||
}, [mergedAllowedIntegrations])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const isProviderAllowed = useMemo(() => {
|
||||
return (providerId: string) => {
|
||||
@@ -95,14 +57,13 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
|
||||
const filterBlocks = useMemo(() => {
|
||||
return <T extends { type: string }>(blocks: T[]): T[] => {
|
||||
if (mergedAllowedIntegrations === null) return blocks
|
||||
if (config.allowedIntegrations === null) return blocks
|
||||
return blocks.filter(
|
||||
(block) =>
|
||||
block.type === 'start_trigger' ||
|
||||
mergedAllowedIntegrations.includes(block.type.toLowerCase())
|
||||
block.type === 'start_trigger' || config.allowedIntegrations!.includes(block.type)
|
||||
)
|
||||
}
|
||||
}, [mergedAllowedIntegrations])
|
||||
}, [config.allowedIntegrations])
|
||||
|
||||
const filterProviders = useMemo(() => {
|
||||
return (providerIds: string[]): string[] => {
|
||||
@@ -116,14 +77,9 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
return featureFlagDisabled || config.disableInvitations
|
||||
}, [config.disableInvitations])
|
||||
|
||||
const mergedConfig = useMemo(
|
||||
() => ({ ...config, allowedIntegrations: mergedAllowedIntegrations }),
|
||||
[config, mergedAllowedIntegrations]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
config: mergedConfig,
|
||||
config,
|
||||
isLoading,
|
||||
isInPermissionGroup,
|
||||
filterBlocks,
|
||||
@@ -133,7 +89,7 @@ export function usePermissionConfig(): PermissionConfigResult {
|
||||
isInvitationsDisabled,
|
||||
}),
|
||||
[
|
||||
mergedConfig,
|
||||
config,
|
||||
isLoading,
|
||||
isInPermissionGroup,
|
||||
filterBlocks,
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
/**
|
||||
* @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]
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,225 +0,0 @@
|
||||
import { auditLog, db } from '@sim/db'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { nanoid } from 'nanoid'
|
||||
|
||||
const logger = createLogger('AuditLog')
|
||||
|
||||
/**
|
||||
* All auditable actions in the platform, grouped by resource type.
|
||||
*/
|
||||
export const AuditAction = {
|
||||
// API Keys
|
||||
API_KEY_CREATED: 'api_key.created',
|
||||
API_KEY_UPDATED: 'api_key.updated',
|
||||
API_KEY_REVOKED: 'api_key.revoked',
|
||||
PERSONAL_API_KEY_CREATED: 'personal_api_key.created',
|
||||
PERSONAL_API_KEY_REVOKED: 'personal_api_key.revoked',
|
||||
|
||||
// BYOK Keys
|
||||
BYOK_KEY_CREATED: 'byok_key.created',
|
||||
BYOK_KEY_DELETED: 'byok_key.deleted',
|
||||
|
||||
// Chat
|
||||
CHAT_DEPLOYED: 'chat.deployed',
|
||||
CHAT_UPDATED: 'chat.updated',
|
||||
CHAT_DELETED: 'chat.deleted',
|
||||
|
||||
// Billing
|
||||
CREDIT_PURCHASED: 'credit.purchased',
|
||||
|
||||
// Credential Sets
|
||||
CREDENTIAL_SET_CREATED: 'credential_set.created',
|
||||
CREDENTIAL_SET_UPDATED: 'credential_set.updated',
|
||||
CREDENTIAL_SET_DELETED: 'credential_set.deleted',
|
||||
CREDENTIAL_SET_MEMBER_REMOVED: 'credential_set_member.removed',
|
||||
CREDENTIAL_SET_MEMBER_LEFT: 'credential_set_member.left',
|
||||
CREDENTIAL_SET_INVITATION_CREATED: 'credential_set_invitation.created',
|
||||
CREDENTIAL_SET_INVITATION_ACCEPTED: 'credential_set_invitation.accepted',
|
||||
CREDENTIAL_SET_INVITATION_RESENT: 'credential_set_invitation.resent',
|
||||
CREDENTIAL_SET_INVITATION_REVOKED: 'credential_set_invitation.revoked',
|
||||
|
||||
// Documents
|
||||
DOCUMENT_UPLOADED: 'document.uploaded',
|
||||
DOCUMENT_UPDATED: 'document.updated',
|
||||
DOCUMENT_DELETED: 'document.deleted',
|
||||
|
||||
// Environment
|
||||
ENVIRONMENT_UPDATED: 'environment.updated',
|
||||
|
||||
// Files
|
||||
FILE_UPLOADED: 'file.uploaded',
|
||||
FILE_DELETED: 'file.deleted',
|
||||
|
||||
// Folders
|
||||
FOLDER_CREATED: 'folder.created',
|
||||
FOLDER_DELETED: 'folder.deleted',
|
||||
FOLDER_DUPLICATED: 'folder.duplicated',
|
||||
|
||||
// Forms
|
||||
FORM_CREATED: 'form.created',
|
||||
FORM_UPDATED: 'form.updated',
|
||||
FORM_DELETED: 'form.deleted',
|
||||
|
||||
// Invitations
|
||||
INVITATION_ACCEPTED: 'invitation.accepted',
|
||||
INVITATION_REVOKED: 'invitation.revoked',
|
||||
|
||||
// Knowledge Bases
|
||||
KNOWLEDGE_BASE_CREATED: 'knowledge_base.created',
|
||||
KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated',
|
||||
KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted',
|
||||
|
||||
// MCP Servers
|
||||
MCP_SERVER_ADDED: 'mcp_server.added',
|
||||
MCP_SERVER_UPDATED: 'mcp_server.updated',
|
||||
MCP_SERVER_REMOVED: 'mcp_server.removed',
|
||||
|
||||
// Members
|
||||
MEMBER_INVITED: 'member.invited',
|
||||
MEMBER_REMOVED: 'member.removed',
|
||||
MEMBER_ROLE_CHANGED: 'member.role_changed',
|
||||
|
||||
// Notifications
|
||||
NOTIFICATION_CREATED: 'notification.created',
|
||||
NOTIFICATION_UPDATED: 'notification.updated',
|
||||
NOTIFICATION_DELETED: 'notification.deleted',
|
||||
|
||||
// OAuth
|
||||
OAUTH_DISCONNECTED: 'oauth.disconnected',
|
||||
|
||||
// Password
|
||||
PASSWORD_RESET: 'password.reset',
|
||||
|
||||
// Organizations
|
||||
ORGANIZATION_CREATED: 'organization.created',
|
||||
ORGANIZATION_UPDATED: 'organization.updated',
|
||||
ORG_MEMBER_ADDED: 'org_member.added',
|
||||
ORG_MEMBER_REMOVED: 'org_member.removed',
|
||||
ORG_MEMBER_ROLE_CHANGED: 'org_member.role_changed',
|
||||
ORG_INVITATION_CREATED: 'org_invitation.created',
|
||||
ORG_INVITATION_ACCEPTED: 'org_invitation.accepted',
|
||||
ORG_INVITATION_REJECTED: 'org_invitation.rejected',
|
||||
ORG_INVITATION_CANCELLED: 'org_invitation.cancelled',
|
||||
ORG_INVITATION_REVOKED: 'org_invitation.revoked',
|
||||
|
||||
// Permission Groups
|
||||
PERMISSION_GROUP_CREATED: 'permission_group.created',
|
||||
PERMISSION_GROUP_UPDATED: 'permission_group.updated',
|
||||
PERMISSION_GROUP_DELETED: 'permission_group.deleted',
|
||||
PERMISSION_GROUP_MEMBER_ADDED: 'permission_group_member.added',
|
||||
PERMISSION_GROUP_MEMBER_REMOVED: 'permission_group_member.removed',
|
||||
|
||||
// Schedules
|
||||
SCHEDULE_UPDATED: 'schedule.updated',
|
||||
|
||||
// Templates
|
||||
TEMPLATE_CREATED: 'template.created',
|
||||
TEMPLATE_UPDATED: 'template.updated',
|
||||
TEMPLATE_DELETED: 'template.deleted',
|
||||
|
||||
// Webhooks
|
||||
WEBHOOK_CREATED: 'webhook.created',
|
||||
WEBHOOK_DELETED: 'webhook.deleted',
|
||||
|
||||
// Workflows
|
||||
WORKFLOW_CREATED: 'workflow.created',
|
||||
WORKFLOW_DELETED: 'workflow.deleted',
|
||||
WORKFLOW_DEPLOYED: 'workflow.deployed',
|
||||
WORKFLOW_UNDEPLOYED: 'workflow.undeployed',
|
||||
WORKFLOW_DUPLICATED: 'workflow.duplicated',
|
||||
WORKFLOW_DEPLOYMENT_ACTIVATED: 'workflow.deployment_activated',
|
||||
WORKFLOW_DEPLOYMENT_REVERTED: 'workflow.deployment_reverted',
|
||||
WORKFLOW_VARIABLES_UPDATED: 'workflow.variables_updated',
|
||||
|
||||
// Workspaces
|
||||
WORKSPACE_CREATED: 'workspace.created',
|
||||
WORKSPACE_DELETED: 'workspace.deleted',
|
||||
WORKSPACE_DUPLICATED: 'workspace.duplicated',
|
||||
} as const
|
||||
|
||||
export type AuditActionType = (typeof AuditAction)[keyof typeof AuditAction]
|
||||
|
||||
/**
|
||||
* All resource types that can appear in audit log entries.
|
||||
*/
|
||||
export const AuditResourceType = {
|
||||
API_KEY: 'api_key',
|
||||
BILLING: 'billing',
|
||||
BYOK_KEY: 'byok_key',
|
||||
CHAT: 'chat',
|
||||
CREDENTIAL_SET: 'credential_set',
|
||||
DOCUMENT: 'document',
|
||||
ENVIRONMENT: 'environment',
|
||||
FILE: 'file',
|
||||
FOLDER: 'folder',
|
||||
FORM: 'form',
|
||||
KNOWLEDGE_BASE: 'knowledge_base',
|
||||
MCP_SERVER: 'mcp_server',
|
||||
NOTIFICATION: 'notification',
|
||||
OAUTH: 'oauth',
|
||||
ORGANIZATION: 'organization',
|
||||
PASSWORD: 'password',
|
||||
PERMISSION_GROUP: 'permission_group',
|
||||
SCHEDULE: 'schedule',
|
||||
TEMPLATE: 'template',
|
||||
WEBHOOK: 'webhook',
|
||||
WORKFLOW: 'workflow',
|
||||
WORKSPACE: 'workspace',
|
||||
} as const
|
||||
|
||||
export type AuditResourceTypeValue = (typeof AuditResourceType)[keyof typeof AuditResourceType]
|
||||
|
||||
interface AuditLogParams {
|
||||
workspaceId?: string | null
|
||||
actorId: string
|
||||
action: AuditActionType
|
||||
resourceType: AuditResourceTypeValue
|
||||
resourceId?: string
|
||||
actorName?: string | null
|
||||
actorEmail?: string | null
|
||||
resourceName?: string
|
||||
description?: string
|
||||
metadata?: Record<string, unknown>
|
||||
request?: Request
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an audit log entry. Fire-and-forget — never throws or blocks the caller.
|
||||
*/
|
||||
export function recordAudit(params: AuditLogParams): void {
|
||||
try {
|
||||
const ipAddress =
|
||||
params.request?.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
|
||||
params.request?.headers.get('x-real-ip') ??
|
||||
undefined
|
||||
const userAgent = params.request?.headers.get('user-agent') ?? undefined
|
||||
|
||||
db.insert(auditLog)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
workspaceId: params.workspaceId || null,
|
||||
actorId: params.actorId,
|
||||
action: params.action,
|
||||
resourceType: params.resourceType,
|
||||
resourceId: params.resourceId,
|
||||
actorName: params.actorName ?? undefined,
|
||||
actorEmail: params.actorEmail ?? undefined,
|
||||
resourceName: params.resourceName,
|
||||
description: params.description,
|
||||
metadata: params.metadata ?? {},
|
||||
ipAddress,
|
||||
userAgent,
|
||||
})
|
||||
.then(() => {
|
||||
logger.debug('Audit log recorded', {
|
||||
action: params.action,
|
||||
resourceType: params.resourceType,
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('Failed to record audit log', { error, action: params.action })
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to initiate audit log', { error, action: params.action })
|
||||
}
|
||||
}
|
||||
@@ -483,17 +483,6 @@ export const auth = betterAuth({
|
||||
throw new Error(`Failed to send reset password email: ${result.message}`)
|
||||
}
|
||||
},
|
||||
onPasswordReset: async ({ user: resetUser }) => {
|
||||
const { AuditAction, AuditResourceType, recordAudit } = await import('@/lib/audit/log')
|
||||
recordAudit({
|
||||
actorId: resetUser.id,
|
||||
actorName: resetUser.name,
|
||||
actorEmail: resetUser.email,
|
||||
action: AuditAction.PASSWORD_RESET,
|
||||
resourceType: AuditResourceType.PASSWORD,
|
||||
description: 'Password reset completed',
|
||||
})
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { db } from '@sim/db'
|
||||
import { user } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
|
||||
import { getSession } from '@/lib/auth'
|
||||
@@ -12,32 +9,11 @@ const logger = createLogger('HybridAuth')
|
||||
export interface AuthResult {
|
||||
success: boolean
|
||||
userId?: string
|
||||
userName?: string | null
|
||||
userEmail?: string | null
|
||||
authType?: 'session' | 'api_key' | 'internal_jwt'
|
||||
apiKeyType?: 'personal' | 'workspace'
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a user's name and email by ID. Returns empty values on failure
|
||||
* so auth is never blocked by a lookup error.
|
||||
*/
|
||||
async function lookupUserInfo(
|
||||
userId: string
|
||||
): Promise<{ userName: string | null; userEmail: string | null }> {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({ name: user.name, email: user.email })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
return { userName: row?.name ?? null, userEmail: row?.email ?? null }
|
||||
} catch {
|
||||
return { userName: null, userEmail: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves userId from a verified internal JWT token.
|
||||
* Extracts userId from the JWT payload, URL search params, or POST body.
|
||||
@@ -68,8 +44,7 @@ async function resolveUserFromJwt(
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const { userName, userEmail } = await lookupUserInfo(userId)
|
||||
return { success: true, userId, userName, userEmail, authType: 'internal_jwt' }
|
||||
return { success: true, userId, authType: 'internal_jwt' }
|
||||
}
|
||||
|
||||
if (options.requireWorkflowId !== false) {
|
||||
@@ -167,8 +142,6 @@ export async function checkSessionOrInternalAuth(
|
||||
return {
|
||||
success: true,
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
userEmail: session.user.email,
|
||||
authType: 'session',
|
||||
}
|
||||
}
|
||||
@@ -216,8 +189,6 @@ export async function checkHybridAuth(
|
||||
return {
|
||||
success: true,
|
||||
userId: session.user.id,
|
||||
userName: session.user.name,
|
||||
userEmail: session.user.email,
|
||||
authType: 'session',
|
||||
}
|
||||
}
|
||||
@@ -228,12 +199,9 @@ export async function checkHybridAuth(
|
||||
const result = await authenticateApiKeyFromHeader(apiKeyHeader)
|
||||
if (result.success) {
|
||||
await updateApiKeyLastUsed(result.keyId!)
|
||||
const { userName, userEmail } = await lookupUserInfo(result.userId!)
|
||||
return {
|
||||
success: true,
|
||||
userId: result.userId!,
|
||||
userName,
|
||||
userEmail,
|
||||
authType: 'api_key',
|
||||
apiKeyType: result.keyType,
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user