feat(ai): 内部 HTTP 端点 + 服务票校验 + 契约漂移守卫(166 测试)
CI / ai-gates (push) Successful in 39s

按 openapi-internal.yaml 落地两个端点与鉴权,全部离线可测。

**鉴权(app/api/auth.py)**是这一层的安全边界,三条纪律各有测试:
- **强制校验 aud 与 iss**(交给 pyjwt 强制,不解出来自己比——自己比容易漏掉
  「claim 不存在」那一路)。这是独立密钥对方案唯一的意义所在:Java 侧现有的
  JwtVerifier 只验签名、不校验 aud,正因如此才必须独立密钥;若本服务也只验签名,
  那对独立密钥就白搭了。
- **额外强制 typ=service**,作为「有人误把用户密钥配到这里」的第二道防线。
- **缺公钥时全拒而非全过**。配置缺失必须立刻暴露,而不是变成一扇敞开的门;
  与 Java 侧 InternalAuthFilter 未配置 internal-token 时全拒 /internal/** 一致。
- 失败一律返回同一个笼统原因,**不告诉调用方是签名错/过期/aud 不符**——对方是
  我们自己的服务,真正原因在日志里;细节回给调用方只会在将来某天变成提示。

**三类失败分开**(契约核心)。第三类「任务失败」走 200 + state=failed + failure,
因为那种情况下 HTTP 调用是成功的;做成非 2xx 会让「查不到任务」与「任务失败了」
难以区分。另外两处刻意选择:
- 服务商 429 **不透传**,一律 502 + provider 信封。429 既可能是「等几秒」也可能是
  「等到明天」,状态码表达不了这个区别,retry 才是权威信号。
- 超出模型限制的请求归 service/invalid_request 而非 provider——请求**根本没发出去**,
  归到 provider 会让调用方以为是上游问题而去退避重试,而重试同样的参数永远不会变好。
- 服务商 404(含超出其 7 天保留期)归 service/task_not_found:调用方要做的是
  **停止轮询**,不是退避重试。
- 接管 FastAPI 默认的 {"detail": [...]} 校验错误,否则调用方要处理三种形状,
  而第三种还是框架实现细节。

**契约漂移守卫(tests/api/test_contract_drift.py,14 项)**:本项目开工审计已证明
「没有门禁的契约必然漂移」——四模块快照锁实际不存在,白名单 42/41 之误四份文档
一路不可见整整一个迭代。内部契约同样需要门禁。比对路径/方法/字段集/枚举/必填项,
**不比对字节**(永远不会相同,只会制造一个天天红然后被 skip 绕过的门禁)。

写这个守卫时它立刻抓到一处问题,但**问题在我的内省方式而不在应用**:本版本
FastAPI 的 include_router 会包一层内部 wrapper,路由并不平铺在 app.routes 上。
已改用 app.openapi()——它是「究竟暴露了什么」的权威视图,且不依赖框架内部结构,
否则这个门禁会在升级框架时莫名失效,而一个悄悄失效的门禁比没有门禁更糟。

**新增 pyproject.toml**:`Depends()` 放在参数默认值里正是 FastAPI 的标准写法,
ruff 的 B008 在此是误报。按 ruff 正规方式声明这些调用可安全求值,而不是在每个
端点上撒 noqa——后者迟早被用来压制真正的问题。

schemas 用 extra="forbid":拼错的字段应当立刻报错而非被静默忽略,后者表现为
「我传了 durationSec 却没生效」这类极难查的问题。测试覆盖了 camelCase 与
user_id(契约刻意不含它)两种被拒情形。

新增依赖 fastapi / uvicorn / pyjwt[crypto];测试 127 → 166。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 16:59:44 +08:00
parent fd23dfc0b9
commit 062e11b1ff
15 changed files with 1329 additions and 1 deletions
+78
View File
@@ -0,0 +1,78 @@
"""服务身份 JWT 校验(ADR-023 §5)。
本模块是**唯一**的鉴权入口。三条纪律,每条都有测试钉住:
1. **强制校验 `aud` 与 `typ`。** 这是独立密钥对方案唯一的意义所在——Java 侧那边
现有的 `JwtVerifier` 只验签名、不校验 `aud`/`iss`,正是因为如此才必须用一对
独立密钥;如果本服务也只验签名,那对独立密钥就白搭了。
2. **用户 token 一律拒绝。** 它们由另一对密钥签发,签名校验本身就会失败;但即使
有人误把用户密钥配到这里,缺 `aud` 与 `typ=service` 也会被拦下——两道。
3. **缺公钥时拒绝所有请求,而不是放行。** 配置缺失必须是「全拒」而非「全过」:
前者立刻暴露,后者是敞开的门。这与 Java 侧 `InternalAuthFilter` 未配置
`internal-token` 时全拒 `/internal/**` 的既有选择一致。
"""
from __future__ import annotations
import logging
import jwt
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import ServiceAuthSettings
from .errors import ServiceError, ServiceErrorCode
log = logging.getLogger(__name__)
#: auto_error=False:自己产生 401 以便统一走 service 错误信封,
#: 而不是 FastAPI 默认的 `{"detail": ...}`——后者与本契约的两类错误形状都不符。
_bearer = HTTPBearer(auto_error=False)
def verify_service_token(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> None:
"""校验服务票;不通过即抛 `ServiceError(unauthenticated)`。
所有失败都返回同一个笼统原因,**不告诉调用方是签名错、过期、还是 aud 不符**。
对方是我们自己的服务,真正的原因在本服务日志里;把细节回给调用方只会在将来
某天变成攻击者的提示。
"""
settings: ServiceAuthSettings = request.app.state.settings.service_auth
if not settings.public_key:
# 配置缺失 → 全拒。绝不「未配置即放行」。
log.error("PATBOND_SERVICE_JWT_PUBLIC_KEY 未配置,/internal/** 全部拒绝")
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务端未配置服务票公钥")
if credentials is None or not credentials.credentials:
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "缺少服务票")
try:
claims = jwt.decode(
credentials.credentials,
settings.public_key,
algorithms=["RS256"],
# audience 与 issuer 交给库强制校验,而不是解出来自己比——
# 自己比容易漏掉「claim 不存在」这一路。
audience=settings.audience,
issuer=settings.issuer,
options={"require": ["exp", "aud", "iss", "sub"]},
)
except jwt.InvalidTokenError as exc:
# 具体原因只进日志。
log.warning("服务票校验失败:%s", exc)
raise ServiceError(
ServiceErrorCode.UNAUTHENTICATED, "服务票无效或已过期"
) from exc
token_type = claims.get("typ")
if token_type != settings.required_type:
# 用户 token 走到这里的唯一可能是有人误配了用户密钥。拦住它。
log.warning(
"服务票 typ 不符:期望 %s,实际 %r", settings.required_type, token_type
)
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务票类型不符")