Files
cma-management/docs/audit-and-notification-caliber-20260922.md
T
wecom-fullstack 477515c453
ci/woodpecker/push/main Pipeline was canceled
fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
任务 dama-entity-isolation-sweep-20260923(DAMA 独立回查漏项 / 任总 2026-09-23「按照建议执行」)

① 补漏同源全局键 kpi_alert_higher_better(system_configs,零 DDL)
   - deviation_engine: 新增 higher_better_key(entity_id);get_higher_better_codes(db, entity_id)
     改为只读本账套键、不回退旧全局键;未配置 → DEFAULT_HIGHER_BETTER(兜底,与原行为逐字一致)
   - budget.py: GET/PUT /alert-direction 挂 get_entity_id,只读写会话账套键;body entity_id 不信
   - scripts/migrate_higher_better_key_entity_20260923.py: --check/--apply/--down + 库名保护 + 幂等
   - 生产实况:库内该键 0 行 ⇒ 无原值可搬运(--apply 走 skip 零写入),e1 行为不变

② kpi_visibility 口径裁定:角色级天然全局 = 非账套维度,不拆(关闭本项)
   - auth_middleware 注释 + docstring 登记「有意不做账套后缀」;口径字典
     docs/global-config-keys-caliber-20260923.md(含 budget_mode/budget_driver_mode 评估结论)

③ tenant/switch 审计收口
   - detail 显式 from_entity_id → to_entity_id + source + operator{id,username,name,role}
   - 审计失败不静默:同事务回滚 + AUDIT_WRITE_FAILED ERROR + HTTP 500(不留「改了但没审计」)
   - GET /current 无写路径不落审计;历史缺口不补写

证据:新增/改写用例 17 条;全量 1397 passed(去新增文件 1380 + 17);RED 态 8 failed
2026-09-23 18:29:52 +08:00

6.2 KiB
Raw Blame History

口径登记:审计留痕(operation_logs)与通知发送记录(notification_logs)

  • 立档: wecom-fullstack,2026-09-22
  • 来源: DAMA 独立回查 dama-recheck-workbench-entity-isolation-20260922(2026-09-22 11:07–11:20) §4b(审计字面与库内既有口径不一致 / 无 entity_id 列 / user_id=None)与 §5(B2 上线后 /notifications/logs 在 e1/e2 均为 0 条 = 口径副作用)两处待办
  • 性质: 口径登记 + 代码收口说明。不含任何数据订正(数据订正另立单 + 审批)

一、operation_logs 审计字面字典(换账套类)

action 字面 写入端点 语义 target_type target_id
switch_entity post /api/cma/auth/switch-entity(app/api/auth.py) 改用户自己的会话账套(重签 token、旧 token 下线) entity 目标 entity_id
switch_tenant_state post /api/cma/tenant/switch(app/api/tenant.py) 改平台全局状态记录 system_configs.current_tenant(项目Bot 分发 A2A 任务用;非数据维度) entity 目标 entity_id

为什么两个字面不合并:两者作用域不同(个人会话 vs 全局单值键),合并后审计上不可分辨 「谁把自己切走了」与「谁改了全平台状态」。DAMA 复核提出「统一为 switch_entity 或登记字典」—— 本档取后者(登记不合并),原因是前者会丢语义可分辨性;若后续裁定要求合并,改动面为 app/api/tenant.py 一行字面 + 本表一行(可逆)。

可归属与可过滤(2026-09-22 收口,随本档同一提交落地):

  • operation_logs 无 entity_id 列(仅 user_id / action / target_type / target_id / detail / created_at)。 故「按账套查换账套记录」不靠新列,而靠目标维度:
    SELECT * FROM operation_logs WHERE target_type='entity' AND target_id=? ORDER BY id DESC;
    
  • /tenant/switch 原写 user_id=None(端点无当前用户注入)⇒ 事后无法追操作人。 已改为注入会话用户(user=Depends(require_auth_401) → user_id=user.id), 并把 target_type='entity' / target_id=entity_id 与 switch_entity 对齐。
  • detail JSON 仍保留 entity_id / entity_name / source / previous(人读明细)。

