feat(filelib): 彻底删除改与条目可见性同权(ADR-0034,supersede ADR-0031 仅管理员条款)

能删进回收站(MANAGE)的人就能清空;无关者 404(D8)。二次确认与
node.purge 审计不变;BinView 彻底删除按钮对全部可见条目开放。
This commit is contained in:
ymy
2026-07-31 14:01:46 +08:00
parent b1fd2e8f7b
commit beaa92de2e
4 changed files with 57 additions and 22 deletions
+28
View File
@@ -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.
+9 -10
View File
@@ -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 @@
>
<Icon name="restore" size={12} /> 恢复
</button>
{#if $me?.isWebsiteAdmin}
<button
class="btn btn-sm btn-danger disabled:opacity-50"
onclick={() => void purge(e)}
disabled={busyId === e.id}
>
<Icon name="trash" size={12} /> 彻底删除
</button>
{/if}
<!-- 彻底删除与条目可见性同权(ADR-0034):能在回收站看到,就能清空 -->
<button
class="btn btn-sm btn-danger disabled:opacity-50"
onclick={() => void purge(e)}
disabled={busyId === e.id}
>
<Icon name="trash" size={12} /> 彻底删除
</button>
</div>
{/each}
</div>
+4 -8
View File
@@ -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: {
+16 -4
View File
@@ -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);
});
});