Files
group_xinghuo_jinrong/app/api/deps.py
T
GaoYiYuan_0626 90d0660b44 feat: C5 FR-9 预警处置时效升级(RISK-007,P0)
- 仓储:新增 list_pending_alerts_all / update_alert_escalation(统一读改写 payload,
  升级标记独占写入,status/handler_* 列不碰;_parse_alert 补 created_at/handled_at
  字符串→datetime 解析,兼容 sqlite 原生 DDL)
- escalation_service.scan_and_escalate:扫描判级(普通 4h/24h、AML 1h/4h 短通道)、
  幂等闸门(仅升不降)、先持久化再推送、按 (customer_id,level) 降噪合并一次推送、
  通知链累积(L1 含 risk_manager / L2 含 compliance)、逐单+任务级审计
- scripts/cron/escalation_scan.py:15min 定时扫描壳(sys.path 引导 + new_trace + JSON 摘要)
- 对话线 query_overdue_alerts Tool + 注册表 + 意图词(置于 alert_query 之前)+ summarize 分支
- C5 前置:seed STAFF-31001/31002(risk_manager) + deps 矩阵放行 + chat.py 显式 deny +
  risk.py 台账全量只读分支;JWT 手册 §5.3/§5.4/§6.1 增补 risk_manager
- 测试:conftest 回拨 fixture + test_escalation_service(9) + risk_api/manager(5) +
  chat deny(1) + chat_tools overdue(2);全量 470 绿(453+17)
2026-09-07 19:28:04 +08:00

