Files
XingHuo/app/api/deps.py
T
GaoYiYuan_0626 a78e9d4d31 fix: B6 复审收口——测试 Redis 隔离/网关审计口径 platform/死常量清理/回归断言补强
- P2: test_risk_api env 注入 FakePublisher,aml 推送不触真 Redis
- P3: deny/_authz_audit 增 agent_type 参数,simulate 越权审计传 platform
  (与网关放行审计同口径);删 HANDLE_RESULTS 死常量(Literal 单处定义)
- P3: 补 start_date/end_date 过滤+分页 422 边界+suitability api 层审计断言;
  补 app/service/risk/__init__.py
- 挂账: B7 行新增④项(启动期拒绝/引擎工厂覆盖三实例化点/handle 原子性/
  input_guard_log);B9b 行核查单(disclaimer/scan 幂等/Swagger 手测/analyst);
  TODO.md T-30/T-31/T-32 进度同步
2026-09-06 19:21:15 +08:00

154 lines
6.3 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,
agent_type: str = "risk",
) -> None:
"""鉴权失败审计(event_type='authz';手册 P-05,B6 评审 P1-1)。
agent_type 按路由归属传入(网关路由传 'platform',复审 P3:与放行审计
同口径,避免按模块检索审计时漏网关越权事件)。
"""
risk_repo.insert_audit_log(
{
"trace_id": current_trace() or new_trace(),
"event_type": "authz",
"agent_type": agent_type,
"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,
agent_type: str = "risk",
) -> None:
"""越权出口:审计 + 403(全部 403 必经此函数,保证留痕与错误码)。"""
_authz_audit(risk_repo, auth, customer_id, code, agent_type)
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)