From 03e0e230d518f9897e1ca0b0b48a507eae7b7d6d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 7 Jul 2026 15:12:28 -0700 Subject: [PATCH] feat(custom-block): audit-log publish, update, and unpublish events --- apps/sim/app/api/custom-blocks/[id]/route.ts | 46 +++++++++++++++++-- apps/sim/app/api/custom-blocks/route.ts | 14 ++++++ .../lib/workflows/custom-blocks/operations.ts | 11 +++-- packages/audit/src/types.ts | 6 +++ packages/testing/src/mocks/audit.mock.ts | 4 ++ 5 files changed, 74 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/api/custom-blocks/[id]/route.ts b/apps/sim/app/api/custom-blocks/[id]/route.ts index 3e995a96e72..14d7d06ee9f 100644 --- a/apps/sim/app/api/custom-blocks/[id]/route.ts +++ b/apps/sim/app/api/custom-blocks/[id]/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -29,19 +30,28 @@ type RouteContext = { params: Promise<{ id: string }> } * different workspace does not, so they cannot alter another workspace's block or * its exposed outputs. */ -async function authorizeManage(userId: string, id: string) { +type ManageContext = NonNullable>> + +async function authorizeManage( + userId: string, + id: string +): Promise<{ error: NextResponse; ctx: null } | { error: null; ctx: ManageContext }> { const ctx = await getCustomBlockManageContext(id) - if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }) } + if (!ctx) return { error: NextResponse.json({ error: 'Not found' }, { status: 404 }), ctx: null } if (!(await isFeatureEnabled('deploy-as-block', { userId, orgId: ctx.organizationId }))) { return { error: NextResponse.json({ error: 'Deploy as block is not enabled' }, { status: 403 }), + ctx: null, } } if (!ctx.sourceWorkspaceId || !(await hasWorkspaceAdminAccess(userId, ctx.sourceWorkspaceId))) { - return { error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }) } + return { + error: NextResponse.json({ error: 'Admin permissions required' }, { status: 403 }), + ctx: null, + } } - return { error: null } + return { error: null, ctx } } export const PATCH = withRouteHandler(async (request: NextRequest, context: RouteContext) => { @@ -56,6 +66,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout const { id } = parsed.data.params const authz = await authorizeManage(session.user.id, id) if (authz.error) return authz.error + const { ctx } = authz const { name, description, enabled, iconUrl, inputs, exposedOutputs } = parsed.data.body try { @@ -67,6 +78,19 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout iconUrl, exposedOutputs, }) + recordAudit({ + workspaceId: ctx.sourceWorkspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CUSTOM_BLOCK_UPDATED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: id, + resourceName: name ?? ctx.name, + description: `Updated custom block "${name ?? ctx.name}"`, + metadata: { organizationId: ctx.organizationId, type: ctx.type }, + request, + }) return NextResponse.json({ success: true as const }) } catch (error) { if (error instanceof CustomBlockValidationError) { @@ -89,7 +113,21 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou const { id } = parsed.data.params const authz = await authorizeManage(session.user.id, id) if (authz.error) return authz.error + const { ctx } = authz await deleteCustomBlock(id) + recordAudit({ + workspaceId: ctx.sourceWorkspaceId, + actorId: session.user.id, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CUSTOM_BLOCK_DELETED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: id, + resourceName: ctx.name, + description: `Unpublished custom block "${ctx.name}"`, + metadata: { organizationId: ctx.organizationId, type: ctx.type }, + request, + }) return NextResponse.json({ success: true as const }) }) diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts index 12071007881..99b6319aa3d 100644 --- a/apps/sim/app/api/custom-blocks/route.ts +++ b/apps/sim/app/api/custom-blocks/route.ts @@ -1,3 +1,4 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -119,6 +120,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { inputs, exposedOutputs, }) + recordAudit({ + workspaceId, + actorId: userId, + actorName: session.user.name, + actorEmail: session.user.email, + action: AuditAction.CUSTOM_BLOCK_PUBLISHED, + resourceType: AuditResourceType.CUSTOM_BLOCK, + resourceId: block.id, + resourceName: block.name, + description: `Published custom block "${block.name}"`, + metadata: { organizationId, type: block.type, workflowId }, + request, + }) return NextResponse.json({ customBlock: toWire(block) }) } catch (error) { if (error instanceof CustomBlockValidationError) { diff --git a/apps/sim/lib/workflows/custom-blocks/operations.ts b/apps/sim/lib/workflows/custom-blocks/operations.ts index a6732998c30..83df77525ca 100644 --- a/apps/sim/lib/workflows/custom-blocks/operations.ts +++ b/apps/sim/lib/workflows/custom-blocks/operations.ts @@ -203,13 +203,18 @@ export async function getCustomBlockById(id: string) { * (or an org admin, who holds admin on every org workspace) can change its outputs. * `null` when no block matches. */ -export async function getCustomBlockManageContext( - id: string -): Promise<{ organizationId: string; sourceWorkspaceId: string | null } | null> { +export async function getCustomBlockManageContext(id: string): Promise<{ + organizationId: string + sourceWorkspaceId: string | null + type: string + name: string +} | null> { const [row] = await db .select({ organizationId: customBlock.organizationId, sourceWorkspaceId: workflow.workspaceId, + type: customBlock.type, + name: customBlock.name, }) .from(customBlock) .innerJoin(workflow, eq(workflow.id, customBlock.workflowId)) diff --git a/packages/audit/src/types.ts b/packages/audit/src/types.ts index 3ad3ad9dc9a..ab0818a5724 100644 --- a/packages/audit/src/types.ts +++ b/packages/audit/src/types.ts @@ -19,6 +19,11 @@ export const AuditAction = { CHAT_UPDATED: 'chat.updated', CHAT_DELETED: 'chat.deleted', + // Custom Blocks (deploy-as-block) + CUSTOM_BLOCK_PUBLISHED: 'custom_block.published', + CUSTOM_BLOCK_UPDATED: 'custom_block.updated', + CUSTOM_BLOCK_DELETED: 'custom_block.deleted', + // Custom Tools CUSTOM_TOOL_CREATED: 'custom_tool.created', CUSTOM_TOOL_UPDATED: 'custom_tool.updated', @@ -188,6 +193,7 @@ export const AuditResourceType = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL: 'credential', + CUSTOM_BLOCK: 'custom_block', CUSTOM_TOOL: 'custom_tool', DATA_DRAIN: 'data_drain', DOCUMENT: 'document', diff --git a/packages/testing/src/mocks/audit.mock.ts b/packages/testing/src/mocks/audit.mock.ts index 82a59734b8f..65685d9237d 100644 --- a/packages/testing/src/mocks/audit.mock.ts +++ b/packages/testing/src/mocks/audit.mock.ts @@ -47,6 +47,9 @@ export const auditMock = { CREDENTIAL_MEMBER_REMOVED: 'credential_member.removed', CREDENTIAL_MEMBER_ROLE_CHANGED: 'credential_member.role_changed', CREDIT_PURCHASED: 'credit.purchased', + CUSTOM_BLOCK_PUBLISHED: 'custom_block.published', + CUSTOM_BLOCK_UPDATED: 'custom_block.updated', + CUSTOM_BLOCK_DELETED: 'custom_block.deleted', CUSTOM_TOOL_CREATED: 'custom_tool.created', CUSTOM_TOOL_UPDATED: 'custom_tool.updated', CUSTOM_TOOL_DELETED: 'custom_tool.deleted', @@ -156,6 +159,7 @@ export const auditMock = { CHAT: 'chat', CONNECTOR: 'connector', CREDENTIAL: 'credential', + CUSTOM_BLOCK: 'custom_block', CUSTOM_TOOL: 'custom_tool', DATA_DRAIN: 'data_drain', DOCUMENT: 'document',