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);
+ });
});