diff --git a/docs/adr/0039-filelib-audit-log-module.md b/docs/adr/0039-filelib-audit-log-module.md new file mode 100644 index 0000000..82ab957 --- /dev/null +++ b/docs/adr/0039-filelib-audit-log-module.md @@ -0,0 +1,140 @@ +# ADR 0039: File Library Audit Log Is A Cross-Cutting Append-Only Module + +## Status + +Accepted. + +## Context + +Audit was previously a single sink function (`filelib/audit.ts`) writing rows into +the shared `AuditEntry` table: `action` + `actorUserId` + `organizationId` + a free +`metadata` JSON blob. It satisfied "the write happened", nothing more. + +The file-library requirements ask for materially stronger properties across the +version-management and permission-management submodules: structured before/after +values, actor name snapshot, client IP/User-Agent, success/failure with a reason, +conflict-detection events carrying base and conflicting versions, combined +multi-dimension query with export, three-tier read visibility, tamper resistance, +and a retention floor of 180 days. + +Three of those cannot be expressed on the old shape at all: + +- **Failure results.** Every audit write sat inside the business transaction, so a + failed operation rolled its own audit row back. "Operation result = failure and + reason" was structurally unrecordable. This also silently dropped + `file.conflict_detected`, an event the requirements name explicitly — it was + written and then rolled back with the 409. +- **Query by dimension.** Object type, object id, result, and path lived inside the + `metadata` JSON. Filtering on them means JSON path scans, and there is no index + to hang on them. +- **Tamper resistance.** `AuditEntry` is documented as best-effort telemetry + (ADR-0023) and is Project/Run oriented. It carries no hash chain and no + append-only enforcement. + +ADR-0023 already fixed that privileged *platform* audit must be separate from +customer Project/Run audit. It did not cover the file library's own submodule +audit, which is the subject here. + +## Decision + +### Separate store + +File-library audit gets its own table, `FileLibAuditLog`, distinct from both +`AuditEntry` (Project/Run, best-effort) and Platform Audit Entry (ADR-0023). +Every required field is a real column: `occurredAt` (timestamptz), `actorUserId` +plus `actorName` snapshot, `actorIsAdmin`, `objectType`/`objectId`/`objectName`/ +`objectPath`, `beforeValue`/`afterValue`/`context` (JSONB), `result` and +`failureReason`, `clientIp` and `userAgent`, and the chain fields `seq`, +`entryHash`, `prevHash`. + +`actorName` is a snapshot, not a join: audit records who acted under the name they +had at the time. Object references carry no foreign key — a purged object's history +must survive the object. + +### Two write paths with opposite error semantics + +- **Success** writes inside the caller's business transaction and **does not + swallow errors**. If the audit row cannot be written, the business operation + rolls back. This is the implementation of "operation succeeded ⟹ log exists". +- **Failure and conflict-detection** write out-of-band on an independent + connection, and **do swallow errors**. The business transaction has already + rolled back, so a same-transaction write would vanish; and an audit failure at + that point must not turn a 409 into a 500. + +The two paths' error handling is deliberately inverted. Unifying them breaks one +guarantee or the other. + +### Tamper resistance + +Three independent layers, in order of what each defends against: + +1. Service paths only INSERT and SELECT. +2. A `BEFORE UPDATE OR DELETE` trigger rejects every row change except a + write-once `archivedAt` stamp; a `BEFORE TRUNCATE` statement trigger blocks the + row-trigger bypass. This holds even when the application layer is wrong. +3. A sha256 hash chain: `entryHash = sha256(prevHash + canonical payload)`, linked + per organization by a monotonic `seq`. The chain is recomputable offline, so + changes made by bypassing the service entirely — direct DB access, restoring a + doctored backup — still surface as a break. + +Payload canonicalization sorts object keys. Without it the same record serialized +in a different key order hashes differently and verification reports phantom +tampering. + +### Retention + +Default retention is 180 days and that is a floor, not a default: +`HUB_FILELIB_AUDIT_RETENTION_DAYS` may raise it and cannot lower it. Expired +records are **archived by stamping `archivedAt`, never physically deleted** — +consistent with the DELETE trigger. Archived records drop out of default queries +but remain readable with an explicit flag, and remain in the hash chain. The +archive run itself is audited. + +### Read visibility + +Three tiers, resolved entirely in the query layer so no second authorization +implementation can drift from it: + +- **Website administrator** (silo org OWNER/ADMIN, D19): the whole system. +- **MANAGE holder**: logs for nodes they hold MANAGE on **and their subtrees**, + matched by `objectPath` prefix against the `pathIds` materialized path. File + logs use `:`, so subtree matching covers them without a + second rule. +- **Everyone else**: an empty result, not a 403 — consistent with D8's refusal to + leak existence. + +Group logs use `group:` as their `objectPath`. That is outside the node path +space, so they are visible to website administrators only. + +### Module boundary + +The audit capability lives in `src/database/audit/` and imports no file-library +business type. Business services depend on it through one adapter, +`filelib/audit.ts`, which translates domain concepts (FileLibActor, node kind, +pathIds) into audit ones. The dependency is one-directional, so the module can be +replaced by a standalone audit service without touching business code. + +Client IP and User-Agent are captured once in the HTTP guard and travel on the +actor. Business services never see the request object. + +## Consequences + +- `AuditEntry` remains for Project/Run telemetry. File-library audit no longer + writes to it; the dashboard's recent-activity feed reads the new table, which is + why it can show actor and object names without a join. +- Audit failure can now fail a business write. That is the intended trade: a + silently unlogged permission change is worse than a failed one. +- Failure records exist without a corresponding state change. Consumers must read + `result` — counting rows per action no longer counts successful operations. +- The hash chain serializes audit writes per organization through a + `max(seq) + 1` read. Under the alpha Silo's one-org-one-process deployment + (ADR-0025) contention is negligible; the unique constraint on + `(organizationId, seq)` makes a collision a retry rather than a corrupt chain. + A future high-write deployment will need a sequence or a per-org writer. +- Changing the set of fields fed into `computeEntryHash` invalidates every + existing chain. It is a breaking change requiring a new ADR and a re-anchoring + procedure, not a refactor. +- The requirement names "project independent permission enable/disable/change". + ADR-0030 removed that feature — `FileLibProjectSettings` is no longer read — so + no code path can emit those events today. The three action values are reserved + in the vocabulary; if the feature returns, it wires into the existing writer. diff --git a/hub/filelib-web/src/lib/UserPicker.svelte b/hub/filelib-web/src/lib/UserPicker.svelte new file mode 100644 index 0000000..d769670 --- /dev/null +++ b/hub/filelib-web/src/lib/UserPicker.svelte @@ -0,0 +1,202 @@ + + +{#if searchUnavailable} + + +{:else} +
+ + {#if userId !== ""} + + {/if} + + {#if open && options !== null && options.length > 0} + + +
+ {#each options as u, i (u.userId)} + + {/each} +
+ {:else if open && options !== null} +
+ 没有匹配的用户 +
+ {/if} +
+ + {#if selected !== null} + + 已选 {selected.userId} + + {/if} +{/if} diff --git a/hub/filelib-web/src/lib/labels.ts b/hub/filelib-web/src/lib/labels.ts index 3468e83..6837ef3 100644 --- a/hub/filelib-web/src/lib/labels.ts +++ b/hub/filelib-web/src/lib/labels.ts @@ -1,6 +1,6 @@ /** 展示层文案(与 API 枚举值解耦;传参仍用英文枚举)。 */ -import type { Role } from "./types.js"; +import type { AuditObjectType, Role } from "./types.js"; /** 文件库权限级(契约 8.1 MANAGE>EDIT>VIEW)的中文展示名。 */ export const ROLE_LABEL: Record = { @@ -8,3 +8,56 @@ export const ROLE_LABEL: Record = { EDIT: "可编辑", MANAGE: "可管理", }; + +/** + * 审计操作类型的中文展示名。键是后端 AUDIT_ACTIONS 的值 —— + * 缺键时 UI 回落显示原始动作串,不会因为后端加了新动作而崩。 + */ +export const AUDIT_ACTION_LABEL: Record = { + "folder.create": "创建文件夹", + "folder.rename": "重命名文件夹", + "folder.move": "移动文件夹", + "folder.delete": "删除文件夹", + "folder.restore": "恢复文件夹", + "project.create": "创建项目", + "project.rename": "重命名项目", + "project.move": "移动项目", + "project.delete": "删除项目", + "project.restore": "恢复项目", + "node.purge": "彻底删除", + "permission.grant": "授予权限", + "permission.update": "修改权限", + "permission.revoke": "收回权限", + "project.independent_permission.enable": "开启独立权限", + "project.independent_permission.disable": "关闭独立权限", + "project.independent_permission.change": "变更独立权限", + "file.upload": "上传文件", + "file.rename": "重命名文件", + "file.delete": "删除文件", + "file.commit": "提交文件修改", + "file.conflict_detected": "检测到版本冲突", + "export.run": "导出", + "admin.force_adjust": "管理员强制调整权限", + "group.create": "创建 Group", + "group.update": "修改 Group", + "group.delete": "删除 Group", + "group.restore": "恢复 Group", + "group.member_add": "添加 Group 成员", + "group.member_remove": "移除 Group 成员", + "group.reparent": "变更 Group 嵌套关系", + "audit.retention_archive": "审计日志归档", +}; + +export function auditActionLabel(action: string): string { + return AUDIT_ACTION_LABEL[action] ?? action; +} + +export const AUDIT_OBJECT_TYPE_LABEL: Record = { + FOLDER: "文件夹", + PROJECT: "项目", + FILE: "文件", + GRANT: "权限", + EXPORT_JOB: "导出任务", + GROUP: "Group", + SYSTEM: "系统", +}; diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 0da62a3..e1ca8c4 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -162,9 +162,81 @@ export interface DashboardStats { readonly folders: number; readonly label: string; /** ISO 串;后端 JSON 序列化后不再是 Date。 */ readonly when: string; + readonly result: AuditResult; }>; } +/* -------------------------------------------------- 审计日志(/database/api/audit/*) */ + +export type AuditResult = "SUCCESS" | "FAILURE"; + +export type AuditObjectType = + | "FOLDER" | "PROJECT" | "FILE" | "GRANT" | "EXPORT_JOB" | "GROUP" | "SYSTEM"; + +/** 单条审计日志。字段与需求「日志字段」逐项对应。 */ +export interface AuditLogEntry { + readonly id: string; + /** org 内单调序号(哈希链顺序);BigInt 出库转字符串。 */ + readonly seq: string; + readonly occurredAt: string; + 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: string | null; +} + +export interface AuditLogPage { + readonly total: number; + readonly offset: number; + readonly limit: number; + readonly entries: readonly AuditLogEntry[]; +} + +/** 筛选器元数据(GET /database/api/audit/meta)。 */ +export interface AuditMeta { + readonly actionGroups: ReadonlyArray<{ + readonly key: string; + readonly label: string; + readonly actions: readonly string[]; + }>; + readonly objectTypes: readonly AuditObjectType[]; + readonly exportRowLimit: number; + /** all = 网站管理员看全系统;managed = 仅自己有 MANAGE 的范围。 */ + readonly scope: "all" | "managed"; +} + +/** 哈希链校验结果(GET /database/api/audit/verify)。 */ +export interface AuditVerifyResult { + readonly checked: number; + readonly ok: boolean; + readonly breaks: ReadonlyArray<{ + readonly id: string; + readonly seq: string; + readonly occurredAt: string; + readonly reason: "hash_mismatch" | "prev_hash_mismatch" | "seq_gap"; + }>; +} + +/** 保留策略归档结果(POST /database/api/audit/archive)。 */ +export interface AuditArchiveResult { + readonly archived: number; + readonly cutoff: string; + readonly retentionDays: number; +} + /** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */ export type OrgRole = "OWNER" | "ADMIN" | "MEMBER"; diff --git a/hub/filelib-web/src/routes/database/dashboard/+layout.svelte b/hub/filelib-web/src/routes/database/dashboard/+layout.svelte index a6f84e6..c0d649a 100644 --- a/hub/filelib-web/src/routes/database/dashboard/+layout.svelte +++ b/hub/filelib-web/src/routes/database/dashboard/+layout.svelte @@ -22,7 +22,7 @@ { seg: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" }, { seg: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" }, { seg: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" }, - { seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" }, + { seg: "search", label: "操作日志", icon: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6ZM14 2v6h6M8 13h8M8 17h8M8 9h2" }, { seg: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" }, ] as const; diff --git a/hub/filelib-web/src/routes/database/dashboard/search/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/search/+page.svelte index 6154d90..988957a 100644 --- a/hub/filelib-web/src/routes/database/dashboard/search/+page.svelte +++ b/hub/filelib-web/src/routes/database/dashboard/search/+page.svelte @@ -1,4 +1,449 @@ + +
-

查询

-

查询功能建设中

+
+
+

操作日志

+

+ {#if meta === null} + 加载中… + {:else if meta.scope === "all"} + 你是网站管理员,可查询全系统日志。 + {:else} + 你可查询自己拥有「可管理」权限的文件夹/项目及其子树相关日志。 + {/if} +

+
+ {#if isAdmin} +
+ + +
+ {/if} +
+ + {#if verifyState !== null} +
+
防篡改校验
+ {#if verifyState.ok} +

+ 已校验 {verifyState.checked} 条,哈希链连续完整,未发现改写或缺失。 +

+ {:else} +

+ 已校验 {verifyState.checked} 条,发现 {verifyState.breaks.length} 处异常: +

+
    + {#each verifyState.breaks.slice(0, 10) as b (b.id)} +
  • + seq {b.seq} · {formatTime(b.occurredAt)} · + {b.reason === "hash_mismatch" ? "内容被改写" : b.reason === "seq_gap" ? "记录缺失" : "链接断裂"} +
  • + {/each} +
+ {/if} +
+ {/if} + +
+
筛选条件
+ +
+ + +
+ + {#key actorPickerKey} + + {/key} +
+ + + + +
+ + {#if meta !== null} +
+ 操作类型{selectedActions.length > 0 ? `(已选 ${selectedActions.length})` : "(不选 = 全部)"} +
+ {#each meta.actionGroups as group (group.key)} +
+ + {#each group.actions as action (action)} + {@const on = selectedActions.includes(action)} + + {/each} +
+ {/each} +
+
+ {/if} + +
+ + + + + {#if meta !== null} + 导出上限 {meta.exportRowLimit.toLocaleString()} 行 + {/if} +
+
+ +
+
+
+ 查询结果{result !== null ? `(共 ${result.total.toLocaleString()} 条)` : ""} +
+ {#if result !== null && result.total > PAGE_SIZE} +
+ + {currentPage} / {totalPages} + +
+ {/if} +
+ + {#if error !== null} +
{error}
+ {:else if result === null} +
加载中…
+ {:else if result.entries.length === 0} +
+ {meta?.scope === "managed" + ? "没有匹配的日志。你只能查询自己拥有「可管理」权限的范围。" + : "没有匹配的日志。"} +
+ {:else} + + + + + + + + + + + + + {#each result.entries as entry (rowKey(entry))} + + + + + + + + + {#if expanded === entry.id} + + + + {/if} + {/each} + +
操作时间操作人操作类型操作对象结果
{formatTime(entry.occurredAt)} + {entry.actorName} + {#if entry.actorIsAdmin} + 管理员 + {/if} +
{entry.actorUserId}
+
{auditActionLabel(entry.action)} + {entry.objectName} +
+ {AUDIT_OBJECT_TYPE_LABEL[entry.objectType] ?? entry.objectType} · {entry.objectId.slice(0, 8)} +
+
+ {#if entry.result === "SUCCESS"} + 成功 + {:else} + 失败 + {/if} + {#if entry.archivedAt !== null} + 已归档 + {/if} + + +
+
+
+
日志 ID: {entry.id}
+
链序号: {entry.seq}
+
+ 对象路径: {entry.objectPath} +
+
客户端 IP: {entry.clientIp ?? "—"}
+
+ User-Agent: {entry.userAgent ?? "—"} +
+ {#if entry.failureReason !== null} +
+ 失败原因: {entry.failureReason} +
+ {/if} +
+
+
+
操作前值
+
{formatJson(entry.beforeValue)}
+
+
+
操作后值
+
{formatJson(entry.afterValue)}
+
+
+
附加上下文
+
{formatJson(entry.context)}
+
+
+
+
+ {/if} +
diff --git a/hub/prisma/migrations/20260806120000_filelib_audit_log/migration.sql b/hub/prisma/migrations/20260806120000_filelib_audit_log/migration.sql new file mode 100644 index 0000000..c60607f --- /dev/null +++ b/hub/prisma/migrations/20260806120000_filelib_audit_log/migration.sql @@ -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 中断链暴露。 diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 5e4e9b2..a80fa0c 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -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,文件为 ":"。前缀匹配可查子树。 + 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]) +} diff --git a/hub/src/database/README.md b/hub/src/database/README.md index 6cb06aa..88b25df 100644 --- a/hub/src/database/README.md +++ b/hub/src/database/README.md @@ -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 前缀匹配;文件日志的 `:` 天然被覆盖) +- 其余人 → 空结果而非 403(与 D8 不泄露存在性一致) + +Group 日志的 objectPath 是 `group:`,不在节点路径空间内,因此只有网站 +管理员可见。 + +环境变量:`HUB_FILELIB_AUDIT_RETENTION_DAYS` — 保留天数,下限 180(默认)。 + +> **已知缺口**:需求点名的「项目独立权限的开启/关闭/变更」在 ADR-0030 之后 +> 已被废除(`FileLibProjectSettings` 不再被读取,见 `filelib/permission.ts`), +> 当前没有产生该事件的代码路径。词表里的三个 `project.independent_permission.*` +> 动作是预留位;该功能若恢复,直接调 `writeFileLibAudit` 即可。 ## 约定(与 admin 面一致) diff --git a/hub/src/database/audit/auditModel.ts b/hub/src/database/audit/auditModel.ts new file mode 100644 index 0000000..cded1f9 --- /dev/null +++ b/hub/src/database/audit/auditModel.ts @@ -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,或文件的 `:`。前缀匹配即子树查询。 */ + 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; +} diff --git a/hub/src/database/audit/auditQuery.ts b/hub/src/database/audit/auditQuery.ts new file mode 100644 index 0000000..148ee04 --- /dev/null +++ b/hub/src/database/audit/auditQuery.ts @@ -0,0 +1,272 @@ +/** + * 审计查询与导出。语义锚点:ADR-0039「读可见性三级」。 + * + * 可见性三级: + * - 网站管理员(silo org OWNER/ADMIN):全系统日志 + * - 持 MANAGE 的用户:仅自己拥有 MANAGE 的文件夹/项目**及其子树**相关日志 + * - 普通用户:默认不可查看 —— scope 解析出空可见集,返回空页而非 403 + * (不泄露"系统里有没有日志"这件事,与 D8 的不泄露存在性一致) + * + * 子树语义:MANAGE 挂在文件夹上时,该文件夹下所有节点的日志都算"相关"。 + * 用 objectPath 的 pathIds 前缀匹配实现 —— 与树服务的物化路径同一套编码。 + * 文件日志的 objectPath 是 `:`,前缀匹配天然覆盖。 + * + * 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; +} + +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 { + 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 { + 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 精确等于,或以 "/" / ":" 开头。 + 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 { + 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 { + 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`; +} diff --git a/hub/src/database/audit/auditRetention.ts b/hub/src/database/audit/auditRetention.ts new file mode 100644 index 0000000..144604f --- /dev/null +++ b/hub/src/database/audit/auditRetention.ts @@ -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 { + 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, + options: { readonly batchSize?: number | undefined } = {}, +): Promise { + 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 }; +} diff --git a/hub/src/database/audit/auditRoutes.ts b/hub/src/database/audit/auditRoutes.ts new file mode 100644 index 0000000..df41fc2 --- /dev/null +++ b/hub/src/database/audit/auditRoutes.ts @@ -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; + /** 复用文件库门禁:返回 null 时响应已发出。 */ + readonly actorOrNull: ( + request: FastifyRequest, + reply: FastifyReply, + ) => Promise; +} + +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): 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 { + 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 { + 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; + 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), + ); + 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); + } + }); +} diff --git a/hub/src/database/audit/auditWriter.ts b/hub/src/database/audit/auditWriter.ts new file mode 100644 index 0000000..f2c065e --- /dev/null +++ b/hub/src/database/audit/auditWriter.ts @@ -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 { + 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 { + await insertOnce(tx, deps, input); +} + +/** 一次写多条(如创建节点时附带的初始授权),顺序即链上顺序。 */ +export async function writeAuditMany( + tx: Prisma.TransactionClient, + deps: AuditWriteDeps, + inputs: readonly AuditRecordInput[], +): Promise { + 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 { + 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; + } + } +} diff --git a/hub/src/database/audit/index.ts b/hub/src/database/audit/index.ts new file mode 100644 index 0000000..baeb7ce --- /dev/null +++ b/hub/src/database/audit/index.ts @@ -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"; diff --git a/hub/src/database/audit/requestContext.ts b/hub/src/database/audit/requestContext.ts new file mode 100644 index 0000000..99dd669 --- /dev/null +++ b/hub/src/database/audit/requestContext.ts @@ -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; +} + +/** 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 }; +} diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts index 6bdafe8..4e767f6 100644 --- a/hub/src/database/filelib/audit.ts +++ b/hub/src/database/filelib/audit.ts @@ -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,或文件的 `:`。 */ readonly objectPath: string; - readonly detail?: Record | undefined; + readonly before?: unknown; + readonly after?: unknown; + readonly context?: Record | 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 { - const metadata: Record = { - 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 { + await writeAuditOutOfBand( + { prisma, organizationId: entry.organizationId }, + { ...toRecord(entry), result: entry.result ?? "FAILURE" }, + ); } diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts index 15e0687..4e14efb 100644 --- a/hub/src/database/filelib/binService.ts +++ b/hub/src/database/filelib/binService.ts @@ -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 }; }); diff --git a/hub/src/database/filelib/exportService.ts b/hub/src/database/filelib/exportService.ts index de9ef41..683c29b 100644 --- a/hub/src/database/filelib/exportService.ts +++ b/hub/src/database/filelib/exportService.ts @@ -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; }); diff --git a/hub/src/database/filelib/fileService.ts b/hub/src/database/filelib/fileService.ts index a94dafa..2a5b8f5 100644 --- a/hub/src/database/filelib/fileService.ts +++ b/hub/src/database/filelib/fileService.ts @@ -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, + values: { + readonly before?: unknown; + readonly after?: unknown; + readonly context?: Record | undefined; + }, ): Promise { 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 { + 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 }, + }); } diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts index 1abaaa9..fb7f492 100644 --- a/hub/src/database/filelib/grantService.ts +++ b/hub/src/database/filelib/grantService.ts @@ -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, + node: { readonly id: string; readonly name: string; readonly pathIds: string }, + values: { + readonly before?: unknown; + readonly after?: unknown; + readonly context?: Record | undefined; + }, ): Promise { 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, }); } diff --git a/hub/src/database/filelib/guards.ts b/hub/src/database/filelib/guards.ts index 5d84b32..343fdd3 100644 --- a/hub/src/database/filelib/guards.ts +++ b/hub/src/database/filelib/guards.ts @@ -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), }; } diff --git a/hub/src/database/filelib/memberGroupService.ts b/hub/src/database/filelib/memberGroupService.ts index b0a5b30..b2f0903 100644 --- a/hub/src/database/filelib/memberGroupService.ts +++ b/hub/src/database/filelib/memberGroupService.ts @@ -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:" 占位 —— + // 与节点路径空间隔离,因此组日志只对网站管理员可见(见 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 } }, ], }), }, diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts index 10fcba1..0de9cff 100644 --- a/hub/src/database/filelib/treeService.ts +++ b/hub/src/database/filelib/treeService.ts @@ -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 { 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() }, }); }); } diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index ab344ea..a32956f 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -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; - 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 }; } diff --git a/hub/test/integration/filelib-routes.test.ts b/hub/test/integration/filelib-routes.test.ts index ea2a2c5..15e7620 100644 --- a/hub/test/integration/filelib-routes.test.ts +++ b/hub/test/integration/filelib-routes.test.ts @@ -226,10 +226,18 @@ describe("filelib http · 文件冲突流", () => { expect(conflict.statusCode).toBe(409); expect(conflict.json().error.currentVersion).toBe(commit.json().version); - const audit = await prisma.auditEntry.findFirst({ + // 冲突事件走事务外旁路(ADR-0039):业务抛了 409,日志仍须存在, + // 且带上起始版本与冲突版本。 + const audit = await prisma.fileLibAuditLog.findFirst({ where: { organizationId: DEFAULT_ORG_ID, action: "file.conflict_detected" }, }); expect(audit).not.toBeNull(); + expect(audit!.result).toBe("FAILURE"); + expect(audit!.failureReason).toContain("version_conflict"); + expect(audit!.context).toMatchObject({ + baseVersion: v1, + currentVersion: commit.json().version, + }); }); }); diff --git a/hub/test/integration/filelib-tree.test.ts b/hub/test/integration/filelib-tree.test.ts index b2605af..822b801 100644 --- a/hub/test/integration/filelib-tree.test.ts +++ b/hub/test/integration/filelib-tree.test.ts @@ -163,16 +163,44 @@ describe("treeService · D17 breadcrumb", () => { }); }); -describe("treeService · 审计落库(C3)", () => { - it("创建/改名/移动/删除均写 AuditEntry", async () => { +describe("treeService · 审计落库(ADR-0039)", () => { + it("创建/改名/移动/删除均写 FileLibAuditLog", async () => { const root = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); await renameNode(deps(), ADMIN, root.id, "物理学"); await softDeleteNode(deps(), ADMIN, root.id); - const actions = (await prisma.auditEntry.findMany({ + const rows = await prisma.fileLibAuditLog.findMany({ where: { organizationId: DEFAULT_ORG_ID }, - select: { action: true }, - orderBy: { createdAt: "asc" }, - })).map((e) => e.action); - expect(actions).toEqual(["folder.create", "folder.rename", "folder.delete"]); + orderBy: { seq: "asc" }, + }); + expect(rows.map((e) => e.action)).toEqual(["folder.create", "folder.rename", "folder.delete"]); + + // 改名要留下前后值 —— 这正是旧 metadata blob 做不到的事。 + const rename = rows[1]!; + expect(rename.beforeValue).toMatchObject({ name: "物理" }); + expect(rename.afterValue).toMatchObject({ name: "物理学" }); + expect(rename.result).toBe("SUCCESS"); + expect(rename.actorUserId).toBe(ADMIN.userId); + // 姓名快照与对象名称直接落列,查询无需回查 User/Node。 + expect(rename.actorName).not.toBe(""); + expect(rename.objectName).toBe("物理学"); + + // 哈希链:seq 连续,prevHash 串上一条。 + expect(rows.map((e) => e.seq)).toEqual([1n, 2n, 3n]); + expect(rows[0]!.prevHash).toBeNull(); + expect(rows[1]!.prevHash).toBe(rows[0]!.entryHash); + expect(rows[2]!.prevHash).toBe(rows[1]!.entryHash); + }); + + it("日志只追加:UPDATE 与 DELETE 被数据库触发器拒绝", async () => { + const node = await createNode(deps(), ADMIN, { parentId: null, kind: "FOLDER", name: "不可篡改" }); + const row = await prisma.fileLibAuditLog.findFirstOrThrow({ + where: { organizationId: DEFAULT_ORG_ID, objectId: node.id }, + }); + await expect( + prisma.$executeRawUnsafe(`UPDATE "FileLibAuditLog" SET "action" = 'forged' WHERE "id" = $1`, row.id), + ).rejects.toThrow(/immutable/); + await expect( + prisma.$executeRawUnsafe(`DELETE FROM "FileLibAuditLog" WHERE "id" = $1`, row.id), + ).rejects.toThrow(/append-only/); }); }); diff --git a/hub/test/integration/member-groups.test.ts b/hub/test/integration/member-groups.test.ts index a4a294f..56c2163 100644 --- a/hub/test/integration/member-groups.test.ts +++ b/hub/test/integration/member-groups.test.ts @@ -158,15 +158,16 @@ describe("memberGroupService · 改名/改描述(决策6)", () => { await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 }); }); - it("改名写 group.update 审计", async () => { + it("改名写 group.update 审计(含前后值)", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" }); - const actions = (await prisma.auditEntry.findMany({ + const rows = await prisma.fileLibAuditLog.findMany({ where: { organizationId: DEFAULT_ORG_ID }, - select: { action: true }, - orderBy: { createdAt: "asc" }, - })).map((e) => e.action); - expect(actions).toEqual(["group.create", "group.update"]); + orderBy: { seq: "asc" }, + }); + expect(rows.map((e) => e.action)).toEqual(["group.create", "group.update"]); + expect(rows[1]!.beforeValue).toMatchObject({ name: "G" }); + expect(rows[1]!.afterValue).toMatchObject({ name: "G2" }); }); }); @@ -293,19 +294,22 @@ describe("memberGroupService · 搜索 breadcrumb", () => { }); }); -describe("memberGroupService · 审计(C3/决策4)", () => { - it("建组/加成员/删组写 AuditEntry(挂 silo org)", async () => { +describe("memberGroupService · 审计(ADR-0039/决策4)", () => { + it("建组/加成员/删组写 FileLibAuditLog(挂 silo org)", async () => { const g = await createMemberGroup(svc(), ADMIN, { name: "G" }); await addMember(svc(), ADMIN, g.id, { userId: "u_alice" }); await removeMember(svc(), ADMIN, g.id, "u_alice"); await deleteMemberGroup(svc(), ADMIN, g.id); - const actions = (await prisma.auditEntry.findMany({ + const rows = await prisma.fileLibAuditLog.findMany({ where: { organizationId: DEFAULT_ORG_ID }, - select: { action: true }, - orderBy: { createdAt: "asc" }, - })).map((e) => e.action); - expect(actions).toEqual([ + orderBy: { seq: "asc" }, + }); + expect(rows.map((e) => e.action)).toEqual([ "group.create", "group.member_add", "group.member_remove", "group.delete", ]); + // 组不在文件库树上,objectPath 用 group: 前缀 —— 与节点路径空间隔离, + // 因此组日志只对网站管理员可见(ADR-0039)。 + expect(rows.every((e) => e.objectPath === `group:${g.id}`)).toBe(true); + expect(rows.every((e) => e.objectType === "GROUP")).toBe(true); }); }); diff --git a/hub/test/unit/filelib-audit.test.ts b/hub/test/unit/filelib-audit.test.ts new file mode 100644 index 0000000..58e83b2 --- /dev/null +++ b/hub/test/unit/filelib-audit.test.ts @@ -0,0 +1,203 @@ +/** + * 审计模块纯逻辑单测:哈希链、规范化、保留期下限、客户端信息采集、CSV 导出。 + * + * 落库路径(writeAudit / queryAuditLogs)依赖真 DB,不在这一层测 —— 这里只钉 + * 那些"算错了不会报错、只会静默产出错数据"的纯函数,尤其是哈希链: + * 它一旦算不稳,防篡改校验就会假报或漏报。 + */ +import { describe, expect, it } from "vitest"; +import { + AUDIT_RETENTION_DAYS_MIN, + actorName, + canonicalize, + computeEntryHash, + resolveRetentionDays, + type HashableEntry, +} from "../../src/database/audit/auditModel.js"; +import { requestClient } from "../../src/database/audit/requestContext.js"; +import { toCsv, type AuditEntryDto } from "../../src/database/audit/auditQuery.js"; + +function entry(partial: Partial = {}): HashableEntry { + return { + organizationId: "org1", + seq: 1n, + occurredAt: new Date("2026-08-06T10:00:00.000Z"), + action: "folder.create", + result: "SUCCESS", + failureReason: null, + actorUserId: "u1", + actorName: "张老师", + actorIsAdmin: false, + objectType: "FOLDER", + objectId: "n1", + objectName: "教案", + objectPath: "/n1", + beforeValue: null, + afterValue: { name: "教案" }, + context: null, + clientIp: "10.0.0.1", + userAgent: "Mozilla/5.0", + ...partial, + }; +} + +describe("canonicalize · 规范化", () => { + it("键序不影响结果 —— 否则同一条记录换个写法就算出不同哈希", () => { + expect(canonicalize({ b: 1, a: 2 })).toBe(canonicalize({ a: 2, b: 1 })); + }); + + it("嵌套对象同样排序", () => { + expect(canonicalize({ x: { q: 1, p: 2 } })).toBe(canonicalize({ x: { p: 2, q: 1 } })); + }); + + it("数组保序(顺序是数组的语义)", () => { + expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1])); + }); + + it("undefined 值不参与,与缺键等价", () => { + expect(canonicalize({ a: 1, b: undefined })).toBe(canonicalize({ a: 1 })); + }); + + it("null 与缺键不等价", () => { + expect(canonicalize({ a: 1, b: null })).not.toBe(canonicalize({ a: 1 })); + }); +}); + +describe("computeEntryHash · 哈希链", () => { + it("同输入同输出(确定性)", () => { + expect(computeEntryHash(entry(), null)).toBe(computeEntryHash(entry(), null)); + }); + + it("prevHash 不同 → 哈希不同(链得真的串起来)", () => { + expect(computeEntryHash(entry(), null)).not.toBe(computeEntryHash(entry(), "abc")); + }); + + it.each([ + ["action", { action: "folder.delete" }], + ["actorUserId", { actorUserId: "u2" }], + ["actorName", { actorName: "李老师" }], + ["actorIsAdmin", { actorIsAdmin: true }], + ["objectId", { objectId: "n2" }], + ["objectName", { objectName: "改过的名字" }], + ["objectPath", { objectPath: "/n2" }], + ["result", { result: "FAILURE" as const }], + ["failureReason", { failureReason: "forbidden" }], + ["beforeValue", { beforeValue: { name: "旧" } }], + ["afterValue", { afterValue: { name: "篡改" } }], + ["context", { context: { k: 1 } }], + ["clientIp", { clientIp: "10.0.0.9" }], + ["userAgent", { userAgent: "curl/8" }], + ["seq", { seq: 2n }], + ["occurredAt", { occurredAt: new Date("2026-08-06T10:00:01.000Z") }], + ["organizationId", { organizationId: "org2" }], + ])("改 %s 即改哈希(任一字段被动过都要暴露)", (_field, patch) => { + expect(computeEntryHash(entry(patch as Partial), "prev")).not.toBe( + computeEntryHash(entry(), "prev"), + ); + }); + + it("seq 用字符串参与,bigint 与等值 number 结果一致", () => { + expect(computeEntryHash(entry({ seq: 5n }), null)).toBe(computeEntryHash(entry({ seq: 5 }), null)); + }); + + it("输出是 64 位 hex(sha256)", () => { + expect(computeEntryHash(entry(), null)).toMatch(/^[0-9a-f]{64}$/); + }); +}); + +describe("resolveRetentionDays · 保留期", () => { + it("默认 180 天", () => { + expect(resolveRetentionDays(undefined)).toBe(AUDIT_RETENTION_DAYS_MIN); + expect(AUDIT_RETENTION_DAYS_MIN).toBe(180); + }); + + it("可以调高", () => { + expect(resolveRetentionDays("365")).toBe(365); + }); + + it("调低到下限以下被拒 —— 合规底线不可配置", () => { + expect(resolveRetentionDays("30")).toBe(180); + expect(resolveRetentionDays("0")).toBe(180); + expect(resolveRetentionDays("-1")).toBe(180); + }); + + it("非法值按缺省处理", () => { + expect(resolveRetentionDays("abc")).toBe(180); + expect(resolveRetentionDays("1.5")).toBe(180); + expect(resolveRetentionDays("")).toBe(180); + }); +}); + +describe("actorName · 姓名快照", () => { + it("有 displayName 用它", () => { + expect(actorName({ userId: "u1", displayName: "张老师" })).toBe("张老师"); + }); + + it("缺失或空白回落 userId —— 字段永不为空", () => { + expect(actorName({ userId: "u1" })).toBe("u1"); + expect(actorName({ userId: "u1", displayName: " " })).toBe("u1"); + }); +}); + +describe("requestClient · 客户端信息", () => { + it("优先取 X-Forwarded-For 最左一跳(真实客户端)", () => { + const client = requestClient({ + ip: "127.0.0.1", + headers: { "x-forwarded-for": "203.0.113.5, 10.0.0.1", "user-agent": "UA" }, + }); + expect(client?.ip).toBe("203.0.113.5"); + }); + + it("无 XFF 时回落 request.ip", () => { + expect(requestClient({ ip: "10.1.2.3", headers: {} })?.ip).toBe("10.1.2.3"); + }); + + it("超长 UA 截断(不撑爆行)", () => { + const client = requestClient({ ip: "1.1.1.1", headers: { "user-agent": "x".repeat(2000) } }); + expect(client?.userAgent?.length).toBe(512); + }); + + it("两项都采不到 → undefined(客户端信息是可选字段)", () => { + expect(requestClient({ headers: {} })).toBeUndefined(); + expect(requestClient(undefined)).toBeUndefined(); + }); +}); + +describe("toCsv · 导出", () => { + function dto(partial: Partial = {}): AuditEntryDto { + return { + id: "log1", seq: "1", occurredAt: new Date("2026-08-06T10:00:00.000Z"), + action: "folder.create", result: "SUCCESS", failureReason: null, + actorUserId: "u1", actorName: "张老师", actorIsAdmin: false, + objectType: "FOLDER", objectId: "n1", objectName: "教案", objectPath: "/n1", + beforeValue: null, afterValue: { name: "教案" }, context: null, + clientIp: "10.0.0.1", userAgent: "UA", archivedAt: null, + ...partial, + }; + } + + it("带 UTF-8 BOM(否则 Excel 打开中文全乱码)", () => { + expect(toCsv([])).toMatch(/^/); + }); + + it("含双引号的值被正确转义", () => { + expect(toCsv([dto({ objectName: 'a"b' })])).toContain('"a""b"'); + }); + + it("公式前缀被中和 —— 防 Excel CSV 注入", () => { + for (const evil of ["=1+1", "+cmd", "-2", "@SUM(A1)"]) { + expect(toCsv([dto({ objectName: evil })])).toContain(`"'${evil}"`); + } + }); + + it("换行不破坏结构(整格加引号)", () => { + const csv = toCsv([dto({ objectName: "a\nb" })]); + expect(csv).toContain('"a\nb"'); + }); + + it("失败记录带原因", () => { + const csv = toCsv([dto({ result: "FAILURE", failureReason: "version_conflict" })]); + expect(csv).toContain("失败"); + expect(csv).toContain("version_conflict"); + }); +});