Files
ctms/backend/app/core/request_context.py
T

74 lines
2.5 KiB
Python

"""Request-scoped metadata used by server-side audit writers."""
from __future__ import annotations
import re
from contextvars import ContextVar, Token
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class RequestAuditContext:
client_ip: str | None = None
user_agent: str | None = None
client_type: str | None = None
client_version: str | None = None
client_platform: str | None = None
build_channel: str | None = None
build_commit: str | None = None
_request_audit_context: ContextVar[RequestAuditContext | None] = ContextVar(
"request_audit_context",
default=None,
)
_SENSITIVE_TEXT_PATTERN = re.compile(
r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]+|((?:access_)?token|authorization)=([^&\s]+)"
)
def _clean_header_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())
if not cleaned:
return None
return cleaned[:max_length]
def resolve_client_ip(request: Any) -> str | None:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return _clean_header_value(forwarded.split(",")[0].strip(), 45)
real_ip = request.headers.get("x-real-ip")
if real_ip:
return _clean_header_value(real_ip.strip(), 45)
return _clean_header_value(request.client.host if request.client else None, 45)
def build_request_audit_context(request: Any) -> RequestAuditContext:
headers = request.headers
return RequestAuditContext(
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_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),
)
def set_request_audit_context(context: RequestAuditContext) -> Token[RequestAuditContext | None]:
return _request_audit_context.set(context)
def reset_request_audit_context(token: Token[RequestAuditContext | None]) -> None:
_request_audit_context.reset(token)
def get_request_audit_context() -> RequestAuditContext | None:
return _request_audit_context.get()