feat(监控): 完善系统监控、登录状态与访问审计能力
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-10 17:11:24 +08:00
parent 400c9be3a7
commit f11a5c84d9
82 changed files with 10283 additions and 1781 deletions
+4 -6
View File
@@ -246,7 +246,7 @@ async def test_project_member_can_read_own_effective_permissions(db_session: Asy
@pytest.mark.asyncio
async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session: AsyncSession):
async def test_project_pm_cannot_access_system_monitoring(db_session: AsyncSession):
pm_id = uuid.uuid4()
own_study_id = uuid.uuid4()
other_study_id = uuid.uuid4()
@@ -256,12 +256,10 @@ async def test_project_pm_monitoring_scope_is_limited_to_own_projects(db_session
await _seed_member(db_session, own_study_id, pm_id, "PM")
await db_session.commit()
scope = await resolve_monitoring_scope(db_session, UserStub(id=pm_id))
with pytest.raises(HTTPException) as exc_info:
await resolve_monitoring_scope(db_session, UserStub(id=pm_id))
assert scope.is_admin is False
assert scope.study_ids == {own_study_id}
assert scope.can_access_study(own_study_id)
assert not scope.can_access_study(other_study_id)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
+4
View File
@@ -54,6 +54,9 @@ class _DummyTask:
def cancel(self):
self.cancelled = True
def done(self):
return False
def __await__(self):
async def _wait():
self.awaited = True
@@ -106,6 +109,7 @@ async def test_development_startup_does_not_seed_business_data(monkeypatch):
monkeypatch.setattr(main.settings, "ENV", "development")
monkeypatch.setattr(main, "ensure_admin_exists", ensure_admin_exists)
monkeypatch.setattr(main, "run_daily_lost_visit_job", fake_scheduler)
monkeypatch.setattr(main, "resolve_monitoring_server_location", AsyncMock(return_value=None))
monkeypatch.setattr(main.asyncio, "create_task", fake_create_task)
monkeypatch.setattr(main, "engine", _DummyEngine())
monkeypatch.setattr(main, "SessionLocal", fake_session_local)
+145
View File
@@ -4,6 +4,7 @@ import pytest
from app.core.request_context import (
RequestAuditContext,
build_request_audit_context,
reset_request_audit_context,
set_request_audit_context,
)
@@ -22,6 +23,48 @@ class FakeAsyncSession:
self.flushed = True
class FakeClient:
def __init__(self, host: str = "10.0.0.8") -> None:
self.host = host
self.port = 54321
class FakeUrl:
scheme = "https"
path = "/api/v1/audit"
query = "token=secret-token&view=all&subject_name=Alice"
class FakeQueryParams:
def __init__(self) -> None:
self._items = [
("token", "secret-token"),
("view", "all"),
("subject_name", "Alice"),
]
def __bool__(self) -> bool:
return True
def multi_items(self):
return list(self._items)
class FakeRequest:
def __init__(self, headers: dict[str, str], *, client_host: str = "10.0.0.8") -> None:
self.headers = headers
self.client = FakeClient(client_host)
self.method = "GET"
self.url = FakeUrl()
self.query_params = FakeQueryParams()
self.scope = {
"http_version": "1.1",
"scheme": "https",
"path": "/api/v1/audit",
"method": "GET",
}
@pytest.mark.asyncio
async def test_audit_log_action_captures_request_access_context():
context_token = set_request_audit_context(
@@ -60,3 +103,105 @@ async def test_audit_log_action_captures_request_access_context():
assert log.client_platform == "macos"
assert log.build_channel == "release"
assert log.build_commit == "abcdef1"
def test_request_audit_context_captures_sanitized_headers():
context = build_request_audit_context(
FakeRequest(
{
"user-agent": "Chrome",
"referer": "https://ctms.local/audit?token=secret-token",
"authorization": "Bearer secret-token",
"cookie": "sid=secret",
"x-ctms-client-source": "ctms-web",
"x-ctms-client-type": "web",
"x-ctms-client-version": "0.1.0",
"x-request-id": "req-123",
"x-custom-secret": "hidden",
"x-random-debug": "skip-me",
}
)
)
assert context.user_agent == "Chrome"
assert context.client_type == "web"
assert context.client_ip == "10.0.0.8"
assert uuid.UUID(context.request_id)
assert context.request_headers == {
"authorization": "[redacted]",
"cookie": "[redacted]",
"user-agent": "Chrome",
"x-ctms-client-source": "ctms-web",
"x-ctms-client-type": "web",
"x-ctms-client-version": "0.1.0",
"x-custom-secret": "[redacted]",
"x-request-id": "req-123",
}
assert context.request_snapshot is not None
assert context.request_snapshot["method"] == "GET"
assert context.request_snapshot["request_id"] == context.request_id
assert context.request_snapshot["path"] == "/api/v1/audit"
assert context.request_snapshot["query_string"] == (
"token=[redacted]&view=all&subject_name=[redacted]"
)
assert context.request_snapshot["query_params"] == [
{"name": "token", "value": "[redacted]"},
{"name": "view", "value": "all"},
{"name": "subject_name", "value": "[redacted]"},
]
assert context.request_snapshot["headers"]["authorization"] == "[redacted]"
assert context.request_snapshot["headers"]["cookie"] == "[redacted]"
assert context.request_snapshot["headers"]["x-ctms-client-source"] == "ctms-web"
assert "referer" not in context.request_snapshot["headers"]
assert "x-random-debug" not in context.request_snapshot["headers"]
assert context.request_snapshot["client"] == {"host": "10.0.0.8", "port": 54321}
assert context.request_snapshot["body"] == {
"captured": False,
"reason": "body_not_captured_by_audit_policy",
}
def test_request_audit_context_prefers_explicit_ctms_client_source():
context = build_request_audit_context(
FakeRequest(
{
"x-ctms-client-source": "ctms-desktop",
"x-ctms-client-type": "web",
}
)
)
assert context.client_type == "desktop"
assert context.request_headers == {
"x-ctms-client-source": "ctms-desktop",
"x-ctms-client-type": "web",
}
def test_request_audit_context_uses_proxy_observed_ip_instead_of_spoofed_forwarded_prefix(monkeypatch):
monkeypatch.setattr("app.core.request_context.settings.TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
context = build_request_audit_context(
FakeRequest(
{
"x-real-ip": "203.0.113.20",
"x-forwarded-for": "1.2.3.4, 203.0.113.20",
}
)
)
assert context.client_ip == "203.0.113.20"
def test_request_audit_context_ignores_forwarded_headers_from_untrusted_peer(monkeypatch):
monkeypatch.setattr("app.core.request_context.settings.TRUSTED_PROXY_CIDRS", "10.0.0.0/8")
context = build_request_audit_context(
FakeRequest(
{
"x-real-ip": "203.0.113.20",
"x-forwarded-for": "1.2.3.4",
},
client_host="198.51.100.8",
)
)
assert context.client_ip == "198.51.100.8"
+210
View File
@@ -0,0 +1,210 @@
from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.permission_access_log import PermissionAccessLog
from app.models.permission_metric_snapshot import PermissionMetricSnapshot
from app.models.security_access_log import SecurityAccessLog
from app.models.study import Study
from app.models.user import User, UserStatus
from app.services import monitoring_retention
from app.services import permission_log_writer, security_access_log_writer
class _FakeSession:
def __init__(self) -> None:
self.added: list[object] = []
self.committed = False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def add(self, value: object) -> None:
self.added.append(value)
async def commit(self) -> None:
self.committed = True
@pytest.mark.asyncio
async def test_permission_writer_gracefully_flushes_and_reports_stats(monkeypatch):
session = _FakeSession()
monkeypatch.setattr(permission_log_writer, "SessionLocal", lambda: session)
writer = permission_log_writer.PermissionLogWriter()
await writer.start()
writer.enqueue(
{
"study_id": uuid.uuid4(),
"user_id": uuid.uuid4(),
"endpoint_key": "subjects.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 2.5,
}
)
await writer.stop()
stats = writer.stats()
assert session.committed is True
assert len(session.added) == 1
assert stats["running"] is False
assert stats["accepted_entries"] == 1
assert stats["written_entries"] == 1
assert stats["dropped_entries"] == 0
assert stats["queue_size"] == 0
assert stats["last_success_at"] is not None
@pytest.mark.asyncio
async def test_permission_writer_retries_and_exposes_permanent_failure(monkeypatch):
class _FailingSession:
async def __aenter__(self):
raise RuntimeError("database unavailable")
async def __aexit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(permission_log_writer, "SessionLocal", _FailingSession)
writer = permission_log_writer.PermissionLogWriter()
batch = [
{
"study_id": uuid.uuid4(),
"user_id": uuid.uuid4(),
"endpoint_key": "subjects.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 2.5,
}
]
assert await writer._write_batch(batch) is False
stats = writer.stats()
assert stats["retry_count"] == 1
assert stats["failed_batches"] == 1
assert stats["failed_entries"] == 1
assert stats["last_error_type"] == "RuntimeError"
def test_writer_queue_overflow_is_counted():
writer = security_access_log_writer.SecurityAccessLogWriter()
writer._queue = asyncio.Queue(maxsize=1)
writer.enqueue({"path": "/first"})
writer.enqueue({"path": "/dropped"})
stats = writer.stats()
assert stats["accepted_entries"] == 1
assert stats["dropped_entries"] == 1
assert stats["queue_size"] == 1
@pytest.mark.asyncio
async def test_retention_deletes_expired_rows_and_preserves_recent_rows(
db_session, test_engine, monkeypatch
):
now = datetime(2026, 7, 10, tzinfo=timezone.utc)
study_id = (await db_session.execute(select(Study.id).limit(1))).scalar_one()
user = User(
email=f"retention-{uuid.uuid4()}@example.com",
password_hash="hash",
full_name="Retention Test",
clinical_department="QA",
status=UserStatus.ACTIVE,
)
db_session.add(user)
await db_session.flush()
old_permission = PermissionAccessLog(
study_id=study_id,
user_id=user.id,
endpoint_key="old",
role="PM",
allowed=True,
elapsed_ms=1,
created_at=now - timedelta(days=91),
)
recent_permission = PermissionAccessLog(
study_id=study_id,
user_id=user.id,
endpoint_key="recent",
role="PM",
allowed=True,
elapsed_ms=1,
created_at=now - timedelta(days=89),
)
old_security = SecurityAccessLog(
method="GET",
path="/old",
status_code=404,
elapsed_ms=1,
auth_status="ANONYMOUS",
created_at=now - timedelta(days=91),
)
recent_security = SecurityAccessLog(
method="GET",
path="/recent",
status_code=200,
elapsed_ms=1,
auth_status="AUTHENTICATED",
created_at=now - timedelta(days=89),
)
old_metric = PermissionMetricSnapshot(
bucket_time=now - timedelta(days=401)
)
recent_metric = PermissionMetricSnapshot(
bucket_time=now - timedelta(days=399)
)
db_session.add_all(
[
old_permission,
recent_permission,
old_security,
recent_security,
old_metric,
recent_metric,
]
)
await db_session.commit()
old_permission_id = old_permission.id
recent_permission_id = recent_permission.id
old_security_id = old_security.id
recent_security_id = recent_security.id
old_metric_id = old_metric.id
recent_metric_id = recent_metric.id
session_factory = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
monkeypatch.setattr(monitoring_retention, "SessionLocal", session_factory)
monkeypatch.setattr(
monitoring_retention.settings, "MONITORING_ACCESS_LOG_RETENTION_DAYS", 90
)
monkeypatch.setattr(
monitoring_retention.settings, "MONITORING_METRIC_RETENTION_DAYS", 400
)
deleted = await monitoring_retention.purge_expired_monitoring_data(now=now)
db_session.expire_all()
assert deleted["permission_access_logs"] >= 1
assert deleted["security_access_logs"] >= 1
assert deleted["permission_metric_snapshots"] >= 1
assert await db_session.get(PermissionAccessLog, old_permission_id) is None
assert await db_session.get(SecurityAccessLog, old_security_id) is None
assert await db_session.get(PermissionMetricSnapshot, old_metric_id) is None
assert await db_session.get(PermissionAccessLog, recent_permission_id) is not None
assert await db_session.get(SecurityAccessLog, recent_security_id) is not None
assert await db_session.get(PermissionMetricSnapshot, recent_metric_id) is not None
@@ -0,0 +1,53 @@
from types import SimpleNamespace
import pytest
from app.core.config import settings
from app.services import monitoring_server_location
def test_public_ip_normalization_rejects_private_addresses():
assert monitoring_server_location._normalize_public_ip("8.8.8.8\n") == "8.8.8.8"
assert monitoring_server_location._normalize_public_ip("10.0.0.8") is None
assert monitoring_server_location._normalize_public_ip("127.0.0.1") is None
assert monitoring_server_location._normalize_public_ip("not-an-ip") is None
@pytest.mark.asyncio
async def test_server_location_is_derived_from_configured_public_ip(monkeypatch):
monkeypatch.setattr(settings, "MONITORING_SERVER_PUBLIC_IP", "8.8.8.8")
monkeypatch.setattr(
monitoring_server_location,
"resolve_ip_location",
lambda ip: SimpleNamespace(
location="United States / California / San Jose",
country="United States",
province="California",
city="San Jose",
isp="Google",
),
)
monitoring_server_location.reset_monitoring_server_location_cache()
location = await monitoring_server_location.resolve_monitoring_server_location()
assert location is not None
assert location.name == "United States / California / San Jose"
assert location.longitude == -121.8863
assert location.latitude == 37.3382
assert location.resolution_source == "configured_public_ip"
monitoring_server_location.reset_monitoring_server_location_cache()
@pytest.mark.asyncio
async def test_server_location_returns_none_instead_of_a_hardcoded_fallback(monkeypatch):
async def no_public_ip():
return None, "unavailable"
monkeypatch.setattr(monitoring_server_location, "_discover_public_ip", no_public_ip)
monitoring_server_location.reset_monitoring_server_location_cache()
location = await monitoring_server_location.resolve_monitoring_server_location()
assert location is None
monitoring_server_location.reset_monitoring_server_location_cache()
+554 -22
View File
@@ -1,7 +1,9 @@
"""监控API测试:权限系统监控API端点验证。"""
import inspect
import json
import uuid
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
@@ -10,6 +12,7 @@ from sqlalchemy import text
from app.core.permission_monitor import set_permission_monitor, PermissionMonitor
from app.api.v1 import permission_monitoring
from app.api.v1.system_permissions import list_system_permissions
from app.services.monitoring_server_location import MonitoringServerLocation
class FakeIpInfo:
@@ -30,6 +33,18 @@ class ProjectPmUserStub:
is_admin = False
@pytest.fixture(autouse=True)
def disable_server_public_ip_discovery(monkeypatch):
async def no_server_location():
return None
monkeypatch.setattr(
permission_monitoring,
"resolve_monitoring_server_location",
no_server_location,
)
@pytest.mark.asyncio
async def test_resolve_monitoring_scope_rejects_project_pm(db_session):
"""系统监测模块仅允许系统管理员访问,项目 PM 不再具备监测范围。"""
@@ -103,9 +118,11 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
text(
"""
INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
request_snapshot, created_at)
VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
"""
),
{
@@ -117,6 +134,7 @@ async def _seed_permission_log(db_session, study_id: uuid.UUID, user_id: uuid.UU
"allowed": allowed,
"elapsed_ms": elapsed_ms,
"ip_address": "127.0.0.1",
"request_snapshot": None,
},
)
await db_session.commit()
@@ -262,8 +280,8 @@ async def test_permission_system_health_degraded(db_session):
assert any("权限拒绝率偏高" in issue for issue in data["issues"])
assert any("缓存命中率偏低" in issue for issue in data["issues"])
assert data["status"] == "unhealthy"
assert data["health_score"] == 30
assert data["score_breakdown"]["performance"]["deduction"] == 25
assert data["health_score"] == 35
assert data["score_breakdown"]["performance"]["deduction"] == 20
assert data["score_breakdown"]["deny_rate"]["deduction"] == 20
assert data["score_breakdown"]["cache"]["deduction"] == 25
@@ -279,6 +297,10 @@ async def test_permission_system_health_includes_metrics(db_session):
assert "cache_stats" in data
assert "score_breakdown" in data
assert "sample_sufficient" in data
assert "components" in data
assert "storage" in data
assert "runtime" in data["score_breakdown"]
assert data["components"]["database"]["status"] == "healthy"
assert "total_checks" in data["last_hour"]
assert "cache_metrics" in data["cache_stats"]
@@ -304,6 +326,41 @@ async def test_permission_system_health_ignores_small_cache_sample(db_session):
assert data["health_score"] == 100 - non_cache_deductions
@pytest.mark.asyncio
async def test_permission_system_health_reports_writer_degradation(db_session, monkeypatch):
monitor = PermissionMonitor()
set_permission_monitor(monitor)
writer = SimpleNamespace(
stats=lambda: {
"running": True,
"queue_size": 3,
"queue_capacity": 100,
"failed_batches": 0,
"dropped_entries": 2,
}
)
monkeypatch.setattr(permission_monitoring, "get_log_writer", lambda: writer)
monkeypatch.setattr(permission_monitoring, "get_security_log_writer", lambda: None)
monkeypatch.setattr(
permission_monitoring,
"get_monitoring_retention_status",
lambda: {
"running": False,
"last_run_started_at": None,
"last_success_at": None,
"last_error_at": None,
},
)
data = await permission_monitoring.permission_system_health(
db=db_session, _=AdminUserStub()
)
assert data["components"]["permission_log_writer"]["status"] == "degraded"
assert data["score_breakdown"]["runtime"]["deduction"] == 10
assert any("权限日志写入器" in issue for issue in data["issues"])
@pytest.mark.asyncio
async def test_ip_locations_counts_unique_users_per_location(db_session, monkeypatch):
"""IP 属地统计应按省市聚合访问次数、来源 IP 数和访问用户数。"""
@@ -381,10 +438,26 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
)
await db_session.commit()
resolved_ips = []
def fake_resolve_ip_location(ip):
resolved_ips.append(ip)
return FakeIpInfo("广东省", "深圳市")
async def fake_resolve_monitoring_server_location():
return MonitoringServerLocation(
name="China / 重庆 / 重庆市",
longitude=106.5516,
latitude=29.563,
accuracy_level="city",
resolution_source="public_ip_discovery",
)
monkeypatch.setattr(permission_monitoring, "resolve_ip_location", fake_resolve_ip_location)
monkeypatch.setattr(
permission_monitoring,
"resolve_ip_location",
lambda ip: FakeIpInfo("广东省", "深圳市"),
"resolve_monitoring_server_location",
fake_resolve_monitoring_server_location,
)
result = await permission_monitoring.get_ip_locations(db=db_session, _=AdminUserStub(), days=7, limit=10)
@@ -396,6 +469,56 @@ async def test_ip_locations_counts_unique_users_per_location(db_session, monkeyp
assert result["summary"]["total_count"] == 3
assert result["summary"]["unique_ip_count"] == 2
assert result["summary"]["unique_user_count"] == 2
assert result["items"][0]["country_code"] == "CN"
assert result["items"][0]["region_code"] == "CN-GD"
assert result["items"][0]["longitude"] is not None
assert result["items"][0]["risk_level"] == "attention"
assert result["period"]["mode"] == "rolling"
assert result["period"]["retention_days"] >= 7
assert result["data_quality"]["located_ip_count"] == 2
assert result["server_location"]["longitude"] == 106.5516
assert result["server_location"]["resolution_source"] == "public_ip_discovery"
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2"]
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,
datetime('now', '-120 days'))
"""
),
{
"study_id": study_id,
"user_id": user_a,
"endpoint_key": "admin.permissions.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 2.4,
"ip_address": "10.1.1.3",
},
)
await db_session.commit()
resolved_ips.clear()
all_result = await permission_monitoring.get_ip_locations(
db=db_session,
_=AdminUserStub(),
days=7,
limit=10,
all_time=True,
)
assert all_result["days"] is None
assert all_result["summary"]["total_count"] == 4
assert all_result["summary"]["unique_ip_count"] == 3
assert sorted(resolved_ips) == ["10.1.1.1", "10.1.1.2", "10.1.1.3"]
@pytest.mark.asyncio
@@ -530,9 +653,11 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
text(
"""
INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
request_snapshot, created_at)
VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
"""
),
{
@@ -544,6 +669,7 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
"allowed": True,
"elapsed_ms": 3.2,
"ip_address": "192.168.1.10",
"request_snapshot": None,
},
)
await db_session.commit()
@@ -556,8 +682,8 @@ async def test_ip_locations_keeps_private_network_location_label(db_session, mon
@pytest.mark.asyncio
async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypatch):
"""来源分析应同时统计安全事件中的异常来源 IP"""
async def test_ip_locations_scores_security_sources_by_behavior(db_session, monkeypatch):
"""来源分析应按安全行为而非地域评估来源风险"""
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit()
@@ -603,9 +729,11 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
text(
"""
INSERT INTO permission_access_logs
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address, created_at)
(id, study_id, user_id, endpoint_key, role, allowed, elapsed_ms, ip_address,
request_snapshot, created_at)
VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address, CURRENT_TIMESTAMP)
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
"""
),
{
@@ -617,9 +745,10 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
"allowed": True,
"elapsed_ms": 3.2,
"ip_address": "10.3.1.1",
"request_snapshot": None,
},
)
for ip_address in ["8.8.8.8", "1.1.1.1"]:
for ip_address, status_code in [("8.8.8.8", 403), ("1.1.1.1", 200)]:
await db_session.execute(
text(
"""
@@ -633,7 +762,7 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
"id": str(uuid.uuid4()),
"method": "GET",
"path": "/api/v1/admin",
"status_code": 403,
"status_code": status_code,
"elapsed_ms": 8.5,
"client_ip": ip_address,
"user_agent": "pytest",
@@ -658,18 +787,23 @@ async def test_ip_locations_includes_abnormal_security_ips(db_session, monkeypat
locations = {item["country"]: item for item in result["items"]}
assert result["summary"]["total_count"] == 3
assert result["summary"]["denied_count"] == 2
assert result["summary"]["denied_count"] == 1
assert result["summary"]["unique_ip_count"] == 3
assert result["summary"]["unique_user_count"] == 1
assert locations["United States"]["total_count"] == 1
assert locations["United States"]["denied_count"] == 1
assert locations["United States"]["risk_level"] == "high"
assert locations["United States"]["country_code"] == "US"
assert locations["Australia"]["total_count"] == 1
assert locations["Australia"]["denied_count"] == 0
assert locations["Australia"]["risk_level"] == "normal"
@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.execute(text("DELETE FROM security_access_logs"))
await db_session.commit()
study_id = "00000000-0000-0000-0000-000000000201"
@@ -784,10 +918,300 @@ def test_access_logs_user_stats_query_avoids_postgresql_uuid_max():
assert "func.max(PermissionAccessLog.user_id)" not in source
@pytest.mark.asyncio
async def test_access_logs_filter_by_account_location_and_client_source(db_session, monkeypatch):
"""访问审计应支持按账号、IP 属地和客户端来源排查。"""
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit()
study_id = "00000000-0000-0000-0000-000000001401"
user_a = "00000000-0000-0000-0000-000000001501"
user_b = "00000000-0000-0000-0000-000000001502"
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-CONTEXT-STUDY",
"name": "Access Context Study",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": "[]",
},
)
for user_id, email, name in [
(user_a, "audit-shanghai@example.com", "上海审计用户"),
(user_b, "audit-beijing@example.com", "北京审计用户"),
]:
await db_session.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
"""
),
{
"id": user_id,
"email": email,
"password_hash": "hash",
"full_name": name,
"clinical_department": "临床运营",
"is_admin": False,
"status": "ACTIVE",
},
)
rows = [
(user_a, "203.0.113.10", "web", "Chrome", "macos", {"user-agent": "Chrome", "x-request-id": "req-web"}),
(user_b, "198.51.100.20", "desktop", "CTMS Desktop", "windows", {"user-agent": "CTMS Desktop"}),
(user_a, "10.10.10.10", None, "curl/8.0", None, {"user-agent": "curl/8.0"}),
]
for user, ip_address, client_type, user_agent, platform, request_headers 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,
user_agent, client_type, client_version, client_platform, build_channel, build_commit, request_headers, created_at)
VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:user_agent, :client_type, :client_version, :client_platform, :build_channel, :build_commit, :request_headers, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"study_id": study_id,
"user_id": user,
"endpoint_key": "admin.permissions.read",
"role": "PM",
"allowed": True,
"elapsed_ms": 4.0,
"ip_address": ip_address,
"user_agent": user_agent,
"client_type": client_type,
"client_version": "0.1.0" if client_type else None,
"client_platform": platform,
"build_channel": "local" if client_type else None,
"build_commit": "abcdef1" if client_type else None,
"request_headers": json.dumps(request_headers),
},
)
await db_session.commit()
ip_info_by_address = {
"203.0.113.10": FakeIpInfo("上海市", "上海市", "电信"),
"198.51.100.20": FakeIpInfo("北京市", "北京市", "联通"),
"10.10.10.10": FakeIpInfo("", "", "", "局域网"),
}
monkeypatch.setattr(
permission_monitoring,
"resolve_ip_location",
lambda ip: ip_info_by_address[ip],
)
result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type="web",
keyword="上海",
page=1,
page_size=50,
)
assert result["total"] == 1
assert result["items"][0]["user_email"] == "audit-shanghai@example.com"
assert result["items"][0]["client_type"] == "web"
assert result["items"][0]["client_platform"] == "macos"
assert result["items"][0]["user_agent"] == "Chrome"
assert result["items"][0]["request_headers"]["x-request-id"] == "req-web"
assert result["items"][0]["ip_city"] == "上海市"
unknown_result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type="unknown",
keyword="curl",
page=1,
page_size=50,
)
assert unknown_result["total"] == 1
assert unknown_result["items"][0]["ip_address"] == "10.10.10.10"
@pytest.mark.asyncio
async def test_access_logs_include_security_events_for_admin(db_session, monkeypatch):
"""访问日志应合并业务权限日志和安全访问日志,避免匿名/探测请求审计盲区。"""
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit()
study_id = "00000000-0000-0000-0000-000000001601"
user_id = "00000000-0000-0000-0000-000000001701"
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-SECURITY-FEED",
"name": "Access Security Feed",
"status": "ACTIVE",
"is_locked": False,
"visit_schedule": "[]",
"active_roles": "[]",
},
)
await db_session.execute(
text(
"""
INSERT INTO users (id, email, password_hash, full_name, clinical_department, is_admin, status)
VALUES (:id, :email, :password_hash, :full_name, :clinical_department, :is_admin, :status)
"""
),
{
"id": user_id,
"email": "access-feed@example.com",
"password_hash": "hash",
"full_name": "统一访问用户",
"clinical_department": "临床运营",
"is_admin": False,
"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,
request_snapshot, created_at)
VALUES
(:id, :study_id, :user_id, :endpoint_key, :role, :allowed, :elapsed_ms, :ip_address,
:request_snapshot, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"study_id": study_id,
"user_id": user_id,
"endpoint_key": "project_overview:read",
"role": "PM",
"allowed": True,
"elapsed_ms": 3.0,
"ip_address": "192.168.10.2",
"request_snapshot": json.dumps(
{
"method": "GET",
"path": "/api/v1/projects/overview",
"query_string": "tab=summary",
"headers": {"user-agent": "Chrome", "x-request-id": "perm-1"},
"body": {"captured": False, "reason": "body_not_captured_by_audit_policy"},
}
),
},
)
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, request_headers, request_snapshot, created_at)
VALUES
(:id, :method, :path, :status_code, :elapsed_ms, :client_ip, :user_agent, :auth_status,
:user_identifier, :request_headers, :request_snapshot, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"method": "GET",
"path": "/api/.git/config",
"status_code": 404,
"elapsed_ms": 1.5,
"client_ip": "45.148.10.95",
"user_agent": "probe-bot/1.0",
"auth_status": "ANONYMOUS",
"user_identifier": None,
"request_headers": json.dumps({"user-agent": "probe-bot/1.0", "x-request-id": "sec-1"}),
"request_snapshot": json.dumps(
{
"method": "GET",
"path": "/api/.git/config",
"query_string": "token=[redacted]",
"headers": {"user-agent": "probe-bot/1.0", "authorization": "[redacted]"},
"body": {"captured": False, "reason": "body_not_captured_by_audit_policy"},
}
),
},
)
await db_session.commit()
ip_info_by_address = {
"192.168.10.2": FakeIpInfo("", "", "", "局域网"),
"45.148.10.95": FakeIpInfo("South Holland", "", "", "Netherlands"),
}
monkeypatch.setattr(permission_monitoring, "resolve_ip_location", lambda ip: ip_info_by_address[ip])
result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type=None,
keyword=None,
page=1,
page_size=50,
)
assert result["total"] == 2
assert result["summary"]["total_count"] == 2
assert result["summary"]["denied_count"] == 1
items_by_type = {item["event_type"]: item for item in result["items"]}
assert items_by_type["permission"]["endpoint_key"] == "project_overview:read"
assert items_by_type["security"]["status_code"] == 404
assert items_by_type["security"]["allowed"] is False
assert items_by_type["security"]["category"] == "PROBE"
assert items_by_type["security"]["severity"] == "CRITICAL"
assert items_by_type["security"]["request_headers"]["x-request-id"] == "sec-1"
assert items_by_type["permission"]["request_snapshot"]["path"] == "/api/v1/projects/overview"
assert items_by_type["security"]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
@pytest.mark.asyncio
async def test_access_logs_ip_ranking_aggregates_by_ip_total(db_session):
"""IP 访问排行应按同一 IP 的整体访问量聚合排序,而不是按用户/IP/角色拆分。"""
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
await db_session.commit()
study_id = "00000000-0000-0000-0000-000000001201"
@@ -896,10 +1320,13 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session, mo
text(
"""
INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status, user_identifier, created_at)
(id, method, path, status_code, elapsed_ms, client_ip, user_agent, auth_status,
user_identifier, request_snapshot, 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)
'203.0.113.10', 'attack-bot/1.0', 'ANONYMOUS', NULL,
'{"method":"POST","path":"/api/v1/auth/login","headers":{"authorization":"[redacted]"}}',
CURRENT_TIMESTAMP)
"""
)
)
@@ -925,6 +1352,7 @@ async def test_security_access_logs_include_anonymous_ip_attempts(db_session, mo
assert result["items"][0]["ip_isp"] == "联通"
assert result["items"][0]["account_label"] == "未知账号"
assert result["items"][0]["status_code"] == 401
assert result["items"][0]["request_snapshot"]["headers"]["authorization"] == "[redacted]"
@pytest.mark.asyncio
@@ -993,8 +1421,8 @@ async def test_security_access_logs_prioritize_sensitive_probe_over_foreign_ip(d
@pytest.mark.asyncio
async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session, monkeypatch):
"""安全事件明细应把非中国公网 IP 归类为异常 IP"""
async def test_security_access_logs_classify_foreign_invalid_token_by_behavior(db_session, monkeypatch):
"""海外来源不应单凭地域升级风险,仍按认证行为分类"""
await db_session.execute(text("DELETE FROM security_access_logs"))
monkeypatch.setattr(
permission_monitoring,
@@ -1024,8 +1452,8 @@ async def test_security_access_logs_classify_non_china_ip_as_abnormal(db_session
)
item = result["items"][0]
assert item["category"] == "ABNORMAL_IP"
assert item["severity"] == "HIGH"
assert item["category"] == "INVALID_TOKEN"
assert item["severity"] == "MEDIUM"
assert item["ip_location"] == "United States / California / San Jose / Google"
assert item["ip_country"] == "United States"
@@ -1062,4 +1490,108 @@ async def test_security_access_logs_resolve_public_ip_with_packaged_ip2region(db
assert item["ip_country"] == "United States"
assert item["ip_province"] == "California"
assert item["ip_city"] == "San Jose"
assert item["category"] == "ABNORMAL_IP"
assert item["category"] == "NOT_FOUND_NOISE"
@pytest.mark.asyncio
async def test_access_logs_deduplicate_permission_and_security_rows_by_request_id(db_session):
await db_session.execute(text("DELETE FROM permission_access_logs"))
await db_session.execute(text("DELETE FROM security_access_logs"))
request_id = str(uuid.uuid4())
study_id = str(uuid.uuid4())
user_id = str(uuid.uuid4())
await _seed_permission_log(db_session, uuid.UUID(study_id), uuid.UUID(user_id), allowed=False, elapsed_ms=4.0)
await db_session.execute(
text("UPDATE permission_access_logs SET request_id = :request_id"),
{"request_id": request_id},
)
await db_session.execute(
text(
"""
INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, auth_status, user_identifier,
request_id, category, severity, created_at)
VALUES
(:id, 'GET', '/api/v1/studies/denied', 403, 5.0, '203.0.113.20', 'AUTHENTICATED', :user_id,
:request_id, 'OTHER', 'LOW', CURRENT_TIMESTAMP)
"""
),
{"id": str(uuid.uuid4()), "user_id": user_id, "request_id": request_id},
)
await db_session.commit()
result = await permission_monitoring.get_access_logs(
db=db_session,
_=AdminUserStub(),
study_id=None,
user_id=None,
endpoint_key=None,
role=None,
allowed=None,
start_time=None,
end_time=None,
client_ip=None,
client_type=None,
keyword=None,
page=1,
page_size=50,
)
assert result["total"] == 1
assert result["items"][0]["event_type"] == "security"
assert result["items"][0]["request_id"] == request_id
@pytest.mark.asyncio
async def test_security_events_only_includes_persisted_probe(db_session):
await db_session.execute(text("DELETE FROM security_access_logs"))
for category, ip in [("PROBE", "52.53.218.145"), ("OTHER", "203.0.113.20")]:
await db_session.execute(
text(
"""
INSERT INTO security_access_logs
(id, method, path, status_code, elapsed_ms, client_ip, auth_status,
category, severity, created_at)
VALUES
(:id, 'GET', '/api/v1/studies/', 200, 3.0, :ip, 'AUTHENTICATED',
:category, :severity, CURRENT_TIMESTAMP)
"""
),
{
"id": str(uuid.uuid4()),
"ip": ip,
"category": category,
"severity": "CRITICAL" if category == "PROBE" else "LOW",
},
)
await db_session.commit()
result = await permission_monitoring.get_security_access_logs(
db=db_session,
_=AdminUserStub(),
status_min=None,
auth_status=None,
events_only=True,
page=1,
page_size=20,
)
assert result["total"] == 1
assert result["items"][0]["category"] == "PROBE"
assert result["summary"]["high_risk_count"] == 1
def test_server_error_classification_takes_priority_over_foreign_ip():
log = SimpleNamespace(
path="/api/v1/studies/",
status_code=503,
auth_status="AUTHENTICATED",
category=None,
severity=None,
)
location = FakeIpInfo("California", "San Jose", "Google", "United States")
assert permission_monitoring._classify_security_access_log(log, location) == {
"category": "SERVER_ERROR",
"severity": "HIGH",
}
+13
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone
import base64
import json
import os
import uuid
from app.main import create_app
from app.core.config import settings
@@ -397,6 +398,7 @@ async def test_root_returns_service_metadata(client_and_db):
client, _ = client_and_db
resp = await client.get("/")
assert resp.status_code == 200
assert uuid.UUID(resp.headers["x-request-id"])
assert resp.json() == {
"service": "ctms-backend",
"status": "ok",
@@ -406,6 +408,17 @@ async def test_root_returns_service_metadata(client_and_db):
}
@pytest.mark.asyncio
async def test_readiness_checks_database(client_and_db):
client, _ = client_and_db
resp = await client.get("/readyz")
assert resp.status_code == 200
payload = resp.json()
assert payload["status"] == "ready"
assert payload["database"]["status"] == "healthy"
assert payload["database"]["latency_ms"] >= 0
@pytest.mark.asyncio
async def test_registered_user_can_login_after_email_verification(client_and_db):
client, SessionLocal = client_and_db
@@ -0,0 +1,68 @@
from datetime import datetime, timedelta, timezone
import pytest
from app.models.source_location_snapshot import SourceLocationSnapshot
from app.services.source_location_aggregator import _identity_hash, get_source_location_timeline
def _snapshot(bucket_time: datetime, *, allowed: int, denied: int) -> SourceLocationSnapshot:
return SourceLocationSnapshot(
bucket_time=bucket_time,
ip_hash=_identity_hash("ip", "203.0.113.10"),
user_hash=_identity_hash("user", "user-1"),
country="United States",
country_code="US",
province="California",
region_code="",
city="San Jose",
isp="Example ISP",
location="United States / California / San Jose",
longitude=-121.8863,
latitude=37.3382,
accuracy_level="city",
allowed_count=allowed,
denied_count=denied,
security_event_count=denied,
high_risk_count=0,
auth_failure_count=denied,
first_seen_at=bucket_time,
last_seen_at=bucket_time + timedelta(minutes=30),
)
def test_source_location_identity_hash_is_stable_and_does_not_expose_ip():
first = _identity_hash("ip", "203.0.113.10")
second = _identity_hash("ip", "203.0.113.10")
assert first == second
assert len(first) == 64
assert "203.0.113.10" not in first
@pytest.mark.asyncio
async def test_source_location_timeline_supports_hour_and_day_rollups(db_session):
start = datetime(2026, 7, 10, 8, tzinfo=timezone.utc)
db_session.add(_snapshot(start, allowed=4, denied=1))
db_session.add(_snapshot(start + timedelta(hours=1), allowed=3, denied=2))
await db_session.commit()
hourly = await get_source_location_timeline(
db_session,
start_at=start,
end_at=start + timedelta(hours=2),
granularity="hour",
)
daily = await get_source_location_timeline(
db_session,
start_at=start,
end_at=start + timedelta(hours=2),
granularity="day",
)
assert [item["total_count"] for item in hourly] == [5, 5]
assert len(daily) == 1
assert daily[0]["total_count"] == 10
assert daily[0]["denied_count"] == 3
assert daily[0]["unique_ip_count"] == 1
assert daily[0]["unique_user_count"] == 1
+61
View File
@@ -0,0 +1,61 @@
from datetime import datetime, timedelta, timezone
import uuid
import pytest
from app.core.config import settings
from app.models.user import User, UserStatus
from app.models.user_login_session import UserLoginSession
from app.services.user_login_sessions import get_login_summaries, session_id_from_payload
def test_legacy_session_id_is_stable_without_storing_a_token():
payload = {"sub": "00000000-0000-0000-0000-000000000101", "orig_iat": 1_700_000_000, "client_type": "web"}
first = session_id_from_payload(payload)
second = session_id_from_payload(payload)
assert first == second
assert isinstance(first, uuid.UUID)
@pytest.mark.asyncio
async def test_login_summary_marks_recent_unended_sessions_online(db_session, monkeypatch):
now = datetime.now(timezone.utc)
monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300)
user = User(
id=uuid.uuid4(),
email="login-summary@example.com",
password_hash="hash",
full_name="Login Summary",
clinical_department="IT",
status=UserStatus.ACTIVE,
)
db_session.add(user)
db_session.add_all(
[
UserLoginSession(
id=uuid.uuid4(),
user_id=user.id,
client_type="desktop",
login_at=now - timedelta(minutes=10),
last_seen_at=now - timedelta(seconds=20),
),
UserLoginSession(
id=uuid.uuid4(),
user_id=user.id,
client_type="web",
login_at=now - timedelta(days=1),
last_seen_at=now - timedelta(days=1),
ended_at=now - timedelta(days=1),
end_reason="logout",
),
]
)
await db_session.commit()
summaries = await get_login_summaries(db_session, [user.id])
assert summaries[user.id].status == "ONLINE"
assert summaries[user.id].active_session_count == 1
assert summaries[user.id].client_type == "desktop"