Compare commits

..

2 Commits

36 changed files with 2906 additions and 253 deletions
+1 -1
View File
@@ -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,4 +1,4 @@
# ADR 0028: Member Group Management And Resolution
# ADR 0038: Member Group Management And Resolution
## Status
+140
View File
@@ -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.
+202
View File
@@ -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}
+54 -1
View File
@@ -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: "系统",
};
+74 -2
View File
@@ -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 中断链暴露。
+84
View File
@@ -52,6 +52,7 @@ model Organization {
auditEntries AuditEntry[] @relation("organizationAudit")
projectSearchDocuments ProjectSearchDocument[]
fileLibNodes FileLibNode[]
fileLibAuditLogs FileLibAuditLog[] @relation("organizationFileLibAudit")
@@index([status])
}
@@ -1140,3 +1141,86 @@ model FileLibExportJob {
@@index([organizationId, status])
}
// --- 文件库审计日志(横切模块) -------------------------------------------
/// 审计对象类型。objectId 故意不建 FK:审计是不可变历史事实,对象被彻底
/// 删除(node.purge)后日志仍须完整可读,不能被级联带走。
enum FileLibAuditObjectType {
FOLDER
PROJECT
FILE
GRANT
EXPORT_JOB
GROUP
SYSTEM
}
enum FileLibAuditResult {
SUCCESS
FAILURE
}
/// 文件库审计日志(ADR-0039)。与既有 `AuditEntry`(Project/Run 口径、
/// best-effort)及 Platform Audit(ADR-0023)三者分离:本表是文件版本管理与
/// 权限管理子模块的合规证据,字段结构化、只追加、带哈希链防篡改。
///
/// 不可篡改:服务路径只 INSERT/SELECT。迁移 SQL 里装了 BEFORE UPDATE OR
/// DELETE 触发器,任何改写/删除一律抛异常 —— 应用层写错也拦得住。保留策略
/// 归档(≥180 天)是唯一合法的出场方式,走 archivedAt 打标而非物理删除。
///
/// 哈希链:每条 entryHash = sha256(规范化载荷 + prevHash),按 org 串成单链,
/// seq 单调递增。整链可离线校验(见 auditVerify),断链即篡改或漏写。
model FileLibAuditLog {
id String @id @default(cuid())
organizationId String
/// org 内单调递增序号,哈希链的顺序权威(createdAt 同秒不可排序)。
seq BigInt
/// 操作时间。DB 侧 timestamptz,读出即带时区。
occurredAt DateTime @default(now())
action String
result FileLibAuditResult @default(SUCCESS)
/// 失败原因(result=FAILURE 时的错误码 + 消息);成功为 null。
failureReason String?
/// 操作人 id。故意不建 FK:用户注销后审计仍须留痕。
actorUserId String
/// 操作人姓名快照 —— 记录当时的显示名,不随用户改名而变。
actorName String
/// 操作时是否以网站管理员身份行事(高危操作甄别)。
actorIsAdmin Boolean @default(false)
objectType FileLibAuditObjectType
objectId String
/// 对象名称;节点为 name,文件为文件名,组为组名。
objectName String
/// 对象路径:节点 pathIds,文件为 "<pathIds>:<filePath>"。前缀匹配可查子树。
objectPath String
/// 操作前值(结构化)。创建类操作为 null。
beforeValue Json?
/// 操作后值(结构化)。删除类操作为 null。
afterValue Json?
/// 附加上下文:冲突基线版本、导出参数、commit message 等。
context Json?
clientIp String?
userAgent String?
/// 本条的链哈希;prevHash 为 org 内上一条的 entryHash(首条为 null)。
entryHash String
prevHash String?
/// 保留策略归档时间(≥180 天后打标)。非 null 即已归档,默认查询不返回。
archivedAt DateTime?
organization Organization @relation("organizationFileLibAudit", fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([organizationId, seq])
@@index([organizationId, occurredAt])
@@index([organizationId, actorUserId, occurredAt])
@@index([organizationId, action, occurredAt])
@@index([organizationId, objectType, objectId])
@@index([organizationId, objectPath])
@@index([organizationId, archivedAt])
}
+64 -10
View File
@@ -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、D11D19 等)
出自两份已删除的文档:《文件库-接口契约.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 面一致)
+246
View File
@@ -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;
}
+272
View File
@@ -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`;
}
+156
View File
@@ -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 };
}
+205
View File
@@ -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);
}
});
}
+165
View File
@@ -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;
}
}
}
+65
View File
@@ -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";
+41
View File
@@ -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 };
}
+78 -62
View File
@@ -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" },
);
}
+13 -7
View File
@@ -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 };
});
+6 -3
View File
@@ -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;
});
+56 -9
View File
@@ -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 },
});
}
+43 -28
View File
@@ -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 -2
View File
@@ -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 过渡实现不再接线,保留仅为历史参照
* (以及潜在的迁移对照),新代码不要使用。
*
+4 -1
View File
@@ -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 上,
+60 -52
View File
@@ -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 } },
],
}),
},
+39 -19
View File
@@ -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() },
});
});
}
+24 -24
View File
@@ -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 };
}
+1 -1
View File
@@ -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 {
+2 -2
View File
@@ -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)。
+9 -1
View File
@@ -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,
});
});
});
+35 -7
View File
@@ -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/);
});
});
+19 -15
View File
@@ -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);
});
});
+203
View File
@@ -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");
});
});