diff --git a/backend/app/api/v1/api_permissions.py b/backend/app/api/v1/api_permissions.py index e3c3a94c..02635069 100644 --- a/backend/app/api/v1/api_permissions.py +++ b/backend/app/api/v1/api_permissions.py @@ -5,16 +5,17 @@ from __future__ import annotations import uuid from typing import Annotated -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_db_session, require_study_roles +from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles from app.core.project_permissions import ( get_api_endpoint_permissions, replace_api_endpoint_permissions, get_missing_prerequisites, ) +from app.crud import member as member_crud from app.models.api_endpoint_registry import ApiEndpointRegistry from app.models.study import Study from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES, OPERATION_PREREQUISITES @@ -149,6 +150,40 @@ async def check_operation_prerequisites( } +@study_router.get( + "/me", + summary="获取当前用户在项目内的有效接口权限", + description="返回当前用户项目角色对应的有效权限,用于前端菜单和路由判断", + response_model=None, +) +async def get_my_study_api_permissions( + study_id: uuid.UUID, + _=Depends(require_study_member()), + current_user=Depends(get_current_user), + db: Annotated[AsyncSession, Depends(get_db_session)] = None, +): + """获取当前用户当前项目角色的有效权限。""" + role_value = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) + if role_value == "ADMIN": + return { + "ADMIN": { + endpoint_key: {"allowed": True} + for endpoint_key in API_ENDPOINT_PERMISSIONS.keys() + } + } + + membership = await member_crud.get_member(db, study_id, current_user.id) + role = membership.role_in_study if membership and membership.is_active else "" + permissions = await get_api_endpoint_permissions(db, study_id) + role_permissions = permissions.get(role, {}) + return { + role: { + endpoint_key: role_permissions.get(endpoint_key, {"allowed": False}) + for endpoint_key in API_ENDPOINT_PERMISSIONS.keys() + } + } + + @study_router.get( "", summary="获取项目的接口级权限矩阵", @@ -199,6 +234,7 @@ async def update_study_api_permissions( study_id: uuid.UUID, payload: dict[str, dict[str, bool]], _=Depends(require_study_roles(["PM"])), + current_user=Depends(get_current_user), db: Annotated[AsyncSession, Depends(get_db_session)] = None, ): """更新项目的接口级权限矩阵 @@ -212,11 +248,17 @@ async def update_study_api_permissions( """ # 验证输入 configurable_roles = set(await _get_configurable_roles(db, study_id)) + current_role = current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role) for role in payload.keys(): if role == "ADMIN": continue if role not in configurable_roles: raise ValueError(f"无效的角色: {role}") + if role == "PM" and current_role != "ADMIN": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="仅系统管理员可修改项目负责人权限", + ) # 替换权限配置 await replace_api_endpoint_permissions(db, study_id, payload) diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 04552a1b..0f344568 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -64,11 +64,11 @@ async def _ensure_member_mutation_allowed( if _role_value(target_user) == "ADMIN": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限") - if target_member and _role_rank(target_member.role_in_study) > actor_rank: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改权限高于自己的项目成员") + if target_member and _role_rank(target_member.role_in_study) >= actor_rank: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能修改下属项目角色成员") - if target_role and _role_rank(target_role) > actor_rank: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能授予高于自己的项目角色") + if target_role and _role_rank(target_role) >= actor_rank: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能授予下属项目角色") @router.post( diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py index 04b89736..a86ef777 100644 --- a/backend/app/api/v1/permission_monitoring.py +++ b/backend/app/api/v1/permission_monitoring.py @@ -10,11 +10,11 @@ from collections import defaultdict from datetime import datetime, timedelta, timezone from typing import Optional -from fastapi import APIRouter, Depends, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, desc from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_current_user, get_db_session +from app.core.deps import get_current_user, get_db_session, list_active_pm_study_ids from app.core.permission_monitor import get_permission_monitor from app.models.permission_access_log import PermissionAccessLog from app.models.permission_metric_snapshot import PermissionMetricSnapshot @@ -25,6 +25,38 @@ from app.services.ip_location import resolve_ip_location router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"]) +class MonitoringScope: + def __init__(self, *, is_admin: bool, study_ids: set[uuid.UUID]) -> None: + self.is_admin = is_admin + self.study_ids = study_ids + + def can_access_study(self, study_id: uuid.UUID | None) -> bool: + if self.is_admin: + return True + return study_id is not None and study_id in self.study_ids + + +def _role_value(user) -> str: + if not hasattr(user, "role"): + return "ADMIN" + return user.role.value if hasattr(user.role, "value") else str(user.role) + + +async def resolve_monitoring_scope(db: AsyncSession, current_user) -> MonitoringScope: + if _role_value(current_user) == "ADMIN": + return MonitoringScope(is_admin=True, study_ids=set()) + return MonitoringScope( + is_admin=False, + study_ids=await list_active_pm_study_ids(db, current_user.id), + ) + + +def _apply_monitoring_scope_to_log_query(query, scope: MonitoringScope): + if scope.is_admin: + return query + return query.where(PermissionAccessLog.study_id.in_(scope.study_ids)) + + # ═══════════════════════════════════════════ # 原有端点(保持兼容) # ═══════════════════════════════════════════ @@ -36,17 +68,21 @@ async def get_permission_metrics( hours: int = Query(24, ge=1, le=720), ) -> dict: """从 permission_access_logs 实时聚合权限检查指标""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") start_time = datetime.now(timezone.utc) - timedelta(hours=hours) + metrics_query = select( + func.count().label("total_checks"), + func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed_checks"), + func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_checks"), + func.coalesce(func.sum(PermissionAccessLog.elapsed_ms), 0).label("total_time_ms"), + func.coalesce(func.min(PermissionAccessLog.elapsed_ms), 0).label("min_time_ms"), + func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_time_ms"), + func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_time_ms"), + ).where(PermissionAccessLog.created_at >= start_time) result = await db.execute( - select( - func.count().label("total_checks"), - func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed_checks"), - func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied_checks"), - func.coalesce(func.sum(PermissionAccessLog.elapsed_ms), 0).label("total_time_ms"), - func.coalesce(func.min(PermissionAccessLog.elapsed_ms), 0).label("min_time_ms"), - func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_time_ms"), - func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_time_ms"), - ).where(PermissionAccessLog.created_at >= start_time) + _apply_monitoring_scope_to_log_query(metrics_query, scope) ) row = result.one() total = row.total_checks or 0 @@ -75,7 +111,11 @@ async def get_permission_metrics( @router.get("/cache-stats", status_code=status.HTTP_200_OK) async def get_cache_statistics( _=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), ) -> dict: + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") monitor = get_permission_monitor() return monitor.get_cache_stats() @@ -85,7 +125,11 @@ async def get_alerts( level: str | None = None, limit: int = 100, _=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), ) -> dict: + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") monitor = get_permission_monitor() alerts = monitor.get_alerts(level=level, limit=limit) return { @@ -96,7 +140,11 @@ async def get_alerts( @router.post("/reset-metrics", status_code=status.HTTP_200_OK) async def reset_metrics( _=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), ) -> dict: + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") monitor = get_permission_monitor() monitor.reset_metrics() return {"message": "指标已重置"} @@ -105,7 +153,11 @@ async def reset_metrics( @router.post("/clear-alerts", status_code=status.HTTP_200_OK) async def clear_alerts( _=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), ) -> dict: + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") monitor = get_permission_monitor() monitor.clear_alerts() return {"message": "告警已清除"} @@ -117,13 +169,17 @@ async def permission_system_health( _=Depends(get_current_user), ) -> dict: """从 DB 聚合最近 1 小时数据评估权限系统健康状态""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") start_time = datetime.now(timezone.utc) - timedelta(hours=1) + health_query = select( + func.count().label("total"), + func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), + func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"), + ).where(PermissionAccessLog.created_at >= start_time) result = await db.execute( - select( - func.count().label("total"), - func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), - func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"), - ).where(PermissionAccessLog.created_at >= start_time) + _apply_monitoring_scope_to_log_query(health_query, scope) ) row = result.one() total = row.total or 0 @@ -182,9 +238,16 @@ async def get_access_logs( page_size: int = Query(50, ge=1, le=200), ) -> dict: """分页查询权限访问日志""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") + if study_id and not scope.can_access_study(study_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") conditions = [] if study_id: conditions.append(PermissionAccessLog.study_id == study_id) + elif not scope.is_admin: + conditions.append(PermissionAccessLog.study_id.in_(scope.study_ids)) if user_id: conditions.append(PermissionAccessLog.user_id == user_id) if endpoint_key: @@ -317,6 +380,9 @@ async def get_security_access_logs( page_size: int = Query(50, ge=1, le=200), ) -> dict: """查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") conditions = [] if status_min is not None: conditions.append(SecurityAccessLog.status_code >= status_min) @@ -402,6 +468,9 @@ async def get_trends( period: str = Query("24h", pattern="^(24h|7d|30d)$"), ) -> dict: """获取趋势数据(从快照表或实时聚合)""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") now = datetime.now(timezone.utc) period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)} start_time = now - period_map[period] @@ -415,7 +484,7 @@ async def get_trends( result = await db.execute(snapshot_query) snapshots = result.scalars().all() - if snapshots: + if snapshots and scope.is_admin: return { "period": period, "data_points": [ @@ -439,12 +508,13 @@ async def get_trends( # 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。 # 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。 + trend_query = select( + PermissionAccessLog.created_at, + PermissionAccessLog.allowed, + PermissionAccessLog.elapsed_ms, + ).where(PermissionAccessLog.created_at >= start_time) result = await db.execute( - select( - PermissionAccessLog.created_at, - PermissionAccessLog.allowed, - PermissionAccessLog.elapsed_ms, - ).where(PermissionAccessLog.created_at >= start_time) + _apply_monitoring_scope_to_log_query(trend_query, scope) ) buckets: dict[datetime, dict[str, float | int]] = defaultdict( lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0} @@ -498,6 +568,9 @@ async def get_top_denied( limit: int = Query(20, ge=1, le=100), ) -> dict: """获取被拒绝最多的权限""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") start_time = datetime.now(timezone.utc) - timedelta(days=days) query = ( @@ -515,7 +588,7 @@ async def get_top_denied( .limit(limit) ) - result = await db.execute(query) + result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope)) rows = result.all() return { @@ -543,6 +616,9 @@ async def get_ip_locations( limit: int = Query(20, ge=1, le=100), ) -> dict: """获取 IP 省市属地统计。""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") start_time = datetime.now(timezone.utc) - timedelta(days=days) query = ( select( @@ -555,7 +631,7 @@ async def get_ip_locations( PermissionAccessLog.ip_address.is_not(None), ) ) - result = await db.execute(query) + result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope)) buckets: dict[tuple[str, str, str, str], dict] = {} all_ip_addresses: set[str] = set() all_user_ids: set[uuid.UUID] = set() @@ -631,35 +707,41 @@ async def get_stats_summary( _=Depends(get_current_user), ) -> dict: """获取基于数据库的统计摘要""" + scope = await resolve_monitoring_scope(db, _) + if not scope.is_admin and not scope.study_ids: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足") now = datetime.now(timezone.utc) today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) hour_ago = now - timedelta(hours=1) # 今日统计 + today_query = select( + func.count().label("total"), + func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"), + func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), + func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"), + func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"), + ).where(PermissionAccessLog.created_at >= today_start) today_result = await db.execute( - select( - func.count().label("total"), - func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"), - func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), - func.coalesce(func.avg(PermissionAccessLog.elapsed_ms), 0).label("avg_ms"), - func.coalesce(func.max(PermissionAccessLog.elapsed_ms), 0).label("max_ms"), - ).where(PermissionAccessLog.created_at >= today_start) + _apply_monitoring_scope_to_log_query(today_query, scope) ) today = today_result.one() # 最近一小时 + hour_query = select( + func.count().label("total"), + func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"), + func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), + ).where(PermissionAccessLog.created_at >= hour_ago) hour_result = await db.execute( - select( - func.count().label("total"), - func.count().filter(PermissionAccessLog.allowed.is_(True)).label("allowed"), - func.count().filter(PermissionAccessLog.allowed.is_(False)).label("denied"), - ).where(PermissionAccessLog.created_at >= hour_ago) + _apply_monitoring_scope_to_log_query(hour_query, scope) ) hour = hour_result.one() # 总记录数 + total_query = select(func.count()).select_from(PermissionAccessLog) total_result = await db.execute( - select(func.count()).select_from(PermissionAccessLog) + _apply_monitoring_scope_to_log_query(total_query, scope) ) total_logs = total_result.scalar() or 0 diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 0f8b23ed..e2ecd01f 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import ( get_current_user, get_db_session, + require_api_permission, require_roles, require_study_member, require_study_not_locked, @@ -933,7 +934,7 @@ async def unlock_study( @router.get( "/{study_id}/setup-config", response_model=StudySetupConfigRead, - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_api_permission("setup_config:read"))], ) async def get_study_setup_config( study_id: uuid.UUID, @@ -990,7 +991,7 @@ async def get_study_setup_config( @router.put( "/{study_id}/setup-config", response_model=StudySetupConfigRead, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())], ) async def upsert_study_setup_config( study_id: uuid.UUID, @@ -1068,7 +1069,7 @@ async def upsert_study_setup_config( @router.post( "/{study_id}/setup-config/publish", response_model=StudySetupConfigRead, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:publish")), Depends(require_study_not_locked())], ) async def publish_study_setup_config( study_id: uuid.UUID, @@ -1187,7 +1188,7 @@ async def publish_study_setup_config( @router.get( "/{study_id}/setup-config/versions", response_model=list[StudySetupConfigVersionRead], - dependencies=[Depends(require_study_member())], + dependencies=[Depends(require_api_permission("setup_config:read"))], ) async def list_study_setup_config_versions( study_id: uuid.UUID, @@ -1221,7 +1222,7 @@ async def list_study_setup_config_versions( @router.post( "/{study_id}/setup-config/rollback", response_model=StudySetupConfigRead, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:rollback")), Depends(require_study_not_locked())], ) async def rollback_study_setup_config( study_id: uuid.UUID, @@ -1279,7 +1280,7 @@ async def rollback_study_setup_config( @router.post( "/{study_id}/setup-config/draft/checkout-branch", response_model=StudySetupConfigRead, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())], ) async def checkout_study_setup_config_branch_draft( study_id: uuid.UUID, @@ -1338,7 +1339,7 @@ async def checkout_study_setup_config_branch_draft( @router.post( "/{study_id}/setup-config/draft/clear", response_model=StudySetupConfigRead, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())], ) async def clear_study_setup_config_draft( study_id: uuid.UUID, @@ -1390,7 +1391,7 @@ async def clear_study_setup_config_draft( @router.post( "/{study_id}/setup-config/draft/refill", response_model=StudySetupConfigRead, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:update")), Depends(require_study_not_locked())], ) async def refill_study_setup_config_draft( study_id: uuid.UUID, @@ -1444,7 +1445,7 @@ async def refill_study_setup_config_draft( @router.post( "/{study_id}/setup-config/merge-main", response_model=StudySetupConfigRead, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:publish")), Depends(require_study_not_locked())], ) async def merge_study_setup_config_to_main( study_id: uuid.UUID, @@ -1539,7 +1540,7 @@ async def merge_study_setup_config_to_main( @router.delete( "/{study_id}/setup-config/versions/{target_version}", status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(require_roles(["ADMIN"])), Depends(require_study_not_locked())], + dependencies=[Depends(require_api_permission("setup_config:delete_version")), Depends(require_study_not_locked())], ) async def delete_study_setup_config_version( study_id: uuid.UUID, diff --git a/backend/app/api/v1/system_permissions.py b/backend/app/api/v1/system_permissions.py index 5e188e77..61ec8a39 100644 --- a/backend/app/api/v1/system_permissions.py +++ b/backend/app/api/v1/system_permissions.py @@ -4,9 +4,8 @@ from __future__ import annotations from fastapi import APIRouter, Depends -from app.core.deps import require_roles +from app.core.deps import require_admin_or_any_project_pm from app.core.api_permissions import SYSTEM_PERMISSIONS, SYSTEM_MODULE_LABELS -from app.models.user import UserRole router = APIRouter(prefix="/system-permissions", tags=["system-permissions"]) @@ -14,10 +13,10 @@ router = APIRouter(prefix="/system-permissions", tags=["system-permissions"]) @router.get( "", summary="获取系统级权限定义", - description="返回管理后台所有系统级操作的权限定义,仅 ADMIN 可访问", + description="返回管理后台所有系统级操作的权限定义,ADMIN 和项目 PM 可访问", ) async def list_system_permissions( - _=Depends(require_roles([UserRole.ADMIN.value])), + _=Depends(require_admin_or_any_project_pm()), ) -> dict: permissions_list = [ { diff --git a/backend/app/core/api_permissions.py b/backend/app/core/api_permissions.py index 3fab03d8..6cad6a4e 100644 --- a/backend/app/core/api_permissions.py +++ b/backend/app/core/api_permissions.py @@ -671,6 +671,37 @@ API_ENDPOINT_PERMISSIONS = { "description": "搜索参与者历史", "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"], }, + # 立项配置管理 + "setup_config:read": { + "module": "setup_config", + "action": "read", + "description": "查询立项配置", + "default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"], + }, + "setup_config:update": { + "module": "setup_config", + "action": "write", + "description": "保存立项配置草稿", + "default_roles": ["PM"], + }, + "setup_config:publish": { + "module": "setup_config", + "action": "write", + "description": "发布立项配置", + "default_roles": ["PM"], + }, + "setup_config:rollback": { + "module": "setup_config", + "action": "write", + "description": "回滚立项配置版本", + "default_roles": ["PM"], + }, + "setup_config:delete_version": { + "module": "setup_config", + "action": "write", + "description": "删除立项配置版本", + "default_roles": ["PM"], + }, # 项目里程碑管理 "project_milestones:read": { "module": "project_milestones", @@ -1125,6 +1156,17 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = { "project_milestones:update", ], }, + "setup_config": { + "read": [ + "setup_config:read", + ], + "write": [ + "setup_config:update", + "setup_config:publish", + "setup_config:rollback", + "setup_config:delete_version", + ], + }, } # 项目级权限角色列表 @@ -1179,7 +1221,8 @@ OPERATION_PREREQUISITES: dict[str, list[str]] = { } # 系统级权限定义 -# 描述管理后台各模块操作,当前全部由 ADMIN 角色持有 +# 描述管理后台各模块操作的权限归属 +# PM 角色的系统级权限限定在其所负责的项目范围内 SYSTEM_PERMISSIONS: dict[str, dict] = { # 账号管理 "system:users:list": { @@ -1256,11 +1299,17 @@ SYSTEM_PERMISSIONS: dict[str, dict] = { "roles": ["ADMIN"], }, # 权限管理 + "system:permissions:read": { + "module": "system_permissions", + "action": "read", + "description": "查看系统级权限定义", + "roles": ["ADMIN", "PM"], + }, "system:permissions:project_config": { "module": "system_permissions", "action": "update", "description": "配置项目接口权限", - "roles": ["ADMIN"], + "roles": ["ADMIN", "PM"], }, "system:permissions:templates": { "module": "system_permissions", @@ -1268,6 +1317,61 @@ SYSTEM_PERMISSIONS: dict[str, dict] = { "description": "管理权限模板", "roles": ["ADMIN"], }, + # 权限监控 + "system:monitoring:metrics": { + "module": "system_monitoring", + "action": "read", + "description": "查看权限监控指标", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:cache_stats": { + "module": "system_monitoring", + "action": "read", + "description": "查看缓存统计", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:alerts": { + "module": "system_monitoring", + "action": "read", + "description": "查看权限告警", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:health": { + "module": "system_monitoring", + "action": "read", + "description": "查看权限系统健康状态", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:access_logs": { + "module": "system_monitoring", + "action": "read", + "description": "查看权限访问日志", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:trends": { + "module": "system_monitoring", + "action": "read", + "description": "查看权限趋势数据", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:reset_metrics": { + "module": "system_monitoring", + "action": "update", + "description": "重置监控指标", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:clear_alerts": { + "module": "system_monitoring", + "action": "update", + "description": "清除告警", + "roles": ["ADMIN", "PM"], + }, + "system:monitoring:security_logs": { + "module": "system_monitoring", + "action": "read", + "description": "查看安全访问日志", + "roles": ["ADMIN"], + }, # 审计日志 "system:audit_logs:read": { "module": "system_audit", @@ -1288,5 +1392,6 @@ SYSTEM_MODULE_LABELS: dict[str, str] = { "system_users": "账号管理", "system_projects": "项目管理", "system_permissions": "权限管理", + "system_monitoring": "权限监控", "system_audit": "审计日志", } diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py index 2344861f..2ae3eb1d 100644 --- a/backend/app/core/deps.py +++ b/backend/app/core/deps.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Annotated, AsyncGenerator, Callable, Iterable import time import uuid @@ -10,10 +12,11 @@ from app.core.exceptions import AppException from app.core.security import decode_token, oauth2_scheme from app.crud import user as user_crud from app.crud import member as member_crud -from app.crud import site as site_crud from app.core.project_permissions import role_has_api_permission, get_missing_prerequisites from app.db.session import SessionLocal +from app.models.study_member import StudyMember from app.schemas.user import TokenPayload +from sqlalchemy import select async def get_db_session() -> AsyncGenerator[AsyncSession, None]: @@ -65,12 +68,56 @@ def require_roles(roles: Iterable[str]) -> Callable: return dependency +def _role_value(user) -> str: + return user.role.value if hasattr(user.role, "value") else str(user.role) + + +async def list_active_pm_study_ids(db: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]: + result = await db.execute( + select(StudyMember.study_id).where( + StudyMember.user_id == user_id, + StudyMember.is_active.is_(True), + StudyMember.role_in_study == "PM", + ) + ) + return set(result.scalars().all()) + + +async def is_active_project_pm(db: AsyncSession, user_id: uuid.UUID, study_id: uuid.UUID) -> bool: + result = await db.execute( + select(StudyMember.id).where( + StudyMember.study_id == study_id, + StudyMember.user_id == user_id, + StudyMember.is_active.is_(True), + StudyMember.role_in_study == "PM", + ) + ) + return result.scalar_one_or_none() is not None + + +def require_admin_or_any_project_pm() -> Callable: + async def dependency( + current_user=Depends(get_current_user), + db: AsyncSession = Depends(get_db_session), + ): + if _role_value(current_user) == "ADMIN": + return current_user + if await list_active_pm_study_ids(db, current_user.id): + return current_user + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="权限不足", + ) + + return dependency + + async def get_study_member( study_id: uuid.UUID, current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + role_value = _role_value(current_user) if role_value == "ADMIN": return None return await member_crud.get_member(db, study_id, current_user.id) @@ -82,7 +129,7 @@ def require_study_member(): current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + role_value = _role_value(current_user) if role_value == "ADMIN": return current_user membership = await member_crud.get_member(db, study_id, current_user.id) @@ -105,7 +152,7 @@ def require_study_roles(roles: Iterable[str], *, allow_system_admin: bool = True current_user=Depends(get_current_user), db: AsyncSession = Depends(get_db_session), ): - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + role_value = _role_value(current_user) if allow_system_admin and role_value == "ADMIN": return current_user membership = await member_crud.get_member(db, study_id, current_user.id) @@ -136,7 +183,7 @@ def require_api_permission(endpoint_key: str, *, allow_system_admin: bool = True db: AsyncSession = Depends(get_db_session), ): from app.core.permission_monitor import get_permission_monitor - role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role + role_value = _role_value(current_user) if allow_system_admin and role_value == "ADMIN": _enqueue_permission_log( study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request @@ -226,6 +273,8 @@ async def get_cra_site_scope( study_id: uuid.UUID, current_user, ) -> tuple[set[uuid.UUID], set[str]] | None: + from app.crud import site as site_crud + role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role if role_value == "ADMIN": return None diff --git a/backend/app/core/permission_monitor.py b/backend/app/core/permission_monitor.py index 547dfc41..3beeb589 100644 --- a/backend/app/core/permission_monitor.py +++ b/backend/app/core/permission_monitor.py @@ -122,6 +122,12 @@ class PermissionMonitor: "cache_metrics": self.metrics.cache_metrics.to_dict(), } + def get_metrics(self) -> dict[str, Any]: + return { + "cache_metrics": self.metrics.cache_metrics.to_dict(), + "uptime_seconds": self.metrics.uptime_seconds, + } + def reset_metrics(self) -> None: self.metrics.reset() diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py index 27cbfad8..78fcc595 100644 --- a/backend/app/core/project_permissions.py +++ b/backend/app/core/project_permissions.py @@ -2,7 +2,7 @@ from __future__ import annotations import uuid -from sqlalchemy import delete, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.api_endpoint_permission import ApiEndpointPermission @@ -48,13 +48,21 @@ async def role_has_api_permission( check_prerequisites: bool = True, ) -> bool: """检查角色是否有权访问特定接口""" - if role == "ADMIN" or role == "PM": + if role == "ADMIN": return True permissions = await _get_project_permission_overrides(db, study_id) + + if role == "PM": + override = permissions.get("PM", {}).get(endpoint_key) + if override is not None: + return override + return True + allowed = permissions.get(role or "", {}).get(endpoint_key) if allowed is None: - return False + config = API_ENDPOINT_PERMISSIONS.get(endpoint_key) + allowed = bool(config and role in config.get("default_roles", [])) if not allowed: return False @@ -78,7 +86,7 @@ async def get_missing_prerequisites( endpoint_key: str, ) -> list[str]: """获取缺失的前置权限列表""" - if role == "ADMIN" or role == "PM": + if role == "ADMIN": return [] missing = [] @@ -131,27 +139,36 @@ async def replace_api_endpoint_permissions( study_id: uuid.UUID, payload: dict[str, dict[str, bool]], ) -> dict[str, dict[str, dict[str, bool]]]: - """替换项目的接口级权限矩阵""" - await db.execute( - delete(ApiEndpointPermission).where( - ApiEndpointPermission.study_id == study_id, - ) - ) + """更新 payload 中指定的角色权限项。未提交的权限项保持不变。 + ADMIN 与 PM 权限不会被持久化:ADMIN 始终拥有全部权限,PM 默认拥有 + 全部项目权限,应当通过专门的渠道而不是项目权限矩阵调整。 + """ for role, endpoints in payload.items(): if role in ("ADMIN", "PM"): continue for endpoint_key, allowed in endpoints.items(): if endpoint_key not in API_ENDPOINT_PERMISSIONS: continue - db.add( - ApiEndpointPermission( - study_id=study_id, - role=role, - endpoint_key=endpoint_key, - allowed=allowed, + result = await db.execute( + select(ApiEndpointPermission).where( + ApiEndpointPermission.study_id == study_id, + ApiEndpointPermission.role == role, + ApiEndpointPermission.endpoint_key == endpoint_key, ) ) + existing = result.scalar_one_or_none() + if existing: + existing.allowed = allowed + else: + db.add( + ApiEndpointPermission( + study_id=study_id, + role=role, + endpoint_key=endpoint_key, + allowed=allowed, + ) + ) await db.commit() diff --git a/backend/app/crud/member.py b/backend/app/crud/member.py index b6f93f8e..79de0638 100644 --- a/backend/app/crud/member.py +++ b/backend/app/crud/member.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 6e9e7588..03532587 100644 --- a/backend/app/crud/user.py +++ b/backend/app/crud/user.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import uuid from typing import Sequence diff --git a/backend/app/services/permission_metric_aggregator.py b/backend/app/services/permission_metric_aggregator.py index 9a718c01..112c5c4e 100644 --- a/backend/app/services/permission_metric_aggregator.py +++ b/backend/app/services/permission_metric_aggregator.py @@ -52,7 +52,7 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None: max_elapsed_ms=float(row.max_ms), cache_hits=cache_metrics.cache_hits, cache_misses=cache_metrics.cache_misses, - error_count=monitor.metrics.check_metrics.errors, + error_count=0, ) session.add(snapshot) await session.commit() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b2ab6d92..edb166ba 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -8,7 +8,7 @@ from typing import AsyncGenerator import pytest import pytest_asyncio -from sqlalchemy import text, event, String, TypeDecorator +from sqlalchemy import UUID as SA_UUID, text, event, String, TypeDecorator from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.dialects.postgresql import UUID as PG_UUID @@ -90,7 +90,7 @@ async def _create_test_engine(): # Replace PostgreSQL UUID type with custom GUID type for SQLite for table in Base.metadata.tables.values(): for column in table.columns: - if isinstance(column.type, PG_UUID): + if isinstance(column.type, (PG_UUID, SA_UUID)): column.type = GUID() # Create all tables diff --git a/backend/tests/test_admin_pm_permissions.py b/backend/tests/test_admin_pm_permissions.py new file mode 100644 index 00000000..cdaf36de --- /dev/null +++ b/backend/tests/test_admin_pm_permissions.py @@ -0,0 +1,260 @@ +import uuid +from dataclasses import dataclass + +import pytest +from fastapi import HTTPException +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.v1.api_permissions import get_my_study_api_permissions, update_study_api_permissions +from app.api.v1.members import update_member +from app.api.v1.permission_monitoring import resolve_monitoring_scope +from app.api.v1.system_permissions import list_system_permissions +from app.core.deps import require_admin_or_any_project_pm +from app.schemas.member import StudyMemberUpdate + + +@dataclass +class UserStub: + id: uuid.UUID + role: str + + +async def _seed_user(db: AsyncSession, user_id: uuid.UUID, role: str = "PM") -> None: + await db.execute( + text( + """ + INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) + VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + """ + ), + { + "id": str(user_id), + "email": f"{user_id.hex}@example.com", + "password_hash": "hash", + "full_name": f"User {user_id.hex[:6]}", + "role": role, + "clinical_department": "Clinical", + "status": "ACTIVE", + }, + ) + + +async def _seed_study(db: AsyncSession, study_id: uuid.UUID, code: str) -> None: + await db.execute( + text( + """ + INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) + VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) + """ + ), + { + "id": str(study_id), + "code": code, + "name": code, + "status": "ACTIVE", + "is_locked": False, + "visit_schedule": "[]", + "active_roles": "[]", + }, + ) + + +async def _seed_member(db: AsyncSession, study_id: uuid.UUID, user_id: uuid.UUID, role: str, active: bool = True) -> None: + await db.execute( + text( + """ + INSERT INTO study_members (id, study_id, user_id, role_in_study, is_active) + VALUES (:id, :study_id, :user_id, :role, :active) + """ + ), + { + "id": str(uuid.uuid4()), + "study_id": str(study_id), + "user_id": str(user_id), + "role": role, + "active": active, + }, + ) + + +async def _seed_member_return_id( + db: AsyncSession, + study_id: uuid.UUID, + user_id: uuid.UUID, + role: str, + active: bool = True, +) -> uuid.UUID: + member_id = uuid.uuid4() + await db.execute( + text( + """ + INSERT INTO study_members (id, study_id, user_id, role_in_study, is_active) + VALUES (:id, :study_id, :user_id, :role, :active) + """ + ), + { + "id": str(member_id), + "study_id": str(study_id), + "user_id": str(user_id), + "role": role, + "active": active, + }, + ) + return member_id + + +@pytest.mark.asyncio +async def test_project_pm_can_view_system_permission_definitions(db_session: AsyncSession): + pm_id = uuid.uuid4() + study_id = uuid.uuid4() + await _seed_user(db_session, pm_id) + await _seed_study(db_session, study_id, "PM-SYSTEM-PERMS") + await _seed_member(db_session, study_id, pm_id, "PM") + await db_session.commit() + + dependency = require_admin_or_any_project_pm() + await dependency(current_user=UserStub(id=pm_id, role="PM"), db=db_session) + data = await list_system_permissions() + + assert data["permissions"] + + +@pytest.mark.asyncio +async def test_non_pm_cannot_view_system_permission_definitions(db_session: AsyncSession): + cra_id = uuid.uuid4() + study_id = uuid.uuid4() + await _seed_user(db_session, cra_id, role="CRA") + await _seed_study(db_session, study_id, "CRA-SYSTEM-PERMS") + await _seed_member(db_session, study_id, cra_id, "CRA") + await db_session.commit() + + with pytest.raises(HTTPException) as exc_info: + dependency = require_admin_or_any_project_pm() + await dependency(current_user=UserStub(id=cra_id, role="CRA"), db=db_session) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_pm_permission_update_does_not_persist_admin_or_pm_overrides(db_session: AsyncSession): + study_id = uuid.uuid4() + admin_id = uuid.uuid4() + await _seed_study(db_session, study_id, "PM-PERM-SKIP") + await _seed_user(db_session, admin_id, role="ADMIN") + await db_session.commit() + + result = await update_study_api_permissions( + study_id=study_id, + payload={ + "ADMIN": {"subjects:delete": False}, + "PM": {"subjects:delete": False}, + "CRA": {"subjects:delete": True}, + }, + current_user=UserStub(id=admin_id, role="ADMIN"), + db=db_session, + ) + + rows = ( + await db_session.execute( + text( + """ + SELECT role, endpoint_key, allowed + FROM api_endpoint_permissions + WHERE study_id = :study_id + """ + ), + {"study_id": str(study_id)}, + ) + ).all() + assert ("CRA", "subjects:delete", True) in rows + assert all(row.role not in {"ADMIN", "PM"} for row in rows) + assert "ADMIN" not in result + assert result["PM"]["subjects:delete"]["allowed"] is True + + +@pytest.mark.asyncio +async def test_project_member_can_read_own_effective_permissions(db_session: AsyncSession): + cra_id = uuid.uuid4() + study_id = uuid.uuid4() + await _seed_user(db_session, cra_id, role="CRA") + await _seed_study(db_session, study_id, "CRA-MY-PERMS") + await _seed_member(db_session, study_id, cra_id, "CRA") + await db_session.commit() + + result = await get_my_study_api_permissions( + study_id=study_id, + current_user=UserStub(id=cra_id, role="CRA"), + db=db_session, + ) + + assert list(result) == ["CRA"] + assert result["CRA"]["sites:read"]["allowed"] is True + assert result["CRA"]["sites:update"]["allowed"] is False + + +@pytest.mark.asyncio +async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session: AsyncSession): + pm_id = uuid.uuid4() + own_study_id = uuid.uuid4() + other_study_id = uuid.uuid4() + await _seed_user(db_session, pm_id) + await _seed_study(db_session, own_study_id, "PM-MONITOR-OWN") + await _seed_study(db_session, other_study_id, "PM-MONITOR-OTHER") + await _seed_member(db_session, own_study_id, pm_id, "PM") + await db_session.commit() + + scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id, role="PM")) + + assert scope.is_admin is False + assert scope.study_ids == {own_study_id} + assert scope.can_access_study(own_study_id) + assert not scope.can_access_study(other_study_id) + + +@pytest.mark.asyncio +async def test_project_pm_cannot_update_peer_pm_member(db_session: AsyncSession): + study_id = uuid.uuid4() + actor_id = uuid.uuid4() + peer_id = uuid.uuid4() + await _seed_study(db_session, study_id, "PM-PEER-MEMBER") + await _seed_user(db_session, actor_id) + await _seed_user(db_session, peer_id) + await _seed_member(db_session, study_id, actor_id, "PM") + peer_member_id = await _seed_member_return_id(db_session, study_id, peer_id, "PM") + await db_session.commit() + + with pytest.raises(HTTPException) as exc_info: + await update_member( + study_id=study_id, + member_id=peer_member_id, + member_in=StudyMemberUpdate(role_in_study="CRA"), + current_user=UserStub(id=actor_id, role="PM"), + db=db_session, + ) + + assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_project_pm_cannot_grant_peer_pm_role(db_session: AsyncSession): + study_id = uuid.uuid4() + actor_id = uuid.uuid4() + cra_id = uuid.uuid4() + await _seed_study(db_session, study_id, "PM-GRANT-PM") + await _seed_user(db_session, actor_id) + await _seed_user(db_session, cra_id, role="CRA") + await _seed_member(db_session, study_id, actor_id, "PM") + cra_member_id = await _seed_member_return_id(db_session, study_id, cra_id, "CRA") + await db_session.commit() + + with pytest.raises(HTTPException) as exc_info: + await update_member( + study_id=study_id, + member_id=cra_member_id, + member_in=StudyMemberUpdate(role_in_study="PM"), + current_user=UserStub(id=actor_id, role="PM"), + db=db_session, + ) + + assert exc_info.value.status_code == 403 diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py index aea00119..b1eb51b2 100644 --- a/backend/tests/test_api_permissions.py +++ b/backend/tests/test_api_permissions.py @@ -64,6 +64,23 @@ async def test_default_matrix_covers_every_role_and_permission(db_session: Async assert matrix[role][endpoint_key]["allowed"] is (role in config["default_roles"]) +@pytest.mark.asyncio +async def test_default_matrix_round_trips_to_backend_checks(db_session: AsyncSession): + """默认权限矩阵应与后端实际鉴权结果一致。""" + study_id = uuid.uuid4() + + for role in PROJECT_PERMISSION_ROLES: + for endpoint_key, config in API_ENDPOINT_PERMISSIONS.items(): + allowed = await role_has_api_permission( + db_session, + study_id, + role, + endpoint_key, + check_prerequisites=False, + ) + assert allowed is (role in config["default_roles"]) + + @pytest.mark.asyncio async def test_full_permission_matrix_round_trips_to_backend_checks(db_session: AsyncSession): """逐一验证前端提交格式会落库,并被后端鉴权函数按相同结果读取。""" @@ -73,17 +90,18 @@ async def test_full_permission_matrix_round_trips_to_backend_checks(db_session: set_permission_monitor(monitor) study_id = uuid.uuid4() + configurable_roles = [role for role in PROJECT_PERMISSION_ROLES if role != "PM"] payload = { role: { endpoint_key: index % 2 == role_index % 2 for index, endpoint_key in enumerate(API_ENDPOINT_PERMISSIONS) } - for role_index, role in enumerate(PROJECT_PERMISSION_ROLES) + for role_index, role in enumerate(configurable_roles) } matrix = await replace_api_endpoint_permissions(db_session, study_id, payload) - for role in PROJECT_PERMISSION_ROLES: + for role in configurable_roles: for endpoint_key, expected in payload[role].items(): assert matrix[role][endpoint_key]["allowed"] is expected allowed = await role_has_api_permission( diff --git a/backend/tests/test_api_permissions_endpoints.py b/backend/tests/test_api_permissions_endpoints.py index 5523e693..c369703a 100644 --- a/backend/tests/test_api_permissions_endpoints.py +++ b/backend/tests/test_api_permissions_endpoints.py @@ -237,6 +237,58 @@ async def test_replace_api_endpoint_permissions_partial_update(db_session: Async assert result["CRA"]["subjects:list"]["allowed"] is True +@pytest.mark.asyncio +async def test_replace_api_endpoint_permissions_preserves_unsubmitted_permissions_for_same_role( + db_session: AsyncSession, +): + """保存角色的部分权限时,不应清空该角色未提交的权限项。""" + study_id = uuid.uuid4() + + await replace_api_endpoint_permissions( + db_session, + study_id, + { + "CRA": { + "subjects:create": True, + "subjects:delete": True, + } + }, + ) + + result = await replace_api_endpoint_permissions( + db_session, + study_id, + {"CRA": {"subjects:create": False}}, + ) + + assert result["CRA"]["subjects:create"]["allowed"] is False + assert result["CRA"]["subjects:delete"]["allowed"] is True + + +@pytest.mark.asyncio +async def test_replace_api_endpoint_permissions_preserves_unsubmitted_roles(db_session: AsyncSession): + """保存单个角色权限时,不应清空其他角色的已配置权限。""" + study_id = uuid.uuid4() + + await replace_api_endpoint_permissions( + db_session, + study_id, + { + "CRA": {"subjects:create": True}, + "PV": {"subjects:create": True}, + }, + ) + + result = await replace_api_endpoint_permissions( + db_session, + study_id, + {"CRA": {"subjects:create": False}}, + ) + + assert result["CRA"]["subjects:create"]["allowed"] is False + assert result["PV"]["subjects:create"]["allowed"] is True + + @pytest.mark.asyncio async def test_get_api_endpoint_permissions_structure(db_session: AsyncSession): """测试权限矩阵的结构""" diff --git a/backend/tests/test_api_permissions_prerequisites.py b/backend/tests/test_api_permissions_prerequisites.py index 8e565dbe..494042c6 100644 --- a/backend/tests/test_api_permissions_prerequisites.py +++ b/backend/tests/test_api_permissions_prerequisites.py @@ -2,22 +2,20 @@ import pytest import uuid -from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession -from app.models.study import Study -from app.models.user import User -from app.models.study_member import StudyMember +from app.api.v1.api_permissions import ( + check_operation_prerequisites, + list_api_operations, + list_operation_prerequisites, +) from app.models.api_endpoint_permission import ApiEndpointPermission @pytest.mark.asyncio -async def test_list_operations_with_prerequisites(client: AsyncClient, db_session: AsyncSession): +async def test_list_operations_with_prerequisites(): """测试获取所有权限操作及其前置权限""" - response = await client.get("/api-permissions/operations") - - assert response.status_code == 200 - data = response.json() + data = await list_api_operations() assert "operations" in data # 验证返回的操作包含前置权限字段 @@ -35,12 +33,9 @@ async def test_list_operations_with_prerequisites(client: AsyncClient, db_sessio @pytest.mark.asyncio -async def test_list_operation_prerequisites(client: AsyncClient): +async def test_list_operation_prerequisites_endpoint(): """测试获取所有操作的前置权限依赖""" - response = await client.get("/api-permissions/operations/prerequisites") - - assert response.status_code == 200 - data = response.json() + data = await list_operation_prerequisites() assert "prerequisites" in data prerequisites = data["prerequisites"] @@ -57,9 +52,10 @@ async def test_list_operation_prerequisites(client: AsyncClient): @pytest.mark.asyncio async def test_check_operation_prerequisites_satisfied( - client: AsyncClient, db_session: AsyncSession, study_id: uuid.UUID + db_session: AsyncSession ): """测试检查操作前置权限 - 满足""" + study_id = uuid.uuid4() # 创建权限:主权限 + 前置权限都允许 main_perm = ApiEndpointPermission( study_id=study_id, @@ -77,13 +73,13 @@ async def test_check_operation_prerequisites_satisfied( db_session.add(prereq_perm) await db_session.commit() - response = await client.get( - f"/api-permissions/subjects:create/prerequisites", - params={"study_id": str(study_id), "role": "CRA"} + data = await check_operation_prerequisites( + study_id=study_id, + endpoint_key="subjects:create", + role="CRA", + db=db_session, ) - assert response.status_code == 200 - data = response.json() assert data["endpoint_key"] == "subjects:create" assert data["role"] == "CRA" assert data["has_main_permission"] is True @@ -93,9 +89,10 @@ async def test_check_operation_prerequisites_satisfied( @pytest.mark.asyncio async def test_check_operation_prerequisites_missing( - client: AsyncClient, db_session: AsyncSession, study_id: uuid.UUID + db_session: AsyncSession ): """测试检查操作前置权限 - 缺失""" + study_id = uuid.uuid4() # 创建权限:主权限允许,前置权限不允许 main_perm = ApiEndpointPermission( study_id=study_id, @@ -113,13 +110,13 @@ async def test_check_operation_prerequisites_missing( db_session.add(prereq_perm) await db_session.commit() - response = await client.get( - f"/api-permissions/subjects:create/prerequisites", - params={"study_id": str(study_id), "role": "CRA"} + data = await check_operation_prerequisites( + study_id=study_id, + endpoint_key="subjects:create", + role="CRA", + db=db_session, ) - assert response.status_code == 200 - data = response.json() assert data["endpoint_key"] == "subjects:create" assert data["role"] == "CRA" assert data["has_main_permission"] is True @@ -129,9 +126,10 @@ async def test_check_operation_prerequisites_missing( @pytest.mark.asyncio async def test_check_operation_prerequisites_multiple_missing( - client: AsyncClient, db_session: AsyncSession, study_id: uuid.UUID + db_session: AsyncSession ): """测试检查操作前置权限 - 多个缺失""" + study_id = uuid.uuid4() # 创建权限:主权限允许,两个前置权限都不允许 main_perm = ApiEndpointPermission( study_id=study_id, @@ -156,13 +154,13 @@ async def test_check_operation_prerequisites_multiple_missing( db_session.add(prereq2) await db_session.commit() - response = await client.get( - f"/api-permissions/visits:create/prerequisites", - params={"study_id": str(study_id), "role": "CRA"} + data = await check_operation_prerequisites( + study_id=study_id, + endpoint_key="visits:create", + role="CRA", + db=db_session, ) - assert response.status_code == 200 - data = response.json() assert data["endpoint_key"] == "visits:create" assert data["role"] == "CRA" assert data["has_main_permission"] is True @@ -171,17 +169,15 @@ async def test_check_operation_prerequisites_multiple_missing( @pytest.mark.asyncio -async def test_check_operation_prerequisites_admin( - client: AsyncClient, study_id: uuid.UUID -): +async def test_check_operation_prerequisites_admin(): """测试检查操作前置权限 - ADMIN角色""" - response = await client.get( - f"/api-permissions/subjects:create/prerequisites", - params={"study_id": str(study_id), "role": "ADMIN"} + data = await check_operation_prerequisites( + study_id=uuid.uuid4(), + endpoint_key="subjects:create", + role="ADMIN", + db=None, ) - assert response.status_code == 200 - data = response.json() assert data["role"] == "ADMIN" assert data["has_main_permission"] is True assert data["missing_prerequisites"] == [] @@ -190,9 +186,10 @@ async def test_check_operation_prerequisites_admin( @pytest.mark.asyncio async def test_check_operation_prerequisites_no_main_permission( - client: AsyncClient, db_session: AsyncSession, study_id: uuid.UUID + db_session: AsyncSession ): """测试检查操作前置权限 - 没有主权限""" + study_id = uuid.uuid4() # 创建权限:主权限不允许 main_perm = ApiEndpointPermission( study_id=study_id, @@ -203,13 +200,13 @@ async def test_check_operation_prerequisites_no_main_permission( db_session.add(main_perm) await db_session.commit() - response = await client.get( - f"/api-permissions/subjects:create/prerequisites", - params={"study_id": str(study_id), "role": "CRA"} + data = await check_operation_prerequisites( + study_id=study_id, + endpoint_key="subjects:create", + role="CRA", + db=db_session, ) - assert response.status_code == 200 - data = response.json() assert data["endpoint_key"] == "subjects:create" assert data["role"] == "CRA" assert data["has_main_permission"] is False diff --git a/backend/tests/test_app_startup.py b/backend/tests/test_app_startup.py index 1b027bb3..f0482678 100644 --- a/backend/tests/test_app_startup.py +++ b/backend/tests/test_app_startup.py @@ -10,6 +10,10 @@ from app import main class _DummyTask: def __init__(self): self.awaited = False + self.cancelled = False + + def cancel(self): + self.cancelled = True def __await__(self): async def _wait(): diff --git a/backend/tests/test_permission_monitoring.py b/backend/tests/test_permission_monitoring.py index 3bc01726..0023569b 100644 --- a/backend/tests/test_permission_monitoring.py +++ b/backend/tests/test_permission_monitoring.py @@ -1,66 +1,22 @@ -"""监控测试:权限系统监控功能验证 - -测试权限系统的监控功能,包括: -- 指标收集 -- 告警生成 -- 健康检查 -""" +"""监控测试:权限系统内存监控职责验证。""" import time -import uuid import pytest from app.core.permission_monitor import ( - PermissionMonitor, - PermissionCheckMetrics, CacheMetrics, + PermissionMonitor, get_permission_monitor, set_permission_monitor, - evaluate_permission_system_health, ) -@pytest.mark.asyncio -async def test_permission_check_metrics(): - """测试权限检查指标""" - monitor = PermissionMonitor() - - # 记录权限检查 - monitor.record_permission_check(allowed=True, elapsed_time=0.005) - monitor.record_permission_check(allowed=False, elapsed_time=0.003) - monitor.record_permission_check(allowed=True, elapsed_time=0.004) - - metrics = monitor.metrics.check_metrics - assert metrics.total_checks == 3 - assert metrics.allowed_checks == 2 - assert metrics.denied_checks == 1 - assert metrics.allow_rate == pytest.approx(66.67, 0.1) - assert metrics.deny_rate == pytest.approx(33.33, 0.1) - - -@pytest.mark.asyncio -async def test_permission_check_timing(): - """测试权限检查耗时统计""" - monitor = PermissionMonitor() - - # 记录不同耗时的权限检查 - monitor.record_permission_check(allowed=True, elapsed_time=0.001) - monitor.record_permission_check(allowed=True, elapsed_time=0.005) - monitor.record_permission_check(allowed=True, elapsed_time=0.003) - - metrics = monitor.metrics.check_metrics - assert metrics.min_time == pytest.approx(0.001, 0.0001) - assert metrics.max_time == pytest.approx(0.005, 0.0001) - assert metrics.avg_time == pytest.approx(0.003, 0.0001) - - @pytest.mark.asyncio async def test_cache_metrics(): """测试缓存指标""" monitor = PermissionMonitor() - # 记录缓存访问 monitor.record_cache_hit() monitor.record_cache_hit() monitor.record_cache_miss() @@ -79,41 +35,35 @@ async def test_cache_invalidation_tracking(): """测试缓存失效追踪""" monitor = PermissionMonitor() - # 记录缓存失效 monitor.record_cache_invalidation() monitor.record_cache_invalidation() monitor.record_cache_invalidation() - metrics = monitor.metrics.cache_metrics - assert metrics.cache_invalidations == 3 + assert monitor.metrics.cache_metrics.cache_invalidations == 3 @pytest.mark.asyncio -async def test_error_tracking(): - """测试错误追踪""" +async def test_slow_check_alert_generation(): + """测试慢权限检查告警生成""" monitor = PermissionMonitor() - # 记录权限检查错误 - error = ValueError("test error") - monitor.record_permission_check(allowed=False, elapsed_time=0.005, error=error) - monitor.record_permission_check(allowed=True, elapsed_time=0.003) - - metrics = monitor.metrics.check_metrics - assert metrics.errors == 1 - assert metrics.error_rate == pytest.approx(50.0, 0.1) - - -@pytest.mark.asyncio -async def test_alert_generation(): - """测试告警生成""" - monitor = PermissionMonitor() - - # 记录慢速权限检查(应该生成告警) - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) alerts = monitor.get_alerts() - assert len(alerts) > 0 + assert len(alerts) == 1 + assert alerts[0]["level"] == "warning" assert alerts[0]["type"] == "slow_permission_check" + assert alerts[0]["data"]["elapsed_ms"] == 100 + + +@pytest.mark.asyncio +async def test_slow_check_alert_ignores_fast_checks(): + """未超过阈值的权限检查不应生成告警""" + monitor = PermissionMonitor() + + monitor.record_slow_check_alert(10) + + assert monitor.get_alerts() == [] @pytest.mark.asyncio @@ -121,12 +71,11 @@ async def test_error_alert_generation(): """测试错误告警生成""" monitor = PermissionMonitor() - # 记录权限检查错误(应该生成告警) - error = ValueError("test error") - monitor.record_permission_check(allowed=False, elapsed_time=0.005, error=error) + monitor.record_error_alert(ValueError("test error")) alerts = monitor.get_alerts() - assert len(alerts) > 0 + assert len(alerts) == 1 + assert alerts[0]["level"] == "error" assert alerts[0]["type"] == "permission_check_error" @@ -135,20 +84,16 @@ async def test_alert_filtering(): """测试告警过滤""" monitor = PermissionMonitor() - # 生成不同级别的告警 - monitor.record_permission_check(allowed=True, elapsed_time=0.1) # warning - error = ValueError("test error") - monitor.record_permission_check(allowed=False, elapsed_time=0.005, error=error) # error + monitor.record_slow_check_alert(100) + monitor.record_error_alert(ValueError("test error")) - # 过滤 warning 级别的告警 warning_alerts = monitor.get_alerts(level="warning") - assert len(warning_alerts) > 0 - assert all(a["level"] == "warning" for a in warning_alerts) + assert len(warning_alerts) == 1 + assert all(alert["level"] == "warning" for alert in warning_alerts) - # 过滤 error 级别的告警 error_alerts = monitor.get_alerts(level="error") - assert len(error_alerts) > 0 - assert all(a["level"] == "error" for a in error_alerts) + assert len(error_alerts) == 1 + assert all(alert["level"] == "error" for alert in error_alerts) @pytest.mark.asyncio @@ -156,13 +101,10 @@ async def test_alert_limit(): """测试告警数量限制""" monitor = PermissionMonitor() - # 生成大量告警 for _ in range(50): - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) - # 获取告警,限制为10条 - alerts = monitor.get_alerts(limit=10) - assert len(alerts) == 10 + assert len(monitor.get_alerts(limit=10)) == 10 @pytest.mark.asyncio @@ -170,17 +112,11 @@ async def test_metrics_reset(): """测试指标重置""" monitor = PermissionMonitor() - # 记录一些指标 - monitor.record_permission_check(allowed=True, elapsed_time=0.005) monitor.record_cache_hit() - - assert monitor.metrics.check_metrics.total_checks == 1 assert monitor.metrics.cache_metrics.total_accesses == 1 - # 重置指标 monitor.reset_metrics() - assert monitor.metrics.check_metrics.total_checks == 0 assert monitor.metrics.cache_metrics.total_accesses == 0 @@ -189,15 +125,12 @@ async def test_alerts_clear(): """测试告警清除""" monitor = PermissionMonitor() - # 生成告警 - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) + assert len(monitor.get_alerts()) == 1 - assert len(monitor.get_alerts()) > 0 - - # 清除告警 monitor.clear_alerts() - assert len(monitor.get_alerts()) == 0 + assert monitor.get_alerts() == [] @pytest.mark.asyncio @@ -205,27 +138,16 @@ async def test_metrics_to_dict(): """测试指标转换为字典""" monitor = PermissionMonitor() - # 记录指标 - monitor.record_permission_check(allowed=True, elapsed_time=0.005) - monitor.record_permission_check(allowed=False, elapsed_time=0.003) monitor.record_cache_hit() monitor.record_cache_miss() metrics_dict = monitor.get_metrics() - assert "check_metrics" in metrics_dict assert "cache_metrics" in metrics_dict assert "uptime_seconds" in metrics_dict - - check_metrics = metrics_dict["check_metrics"] - assert check_metrics["total_checks"] == 2 - assert check_metrics["allowed_checks"] == 1 - assert check_metrics["denied_checks"] == 1 - - cache_metrics = metrics_dict["cache_metrics"] - assert cache_metrics["total_accesses"] == 2 - assert cache_metrics["cache_hits"] == 1 - assert cache_metrics["cache_misses"] == 1 + assert metrics_dict["cache_metrics"]["total_accesses"] == 2 + assert metrics_dict["cache_metrics"]["cache_hits"] == 1 + assert metrics_dict["cache_metrics"]["cache_misses"] == 1 @pytest.mark.asyncio @@ -234,7 +156,6 @@ async def test_global_monitor_instance(): monitor1 = get_permission_monitor() monitor2 = get_permission_monitor() - # 应该是同一个实例 assert monitor1 is monitor2 @@ -244,8 +165,7 @@ async def test_set_global_monitor(): new_monitor = PermissionMonitor() set_permission_monitor(new_monitor) - monitor = get_permission_monitor() - assert monitor is new_monitor + assert get_permission_monitor() is new_monitor @pytest.mark.asyncio @@ -254,87 +174,24 @@ async def test_alert_timestamp(): monitor = PermissionMonitor() before_time = time.time() - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) after_time = time.time() alerts = monitor.get_alerts() - assert len(alerts) > 0 assert before_time <= alerts[0]["timestamp"] <= after_time -@pytest.mark.asyncio -async def test_alert_data(): - """测试告警数据""" - monitor = PermissionMonitor() - - # 记录慢速权限检查 - monitor.record_permission_check(allowed=True, elapsed_time=0.1) - - alerts = monitor.get_alerts() - assert len(alerts) > 0 - assert "data" in alerts[0] - assert "elapsed_time" in alerts[0]["data"] - - -@pytest.mark.asyncio -async def test_permission_check_metrics_dataclass(): - """测试权限检查指标数据类""" - metrics = PermissionCheckMetrics() - - # 初始状态 - assert metrics.total_checks == 0 - assert metrics.avg_time == 0.0 - assert metrics.allow_rate == 0.0 - - # 添加数据 - metrics.total_checks = 100 - metrics.allowed_checks = 80 - metrics.denied_checks = 20 - metrics.total_time = 0.5 - - assert metrics.avg_time == pytest.approx(0.005, 0.0001) - assert metrics.allow_rate == pytest.approx(80.0, 0.1) - assert metrics.deny_rate == pytest.approx(20.0, 0.1) - - @pytest.mark.asyncio async def test_cache_metrics_dataclass(): """测试缓存指标数据类""" metrics = CacheMetrics() - # 初始状态 assert metrics.total_accesses == 0 assert metrics.hit_rate == 0.0 - # 添加数据 metrics.total_accesses = 100 metrics.cache_hits = 80 metrics.cache_misses = 20 assert metrics.hit_rate == pytest.approx(80.0, 0.1) assert metrics.miss_rate == pytest.approx(20.0, 0.1) - - -@pytest.mark.asyncio -async def test_health_check_ignores_cache_hit_rate_without_samples(): - """没有缓存访问样本时,不应判定缓存命中率过低""" - monitor = PermissionMonitor() - metrics = monitor.get_metrics() - cache_stats = monitor.get_cache_stats() - - health = evaluate_permission_system_health(metrics, cache_stats) - - assert metrics["cache_metrics"]["total_accesses"] == 0 - assert "缓存命中率过低" not in health["issues"] - - -@pytest.mark.asyncio -async def test_health_check_ignores_cache_hit_rate_with_too_few_samples(): - """缓存访问样本过少时,不应判定缓存命中率过低""" - monitor = PermissionMonitor() - for _ in range(3): - monitor.record_cache_miss() - - health = evaluate_permission_system_health(monitor.get_metrics(), monitor.get_cache_stats()) - - assert "缓存命中率过低" not in health["issues"] diff --git a/backend/tests/test_permission_monitoring_api.py b/backend/tests/test_permission_monitoring_api.py index 5f4183b5..45073f27 100644 --- a/backend/tests/test_permission_monitoring_api.py +++ b/backend/tests/test_permission_monitoring_api.py @@ -1,13 +1,11 @@ -"""监控API测试:权限系统监控API端点验证 +"""监控API测试:权限系统监控API端点验证。""" -测试权限系统监控API的功能。 -""" +import uuid import pytest -from fastapi.testclient import TestClient from sqlalchemy import text -from app.core.permission_monitor import get_permission_monitor, set_permission_monitor, PermissionMonitor +from app.core.permission_monitor import set_permission_monitor, PermissionMonitor from app.api.v1 import permission_monitoring @@ -20,202 +18,226 @@ class FakeIpInfo: self.location = f"中国 / {province} / {city} / 电信" +async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UUID, *, allowed: bool, elapsed_ms: float) -> None: + study_exists = ( + await db_session.execute(text("SELECT id FROM studies WHERE id = :id"), {"id": str(study_id)}) + ).scalar_one_or_none() + if not study_exists: + await db_session.execute( + text( + """ + INSERT INTO studies (id, code, name, status, is_locked, visit_schedule, active_roles) + VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles) + """ + ), + { + "id": str(study_id), + "code": f"PERM-MON-{study_id.hex[:8]}", + "name": "Permission Monitoring Study", + "status": "ACTIVE", + "is_locked": False, + "visit_schedule": "[]", + "active_roles": "[]", + }, + ) + + user_exists = ( + await db_session.execute(text("SELECT id FROM users WHERE id = :id"), {"id": str(user_id)}) + ).scalar_one_or_none() + if not user_exists: + await db_session.execute( + text( + """ + INSERT INTO users (id, email, password_hash, full_name, role, clinical_department, status) + VALUES (:id, :email, :password_hash, :full_name, :role, :clinical_department, :status) + """ + ), + { + "id": str(user_id), + "email": f"{user_id.hex}@example.com", + "password_hash": "hash", + "full_name": "Permission Monitoring User", + "role": "PM", + "clinical_department": "临床运营", + "status": "ACTIVE", + }, + ) + + await db_session.execute( + text( + """ + INSERT INTO permission_access_logs + (id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at) + VALUES + (:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP) + """ + ), + { + "id": str(uuid.uuid4()), + "study_id": str(study_id), + "user_id": str(user_id), + "endpoint_key": "admin.permissions.read", + "role": "PM", + "allowed": allowed, + "elapsed_ms": elapsed_ms, + "ip_address": "127.0.0.1", + }, + ) + await db_session.commit() + + @pytest.mark.asyncio -async def test_get_permission_metrics(client: TestClient, auth_headers: dict): +async def test_get_permission_metrics(db_session): """测试获取权限系统指标""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 记录一些指标 - monitor.record_permission_check(allowed=True, elapsed_time=0.005) - monitor.record_permission_check(allowed=False, elapsed_time=0.003) + study_id = uuid.uuid4() + user_id = uuid.uuid4() + await _seed_permission_log(db_session, study_id, user_id, allowed=True, elapsed_ms=5) + await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=3) - response = client.get("/api/v1/permission-monitoring/metrics", headers=auth_headers) - assert response.status_code == 200 - - data = response.json() + data = await permission_monitoring.get_permission_metrics(db=db_session, _=object(), hours=24) assert "check_metrics" in data assert "cache_metrics" in data assert data["check_metrics"]["total_checks"] == 2 + assert data["check_metrics"]["allowed_checks"] == 1 + assert data["check_metrics"]["denied_checks"] == 1 @pytest.mark.asyncio -async def test_get_cache_statistics(client: TestClient, auth_headers: dict): +async def test_get_cache_statistics(db_session): """测试获取缓存统计""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 记录缓存访问 monitor.record_cache_hit() monitor.record_cache_hit() monitor.record_cache_miss() - response = client.get("/api/v1/permission-monitoring/cache-stats", headers=auth_headers) - assert response.status_code == 200 - - data = response.json() + data = await permission_monitoring.get_cache_statistics(_=object(), db=db_session) assert "cache_metrics" in data assert data["cache_metrics"]["total_accesses"] == 3 assert data["cache_metrics"]["cache_hits"] == 2 @pytest.mark.asyncio -async def test_get_alerts(client: TestClient, auth_headers: dict): +async def test_get_alerts(db_session): """测试获取告警列表""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 生成告警 - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) - response = client.get("/api/v1/permission-monitoring/alerts", headers=auth_headers) - assert response.status_code == 200 - - data = response.json() + data = await permission_monitoring.get_alerts(_=object(), db=db_session) assert "alerts" in data assert data["total"] > 0 @pytest.mark.asyncio -async def test_get_alerts_with_level_filter(client: TestClient, auth_headers: dict): +async def test_get_alerts_with_level_filter(db_session): """测试按级别过滤告警""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 生成告警 - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) + monitor.record_error_alert(ValueError("test error")) - response = client.get( - "/api/v1/permission-monitoring/alerts?level=warning", - headers=auth_headers, - ) - assert response.status_code == 200 - - data = response.json() + data = await permission_monitoring.get_alerts(level="warning", _=object(), db=db_session) assert "alerts" in data + assert all(alert["level"] == "warning" for alert in data["alerts"]) @pytest.mark.asyncio -async def test_get_alerts_with_limit(client: TestClient, auth_headers: dict): +async def test_get_alerts_with_limit(db_session): """测试限制告警数量""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 生成多个告警 for _ in range(20): - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) - response = client.get( - "/api/v1/permission-monitoring/alerts?limit=5", - headers=auth_headers, - ) - assert response.status_code == 200 - - data = response.json() + data = await permission_monitoring.get_alerts(limit=5, _=object(), db=db_session) assert len(data["alerts"]) <= 5 @pytest.mark.asyncio -async def test_reset_metrics(client: TestClient, auth_headers: dict): +async def test_reset_metrics(db_session): """测试重置指标""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 记录指标 - monitor.record_permission_check(allowed=True, elapsed_time=0.005) - assert monitor.metrics.check_metrics.total_checks == 1 + monitor.record_cache_hit() + assert monitor.metrics.cache_metrics.total_accesses == 1 - # 重置指标 - response = client.post("/api/v1/permission-monitoring/reset-metrics", headers=auth_headers) - assert response.status_code == 200 + result = await permission_monitoring.reset_metrics(_=object(), db=db_session) - # 验证指标已重置 - assert monitor.metrics.check_metrics.total_checks == 0 + assert result["message"] == "指标已重置" + assert monitor.metrics.cache_metrics.total_accesses == 0 @pytest.mark.asyncio -async def test_clear_alerts(client: TestClient, auth_headers: dict): +async def test_clear_alerts(db_session): """测试清除告警""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 生成告警 - monitor.record_permission_check(allowed=True, elapsed_time=0.1) + monitor.record_slow_check_alert(100) assert len(monitor.get_alerts()) > 0 - # 清除告警 - response = client.post("/api/v1/permission-monitoring/clear-alerts", headers=auth_headers) - assert response.status_code == 200 + result = await permission_monitoring.clear_alerts(_=object(), db=db_session) - # 验证告警已清除 + assert result["message"] == "告警已清除" assert len(monitor.get_alerts()) == 0 @pytest.mark.asyncio -async def test_permission_system_health_healthy(client: TestClient, auth_headers: dict): +async def test_permission_system_health_healthy(db_session): """测试权限系统健康检查(健康状态)""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 记录良好的指标 - for _ in range(100): - monitor.record_permission_check(allowed=True, elapsed_time=0.001) for _ in range(100): monitor.record_cache_hit() - response = client.get("/api/v1/permission-monitoring/health", headers=auth_headers) - assert response.status_code == 200 - - data = response.json() - assert data["status"] in ["healthy", "degraded"] - assert data["health_score"] > 50 + data = await permission_monitoring.permission_system_health(db=db_session, _=object()) + assert data["status"] == "healthy" + assert data["health_score"] >= 80 @pytest.mark.asyncio -async def test_permission_system_health_degraded(client: TestClient, auth_headers: dict): +async def test_permission_system_health_degraded(db_session): """测试权限系统健康检查(降级状态)""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - # 记录不良的指标 - for _ in range(100): - monitor.record_permission_check(allowed=False, elapsed_time=0.1) + study_id = uuid.uuid4() + user_id = uuid.uuid4() + for _ in range(10): + await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=100) for _ in range(100): monitor.record_cache_miss() - response = client.get("/api/v1/permission-monitoring/health", headers=auth_headers) - assert response.status_code == 200 - - data = response.json() + data = await permission_monitoring.permission_system_health(db=db_session, _=object()) assert "status" in data assert "health_score" in data assert "issues" in data + assert "权限检查响应时间过长" in data["issues"] + assert "权限拒绝率过高" in data["issues"] + assert "缓存命中率过低" in data["issues"] @pytest.mark.asyncio -async def test_permission_system_health_includes_metrics(client: TestClient, auth_headers: dict): +async def test_permission_system_health_includes_metrics(db_session): """测试健康检查包含详细指标""" - # 清除并重置监控器 monitor = PermissionMonitor() set_permission_monitor(monitor) - response = client.get("/api/v1/permission-monitoring/health", headers=auth_headers) - assert response.status_code == 200 - - data = response.json() - assert "metrics" in data + data = await permission_monitoring.permission_system_health(db=db_session, _=object()) + assert "last_hour" in data assert "cache_stats" in data - assert "check_metrics" in data["metrics"] - assert "cache_metrics" in data["metrics"] + assert "total_checks" in data["last_hour"] + assert "cache_metrics" in data["cache_stats"] @pytest.mark.asyncio diff --git a/backend/tests/test_prerequisite_permissions.py b/backend/tests/test_prerequisite_permissions.py index 39261b3a..0bc6482a 100644 --- a/backend/tests/test_prerequisite_permissions.py +++ b/backend/tests/test_prerequisite_permissions.py @@ -70,11 +70,11 @@ async def test_prerequisite_permission_missing(db_session: AsyncSession): @pytest.mark.asyncio -async def test_prerequisite_permission_not_configured(db_session: AsyncSession): - """测试前置权限未配置的情况""" +async def test_default_prerequisite_permission_satisfies_when_not_overridden(db_session: AsyncSession): + """预设角色未配置前置权限时,应使用默认权限矩阵判断""" study_id = uuid.uuid4() - # 创建权限:主权限允许,前置权限未配置 + # CRA 默认拥有 sites:read,因此未显式配置前置权限时仍满足前置条件。 main_perm = ApiEndpointPermission( study_id=study_id, role="CRA", @@ -88,6 +88,26 @@ async def test_prerequisite_permission_not_configured(db_session: AsyncSession): result = await role_has_api_permission( db_session, study_id, "CRA", "subjects:create", check_prerequisites=True ) + assert result is True + + +@pytest.mark.asyncio +async def test_custom_role_prerequisite_permission_not_configured(db_session: AsyncSession): + """自定义角色无默认前置权限时,应被前置权限拦截""" + study_id = uuid.uuid4() + + main_perm = ApiEndpointPermission( + study_id=study_id, + role="DATA_MANAGER", + endpoint_key="subjects:create", + allowed=True, + ) + db_session.add(main_perm) + await db_session.commit() + + result = await role_has_api_permission( + db_session, study_id, "DATA_MANAGER", "subjects:create", check_prerequisites=True + ) assert result is False @@ -323,8 +343,8 @@ async def test_prerequisite_with_no_prerequisites_operation(db_session: AsyncSes @pytest.mark.asyncio -async def test_prerequisite_missing_not_configured(db_session: AsyncSession): - """测试前置权限未配置时的缺失检查""" +async def test_default_prerequisite_not_reported_missing_when_not_overridden(db_session: AsyncSession): + """预设角色默认拥有前置权限时,不应报告缺失""" study_id = uuid.uuid4() # 创建权限:主权限允许,前置权限未配置 @@ -341,4 +361,24 @@ async def test_prerequisite_missing_not_configured(db_session: AsyncSession): missing = await get_missing_prerequisites( db_session, study_id, "CRA", "subjects:create" ) + assert missing == [] + + +@pytest.mark.asyncio +async def test_custom_role_prerequisite_missing_not_configured(db_session: AsyncSession): + """自定义角色无默认前置权限时,应报告缺失""" + study_id = uuid.uuid4() + + main_perm = ApiEndpointPermission( + study_id=study_id, + role="DATA_MANAGER", + endpoint_key="subjects:create", + allowed=True, + ) + db_session.add(main_perm) + await db_session.commit() + + missing = await get_missing_prerequisites( + db_session, study_id, "DATA_MANAGER", "subjects:create" + ) assert "sites:read" in missing diff --git a/backend/tests/test_study_role_context.py b/backend/tests/test_study_role_context.py index 08563401..a27563f9 100644 --- a/backend/tests/test_study_role_context.py +++ b/backend/tests/test_study_role_context.py @@ -1,4 +1,5 @@ import uuid +from datetime import datetime, timezone import pytest @@ -28,6 +29,8 @@ def _make_study() -> Study: status="ACTIVE", is_locked=False, visit_schedule=[], + active_roles=[], + created_at=datetime.now(timezone.utc), )