Files
XingHuo/app/api/deps.py
T

157 lines
6.4 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:非 dev 且 AUTH_FACTORY_IS_DEBUG 拒绝启动,挂账⑤);
T-01 就绪后替换工厂内部为 JWT 解析并置 AUTH_FACTORY_IS_DEBUG=False,
签名与调用方零改动。
归属断言 `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);响应体为统一错误结构(utils/response,手册 §10,B7 挂账④);
多角色按 fail-closed 口径固化(customer 分支优先,命中 deny 即拒,不再并集
放宽——评审 P3-1②,T-01 引入 token_type 后收紧)。
"""
from __future__ import annotations
from fastapi import Request
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 ApiError, 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"
# B7 挂账⑤:debug 头工厂是 T-01 过渡实现;main.lifespan 据此在非 dev 环境
# 拒绝启动。T-01 接入 JWT 工厂后置 False(或改为按注册工厂判定)。
AUTH_FACTORY_IS_DEBUG = True
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 _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 ApiError(
401, "AUTH_401_MISSING_DEBUG_HEADERS", "missing X-Debug-Role/X-Debug-Actor 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)