diff --git a/backend/app/api/v1/permission_monitoring.py b/backend/app/api/v1/permission_monitoring.py
index 725b271e..bf1b59fd 100644
--- a/backend/app/api/v1/permission_monitoring.py
+++ b/backend/app/api/v1/permission_monitoring.py
@@ -8,7 +8,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, status
from app.core.deps import get_current_user
-from app.core.permission_monitor import get_permission_monitor
+from app.core.permission_monitor import evaluate_permission_system_health, get_permission_monitor
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
@@ -98,37 +98,4 @@ async def permission_system_health(
metrics = monitor.get_metrics()
cache_stats = monitor.get_cache_stats()
- # 计算健康分数
- check_metrics = metrics["check_metrics"]
- cache_metrics = metrics["cache_metrics"]
-
- health_score = 100
- issues = []
-
- # 检查错误率
- if check_metrics["error_rate"] > 1:
- health_score -= 20
- issues.append("权限检查错误率过高")
-
- # 检查缓存命中率
- if cache_metrics["hit_rate"] < 50:
- health_score -= 10
- issues.append("缓存命中率过低")
-
- # 检查平均响应时间
- if check_metrics["avg_time"] > 0.01: # 10ms
- health_score -= 10
- issues.append("权限检查响应时间过长")
-
- # 检查拒绝率
- if check_metrics["deny_rate"] > 50:
- health_score -= 5
- issues.append("权限拒绝率过高")
-
- return {
- "status": "healthy" if health_score >= 80 else "degraded" if health_score >= 50 else "unhealthy",
- "health_score": max(0, health_score),
- "issues": issues,
- "metrics": metrics,
- "cache_stats": cache_stats,
- }
+ return evaluate_permission_system_health(metrics, cache_stats)
diff --git a/backend/app/core/permission_cache.py b/backend/app/core/permission_cache.py
index a20047d3..9e5445e7 100644
--- a/backend/app/core/permission_cache.py
+++ b/backend/app/core/permission_cache.py
@@ -52,6 +52,35 @@ class PermissionCache:
self._member_role_cache[cache_key] = (role, time.time())
return role
+ def get_project_permissions(
+ self,
+ study_id: uuid.UUID,
+ ttl: int | None = None,
+ ) -> dict[str, dict[str, bool]] | None:
+ """获取项目接口权限覆盖表缓存"""
+ if ttl is None:
+ ttl = self.default_ttl
+
+ cache_key = self._make_project_cache_key(study_id)
+ if cache_key not in self._project_permissions_cache:
+ return None
+
+ permissions, timestamp = self._project_permissions_cache[cache_key]
+ if self._is_expired(timestamp, ttl):
+ self._project_permissions_cache.pop(cache_key, None)
+ return None
+
+ return permissions
+
+ def set_project_permissions(
+ self,
+ study_id: uuid.UUID,
+ permissions: dict[str, dict[str, bool]],
+ ) -> None:
+ """缓存项目接口权限覆盖表"""
+ cache_key = self._make_project_cache_key(study_id)
+ self._project_permissions_cache[cache_key] = (permissions, time.time())
+
def invalidate_project_permissions(self, study_id: uuid.UUID) -> None:
cache_key = self._make_project_cache_key(study_id)
self._project_permissions_cache.pop(cache_key, None)
diff --git a/backend/app/core/permission_monitor.py b/backend/app/core/permission_monitor.py
index 7e837dba..b6e8f9be 100644
--- a/backend/app/core/permission_monitor.py
+++ b/backend/app/core/permission_monitor.py
@@ -17,6 +17,9 @@ from typing import Any
from app.core.permission_cache import get_permission_cache
+CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 10
+
+
@dataclass
class PermissionCheckMetrics:
"""权限检查指标"""
@@ -293,3 +296,39 @@ def set_permission_monitor(monitor: PermissionMonitor) -> None:
"""设置全局权限监控器实例(用于测试)"""
global _permission_monitor
_permission_monitor = monitor
+
+
+def evaluate_permission_system_health(metrics: dict[str, Any], cache_stats: dict[str, Any]) -> dict[str, Any]:
+ """根据权限系统指标评估健康状态"""
+ check_metrics = metrics["check_metrics"]
+ cache_metrics = metrics["cache_metrics"]
+
+ health_score = 100
+ issues = []
+
+ if check_metrics["error_rate"] > 1:
+ health_score -= 20
+ issues.append("权限检查错误率过高")
+
+ if (
+ cache_metrics["total_accesses"] >= CACHE_HIT_RATE_HEALTH_MIN_ACCESSES
+ and cache_metrics["hit_rate"] < 50
+ ):
+ health_score -= 10
+ issues.append("缓存命中率过低")
+
+ if check_metrics["avg_time"] > 0.01:
+ health_score -= 10
+ issues.append("权限检查响应时间过长")
+
+ if check_metrics["deny_rate"] > 50:
+ health_score -= 5
+ issues.append("权限拒绝率过高")
+
+ return {
+ "status": "healthy" if health_score >= 80 else "degraded" if health_score >= 50 else "unhealthy",
+ "health_score": max(0, health_score),
+ "issues": issues,
+ "metrics": metrics,
+ "cache_stats": cache_stats,
+ }
diff --git a/backend/app/core/project_permissions.py b/backend/app/core/project_permissions.py
index edcfab9b..7b481260 100644
--- a/backend/app/core/project_permissions.py
+++ b/backend/app/core/project_permissions.py
@@ -10,6 +10,35 @@ from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQU
from app.core.permission_cache import get_permission_cache
+async def _get_project_permission_overrides(
+ db: AsyncSession,
+ study_id: uuid.UUID,
+) -> dict[str, dict[str, bool]]:
+ cache = get_permission_cache()
+ cached_permissions = cache.get_project_permissions(study_id)
+ from app.core.permission_monitor import get_permission_monitor
+
+ monitor = get_permission_monitor()
+ if cached_permissions is not None:
+ monitor.record_cache_hit()
+ return cached_permissions
+
+ monitor.record_cache_miss()
+ result = await db.execute(
+ select(ApiEndpointPermission).where(
+ ApiEndpointPermission.study_id == study_id,
+ )
+ )
+ rows = result.scalars().all()
+
+ permissions: dict[str, dict[str, bool]] = {}
+ for row in rows:
+ permissions.setdefault(row.role, {})[row.endpoint_key] = row.allowed
+
+ cache.set_project_permissions(study_id, permissions)
+ return permissions
+
+
async def role_has_api_permission(
db: AsyncSession,
study_id: uuid.UUID,
@@ -21,18 +50,12 @@ async def role_has_api_permission(
if role == "ADMIN":
return True
- result = await db.execute(
- select(ApiEndpointPermission).where(
- ApiEndpointPermission.study_id == study_id,
- ApiEndpointPermission.role == role,
- ApiEndpointPermission.endpoint_key == endpoint_key,
- )
- )
- perm = result.scalar_one_or_none()
- if perm is None:
+ permissions = await _get_project_permission_overrides(db, study_id)
+ allowed = permissions.get(role or "", {}).get(endpoint_key)
+ if allowed is None:
return False
- if not perm.allowed:
+ if not allowed:
return False
if check_prerequisites:
@@ -77,12 +100,7 @@ async def get_api_endpoint_permissions(
返回格式: {role: {endpoint_key: {allowed: bool}}}
"""
- result = await db.execute(
- select(ApiEndpointPermission).where(
- ApiEndpointPermission.study_id == study_id,
- )
- )
- rows = result.scalars().all()
+ overrides = await _get_project_permission_overrides(db, study_id)
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
matrix: dict[str, dict[str, dict[str, bool]]] = {}
@@ -92,10 +110,11 @@ async def get_api_endpoint_permissions(
default_allowed = role in config.get("default_roles", [])
matrix[role][endpoint_key] = {"allowed": default_allowed}
- for row in rows:
- if row.role not in matrix:
- matrix[row.role] = {}
- matrix[row.role][row.endpoint_key] = {"allowed": row.allowed}
+ for role, endpoints in overrides.items():
+ if role not in matrix:
+ matrix[role] = {}
+ for endpoint_key, allowed in endpoints.items():
+ matrix[role][endpoint_key] = {"allowed": allowed}
return matrix
@@ -132,5 +151,8 @@ async def replace_api_endpoint_permissions(
cache = get_permission_cache()
cache.invalidate_project_permissions(study_id)
cache.invalidate_all_member_roles(study_id)
+ from app.core.permission_monitor import get_permission_monitor
+
+ get_permission_monitor().record_cache_invalidation()
return await get_api_endpoint_permissions(db, study_id)
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 656eb61f..2c12cdc3 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -111,8 +111,8 @@ async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
study_code = f"TEST-STUDY-{uuid.uuid4().hex[:8]}"
await session.execute(
text("""
- INSERT INTO studies (id, code, name, status, is_locked, visit_schedule)
- VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule)
+ 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(uuid.uuid4()),
@@ -121,6 +121,7 @@ async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
+ "active_roles": "[]",
}
)
await session.commit()
diff --git a/backend/tests/test_api_permissions.py b/backend/tests/test_api_permissions.py
index 7f82a7da..c4258806 100644
--- a/backend/tests/test_api_permissions.py
+++ b/backend/tests/test_api_permissions.py
@@ -2,9 +2,12 @@
import pytest
import uuid
+from sqlalchemy import delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.project_permissions import role_has_api_permission
+from app.core.permission_cache import PermissionCache, set_permission_cache
+from app.core.permission_monitor import PermissionMonitor, set_permission_monitor
from app.models.api_endpoint_permission import ApiEndpointPermission
@@ -208,3 +211,75 @@ async def test_api_permission_unknown_endpoint(db_session: AsyncSession):
db_session, study_id, "CRA", "unknown:endpoint", check_prerequisites=False
)
assert result is False
+
+
+@pytest.mark.asyncio
+async def test_api_permission_check_uses_project_permission_cache(db_session: AsyncSession):
+ """测试接口权限检查会复用项目权限缓存并记录命中指标"""
+ cache = PermissionCache()
+ monitor = PermissionMonitor()
+ set_permission_cache(cache)
+ set_permission_monitor(monitor)
+ study_id = uuid.uuid4()
+
+ db_session.add(
+ ApiEndpointPermission(
+ study_id=study_id,
+ role="CRA",
+ endpoint_key="subjects:create",
+ allowed=True,
+ )
+ )
+ await db_session.commit()
+
+ first_result = await role_has_api_permission(
+ db_session, study_id, "CRA", "subjects:create", check_prerequisites=False
+ )
+ await db_session.execute(
+ delete(ApiEndpointPermission).where(ApiEndpointPermission.study_id == study_id)
+ )
+ await db_session.commit()
+ second_result = await role_has_api_permission(
+ db_session, study_id, "CRA", "subjects:create", check_prerequisites=False
+ )
+
+ cache_metrics = monitor.get_metrics()["cache_metrics"]
+ assert first_result is True
+ assert second_result is True
+ assert cache.get_cache_stats()["project_permissions_count"] == 1
+ assert cache_metrics["cache_misses"] == 1
+ assert cache_metrics["cache_hits"] == 1
+
+
+@pytest.mark.asyncio
+async def test_replace_api_endpoint_permissions_invalidates_project_permission_cache(
+ db_session: AsyncSession,
+):
+ """测试替换权限后会失效旧缓存并记录失效指标"""
+ from app.core.project_permissions import replace_api_endpoint_permissions
+
+ cache = PermissionCache()
+ monitor = PermissionMonitor()
+ set_permission_cache(cache)
+ set_permission_monitor(monitor)
+ study_id = uuid.uuid4()
+
+ await replace_api_endpoint_permissions(
+ db_session,
+ study_id,
+ {"CRA": {"subjects:create": True}},
+ )
+ assert cache.get_cache_stats()["project_permissions_count"] == 1
+
+ await replace_api_endpoint_permissions(
+ db_session,
+ study_id,
+ {"CRA": {"subjects:create": False}},
+ )
+
+ result = await role_has_api_permission(
+ db_session, study_id, "CRA", "subjects:create", check_prerequisites=False
+ )
+ cache_metrics = monitor.get_metrics()["cache_metrics"]
+ assert result is False
+ assert cache_metrics["cache_invalidations"] == 2
diff --git a/backend/tests/test_api_permissions_endpoints.py b/backend/tests/test_api_permissions_endpoints.py
index 2ea161d0..5523e693 100644
--- a/backend/tests/test_api_permissions_endpoints.py
+++ b/backend/tests/test_api_permissions_endpoints.py
@@ -30,7 +30,7 @@ async def test_get_api_endpoint_permissions_with_custom(db_session: AsyncSession
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
- endpoint_key="POST:/subjects",
+ endpoint_key="subjects:create",
allowed=True,
)
db_session.add(perm)
@@ -40,8 +40,8 @@ async def test_get_api_endpoint_permissions_with_custom(db_session: AsyncSession
assert isinstance(result, dict)
assert "CRA" in result
- assert "POST:/subjects" in result["CRA"]
- assert result["CRA"]["POST:/subjects"]["allowed"] is True
+ assert "subjects:create" in result["CRA"]
+ assert result["CRA"]["subjects:create"]["allowed"] is True
@pytest.mark.asyncio
@@ -51,9 +51,9 @@ async def test_replace_api_endpoint_permissions_single_role(db_session: AsyncSes
payload = {
"CRA": {
- "POST:/subjects": True,
- "GET:/subjects": True,
- "PATCH:/subjects/{id}": True,
+ "subjects:create": True,
+ "subjects:list": True,
+ "subjects:update": True,
}
}
@@ -61,9 +61,9 @@ async def test_replace_api_endpoint_permissions_single_role(db_session: AsyncSes
assert isinstance(result, dict)
assert "CRA" in result
- assert result["CRA"]["POST:/subjects"]["allowed"] is True
- assert result["CRA"]["GET:/subjects"]["allowed"] is True
- assert result["CRA"]["PATCH:/subjects/{id}"]["allowed"] is True
+ assert result["CRA"]["subjects:create"]["allowed"] is True
+ assert result["CRA"]["subjects:list"]["allowed"] is True
+ assert result["CRA"]["subjects:update"]["allowed"] is True
@pytest.mark.asyncio
@@ -73,21 +73,21 @@ async def test_replace_api_endpoint_permissions_multiple_roles(db_session: Async
payload = {
"CRA": {
- "POST:/subjects": True,
- "GET:/subjects": True,
+ "subjects:create": True,
+ "subjects:list": True,
},
"PV": {
- "GET:/subjects": True,
- "GET:/subjects/{id}": True,
+ "subjects:list": True,
+ "subjects:read": True,
}
}
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
- assert result["CRA"]["POST:/subjects"]["allowed"] is True
- assert result["CRA"]["GET:/subjects"]["allowed"] is True
- assert result["PV"]["GET:/subjects"]["allowed"] is True
- assert result["PV"]["GET:/subjects/{id}"]["allowed"] is True
+ assert result["CRA"]["subjects:create"]["allowed"] is True
+ assert result["CRA"]["subjects:list"]["allowed"] is True
+ assert result["PV"]["subjects:list"]["allowed"] is True
+ assert result["PV"]["subjects:read"]["allowed"] is True
@pytest.mark.asyncio
@@ -97,13 +97,13 @@ async def test_replace_api_endpoint_permissions_deny(db_session: AsyncSession):
payload = {
"PV": {
- "POST:/subjects": False,
+ "subjects:create": False,
}
}
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
- assert result["PV"]["POST:/subjects"]["allowed"] is False
+ assert result["PV"]["subjects:create"]["allowed"] is False
@pytest.mark.asyncio
@@ -115,7 +115,7 @@ async def test_replace_api_endpoint_permissions_overwrites_existing(db_session:
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
- endpoint_key="POST:/subjects",
+ endpoint_key="subjects:create",
allowed=True,
)
db_session.add(perm)
@@ -124,13 +124,13 @@ async def test_replace_api_endpoint_permissions_overwrites_existing(db_session:
# 替换权限
payload = {
"CRA": {
- "POST:/subjects": False,
+ "subjects:create": False,
}
}
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
- assert result["CRA"]["POST:/subjects"]["allowed"] is False
+ assert result["CRA"]["subjects:create"]["allowed"] is False
@pytest.mark.asyncio
@@ -140,23 +140,23 @@ async def test_replace_api_endpoint_permissions_multiple_endpoints(db_session: A
payload = {
"CRA": {
- "POST:/subjects": True,
- "GET:/subjects": True,
- "PATCH:/subjects/{id}": True,
- "DELETE:/subjects/{id}": False,
- "POST:/risk-issues": True,
- "GET:/risk-issues": True,
+ "subjects:create": True,
+ "subjects:list": True,
+ "subjects:update": True,
+ "subjects:delete": False,
+ "risk_issues:create": True,
+ "risk_issues:list": True,
}
}
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
- assert result["CRA"]["POST:/subjects"]["allowed"] is True
- assert result["CRA"]["GET:/subjects"]["allowed"] is True
- assert result["CRA"]["PATCH:/subjects/{id}"]["allowed"] is True
- assert result["CRA"]["DELETE:/subjects/{id}"]["allowed"] is False
- assert result["CRA"]["POST:/risk-issues"]["allowed"] is True
- assert result["CRA"]["GET:/risk-issues"]["allowed"] is True
+ assert result["CRA"]["subjects:create"]["allowed"] is True
+ assert result["CRA"]["subjects:list"]["allowed"] is True
+ assert result["CRA"]["subjects:update"]["allowed"] is True
+ assert result["CRA"]["subjects:delete"]["allowed"] is False
+ assert result["CRA"]["risk_issues:create"]["allowed"] is True
+ assert result["CRA"]["risk_issues:list"]["allowed"] is True
@pytest.mark.asyncio
@@ -167,13 +167,13 @@ async def test_replace_api_endpoint_permissions_different_studies(db_session: As
payload_1 = {
"CRA": {
- "POST:/subjects": True,
+ "subjects:create": True,
}
}
payload_2 = {
"CRA": {
- "POST:/subjects": False,
+ "subjects:create": False,
}
}
@@ -183,8 +183,8 @@ async def test_replace_api_endpoint_permissions_different_studies(db_session: As
result_1 = await get_api_endpoint_permissions(db_session, study_id_1)
result_2 = await get_api_endpoint_permissions(db_session, study_id_2)
- assert result_1["CRA"]["POST:/subjects"]["allowed"] is True
- assert result_2["CRA"]["POST:/subjects"]["allowed"] is False
+ assert result_1["CRA"]["subjects:create"]["allowed"] is True
+ assert result_2["CRA"]["subjects:create"]["allowed"] is False
@pytest.mark.asyncio
@@ -209,13 +209,13 @@ async def test_replace_api_endpoint_permissions_partial_update(db_session: Async
perm1 = ApiEndpointPermission(
study_id=study_id,
role="CRA",
- endpoint_key="POST:/subjects",
+ endpoint_key="subjects:create",
allowed=True,
)
perm2 = ApiEndpointPermission(
study_id=study_id,
role="CRA",
- endpoint_key="GET:/subjects",
+ endpoint_key="subjects:list",
allowed=True,
)
db_session.add(perm1)
@@ -225,16 +225,16 @@ async def test_replace_api_endpoint_permissions_partial_update(db_session: Async
# 只更新一个权限
payload = {
"CRA": {
- "POST:/subjects": False,
+ "subjects:create": False,
}
}
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
# POST权限应该被更新
- assert result["CRA"]["POST:/subjects"]["allowed"] is False
+ assert result["CRA"]["subjects:create"]["allowed"] is False
# GET权限应该保持不变
- assert result["CRA"]["GET:/subjects"]["allowed"] is True
+ assert result["CRA"]["subjects:list"]["allowed"] is True
@pytest.mark.asyncio
@@ -246,7 +246,7 @@ async def test_get_api_endpoint_permissions_structure(db_session: AsyncSession):
perm = ApiEndpointPermission(
study_id=study_id,
role="CRA",
- endpoint_key="POST:/subjects",
+ endpoint_key="subjects:create",
allowed=True,
)
db_session.add(perm)
diff --git a/backend/tests/test_permission_monitoring.py b/backend/tests/test_permission_monitoring.py
index 1643df67..3bc01726 100644
--- a/backend/tests/test_permission_monitoring.py
+++ b/backend/tests/test_permission_monitoring.py
@@ -17,6 +17,7 @@ from app.core.permission_monitor import (
CacheMetrics,
get_permission_monitor,
set_permission_monitor,
+ evaluate_permission_system_health,
)
@@ -312,3 +313,28 @@ async def test_cache_metrics_dataclass():
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"]
diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue
index 2effd991..8aa27cc5 100644
--- a/frontend/src/components/Layout.vue
+++ b/frontend/src/components/Layout.vue
@@ -33,10 +33,15 @@
{{ TEXT.menu.auditLogs }}
-
-
- {{ TEXT.menu.permissionManagement }}
-
+
+
+
+ {{ TEXT.menu.permissionManagement }}
+
+ 系统级权限
+ 项目权限配置
+ 权限监控
+