From cd72cfbff47e76b6c3031fce5f3d0f0910969b10 Mon Sep 17 00:00:00 2001 From: charles-gauthereau Date: Sun, 5 Jul 2026 17:46:16 +0200 Subject: [PATCH] fix(database): authorize soft-delete by agent org + add onError + guard bulk-restore lists --- .../components/database-delete-button.tsx | 3 + .../database/actions/bulk-restore.action.ts | 4 +- .../actions/database-delete.action.ts | 70 +++++++++++++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/features/agents/components/database-delete-button.tsx b/src/features/agents/components/database-delete-button.tsx index 10d2ada7..4fccc3d4 100644 --- a/src/features/agents/components/database-delete-button.tsx +++ b/src/features/agents/components/database-delete-button.tsx @@ -24,6 +24,9 @@ export const DatabaseDeleteButton = (props: DatabaseDeleteButtonProps) => { toast.error(result.data?.actionError?.message || "Unknown error occurred."); } }, + onError: () => { + toast.error("Failed to delete database."); + }, }); return ( diff --git a/src/features/database/actions/bulk-restore.action.ts b/src/features/database/actions/bulk-restore.action.ts index 22a16c24..cfa2a6a4 100644 --- a/src/features/database/actions/bulk-restore.action.ts +++ b/src/features/database/actions/bulk-restore.action.ts @@ -17,7 +17,7 @@ export async function assertDatabasesInOrgProject(projectId: string, databaseIds eq(drizzleDb.schemas.project.id, projectId), eq(drizzleDb.schemas.project.organizationId, organization.id), ), - with: {databases: true}, + with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}}, }); if (!project) throw new ActionError("Project not found."); @@ -40,7 +40,7 @@ export type RestorePreviewRow = { async function resolveLatestRestorable(projectId: string, databaseIds: string[]): Promise { const project = await db.query.project.findFirst({ where: eq(drizzleDb.schemas.project.id, projectId), - with: {databases: true}, + with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}}, }); const nameById = new Map((project?.databases ?? []).map((d) => [d.id, d.name] as const)); diff --git a/src/features/database/actions/database-delete.action.ts b/src/features/database/actions/database-delete.action.ts index e4c1aab7..bd8ed01d 100644 --- a/src/features/database/actions/database-delete.action.ts +++ b/src/features/database/actions/database-delete.action.ts @@ -9,6 +9,9 @@ import {ServerActionResult} from "@/types/action-type"; import {userAction} from "@/lib/safe-actions/actions"; import {zString} from "@/lib/zod"; import {withUpdatedAt} from "@/db/utils"; +import {currentUser} from "@/lib/auth/current-user"; +import {getActiveMember, getOrganization} from "@/lib/auth/auth"; +import {computeOrganizationPermissions} from "@/lib/acl/organization-acl"; type DeleteDatabaseInput = { databaseId: string; @@ -21,9 +24,63 @@ class DatabaseNotFoundError extends Error { } } +class UnauthorizedError extends Error { + constructor(databaseId: string) { + super(`Not authorized to delete this database: ${databaseId}`); + this.name = "UnauthorizedError"; + } +} + +async function assertCanDeleteDatabase(databaseId: string): Promise { + const database = await db.query.database.findFirst({ + where: eq(drizzleDb.schemas.database.id, databaseId), + with: { + agent: { + with: { + organizations: true, + }, + }, + }, + }); + + if (!database || !database.agent) { + throw new DatabaseNotFoundError(databaseId); + } + + const user = await currentUser(); + if (!user) { + throw new UnauthorizedError(databaseId); + } + + const agent = database.agent; + const isAdmin = user.role === "superadmin" || user.role === "admin"; + + let authorized: boolean; + if (agent.organizationId === null) { + authorized = isAdmin; + } else { + const organization = await getOrganization({}); + const activeMember = await getActiveMember(); + const canManage = activeMember + ? computeOrganizationPermissions(activeMember).canManageAgents + : false; + const hasAccess = + !!organization && + (agent.organizationId === organization.id || + agent.organizations.some((o) => o.organizationId === organization.id)); + authorized = canManage && hasAccess; + } + + if (!authorized) { + throw new UnauthorizedError(databaseId); + } +} + export async function deleteDatabaseService(input: DeleteDatabaseInput): Promise { const {databaseId} = input; + await assertCanDeleteDatabase(databaseId); + await db .delete(drizzleDb.schemas.retentionPolicy) .where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId)) @@ -81,6 +138,19 @@ export const deleteDatabaseAction = userAction }, }; } catch (error) { + if (error instanceof UnauthorizedError) { + return { + success: false, + actionError: { + message: "Not authorized to delete this database.", + status: 403, + messageParams: { + databaseId: parsedInput.databaseId, + }, + }, + }; + } + if (error instanceof DatabaseNotFoundError) { return { success: false,