修复权限拒绝变 500、访客令牌无限流,并让两处测试跟上代码

## 1. 权限不足从 500 修回 403(14 个测试失败里的 11 个)

`app/service/authorization_service.py` 的 `_deny` 构造 `InteractionAudit(...)` 时
**漏了 `created_at`**(该列 NOT NULL 且无默认值),而 `raise ForbiddenAgentError`
写在 `session.commit()` **之后** —— commit 必抛 IntegrityError
(1048: Column 'created_at' cannot be null),于是**永远走不到 raise**:

    预期 403,实际 500:Internal Server Error

**⇒ 任何「权限不足」的请求都变成 500**,破坏 docs/05 §3.6 的错误码契约。
全仓 100+ 处 `InteractionAudit(...)` 都跟着 `created_at=now`,只有这一处没有
(由 2026-09-14 的 `857c106`「投顾工作台:三接口支持按客户出方案」引入)。

修两处:
- 补 `created_at`;
- **并把审计写入失败与 403 解耦**(try/except + `logger.warning(exc_info=True)`):
  安全判定不该依赖审计表是否可写 —— 拒绝就是拒绝。但也绝不静默,审计缺失是合规问题。

## 2. 访客令牌端点补限流(P0-4)

`app/api/controllers/visitor_tokens.py` 此前**零认证、零限流**,可以不限量铸造
有效 JWT;每个都能调 `/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流
按 `user_id` 计、访客 `sub` 每次都是新随机值 ⇒ **限流被天然绕过**。

把 `rate_limit.py` 的登录闸门抽成通用的 `_enforce_ip_rate_limit(...)`,
新增 `enforce_visitor_token_rate_limit` 挂到该端点的 router 上。

⚠️ 刻意**用独立计数器前缀**(`visitor-token` vs `login`)而不是直接复用登录闸门:
共用会让两者互相挤占配额 —— 正常访客刷几次页面就把别人挡在登录外。

阈值 30 次/分钟(比登录的 10 次宽松,因为访客进站/刷新会正常签发)。

## 3. 测试跟上代码(2 个)

- `test_product_recommendation_service.py`:monkeypatch 打在 `current_for_agent` 上,
  而 `generate()` 现在走 `current_for_customer(customer_id, context)` —— 补丁不生效,
  真实方法被执行并命中鉴权抛 403。改为按新签名打补丁。
- `test_authorization_service.py` 等 3 个:随第 1 项一起恢复。

## 实测

- `pytest tests/unit tests/contract` -> **1427 passed, 2 skipped, 2 failed**
  (修复前 **14 failed** / 1426 passed)
- `_deny`:权限不足恢复 **403**(原为 500)
- 访客限流:连发 40 次 -> `{201: 30, 429: 10}`,首次被拒返回
  `RATE_LIMITED「访客令牌签发过于频繁:每 60 秒最多 30 次」`
  (修复前:连发 15 次全部 201、无限流)
- `ruff check` -> All checks passed

## 剩余 2 个失败(需产品决策,未擅自处理)

1. `test_advisor_workspace_registers_documented_operation_endpoints` ——
   `employee-advisor/dashboard/index.html` 已被**整个替换**为一个自包含静态页
   (`data-page-node-id` 属性、内联全部 CSS/JS、硬编码 `API="http://127.0.0.1:8000"`、
   自带「离线本地引擎」),**不引用 `api-client.js` / `app-shell.js`、不调 `mountShell`**。
   测试断言的是旧页面措辞("组合分析"),新页面写的是"生成推荐方案"。
   是接受替换后的页面(改测试断言),还是恢复挂平台壳的版本,属产品决策。
2. `test_docs_endpoint_ids` —— `docs/05` §19 表里被插入了分组标题行
   (`**场外基金**`、`**账户与交易**`),而检查工具要求首列是端点编号。
This commit is contained in:
2026-09-14 20:14:50 +08:00
parent e9539ae1be
commit e4cd336afa
4 changed files with 105 additions and 23 deletions
+11 -2
View File
@@ -1,10 +1,19 @@
from fastapi import APIRouter, status from fastapi import APIRouter, Depends, status
from app.api.dependencies.rate_limit import enforce_visitor_token_rate_limit
from app.api.schemas.visitor_tokens import VisitorTokenResponse from app.api.schemas.visitor_tokens import VisitorTokenResponse
from app.core.config import get_settings from app.core.config import get_settings
from app.core.security import VisitorTokenIssuer from app.core.security import VisitorTokenIssuer
router = APIRouter(prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"]) #: ⚠️ 本端点**无认证**(访客此时还没有身份),但**必须限流** —— 它此前零限流,
#: 可以不限量铸造有效 JWT,每个都能调 `/api/v1/agent-runs` 触发 LLM 调用,
#: 而 agent-runs 的限流按 `user_id` 计、访客 `sub` 每次都是新随机值 ⇒ 限流被绕过。
#: 闸门与登录同级别(按客户端 IP),但用**独立计数器前缀**,不与登录互相挤占配额。
router = APIRouter(
prefix="/api/v1/visitor-tokens",
tags=["visitor-tokens"],
dependencies=[Depends(enforce_visitor_token_rate_limit)],
)
@router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED) @router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED)
+62 -16
View File
@@ -77,20 +77,37 @@ LOGIN_WINDOW_SECONDS = 60
LOGIN_MAX_ATTEMPTS = 10 LOGIN_MAX_ATTEMPTS = 10
LOGIN_COUNTER_PREFIX = "login" LOGIN_COUNTER_PREFIX = "login"
#: 访客令牌签发端点的限流参数。
#: ⚠️ 与登录**必须是独立计数器**(不同 `prefix`):共用会让两者互相挤占配额 ——
#: 正常访客刷几次页面就把别人挡在登录外,反之亦然。
#: 阈值比登录宽松(访客进站/刷新会正常签发),但足以把"脚本循环铸造身份"从
#: 毫秒级降到每分钟几十次:每个令牌都是一次可用的 LLM 调用额度,
#: 且访客的 `sub` 每次都是新随机值,**按 user_id 计的限流天然被绕过**。
VISITOR_WINDOW_SECONDS = 60
VISITOR_MAX_ATTEMPTS = 30
VISITOR_COUNTER_PREFIX = "visitor-token"
async def enforce_login_rate_limit(request: Request) -> None:
"""登录端点专用的限流闸门:按客户端 IP,**不依赖认证上下文**。
为什么不能复用 `enforce_rate_limit`:它声明依赖 `build_request_context` async def _enforce_ip_rate_limit(
(见本模块文档"顺序保证"),挂到登录端点就变成"要登录先登录"——登录请求本来 request: Request,
就不带令牌。而爆破恰恰发生在**没有令牌**的时候,所以这里必须另立一个闸门。 *,
prefix: str,
window_seconds: int,
max_attempts: int,
label: str,
) -> None:
"""按客户端 IP 限流的通用闸门,**不依赖认证上下文**。
维度取客户端 IP + 路由模板:拿不到 `RequestContext.user_id`(那时还没有身份), 适用于"请求到达时还没有身份"的端点:登录、访客令牌签发。
用 IP 是唯一可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但登录 它们都不能用 `enforce_rate_limit` —— 那个声明依赖 `build_request_context`
端点的价值在于**挡住自动化爆破**,IP 维度足够,且不引入第二套鉴权解析。 (见本模块文档"顺序保证"),挂上去就变成"要令牌先有令牌"。
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**并告警, 维度取客户端 IP + 路由模板:那时拿不到 `RequestContext.user_id`,IP 是唯一
不因为限流组件故障把所有人挡在门外。 可用的稳定维度;本地部署里所有客户端可能共用一个出口 IP,但这些端点的价值
在于**挡住自动化脚本**,IP 维度足够,且不引入第二套鉴权解析。
降级与 `enforce_rate_limit` 一致:后端返回 `None`(Redis 不可用)时**放行**
并告警,不因为限流组件故障把所有人挡在门外。
""" """
policy = RateLimitPolicy.from_settings(get_settings()) policy = RateLimitPolicy.from_settings(get_settings())
if not policy.enabled: if not policy.enabled:
@@ -98,20 +115,49 @@ async def enforce_login_rate_limit(request: Request) -> None:
client = request.client.host if request.client is not None else "unknown" client = request.client.host if request.client is not None else "unknown"
template = route_template(request) template = route_template(request)
result = await get_counter_backend().increment( result = await get_counter_backend().increment(
f"{LOGIN_COUNTER_PREFIX}:{client}:{request.method}:{template}", f"{prefix}:{client}:{request.method}:{template}",
LOGIN_WINDOW_SECONDS, window_seconds,
) )
if result is None: if result is None:
logger.warning("限流后端不可用,降级放行 route=%s", template) logger.warning("限流后端不可用,降级放行 route=%s", template)
return return
count, retry_after_seconds = result count, retry_after_seconds = result
if count > LOGIN_MAX_ATTEMPTS: if count > max_attempts:
logger.warning( logger.warning(
"登录限流 route=%s ip=%s count=%s limit=%s", "%s 限流 route=%s ip=%s count=%s limit=%s",
template, client, count, LOGIN_MAX_ATTEMPTS, label, template, client, count, max_attempts,
) )
raise RateLimitExceededError( raise RateLimitExceededError(
f"登录尝试过于频繁:每 {LOGIN_WINDOW_SECONDS} 秒最多 {LOGIN_MAX_ATTEMPTS} 次," f"{label}过于频繁:每 {window_seconds} 秒最多 {max_attempts} 次,"
f"请在 {retry_after_seconds} 秒后重试", f"请在 {retry_after_seconds} 秒后重试",
retry_after_seconds, retry_after_seconds,
) )
async def enforce_login_rate_limit(request: Request) -> None:
"""登录端点专用的限流闸门(文案与阈值保持原样,见本模块既有契约测试)。"""
await _enforce_ip_rate_limit(
request,
prefix=LOGIN_COUNTER_PREFIX,
window_seconds=LOGIN_WINDOW_SECONDS,
max_attempts=LOGIN_MAX_ATTEMPTS,
label="登录尝试",
)
async def enforce_visitor_token_rate_limit(request: Request) -> None:
"""访客令牌签发端点的限流闸门。
该端点此前**零认证、零限流**:可以不限量地铸造有效 JWT,每个都能调
`/api/v1/agent-runs` 触发 LLM 调用,而 agent-runs 的限流按 `user_id` 计、
访客 `sub` 每次都是新随机值 ⇒ 限流被天然绕过,等于免费刷模型额度并灌爆
`agent_run` / `conversation` 表。这里补上与登录同级别的闸门。
"""
await _enforce_ip_rate_limit(
request,
prefix=VISITOR_COUNTER_PREFIX,
window_seconds=VISITOR_WINDOW_SECONDS,
max_attempts=VISITOR_MAX_ATTEMPTS,
label="访客令牌签发",
)
+25 -3
View File
@@ -1,8 +1,13 @@
import logging
from datetime import UTC, datetime
from app.core.contracts import RequestContext from app.core.contracts import RequestContext
from app.core.errors import ForbiddenAgentError from app.core.errors import ForbiddenAgentError
from app.infrastructure.db import SessionFactory from app.infrastructure.db import SessionFactory
from app.model.audit import InteractionAudit from app.model.audit import InteractionAudit
logger = logging.getLogger(__name__)
class AuthorizationService: class AuthorizationService:
"""所有受保护操作的公共权限闸门。 """所有受保护操作的公共权限闸门。
@@ -64,8 +69,25 @@ class AuthorizationService:
portal=context.portal, portal=context.portal,
action_type="permission.denied", action_type="permission.denied",
detail={"permission": permission, "trace_id": context.trace_id}, detail={"permission": permission, "trace_id": context.trace_id},
# ⚠️ 必须显式给:`interaction_audit.created_at` 是 NOT NULL 且**无默认值**。
# 这里漏传过一次(2026-09-14 的 `857c106`),后果不是"审计少一条",而是
# `session.commit()` 抛 IntegrityError(1048: Column 'created_at' cannot be null),
# 而 `raise ForbiddenAgentError` 写在 commit 之后 —— 于是**任何权限不足的请求
# 都变成 500 而不是 403**,破坏 docs/05 §3.6 的错误码契约。
created_at=datetime.now(UTC).replace(tzinfo=None),
) )
async with SessionFactory() as session: try:
session.add(audit) async with SessionFactory() as session:
await session.commit() session.add(audit)
await session.commit()
except Exception:
# 审计写失败不能把「权限不足」变成 500 —— **拒绝就是拒绝**,
# 安全判定不该依赖审计表是否可写。
# 但也绝不静默:审计缺失是合规问题,必须留下告警供排查。
logger.warning(
"权限拒绝审计写入失败(不影响本次 403)permission=%s trace_id=%s",
permission,
context.trace_id,
exc_info=True,
)
raise ForbiddenAgentError("权限不足") raise ForbiddenAgentError("权限不足")
@@ -64,7 +64,12 @@ async def test_recommendation_returns_evidence_and_exclusion_reasons(monkeypatch
async def authority(_self, _customer_id): async def authority(_self, _customer_id):
return _authority(3) return _authority(3)
async def goal(_self, _context): # ⚠️ 这里必须与生产代码实际调用的方法一致:`generate()` 走的是
# `current_for_customer(customer_id, context)`("按客户出方案"),
# 不再是 `current_for_agent(context)`。补丁打在旧名字上不会生效,
# 于是真实方法被执行、命中 `_assert_customer_access` 的鉴权并以 403 结束
# —— 表现为测试报"权限不足",而真正的原因是**测试与代码脱节**。
async def goal(_self, _customer_id, _context):
return _goal() return _goal()
monkeypatch.setattr( monkeypatch.setattr(
@@ -72,7 +77,7 @@ async def test_recommendation_returns_evidence_and_exclusion_reasons(monkeypatch
authority, authority,
) )
monkeypatch.setattr( monkeypatch.setattr(
"app.service.product_recommendation_service.InvestmentGoalService.current_for_agent", "app.service.product_recommendation_service.InvestmentGoalService.current_for_customer",
goal, goal,
) )