Files
group_fqcd_jr/app/service/authorization_service.py
T
张胜宇 e239eb778b docs: 品牌全量口径统一为「南方基金」+ 作废文档清理
1) 客服 Agent 四份交付文档 + 构建脚手架:品牌由包装占位 XX科技 / 旧名 南方财富
   统一为南方基金(热线 400-889-8899 / 官网 nffund.com),系统名改为「智能服务系统」;
   同步追加 §0.4 修订记录行,工程记录行保留原占位字面以支撑硬编码扫描验收。
2) 开发文档:清理 28 份已作废/残留文档(14 份移出归档 + 14 份仓库副本),
   新增《文档规整方案与开发前待决事项-2026-09-17》。
3) 客服agent 四份交付文档首次纳入本分支。
2026-09-17 15:15:22 +08:00

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