日志审计
HeteroFlow 是本站的异构算力与大模型调度产品。日志审计。审计日志 HeteroFlow 的审计体系由两条相互独立的留痕通道构成: 租户审计日志 ( 表)由审计中间件对所有修改型 API 请求自动逐条留痕,回答"谁在何时对什么资源做了什么、结果如何"; 平台日志 ( 表)由关键业务节点主动写入 AUDIT 级事件,记录登录、任务生命周期…
审计日志
HeteroFlow 的审计体系由两条相互独立的留痕通道构成:租户审计日志(tenant_audit_logs 表)由审计中间件对所有修改型 API 请求自动逐条留痕,回答"谁在何时对什么资源做了什么、结果如何";平台日志(logs 表)由关键业务节点主动写入 AUDIT 级事件,记录登录、任务生命周期、推理后端摘除等平台侧大事。前者面向多租户合规与追责,行级完整、带脱敏与保留期管理;后者面向运维排障,按模块与级别过滤浏览。
两条通道的分工可以从一个具体问题感受:想知道"训练任务 tg-x 昨天被谁扩容到 8 副本",查租户审计页——那里有操作者、IP、请求摘要、状态码与耗时;想知道"推理后端昨晚为什么被摘除",查平台日志页——网关健康探测把摘除原因写成了 AUDIT 行。一者证明人的行为,一者记录系统的事件。
租户审计的设计立场是自动化优先:只要请求方法属于 POST/PUT/PATCH/DELETE,中间件就无差别留痕——包括被权限层拒绝的请求(审计层位于 scope 校验之前,越权尝试本身就是最有价值的审计素材);业务代码无需也无处调用"记一条审计"。平台日志则相反,由各业务模块在自己认为关键的时刻显式写入,二者互不替代。
功能总览
| 能力 | 一句话说明 | 状态 |
|---|---|---|
| 修改型请求全量自动留痕 | POST/PUT/PATCH/DELETE 无差别逐条审计,含被权限层拒绝的请求 | 已实现 |
| 敏感读入审计 | 整租户数据导出(GET /tenant/export)与写操作同等留痕 | 已实现 |
| 三维操作者归属 | 谁发起(user_id)、代谁操作(acted_for_user_id)、跨租户代操作目标(target_tenant_id) | 已实现 |
| 请求摘要脱敏 | 命中 password/secret/key/token 等敏感键名的值一律替换为 [REDACTED] | 已实现 |
| 旁路捕获请求体 | 审计只复制前 4096 字节副本,业务 handler 读到的内容逐字节不受影响 | 已实现 |
| 结构化资源用量列 | 建组请求的 GPU 数/节点数/GPU 型号独立落列,可 SQL 聚合 | 已实现 |
| agent 例行回写豁免 | 心跳与日志回写不灌审计表,被拒(≥400)与代报请求照记 | 已实现 |
| 保留期自动清理 | 默认 365 天、下限 30 天可配,分批短事务删除不长时间持锁 | 已实现 |
| 平台日志四级浏览 | INFO/WARN/ERROR/AUDIT 级别+模块+关键字+时间范围服务端筛选 | 已实现 |
| 日志统计 | 总量与按级别/模块计数,统计卡片一屏总览 | 已实现 |
| 租户审计页 | 十列审计明细表,日期范围+方法过滤+分页,运营者可定向任一租户 | 已实现 |
| 平台日志页 | 统计卡片+筛选工具栏+详情弹窗+四向分页,60 秒自动刷新 | 已实现 |
| 整租户数据导出 | task-groups/endpoints/models 全量导出,本身作为敏感读被审计 | 已实现 |
| 审计读取权限收紧 | 仅平台运营者或该租户 tenant-admin 可读审计流(此前任意已认证成员可读) | 已实现 |
| 审计接口只读语义 | 非 GET/HEAD 访问审计查询接口一律 405,杜绝"用 DELETE 读走审计"的语义混乱 | 已实现 |
审计中间件的位置:为什么被拒绝的请求也有审计行
理解租户审计的覆盖面,关键在中间件链的排布。一次 API 请求从外到内经过:指标采集 → panic 恢复 → 请求体大小限制 → 租户上下文注入 → 审计 → 权限范围校验(scope)→ 限流 → 业务 handler。审计层刻意排在权限校验之前——一个被 403 拒绝的越权尝试、一个被 409 挡下的非法删除,都会先留下完整审计行再被拒绝。对追责而言,"谁试图做什么但没做成"与"谁做了什么"同样重要。
请求体的留痕走旁路捕获而非预读:中间件把请求体包一层透明管道,业务 handler 读多少就透传多少(逐字节不动),审计侧同步留一份最多 4096 字节的副本。这个设计是用一次真实 P0 事故换来的——早期实现预读 4096 字节后把截断内容替换回请求体,结果任何超过 4096 字节的写请求(带训练日志的任务完成上报实测 5958 字节)到 handler 手里都是半截 JSON、解析必然 400,成功的训练永远无法推进终态,GPU 被白占到 20 分钟超时后误记为失败。如今截断只发生在审计副本一侧,业务路径零感知。
同样在这条链上,审计的响应包装层实现了 Unwrap 透传——没有它,推理代理放宽写超时所需的 SetWriteDeadline 会被包装层挡住而静默失效,高并发压测(450 并发)曾因此大面积失败。审计层作为全请求的必经包装,其工程细节直接牵动整条服务的能力边界。
审计事件全类型清单
租户审计(tenant_audit_logs)的覆盖面
租户审计按"请求形态"而非"业务枚举"覆盖——任何修改型请求都留痕,因此下表按业务语义归类,实际覆盖等于全部 mutating 路由:
| 类别 | 覆盖的操作(示例) | 留痕方式 |
|---|---|---|
| 认证与平台用户 | 用户登录由平台日志记录(见下节);平台用户增删改走 mutating 自动留痕 | 中间件自动 |
| 任务与任务组 | 任务创建/取消/重试/删除、建组、扩缩容(scale)、滚动更新、健康检查与空闲超时修改 | 中间件自动 |
| 推理服务与端点 | 建组(task_type=inference)、端点绑定与删除、手动扩缩 | 中间件自动 |
| 训练服务 | 建组(task_type=training)、checkpoint 文件通道请求 | 中间件自动 |
| 模型与路由 | 模型注册/更新/删除、模型路由与路由策略变更 | 中间件自动 |
| 节点与分片 | 手动建节点、MIG 分片指令、存储卷登记 | 中间件自动 |
| 租户与成员管理 | 租户 CRUD、成员增删改角色、配额调整 | 中间件自动 |
| API Key 操作 | 租户 Key/成员 Key 的创建、改名改额度、吊销——resource_id 列精确到具体 Key ID | 中间件自动 |
| 共享租户组 | 建组/改组/解散,额外补一条结构化汇总事件(成员前后清单、被连带删除的跨租户路由数) | 中间件 + 专项补充 |
| 平台运营者代操作 | ?tenant_id= 代其它租户写操作,目标租户落独立列 target_tenant_id | 中间件自动 |
| admin 代管成员资源 | 代 member/viewer 操作其资源,资源属主落 acted_for_user_id | 中间件 + handler 标记 |
| 运营者冒充 agent 上报 | 运营者走 agent 专属通道代发注册/心跳/状态回写,resource_kind 打 operator_agent_report 标记 | 中间件 + handler 标记 |
| 敏感读 | 整租户数据导出 GET /tenant/export(当前唯一纳入审计的读操作) | 中间件自动 |
| agent 例行回写 | 心跳、纯日志字段任务回写——豁免不留痕(被拒请求与代报不豁免) | 显式跳过 |
平台日志(logs 表)的 AUDIT 写入点
平台日志由业务模块显式写入,当前共 14 处(grep Level: "AUDIT"/Level: "INFO" 全量核实):
| 模块 | 事件 | 级别 |
|---|---|---|
| auth | 用户登录成功(用户名与角色) | AUDIT |
| task | 任务创建(ID 与镜像) | AUDIT |
| task | 任务状态迁移请求(动作与前后状态) | AUDIT |
| task | 任务唤醒请求(sleeping → scheduled) | AUDIT |
| task | 任务取消 | AUDIT |
| task | 任务重试(含重试轮次) | AUDIT |
| task | 任务删除(ID 与镜像) | AUDIT |
| task | 任务状态更新(Agent 回写侧) | INFO |
| task-group | 任务组创建(含节点数) | AUDIT |
| task-group-scale | 任务组扩缩(目标副本数与原值) | AUDIT |
| task-group | 健康检查配置更新 | AUDIT |
| task-group | 空闲超时更新(含向下广播的任务数) | AUDIT |
| task-group | 滚动更新(镜像/世代/策略) | AUDIT |
| gateway | 推理后端健康摘除与加回(含原因) | AUDIT |
INFO/WARN/ERROR 级别在筛选器与统计卡片中可用,但当前代码的写入方只产生 AUDIT 与 INFO 两级行。
留痕字段与不可抵赖性
租户审计每行 20 列,构成完整的追责证据链——谁(user_id + IP + User-Agent)、何时(created_at)、对什么(method + path + resource_type + resource_id)、做了什么(request_summary)、结果如何(status_code + error_message + duration_ms):
| 字段 | 说明 |
|---|---|
| tenant_id | 操作者归属租户(无租户上下文落 default) |
| user_id | 操作者 ID;JWT 认证请求由中间件自包含解析回填,追溯不到"匿名管理员" |
| method / path | 请求方法与完整路径;query 串并入 path,运营者 ?tenant_id= 的目标对审计可见 |
| resource_type / resource_id | 从路径提取的资源类型与具体 ID;两级命名空间(tenant/admin/gateway/agent)已修正取位 |
| status_code | 响应状态码——被 403/409 拒绝的请求照记,越权尝试与失败操作均有据可查 |
| error_message | 响应 ≥400 时提取的错误消息 |
| ip / user_agent | 客户端 IP(X-Real-IP/X-Forwarded-For/RemoteAddr 三级取值)与 User-Agent |
| request_summary | 请求体摘要(脱敏后 ≤500 字符) |
| duration_ms | 请求处理耗时 |
| target_tenant_id | 平台运营者跨租户代操作的目标租户(非运营者或同租户留空) |
| acted_for_user_id | admin 代管的成员资源属主(非代管留空) |
| resource_kind / resource_gpu_count / resource_node_count / resource_gpu_type | 建组请求的结构化资源用量;NULL=无资源含义,不是"申请了 0" |
| created_at | 落库时间 |
脱敏是硬规则:摘要落库前对 JSON 体递归脱敏,低歧义子串(password/passwd/secret/apikey/api_key/authorization/credential)子串包含即替换为 [REDACTED],因此 ssh_password、root_password 这类未来新增字段天然覆盖;高歧义词(key/token)只在精确等于或 _key/_token 结尾时命中,keyword/keyboard/tokenizer 不误伤。非 JSON 请求体不落原文只记字节数,超 4096 字节的请求体不落半截 JSON(无法可靠脱敏)只记超限事实。
关于"不可抵赖"的如实边界:审计写入是异步旁路(独立 goroutine、5 秒超时),不与业务事务同生共死——业务成功而审计写库失败时,失败会被 WARN 日志如实记录但主流程不受影响,极端情况下存在单行丢失的可能。它是为合规追责设计的完整流水,不是防篡改账本:行存于同一 PostgreSQL,具备数据库权限的主体理论上可改写。日志审计页的检索过滤(时间、方法、路径、状态码)均由服务端执行,防止越权用户通过前端绕过。
保留期与清理策略
租户审计表有完整的生命周期管理:保留期默认 365 天(环境变量 HETEROFLOW_AUDIT_RETENTION_DAYS 可配,下限 30 天——配得更低会回落到默认值,避免清理退化成"删光审计")。清理由审计写入自身触发:每累计 500 条写入、或距上次清理超过 6 小时二者其一即触发一次(低流量部署不会因攒不满计数而停摆),独立 goroutine 异步执行、CAS 防重入、60 秒超时。删除按 5000 行一批的短事务推进——此前单条大 DELETE 长时间持锁,超时回滚后下次从头再来永远删不完;分批后超时只丢最后一批,进度可累积。
agent 例行回写豁免(K74⑥)是审计表健康度的关键治理:历史上 agent 心跳每 5 秒一跳全部入审计,15 节点一天灌入约 26 万行/170MB,审计价值为零却把真正的管理操作淹没。现心跳与纯日志回写在"agent 系统密钥调用 + handler 标记例行 + 未标记必须保留(如心跳首次注册节点)+ 无运营者代报 + 状态码 < 400"全部满足时跳过留痕;终态上报、状态 CAS、节点显式注册、文件/镜像/分片通道请求都不标记,照常审计;被拒请求照记。
平台日志(logs 表)当前没有自动清理:存储层留有按天数清理与清空的方法,但无任何生产调用点接线,该表只增不减,需要运维自行清理(见已知边界)。
页面功能介绍
/tenant/audit-logs 租户审计页
多租户导航"操作审计"入口,登录即可见页面,数据接口要求租户管理员权限(见下方权限差异)。十列审计明细表,每页 50 条。
| 操作 / 区域 | 行为 | 背后 API |
|---|---|---|
| 租户选择器(管理员) | 平台管理员下拉定向查看任一租户的审计流,切换自动回第 1 页 | GET /tenant/audit-logs?tenant_id= |
| 起始/结束日期 | 日期范围筛选,带 min/max 互锁;条件变化回第 1 页 | GET /tenant/audit-logs?start=&end= |
| 方法筛选下拉 | GET/POST/PUT/DELETE/PATCH 五选一;后端接口无方法参数,仅对当前页做客户端过滤,界面如实标注 | 前端过滤 |
| 清除筛选 | 一键清空日期与方法条件并回第 1 页 | — |
| 审计表格十列 | 时间/方法(彩色)/路径/资源类型/资源用量/目标租户/状态码(≥400 红)/耗时/IP/错误消息 | GET /tenant/audit-logs |
| 资源用量列 | 建组请求显示"训练(GPU×8 / 节点×2 / A800)"形态;无资源含义显示"—"而非 0 | 同上 |
| 目标租户列 | 运营者跨租户代操作高亮显示目标租户,其余"—" | 同上 |
| 上一页/下一页 | 后端不回总数,前端多取 1 条探测是否还有下一页,杜绝"死下一页" | GET /tenant/audit-logs?limit=51&offset= |
/logs 平台日志页
仅平台管理员可见(AdminRoute 守卫 + 后端 system:read 权限),60 秒自动刷新。
| 操作 / 区域 | 行为 | 背后 API |
|---|---|---|
| 刷新按钮 | 手动拉取,刷新期间禁用防抖 | GET /logs + GET /logs/stats |
| 统计卡片 | 总计/错误/警告/信息/审计五张卡片,按级别实时计数 | GET /logs/stats |
| 搜索关键字 | 消息与日志 ID 的大小写不敏感搜索(服务端 ILIKE) | GET /logs?search= |
| 级别下拉 | 全部/INFO/WARN/ERROR/AUDIT | GET /logs?level= |
| 模块下拉 | 选项动态取自统计接口的 by_module(真实存在的模块),静态映射仅做中文翻译 | GET /logs?module= |
| 开始/结束时间 | 时间范围筛选 | GET /logs?start_time=&end_time= |
| 清除筛选 | 全部条件归零并回第 1 页 | — |
| 日志表格 | 时间/级别徽章/中文模块名/消息(超 100 字符截断悬停看全) | GET /logs |
| 行点击或详情按钮 | 弹窗展示 ID/时间/级别/模块/用户/关联节点/关联任务/完整消息(保留原始格式) | GET /logs |
| 首页/上一页/下一页/末页 | 服务端分页每页 50 条,页脚显示"显示 x-y / 共 n 条" | GET /logs?limit=50&offset= |
模块下拉的中文翻译映射(后端模块名 → 界面显示名;列表里出现映射外的新模块时按原名显示):
| 后端 module | 显示名 | 后端 module | 显示名 |
|---|---|---|---|
| scheduler | 调度器 | task | 任务 |
| controller | 控制器 | task-group | 任务组 |
| storage | 存储器 | task-group-scale | 任务组伸缩 |
| notifier | 通知器 | node | 节点 |
| auth | 认证 | plugin | 插件 |
| api | API | health | 健康检查 |
| system | 系统 | gateway(见映射外回落) | 原名显示 |
权限差异一览:
| 页面 | 前端守卫 | 后端门禁 | 谁能看 |
|---|---|---|---|
| /logs 平台日志 | AdminRoute | requireAdminAuth + system:read | 仅平台管理员 |
| /tenant/audit-logs 租户审计 | ProtectedRoute(登录即可见入口) | 平台运营者或该租户 tenant-admin | 租户管理员及以上 |
member/viewer 登录后导航里能看到"操作审计"入口,但打开后数据请求被后端 403 拒绝并显示错误横幅——权限收敛在服务端强制执行,前端不隐藏入口只是体验上的已知瑕疵。审计读取收紧为管理员级是刻意决策:审计摘要历史上出现过明文凭据,收紧前任意已认证成员可读走全租户审计流(#111 修复)。
后端逻辑概念表
| 概念 | 定义 | 代码出处 |
|---|---|---|
| AuditLogMiddleware | 修改型请求全量审计中间件,位于 scope 校验之前,越权请求也留痕 | core/pkg/api/tenant_audit.go:283 |
| mutatingMethods | POST/PUT/PATCH/DELETE 四个触发审计的方法 | core/pkg/api/tenant_audit.go:215 |
| sensitiveReadPaths | 即便是 GET 也留痕的敏感读端点登记表(当前仅 /tenant/export) | core/pkg/api/tenant_audit.go:226 |
| auditBodyCapture | 请求体旁路捕获器:下游读多少透传多少,审计副本只留前 4096 字节 | core/pkg/api/tenant_audit.go:124 |
| isSensitiveKey | 脱敏键名判定:低歧义子串包含 + 高歧义词边界匹配 | core/pkg/api/tenant_audit.go:196 |
| acted_for_user_id | admin 代管成员资源时的资源属主列,handler 经 markAuditDelegation 写入 | core/pkg/api/audit_delegation.go:40 |
| target_tenant_id | 运营者 ?tenant_id= 跨租户代操作的目标租户独立列(D4) | core/pkg/api/tenant_audit.go:363 |
| operator_agent_report | 运营者冒充 agent 上报的专属 resource_kind 标记(D16(b)) | core/pkg/api/audit_delegation.go:58 |
| skipAgentRoutineAudit | agent 例行回写豁免判定:agent 密钥+例行标记+未标记保留+无代报+状态码<400 全满足才跳过(K74⑥) | core/pkg/api/audit_delegation.go:108 |
| parseResourceAuditFields | 建组请求解析结构化资源用量四列,只读数字不涉敏感键 | core/pkg/api/tenant_audit_resource.go:62 |
| MaybeCleanupAuditLogs | 保留期清理触发器:每 500 条写入或超 6 小时 | core/pkg/api/tenant_audit.go:77 |
| DeleteOlderThanBatched | 分批删除(5000 行/批、ctid 定位、短事务可累积) | core/pkg/storage/postgres/tenant_audit.go:152 |
| callerIsTenantAdmin | 审计读门禁:平台运营者或租户内 role=admin,查询失败 fail-closed | core/pkg/api/tenant_users.go:40 |
| statusRecorder | 捕获响应状态码与错误消息的响应包装器;实现 Unwrap 供写超时控制穿透 | core/pkg/api/tenant_audit.go:551 |
| 两级路径命名空间白名单 | tenant/admin/gateway/agent 前缀下 resource_id 取第 3 段,测试盯着新增命名空间 | core/pkg/api/tenant_audit.go:512 |
| LogStore | 平台日志存储:List/Search/Count/Stats 四查询族 | core/pkg/storage/postgres/log.go |
API 参考
| 接口 | 说明 |
|---|---|
GET /api/v1/tenant/audit-logs | 租户审计查询。参数:limit(默认 100,上限 1000)/offset 分页;start/end 日期(YYYY-MM-DD,end 含当日);tenant_id 仅平台运营者可指定查任一租户。权限:平台运营者或该租户 tenant-admin,其余 403 |
GET /api/v1/tenant/export | 整租户数据导出(task-groups/endpoints/models 全量)。权限同上;该 GET 本身作为敏感读被审计留痕 |
GET /api/v1/logs | 平台日志查询。参数:level/module 筛选;start_time/end_time(RFC3339);user/node_id/task_id 关联过滤;search 关键字;limit(默认 50)/offset。权限:管理员 + system:read |
GET /api/v1/logs/stats | 日志统计(总量、按级别、按模块计数)。权限同上 |
租户审计接口有严格的方法守卫:非 GET/HEAD 一律 405(此前用 DELETE 也能读走审计列表,已收敛为只读语义)。列表为空时返回 [] 而非 null。
租户审计查询示例(按日期范围取第 2 页):
curl -H "Authorization: Bearer <token>" \
"http://localhost:3333/api/v1/tenant/audit-logs?start=2026-10-01&end=2026-10-05&limit=50&offset=50"响应骨架(字段与顺序同 ListByTenant 的 SELECT 列一一对应):
{
"tenant_id": "acme",
"limit": 50,
"offset": 50,
"logs": [
{
"id": 1024,
"tenant_id": "acme",
"user_id": "u-7",
"method": "POST",
"path": "/api/v1/task-groups",
"resource_type": "task-groups",
"resource_id": "",
"status_code": 200,
"error_message": "",
"ip": "203.0.113.10",
"user_agent": "Mozilla/5.0",
"request_summary": "{\"training\":{\"n_nodes\":2},\"task\":{\"gpu_required\":8,\"gpu_type\":\"nvidia\"}}",
"duration_ms": 63,
"target_tenant_id": "",
"acted_for_user_id": "",
"resource_kind": "training",
"resource_gpu_count": 8,
"resource_node_count": 2,
"resource_gpu_type": "nvidia",
"created_at": "2026-10-03T09:15:22Z"
}
]
}平台日志查询示例(AUDIT 级别 + 关键字搜索"任务取消",URL 编码后传参):
curl -H "Authorization: Bearer <token>" \
"http://localhost:3333/api/v1/logs?level=AUDIT&search=%E4%BB%BB%E5%8A%A1%E5%8F%96%E6%B6%88&limit=50"响应载荷为 {data: [...], total: n} 双层结构,日志行按时间倒序,字段为 id/timestamp/level/module/message/user/node_id/task_id。
已知边界
1. 租户审计页日期筛选当前不生效:前端把日期转成 RFC3339 发送,后端按纯日期(YYYY-MM-DD)解析且失败静默忽略,两边格式不匹配导致 start/end 条件实际被丢弃(Go time.Parse 对 RFC3339 输入报 extra text 已实测复现)。API 直接调用传 YYYY-MM-DD 正常工作;前端修复待排期。
2. 平台日志无自动清理:logs 表的清理方法在存储层存在但无生产调用点,表只增不减;租户审计表则有 365 天自动保留期。长期运行需运维自行清理 logs 表。
3. WARN/ERROR 级别无当前写入方:筛选器与统计卡片支持四级,但现有代码只产生 AUDIT 与 INFO 行,两张卡片常态为零。
4. 方法过滤仅当前页生效:租户审计接口无方法参数,前端方法下拉只过滤本页 50 条,翻页后需重新面对全量——界面已如实标注。
5. member/viewer 可见审计入口但无数据权限:导航不按租户角色隐藏"操作审计",普通成员打开得到 403 错误横幅;权限本身由服务端强制,无泄露。
6. 审计写入是异步旁路:独立 goroutine 5 秒超时,写库失败只记 WARN 不阻断业务,极端情况下单行可能丢失且不可补录(请求体已消费完毕)。
7. 审计行为防篡改能力:行存于同一 PostgreSQL,具备库权限的主体理论上可改写;平台未提供哈希链/外部锚定等防篡改机制,合规场景如有硬要求需另行建设。
8. 系统日志时间筛选的格式挑剔:后端按 RFC3339 严格解析,解析失败静默忽略该条件;前端 datetime-local 控件原生值不含秒与时区,经 API 调用时请传完整 RFC3339(如 2026-08-19T10:30:00Z)。
9. 敏感读范围刻意收窄:当前只有整租户导出一个 GET 端点纳入审计;把所有 GET 都记会淹没审计表并拖慢读路径,新增敏感读端点需在登记表显式登记。
<!-- 状态标注说明: 本文全部能力为[已实现]口径——生产代码已实现并在开发环境长期运行;已知边界第 1/4 条为前端体验缺陷如实记录 -->