2026-09-23 收口(dama-entity-isolation-sweep-20260923 §二):

  • detail 再补 显式 from→to 与操作者三段:from_entity_id / to_entity_id(原只有 previous 整包, 要人肉解析才知道「从哪来」)、source、operator{user_id,username,name,role}。 entity_id 字段保留(= to_entity_id,兼容既有消费方)。
  • 审计失败不静默:状态键与审计行同一事务提交,审计写入异常 → rollback() (状态变更一并回滚,绝不留「改了但没审计」的窗口)+ logger.error("AUDIT_WRITE_FAILED …") + HTTP 500。 运维可在 logs 里 grep AUDIT_WRITE_FAILED 订阅。
  • GET /tenant/current 无写路径(纯只读、已限 it/ceo)⇒ 不落审计(不给读操作写审计表)。
  • 历史缺口(2026-09-22 10:36 止血那次 POST /tenant/switch 无审计行)不补写:审计不得事后伪造。 该次操作的留痕以 ops 止血单 + 本档 §一「现网实况」为准。

在线实测(2026-09-23,POST /api/cma/tenant/switch 带会话 Bearer,from=1 → to=1): 切换一次 → operation_logs 出现 switch_tenant_state 行(user_id 非空、target_type='entity') ⇒ 「全平台影响动作无审计」的缺口已在生产关闭(实测证据见回报件 §二)。

现网实况(只读核对,2026-09-22 11:15,cma_reader 16/16 PASS): switch_entity = 49 行、switch_tenant_state = 0 行(本单尚未部署 ⇒ 字面是新引入的, 上线前库里无该字面属正常)。ops 止血那次 POST /tenant/switch(10:36:01,部署码)没有审计行—— 部署码该端点无审计,正是本单补齐项。


二、/api/cma/notifications/logs 口径(发送记录页)

  • 归属口径:notification_logs 无 entity_id 列 ⇒ 经父键 join 归属: notification_logs.alert_id → kpi_alerts.id → kpi_alerts.entity_id;按会话账套过滤, 与 batch5 对 kpi_data_quality_log 的处置同口径(宁少勿错)。
  • alert_id IS NULL 的行:无可归属父键 ⇒ 不返回给任何账套(不跨账套暴露)。 现库 8 行历史记录(2026-09-11/12「OKR 季度复盘提醒」)100% 属此类, 写入方 = backend/scripts/okr_review_reminder.py(复用本表留存审计,alert_id 留空)。
  • 上线后现象(须预期,不是故障):/notifications/logs 在 e1/e2 均返回 total=0, 前端发送记录页为空。现役预警推送写入器 app/utils/notifier.py 必带 alert_id, 故此后由预警产生的新记录会正常显示;只有「无父键历史行」永不出现在该页。
  • 页面与接口的提示(随本档同一提交落地):
    • 接口返回 policy="alert-linked-only" + note(说明「仅显示与预警关联的推送」);
    • 前端 NotificationManage.vue 发送记录表 empty-text 写明同一口径。
  • 未做(留待 P2 裁定):为无父键通知单列只读视图(需先给 notification_logs 补账套维度, 属 DDL/迁移,超出「零迁移 B2」范围)。

三、本档未覆盖(另立单,见对应任务文件)

  1. kpi_alerts id 589/590 跨账套错挂(entity_id=1 挂 e2 的 BH_ADM_EXPENSE):数据订正须 另立单 + 任总放行(生产写)。
  2. kpi_id=1 兜底残留清点(9 行、其中 1 行 pending)与归属裁定。
  3. 同源全局键继续拆账套:kpi_alert_higher_better、kpi_visibility(DAMA §7 反向挑错新增漏项)、 budget_mode / budget_driver_mode(batch5 §七 已记录未施工)。 → 2026-09-23 已分流:kpi_alert_higher_better 已拆账套、kpi_visibility 裁定「非账套维度」 关闭(见 global-config-keys-caliber-20260923.md §二/§三);budget_mode / budget_driver_mode 评估为「应拆」但另立 P2 单(原值落账需拍板,见同档 §四)。