Files
curriculum-project-hub/hub/src/database/audit/auditModel.ts
T

247 lines
9.1 KiB
TypeScript

/**
* 审计日志模块 —— 领域类型与哈希链(纯逻辑,不碰 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;
}