forked from EduCraft/curriculum-project-hub
Compare commits
2 Commits
ecc92c9a87
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 26523d1b54 | |||
| c96ea60482 |
+1
-1
@@ -101,5 +101,5 @@ An audited Platform Administrator control that prevents new agent work for one O
|
||||
_Avoid_: Organization deletion, service restart
|
||||
|
||||
**Member Group**:
|
||||
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
|
||||
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0038). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
|
||||
_Avoid_: Team (the org-scoped flat grouping), Feishu department
|
||||
|
||||
@@ -104,7 +104,7 @@ route outranks the fallback.
|
||||
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
|
||||
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
|
||||
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
|
||||
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
|
||||
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0038, ADR-0021).
|
||||
|
||||
- **A migrated surface is only done when its endpoint coverage matches.** Two
|
||||
panels were rebuilt from a superficially similar component that predated the
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# ADR 0028: Member Group Management And Resolution
|
||||
# ADR 0038: Member Group Management And Resolution
|
||||
|
||||
## Status
|
||||
|
||||
@@ -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 `<pathIds>:<filePath>`, 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:<id>` 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.
|
||||
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 可搜索的单个用户选择器(combobox)。
|
||||
*
|
||||
* 语义:对外只暴露一个 `userId`(选中 = 非空,未选 = 空串)。调用方拿到的
|
||||
* 始终是精确 id —— 后端 `actorUserId` 等筛选口是精确匹配,不接受模糊串。
|
||||
*
|
||||
* 权限降级:`/database/api/users/search` 仅网站管理员可调
|
||||
* (memberGroupService.ts `searchUsers` 的 `requireAdmin`),持 MANAGE 的
|
||||
* 老师拿 403。此时不残废成"搜不到",而是回落为手输 id —— 与 GrantsPanel
|
||||
* 既有做法一致。
|
||||
*
|
||||
* 已存在两处内联实现(GrantsPanel.svelte 的授权主体选择器、
|
||||
* GroupAdmin.svelte 的加成员搜索)。本组件是它们的沉淀版,但**没有**改动
|
||||
* 那两处 —— 后续可分别迁过来,迁移时注意 GrantsPanel 还要选 GROUP 主体,
|
||||
* 那部分不在本组件职责内。
|
||||
*/
|
||||
import { api } from "./api.js";
|
||||
import type { UserSearchResult } from "./types.js";
|
||||
import Avatar from "./Avatar.svelte";
|
||||
|
||||
let {
|
||||
userId = $bindable(""),
|
||||
placeholder = "搜索姓名 / openId / userId",
|
||||
id,
|
||||
}: {
|
||||
/** 选中的 userId;未选时为空串。双向绑定。 */
|
||||
userId?: string;
|
||||
placeholder?: string;
|
||||
/** 传入时套到 input 上,便于外部 <label for>。 */
|
||||
id?: string;
|
||||
} = $props();
|
||||
|
||||
let query = $state("");
|
||||
let options = $state<readonly UserSearchResult[] | null>(null);
|
||||
let selected = $state<UserSearchResult | null>(null);
|
||||
let searchUnavailable = $state(false);
|
||||
let open = $state(false);
|
||||
/** 键盘高亮项下标;-1 = 无。 */
|
||||
let active = $state(-1);
|
||||
// seq 防乱序:慢响应不覆盖新查询的结果。
|
||||
let searchSeq = 0;
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const label = (u: UserSearchResult): string => (u.displayName === "" ? u.userId : u.displayName);
|
||||
|
||||
async function search(q: string): Promise<void> {
|
||||
const seq = ++searchSeq;
|
||||
try {
|
||||
const r = await api<{ users: UserSearchResult[] }>(
|
||||
`/database/api/users/search?q=${encodeURIComponent(q)}`,
|
||||
);
|
||||
if (seq !== searchSeq) return;
|
||||
options = r.users;
|
||||
active = -1;
|
||||
searchUnavailable = false;
|
||||
} catch {
|
||||
if (seq !== searchSeq) return;
|
||||
// 无搜索权限(403)——回落手输 id。
|
||||
options = null;
|
||||
searchUnavailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
// 输入即视为放弃已选:输入框内容与 userId 不再对应,不能让旧选中残留。
|
||||
selected = null;
|
||||
userId = "";
|
||||
open = true;
|
||||
if (searchTimer !== undefined) clearTimeout(searchTimer);
|
||||
const q = query.trim();
|
||||
searchTimer = setTimeout(() => void search(q), 250);
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
open = true;
|
||||
// 首次聚焦拉一次默认候选(后端 q 为空返回前 20 个)。
|
||||
if (options === null && !searchUnavailable) void search(query.trim());
|
||||
}
|
||||
|
||||
function pick(u: UserSearchResult): void {
|
||||
selected = u;
|
||||
userId = u.userId;
|
||||
query = label(u);
|
||||
options = null;
|
||||
active = -1;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
selected = null;
|
||||
userId = "";
|
||||
query = "";
|
||||
options = null;
|
||||
active = -1;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
const list = options;
|
||||
if (e.key === "Escape") {
|
||||
open = false;
|
||||
return;
|
||||
}
|
||||
if (list === null || list.length === 0) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
active = (active + 1) % list.length;
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
open = true;
|
||||
active = active <= 0 ? list.length - 1 : active - 1;
|
||||
} else if (e.key === "Enter" && open && active >= 0) {
|
||||
// 只在有高亮项时拦 Enter —— 否则要让 Enter 冒泡去触发外层的「查询」。
|
||||
e.preventDefault();
|
||||
const hit = list[active];
|
||||
if (hit !== undefined) pick(hit);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if searchUnavailable}
|
||||
<!-- 无 users/search 权限:直接手输 id(与 GrantsPanel 的降级一致)。 -->
|
||||
<input
|
||||
{id}
|
||||
class="input font-mono"
|
||||
placeholder="无搜索权限,请直接填写 userId"
|
||||
bind:value={userId}
|
||||
/>
|
||||
{:else}
|
||||
<div class="relative">
|
||||
<input
|
||||
{id}
|
||||
class="input"
|
||||
class:pr-7={userId !== ""}
|
||||
{placeholder}
|
||||
autocomplete="off"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
aria-controls="user-picker-list"
|
||||
bind:value={query}
|
||||
oninput={onInput}
|
||||
onfocus={onFocus}
|
||||
onkeydown={onKeydown}
|
||||
/>
|
||||
{#if userId !== ""}
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-1/2 right-2 -translate-y-1/2 text-[13px] leading-none text-ink-3 hover:text-ink"
|
||||
title="清除"
|
||||
aria-label="清除已选操作人"
|
||||
onclick={clear}>×</button
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if open && options !== null && options.length > 0}
|
||||
<!-- 点击外部关闭:全屏透明遮罩接管点击,与 ContextMenu.svelte 同做法。 -->
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-10 cursor-default"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
onclick={() => (open = false)}
|
||||
></button>
|
||||
<div
|
||||
id="user-picker-list"
|
||||
role="listbox"
|
||||
class="absolute top-full right-0 left-0 z-20 mt-1 max-h-[220px] overflow-y-auto rounded-lg border border-line-soft bg-panel shadow-[0_8px_28px_rgba(26,26,24,.12)]"
|
||||
>
|
||||
{#each options as u, i (u.userId)}
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={i === active}
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-hover"
|
||||
class:bg-selected={i === active}
|
||||
onclick={() => pick(u)}
|
||||
>
|
||||
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={22} />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-[13px] text-ink">{label(u)}</span>
|
||||
<span class="block truncate font-mono text-[11px] text-ink-3">{u.userId}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if open && options !== null}
|
||||
<div
|
||||
class="absolute top-full right-0 left-0 z-20 mt-1 rounded-lg border border-line-soft bg-panel px-3 py-2 text-[12px] text-ink-3 shadow-[0_8px_28px_rgba(26,26,24,.12)]"
|
||||
>
|
||||
没有匹配的用户
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if selected !== null}
|
||||
<span class="mt-1 block truncate font-mono text-[11px] text-ink-3" title={selected.userId}>
|
||||
已选 {selected.userId}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -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<Role, string> = {
|
||||
@@ -8,3 +8,56 @@ export const ROLE_LABEL: Record<Role, string> = {
|
||||
EDIT: "可编辑",
|
||||
MANAGE: "可管理",
|
||||
};
|
||||
|
||||
/**
|
||||
* 审计操作类型的中文展示名。键是后端 AUDIT_ACTIONS 的值 ——
|
||||
* 缺键时 UI 回落显示原始动作串,不会因为后端加了新动作而崩。
|
||||
*/
|
||||
export const AUDIT_ACTION_LABEL: Record<string, string> = {
|
||||
"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<AuditObjectType, string> = {
|
||||
FOLDER: "文件夹",
|
||||
PROJECT: "项目",
|
||||
FILE: "文件",
|
||||
GRANT: "权限",
|
||||
EXPORT_JOB: "导出任务",
|
||||
GROUP: "Group",
|
||||
SYSTEM: "系统",
|
||||
};
|
||||
|
||||
@@ -89,7 +89,7 @@ export interface GroupSearchResult {
|
||||
}
|
||||
|
||||
|
||||
/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */
|
||||
/** 成员组(ADR-0038);后端返回扁平列表,前端按 parentId/depth 拼树。 */
|
||||
export interface MemberGroupNode {
|
||||
readonly id: string;
|
||||
readonly parentId: string | null;
|
||||
@@ -97,7 +97,7 @@ export interface MemberGroupNode {
|
||||
readonly description: string | null;
|
||||
readonly depth: number;
|
||||
readonly memberCount: number;
|
||||
/** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。
|
||||
/** 软删标记(ADR-0038 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。
|
||||
* 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */
|
||||
readonly archivedAt: string | null;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -1,4 +1,449 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* 审计日志查询(需求 5.4)。
|
||||
*
|
||||
* 可见性由后端裁剪,前端不做第二套判权:管理员看全系统,持 MANAGE 的用户
|
||||
* 只看自己管辖的文件夹/项目子树,其余人拿到空结果。`meta.scope` 只用来
|
||||
* 显示一行提示,不控制任何数据。
|
||||
*
|
||||
* 导出走 `logs.csv`,带上当前全部筛选条件 —— 「导出结果」的语义是
|
||||
* 「导出你现在看到的这一组」,不是「导出全部」。
|
||||
*/
|
||||
import { onMount } from "svelte";
|
||||
import { api } from "$lib/api.js";
|
||||
import { toastOk, toastErr } from "$lib/stores.js";
|
||||
import { auditActionLabel, AUDIT_OBJECT_TYPE_LABEL } from "$lib/labels.js";
|
||||
import Icon from "$lib/Icon.svelte";
|
||||
import UserPicker from "$lib/UserPicker.svelte";
|
||||
import type {
|
||||
AuditArchiveResult,
|
||||
AuditLogEntry,
|
||||
AuditLogPage,
|
||||
AuditMeta,
|
||||
AuditObjectType,
|
||||
AuditVerifyResult,
|
||||
} from "$lib/types.js";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
let meta = $state<AuditMeta | null>(null);
|
||||
let result = $state<AuditLogPage | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// 筛选条件。datetime-local 的值是「本地时间无时区」串,提交前转 ISO。
|
||||
let from = $state("");
|
||||
let to = $state("");
|
||||
let actorUserId = $state("");
|
||||
// 「重置」要连带清掉选择器输入框里的姓名文本,而它是 UserPicker 的内部状态
|
||||
// (对外只暴露 userId)。递增此 key 强制重挂载 —— 比为一次重置给组件加
|
||||
// reset() 命令式出口更简单。
|
||||
let actorPickerKey = $state(0);
|
||||
let objectType = $state<AuditObjectType | "">("");
|
||||
let objectId = $state("");
|
||||
let objectPath = $state("");
|
||||
let resultFilter = $state<"" | "SUCCESS" | "FAILURE">("");
|
||||
let includeArchived = $state(false);
|
||||
let selectedActions = $state<string[]>([]);
|
||||
let offset = $state(0);
|
||||
|
||||
// 展开的行(看前后值 / 上下文详情)。
|
||||
let expanded = $state<string | null>(null);
|
||||
let verifyState = $state<AuditVerifyResult | null>(null);
|
||||
let verifying = $state(false);
|
||||
|
||||
const isAdmin = $derived(meta?.scope === "all");
|
||||
const totalPages = $derived(result === null ? 0 : Math.ceil(result.total / PAGE_SIZE));
|
||||
const currentPage = $derived(Math.floor(offset / PAGE_SIZE) + 1);
|
||||
|
||||
/** 筛选条件 → 查询串。空值一律省略,后端按"未过滤"处理。 */
|
||||
function buildParams(extra: Record<string, string> = {}): URLSearchParams {
|
||||
const params = new URLSearchParams();
|
||||
// datetime-local 没有时区,new Date() 按浏览器本地时区解释 —— 与用户
|
||||
// 在输入框里看到的时间一致,这是想要的行为。
|
||||
if (from !== "") params.set("from", new Date(from).toISOString());
|
||||
if (to !== "") params.set("to", new Date(to).toISOString());
|
||||
if (actorUserId.trim() !== "") params.set("actorUserId", actorUserId.trim());
|
||||
if (objectType !== "") params.set("objectType", objectType);
|
||||
if (objectId.trim() !== "") params.set("objectId", objectId.trim());
|
||||
if (objectPath.trim() !== "") params.set("objectPath", objectPath.trim());
|
||||
if (resultFilter !== "") params.set("result", resultFilter);
|
||||
if (includeArchived) params.set("includeArchived", "true");
|
||||
if (selectedActions.length > 0) params.set("actions", selectedActions.join(","));
|
||||
for (const [k, v] of Object.entries(extra)) params.set(k, v);
|
||||
return params;
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading = true;
|
||||
try {
|
||||
const params = buildParams({ offset: String(offset), limit: String(PAGE_SIZE) });
|
||||
result = await api<AuditLogPage>(`/database/api/audit/logs?${params.toString()}`);
|
||||
error = null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function search(): void {
|
||||
offset = 0;
|
||||
void load();
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
from = "";
|
||||
to = "";
|
||||
actorUserId = "";
|
||||
actorPickerKey += 1;
|
||||
objectType = "";
|
||||
objectId = "";
|
||||
objectPath = "";
|
||||
resultFilter = "";
|
||||
includeArchived = false;
|
||||
selectedActions = [];
|
||||
offset = 0;
|
||||
void load();
|
||||
}
|
||||
|
||||
function toggleAction(action: string): void {
|
||||
selectedActions = selectedActions.includes(action)
|
||||
? selectedActions.filter((a) => a !== action)
|
||||
: [...selectedActions, action];
|
||||
}
|
||||
|
||||
function toggleGroup(actions: readonly string[]): void {
|
||||
const allOn = actions.every((a) => selectedActions.includes(a));
|
||||
selectedActions = allOn
|
||||
? selectedActions.filter((a) => !actions.includes(a))
|
||||
: [...new Set([...selectedActions, ...actions])];
|
||||
}
|
||||
|
||||
function page(delta: number): void {
|
||||
const next = offset + delta * PAGE_SIZE;
|
||||
if (next < 0 || (result !== null && next >= result.total)) return;
|
||||
offset = next;
|
||||
void load();
|
||||
}
|
||||
|
||||
/** 导出:浏览器直接打开下载链接(带 Content-Disposition,不需要 fetch)。 */
|
||||
function exportCsv(): void {
|
||||
window.location.href = `/database/api/audit/logs.csv?${buildParams().toString()}`;
|
||||
}
|
||||
|
||||
async function verify(): Promise<void> {
|
||||
verifying = true;
|
||||
try {
|
||||
verifyState = await api<AuditVerifyResult>("/database/api/audit/verify");
|
||||
if (verifyState.ok) toastOk(`哈希链完整,已校验 ${verifyState.checked} 条`);
|
||||
else toastErr(`发现 ${verifyState.breaks.length} 处异常`);
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
verifying = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archive(): Promise<void> {
|
||||
if (!confirm("按保留策略归档超期日志?归档后默认不在查询结果中显示(仍可勾选「含已归档」查看),且不可撤销。")) return;
|
||||
try {
|
||||
const r = await api<AuditArchiveResult>("/database/api/audit/archive", { method: "POST" });
|
||||
toastOk(`已归档 ${r.archived} 条(保留期 ${r.retentionDays} 天)`);
|
||||
await load();
|
||||
} catch (e) {
|
||||
toastErr(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
/** 精确到秒 + 时区(需求「操作时间精确到秒,建议含时区」)。 */
|
||||
function formatTime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number): string => String(n).padStart(2, "0");
|
||||
const offsetMin = -d.getTimezoneOffset();
|
||||
const sign = offsetMin >= 0 ? "+" : "-";
|
||||
const tz = `UTC${sign}${pad(Math.floor(Math.abs(offsetMin) / 60))}:${pad(Math.abs(offsetMin) % 60)}`;
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())} ${tz}`;
|
||||
}
|
||||
|
||||
function formatJson(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function rowKey(entry: AuditLogEntry): string {
|
||||
return entry.id;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
meta = await api<AuditMeta>("/database/api/audit/meta");
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="flex-1 overflow-y-auto p-7">
|
||||
<h1 class="mb-1 text-lg font-semibold text-ink">查询</h1>
|
||||
<p class="text-[12.5px] text-ink-3">查询功能建设中</p>
|
||||
<div class="mb-4 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="mb-1 text-lg font-semibold text-ink">操作日志</h1>
|
||||
<p class="text-[12.5px] text-ink-3">
|
||||
{#if meta === null}
|
||||
加载中…
|
||||
{:else if meta.scope === "all"}
|
||||
你是网站管理员,可查询全系统日志。
|
||||
{:else}
|
||||
你可查询自己拥有「可管理」权限的文件夹/项目及其子树相关日志。
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{#if isAdmin}
|
||||
<div class="flex shrink-0 gap-2">
|
||||
<button class="btn disabled:opacity-50" onclick={verify} disabled={verifying}>
|
||||
{verifying ? "校验中…" : "校验防篡改"}
|
||||
</button>
|
||||
<button class="btn" onclick={archive}>保留策略归档</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if verifyState !== null}
|
||||
<div class="panel mb-3.5">
|
||||
<div class="section-title mb-1.5">防篡改校验</div>
|
||||
{#if verifyState.ok}
|
||||
<p class="text-[12.5px] text-ink-3">
|
||||
已校验 {verifyState.checked} 条,哈希链连续完整,未发现改写或缺失。
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mb-2 text-[12.5px] text-danger">
|
||||
已校验 {verifyState.checked} 条,发现 {verifyState.breaks.length} 处异常:
|
||||
</p>
|
||||
<ul class="text-[12px] text-ink-3">
|
||||
{#each verifyState.breaks.slice(0, 10) as b (b.id)}
|
||||
<li class="font-mono">
|
||||
seq {b.seq} · {formatTime(b.occurredAt)} ·
|
||||
{b.reason === "hash_mismatch" ? "内容被改写" : b.reason === "seq_gap" ? "记录缺失" : "链接断裂"}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="panel mb-3.5">
|
||||
<div class="section-title mb-2.5">筛选条件</div>
|
||||
|
||||
<div class="mb-3 grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-4">
|
||||
<label class="block">
|
||||
<span class="form-label">起始时间</span>
|
||||
<input class="input" type="datetime-local" bind:value={from} />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">结束时间</span>
|
||||
<input class="input" type="datetime-local" bind:value={to} />
|
||||
</label>
|
||||
<div class="block">
|
||||
<label class="form-label" for="audit-actor">操作人</label>
|
||||
{#key actorPickerKey}
|
||||
<UserPicker id="audit-actor" bind:userId={actorUserId} />
|
||||
{/key}
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="form-label">操作结果</span>
|
||||
<select class="select" bind:value={resultFilter}>
|
||||
<option value="">全部</option>
|
||||
<option value="SUCCESS">成功</option>
|
||||
<option value="FAILURE">失败</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">对象类型</span>
|
||||
<select class="select" bind:value={objectType}>
|
||||
<option value="">全部</option>
|
||||
{#each meta?.objectTypes ?? [] as t (t)}
|
||||
<option value={t}>{AUDIT_OBJECT_TYPE_LABEL[t] ?? t}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="form-label">对象 ID</span>
|
||||
<input class="input" placeholder="节点 / 组 / 任务 id" bind:value={objectId} />
|
||||
</label>
|
||||
<label class="block lg:col-span-2">
|
||||
<span class="form-label">对象路径前缀</span>
|
||||
<input class="input" placeholder="如 /rootId/childId(含整个子树)" bind:value={objectPath} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if meta !== null}
|
||||
<div class="mb-3">
|
||||
<span class="form-label">操作类型{selectedActions.length > 0 ? `(已选 ${selectedActions.length})` : "(不选 = 全部)"}</span>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each meta.actionGroups as group (group.key)}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
class="tag shrink-0 cursor-pointer hover:border-accent"
|
||||
onclick={() => toggleGroup(group.actions)}
|
||||
title="全选/全不选本组"
|
||||
>
|
||||
{group.label}
|
||||
</button>
|
||||
{#each group.actions as action (action)}
|
||||
{@const on = selectedActions.includes(action)}
|
||||
<button
|
||||
class="tag cursor-pointer"
|
||||
class:!border-accent={on}
|
||||
class:!text-ink={on}
|
||||
class:bg-selected={on}
|
||||
onclick={() => toggleAction(action)}
|
||||
>
|
||||
{auditActionLabel(action)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button class="btn btn-primary disabled:opacity-50" onclick={search} disabled={loading}>
|
||||
{loading ? "查询中…" : "查询"}
|
||||
</button>
|
||||
<button class="btn" onclick={reset}>重置</button>
|
||||
<button class="btn" onclick={exportCsv} title="导出当前筛选结果为 CSV">
|
||||
<Icon name="download" size={13} /> 导出 CSV
|
||||
</button>
|
||||
<label class="ml-1 flex cursor-pointer items-center gap-1.5 text-[12.5px] text-ink-3">
|
||||
<input type="checkbox" bind:checked={includeArchived} />
|
||||
含已归档
|
||||
</label>
|
||||
{#if meta !== null}
|
||||
<span class="section-note ml-auto">导出上限 {meta.exportRowLimit.toLocaleString()} 行</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="mb-2.5 flex items-center justify-between gap-2">
|
||||
<div class="section-title">
|
||||
查询结果{result !== null ? `(共 ${result.total.toLocaleString()} 条)` : ""}
|
||||
</div>
|
||||
{#if result !== null && result.total > PAGE_SIZE}
|
||||
<div class="flex items-center gap-2 text-[12.5px] text-ink-3">
|
||||
<button class="btn btn-sm disabled:opacity-40" onclick={() => page(-1)} disabled={offset === 0 || loading}>上一页</button>
|
||||
<span>{currentPage} / {totalPages}</span>
|
||||
<button
|
||||
class="btn btn-sm disabled:opacity-40"
|
||||
onclick={() => page(1)}
|
||||
disabled={offset + PAGE_SIZE >= result.total || loading}
|
||||
>下一页</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if error !== null}
|
||||
<div class="py-3 text-[12.5px] text-danger">{error}</div>
|
||||
{:else if result === null}
|
||||
<div class="quiet py-[18px] text-center">加载中…</div>
|
||||
{:else if result.entries.length === 0}
|
||||
<div class="quiet py-[18px] text-center">
|
||||
{meta?.scope === "managed"
|
||||
? "没有匹配的日志。你只能查询自己拥有「可管理」权限的范围。"
|
||||
: "没有匹配的日志。"}
|
||||
</div>
|
||||
{:else}
|
||||
<table class="list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>操作时间</th>
|
||||
<th>操作人</th>
|
||||
<th>操作类型</th>
|
||||
<th>操作对象</th>
|
||||
<th>结果</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each result.entries as entry (rowKey(entry))}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap font-mono text-[11.5px] text-ink-3">{formatTime(entry.occurredAt)}</td>
|
||||
<td>
|
||||
<span class="text-ink">{entry.actorName}</span>
|
||||
{#if entry.actorIsAdmin}
|
||||
<span class="tag ml-1">管理员</span>
|
||||
{/if}
|
||||
<div class="file-meta">{entry.actorUserId}</div>
|
||||
</td>
|
||||
<td class="text-ink">{auditActionLabel(entry.action)}</td>
|
||||
<td>
|
||||
<span class="text-ink">{entry.objectName}</span>
|
||||
<div class="file-meta">
|
||||
{AUDIT_OBJECT_TYPE_LABEL[entry.objectType] ?? entry.objectType} · {entry.objectId.slice(0, 8)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap">
|
||||
{#if entry.result === "SUCCESS"}
|
||||
<span class="text-[12px] text-ink-3">成功</span>
|
||||
{:else}
|
||||
<span class="text-[12px] text-danger">失败</span>
|
||||
{/if}
|
||||
{#if entry.archivedAt !== null}
|
||||
<span class="tag ml-1">已归档</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
class="btn btn-sm"
|
||||
onclick={() => (expanded = expanded === entry.id ? null : entry.id)}
|
||||
>
|
||||
{expanded === entry.id ? "收起" : "详情"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{#if expanded === entry.id}
|
||||
<tr>
|
||||
<td colspan="6" class="!pt-0">
|
||||
<div class="rounded-lg border border-line-soft bg-sidebar p-3 text-[12px]">
|
||||
<div class="mb-2 grid grid-cols-1 gap-x-6 gap-y-1 md:grid-cols-2">
|
||||
<div><span class="text-ink-3">日志 ID:</span> <span class="font-mono">{entry.id}</span></div>
|
||||
<div><span class="text-ink-3">链序号:</span> <span class="font-mono">{entry.seq}</span></div>
|
||||
<div class="md:col-span-2">
|
||||
<span class="text-ink-3">对象路径:</span> <span class="font-mono break-all">{entry.objectPath}</span>
|
||||
</div>
|
||||
<div><span class="text-ink-3">客户端 IP:</span> <span class="font-mono">{entry.clientIp ?? "—"}</span></div>
|
||||
<div class="truncate" title={entry.userAgent ?? ""}>
|
||||
<span class="text-ink-3">User-Agent:</span> <span class="font-mono">{entry.userAgent ?? "—"}</span>
|
||||
</div>
|
||||
{#if entry.failureReason !== null}
|
||||
<div class="md:col-span-2 text-danger">
|
||||
<span class="text-ink-3">失败原因:</span> {entry.failureReason}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<div class="form-label">操作前值</div>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] text-ink-3">{formatJson(entry.beforeValue)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label">操作后值</div>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] text-ink-3">{formatJson(entry.afterValue)}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div class="form-label">附加上下文</div>
|
||||
<pre class="overflow-x-auto whitespace-pre-wrap break-all font-mono text-[11px] text-ink-3">{formatJson(entry.context)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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 中断链暴露。
|
||||
@@ -52,6 +52,7 @@ model Organization {
|
||||
auditEntries AuditEntry[] @relation("organizationAudit")
|
||||
projectSearchDocuments ProjectSearchDocument[]
|
||||
fileLibNodes FileLibNode[]
|
||||
fileLibAuditLogs FileLibAuditLog[] @relation("organizationFileLibAudit")
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
@@ -1140,3 +1141,86 @@ model FileLibExportJob {
|
||||
|
||||
@@index([organizationId, status])
|
||||
}
|
||||
|
||||
// --- 文件库审计日志(横切模块) -------------------------------------------
|
||||
|
||||
/// 审计对象类型。objectId 故意不建 FK:审计是不可变历史事实,对象被彻底
|
||||
/// 删除(node.purge)后日志仍须完整可读,不能被级联带走。
|
||||
enum FileLibAuditObjectType {
|
||||
FOLDER
|
||||
PROJECT
|
||||
FILE
|
||||
GRANT
|
||||
EXPORT_JOB
|
||||
GROUP
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum FileLibAuditResult {
|
||||
SUCCESS
|
||||
FAILURE
|
||||
}
|
||||
|
||||
/// 文件库审计日志(ADR-0039)。与既有 `AuditEntry`(Project/Run 口径、
|
||||
/// best-effort)及 Platform Audit(ADR-0023)三者分离:本表是文件版本管理与
|
||||
/// 权限管理子模块的合规证据,字段结构化、只追加、带哈希链防篡改。
|
||||
///
|
||||
/// 不可篡改:服务路径只 INSERT/SELECT。迁移 SQL 里装了 BEFORE UPDATE OR
|
||||
/// DELETE 触发器,任何改写/删除一律抛异常 —— 应用层写错也拦得住。保留策略
|
||||
/// 归档(≥180 天)是唯一合法的出场方式,走 archivedAt 打标而非物理删除。
|
||||
///
|
||||
/// 哈希链:每条 entryHash = sha256(规范化载荷 + prevHash),按 org 串成单链,
|
||||
/// seq 单调递增。整链可离线校验(见 auditVerify),断链即篡改或漏写。
|
||||
model FileLibAuditLog {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
/// org 内单调递增序号,哈希链的顺序权威(createdAt 同秒不可排序)。
|
||||
seq BigInt
|
||||
/// 操作时间。DB 侧 timestamptz,读出即带时区。
|
||||
occurredAt DateTime @default(now())
|
||||
action String
|
||||
result FileLibAuditResult @default(SUCCESS)
|
||||
/// 失败原因(result=FAILURE 时的错误码 + 消息);成功为 null。
|
||||
failureReason String?
|
||||
|
||||
/// 操作人 id。故意不建 FK:用户注销后审计仍须留痕。
|
||||
actorUserId String
|
||||
/// 操作人姓名快照 —— 记录当时的显示名,不随用户改名而变。
|
||||
actorName String
|
||||
/// 操作时是否以网站管理员身份行事(高危操作甄别)。
|
||||
actorIsAdmin Boolean @default(false)
|
||||
|
||||
objectType FileLibAuditObjectType
|
||||
objectId String
|
||||
/// 对象名称;节点为 name,文件为文件名,组为组名。
|
||||
objectName String
|
||||
/// 对象路径:节点 pathIds,文件为 "<pathIds>:<filePath>"。前缀匹配可查子树。
|
||||
objectPath String
|
||||
|
||||
/// 操作前值(结构化)。创建类操作为 null。
|
||||
beforeValue Json?
|
||||
/// 操作后值(结构化)。删除类操作为 null。
|
||||
afterValue Json?
|
||||
/// 附加上下文:冲突基线版本、导出参数、commit message 等。
|
||||
context Json?
|
||||
|
||||
clientIp String?
|
||||
userAgent String?
|
||||
|
||||
/// 本条的链哈希;prevHash 为 org 内上一条的 entryHash(首条为 null)。
|
||||
entryHash String
|
||||
prevHash String?
|
||||
|
||||
/// 保留策略归档时间(≥180 天后打标)。非 null 即已归档,默认查询不返回。
|
||||
archivedAt DateTime?
|
||||
|
||||
organization Organization @relation("organizationFileLibAudit", fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([organizationId, seq])
|
||||
@@index([organizationId, occurredAt])
|
||||
@@index([organizationId, actorUserId, occurredAt])
|
||||
@@index([organizationId, action, occurredAt])
|
||||
@@index([organizationId, objectType, objectId])
|
||||
@@index([organizationId, objectPath])
|
||||
@@index([organizationId, archivedAt])
|
||||
}
|
||||
|
||||
+64
-10
@@ -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` 的
|
||||
@@ -85,9 +90,10 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `routes/databaseRoutes.ts` | `/database/config`、`/database/api/stats`、dev 旁路 + 各子路由装配点 |
|
||||
| `routes/filelibRoutes.ts` | 文件库 树/授权 API |
|
||||
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
|
||||
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
|
||||
| `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/...")`,
|
||||
@@ -99,7 +105,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
独立文件库模块。代码注释里的 C/D 编号(契约 8.1、C2、C4、D11–D19 等)
|
||||
出自两份已删除的文档:《文件库-接口契约.md》与 `.omo/文件库-开工计划.md`,
|
||||
内容可从 git 历史取回。其中 D19(网站管理员 = silo org OWNER/ADMIN)
|
||||
另见 ADR-0028。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是
|
||||
另见 ADR-0038。**与 hub 自己的 Folder/Project(ADR-0021 explorer)是
|
||||
两套体系,不复用。**
|
||||
|
||||
| 文件 | 职责 |
|
||||
@@ -112,11 +118,11 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
|
||||
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(**仅测试用**,ADR-0030) |
|
||||
| `filelib/gitVersionStore.ts` | **生产** C1 实现:一项目一 git 仓库,VersionId = commit hash(ADR-0030) |
|
||||
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
|
||||
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
|
||||
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
|
||||
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0038) |
|
||||
| `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` | 路由共享件(依赖装配/错误映射/请求体校验) |
|
||||
|
||||
@@ -124,7 +130,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
|
||||
- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`)
|
||||
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
|
||||
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
|
||||
MemberGroup 闭包**(ADR-0038 起的默认;此前是扁平 hub Team)
|
||||
|
||||
## 存储布局(ADR-0030)
|
||||
|
||||
@@ -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 前缀匹配;文件日志的 `<pathIds>:<filePath>` 天然被覆盖)
|
||||
- 其余人 → 空结果而非 403(与 D8 不泄露存在性一致)
|
||||
|
||||
Group 日志的 objectPath 是 `group:<id>`,不在节点路径空间内,因此只有网站
|
||||
管理员可见。
|
||||
|
||||
环境变量:`HUB_FILELIB_AUDIT_RETENTION_DAYS` — 保留天数,下限 180(默认)。
|
||||
|
||||
> **已知缺口**:需求点名的「项目独立权限的开启/关闭/变更」在 ADR-0030 之后
|
||||
> 已被废除(`FileLibProjectSettings` 不再被读取,见 `filelib/permission.ts`),
|
||||
> 当前没有产生该事件的代码路径。词表里的三个 `project.independent_permission.*`
|
||||
> 动作是预留位;该功能若恢复,直接调 `writeFileLibAudit` 即可。
|
||||
|
||||
## 约定(与 admin 面一致)
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 审计日志模块 —— 领域类型与哈希链(纯逻辑,不碰 IO)。
|
||||
*
|
||||
* 语义锚点:ADR-0039(横切、只追加、哈希链、≥180 天保留、三级可见性)。
|
||||
*
|
||||
* 本模块是横切能力,单向依赖:业务 service → audit 模块。审计不反向依赖任何
|
||||
* 业务 service,只认下面这组与领域无关的原语(action 词表 + 记录形状),
|
||||
* 所以可以整体摘除或替换成独立审计服务而不牵动业务代码。
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/* ---------------------------------------------------------------- 动作词表 */
|
||||
|
||||
/**
|
||||
* 审计动作词表。覆盖需求点名的全部关键写操作。
|
||||
* 值是稳定标识(入库、进查询过滤器、可 grep),改值等于改历史口径,不要动。
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
// 文件夹/项目:创建、删除、移动、重命名
|
||||
folderCreate: "folder.create",
|
||||
folderRename: "folder.rename",
|
||||
folderMove: "folder.move",
|
||||
folderDelete: "folder.delete",
|
||||
projectCreate: "project.create",
|
||||
projectRename: "project.rename",
|
||||
projectMove: "project.move",
|
||||
projectDelete: "project.delete",
|
||||
|
||||
// 归档/取消归档(= 回收站软删的还原侧 + 彻底删除)
|
||||
folderRestore: "folder.restore",
|
||||
projectRestore: "project.restore",
|
||||
nodePurge: "node.purge",
|
||||
|
||||
// 个人与 Group 权限的授予、修改、收回(含 MANAGE/EDIT/VIEW)
|
||||
permissionGrant: "permission.grant",
|
||||
permissionUpdate: "permission.update",
|
||||
permissionRevoke: "permission.revoke",
|
||||
|
||||
// 项目独立权限的开启、关闭及变更
|
||||
independentEnable: "project.independent_permission.enable",
|
||||
independentDisable: "project.independent_permission.disable",
|
||||
independentChange: "project.independent_permission.change",
|
||||
|
||||
// 文件编辑提交(含冲突合并后的提交)与冲突检测事件
|
||||
fileUpload: "file.upload",
|
||||
fileRename: "file.rename",
|
||||
fileDelete: "file.delete",
|
||||
fileCommit: "file.commit",
|
||||
fileConflictDetected: "file.conflict_detected",
|
||||
|
||||
// 导出
|
||||
exportRun: "export.run",
|
||||
|
||||
// 网站管理员高危操作(根目录创建走 folder/project.create + actorIsAdmin 标记)
|
||||
adminForceAdjust: "admin.force_adjust",
|
||||
|
||||
// Group 的创建、删除、成员增删、嵌套关系变更
|
||||
groupCreate: "group.create",
|
||||
groupUpdate: "group.update",
|
||||
groupDelete: "group.delete",
|
||||
groupRestore: "group.restore",
|
||||
groupMemberAdd: "group.member_add",
|
||||
groupMemberRemove: "group.member_remove",
|
||||
groupReparent: "group.reparent",
|
||||
|
||||
// 保留策略归档(日志自身的生命周期事件)
|
||||
retentionArchive: "audit.retention_archive",
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||
|
||||
/** 全部动作值,查询层用它校验过滤器、前端用它渲染下拉。 */
|
||||
export const ALL_AUDIT_ACTIONS: readonly string[] = Object.values(AUDIT_ACTIONS);
|
||||
|
||||
/** 动作分组 —— 仅供查询 UI 折叠展示,不参与任何判定。 */
|
||||
export const AUDIT_ACTION_GROUPS: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly actions: readonly string[];
|
||||
}> = [
|
||||
{
|
||||
key: "node",
|
||||
label: "文件夹/项目",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.folderCreate, AUDIT_ACTIONS.folderRename, AUDIT_ACTIONS.folderMove, AUDIT_ACTIONS.folderDelete,
|
||||
AUDIT_ACTIONS.projectCreate, AUDIT_ACTIONS.projectRename, AUDIT_ACTIONS.projectMove, AUDIT_ACTIONS.projectDelete,
|
||||
AUDIT_ACTIONS.folderRestore, AUDIT_ACTIONS.projectRestore, AUDIT_ACTIONS.nodePurge,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "permission",
|
||||
label: "权限",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.permissionGrant, AUDIT_ACTIONS.permissionUpdate, AUDIT_ACTIONS.permissionRevoke,
|
||||
AUDIT_ACTIONS.independentEnable, AUDIT_ACTIONS.independentDisable, AUDIT_ACTIONS.independentChange,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "file",
|
||||
label: "文件",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.fileUpload, AUDIT_ACTIONS.fileCommit, AUDIT_ACTIONS.fileRename,
|
||||
AUDIT_ACTIONS.fileDelete, AUDIT_ACTIONS.fileConflictDetected,
|
||||
],
|
||||
},
|
||||
{ key: "export", label: "导出", actions: [AUDIT_ACTIONS.exportRun] },
|
||||
{
|
||||
key: "group",
|
||||
label: "Group",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.groupCreate, AUDIT_ACTIONS.groupUpdate, AUDIT_ACTIONS.groupDelete,
|
||||
AUDIT_ACTIONS.groupRestore, AUDIT_ACTIONS.groupMemberAdd, AUDIT_ACTIONS.groupMemberRemove,
|
||||
AUDIT_ACTIONS.groupReparent,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "管理员高危",
|
||||
actions: [AUDIT_ACTIONS.adminForceAdjust, AUDIT_ACTIONS.retentionArchive],
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------- 记录形状 */
|
||||
|
||||
export type AuditObjectType =
|
||||
| "FOLDER" | "PROJECT" | "FILE" | "GRANT" | "EXPORT_JOB" | "GROUP" | "SYSTEM";
|
||||
|
||||
export type AuditResult = "SUCCESS" | "FAILURE";
|
||||
|
||||
/** 操作人身份(从 FileLibActor 摘出的最小面,审计模块不 import 业务类型)。 */
|
||||
export interface AuditActor {
|
||||
readonly userId: string;
|
||||
/** 姓名快照。缺失时调用方回落 userId —— 审计字段要求「用户ID + 用户名」。 */
|
||||
readonly displayName?: string | undefined;
|
||||
readonly isWebsiteAdmin?: boolean | undefined;
|
||||
}
|
||||
|
||||
/** 客户端信息(可选;从请求头提取,见 requestContext)。 */
|
||||
export interface AuditClient {
|
||||
readonly ip?: string | undefined;
|
||||
readonly userAgent?: string | undefined;
|
||||
}
|
||||
|
||||
/** 一条待写入的审计记录。 */
|
||||
export interface AuditRecordInput {
|
||||
readonly action: string;
|
||||
readonly actor: AuditActor;
|
||||
readonly objectType: AuditObjectType;
|
||||
readonly objectId: string;
|
||||
readonly objectName: string;
|
||||
/** 节点 pathIds,或文件的 `<pathIds>:<filePath>`。前缀匹配即子树查询。 */
|
||||
readonly objectPath: string;
|
||||
readonly result?: AuditResult | undefined;
|
||||
readonly failureReason?: string | undefined;
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | undefined;
|
||||
readonly client?: AuditClient | undefined;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 哈希链 */
|
||||
|
||||
/**
|
||||
* 规范化载荷 → sha256(prevHash + payload)。ADR-0039:改动参与哈希的字段集合
|
||||
* 会使既有链整体失配 —— 那是需要新 ADR 与重锚流程的破坏性变更,不是重构。
|
||||
*
|
||||
* 规范化的关键是 key 排序:JSON.stringify 的键序取决于对象构造顺序,
|
||||
* 不排序的话同一条记录换个写法就算出不同哈希,校验会假报篡改。
|
||||
*/
|
||||
export function canonicalize(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value !== "object") return JSON.stringify(value) ?? "null";
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/** 参与哈希的字段集合。改这里等于换链算法,历史链会整体失配。 */
|
||||
export interface HashableEntry {
|
||||
readonly organizationId: string;
|
||||
readonly seq: bigint | number;
|
||||
readonly occurredAt: Date;
|
||||
readonly action: string;
|
||||
readonly result: AuditResult;
|
||||
readonly failureReason: string | null;
|
||||
readonly actorUserId: string;
|
||||
readonly actorName: string;
|
||||
readonly actorIsAdmin: boolean;
|
||||
readonly objectType: AuditObjectType;
|
||||
readonly objectId: string;
|
||||
readonly objectName: string;
|
||||
readonly objectPath: string;
|
||||
readonly beforeValue: unknown;
|
||||
readonly afterValue: unknown;
|
||||
readonly context: unknown;
|
||||
readonly clientIp: string | null;
|
||||
readonly userAgent: string | null;
|
||||
}
|
||||
|
||||
export function computeEntryHash(entry: HashableEntry, prevHash: string | null): string {
|
||||
const payload = canonicalize({
|
||||
organizationId: entry.organizationId,
|
||||
seq: entry.seq.toString(),
|
||||
occurredAt: entry.occurredAt.toISOString(),
|
||||
action: entry.action,
|
||||
result: entry.result,
|
||||
failureReason: entry.failureReason,
|
||||
actorUserId: entry.actorUserId,
|
||||
actorName: entry.actorName,
|
||||
actorIsAdmin: entry.actorIsAdmin,
|
||||
objectType: entry.objectType,
|
||||
objectId: entry.objectId,
|
||||
objectName: entry.objectName,
|
||||
objectPath: entry.objectPath,
|
||||
beforeValue: entry.beforeValue ?? null,
|
||||
afterValue: entry.afterValue ?? null,
|
||||
context: entry.context ?? null,
|
||||
clientIp: entry.clientIp,
|
||||
userAgent: entry.userAgent,
|
||||
});
|
||||
return createHash("sha256").update(`${prevHash ?? ""}\n${payload}`).digest("hex");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 保留策略 */
|
||||
|
||||
/** ADR-0039:保留期下限 180 天。配置只能调高,不能调低。 */
|
||||
export const AUDIT_RETENTION_DAYS_MIN = 180;
|
||||
|
||||
/** 生效保留天数;配置低于下限时按下限执行(合规底线不可调低)。 */
|
||||
export function resolveRetentionDays(raw?: string | undefined): number {
|
||||
if (raw === undefined || raw.trim() === "") return AUDIT_RETENTION_DAYS_MIN;
|
||||
const parsed = Number(raw.trim());
|
||||
if (!Number.isSafeInteger(parsed) || parsed < AUDIT_RETENTION_DAYS_MIN) {
|
||||
return AUDIT_RETENTION_DAYS_MIN;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 姓名快照:缺 displayName 时回落 userId,保证字段永不为空。 */
|
||||
export function actorName(actor: AuditActor): string {
|
||||
const name = actor.displayName?.trim();
|
||||
return name === undefined || name === "" ? actor.userId : name;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 审计查询与导出。语义锚点:ADR-0039「读可见性三级」。
|
||||
*
|
||||
* 可见性三级:
|
||||
* - 网站管理员(silo org OWNER/ADMIN):全系统日志
|
||||
* - 持 MANAGE 的用户:仅自己拥有 MANAGE 的文件夹/项目**及其子树**相关日志
|
||||
* - 普通用户:默认不可查看 —— scope 解析出空可见集,返回空页而非 403
|
||||
* (不泄露"系统里有没有日志"这件事,与 D8 的不泄露存在性一致)
|
||||
*
|
||||
* 子树语义:MANAGE 挂在文件夹上时,该文件夹下所有节点的日志都算"相关"。
|
||||
* 用 objectPath 的 pathIds 前缀匹配实现 —— 与树服务的物化路径同一套编码。
|
||||
* 文件日志的 objectPath 是 `<pathIds>:<filePath>`,前缀匹配天然覆盖。
|
||||
*
|
||||
* 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<readonly string[]>;
|
||||
}
|
||||
|
||||
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<readonly string[] | null> {
|
||||
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<Prisma.FileLibAuditLogWhereInput | null> {
|
||||
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 精确等于,或以 "<pathIds>/" / "<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<AuditPage> {
|
||||
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<readonly AuditEntryDto[]> {
|
||||
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`;
|
||||
}
|
||||
@@ -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<ArchiveResult> {
|
||||
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<AuditMaintenanceDeps, "prisma" | "organizationId">,
|
||||
options: { readonly batchSize?: number | undefined } = {},
|
||||
): Promise<VerifyResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<readonly string[]>;
|
||||
/** 复用文件库门禁:返回 null 时响应已发出。 */
|
||||
readonly actorOrNull: (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) => Promise<AuditQueryActor | null>;
|
||||
}
|
||||
|
||||
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<string, string | undefined>): 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<void> {
|
||||
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<void> {
|
||||
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<string, string | undefined>;
|
||||
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<string, string | undefined>),
|
||||
);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await insertOnce(tx, deps, input);
|
||||
}
|
||||
|
||||
/** 一次写多条(如创建节点时附带的初始授权),顺序即链上顺序。 */
|
||||
export async function writeAuditMany(
|
||||
tx: Prisma.TransactionClient,
|
||||
deps: AuditWriteDeps,
|
||||
inputs: readonly AuditRecordInput[],
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
}
|
||||
@@ -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-0028:成员组内置进 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,或文件的 `<pathIds>:<filePath>`。 */
|
||||
readonly objectPath: string;
|
||||
readonly detail?: Record<string, unknown> | undefined;
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | 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<void> {
|
||||
const metadata: Record<string, unknown> = {
|
||||
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<void> {
|
||||
await writeAuditOutOfBand(
|
||||
{ prisma, organizationId: entry.organizationId },
|
||||
{ ...toRecord(entry), result: entry.result ?? "FAILURE" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
values: {
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
node: { readonly id: string; readonly name: string; readonly pathIds: string },
|
||||
values: {
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* GroupResolver port(契约 C2)。
|
||||
*
|
||||
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
|
||||
* ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
|
||||
* ADR-0038 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
|
||||
* (`createMemberGroupResolver`,见 memberGroupResolver.ts);
|
||||
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
|
||||
* 调用方只依赖此 port,不换调用点。
|
||||
@@ -15,7 +15,7 @@ export interface GroupResolver {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
|
||||
* @deprecated ADR-0038:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
|
||||
* `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
|
||||
* (以及潜在的迁移对照),新代码不要使用。
|
||||
*
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0028)。
|
||||
* 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0038)。
|
||||
*
|
||||
* resolveMemberGroupIds(user) = 用户**活跃直接组 ∪ 这些组的活跃祖先**,去重
|
||||
* (闭包 depth0 自身行令每个直接组也是自己的祖先)。等价于:授权放在组 G 上,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 成员组(MemberGroup)管理服务(ADR-0028)。
|
||||
* 成员组(MemberGroup)管理服务(ADR-0038)。
|
||||
*
|
||||
* 语义锚定:
|
||||
* - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org
|
||||
@@ -84,14 +84,14 @@ type Tx = Prisma.TransactionClient;
|
||||
|
||||
/* ---------------------------------------------------------------- 内部工具 */
|
||||
|
||||
/** 管理门禁:非网站管理员一律 403(决策2)。 */
|
||||
// 管理门禁:非网站管理员一律 403。
|
||||
function requireAdmin(actor: FileLibActor): void {
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
throw new FileLibError(403, "forbidden", "group management requires website administrator");
|
||||
}
|
||||
}
|
||||
|
||||
/** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */
|
||||
// 组名校验。
|
||||
function normalizeGroupName(raw: string): string {
|
||||
const name = raw.trim();
|
||||
if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty");
|
||||
@@ -111,7 +111,7 @@ async function requireActiveGroup(
|
||||
return group;
|
||||
}
|
||||
|
||||
/** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */
|
||||
// 全局用户解析:按 userId 或 feishuOpenId。不要求 org 成员。
|
||||
async function resolveUser(
|
||||
tx: Tx,
|
||||
input: AddMemberInput,
|
||||
@@ -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:<id>" 占位 ——
|
||||
// 与节点路径空间隔离,因此组日志只对网站管理员可见(见 auditQuery)。
|
||||
objectPath: `group:${group.id}`,
|
||||
after: { name, parentId, description, depth },
|
||||
context: { nested: parentId !== null },
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -211,10 +215,7 @@ export async function createMemberGroup(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1
|
||||
* 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。
|
||||
*/
|
||||
/** 改名/改描述(决策6)。不动 parentId(决策5)。 */
|
||||
export async function updateMemberGroup(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
@@ -229,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: {
|
||||
@@ -248,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 {
|
||||
@@ -295,26 +300,22 @@ 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 };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7):
|
||||
* - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。
|
||||
* - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、
|
||||
* depth 推导(闭包行数)与"祖先必活跃"的前提脱节。
|
||||
* - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次
|
||||
* 恢复意外把整支历史组全部重新授权。
|
||||
* 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。
|
||||
*/
|
||||
/** 恢复(取消归档)。与删除不对称(决策7):只恢复本组+已归档祖先,不动子树。 */
|
||||
export async function restoreMemberGroup(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
@@ -344,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 };
|
||||
});
|
||||
@@ -402,10 +407,7 @@ export async function listMemberGroups(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在,
|
||||
* 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。
|
||||
*/
|
||||
/** 组成员列表。已归档组也可读(决策7)。 */
|
||||
export async function listMembers(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
@@ -458,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,
|
||||
@@ -496,21 +501,23 @@ 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)
|
||||
* —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大
|
||||
* 已有能力面,只是把"盲敲 id"变成"搜索选择"。
|
||||
* excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。
|
||||
* 成员选择器:按显示名/openId/userId 搜全局用户(决策2)。
|
||||
*
|
||||
* userId 也纳入匹配:审计日志查询结果里显示的就是 userId,用户从表格拷一个
|
||||
* id 粘进筛选框必须能命中。共享端点,授权面板与成员组选择器一并受益。
|
||||
*/
|
||||
export async function searchUsers(
|
||||
deps: MemberGroupServiceDeps,
|
||||
@@ -540,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 } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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<void> {
|
||||
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() },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -138,7 +138,7 @@ export async function registerDatabaseRoutes(
|
||||
// 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。
|
||||
// 依赖装配:VersionStore 是真 git —— 一项目一仓库 <storageRoot>/<nodeId>,
|
||||
// VersionId = commit hash(ADR-0030);
|
||||
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
|
||||
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0038),
|
||||
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
||||
const siloOrg = await config.prisma.organization.findUnique({
|
||||
@@ -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<string, unknown>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@ export async function registerFileLibRoutes(
|
||||
});
|
||||
|
||||
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
|
||||
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
|
||||
// MemberGroup 闭包(ADR-0038)。此处不再注册,避免重复。
|
||||
}
|
||||
|
||||
function parseGrants(raw: unknown): InitialGrant[] | undefined {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* /database/api/groups/* 成员组管理端点(ADR-0028)。
|
||||
* /database/api/groups/* 成员组管理端点(ADR-0038)。
|
||||
* 约定:绝对路径;actorOrNull 前置 fail closed;业务全走 memberGroupService;
|
||||
* 错误统一 sendRouteError。
|
||||
*
|
||||
@@ -108,7 +108,7 @@ export async function registerMemberGroupRoutes(
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
if (body["parentId"] !== undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)");
|
||||
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0038)");
|
||||
}
|
||||
// description 需区分"未传"(不动)与 ""(清空),故不用 optionalString
|
||||
// (它把 "" 也归为 undefined)。
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。
|
||||
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0038。
|
||||
* 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递
|
||||
* (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。
|
||||
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
|
||||
@@ -128,7 +128,7 @@ describe("memberGroupService · 改名/改描述(决策6)", () => {
|
||||
expect(updated.parentId).toBe(a);
|
||||
expect(updated.depth).toBe(1);
|
||||
|
||||
// 闭包逐行未变 —— rename 不碰层级(ADR-0028 决策6 的核心不变量)。
|
||||
// 闭包逐行未变 —— rename 不碰层级(ADR-0038 决策6 的核心不变量)。
|
||||
const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
|
||||
expect(after).toEqual(before);
|
||||
// C 仍在 B 之下,depth 不变。
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> = {}): 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<HashableEntry>), "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> = {}): 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user