/** * 授权管理(契约 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; /** 主体显示名(用户 displayName / 组 name);主体已删时为 null,前端回落 principalId。 */ readonly principalName: string | null; /** USER 主体的飞书 openId;GROUP 或主体已删时为 null。 */ readonly principalOpenId: string | null; 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: null, principalOpenId: null, role: grant.role, isCreatorGrant: grant.isCreatorGrant, createdAt: grant.createdAt, }; } /** 批量回填主体显示名与飞书 openId(两次查询,不做 per-row 往返)。可在事务内调用。 */ async function withPrincipalNames( prisma: Pick, grants: readonly GrantDto[], ): Promise { 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 = userIds.length === 0 ? [] : await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true, feishuOpenId: true }, }); const groups = groupIds.length === 0 ? [] : await prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }); const nameById = new Map([ ...users.map((u) => [u.id, u.displayName] as const), ...groups.map((g) => [g.id, g.name] as const), ]); const openIdById = new Map(users.map((u) => [u.id, u.feishuOpenId] as const)); return grants.map((g) => ({ ...g, principalName: nameById.get(g.principalId) ?? null, principalOpenId: g.principalType === "USER" ? openIdById.get(g.principalId) ?? null : null, })); } 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> { 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(); 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 { 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 { 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 withPrincipalNames(deps.prisma, grants.map((g) => toDto(g))); } 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, { before: { principalType: item.principalType, principalId: item.principalId, role: existing.role }, after: { principalType: item.principalType, principalId: item.principalId, role: 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.permissionGrant, node, { after: { 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 withPrincipalNames(tx, grants.map((g) => toDto(g))) }; }); } /** 收回授权(需 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, { before: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role, }, // 收回:无后值(授权不复存在)。 context: { grantId: grant.id }, }); }); } /** * 网站管理员强制调整(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, { before: { principalType: item.principalType, principalId: item.principalId, role: existing.role }, after: { principalType: item.principalType, principalId: item.principalId, role: item.role }, context: { change: "update", forced: true }, }); } } 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, { after: { principalType: item.principalType, principalId: item.principalId, role: item.role }, context: { change: "grant", forced: true }, }); } } 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 withPrincipalNames(tx, grants.map((g) => toDto(g))) }; }); } 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); } } /** * 授权审计:objectType 恒为 GRANT,objectId/objectPath 用被授权的节点 —— * 「谁在哪个节点上动了谁的权限」是查询时的主索引。 */ async function audit( tx: Prisma.TransactionClient, deps: Deps, actor: FileLibActor, action: string, node: { readonly id: string; readonly name: string; readonly pathIds: string }, values: { readonly before?: unknown; readonly after?: unknown; readonly context?: Record | undefined; }, ): Promise { await writeFileLibAudit(tx, { action, actor, organizationId: deps.organizationId, objectType: "GRANT", objectId: node.id, objectName: node.name, objectPath: node.pathIds, before: values.before, after: values.after, context: values.context, }); }