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);
|
let exportJob = $state<ExportJob | null>(null);
|
||||||
|
|
||||||
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
|
||||||
const canManage = $derived(node.role === "MANAGE");
|
|
||||||
const roleLabel = $derived(ROLE_LABEL[node.role]);
|
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(() => {
|
$effect(() => {
|
||||||
void node.id;
|
void node.id;
|
||||||
exportJob = null;
|
exportJob = null;
|
||||||
@@ -109,18 +92,8 @@
|
|||||||
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
|
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
|
<!-- 导出只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
|
||||||
{#if node.kind === "PROJECT"}
|
{#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="my-4 border-t border-line-soft"></div>
|
||||||
|
|
||||||
<div class="section-title mb-2">导出</div>
|
<div class="section-title mb-2">导出</div>
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export interface NodeDetail {
|
|||||||
readonly description: string | null;
|
readonly description: string | null;
|
||||||
readonly role: Role;
|
readonly role: Role;
|
||||||
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
|
readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED";
|
||||||
readonly independentPermission: boolean;
|
|
||||||
readonly createdAt: string;
|
readonly createdAt: string;
|
||||||
readonly updatedAt: 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 {
|
function validateGrantItems(items: readonly InitialGrant[]): void {
|
||||||
if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty");
|
if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty");
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 纯权限 reducer(契约 P6 / D11 / D8)。
|
* 纯权限 reducer(契约 P6 / D8)。
|
||||||
*
|
*
|
||||||
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
|
* 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链
|
||||||
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
|
* 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离
|
||||||
@@ -23,8 +23,6 @@ export interface EffectiveRoleInput {
|
|||||||
readonly nodeKind: "FOLDER" | "PROJECT";
|
readonly nodeKind: "FOLDER" | "PROJECT";
|
||||||
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
|
/** 目标的全部祖先 id(不含 self,顺序无关)。 */
|
||||||
readonly ancestorIds: readonly string[];
|
readonly ancestorIds: readonly string[];
|
||||||
/** 项目独立权限开关(D11/P5);文件夹忽略此值。 */
|
|
||||||
readonly independentPermissionsEnabled: boolean;
|
|
||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
|
/** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */
|
||||||
readonly groupIds: readonly string[];
|
readonly groupIds: readonly string[];
|
||||||
@@ -37,20 +35,16 @@ export interface EffectiveRoleInput {
|
|||||||
* r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。
|
* r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。
|
||||||
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
|
* "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。
|
||||||
*
|
*
|
||||||
* D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant
|
* 项目级 grant 恒参与计算(ADR-0030):原 D11 独立权限开关已废除,
|
||||||
* 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的
|
* FileLibProjectSettings 不再被读取。
|
||||||
* grant 不受开关影响。
|
|
||||||
*/
|
*/
|
||||||
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
|
export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null {
|
||||||
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
|
const onChain = new Set<string>([input.nodeId, ...input.ancestorIds]);
|
||||||
const groups = new Set(input.groupIds);
|
const groups = new Set(input.groupIds);
|
||||||
const freezeProjectGrants =
|
|
||||||
input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled;
|
|
||||||
|
|
||||||
let best: FileLibRole | null = null;
|
let best: FileLibRole | null = null;
|
||||||
for (const grant of input.grants) {
|
for (const grant of input.grants) {
|
||||||
if (!onChain.has(grant.nodeId)) continue;
|
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 === "USER" && grant.principalId !== input.userId) continue;
|
||||||
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
|
if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue;
|
||||||
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
|
if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ async function loadVisibleChain(
|
|||||||
return { node, ancestors: ordered };
|
return { node, ancestors: ordered };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
|
/** 数据获取层:把 chain、grants、groups 装配成纯 reducer 的输入。 */
|
||||||
async function resolveRole(
|
async function resolveRole(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
deps: AccessDeps,
|
deps: AccessDeps,
|
||||||
@@ -106,20 +106,11 @@ async function resolveRole(
|
|||||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
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);
|
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||||
return effectiveRole({
|
return effectiveRole({
|
||||||
nodeId: chain.node.id,
|
nodeId: chain.node.id,
|
||||||
nodeKind: chain.node.kind,
|
nodeKind: chain.node.kind,
|
||||||
ancestorIds: chain.ancestors.map((a) => a.id),
|
ancestorIds: chain.ancestors.map((a) => a.id),
|
||||||
independentPermissionsEnabled,
|
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
groupIds,
|
groupIds,
|
||||||
grants,
|
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, {
|
await writeFileLibAudit(tx, {
|
||||||
action: nodeAction(input.kind, "Create"),
|
action: nodeAction(input.kind, "Create"),
|
||||||
@@ -482,20 +468,12 @@ export async function breadcrumb(
|
|||||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
|
||||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
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) => {
|
return chainNodes.map((current, depth) => {
|
||||||
const role = effectiveRole({
|
const role = effectiveRole({
|
||||||
nodeId: current.id,
|
nodeId: current.id,
|
||||||
nodeKind: current.kind,
|
nodeKind: current.kind,
|
||||||
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
|
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
|
||||||
independentPermissionsEnabled:
|
|
||||||
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
|
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
groupIds,
|
groupIds,
|
||||||
grants: allGrants,
|
grants: allGrants,
|
||||||
@@ -545,14 +523,6 @@ export async function listChildren(
|
|||||||
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
|
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
|
||||||
select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true },
|
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 groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
|
||||||
|
|
||||||
const out: ChildNodeDto[] = [];
|
const out: ChildNodeDto[] = [];
|
||||||
@@ -561,7 +531,6 @@ export async function listChildren(
|
|||||||
nodeId: child.id,
|
nodeId: child.id,
|
||||||
nodeKind: child.kind,
|
nodeKind: child.kind,
|
||||||
ancestorIds: parentAncestorIds,
|
ancestorIds: parentAncestorIds,
|
||||||
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
|
|
||||||
userId: actor.userId,
|
userId: actor.userId,
|
||||||
groupIds,
|
groupIds,
|
||||||
grants: allGrants,
|
grants: allGrants,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
listGrants,
|
listGrants,
|
||||||
putGrants,
|
putGrants,
|
||||||
revokeGrant,
|
revokeGrant,
|
||||||
setIndependentPermission,
|
|
||||||
} from "../filelib/grantService.js";
|
} from "../filelib/grantService.js";
|
||||||
import { FileLibError } from "../filelib/model.js";
|
import { FileLibError } from "../filelib/model.js";
|
||||||
import {
|
import {
|
||||||
@@ -114,9 +113,6 @@ export async function registerFileLibRoutes(
|
|||||||
where: { id, organizationId: deps.organizationId },
|
where: { id, organizationId: deps.organizationId },
|
||||||
});
|
});
|
||||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
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 {
|
return {
|
||||||
node: {
|
node: {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
@@ -126,7 +122,6 @@ export async function registerFileLibRoutes(
|
|||||||
description: node.description,
|
description: node.description,
|
||||||
role,
|
role,
|
||||||
provisionStatus: node.provisionStatus,
|
provisionStatus: node.provisionStatus,
|
||||||
independentPermission: settings?.independentPermissionsEnabled ?? false,
|
|
||||||
createdAt: node.createdAt,
|
createdAt: node.createdAt,
|
||||||
updatedAt: node.updatedAt,
|
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
|
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
|
||||||
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
|
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,20 +80,15 @@ describe("treeService · 创建规则", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("treeService · D11 独立权限开关", () => {
|
describe("treeService · 项目级 grant 恒生效(ADR-0030)", () => {
|
||||||
it("关闭时项目级非创建者 grant 冻结,创建者仍 MANAGE", async () => {
|
it("项目级非创建者 grant 创建即生效,创建者仍 MANAGE", async () => {
|
||||||
const project = await createNode(deps(), ADMIN, {
|
const project = await createNode(deps(), ADMIN, {
|
||||||
parentId: null, kind: "PROJECT", name: "TH-141",
|
parentId: null, kind: "PROJECT", name: "TH-141",
|
||||||
grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
|
grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }],
|
||||||
});
|
});
|
||||||
await expect(getEffectiveRole(deps(), ALICE, project.id))
|
// 无开关、无冻结:alice 的项目级 EDIT 立即可见。
|
||||||
.rejects.toMatchObject({ statusCode: 404 }); // 冻结 = 无权限 = D8 不可见
|
expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT");
|
||||||
expect(await getEffectiveRole(deps(), ADMIN, project.id)).toBe("MANAGE");
|
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)。
|
* 纯权限 reducer 单测(契约 P6 / 2.3)。
|
||||||
* 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结;
|
* 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限;
|
||||||
* 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。
|
* 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
@@ -10,7 +10,6 @@ const base: EffectiveRoleInput = {
|
|||||||
nodeId: "N",
|
nodeId: "N",
|
||||||
nodeKind: "FOLDER",
|
nodeKind: "FOLDER",
|
||||||
ancestorIds: ["A", "R"], // N ⊂ A ⊂ R
|
ancestorIds: ["A", "R"], // N ⊂ A ⊂ R
|
||||||
independentPermissionsEnabled: false,
|
|
||||||
userId: "u1",
|
userId: "u1",
|
||||||
groupIds: ["g1"],
|
groupIds: ["g1"],
|
||||||
grants: [],
|
grants: [],
|
||||||
@@ -75,33 +74,28 @@ describe("effectiveRole · 契约 P6 矩阵", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("effectiveRole · D11 独立权限开关", () => {
|
describe("effectiveRole · 项目级 grant 恒生效(ADR-0030)", () => {
|
||||||
const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" };
|
const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" };
|
||||||
|
|
||||||
it("开关关闭:项目级非创建者 grant 冻结", () => {
|
it("项目级非创建者 grant 直接参与(无开关、无冻结)", () => {
|
||||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
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 })];
|
const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })];
|
||||||
expect(effectiveRole({ ...project, grants })).toBe("MANAGE");
|
expect(effectiveRole({ ...project, grants })).toBe("MANAGE");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("开关关闭:祖先链 grant 不受影响", () => {
|
it("项目级与祖先链 grant 同取 max", () => {
|
||||||
const grants = [
|
const grants = [
|
||||||
grant({ nodeId: "P", role: "MANAGE" }), // 冻结
|
grant({ nodeId: "P", role: "VIEW" }),
|
||||||
grant({ nodeId: "A", role: "VIEW" }), // 生效
|
grant({ nodeId: "A", role: "EDIT" }),
|
||||||
];
|
];
|
||||||
expect(effectiveRole({ ...project, grants })).toBe("VIEW");
|
expect(effectiveRole({ ...project, grants })).toBe("EDIT");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("开关开启:项目级 grant 恢复参与", () => {
|
it("文件夹与项目语义一致(self grant 照常参与)", () => {
|
||||||
const grants = [grant({ nodeId: "P", role: "EDIT" })];
|
|
||||||
expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("文件夹忽略开关(self grant 照常参与)", () => {
|
|
||||||
const grants = [grant({ nodeId: "N", role: "EDIT" })];
|
const grants = [grant({ nodeId: "N", role: "EDIT" })];
|
||||||
expect(effectiveRole({ ...base, grants })).toBe("EDIT");
|
expect(effectiveRole({ ...base, grants })).toBe("EDIT");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user