"""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()