2026-09-06 15:18:35 +08:00
|
|
|
|
"""API 依赖:鉴权上下文(架构 §5.7 · 归属校验统一在此层)。
|
|
|
|
|
|
|
2026-09-06 18:37:53 +08:00
|
|
|
|
AuthContext 模型在 A4 冻结(开发计划 v1.1);B6 落地 `get_auth_context()`:
|
|
|
|
|
|
dev(app_env=development)从 `X-Debug-Role` / `X-Debug-Actor` 请求头构造
|
2026-09-06 20:48:50 +08:00
|
|
|
|
(customer 角色 customer_id=actor_id),非 dev 请求时拒绝(启动期全局检查
|
|
|
|
|
|
归 B7 lifespan:非 dev 且 AUTH_FACTORY_IS_DEBUG 拒绝启动,挂账⑤);
|
|
|
|
|
|
T-01 就绪后替换工厂内部为 JWT 解析并置 AUTH_FACTORY_IS_DEBUG=False,
|
|
|
|
|
|
签名与调用方零改动。
|
2026-09-06 18:37:53 +08:00
|
|
|
|
|
|
|
|
|
|
归属断言 `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)。
|
2026-09-06 18:58:48 +08:00
|
|
|
|
所有 403/401 一律经 `deny`/`unauthenticated` 审计(手册 P-05 全局铁律,
|
2026-09-06 20:48:50 +08:00
|
|
|
|
B6 评审 P1-1);响应体为统一错误结构(utils/response,手册 §10,B7 挂账④);
|
|
|
|
|
|
多角色按 fail-closed 口径固化(customer 分支优先,命中 deny 即拒,不再并集
|
|
|
|
|
|
放宽——评审 P3-1②,T-01 引入 token_type 后收紧)。
|
2026-09-06 15:18:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-09-06 20:48:50 +08:00
|
|
|
|
from fastapi import Request
|
2026-09-06 15:18:35 +08:00
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
2026-09-06 18:37:53 +08:00
|
|
|
|
from app.config.settings import settings
|
|
|
|
|
|
from app.repository.core_ro import CoreReadOnlyRepository
|
|
|
|
|
|
from app.repository.risk_repository import RiskRepository
|
2026-09-06 20:48:50 +08:00
|
|
|
|
from app.utils.exceptions import ApiError, PermissionDenied
|
2026-09-06 18:37:53 +08:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-09-06 20:48:50 +08:00
|
|
|
|
# B7 挂账⑤:debug 头工厂是 T-01 过渡实现;main.lifespan 据此在非 dev 环境
|
|
|
|
|
|
# 拒绝启动。T-01 接入 JWT 工厂后置 False(或改为按注册工厂判定)。
|
|
|
|
|
|
AUTH_FACTORY_IS_DEBUG = True
|
|
|
|
|
|
|
2026-09-06 15:18:35 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-06 18:58:48 +08:00
|
|
|
|
def _authz_audit(
|
|
|
|
|
|
risk_repo: RiskRepository,
|
|
|
|
|
|
auth: AuthContext | None,
|
|
|
|
|
|
customer_id: str | None,
|
|
|
|
|
|
code: str,
|
2026-09-06 19:21:15 +08:00
|
|
|
|
agent_type: str = "risk",
|
2026-09-06 18:58:48 +08:00
|
|
|
|
) -> None:
|
2026-09-06 19:21:15 +08:00
|
|
|
|
"""鉴权失败审计(event_type='authz';手册 P-05,B6 评审 P1-1)。
|
|
|
|
|
|
|
|
|
|
|
|
agent_type 按路由归属传入(网关路由传 'platform',复审 P3:与放行审计
|
|
|
|
|
|
同口径,避免按模块检索审计时漏网关越权事件)。
|
|
|
|
|
|
"""
|
2026-09-06 18:58:48 +08:00
|
|
|
|
risk_repo.insert_audit_log(
|
|
|
|
|
|
{
|
|
|
|
|
|
"trace_id": current_trace() or new_trace(),
|
|
|
|
|
|
"event_type": "authz",
|
2026-09-06 19:21:15 +08:00
|
|
|
|
"agent_type": agent_type,
|
2026-09-06 18:58:48 +08:00
|
|
|
|
"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,
|
2026-09-06 19:21:15 +08:00
|
|
|
|
agent_type: str = "risk",
|
2026-09-06 18:58:48 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""越权出口:审计 + 403(全部 403 必经此函数,保证留痕与错误码)。"""
|
2026-09-06 19:21:15 +08:00
|
|
|
|
_authz_audit(risk_repo, auth, customer_id, code, agent_type)
|
2026-09-06 18:58:48 +08:00
|
|
|
|
raise PermissionDenied(code, message or f"forbidden: {code}")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-06 18:37:53 +08:00
|
|
|
|
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:
|
2026-09-06 18:58:48 +08:00
|
|
|
|
# 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,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-09-06 20:48:50 +08:00
|
|
|
|
raise ApiError(
|
|
|
|
|
|
401, "AUTH_401_MISSING_DEBUG_HEADERS", "missing X-Debug-Role/X-Debug-Actor headers"
|
|
|
|
|
|
)
|
2026-09-06 18:37:53 +08:00
|
|
|
|
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,
|
2026-09-06 18:58:48 +08:00
|
|
|
|
core_ro: CoreReadOnlyRepository,
|
|
|
|
|
|
risk_repo: RiskRepository,
|
2026-09-06 18:37:53 +08:00
|
|
|
|
) -> None:
|
|
|
|
|
|
"""G-01 归属断言(customer/advisor/risk_officer;其他角色一律拒绝)。
|
|
|
|
|
|
|
|
|
|
|
|
customer 仅本人;advisor 需 customer_advisor_rel active;risk_officer 全量。
|
|
|
|
|
|
compliance 不在客户业务数据访问白名单(仅审计类读,JWT 手册 §5.3)。
|
2026-09-06 18:58:48 +08:00
|
|
|
|
core_ro/risk_repo 必传(评审 P3-3/P3-4:审计与归属查询不得静默降级)。
|
|
|
|
|
|
多角色 fail-closed:customer 分支 deny 即终止(P3-1② 固化口径)。
|
2026-09-06 18:37:53 +08:00
|
|
|
|
"""
|
|
|
|
|
|
if auth.has_role(*STAFF_FULL_ACCESS_ROLES):
|
|
|
|
|
|
return
|
|
|
|
|
|
if "customer" in auth.roles:
|
|
|
|
|
|
if auth.customer_id == customer_id:
|
|
|
|
|
|
return
|
2026-09-06 18:58:48 +08:00
|
|
|
|
deny(auth, "AUTH_403_NOT_OWNER", risk_repo, customer_id)
|
2026-09-06 18:37:53 +08:00
|
|
|
|
if "advisor" in auth.roles:
|
2026-09-06 18:58:48 +08:00
|
|
|
|
if core_ro.is_advisor_assigned(auth.actor_id, customer_id):
|
2026-09-06 18:37:53 +08:00
|
|
|
|
return
|
2026-09-06 18:58:48 +08:00
|
|
|
|
deny(auth, "AUTH_403_NOT_ASSIGNED", risk_repo, customer_id)
|
|
|
|
|
|
deny(auth, "AUTH_403_SCOPE", risk_repo, customer_id)
|