Files
group_fqcd_jr/app/service/authorization_service.py
T

94 lines
4.2 KiB
Python
Raw Normal View History

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("权限不足")