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
302 lines
12 KiB
Python
302 lines
12 KiB
Python
"""
|
||
角色权限中间件 — 管理会计OS
|
||
4角色: ceo(CEO/总览), finance(财务), business(业务), it(IT/运维)
|
||
权限配置支持从数据库动态加载
|
||
"""
|
||
|
||
from fastapi import Request, HTTPException, Depends
|
||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||
from sqlalchemy.orm import Session
|
||
from app.database import get_db
|
||
from app.models import User, RolePermission
|
||
from typing import Optional
|
||
import secrets
|
||
import json
|
||
|
||
# 角色定义(固定)
|
||
ROLES = {
|
||
"ceo": {"name": "CEO", "priority": 1},
|
||
"finance": {"name": "财务", "priority": 2},
|
||
"business": {"name": "业务", "priority": 3},
|
||
"it": {"name": "IT运维", "priority": 4},
|
||
}
|
||
|
||
# 默认权限(数据库没有时的 fallback)
|
||
DEFAULT_ROUTE_PERMISSIONS = {
|
||
"ceo": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source", "user_manage", "system_config"],
|
||
"finance": ["dashboard", "kpis", "kpi_detail", "maps", "alerts", "ai_analysis", "data_source"],
|
||
"business": ["dashboard", "kpis", "kpi_detail", "alerts"],
|
||
"it": ["dashboard", "kpis", "kpi_detail", "alerts", "data_source", "user_manage", "system_config"],
|
||
}
|
||
|
||
DEFAULT_KPI_VISIBILITY = {
|
||
"ceo": ["*"], # CEO看全部维度
|
||
"finance": ["finance_*"], # 财务只看财务
|
||
"business": ["customer_*", "process_*", "learning_*"], # 业务看客户/流程/学习
|
||
"it": ["*"], # IT看全部(运维)
|
||
}
|
||
|
||
DEFAULT_ACTION_PERMISSIONS = {
|
||
"ceo": ["read", "approve"],
|
||
"finance": ["read", "write", "import", "export"],
|
||
"business": ["read", "write"],
|
||
"it": ["read", "write", "delete", "admin"],
|
||
}
|
||
|
||
import logging
|
||
logger = logging.getLogger("cma.auth")
|
||
|
||
# Redis token 存储(跨 worker 共享)
|
||
try:
|
||
import redis as redis_lib
|
||
_redis = redis_lib.Redis(
|
||
host="127.0.0.1", port=6379, db=1,
|
||
decode_responses=True, socket_connect_timeout=2, socket_timeout=3
|
||
)
|
||
_redis.ping()
|
||
_redis_available = True
|
||
except Exception:
|
||
_redis = None
|
||
_redis_available = False
|
||
logger.warning("Redis不可用,token存储降级到内存(不支持多worker)")
|
||
|
||
# 内存 fallback
|
||
_token_store: dict[str, dict] = {}
|
||
|
||
TOKEN_PREFIX = "cma:token:"
|
||
TOKEN_TTL = 86400 # 24小时
|
||
|
||
# 缓存权限配置(每5分钟刷新)
|
||
_permissions_cache = {"route": None, "action": None, "ts": 0}
|
||
_PERM_CACHE_TTL = 300
|
||
|
||
|
||
def _load_permissions(db: Session = None):
|
||
"""从数据库加载权限配置"""
|
||
import time
|
||
now = time.time()
|
||
if db is None:
|
||
if now - _permissions_cache["ts"] < _PERM_CACHE_TTL:
|
||
return _permissions_cache["route"] or DEFAULT_ROUTE_PERMISSIONS, _permissions_cache["action"] or DEFAULT_ACTION_PERMISSIONS
|
||
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
|
||
|
||
try:
|
||
route_perm = db.query(RolePermission).filter(RolePermission.key == "route_permissions").first()
|
||
action_perm = db.query(RolePermission).filter(RolePermission.key == "action_permissions").first()
|
||
|
||
routes = route_perm.value if route_perm else DEFAULT_ROUTE_PERMISSIONS
|
||
actions = action_perm.value if action_perm else DEFAULT_ACTION_PERMISSIONS
|
||
|
||
_permissions_cache["route"] = routes
|
||
_permissions_cache["action"] = actions
|
||
_permissions_cache["ts"] = now
|
||
|
||
return routes, actions
|
||
except Exception:
|
||
return DEFAULT_ROUTE_PERMISSIONS, DEFAULT_ACTION_PERMISSIONS
|
||
|
||
|
||
def create_token(user_id: int, entity_id: int = None) -> str:
|
||
"""签发token:Redis存储 JSON {user_id, entity_id}(账套模式)
|
||
兼容旧调用 create_token(user_id) → entity_id=None(切换器会重新签发)
|
||
"""
|
||
token = secrets.token_hex(32)
|
||
payload = json.dumps({"user_id": user_id, "entity_id": entity_id}, ensure_ascii=False)
|
||
if _redis_available:
|
||
_redis.setex(f"{TOKEN_PREFIX}{token}", TOKEN_TTL, payload)
|
||
else:
|
||
_token_store[token] = payload
|
||
return token
|
||
|
||
|
||
def _resolve_token_data(token: str) -> dict | None:
|
||
"""解析token → {user_id, entity_id}
|
||
- 新格式 JSON → 返回 dict
|
||
- 旧格式 int(改造前)→ 返回 None(强制下线,账套模式需重新登录)
|
||
- 不存在 → None
|
||
"""
|
||
if _redis_available:
|
||
val = _redis.get(f"{TOKEN_PREFIX}{token}")
|
||
if val is None:
|
||
return None
|
||
try:
|
||
data = json.loads(val)
|
||
if isinstance(data, dict) and "user_id" in data:
|
||
return data
|
||
except (json.JSONDecodeError, ValueError, TypeError):
|
||
pass
|
||
# 旧格式纯 int → 强制下线
|
||
return None
|
||
val = _token_store.get(token)
|
||
if val is None:
|
||
return None
|
||
if isinstance(val, dict):
|
||
return val
|
||
try:
|
||
data = json.loads(val)
|
||
if isinstance(data, dict) and "user_id" in data:
|
||
return data
|
||
except (json.JSONDecodeError, ValueError, TypeError):
|
||
pass
|
||
return None
|
||
|
||
|
||
def _resolve_user_id(token: str) -> int | None:
|
||
data = _resolve_token_data(token)
|
||
return int(data["user_id"]) if data else None
|
||
|
||
|
||
def get_token_entity_id(token: str) -> int | None:
|
||
"""从token解析绑定的entity_id(token不存在/旧格式 → None)"""
|
||
data = _resolve_token_data(token)
|
||
if not data:
|
||
return None
|
||
eid = data.get("entity_id")
|
||
return int(eid) if eid else None
|
||
|
||
|
||
def user_has_entity(db: Session, user_id: int, entity_id: int) -> bool:
|
||
"""校验用户是否被授权访问指定企业(账套授权表 user_entities)"""
|
||
from app.models import UserEntity, Entity
|
||
ent = db.query(Entity).filter(Entity.id == entity_id).first()
|
||
if not ent or ent.status != "active":
|
||
return False
|
||
rel = db.query(UserEntity).filter(
|
||
UserEntity.user_id == user_id,
|
||
UserEntity.entity_id == entity_id,
|
||
).first()
|
||
return rel is not None
|
||
|
||
|
||
def extract_bearer_token(request) -> str | None:
|
||
"""从Request提取Bearer token(无/非Bearer格式 → None)"""
|
||
auth = request.headers.get("Authorization", "")
|
||
if auth.startswith("Bearer "):
|
||
return auth[7:].strip()
|
||
return None
|
||
|
||
|
||
# ── 统一全站鉴权语义(cma-auth-401-403-unify-20260915)────────────────────
|
||
# 口径:**未认证(缺 token / 无效 token)→ 401**;**已认证但无权(跨实体 / 角色不足)→ 403**。
|
||
# FastAPI 的 HTTPBearer(auto_error=True) 对「缺 Authorization 头」返回 403,与
|
||
# require_auth_401 的 401 语义在同站并存(同一种「没带 token」两种状态码)。
|
||
# 现统一为 auto_error=False 自行判空:缺 header / 非 Bearer 格式 → 显式 401;
|
||
# 有 token → 仍按原逻辑校验身份(无效/过期 token → 401)。
|
||
# 403 语义保留:require_role(角色不足)、deps.assert_entity_scope(跨实体无权)。
|
||
_optional_bearer = HTTPBearer(auto_error=False)
|
||
|
||
|
||
def require_auth(
|
||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
|
||
db: Session = Depends(get_db),
|
||
) -> User:
|
||
"""认证依赖(全站统一):缺 token / 非 Bearer 格式 → 401;无效 token → 401。
|
||
|
||
已认证但无权(角色/实体)由 require_role、deps.assert_entity_scope 另行返回 403。
|
||
"""
|
||
if credentials is None or not credentials.credentials:
|
||
raise HTTPException(status_code=401, detail="未认证:缺少 Bearer token")
|
||
token = credentials.credentials
|
||
user_id = _resolve_user_id(token)
|
||
if user_id is None:
|
||
raise HTTPException(401, "无效的token,请重新登录")
|
||
user = db.query(User).filter(User.id == user_id).first()
|
||
if not user:
|
||
raise HTTPException(401, "用户不存在")
|
||
return user
|
||
|
||
|
||
def require_role(*roles: str):
|
||
async def role_checker(
|
||
current_user: User = Depends(require_auth),
|
||
) -> User:
|
||
if current_user.role not in roles:
|
||
raise HTTPException(403, f"权限不足: 需要 {', '.join(roles)} 角色")
|
||
return current_user
|
||
return role_checker
|
||
|
||
|
||
# ── 401 语义的认证依赖(cma-api-auth-surface-audit-20260914)
|
||
# 2026-09-15(cma-auth-401-403-unify-20260915):语义与 _optional_bearer 已统一,
|
||
# 本入口保留作兼容别名(既有 router 与审计器 AUTH_STRICT 均引用此名)。
|
||
def require_auth_401(
|
||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_bearer),
|
||
db: Session = Depends(get_db),
|
||
) -> User:
|
||
"""用户端点统一鉴权入口:缺 Bearer → 401;有 token → require_auth 校验(无效 → 401)
|
||
|
||
2026-09-15(cma-auth-401-403-unify-20260915)起与 require_auth 语义完全一致
|
||
(require_auth 也改为 auto_error=False 自判空 → 缺 token 401)。
|
||
保留本名称:既有 router 的 dependencies=[Depends(require_auth_401)] 与
|
||
backend/scripts/audit_api_auth_surface.py 的 AUTH_STRICT 分类仍引用它。
|
||
"""
|
||
return require_auth(credentials=credentials, db=db)
|
||
|
||
|
||
def has_permission(user: User, module: str, db: Session = None) -> bool:
|
||
routes, _ = _load_permissions(db)
|
||
return module in routes.get(user.role, [])
|
||
|
||
|
||
def has_action(user: User, action: str, db: Session = None) -> bool:
|
||
_, actions = _load_permissions(db)
|
||
return action in actions.get(user.role, [])
|
||
|
||
|
||
# ─── KPI可见性(按维度/分类过滤) ───
|
||
#
|
||
# ⚠️ 口径裁定(2026-09-23 dama-entity-isolation-sweep-20260923 §一②):
|
||
# `role_permissions.key = 'kpi_visibility'` **属「非账套维度」的全局配置,不拆账套**
|
||
# —— 理由:① 值本身是 `{role: [维度规则]}` 的**角色维度**字典,无账套语义;
|
||
# ② 角色枚举(ceo/finance/business/it)是平台级字典,`role_permissions` 表
|
||
# 亦无 `entity_id` 列(`key` unique),「按角色看哪些维度」是集团统一口径;
|
||
# ③ 与 `route_permissions` / `action_permissions` 同族(同为角色级权限面),
|
||
# 若只拆本键会与同表另两键口径分裂。
|
||
# 判别口径:**同表同族的键按同一维度处理**;本键不消费任何账套状态、也不被账套写侧改写。
|
||
# 登记:`docs/global-config-keys-caliber-20260923.md`(键维度字典)。
|
||
|
||
|
||
def _load_kpi_visibility(db: Session = None):
|
||
"""从RolePermission表加载kpi_visibility配置(独立缓存)
|
||
|
||
**非账套维度**(角色级天然全局,口径见上方注释与本档登记):
|
||
只读 `role_permissions.key = 'kpi_visibility'`,**有意不做账套后缀**——
|
||
这是集团共享口径,不是漏隔离。
|
||
"""
|
||
import time
|
||
if not hasattr(_load_kpi_visibility, "_cache"):
|
||
_load_kpi_visibility._cache = {"data": None, "ts": 0}
|
||
cache = _load_kpi_visibility._cache
|
||
now = time.time()
|
||
if db is None or (cache["data"] and now - cache["ts"] < _PERM_CACHE_TTL):
|
||
return cache["data"] or DEFAULT_KPI_VISIBILITY
|
||
try:
|
||
perm = db.query(RolePermission).filter(RolePermission.key == "kpi_visibility").first()
|
||
cache["data"] = perm.value if perm else DEFAULT_KPI_VISIBILITY
|
||
cache["ts"] = now
|
||
return cache["data"]
|
||
except Exception:
|
||
return DEFAULT_KPI_VISIBILITY
|
||
|
||
|
||
def kpi_visible_dims(role: str, db: Session = None) -> list[str]:
|
||
"""返回角色可见的维度列表(空列表=全部可见)"""
|
||
vis = _load_kpi_visibility(db)
|
||
rules = vis.get(role, ["*"])
|
||
if "*" in rules:
|
||
return [] # 空=全部可见
|
||
# 提取维度前缀:finance_* -> finance
|
||
dims = set()
|
||
for r in rules:
|
||
if r.endswith("_*"):
|
||
dims.add(r[:-2])
|
||
return list(dims)
|
||
|
||
|
||
def filter_kpis_by_role(kpis: list, role: str, db: Session = None) -> list:
|
||
"""按角色可见性过滤KPI列表"""
|
||
dims = kpi_visible_dims(role, db)
|
||
if not dims:
|
||
return kpis # 全部可见
|
||
return [k for k in kpis if k.dimension in dims or (hasattr(k, 'dimension') and k.dimension in dims)]
|