Files
curriculum-project-hub/hub/src/database/filelib/grantService.ts
T

359 lines
14 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;
/** 主体显示名(用户 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<PrismaClient, "user" | "memberGroup">,
grants: readonly GrantDto[],
): Promise<readonly GrantDto[]> {
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<string, string>([
...users.map((u) => [u.id, u.displayName] as const),
...groups.map((g) => [g.id, g.name] as const),
]);
const openIdById = new Map<string, string>(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<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 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<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, {
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<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, {
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<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, {
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<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);
}
}
/**
* 授权审计: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<string, unknown> | undefined;
},
): Promise<void> {
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,
});
}