完善权限缓存与监控指标
This commit is contained in:
@@ -8,7 +8,7 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, status
|
||||||
|
|
||||||
from app.core.deps import get_current_user
|
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"])
|
router = APIRouter(prefix="/permission-monitoring", tags=["permission-monitoring"])
|
||||||
|
|
||||||
@@ -98,37 +98,4 @@ async def permission_system_health(
|
|||||||
metrics = monitor.get_metrics()
|
metrics = monitor.get_metrics()
|
||||||
cache_stats = monitor.get_cache_stats()
|
cache_stats = monitor.get_cache_stats()
|
||||||
|
|
||||||
# 计算健康分数
|
return evaluate_permission_system_health(metrics, 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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -52,6 +52,35 @@ class PermissionCache:
|
|||||||
self._member_role_cache[cache_key] = (role, time.time())
|
self._member_role_cache[cache_key] = (role, time.time())
|
||||||
return role
|
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:
|
def invalidate_project_permissions(self, study_id: uuid.UUID) -> None:
|
||||||
cache_key = self._make_project_cache_key(study_id)
|
cache_key = self._make_project_cache_key(study_id)
|
||||||
self._project_permissions_cache.pop(cache_key, None)
|
self._project_permissions_cache.pop(cache_key, None)
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ from typing import Any
|
|||||||
from app.core.permission_cache import get_permission_cache
|
from app.core.permission_cache import get_permission_cache
|
||||||
|
|
||||||
|
|
||||||
|
CACHE_HIT_RATE_HEALTH_MIN_ACCESSES = 10
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PermissionCheckMetrics:
|
class PermissionCheckMetrics:
|
||||||
"""权限检查指标"""
|
"""权限检查指标"""
|
||||||
@@ -293,3 +296,39 @@ def set_permission_monitor(monitor: PermissionMonitor) -> None:
|
|||||||
"""设置全局权限监控器实例(用于测试)"""
|
"""设置全局权限监控器实例(用于测试)"""
|
||||||
global _permission_monitor
|
global _permission_monitor
|
||||||
_permission_monitor = 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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,35 @@ from app.core.api_permissions import API_ENDPOINT_PERMISSIONS, OPERATION_PREREQU
|
|||||||
from app.core.permission_cache import get_permission_cache
|
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(
|
async def role_has_api_permission(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
study_id: uuid.UUID,
|
study_id: uuid.UUID,
|
||||||
@@ -21,18 +50,12 @@ async def role_has_api_permission(
|
|||||||
if role == "ADMIN":
|
if role == "ADMIN":
|
||||||
return True
|
return True
|
||||||
|
|
||||||
result = await db.execute(
|
permissions = await _get_project_permission_overrides(db, study_id)
|
||||||
select(ApiEndpointPermission).where(
|
allowed = permissions.get(role or "", {}).get(endpoint_key)
|
||||||
ApiEndpointPermission.study_id == study_id,
|
if allowed is None:
|
||||||
ApiEndpointPermission.role == role,
|
|
||||||
ApiEndpointPermission.endpoint_key == endpoint_key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
perm = result.scalar_one_or_none()
|
|
||||||
if perm is None:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if not perm.allowed:
|
if not allowed:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if check_prerequisites:
|
if check_prerequisites:
|
||||||
@@ -77,12 +100,7 @@ async def get_api_endpoint_permissions(
|
|||||||
|
|
||||||
返回格式: {role: {endpoint_key: {allowed: bool}}}
|
返回格式: {role: {endpoint_key: {allowed: bool}}}
|
||||||
"""
|
"""
|
||||||
result = await db.execute(
|
overrides = await _get_project_permission_overrides(db, study_id)
|
||||||
select(ApiEndpointPermission).where(
|
|
||||||
ApiEndpointPermission.study_id == study_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rows = result.scalars().all()
|
|
||||||
|
|
||||||
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
|
roles = ["PM", "CRA", "PV", "MEDICAL_REVIEW", "IMP", "QA"]
|
||||||
matrix: dict[str, dict[str, dict[str, bool]]] = {}
|
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", [])
|
default_allowed = role in config.get("default_roles", [])
|
||||||
matrix[role][endpoint_key] = {"allowed": default_allowed}
|
matrix[role][endpoint_key] = {"allowed": default_allowed}
|
||||||
|
|
||||||
for row in rows:
|
for role, endpoints in overrides.items():
|
||||||
if row.role not in matrix:
|
if role not in matrix:
|
||||||
matrix[row.role] = {}
|
matrix[role] = {}
|
||||||
matrix[row.role][row.endpoint_key] = {"allowed": row.allowed}
|
for endpoint_key, allowed in endpoints.items():
|
||||||
|
matrix[role][endpoint_key] = {"allowed": allowed}
|
||||||
|
|
||||||
return matrix
|
return matrix
|
||||||
|
|
||||||
@@ -132,5 +151,8 @@ async def replace_api_endpoint_permissions(
|
|||||||
cache = get_permission_cache()
|
cache = get_permission_cache()
|
||||||
cache.invalidate_project_permissions(study_id)
|
cache.invalidate_project_permissions(study_id)
|
||||||
cache.invalidate_all_member_roles(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)
|
return await get_api_endpoint_permissions(db, study_id)
|
||||||
|
|||||||
@@ -111,8 +111,8 @@ async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
|||||||
study_code = f"TEST-STUDY-{uuid.uuid4().hex[:8]}"
|
study_code = f"TEST-STUDY-{uuid.uuid4().hex[:8]}"
|
||||||
await session.execute(
|
await session.execute(
|
||||||
text("""
|
text("""
|
||||||
INSERT INTO studies (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)
|
VALUES (:id, :code, :name, :status, :is_locked, :visit_schedule, :active_roles)
|
||||||
"""),
|
"""),
|
||||||
{
|
{
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
@@ -121,6 +121,7 @@ async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
|
|||||||
"status": "ACTIVE",
|
"status": "ACTIVE",
|
||||||
"is_locked": False,
|
"is_locked": False,
|
||||||
"visit_schedule": "[]",
|
"visit_schedule": "[]",
|
||||||
|
"active_roles": "[]",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import uuid
|
import uuid
|
||||||
|
from sqlalchemy import delete
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.project_permissions import role_has_api_permission
|
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
|
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
|
db_session, study_id, "CRA", "unknown:endpoint", check_prerequisites=False
|
||||||
)
|
)
|
||||||
assert result is 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
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async def test_get_api_endpoint_permissions_with_custom(db_session: AsyncSession
|
|||||||
perm = ApiEndpointPermission(
|
perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
role="CRA",
|
role="CRA",
|
||||||
endpoint_key="POST:/subjects",
|
endpoint_key="subjects:create",
|
||||||
allowed=True,
|
allowed=True,
|
||||||
)
|
)
|
||||||
db_session.add(perm)
|
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 isinstance(result, dict)
|
||||||
assert "CRA" in result
|
assert "CRA" in result
|
||||||
assert "POST:/subjects" in result["CRA"]
|
assert "subjects:create" in result["CRA"]
|
||||||
assert result["CRA"]["POST:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:create"]["allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -51,9 +51,9 @@ async def test_replace_api_endpoint_permissions_single_role(db_session: AsyncSes
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"CRA": {
|
"CRA": {
|
||||||
"POST:/subjects": True,
|
"subjects:create": True,
|
||||||
"GET:/subjects": True,
|
"subjects:list": True,
|
||||||
"PATCH:/subjects/{id}": 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 isinstance(result, dict)
|
||||||
assert "CRA" in result
|
assert "CRA" in result
|
||||||
assert result["CRA"]["POST:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:create"]["allowed"] is True
|
||||||
assert result["CRA"]["GET:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:list"]["allowed"] is True
|
||||||
assert result["CRA"]["PATCH:/subjects/{id}"]["allowed"] is True
|
assert result["CRA"]["subjects:update"]["allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -73,21 +73,21 @@ async def test_replace_api_endpoint_permissions_multiple_roles(db_session: Async
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"CRA": {
|
"CRA": {
|
||||||
"POST:/subjects": True,
|
"subjects:create": True,
|
||||||
"GET:/subjects": True,
|
"subjects:list": True,
|
||||||
},
|
},
|
||||||
"PV": {
|
"PV": {
|
||||||
"GET:/subjects": True,
|
"subjects:list": True,
|
||||||
"GET:/subjects/{id}": True,
|
"subjects:read": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
||||||
|
|
||||||
assert result["CRA"]["POST:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:create"]["allowed"] is True
|
||||||
assert result["CRA"]["GET:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:list"]["allowed"] is True
|
||||||
assert result["PV"]["GET:/subjects"]["allowed"] is True
|
assert result["PV"]["subjects:list"]["allowed"] is True
|
||||||
assert result["PV"]["GET:/subjects/{id}"]["allowed"] is True
|
assert result["PV"]["subjects:read"]["allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -97,13 +97,13 @@ async def test_replace_api_endpoint_permissions_deny(db_session: AsyncSession):
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"PV": {
|
"PV": {
|
||||||
"POST:/subjects": False,
|
"subjects:create": False,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -115,7 +115,7 @@ async def test_replace_api_endpoint_permissions_overwrites_existing(db_session:
|
|||||||
perm = ApiEndpointPermission(
|
perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
role="CRA",
|
role="CRA",
|
||||||
endpoint_key="POST:/subjects",
|
endpoint_key="subjects:create",
|
||||||
allowed=True,
|
allowed=True,
|
||||||
)
|
)
|
||||||
db_session.add(perm)
|
db_session.add(perm)
|
||||||
@@ -124,13 +124,13 @@ async def test_replace_api_endpoint_permissions_overwrites_existing(db_session:
|
|||||||
# 替换权限
|
# 替换权限
|
||||||
payload = {
|
payload = {
|
||||||
"CRA": {
|
"CRA": {
|
||||||
"POST:/subjects": False,
|
"subjects:create": False,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -140,23 +140,23 @@ async def test_replace_api_endpoint_permissions_multiple_endpoints(db_session: A
|
|||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"CRA": {
|
"CRA": {
|
||||||
"POST:/subjects": True,
|
"subjects:create": True,
|
||||||
"GET:/subjects": True,
|
"subjects:list": True,
|
||||||
"PATCH:/subjects/{id}": True,
|
"subjects:update": True,
|
||||||
"DELETE:/subjects/{id}": False,
|
"subjects:delete": False,
|
||||||
"POST:/risk-issues": True,
|
"risk_issues:create": True,
|
||||||
"GET:/risk-issues": True,
|
"risk_issues:list": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
||||||
|
|
||||||
assert result["CRA"]["POST:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:create"]["allowed"] is True
|
||||||
assert result["CRA"]["GET:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:list"]["allowed"] is True
|
||||||
assert result["CRA"]["PATCH:/subjects/{id}"]["allowed"] is True
|
assert result["CRA"]["subjects:update"]["allowed"] is True
|
||||||
assert result["CRA"]["DELETE:/subjects/{id}"]["allowed"] is False
|
assert result["CRA"]["subjects:delete"]["allowed"] is False
|
||||||
assert result["CRA"]["POST:/risk-issues"]["allowed"] is True
|
assert result["CRA"]["risk_issues:create"]["allowed"] is True
|
||||||
assert result["CRA"]["GET:/risk-issues"]["allowed"] is True
|
assert result["CRA"]["risk_issues:list"]["allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -167,13 +167,13 @@ async def test_replace_api_endpoint_permissions_different_studies(db_session: As
|
|||||||
|
|
||||||
payload_1 = {
|
payload_1 = {
|
||||||
"CRA": {
|
"CRA": {
|
||||||
"POST:/subjects": True,
|
"subjects:create": True,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
payload_2 = {
|
payload_2 = {
|
||||||
"CRA": {
|
"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_1 = await get_api_endpoint_permissions(db_session, study_id_1)
|
||||||
result_2 = await get_api_endpoint_permissions(db_session, study_id_2)
|
result_2 = await get_api_endpoint_permissions(db_session, study_id_2)
|
||||||
|
|
||||||
assert result_1["CRA"]["POST:/subjects"]["allowed"] is True
|
assert result_1["CRA"]["subjects:create"]["allowed"] is True
|
||||||
assert result_2["CRA"]["POST:/subjects"]["allowed"] is False
|
assert result_2["CRA"]["subjects:create"]["allowed"] is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -209,13 +209,13 @@ async def test_replace_api_endpoint_permissions_partial_update(db_session: Async
|
|||||||
perm1 = ApiEndpointPermission(
|
perm1 = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
role="CRA",
|
role="CRA",
|
||||||
endpoint_key="POST:/subjects",
|
endpoint_key="subjects:create",
|
||||||
allowed=True,
|
allowed=True,
|
||||||
)
|
)
|
||||||
perm2 = ApiEndpointPermission(
|
perm2 = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
role="CRA",
|
role="CRA",
|
||||||
endpoint_key="GET:/subjects",
|
endpoint_key="subjects:list",
|
||||||
allowed=True,
|
allowed=True,
|
||||||
)
|
)
|
||||||
db_session.add(perm1)
|
db_session.add(perm1)
|
||||||
@@ -225,16 +225,16 @@ async def test_replace_api_endpoint_permissions_partial_update(db_session: Async
|
|||||||
# 只更新一个权限
|
# 只更新一个权限
|
||||||
payload = {
|
payload = {
|
||||||
"CRA": {
|
"CRA": {
|
||||||
"POST:/subjects": False,
|
"subjects:create": False,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
result = await replace_api_endpoint_permissions(db_session, study_id, payload)
|
||||||
|
|
||||||
# POST权限应该被更新
|
# POST权限应该被更新
|
||||||
assert result["CRA"]["POST:/subjects"]["allowed"] is False
|
assert result["CRA"]["subjects:create"]["allowed"] is False
|
||||||
# GET权限应该保持不变
|
# GET权限应该保持不变
|
||||||
assert result["CRA"]["GET:/subjects"]["allowed"] is True
|
assert result["CRA"]["subjects:list"]["allowed"] is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -246,7 +246,7 @@ async def test_get_api_endpoint_permissions_structure(db_session: AsyncSession):
|
|||||||
perm = ApiEndpointPermission(
|
perm = ApiEndpointPermission(
|
||||||
study_id=study_id,
|
study_id=study_id,
|
||||||
role="CRA",
|
role="CRA",
|
||||||
endpoint_key="POST:/subjects",
|
endpoint_key="subjects:create",
|
||||||
allowed=True,
|
allowed=True,
|
||||||
)
|
)
|
||||||
db_session.add(perm)
|
db_session.add(perm)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from app.core.permission_monitor import (
|
|||||||
CacheMetrics,
|
CacheMetrics,
|
||||||
get_permission_monitor,
|
get_permission_monitor,
|
||||||
set_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.hit_rate == pytest.approx(80.0, 0.1)
|
||||||
assert metrics.miss_rate == pytest.approx(20.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"]
|
||||||
|
|||||||
@@ -33,10 +33,15 @@
|
|||||||
<el-icon><Document /></el-icon>
|
<el-icon><Document /></el-icon>
|
||||||
<span>{{ TEXT.menu.auditLogs }}</span>
|
<span>{{ TEXT.menu.auditLogs }}</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item v-if="isAdmin" index="/admin/permissions">
|
<el-sub-menu v-if="isAdmin" index="permissions">
|
||||||
<el-icon><Key /></el-icon>
|
<template #title>
|
||||||
<span>{{ TEXT.menu.permissionManagement }}</span>
|
<el-icon><Key /></el-icon>
|
||||||
</el-menu-item>
|
<span>{{ TEXT.menu.permissionManagement }}</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/admin/permissions/system">系统级权限</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/permissions/project">项目权限配置</el-menu-item>
|
||||||
|
<el-menu-item index="/admin/permissions/monitoring">权限监控</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
</el-menu-item-group>
|
</el-menu-item-group>
|
||||||
|
|
||||||
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group">
|
<el-menu-item-group v-if="study.currentStudy && hasAnyProjectModuleAccess" class="menu-group">
|
||||||
@@ -280,6 +285,7 @@ const activeMenu = computed(() => {
|
|||||||
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
if (path.startsWith("/knowledge/instruction-files")) return "/knowledge/instruction-files";
|
||||||
if (path.startsWith("/projects/")) return "/admin/projects";
|
if (path.startsWith("/projects/")) return "/admin/projects";
|
||||||
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
if (path.startsWith("/admin/projects/")) return "/admin/projects";
|
||||||
|
if (path.startsWith("/admin/permissions/")) return path;
|
||||||
return path;
|
return path;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1154,7 +1154,7 @@ export const TEXT = {
|
|||||||
permissionManagement: {
|
permissionManagement: {
|
||||||
title: "权限管理",
|
title: "权限管理",
|
||||||
moduleLevel: "模块级权限",
|
moduleLevel: "模块级权限",
|
||||||
apiLevel: "接口级权限",
|
apiLevel: "角色权限",
|
||||||
monitoring: "权限监控",
|
monitoring: "权限监控",
|
||||||
refreshMetrics: "刷新指标",
|
refreshMetrics: "刷新指标",
|
||||||
save: "保存",
|
save: "保存",
|
||||||
|
|||||||
@@ -436,11 +436,11 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "projects/:projectId/members",
|
path: "projects/:projectId/members",
|
||||||
redirect: (to) => ({ path: "/admin/permissions", query: { projectId: to.params.projectId as string, sub: "members" } }),
|
redirect: (to) => ({ path: "/admin/permissions/project", query: { projectId: to.params.projectId as string, sub: "members" } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "projects/:id/api-permissions",
|
path: "projects/:id/api-permissions",
|
||||||
redirect: (to) => ({ path: "/admin/permissions", query: { projectId: to.params.id as string } }),
|
redirect: (to) => ({ path: "/admin/permissions/project", query: { projectId: to.params.id as string } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "projects/:projectId/sites",
|
path: "projects/:projectId/sites",
|
||||||
@@ -456,9 +456,25 @@ const routes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "permissions",
|
path: "permissions",
|
||||||
name: "AdminPermissions",
|
redirect: "/admin/permissions/system",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "permissions/system",
|
||||||
|
name: "AdminPermissionsSystem",
|
||||||
component: PermissionManagement,
|
component: PermissionManagement,
|
||||||
meta: { title: TEXT.menu.permissionManagement, requiresAdmin: true },
|
meta: { title: "系统级权限", requiresAdmin: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "permissions/project",
|
||||||
|
name: "AdminPermissionsProject",
|
||||||
|
component: PermissionManagement,
|
||||||
|
meta: { title: "项目权限配置", requiresAdmin: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "permissions/monitoring",
|
||||||
|
name: "AdminPermissionsMonitoring",
|
||||||
|
component: PermissionManagement,
|
||||||
|
meta: { title: "权限监控", requiresAdmin: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<!-- 标签页 -->
|
<!-- 标签页 -->
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<!-- 接口级权限 -->
|
<!-- 接口级权限 -->
|
||||||
<el-tab-pane label="接口级权限" name="api">
|
<el-tab-pane label="角色权限" name="api">
|
||||||
<PermissionTemplateSelector
|
<PermissionTemplateSelector
|
||||||
:study-id="studyId"
|
:study-id="studyId"
|
||||||
:current-permissions="currentPermissionsForTemplate"
|
:current-permissions="currentPermissionsForTemplate"
|
||||||
|
|||||||
@@ -1,202 +1,197 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="permission-mgmt-page">
|
<div class="perm-page" v-loading="pageLoading">
|
||||||
<div class="permission-mgmt-shell unified-shell" v-loading="pageLoading">
|
|
||||||
<el-tabs v-model="activeTab" class="mgmt-tabs">
|
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════
|
<!-- ══════════════════════════════════════
|
||||||
标签页一:项目权限配置
|
项目权限配置
|
||||||
══════════════════════════════════════ -->
|
══════════════════════════════════════ -->
|
||||||
<el-tab-pane label="项目权限配置" name="project">
|
<template v-if="activeTab === 'project'">
|
||||||
<div class="project-tab-layout">
|
<div class="perm-header">
|
||||||
<!-- 项目选择栏 -->
|
<div class="perm-header-left">
|
||||||
<div class="project-selector-bar unified-action-bar">
|
<h2 class="perm-title">项目权限配置</h2>
|
||||||
<div class="filter-form">
|
<span class="perm-subtitle">为每个项目的角色配置接口级权限</span>
|
||||||
<span class="selector-label">选择项目</span>
|
</div>
|
||||||
<el-select
|
<div class="perm-header-actions">
|
||||||
v-model="selectedStudyId"
|
<el-select
|
||||||
placeholder="请选择项目"
|
v-model="selectedStudyId"
|
||||||
filterable
|
placeholder="选择项目"
|
||||||
style="width: 320px"
|
filterable
|
||||||
@change="onStudyChange"
|
style="width: 280px"
|
||||||
>
|
@change="onStudyChange"
|
||||||
<el-option
|
>
|
||||||
v-for="s in studies"
|
<el-option
|
||||||
:key="s.id"
|
v-for="s in studies"
|
||||||
:label="`${s.code} · ${s.name}`"
|
:key="s.id"
|
||||||
:value="s.id"
|
:label="`${s.code} · ${s.name}`"
|
||||||
/>
|
:value="s.id"
|
||||||
</el-select>
|
|
||||||
<div class="filter-spacer" />
|
|
||||||
<template v-if="selectedStudyId && projectSubTab === 'api'">
|
|
||||||
<el-button type="default" @click="templateDrawerVisible = true">
|
|
||||||
<el-icon><Files /></el-icon>
|
|
||||||
模板管理
|
|
||||||
</el-button>
|
|
||||||
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
|
|
||||||
<el-icon><Check /></el-icon>
|
|
||||||
保存
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
<template v-if="selectedStudyId && projectSubTab === 'members'">
|
|
||||||
<el-button type="primary" @click="openAddMember">
|
|
||||||
添加成员
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 未选项目时的空态 -->
|
|
||||||
<div v-if="!selectedStudyId" class="empty-hint">
|
|
||||||
<el-empty description="请先选择一个项目以配置权限" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 已选项目:子标签 -->
|
|
||||||
<template v-else>
|
|
||||||
<el-tabs v-model="projectSubTab" class="project-sub-tabs">
|
|
||||||
|
|
||||||
<!-- 接口级权限 -->
|
|
||||||
<el-tab-pane label="接口级权限" name="api">
|
|
||||||
<PermissionTemplateSelector
|
|
||||||
:study-id="selectedStudyId"
|
|
||||||
:current-permissions="currentPermissionsForTemplate"
|
|
||||||
:active-roles="activeRolesInStudy"
|
|
||||||
@edit-role="openRoleEditor"
|
|
||||||
/>
|
|
||||||
<el-divider />
|
|
||||||
<ApiEndpointPermissions
|
|
||||||
:project="selectedStudy"
|
|
||||||
:matrix="apiMatrix"
|
|
||||||
@update="onApiMatrixUpdate"
|
|
||||||
/>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- 成员管理 -->
|
|
||||||
<el-tab-pane label="成员管理" name="members">
|
|
||||||
<el-table :data="memberRows" v-loading="membersLoading" stripe>
|
|
||||||
<el-table-column prop="full_name" label="姓名" min-width="140" />
|
|
||||||
<el-table-column label="项目角色" min-width="140">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-select
|
|
||||||
v-model="row.role_in_study"
|
|
||||||
size="small"
|
|
||||||
style="width: 130px"
|
|
||||||
@change="(val: string) => updateMemberRole(row.id, val)"
|
|
||||||
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
|
||||||
>
|
|
||||||
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
|
|
||||||
</el-select>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="状态" width="110">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-tooltip v-if="row.effectiveStatus === 'DISABLED_GLOBAL'" content="请前往账号管理启用该账号" placement="top">
|
|
||||||
<el-tag type="danger">全局已禁用</el-tag>
|
|
||||||
</el-tooltip>
|
|
||||||
<el-tag v-else :type="row.is_active ? 'success' : 'danger'">
|
|
||||||
{{ row.is_active ? "启用" : "已禁用" }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="added_at" label="加入时间" min-width="160">
|
|
||||||
<template #default="{ row }">{{ displayDateTime(row.added_at) }}</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="160" align="center">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<el-button
|
|
||||||
link
|
|
||||||
:type="row.is_active ? 'warning' : 'primary'"
|
|
||||||
size="small"
|
|
||||||
:disabled="!canEditMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
|
||||||
@click="toggleMemberActive(row)"
|
|
||||||
>
|
|
||||||
{{ row.is_active ? "停用" : "启用" }}
|
|
||||||
</el-button>
|
|
||||||
<el-button link type="danger" size="small" :disabled="!canEditMember(row)" @click="deleteMember(row)">
|
|
||||||
移除
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
</el-tabs>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════
|
|
||||||
标签页二:系统级权限
|
|
||||||
══════════════════════════════════════ -->
|
|
||||||
<el-tab-pane label="系统级权限" name="system">
|
|
||||||
<div class="system-tab-layout">
|
|
||||||
<el-alert
|
|
||||||
title="系统级权限为只读展示,所有系统管理操作当前仅限 ADMIN 角色执行。"
|
|
||||||
type="info"
|
|
||||||
:closable="false"
|
|
||||||
show-icon
|
|
||||||
style="margin-bottom: 16px"
|
|
||||||
/>
|
/>
|
||||||
<div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
|
</el-select>
|
||||||
<div class="system-module-header">
|
</div>
|
||||||
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
|
</div>
|
||||||
<el-tag size="small" type="info">{{ items.length }} 项操作</el-tag>
|
|
||||||
|
<div class="perm-body">
|
||||||
|
<div v-if="!selectedStudyId" class="perm-empty">
|
||||||
|
<el-empty description="请先选择一个项目以配置权限" />
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<el-tabs v-model="projectSubTab" class="project-sub-tabs">
|
||||||
|
<el-tab-pane label="角色权限" name="api">
|
||||||
|
<div class="sub-tab-toolbar">
|
||||||
|
<div class="sub-tab-spacer" />
|
||||||
|
<el-button @click="templateDrawerVisible = true">
|
||||||
|
<el-icon><Files /></el-icon>
|
||||||
|
模板管理
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">
|
||||||
|
<el-icon><Check /></el-icon>
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-table :data="items" border size="small" class="system-perm-table">
|
<PermissionTemplateSelector
|
||||||
<el-table-column prop="description" label="操作描述" min-width="180" />
|
:study-id="selectedStudyId"
|
||||||
<el-table-column prop="permission_key" label="权限标识" min-width="220">
|
:current-permissions="currentPermissionsForTemplate"
|
||||||
|
:active-roles="activeRolesInStudy"
|
||||||
|
@edit-role="openRoleEditor"
|
||||||
|
/>
|
||||||
|
<el-divider />
|
||||||
|
<ApiEndpointPermissions
|
||||||
|
:project="selectedStudy"
|
||||||
|
:matrix="apiMatrix"
|
||||||
|
@update="onApiMatrixUpdate"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="成员管理" name="members">
|
||||||
|
<div class="sub-tab-toolbar">
|
||||||
|
<div class="sub-tab-spacer" />
|
||||||
|
<el-button type="primary" @click="openAddMember">添加成员</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="memberRows" v-loading="membersLoading" stripe class="full-table">
|
||||||
|
<el-table-column prop="full_name" label="姓名" min-width="140" />
|
||||||
|
<el-table-column label="项目角色" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<code class="perm-key">{{ row.permission_key }}</code>
|
<el-select
|
||||||
|
v-model="row.role_in_study"
|
||||||
|
size="small"
|
||||||
|
style="width: 130px"
|
||||||
|
@change="(val: string) => updateMemberRole(row.id, val)"
|
||||||
|
:disabled="!canEditMember(row) || !row.is_active || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||||
|
>
|
||||||
|
<el-option v-for="(label, val) in activeRoleLabels" :key="val" :label="label" :value="val" />
|
||||||
|
</el-select>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="action" label="操作类型" width="100">
|
<el-table-column label="状态" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="opTagType(row.action)" size="small">
|
<el-tooltip v-if="row.effectiveStatus === 'DISABLED_GLOBAL'" content="请前往账号管理启用该账号" placement="top">
|
||||||
{{ opActionLabel(row.action) }}
|
<el-tag type="danger">全局已禁用</el-tag>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-tag v-else :type="row.is_active ? 'success' : 'danger'">
|
||||||
|
{{ row.is_active ? "启用" : "已禁用" }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="授权角色" width="120">
|
<el-table-column prop="added_at" label="加入时间" min-width="160">
|
||||||
|
<template #default="{ row }">{{ displayDateTime(row.added_at) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag v-for="r in row.roles" :key="r" size="small" type="danger" style="margin-right: 4px">{{ r }}</el-tag>
|
<el-button
|
||||||
|
link
|
||||||
|
:type="row.is_active ? 'warning' : 'primary'"
|
||||||
|
size="small"
|
||||||
|
:disabled="!canEditMember(row) || row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||||
|
@click="toggleMemberActive(row)"
|
||||||
|
>
|
||||||
|
{{ row.is_active ? "停用" : "启用" }}
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" size="small" :disabled="!canEditMember(row)" @click="deleteMember(row)">
|
||||||
|
移除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</el-tab-pane>
|
||||||
<div v-if="systemLoading" class="empty-hint">
|
</el-tabs>
|
||||||
<el-skeleton :rows="6" animated />
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!systemPermissions.length" class="empty-hint">
|
</template>
|
||||||
<el-empty description="暂无系统级权限数据" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════
|
<!-- ══════════════════════════════════════
|
||||||
标签页三:权限监控
|
系统级权限
|
||||||
══════════════════════════════════════ -->
|
══════════════════════════════════════ -->
|
||||||
<el-tab-pane label="权限监控" name="monitoring">
|
<template v-else-if="activeTab === 'system'">
|
||||||
<div class="monitoring-tab-layout">
|
<div class="perm-header">
|
||||||
<div class="unified-action-bar" style="margin-bottom: 12px">
|
<div class="perm-header-left">
|
||||||
<div class="filter-form">
|
<h2 class="perm-title">系统级权限</h2>
|
||||||
<div class="filter-spacer" />
|
<span class="perm-subtitle">只读展示,所有系统管理操作仅限 ADMIN 角色执行</span>
|
||||||
<el-button @click="loadMonitoringData" :loading="monitoringLoading">
|
</div>
|
||||||
<el-icon><RefreshRight /></el-icon>
|
</div>
|
||||||
刷新
|
<div class="perm-body">
|
||||||
</el-button>
|
<div v-if="systemLoading" class="perm-empty">
|
||||||
<el-button @click="doResetMetrics" :loading="resetting">重置指标</el-button>
|
<el-skeleton :rows="8" animated />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="!systemPermissions.length" class="perm-empty">
|
||||||
|
<el-empty description="暂无系统级权限数据" />
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div v-for="(items, moduleKey) in systemPermissionsByModule" :key="moduleKey" class="system-module-block">
|
||||||
|
<div class="system-module-header">
|
||||||
|
<span class="system-module-title">{{ systemModuleLabels[moduleKey] || moduleKey }}</span>
|
||||||
|
<el-tag size="small" type="info" round>{{ items.length }} 项操作</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<PermissionMonitoring
|
<el-table :data="items" border size="small" class="system-perm-table">
|
||||||
:metrics="metrics"
|
<el-table-column prop="description" label="操作描述" min-width="180" />
|
||||||
:health="health"
|
<el-table-column prop="permission_key" label="权限标识" min-width="220">
|
||||||
:alerts="alerts"
|
<template #default="{ row }">
|
||||||
@refresh="loadMonitoringData"
|
<code class="perm-key">{{ row.permission_key }}</code>
|
||||||
/>
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="action" label="操作类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="opTagType({ operation_key: row.permission_key, action: row.action })" size="small">
|
||||||
|
{{ opActionLabel({ operation_key: row.permission_key, action: row.action }) }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="授权角色" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-for="r in row.roles" :key="r" size="small" type="danger" style="margin-right: 4px">{{ r }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ══════════════════════════════════════
|
||||||
|
权限监控
|
||||||
|
══════════════════════════════════════ -->
|
||||||
|
<template v-else-if="activeTab === 'monitoring'">
|
||||||
|
<div class="perm-header">
|
||||||
|
<div class="perm-header-left">
|
||||||
|
<h2 class="perm-title">权限监控</h2>
|
||||||
|
<span class="perm-subtitle">实时监控权限使用情况与异常告警</span>
|
||||||
|
</div>
|
||||||
|
<div class="perm-header-actions">
|
||||||
|
<el-button @click="loadMonitoringData" :loading="monitoringLoading">
|
||||||
|
<el-icon><RefreshRight /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="doResetMetrics" :loading="resetting">重置指标</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="perm-body">
|
||||||
|
<div class="perm-body-padded">
|
||||||
|
<PermissionMonitoring
|
||||||
|
:metrics="metrics"
|
||||||
|
:health="health"
|
||||||
|
:alerts="alerts"
|
||||||
|
@refresh="loadMonitoringData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
</el-tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════
|
<!-- ══════════════════════════════════════
|
||||||
模板管理抽屉
|
模板管理抽屉
|
||||||
@@ -316,7 +311,7 @@
|
|||||||
:model-value="roleEditorDraft[op.operation_key] ?? false"
|
:model-value="roleEditorDraft[op.operation_key] ?? false"
|
||||||
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
|
@change="(v: boolean) => roleEditorDraft[op.operation_key] = v"
|
||||||
/>
|
/>
|
||||||
<el-tag :type="opTagType(op.action)" size="small" class="op-action-tag">{{ opActionLabel(op.action) }}</el-tag>
|
<el-tag :type="opTagType(op)" size="small" class="op-action-tag">{{ opActionLabel(op) }}</el-tag>
|
||||||
<span class="op-desc">{{ op.description || op.operation_key }}</span>
|
<span class="op-desc">{{ op.description || op.operation_key }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -460,7 +455,19 @@ const ROLE_RANK: Record<string, number> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── 顶层标签 ──
|
// ── 顶层标签 ──
|
||||||
const activeTab = ref<"project" | "system" | "monitoring">("project");
|
const tabFromPath = (): "project" | "system" | "monitoring" => {
|
||||||
|
const p = route.path;
|
||||||
|
if (p.endsWith("/system")) return "system";
|
||||||
|
if (p.endsWith("/monitoring")) return "monitoring";
|
||||||
|
return "project";
|
||||||
|
};
|
||||||
|
const activeTab = computed({
|
||||||
|
get: () => tabFromPath(),
|
||||||
|
set: (tab: "project" | "system" | "monitoring") => {
|
||||||
|
const pathMap = { system: "/admin/permissions/system", project: "/admin/permissions/project", monitoring: "/admin/permissions/monitoring" };
|
||||||
|
router.push({ path: pathMap[tab], query: route.query });
|
||||||
|
},
|
||||||
|
});
|
||||||
const pageLoading = ref(false);
|
const pageLoading = ref(false);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -928,11 +935,25 @@ const ROLE_EDITOR_MODULE_LABELS: Record<string, string> = {
|
|||||||
|
|
||||||
const roleEditorModuleLabel = (mod: string) => ROLE_EDITOR_MODULE_LABELS[mod] || mod;
|
const roleEditorModuleLabel = (mod: string) => ROLE_EDITOR_MODULE_LABELS[mod] || mod;
|
||||||
|
|
||||||
const opActionLabel = (action: string) =>
|
const getOpVerb = (op: { operation_key: string; action: string }): string => {
|
||||||
({ create: "创建", read: "读取", update: "更新", delete: "删除", export: "导出", write: "写入" }[action] ?? action);
|
const suffix = op.operation_key.split(":").pop() || "";
|
||||||
|
if (suffix === "create") return "create";
|
||||||
|
if (suffix === "read" || suffix === "list") return "read";
|
||||||
|
if (suffix === "update") return "update";
|
||||||
|
if (suffix === "delete") return "delete";
|
||||||
|
if (suffix === "export") return "export";
|
||||||
|
return op.action;
|
||||||
|
};
|
||||||
|
|
||||||
const opTagType = (action: string) =>
|
const opActionLabel = (op: { operation_key: string; action: string }) => {
|
||||||
({ create: "success", read: "info", update: "warning", delete: "danger", export: "", write: "success" } as Record<string, string>)[action] ?? "info";
|
const verb = getOpVerb(op);
|
||||||
|
return ({ create: "创建", read: "读取", update: "更新", delete: "删除", export: "导出", write: "写入" }[verb] ?? verb);
|
||||||
|
};
|
||||||
|
|
||||||
|
const opTagType = (op: { operation_key: string; action: string }) => {
|
||||||
|
const verb = getOpVerb(op);
|
||||||
|
return (({ create: "success", read: "info", update: "warning", delete: "danger", export: "", write: "success" } as Record<string, string>)[verb] ?? "info");
|
||||||
|
};
|
||||||
|
|
||||||
const roleEditorGrouped = computed(() => {
|
const roleEditorGrouped = computed(() => {
|
||||||
const search = roleEditorSearch.value.toLowerCase();
|
const search = roleEditorSearch.value.toLowerCase();
|
||||||
@@ -1017,9 +1038,9 @@ const saveRoleEditor = async () => {
|
|||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// 懒加载 & 初始化
|
// 懒加载 & 初始化
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
watch(activeTab, (tab) => {
|
watch(() => route.path, (path) => {
|
||||||
if (tab === "system" && !systemPermissions.value.length) loadSystemPermissions();
|
if (path.endsWith("/system") && !systemPermissions.value.length) loadSystemPermissions();
|
||||||
if (tab === "monitoring" && !metrics.value) loadMonitoringData();
|
if (path.endsWith("/monitoring") && !metrics.value) loadMonitoringData();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -1034,71 +1055,137 @@ onMounted(async () => {
|
|||||||
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
|
await Promise.all([loadPermissionData(), loadMembers(), loadActiveRoles()]);
|
||||||
loadCandidates();
|
loadCandidates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tab = tabFromPath();
|
||||||
|
if (tab === "system" && !systemPermissions.value.length) loadSystemPermissions();
|
||||||
|
if (tab === "monitoring" && !metrics.value) loadMonitoringData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.permission-mgmt-page { padding: 20px; }
|
/* ── 页面容器:铺满,无 padding ── */
|
||||||
|
.perm-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin: -6px -8px;
|
||||||
|
min-height: calc(100vh - 52px);
|
||||||
|
min-height: calc(100dvh - 52px);
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
.permission-mgmt-shell {
|
/* ── 顶部标题栏 ── */
|
||||||
|
.perm-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 28px 16px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 4px;
|
border-bottom: 1px solid #e8edf3;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
flex-shrink: 0;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mgmt-tabs { padding: 0 20px 20px; }
|
.perm-header-left {
|
||||||
|
display: flex;
|
||||||
.project-tab-layout,
|
flex-direction: column;
|
||||||
.system-tab-layout,
|
gap: 3px;
|
||||||
.monitoring-tab-layout { padding-top: 4px; }
|
|
||||||
|
|
||||||
.project-selector-bar { margin-bottom: 16px; }
|
|
||||||
|
|
||||||
.selector-label {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #606266;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-sub-tabs { margin-top: 4px; }
|
.perm-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1f2e;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-hint { padding: 60px 0; }
|
.perm-subtitle {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8c96a8;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-form {
|
.perm-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-spacer { flex: 1; }
|
/* ── 内容区:可滚动 ── */
|
||||||
|
.perm-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.system-module-block { margin-bottom: 24px; }
|
.perm-empty {
|
||||||
|
padding: 80px 28px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.perm-body-padded {
|
||||||
|
padding: 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 项目权限:子 tabs ── */
|
||||||
|
.project-sub-tabs {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0 28px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.project-sub-tabs .el-tabs__header) {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-tab-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.full-table { width: 100%; }
|
||||||
|
|
||||||
|
/* ── 系统级权限:模块块 ── */
|
||||||
|
.system-module-block {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 20px 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #eef0f4;
|
||||||
|
}
|
||||||
|
|
||||||
.system-module-header {
|
.system-module-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-module-title {
|
.system-module-title {
|
||||||
font-size: 14px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #303133;
|
color: #1a1f2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.system-perm-table { width: 100%; }
|
.system-perm-table { width: 100%; }
|
||||||
|
|
||||||
.perm-key {
|
.perm-key {
|
||||||
font-family: monospace;
|
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
background: #f5f7fa;
|
background: #f0f4f8;
|
||||||
padding: 2px 6px;
|
padding: 2px 8px;
|
||||||
border-radius: 3px;
|
border-radius: 4px;
|
||||||
color: #606266;
|
color: #4a5568;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 抽屉内部 ── */
|
||||||
|
.filter-spacer { flex: 1; }
|
||||||
|
|
||||||
.drawer-toolbar {
|
.drawer-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1117,6 +1204,7 @@ onMounted(async () => {
|
|||||||
border-top: 1px solid #edf2f8;
|
border-top: 1px solid #edf2f8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 生效角色列表 ── */
|
||||||
.active-roles-list {
|
.active-roles-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1157,6 +1245,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.template-name { font-weight: 500; }
|
.template-name { font-weight: 500; }
|
||||||
|
|
||||||
|
/* ── 角色编辑器 ── */
|
||||||
.role-editor-layout {
|
.role-editor-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: calc(100vh - 200px);
|
height: calc(100vh - 200px);
|
||||||
@@ -1230,9 +1319,7 @@ onMounted(async () => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.role-editor-module {
|
.role-editor-module { margin-bottom: 20px; }
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.role-editor-module-header {
|
.role-editor-module-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const canProject = (row: Study, module: string, action: "read" | "write") => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goMembers = (row: Study) => {
|
const goMembers = (row: Study) => {
|
||||||
router.push(`/admin/permissions?projectId=${row.id}&sub=members`);
|
router.push(`/admin/permissions/project?projectId=${row.id}&sub=members`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goSites = (row: Study) => {
|
const goSites = (row: Study) => {
|
||||||
@@ -149,7 +149,7 @@ const goSites = (row: Study) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goPermissions = (row: Study) => {
|
const goPermissions = (row: Study) => {
|
||||||
router.push(`/admin/permissions?projectId=${row.id}`);
|
router.push(`/admin/permissions/project?projectId=${row.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const goAuditLogs = async (row: Study) => {
|
const goAuditLogs = async (row: Study) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user