306 lines
14 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()`;
T-01(2026-09-07)工厂内部替换为 JWT 解析(auth_service.verify_token,
手册 §4/§8),`Authorization: Bearer` 为所有环境首选通道:
- JWT 通道:验签 + claims + jti 吊销 + `X-Agent-Type` 交叉校验(手册 §4.6
必填、§5.4 准入矩阵;缺失 AUTH_401_MISSING_AGENT_TYPE、不符
AUTH_403_AGENT_MISMATCH);
- debug 头兜底:仅 development 无 Bearer 时从 `X-Debug-Role`/`X-Debug-Actor`
构造(B6 过渡口径,演示 SOP 与既有测试依赖此通道;不校验 X-Agent-Type);
非 dev 无 Bearer 一律 401 留痕(AUTH_401_MISSING_BEARER)。
归属断言 `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②)。
"""
from __future__ import annotations
import logging
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.service.auth_service import Claims, TokenInvalid, verify_token
from app.utils.authz import AGENT_TYPES, record_authz_denial
from app.utils.exceptions import ApiError, PermissionDenied
from app.utils.trace import current_trace, new_trace
logger = logging.getLogger(__name__)
STAFF_FULL_ACCESS_ROLES = ("risk_officer",)
DEBUG_ROLE_HEADER = "X-Debug-Role"
DEBUG_ACTOR_HEADER = "X-Debug-Actor"
AGENT_TYPE_HEADER = "X-Agent-Type"
# 手册 §5.4 Agent × 角色/token_type 准入矩阵(JWT 通道强制;debug 头通道
# 维持 B6 路由内角色口径——演示 SOP 的 compliance 台账只读属其扩展)
AGENT_ACCESS_MATRIX: dict[str, dict[str, tuple[str, ...]]] = {
"customer": {"token_types": ("customer",), "roles": ("customer",)},
"advisor": {"token_types": ("staff",), "roles": ("advisor", "compliance", "ops")},
"analyst": {"token_types": ("staff",), "roles": ("analyst", "compliance")},
# C5(PRD 4A.1):增补 risk_manager——HTTP 台账请求经此交叉校验,
# 不加则 manager 连 GET /api/risk/alerts 都会被 AUTH_403_AGENT_MISMATCH 挡掉。
# 对话线不放行(FR-6 冻结口径),由 chat.py 显式 deny 兜底。
"risk": {
"token_types": ("staff", "service"),
"roles": ("risk_officer", "risk_manager", "service_risk"),
},
}
# T-01 完成:JWT 已接入,debug 头降级为 dev 兜底(main.lifespan 据此放行
# 非 dev 启动,改为校验 jwt_ready)。
AUTH_FACTORY_IS_DEBUG = False
class AuthContext(BaseModel):
"""统一鉴权上下文(全部 API 依赖层的产出;service 层签名接收此类型)。
T-01 扩展(手册 §8.1):token_type/permissions/tenant_id/jti;debug 头
通道 token_type 按角色推断(customer 角色 → customer,其余 staff),
permissions/tenant_id/jti 为空(过渡口径)。
"""
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;其余为空")
token_type: str = Field("staff", description="customer | staff | service(手册 §3)")
permissions: list[str] = Field(default_factory=list, description="细粒度权限(JWT claims)")
tenant_id: str | None = Field(None, description="租户/法人主体")
jti: str | None = Field(None, description="Token 唯一 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 has_permission(self, permission: str) -> bool:
return permission in self.permissions
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)。
T-02 双写(挂账⑧收口):audit_log + input_guard_log(P-05 要求双留痕;
guard_type 用 ENUM 四值内的 illegal_param 承载鉴权拒绝类)。agent_type=
'platform'(simulate 网关路由)时跳过 input_guard_log——该表 ENUM 仅
四 Agent 入口,网关越权仅 audit_log 留痕(复审 P3 同口径)。
评审 P2-5:审计写库失败降级 warning(同 _unauthenticated_audit 口径,
拒绝语义优先;本地日志留底,生产可切 fail-closed)。
T-04 评审 P1-2:实际实现已下沉到 app/utils/authz.py——对话 Tool 层的
归属拒绝共用同一出口,避免 service 反向依赖 api。
"""
record_authz_denial(
risk_repo,
actor_id=auth.actor_id if auth else "anonymous",
roles=auth.roles if auth else [],
customer_id=customer_id,
code=code,
agent_type=agent_type,
)
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 _unauthenticated_audit(
risk_repo: RiskRepository,
actor_id: str,
code: str,
agent_type: str = "platform",
) -> None:
"""401 留痕(P-05;B6 debug 通道既有口径,T-01 推广到 JWT 通道)。
agent_type 默认 'platform':401 发生在准入判定之前,请求目标 agent 不可
信(防攻击者打 customer agent 的 401 污染 risk 审计维度,评审 P3-2);
input_guard_log 双写仍限四 Agent ENUM(platform 自动跳过)。
T-02 评审闭环(P2-5):审计写库失败降级 warning(本地日志留底)——拒绝
语义优先(401/403 不因留痕故障漂移为 500),生产可切 fail-closed。
"""
trace_id = current_trace() or new_trace()
try:
risk_repo.insert_audit_log(
{
"trace_id": trace_id,
"event_type": "authz",
"agent_type": agent_type,
"actor_id": actor_id or "anonymous",
"customer_id": None,
"rule_id": None,
"input_summary": {"code": code},
"decision": "unauthenticated",
"risk_score": None,
"handler_id": None,
"handler_result": None,
"handler_comment": None,
}
)
if agent_type in AGENT_TYPES:
risk_repo.insert_input_guard_log(
trace_id=trace_id,
agent_type=agent_type,
actor_id=actor_id or "anonymous",
guard_type="illegal_param",
action="blocked",
raw_excerpt=code,
)
except Exception:
logger.exception("unauthenticated audit failed (degraded): code=%s", code)
def _safe_unauth_audit(actor_id: str, code: str) -> None:
"""401 留痕统一出口(评审 P2-5 显式化):仓储构造/写库失败降级 warning,
拒绝语义(401)不因留痕故障漂移为 500;本地日志留底,生产可切 fail-closed。"""
try:
_unauthenticated_audit(RiskRepository(), actor_id, code)
except Exception:
logger.exception("unauthenticated audit failed (degraded): code=%s", code)
def _claims_to_auth(claims: Claims) -> AuthContext:
"""JWT claims → AuthContext(手册 §8.1;customer_id 仅 customer token)。"""
return AuthContext(
actor_id=claims.sub,
roles=claims.roles,
customer_id=claims.customer_id if claims.token_type == "customer" else None,
token_type=claims.token_type,
permissions=claims.permissions,
tenant_id=claims.tenant_id,
jti=claims.jti,
)
def _bind_state(request: Request, auth: AuthContext) -> AuthContext:
"""auth 注入 request.state(T-02 访问审计取 actor;JWT/debug 通道统一出口)。"""
request.state.auth = auth
return auth
def assert_agent_access(
auth: AuthContext,
agent_type: str,
risk_repo: RiskRepository | None = None,
) -> None:
"""Agent 入口准入(手册 §5.4 矩阵;chat 等显式入口调用)。
不匹配 → AUTH_403_AGENT_MISMATCH + 审计(deny)。risk_repo 传 None 时
仅抛异常不留痕(仅限 JWT 通道已审计的交叉校验复用路径)。
"""
rule = AGENT_ACCESS_MATRIX.get(agent_type)
if rule is None:
raise ApiError(400, "BAD_REQUEST", f"unknown agent_type: {agent_type}")
if auth.token_type in rule["token_types"] and set(auth.roles) & set(rule["roles"]):
return
if risk_repo is not None:
deny(auth, "AUTH_403_AGENT_MISMATCH", risk_repo, message=f"not allowed for agent '{agent_type}'")
raise PermissionDenied("AUTH_403_AGENT_MISMATCH", f"not allowed for agent '{agent_type}'")
def get_auth_context(request: Request) -> AuthContext:
"""鉴权工厂(T-01):Bearer JWT 优先(全环境),dev 无 Bearer 时 debug 头兜底。
JWT 通道强制 `X-Agent-Type` 交叉校验(手册 §4.6/§5.4):缺失 401、
值域外 400(鉴权相关拒绝,同留痕 P3-1)、与 token 不符 403(均留痕)。
debug 头兜底双闸门(评审 P2-4 纵深防御):仅当 APP_ENV=development 且
未配置 RS256 公钥(jwt_public_key_path 非空即视为生产形态,无条件禁用),
防生产误配 APP_ENV=development 放开无签名身份。
"""
auth_header = request.headers.get("Authorization", "")
if auth_header[:7].lower() == "bearer ": # RFC 6750 scheme 大小写不敏感(评审 P3-3)
token = auth_header[7:].strip()
if not token:
_safe_unauth_audit("anonymous", "AUTH_401_INVALID_TOKEN")
raise ApiError(401, "AUTH_401_INVALID_TOKEN", "empty bearer token")
try:
claims = verify_token(token)
except TokenInvalid as exc:
_safe_unauth_audit("anonymous", exc.code)
raise ApiError(401, exc.code, exc.message) from exc
auth = _claims_to_auth(claims)
agent_type = request.headers.get(AGENT_TYPE_HEADER, "").strip()
if not agent_type:
_safe_unauth_audit(auth.actor_id, "AUTH_401_MISSING_AGENT_TYPE")
raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE", f"missing {AGENT_TYPE_HEADER} header")
if agent_type not in AGENT_TYPES:
_safe_unauth_audit(auth.actor_id, "AUTH_400_INVALID_AGENT_TYPE")
raise ApiError(400, "BAD_REQUEST", f"invalid {AGENT_TYPE_HEADER}: {agent_type}")
# 交叉校验失败也走 deny 全量审计(fail-closed;agent_type 归请求目标)
assert_agent_access(auth, agent_type, risk_repo=RiskRepository())
return _bind_state(request, auth)
if settings.app_env != "development" or settings.jwt_public_key_path:
_safe_unauth_audit("anonymous", "AUTH_401_MISSING_BEARER")
raise ApiError(401, "AUTH_401_MISSING_BEARER", "missing Authorization bearer token")
# dev debug 头兜底(B6 过渡口径;演示 SOP 与既有权限矩阵测试依赖此通道)
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 中间件拒绝
_safe_unauth_audit(actor_id, "AUTH_401_MISSING_DEBUG_HEADERS")
raise ApiError(
401, "AUTH_401_MISSING_DEBUG_HEADERS", "missing X-Debug-Role/X-Debug-Actor headers"
)
return _bind_state(
request,
AuthContext(
actor_id=actor_id,
roles=roles,
customer_id=actor_id if "customer" in roles else None,
token_type="customer" if "customer" in roles else "staff",
),
)
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)