Files

94 lines
4.2 KiB
Python
Raw Permalink Normal View History

import logging
from datetime import UTC, datetime
2026-09-09 21:55:37 +08:00
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__)
2026-09-09 21:55:37 +08:00
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")
2026-09-09 21:55:37 +08:00
@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":
2026-09-09 21:55:37 +08:00
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("权限不足")