forked from bai/curriculum-project-hub
Merge branch 'feat/filelib-always-independent'
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -13,25 +13,8 @@
|
||||
let exportJob = $state<ExportJob | null>(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<void> {
|
||||
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 @@
|
||||
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
|
||||
</div>
|
||||
|
||||
<!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
|
||||
<!-- 导出只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
|
||||
{#if node.kind === "PROJECT"}
|
||||
<div class="my-4 border-t border-line-soft"></div>
|
||||
<div class="flex flex-wrap items-center gap-2.5">
|
||||
<span class="quiet">独立权限</span>
|
||||
<b class="text-[13px]">{node.independentPermission ? "开启" : "关闭"}</b>
|
||||
{#if canManage}
|
||||
<button class="btn" onclick={toggleIndependent}>{node.independentPermission ? "关闭" : "开启"}</button>
|
||||
{/if}
|
||||
<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>
|
||||
</div>
|
||||
|
||||
<div class="my-4 border-t border-line-soft"></div>
|
||||
|
||||
<div class="section-title mb-2">导出</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
|
||||
@@ -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<string>([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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)。此处不再注册,避免重复。
|
||||
}
|
||||
|
||||
@@ -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"); // 恢复
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user