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