Files
cma-management/backend/scripts/inspect_entity_isolation_sweep_20260923.py
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

93 lines
4.1 KiB
Python
Raw Permalink 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.
#!/usr/bin/env python3
"""只读侦查(生产读,本单 dama-entity-isolation-sweep-20260923 §一/§二 前置)
只 SELECT,无任何写。用于:
1. system_configs / role_permissions 全量键实况(含 kpi_alert_higher_better / budget_mode /
budget_driver_mode / current_tenant / cash.current_balance.* / alignment_config.*)
2. operation_logs 实况(action 分布、switch_entity / switch_tenant_state 行数、最近行)
3. entities 实况(账套清单)
4. kpi_definitions 列面(是否有自身方向字段)+ 逐账套 KPI 分布
用法: python3 <this> [--json out.json]
"""
import json
import os
import sys
from pathlib import Path
BACKEND = Path("/srv/hermes/shared/cma-management/backend")
os.chdir(BACKEND)
sys.path.insert(0, str(BACKEND))
from sqlalchemy import text # noqa: E402
from app.database import get_engine # noqa: E402
out = {}
eng = get_engine()
with eng.connect() as c:
out["db"] = c.execute(text("SELECT DATABASE()")).scalar()
out["system_configs"] = [
{"id": r[0], "config_key": r[1], "config_value": r[2], "description": r[3],
"updated_at": str(r[4])}
for r in c.execute(text(
"SELECT id, config_key, config_value, description, updated_at "
"FROM system_configs ORDER BY config_key"))
]
out["role_permissions"] = [
{"id": r[0], "key": r[1], "value": r[2], "updated_at": str(r[3])}
for r in c.execute(text("SELECT id, `key`, `value`, updated_at FROM role_permissions ORDER BY `key`"))
]
out["entities"] = [
{"id": r[0], "name": r[1], "short_name": r[2], "status": r[3]}
for r in c.execute(text("SELECT id, name, short_name, status FROM entities ORDER BY id"))
]
out["op_logs_actions"] = [
{"action": r[0], "n": r[1], "last": str(r[2])}
for r in c.execute(text(
"SELECT action, COUNT(*) n, MAX(created_at) last FROM operation_logs "
"GROUP BY action ORDER BY n DESC"))
]
out["op_logs_switch_rows"] = [
{"id": r[0], "user_id": r[1], "action": r[2], "target_type": r[3], "target_id": r[4],
"detail": (r[5] or "")[:300], "created_at": str(r[6])}
for r in c.execute(text(
"SELECT id, user_id, action, target_type, target_id, detail, created_at "
"FROM operation_logs WHERE action LIKE '%switch%' OR action LIKE '%tenant%' "
"ORDER BY id DESC LIMIT 10"))
]
out["op_logs_recent"] = [
{"id": r[0], "user_id": r[1], "action": r[2], "target_type": r[3], "target_id": r[4],
"created_at": str(r[5])}
for r in c.execute(text(
"SELECT id, user_id, action, target_type, target_id, created_at "
"FROM operation_logs ORDER BY id DESC LIMIT 12"))
]
out["system_configs_cols"] = [
{"col": r[0], "type": r[1], "null": r[2], "key": r[3], "default": str(r[4])}
for r in c.execute(text(
"SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT "
"FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() "
"AND TABLE_NAME IN ('system_configs','role_permissions','operation_logs') "
"ORDER BY TABLE_NAME, ORDINAL_POSITION"))
]
out["kpi_def_cols"] = [
r[0] for r in c.execute(text(
"SELECT COLUMN_NAME FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='kpi_definitions' "
"ORDER BY ORDINAL_POSITION"))
]
out["kpi_by_entity"] = [
{"entity_id": r[0], "n": r[1]} for r in c.execute(text(
"SELECT entity_id, COUNT(*) n FROM kpi_definitions GROUP BY entity_id ORDER BY entity_id"))
]
out["budgetplan_by_entity"] = [
{"entity_id": r[0], "n": r[1]} for r in c.execute(text(
"SELECT kd.entity_id, COUNT(*) n FROM budget_plans bp "
"JOIN kpi_definitions kd ON kd.id=bp.kpi_id GROUP BY kd.entity_id ORDER BY kd.entity_id"))
]
print(json.dumps(out, ensure_ascii=False, indent=1, default=str))
if "--json" in sys.argv:
p = sys.argv[sys.argv.index("--json") + 1]
Path(p).write_text(json.dumps(out, ensure_ascii=False, indent=1, default=str))
print("WROTE", p)