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
169 lines
8.2 KiB
Python
169 lines
8.2 KiB
Python
"""租户状态API — CMA系统切换公司时记录当前tenant,供项目Bot分发A2A任务
|
||
|
||
⚠️ 语义收敛(2026-09-22 dama-workbench-entity-isolation-fullstack-20260922,任总已批「都批」)
|
||
--------------------------------------------------------------------------------------
|
||
`system_configs.current_tenant` 是**全局唯一键**(该表无 entity_id 列,`config_key` unique)
|
||
⇒ 一次写入全平台生效。本次事故(2026-09-04 前端企业切换器把它写成 entity_id=2)暴露的
|
||
结构性缺陷正是「全局单值键被当成个人当前视图 / 数据维度」。
|
||
|
||
本文件的收敛口径:
|
||
1. **它不是数据维度**。工作台/看板/报表取数一律用**登录会话的 entity**
|
||
(`app/deps.py::get_entity_id` ← JWT token 绑定),前端本地视图用
|
||
`localStorage.cma_entity_id`(登录窗口写入)。禁止任何取数端点读本键。
|
||
2. 它的用途**只剩**:项目 Bot 分发 A2A 任务时的状态记录(本模块原始设计目的)。
|
||
3. **仅 admin 会话可读**(GET /current):系统无 `admin` 角色(ROLES: ceo/finance/
|
||
business/it),故映射为持有 `admin` 动作权限的 `it` + 业务负责人 `ceo`。
|
||
4. 写(POST /switch)**不额外收紧角色**:项目 Bot 分发任务时需要写该状态,
|
||
收紧会切断其原始用途。
|
||
5. 每次写都落 `operation_logs`(action=`switch_tenant_state`)留痕 —— 修复前本端点
|
||
无审计(ops 止血单假定「走端点即有审计」不成立,已在本单补齐)。
|
||
6. **审计行可归属(2026-09-22 DAMA 独立回查 §4b 后补)**:`user_id` = 当前会话用户
|
||
(原写 None ⇒ 事后无法追操作人);`target_type="entity"` + `target_id=<entity_id>`
|
||
与 `POST /auth/switch-entity`(action=`switch_entity`,`auth.py`)**共用同一目标维度**
|
||
⇒「按账套查换账套记录」= `WHERE target_type='entity' AND target_id=?`(`operation_logs`
|
||
无 `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
|
||
from sqlalchemy.orm import Session
|
||
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。
|
||
router = APIRouter(prefix="/api/cma/tenant", tags=["多租户"],
|
||
dependencies=[Depends(require_auth_401)])
|
||
|
||
TENANT_KEY = "current_tenant"
|
||
|
||
class TenantSwitchRequest(BaseModel):
|
||
entity_id: int
|
||
source: str = "cma-system"
|
||
|
||
|
||
def _tenant_name(entity_id: int) -> str:
|
||
"""entity_id → tenant_id"""
|
||
return "company_b" if entity_id == 2 else "company_a"
|
||
|
||
|
||
@router.get("/current")
|
||
def get_current_tenant(
|
||
db: Session = Depends(get_db),
|
||
user=Depends(require_role("it", "ceo")),
|
||
):
|
||
"""查询当前租户状态记录(**仅 admin 会话**:it/ceo)
|
||
|
||
2026-09-22 收敛:本键**不是**数据维度——前端/接口取数一律按会话 entity,
|
||
不得读本键决定「取哪个实体的数据」(本次工作台跨实体污染的直接触发点)。
|
||
"""
|
||
cfg = db.query(SystemConfig).filter(SystemConfig.config_key == TENANT_KEY).first()
|
||
if cfg and cfg.config_value:
|
||
import json
|
||
try:
|
||
return json.loads(cfg.config_value)
|
||
except:
|
||
pass
|
||
return {"tenant_id": "company_a", "entity_id": 1, "name": "陕西酣客文化传媒"}
|
||
|
||
|
||
@router.post("/switch")
|
||
def switch_tenant(data: TenantSwitchRequest, db: Session = Depends(get_db),
|
||
user=Depends(require_auth_401)):
|
||
"""切换当前租户状态记录(项目Bot 分发 A2A 任务用;CMA前端企业切换器已下线)
|
||
|
||
2026-09-22:写操作**不动**(保留 Bot 通道可用性),补 `operation_logs` 审计留痕。
|
||
"""
|
||
ent = db.query(Entity).filter(Entity.id == data.entity_id).first()
|
||
if not ent:
|
||
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
|
||
|
||
state = {
|
||
"tenant_id": _tenant_name(data.entity_id),
|
||
"entity_id": data.entity_id,
|
||
"name": ent.name,
|
||
"short_name": ent.short_name,
|
||
"source": data.source,
|
||
"switched_at": __import__("datetime").datetime.now().isoformat(),
|
||
}
|
||
|
||
if cfg:
|
||
cfg.config_value = json.dumps(state, ensure_ascii=False)
|
||
else:
|
||
db.add(SystemConfig(
|
||
config_key=TENANT_KEY,
|
||
config_value=json.dumps(state, ensure_ascii=False),
|
||
description="当前租户状态记录(仅供项目Bot分发A2A任务;非数据维度,2026-09-22 语义收敛)",
|
||
))
|
||
|
||
# 审计留痕(2026-09-22 补齐:本端点原无 operation_logs 记录)
|
||
# 2026-09-22 DAMA 回查 §4b 后补:user_id 记操作人(原 None ⇒ 无法追人);
|
||
# target_type/target_id 与 /auth/switch-entity 的 switch_entity 对齐 ⇒ 按账套可过滤
|
||
# (operation_logs 无 entity_id 列,无须 DDL)。
|
||
# 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),
|
||
},
|
||
"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}
|