forked from bai/curriculum-project-hub
Compare commits
2 Commits
b1fd2e8f7b
...
8bc5dbf9e2
| Author | SHA1 | Date | |
|---|---|---|---|
| 8bc5dbf9e2 | |||
| beaa92de2e |
@@ -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.
|
||||
@@ -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,7 +94,7 @@
|
||||
>
|
||||
<Icon name="restore" size={12} /> 恢复
|
||||
</button>
|
||||
{#if $me?.isWebsiteAdmin}
|
||||
<!-- 彻底删除与条目可见性同权(ADR-0034):能在回收站看到,就能清空 -->
|
||||
<button
|
||||
class="btn btn-sm btn-danger disabled:opacity-50"
|
||||
onclick={() => void purge(e)}
|
||||
@@ -102,7 +102,6 @@
|
||||
>
|
||||
<Icon name="trash" size={12} /> 彻底删除
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user