安全:增加测试批次上下文硬校验

This commit is contained in:
焦龙言
2026-09-22 15:11:53 +08:00
parent 17bfa2c5fe
commit 763ea1efcc
9 changed files with 666 additions and 4 deletions
@@ -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