fix(cma): 隔离扫尾 — kpi_alert_higher_better 拆账套 + tenant/switch 审计收口
ci/woodpecker/push/main Pipeline was canceled
ci/woodpecker/push/main Pipeline was canceled
任务 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
This commit is contained in:
+51
-13
@@ -24,6 +24,15 @@
|
||||
无 `entity_id` 列,无需 DDL)。两字面**刻意不合并**:一个改「用户自己的会话账套」、
|
||||
一个改「平台全局状态记录」,合并后审计上不可分辨。口径登记见
|
||||
`docs/audit-and-notification-caliber-20260922.md`。
|
||||
7. **审计缺口的收口(2026-09-23 dama-entity-isolation-sweep-20260923 §二)**:
|
||||
- `detail` 显式给出 `from_entity_id → to_entity_id`(原先只有 `previous` 整包,
|
||||
要人肉解析才知道「从哪来」)+ `source` + `operator`(id/username/role)。
|
||||
- **审计失败不静默**:状态键与审计行走**同一事务**,审计写入异常 → `rollback()`
|
||||
(状态变更一并回滚,绝不留「改了但没审计」的窗口)+ `logger.error(AUDIT_WRITE_FAILED…)`
|
||||
+ HTTP 500。日志前缀 `AUDIT_WRITE_FAILED` 供运维 grep/告警链订阅。
|
||||
- `GET /current` **无写路径**(纯只读,且已限 it/ceo),故不落审计——不给读操作
|
||||
写审计表,避免审计面被读流量稀释。
|
||||
- 历史缺口(2026-09-22 止血操作无审计行)**不补写**:审计不得事后伪造。
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
@@ -32,6 +41,10 @@ from app.database import get_db
|
||||
from app.auth_middleware import require_auth_401, require_role
|
||||
from app.models import SystemConfig, Entity, OperationLog
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger("cma.tenant")
|
||||
|
||||
# 用户端点:统一要求会话 Bearer 鉴权(cma-api-auth-surface-audit-20260914)
|
||||
# 缺口:本 router 原无鉴权——无 token 可读当前租户、并可 POST /switch 改写
|
||||
# system_config.current_tenant(状态改写面)。收口为 require_auth_401。
|
||||
@@ -82,11 +95,14 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
||||
raise HTTPException(404, "企业不存在")
|
||||
|
||||
import json
|
||||
from_entity_id = None
|
||||
previous = None
|
||||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||||
if cfg and cfg.config_value:
|
||||
try:
|
||||
previous = json.loads(cfg.config_value)
|
||||
if isinstance(previous, dict):
|
||||
from_entity_id = previous.get("entity_id")
|
||||
except Exception:
|
||||
previous = None
|
||||
|
||||
@@ -112,19 +128,41 @@ def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
||||
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
|
||||
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
|
||||
# (operation_logs 无 entity_id 列,无须 DDL)。
|
||||
db.add(OperationLog(
|
||||
user_id=user.id,
|
||||
action="switch_tenant_state",
|
||||
target_type="entity",
|
||||
target_id=data.entity_id,
|
||||
detail={
|
||||
"entity_id": data.entity_id,
|
||||
"entity_name": ent.name,
|
||||
"source": data.source,
|
||||
"previous": previous,
|
||||
"note": "current_tenant 状态记录变更(非数据维度)",
|
||||
# 2026-09-23 收口:detail 显式 from→to / source / operator;审计失败不静默(见 §7)。
|
||||
audit_detail = {
|
||||
"from_entity_id": from_entity_id,
|
||||
"to_entity_id": data.entity_id,
|
||||
"entity_id": data.entity_id, # 兼容既有消费方/用例(= to)
|
||||
"entity_name": ent.name,
|
||||
"source": data.source,
|
||||
"operator": {
|
||||
"user_id": user.id,
|
||||
"username": getattr(user, "username", None),
|
||||
"name": getattr(user, "name", None),
|
||||
"role": getattr(user, "role", None),
|
||||
},
|
||||
))
|
||||
db.commit()
|
||||
"previous": previous,
|
||||
"note": "current_tenant 状态记录变更(非数据维度)",
|
||||
}
|
||||
try:
|
||||
db.add(OperationLog(
|
||||
user_id=user.id,
|
||||
action="switch_tenant_state",
|
||||
target_type="entity",
|
||||
target_id=data.entity_id,
|
||||
detail=audit_detail,
|
||||
))
|
||||
# 状态键与审计行同一事务提交:审计写不进 ⇒ 状态变更一并回滚
|
||||
# (绝不留「改了但没审计」的窗口;失败必须可见,不得静默)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.error(
|
||||
"AUDIT_WRITE_FAILED tenant/switch from=%s to=%s user_id=%s source=%s "
|
||||
"→ 审计写入失败,状态变更已回滚(无审计不得留变更)",
|
||||
from_entity_id, data.entity_id, getattr(user, "id", None), data.source,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(status_code=500, detail="审计写入失败,状态变更已回滚")
|
||||
|
||||
return {"success": True, "current": state}
|
||||
|
||||
Reference in New Issue
Block a user