fix(dashboard-agent-db): scope softDeleteChat by organizationId

This commit is contained in:
Katia Bulatova
2026-08-10 18:54:21 +00:00
parent 3977c2dbeb
commit bd5f8fd67f
4 changed files with 101 additions and 6 deletions
@@ -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.
@@ -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 });
}
}
@@ -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<DashboardAgentDb> {
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
);
});
@@ -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 };