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()