747dd55225
- 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>
480 lines
17 KiB
Python
480 lines
17 KiB
Python
"""监控API测试:权限系统监控API端点验证。"""
|
|
|
|
import uuid
|
|
|
|
import pytest
|
|
from sqlalchemy import text
|
|
|
|
from app.core.permission_monitor import set_permission_monitor, PermissionMonitor
|
|
from app.api.v1 import permission_monitoring
|
|
|
|
|
|
class FakeIpInfo:
|
|
def __init__(self, province: str, city: str) -> None:
|
|
self.country = "中国"
|
|
self.province = province
|
|
self.city = city
|
|
self.isp = "电信"
|
|
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(db_session):
|
|
"""测试获取权限系统指标"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
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)
|
|
|
|
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(db_session):
|
|
"""测试获取缓存统计"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
monitor.record_cache_hit()
|
|
monitor.record_cache_hit()
|
|
monitor.record_cache_miss()
|
|
|
|
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(db_session):
|
|
"""测试获取告警列表"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
monitor.record_slow_check_alert(100)
|
|
|
|
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(db_session):
|
|
"""测试按级别过滤告警"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
monitor.record_slow_check_alert(100)
|
|
monitor.record_error_alert(ValueError("test error"))
|
|
|
|
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(db_session):
|
|
"""测试限制告警数量"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
for _ in range(20):
|
|
monitor.record_slow_check_alert(100)
|
|
|
|
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(db_session):
|
|
"""测试重置指标"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
monitor.record_cache_hit()
|
|
assert monitor.metrics.cache_metrics.total_accesses == 1
|
|
|
|
result = await permission_monitoring.reset_metrics(_=object(), db=db_session)
|
|
|
|
assert result["message"] == "指标已重置"
|
|
assert monitor.metrics.cache_metrics.total_accesses == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_alerts(db_session):
|
|
"""测试清除告警"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
monitor.record_slow_check_alert(100)
|
|
assert len(monitor.get_alerts()) > 0
|
|
|
|
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(db_session):
|
|
"""测试权限系统健康检查(健康状态)"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
for _ in range(100):
|
|
monitor.record_cache_hit()
|
|
|
|
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(db_session):
|
|
"""测试权限系统健康检查(降级状态)"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
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()
|
|
|
|
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(db_session):
|
|
"""测试健康检查包含详细指标"""
|
|
monitor = PermissionMonitor()
|
|
set_permission_monitor(monitor)
|
|
|
|
data = await permission_monitoring.permission_system_health(db=db_session, _=object())
|
|
assert "last_hour" in data
|
|
assert "cache_stats" in data
|
|
assert "total_checks" in data["last_hour"]
|
|
assert "cache_metrics" in data["cache_stats"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
|
|
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
|
|
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
|
await db_session.commit()
|
|
|
|
study_id = "00000000-0000-0000-0000-000000000001"
|
|
user_a = "00000000-0000-0000-0000-000000000101"
|
|
user_b = "00000000-0000-0000-0000-000000000102"
|
|
|
|
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": study_id,
|
|
"code": "IP-LOCATION-STUDY",
|
|
"name": "IP Location Study",
|
|
"status": "ACTIVE",
|
|
"is_locked": False,
|
|
"visit_schedule": "[]",
|
|
"active_roles": "[]",
|
|
},
|
|
)
|
|
for user_id, email in [(user_a, "user-a@example.com"), (user_b, "user-b@example.com")]:
|
|
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": user_id,
|
|
"email": email,
|
|
"password_hash": "hash",
|
|
"full_name": email,
|
|
"role": "PM",
|
|
"clinical_department": "临床运营",
|
|
"status": "ACTIVE",
|
|
},
|
|
)
|
|
|
|
rows = [
|
|
(study_id, user_a, "10.1.1.1", True),
|
|
(study_id, user_a, "10.1.1.1", False),
|
|
(study_id, user_b, "10.1.1.2", True),
|
|
]
|
|
for study, user, ip_address, allowed in rows:
|
|
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
|
|
(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
|
|
substr(lower(hex(randomblob(2))),2) || '-' ||
|
|
substr('89ab', abs(random()) % 4 + 1, 1) ||
|
|
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
|
|
:study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
|
"""
|
|
),
|
|
{
|
|
"study_id": study,
|
|
"user_id": user,
|
|
"endpoint_key": "admin.permissions.read",
|
|
"role": "PM",
|
|
"allowed": allowed,
|
|
"elapsed_ms": 3.2,
|
|
"ip_address": ip_address,
|
|
},
|
|
)
|
|
await db_session.commit()
|
|
|
|
monkeypatch.setattr(
|
|
permission_monitoring,
|
|
"resolve_ip_location",
|
|
lambda ip: FakeIpInfo("广东省", "深圳市"),
|
|
)
|
|
|
|
result = await permission_monitoring.get_ip_locations(db=db_session, _=object(), days=7, limit=10)
|
|
|
|
assert result["items"][0]["province"] == "广东省"
|
|
assert result["items"][0]["total_count"] == 3
|
|
assert result["items"][0]["unique_ip_count"] == 2
|
|
assert result["items"][0]["unique_user_count"] == 2
|
|
assert result["summary"]["total_count"] == 3
|
|
assert result["summary"]["unique_ip_count"] == 2
|
|
assert result["summary"]["unique_user_count"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_access_logs_include_user_behavior_summary(db_session):
|
|
"""访问日志应返回用户行为审计汇总和用户排行。"""
|
|
await db_session.execute(text("DELETE FROM permission_access_logs"))
|
|
await db_session.commit()
|
|
|
|
study_id = "00000000-0000-0000-0000-000000000201"
|
|
user_a = "00000000-0000-0000-0000-000000000301"
|
|
user_b = "00000000-0000-0000-0000-000000000302"
|
|
|
|
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": study_id,
|
|
"code": "ACCESS-LOG-STUDY",
|
|
"name": "Access Log Study",
|
|
"status": "ACTIVE",
|
|
"is_locked": False,
|
|
"visit_schedule": "[]",
|
|
"active_roles": "[]",
|
|
},
|
|
)
|
|
for user_id, email, name, role in [
|
|
(user_a, "audit-a@example.com", "审计用户A", "PM"),
|
|
(user_b, "audit-b@example.com", "审计用户B", "CRA"),
|
|
]:
|
|
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": user_id,
|
|
"email": email,
|
|
"password_hash": "hash",
|
|
"full_name": name,
|
|
"role": role,
|
|
"clinical_department": "临床运营",
|
|
"status": "ACTIVE",
|
|
},
|
|
)
|
|
|
|
rows = [
|
|
(study_id, user_a, "admin.permissions.read", "PM", True, 3.0, "10.1.1.1"),
|
|
(study_id, user_a, "admin.users.delete", "PM", False, 9.0, "10.1.1.2"),
|
|
(study_id, user_b, "admin.permissions.read", "CRA", True, 6.0, "10.1.1.3"),
|
|
]
|
|
for study, user, endpoint, role, allowed, elapsed_ms, ip_address in rows:
|
|
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
|
|
(lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' ||
|
|
substr(lower(hex(randomblob(2))),2) || '-' ||
|
|
substr('89ab', abs(random()) % 4 + 1, 1) ||
|
|
substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6))),
|
|
:study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
|
|
"""
|
|
),
|
|
{
|
|
"study_id": study,
|
|
"user_id": user,
|
|
"endpoint_key": endpoint,
|
|
"role": role,
|
|
"allowed": allowed,
|
|
"elapsed_ms": elapsed_ms,
|
|
"ip_address": ip_address,
|
|
},
|
|
)
|
|
await db_session.commit()
|
|
|
|
result = await permission_monitoring.get_access_logs(
|
|
db=db_session,
|
|
_=object(),
|
|
study_id=None,
|
|
user_id=None,
|
|
endpoint_key=None,
|
|
role=None,
|
|
allowed=None,
|
|
start_time=None,
|
|
end_time=None,
|
|
page=1,
|
|
page_size=50,
|
|
)
|
|
|
|
assert result["summary"]["total_count"] == 3
|
|
assert result["summary"]["unique_user_count"] == 2
|
|
assert result["summary"]["denied_count"] == 1
|
|
assert result["summary"]["avg_elapsed_ms"] == 6.0
|
|
assert result["user_stats"][0]["user_name"] == "审计用户A"
|
|
assert result["user_stats"][0]["total_count"] == 1
|
|
assert result["user_stats"][0]["denied_count"] in {0, 1}
|
|
assert result["user_stats"][0]["unique_ip_count"] == 1
|
|
assert result["user_stats"][0]["sample_ip_address"] in {"10.1.1.1", "10.1.1.2"}
|
|
user_a_ips = {
|
|
stat["sample_ip_address"]
|
|
for stat in result["user_stats"]
|
|
if stat["user_name"] == "审计用户A"
|
|
}
|
|
assert user_a_ips == {"10.1.1.1", "10.1.1.2"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_security_access_logs_include_anonymous_ip_attempts(db_session):
|
|
"""安全访问日志应覆盖未登录或未知账号的底层访问尝试。"""
|
|
await db_session.execute(text("DELETE FROM security_access_logs"))
|
|
await db_session.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO security_access_logs
|
|
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
|
|
VALUES
|
|
('00000000-0000-4000-8000-000000000501', 'POST', '/api/v1/auth/login', 401, 12.5,
|
|
'203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL, CURRENT_TIMESTAMP)
|
|
"""
|
|
)
|
|
)
|
|
await db_session.commit()
|
|
|
|
result = await permission_monitoring.get_security_access_logs(
|
|
db=db_session,
|
|
_=object(),
|
|
status_min=400,
|
|
auth_status=None,
|
|
page=1,
|
|
page_size=20,
|
|
)
|
|
|
|
assert result["summary"]["total_count"] == 1
|
|
assert result["summary"]["anonymous_count"] == 1
|
|
assert result["summary"]["error_count"] == 1
|
|
assert result["items"][0]["client_ip"] == "203.0.113.10"
|
|
assert result["items"][0]["account_label"] == "未知账号"
|
|
assert result["items"][0]["status_code"] == 401
|