fix(dashboard-agent-db): scope softDeleteChat by organizationId
This commit is contained in:
@@ -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.
|
||||
+12
-4
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user