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
+12
View File
@@ -33,6 +33,18 @@ class Settings(BaseSettings):
)
IP2REGION_XDB_PATH: Optional[str] = None
IP2REGION_IPV6_XDB_PATH: Optional[str] = None
TRUSTED_PROXY_CIDRS: str = "127.0.0.1/32,::1/128,172.16.0.0/12"
MONITORING_SERVER_PUBLIC_IP: Optional[str] = None
MONITORING_PUBLIC_IP_DISCOVERY_URLS: str = (
"https://api64.ipify.org,https://icanhazip.com"
)
MONITORING_PUBLIC_IP_DISCOVERY_TIMEOUT_SECONDS: float = Field(default=2.5, ge=0.5, le=10)
MONITORING_SERVER_LOCATION_CACHE_SECONDS: int = Field(default=86400, ge=300, le=604800)
MONITORING_ACCESS_LOG_RETENTION_DAYS: int = Field(default=90, ge=7, le=3650)
MONITORING_METRIC_RETENTION_DAYS: int = Field(default=400, ge=30, le=3650)
MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800)
USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650)
USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600)
@lru_cache
+13 -3
View File
@@ -286,8 +286,9 @@ def _enqueue_permission_log(
writer = get_log_writer()
if writer:
forwarded = request.headers.get("x-forwarded-for")
ip = forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else None)
from app.core.request_context import build_request_audit_context, get_request_audit_context
context = get_request_audit_context() or build_request_audit_context(request)
writer.enqueue({
"study_id": study_id,
"user_id": user_id,
@@ -295,7 +296,16 @@ def _enqueue_permission_log(
"role": role,
"allowed": allowed,
"elapsed_ms": elapsed_ms,
"ip_address": ip,
"ip_address": context.client_ip,
"user_agent": context.user_agent,
"client_type": context.client_type,
"client_version": context.client_version,
"client_platform": context.client_platform,
"build_channel": context.build_channel,
"build_commit": context.build_commit,
"request_headers": context.request_headers,
"request_snapshot": context.request_snapshot,
"request_id": context.request_id,
})
+187 -6
View File
@@ -2,14 +2,19 @@
from __future__ import annotations
import ipaddress
import re
import uuid
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Any
from app.core.config import settings
@dataclass(frozen=True)
class RequestAuditContext:
request_id: str | None = None
client_ip: str | None = None
user_agent: str | None = None
client_type: str | None = None
@@ -17,6 +22,8 @@ class RequestAuditContext:
client_platform: str | None = None
build_channel: str | None = None
build_commit: str | None = None
request_headers: dict[str, str] | None = None
request_snapshot: dict[str, Any] | None = None
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
@@ -25,11 +32,37 @@ _request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
)
_SENSITIVE_TEXT_PATTERN = re.compile(
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)"
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization|password|passwd|secret|credential|api[-_]?key|session)=([^&\s]+)"
)
_SENSITIVE_HEADER_NAME_PATTERN = re.compile(
r"(?i)(authorization|cookie|set-cookie|token|password|passwd|secret|credential|api[-_]?key|session)"
)
_SAFE_REQUEST_HEADER_NAMES = frozenset(
{
"accept",
"accept-language",
"content-type",
"host",
"origin",
"user-agent",
"x-request-id",
"x-correlation-id",
}
)
_SAFE_REQUEST_HEADER_PREFIXES = ("x-ctms-",)
_SENSITIVE_PARAMETER_NAME_PATTERN = re.compile(
r"(?i)(token|authorization|password|passwd|secret|credential|api[-_]?key|session|"
r"subject|participant|patient|user_?name|full_?name|email|phone|mobile|id_?card|"
r"identity|certificate|contact|address)"
)
_KNOWN_CLIENT_TYPES = frozenset({"web", "desktop"})
_CTMS_CLIENT_SOURCE_TO_TYPE = {
"ctms-web": "web",
"ctms-desktop": "desktop",
}
def _clean_header_value(value: str | None, max_length: int) -> str | None:
def _clean_audit_value(value: str | None, max_length: int) -> str | None:
if value is None:
return None
cleaned = _SENSITIVE_TEXT_PATTERN.sub(lambda m: f"{m.group(1) or m.group(2) + '='}[redacted]", value.strip())
@@ -38,26 +71,174 @@ def _clean_header_value(value: str | None, max_length: int) -> str | None:
return cleaned[:max_length]
def _clean_header_value(value: str | None, max_length: int) -> str | None:
return _clean_audit_value(value, max_length)
def resolve_ctms_client_type(headers: Any) -> str | None:
source = _clean_header_value(headers.get("x-ctms-client-source"), 32)
if source:
mapped_type = _CTMS_CLIENT_SOURCE_TO_TYPE.get(source.strip().lower())
if mapped_type:
return mapped_type
client_type = _clean_header_value(headers.get("x-ctms-client-type"), 16)
if not client_type:
return None
normalized = client_type.strip().lower()
return normalized if normalized in _KNOWN_CLIENT_TYPES else normalized[:16]
def build_sanitized_request_headers(request: Any) -> dict[str, str] | None:
captured: dict[str, str] = {}
for raw_name, raw_value in request.headers.items():
name = str(raw_name).strip().lower()
if not name:
continue
if _SENSITIVE_HEADER_NAME_PATTERN.search(name):
captured[name] = "[redacted]"
continue
if name not in _SAFE_REQUEST_HEADER_NAMES and not name.startswith(_SAFE_REQUEST_HEADER_PREFIXES):
continue
value = _clean_header_value(str(raw_value), 500)
if value:
captured[name] = value
return captured or None
def _sanitize_named_value(raw_name: Any, raw_value: Any, max_length: int = 500) -> dict[str, str] | None:
name = _clean_audit_value(str(raw_name), 120)
if not name:
return None
if _SENSITIVE_PARAMETER_NAME_PATTERN.search(name):
return {"name": name, "value": "[redacted]"}
value = _clean_audit_value(str(raw_value), max_length)
if value is None:
return {"name": name, "value": ""}
return {"name": name, "value": value}
def _query_param_items(request: Any) -> list[dict[str, str]] | None:
query_params = getattr(request, "query_params", None)
if not query_params:
return None
if hasattr(query_params, "multi_items"):
raw_items = query_params.multi_items()
else:
raw_items = query_params.items()
items = [
item
for item in (_sanitize_named_value(name, value) for name, value in raw_items)
if item is not None
][:50]
return items or None
def _sanitized_query_string(items: list[dict[str, str]] | None) -> str | None:
if not items:
return None
value = "&".join(f"{item['name']}={item['value']}" for item in items)
return value[:2000] or None
def _request_client_snapshot(request: Any) -> dict[str, str | int | None] | None:
client = getattr(request, "client", None)
if not client:
return None
host = _clean_audit_value(getattr(client, "host", None), 120)
port = getattr(client, "port", None)
if host is None and port is None:
return None
return {"host": host, "port": port}
def build_request_snapshot(request: Any, *, request_id: str | None = None) -> dict[str, Any]:
headers = getattr(request, "headers", {})
scope = getattr(request, "scope", {}) or {}
url = getattr(request, "url", None)
query_params = _query_param_items(request)
path = getattr(url, "path", None) or scope.get("path")
method = getattr(request, "method", None) or scope.get("method")
snapshot: dict[str, Any] = {
"request_id": request_id,
"method": _clean_audit_value(str(method).upper() if method else None, 12),
"path": _clean_audit_value(path, 500),
"query_string": _sanitized_query_string(query_params),
"query_params": query_params,
"headers": build_sanitized_request_headers(request),
"http_version": _clean_audit_value(scope.get("http_version"), 16),
"scheme": _clean_audit_value(getattr(url, "scheme", None) or scope.get("scheme"), 16),
"client": _request_client_snapshot(request),
"content": {
"type": _clean_audit_value(headers.get("content-type"), 200),
"length": _clean_audit_value(headers.get("content-length"), 32),
},
"body": {
"captured": False,
"reason": "body_not_captured_by_audit_policy",
},
}
return {key: value for key, value in snapshot.items() if value not in (None, {}, [])}
def _parse_ip(value: str | None):
try:
return ipaddress.ip_address((value or "").strip())
except ValueError:
return None
def _trusted_proxy_networks():
networks = []
for value in settings.TRUSTED_PROXY_CIDRS.split(","):
candidate = value.strip()
if not candidate:
continue
try:
networks.append(ipaddress.ip_network(candidate, strict=False))
except ValueError:
continue
return tuple(networks)
def _is_trusted_proxy(value: str | None) -> bool:
address = _parse_ip(value)
return bool(address and any(address in network for network in _trusted_proxy_networks()))
def resolve_client_ip(request: Any) -> str | None:
peer_ip = request.client.host if request.client else None
if not _is_trusted_proxy(peer_ip):
return _clean_header_value(peer_ip, 45)
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return _clean_header_value(forwarded.split(",")[0].strip(), 45)
chain = [part.strip() for part in forwarded.split(",") if _parse_ip(part)]
chain.append(str(peer_ip))
for candidate in reversed(chain):
if not _is_trusted_proxy(candidate):
return _clean_header_value(candidate, 45)
real_ip = request.headers.get("x-real-ip")
if real_ip:
if _parse_ip(real_ip):
return _clean_header_value(real_ip.strip(), 45)
return _clean_header_value(request.client.host if request.client else None, 45)
return _clean_header_value(peer_ip, 45)
def build_request_audit_context(request: Any) -> RequestAuditContext:
headers = request.headers
request_id = str(uuid.uuid4())
return RequestAuditContext(
request_id=request_id,
client_ip=resolve_client_ip(request),
user_agent=_clean_header_value(headers.get("user-agent"), 500),
client_type=_clean_header_value(headers.get("x-ctms-client-type"), 16),
client_type=resolve_ctms_client_type(headers),
client_version=_clean_header_value(headers.get("x-ctms-client-version"), 32),
client_platform=_clean_header_value(headers.get("x-ctms-client-platform"), 16),
build_channel=_clean_header_value(headers.get("x-ctms-build-channel"), 16),
build_commit=_clean_header_value(headers.get("x-ctms-build-commit"), 64),
request_headers=build_sanitized_request_headers(request),
request_snapshot=build_request_snapshot(request, request_id=request_id),
)
+3
View File
@@ -22,6 +22,7 @@ def create_access_token(
max_age_seconds: Optional[int] = None,
issued_at: Optional[datetime] = None,
client_type: Optional[str] = None,
session_id: Optional[str] = None,
) -> str:
now = issued_at or datetime.now(timezone.utc)
if now.tzinfo is None:
@@ -42,6 +43,8 @@ def create_access_token(
}
if client_type:
to_encode["client_type"] = client_type
if session_id:
to_encode["sid"] = session_id
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)