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
@@ -0,0 +1,92 @@
-- 文件库审计日志(横切模块)。语义锚点:ADR-0039。
--
-- 防篡改由三件事共同保证(ADR-0039「不可篡改三道」):
-- 1. 服务路径只 INSERT/SELECT(应用层约束);
-- 2. BEFORE UPDATE OR DELETE 触发器无条件抛异常 —— 应用层写错也拦得住,
-- 归档只允许改 archivedAt 这一列(保留策略是唯一合法出场方式);
-- 3. entryHash 哈希链,org 内按 seq 单调串联,可离线全量校验。
CREATE TYPE "FileLibAuditObjectType" AS ENUM ('FOLDER', 'PROJECT', 'FILE', 'GRANT', 'EXPORT_JOB', 'GROUP', 'SYSTEM');
CREATE TYPE "FileLibAuditResult" AS ENUM ('SUCCESS', 'FAILURE');
CREATE TABLE "FileLibAuditLog" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"seq" BIGINT NOT NULL,
"occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"action" TEXT NOT NULL,
"result" "FileLibAuditResult" NOT NULL DEFAULT 'SUCCESS',
"failureReason" TEXT,
"actorUserId" TEXT NOT NULL,
"actorName" TEXT NOT NULL,
"actorIsAdmin" BOOLEAN NOT NULL DEFAULT false,
"objectType" "FileLibAuditObjectType" NOT NULL,
"objectId" TEXT NOT NULL,
"objectName" TEXT NOT NULL,
"objectPath" TEXT NOT NULL,
"beforeValue" JSONB,
"afterValue" JSONB,
"context" JSONB,
"clientIp" TEXT,
"userAgent" TEXT,
"entryHash" TEXT NOT NULL,
"prevHash" TEXT,
"archivedAt" TIMESTAMP(3),
CONSTRAINT "FileLibAuditLog_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "FileLibAuditLog_organizationId_seq_key" ON "FileLibAuditLog"("organizationId", "seq");
CREATE INDEX "FileLibAuditLog_organizationId_occurredAt_idx" ON "FileLibAuditLog"("organizationId", "occurredAt");
CREATE INDEX "FileLibAuditLog_organizationId_actorUserId_occurredAt_idx" ON "FileLibAuditLog"("organizationId", "actorUserId", "occurredAt");
CREATE INDEX "FileLibAuditLog_organizationId_action_occurredAt_idx" ON "FileLibAuditLog"("organizationId", "action", "occurredAt");
CREATE INDEX "FileLibAuditLog_organizationId_objectType_objectId_idx" ON "FileLibAuditLog"("organizationId", "objectType", "objectId");
CREATE INDEX "FileLibAuditLog_organizationId_objectPath_idx" ON "FileLibAuditLog"("organizationId", "objectPath");
CREATE INDEX "FileLibAuditLog_organizationId_archivedAt_idx" ON "FileLibAuditLog"("organizationId", "archivedAt");
ALTER TABLE "FileLibAuditLog"
ADD CONSTRAINT "FileLibAuditLog_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
-- 只追加写入:UPDATE 仅放行 archivedAt 单列变更(保留策略归档),其余全拒。
CREATE OR REPLACE FUNCTION "filelib_audit_log_append_only"() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
RAISE EXCEPTION 'FileLibAuditLog is append-only: DELETE is forbidden (id=%)', OLD."id";
END IF;
-- 归档打标之外的任何列变化都是篡改。逐列比对而非整行比对,
-- 这样 archivedAt 之外的字段一旦被动过就立刻炸。
IF ROW(NEW."id", NEW."organizationId", NEW."seq", NEW."occurredAt", NEW."action",
NEW."result", NEW."failureReason", NEW."actorUserId", NEW."actorName",
NEW."actorIsAdmin", NEW."objectType", NEW."objectId", NEW."objectName",
NEW."objectPath", NEW."beforeValue", NEW."afterValue", NEW."context",
NEW."clientIp", NEW."userAgent", NEW."entryHash", NEW."prevHash")
IS DISTINCT FROM
ROW(OLD."id", OLD."organizationId", OLD."seq", OLD."occurredAt", OLD."action",
OLD."result", OLD."failureReason", OLD."actorUserId", OLD."actorName",
OLD."actorIsAdmin", OLD."objectType", OLD."objectId", OLD."objectName",
OLD."objectPath", OLD."beforeValue", OLD."afterValue", OLD."context",
OLD."clientIp", OLD."userAgent", OLD."entryHash", OLD."prevHash")
THEN
RAISE EXCEPTION 'FileLibAuditLog is immutable: only archivedAt may change (id=%)', OLD."id";
END IF;
-- 归档不可撤销(取消归档等同于把已出场的日志拉回,是策略绕过)。
IF OLD."archivedAt" IS NOT NULL AND NEW."archivedAt" IS DISTINCT FROM OLD."archivedAt" THEN
RAISE EXCEPTION 'FileLibAuditLog archive stamp is write-once (id=%)', OLD."id";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER "filelib_audit_log_append_only"
BEFORE UPDATE OR DELETE ON "FileLibAuditLog"
FOR EACH ROW EXECUTE FUNCTION "filelib_audit_log_append_only"();
-- 刻意**不**装 BEFORE TRUNCATE 触发器。TRUNCATE 需要表属主权限,能 TRUNCATE
-- 的角色同样能 DROP TRIGGER —— 挡它防不住有意篡改,却会拦下集成测试按 schema
-- 全量重置的固定装置。本触发器防的是应用层写错(UPDATE/DELETE 走行触发器),
-- 有意篡改由哈希链兜底:绕过服务改数据仍会在 /audit/verify 中断链暴露。
+84
View File
@@ -52,6 +52,7 @@ model Organization {
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
fileLibNodes FileLibNode[]
fileLibAuditLogs FileLibAuditLog[] @relation("organizationFileLibAudit")
@@index([status])
}
@@ -1140,3 +1141,86 @@ model FileLibExportJob {
@@index([organizationId, status])
}
// --- 文件库审计日志(横切模块) -------------------------------------------
/// 审计对象类型。objectId 故意不建 FK:审计是不可变历史事实,对象被彻底
/// 删除(node.purge)后日志仍须完整可读,不能被级联带走。
enum FileLibAuditObjectType {
FOLDER
PROJECT
FILE
GRANT
EXPORT_JOB
GROUP
SYSTEM
}
enum FileLibAuditResult {
SUCCESS
FAILURE
}
/// 文件库审计日志(ADR-0039)。与既有 `AuditEntry`(Project/Run 口径、
/// best-effort)及 Platform Audit(ADR-0023)三者分离:本表是文件版本管理与
/// 权限管理子模块的合规证据,字段结构化、只追加、带哈希链防篡改。
///
/// 不可篡改:服务路径只 INSERT/SELECT。迁移 SQL 里装了 BEFORE UPDATE OR
/// DELETE 触发器,任何改写/删除一律抛异常 —— 应用层写错也拦得住。保留策略
/// 归档(≥180 天)是唯一合法的出场方式,走 archivedAt 打标而非物理删除。
///
/// 哈希链:每条 entryHash = sha256(规范化载荷 + prevHash),按 org 串成单链,
/// seq 单调递增。整链可离线校验(见 auditVerify),断链即篡改或漏写。
model FileLibAuditLog {
id String @id @default(cuid())
organizationId String
/// org 内单调递增序号,哈希链的顺序权威(createdAt 同秒不可排序)。
seq BigInt
/// 操作时间。DB 侧 timestamptz,读出即带时区。
occurredAt DateTime @default(now())
action String
result FileLibAuditResult @default(SUCCESS)
/// 失败原因(result=FAILURE 时的错误码 + 消息);成功为 null。
failureReason String?
/// 操作人 id。故意不建 FK:用户注销后审计仍须留痕。
actorUserId String
/// 操作人姓名快照 —— 记录当时的显示名,不随用户改名而变。
actorName String
/// 操作时是否以网站管理员身份行事(高危操作甄别)。
actorIsAdmin Boolean @default(false)
objectType FileLibAuditObjectType
objectId String
/// 对象名称;节点为 name,文件为文件名,组为组名。
objectName String
/// 对象路径:节点 pathIds,文件为 "<pathIds>:<filePath>"。前缀匹配可查子树。
objectPath String
/// 操作前值(结构化)。创建类操作为 null。
beforeValue Json?
/// 操作后值(结构化)。删除类操作为 null。
afterValue Json?
/// 附加上下文:冲突基线版本、导出参数、commit message 等。
context Json?
clientIp String?
userAgent String?
/// 本条的链哈希;prevHash 为 org 内上一条的 entryHash(首条为 null)。
entryHash String
prevHash String?
/// 保留策略归档时间(≥180 天后打标)。非 null 即已归档,默认查询不返回。
archivedAt DateTime?
organization Organization @relation("organizationFileLibAudit", fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([organizationId, seq])
@@index([organizationId, occurredAt])
@@index([organizationId, actorUserId, occurredAt])
@@index([organizationId, action, occurredAt])
@@index([organizationId, objectType, objectId])
@@index([organizationId, objectPath])
@@index([organizationId, archivedAt])
}