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