Files
group_fqcd_jr/app/service/authorization_service.py
lzf_0626 e4cd336afa 修复权限拒绝变 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 表里被插入了分组标题行
   (`**场外基金**`、`**账户与交易**`),而检查工具要求首列是端点编号。
2026-09-14 20:14:50 +08:00

94 lines
4.2 KiB
Python
Raw Permalink 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.
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("权限不足")