安全:增加测试批次上下文硬校验
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from module_business.domain.enums import TestBatchStatus
|
||||
from module_business.entity.do.test_batch_do import BizTestBatch, BizTestBatchUser
|
||||
|
||||
|
||||
class TestBatchDao:
|
||||
"""测试批次访问数据库操作层。"""
|
||||
|
||||
@classmethod
|
||||
async def get_batch(cls, db: AsyncSession, batch_id: int) -> BizTestBatch | None:
|
||||
result = await db.execute(select(BizTestBatch).where(BizTestBatch.batch_id == batch_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def get_batch_for_update(cls, db: AsyncSession, batch_id: int) -> BizTestBatch | None:
|
||||
result = await db.execute(
|
||||
select(BizTestBatch)
|
||||
.where(BizTestBatch.batch_id == batch_id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def user_is_authorized(cls, db: AsyncSession, batch_id: int, user_id: int) -> bool:
|
||||
result = await db.execute(
|
||||
select(BizTestBatchUser.batch_id).where(
|
||||
BizTestBatchUser.batch_id == batch_id,
|
||||
BizTestBatchUser.user_id == user_id,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@classmethod
|
||||
async def list_available_batches(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
is_admin: bool,
|
||||
) -> list[BizTestBatch]:
|
||||
statement = select(BizTestBatch).where(BizTestBatch.status == TestBatchStatus.ACTIVE.value)
|
||||
if not is_admin:
|
||||
statement = statement.join(BizTestBatchUser).where(BizTestBatchUser.user_id == user_id)
|
||||
result = await db.execute(statement.order_by(BizTestBatch.batch_id.desc()))
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,47 @@
|
||||
from fastapi import Depends, Request, params
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.aspect.pre_auth import CurrentUserDependency
|
||||
from common.context import RequestContext
|
||||
from config.get_db import get_db
|
||||
from exceptions.exception import ServiceWarning
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
|
||||
|
||||
def parse_optional_positive_batch_id(raw: str | None) -> int | None:
|
||||
"""解析可选的测试批次请求头。"""
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
batch_id = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ServiceWarning('', '测试批次标识必须为正整数') from None
|
||||
if batch_id <= 0:
|
||||
raise ServiceWarning('', '测试批次标识必须为正整数')
|
||||
return batch_id
|
||||
|
||||
|
||||
class TestBatchScopeResolver:
|
||||
"""将请求头解析为已验证的测试批次作用域。"""
|
||||
|
||||
def __init__(self, *, write: bool = False) -> None:
|
||||
self.write = write
|
||||
|
||||
async def __call__(
|
||||
self,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: CurrentUserModel = CurrentUserDependency(),
|
||||
) -> TestBatchScope:
|
||||
raw = request.headers.get('X-Test-Batch-Id')
|
||||
batch_id = parse_optional_positive_batch_id(raw)
|
||||
scope = await TestBatchAccessService.resolve_scope(db, current_user, batch_id, write=self.write)
|
||||
RequestContext.set_current_test_batch_scope(scope)
|
||||
return scope
|
||||
|
||||
|
||||
def TestBatchScopeDependency(*, write: bool = False) -> params.Depends: # noqa: N802
|
||||
"""构造可配置读写语义的批次作用域依赖。"""
|
||||
return Depends(TestBatchScopeResolver(write=write))
|
||||
@@ -0,0 +1,33 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from sqlalchemy.orm import InstrumentedAttribute
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from exceptions.exception import ServiceException
|
||||
from module_business.domain.enums import TestBatchStatus
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TestBatchScope:
|
||||
"""当前请求可信任的数据批次作用域。"""
|
||||
|
||||
batch_id: int | None
|
||||
mode: Literal['FORMAL', 'TEST']
|
||||
status: TestBatchStatus | None
|
||||
writable: bool
|
||||
|
||||
@classmethod
|
||||
def formal(cls) -> 'TestBatchScope':
|
||||
return cls(batch_id=None, mode='FORMAL', status=None, writable=True)
|
||||
|
||||
|
||||
def batch_scope_condition(column: InstrumentedAttribute, scope: TestBatchScope) -> ColumnElement[bool]:
|
||||
"""生成正式数据或当前测试批次的查询条件。"""
|
||||
return column.is_(None) if scope.batch_id is None else column == scope.batch_id
|
||||
|
||||
|
||||
def assert_record_in_scope(record: Any, scope: TestBatchScope, label: str) -> None:
|
||||
"""防止已加载记录跨数据模式或测试批次被使用。"""
|
||||
if record is None or record.test_batch_id != scope.batch_id:
|
||||
raise ServiceException('', f'{label}不属于当前数据模式或测试批次')
|
||||
@@ -0,0 +1,81 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from exceptions.exception import PermissionException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.test_batch_dao import TestBatchDao
|
||||
from module_business.domain.enums import TestBatchStatus
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.test_batch_do import BizTestBatch
|
||||
|
||||
|
||||
class TestBatchAccessService:
|
||||
"""测试批次作用域与管理权限校验。"""
|
||||
|
||||
@staticmethod
|
||||
def _is_super_admin(current_user: CurrentUserModel) -> bool:
|
||||
return current_user.user is not None and current_user.user.admin is True
|
||||
|
||||
@staticmethod
|
||||
def _raise_unavailable() -> None:
|
||||
raise PermissionException({'reason': 'TEST_BATCH_UNAVAILABLE'}, '当前测试批次不可用')
|
||||
|
||||
@staticmethod
|
||||
def _raise_closed() -> None:
|
||||
raise PermissionException({'reason': 'TEST_BATCH_UNAVAILABLE'}, '当前测试批次已关闭')
|
||||
|
||||
@classmethod
|
||||
def assert_super_admin(cls, current_user: CurrentUserModel) -> None:
|
||||
"""确保当前用户由超级管理员标记明确授信。"""
|
||||
if not cls._is_super_admin(current_user):
|
||||
raise PermissionException('', '仅超级管理员可执行测试批次管理操作')
|
||||
|
||||
@classmethod
|
||||
async def resolve_scope(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
batch_id: int | None,
|
||||
*,
|
||||
write: bool = False,
|
||||
) -> TestBatchScope:
|
||||
"""解析并验证业务请求可使用的数据作用域。"""
|
||||
if batch_id is None:
|
||||
return TestBatchScope.formal()
|
||||
|
||||
batch = await TestBatchDao.get_batch(db, batch_id)
|
||||
if batch is None or batch.status == TestBatchStatus.PURGED.value:
|
||||
cls._raise_unavailable()
|
||||
|
||||
if not cls._is_super_admin(current_user):
|
||||
user_id = current_user.user.user_id if current_user.user is not None else None
|
||||
if user_id is None or not await TestBatchDao.user_is_authorized(db, batch_id, user_id):
|
||||
cls._raise_unavailable()
|
||||
|
||||
if batch.status == TestBatchStatus.CLOSED.value:
|
||||
if write:
|
||||
cls._raise_closed()
|
||||
cls._raise_unavailable()
|
||||
if batch.status != TestBatchStatus.ACTIVE.value:
|
||||
cls._raise_unavailable()
|
||||
|
||||
return TestBatchScope(
|
||||
batch_id=batch_id,
|
||||
mode='TEST',
|
||||
status=TestBatchStatus.ACTIVE,
|
||||
writable=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def lock_active_batch(cls, db: AsyncSession, scope: TestBatchScope) -> BizTestBatch | None:
|
||||
"""在写入前锁定并重新确认测试批次仍为 ACTIVE。"""
|
||||
if scope.mode == 'FORMAL':
|
||||
return None
|
||||
|
||||
batch = await TestBatchDao.get_batch_for_update(db, scope.batch_id)
|
||||
if batch is None or batch.status == TestBatchStatus.PURGED.value:
|
||||
cls._raise_unavailable()
|
||||
if batch.status == TestBatchStatus.CLOSED.value:
|
||||
cls._raise_closed()
|
||||
if batch.status != TestBatchStatus.ACTIVE.value:
|
||||
cls._raise_unavailable()
|
||||
return batch
|
||||
Reference in New Issue
Block a user