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

87 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 口径登记:审计留痕(`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`)。
故「按账套查换账套记录」不靠新列,而靠**目标维度**:
```sql
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 单**(原值落账需拍板,见同档 §四)。