From 39a2be6347774aca659910d79bbc16d2c7271823 Mon Sep 17 00:00:00 2001 From: ymy Date: Thu, 30 Jul 2026 22:27:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(filelib):=20=E9=A1=B9=E7=9B=AE=E7=BA=A7?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E6=81=92=E7=94=9F=E6=95=88,=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E7=8B=AC=E7=AB=8B=E6=9D=83=E9=99=90=E5=BC=80=E5=85=B3?= =?UTF-8?q?(ADR-0030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - permission.ts: effectiveRole 删除 D11 冻结分支,输入不再含开关字段 - treeService: 停止读 FileLibProjectSettings;建项目不再写默认行 - grantService/routes: 删 setIndependentPermission 与 PUT 路由;节点详情 DTO 去掉 independentPermission - filelib-web: 概览 tab 移除开关;NodeDetail 类型同步 - 测试: 单测/集成改为断言恒生效语义;ADR-0030 废除契约 D11/P5 - FileLibProjectSettings 表保留(存量行忽略,不再读写),审计词表保留历史读取 --- .../ses_06d37cad5ffeB8d2unoGG7uv3L.json | 10 +++++ .../ses_06dc3e1e5ffeER5ROC55lzS6xX.json | 10 +++++ docs/adr/0030-project-grants-always-live.md | 43 +++++++++++++++++++ hub/filelib-web/src/lib/OverviewPanel.svelte | 29 +------------ hub/filelib-web/src/lib/types.ts | 1 - hub/src/database/filelib/grantService.ts | 39 ----------------- hub/src/database/filelib/permission.ts | 12 ++---- hub/src/database/filelib/treeService.ts | 33 +------------- hub/src/database/routes/filelibRoutes.ts | 20 --------- hub/test/integration/filelib-tree.test.ts | 13 ++---- hub/test/unit/filelib-permission.test.ts | 28 +++++------- 11 files changed, 83 insertions(+), 155 deletions(-) create mode 100644 .omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json create mode 100644 .omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json create mode 100644 docs/adr/0030-project-grants-always-live.md diff --git a/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json b/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json new file mode 100644 index 0000000..4573c9f --- /dev/null +++ b/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_06d37cad5ffeB8d2unoGG7uv3L", + "updatedAt": "2026-07-24T06:34:59.318Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-24T06:34:59.318Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json b/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json new file mode 100644 index 0000000..f65f3a7 --- /dev/null +++ b/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_06dc3e1e5ffeER5ROC55lzS6xX", + "updatedAt": "2026-07-24T06:08:43.845Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-24T06:08:43.845Z" + } + } +} \ No newline at end of file diff --git a/docs/adr/0030-project-grants-always-live.md b/docs/adr/0030-project-grants-always-live.md new file mode 100644 index 0000000..8e17408 --- /dev/null +++ b/docs/adr/0030-project-grants-always-live.md @@ -0,0 +1,43 @@ +# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed + +## Status + +Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》, +since deleted; recoverable from git history) which introduced the per-project +"独立权限" (independent permission) switch. + +## Context + +D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`, +default off). While off, project-level non-creator grants were **frozen** — present in +`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's +auto-grant were unaffected. The intent was to support two workflows: "project follows the +folder's ACL" (off) vs "project has its own ACL" (on). + +In practice the toggle surprised operators twice: grants appeared to "not work" until +someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state +was indistinguishable from missing grants in the UI. The product decision is that +project-level grants should simply always be live. + +## Decision + +- **Project-level grants always participate in `effectiveRole`.** The freeze branch in + `hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer + carries `independentPermissionsEnabled`. +- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`, + `grantService.setIndependentPermission`, the `independentPermission` field in the node + detail DTO, and the 概览 tab switch in `filelib-web`. +- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are + ignored, no data migration); new projects no longer get a default row. It may be dropped + in a future migration once nothing references it. +- Audit action vocabulary `independent_enable` / `independent_disable` is retained for + reading historical audit entries; no new entries are produced. + +Behavior change for existing deployments: projects whose toggle was off now have their +project-level grants effective immediately — this is the intended effect of the decision. + +## Consequences + +- Permission semantics shrink to the single P6 rule: `effective = max(grants on self ∪ + ancestors for user ∪ resolved groups)`, no exceptions by node kind. +- One less state dimension in tests and in the admin UI. diff --git a/hub/filelib-web/src/lib/OverviewPanel.svelte b/hub/filelib-web/src/lib/OverviewPanel.svelte index 326a536..694bf02 100644 --- a/hub/filelib-web/src/lib/OverviewPanel.svelte +++ b/hub/filelib-web/src/lib/OverviewPanel.svelte @@ -13,25 +13,8 @@ let exportJob = $state(null); const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT"); - const canManage = $derived(node.role === "MANAGE"); const roleLabel = $derived(ROLE_LABEL[node.role]); - /** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */ - async function toggleIndependent(): Promise { - try { - await api(`/database/api/projects/${node.id}/independent-permission`, { - method: "PUT", - body: { enabled: !node.independentPermission }, - }); - toastOk("已切换"); - currentNode.update((n) => - n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n, - ); - } catch (e) { - toastErr(e instanceof Error ? e.message : String(e)); - } - } - $effect(() => { void node.id; exportJob = null; @@ -109,18 +92,8 @@
更新时间 {new Date(node.updatedAt).toLocaleString("zh-CN")}
- + {#if node.kind === "PROJECT"} -
-
- 独立权限 - {node.independentPermission ? "开启" : "关闭"} - {#if canManage} - - {/if} - 关闭时仅继承父级权限(创建者除外) -
-
导出
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index b46e774..74f472b 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -28,7 +28,6 @@ export interface NodeDetail { readonly description: string | null; readonly role: Role; readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED"; - readonly independentPermission: boolean; readonly createdAt: string; readonly updatedAt: string; } diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts index f7be820..3599062 100644 --- a/hub/src/database/filelib/grantService.ts +++ b/hub/src/database/filelib/grantService.ts @@ -274,45 +274,6 @@ export async function forceAdjustGrants( }); } -/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */ -export async function setIndependentPermission( - deps: Deps, - actor: FileLibActor, - nodeId: string, - enabled: boolean, -): Promise<{ readonly enabled: boolean }> { - return deps.prisma.$transaction(async (tx) => { - const { node } = await requireManage(deps, actor, nodeId, tx); - if (node.kind !== "PROJECT") { - throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only"); - } - const current = await tx.fileLibProjectSettings.findUnique({ - where: { nodeId: node.id }, - select: { independentPermissionsEnabled: true }, - }); - if ((current?.independentPermissionsEnabled ?? false) === enabled) { - return { enabled }; // 状态未变:空操作,不产生审计 - } - await tx.fileLibProjectSettings.upsert({ - where: { nodeId: node.id }, - update: { independentPermissionsEnabled: enabled }, - create: { nodeId: node.id, independentPermissionsEnabled: enabled }, - }); - await writeFileLibAudit(tx, { - action: enabled - ? FILE_LIB_AUDIT_ACTIONS.independentEnable - : FILE_LIB_AUDIT_ACTIONS.independentDisable, - actorUserId: actor.userId, - organizationId: deps.organizationId, - objectType: "project", - objectId: node.id, - objectPath: node.pathIds, - detail: { enabled }, - }); - return { enabled }; - }); -} - function validateGrantItems(items: readonly InitialGrant[]): void { if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty"); const seen = new Set(); diff --git a/hub/src/database/filelib/permission.ts b/hub/src/database/filelib/permission.ts index c6bdba4..7724494 100644 --- a/hub/src/database/filelib/permission.ts +++ b/hub/src/database/filelib/permission.ts @@ -1,5 +1,5 @@ /** - * 纯权限 reducer(契约 P6 / D11 / D8)。 + * 纯权限 reducer(契约 P6 / D8)。 * * 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链 * 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离 @@ -23,8 +23,6 @@ export interface EffectiveRoleInput { readonly nodeKind: "FOLDER" | "PROJECT"; /** 目标的全部祖先 id(不含 self,顺序无关)。 */ readonly ancestorIds: readonly string[]; - /** 项目独立权限开关(D11/P5);文件夹忽略此值。 */ - readonly independentPermissionsEnabled: boolean; readonly userId: string; /** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */ readonly groupIds: readonly string[]; @@ -37,20 +35,16 @@ export interface EffectiveRoleInput { * r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。 * "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。 * - * D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant - * 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的 - * grant 不受开关影响。 + * 项目级 grant 恒参与计算(ADR-0030):原 D11 独立权限开关已废除, + * FileLibProjectSettings 不再被读取。 */ export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null { const onChain = new Set([input.nodeId, ...input.ancestorIds]); const groups = new Set(input.groupIds); - const freezeProjectGrants = - input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled; let best: FileLibRole | null = null; for (const grant of input.grants) { if (!onChain.has(grant.nodeId)) continue; - if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue; if (grant.principalType === "USER" && grant.principalId !== input.userId) continue; if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue; if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role; diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts index 75a1825..10322b4 100644 --- a/hub/src/database/filelib/treeService.ts +++ b/hub/src/database/filelib/treeService.ts @@ -94,7 +94,7 @@ async function loadVisibleChain( return { node, ancestors: ordered }; } -/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */ +/** 数据获取层:把 chain、grants、groups 装配成纯 reducer 的输入。 */ async function resolveRole( tx: Tx, deps: AccessDeps, @@ -106,20 +106,11 @@ async function resolveRole( where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); - let independentPermissionsEnabled = false; - if (chain.node.kind === "PROJECT") { - const settings = await tx.fileLibProjectSettings.findUnique({ - where: { nodeId: chain.node.id }, - select: { independentPermissionsEnabled: true }, - }); - independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false; - } const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); return effectiveRole({ nodeId: chain.node.id, nodeKind: chain.node.kind, ancestorIds: chain.ancestors.map((a) => a.id), - independentPermissionsEnabled, userId: actor.userId, groupIds, grants, @@ -278,11 +269,6 @@ export async function createNode( }, }); } - if (input.kind === "PROJECT") { - await tx.fileLibProjectSettings.create({ - data: { nodeId: id, independentPermissionsEnabled: false }, - }); - } await writeFileLibAudit(tx, { action: nodeAction(input.kind, "Create"), @@ -482,20 +468,12 @@ export async function breadcrumb( where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); - const settings = chain.node.kind === "PROJECT" - ? await tx.fileLibProjectSettings.findUnique({ - where: { nodeId: chain.node.id }, - select: { independentPermissionsEnabled: true }, - }) - : null; return chainNodes.map((current, depth) => { const role = effectiveRole({ nodeId: current.id, nodeKind: current.kind, ancestorIds: chainNodes.slice(0, depth).map((n) => n.id), - independentPermissionsEnabled: - current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false, userId: actor.userId, groupIds, grants: allGrants, @@ -545,14 +523,6 @@ export async function listChildren( where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); - const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id); - const settingsRows = projectIds.length === 0 - ? [] - : await tx.fileLibProjectSettings.findMany({ - where: { nodeId: { in: projectIds } }, - select: { nodeId: true, independentPermissionsEnabled: true }, - }); - const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled])); const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); const out: ChildNodeDto[] = []; @@ -561,7 +531,6 @@ export async function listChildren( nodeId: child.id, nodeKind: child.kind, ancestorIds: parentAncestorIds, - independentPermissionsEnabled: toggleByNode.get(child.id) ?? false, userId: actor.userId, groupIds, grants: allGrants, diff --git a/hub/src/database/routes/filelibRoutes.ts b/hub/src/database/routes/filelibRoutes.ts index 39cd339..9c6a2ac 100644 --- a/hub/src/database/routes/filelibRoutes.ts +++ b/hub/src/database/routes/filelibRoutes.ts @@ -21,7 +21,6 @@ import { listGrants, putGrants, revokeGrant, - setIndependentPermission, } from "../filelib/grantService.js"; import { FileLibError } from "../filelib/model.js"; import { @@ -114,9 +113,6 @@ export async function registerFileLibRoutes( where: { id, organizationId: deps.organizationId }, }); if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); - const settings = node.kind === "PROJECT" - ? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } }) - : null; return { node: { id: node.id, @@ -126,7 +122,6 @@ export async function registerFileLibRoutes( description: node.description, role, provisionStatus: node.provisionStatus, - independentPermission: settings?.independentPermissionsEnabled ?? false, createdAt: node.createdAt, updatedAt: node.updatedAt, }, @@ -258,21 +253,6 @@ export async function registerFileLibRoutes( } }); - app.put("/database/api/projects/:id/independent-permission", async (request, reply) => { - const actor = await actorOrNull(request, reply, deps); - if (actor === null) return reply; - try { - const { id } = request.params as { id: string }; - const body = bodyObject(request.body); - if (typeof body["enabled"] !== "boolean") { - throw new FileLibError(400, "invalid_request", "enabled must be a boolean"); - } - return await setIndependentPermission(grantDeps, actor, id, body["enabled"]); - } catch (error) { - return sendRouteError(reply, error); - } - }); - // Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub // MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。 } diff --git a/hub/test/integration/filelib-tree.test.ts b/hub/test/integration/filelib-tree.test.ts index 93d8621..b2605af 100644 --- a/hub/test/integration/filelib-tree.test.ts +++ b/hub/test/integration/filelib-tree.test.ts @@ -80,20 +80,15 @@ describe("treeService · 创建规则", () => { }); }); -describe("treeService · D11 独立权限开关", () => { - it("关闭时项目级非创建者 grant 冻结,创建者仍 MANAGE", async () => { +describe("treeService · 项目级 grant 恒生效(ADR-0030)", () => { + it("项目级非创建者 grant 创建即生效,创建者仍 MANAGE", async () => { const project = await createNode(deps(), ADMIN, { parentId: null, kind: "PROJECT", name: "TH-141", grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }], }); - await expect(getEffectiveRole(deps(), ALICE, project.id)) - .rejects.toMatchObject({ statusCode: 404 }); // 冻结 = 无权限 = D8 不可见 + // 无开关、无冻结:alice 的项目级 EDIT 立即可见。 + expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); expect(await getEffectiveRole(deps(), ADMIN, project.id)).toBe("MANAGE"); - await prisma.fileLibProjectSettings.update({ - where: { nodeId: project.id }, - data: { independentPermissionsEnabled: true }, - }); - expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); // 恢复 }); }); diff --git a/hub/test/unit/filelib-permission.test.ts b/hub/test/unit/filelib-permission.test.ts index c54069e..102cde7 100644 --- a/hub/test/unit/filelib-permission.test.ts +++ b/hub/test/unit/filelib-permission.test.ts @@ -1,6 +1,6 @@ /** - * 纯权限 reducer 单测(契约 P6 / D11 / 2.3)。 - * 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结; + * 纯权限 reducer 单测(契约 P6 / 2.3)。 + * 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限; * 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。 */ import { describe, expect, it } from "vitest"; @@ -10,7 +10,6 @@ const base: EffectiveRoleInput = { nodeId: "N", nodeKind: "FOLDER", ancestorIds: ["A", "R"], // N ⊂ A ⊂ R - independentPermissionsEnabled: false, userId: "u1", groupIds: ["g1"], grants: [], @@ -75,33 +74,28 @@ describe("effectiveRole · 契约 P6 矩阵", () => { }); }); -describe("effectiveRole · D11 独立权限开关", () => { +describe("effectiveRole · 项目级 grant 恒生效(ADR-0030)", () => { const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" }; - it("开关关闭:项目级非创建者 grant 冻结", () => { + it("项目级非创建者 grant 直接参与(无开关、无冻结)", () => { const grants = [grant({ nodeId: "P", role: "EDIT" })]; - expect(effectiveRole({ ...project, grants })).toBeNull(); + expect(effectiveRole({ ...project, grants })).toBe("EDIT"); }); - it("开关关闭:创建者 grant 仍生效", () => { + it("创建者 grant 照常生效", () => { const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })]; expect(effectiveRole({ ...project, grants })).toBe("MANAGE"); }); - it("开关关闭:祖先链 grant 不受影响", () => { + it("项目级与祖先链 grant 同取 max", () => { const grants = [ - grant({ nodeId: "P", role: "MANAGE" }), // 冻结 - grant({ nodeId: "A", role: "VIEW" }), // 生效 + grant({ nodeId: "P", role: "VIEW" }), + grant({ nodeId: "A", role: "EDIT" }), ]; - expect(effectiveRole({ ...project, grants })).toBe("VIEW"); + expect(effectiveRole({ ...project, grants })).toBe("EDIT"); }); - it("开关开启:项目级 grant 恢复参与", () => { - const grants = [grant({ nodeId: "P", role: "EDIT" })]; - expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT"); - }); - - it("文件夹忽略开关(self grant 照常参与)", () => { + it("文件夹与项目语义一致(self grant 照常参与)", () => { const grants = [grant({ nodeId: "N", role: "EDIT" })]; expect(effectiveRole({ ...base, grants })).toBe("EDIT"); });