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