## 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 表里被插入了分组标题行
(`**场外基金**`、`**账户与交易**`),而检查工具要求首列是端点编号。
94 lines
4.2 KiB
Python
94 lines
4.2 KiB
Python
import logging
|
||
from datetime import UTC, datetime
|
||
|
||
from app.core.contracts import RequestContext
|
||
from app.core.errors import ForbiddenAgentError
|
||
from app.infrastructure.db import SessionFactory
|
||
from app.model.audit import InteractionAudit
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class AuthorizationService:
|
||
"""所有受保护操作的公共权限闸门。
|
||
|
||
契约见 `tests/unit/service/test_authorization_service.py`:
|
||
1. 有权限时**直接返回**,不写审计、更不开数据库会话;
|
||
2. 拒绝时**开新事务**写 `permission.denied` 审计,随后抛 `ForbiddenAgentError`;
|
||
3. `admin=True` 时除权限码外还须具备 `admin` / `super_admin` 角色;
|
||
4. 审计记录 `permission` 与 `trace_id`,供事后追责。
|
||
|
||
审计用独立的 `SessionFactory()` 事务,与调用方可能持有的业务会话解耦,
|
||
保证"拒绝即留痕"不受主流程回滚影响。
|
||
"""
|
||
|
||
_ADMIN_ROLES = ("admin", "super_admin")
|
||
|
||
@staticmethod
|
||
async def require(
|
||
context: RequestContext, permission: str, *, admin: bool = False
|
||
) -> None:
|
||
if permission not in context.permissions:
|
||
await AuthorizationService._deny(context, permission)
|
||
if admin and not any(role in AuthorizationService._ADMIN_ROLES for role in context.roles):
|
||
await AuthorizationService._deny(context, permission)
|
||
|
||
@staticmethod
|
||
async def require_customer_scope(
|
||
context: RequestContext, permission: str, customer_id: int
|
||
) -> None:
|
||
"""客户级权限:先校验权限码,再校验数据范围是否覆盖该客户。
|
||
|
||
数据范围取自 `sys_permission.data_scope`(由 `IdentityService.resolve` 放进
|
||
`context.permission_scopes`):
|
||
- `all`:覆盖全部客户,直接放行;
|
||
- `own_customers`:仅当 `customer_id` 在 `context.customer_ids` 内才放行;
|
||
- 其他(含默认 `self`):拒绝。
|
||
|
||
用于「顾问代客」场景(`customer_id` 非登录用户自身时),与
|
||
`InvestmentGoalService._assert_customer_access` 的口径保持一致。
|
||
"""
|
||
await AuthorizationService.require(context, permission)
|
||
scope = context.permission_scopes.get(permission, "self")
|
||
if scope == "all":
|
||
return
|
||
if scope == "own_customers" and str(customer_id) in context.customer_ids:
|
||
return
|
||
await AuthorizationService._deny(context, permission)
|
||
|
||
@staticmethod
|
||
async def _deny(context: RequestContext, permission: str) -> None:
|
||
actor_id: int | None = None
|
||
try:
|
||
actor_id = int(context.user_id)
|
||
except (TypeError, ValueError):
|
||
actor_id = None
|
||
audit = InteractionAudit(
|
||
actor_type="user",
|
||
actor_id=actor_id,
|
||
portal=context.portal,
|
||
action_type="permission.denied",
|
||
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),
|
||
)
|
||
try:
|
||
async with SessionFactory() as session:
|
||
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("权限不足")
|