forked from EduCraft/curriculum-project-hub
feat(filelib): 操作日志模块——防篡改哈希链、组合查询与 CSV 导出
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user