diff --git a/docs/adr/0034-purge-follows-manage.md b/docs/adr/0034-purge-follows-manage.md new file mode 100644 index 0000000..e2f1bcb --- /dev/null +++ b/docs/adr/0034-purge-follows-manage.md @@ -0,0 +1,28 @@ +# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator + +## Status + +Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is +website-administrator only". + +## Context + +ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation +precaution. The product call is that this is inconsistent with the rest of the +permission model: soft delete already requires only MANAGE on the node, and a +MANAGE holder who can delete a node into the bin should also be able to purge it — +the authority that grants deletion grants destruction. Admin-only purge strands +non-admin managers with bins they cannot empty. + +## Decision + +Permanent delete uses **the same visibility rule as the bin entry itself**: website +administrator, or an actor with an active MANAGE grant on the deleted node (direct +grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation +in the UI and the `node.purge` audit entry are unchanged. + +## Consequences + +- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces. +- The operation remains irreversible and audited; no new capability is granted to + anyone who could not already delete the node (soft) and see it in the bin. diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte index 91732c6..a74b71b 100644 --- a/hub/filelib-web/src/lib/BinView.svelte +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -5,7 +5,7 @@ */ import { onMount } from "svelte"; import { api } from "./api.js"; - import { me, toastErr, toastOk } from "./stores.js"; + import { toastErr, toastOk } from "./stores.js"; import type { BinEntry } from "./types.js"; import Icon from "./Icon.svelte"; @@ -94,15 +94,14 @@ > 恢复 - {#if $me?.isWebsiteAdmin} - - {/if} + + {/each} diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts index dc702df..7aaadd2 100644 --- a/hub/src/database/filelib/binService.ts +++ b/hub/src/database/filelib/binService.ts @@ -169,19 +169,15 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId } /** - * 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举, + * 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE; + * 能删进回收站的人就能清空)。整支硬删:子树经 pathIds 前缀枚举, * 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT, * 父行必须晚于全部子孙行删除。 */ export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> { - if (!actor.isWebsiteAdmin) { - throw new FileLibError(404, "node_not_found", "node not found"); - } + const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); return deps.prisma.$transaction(async (tx) => { - const node = await tx.fileLibNode.findFirst({ - where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } }, - }); - if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); + const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); const subtree = await tx.fileLibNode.findMany({ where: { diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index c66299b..f8de9fd 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -120,15 +120,15 @@ describe("binService · 恢复", () => { }); describe("binService · 彻底删除", () => { - it("仅管理员;整支硬删(含子孙/授权),落 node.purge 审计", async () => { + it("ADR-0034:与条目可见性同权 —— 直连 MANAGE 可清空,无关者 404;整支硬删 + node.purge 审计", async () => { const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141", - grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }], + grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }], }); - await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶 + await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶;alice 在 root 上无直连 MANAGE - await expect(purgeBinEntry(binDeps(), ALICE, root.id)).rejects.toMatchObject({ statusCode: 404 }); + await expect(purgeBinEntry(binDeps(), BOB, root.id)).rejects.toMatchObject({ statusCode: 404 }); const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id); expect(removed).toBe(2); @@ -138,4 +138,16 @@ describe("binService · 彻底删除", () => { const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } }); expect(audits).toHaveLength(1); }); + + it("ADR-0034:非管理员的直连 MANAGE 持有者也能彻底删除", async () => { + const own = await createNode(treeDeps(), ADMIN, { + parentId: null, kind: "PROJECT", name: "alice 项目", + grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }], + }); + await softDeleteNode(treeDeps(), ADMIN, own.id); + + const { removed } = await purgeBinEntry(binDeps(), ALICE, own.id); + expect(removed).toBe(1); + expect(await prisma.fileLibNode.count({ where: { id: own.id } })).toBe(0); + }); });