Files
XingHuo/app/api/deps.py
T
GaoYiYuan_0626 3e40a2bb49 fix: B6 评审修复——鉴权审计统一 deny/401 留痕、start_date/end_date 对齐 PRD、simulate 回挂鉴权、异常与审计注入收敛
- P1-1: 403/401 全部经 deps.deny/_authz_audit 留痕(event_type='authz')
- P2-1: GET /alerts 参数名 start_date/end_date 对齐 PRD;page/page_size 用 fastapi.Query
- P2-2: /api/simulate/trade 回挂 get_auth_context(risk_demo 或本人),越权 403+审计
- P2-3: suitability/aml 直调补审计+request_ref 透传
- P3-1: 多角色 fail-closed 口径固化进测试;P3-3/P3-4 core_ro/risk_repo 必传;
  P3-5 LookupError→NotFoundError 统一;P3-9 alert_service import 上提
- 测试: test_risk_api 权限矩阵/审计断言/多角色组合/非 dev 拒绝;
  test_trade_gateway 客户本人 vs 越权 403(P3-4 模拟 _repo 注入 sqlite)
- 挂账: P3-2/P3-7(响应外壳+disclaimer)→B7;P3-6(scan 幂等)→B9b 前
2026-09-06 18:58:48 +08:00

148 lines
6.1 KiB
Python
Raw 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.
"""API 依赖:鉴权上下文(架构 §5.7 · 归属校验统一在此层)。
AuthContext 模型在 A4 冻结(开发计划 v1.1);B6 落地 `get_auth_context()`:
dev(app_env=development)从 `X-Debug-Role` / `X-Debug-Actor` 请求头构造
(customer 角色 customer_id=actor_id),非 dev 直接拒绝(启动期全局检查归
B7 lifespan);T-01 就绪后仅替换工厂内部为 JWT 解析,签名与调用方零改动。
归属断言 `assert_customer_access` 对齐 JWT 手册 §6.1/§6.2 与 PRD G-01:
customer 仅本人(AUTH_403_NOT_OWNER)、advisor 经 customer_advisor_rel
(AUTH_403_NOT_ASSIGNED)、risk_officer 全量;越权 403 + audit 留痕(A-9)。
所有 403/401 一律经 `deny`/`unauthenticated` 审计(手册 P-05 全局铁律,
B6 评审 P1-1);多角色按 fail-closed 口径固化(customer 分支优先,命中 deny
即拒,不再并集放宽——评审 P3-1②,T-01 引入 token_type 后收紧)。
"""
from __future__ import annotations
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.config.settings import settings
from app.repository.core_ro import CoreReadOnlyRepository
from app.repository.risk_repository import RiskRepository
from app.utils.exceptions import PermissionDenied
from app.utils.trace import current_trace, new_trace
STAFF_FULL_ACCESS_ROLES = ("risk_officer",)
DEBUG_ROLE_HEADER = "X-Debug-Role"
DEBUG_ACTOR_HEADER = "X-Debug-Actor"
class AuthContext(BaseModel):
"""统一鉴权上下文(全部 API 依赖层的产出;service 层签名接收此类型)。"""
actor_id: str = Field(..., description="操作者 ID:staff_id 或 customer_id")
roles: list[str] = Field(default_factory=list, description="角色集合,如 ['risk_officer','risk_demo']")
customer_id: str | None = Field(None, description="customer 角色时 = 本人 customer_id;其余为空")
def has_role(self, *roles: str) -> bool:
return any(r in self.roles for r in roles)
def is_customer(self) -> bool:
return "customer" in self.roles
def permission_denied_handler(request: Request, exc: PermissionDenied) -> JSONResponse:
"""FastAPI 异常 handler:PermissionDenied → 403 + 错误码(B7 注册进 main)。"""
return JSONResponse(status_code=403, content={"detail": str(exc), "code": exc.code})
def _authz_audit(
risk_repo: RiskRepository,
auth: AuthContext | None,
customer_id: str | None,
code: str,
) -> None:
"""鉴权失败审计(event_type='authz';手册 P-05,B6 评审 P1-1)。"""
risk_repo.insert_audit_log(
{
"trace_id": current_trace() or new_trace(),
"event_type": "authz",
"agent_type": "risk",
"actor_id": auth.actor_id if auth else "anonymous",
"customer_id": customer_id,
"rule_id": None,
"input_summary": {"roles": auth.roles if auth else [], "code": code},
"decision": "forbidden",
"risk_score": None,
"handler_id": None,
"handler_result": None,
"handler_comment": None,
}
)
def deny(
auth: AuthContext,
code: str,
risk_repo: RiskRepository,
customer_id: str | None = None,
message: str | None = None,
) -> None:
"""越权出口:审计 + 403(全部 403 必经此函数,保证留痕与错误码)。"""
_authz_audit(risk_repo, auth, customer_id, code)
raise PermissionDenied(code, message or f"forbidden: {code}")
def get_auth_context(request: Request) -> AuthContext:
"""鉴权工厂(B6):dev 读 debug 头,非 dev 拒绝;T-01 后替换内部为 JWT 解析。"""
if settings.app_env != "development":
raise RuntimeError(
f"debug auth disabled outside development (app_env={settings.app_env})"
)
roles = [r.strip() for r in request.headers.get(DEBUG_ROLE_HEADER, "").split(",") if r.strip()]
actor_id = request.headers.get(DEBUG_ACTOR_HEADER, "").strip()
if not roles or not actor_id:
# 401 也留痕(P1-1);debug 通道仅 dev,生产等价流量由 JWT 中间件拒绝
repo = RiskRepository()
repo.insert_audit_log(
{
"trace_id": current_trace() or new_trace(),
"event_type": "authz",
"agent_type": "risk",
"actor_id": actor_id or "anonymous",
"customer_id": None,
"rule_id": None,
"input_summary": {"roles": roles, "code": "AUTH_401_MISSING_DEBUG_HEADERS"},
"decision": "unauthenticated",
"risk_score": None,
"handler_id": None,
"handler_result": None,
"handler_comment": None,
}
)
raise HTTPException(status_code=401, detail="AUTH_401_MISSING_DEBUG_HEADERS")
return AuthContext(
actor_id=actor_id,
roles=roles,
customer_id=actor_id if "customer" in roles else None,
)
def assert_customer_access(
auth: AuthContext,
customer_id: str,
core_ro: CoreReadOnlyRepository,
risk_repo: RiskRepository,
) -> None:
"""G-01 归属断言(customer/advisor/risk_officer;其他角色一律拒绝)。
customer 仅本人;advisor 需 customer_advisor_rel active;risk_officer 全量。
compliance 不在客户业务数据访问白名单(仅审计类读,JWT 手册 §5.3)。
core_ro/risk_repo 必传(评审 P3-3/P3-4:审计与归属查询不得静默降级)。
多角色 fail-closed:customer 分支 deny 即终止(P3-1② 固化口径)。
"""
if auth.has_role(*STAFF_FULL_ACCESS_ROLES):
return
if "customer" in auth.roles:
if auth.customer_id == customer_id:
return
deny(auth, "AUTH_403_NOT_OWNER", risk_repo, customer_id)
if "advisor" in auth.roles:
if core_ro.is_advisor_assigned(auth.actor_id, customer_id):
return
deny(auth, "AUTH_403_NOT_ASSIGNED", risk_repo, customer_id)
deny(auth, "AUTH_403_SCOPE", risk_repo, customer_id)