fix(risk): 日报邮件端点补上授权校验——它此前是风控里唯一没有校验的端点
**问题**: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 全绿。
This commit is contained in:
@@ -209,10 +209,11 @@ async def send_risk_daily_report_mail(
|
|||||||
payload: RiskDailyReportMailRequest,
|
payload: RiskDailyReportMailRequest,
|
||||||
context: RequestContext = Depends(build_request_context), # noqa: B008
|
context: RequestContext = Depends(build_request_context), # noqa: B008
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
data = RiskDailyReportMailService().send(
|
data = await RiskDailyReportMailService().send(
|
||||||
payload.recipients,
|
payload.recipients,
|
||||||
payload.subject,
|
payload.subject,
|
||||||
payload.content,
|
payload.content,
|
||||||
|
context=context,
|
||||||
)
|
)
|
||||||
return _envelope(data, context)
|
return _envelope(data, context)
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,30 @@ from collections.abc import Mapping
|
|||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
from email.utils import formatdate, make_msgid
|
from email.utils import formatdate, make_msgid
|
||||||
|
|
||||||
|
from app.core.contracts import RequestContext
|
||||||
|
from app.service.authorization_service import AuthorizationService
|
||||||
|
|
||||||
|
|
||||||
class RiskDailyReportMailService:
|
class RiskDailyReportMailService:
|
||||||
def __init__(self, *, environment: Mapping[str, str] | None = None) -> None:
|
def __init__(self, *, environment: Mapping[str, str] | None = None) -> None:
|
||||||
self.environment = environment or os.environ
|
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"):
|
if not _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_ENABLED"):
|
||||||
return {"status": "disabled", "recipient_count": len(recipients)}
|
return {"status": "disabled", "recipient_count": len(recipients)}
|
||||||
if _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_DRY_RUN", default=True):
|
if _enabled(self.environment, "RISK_DAILY_REPORT_MAIL_DRY_RUN", default=True):
|
||||||
|
|||||||
@@ -103,7 +103,17 @@ class StubRiskDailyReportService:
|
|||||||
|
|
||||||
|
|
||||||
class StubRiskDailyReportMailService:
|
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)}
|
return {"status": "dry_run", "recipient_count": len(recipients)}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from types import MappingProxyType, SimpleNamespace
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.contracts import RequestContext
|
from app.core.contracts import RequestContext
|
||||||
|
from app.core.errors import ForbiddenAgentError
|
||||||
from app.repository.fund_query_repository import FundRecord
|
from app.repository.fund_query_repository import FundRecord
|
||||||
from app.repository.risk_repository import RiskReportSnapshot
|
from app.repository.risk_repository import RiskReportSnapshot
|
||||||
from app.service.risk_daily_report_mail_service import RiskDailyReportMailService
|
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. 根据模型生成日报建议"
|
assert report["optimization_suggestions"] == "1. 根据模型生成日报建议"
|
||||||
|
|
||||||
|
|
||||||
def test_mail_service_is_disabled_by_default() -> None:
|
def _mail_context(*permissions: str) -> RequestContext:
|
||||||
result = RiskDailyReportMailService(environment={}).send(
|
"""邮件端点的调用上下文。真实实现在发送前会 `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"],
|
["risk@example.com"],
|
||||||
"日报",
|
"日报",
|
||||||
"正文",
|
"正文",
|
||||||
|
context=_mail_context("risk:report:mail"),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result == {"status": "disabled", "recipient_count": 1}
|
assert result == {"status": "disabled", "recipient_count": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_mail_service_uses_dry_run_without_connecting() -> None:
|
@pytest.mark.asyncio
|
||||||
result = RiskDailyReportMailService(
|
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={
|
environment={
|
||||||
"RISK_DAILY_REPORT_MAIL_ENABLED": "true",
|
"RISK_DAILY_REPORT_MAIL_ENABLED": "true",
|
||||||
"RISK_DAILY_REPORT_MAIL_DRY_RUN": "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}
|
assert result == {"status": "dry_run", "recipient_count": 1}
|
||||||
|
|
||||||
|
|
||||||
def test_mail_service_reports_missing_configuration() -> None:
|
@pytest.mark.asyncio
|
||||||
result = RiskDailyReportMailService(
|
async def test_mail_service_reports_missing_configuration() -> None:
|
||||||
|
result = await RiskDailyReportMailService(
|
||||||
environment={
|
environment={
|
||||||
"RISK_DAILY_REPORT_MAIL_ENABLED": "true",
|
"RISK_DAILY_REPORT_MAIL_ENABLED": "true",
|
||||||
"RISK_DAILY_REPORT_MAIL_DRY_RUN": "false",
|
"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}
|
assert result == {"status": "configuration_error", "recipient_count": 1}
|
||||||
|
|||||||
Reference in New Issue
Block a user