feat(perm): 项目 PM 共管接口权限与监控并细化 PM 写权限边界
- deps 新增 list_active_pm_study_ids、is_active_project_pm 与 require_admin_or_any_project_pm 依赖,用于把 PM 项目范围带进鉴权与 监控。get_cra_site_scope 内部延后导入 site CRUD,避免循环依赖。 - system_permissions API 改用 PM/ADMIN 双角色入口,permissions/monitoring 系统级权限新增 PM 配额,并细化访问日志、告警、监控指标的可见范围。 - members API 调整:项目 PM 仅可管理低于 PM 的项目角色,禁止互相 改写或授予 PM。 - api_permissions API 增加 GET /api-permissions/me,返回当前用户在该 项目的有效权限矩阵;保存权限矩阵时校验 PM 行为不被篡改。 - core/api_permissions:新增立项配置接口键、PM 默认拥有的监控/权限 系统级条目,并在权限元信息中标注 PM 共享角色。 - core/project_permissions:role_has_api_permission 命中默认角色矩阵; replace_api_endpoint_permissions 改为部分更新且永不持久化 ADMIN/PM。 - studies setup-config 各端点改用接口级权限装饰器,与新的 setup_config 权限键对齐。permission_monitor 新增 get_metrics 摘要供 PM 视图调用。 - 测试:新增 test_admin_pm_permissions 覆盖 PM 系统级权限、监控范围和 成员管理边界;conftest 兼容 SA_UUID 列;权限相关用例同步移除已失效 的 module_permission 链路。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,16 +5,17 @@ from __future__ import annotations
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 (
|
from app.core.project_permissions import (
|
||||||
get_api_endpoint_permissions,
|
get_api_endpoint_permissions,
|
||||||
replace_api_endpoint_permissions,
|
replace_api_endpoint_permissions,
|
||||||
get_missing_prerequisites,
|
get_missing_prerequisites,
|
||||||
)
|
)
|
||||||
|
from app.crud import member as member_crud
|
||||||
from app.models.api_endpoint_registry import ApiEndpointRegistry
|
from app.models.api_endpoint_registry import ApiEndpointRegistry
|
||||||
from app.models.study import Study
|
from app.models.study import Study
|
||||||
from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, PROJECT_PERMISSION_ROLES, OPERATION_PREREQUISITES
|
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(
|
@study_router.get(
|
||||||
"",
|
"",
|
||||||
summary="获取项目的接口级权限矩阵",
|
summary="获取项目的接口级权限矩阵",
|
||||||
@@ -199,6 +234,7 @@ async def update_study_api_permissions(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
payload: dict[str, dict[str, bool]],
|
payload: dict[str, dict[str, bool]],
|
||||||
_=Depends(require_study_roles(["PM"])),
|
_=Depends(require_study_roles(["PM"])),
|
||||||
|
current_user=Depends(get_current_user),
|
||||||
db: Annotated[AsyncSession, Depends(get_db_session)] = None,
|
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))
|
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():
|
for role in payload.keys():
|
||||||
if role == "ADMIN":
|
if role == "ADMIN":
|
||||||
continue
|
continue
|
||||||
if role not in configurable_roles:
|
if role not in configurable_roles:
|
||||||
raise ValueError(f"无效的角色: {role}")
|
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)
|
await replace_api_endpoint_permissions(db, study_id, payload)
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ async def _ensure_member_mutation_allowed(
|
|||||||
if _role_value(target_user) == "ADMIN":
|
if _role_value(target_user) == "ADMIN":
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改系统管理员账号的项目权限")
|
||||||
|
|
||||||
if target_member and _role_rank(target_member.role_in_study) > actor_rank:
|
if target_member and _role_rank(target_member.role_in_study) >= actor_rank:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能修改权限高于自己的项目成员")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能修改下属项目角色成员")
|
||||||
|
|
||||||
if target_role and _role_rank(target_role) > actor_rank:
|
if target_role and _role_rank(target_role) >= actor_rank:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不能授予高于自己的项目角色")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只能授予下属项目角色")
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ from collections import defaultdict
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
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 import func, select, desc
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.core.permission_monitor import get_permission_monitor
|
||||||
from app.models.permission_access_log import PermissionAccessLog
|
from app.models.permission_access_log import PermissionAccessLog
|
||||||
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
|
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"])
|
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),
|
hours: int = Query(24, ge=1, le=720),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""从 permission_access_logs 实时聚合权限检查指标"""
|
"""从 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)
|
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(
|
result = await db.execute(
|
||||||
select(
|
_apply_monitoring_scope_to_log_query(metrics_query, scope)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
row = result.one()
|
row = result.one()
|
||||||
total = row.total_checks or 0
|
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)
|
@router.get("/cache-stats", status_code=status.HTTP_200_OK)
|
||||||
async def get_cache_statistics(
|
async def get_cache_statistics(
|
||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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 = get_permission_monitor()
|
||||||
return monitor.get_cache_stats()
|
return monitor.get_cache_stats()
|
||||||
|
|
||||||
@@ -85,7 +125,11 @@ async def get_alerts(
|
|||||||
level: str | None = None,
|
level: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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 = get_permission_monitor()
|
||||||
alerts = monitor.get_alerts(level=level, limit=limit)
|
alerts = monitor.get_alerts(level=level, limit=limit)
|
||||||
return {
|
return {
|
||||||
@@ -96,7 +140,11 @@ async def get_alerts(
|
|||||||
@router.post("/reset-metrics", status_code=status.HTTP_200_OK)
|
@router.post("/reset-metrics", status_code=status.HTTP_200_OK)
|
||||||
async def reset_metrics(
|
async def reset_metrics(
|
||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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 = get_permission_monitor()
|
||||||
monitor.reset_metrics()
|
monitor.reset_metrics()
|
||||||
return {"message": "指标已重置"}
|
return {"message": "指标已重置"}
|
||||||
@@ -105,7 +153,11 @@ async def reset_metrics(
|
|||||||
@router.post("/clear-alerts", status_code=status.HTTP_200_OK)
|
@router.post("/clear-alerts", status_code=status.HTTP_200_OK)
|
||||||
async def clear_alerts(
|
async def clear_alerts(
|
||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> dict:
|
) -> 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 = get_permission_monitor()
|
||||||
monitor.clear_alerts()
|
monitor.clear_alerts()
|
||||||
return {"message": "告警已清除"}
|
return {"message": "告警已清除"}
|
||||||
@@ -117,13 +169,17 @@ async def permission_system_health(
|
|||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""从 DB 聚合最近 1 小时数据评估权限系统健康状态"""
|
"""从 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)
|
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(
|
result = await db.execute(
|
||||||
select(
|
_apply_monitoring_scope_to_log_query(health_query, scope)
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
row = result.one()
|
row = result.one()
|
||||||
total = row.total or 0
|
total = row.total or 0
|
||||||
@@ -182,9 +238,16 @@ async def get_access_logs(
|
|||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
) -> dict:
|
) -> 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 = []
|
conditions = []
|
||||||
if study_id:
|
if study_id:
|
||||||
conditions.append(PermissionAccessLog.study_id == 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:
|
if user_id:
|
||||||
conditions.append(PermissionAccessLog.user_id == user_id)
|
conditions.append(PermissionAccessLog.user_id == user_id)
|
||||||
if endpoint_key:
|
if endpoint_key:
|
||||||
@@ -317,6 +380,9 @@ async def get_security_access_logs(
|
|||||||
page_size: int = Query(50, ge=1, le=200),
|
page_size: int = Query(50, ge=1, le=200),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
|
"""查询底层安全访问日志,覆盖匿名、无效令牌和异常状态请求。"""
|
||||||
|
scope = await resolve_monitoring_scope(db, _)
|
||||||
|
if not scope.is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="权限不足")
|
||||||
conditions = []
|
conditions = []
|
||||||
if status_min is not None:
|
if status_min is not None:
|
||||||
conditions.append(SecurityAccessLog.status_code >= status_min)
|
conditions.append(SecurityAccessLog.status_code >= status_min)
|
||||||
@@ -402,6 +468,9 @@ async def get_trends(
|
|||||||
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
period: str = Query("24h", pattern="^(24h|7d|30d)$"),
|
||||||
) -> dict:
|
) -> 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)
|
now = datetime.now(timezone.utc)
|
||||||
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
period_map = {"24h": timedelta(hours=24), "7d": timedelta(days=7), "30d": timedelta(days=30)}
|
||||||
start_time = now - period_map[period]
|
start_time = now - period_map[period]
|
||||||
@@ -415,7 +484,7 @@ async def get_trends(
|
|||||||
result = await db.execute(snapshot_query)
|
result = await db.execute(snapshot_query)
|
||||||
snapshots = result.scalars().all()
|
snapshots = result.scalars().all()
|
||||||
|
|
||||||
if snapshots:
|
if snapshots and scope.is_admin:
|
||||||
return {
|
return {
|
||||||
"period": period,
|
"period": period,
|
||||||
"data_points": [
|
"data_points": [
|
||||||
@@ -439,12 +508,13 @@ async def get_trends(
|
|||||||
|
|
||||||
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
|
# 如果没有快照数据,从原始日志实时聚合(适用于刚部署时)。
|
||||||
# 这里使用 Python 分桶,避免 SQLite 测试库不支持 PostgreSQL date_trunc。
|
# 这里使用 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(
|
result = await db.execute(
|
||||||
select(
|
_apply_monitoring_scope_to_log_query(trend_query, scope)
|
||||||
PermissionAccessLog.created_at,
|
|
||||||
PermissionAccessLog.allowed,
|
|
||||||
PermissionAccessLog.elapsed_ms,
|
|
||||||
).where(PermissionAccessLog.created_at >= start_time)
|
|
||||||
)
|
)
|
||||||
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
|
buckets: dict[datetime, dict[str, float | int]] = defaultdict(
|
||||||
lambda: {"total": 0, "allowed": 0, "denied": 0, "elapsed_sum": 0.0, "max_ms": 0.0}
|
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),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
) -> dict:
|
) -> 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)
|
start_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
@@ -515,7 +588,7 @@ async def get_top_denied(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(_apply_monitoring_scope_to_log_query(query, scope))
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -543,6 +616,9 @@ async def get_ip_locations(
|
|||||||
limit: int = Query(20, ge=1, le=100),
|
limit: int = Query(20, ge=1, le=100),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""获取 IP 省市属地统计。"""
|
"""获取 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)
|
start_time = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
query = (
|
query = (
|
||||||
select(
|
select(
|
||||||
@@ -555,7 +631,7 @@ async def get_ip_locations(
|
|||||||
PermissionAccessLog.ip_address.is_not(None),
|
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] = {}
|
buckets: dict[tuple[str, str, str, str], dict] = {}
|
||||||
all_ip_addresses: set[str] = set()
|
all_ip_addresses: set[str] = set()
|
||||||
all_user_ids: set[uuid.UUID] = set()
|
all_user_ids: set[uuid.UUID] = set()
|
||||||
@@ -631,35 +707,41 @@ async def get_stats_summary(
|
|||||||
_=Depends(get_current_user),
|
_=Depends(get_current_user),
|
||||||
) -> dict:
|
) -> 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)
|
now = datetime.now(timezone.utc)
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
hour_ago = now - timedelta(hours=1)
|
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(
|
today_result = await db.execute(
|
||||||
select(
|
_apply_monitoring_scope_to_log_query(today_query, scope)
|
||||||
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 = today_result.one()
|
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(
|
hour_result = await db.execute(
|
||||||
select(
|
_apply_monitoring_scope_to_log_query(hour_query, scope)
|
||||||
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 = hour_result.one()
|
hour = hour_result.one()
|
||||||
|
|
||||||
# 总记录数
|
# 总记录数
|
||||||
|
total_query = select(func.count()).select_from(PermissionAccessLog)
|
||||||
total_result = await db.execute(
|
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
|
total_logs = total_result.scalar() or 0
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.core.deps import (
|
from app.core.deps import (
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_db_session,
|
get_db_session,
|
||||||
|
require_api_permission,
|
||||||
require_roles,
|
require_roles,
|
||||||
require_study_member,
|
require_study_member,
|
||||||
require_study_not_locked,
|
require_study_not_locked,
|
||||||
@@ -933,7 +934,7 @@ async def unlock_study(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/{study_id}/setup-config",
|
"/{study_id}/setup-config",
|
||||||
response_model=StudySetupConfigRead,
|
response_model=StudySetupConfigRead,
|
||||||
dependencies=[Depends(require_study_member())],
|
dependencies=[Depends(require_api_permission("setup_config:read"))],
|
||||||
)
|
)
|
||||||
async def get_study_setup_config(
|
async def get_study_setup_config(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -990,7 +991,7 @@ async def get_study_setup_config(
|
|||||||
@router.put(
|
@router.put(
|
||||||
"/{study_id}/setup-config",
|
"/{study_id}/setup-config",
|
||||||
response_model=StudySetupConfigRead,
|
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(
|
async def upsert_study_setup_config(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1068,7 +1069,7 @@ async def upsert_study_setup_config(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/publish",
|
"/{study_id}/setup-config/publish",
|
||||||
response_model=StudySetupConfigRead,
|
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(
|
async def publish_study_setup_config(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1187,7 +1188,7 @@ async def publish_study_setup_config(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/{study_id}/setup-config/versions",
|
"/{study_id}/setup-config/versions",
|
||||||
response_model=list[StudySetupConfigVersionRead],
|
response_model=list[StudySetupConfigVersionRead],
|
||||||
dependencies=[Depends(require_study_member())],
|
dependencies=[Depends(require_api_permission("setup_config:read"))],
|
||||||
)
|
)
|
||||||
async def list_study_setup_config_versions(
|
async def list_study_setup_config_versions(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1221,7 +1222,7 @@ async def list_study_setup_config_versions(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/rollback",
|
"/{study_id}/setup-config/rollback",
|
||||||
response_model=StudySetupConfigRead,
|
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(
|
async def rollback_study_setup_config(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1279,7 +1280,7 @@ async def rollback_study_setup_config(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/draft/checkout-branch",
|
"/{study_id}/setup-config/draft/checkout-branch",
|
||||||
response_model=StudySetupConfigRead,
|
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(
|
async def checkout_study_setup_config_branch_draft(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1338,7 +1339,7 @@ async def checkout_study_setup_config_branch_draft(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/draft/clear",
|
"/{study_id}/setup-config/draft/clear",
|
||||||
response_model=StudySetupConfigRead,
|
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(
|
async def clear_study_setup_config_draft(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1390,7 +1391,7 @@ async def clear_study_setup_config_draft(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/draft/refill",
|
"/{study_id}/setup-config/draft/refill",
|
||||||
response_model=StudySetupConfigRead,
|
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(
|
async def refill_study_setup_config_draft(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1444,7 +1445,7 @@ async def refill_study_setup_config_draft(
|
|||||||
@router.post(
|
@router.post(
|
||||||
"/{study_id}/setup-config/merge-main",
|
"/{study_id}/setup-config/merge-main",
|
||||||
response_model=StudySetupConfigRead,
|
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(
|
async def merge_study_setup_config_to_main(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -1539,7 +1540,7 @@ async def merge_study_setup_config_to_main(
|
|||||||
@router.delete(
|
@router.delete(
|
||||||
"/{study_id}/setup-config/versions/{target_version}",
|
"/{study_id}/setup-config/versions/{target_version}",
|
||||||
status_code=status.HTTP_204_NO_CONTENT,
|
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(
|
async def delete_study_setup_config_version(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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.core.api_permissions import SYSTEM_PERMISSIONS, SYSTEM_MODULE_LABELS
|
||||||
from app.models.user import UserRole
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/system-permissions", tags=["system-permissions"])
|
router = APIRouter(prefix="/system-permissions", tags=["system-permissions"])
|
||||||
|
|
||||||
@@ -14,10 +13,10 @@ router = APIRouter(prefix="/system-permissions", tags=["system-permissions"])
|
|||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
summary="获取系统级权限定义",
|
summary="获取系统级权限定义",
|
||||||
description="返回管理后台所有系统级操作的权限定义,仅 ADMIN 可访问",
|
description="返回管理后台所有系统级操作的权限定义,ADMIN 和项目 PM 可访问",
|
||||||
)
|
)
|
||||||
async def list_system_permissions(
|
async def list_system_permissions(
|
||||||
_=Depends(require_roles([UserRole.ADMIN.value])),
|
_=Depends(require_admin_or_any_project_pm()),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
permissions_list = [
|
permissions_list = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -671,6 +671,37 @@ API_ENDPOINT_PERMISSIONS = {
|
|||||||
"description": "搜索参与者历史",
|
"description": "搜索参与者历史",
|
||||||
"default_roles": ["PM", "CRA", "PV", "MEDICAL_REVIEW"],
|
"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": {
|
"project_milestones:read": {
|
||||||
"module": "project_milestones",
|
"module": "project_milestones",
|
||||||
@@ -1125,6 +1156,17 @@ OPERATION_TO_ENDPOINTS: dict[str, dict[str, list[str]]] = {
|
|||||||
"project_milestones:update",
|
"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_PERMISSIONS: dict[str, dict] = {
|
||||||
# 账号管理
|
# 账号管理
|
||||||
"system:users:list": {
|
"system:users:list": {
|
||||||
@@ -1256,11 +1299,17 @@ SYSTEM_PERMISSIONS: dict[str, dict] = {
|
|||||||
"roles": ["ADMIN"],
|
"roles": ["ADMIN"],
|
||||||
},
|
},
|
||||||
# 权限管理
|
# 权限管理
|
||||||
|
"system:permissions:read": {
|
||||||
|
"module": "system_permissions",
|
||||||
|
"action": "read",
|
||||||
|
"description": "查看系统级权限定义",
|
||||||
|
"roles": ["ADMIN", "PM"],
|
||||||
|
},
|
||||||
"system:permissions:project_config": {
|
"system:permissions:project_config": {
|
||||||
"module": "system_permissions",
|
"module": "system_permissions",
|
||||||
"action": "update",
|
"action": "update",
|
||||||
"description": "配置项目接口权限",
|
"description": "配置项目接口权限",
|
||||||
"roles": ["ADMIN"],
|
"roles": ["ADMIN", "PM"],
|
||||||
},
|
},
|
||||||
"system:permissions:templates": {
|
"system:permissions:templates": {
|
||||||
"module": "system_permissions",
|
"module": "system_permissions",
|
||||||
@@ -1268,6 +1317,61 @@ SYSTEM_PERMISSIONS: dict[str, dict] = {
|
|||||||
"description": "管理权限模板",
|
"description": "管理权限模板",
|
||||||
"roles": ["ADMIN"],
|
"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": {
|
"system:audit_logs:read": {
|
||||||
"module": "system_audit",
|
"module": "system_audit",
|
||||||
@@ -1288,5 +1392,6 @@ SYSTEM_MODULE_LABELS: dict[str, str] = {
|
|||||||
"system_users": "账号管理",
|
"system_users": "账号管理",
|
||||||
"system_projects": "项目管理",
|
"system_projects": "项目管理",
|
||||||
"system_permissions": "权限管理",
|
"system_permissions": "权限管理",
|
||||||
|
"system_monitoring": "权限监控",
|
||||||
"system_audit": "审计日志",
|
"system_audit": "审计日志",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Annotated, AsyncGenerator, Callable, Iterable
|
from typing import Annotated, AsyncGenerator, Callable, Iterable
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -10,10 +12,11 @@ from app.core.exceptions import AppException
|
|||||||
from app.core.security import decode_token, oauth2_scheme
|
from app.core.security import decode_token, oauth2_scheme
|
||||||
from app.crud import user as user_crud
|
from app.crud import user as user_crud
|
||||||
from app.crud import member as member_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.core.project_permissions import role_has_api_permission, get_missing_prerequisites
|
||||||
from app.db.session import SessionLocal
|
from app.db.session import SessionLocal
|
||||||
|
from app.models.study_member import StudyMember
|
||||||
from app.schemas.user import TokenPayload
|
from app.schemas.user import TokenPayload
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
||||||
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
@@ -65,12 +68,56 @@ def require_roles(roles: Iterable[str]) -> Callable:
|
|||||||
return dependency
|
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(
|
async def get_study_member(
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db_session),
|
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":
|
if role_value == "ADMIN":
|
||||||
return None
|
return None
|
||||||
return await member_crud.get_member(db, study_id, current_user.id)
|
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),
|
current_user=Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db_session),
|
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":
|
if role_value == "ADMIN":
|
||||||
return current_user
|
return current_user
|
||||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
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),
|
current_user=Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db_session),
|
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":
|
if allow_system_admin and role_value == "ADMIN":
|
||||||
return current_user
|
return current_user
|
||||||
membership = await member_crud.get_member(db, study_id, current_user.id)
|
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),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
):
|
):
|
||||||
from app.core.permission_monitor import get_permission_monitor
|
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":
|
if allow_system_admin and role_value == "ADMIN":
|
||||||
_enqueue_permission_log(
|
_enqueue_permission_log(
|
||||||
study_id, current_user.id, endpoint_key, "ADMIN", True, 0.0, request
|
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,
|
study_id: uuid.UUID,
|
||||||
current_user,
|
current_user,
|
||||||
) -> tuple[set[uuid.UUID], set[str]] | None:
|
) -> 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
|
role_value = current_user.role.value if hasattr(current_user.role, "value") else current_user.role
|
||||||
if role_value == "ADMIN":
|
if role_value == "ADMIN":
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ class PermissionMonitor:
|
|||||||
"cache_metrics": self.metrics.cache_metrics.to_dict(),
|
"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:
|
def reset_metrics(self) -> None:
|
||||||
self.metrics.reset()
|
self.metrics.reset()
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||||
@@ -48,13 +48,21 @@ async def role_has_api_permission(
|
|||||||
check_prerequisites: bool = True,
|
check_prerequisites: bool = True,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""检查角色是否有权访问特定接口"""
|
"""检查角色是否有权访问特定接口"""
|
||||||
if role == "ADMIN" or role == "PM":
|
if role == "ADMIN":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
permissions = await _get_project_permission_overrides(db, study_id)
|
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)
|
allowed = permissions.get(role or "", {}).get(endpoint_key)
|
||||||
if allowed is None:
|
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:
|
if not allowed:
|
||||||
return False
|
return False
|
||||||
@@ -78,7 +86,7 @@ async def get_missing_prerequisites(
|
|||||||
endpoint_key: str,
|
endpoint_key: str,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""获取缺失的前置权限列表"""
|
"""获取缺失的前置权限列表"""
|
||||||
if role == "ADMIN" or role == "PM":
|
if role == "ADMIN":
|
||||||
return []
|
return []
|
||||||
|
|
||||||
missing = []
|
missing = []
|
||||||
@@ -131,27 +139,36 @@ async def replace_api_endpoint_permissions(
|
|||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
payload: dict[str, dict[str, bool]],
|
payload: dict[str, dict[str, bool]],
|
||||||
) -> dict[str, dict[str, dict[str, bool]]]:
|
) -> dict[str, dict[str, dict[str, bool]]]:
|
||||||
"""替换项目的接口级权限矩阵"""
|
"""更新 payload 中指定的角色权限项。未提交的权限项保持不变。
|
||||||
await db.execute(
|
|
||||||
delete(ApiEndpointPermission).where(
|
|
||||||
ApiEndpointPermission.study_id == study_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
ADMIN 与 PM 权限不会被持久化:ADMIN 始终拥有全部权限,PM 默认拥有
|
||||||
|
全部项目权限,应当通过专门的渠道而不是项目权限矩阵调整。
|
||||||
|
"""
|
||||||
for role, endpoints in payload.items():
|
for role, endpoints in payload.items():
|
||||||
if role in ("ADMIN", "PM"):
|
if role in ("ADMIN", "PM"):
|
||||||
continue
|
continue
|
||||||
for endpoint_key, allowed in endpoints.items():
|
for endpoint_key, allowed in endpoints.items():
|
||||||
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
|
if endpoint_key not in API_ENDPOINT_PERMISSIONS:
|
||||||
continue
|
continue
|
||||||
db.add(
|
result = await db.execute(
|
||||||
ApiEndpointPermission(
|
select(ApiEndpointPermission).where(
|
||||||
study_id=study_id,
|
ApiEndpointPermission.study_id == study_id,
|
||||||
role=role,
|
ApiEndpointPermission.role == role,
|
||||||
endpoint_key=endpoint_key,
|
ApiEndpointPermission.endpoint_key == endpoint_key,
|
||||||
allowed=allowed,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
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()
|
await db.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Sequence
|
from typing import Sequence
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ async def _aggregate_hour(bucket_start: datetime, bucket_end: datetime) -> None:
|
|||||||
max_elapsed_ms=float(row.max_ms),
|
max_elapsed_ms=float(row.max_ms),
|
||||||
cache_hits=cache_metrics.cache_hits,
|
cache_hits=cache_metrics.cache_hits,
|
||||||
cache_misses=cache_metrics.cache_misses,
|
cache_misses=cache_metrics.cache_misses,
|
||||||
error_count=monitor.metrics.check_metrics.errors,
|
error_count=0,
|
||||||
)
|
)
|
||||||
session.add(snapshot)
|
session.add(snapshot)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import AsyncGenerator
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import pytest_asyncio
|
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.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
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
|
# Replace PostgreSQL UUID type with custom GUID type for SQLite
|
||||||
for table in Base.metadata.tables.values():
|
for table in Base.metadata.tables.values():
|
||||||
for column in table.columns:
|
for column in table.columns:
|
||||||
if isinstance(column.type, PG_UUID):
|
if isinstance(column.type, (PG_UUID, SA_UUID)):
|
||||||
column.type = GUID()
|
column.type = GUID()
|
||||||
|
|
||||||
# Create all tables
|
# Create all tables
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"])
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_full_permission_matrix_round_trips_to_backend_checks(db_session: AsyncSession):
|
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)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
study_id = uuid.uuid4()
|
study_id = uuid.uuid4()
|
||||||
|
configurable_roles = [role for role in PROJECT_PERMISSION_ROLES if role != "PM"]
|
||||||
payload = {
|
payload = {
|
||||||
role: {
|
role: {
|
||||||
endpoint_key: index % 2 == role_index % 2
|
endpoint_key: index % 2 == role_index % 2
|
||||||
for index, endpoint_key in enumerate(API_ENDPOINT_PERMISSIONS)
|
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)
|
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():
|
for endpoint_key, expected in payload[role].items():
|
||||||
assert matrix[role][endpoint_key]["allowed"] is expected
|
assert matrix[role][endpoint_key]["allowed"] is expected
|
||||||
allowed = await role_has_api_permission(
|
allowed = await role_has_api_permission(
|
||||||
|
|||||||
@@ -237,6 +237,58 @@ async def test_replace_api_endpoint_permissions_partial_update(db_session: Async
|
|||||||
assert result["CRA"]["subjects:list"]["allowed"] is True
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_api_endpoint_permissions_structure(db_session: AsyncSession):
|
async def test_get_api_endpoint_permissions_structure(db_session: AsyncSession):
|
||||||
"""测试权限矩阵的结构"""
|
"""测试权限矩阵的结构"""
|
||||||
|
|||||||
@@ -2,22 +2,20 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import uuid
|
import uuid
|
||||||
from httpx import AsyncClient
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.study import Study
|
from app.api.v1.api_permissions import (
|
||||||
from app.models.user import User
|
check_operation_prerequisites,
|
||||||
from app.models.study_member import StudyMember
|
list_api_operations,
|
||||||
|
list_operation_prerequisites,
|
||||||
|
)
|
||||||
from app.models.api_endpoint_permission import ApiEndpointPermission
|
from app.models.api_endpoint_permission import ApiEndpointPermission
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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")
|
data = await list_api_operations()
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "operations" in data
|
assert "operations" in data
|
||||||
|
|
||||||
# 验证返回的操作包含前置权限字段
|
# 验证返回的操作包含前置权限字段
|
||||||
@@ -35,12 +33,9 @@ async def test_list_operations_with_prerequisites(client: AsyncClient, db_sessio
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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")
|
data = await list_operation_prerequisites()
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert "prerequisites" in data
|
assert "prerequisites" in data
|
||||||
|
|
||||||
prerequisites = data["prerequisites"]
|
prerequisites = data["prerequisites"]
|
||||||
@@ -57,9 +52,10 @@ async def test_list_operation_prerequisites(client: AsyncClient):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_operation_prerequisites_satisfied(
|
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(
|
main_perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -77,13 +73,13 @@ async def test_check_operation_prerequisites_satisfied(
|
|||||||
db_session.add(prereq_perm)
|
db_session.add(prereq_perm)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
response = await client.get(
|
data = await check_operation_prerequisites(
|
||||||
f"/api-permissions/subjects:create/prerequisites",
|
study_id=study_id,
|
||||||
params={"study_id": str(study_id), "role": "CRA"}
|
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["endpoint_key"] == "subjects:create"
|
||||||
assert data["role"] == "CRA"
|
assert data["role"] == "CRA"
|
||||||
assert data["has_main_permission"] is True
|
assert data["has_main_permission"] is True
|
||||||
@@ -93,9 +89,10 @@ async def test_check_operation_prerequisites_satisfied(
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_operation_prerequisites_missing(
|
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(
|
main_perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -113,13 +110,13 @@ async def test_check_operation_prerequisites_missing(
|
|||||||
db_session.add(prereq_perm)
|
db_session.add(prereq_perm)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
response = await client.get(
|
data = await check_operation_prerequisites(
|
||||||
f"/api-permissions/subjects:create/prerequisites",
|
study_id=study_id,
|
||||||
params={"study_id": str(study_id), "role": "CRA"}
|
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["endpoint_key"] == "subjects:create"
|
||||||
assert data["role"] == "CRA"
|
assert data["role"] == "CRA"
|
||||||
assert data["has_main_permission"] is True
|
assert data["has_main_permission"] is True
|
||||||
@@ -129,9 +126,10 @@ async def test_check_operation_prerequisites_missing(
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_operation_prerequisites_multiple_missing(
|
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(
|
main_perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -156,13 +154,13 @@ async def test_check_operation_prerequisites_multiple_missing(
|
|||||||
db_session.add(prereq2)
|
db_session.add(prereq2)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
response = await client.get(
|
data = await check_operation_prerequisites(
|
||||||
f"/api-permissions/visits:create/prerequisites",
|
study_id=study_id,
|
||||||
params={"study_id": str(study_id), "role": "CRA"}
|
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["endpoint_key"] == "visits:create"
|
||||||
assert data["role"] == "CRA"
|
assert data["role"] == "CRA"
|
||||||
assert data["has_main_permission"] is True
|
assert data["has_main_permission"] is True
|
||||||
@@ -171,17 +169,15 @@ async def test_check_operation_prerequisites_multiple_missing(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_operation_prerequisites_admin(
|
async def test_check_operation_prerequisites_admin():
|
||||||
client: AsyncClient, study_id: uuid.UUID
|
|
||||||
):
|
|
||||||
"""测试检查操作前置权限 - ADMIN角色"""
|
"""测试检查操作前置权限 - ADMIN角色"""
|
||||||
response = await client.get(
|
data = await check_operation_prerequisites(
|
||||||
f"/api-permissions/subjects:create/prerequisites",
|
study_id=uuid.uuid4(),
|
||||||
params={"study_id": str(study_id), "role": "ADMIN"}
|
endpoint_key="subjects:create",
|
||||||
|
role="ADMIN",
|
||||||
|
db=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["role"] == "ADMIN"
|
assert data["role"] == "ADMIN"
|
||||||
assert data["has_main_permission"] is True
|
assert data["has_main_permission"] is True
|
||||||
assert data["missing_prerequisites"] == []
|
assert data["missing_prerequisites"] == []
|
||||||
@@ -190,9 +186,10 @@ async def test_check_operation_prerequisites_admin(
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_check_operation_prerequisites_no_main_permission(
|
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(
|
main_perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
@@ -203,13 +200,13 @@ async def test_check_operation_prerequisites_no_main_permission(
|
|||||||
db_session.add(main_perm)
|
db_session.add(main_perm)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
response = await client.get(
|
data = await check_operation_prerequisites(
|
||||||
f"/api-permissions/subjects:create/prerequisites",
|
study_id=study_id,
|
||||||
params={"study_id": str(study_id), "role": "CRA"}
|
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["endpoint_key"] == "subjects:create"
|
||||||
assert data["role"] == "CRA"
|
assert data["role"] == "CRA"
|
||||||
assert data["has_main_permission"] is False
|
assert data["has_main_permission"] is False
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ from app import main
|
|||||||
class _DummyTask:
|
class _DummyTask:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.awaited = False
|
self.awaited = False
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self.cancelled = True
|
||||||
|
|
||||||
def __await__(self):
|
def __await__(self):
|
||||||
async def _wait():
|
async def _wait():
|
||||||
|
|||||||
@@ -1,66 +1,22 @@
|
|||||||
"""监控测试:权限系统监控功能验证
|
"""监控测试:权限系统内存监控职责验证。"""
|
||||||
|
|
||||||
测试权限系统的监控功能,包括:
|
|
||||||
- 指标收集
|
|
||||||
- 告警生成
|
|
||||||
- 健康检查
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.core.permission_monitor import (
|
from app.core.permission_monitor import (
|
||||||
PermissionMonitor,
|
|
||||||
PermissionCheckMetrics,
|
|
||||||
CacheMetrics,
|
CacheMetrics,
|
||||||
|
PermissionMonitor,
|
||||||
get_permission_monitor,
|
get_permission_monitor,
|
||||||
set_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
|
@pytest.mark.asyncio
|
||||||
async def test_cache_metrics():
|
async def test_cache_metrics():
|
||||||
"""测试缓存指标"""
|
"""测试缓存指标"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 记录缓存访问
|
|
||||||
monitor.record_cache_hit()
|
monitor.record_cache_hit()
|
||||||
monitor.record_cache_hit()
|
monitor.record_cache_hit()
|
||||||
monitor.record_cache_miss()
|
monitor.record_cache_miss()
|
||||||
@@ -79,41 +35,35 @@ async def test_cache_invalidation_tracking():
|
|||||||
"""测试缓存失效追踪"""
|
"""测试缓存失效追踪"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 记录缓存失效
|
|
||||||
monitor.record_cache_invalidation()
|
monitor.record_cache_invalidation()
|
||||||
monitor.record_cache_invalidation()
|
monitor.record_cache_invalidation()
|
||||||
monitor.record_cache_invalidation()
|
monitor.record_cache_invalidation()
|
||||||
|
|
||||||
metrics = monitor.metrics.cache_metrics
|
assert monitor.metrics.cache_metrics.cache_invalidations == 3
|
||||||
assert metrics.cache_invalidations == 3
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_error_tracking():
|
async def test_slow_check_alert_generation():
|
||||||
"""测试错误追踪"""
|
"""测试慢权限检查告警生成"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 记录权限检查错误
|
monitor.record_slow_check_alert(100)
|
||||||
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)
|
|
||||||
|
|
||||||
alerts = monitor.get_alerts()
|
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]["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
|
@pytest.mark.asyncio
|
||||||
@@ -121,12 +71,11 @@ async def test_error_alert_generation():
|
|||||||
"""测试错误告警生成"""
|
"""测试错误告警生成"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 记录权限检查错误(应该生成告警)
|
monitor.record_error_alert(ValueError("test error"))
|
||||||
error = ValueError("test error")
|
|
||||||
monitor.record_permission_check(allowed=False, elapsed_time=0.005, error=error)
|
|
||||||
|
|
||||||
alerts = monitor.get_alerts()
|
alerts = monitor.get_alerts()
|
||||||
assert len(alerts) > 0
|
assert len(alerts) == 1
|
||||||
|
assert alerts[0]["level"] == "error"
|
||||||
assert alerts[0]["type"] == "permission_check_error"
|
assert alerts[0]["type"] == "permission_check_error"
|
||||||
|
|
||||||
|
|
||||||
@@ -135,20 +84,16 @@ async def test_alert_filtering():
|
|||||||
"""测试告警过滤"""
|
"""测试告警过滤"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 生成不同级别的告警
|
monitor.record_slow_check_alert(100)
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1) # warning
|
monitor.record_error_alert(ValueError("test error"))
|
||||||
error = ValueError("test error")
|
|
||||||
monitor.record_permission_check(allowed=False, elapsed_time=0.005, error=error) # error
|
|
||||||
|
|
||||||
# 过滤 warning 级别的告警
|
|
||||||
warning_alerts = monitor.get_alerts(level="warning")
|
warning_alerts = monitor.get_alerts(level="warning")
|
||||||
assert len(warning_alerts) > 0
|
assert len(warning_alerts) == 1
|
||||||
assert all(a["level"] == "warning" for a in warning_alerts)
|
assert all(alert["level"] == "warning" for alert in warning_alerts)
|
||||||
|
|
||||||
# 过滤 error 级别的告警
|
|
||||||
error_alerts = monitor.get_alerts(level="error")
|
error_alerts = monitor.get_alerts(level="error")
|
||||||
assert len(error_alerts) > 0
|
assert len(error_alerts) == 1
|
||||||
assert all(a["level"] == "error" for a in error_alerts)
|
assert all(alert["level"] == "error" for alert in error_alerts)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -156,13 +101,10 @@ async def test_alert_limit():
|
|||||||
"""测试告警数量限制"""
|
"""测试告警数量限制"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 生成大量告警
|
|
||||||
for _ in range(50):
|
for _ in range(50):
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1)
|
monitor.record_slow_check_alert(100)
|
||||||
|
|
||||||
# 获取告警,限制为10条
|
assert len(monitor.get_alerts(limit=10)) == 10
|
||||||
alerts = monitor.get_alerts(limit=10)
|
|
||||||
assert len(alerts) == 10
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -170,17 +112,11 @@ async def test_metrics_reset():
|
|||||||
"""测试指标重置"""
|
"""测试指标重置"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 记录一些指标
|
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.005)
|
|
||||||
monitor.record_cache_hit()
|
monitor.record_cache_hit()
|
||||||
|
|
||||||
assert monitor.metrics.check_metrics.total_checks == 1
|
|
||||||
assert monitor.metrics.cache_metrics.total_accesses == 1
|
assert monitor.metrics.cache_metrics.total_accesses == 1
|
||||||
|
|
||||||
# 重置指标
|
|
||||||
monitor.reset_metrics()
|
monitor.reset_metrics()
|
||||||
|
|
||||||
assert monitor.metrics.check_metrics.total_checks == 0
|
|
||||||
assert monitor.metrics.cache_metrics.total_accesses == 0
|
assert monitor.metrics.cache_metrics.total_accesses == 0
|
||||||
|
|
||||||
|
|
||||||
@@ -189,15 +125,12 @@ async def test_alerts_clear():
|
|||||||
"""测试告警清除"""
|
"""测试告警清除"""
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
# 生成告警
|
monitor.record_slow_check_alert(100)
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1)
|
assert len(monitor.get_alerts()) == 1
|
||||||
|
|
||||||
assert len(monitor.get_alerts()) > 0
|
|
||||||
|
|
||||||
# 清除告警
|
|
||||||
monitor.clear_alerts()
|
monitor.clear_alerts()
|
||||||
|
|
||||||
assert len(monitor.get_alerts()) == 0
|
assert monitor.get_alerts() == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -205,27 +138,16 @@ async def test_metrics_to_dict():
|
|||||||
"""测试指标转换为字典"""
|
"""测试指标转换为字典"""
|
||||||
monitor = PermissionMonitor()
|
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_hit()
|
||||||
monitor.record_cache_miss()
|
monitor.record_cache_miss()
|
||||||
|
|
||||||
metrics_dict = monitor.get_metrics()
|
metrics_dict = monitor.get_metrics()
|
||||||
|
|
||||||
assert "check_metrics" in metrics_dict
|
|
||||||
assert "cache_metrics" in metrics_dict
|
assert "cache_metrics" in metrics_dict
|
||||||
assert "uptime_seconds" in metrics_dict
|
assert "uptime_seconds" in metrics_dict
|
||||||
|
assert metrics_dict["cache_metrics"]["total_accesses"] == 2
|
||||||
check_metrics = metrics_dict["check_metrics"]
|
assert metrics_dict["cache_metrics"]["cache_hits"] == 1
|
||||||
assert check_metrics["total_checks"] == 2
|
assert metrics_dict["cache_metrics"]["cache_misses"] == 1
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -234,7 +156,6 @@ async def test_global_monitor_instance():
|
|||||||
monitor1 = get_permission_monitor()
|
monitor1 = get_permission_monitor()
|
||||||
monitor2 = get_permission_monitor()
|
monitor2 = get_permission_monitor()
|
||||||
|
|
||||||
# 应该是同一个实例
|
|
||||||
assert monitor1 is monitor2
|
assert monitor1 is monitor2
|
||||||
|
|
||||||
|
|
||||||
@@ -244,8 +165,7 @@ async def test_set_global_monitor():
|
|||||||
new_monitor = PermissionMonitor()
|
new_monitor = PermissionMonitor()
|
||||||
set_permission_monitor(new_monitor)
|
set_permission_monitor(new_monitor)
|
||||||
|
|
||||||
monitor = get_permission_monitor()
|
assert get_permission_monitor() is new_monitor
|
||||||
assert monitor is new_monitor
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -254,87 +174,24 @@ async def test_alert_timestamp():
|
|||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
|
|
||||||
before_time = time.time()
|
before_time = time.time()
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1)
|
monitor.record_slow_check_alert(100)
|
||||||
after_time = time.time()
|
after_time = time.time()
|
||||||
|
|
||||||
alerts = monitor.get_alerts()
|
alerts = monitor.get_alerts()
|
||||||
assert len(alerts) > 0
|
|
||||||
assert before_time <= alerts[0]["timestamp"] <= after_time
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_cache_metrics_dataclass():
|
async def test_cache_metrics_dataclass():
|
||||||
"""测试缓存指标数据类"""
|
"""测试缓存指标数据类"""
|
||||||
metrics = CacheMetrics()
|
metrics = CacheMetrics()
|
||||||
|
|
||||||
# 初始状态
|
|
||||||
assert metrics.total_accesses == 0
|
assert metrics.total_accesses == 0
|
||||||
assert metrics.hit_rate == 0.0
|
assert metrics.hit_rate == 0.0
|
||||||
|
|
||||||
# 添加数据
|
|
||||||
metrics.total_accesses = 100
|
metrics.total_accesses = 100
|
||||||
metrics.cache_hits = 80
|
metrics.cache_hits = 80
|
||||||
metrics.cache_misses = 20
|
metrics.cache_misses = 20
|
||||||
|
|
||||||
assert metrics.hit_rate == pytest.approx(80.0, 0.1)
|
assert metrics.hit_rate == pytest.approx(80.0, 0.1)
|
||||||
assert metrics.miss_rate == pytest.approx(20.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"]
|
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"""监控API测试:权限系统监控API端点验证
|
"""监控API测试:权限系统监控API端点验证。"""
|
||||||
|
|
||||||
测试权限系统监控API的功能。
|
import uuid
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from sqlalchemy import text
|
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
|
from app.api.v1 import permission_monitoring
|
||||||
|
|
||||||
|
|
||||||
@@ -20,202 +18,226 @@ class FakeIpInfo:
|
|||||||
self.location = f"中国 / {province} / {city} / 电信"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_get_permission_metrics(client: TestClient, auth_headers: dict):
|
async def test_get_permission_metrics(db_session):
|
||||||
"""测试获取权限系统指标"""
|
"""测试获取权限系统指标"""
|
||||||
# 清除并重置监控器
|
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 记录一些指标
|
study_id = uuid.uuid4()
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.005)
|
user_id = uuid.uuid4()
|
||||||
monitor.record_permission_check(allowed=False, elapsed_time=0.003)
|
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)
|
data = await permission_monitoring.get_permission_metrics(db=db_session, _=object(), hours=24)
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
assert "check_metrics" in data
|
assert "check_metrics" in data
|
||||||
assert "cache_metrics" in data
|
assert "cache_metrics" in data
|
||||||
assert data["check_metrics"]["total_checks"] == 2
|
assert data["check_metrics"]["total_checks"] == 2
|
||||||
|
assert data["check_metrics"]["allowed_checks"] == 1
|
||||||
|
assert data["check_metrics"]["denied_checks"] == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_cache_statistics(client: TestClient, auth_headers: dict):
|
async def test_get_cache_statistics(db_session):
|
||||||
"""测试获取缓存统计"""
|
"""测试获取缓存统计"""
|
||||||
# 清除并重置监控器
|
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 记录缓存访问
|
|
||||||
monitor.record_cache_hit()
|
monitor.record_cache_hit()
|
||||||
monitor.record_cache_hit()
|
monitor.record_cache_hit()
|
||||||
monitor.record_cache_miss()
|
monitor.record_cache_miss()
|
||||||
|
|
||||||
response = client.get("/api/v1/permission-monitoring/cache-stats", headers=auth_headers)
|
data = await permission_monitoring.get_cache_statistics(_=object(), db=db_session)
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
assert "cache_metrics" in data
|
assert "cache_metrics" in data
|
||||||
assert data["cache_metrics"]["total_accesses"] == 3
|
assert data["cache_metrics"]["total_accesses"] == 3
|
||||||
assert data["cache_metrics"]["cache_hits"] == 2
|
assert data["cache_metrics"]["cache_hits"] == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_alerts(client: TestClient, auth_headers: dict):
|
async def test_get_alerts(db_session):
|
||||||
"""测试获取告警列表"""
|
"""测试获取告警列表"""
|
||||||
# 清除并重置监控器
|
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 生成告警
|
monitor.record_slow_check_alert(100)
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1)
|
|
||||||
|
|
||||||
response = client.get("/api/v1/permission-monitoring/alerts", headers=auth_headers)
|
data = await permission_monitoring.get_alerts(_=object(), db=db_session)
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
assert "alerts" in data
|
assert "alerts" in data
|
||||||
assert data["total"] > 0
|
assert data["total"] > 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 生成告警
|
monitor.record_slow_check_alert(100)
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1)
|
monitor.record_error_alert(ValueError("test error"))
|
||||||
|
|
||||||
response = client.get(
|
data = await permission_monitoring.get_alerts(level="warning", _=object(), db=db_session)
|
||||||
"/api/v1/permission-monitoring/alerts?level=warning",
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
assert "alerts" in data
|
assert "alerts" in data
|
||||||
|
assert all(alert["level"] == "warning" for alert in data["alerts"])
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 生成多个告警
|
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1)
|
monitor.record_slow_check_alert(100)
|
||||||
|
|
||||||
response = client.get(
|
data = await permission_monitoring.get_alerts(limit=5, _=object(), db=db_session)
|
||||||
"/api/v1/permission-monitoring/alerts?limit=5",
|
|
||||||
headers=auth_headers,
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
assert len(data["alerts"]) <= 5
|
assert len(data["alerts"]) <= 5
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reset_metrics(client: TestClient, auth_headers: dict):
|
async def test_reset_metrics(db_session):
|
||||||
"""测试重置指标"""
|
"""测试重置指标"""
|
||||||
# 清除并重置监控器
|
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 记录指标
|
monitor.record_cache_hit()
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.005)
|
assert monitor.metrics.cache_metrics.total_accesses == 1
|
||||||
assert monitor.metrics.check_metrics.total_checks == 1
|
|
||||||
|
|
||||||
# 重置指标
|
result = await permission_monitoring.reset_metrics(_=object(), db=db_session)
|
||||||
response = client.post("/api/v1/permission-monitoring/reset-metrics", headers=auth_headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# 验证指标已重置
|
assert result["message"] == "指标已重置"
|
||||||
assert monitor.metrics.check_metrics.total_checks == 0
|
assert monitor.metrics.cache_metrics.total_accesses == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_clear_alerts(client: TestClient, auth_headers: dict):
|
async def test_clear_alerts(db_session):
|
||||||
"""测试清除告警"""
|
"""测试清除告警"""
|
||||||
# 清除并重置监控器
|
|
||||||
monitor = PermissionMonitor()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 生成告警
|
monitor.record_slow_check_alert(100)
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.1)
|
|
||||||
assert len(monitor.get_alerts()) > 0
|
assert len(monitor.get_alerts()) > 0
|
||||||
|
|
||||||
# 清除告警
|
result = await permission_monitoring.clear_alerts(_=object(), db=db_session)
|
||||||
response = client.post("/api/v1/permission-monitoring/clear-alerts", headers=auth_headers)
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
# 验证告警已清除
|
assert result["message"] == "告警已清除"
|
||||||
assert len(monitor.get_alerts()) == 0
|
assert len(monitor.get_alerts()) == 0
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 记录良好的指标
|
|
||||||
for _ in range(100):
|
|
||||||
monitor.record_permission_check(allowed=True, elapsed_time=0.001)
|
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
monitor.record_cache_hit()
|
monitor.record_cache_hit()
|
||||||
|
|
||||||
response = client.get("/api/v1/permission-monitoring/health", headers=auth_headers)
|
data = await permission_monitoring.permission_system_health(db=db_session, _=object())
|
||||||
assert response.status_code == 200
|
assert data["status"] == "healthy"
|
||||||
|
assert data["health_score"] >= 80
|
||||||
data = response.json()
|
|
||||||
assert data["status"] in ["healthy", "degraded"]
|
|
||||||
assert data["health_score"] > 50
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
# 记录不良的指标
|
study_id = uuid.uuid4()
|
||||||
for _ in range(100):
|
user_id = uuid.uuid4()
|
||||||
monitor.record_permission_check(allowed=False, elapsed_time=0.1)
|
for _ in range(10):
|
||||||
|
await _seed_permission_log(db_session, study_id, user_id, allowed=False, elapsed_ms=100)
|
||||||
for _ in range(100):
|
for _ in range(100):
|
||||||
monitor.record_cache_miss()
|
monitor.record_cache_miss()
|
||||||
|
|
||||||
response = client.get("/api/v1/permission-monitoring/health", headers=auth_headers)
|
data = await permission_monitoring.permission_system_health(db=db_session, _=object())
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
assert "status" in data
|
assert "status" in data
|
||||||
assert "health_score" in data
|
assert "health_score" in data
|
||||||
assert "issues" in data
|
assert "issues" in data
|
||||||
|
assert "权限检查响应时间过长" in data["issues"]
|
||||||
|
assert "权限拒绝率过高" in data["issues"]
|
||||||
|
assert "缓存命中率过低" in data["issues"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
monitor = PermissionMonitor()
|
||||||
set_permission_monitor(monitor)
|
set_permission_monitor(monitor)
|
||||||
|
|
||||||
response = client.get("/api/v1/permission-monitoring/health", headers=auth_headers)
|
data = await permission_monitoring.permission_system_health(db=db_session, _=object())
|
||||||
assert response.status_code == 200
|
assert "last_hour" in data
|
||||||
|
|
||||||
data = response.json()
|
|
||||||
assert "metrics" in data
|
|
||||||
assert "cache_stats" in data
|
assert "cache_stats" in data
|
||||||
assert "check_metrics" in data["metrics"]
|
assert "total_checks" in data["last_hour"]
|
||||||
assert "cache_metrics" in data["metrics"]
|
assert "cache_metrics" in data["cache_stats"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -70,11 +70,11 @@ async def test_prerequisite_permission_missing(db_session: AsyncSession):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
study_id = uuid.uuid4()
|
||||||
|
|
||||||
# 创建权限:主权限允许,前置权限未配置
|
# CRA 默认拥有 sites:read,因此未显式配置前置权限时仍满足前置条件。
|
||||||
main_perm = ApiEndpointPermission(
|
main_perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
role="CRA",
|
role="CRA",
|
||||||
@@ -88,6 +88,26 @@ async def test_prerequisite_permission_not_configured(db_session: AsyncSession):
|
|||||||
result = await role_has_api_permission(
|
result = await role_has_api_permission(
|
||||||
db_session, study_id, "CRA", "subjects:create", check_prerequisites=True
|
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
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
@@ -323,8 +343,8 @@ async def test_prerequisite_with_no_prerequisites_operation(db_session: AsyncSes
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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()
|
study_id = uuid.uuid4()
|
||||||
|
|
||||||
# 创建权限:主权限允许,前置权限未配置
|
# 创建权限:主权限允许,前置权限未配置
|
||||||
@@ -341,4 +361,24 @@ async def test_prerequisite_missing_not_configured(db_session: AsyncSession):
|
|||||||
missing = await get_missing_prerequisites(
|
missing = await get_missing_prerequisites(
|
||||||
db_session, study_id, "CRA", "subjects:create"
|
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
|
assert "sites:read" in missing
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ def _make_study() -> Study:
|
|||||||
status="ACTIVE",
|
status="ACTIVE",
|
||||||
is_locked=False,
|
is_locked=False,
|
||||||
visit_schedule=[],
|
visit_schedule=[],
|
||||||
|
active_roles=[],
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user