forked from EduCraft/curriculum-project-hub
4849a765da
GrantDto 加 principalName:USER → User.displayName,GROUP → MemberGroup.name, 取不到行(用户/组已删)时回落为 principalId,与 /database/api/me 同一回落语义。 解析走批量 helper(两条 IN 查询,非 N+1),listGrants/putGrants/forceAdjustGrants 三个出口共用,保证 GET 与 PUT 响应同形状。组不按 archivedAt 过滤 —— 已归档组的 历史授权仍需显示名字,否则管理员无法辨认后收回。 principalName 是纯展示字段;写路径仍只认 principalId,不得据此做授权判断。 前端: - GrantsPanel 主体列由裸 id 改为展示名,id 移入 title 供排查;收回确认框同步。 - LibraryView 侧栏身份区改用 $me.displayName(/me 早已返回,此前未消费)。 - types.ts 去掉重复声明的 Grant 与无引用的 GroupSearchResult。 集成测试断言三种情形(displayName / 组名 / 已删主体回落)。
357 lines
13 KiB
TypeScript
357 lines
13 KiB
TypeScript
/**
|
|
* 授权管理(契约 8.1 矩阵的服务端强制)。
|
|
*
|
|
* 矩阵:
|
|
* - 创建者(creatorId 不可变):可授/改/收 MANAGE、EDIT、VIEW;自身 creator grant 不可动
|
|
* - MANAGE 持有者:可授/改/收 EDIT、VIEW;不可碰 MANAGE;不可动创建者
|
|
* - EDIT/VIEW:无授权能力(requireAccess MANAGE 已挡)
|
|
* - 网站管理员:走 forceAdjustGrants(不查节点权限,D19),全部留 admin.force_adjust 审计
|
|
*
|
|
* D8:目标节点不可见/无权限 → 404;有权限但矩阵禁止 → 403。
|
|
*/
|
|
|
|
import { Prisma } from "@prisma/client";
|
|
import type { FileLibGrant, FileLibNode, PrismaClient } from "@prisma/client";
|
|
import { FileLibError, type FileLibRole } from "./model.js";
|
|
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
|
import {
|
|
requireAccessInTx,
|
|
type AccessDeps,
|
|
type FileLibActor,
|
|
type InitialGrant,
|
|
} from "./treeService.js";
|
|
|
|
export interface GrantDto {
|
|
readonly id: string;
|
|
readonly principalType: "USER" | "GROUP";
|
|
readonly principalId: string;
|
|
/**
|
|
* 展示名(ADR-0029:后端负责把 id 解析成人看的名字,前端不二次查询)。
|
|
* USER → `User.displayName`;GROUP → `MemberGroup.name`;
|
|
* 取不到行(用户/组已删)时回落为 principalId,与 `/database/api/me` 同一回落语义。
|
|
* 纯展示字段:写路径仍只认 principalId,不得用它做任何授权判断。
|
|
*/
|
|
readonly principalName: string;
|
|
readonly role: FileLibRole;
|
|
readonly isCreatorGrant: boolean;
|
|
readonly createdAt: Date;
|
|
}
|
|
|
|
function toDto(grant: FileLibGrant, principalName?: string): GrantDto {
|
|
return {
|
|
id: grant.id,
|
|
principalType: grant.principalType,
|
|
principalId: grant.principalId,
|
|
principalName: principalName ?? grant.principalId,
|
|
role: grant.role,
|
|
isCreatorGrant: grant.isCreatorGrant,
|
|
createdAt: grant.createdAt,
|
|
};
|
|
}
|
|
|
|
type Tx = Prisma.TransactionClient;
|
|
type Deps = AccessDeps & { readonly prisma: PrismaClient };
|
|
|
|
/**
|
|
* 批量解析 principal 展示名(两条 IN 查询,不做 N+1)。
|
|
* 组不按 archivedAt 过滤:已归档组的历史授权仍要能显示出名字来给管理员收回。
|
|
*/
|
|
async function resolvePrincipalNames(
|
|
tx: Tx | PrismaClient,
|
|
grants: readonly FileLibGrant[],
|
|
): Promise<ReadonlyMap<string, string>> {
|
|
const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))];
|
|
const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))];
|
|
const [users, groups] = await Promise.all([
|
|
userIds.length === 0
|
|
? Promise.resolve([])
|
|
: tx.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }),
|
|
groupIds.length === 0
|
|
? Promise.resolve([])
|
|
: tx.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }),
|
|
]);
|
|
const names = new Map<string, string>();
|
|
for (const u of users) names.set(`USER:${u.id}`, u.displayName);
|
|
for (const g of groups) names.set(`GROUP:${g.id}`, g.name);
|
|
return names;
|
|
}
|
|
|
|
async function toDtosWithNames(tx: Tx | PrismaClient, grants: readonly FileLibGrant[]): Promise<readonly GrantDto[]> {
|
|
const names = await resolvePrincipalNames(tx, grants);
|
|
return grants.map((g) => toDto(g, names.get(`${g.principalType}:${g.principalId}`)));
|
|
}
|
|
|
|
/** MANAGE 门禁:带 tx 时用调用方事务(与后续写同绳),不带时自开一个。 */
|
|
async function requireManage(
|
|
deps: Deps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
tx?: Tx,
|
|
): Promise<{ readonly node: FileLibNode; readonly role: FileLibRole }> {
|
|
if (tx !== undefined) return requireAccessInTx(tx, deps, actor, nodeId, "MANAGE");
|
|
return deps.prisma.$transaction(async (inner) => requireAccessInTx(inner, deps, actor, nodeId, "MANAGE"));
|
|
}
|
|
|
|
/** 列出节点活跃授权(需 MANAGE)。 */
|
|
export async function listGrants(
|
|
deps: Deps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
): Promise<readonly GrantDto[]> {
|
|
await requireManage(deps, actor, nodeId);
|
|
const grants = await deps.prisma.fileLibGrant.findMany({
|
|
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
|
|
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
|
});
|
|
return toDtosWithNames(deps.prisma, grants);
|
|
}
|
|
|
|
export interface PutGrantsResult {
|
|
readonly granted: number;
|
|
readonly updated: number;
|
|
readonly grants: readonly GrantDto[];
|
|
}
|
|
|
|
/**
|
|
* 批量授予/修改(upsert 语义):同 principal 已有活跃授权 → 改级别(permission.update);
|
|
* 没有 → 新建(permission.grant)。8.1 矩阵在写之前整体校验。
|
|
*/
|
|
export async function putGrants(
|
|
deps: Deps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
items: readonly InitialGrant[],
|
|
): Promise<PutGrantsResult> {
|
|
validateGrantItems(items);
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const { node } = await requireManage(deps, actor, nodeId, tx);
|
|
const isCreator = node.creatorId === actor.userId;
|
|
for (const item of items) {
|
|
if (item.role === "MANAGE" && !isCreator) {
|
|
throw new FileLibError(403, "only_creator_can_grant_manage", "only the creator can grant MANAGE");
|
|
}
|
|
if (item.principalType === "USER" && item.principalId === node.creatorId) {
|
|
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
|
|
}
|
|
}
|
|
|
|
let granted = 0;
|
|
let updated = 0;
|
|
for (const item of items) {
|
|
const existing = await tx.fileLibGrant.findFirst({
|
|
where: {
|
|
nodeId: node.id,
|
|
principalType: item.principalType,
|
|
principalId: item.principalId,
|
|
revokedAt: null,
|
|
},
|
|
});
|
|
if (existing !== null) {
|
|
if (existing.isCreatorGrant) {
|
|
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
|
|
}
|
|
if (existing.role !== item.role) {
|
|
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
|
|
updated += 1;
|
|
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item });
|
|
}
|
|
} else {
|
|
await tx.fileLibGrant.create({
|
|
data: {
|
|
organizationId: deps.organizationId,
|
|
nodeId: node.id,
|
|
principalType: item.principalType,
|
|
principalId: item.principalId,
|
|
role: item.role,
|
|
createdByUserId: actor.userId,
|
|
},
|
|
});
|
|
granted += 1;
|
|
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
|
|
}
|
|
}
|
|
const grants = await tx.fileLibGrant.findMany({
|
|
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
|
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
|
});
|
|
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
|
|
});
|
|
}
|
|
|
|
/** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */
|
|
export async function revokeGrant(
|
|
deps: Deps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
grantId: string,
|
|
): Promise<void> {
|
|
await deps.prisma.$transaction(async (tx) => {
|
|
const { node } = await requireManage(deps, actor, nodeId, tx);
|
|
const grant = await tx.fileLibGrant.findFirst({
|
|
where: { id: grantId, nodeId: node.id, revokedAt: null },
|
|
});
|
|
if (grant === null) throw new FileLibError(404, "grant_not_found", "grant not found");
|
|
if (grant.isCreatorGrant) {
|
|
throw new FileLibError(403, "cannot_touch_creator", "the creator's grant is immutable");
|
|
}
|
|
if (grant.role === "MANAGE" && node.creatorId !== actor.userId) {
|
|
throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE");
|
|
}
|
|
await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } });
|
|
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, {
|
|
principalType: grant.principalType,
|
|
principalId: grant.principalId,
|
|
role: grant.role,
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 网站管理员强制调整(D19):凭 node id 操作,不查操作者节点权限;矩阵豁免;
|
|
* 每一条变更都落 admin.force_adjust 审计(高危留痕)。
|
|
*/
|
|
export async function forceAdjustGrants(
|
|
deps: Deps,
|
|
actor: FileLibActor,
|
|
nodeId: string,
|
|
items: readonly InitialGrant[],
|
|
): Promise<PutGrantsResult> {
|
|
if (!actor.isWebsiteAdmin) {
|
|
throw new FileLibError(403, "forbidden", "force adjust requires website administrator");
|
|
}
|
|
validateGrantItems(items);
|
|
return deps.prisma.$transaction(async (tx) => {
|
|
const node = await tx.fileLibNode.findFirst({
|
|
where: { id: nodeId, organizationId: deps.organizationId, deletedAt: null },
|
|
});
|
|
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
|
|
|
let granted = 0;
|
|
let updated = 0;
|
|
for (const item of items) {
|
|
const existing = await tx.fileLibGrant.findFirst({
|
|
where: {
|
|
nodeId: node.id,
|
|
principalType: item.principalType,
|
|
principalId: item.principalId,
|
|
revokedAt: null,
|
|
},
|
|
});
|
|
if (existing !== null) {
|
|
if (existing.role !== item.role) {
|
|
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
|
|
updated += 1;
|
|
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
|
|
change: "update",
|
|
principalType: item.principalType,
|
|
principalId: item.principalId,
|
|
from: existing.role,
|
|
to: item.role,
|
|
});
|
|
}
|
|
} else {
|
|
await tx.fileLibGrant.create({
|
|
data: {
|
|
organizationId: deps.organizationId,
|
|
nodeId: node.id,
|
|
principalType: item.principalType,
|
|
principalId: item.principalId,
|
|
role: item.role,
|
|
createdByUserId: actor.userId,
|
|
},
|
|
});
|
|
granted += 1;
|
|
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
|
|
change: "grant",
|
|
principalType: item.principalType,
|
|
principalId: item.principalId,
|
|
role: item.role,
|
|
});
|
|
}
|
|
}
|
|
const grants = await tx.fileLibGrant.findMany({
|
|
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
|
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
|
});
|
|
return { granted, updated, grants: await toDtosWithNames(tx, grants) };
|
|
});
|
|
}
|
|
|
|
/** 项目独立权限开关(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>();
|
|
for (const item of items) {
|
|
if (item.principalType !== "USER" && item.principalType !== "GROUP") {
|
|
throw new FileLibError(400, "invalid_request", `bad principalType: ${String(item.principalType)}`);
|
|
}
|
|
if (item.role !== "VIEW" && item.role !== "EDIT" && item.role !== "MANAGE") {
|
|
throw new FileLibError(400, "invalid_request", `bad role: ${String(item.role)}`);
|
|
}
|
|
if (item.principalId.trim() === "") {
|
|
throw new FileLibError(400, "invalid_request", "principalId must not be empty");
|
|
}
|
|
const key = `${item.principalType}:${item.principalId}`;
|
|
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate principal: ${key}`);
|
|
seen.add(key);
|
|
}
|
|
}
|
|
|
|
async function audit(
|
|
tx: Prisma.TransactionClient,
|
|
deps: Deps,
|
|
actor: FileLibActor,
|
|
action: string,
|
|
nodeId: string,
|
|
pathIds: string,
|
|
detail: Record<string, unknown>,
|
|
): Promise<void> {
|
|
await writeFileLibAudit(tx, {
|
|
action,
|
|
actorUserId: actor.userId,
|
|
organizationId: deps.organizationId,
|
|
objectType: "grant",
|
|
objectId: nodeId,
|
|
objectPath: pathIds,
|
|
detail,
|
|
});
|
|
}
|