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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user