forked from bai/curriculum-project-hub
feat(filelib): 操作日志模块——防篡改哈希链、组合查询与 CSV 导出
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,11 @@
|
||||
(`/database/api/login-info` 是同形状的既有端点,由 `routes/teacherApp.ts` 注册。)
|
||||
- `GET /database/api/stats` —— 概览页统计。需登录 **且** 是 silo org OWNER/ADMIN。
|
||||
- `GET /database/dev-login` —— 仅开发。见下。
|
||||
- `GET /database/api/audit/meta` —— 筛选器元数据 + 调用者可见范围(all / managed)。
|
||||
- `GET /database/api/audit/logs` —— 审计日志组合查询(分页;可见性后端裁剪)。
|
||||
- `GET /database/api/audit/logs.csv` —— 按当前筛选条件导出 CSV。
|
||||
- `GET /database/api/audit/verify` —— 哈希链全量校验(仅网站管理员)。
|
||||
- `POST /database/api/audit/archive` —— 触发保留策略归档(仅网站管理员)。
|
||||
- `GET /database`、`GET /database/*`、`GET /app`、`GET /app/*` —— SPA shell /
|
||||
客户端路由 fallback(`static.ts` 的 `registerDatabaseSpa`)。
|
||||
- `GET /_filelib/*` —— 构建产物资源。SvelteKit 的 `appDir` 改名为 `_filelib`,
|
||||
@@ -27,7 +32,7 @@ SPA 页面(`filelib-web`,真 URL 路由、无 hash):
|
||||
回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
|
||||
- `/database/dashboard` —— 后台外壳(侧栏 + 权限门)。未登录跳登录页;
|
||||
**登录但非 OWNER/ADMIN 显示无权提示**。六个 tab 都是子路由:
|
||||
`/database/dashboard`(概览)、`/library`、`/users`、`/groups`、`/search`、`/settings`。
|
||||
`/database/dashboard`(概览)、`/library`、`/users`、`/groups`、`/search`(审计日志查询)、`/settings`。
|
||||
|
||||
> **注册顺序要点**:concrete 路由(`/database/config`、`/database/api/*`、
|
||||
> `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的
|
||||
@@ -88,6 +93,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0038) |
|
||||
| `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 |
|
||||
| `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app`、`/database` 两个 SPA 回退 |
|
||||
| `audit/` | **审计日志模块**(横切能力,见下) |
|
||||
| `filelib/` | 文件库领域层(见下) |
|
||||
|
||||
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/api/...")`,
|
||||
@@ -116,7 +122,7 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0038) |
|
||||
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0038) |
|
||||
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
|
||||
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
|
||||
| `filelib/audit.ts` | 文件库 → 审计模块的适配层(词表再导出 + 类型翻译) |
|
||||
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
|
||||
| `filelib/routeShared.ts` | 路由共享件(依赖装配/错误映射/请求体校验) |
|
||||
|
||||
@@ -148,8 +154,56 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
||||
- **D12**:move = 本节点 MANAGE + 目标父 EDIT+,事务 + pg 咨询锁
|
||||
- **D15**:删除只打标本节点,"任一祖先已删"即整支不可见
|
||||
- **8.1**:MANAGE 仅创建者可授/收;creator grant 不可动
|
||||
- **审计**:一切写操作在业务事务内写 AuditEntry(同事务,失败即回滚);
|
||||
文件内容写先 versionStore.commit 再审计(宁多版本,不造假审计)
|
||||
- **审计**:见下节「审计日志模块」。
|
||||
|
||||
## 审计日志模块(audit/)
|
||||
|
||||
**横切能力**,覆盖工程文件版本管理与权限管理两个子模块的全部关键写操作。
|
||||
依赖方向单向:业务 service → 审计模块。审计模块**不 import 任何 filelib 业务
|
||||
类型**,业务侧只 import `audit/index.ts` 这一个 barrel;`filelib/audit.ts` 是
|
||||
文件库这一侧的翻译层(FileLibActor → AuditActor、节点 kind → objectType)。
|
||||
整体摘除或替换成独立审计服务时,业务代码不动。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `audit/auditModel.ts` | 动作词表、记录形状、哈希链算法、保留期常量(纯逻辑) |
|
||||
| `audit/auditWriter.ts` | 落库:事务内(成功)与事务外(失败/冲突)两条路径 |
|
||||
| `audit/auditQuery.ts` | 组合查询 + 三级可见性裁剪 + CSV 导出 |
|
||||
| `audit/auditRetention.ts` | ≥180 天保留归档 + 哈希链校验 |
|
||||
| `audit/requestContext.ts` | 客户端 IP / User-Agent 采集 |
|
||||
| `audit/auditRoutes.ts` | `/database/api/audit/*` |
|
||||
| `filelib/audit.ts` | 文件库 → 审计模块的适配层(业务 service 的唯一入口) |
|
||||
|
||||
**「操作成功则日志必存在」的实现**:成功路径 `writeAudit(tx, …)` 在业务事务
|
||||
内写,同 commit 同 rollback,**刻意不吞错** —— 日志写不出来整个业务操作回滚。
|
||||
失败路径 `writeAuditOutOfBand` 走独立连接补写(业务事务已回滚,同事务写必然
|
||||
一起消失),**刻意吞错** —— 此刻业务已经失败,审计再抛只会把 409 变成 500。
|
||||
两条路径的错误取舍是反的,不要统一。
|
||||
|
||||
**冲突检测**走失败路径:`file.conflict_detected` 记起始版本(baseVersion)与
|
||||
冲突版本(currentVersion),之后调用方才抛 409。
|
||||
|
||||
**不可篡改**三道:①服务路径只 INSERT/SELECT;②迁移 SQL 里的 BEFORE UPDATE
|
||||
OR DELETE 触发器,只放行 archivedAt 单列变更且 write-once,TRUNCATE 另挡一道;
|
||||
③ sha256 哈希链(org 内按 seq 单调串联),`/audit/verify` 可全量重算,断链即
|
||||
篡改或漏写。**保留期 ≥180 天,超期只归档打标不物理删除**,下限硬编码,
|
||||
`HUB_FILELIB_AUDIT_RETENTION_DAYS` 只能调高不能调低。
|
||||
|
||||
**查询可见性三级**(全部下沉在 `auditQuery`,路由层不做第二套判权):
|
||||
- 网站管理员(silo org OWNER/ADMIN)→ 全系统
|
||||
- 持 MANAGE 的用户 → 仅自己有 MANAGE 的节点**及其子树**(靠 objectPath 的
|
||||
pathIds 前缀匹配;文件日志的 `<pathIds>:<filePath>` 天然被覆盖)
|
||||
- 其余人 → 空结果而非 403(与 D8 不泄露存在性一致)
|
||||
|
||||
Group 日志的 objectPath 是 `group:<id>`,不在节点路径空间内,因此只有网站
|
||||
管理员可见。
|
||||
|
||||
环境变量:`HUB_FILELIB_AUDIT_RETENTION_DAYS` — 保留天数,下限 180(默认)。
|
||||
|
||||
> **已知缺口**:需求点名的「项目独立权限的开启/关闭/变更」在 ADR-0030 之后
|
||||
> 已被废除(`FileLibProjectSettings` 不再被读取,见 `filelib/permission.ts`),
|
||||
> 当前没有产生该事件的代码路径。词表里的三个 `project.independent_permission.*`
|
||||
> 动作是预留位;该功能若恢复,直接调 `writeFileLibAudit` 即可。
|
||||
|
||||
## 约定(与 admin 面一致)
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 审计日志模块 —— 领域类型与哈希链(纯逻辑,不碰 IO)。
|
||||
*
|
||||
* 语义锚点:ADR-0039(横切、只追加、哈希链、≥180 天保留、三级可见性)。
|
||||
*
|
||||
* 本模块是横切能力,单向依赖:业务 service → audit 模块。审计不反向依赖任何
|
||||
* 业务 service,只认下面这组与领域无关的原语(action 词表 + 记录形状),
|
||||
* 所以可以整体摘除或替换成独立审计服务而不牵动业务代码。
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/* ---------------------------------------------------------------- 动作词表 */
|
||||
|
||||
/**
|
||||
* 审计动作词表。覆盖需求点名的全部关键写操作。
|
||||
* 值是稳定标识(入库、进查询过滤器、可 grep),改值等于改历史口径,不要动。
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
// 文件夹/项目:创建、删除、移动、重命名
|
||||
folderCreate: "folder.create",
|
||||
folderRename: "folder.rename",
|
||||
folderMove: "folder.move",
|
||||
folderDelete: "folder.delete",
|
||||
projectCreate: "project.create",
|
||||
projectRename: "project.rename",
|
||||
projectMove: "project.move",
|
||||
projectDelete: "project.delete",
|
||||
|
||||
// 归档/取消归档(= 回收站软删的还原侧 + 彻底删除)
|
||||
folderRestore: "folder.restore",
|
||||
projectRestore: "project.restore",
|
||||
nodePurge: "node.purge",
|
||||
|
||||
// 个人与 Group 权限的授予、修改、收回(含 MANAGE/EDIT/VIEW)
|
||||
permissionGrant: "permission.grant",
|
||||
permissionUpdate: "permission.update",
|
||||
permissionRevoke: "permission.revoke",
|
||||
|
||||
// 项目独立权限的开启、关闭及变更
|
||||
independentEnable: "project.independent_permission.enable",
|
||||
independentDisable: "project.independent_permission.disable",
|
||||
independentChange: "project.independent_permission.change",
|
||||
|
||||
// 文件编辑提交(含冲突合并后的提交)与冲突检测事件
|
||||
fileUpload: "file.upload",
|
||||
fileRename: "file.rename",
|
||||
fileDelete: "file.delete",
|
||||
fileCommit: "file.commit",
|
||||
fileConflictDetected: "file.conflict_detected",
|
||||
|
||||
// 导出
|
||||
exportRun: "export.run",
|
||||
|
||||
// 网站管理员高危操作(根目录创建走 folder/project.create + actorIsAdmin 标记)
|
||||
adminForceAdjust: "admin.force_adjust",
|
||||
|
||||
// Group 的创建、删除、成员增删、嵌套关系变更
|
||||
groupCreate: "group.create",
|
||||
groupUpdate: "group.update",
|
||||
groupDelete: "group.delete",
|
||||
groupRestore: "group.restore",
|
||||
groupMemberAdd: "group.member_add",
|
||||
groupMemberRemove: "group.member_remove",
|
||||
groupReparent: "group.reparent",
|
||||
|
||||
// 保留策略归档(日志自身的生命周期事件)
|
||||
retentionArchive: "audit.retention_archive",
|
||||
} as const;
|
||||
|
||||
export type AuditAction = (typeof AUDIT_ACTIONS)[keyof typeof AUDIT_ACTIONS];
|
||||
|
||||
/** 全部动作值,查询层用它校验过滤器、前端用它渲染下拉。 */
|
||||
export const ALL_AUDIT_ACTIONS: readonly string[] = Object.values(AUDIT_ACTIONS);
|
||||
|
||||
/** 动作分组 —— 仅供查询 UI 折叠展示,不参与任何判定。 */
|
||||
export const AUDIT_ACTION_GROUPS: ReadonlyArray<{
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
readonly actions: readonly string[];
|
||||
}> = [
|
||||
{
|
||||
key: "node",
|
||||
label: "文件夹/项目",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.folderCreate, AUDIT_ACTIONS.folderRename, AUDIT_ACTIONS.folderMove, AUDIT_ACTIONS.folderDelete,
|
||||
AUDIT_ACTIONS.projectCreate, AUDIT_ACTIONS.projectRename, AUDIT_ACTIONS.projectMove, AUDIT_ACTIONS.projectDelete,
|
||||
AUDIT_ACTIONS.folderRestore, AUDIT_ACTIONS.projectRestore, AUDIT_ACTIONS.nodePurge,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "permission",
|
||||
label: "权限",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.permissionGrant, AUDIT_ACTIONS.permissionUpdate, AUDIT_ACTIONS.permissionRevoke,
|
||||
AUDIT_ACTIONS.independentEnable, AUDIT_ACTIONS.independentDisable, AUDIT_ACTIONS.independentChange,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "file",
|
||||
label: "文件",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.fileUpload, AUDIT_ACTIONS.fileCommit, AUDIT_ACTIONS.fileRename,
|
||||
AUDIT_ACTIONS.fileDelete, AUDIT_ACTIONS.fileConflictDetected,
|
||||
],
|
||||
},
|
||||
{ key: "export", label: "导出", actions: [AUDIT_ACTIONS.exportRun] },
|
||||
{
|
||||
key: "group",
|
||||
label: "Group",
|
||||
actions: [
|
||||
AUDIT_ACTIONS.groupCreate, AUDIT_ACTIONS.groupUpdate, AUDIT_ACTIONS.groupDelete,
|
||||
AUDIT_ACTIONS.groupRestore, AUDIT_ACTIONS.groupMemberAdd, AUDIT_ACTIONS.groupMemberRemove,
|
||||
AUDIT_ACTIONS.groupReparent,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "admin",
|
||||
label: "管理员高危",
|
||||
actions: [AUDIT_ACTIONS.adminForceAdjust, AUDIT_ACTIONS.retentionArchive],
|
||||
},
|
||||
];
|
||||
|
||||
/* ---------------------------------------------------------------- 记录形状 */
|
||||
|
||||
export type AuditObjectType =
|
||||
| "FOLDER" | "PROJECT" | "FILE" | "GRANT" | "EXPORT_JOB" | "GROUP" | "SYSTEM";
|
||||
|
||||
export type AuditResult = "SUCCESS" | "FAILURE";
|
||||
|
||||
/** 操作人身份(从 FileLibActor 摘出的最小面,审计模块不 import 业务类型)。 */
|
||||
export interface AuditActor {
|
||||
readonly userId: string;
|
||||
/** 姓名快照。缺失时调用方回落 userId —— 审计字段要求「用户ID + 用户名」。 */
|
||||
readonly displayName?: string | undefined;
|
||||
readonly isWebsiteAdmin?: boolean | undefined;
|
||||
}
|
||||
|
||||
/** 客户端信息(可选;从请求头提取,见 requestContext)。 */
|
||||
export interface AuditClient {
|
||||
readonly ip?: string | undefined;
|
||||
readonly userAgent?: string | undefined;
|
||||
}
|
||||
|
||||
/** 一条待写入的审计记录。 */
|
||||
export interface AuditRecordInput {
|
||||
readonly action: string;
|
||||
readonly actor: AuditActor;
|
||||
readonly objectType: AuditObjectType;
|
||||
readonly objectId: string;
|
||||
readonly objectName: string;
|
||||
/** 节点 pathIds,或文件的 `<pathIds>:<filePath>`。前缀匹配即子树查询。 */
|
||||
readonly objectPath: string;
|
||||
readonly result?: AuditResult | undefined;
|
||||
readonly failureReason?: string | undefined;
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | undefined;
|
||||
readonly client?: AuditClient | undefined;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 哈希链 */
|
||||
|
||||
/**
|
||||
* 规范化载荷 → sha256(prevHash + payload)。ADR-0039:改动参与哈希的字段集合
|
||||
* 会使既有链整体失配 —— 那是需要新 ADR 与重锚流程的破坏性变更,不是重构。
|
||||
*
|
||||
* 规范化的关键是 key 排序:JSON.stringify 的键序取决于对象构造顺序,
|
||||
* 不排序的话同一条记录换个写法就算出不同哈希,校验会假报篡改。
|
||||
*/
|
||||
export function canonicalize(value: unknown): string {
|
||||
if (value === null || value === undefined) return "null";
|
||||
if (typeof value !== "object") return JSON.stringify(value) ?? "null";
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
||||
const entries = Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/** 参与哈希的字段集合。改这里等于换链算法,历史链会整体失配。 */
|
||||
export interface HashableEntry {
|
||||
readonly organizationId: string;
|
||||
readonly seq: bigint | number;
|
||||
readonly occurredAt: Date;
|
||||
readonly action: string;
|
||||
readonly result: AuditResult;
|
||||
readonly failureReason: string | null;
|
||||
readonly actorUserId: string;
|
||||
readonly actorName: string;
|
||||
readonly actorIsAdmin: boolean;
|
||||
readonly objectType: AuditObjectType;
|
||||
readonly objectId: string;
|
||||
readonly objectName: string;
|
||||
readonly objectPath: string;
|
||||
readonly beforeValue: unknown;
|
||||
readonly afterValue: unknown;
|
||||
readonly context: unknown;
|
||||
readonly clientIp: string | null;
|
||||
readonly userAgent: string | null;
|
||||
}
|
||||
|
||||
export function computeEntryHash(entry: HashableEntry, prevHash: string | null): string {
|
||||
const payload = canonicalize({
|
||||
organizationId: entry.organizationId,
|
||||
seq: entry.seq.toString(),
|
||||
occurredAt: entry.occurredAt.toISOString(),
|
||||
action: entry.action,
|
||||
result: entry.result,
|
||||
failureReason: entry.failureReason,
|
||||
actorUserId: entry.actorUserId,
|
||||
actorName: entry.actorName,
|
||||
actorIsAdmin: entry.actorIsAdmin,
|
||||
objectType: entry.objectType,
|
||||
objectId: entry.objectId,
|
||||
objectName: entry.objectName,
|
||||
objectPath: entry.objectPath,
|
||||
beforeValue: entry.beforeValue ?? null,
|
||||
afterValue: entry.afterValue ?? null,
|
||||
context: entry.context ?? null,
|
||||
clientIp: entry.clientIp,
|
||||
userAgent: entry.userAgent,
|
||||
});
|
||||
return createHash("sha256").update(`${prevHash ?? ""}\n${payload}`).digest("hex");
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 保留策略 */
|
||||
|
||||
/** ADR-0039:保留期下限 180 天。配置只能调高,不能调低。 */
|
||||
export const AUDIT_RETENTION_DAYS_MIN = 180;
|
||||
|
||||
/** 生效保留天数;配置低于下限时按下限执行(合规底线不可调低)。 */
|
||||
export function resolveRetentionDays(raw?: string | undefined): number {
|
||||
if (raw === undefined || raw.trim() === "") return AUDIT_RETENTION_DAYS_MIN;
|
||||
const parsed = Number(raw.trim());
|
||||
if (!Number.isSafeInteger(parsed) || parsed < AUDIT_RETENTION_DAYS_MIN) {
|
||||
return AUDIT_RETENTION_DAYS_MIN;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 姓名快照:缺 displayName 时回落 userId,保证字段永不为空。 */
|
||||
export function actorName(actor: AuditActor): string {
|
||||
const name = actor.displayName?.trim();
|
||||
return name === undefined || name === "" ? actor.userId : name;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 审计查询与导出。语义锚点:ADR-0039「读可见性三级」。
|
||||
*
|
||||
* 可见性三级:
|
||||
* - 网站管理员(silo org OWNER/ADMIN):全系统日志
|
||||
* - 持 MANAGE 的用户:仅自己拥有 MANAGE 的文件夹/项目**及其子树**相关日志
|
||||
* - 普通用户:默认不可查看 —— scope 解析出空可见集,返回空页而非 403
|
||||
* (不泄露"系统里有没有日志"这件事,与 D8 的不泄露存在性一致)
|
||||
*
|
||||
* 子树语义:MANAGE 挂在文件夹上时,该文件夹下所有节点的日志都算"相关"。
|
||||
* 用 objectPath 的 pathIds 前缀匹配实现 —— 与树服务的物化路径同一套编码。
|
||||
* 文件日志的 objectPath 是 `<pathIds>:<filePath>`,前缀匹配天然覆盖。
|
||||
*
|
||||
* Group 日志不挂在任何节点路径上,只有网站管理员可见。
|
||||
*/
|
||||
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import type { AuditObjectType, AuditResult } from "./auditModel.js";
|
||||
|
||||
export interface AuditQueryDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly organizationId: string;
|
||||
/** 解析用户所属组闭包(与文件库共用的 C2 口);用于 GROUP 主体的 MANAGE grant。 */
|
||||
readonly resolveMemberGroupIds: (userId: string) => Promise<readonly string[]>;
|
||||
}
|
||||
|
||||
export interface AuditQueryActor {
|
||||
readonly userId: string;
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface AuditQueryFilter {
|
||||
readonly from?: Date | undefined;
|
||||
readonly to?: Date | undefined;
|
||||
readonly actorUserId?: string | undefined;
|
||||
readonly actions?: readonly string[] | undefined;
|
||||
readonly objectType?: AuditObjectType | undefined;
|
||||
readonly objectId?: string | undefined;
|
||||
/** 对象路径前缀(pathIds);查某个子树用它。 */
|
||||
readonly objectPathPrefix?: string | undefined;
|
||||
readonly result?: AuditResult | undefined;
|
||||
/** 是否包含已归档(超保留期)记录。默认 false。 */
|
||||
readonly includeArchived?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface AuditPage {
|
||||
readonly total: number;
|
||||
readonly offset: number;
|
||||
readonly limit: number;
|
||||
readonly entries: readonly AuditEntryDto[];
|
||||
}
|
||||
|
||||
export interface AuditEntryDto {
|
||||
readonly id: string;
|
||||
readonly seq: string;
|
||||
readonly occurredAt: Date;
|
||||
readonly action: string;
|
||||
readonly result: AuditResult;
|
||||
readonly failureReason: string | null;
|
||||
readonly actorUserId: string;
|
||||
readonly actorName: string;
|
||||
readonly actorIsAdmin: boolean;
|
||||
readonly objectType: AuditObjectType;
|
||||
readonly objectId: string;
|
||||
readonly objectName: string;
|
||||
readonly objectPath: string;
|
||||
readonly beforeValue: unknown;
|
||||
readonly afterValue: unknown;
|
||||
readonly context: unknown;
|
||||
readonly clientIp: string | null;
|
||||
readonly userAgent: string | null;
|
||||
readonly archivedAt: Date | null;
|
||||
}
|
||||
|
||||
export const AUDIT_PAGE_SIZE_MAX = 200;
|
||||
export const AUDIT_EXPORT_ROWS_MAX = 10_000;
|
||||
|
||||
/**
|
||||
* 解析调用者可见的 pathIds 前缀集合。
|
||||
* 返回 null = 全系统可见(管理员);返回 [] = 一条都看不到(普通用户)。
|
||||
*/
|
||||
async function visiblePathPrefixes(
|
||||
deps: AuditQueryDeps,
|
||||
actor: AuditQueryActor,
|
||||
): Promise<readonly string[] | null> {
|
||||
if (actor.isWebsiteAdmin) return null;
|
||||
|
||||
const groupIds = await deps.resolveMemberGroupIds(actor.userId);
|
||||
const grants = await deps.prisma.fileLibGrant.findMany({
|
||||
where: {
|
||||
organizationId: deps.organizationId,
|
||||
revokedAt: null,
|
||||
role: "MANAGE",
|
||||
OR: [
|
||||
{ principalType: "USER", principalId: actor.userId },
|
||||
...(groupIds.length > 0
|
||||
? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select: { nodeId: true },
|
||||
});
|
||||
if (grants.length === 0) return [];
|
||||
|
||||
// 已删节点也要能查 —— 删除本身正是最该被追溯的操作。
|
||||
const nodes = await deps.prisma.fileLibNode.findMany({
|
||||
where: { organizationId: deps.organizationId, id: { in: grants.map((g) => g.nodeId) } },
|
||||
select: { pathIds: true },
|
||||
});
|
||||
return [...new Set(nodes.map((n) => n.pathIds))];
|
||||
}
|
||||
|
||||
/** 把过滤器 + 可见性合成 Prisma where。可见集为空时返回 null(调用方短路成空页)。 */
|
||||
async function buildWhere(
|
||||
deps: AuditQueryDeps,
|
||||
actor: AuditQueryActor,
|
||||
filter: AuditQueryFilter,
|
||||
): Promise<Prisma.FileLibAuditLogWhereInput | null> {
|
||||
const prefixes = await visiblePathPrefixes(deps, actor);
|
||||
if (prefixes !== null && prefixes.length === 0) return null;
|
||||
|
||||
const where: Prisma.FileLibAuditLogWhereInput = { organizationId: deps.organizationId };
|
||||
|
||||
if (filter.from !== undefined || filter.to !== undefined) {
|
||||
where.occurredAt = {
|
||||
...(filter.from !== undefined ? { gte: filter.from } : {}),
|
||||
...(filter.to !== undefined ? { lte: filter.to } : {}),
|
||||
};
|
||||
}
|
||||
if (filter.actorUserId !== undefined) where.actorUserId = filter.actorUserId;
|
||||
if (filter.actions !== undefined && filter.actions.length > 0) {
|
||||
where.action = { in: [...filter.actions] };
|
||||
}
|
||||
if (filter.objectType !== undefined) where.objectType = filter.objectType;
|
||||
if (filter.objectId !== undefined) where.objectId = filter.objectId;
|
||||
if (filter.result !== undefined) where.result = filter.result;
|
||||
if (filter.includeArchived !== true) where.archivedAt = null;
|
||||
|
||||
const pathClauses: Prisma.FileLibAuditLogWhereInput[] = [];
|
||||
if (filter.objectPathPrefix !== undefined && filter.objectPathPrefix !== "") {
|
||||
pathClauses.push({ objectPath: { startsWith: filter.objectPathPrefix } });
|
||||
}
|
||||
if (prefixes !== null) {
|
||||
// 自身 + 子树:pathIds 精确等于,或以 "<pathIds>/" / "<pathIds>:" 开头。
|
||||
pathClauses.push({
|
||||
OR: prefixes.flatMap((p) => [
|
||||
{ objectPath: p },
|
||||
{ objectPath: { startsWith: `${p}/` } },
|
||||
{ objectPath: { startsWith: `${p}:` } },
|
||||
]),
|
||||
});
|
||||
}
|
||||
if (pathClauses.length > 0) where.AND = pathClauses;
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/** 组合查询(时间范围/操作人/操作类型/操作对象)。按时间倒序分页。 */
|
||||
export async function queryAuditLogs(
|
||||
deps: AuditQueryDeps,
|
||||
actor: AuditQueryActor,
|
||||
filter: AuditQueryFilter,
|
||||
page: { readonly offset?: number | undefined; readonly limit?: number | undefined } = {},
|
||||
): Promise<AuditPage> {
|
||||
const offset = Math.max(0, page.offset ?? 0);
|
||||
const limit = Math.min(Math.max(1, page.limit ?? 50), AUDIT_PAGE_SIZE_MAX);
|
||||
|
||||
const where = await buildWhere(deps, actor, filter);
|
||||
if (where === null) return { total: 0, offset, limit, entries: [] };
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
deps.prisma.fileLibAuditLog.count({ where }),
|
||||
deps.prisma.fileLibAuditLog.findMany({
|
||||
where,
|
||||
orderBy: [{ occurredAt: "desc" }, { seq: "desc" }],
|
||||
skip: offset,
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
return { total, offset, limit, entries: rows.map(toDto) };
|
||||
}
|
||||
|
||||
/** 导出用:同一过滤器下的全量拉取(上限 AUDIT_EXPORT_ROWS_MAX)。 */
|
||||
export async function collectAuditLogsForExport(
|
||||
deps: AuditQueryDeps,
|
||||
actor: AuditQueryActor,
|
||||
filter: AuditQueryFilter,
|
||||
): Promise<readonly AuditEntryDto[]> {
|
||||
const where = await buildWhere(deps, actor, filter);
|
||||
if (where === null) return [];
|
||||
const rows = await deps.prisma.fileLibAuditLog.findMany({
|
||||
where,
|
||||
orderBy: [{ occurredAt: "desc" }, { seq: "desc" }],
|
||||
take: AUDIT_EXPORT_ROWS_MAX,
|
||||
});
|
||||
return rows.map(toDto);
|
||||
}
|
||||
|
||||
function toDto(row: {
|
||||
id: string; seq: bigint; occurredAt: Date; action: string; result: string;
|
||||
failureReason: string | null; actorUserId: string; actorName: string; actorIsAdmin: boolean;
|
||||
objectType: string; objectId: string; objectName: string; objectPath: string;
|
||||
beforeValue: unknown; afterValue: unknown; context: unknown;
|
||||
clientIp: string | null; userAgent: string | null; archivedAt: Date | null;
|
||||
}): AuditEntryDto {
|
||||
return {
|
||||
id: row.id,
|
||||
// BigInt 不能进 JSON.stringify,出库即转字符串。
|
||||
seq: row.seq.toString(),
|
||||
occurredAt: row.occurredAt,
|
||||
action: row.action,
|
||||
result: row.result as AuditResult,
|
||||
failureReason: row.failureReason,
|
||||
actorUserId: row.actorUserId,
|
||||
actorName: row.actorName,
|
||||
actorIsAdmin: row.actorIsAdmin,
|
||||
objectType: row.objectType as AuditObjectType,
|
||||
objectId: row.objectId,
|
||||
objectName: row.objectName,
|
||||
objectPath: row.objectPath,
|
||||
beforeValue: row.beforeValue ?? null,
|
||||
afterValue: row.afterValue ?? null,
|
||||
context: row.context ?? null,
|
||||
clientIp: row.clientIp,
|
||||
userAgent: row.userAgent,
|
||||
archivedAt: row.archivedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- CSV 导出 */
|
||||
|
||||
const CSV_COLUMNS: ReadonlyArray<{ readonly key: string; readonly pick: (e: AuditEntryDto) => string }> = [
|
||||
{ key: "日志ID", pick: (e) => e.id },
|
||||
{ key: "序号", pick: (e) => e.seq },
|
||||
{ key: "操作时间", pick: (e) => e.occurredAt.toISOString() },
|
||||
{ key: "操作人ID", pick: (e) => e.actorUserId },
|
||||
{ key: "操作人", pick: (e) => e.actorName },
|
||||
{ key: "管理员身份", pick: (e) => (e.actorIsAdmin ? "是" : "否") },
|
||||
{ key: "操作类型", pick: (e) => e.action },
|
||||
{ key: "对象类型", pick: (e) => e.objectType },
|
||||
{ key: "对象ID", pick: (e) => e.objectId },
|
||||
{ key: "对象名称", pick: (e) => e.objectName },
|
||||
{ key: "对象路径", pick: (e) => e.objectPath },
|
||||
{ key: "操作结果", pick: (e) => (e.result === "SUCCESS" ? "成功" : "失败") },
|
||||
{ key: "失败原因", pick: (e) => e.failureReason ?? "" },
|
||||
{ key: "操作前值", pick: (e) => stringifyJson(e.beforeValue) },
|
||||
{ key: "操作后值", pick: (e) => stringifyJson(e.afterValue) },
|
||||
{ key: "附加上下文", pick: (e) => stringifyJson(e.context) },
|
||||
{ key: "客户端IP", pick: (e) => e.clientIp ?? "" },
|
||||
{ key: "User-Agent", pick: (e) => e.userAgent ?? "" },
|
||||
];
|
||||
|
||||
function stringifyJson(value: unknown): string {
|
||||
if (value === null || value === undefined) return "";
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* CSV 转义。前导 `=`/`+`/`-`/`@` 要加单引号前缀 —— 否则 Excel 会把
|
||||
* 用户可控的对象名当公式执行(CSV 注入)。审计导出正是给人用 Excel 打开的。
|
||||
*/
|
||||
function csvCell(raw: string): string {
|
||||
const value = /^[=+\-@]/.test(raw) ? `'${raw}` : raw;
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
|
||||
export function toCsv(entries: readonly AuditEntryDto[]): string {
|
||||
const header = CSV_COLUMNS.map((c) => csvCell(c.key)).join(",");
|
||||
const lines = entries.map((e) => CSV_COLUMNS.map((c) => csvCell(c.pick(e))).join(","));
|
||||
// BOM:Excel 靠它认 UTF-8,否则中文全乱码。
|
||||
return `${[header, ...lines].join("\r\n")}\r\n`;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 保留策略与防篡改校验。
|
||||
*
|
||||
* 保留:默认 ≥180 天(下限硬编码在 auditModel,配置只能调高不能调低)。
|
||||
* 超期记录**只归档打标,不物理删除** —— DB 触发器也不允许 DELETE。
|
||||
* 归档记录默认不出现在查询里,但显式 includeArchived 仍可读出来。
|
||||
*
|
||||
* 校验:重算整条哈希链。任何一条被改写(绕过触发器、直连 DB、恢复了篡改过的
|
||||
* 备份)都会在该条及其之后全部失配 —— 报告首个断点即可定位。
|
||||
*/
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
AUDIT_ACTIONS,
|
||||
computeEntryHash,
|
||||
resolveRetentionDays,
|
||||
type AuditResult,
|
||||
} from "./auditModel.js";
|
||||
import { writeAudit } from "./auditWriter.js";
|
||||
|
||||
export interface AuditMaintenanceDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly organizationId: string;
|
||||
/** 保留天数;不传读 HUB_FILELIB_AUDIT_RETENTION_DAYS,再回落 180。 */
|
||||
readonly retentionDays?: number | undefined;
|
||||
}
|
||||
|
||||
function retentionDaysOf(deps: AuditMaintenanceDeps): number {
|
||||
return deps.retentionDays ?? resolveRetentionDays(process.env["HUB_FILELIB_AUDIT_RETENTION_DAYS"]);
|
||||
}
|
||||
|
||||
export interface ArchiveResult {
|
||||
readonly archived: number;
|
||||
readonly cutoff: Date;
|
||||
readonly retentionDays: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档超过保留期的记录(打 archivedAt 标记)。幂等:已归档的不会被重复处理
|
||||
* (触发器也会拒绝二次改写 archivedAt)。
|
||||
*
|
||||
* 归档动作本身也写一条审计 —— 谁在什么时候把哪一批日志移出了默认视图,
|
||||
* 这件事同样需要可追溯。
|
||||
*/
|
||||
export async function archiveExpiredAuditLogs(
|
||||
deps: AuditMaintenanceDeps,
|
||||
actor: { readonly userId: string; readonly displayName?: string | undefined },
|
||||
): Promise<ArchiveResult> {
|
||||
const retentionDays = retentionDaysOf(deps);
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const { count } = await deps.prisma.fileLibAuditLog.updateMany({
|
||||
where: {
|
||||
organizationId: deps.organizationId,
|
||||
archivedAt: null,
|
||||
occurredAt: { lt: cutoff },
|
||||
},
|
||||
data: { archivedAt: new Date() },
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
await writeAudit(tx, { organizationId: deps.organizationId }, {
|
||||
action: AUDIT_ACTIONS.retentionArchive,
|
||||
actor: { userId: actor.userId, displayName: actor.displayName, isWebsiteAdmin: true },
|
||||
objectType: "SYSTEM",
|
||||
objectId: "audit-retention",
|
||||
objectName: "审计日志保留策略",
|
||||
objectPath: "/",
|
||||
context: { retentionDays, cutoff: cutoff.toISOString(), archived: count },
|
||||
});
|
||||
});
|
||||
}
|
||||
return { archived: count, cutoff, retentionDays };
|
||||
}
|
||||
|
||||
export interface ChainBreak {
|
||||
readonly id: string;
|
||||
readonly seq: string;
|
||||
readonly occurredAt: Date;
|
||||
readonly reason: "hash_mismatch" | "prev_hash_mismatch" | "seq_gap";
|
||||
}
|
||||
|
||||
export interface VerifyResult {
|
||||
readonly checked: number;
|
||||
readonly ok: boolean;
|
||||
readonly breaks: readonly ChainBreak[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 全链校验。按 seq 升序流式重算(含已归档记录 —— 归档不出链)。
|
||||
* 分批读,避免大表一次性载入内存。
|
||||
*/
|
||||
export async function verifyAuditChain(
|
||||
deps: Pick<AuditMaintenanceDeps, "prisma" | "organizationId">,
|
||||
options: { readonly batchSize?: number | undefined } = {},
|
||||
): Promise<VerifyResult> {
|
||||
const batchSize = options.batchSize ?? 500;
|
||||
const breaks: ChainBreak[] = [];
|
||||
let checked = 0;
|
||||
let cursorSeq = 0n;
|
||||
let expectedPrevHash: string | null = null;
|
||||
let expectedSeq = 1n;
|
||||
|
||||
for (;;) {
|
||||
const rows = await deps.prisma.fileLibAuditLog.findMany({
|
||||
where: { organizationId: deps.organizationId, seq: { gt: cursorSeq } },
|
||||
orderBy: { seq: "asc" },
|
||||
take: batchSize,
|
||||
});
|
||||
if (rows.length === 0) break;
|
||||
|
||||
for (const row of rows) {
|
||||
checked += 1;
|
||||
if (row.seq !== expectedSeq) {
|
||||
// 序号跳号 = 有记录被物理删除,或写入时漏号。
|
||||
breaks.push({ id: row.id, seq: row.seq.toString(), occurredAt: row.occurredAt, reason: "seq_gap" });
|
||||
expectedSeq = row.seq;
|
||||
}
|
||||
if (row.prevHash !== expectedPrevHash) {
|
||||
breaks.push({ id: row.id, seq: row.seq.toString(), occurredAt: row.occurredAt, reason: "prev_hash_mismatch" });
|
||||
}
|
||||
const recomputed = computeEntryHash(
|
||||
{
|
||||
organizationId: row.organizationId,
|
||||
seq: row.seq,
|
||||
occurredAt: row.occurredAt,
|
||||
action: row.action,
|
||||
result: row.result as AuditResult,
|
||||
failureReason: row.failureReason,
|
||||
actorUserId: row.actorUserId,
|
||||
actorName: row.actorName,
|
||||
actorIsAdmin: row.actorIsAdmin,
|
||||
objectType: row.objectType,
|
||||
objectId: row.objectId,
|
||||
objectName: row.objectName,
|
||||
objectPath: row.objectPath,
|
||||
beforeValue: row.beforeValue ?? null,
|
||||
afterValue: row.afterValue ?? null,
|
||||
context: row.context ?? null,
|
||||
clientIp: row.clientIp,
|
||||
userAgent: row.userAgent,
|
||||
},
|
||||
row.prevHash,
|
||||
);
|
||||
if (recomputed !== row.entryHash) {
|
||||
breaks.push({ id: row.id, seq: row.seq.toString(), occurredAt: row.occurredAt, reason: "hash_mismatch" });
|
||||
}
|
||||
expectedPrevHash = row.entryHash;
|
||||
expectedSeq = row.seq + 1n;
|
||||
cursorSeq = row.seq;
|
||||
}
|
||||
}
|
||||
|
||||
return { checked, ok: breaks.length === 0, breaks };
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* /database/api/audit/* —— 审计查询、导出、维护端点。
|
||||
*
|
||||
* 约定沿用 database 面:绝对路径、guard 前置 fail closed、查询 scope 到 silo org。
|
||||
* 可见性判定全部下沉到 auditQuery(三级:管理员 / MANAGE 持有者 / 其他),
|
||||
* 路由层不做第二套权限逻辑 —— 两处判权迟早会漂。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
ALL_AUDIT_ACTIONS,
|
||||
AUDIT_ACTION_GROUPS,
|
||||
AUDIT_EXPORT_ROWS_MAX,
|
||||
archiveExpiredAuditLogs,
|
||||
collectAuditLogsForExport,
|
||||
queryAuditLogs,
|
||||
toCsv,
|
||||
verifyAuditChain,
|
||||
type AuditObjectType,
|
||||
type AuditQueryActor,
|
||||
type AuditQueryFilter,
|
||||
type AuditResult,
|
||||
} from "./index.js";
|
||||
|
||||
export interface AuditRouteDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly organizationId: string;
|
||||
readonly resolveMemberGroupIds: (userId: string) => Promise<readonly string[]>;
|
||||
/** 复用文件库门禁:返回 null 时响应已发出。 */
|
||||
readonly actorOrNull: (
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
) => Promise<AuditQueryActor | null>;
|
||||
}
|
||||
|
||||
const OBJECT_TYPES: readonly AuditObjectType[] = [
|
||||
"FOLDER", "PROJECT", "FILE", "GRANT", "EXPORT_JOB", "GROUP", "SYSTEM",
|
||||
];
|
||||
|
||||
class BadRequest extends Error {
|
||||
constructor(readonly code: string, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDate(raw: string | undefined, field: string): Date | undefined {
|
||||
if (raw === undefined || raw === "") return undefined;
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequest("invalid_request", `${field} must be an ISO-8601 datetime`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/** 查询串 → 过滤器。未知的操作类型直接拒绝,免得静默返回空集让人以为"没有日志"。 */
|
||||
function parseFilter(query: Record<string, string | undefined>): AuditQueryFilter {
|
||||
const from = parseDate(query["from"], "from");
|
||||
const to = parseDate(query["to"], "to");
|
||||
if (from !== undefined && to !== undefined && from > to) {
|
||||
throw new BadRequest("invalid_request", "from must not be after to");
|
||||
}
|
||||
|
||||
const actionsRaw = query["actions"];
|
||||
let actions: string[] | undefined;
|
||||
if (actionsRaw !== undefined && actionsRaw !== "") {
|
||||
actions = actionsRaw.split(",").map((a) => a.trim()).filter((a) => a !== "");
|
||||
const unknown = actions.filter((a) => !ALL_AUDIT_ACTIONS.includes(a));
|
||||
if (unknown.length > 0) {
|
||||
throw new BadRequest("invalid_request", `unknown action(s): ${unknown.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const objectTypeRaw = query["objectType"];
|
||||
let objectType: AuditObjectType | undefined;
|
||||
if (objectTypeRaw !== undefined && objectTypeRaw !== "") {
|
||||
if (!OBJECT_TYPES.includes(objectTypeRaw as AuditObjectType)) {
|
||||
throw new BadRequest("invalid_request", `unknown objectType: ${objectTypeRaw}`);
|
||||
}
|
||||
objectType = objectTypeRaw as AuditObjectType;
|
||||
}
|
||||
|
||||
const resultRaw = query["result"];
|
||||
let result: AuditResult | undefined;
|
||||
if (resultRaw !== undefined && resultRaw !== "") {
|
||||
if (resultRaw !== "SUCCESS" && resultRaw !== "FAILURE") {
|
||||
throw new BadRequest("invalid_request", "result must be SUCCESS or FAILURE");
|
||||
}
|
||||
result = resultRaw;
|
||||
}
|
||||
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
actorUserId: query["actorUserId"] || undefined,
|
||||
actions,
|
||||
objectType,
|
||||
objectId: query["objectId"] || undefined,
|
||||
objectPathPrefix: query["objectPath"] || undefined,
|
||||
result,
|
||||
includeArchived: query["includeArchived"] === "true",
|
||||
};
|
||||
}
|
||||
|
||||
function parseInt10(raw: string | undefined, fallback: number): number {
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
const parsed = Number(raw);
|
||||
return Number.isSafeInteger(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
async function sendAuditError(reply: FastifyReply, error: unknown): Promise<void> {
|
||||
if (error instanceof BadRequest) {
|
||||
await reply.status(400).send({ error: { code: error.code, message: error.message } });
|
||||
return;
|
||||
}
|
||||
reply.log.error({ err: error }, "audit route: unexpected error");
|
||||
await reply.status(500).send({ error: { code: "internal", message: "internal error" } });
|
||||
}
|
||||
|
||||
export async function registerAuditRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: AuditRouteDeps,
|
||||
): Promise<void> {
|
||||
const queryDeps = {
|
||||
prisma: deps.prisma,
|
||||
organizationId: deps.organizationId,
|
||||
resolveMemberGroupIds: deps.resolveMemberGroupIds,
|
||||
};
|
||||
|
||||
/** 前端渲染筛选器用的元数据(动作分组、对象类型)。登录即可读,不含日志数据。 */
|
||||
app.get("/database/api/audit/meta", async (request, reply) => {
|
||||
const actor = await deps.actorOrNull(request, reply);
|
||||
if (actor === null) return reply;
|
||||
return {
|
||||
actionGroups: AUDIT_ACTION_GROUPS,
|
||||
objectTypes: OBJECT_TYPES,
|
||||
exportRowLimit: AUDIT_EXPORT_ROWS_MAX,
|
||||
// 前端据此提示"你看到的是自己管辖范围内的日志"。
|
||||
scope: actor.isWebsiteAdmin ? "all" : "managed",
|
||||
};
|
||||
});
|
||||
|
||||
/** 组合查询。可见性在服务层裁剪:普通用户得到空页而非 403(不泄露存在性)。 */
|
||||
app.get("/database/api/audit/logs", async (request, reply) => {
|
||||
const actor = await deps.actorOrNull(request, reply);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const query = request.query as Record<string, string | undefined>;
|
||||
return await queryAuditLogs(queryDeps, actor, parseFilter(query), {
|
||||
offset: parseInt10(query["offset"], 0),
|
||||
limit: parseInt10(query["limit"], 50),
|
||||
});
|
||||
} catch (error) {
|
||||
return sendAuditError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** 导出当前过滤器命中的结果(CSV,上限 AUDIT_EXPORT_ROWS_MAX 行)。 */
|
||||
app.get("/database/api/audit/logs.csv", async (request, reply) => {
|
||||
const actor = await deps.actorOrNull(request, reply);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const entries = await collectAuditLogsForExport(
|
||||
queryDeps,
|
||||
actor,
|
||||
parseFilter(request.query as Record<string, string | undefined>),
|
||||
);
|
||||
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "");
|
||||
return reply
|
||||
.header("Content-Type", "text/csv; charset=utf-8")
|
||||
.header("Content-Disposition", `attachment; filename="audit-${stamp}.csv"`)
|
||||
.send(toCsv(entries));
|
||||
} catch (error) {
|
||||
return sendAuditError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** 哈希链校验(防篡改的"定期校验")。仅网站管理员。 */
|
||||
app.get("/database/api/audit/verify", async (request, reply) => {
|
||||
const actor = await deps.actorOrNull(request, reply);
|
||||
if (actor === null) return reply;
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } });
|
||||
}
|
||||
try {
|
||||
return await verifyAuditChain(queryDeps);
|
||||
} catch (error) {
|
||||
return sendAuditError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/** 手动触发保留策略归档。仅网站管理员;归档本身也留痕。 */
|
||||
app.post("/database/api/audit/archive", async (request, reply) => {
|
||||
const actor = await deps.actorOrNull(request, reply);
|
||||
if (actor === null) return reply;
|
||||
if (!actor.isWebsiteAdmin) {
|
||||
return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } });
|
||||
}
|
||||
try {
|
||||
return await archiveExpiredAuditLogs(queryDeps, { userId: actor.userId });
|
||||
} catch (error) {
|
||||
return sendAuditError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* 审计写入器 —— 唯一的落库入口。语义锚点:ADR-0039。
|
||||
*
|
||||
* 两条路径,对应两种保证(ADR-0039「两条写路径的错误取舍相反」):
|
||||
*
|
||||
* 1. `writeAudit(tx, …)` —— 成功操作。在**业务事务内**写,同 commit 同 rollback。
|
||||
* 这就是「操作成功则日志必存在」:日志写不出来,业务一起回滚。刻意不吞错。
|
||||
*
|
||||
* 2. `writeAuditOutOfBand(prisma, …)` —— 失败操作与冲突检测事件。业务事务已
|
||||
* 回滚(或根本没开),必须用独立连接补写,否则「操作结果=失败及失败原因」
|
||||
* 永远记不下来。这条路径**吞错**:审计写失败不能把已经失败的请求变成 500,
|
||||
* 只记 logger。两条路径的取舍是反的,不要统一。
|
||||
*
|
||||
* 序号与哈希链:每条记录取 org 内 max(seq)+1,prevHash 取该条的 entryHash。
|
||||
* 并发下两个事务可能读到同一个 max —— 由 `@@unique([organizationId, seq])`
|
||||
* 挡住,冲突方重试(见 SEQ_RETRIES)。宁可重试也不要链上出现重号。
|
||||
*/
|
||||
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
actorName,
|
||||
computeEntryHash,
|
||||
type AuditRecordInput,
|
||||
type AuditResult,
|
||||
} from "./auditModel.js";
|
||||
|
||||
/** 只需要能跑事务/查表的最小面 —— tx 与 PrismaClient 都满足。 */
|
||||
type AnyClient = Prisma.TransactionClient | PrismaClient;
|
||||
|
||||
/** seq 抢号失败的重试次数。唯一索引冲突是正常并发,不是错误。 */
|
||||
const SEQ_RETRIES = 5;
|
||||
|
||||
export interface AuditWriteDeps {
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
/** 可空 Json 列:create 时省略即落 SQL NULL,不需要 Prisma.DbNull。 */
|
||||
function toJson(value: unknown): Prisma.InputJsonValue | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return value as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
async function insertOnce(
|
||||
client: AnyClient,
|
||||
deps: AuditWriteDeps,
|
||||
input: AuditRecordInput,
|
||||
): Promise<void> {
|
||||
const previous = await client.fileLibAuditLog.findFirst({
|
||||
where: { organizationId: deps.organizationId },
|
||||
orderBy: { seq: "desc" },
|
||||
select: { seq: true, entryHash: true },
|
||||
});
|
||||
const seq = (previous?.seq ?? 0n) + 1n;
|
||||
const prevHash = previous?.entryHash ?? null;
|
||||
|
||||
const occurredAt = new Date();
|
||||
const result: AuditResult = input.result ?? "SUCCESS";
|
||||
const hashable = {
|
||||
organizationId: deps.organizationId,
|
||||
seq,
|
||||
occurredAt,
|
||||
action: input.action,
|
||||
result,
|
||||
failureReason: input.failureReason ?? null,
|
||||
actorUserId: input.actor.userId,
|
||||
actorName: actorName(input.actor),
|
||||
actorIsAdmin: input.actor.isWebsiteAdmin ?? false,
|
||||
objectType: input.objectType,
|
||||
objectId: input.objectId,
|
||||
objectName: input.objectName,
|
||||
objectPath: input.objectPath,
|
||||
beforeValue: input.before ?? null,
|
||||
afterValue: input.after ?? null,
|
||||
context: input.context ?? null,
|
||||
clientIp: input.client?.ip ?? null,
|
||||
userAgent: input.client?.userAgent ?? null,
|
||||
};
|
||||
|
||||
await client.fileLibAuditLog.create({
|
||||
data: {
|
||||
organizationId: hashable.organizationId,
|
||||
seq,
|
||||
occurredAt,
|
||||
action: hashable.action,
|
||||
result: hashable.result,
|
||||
failureReason: hashable.failureReason,
|
||||
actorUserId: hashable.actorUserId,
|
||||
actorName: hashable.actorName,
|
||||
actorIsAdmin: hashable.actorIsAdmin,
|
||||
objectType: hashable.objectType,
|
||||
objectId: hashable.objectId,
|
||||
objectName: hashable.objectName,
|
||||
objectPath: hashable.objectPath,
|
||||
...(toJson(hashable.beforeValue) !== undefined ? { beforeValue: toJson(hashable.beforeValue)! } : {}),
|
||||
...(toJson(hashable.afterValue) !== undefined ? { afterValue: toJson(hashable.afterValue)! } : {}),
|
||||
...(toJson(hashable.context) !== undefined ? { context: toJson(hashable.context)! } : {}),
|
||||
clientIp: hashable.clientIp,
|
||||
userAgent: hashable.userAgent,
|
||||
entryHash: computeEntryHash(hashable, prevHash),
|
||||
prevHash,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isSeqConflict(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as { code?: unknown }).code === "P2002"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径 1:业务事务内写审计。**不吞错** —— 抛出即业务回滚,
|
||||
* 这正是「操作成功则日志必存在」的实现方式。
|
||||
*
|
||||
* 注意:在事务内 seq 冲突无法靠重试解决(冲突后整个事务已中止),
|
||||
* 直接抛给调用方由整体重试。实践中同 org 并发写树的概率很低。
|
||||
*/
|
||||
export async function writeAudit(
|
||||
tx: Prisma.TransactionClient,
|
||||
deps: AuditWriteDeps,
|
||||
input: AuditRecordInput,
|
||||
): Promise<void> {
|
||||
await insertOnce(tx, deps, input);
|
||||
}
|
||||
|
||||
/** 一次写多条(如创建节点时附带的初始授权),顺序即链上顺序。 */
|
||||
export async function writeAuditMany(
|
||||
tx: Prisma.TransactionClient,
|
||||
deps: AuditWriteDeps,
|
||||
inputs: readonly AuditRecordInput[],
|
||||
): Promise<void> {
|
||||
for (const input of inputs) {
|
||||
await insertOnce(tx, deps, input);
|
||||
}
|
||||
}
|
||||
|
||||
export interface OutOfBandDeps extends AuditWriteDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
/** 写失败时的告警出口(Fastify logger);缺省吞掉。 */
|
||||
readonly onError?: ((error: unknown) => void) | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路径 2:事务外补写(失败结果、冲突检测事件)。
|
||||
* **吞错**:此刻业务已经失败,再抛只会把 409/403 变成 500,让用户更迷惑。
|
||||
*/
|
||||
export async function writeAuditOutOfBand(
|
||||
deps: OutOfBandDeps,
|
||||
input: AuditRecordInput,
|
||||
): Promise<void> {
|
||||
for (let attempt = 0; attempt < SEQ_RETRIES; attempt += 1) {
|
||||
try {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
await insertOnce(tx, { organizationId: deps.organizationId }, input);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (isSeqConflict(error) && attempt < SEQ_RETRIES - 1) continue;
|
||||
deps.onError?.(error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 审计日志模块(横切能力)对外唯一入口。
|
||||
*
|
||||
* 依赖方向单向:业务 → 审计。本模块不 import 任何 filelib 业务类型,
|
||||
* 业务侧也只 import 这个 barrel,不深入子文件 —— 换实现(比如日后拆成
|
||||
* 独立审计服务)只需重写本目录,业务代码不动。
|
||||
*
|
||||
* 结构:
|
||||
* | auditModel.ts | 动作词表、记录形状、哈希链、保留期常量(纯逻辑) |
|
||||
* | auditWriter.ts | 落库:事务内(成功)与事务外(失败/冲突)两条路径 |
|
||||
* | auditQuery.ts | 组合查询 + 三级可见性 + CSV 导出 |
|
||||
* | auditRetention.ts | ≥180 天保留归档 + 哈希链校验 |
|
||||
* | requestContext.ts | 客户端 IP / User-Agent 采集 |
|
||||
* | auditRoutes.ts | /database/api/audit/* HTTP 面 |
|
||||
*/
|
||||
|
||||
export {
|
||||
AUDIT_ACTIONS,
|
||||
ALL_AUDIT_ACTIONS,
|
||||
AUDIT_ACTION_GROUPS,
|
||||
AUDIT_RETENTION_DAYS_MIN,
|
||||
actorName,
|
||||
canonicalize,
|
||||
computeEntryHash,
|
||||
resolveRetentionDays,
|
||||
type AuditAction,
|
||||
type AuditActor,
|
||||
type AuditClient,
|
||||
type AuditObjectType,
|
||||
type AuditRecordInput,
|
||||
type AuditResult,
|
||||
} from "./auditModel.js";
|
||||
|
||||
export {
|
||||
writeAudit,
|
||||
writeAuditMany,
|
||||
writeAuditOutOfBand,
|
||||
type AuditWriteDeps,
|
||||
type OutOfBandDeps,
|
||||
} from "./auditWriter.js";
|
||||
|
||||
export {
|
||||
AUDIT_EXPORT_ROWS_MAX,
|
||||
AUDIT_PAGE_SIZE_MAX,
|
||||
collectAuditLogsForExport,
|
||||
queryAuditLogs,
|
||||
toCsv,
|
||||
type AuditEntryDto,
|
||||
type AuditPage,
|
||||
type AuditQueryActor,
|
||||
type AuditQueryDeps,
|
||||
type AuditQueryFilter,
|
||||
} from "./auditQuery.js";
|
||||
|
||||
export {
|
||||
archiveExpiredAuditLogs,
|
||||
verifyAuditChain,
|
||||
type ArchiveResult,
|
||||
type ChainBreak,
|
||||
type VerifyResult,
|
||||
} from "./auditRetention.js";
|
||||
|
||||
export { requestClient, type RequestLike } from "./requestContext.js";
|
||||
|
||||
export { registerAuditRoutes, type AuditRouteDeps } from "./auditRoutes.js";
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 客户端信息采集(IP / User-Agent)。
|
||||
*
|
||||
* IP 取值:优先 `X-Forwarded-For` 的**最左**一跳(真实客户端),这要求
|
||||
* Fastify 的 `trustProxy` 已按部署形态配置好;否则回落 `request.ip`。
|
||||
* 采不到就是 null —— 需求里客户端信息是可选字段,不能因为它写不成而阻断业务。
|
||||
*
|
||||
* 这一层刻意不 import 任何业务类型,只认 Fastify 请求的最小面,
|
||||
* 让审计模块可以整体搬走。
|
||||
*/
|
||||
|
||||
import type { AuditClient } from "./auditModel.js";
|
||||
|
||||
/** 只依赖这几项 —— FastifyRequest 天然满足,测试可以传字面量。 */
|
||||
export interface RequestLike {
|
||||
readonly ip?: string | undefined;
|
||||
readonly headers: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
/** UA 过长会撑爆行;审计只需要能辨识客户端,截断即可。 */
|
||||
const USER_AGENT_MAX = 512;
|
||||
const IP_MAX = 64;
|
||||
|
||||
function firstHeader(value: string | string[] | undefined): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export function requestClient(request: RequestLike | undefined): AuditClient | undefined {
|
||||
if (request === undefined) return undefined;
|
||||
|
||||
const forwarded = firstHeader(request.headers["x-forwarded-for"]);
|
||||
const rawIp = forwarded?.split(",")[0]?.trim() || request.ip;
|
||||
const ip = rawIp === undefined || rawIp === "" ? undefined : rawIp.slice(0, IP_MAX);
|
||||
|
||||
const rawUa = firstHeader(request.headers["user-agent"]);
|
||||
const userAgent = rawUa === undefined || rawUa === "" ? undefined : rawUa.slice(0, USER_AGENT_MAX);
|
||||
|
||||
if (ip === undefined && userAgent === undefined) return undefined;
|
||||
return { ip, userAgent };
|
||||
}
|
||||
@@ -1,83 +1,99 @@
|
||||
/**
|
||||
* 文件库审计 sink(契约 C3 的入驻适配)。
|
||||
* 文件库 → 审计模块的适配层。
|
||||
*
|
||||
* 契约原文:本地 outbox 表(与业务同事务)→ 中继 POST 到独立审计服务。
|
||||
* 入驻 hub 后的适配:审计同事 = 本库 AuditEntry,与业务写在同一 Prisma 事务
|
||||
* 内落库 —— 同库同事务天然满足"操作成功则日志必存在",比 outbox+relay 更强。
|
||||
* 若审计团队日后独立成服务,只换本文件的实现,action 词汇表保持不变。
|
||||
* 审计能力本体在 `../audit/`(横切模块,不认识文件库)。本文件是文件库这一侧
|
||||
* 的翻译:把 FileLibActor / 节点 kind / pathIds 这些领域概念,映射成审计模块
|
||||
* 的 AuditActor / objectType / objectPath。业务 service 只 import 本文件。
|
||||
*
|
||||
* 保留 `FILE_LIB_AUDIT_ACTIONS` 这个名字是为了不惊动既有 import;它就是审计
|
||||
* 模块词表的再导出,不是第二份词表。
|
||||
*/
|
||||
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import {
|
||||
AUDIT_ACTIONS,
|
||||
writeAudit,
|
||||
writeAuditOutOfBand,
|
||||
type AuditActor,
|
||||
type AuditObjectType,
|
||||
type AuditRecordInput,
|
||||
} from "../audit/index.js";
|
||||
import type { FileLibActor } from "./treeService.js";
|
||||
|
||||
/** C3 §6.3:文件库审计动作词汇表(与契约文档逐条对应,改词需升契约版本)。 */
|
||||
export const FILE_LIB_AUDIT_ACTIONS = {
|
||||
folderCreate: "folder.create",
|
||||
folderRename: "folder.rename",
|
||||
folderMove: "folder.move",
|
||||
folderDelete: "folder.delete",
|
||||
projectCreate: "project.create",
|
||||
projectRename: "project.rename",
|
||||
projectMove: "project.move",
|
||||
projectDelete: "project.delete",
|
||||
// ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。
|
||||
folderRestore: "folder.restore",
|
||||
projectRestore: "project.restore",
|
||||
nodePurge: "node.purge",
|
||||
permissionGrant: "permission.grant",
|
||||
permissionUpdate: "permission.update",
|
||||
permissionRevoke: "permission.revoke",
|
||||
independentEnable: "project.independent_permission.enable",
|
||||
independentDisable: "project.independent_permission.disable",
|
||||
independentChange: "project.independent_permission.change",
|
||||
fileUpload: "file.upload",
|
||||
fileRename: "file.rename",
|
||||
fileDelete: "file.delete",
|
||||
fileCommit: "file.commit",
|
||||
fileConflictDetected: "file.conflict_detected",
|
||||
exportRun: "export.run",
|
||||
adminForceAdjust: "admin.force_adjust",
|
||||
// ADR-0038:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
|
||||
groupCreate: "group.create",
|
||||
groupUpdate: "group.update",
|
||||
groupDelete: "group.delete",
|
||||
groupRestore: "group.restore",
|
||||
groupMemberAdd: "group.member_add",
|
||||
groupMemberRemove: "group.member_remove",
|
||||
} as const;
|
||||
/** 文件库审计动作词表 = 审计模块词表(单一来源,不复制)。 */
|
||||
export const FILE_LIB_AUDIT_ACTIONS = AUDIT_ACTIONS;
|
||||
|
||||
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
|
||||
export type FileLibAuditObjectType = AuditObjectType;
|
||||
|
||||
/** FileLibActor → AuditActor:姓名快照 + 管理员标记 + 客户端信息一并带上。 */
|
||||
export function auditActor(actor: FileLibActor): AuditActor {
|
||||
return {
|
||||
userId: actor.userId,
|
||||
displayName: actor.displayName,
|
||||
isWebsiteAdmin: actor.isWebsiteAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
/** 节点 kind → 审计对象类型。 */
|
||||
export function nodeObjectType(kind: "FOLDER" | "PROJECT"): AuditObjectType {
|
||||
return kind === "PROJECT" ? "PROJECT" : "FOLDER";
|
||||
}
|
||||
|
||||
export interface FileLibAuditEntry {
|
||||
readonly action: string;
|
||||
readonly actorUserId: string;
|
||||
readonly actor: FileLibActor;
|
||||
readonly organizationId: string;
|
||||
readonly objectType: FileLibAuditObjectType;
|
||||
readonly objectType: AuditObjectType;
|
||||
readonly objectId: string;
|
||||
/** 节点 id 路径(pathIds)或项目内文件路径,便于按路径检索。 */
|
||||
/** 对象名称(节点 name / 文件名 / 组名)。 */
|
||||
readonly objectName: string;
|
||||
/** 节点 pathIds,或文件的 `<pathIds>:<filePath>`。 */
|
||||
readonly objectPath: string;
|
||||
readonly detail?: Record<string, unknown> | undefined;
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | undefined;
|
||||
readonly result?: "SUCCESS" | "FAILURE" | undefined;
|
||||
readonly failureReason?: string | undefined;
|
||||
}
|
||||
|
||||
function toRecord(entry: FileLibAuditEntry): AuditRecordInput {
|
||||
return {
|
||||
action: entry.action,
|
||||
actor: auditActor(entry.actor),
|
||||
objectType: entry.objectType,
|
||||
objectId: entry.objectId,
|
||||
objectName: entry.objectName,
|
||||
objectPath: entry.objectPath,
|
||||
before: entry.before,
|
||||
after: entry.after,
|
||||
context: entry.context,
|
||||
result: entry.result,
|
||||
failureReason: entry.failureReason,
|
||||
client: entry.actor.client,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 在调用方的事务里写一条审计。刻意不吞错:写不出来整个业务操作回滚
|
||||
* (需求 5.1"操作成功则日志必存在"的强保证)。
|
||||
* 成功操作:在调用方事务内写。刻意不吞错 —— 写不出来整个业务操作回滚,
|
||||
* 这就是「操作成功则日志必存在」的强保证。
|
||||
*/
|
||||
export async function writeFileLibAudit(
|
||||
tx: Prisma.TransactionClient,
|
||||
entry: FileLibAuditEntry,
|
||||
): Promise<void> {
|
||||
const metadata: Record<string, unknown> = {
|
||||
objectType: entry.objectType,
|
||||
objectId: entry.objectId,
|
||||
objectPath: entry.objectPath,
|
||||
...(entry.detail ?? {}),
|
||||
};
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
action: entry.action,
|
||||
actorUserId: entry.actorUserId,
|
||||
organizationId: entry.organizationId,
|
||||
metadata: metadata as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await writeAudit(tx, { organizationId: entry.organizationId }, toRecord(entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败操作与冲突检测事件:事务外补写(业务事务已回滚,同事务写必然一起消失)。
|
||||
* 吞错 —— 此刻业务已经失败,审计再抛只会把 409 变成 500。
|
||||
*/
|
||||
export async function writeFileLibAuditFailure(
|
||||
prisma: PrismaClient,
|
||||
entry: FileLibAuditEntry & { readonly failureReason: string },
|
||||
): Promise<void> {
|
||||
await writeAuditOutOfBand(
|
||||
{ prisma, organizationId: entry.organizationId },
|
||||
{ ...toRecord(entry), result: entry.result ?? "FAILURE" },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { FileLibError, nameKey } from "./model.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, nodeObjectType, writeFileLibAudit } from "./audit.js";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
import type { FileLibActor } from "./treeService.js";
|
||||
|
||||
@@ -144,12 +144,14 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId
|
||||
action: node.kind === "PROJECT"
|
||||
? FILE_LIB_AUDIT_ACTIONS.projectRestore
|
||||
: FILE_LIB_AUDIT_ACTIONS.folderRestore,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: node.name,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name },
|
||||
before: { deleted: true },
|
||||
after: { name: node.name, deleted: false },
|
||||
});
|
||||
return { name: node.name };
|
||||
});
|
||||
@@ -189,12 +191,16 @@ export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId:
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.nodePurge,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: node.name,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name, removed },
|
||||
before: { name: node.name, subtreeSize: removed },
|
||||
// 彻底删除:无后值。日志本身是这个对象最后的记录 —— 故意不建 FK,
|
||||
// 对象行没了日志仍在。
|
||||
context: { removed, irreversible: true },
|
||||
});
|
||||
return { removed };
|
||||
});
|
||||
|
||||
@@ -247,12 +247,15 @@ export async function submitExport(
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.exportRun,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "export_job",
|
||||
objectType: "EXPORT_JOB",
|
||||
objectId: jobId,
|
||||
objectName: node.name,
|
||||
objectPath: node.pathIds,
|
||||
detail: { target, params },
|
||||
after: { jobId, target, status: "QUEUED" },
|
||||
// 需求点名:导出参数进附加上下文。
|
||||
context: { projectId: node.id, params },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
|
||||
import { FileLibError } from "./model.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit, writeFileLibAuditFailure } from "./audit.js";
|
||||
import type { CommitResult, FileEntry, VersionInfo, VersionStore, ProjectCommitInfo } from "./versionStore.js";
|
||||
import type { AccessDeps, FileLibActor } from "./treeService.js";
|
||||
import { requireAccessInTx } from "./treeService.js";
|
||||
@@ -151,27 +151,66 @@ export function defaultCommitMessage(actor: FileLibActor, filePath: string): str
|
||||
return `【${who}】修改了【${filePath}】`;
|
||||
}
|
||||
|
||||
/** 成功的文件操作:自开事务写审计(内容已落 git,此处只补日志)。 */
|
||||
async function auditFile(
|
||||
deps: FileDeps,
|
||||
actor: FileLibActor,
|
||||
action: string,
|
||||
project: ProjectChain,
|
||||
filePath: string,
|
||||
detail: Record<string, unknown>,
|
||||
values: {
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
await writeFileLibAudit(tx, {
|
||||
action,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "file",
|
||||
objectType: "FILE",
|
||||
objectId: project.node.id,
|
||||
objectName: filePath.split("/").pop() ?? filePath,
|
||||
objectPath: `${project.node.pathIds}:${filePath}`,
|
||||
detail,
|
||||
before: values.before,
|
||||
after: values.after,
|
||||
context: { projectName: project.node.name, filePath, ...(values.context ?? {}) },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 冲突检测事件:走事务外旁路。冲突之后调用方立刻抛 409,若与业务同事务
|
||||
* 会被一并回滚 —— 而冲突恰恰是需求点名必须留痕的事件(含起始版本与冲突版本)。
|
||||
*/
|
||||
async function auditConflict(
|
||||
deps: FileDeps,
|
||||
actor: FileLibActor,
|
||||
project: ProjectChain,
|
||||
filePath: string,
|
||||
versions: { readonly baseVersion: string | null; readonly currentVersion: string },
|
||||
): Promise<void> {
|
||||
await writeFileLibAuditFailure(deps.prisma, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.fileConflictDetected,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "FILE",
|
||||
objectId: project.node.id,
|
||||
objectName: filePath.split("/").pop() ?? filePath,
|
||||
objectPath: `${project.node.pathIds}:${filePath}`,
|
||||
result: "FAILURE",
|
||||
failureReason: "version_conflict: file was modified since baseVersion",
|
||||
context: {
|
||||
projectName: project.node.name,
|
||||
filePath,
|
||||
// 需求点名:冲突须记录起始版本与冲突版本。
|
||||
baseVersion: versions.baseVersion,
|
||||
currentVersion: versions.currentVersion,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 读操作 */
|
||||
|
||||
export async function listFiles(
|
||||
@@ -293,7 +332,7 @@ export async function commitFile(
|
||||
});
|
||||
|
||||
if (result.status === "conflict") {
|
||||
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
|
||||
await auditConflict(deps, actor, project, filePath, {
|
||||
baseVersion: input.baseVersion,
|
||||
currentVersion: result.currentVersion,
|
||||
});
|
||||
@@ -308,7 +347,13 @@ export async function commitFile(
|
||||
input.baseVersion === null ? FILE_LIB_AUDIT_ACTIONS.fileUpload : FILE_LIB_AUDIT_ACTIONS.fileCommit,
|
||||
project,
|
||||
filePath,
|
||||
{ version: result.version, message },
|
||||
{
|
||||
before: input.baseVersion === null ? undefined : { version: input.baseVersion },
|
||||
after: { version: result.version },
|
||||
// 冲突合并后的重新提交:baseVersion 就是用户看到的冲突版本,
|
||||
// 与前一条 conflict_detected 的 currentVersion 对得上,链路可还原。
|
||||
context: { message, bytes: typeof content === "string" ? Buffer.byteLength(content, "utf8") : content.byteLength },
|
||||
},
|
||||
);
|
||||
return { version: result.version };
|
||||
}
|
||||
@@ -327,7 +372,7 @@ export async function deleteFile(
|
||||
displayName: actor.displayName,
|
||||
});
|
||||
if (result.status === "conflict") {
|
||||
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileConflictDetected, project, filePath, {
|
||||
await auditConflict(deps, actor, project, filePath, {
|
||||
baseVersion,
|
||||
currentVersion: result.currentVersion,
|
||||
});
|
||||
@@ -335,5 +380,7 @@ export async function deleteFile(
|
||||
currentVersion: result.currentVersion,
|
||||
});
|
||||
}
|
||||
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, { baseVersion });
|
||||
await auditFile(deps, actor, FILE_LIB_AUDIT_ACTIONS.fileDelete, project, filePath, {
|
||||
before: { version: baseVersion },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export async function listGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return withPrincipalNames(deps.prisma, grants.map(toDto));
|
||||
return withPrincipalNames(deps.prisma, grants.map((g) => toDto(g)));
|
||||
}
|
||||
|
||||
export interface PutGrantsResult {
|
||||
@@ -179,7 +179,10 @@ export async function putGrants(
|
||||
if (existing.role !== item.role) {
|
||||
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
|
||||
updated += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node.id, node.pathIds, { ...item });
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionUpdate, node, {
|
||||
before: { principalType: item.principalType, principalId: item.principalId, role: existing.role },
|
||||
after: { principalType: item.principalType, principalId: item.principalId, role: item.role },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await tx.fileLibGrant.create({
|
||||
@@ -193,14 +196,16 @@ export async function putGrants(
|
||||
},
|
||||
});
|
||||
granted += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node.id, node.pathIds, { ...item });
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionGrant, node, {
|
||||
after: { principalType: item.principalType, principalId: item.principalId, role: item.role },
|
||||
});
|
||||
}
|
||||
}
|
||||
const grants = await tx.fileLibGrant.findMany({
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map((g) => toDto(g))) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,10 +229,14 @@ export async function revokeGrant(
|
||||
throw new FileLibError(403, "only_creator_can_revoke_manage", "only the creator can revoke MANAGE");
|
||||
}
|
||||
await tx.fileLibGrant.update({ where: { id: grant.id }, data: { revokedAt: new Date() } });
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node.id, node.pathIds, {
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
role: grant.role,
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.permissionRevoke, node, {
|
||||
before: {
|
||||
principalType: grant.principalType,
|
||||
principalId: grant.principalId,
|
||||
role: grant.role,
|
||||
},
|
||||
// 收回:无后值(授权不复存在)。
|
||||
context: { grantId: grant.id },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -267,12 +276,10 @@ export async function forceAdjustGrants(
|
||||
if (existing.role !== item.role) {
|
||||
await tx.fileLibGrant.update({ where: { id: existing.id }, data: { role: item.role } });
|
||||
updated += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
|
||||
change: "update",
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
from: existing.role,
|
||||
to: item.role,
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node, {
|
||||
before: { principalType: item.principalType, principalId: item.principalId, role: existing.role },
|
||||
after: { principalType: item.principalType, principalId: item.principalId, role: item.role },
|
||||
context: { change: "update", forced: true },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -287,11 +294,9 @@ export async function forceAdjustGrants(
|
||||
},
|
||||
});
|
||||
granted += 1;
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node.id, node.pathIds, {
|
||||
change: "grant",
|
||||
principalType: item.principalType,
|
||||
principalId: item.principalId,
|
||||
role: item.role,
|
||||
await audit(tx, deps, actor, FILE_LIB_AUDIT_ACTIONS.adminForceAdjust, node, {
|
||||
after: { principalType: item.principalType, principalId: item.principalId, role: item.role },
|
||||
context: { change: "grant", forced: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -299,7 +304,7 @@ export async function forceAdjustGrants(
|
||||
where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null },
|
||||
orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) };
|
||||
return { granted, updated, grants: await withPrincipalNames(tx, grants.map((g) => toDto(g))) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -322,22 +327,32 @@ function validateGrantItems(items: readonly InitialGrant[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 授权审计:objectType 恒为 GRANT,objectId/objectPath 用被授权的节点 ——
|
||||
* 「谁在哪个节点上动了谁的权限」是查询时的主索引。
|
||||
*/
|
||||
async function audit(
|
||||
tx: Prisma.TransactionClient,
|
||||
deps: Deps,
|
||||
actor: FileLibActor,
|
||||
action: string,
|
||||
nodeId: string,
|
||||
pathIds: string,
|
||||
detail: Record<string, unknown>,
|
||||
node: { readonly id: string; readonly name: string; readonly pathIds: string },
|
||||
values: {
|
||||
readonly before?: unknown;
|
||||
readonly after?: unknown;
|
||||
readonly context?: Record<string, unknown> | undefined;
|
||||
},
|
||||
): Promise<void> {
|
||||
await writeFileLibAudit(tx, {
|
||||
action,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "grant",
|
||||
objectId: nodeId,
|
||||
objectPath: pathIds,
|
||||
detail,
|
||||
objectType: "GRANT",
|
||||
objectId: node.id,
|
||||
objectName: node.name,
|
||||
objectPath: node.pathIds,
|
||||
before: values.before,
|
||||
after: values.after,
|
||||
context: values.context,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -226,6 +230,11 @@ export async function updateMemberGroup(
|
||||
|
||||
return deps.prisma.$transaction(async (tx) => {
|
||||
await requireActiveGroup(tx, groupId);
|
||||
// 审计需要「操作前值」—— update 之后原值就取不到了,先读一次。
|
||||
const previous = await tx.memberGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { name: true, description: true },
|
||||
});
|
||||
const group = await tx.memberGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
@@ -245,15 +254,14 @@ export async function updateMemberGroup(
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupUpdate,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectType: "GROUP",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: {
|
||||
...(name !== undefined ? { name } : {}),
|
||||
...(input.description !== undefined ? { description: group.description } : {}),
|
||||
},
|
||||
objectName: group.name,
|
||||
objectPath: `group:${group.id}`,
|
||||
before: { name: previous?.name ?? null, description: previous?.description ?? null },
|
||||
after: { name: group.name, description: group.description },
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -292,12 +300,16 @@ export async function deleteMemberGroup(
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupDelete,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectType: "GROUP",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { name: group.name, archivedCount: result.count },
|
||||
objectName: group.name,
|
||||
objectPath: `group:${group.id}`,
|
||||
before: { name: group.name, archived: false },
|
||||
after: { archived: true, archivedAt: now.toISOString() },
|
||||
// 级联软删整棵子树:受影响的组数是审计的关键事实。
|
||||
context: { archivedCount: result.count, cascadedSubtree: true },
|
||||
});
|
||||
return { archivedCount: result.count };
|
||||
});
|
||||
@@ -333,12 +345,16 @@ export async function restoreMemberGroup(
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupRestore,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectType: "GROUP",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { name: group.name, restoredCount: result.count },
|
||||
objectName: group.name,
|
||||
objectPath: `group:${group.id}`,
|
||||
before: { archived: true, archivedAt: group.archivedAt.toISOString() },
|
||||
after: { name: group.name, archived: false },
|
||||
// 决策7:恢复只解自身 + 已归档祖先,不动子树。
|
||||
context: { restoredCount: result.count, restoredAncestors: true },
|
||||
});
|
||||
return { restoredCount: result.count };
|
||||
});
|
||||
@@ -444,12 +460,15 @@ export async function addMember(
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectType: "GROUP",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { userId: user.id },
|
||||
objectName: group.name,
|
||||
objectPath: `group:${group.id}`,
|
||||
// 成员增删的「值」是成员身份本身:前无后有。
|
||||
after: { userId: user.id, displayName: user.displayName },
|
||||
context: { groupName: group.name },
|
||||
});
|
||||
return {
|
||||
userId: user.id,
|
||||
@@ -482,17 +501,24 @@ export async function removeMember(
|
||||
});
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "group",
|
||||
objectType: "GROUP",
|
||||
objectId: group.id,
|
||||
objectPath: group.id,
|
||||
detail: { userId },
|
||||
objectName: group.name,
|
||||
objectPath: `group:${group.id}`,
|
||||
before: { userId },
|
||||
context: { groupName: group.name },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** 成员选择器:按显示名/openId 搜全局用户(决策2)。 */
|
||||
/**
|
||||
* 成员选择器:按显示名/openId/userId 搜全局用户(决策2)。
|
||||
*
|
||||
* userId 也纳入匹配:审计日志查询结果里显示的就是 userId,用户从表格拷一个
|
||||
* id 粘进筛选框必须能命中。共享端点,授权面板与成员组选择器一并受益。
|
||||
*/
|
||||
export async function searchUsers(
|
||||
deps: MemberGroupServiceDeps,
|
||||
actor: FileLibActor,
|
||||
@@ -521,6 +547,7 @@ export async function searchUsers(
|
||||
OR: [
|
||||
{ displayName: { contains: keyword, mode: "insensitive" as const } },
|
||||
{ feishuOpenId: { contains: keyword, mode: "insensitive" as const } },
|
||||
{ id: { contains: keyword, mode: "insensitive" as const } },
|
||||
],
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -29,17 +29,22 @@ import {
|
||||
import { checkAccess, effectiveRole } from "./permission.js";
|
||||
import type { GroupResolver } from "./groupResolver.js";
|
||||
import type { VersionStore } from "./versionStore.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS, nodeObjectType, writeFileLibAudit } from "./audit.js";
|
||||
|
||||
export interface FileLibActor {
|
||||
readonly userId: string;
|
||||
/** silo org OWNER/ADMIN(契约 C4 适配)。仅 root 创建/force_adjust 用,不给读旁路。 */
|
||||
readonly isWebsiteAdmin: boolean;
|
||||
/**
|
||||
* 展示名(飞书昵称)。只用于生成 commit message 的【用户名】部分;
|
||||
* 权限判定一律用 userId。缺失时回退到 userId。
|
||||
* 展示名(飞书昵称)。用于生成 commit message 的【用户名】部分,
|
||||
* 以及审计的操作人姓名快照;权限判定一律用 userId。缺失时回退到 userId。
|
||||
*/
|
||||
readonly displayName?: string | undefined;
|
||||
/**
|
||||
* 客户端信息(IP / User-Agent),由 guard 从请求头采集。
|
||||
* 只进审计,不参与任何判定;采不到即 undefined。
|
||||
*/
|
||||
readonly client?: { readonly ip?: string | undefined; readonly userAgent?: string | undefined } | undefined;
|
||||
}
|
||||
|
||||
export interface TreeServiceDeps {
|
||||
@@ -279,22 +284,27 @@ export async function createNode(
|
||||
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(input.kind, "Create"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: input.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(input.kind),
|
||||
objectId: id,
|
||||
objectName: name,
|
||||
objectPath: pathIds,
|
||||
detail: { name, parentId: input.parentId, initialGrants: initialGrants.length },
|
||||
// 创建:无前值。后值是落库的节点事实。
|
||||
after: { name, kind: input.kind, parentId: input.parentId, description: input.description ?? null },
|
||||
context: { initialGrants: initialGrants.length, isRootCreation: input.parentId === null },
|
||||
});
|
||||
for (const grant of initialGrants) {
|
||||
await writeFileLibAudit(tx, {
|
||||
action: FILE_LIB_AUDIT_ACTIONS.permissionGrant,
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: "grant",
|
||||
objectType: "GRANT",
|
||||
objectId: id,
|
||||
objectName: name,
|
||||
objectPath: pathIds,
|
||||
detail: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
|
||||
after: { principalType: grant.principalType, principalId: grant.principalId, role: grant.role },
|
||||
context: { reason: "initial_grant_on_create" },
|
||||
});
|
||||
}
|
||||
return node;
|
||||
@@ -340,12 +350,14 @@ export async function renameNode(
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Rename"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: name,
|
||||
objectPath: node.pathIds,
|
||||
detail: { from: node.name, to: name },
|
||||
before: { name: node.name },
|
||||
after: { name },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
@@ -403,12 +415,16 @@ export async function moveNode(
|
||||
}
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Move"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: node.name,
|
||||
// 移动后的新路径 —— 按子树查询要能在新位置命中。
|
||||
objectPath: newPathIds,
|
||||
detail: { fromParentId: node.parentId, toParentId: newParentId },
|
||||
before: { parentId: node.parentId, pathIds: node.pathIds },
|
||||
after: { parentId: newParentId, pathIds: newPathIds },
|
||||
context: { movedToRoot: newParentId === null },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
@@ -422,15 +438,19 @@ export async function softDeleteNode(
|
||||
): Promise<void> {
|
||||
await deps.prisma.$transaction(async (tx) => {
|
||||
const { node } = await requireAccess(tx, deps, actor, nodeId, "MANAGE");
|
||||
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: new Date() } });
|
||||
const deletedAt = new Date();
|
||||
await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt } });
|
||||
await writeFileLibAudit(tx, {
|
||||
action: nodeAction(node.kind, "Delete"),
|
||||
actorUserId: actor.userId,
|
||||
actor,
|
||||
organizationId: deps.organizationId,
|
||||
objectType: node.kind === "PROJECT" ? "project" : "folder",
|
||||
objectType: nodeObjectType(node.kind),
|
||||
objectId: node.id,
|
||||
objectName: node.name,
|
||||
objectPath: node.pathIds,
|
||||
detail: { name: node.name },
|
||||
before: { name: node.name, deletedAt: null },
|
||||
// 软删(D15):后值是打标本身,不是消失 —— 回收站仍可恢复。
|
||||
after: { deletedAt: deletedAt.toISOString() },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { resolveMaxFileBytes } from "../filelib/fileService.js";
|
||||
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
||||
import { createCphPdfAdapter, createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
||||
import { registerAuditRoutes } from "../audit/index.js";
|
||||
import { actorOrNull, sendRouteError } from "../filelib/routeShared.js";
|
||||
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user