feat(database): init database folder frontend and permission

This commit is contained in:
ymy
2026-07-23 23:41:11 +08:00
parent 5df1900ca8
commit 4021e58d5d
67 changed files with 9436 additions and 150 deletions
+598
View File
@@ -0,0 +1,598 @@
/**
* 文件库树服务(Phase 1 服务层,Phase 2 路由直接调用)。
*
* 语义锚定:
* - D8 无权限 → 404 不泄露;越权 → 403(loadChain / requireAccess)
* - D11 creator 不可变 + 自动 MANAGE grant;独立权限开关语义在 permission.ts
* - D12 move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁防并发成环
* - D14 命名规则(model.ts)+ 活跃兄弟唯一(DB 部分唯一索引兜底)
* - D15 删除只打标本节点;"任一祖先已删"即整支不可见
* - D17 breadcrumb 无 View 的祖先只给占位,不泄露名字
* - 树表示:parentId 权威;pathIds 为 id 编码的派生物化路径(name 不入路径,
* rename 不重写后代;move 用一次前缀重写维护)
*
* 网站管理员(D19)= silo org 的 OWNER/ADMIN(契约 C4 的入驻适配):仅 root 创建
* 与 force_adjust 特权,不隐式穿透内容权限 —— 本文件所有读路径对它同样走
* effectiveRole,没有 admin 旁路。
*/
import { randomUUID } from "node:crypto";
import path from "node:path";
import { Prisma } from "@prisma/client";
import type { PrismaClient, FileLibNode } from "@prisma/client";
import {
FileLibError,
nameKey,
normalizeNodeName,
type FileLibRole,
} from "./model.js";
import { checkAccess, effectiveRole } from "./permission.js";
import type { GroupResolver } from "./groupResolver.js";
import type { VersionStore } from "./versionStore.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
export interface FileLibActor {
readonly userId: string;
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
readonly isWebsiteAdmin: boolean;
}
export interface TreeServiceDeps {
readonly prisma: PrismaClient;
readonly groupResolver: GroupResolver;
readonly versionStore: VersionStore;
/** 文件库归属的 silo org(ADR-0020 租户隔离,一切查询 scope 到它)。 */
readonly organizationId: string;
/** 项目 git 仓库的磁盘根目录;项目仓 = <storageRoot>/<nodeId>。 */
readonly storageRoot: string;
}
/** 权限判定实际需要的最小依赖(grantService 等兄弟模块复用)。 */
export type AccessDeps = Pick<TreeServiceDeps, "organizationId" | "groupResolver">;
export interface InitialGrant {
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: FileLibRole;
}
type Tx = Prisma.TransactionClient;
interface Chain {
readonly node: FileLibNode;
/** 根在前、直接父在后;不含 node 自身。 */
readonly ancestors: readonly FileLibNode[];
}
/* ---------------------------------------------------------------- 内部工具 */
/** pathIds = "/rootId/.../selfId";切出祖先 id(不含 self)。 */
function ancestorIdsOf(node: FileLibNode): string[] {
return node.pathIds.split("/").filter((seg) => seg !== "").slice(0, -1);
}
/** 取节点 + 祖先链(org scope);D15:自身或任一祖先已删 → 404。 */
async function loadVisibleChain(
tx: Tx,
organizationId: string,
nodeId: string,
): Promise<Chain> {
const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId } });
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
const ancestorIds = ancestorIdsOf(node);
const ancestors = ancestorIds.length === 0
? []
: await tx.fileLibNode.findMany({ where: { organizationId, id: { in: ancestorIds } } });
if (node.deletedAt !== null || ancestors.some((a) => a.deletedAt !== null)) {
// D15:已删子树对外"不存在"(D8 不泄露)。
throw new FileLibError(404, "node_not_found", "node not found");
}
const byId = new Map(ancestors.map((a) => [a.id, a]));
const ordered = ancestorIds
.map((id) => byId.get(id))
.filter((a): a is FileLibNode => a !== undefined);
return { node, ancestors: ordered };
}
/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */
async function resolveRole(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
chain: Chain,
): Promise<FileLibRole | null> {
const chainIds = [...chain.ancestors.map((a) => a.id), chain.node.id];
const grants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
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);
return effectiveRole({
nodeId: chain.node.id,
nodeKind: chain.node.kind,
ancestorIds: chain.ancestors.map((a) => a.id),
independentPermissionsEnabled,
userId: actor.userId,
groupIds,
grants,
});
}
/** D8 门禁:loadVisibleChain + resolveRole + checkAccess,失败抛 FileLibError。 */
async function requireAccess(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
nodeId: string,
minRole: FileLibRole,
): Promise<Chain & { readonly role: FileLibRole }> {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const role = await resolveRole(tx, deps, actor, chain);
const verdict = checkAccess(role, minRole);
if (!verdict.allowed) {
throw verdict.reason === "not_found"
? new FileLibError(404, "node_not_found", "node not found")
: new FileLibError(403, "forbidden", `requires ${minRole}`);
}
return { ...chain, role: verdict.role };
}
/**
* 兄弟模块(grantService 等)共用的 tx 内门禁:在调用方自己的事务里做
* 权限校验,校验与后续写同一根事务绳,避免 check-tx / write-tx 之间的竞态。
*/
export async function requireAccessInTx(
tx: Tx,
deps: AccessDeps,
actor: FileLibActor,
nodeId: string,
minRole: FileLibRole,
): Promise<Chain & { readonly role: FileLibRole }> {
return requireAccess(tx, deps, actor, nodeId, minRole);
}
/** P2002(活跃兄弟名部分唯一索引)→ 409。 */
function rethrowNameConflict(error: unknown, name: string): never {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
throw new FileLibError(409, "name_conflict", `an active sibling named "${name}" already exists`);
}
throw error;
}
function nodeAction(kind: FileLibNode["kind"], verb: "Create" | "Rename" | "Move" | "Delete"): string {
const table = kind === "PROJECT"
? { Create: FILE_LIB_AUDIT_ACTIONS.projectCreate, Rename: FILE_LIB_AUDIT_ACTIONS.projectRename, Move: FILE_LIB_AUDIT_ACTIONS.projectMove, Delete: FILE_LIB_AUDIT_ACTIONS.projectDelete }
: { Create: FILE_LIB_AUDIT_ACTIONS.folderCreate, Rename: FILE_LIB_AUDIT_ACTIONS.folderRename, Move: FILE_LIB_AUDIT_ACTIONS.folderMove, Delete: FILE_LIB_AUDIT_ACTIONS.folderDelete };
return table[verb];
}
function validateInitialGrants(actor: FileLibActor, grants: readonly InitialGrant[]): void {
const seen = new Set<string>();
for (const grant of grants) {
const key = `${grant.principalType}:${grant.principalId}`;
if (seen.has(key)) throw new FileLibError(400, "duplicate_principal", `duplicate grant principal: ${key}`);
seen.add(key);
if (grant.principalType === "USER" && grant.principalId === actor.userId) {
throw new FileLibError(400, "duplicate_principal", "creator already holds MANAGE via the creator grant");
}
// v1:不校验 group 存在性(C2 未提供批量校验口;给不存在 group 的授权天然无效,不危害)。
}
}
/* ---------------------------------------------------------------- 公共操作 */
export interface CreateNodeInput {
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly description?: string | undefined;
readonly grants?: readonly InitialGrant[] | undefined;
}
/**
* 创建文件夹/项目。root 创建仅网站管理员(契约 2.1);非 root 需父节点 EDIT+。
* creator 自动 MANAGE(D11);项目走 provisioning 状态机:PROVISIONING → init → READY。
*/
export async function createNode(
deps: TreeServiceDeps,
actor: FileLibActor,
input: CreateNodeInput,
): Promise<FileLibNode> {
const name = normalizeNodeName(input.name);
const initialGrants = input.grants ?? [];
validateInitialGrants(actor, initialGrants);
const id = randomUUID();
let pathIds: string;
let storageDir: string | null = null;
const created = await deps.prisma.$transaction(async (tx) => {
if (input.parentId === null) {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "root creation requires website administrator");
}
pathIds = `/${id}`;
} else {
const parent = await requireAccess(tx, deps, actor, input.parentId, "EDIT");
if (parent.node.kind !== "FOLDER") {
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
}
pathIds = `${parent.node.pathIds}/${id}`;
}
if (input.kind === "PROJECT") {
storageDir = path.join(deps.storageRoot, id);
}
let node: FileLibNode;
try {
node = await tx.fileLibNode.create({
data: {
id,
organizationId: deps.organizationId,
parentId: input.parentId,
kind: input.kind,
name,
nameLower: nameKey(name),
pathIds,
creatorId: actor.userId,
provisionStatus: input.kind === "PROJECT" ? "PROVISIONING" : "READY",
storageDir,
...(input.description !== undefined && input.description.trim() !== ""
? { description: input.description.trim() }
: {}),
},
});
} catch (error) {
rethrowNameConflict(error, name);
}
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: id,
principalType: "USER",
principalId: actor.userId,
role: "MANAGE",
isCreatorGrant: true,
createdByUserId: actor.userId,
},
});
for (const grant of initialGrants) {
await tx.fileLibGrant.create({
data: {
organizationId: deps.organizationId,
nodeId: id,
principalType: grant.principalType,
principalId: grant.principalId,
role: grant.role,
createdByUserId: actor.userId,
},
});
}
if (input.kind === "PROJECT") {
await tx.fileLibProjectSettings.create({
data: { nodeId: id, independentPermissionsEnabled: false },
});
}
await writeFileLibAudit(tx, {
action: nodeAction(input.kind, "Create"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: input.kind === "PROJECT" ? "project" : "folder",
objectId: id,
objectPath: pathIds,
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
});
for (const grant of initialGrants) {
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: "grant",
objectId: id,
objectPath: pathIds,
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
});
}
return node;
});
// provisioning 状态机(Metis 风险#1):DB 行已持久,init 失败 → FAILED 可重试/对账。
if (input.kind === "PROJECT" && storageDir !== null) {
try {
await deps.versionStore.init(storageDir);
return await deps.prisma.fileLibNode.update({
where: { id: created.id },
data: { provisionStatus: "READY" },
});
} catch (error) {
await deps.prisma.fileLibNode
.update({ where: { id: created.id }, data: { provisionStatus: "FAILED" } })
.catch(() => undefined);
throw new FileLibError(500, "provision_failed", `repository initialization failed: ${String(error)}`);
}
}
return created;
}
/** 重命名(需本节点 MANAGE,契约 8.2)。id 路径不含 name,后代无需重写。 */
export async function renameNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
rawName: string,
): Promise<FileLibNode> {
const name = normalizeNodeName(rawName);
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
let updated: FileLibNode;
try {
updated = await tx.fileLibNode.update({
where: { id: node.id },
data: { name, nameLower: nameKey(name) },
});
} catch (error) {
rethrowNameConflict(error, name);
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Rename"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { from: node.name, to: name },
});
return updated;
});
}
/**
* 移动(D12):本节点 MANAGE + 目标父 EDIT+(移到 root 需网站管理员);
* 事务 + org 级咨询锁防并发成环;后代 pathIds 一次前缀重写。
*/
export async function moveNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
newParentId: string | null,
): Promise<FileLibNode> {
return deps.prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${"filelib:tree:" + deps.organizationId}))`;
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
if (node.parentId === newParentId) return node;
let newPathIds: string;
if (newParentId === null) {
if (!actor.isWebsiteAdmin) {
throw new FileLibError(403, "forbidden", "moving to root requires website administrator");
}
newPathIds = `/${node.id}`;
} else {
const parent = await requireAccess(tx, deps, actor, newParentId, "EDIT");
if (parent.node.kind !== "FOLDER") {
throw new FileLibError(400, "invalid_parent", "projects cannot have children");
}
if (parent.node.id === node.id || parent.node.pathIds.startsWith(`${node.pathIds}/`)) {
throw new FileLibError(400, "move_into_own_subtree", "cannot move a node into its own subtree");
}
newPathIds = `${parent.node.pathIds}/${node.id}`;
}
const oldPrefix = node.pathIds;
let updated: FileLibNode;
try {
updated = await tx.fileLibNode.update({
where: { id: node.id },
data: { parentId: newParentId, pathIds: newPathIds },
});
// 派生列维护:整支后代的前缀重写(id 编码,与 name 无关)。
await tx.$executeRaw`
UPDATE "FileLibNode"
SET "pathIds" = ${newPathIds} || substring("pathIds" from ${oldPrefix.length + 1}::int)
WHERE "organizationId" = ${deps.organizationId}
AND "pathIds" LIKE ${oldPrefix + "/%"}
`;
} catch (error) {
rethrowNameConflict(error, node.name);
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Move"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: newPathIds,
detail: { fromParentId: node.parentId, toParentId: newParentId },
});
return updated;
});
}
/** 软删除(D15):只打标本节点,后代靠"任一祖先已删"过滤;需 MANAGE。 */
export async function softDeleteNode(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } });
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Delete"),
actorUserId: actor.userId,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectId: node.id,
objectPath: node.pathIds,
detail: { name: node.name },
});
});
}
/** 自查生效权限(契约 9.2 effective-permission)。D8:null 角色即不可见,404。 */
export async function getEffectiveRole(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<FileLibRole | null> {
return deps.prisma.$transaction(async (tx) => {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const role = await resolveRole(tx, deps, actor, chain);
if (role === null) throw new FileLibError(404, "node_not_found", "node not found");
return role;
});
}
export interface BreadcrumbEntry {
readonly depth: number;
/** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */
readonly id: string | null;
readonly name: string | null;
readonly kind: "FOLDER" | "PROJECT";
}
/** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */
export async function breadcrumb(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
): Promise<readonly BreadcrumbEntry[]> {
return deps.prisma.$transaction(async (tx) => {
const chain = await loadVisibleChain(tx, deps.organizationId, nodeId);
const selfRole = await resolveRole(tx, deps, actor, chain);
if (checkAccess(selfRole, "VIEW").allowed !== true) {
throw new FileLibError(404, "node_not_found", "node not found");
}
const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId);
const chainNodes = [...chain.ancestors, chain.node];
const chainIds = chainNodes.map((n) => n.id);
const allGrants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } },
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) => {
const role = effectiveRole({
nodeId: current.id,
nodeKind: current.kind,
ancestorIds: chainNodes.slice(0, depth).map((n) => n.id),
independentPermissionsEnabled:
current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false,
userId: actor.userId,
groupIds,
grants: allGrants,
});
const visible = role !== null;
return {
depth,
id: visible ? current.id : null,
name: visible ? current.name : null,
kind: current.kind,
};
});
});
}
export interface ChildNodeDto {
readonly id: string;
readonly parentId: string | null;
readonly kind: "FOLDER" | "PROJECT";
readonly name: string;
readonly role: FileLibRole;
readonly createdAt: Date;
readonly updatedAt: Date;
}
/** 列子节点(parentId=null 列 root);只返回调用者有 View 的(D8/P7)。 */
export async function listChildren(
deps: TreeServiceDeps,
actor: FileLibActor,
parentId: string | null,
): Promise<readonly ChildNodeDto[]> {
return deps.prisma.$transaction(async (tx) => {
let parentAncestorIds: string[] = [];
if (parentId !== null) {
const parent = await requireAccess(tx, deps, actor, parentId, "VIEW");
parentAncestorIds = [...parent.ancestors.map((a) => a.id), parent.node.id];
}
const children = await tx.fileLibNode.findMany({
where: { organizationId: deps.organizationId, parentId, deletedAt: null },
orderBy: [{ kind: "asc" }, { nameLower: "asc" }],
});
if (children.length === 0) return [];
// D13:一次请求只 resolve 一次组、拉一次 grant 集,批量计算,不做 per-child 往返。
const idsToFetch = [...parentAncestorIds, ...children.map((c) => c.id)];
const allGrants = await tx.fileLibGrant.findMany({
where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } },
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 out: ChildNodeDto[] = [];
for (const child of children) {
const role = effectiveRole({
nodeId: child.id,
nodeKind: child.kind,
ancestorIds: parentAncestorIds,
independentPermissionsEnabled: toggleByNode.get(child.id) ?? false,
userId: actor.userId,
groupIds,
grants: allGrants,
});
if (role === null) continue;
out.push({
id: child.id,
parentId: child.parentId,
kind: child.kind,
name: child.name,
role,
createdAt: child.createdAt,
updatedAt: child.updatedAt,
});
}
return out;
});
}
/** 更新节点简介(需 EDIT+;不记审计,非权限敏感的内容字段)。 */
export async function updateNodeDescription(
deps: TreeServiceDeps,
actor: FileLibActor,
nodeId: string,
description: string | null,
): Promise<FileLibNode> {
return deps.prisma.$transaction(async (tx) => {
const { node } = await requireAccessInTx(tx, deps, actor, nodeId, "EDIT");
return tx.fileLibNode.update({
where: { id: node.id },
data: { description: description?.trim() || null },
});
});
}