V2 独立版愿景

日志审计

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_idadmin 代管的成员资源属主(非代管留空)
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/AUDITGET /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插件
apiAPIhealth健康检查
system系统gateway(见映射外回落)原名显示

权限差异一览:

页面前端守卫后端门禁谁能看
/logs 平台日志AdminRouterequireAdminAuth + system:read仅平台管理员
/tenant/audit-logs 租户审计ProtectedRoute(登录即可见入口)平台运营者或该租户 tenant-admin租户管理员及以上

member/viewer 登录后导航里能看到"操作审计"入口,但打开后数据请求被后端 403 拒绝并显示错误横幅——权限收敛在服务端强制执行,前端不隐藏入口只是体验上的已知瑕疵。审计读取收紧为管理员级是刻意决策:审计摘要历史上出现过明文凭据,收紧前任意已认证成员可读走全租户审计流(#111 修复)。

后端逻辑概念表

概念定义代码出处
AuditLogMiddleware修改型请求全量审计中间件,位于 scope 校验之前,越权请求也留痕core/pkg/api/tenant_audit.go:283
mutatingMethodsPOST/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_idadmin 代管成员资源时的资源属主列,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
skipAgentRoutineAuditagent 例行回写豁免判定: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-closedcore/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 条为前端体验缺陷如实记录 -->