From 61711cb70ae38e0efef430995b87a59ee5df019b Mon Sep 17 00:00:00 2001 From: YUAN Date: Mon, 7 Sep 2026 03:03:12 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20Wave=200=20=E7=8B=AC=E7=AB=8B=20AI=20?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E9=97=AD=E7=8E=AF(=E6=9C=89=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E9=80=9A=E8=BF=87=E2=86=92=E9=97=AD=E7=8E=AF,=20273?= =?UTF-8?q?=20=E7=BB=BF)=E2=80=94=E2=80=94P2-1=20python-jose>=3D3.5.0(?= =?UTF-8?q?=E9=98=B2=20CVE-2024-33663/33664)=20P2-2=20audit=20=E9=99=8D?= =?UTF-8?q?=E7=BA=A7+OPTIONS=20=E6=B5=8B=E8=AF=95=20P2-3=20JWT=20=E9=80=9A?= =?UTF-8?q?=E9=81=93=20chat=203=20=E4=BE=8B=20P2-4=20debug=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E5=8F=8C=E9=97=B8=E9=97=A8(dev+=E6=97=A0=20RS256=20?= =?UTF-8?q?=E5=85=AC=E9=92=A5)=20P2-5=20=E7=95=99=E7=97=95=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E9=99=8D=E7=BA=A7=E6=98=BE=E5=BC=8F=E5=8C=96(401/403?= =?UTF-8?q?=20=E6=8B=92=E7=BB=9D=E8=AF=AD=E4=B9=89=E4=B8=8D=E6=BC=82?= =?UTF-8?q?=E7=A7=BB,=20=5Fsafe=5Funauth=5Faudit=20=E4=BF=9D=E6=8A=A4=20re?= =?UTF-8?q?po=20=E6=9E=84=E9=80=A0);=20P3=20=E9=A1=BA=E6=89=8B:=20401=20?= =?UTF-8?q?=E5=BD=92=20platform/=E5=80=BC=E5=9F=9F=E5=A4=96=20400=20?= =?UTF-8?q?=E8=A1=A5=E7=95=99=E7=97=95/Bearer=20=E5=A4=A7=E5=B0=8F?= =?UTF-8?q?=E5=86=99=E4=B8=8D=E6=95=8F=E6=84=9F/jti=20=E5=8A=A0=20uuid/CLI?= =?UTF-8?q?=20permissions+exp=20=E4=B8=8A=E9=99=90/dev=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=AF=86=E9=92=A5=20warning/LLM=20=E5=8D=95=E4=BE=8B?= =?UTF-8?q?=E5=8A=A0=E9=94=81/chat=20=E4=B8=BB=E8=A7=92=E8=89=B2=E4=BC=98?= =?UTF-8?q?=E5=85=88=E5=BA=8F+message=20strip+guard=20=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=84=B1=E6=95=8F/=E5=BF=85=E5=A1=AB=20claims=20=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=8C=96+none=20=E7=AE=97=E6=B3=95=E5=9B=BA=E5=8C=96;?= =?UTF-8?q?=20P3=20=E7=99=BB=E8=AE=B0=E9=A1=B9(=E4=BC=9A=E8=AF=9D=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE/seq=5Fno=20=E9=94=81/trace=5Fid=20=E5=8F=A3=E5=BE=84/?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=90=8C=E6=AD=A5)=E5=BD=92=20M4=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/chat.py | 20 +++- app/api/deps.py | 149 ++++++++++++++++++------------ app/main.py | 8 ++ app/service/agent_service.py | 22 +++-- app/service/auth_service.py | 4 +- app/service/risk/redis_gateway.py | 7 +- requirements.txt | 2 +- scripts/dev/issue_dev_token.py | 10 +- tests/test_audit_middleware.py | 28 +++++- tests/test_auth_jwt.py | 37 +++++++- tests/test_chat.py | 38 ++++++++ 11 files changed, 238 insertions(+), 87 deletions(-) diff --git a/app/api/chat.py b/app/api/chat.py index a8c2247..7bc5cf0 100644 --- a/app/api/chat.py +++ b/app/api/chat.py @@ -62,6 +62,11 @@ class ChatRequest(BaseModel): def _primary_role(auth: AuthContext) -> str: + """会话主角色(agent_session.actor_role):按 Agent 边界优先序取(评审 P3-11, + 多角色 token 落库稳定)。""" + for role in ("customer", "advisor", "analyst", "risk_officer", "compliance", "ops"): + if role in auth.roles: + return role return auth.roles[0] if auth.roles else "unknown" @@ -71,7 +76,7 @@ def _resolve_customer_id( """会话关联客户:customer 强制本人;其余角色指定时过 G-01 归属校验。""" if agent_type == "customer": if requested and requested != auth.customer_id: - deny(auth, "AUTH_403_NOT_OWNER", _repo(), customer_id=requested) + deny(auth, "AUTH_403_NOT_OWNER", _repo(), customer_id=requested, agent_type=agent_type) return auth.customer_id if requested: assert_customer_access(auth, requested, core_ro=_core_ro(), risk_repo=_repo()) @@ -83,11 +88,15 @@ def _resolve_customer_id( def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get_auth_context)) -> dict: agent_type = request.headers.get("X-Agent-Type", "").strip() if not agent_type: - raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE", f"missing X-Agent-Type header") + raise ApiError(401, "AUTH_401_MISSING_AGENT_TYPE", "missing X-Agent-Type header") if agent_type not in AGENT_TYPES: raise ApiError(400, "BAD_REQUEST", f"invalid X-Agent-Type: {agent_type}") assert_agent_access(auth, agent_type, risk_repo=_repo()) + message = req.message.strip() + if not message: + raise ApiError(400, "BAD_REQUEST", "message is blank") + customer_id = _resolve_customer_id(auth, agent_type, req.customer_id) session_repo = _session_repo() @@ -103,6 +112,7 @@ def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get _repo(), customer_id=session.get("customer_id"), message="session belongs to another actor or agent", + agent_type=agent_type, ) if session["status"] != "active": raise ApiError(409, "STATE_CONFLICT", f"session is {session['status']}") @@ -121,13 +131,13 @@ def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get ) history = memory_service.get_recent(agent_type, sid) - result = agent_service.chat(agent_type, history, req.message) + result = agent_service.chat(agent_type, history, message) # 落盘:user + assistant 同步写(异步化归后续);同 trace_id 贯通 trace_id = current_trace() seq = session_repo.next_seq_no(sid) session_repo.insert_message( - session_id=sid, trace_id=trace_id, seq_no=seq, role="user", content=req.message + session_id=sid, trace_id=trace_id, seq_no=seq, role="user", content=message ) session_repo.insert_message( session_id=sid, @@ -141,7 +151,7 @@ def chat_api(req: ChatRequest, request: Request, auth: AuthContext = Depends(get agent_type, sid, [ - {"role": "user", "content": req.message}, + {"role": "user", "content": message}, {"role": "assistant", "content": result["reply"]}, ], ) diff --git a/app/api/deps.py b/app/api/deps.py index ce88f6e..63d6d90 100644 --- a/app/api/deps.py +++ b/app/api/deps.py @@ -21,6 +21,8 @@ B6 评审 P1-1);响应体为统一错误结构(utils/response,手册 §1 from __future__ import annotations +import logging + from fastapi import Request from pydantic import BaseModel, Field @@ -31,6 +33,8 @@ from app.service.auth_service import Claims, TokenInvalid, verify_token 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" @@ -90,33 +94,38 @@ def _authz_audit( guard_type 用 ENUM 四值内的 illegal_param 承载鉴权拒绝类)。agent_type= 'platform'(simulate 网关路由)时跳过 input_guard_log——该表 ENUM 仅 四 Agent 入口,网关越权仅 audit_log 留痕(复审 P3 同口径)。 + 评审 P2-5:审计写库失败降级 warning(同 _unauthenticated_audit 口径, + 拒绝语义优先;本地日志留底,生产可切 fail-closed)。 """ trace_id = current_trace() or new_trace() - risk_repo.insert_audit_log( - { - "trace_id": trace_id, - "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, - } - ) - if agent_type in AGENT_TYPES: - risk_repo.insert_input_guard_log( - trace_id=trace_id, - agent_type=agent_type, - actor_id=auth.actor_id if auth else "anonymous", - guard_type="illegal_param", - action="blocked", - raw_excerpt=code, + try: + risk_repo.insert_audit_log( + { + "trace_id": trace_id, + "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, + } ) + if agent_type in AGENT_TYPES: + risk_repo.insert_input_guard_log( + trace_id=trace_id, + agent_type=agent_type, + actor_id=auth.actor_id if auth else "anonymous", + guard_type="illegal_param", + action="blocked", + raw_excerpt=code, + ) + except Exception: + logger.exception("authz audit failed (degraded): code=%s", code) def deny( @@ -136,38 +145,54 @@ def _unauthenticated_audit( risk_repo: RiskRepository, actor_id: str, code: str, - agent_type: str = "risk", + agent_type: str = "platform", ) -> None: """401 留痕(P-05;B6 debug 通道既有口径,T-01 推广到 JWT 通道)。 - T-02 双写 input_guard_log(同 _authz_audit 口径;agent_type 限四 Agent)。 + 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() - 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, + 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: @@ -213,33 +238,37 @@ def get_auth_context(request: Request) -> AuthContext: """鉴权工厂(T-01):Bearer JWT 优先(全环境),dev 无 Bearer 时 debug 头兜底。 JWT 通道强制 `X-Agent-Type` 交叉校验(手册 §4.6/§5.4):缺失 401、 - 值域外 400、与 token 不符 403(均留痕)。debug 通道保持 B6 行为。 + 值域外 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.startswith("Bearer "): - token = auth_header[len("Bearer "):].strip() + if auth_header[:7].lower() == "bearer ": # RFC 6750 scheme 大小写不敏感(评审 P3-3) + token = auth_header[7:].strip() if not token: - _unauthenticated_audit(RiskRepository(), "anonymous", "AUTH_401_INVALID_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: - _unauthenticated_audit(RiskRepository(), "anonymous", exc.code) + _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: - _unauthenticated_audit(RiskRepository(), auth.actor_id, "AUTH_401_MISSING_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": - _unauthenticated_audit(RiskRepository(), "anonymous", "AUTH_401_MISSING_BEARER") + 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 与既有权限矩阵测试依赖此通道) @@ -247,9 +276,7 @@ def get_auth_context(request: Request) -> AuthContext: actor_id = request.headers.get(DEBUG_ACTOR_HEADER, "").strip() if not roles or not actor_id: # 401 也留痕(P1-1);debug 通道仅 dev,生产等价流量由 JWT 中间件拒绝 - _unauthenticated_audit( - RiskRepository(), actor_id, "AUTH_401_MISSING_DEBUG_HEADERS" - ) + _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" ) diff --git a/app/main.py b/app/main.py index 314fb58..1aa2cca 100644 --- a/app/main.py +++ b/app/main.py @@ -55,6 +55,14 @@ async def lifespan(_: FastAPI): reason = jwt_ready() if reason: raise RuntimeError(f"JWT auth not ready for non-development env: {reason}") + reason = jwt_ready() if settings.app_env != "development" else None + if reason: + raise RuntimeError(f"JWT auth not ready for non-development env: {reason}") + if settings.app_env == "development" and settings.jwt_dev_secret == "change-me-in-dev-only": + logger.warning( + "JWT_DEV_SECRET is the public default; issued dev tokens are forgeable " + "(demo/CI only, never expose to untrusted networks)" + ) redis_gateway.set_gateway(redis_gateway.RedisGateway()) try: yield diff --git a/app/service/agent_service.py b/app/service/agent_service.py index 48b9f28..903bcd7 100644 --- a/app/service/agent_service.py +++ b/app/service/agent_service.py @@ -110,6 +110,7 @@ def build_graph(): _graph = None _graph_lock = threading.Lock() _llm: Any | None = None +_llm_lock = threading.Lock() def _get_graph(): @@ -121,18 +122,19 @@ def _get_graph(): def _get_llm() -> Any: - """DeepSeek 经 langchain-openai 兼容接口(懒构造;测试注入 _llm)。""" + """DeepSeek 经 langchain-openai 兼容接口(懒构造加锁;测试注入 _llm)。""" global _llm - if _llm is None: - from langchain_openai import ChatOpenAI + with _llm_lock: + if _llm is None: + from langchain_openai import ChatOpenAI - _llm = ChatOpenAI( - model="deepseek-chat", - api_key=settings.deepseek_api_key, - base_url=settings.deepseek_base_url, - temperature=0.3, - ) - return _llm + _llm = ChatOpenAI( + model="deepseek-chat", + api_key=settings.deepseek_api_key, + base_url=settings.deepseek_base_url, + temperature=0.3, + ) + return _llm def chat(agent_type: str, history: list[dict], user_message: str) -> dict[str, Any]: diff --git a/app/service/auth_service.py b/app/service/auth_service.py index 5af975f..b28f67a 100644 --- a/app/service/auth_service.py +++ b/app/service/auth_service.py @@ -22,6 +22,7 @@ from __future__ import annotations import logging import time from pathlib import Path +from uuid import uuid4 from jose import JWTError, jwt from pydantic import BaseModel, Field @@ -105,7 +106,8 @@ def issue_dev_token( "aud": settings.jwt_audience, "exp": now + exp_minutes * 60, "iat": now, - "jti": f"jti-{now:x}-{sub}", + # uuid 片段防同秒同 sub 的 jti 撞车(吊销误伤,评审 P3-6) + "jti": f"jti-{now:x}-{sub}-{uuid4().hex[:8]}", "token_type": token_type, "roles": roles, "tenant_id": tenant_id, diff --git a/app/service/risk/redis_gateway.py b/app/service/risk/redis_gateway.py index 73c071d..2cd38f3 100644 --- a/app/service/risk/redis_gateway.py +++ b/app/service/risk/redis_gateway.py @@ -89,9 +89,12 @@ def cache_delete(*keys: str) -> None: def key_exists(key: str) -> bool: - """存在性检查(T-01 jti 吊销黑名单);失败 fail-open 返回 False。""" + """存在性检查(T-01 jti 吊销黑名单);失败 fail-open 返回 False。 + + 日志不打 key 全文(jti 吊销 key 含完整 jti,手册 §11 只打前 8 位口径)。 + """ try: return get_gateway().exists(key) except Exception: - logger.warning("redis EXISTS failed (fail-open): %s", key, exc_info=True) + logger.warning("redis EXISTS failed (fail-open), key prefix=%s", key.split(":")[:2], exc_info=True) return False diff --git a/requirements.txt b/requirements.txt index 8cec466..0fccf3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ langchain-openai>=1.6.0 httpx>=0.28.0 # Auth (dev / gateway) -python-jose[cryptography]>=3.3.0 +python-jose[cryptography]>=3.5.0 # Utils python-multipart>=0.0.18 diff --git a/scripts/dev/issue_dev_token.py b/scripts/dev/issue_dev_token.py index 1fc14f9..0e269a5 100644 --- a/scripts/dev/issue_dev_token.py +++ b/scripts/dev/issue_dev_token.py @@ -28,14 +28,21 @@ def main() -> int: parser.add_argument("--roles", required=True, help="逗号分隔角色,如 risk_officer / customer") parser.add_argument("--token-type", default="staff", choices=["customer", "staff", "service"]) parser.add_argument("--customer-id", default=None, help="token_type=customer 时必填且须等于 sub") - parser.add_argument("--exp-minutes", type=int, default=60) + parser.add_argument("--exp-minutes", type=int, default=60, help="有效期(分钟),上限 480(手册 §11 员工 ≤8h)") parser.add_argument("--tenant-id", default="TENANT-001") + parser.add_argument("--permissions", default=None, help="逗号分隔细粒度权限,如 risk:alert:write,audit:write") args = parser.parse_args() if settings.app_env != "development": print("refused: issue_dev_token only runs with APP_ENV=development", file=sys.stderr) return 1 + if args.exp_minutes < 1 or args.exp_minutes > 480: + print("refused: --exp-minutes must be in [1, 480]", file=sys.stderr) + return 1 roles = [r.strip() for r in args.roles.split(",") if r.strip()] + permissions = ( + [p.strip() for p in args.permissions.split(",") if p.strip()] if args.permissions else None + ) if args.token_type == "customer" and args.customer_id != args.sub: print("refused: customer token requires --customer-id equal to --sub (手册 §4.2)", file=sys.stderr) return 1 @@ -46,6 +53,7 @@ def main() -> int: exp_minutes=args.exp_minutes, tenant_id=args.tenant_id, customer_id=args.customer_id, + permissions=permissions, ) print(token) return 0 diff --git a/tests/test_audit_middleware.py b/tests/test_audit_middleware.py index 3140ed3..04c3e50 100644 --- a/tests/test_audit_middleware.py +++ b/tests/test_audit_middleware.py @@ -156,7 +156,8 @@ def test_request_id_invalid_regenerated(env): # ---------- input_guard_log 双写(挂账⑧) ---------- -def test_guard_log_written_on_401_and_403(env): +def test_guard_log_written_on_403_only(env): + """401 归 platform(准入前 agent 不可信,评审 P3-2)→ 仅 403 写 guard。""" env["client"].get("/api/risk/alerts") # 401 env["client"].post( "/api/risk/alerts/A-1/handle", @@ -164,8 +165,29 @@ def test_guard_log_written_on_401_and_403(env): headers={"X-Debug-Role": "compliance", "X-Debug-Actor": "STAFF-40001"}, # 403 ) rows = _rows(env["engine"], "SELECT agent_type, actor_id, guard_type, action FROM input_guard_log") - assert len(rows) == 2 - assert all(r["guard_type"] == "illegal_param" and r["action"] == "blocked" for r in rows) + assert len(rows) == 1 + assert rows[0]["guard_type"] == "illegal_param" and rows[0]["action"] == "blocked" + unauth = _rows(env["engine"], "SELECT agent_type FROM audit_log WHERE decision = 'unauthenticated'") + assert unauth and unauth[0]["agent_type"] == "platform" + + +def test_http_access_audit_degrades_on_db_failure(env, monkeypatch): + """P2-2:audit 写库失败降级 warning,业务响应不受阻塞。""" + def _boom(): + raise RuntimeError("audit db down") + + monkeypatch.setattr(audit_mod, "_repo", _boom) + r = env["client"].get( + "/api/risk/alerts", headers={"X-Debug-Role": "risk_officer", "X-Debug-Actor": "STAFF-30001"} + ) + assert r.status_code == 200 # 降级不阻塞 + + +def test_options_request_skipped(env): + """OPTIONS(无路由方法 405)跳过审计——预检类噪音不落 http_access。""" + r = env["client"].options("/api/risk/alerts") + assert r.status_code == 405 # Starlette 语义;skip 语义以不落审计为准 + assert _http_access(env["engine"]) == [] def test_guard_log_skipped_for_platform_agent(env): diff --git a/tests/test_auth_jwt.py b/tests/test_auth_jwt.py index 67e8a70..e2bd258 100644 --- a/tests/test_auth_jwt.py +++ b/tests/test_auth_jwt.py @@ -155,18 +155,49 @@ def test_jwt_customer_id_mismatch_rejected(env): assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_INVALID_TOKEN" -def test_jwt_missing_required_claim_rejected(env): +@pytest.mark.parametrize("missing", ["sub", "jti", "token_type", "roles", "tenant_id", "exp", "iat"]) +def test_jwt_missing_required_claim_rejected(env, missing): + """手册 §4.2 必填 claims 逐一缺失 → 401(显式校验,评审 P3-14 参数化)。""" now = int(time.time()) - payload = { # 缺 tenant_id(手册 §4.2 必填) + payload = { "iss": settings.jwt_issuer, "sub": "STAFF-30001", "aud": settings.jwt_audience, "exp": now + 600, "iat": now, "jti": "jti-noclaim", "token_type": "staff", - "roles": ["risk_officer"], + "roles": ["risk_officer"], "tenant_id": "TENANT-001", } + payload.pop(missing) tok = jose_jwt.encode(payload, settings.jwt_dev_secret, algorithm="HS256") r = env["client"].get("/api/risk/alerts", headers=_bearer(tok)) + assert r.status_code == 401 and r.json()["error_code"] == "AUTH_401_INVALID_TOKEN" + + +def test_jwt_none_algorithm_rejected(env): + """alg=none 手工构造 unsigned JWT → 401(jose 白名单库级防护 + 显式测试固化)。""" + import base64 + import json as _json + + now = int(time.time()) + header = base64.urlsafe_b64encode(_json.dumps({"alg": "none", "typ": "JWT"}).encode()).rstrip(b"=") + payload = base64.urlsafe_b64encode(_json.dumps({ + "iss": settings.jwt_issuer, "sub": "STAFF-30001", "aud": settings.jwt_audience, + "exp": now + 600, "iat": now, "jti": "jti-none", "token_type": "staff", + "roles": ["risk_officer"], "tenant_id": "TENANT-001", + }).encode()).rstrip(b"=") + tok = f"{header.decode()}.{payload.decode()}." # 空签名 + r = env["client"].get("/api/risk/alerts", headers=_bearer(tok)) assert r.status_code == 401 +def test_auth_audit_db_failure_keeps_401(env, monkeypatch): + """P2-5 显式化:留痕写库失败降级(拒绝语义不漂移为 500)。""" + def _boom(): + raise RuntimeError("audit db down") + + monkeypatch.setattr(deps_mod, "RiskRepository", _boom) + r = env["client"].get("/api/risk/alerts") + assert r.status_code == 401 + assert r.json()["error_code"] == "AUTH_401_MISSING_DEBUG_HEADERS" + + # ---------- 吊销(jti 黑名单) ---------- diff --git a/tests/test_chat.py b/tests/test_chat.py index 50cdf93..6f693ae 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -24,6 +24,8 @@ from app.repository.core_ro import CoreReadOnlyRepository from app.repository.risk_repository import RiskRepository from app.repository.session_repository import SessionRepository from app.service import memory_service +from app.service.auth_service import issue_dev_token as _issue # noqa: F401 +from app.service import auth_service from app.service.risk import redis_gateway @@ -220,3 +222,39 @@ def test_chat_session_not_found_and_closed(env): conn.execute(text("UPDATE agent_session SET status = 'closed' WHERE session_id = :s"), {"s": sid}) r2 = env["client"].post("/api/chat", json={"message": "hi", "session_id": sid}, headers=CUSTOMER) assert r2.status_code == 409 and r2.json()["error_code"] == "STATE_CONFLICT" + + +# ---------- JWT 通道(生产主链路 · 评审 P2-3) ---------- + + +def test_chat_jwt_customer_new_session(env): + tok = auth_service.issue_dev_token( + sub="CUST-9527", roles=["customer"], token_type="customer", customer_id="CUST-9527" + ) + r = env["client"].post( + "/api/chat", json={"message": "查持仓"}, + headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "customer"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["customer_id"] == "CUST-9527" and body["has_disclaimer"] is True + s = _rows(env["engine"], "SELECT actor_id, actor_role FROM agent_session")[0] + assert (s["actor_id"], s["actor_role"]) == ("CUST-9527", "customer") + + +def test_chat_jwt_advisor_not_assigned_denied(env): + tok = auth_service.issue_dev_token(sub="STAFF-10086", roles=["advisor"]) + r = env["client"].post( + "/api/chat", json={"message": "hi", "customer_id": "CUST-1010"}, + headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "advisor"}, + ) + assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_NOT_ASSIGNED" + + +def test_chat_jwt_agent_mismatch_denied(env): + tok = auth_service.issue_dev_token(sub="STAFF-30001", roles=["risk_officer"]) + r = env["client"].post( + "/api/chat", json={"message": "hi"}, + headers={"Authorization": f"Bearer {tok}", "X-Agent-Type": "customer"}, + ) + assert r.status_code == 403 and r.json()["error_code"] == "AUTH_403_AGENT_MISMATCH"