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
+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"]