forked from EduCraft/curriculum-project-hub
Merge branch 'feat/purge-follows-manage'
This commit is contained in:
@@ -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 { onMount } from "svelte";
|
||||||
import { api } from "./api.js";
|
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 type { BinEntry } from "./types.js";
|
||||||
import Icon from "./Icon.svelte";
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
>
|
>
|
||||||
<Icon name="restore" size={12} /> 恢复
|
<Icon name="restore" size={12} /> 恢复
|
||||||
</button>
|
</button>
|
||||||
{#if $me?.isWebsiteAdmin}
|
<!-- 彻底删除与条目可见性同权(ADR-0034):能在回收站看到,就能清空 -->
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-danger disabled:opacity-50"
|
class="btn btn-sm btn-danger disabled:opacity-50"
|
||||||
onclick={() => void purge(e)}
|
onclick={() => void purge(e)}
|
||||||
@@ -102,7 +102,6 @@
|
|||||||
>
|
>
|
||||||
<Icon name="trash" size={12} /> 彻底删除
|
<Icon name="trash" size={12} /> 彻底删除
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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,
|
* 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT,
|
||||||
* 父行必须晚于全部子孙行删除。
|
* 父行必须晚于全部子孙行删除。
|
||||||
*/
|
*/
|
||||||
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
|
export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> {
|
||||||
if (!actor.isWebsiteAdmin) {
|
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||||
throw new FileLibError(404, "node_not_found", "node not found");
|
|
||||||
}
|
|
||||||
return deps.prisma.$transaction(async (tx) => {
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
const node = await tx.fileLibNode.findFirst({
|
const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId);
|
||||||
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } },
|
|
||||||
});
|
|
||||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
|
||||||
|
|
||||||
const subtree = await tx.fileLibNode.findMany({
|
const subtree = await tx.fileLibNode.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -120,15 +120,15 @@ describe("binService · 恢复", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" });
|
||||||
const child = await createNode(treeDeps(), ADMIN, {
|
const child = await createNode(treeDeps(), ADMIN, {
|
||||||
parentId: root.id, kind: "PROJECT", name: "TH-141",
|
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);
|
const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id);
|
||||||
expect(removed).toBe(2);
|
expect(removed).toBe(2);
|
||||||
|
|
||||||
@@ -138,4 +138,16 @@ describe("binService · 彻底删除", () => {
|
|||||||
const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
|
const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } });
|
||||||
expect(audits).toHaveLength(1);
|
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