/** * 审计日志模块 —— 领域类型与哈希链(纯逻辑,不碰 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,或文件的 `:`。前缀匹配即子树查询。 */ readonly objectPath: string; readonly result?: AuditResult | undefined; readonly failureReason?: string | undefined; readonly before?: unknown; readonly after?: unknown; readonly context?: Record | 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) .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; }