From bd5f8fd67fa779120ca5b3c64af5dcfce7c1904e Mon Sep 17 00:00:00 2001 From: Katia Bulatova Date: Mon, 10 Aug 2026 18:54:21 +0000 Subject: [PATCH] fix(dashboard-agent-db): scope softDeleteChat by organizationId --- .../dashboard-agent-delete-chat-org-scope.md | 6 ++ ...jectParam.env.$envParam.dashboard-agent.ts | 16 +++- ...shboardAgentQueriesTenantIsolation.test.ts | 75 +++++++++++++++++++ .../dashboard-agent-db/src/queries.ts | 10 ++- 4 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 .server-changes/dashboard-agent-delete-chat-org-scope.md create mode 100644 apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts diff --git a/.server-changes/dashboard-agent-delete-chat-org-scope.md b/.server-changes/dashboard-agent-delete-chat-org-scope.md new file mode 100644 index 000000000..3a651c0b8 --- /dev/null +++ b/.server-changes/dashboard-agent-delete-chat-org-scope.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Deleting a dashboard agent chat is now scoped to your organization, so a chat can only be removed from within the org it belongs to. diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts index d34519604..022e3dc79 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts @@ -286,7 +286,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { // handover was dispatched and no message was sent: a session the call did create in // spite of the error idles out having done nothing. The empty row is all there is to undo. // Swallowed so the start's own error is what surfaces and gets logged. - await softDeleteChat(dashboardAgentDb, { chatId, userId }).catch((cleanupError) => { + await softDeleteChat(dashboardAgentDb, { + chatId, + userId, + organizationId: project.organizationId, + }).catch((cleanupError) => { logger.error("Failed to remove a dashboard agent chat whose start failed", { chatId, error: cleanupError, @@ -453,8 +457,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { } case "delete": { - // `softDeleteChat` is owner-scoped but takes no org, so the org scope has to be - // enforced here. + // Existence check gives a 404 for a chat this caller can't see; the delete itself + // is org- and owner-scoped too. if ( !(await chatExists(dashboardAgentDb, { chatId, @@ -464,7 +468,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ) { return json({ error: "Chat not found" }, { status: 404 }); } - await softDeleteChat(dashboardAgentDb, { chatId, userId }); + await softDeleteChat(dashboardAgentDb, { + chatId, + userId, + organizationId: project.organizationId, + }); return json({ ok: true }); } } diff --git a/apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts b/apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts new file mode 100644 index 000000000..52b0af55a --- /dev/null +++ b/apps/webapp/test/dashboardAgentQueriesTenantIsolation.test.ts @@ -0,0 +1,75 @@ +import { + createChat, + createDashboardAgentDb, + listChats, + softDeleteChat, + type DashboardAgentDb, + type DashboardAgentDbClient, +} from "@internal/dashboard-agent-db"; +import { postgresTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect } from "vitest"; + +/** Replays every migration in order, so a new migration can't leave the suite on a stale schema. */ +async function applyAgentSchema(prisma: PrismaClient) { + const folder = path.resolve(__dirname, "../../../internal-packages/dashboard-agent-db/drizzle"); + const migrations = readdirSync(folder) + .filter((file) => file.endsWith(".sql")) + .sort(); + for (const name of migrations) { + const sql = readFileSync(path.join(folder, name), "utf8"); + for (const statement of sql.split("--> statement-breakpoint")) { + const trimmed = statement.trim(); + if (trimmed.length > 0) await prisma.$executeRawUnsafe(trimmed); + } + } +} + +let agentDbClient: DashboardAgentDbClient | undefined; + +async function boot(prisma: PrismaClient, connectionUri: string): Promise { + await applyAgentSchema(prisma); + agentDbClient = createDashboardAgentDb(connectionUri, { max: 2 }); + return agentDbClient.db; +} + +afterEach(async () => { + await agentDbClient?.close(); + agentDbClient = undefined; +}); + +const ORG = "org_owner"; +const OTHER_ORG = "org_other"; +const USER = "user_owner"; + +describe("softDeleteChat tenant isolation", () => { + postgresTest( + "a soft-delete scoped to another org leaves the chat intact", + async ({ prisma, postgresContainer }) => { + const db = await boot(prisma, postgresContainer.getConnectionUri()); + + await createChat(db, { id: "chat_1", organizationId: ORG, userId: USER }); + + // Right user, wrong org: must not delete. + const wrongOrg = await softDeleteChat(db, { + chatId: "chat_1", + userId: USER, + organizationId: OTHER_ORG, + }); + expect(wrongOrg.deleted).toBe(false); + expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(1); + + // Right org and user: deletes. + const rightOrg = await softDeleteChat(db, { + chatId: "chat_1", + userId: USER, + organizationId: ORG, + }); + expect(rightOrg.deleted).toBe(true); + expect(await listChats(db, { organizationId: ORG, userId: USER })).toHaveLength(0); + }, + 30_000 + ); +}); diff --git a/internal-packages/dashboard-agent-db/src/queries.ts b/internal-packages/dashboard-agent-db/src/queries.ts index d2cff6d5c..f8e1853f9 100644 --- a/internal-packages/dashboard-agent-db/src/queries.ts +++ b/internal-packages/dashboard-agent-db/src/queries.ts @@ -225,12 +225,18 @@ export async function setChatPinned( /** Owner-scoped: a client chatId can only delete the caller's own chat. */ export async function softDeleteChat( db: DashboardAgentDb, - params: { chatId: string; userId: string } + params: { chatId: string; userId: string; organizationId: string } ): Promise<{ deleted: boolean }> { const deleted = await db .update(chats) .set({ deletedAt: sql`now()`, updatedAt: sql`now()` }) - .where(and(eq(chats.id, params.chatId), eq(chats.userId, params.userId))) + .where( + and( + eq(chats.id, params.chatId), + eq(chats.userId, params.userId), + eq(chats.organizationId, params.organizationId) + ) + ) .returning({ id: chats.id }); return { deleted: deleted.length > 0 };