feat(filelib): 操作日志模块——防篡改哈希链、组合查询与 CSV 导出

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:54:45 +08:00
parent c96ea60482
commit 26523d1b54
29 changed files with 2881 additions and 209 deletions
+58 -4
View File
@@ -15,6 +15,11 @@
`/database/api/login-info` 是同形状的既有端点,由 `routes/teacherApp.ts` 注册。)
- `GET /database/api/stats` —— 概览页统计。需登录 **且** 是 silo org OWNER/ADMIN。
- `GET /database/dev-login` —— 仅开发。见下。
- `GET /database/api/audit/meta` —— 筛选器元数据 + 调用者可见范围(all / managed)。
- `GET /database/api/audit/logs` —— 审计日志组合查询(分页;可见性后端裁剪)。
- `GET /database/api/audit/logs.csv` —— 按当前筛选条件导出 CSV。
- `GET /database/api/audit/verify` —— 哈希链全量校验(仅网站管理员)。
- `POST /database/api/audit/archive` —— 触发保留策略归档(仅网站管理员)。
- `GET /database``GET /database/*``GET /app``GET /app/*` —— SPA shell /
客户端路由 fallback`static.ts``registerDatabaseSpa`)。
- `GET /_filelib/*` —— 构建产物资源。SvelteKit 的 `appDir` 改名为 `_filelib`
@@ -27,7 +32,7 @@ SPA 页面(`filelib-web`,真 URL 路由、无 hash):
回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
- `/database/dashboard` —— 后台外壳(侧栏 + 权限门)。未登录跳登录页;
**登录但非 OWNER/ADMIN 显示无权提示**。六个 tab 都是子路由:
`/database/dashboard`(概览)、`/library``/users``/groups``/search``/settings`
`/database/dashboard`(概览)、`/library``/users``/groups``/search`(审计日志查询)`/settings`
> **注册顺序要点**concrete 路由(`/database/config`、`/database/api/*`、
> `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的
@@ -88,6 +93,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0038) |
| `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 |
| `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app``/database` 两个 SPA 回退 |
| `audit/` | **审计日志模块**(横切能力,见下) |
| `filelib/` | 文件库领域层(见下) |
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/api/...")`
@@ -116,7 +122,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0038) |
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0038) |
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
| `filelib/audit.ts` | 文件库 → 审计模块的适配层(词表再导出 + 类型翻译) |
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
| `filelib/routeShared.ts` | 路由共享件(依赖装配/错误映射/请求体校验) |
@@ -148,8 +154,56 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
- **D12**:move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁
- **D15**:删除只打标本节点,"任一祖先已删"即整支不可见
- **8.1**:MANAGE 仅创建者可授/收;creator grant 不可动
- **审计**:一切写操作在业务事务内写 AuditEntry(同事务,失败即回滚);
文件内容写先 versionStore.commit 再审计(宁多版本,不造假审计)
- **审计**:见下节「审计日志模块」。
## 审计日志模块(audit/)
**横切能力**,覆盖工程文件版本管理与权限管理两个子模块的全部关键写操作。
依赖方向单向:业务 service → 审计模块。审计模块**不 import 任何 filelib 业务
类型**,业务侧只 import `audit/index.ts` 这一个 barrel;`filelib/audit.ts`
文件库这一侧的翻译层(FileLibActor → AuditActor、节点 kind → objectType)。
整体摘除或替换成独立审计服务时,业务代码不动。
| 文件 | 职责 |
|------|------|
| `audit/auditModel.ts` | 动作词表、记录形状、哈希链算法、保留期常量(纯逻辑) |
| `audit/auditWriter.ts` | 落库:事务内(成功)与事务外(失败/冲突)两条路径 |
| `audit/auditQuery.ts` | 组合查询 + 三级可见性裁剪 + CSV 导出 |
| `audit/auditRetention.ts` | ≥180 天保留归档 + 哈希链校验 |
| `audit/requestContext.ts` | 客户端 IP / User-Agent 采集 |
| `audit/auditRoutes.ts` | `/database/api/audit/*` |
| `filelib/audit.ts` | 文件库 → 审计模块的适配层(业务 service 的唯一入口) |
**「操作成功则日志必存在」的实现**:成功路径 `writeAudit(tx, …)` 在业务事务
内写,同 commit 同 rollback,**刻意不吞错** —— 日志写不出来整个业务操作回滚。
失败路径 `writeAuditOutOfBand` 走独立连接补写(业务事务已回滚,同事务写必然
一起消失),**刻意吞错** —— 此刻业务已经失败,审计再抛只会把 409 变成 500。
两条路径的错误取舍是反的,不要统一。
**冲突检测**走失败路径:`file.conflict_detected` 记起始版本(baseVersion)与
冲突版本(currentVersion),之后调用方才抛 409。
**不可篡改**三道:①服务路径只 INSERT/SELECT;②迁移 SQL 里的 BEFORE UPDATE
OR DELETE 触发器,只放行 archivedAt 单列变更且 write-once,TRUNCATE 另挡一道;
③ sha256 哈希链(org 内按 seq 单调串联),`/audit/verify` 可全量重算,断链即
篡改或漏写。**保留期 ≥180 天,超期只归档打标不物理删除**,下限硬编码,
`HUB_FILELIB_AUDIT_RETENTION_DAYS` 只能调高不能调低。
**查询可见性三级**(全部下沉在 `auditQuery`,路由层不做第二套判权):
- 网站管理员(silo org OWNER/ADMIN)→ 全系统
- 持 MANAGE 的用户 → 仅自己有 MANAGE 的节点**及其子树**(靠 objectPath 的
pathIds 前缀匹配;文件日志的 `<pathIds>:<filePath>` 天然被覆盖)
- 其余人 → 空结果而非 403(与 D8 不泄露存在性一致)
Group 日志的 objectPath 是 `group:<id>`,不在节点路径空间内,因此只有网站
管理员可见。
环境变量:`HUB_FILELIB_AUDIT_RETENTION_DAYS` — 保留天数,下限 180(默认)。
> **已知缺口**:需求点名的「项目独立权限的开启/关闭/变更」在 ADR-0030 之后
> 已被废除(`FileLibProjectSettings` 不再被读取,见 `filelib/permission.ts`),
> 当前没有产生该事件的代码路径。词表里的三个 `project.independent_permission.*`
> 动作是预留位;该功能若恢复,直接调 `writeFileLibAudit` 即可。
## 约定(与 admin 面一致)
+246
View File
@@ -0,0 +1,246 @@
/**
* 审计日志模块 —— 领域类型与哈希链(纯逻辑,不碰 IO)。
*
* 语义锚点:ADR-0039(横切、只追加、哈希链、≥180 天保留、三级可见性)。
*
* 本模块是横切能力,单向依赖:业务 service → audit 模块。审计不反向依赖任何
* 业务 service,只认下面这组与领域无关的原语(action 词表 + 记录形状),
* 所以可以整体摘除或替换成独立审计服务而不牵动业务代码。
*/
import { createHash } from "node:crypto";
/* ---------------------------------------------------------------- 动作词表 */
/**
* 审计动作词表。覆盖需求点名的全部关键写操作。
* 值是稳定标识(入库、进查询过滤器、可 grep),改值等于改历史口径,不要动。
*/
export const AUDIT_ACTIONS = {
// 文件夹/项目:创建、删除、移动、重命名
folderCreate: "folder.create",
folderRename: "folder.rename",
folderMove: "folder.move",
folderDelete: "folder.delete",
projectCreate: "project.create",
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
// 归档/取消归档(= 回收站软删的还原侧 + 彻底删除)
folderRestore: "folder.restore",
projectRestore: "project.restore",
nodePurge: "node.purge",
// 个人与 Group 权限的授予、修改、收回(含 MANAGE/EDIT/VIEW)
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
// 项目独立权限的开启、关闭及变更
independentEnable: "project.independent_permission.enable",
independentDisable: "project.independent_permission.disable",
independentChange: "project.independent_permission.change",
// 文件编辑提交(含冲突合并后的提交)与冲突检测事件
fileUpload: "file.upload",
fileRename: "file.rename",
fileDelete: "file.delete",
fileCommit: "file.commit",
fileConflictDetected: "file.conflict_detected",
// 导出
exportRun: "export.run",
// 网站管理员高危操作(根目录创建走 folder/project.create + actorIsAdmin 标记)
adminForceAdjust: "admin.force_adjust",
// Group 的创建、删除、成员增删、嵌套关系变更
groupCreate: "group.create",
groupUpdate: "group.update",
groupDelete: "group.delete",
groupRestore: "group.restore",
groupMemberAdd: "group.member_add",
groupMemberRemove: "group.member_remove",
groupReparent: "group.reparent",
// 保留策略归档(日志自身的生命周期事件)
retentionArchive: "audit.retention_archive",
} as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
/** 全部动作值,查询层用它校验过滤器、前端用它渲染下拉。 */
export const ALL_AUDIT_ACTIONS: readonly string[] = Object.values(AUDIT_ACTIONS);
/** 动作分组 —— 仅供查询 UI 折叠展示,不参与任何判定。 */
export const AUDIT_ACTION_GROUPS: ReadonlyArray<{
readonly key: string;
readonly label: string;
readonly actions: readonly string[];
}> = [
{
key: "node",
label: "文件夹/项目",
actions: [
AUDIT_ACTIONS.folderCreate, AUDIT_ACTIONS.folderRename, AUDIT_ACTIONS.folderMove, AUDIT_ACTIONS.folderDelete,
AUDIT_ACTIONS.projectCreate, AUDIT_ACTIONS.projectRename, AUDIT_ACTIONS.projectMove, AUDIT_ACTIONS.projectDelete,
AUDIT_ACTIONS.folderRestore, AUDIT_ACTIONS.projectRestore, AUDIT_ACTIONS.nodePurge,
],
},
{
key: "permission",
label: "权限",
actions: [
AUDIT_ACTIONS.permissionGrant, AUDIT_ACTIONS.permissionUpdate, AUDIT_ACTIONS.permissionRevoke,
AUDIT_ACTIONS.independentEnable, AUDIT_ACTIONS.independentDisable, AUDIT_ACTIONS.independentChange,
],
},
{
key: "file",
label: "文件",
actions: [
AUDIT_ACTIONS.fileUpload, AUDIT_ACTIONS.fileCommit, AUDIT_ACTIONS.fileRename,
AUDIT_ACTIONS.fileDelete, AUDIT_ACTIONS.fileConflictDetected,
],
},
{ key: "export", label: "导出", actions: [AUDIT_ACTIONS.exportRun] },
{
key: "group",
label: "Group",
actions: [
AUDIT_ACTIONS.groupCreate, AUDIT_ACTIONS.groupUpdate, AUDIT_ACTIONS.groupDelete,
AUDIT_ACTIONS.groupRestore, AUDIT_ACTIONS.groupMemberAdd, AUDIT_ACTIONS.groupMemberRemove,
AUDIT_ACTIONS.groupReparent,
],
},
{
key: "admin",
label: "管理员高危",
actions: [AUDIT_ACTIONS.adminForceAdjust, AUDIT_ACTIONS.retentionArchive],
},
];
/* ---------------------------------------------------------------- 记录形状 */
export type AuditObjectType =
| "FOLDER" | "PROJECT" | "FILE" | "GRANT" | "EXPORT_JOB" | "GROUP" | "SYSTEM";
export type AuditResult = "SUCCESS" | "FAILURE";
/** 操作人身份(从 FileLibActor 摘出的最小面,审计模块不 import 业务类型)。 */
export interface AuditActor {
readonly userId: string;
/** 姓名快照。缺失时调用方回落 userId —— 审计字段要求「用户ID + 用户名」。 */
readonly displayName?: string | undefined;
readonly isWebsiteAdmin?: boolean | undefined;
}
/** 客户端信息(可选;从请求头提取,见 requestContext)。 */
export interface AuditClient {
readonly ip?: string | undefined;
readonly userAgent?: string | undefined;
}
/** 一条待写入的审计记录。 */
export interface AuditRecordInput {
readonly action: string;
readonly actor: AuditActor;
readonly objectType: AuditObjectType;
readonly objectId: string;
readonly objectName: string;
/** 节点 pathIds,或文件的 `<pathIds>:<filePath>`。前缀匹配即子树查询。 */
readonly objectPath: string;
readonly result?: AuditResult | undefined;
readonly failureReason?: string | undefined;
readonly before?: unknown;
readonly after?: unknown;
readonly context?: Record<string, unknown> | undefined;
readonly client?: AuditClient | undefined;
}
/* ---------------------------------------------------------------- 哈希链 */
/**
* 规范化载荷 → sha256(prevHash + payload)。ADR-0039:改动参与哈希的字段集合
* 会使既有链整体失配 —— 那是需要新 ADR 与重锚流程的破坏性变更,不是重构。
*
* 规范化的关键是 key 排序:JSON.stringify 的键序取决于对象构造顺序,
* 不排序的话同一条记录换个写法就算出不同哈希,校验会假报篡改。
*/
export function canonicalize(value: unknown): string {
if (value === null || value === undefined) return "null";
if (typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
}
/** 参与哈希的字段集合。改这里等于换链算法,历史链会整体失配。 */
export interface HashableEntry {
readonly organizationId: string;
readonly seq: bigint | number;
readonly occurredAt: Date;
readonly action: string;
readonly result: AuditResult;
readonly failureReason: string | null;
readonly actorUserId: string;
readonly actorName: string;
readonly actorIsAdmin: boolean;
readonly objectType: AuditObjectType;
readonly objectId: string;
readonly objectName: string;
readonly objectPath: string;
readonly beforeValue: unknown;
readonly afterValue: unknown;
readonly context: unknown;
readonly clientIp: string | null;
readonly userAgent: string | null;
}
export function computeEntryHash(entry: HashableEntry, prevHash: string | null): string {
const payload = canonicalize({
organizationId: entry.organizationId,
seq: entry.seq.toString(),
occurredAt: entry.occurredAt.toISOString(),
action: entry.action,
result: entry.result,
failureReason: entry.failureReason,
actorUserId: entry.actorUserId,
actorName: entry.actorName,
actorIsAdmin: entry.actorIsAdmin,
objectType: entry.objectType,
objectId: entry.objectId,
objectName: entry.objectName,
objectPath: entry.objectPath,
beforeValue: entry.beforeValue ?? null,
afterValue: entry.afterValue ?? null,
context: entry.context ?? null,
clientIp: entry.clientIp,
userAgent: entry.userAgent,
});
return createHash("sha256").update(`${prevHash ?? ""}\n${payload}`).digest("hex");
}
/* ---------------------------------------------------------------- 保留策略 */
/** ADR-0039:保留期下限 180 天。配置只能调高,不能调低。 */
export const AUDIT_RETENTION_DAYS_MIN = 180;
/** 生效保留天数;配置低于下限时按下限执行(合规底线不可调低)。 */
export function resolveRetentionDays(raw?: string | undefined): number {
if (raw === undefined || raw.trim() === "") return AUDIT_RETENTION_DAYS_MIN;
const parsed = Number(raw.trim());
if (!Number.isSafeInteger(parsed) || parsed < AUDIT_RETENTION_DAYS_MIN) {
return AUDIT_RETENTION_DAYS_MIN;
}
return parsed;
}
/** 姓名快照:缺 displayName 时回落 userId,保证字段永不为空。 */
export function actorName(actor: AuditActor): string {
const name = actor.displayName?.trim();
return name === undefined || name === "" ? actor.userId : name;
}
+272
View File
@@ -0,0 +1,272 @@
/**
* 审计查询与导出。语义锚点:ADR-0039「读可见性三级」。
*
* 可见性三级:
* - 网站管理员(silo org OWNER/ADMIN):全系统日志
* - 持 MANAGE 的用户:仅自己拥有 MANAGE 的文件夹/项目**及其子树**相关日志
* - 普通用户:默认不可查看 —— scope 解析出空可见集,返回空页而非 403
* (不泄露"系统里有没有日志"这件事,与 D8 的不泄露存在性一致)
*
* 子树语义:MANAGE 挂在文件夹上时,该文件夹下所有节点的日志都算"相关"。
* 用 objectPath 的 pathIds 前缀匹配实现 —— 与树服务的物化路径同一套编码。
* 文件日志的 objectPath 是 `<pathIds>:<filePath>`,前缀匹配天然覆盖。
*
* Group 日志不挂在任何节点路径上,只有网站管理员可见。
*/
import type { Prisma, PrismaClient } from "@prisma/client";
import type { AuditObjectType, AuditResult } from "./auditModel.js";
export interface AuditQueryDeps {
readonly prisma: PrismaClient;
readonly organizationId: string;
/** 解析用户所属组闭包(与文件库共用的 C2 口);用于 GROUP 主体的 MANAGE grant。 */
readonly resolveMemberGroupIds: (userId: string) => Promise<readonly string[]>;
}
export interface AuditQueryActor {
readonly userId: string;
readonly isWebsiteAdmin: boolean;
}
export interface AuditQueryFilter {
readonly from?: Date | undefined;
readonly to?: Date | undefined;
readonly actorUserId?: string | undefined;
readonly actions?: readonly string[] | undefined;
readonly objectType?: AuditObjectType | undefined;
readonly objectId?: string | undefined;
/** 对象路径前缀(pathIds);查某个子树用它。 */
readonly objectPathPrefix?: string | undefined;
readonly result?: AuditResult | undefined;
/** 是否包含已归档(超保留期)记录。默认 false。 */
readonly includeArchived?: boolean | undefined;
}
export interface AuditPage {
readonly total: number;
readonly offset: number;
readonly limit: number;
readonly entries: readonly AuditEntryDto[];
}
export interface AuditEntryDto {
readonly id: string;
readonly seq: string;
readonly occurredAt: Date;
readonly action: string;
readonly result: AuditResult;
readonly failureReason: string | null;
readonly actorUserId: string;
readonly actorName: string;
readonly actorIsAdmin: boolean;
readonly objectType: AuditObjectType;
readonly objectId: string;
readonly objectName: string;
readonly objectPath: string;
readonly beforeValue: unknown;
readonly afterValue: unknown;
readonly context: unknown;
readonly clientIp: string | null;
readonly userAgent: string | null;
readonly archivedAt: Date | null;
}
export const AUDIT_PAGE_SIZE_MAX = 200;
export const AUDIT_EXPORT_ROWS_MAX = 10_000;
/**
* 解析调用者可见的 pathIds 前缀集合。
* 返回 null = 全系统可见(管理员);返回 [] = 一条都看不到(普通用户)。
*/
async function visiblePathPrefixes(
deps: AuditQueryDeps,
actor: AuditQueryActor,
): Promise<readonly string[] | null> {
if (actor.isWebsiteAdmin) return null;
const groupIds = await deps.resolveMemberGroupIds(actor.userId);
const grants = await deps.prisma.fileLibGrant.findMany({
where: {
organizationId: deps.organizationId,
revokedAt: null,
role: "MANAGE",
OR: [
{ principalType: "USER", principalId: actor.userId },
...(groupIds.length > 0
? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }]
: []),
],
},
select: { nodeId: true },
});
if (grants.length === 0) return [];
// 已删节点也要能查 —— 删除本身正是最该被追溯的操作。
const nodes = await deps.prisma.fileLibNode.findMany({
where: { organizationId: deps.organizationId, id: { in: grants.map((g) => g.nodeId) } },
select: { pathIds: true },
});
return [...new Set(nodes.map((n) => n.pathIds))];
}
/** 把过滤器 + 可见性合成 Prisma where。可见集为空时返回 null(调用方短路成空页)。 */
async function buildWhere(
deps: AuditQueryDeps,
actor: AuditQueryActor,
filter: AuditQueryFilter,
): Promise<Prisma.FileLibAuditLogWhereInput | null> {
const prefixes = await visiblePathPrefixes(deps, actor);
if (prefixes !== null && prefixes.length === 0) return null;
const where: Prisma.FileLibAuditLogWhereInput = { organizationId: deps.organizationId };
if (filter.from !== undefined || filter.to !== undefined) {
where.occurredAt = {
...(filter.from !== undefined ? { gte: filter.from } : {}),
...(filter.to !== undefined ? { lte: filter.to } : {}),
};
}
if (filter.actorUserId !== undefined) where.actorUserId = filter.actorUserId;
if (filter.actions !== undefined && filter.actions.length > 0) {
where.action = { in: [...filter.actions] };
}
if (filter.objectType !== undefined) where.objectType = filter.objectType;
if (filter.objectId !== undefined) where.objectId = filter.objectId;
if (filter.result !== undefined) where.result = filter.result;
if (filter.includeArchived !== true) where.archivedAt = null;
const pathClauses: Prisma.FileLibAuditLogWhereInput[] = [];
if (filter.objectPathPrefix !== undefined && filter.objectPathPrefix !== "") {
pathClauses.push({ objectPath: { startsWith: filter.objectPathPrefix } });
}
if (prefixes !== null) {
// 自身 + 子树:pathIds 精确等于,或以 "<pathIds>/" / "<pathIds>:" 开头。
pathClauses.push({
OR: prefixes.flatMap((p) => [
{ objectPath: p },
{ objectPath: { startsWith: `${p}/` } },
{ objectPath: { startsWith: `${p}:` } },
]),
});
}
if (pathClauses.length > 0) where.AND = pathClauses;
return where;
}
/** 组合查询(时间范围/操作人/操作类型/操作对象)。按时间倒序分页。 */
export async function queryAuditLogs(
deps: AuditQueryDeps,
actor: AuditQueryActor,
filter: AuditQueryFilter,
page: { readonly offset?: number | undefined; readonly limit?: number | undefined } = {},
): Promise<AuditPage> {
const offset = Math.max(0, page.offset ?? 0);
const limit = Math.min(Math.max(1, page.limit ?? 50), AUDIT_PAGE_SIZE_MAX);
const where = await buildWhere(deps, actor, filter);
if (where === null) return { total: 0, offset, limit, entries: [] };
const [total, rows] = await Promise.all([
deps.prisma.fileLibAuditLog.count({ where }),
deps.prisma.fileLibAuditLog.findMany({
where,
orderBy: [{ occurredAt: "desc" }, { seq: "desc" }],
skip: offset,
take: limit,
}),
]);
return { total, offset, limit, entries: rows.map(toDto) };
}
/** 导出用:同一过滤器下的全量拉取(上限 AUDIT_EXPORT_ROWS_MAX)。 */
export async function collectAuditLogsForExport(
deps: AuditQueryDeps,
actor: AuditQueryActor,
filter: AuditQueryFilter,
): Promise<readonly AuditEntryDto[]> {
const where = await buildWhere(deps, actor, filter);
if (where === null) return [];
const rows = await deps.prisma.fileLibAuditLog.findMany({
where,
orderBy: [{ occurredAt: "desc" }, { seq: "desc" }],
take: AUDIT_EXPORT_ROWS_MAX,
});
return rows.map(toDto);
}
function toDto(row: {
id: string; seq: bigint; occurredAt: Date; action: string; result: string;
failureReason: string | null; actorUserId: string; actorName: string; actorIsAdmin: boolean;
objectType: string; objectId: string; objectName: string; objectPath: string;
beforeValue: unknown; afterValue: unknown; context: unknown;
clientIp: string | null; userAgent: string | null; archivedAt: Date | null;
}): AuditEntryDto {
return {
id: row.id,
// BigInt 不能进 JSON.stringify,出库即转字符串。
seq: row.seq.toString(),
occurredAt: row.occurredAt,
action: row.action,
result: row.result as AuditResult,
failureReason: row.failureReason,
actorUserId: row.actorUserId,
actorName: row.actorName,
actorIsAdmin: row.actorIsAdmin,
objectType: row.objectType as AuditObjectType,
objectId: row.objectId,
objectName: row.objectName,
objectPath: row.objectPath,
beforeValue: row.beforeValue ?? null,
afterValue: row.afterValue ?? null,
context: row.context ?? null,
clientIp: row.clientIp,
userAgent: row.userAgent,
archivedAt: row.archivedAt,
};
}
/* ---------------------------------------------------------------- CSV 导出 */
const CSV_COLUMNS: ReadonlyArray<{ readonly key: string; readonly pick: (e: AuditEntryDto) => string }> = [
{ key: "日志ID", pick: (e) => e.id },
{ key: "序号", pick: (e) => e.seq },
{ key: "操作时间", pick: (e) => e.occurredAt.toISOString() },
{ key: "操作人ID", pick: (e) => e.actorUserId },
{ key: "操作人", pick: (e) => e.actorName },
{ key: "管理员身份", pick: (e) => (e.actorIsAdmin ? "是" : "否") },
{ key: "操作类型", pick: (e) => e.action },
{ key: "对象类型", pick: (e) => e.objectType },
{ key: "对象ID", pick: (e) => e.objectId },
{ key: "对象名称", pick: (e) => e.objectName },
{ key: "对象路径", pick: (e) => e.objectPath },
{ key: "操作结果", pick: (e) => (e.result === "SUCCESS" ? "成功" : "失败") },
{ key: "失败原因", pick: (e) => e.failureReason ?? "" },
{ key: "操作前值", pick: (e) => stringifyJson(e.beforeValue) },
{ key: "操作后值", pick: (e) => stringifyJson(e.afterValue) },
{ key: "附加上下文", pick: (e) => stringifyJson(e.context) },
{ key: "客户端IP", pick: (e) => e.clientIp ?? "" },
{ key: "User-Agent", pick: (e) => e.userAgent ?? "" },
];
function stringifyJson(value: unknown): string {
if (value === null || value === undefined) return "";
return JSON.stringify(value);
}
/**
* CSV 转义。前导 `=`/`+`/`-`/`@` 要加单引号前缀 —— 否则 Excel 会把
* 用户可控的对象名当公式执行(CSV 注入)。审计导出正是给人用 Excel 打开的。
*/
function csvCell(raw: string): string {
const value = /^[=+\-@]/.test(raw) ? `'${raw}` : raw;
return `"${value.replace(/"/g, '""')}"`;
}
export function toCsv(entries: readonly AuditEntryDto[]): string {
const header = CSV_COLUMNS.map((c) => csvCell(c.key)).join(",");
const lines = entries.map((e) => CSV_COLUMNS.map((c) => csvCell(c.pick(e))).join(","));
// BOM:Excel 靠它认 UTF-8,否则中文全乱码。
return `${[header, ...lines].join("\r\n")}\r\n`;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* 保留策略与防篡改校验。
*
* 保留:默认 ≥180 天(下限硬编码在 auditModel,配置只能调高不能调低)。
* 超期记录**只归档打标,不物理删除** —— DB 触发器也不允许 DELETE。
* 归档记录默认不出现在查询里,但显式 includeArchived 仍可读出来。
*
* 校验:重算整条哈希链。任何一条被改写(绕过触发器、直连 DB、恢复了篡改过的
* 备份)都会在该条及其之后全部失配 —— 报告首个断点即可定位。
*/
import type { PrismaClient } from "@prisma/client";
import {
AUDIT_ACTIONS,
computeEntryHash,
resolveRetentionDays,
type AuditResult,
} from "./auditModel.js";
import { writeAudit } from "./auditWriter.js";
export interface AuditMaintenanceDeps {
readonly prisma: PrismaClient;
readonly organizationId: string;
/** 保留天数;不传读 HUB_FILELIB_AUDIT_RETENTION_DAYS,再回落 180。 */
readonly retentionDays?: number | undefined;
}
function retentionDaysOf(deps: AuditMaintenanceDeps): number {
return deps.retentionDays ?? resolveRetentionDays(process.env["HUB_FILELIB_AUDIT_RETENTION_DAYS"]);
}
export interface ArchiveResult {
readonly archived: number;
readonly cutoff: Date;
readonly retentionDays: number;
}
/**
* 归档超过保留期的记录(打 archivedAt 标记)。幂等:已归档的不会被重复处理
* (触发器也会拒绝二次改写 archivedAt)。
*
* 归档动作本身也写一条审计 —— 谁在什么时候把哪一批日志移出了默认视图,
* 这件事同样需要可追溯。
*/
export async function archiveExpiredAuditLogs(
deps: AuditMaintenanceDeps,
actor: { readonly userId: string; readonly displayName?: string | undefined },
): Promise<ArchiveResult> {
const retentionDays = retentionDaysOf(deps);
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
const { count } = await deps.prisma.fileLibAuditLog.updateMany({
where: {
organizationId: deps.organizationId,
archivedAt: null,
occurredAt: { lt: cutoff },
},
data: { archivedAt: new Date() },
});
if (count > 0) {
await deps.prisma.$transaction(async (tx) => {
await writeAudit(tx, { organizationId: deps.organizationId }, {
action: AUDIT_ACTIONS.retentionArchive,
actor: { userId: actor.userId, displayName: actor.displayName, isWebsiteAdmin: true },
objectType: "SYSTEM",
objectId: "audit-retention",
objectName: "审计日志保留策略",
objectPath: "/",
context: { retentionDays, cutoff: cutoff.toISOString(), archived: count },
});
});
}
return { archived: count, cutoff, retentionDays };
}
export interface ChainBreak {
readonly id: string;
readonly seq: string;
readonly occurredAt: Date;
readonly reason: "hash_mismatch" | "prev_hash_mismatch" | "seq_gap";
}
export interface VerifyResult {
readonly checked: number;
readonly ok: boolean;
readonly breaks: readonly ChainBreak[];
}
/**
* 全链校验。按 seq 升序流式重算(含已归档记录 —— 归档不出链)。
* 分批读,避免大表一次性载入内存。
*/
export async function verifyAuditChain(
deps: Pick<AuditMaintenanceDeps, "prisma" | "organizationId">,
options: { readonly batchSize?: number | undefined } = {},
): Promise<VerifyResult> {
const batchSize = options.batchSize ?? 500;
const breaks: ChainBreak[] = [];
let checked = 0;
let cursorSeq = 0n;
let expectedPrevHash: string | null = null;
let expectedSeq = 1n;
for (;;) {
const rows = await deps.prisma.fileLibAuditLog.findMany({
where: { organizationId: deps.organizationId, seq: { gt: cursorSeq } },
orderBy: { seq: "asc" },
take: batchSize,
});
if (rows.length === 0) break;
for (const row of rows) {
checked += 1;
if (row.seq !== expectedSeq) {
// 序号跳号 = 有记录被物理删除,或写入时漏号。
breaks.push({ id: row.id, seq: row.seq.toString(), occurredAt: row.occurredAt, reason: "seq_gap" });
expectedSeq = row.seq;
}
if (row.prevHash !== expectedPrevHash) {
breaks.push({ id: row.id, seq: row.seq.toString(), occurredAt: row.occurredAt, reason: "prev_hash_mismatch" });
}
const recomputed = computeEntryHash(
{
organizationId: row.organizationId,
seq: row.seq,
occurredAt: row.occurredAt,
action: row.action,
result: row.result as AuditResult,
failureReason: row.failureReason,
actorUserId: row.actorUserId,
actorName: row.actorName,
actorIsAdmin: row.actorIsAdmin,
objectType: row.objectType,
objectId: row.objectId,
objectName: row.objectName,
objectPath: row.objectPath,
beforeValue: row.beforeValue ?? null,
afterValue: row.afterValue ?? null,
context: row.context ?? null,
clientIp: row.clientIp,
userAgent: row.userAgent,
},
row.prevHash,
);
if (recomputed !== row.entryHash) {
breaks.push({ id: row.id, seq: row.seq.toString(), occurredAt: row.occurredAt, reason: "hash_mismatch" });
}
expectedPrevHash = row.entryHash;
expectedSeq = row.seq + 1n;
cursorSeq = row.seq;
}
}
return { checked, ok: breaks.length === 0, breaks };
}
+205
View File
@@ -0,0 +1,205 @@
/**
* /database/api/audit/* —— 审计查询、导出、维护端点。
*
* 约定沿用 database 面:绝对路径、guard 前置 fail closed、查询 scope 到 silo org。
* 可见性判定全部下沉到 auditQuery(三级:管理员 / MANAGE 持有者 / 其他),
* 路由层不做第二套权限逻辑 —— 两处判权迟早会漂。
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import type { PrismaClient } from "@prisma/client";
import {
ALL_AUDIT_ACTIONS,
AUDIT_ACTION_GROUPS,
AUDIT_EXPORT_ROWS_MAX,
archiveExpiredAuditLogs,
collectAuditLogsForExport,
queryAuditLogs,
toCsv,
verifyAuditChain,
type AuditObjectType,
type AuditQueryActor,
type AuditQueryFilter,
type AuditResult,
} from "./index.js";
export interface AuditRouteDeps {
readonly prisma: PrismaClient;
readonly organizationId: string;
readonly resolveMemberGroupIds: (userId: string) => Promise<readonly string[]>;
/** 复用文件库门禁:返回 null 时响应已发出。 */
readonly actorOrNull: (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<AuditQueryActor | null>;
}
const OBJECT_TYPES: readonly AuditObjectType[] = [
"FOLDER", "PROJECT", "FILE", "GRANT", "EXPORT_JOB", "GROUP", "SYSTEM",
];
class BadRequest extends Error {
constructor(readonly code: string, message: string) {
super(message);
}
}
function parseDate(raw: string | undefined, field: string): Date | undefined {
if (raw === undefined || raw === "") return undefined;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) {
throw new BadRequest("invalid_request", `${field} must be an ISO-8601 datetime`);
}
return date;
}
/** 查询串 → 过滤器。未知的操作类型直接拒绝,免得静默返回空集让人以为"没有日志"。 */
function parseFilter(query: Record<string, string | undefined>): AuditQueryFilter {
const from = parseDate(query["from"], "from");
const to = parseDate(query["to"], "to");
if (from !== undefined && to !== undefined && from > to) {
throw new BadRequest("invalid_request", "from must not be after to");
}
const actionsRaw = query["actions"];
let actions: string[] | undefined;
if (actionsRaw !== undefined && actionsRaw !== "") {
actions = actionsRaw.split(",").map((a) => a.trim()).filter((a) => a !== "");
const unknown = actions.filter((a) => !ALL_AUDIT_ACTIONS.includes(a));
if (unknown.length > 0) {
throw new BadRequest("invalid_request", `unknown action(s): ${unknown.join(", ")}`);
}
}
const objectTypeRaw = query["objectType"];
let objectType: AuditObjectType | undefined;
if (objectTypeRaw !== undefined && objectTypeRaw !== "") {
if (!OBJECT_TYPES.includes(objectTypeRaw as AuditObjectType)) {
throw new BadRequest("invalid_request", `unknown objectType: ${objectTypeRaw}`);
}
objectType = objectTypeRaw as AuditObjectType;
}
const resultRaw = query["result"];
let result: AuditResult | undefined;
if (resultRaw !== undefined && resultRaw !== "") {
if (resultRaw !== "SUCCESS" && resultRaw !== "FAILURE") {
throw new BadRequest("invalid_request", "result must be SUCCESS or FAILURE");
}
result = resultRaw;
}
return {
from,
to,
actorUserId: query["actorUserId"] || undefined,
actions,
objectType,
objectId: query["objectId"] || undefined,
objectPathPrefix: query["objectPath"] || undefined,
result,
includeArchived: query["includeArchived"] === "true",
};
}
function parseInt10(raw: string | undefined, fallback: number): number {
if (raw === undefined || raw === "") return fallback;
const parsed = Number(raw);
return Number.isSafeInteger(parsed) ? parsed : fallback;
}
async function sendAuditError(reply: FastifyReply, error: unknown): Promise<void> {
if (error instanceof BadRequest) {
await reply.status(400).send({ error: { code: error.code, message: error.message } });
return;
}
reply.log.error({ err: error }, "audit route: unexpected error");
await reply.status(500).send({ error: { code: "internal", message: "internal error" } });
}
export async function registerAuditRoutes(
app: FastifyInstance,
deps: AuditRouteDeps,
): Promise<void> {
const queryDeps = {
prisma: deps.prisma,
organizationId: deps.organizationId,
resolveMemberGroupIds: deps.resolveMemberGroupIds,
};
/** 前端渲染筛选器用的元数据(动作分组、对象类型)。登录即可读,不含日志数据。 */
app.get("/database/api/audit/meta", async (request, reply) => {
const actor = await deps.actorOrNull(request, reply);
if (actor === null) return reply;
return {
actionGroups: AUDIT_ACTION_GROUPS,
objectTypes: OBJECT_TYPES,
exportRowLimit: AUDIT_EXPORT_ROWS_MAX,
// 前端据此提示"你看到的是自己管辖范围内的日志"。
scope: actor.isWebsiteAdmin ? "all" : "managed",
};
});
/** 组合查询。可见性在服务层裁剪:普通用户得到空页而非 403(不泄露存在性)。 */
app.get("/database/api/audit/logs", async (request, reply) => {
const actor = await deps.actorOrNull(request, reply);
if (actor === null) return reply;
try {
const query = request.query as Record<string, string | undefined>;
return await queryAuditLogs(queryDeps, actor, parseFilter(query), {
offset: parseInt10(query["offset"], 0),
limit: parseInt10(query["limit"], 50),
});
} catch (error) {
return sendAuditError(reply, error);
}
});
/** 导出当前过滤器命中的结果(CSV,上限 AUDIT_EXPORT_ROWS_MAX 行)。 */
app.get("/database/api/audit/logs.csv", async (request, reply) => {
const actor = await deps.actorOrNull(request, reply);
if (actor === null) return reply;
try {
const entries = await collectAuditLogsForExport(
queryDeps,
actor,
parseFilter(request.query as Record<string, string | undefined>),
);
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "");
return reply
.header("Content-Type", "text/csv; charset=utf-8")
.header("Content-Disposition", `attachment; filename="audit-${stamp}.csv"`)
.send(toCsv(entries));
} catch (error) {
return sendAuditError(reply, error);
}
});
/** 哈希链校验(防篡改的"定期校验")。仅网站管理员。 */
app.get("/database/api/audit/verify", async (request, reply) => {
const actor = await deps.actorOrNull(request, reply);
if (actor === null) return reply;
if (!actor.isWebsiteAdmin) {
return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } });
}
try {
return await verifyAuditChain(queryDeps);
} catch (error) {
return sendAuditError(reply, error);
}
});
/** 手动触发保留策略归档。仅网站管理员;归档本身也留痕。 */
app.post("/database/api/audit/archive", async (request, reply) => {
const actor = await deps.actorOrNull(request, reply);
if (actor === null) return reply;
if (!actor.isWebsiteAdmin) {
return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } });
}
try {
return await archiveExpiredAuditLogs(queryDeps, { userId: actor.userId });
} catch (error) {
return sendAuditError(reply, error);
}
});
}
+165
View File
@@ -0,0 +1,165 @@
/**
* 审计写入器 —— 唯一的落库入口。语义锚点:ADR-0039。
*
* 两条路径,对应两种保证(ADR-0039「两条写路径的错误取舍相反」):
*
* 1. `writeAudit(tx, …)` —— 成功操作。在**业务事务内**写,同 commit 同 rollback。
* 这就是「操作成功则日志必存在」:日志写不出来,业务一起回滚。刻意不吞错。
*
* 2. `writeAuditOutOfBand(prisma, …)` —— 失败操作与冲突检测事件。业务事务已
* 回滚(或根本没开),必须用独立连接补写,否则「操作结果=失败及失败原因」
* 永远记不下来。这条路径**吞错**:审计写失败不能把已经失败的请求变成 500,
* 只记 logger。两条路径的取舍是反的,不要统一。
*
* 序号与哈希链:每条记录取 org 内 max(seq)+1,prevHash 取该条的 entryHash。
* 并发下两个事务可能读到同一个 max —— 由 `@@unique([organizationId, seq])`
* 挡住,冲突方重试(见 SEQ_RETRIES)。宁可重试也不要链上出现重号。
*/
import type { Prisma, PrismaClient } from "@prisma/client";
import {
actorName,
computeEntryHash,
type AuditRecordInput,
type AuditResult,
} from "./auditModel.js";
/** 只需要能跑事务/查表的最小面 —— tx 与 PrismaClient 都满足。 */
type AnyClient = Prisma.TransactionClient | PrismaClient;
/** seq 抢号失败的重试次数。唯一索引冲突是正常并发,不是错误。 */
const SEQ_RETRIES = 5;
export interface AuditWriteDeps {
readonly organizationId: string;
}
/** 可空 Json 列:create 时省略即落 SQL NULL,不需要 Prisma.DbNull。 */
function toJson(value: unknown): Prisma.InputJsonValue | undefined {
if (value === undefined || value === null) return undefined;
return value as Prisma.InputJsonValue;
}
async function insertOnce(
client: AnyClient,
deps: AuditWriteDeps,
input: AuditRecordInput,
): Promise<void> {
const previous = await client.fileLibAuditLog.findFirst({
where: { organizationId: deps.organizationId },
orderBy: { seq: "desc" },
select: { seq: true, entryHash: true },
});
const seq = (previous?.seq ?? 0n) + 1n;
const prevHash = previous?.entryHash ?? null;
const occurredAt = new Date();
const result: AuditResult = input.result ?? "SUCCESS";
const hashable = {
organizationId: deps.organizationId,
seq,
occurredAt,
action: input.action,
result,
failureReason: input.failureReason ?? null,
actorUserId: input.actor.userId,
actorName: actorName(input.actor),
actorIsAdmin: input.actor.isWebsiteAdmin ?? false,
objectType: input.objectType,
objectId: input.objectId,
objectName: input.objectName,
objectPath: input.objectPath,
beforeValue: input.before ?? null,
afterValue: input.after ?? null,
context: input.context ?? null,
clientIp: input.client?.ip ?? null,
userAgent: input.client?.userAgent ?? null,
};
await client.fileLibAuditLog.create({
data: {
organizationId: hashable.organizationId,
seq,
occurredAt,
action: hashable.action,
result: hashable.result,
failureReason: hashable.failureReason,
actorUserId: hashable.actorUserId,
actorName: hashable.actorName,
actorIsAdmin: hashable.actorIsAdmin,
objectType: hashable.objectType,
objectId: hashable.objectId,
objectName: hashable.objectName,
objectPath: hashable.objectPath,
...(toJson(hashable.beforeValue) !== undefined ? { beforeValue: toJson(hashable.beforeValue)! } : {}),
...(toJson(hashable.afterValue) !== undefined ? { afterValue: toJson(hashable.afterValue)! } : {}),
...(toJson(hashable.context) !== undefined ? { context: toJson(hashable.context)! } : {}),
clientIp: hashable.clientIp,
userAgent: hashable.userAgent,
entryHash: computeEntryHash(hashable, prevHash),
prevHash,
},
});
}
function isSeqConflict(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
(error as { code?: unknown }).code === "P2002"
);
}
/**
* 路径 1:业务事务内写审计。**不吞错** —— 抛出即业务回滚,
* 这正是「操作成功则日志必存在」的实现方式。
*
* 注意:在事务内 seq 冲突无法靠重试解决(冲突后整个事务已中止),
* 直接抛给调用方由整体重试。实践中同 org 并发写树的概率很低。
*/
export async function writeAudit(
tx: Prisma.TransactionClient,
deps: AuditWriteDeps,
input: AuditRecordInput,
): Promise<void> {
await insertOnce(tx, deps, input);
}
/** 一次写多条(如创建节点时附带的初始授权),顺序即链上顺序。 */
export async function writeAuditMany(
tx: Prisma.TransactionClient,
deps: AuditWriteDeps,
inputs: readonly AuditRecordInput[],
): Promise<void> {
for (const input of inputs) {
await insertOnce(tx, deps, input);
}
}
export interface OutOfBandDeps extends AuditWriteDeps {
readonly prisma: PrismaClient;
/** 写失败时的告警出口(Fastify logger);缺省吞掉。 */
readonly onError?: ((error: unknown) => void) | undefined;
}
/**
* 路径 2:事务外补写(失败结果、冲突检测事件)。
* **吞错**:此刻业务已经失败,再抛只会把 409/403 变成 500,让用户更迷惑。
*/
export async function writeAuditOutOfBand(
deps: OutOfBandDeps,
input: AuditRecordInput,
): Promise<void> {
for (let attempt = 0; attempt < SEQ_RETRIES; attempt += 1) {
try {
await deps.prisma.$transaction(async (tx) => {
await insertOnce(tx, { organizationId: deps.organizationId }, input);
});
return;
} catch (error) {
if (isSeqConflict(error) && attempt < SEQ_RETRIES - 1) continue;
deps.onError?.(error);
return;
}
}
}
+65
View File
@@ -0,0 +1,65 @@
/**
* 审计日志模块(横切能力)对外唯一入口。
*
* 依赖方向单向:业务 → 审计。本模块不 import 任何 filelib 业务类型,
* 业务侧也只 import 这个 barrel,不深入子文件 —— 换实现(比如日后拆成
* 独立审计服务)只需重写本目录,业务代码不动。
*
* 结构:
* | auditModel.ts | 动作词表、记录形状、哈希链、保留期常量(纯逻辑) |
* | auditWriter.ts | 落库:事务内(成功)与事务外(失败/冲突)两条路径 |
* | auditQuery.ts | 组合查询 + 三级可见性 + CSV 导出 |
* | auditRetention.ts | ≥180 天保留归档 + 哈希链校验 |
* | requestContext.ts | 客户端 IP / User-Agent 采集 |
* | auditRoutes.ts | /database/api/audit/* HTTP 面 |
*/
export {
AUDIT_ACTIONS,
ALL_AUDIT_ACTIONS,
AUDIT_ACTION_GROUPS,
AUDIT_RETENTION_DAYS_MIN,
actorName,
canonicalize,
computeEntryHash,
resolveRetentionDays,
type AuditAction,
type AuditActor,
type AuditClient,
type AuditObjectType,
type AuditRecordInput,
type AuditResult,
} from "./auditModel.js";
export {
writeAudit,
writeAuditMany,
writeAuditOutOfBand,
type AuditWriteDeps,
type OutOfBandDeps,
} from "./auditWriter.js";
export {
AUDIT_EXPORT_ROWS_MAX,
AUDIT_PAGE_SIZE_MAX,
collectAuditLogsForExport,
queryAuditLogs,
toCsv,
type AuditEntryDto,
type AuditPage,
type AuditQueryActor,
type AuditQueryDeps,
type AuditQueryFilter,
} from "./auditQuery.js";
export {
archiveExpiredAuditLogs,
verifyAuditChain,
type ArchiveResult,
type ChainBreak,
type VerifyResult,
} from "./auditRetention.js";
export { requestClient, type RequestLike } from "./requestContext.js";
export { registerAuditRoutes, type AuditRouteDeps } from "./auditRoutes.js";
+41
View File
@@ -0,0 +1,41 @@
/**
* 客户端信息采集(IP / User-Agent)。
*
* IP 取值:优先 `X-Forwarded-For` 的**最左**一跳(真实客户端),这要求
* Fastify 的 `trustProxy` 已按部署形态配置好;否则回落 `request.ip`。
* 采不到就是 null —— 需求里客户端信息是可选字段,不能因为它写不成而阻断业务。
*
* 这一层刻意不 import 任何业务类型,只认 Fastify 请求的最小面,
* 让审计模块可以整体搬走。
*/
import type { AuditClient } from "./auditModel.js";
/** 只依赖这几项 —— FastifyRequest 天然满足,测试可以传字面量。 */
export interface RequestLike {
readonly ip?: string | undefined;
readonly headers: Record<string, string | string[] | undefined>;
}
/** UA 过长会撑爆行;审计只需要能辨识客户端,截断即可。 */
const USER_AGENT_MAX = 512;
const IP_MAX = 64;
function firstHeader(value: string | string[] | undefined): string | undefined {
if (value === undefined) return undefined;
return Array.isArray(value) ? value[0] : value;
}
export function requestClient(request: RequestLike | undefined): AuditClient | undefined {
if (request === undefined) return undefined;
const forwarded = firstHeader(request.headers["x-forwarded-for"]);
const rawIp = forwarded?.split(",")[0]?.trim() || request.ip;
const ip = rawIp === undefined || rawIp === "" ? undefined : rawIp.slice(0, IP_MAX);
const rawUa = firstHeader(request.headers["user-agent"]);
const userAgent = rawUa === undefined || rawUa === "" ? undefined : rawUa.slice(0, USER_AGENT_MAX);
if (ip === undefined && userAgent === undefined) return undefined;
return { ip, userAgent };
}
+78 -62
View File
@@ -1,83 +1,99 @@
/**
* 文件库审计 sink(契约 C3 的入驻适配)
* 文件库 → 审计模块的适配
*
* 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。
* 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务
* 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强
* 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。
* 审计能力本体在 `../audit/`(横切模块,不认识文件库)。本文件是文件库这一侧
* 的翻译:把 FileLibActor / 节点 kind / pathIds 这些领域概念,映射成审计模块
* 的 AuditActor / objectType / objectPath。业务 service 只 import 本文件
*
* 保留 `FILE_LIB_AUDIT_ACTIONS` 这个名字是为了不惊动既有 import;它就是审计
* 模块词表的再导出,不是第二份词表。
*/
import type { Prisma } from "@prisma/client";
import type { Prisma, PrismaClient } from "@prisma/client";
import {
AUDIT_ACTIONS,
writeAudit,
writeAuditOutOfBand,
type AuditActor,
type AuditObjectType,
type AuditRecordInput,
} from "../audit/index.js";
import type { FileLibActor } from "./treeService.js";
/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */
export const FILE_LIB_AUDIT_ACTIONS = {
folderCreate: "folder.create",
folderRename: "folder.rename",
folderMove: "folder.move",
folderDelete: "folder.delete",
projectCreate: "project.create",
projectRename: "project.rename",
projectMove: "project.move",
projectDelete: "project.delete",
// ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。
folderRestore: "folder.restore",
projectRestore: "project.restore",
nodePurge: "node.purge",
permissionGrant: "permission.grant",
permissionUpdate: "permission.update",
permissionRevoke: "permission.revoke",
independentEnable: "project.independent_permission.enable",
independentDisable: "project.independent_permission.disable",
independentChange: "project.independent_permission.change",
fileUpload: "file.upload",
fileRename: "file.rename",
fileDelete: "file.delete",
fileCommit: "file.commit",
fileConflictDetected: "file.conflict_detected",
exportRun: "export.run",
adminForceAdjust: "admin.force_adjust",
// ADR-0038:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
groupCreate: "group.create",
groupUpdate: "group.update",
groupDelete: "group.delete",
groupRestore: "group.restore",
groupMemberAdd: "group.member_add",
groupMemberRemove: "group.member_remove",
} as const;
/** 文件库审计动作词表 = 审计模块词表(单一来源,不复制)。 */
export const FILE_LIB_AUDIT_ACTIONS = AUDIT_ACTIONS;
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
export type FileLibAuditObjectType = AuditObjectType;
/** FileLibActor → AuditActor:姓名快照 + 管理员标记 + 客户端信息一并带上。 */
export function auditActor(actor: FileLibActor): AuditActor {
return {
userId: actor.userId,
displayName: actor.displayName,
isWebsiteAdmin: actor.isWebsiteAdmin,
};
}
/** 节点 kind → 审计对象类型。 */
export function nodeObjectType(kind: "FOLDER" | "PROJECT"): AuditObjectType {
return kind === "PROJECT" ? "PROJECT" : "FOLDER";
}
export interface FileLibAuditEntry {
readonly action: string;
readonly actorUserId: string;
readonly actor: FileLibActor;
readonly organizationId: string;
readonly objectType: FileLibAuditObjectType;
readonly objectType: AuditObjectType;
readonly objectId: string;
/** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */
/** 对象名称(节点 name / 文件名 / 组名)。 */
readonly objectName: string;
/** 节点 pathIds,或文件的 `<pathIds>:<filePath>`。 */
readonly objectPath: string;
readonly detail?: Record<string, unknown> | undefined;
readonly before?: unknown;
readonly after?: unknown;
readonly context?: Record<string, unknown> | undefined;
readonly result?: "SUCCESS" | "FAILURE" | undefined;
readonly failureReason?: string | undefined;
}
function toRecord(entry: FileLibAuditEntry): AuditRecordInput {
return {
action: entry.action,
actor: auditActor(entry.actor),
objectType: entry.objectType,
objectId: entry.objectId,
objectName: entry.objectName,
objectPath: entry.objectPath,
before: entry.before,
after: entry.after,
context: entry.context,
result: entry.result,
failureReason: entry.failureReason,
client: entry.actor.client,
};
}
/**
* 在调用方事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚
* (需求 5.1"操作成功则日志必存在"的强保证)
* 成功操作:在调用方事务内写。刻意不吞错 —— 写不出来整个业务操作回滚,
* 这就是「操作成功则日志必存在的强保证。
*/
export async function writeFileLibAudit(
tx: Prisma.TransactionClient,
entry: FileLibAuditEntry,
): Promise<void> {
const metadata: Record<string, unknown> = {
objectType: entry.objectType,
objectId: entry.objectId,
objectPath: entry.objectPath,
...(entry.detail ?? {}),
};
await tx.auditEntry.create({
data: {
action: entry.action,
actorUserId: entry.actorUserId,
organizationId: entry.organizationId,
metadata: metadata as Prisma.InputJsonValue,
},
});
await writeAudit(tx, { organizationId: entry.organizationId }, toRecord(entry));
}
/**
* 失败操作与冲突检测事件:事务外补写(业务事务已回滚,同事务写必然一起消失)。
* 吞错 —— 此刻业务已经失败,审计再抛只会把 409 变成 500。
*/
export async function writeFileLibAuditFailure(
prisma: PrismaClient,
entry: FileLibAuditEntry & { readonly failureReason: string },
): Promise<void> {
await writeAuditOutOfBand(
{ prisma, organizationId: entry.organizationId },
{ ...toRecord(entry), result: entry.result ?? "FAILURE" },
);
}
+13 -7
View File
@@ -12,7 +12,7 @@
import type { PrismaClient } from "@prisma/client";
import { FileLibError, nameKey } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import { FILE_LIB_AUDIT_ACTIONS, nodeObjectType, writeFileLibAudit } from "./audit.js";
import type { GroupResolver } from "./groupResolver.js";
import type { FileLibActor } from "./treeService.js";
@@ -144,12 +144,14 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId
action: node.kind === "PROJECT"
? FILE_LIB_AUDIT_ACTIONS.projectRestore
: FILE_LIB_AUDIT_ACTIONS.folderRestore,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectType: nodeObjectType(node.kind),
objectId: node.id,
objectName: node.name,
objectPath: node.pathIds,
detail: { name: node.name },
before: { deleted: true },
after: { name: node.name, deleted: false },
});
return { name: node.name };
});
@@ -189,12 +191,16 @@ export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId:
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.nodePurge,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectType: nodeObjectType(node.kind),
objectId: node.id,
objectName: node.name,
objectPath: node.pathIds,
detail: { name: node.name, removed },
before: { name: node.name, subtreeSize: removed },
// 彻底删除:无后值。日志本身是这个对象最后的记录 —— 故意不建 FK,
// 对象行没了日志仍在。
context: { removed, irreversible: true },
});
return { removed };
});
+6 -3
View File
@@ -247,12 +247,15 @@ export async function submitExport(
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.exportRun,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "export_job",
objectType: "EXPORT_JOB",
objectId: jobId,
objectName: node.name,
objectPath: node.pathIds,
detail: { target, params },
after: { jobId, target, status: "QUEUED" },
// 需求点名:导出参数进附加上下文。
context: { projectId: node.id, params },
});
return created;
});
+56 -9
View File
@@ -9,7 +9,7 @@
*/
import { FileLibError } from "./model.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit, writeFileLibAuditFailure } from "./audit.js";
import type { CommitResult, FileEntry, VersionInfo, VersionStore, ProjectCommitInfo } from "./versionStore.js";
import type { AccessDeps, FileLibActor } from "./treeService.js";
import { requireAccessInTx } from "./treeService.js";
@@ -151,27 +151,66 @@ export function defaultCommitMessage(actor: FileLibActor, filePath: string): str
return `${who}】修改了【${filePath}`;
}
/** 成功的文件操作:自开事务写审计(内容已落 git,此处只补日志)。 */
async function auditFile(
deps: FileDeps,
actor: FileLibActor,
action: string,
project: ProjectChain,
filePath: string,
detail: Record<string, unknown>,
values: {
readonly before?: unknown;
readonly after?: unknown;
readonly context?: Record<string, unknown> | undefined;
},
): Promise<void> {
await deps.prisma.$transaction(async (tx) => {
await writeFileLibAudit(tx, {
action,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "file",
objectType: "FILE",
objectId: project.node.id,
objectName: filePath.split("/").pop() ?? filePath,
objectPath: `${project.node.pathIds}:${filePath}`,
detail,
before: values.before,
after: values.after,
context: { projectName: project.node.name, filePath, ...(values.context ?? {}) },
});
});
}
/**
* 冲突检测事件:走事务外旁路。冲突之后调用方立刻抛 409,若与业务同事务
* 会被一并回滚 —— 而冲突恰恰是需求点名必须留痕的事件(含起始版本与冲突版本)。
*/
async function auditConflict(
deps: FileDeps,
actor: FileLibActor,
project: ProjectChain,
filePath: string,
versions: { readonly baseVersion: string | null; readonly currentVersion: string },
): Promise<void> {
await writeFileLibAuditFailure(deps.prisma, {
action: FILE_LIB_AUDIT_ACTIONS.fileConflictDetected,
actor,
organizationId: deps.organizationId,
objectType: "FILE",
objectId: project.node.id,
objectName: filePath.split("/").pop() ?? filePath,
objectPath: `${project.node.pathIds}:${filePath}`,
result: "FAILURE",
failureReason: "version_conflict: file was modified since baseVersion",
context: {
projectName: project.node.name,
filePath,
// 需求点名:冲突须记录起始版本与冲突版本。
baseVersion: versions.baseVersion,
currentVersion: versions.currentVersion,
},
});
}
/* ---------------------------------------------------------------- 读操作 */
export async function listFiles(
@@ -293,7 +332,7 @@ export async function commitFile(
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
await auditConflict(deps, actor, project, filePath, {
baseVersion: input.baseVersion,
currentVersion: result.currentVersion,
});
@@ -308,7 +347,13 @@ export async function commitFile(
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
project,
filePath,
{ version: result.version, message },
{
before: input.baseVersion === null ? undefined : { version: input.baseVersion },
after: { version: result.version },
// 冲突合并后的重新提交:baseVersion 就是用户看到的冲突版本,
// 与前一条 conflict_detected 的 currentVersion 对得上,链路可还原。
context: { message, bytes: typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength },
},
);
return { version: result.version };
}
@@ -327,7 +372,7 @@ export async function deleteFile(
displayName: actor.displayName,
});
if (result.status === "conflict") {
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
await auditConflict(deps, actor, project, filePath, {
baseVersion,
currentVersion: result.currentVersion,
});
@@ -335,5 +380,7 @@ export async function deleteFile(
currentVersion: result.currentVersion,
});
}
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion });
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, {
before: { version: baseVersion },
});
}
+43 -28
View File
@@ -129,7 +129,7 @@ export async function listGrants(
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return withPrincipalNames(deps.prisma, grants.map(toDto));
return withPrincipalNames(deps.prisma, grants.map((g) => toDto(g)));
}
export interface PutGrantsResult {
@@ -179,7 +179,10 @@ export async function putGrants(
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 });
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({
@@ -193,14 +196,16 @@ export async function putGrants(
},
});
granted += 1;
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
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(toDto)) };
return { granted, updated, grants: await withPrincipalNames(tx, grants.map((g) => toDto(g))) };
});
}
@@ -224,10 +229,14 @@ export async function revokeGrant(
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,
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 },
});
});
}
@@ -267,12 +276,10 @@ export async function forceAdjustGrants(
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,
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 {
@@ -287,11 +294,9 @@ export async function forceAdjustGrants(
},
});
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,
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 },
});
}
}
@@ -299,7 +304,7 @@ export async function forceAdjustGrants(
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
});
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
return { granted, updated, grants: await withPrincipalNames(tx, grants.map((g) => toDto(g))) };
});
}
@@ -322,22 +327,32 @@ function validateGrantItems(items: readonly InitialGrant[]): void {
}
}
/**
* 授权审计:objectType 恒为 GRANT,objectId/objectPath 用被授权的节点 ——
* 「谁在哪个节点上动了谁的权限」是查询时的主索引。
*/
async function audit(
tx: Prisma.TransactionClient,
deps: Deps,
actor: FileLibActor,
action: string,
nodeId: string,
pathIds: string,
detail: Record<string, unknown>,
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,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "grant",
objectId: nodeId,
objectPath: pathIds,
detail,
objectType: "GRANT",
objectId: node.id,
objectName: node.name,
objectPath: node.pathIds,
before: values.before,
after: values.after,
context: values.context,
});
}
+4 -1
View File
@@ -9,6 +9,7 @@
import type { FastifyReply, FastifyRequest } from "fastify";
import type { OrganizationMemberRole, PrismaClient } from "@prisma/client";
import { requireSession, sendError } from "../../admin/auth/guards.js";
import { requestClient } from "../audit/index.js";
import type { FileLibActor } from "./treeService.js";
export interface FileLibGuardDeps {
@@ -43,7 +44,9 @@ export async function requireFileLibActor(
return {
userId: auth.user.id,
isWebsiteAdmin: WEBSITE_ADMIN_ROLES.includes(membership.role),
// 仅用于 commit message 的【用户名】;权限判定一律走 userId。
displayName: auth.user.displayName,
// 审计的客户端信息(IP/UA)在此一次性采集,随 actor 流到所有写操作 ——
// 业务 service 不认识 FastifyRequest,不能自己去掏。
client: requestClient(request),
};
}
+55 -28
View File
@@ -191,12 +191,16 @@ export async function createMemberGroup(
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupCreate,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "group",
objectType: "GROUP",
objectId: group.id,
objectPath: group.id,
detail: { name, parentId },
objectName: name,
// 组不在文件库树上,没有 pathIds。用 "group:<id>" 占位 ——
// 与节点路径空间隔离,因此组日志只对网站管理员可见(见 auditQuery)。
objectPath: `group:${group.id}`,
after: { name, parentId, description, depth },
context: { nested: parentId !== null },
});
return {
@@ -226,6 +230,11 @@ export async function updateMemberGroup(
return deps.prisma.$transaction(async (tx) => {
await requireActiveGroup(tx, groupId);
// 审计需要「操作前值」—— update 之后原值就取不到了,先读一次。
const previous = await tx.memberGroup.findUnique({
where: { id: groupId },
select: { name: true, description: true },
});
const group = await tx.memberGroup.update({
where: { id: groupId },
data: {
@@ -245,15 +254,14 @@ export async function updateMemberGroup(
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupUpdate,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "group",
objectType: "GROUP",
objectId: group.id,
objectPath: group.id,
detail: {
...(name !== undefined ? { name } : {}),
...(input.description !== undefined ? { description: group.description } : {}),
},
objectName: group.name,
objectPath: `group:${group.id}`,
before: { name: previous?.name ?? null, description: previous?.description ?? null },
after: { name: group.name, description: group.description },
});
return {
@@ -292,12 +300,16 @@ export async function deleteMemberGroup(
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupDelete,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "group",
objectType: "GROUP",
objectId: group.id,
objectPath: group.id,
detail: { name: group.name, archivedCount: result.count },
objectName: group.name,
objectPath: `group:${group.id}`,
before: { name: group.name, archived: false },
after: { archived: true, archivedAt: now.toISOString() },
// 级联软删整棵子树:受影响的组数是审计的关键事实。
context: { archivedCount: result.count, cascadedSubtree: true },
});
return { archivedCount: result.count };
});
@@ -333,12 +345,16 @@ export async function restoreMemberGroup(
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupRestore,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "group",
objectType: "GROUP",
objectId: group.id,
objectPath: group.id,
detail: { name: group.name, restoredCount: result.count },
objectName: group.name,
objectPath: `group:${group.id}`,
before: { archived: true, archivedAt: group.archivedAt.toISOString() },
after: { name: group.name, archived: false },
// 决策7:恢复只解自身 + 已归档祖先,不动子树。
context: { restoredCount: result.count, restoredAncestors: true },
});
return { restoredCount: result.count };
});
@@ -444,12 +460,15 @@ export async function addMember(
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "group",
objectType: "GROUP",
objectId: group.id,
objectPath: group.id,
detail: { userId: user.id },
objectName: group.name,
objectPath: `group:${group.id}`,
// 成员增删的「值」是成员身份本身:前无后有。
after: { userId: user.id, displayName: user.displayName },
context: { groupName: group.name },
});
return {
userId: user.id,
@@ -482,17 +501,24 @@ export async function removeMember(
});
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "group",
objectType: "GROUP",
objectId: group.id,
objectPath: group.id,
detail: { userId },
objectName: group.name,
objectPath: `group:${group.id}`,
before: { userId },
context: { groupName: group.name },
});
});
}
/** 成员选择器:按显示名/openId 搜全局用户(决策2)。 */
/**
* 成员选择器:按显示名/openId/userId 搜全局用户(决策2)。
*
* userId 也纳入匹配:审计日志查询结果里显示的就是 userId,用户从表格拷一个
* id 粘进筛选框必须能命中。共享端点,授权面板与成员组选择器一并受益。
*/
export async function searchUsers(
deps: MemberGroupServiceDeps,
actor: FileLibActor,
@@ -521,6 +547,7 @@ export async function searchUsers(
OR: [
{ displayName: { contains: keyword, mode: "insensitive" as const } },
{ feishuOpenId: { contains: keyword, mode: "insensitive" as const } },
{ id: { contains: keyword, mode: "insensitive" as const } },
],
}),
},
+39 -19
View File
@@ -29,17 +29,22 @@ import {
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";
import { FILE_LIB_AUDIT_ACTIONS, nodeObjectType, writeFileLibAudit } from "./audit.js";
export interface FileLibActor {
readonly userId: string;
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
readonly isWebsiteAdmin: boolean;
/**
* 展示名(飞书昵称)。用于生成 commit message 的【用户名】部分;
* 权限判定一律用 userId。缺失时回退到 userId。
* 展示名(飞书昵称)。用于生成 commit message 的【用户名】部分,
* 以及审计的操作人姓名快照;权限判定一律用 userId。缺失时回退到 userId。
*/
readonly displayName?: string | undefined;
/**
* 客户端信息(IP / User-Agent),由 guard 从请求头采集。
* 只进审计,不参与任何判定;采不到即 undefined。
*/
readonly client?: { readonly ip?: string | undefined; readonly userAgent?: string | undefined } | undefined;
}
export interface TreeServiceDeps {
@@ -279,22 +284,27 @@ export async function createNode(
await writeFileLibAudit(tx, {
action: nodeAction(input.kind, "Create"),
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: input.kind === "PROJECT" ? "project" : "folder",
objectType: nodeObjectType(input.kind),
objectId: id,
objectName: name,
objectPath: pathIds,
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
// 创建:无前值。后值是落库的节点事实。
after: { name, kind: input.kind, parentId: input.parentId, description: input.description ?? null },
context: { initialGrants: initialGrants.length, isRootCreation: input.parentId === null },
});
for (const grant of initialGrants) {
await writeFileLibAudit(tx, {
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: "grant",
objectType: "GRANT",
objectId: id,
objectName: name,
objectPath: pathIds,
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
after: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
context: { reason: "initial_grant_on_create" },
});
}
return node;
@@ -340,12 +350,14 @@ export async function renameNode(
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Rename"),
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectType: nodeObjectType(node.kind),
objectId: node.id,
objectName: name,
objectPath: node.pathIds,
detail: { from: node.name, to: name },
before: { name: node.name },
after: { name },
});
return updated;
});
@@ -403,12 +415,16 @@ export async function moveNode(
}
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Move"),
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectType: nodeObjectType(node.kind),
objectId: node.id,
objectName: node.name,
// 移动后的新路径 —— 按子树查询要能在新位置命中。
objectPath: newPathIds,
detail: { fromParentId: node.parentId, toParentId: newParentId },
before: { parentId: node.parentId, pathIds: node.pathIds },
after: { parentId: newParentId, pathIds: newPathIds },
context: { movedToRoot: newParentId === null },
});
return updated;
});
@@ -422,15 +438,19 @@ export async function softDeleteNode(
): 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() } });
const deletedAt = new Date();
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt } });
await writeFileLibAudit(tx, {
action: nodeAction(node.kind, "Delete"),
actorUserId: actor.userId,
actor,
organizationId: deps.organizationId,
objectType: node.kind === "PROJECT" ? "project" : "folder",
objectType: nodeObjectType(node.kind),
objectId: node.id,
objectName: node.name,
objectPath: node.pathIds,
detail: { name: node.name },
before: { name: node.name, deletedAt: null },
// 软删(D15):后值是打标本身,不是消失 —— 回收站仍可恢复。
after: { deletedAt: deletedAt.toISOString() },
});
});
}
+23 -23
View File
@@ -35,7 +35,7 @@ import { resolveMaxFileBytes } from "../filelib/fileService.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
import { registerAuditRoutes } from "../audit/index.js";
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
@@ -168,6 +168,14 @@ export async function registerDatabaseRoutes(
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
await registerBinRoutes(app, filelibDeps);
// 审计日志模块(横切):自带 /database/api/audit/*。它只认 prisma + org +
// 组解析口 + 一个 actor 门禁函数,不依赖任何 filelib service。
await registerAuditRoutes(app, {
prisma: config.prisma,
organizationId: siloOrg.id,
resolveMemberGroupIds: (userId) => filelibDeps.groupResolver.resolveMemberGroupIds(userId),
actorOrNull: async (request, reply) => actorOrNull(request, reply, filelibDeps),
});
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
@@ -196,6 +204,7 @@ interface DashboardStats {
readonly actor: string;
readonly label: string;
readonly when: Date;
readonly result: string;
}>;
}
@@ -225,29 +234,20 @@ async function loadDashboardStats(
files += (await deps.versionStore.list(project.storageDir)).length;
} catch { /* repo 缺失(如重启未恢复)不计 */ }
}
const entries = await prisma.auditEntry.findMany({
where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } },
orderBy: { createdAt: "desc" },
// 最近活动读审计日志模块的表(FileLibAuditLog)。它自带操作人姓名快照与
// 对象名称,不需要再回查 User —— 这也是审计字段结构化后的直接收益。
const entries = await prisma.fileLibAuditLog.findMany({
where: { organizationId, archivedAt: null },
orderBy: [{ occurredAt: "desc" }, { seq: "desc" }],
take: 8,
select: { action: true, actorName: true, objectName: true, occurredAt: true, result: true },
});
const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))];
const users = actorIds.length === 0
? []
: await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } });
const nameById = new Map(users.map((u) => [u.id, u.displayName]));
const recent = entries.map((entry) => {
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
const label =
(typeof meta["name"] === "string" ? meta["name"] : undefined) ??
(typeof meta["to"] === "string" ? meta["to"] : undefined) ??
(typeof meta["path"] === "string" ? meta["path"] : undefined) ??
(typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : "");
return {
action: entry.action,
actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown",
label,
when: entry.createdAt,
};
});
const recent = entries.map((entry) => ({
action: entry.action,
actor: entry.actorName,
label: entry.objectName,
when: entry.occurredAt,
result: entry.result,
}));
return { folders, projects, files, grants, recent };
}