"""租户状态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=` 与 `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`。 """ 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 # 用户端点:统一要求会话 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 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) 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)。 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 状态记录变更(非数据维度)", }, )) db.commit() return {"success": True, "current": state}