From 0d3447bde4d7eb004281a1c884d7c4d07059a2a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=BF=E4=BA=91=E7=A7=8B=E6=9C=88?= <15273589815@163.com> Date: Fri, 11 Sep 2026 13:39:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(risk):=20=E6=97=A5=E6=8A=A5=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E7=AB=AF=E7=82=B9=E8=A1=A5=E4=B8=8A=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E2=80=94=E2=80=94=E5=AE=83=E6=AD=A4=E5=89=8D?= =?UTF-8?q?=E6=98=AF=E9=A3=8E=E6=8E=A7=E9=87=8C=E5=94=AF=E4=B8=80=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E6=A0=A1=E9=AA=8C=E7=9A=84=E7=AB=AF=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **问题**:POST /api/v1/risk/daily-report/mail 原先只取 context 做 401 判定, **没有任何授权校验**;RiskDailyReportMailService.send 既拿不到 context、也不调用 AuthorizationService。收件人、标题、正文**全部由客户端决定** —— 一旦运维开启 SMTP (RISK_DAILY_REPORT_MAIL_ENABLED),它就是一个未授权的邮件发送器。 默认关闭(ENABLED 默认 false + DRY_RUN 默认 true)让它至今没出事,但那不是可依赖的保护。 **改动**: - send 改为 async 并接收 context,入口处 wait AuthorizationService.require( context, "risk:report:mail")。校验放在 **service 层**而不是 controller —— 本项目风控 端点的授权一律落在 service(risk_query / risk_action / risk_scan 等都是这样), controller 只负责取 context;这个端点是唯一的例外,现在补齐。 - controller 相应改为 wait ...send(..., context=context)。 - 权限 isk:report:mail 已在上一轮随另外三个一起创建并授予 risk_operator 与 admin。 **实测**: - 9002(risk_operator) → **200** + {"status":"disabled","recipient_count":1}(默认关闭) - 9001(customer) → **403** AGENT_PERMISSION_DENIED「缺少操作权限」 **测试**:3 个既有用例改为 async 并传入带权限的 context;**新增** est_mail_service_requires_the_permission,断言无权限身份必须被拒 —— 钉住本次修复。 ruff / mypy(136 文件) / 623 unit+contract / 29 integration 全绿。 --- app/api/controllers/risk.py | 3 +- app/service/risk_daily_report_mail_service.py | 20 ++++++- tests/unit/api/test_risk_controller.py | 12 +++- .../service/test_risk_daily_report_service.py | 57 ++++++++++++++++--- 4 files changed, 81 insertions(+), 11 deletions(-) diff --git a/app/api/controllers/risk.py b/app/api/controllers/risk.py index b938357..18aec6a 100644 --- a/app/api/controllers/risk.py +++ b/app/api/controllers/risk.py @@ -209,10 +209,11 @@ async def send_risk_daily_report_mail( payload: RiskDailyReportMailRequest, context: RequestContext = Depends(build_request_context), # noqa: B008 ) -> dict[str, object]: - data = RiskDailyReportMailService().send( + data = await RiskDailyReportMailService().send( payload.recipients, payload.subject, payload.content, + context=context, ) return _envelope(data, context) diff --git a/app/service/risk_daily_report_mail_service.py b/app/service/risk_daily_report_mail_service.py index 76cae56..3bff8a0 100644 --- a/app/service/risk_daily_report_mail_service.py +++ b/app/service/risk_daily_report_mail_service.py @@ -8,12 +8,30 @@ from collections.abc import Mapping from email.message import EmailMessage from email.utils import formatdate, make_msgid +from app.core.contracts import RequestContext +from app.service.authorization_service import AuthorizationService + class RiskDailyReportMailService: def __init__(self, *, environment: Mapping[str, str] | None = None) -> None: self.environment = environment or os.environ - def send(self, recipients: list[str], subject: str, content: str) -> dict[str, object]: + async def send( + self, + recipients: list[str], + subject: str, + content: str, + *, + context: RequestContext, + ) -> dict[str, object]: + """发送风控日报邮件。 + + **权限校验放在这里,而不是 controller**:本项目风控端点的授权一律落在 service 层 + (`risk_query_service` / `risk_action_service` / `risk_scan_service` 等都是这样), + controller 只负责取 context。这个端点是此前**唯一没有校验的** —— 收件人、标题、 + 正文全由客户端决定,一旦运维开启 SMTP,它就是一个未授权的邮件发送器。 + """ + await AuthorizationService.require(context, "risk:report:mail") if not _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_ENABLED"): return {"status": "disabled", "recipient_count": len(recipients)} if _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_DRY_RUN", default=True): diff --git a/tests/unit/api/test_risk_controller.py b/tests/unit/api/test_risk_controller.py index 8b7d700..60d8f2e 100644 --- a/tests/unit/api/test_risk_controller.py +++ b/tests/unit/api/test_risk_controller.py @@ -103,7 +103,17 @@ class StubRiskDailyReportService: class StubRiskDailyReportMailService: - def send(self, recipients: list[str], _subject: str, _content: str) -> dict[str, Any]: + async def send( + self, + recipients: list[str], + _subject: str, + _content: str, + *, + context: RequestContext, + ) -> dict[str, Any]: + # 真实实现会先 `require("risk:report:mail")`;这里只复现调用形状, + # 权限本身由 test_risk_daily_report_service 里那组用例覆盖。 + del context return {"status": "dry_run", "recipient_count": len(recipients)} diff --git a/tests/unit/service/test_risk_daily_report_service.py b/tests/unit/service/test_risk_daily_report_service.py index e6a87e9..650600f 100644 --- a/tests/unit/service/test_risk_daily_report_service.py +++ b/tests/unit/service/test_risk_daily_report_service.py @@ -4,6 +4,7 @@ from types import MappingProxyType, SimpleNamespace import pytest from app.core.contracts import RequestContext +from app.core.errors import ForbiddenAgentError from app.repository.fund_query_repository import FundRecord from app.repository.risk_repository import RiskReportSnapshot from app.service.risk_daily_report_mail_service import RiskDailyReportMailService @@ -170,33 +171,73 @@ async def test_daily_report_lazily_initializes_model_service(monkeypatch) -> Non assert report["optimization_suggestions"] == "1. 根据模型生成日报建议" -def test_mail_service_is_disabled_by_default() -> None: - result = RiskDailyReportMailService(environment={}).send( +def _mail_context(*permissions: str) -> RequestContext: + """邮件端点的调用上下文。真实实现在发送前会 `require("risk:report:mail")`。""" + return RequestContext( + user_id="900000002", + trace_id="risk-mail-test", + permissions=permissions, + data_scope="all", + ) + + +@pytest.mark.asyncio +async def test_mail_service_is_disabled_by_default() -> None: + result = await RiskDailyReportMailService(environment={}).send( ["risk@example.com"], "日报", "正文", + context=_mail_context("risk:report:mail"), ) assert result == {"status": "disabled", "recipient_count": 1} -def test_mail_service_uses_dry_run_without_connecting() -> None: - result = RiskDailyReportMailService( +@pytest.mark.asyncio +async def test_mail_service_requires_the_permission() -> None: + """没有 `risk:report:mail` 的身份必须被拒。 + + 这个端点此前是风控里**唯一没有授权校验**的:收件人、标题、正文全由客户端决定, + 一旦运维开启 SMTP,它就是一个未授权的邮件发送器。这条用例把它钉住。 + """ + with pytest.raises(ForbiddenAgentError): + await RiskDailyReportMailService(environment={}).send( + ["attacker@example.com"], + "任意标题", + "任意正文", + context=_mail_context(), + ) + + +@pytest.mark.asyncio +async def test_mail_service_uses_dry_run_without_connecting() -> None: + result = await RiskDailyReportMailService( environment={ "RISK_DAILY_REPORT_MAIL_ENABLED": "true", "RISK_DAILY_REPORT_MAIL_DRY_RUN": "true", } - ).send(["risk@example.com"], "日报", "正文") + ).send( + ["risk@example.com"], + "日报", + "正文", + context=_mail_context("risk:report:mail"), + ) assert result == {"status": "dry_run", "recipient_count": 1} -def test_mail_service_reports_missing_configuration() -> None: - result = RiskDailyReportMailService( +@pytest.mark.asyncio +async def test_mail_service_reports_missing_configuration() -> None: + result = await RiskDailyReportMailService( environment={ "RISK_DAILY_REPORT_MAIL_ENABLED": "true", "RISK_DAILY_REPORT_MAIL_DRY_RUN": "false", } - ).send(["risk@example.com"], "日报", "正文") + ).send( + ["risk@example.com"], + "日报", + "正文", + context=_mail_context("risk:report:mail"), + ) assert result == {"status": "configuration_error", "recipient_count": 1}