/** * 授权管理(契约 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; readonly role: FileLibRole; readonly isCreatorGrant: boolean; readonly createdAt: Date; } function toDto(grant: FileLibGrant): GrantDto { return { id: grant.id, principalType: grant.principalType, principalId: grant.principalId, role: grant.role, isCreatorGrant: grant.isCreatorGrant, createdAt: grant.createdAt, }; } type Tx = Prisma.TransactionClient; type Deps = AccessDeps & { readonly prisma: PrismaClient }; /** 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 { 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 grants.map(toDto); } 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 { 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: grants.map(toDto) }; }); } /** 收回授权(需 MANAGE;creator grant 与 MANAGE grant 有额外限制,见 8.1)。 */ export async function revokeGrant( deps: Deps, actor: FileLibActor, nodeId: string, grantId: string, ): Promise { 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 { 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: grants.map(toDto) }; }); } /** 项目独立权限开关(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(); 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, ): Promise { await writeFileLibAudit(tx, { action, actorUserId: actor.userId, organizationId: deps.organizationId, objectType: "grant", objectId: nodeId, objectPath: pathIds, detail, }); }