from __future__ import annotations from collections.abc import Mapping from uuid import uuid4 from typing import Any from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.change_sequence import record_change from govoplan_core.core.access import AuditEvent, AuditRecordRef, AuditRecorder, CAPABILITY_AUDIT_RECORDER from govoplan_core.core.events import ( EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, current_event_trace, normalize_trace_id, emit_platform_event, ) from govoplan_core.core.runtime import get_registry from govoplan_core.privacy.retention import sanitize_audit_details_for_policy from govoplan_core.security.redaction import redact_secret_values AUDIT_MODULE_ID = "audit" AUDIT_TENANT_EVENTS_COLLECTION = "audit.admin.tenant_events" AUDIT_SYSTEM_EVENTS_COLLECTION = "audit.admin.system_events" _ACTION_MODULE_PREFIXES = { "api_key": "access", "configuration_change": "access", "configuration_package": "access", "group": "access", "idm": "idm", "profile": "access", "role": "access", "system_access": "access", "system_account": "access", "system_memberships": "access", "system_role": "access", "user": "access", "tenant": "tenancy", "privacy_retention": "policy", "retention_policy": "policy", "files": "files", "mail": "mail", "campaign": "campaigns", "report": "campaigns", } def _optional_text(value: object | None) -> str | None: if value is None: return None candidate = str(value).strip() return candidate or None def _compact_trace(trace: Mapping[str, object] | None) -> dict[str, str]: if not isinstance(trace, Mapping): return {} compact: dict[str, str] = {} correlation_id = _optional_text(trace.get("correlation_id")) causation_id = _optional_text(trace.get("causation_id")) clean_correlation_id = normalize_trace_id(correlation_id) clean_causation_id = normalize_trace_id(causation_id) if clean_correlation_id: compact["correlation_id"] = clean_correlation_id if clean_causation_id: compact["causation_id"] = clean_causation_id return compact def _sanitize_details(value: Any) -> Any: return redact_secret_values(value) def audit_operation_context( *, module_id: object | None = None, request_id: object | None = None, run_id: object | None = None, outcome: object | None = None, trace: Mapping[str, object] | None = None, **details: Any, ) -> dict[str, Any]: """Build concise operational audit details for admin and lifecycle actions.""" payload: dict[str, Any] = {} for key, value in ( ("module_id", module_id), ("request_id", request_id), ("run_id", run_id), ("outcome", outcome), ): text = _optional_text(value) if text is not None: payload[key] = text for key, value in details.items(): if value is not None: payload[key] = _sanitize_details(value) compact_trace = _compact_trace(trace) if compact_trace: existing = payload.get("_trace") payload["_trace"] = {**existing, **compact_trace} if isinstance(existing, dict) else compact_trace return payload def _trace_details( details: dict[str, Any], *, correlation_id: str | None = None, causation_id: str | None = None, ) -> tuple[dict[str, Any], dict[str, str]]: active_trace = current_event_trace() trace: dict[str, str] = {} clean_correlation_id = normalize_trace_id(correlation_id) clean_causation_id = normalize_trace_id(causation_id) if clean_correlation_id or active_trace: trace["correlation_id"] = clean_correlation_id or active_trace.correlation_id if clean_causation_id or (active_trace and active_trace.causation_id): trace["causation_id"] = clean_causation_id or str(active_trace.causation_id) if not trace: return details, {} merged = dict(details) existing = merged.get("_trace") merged["_trace"] = {**existing, **trace} if isinstance(existing, dict) else trace return merged, trace def _restore_trace_after_policy(details: dict[str, Any], trace: dict[str, str]) -> dict[str, Any]: if not trace: return details if details.get("_trace") == trace: return details restored = dict(details) existing = restored.get("_trace") restored["_trace"] = {**existing, **trace} if isinstance(existing, dict) else trace return restored def _module_id_for_audit_action(action: str) -> str: prefix = action.split(".", 1)[0].strip().casefold() return _ACTION_MODULE_PREFIXES.get(prefix, prefix or AUDIT_MODULE_ID) def _audit_recorder() -> AuditRecorder: registry = get_registry() if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(CAPABILITY_AUDIT_RECORDER): return _NullAuditRecorder() capability = registry.require_capability(CAPABILITY_AUDIT_RECORDER) if not isinstance(capability, AuditRecorder): raise RuntimeError("Audit recorder capability is invalid") return capability class _NullAuditRecorder: def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef: del session return AuditRecordRef( id=str(uuid4()), scope=event.scope, tenant_id=event.tenant_id, user_id=event.user_id, api_key_id=event.api_key_id, action=event.action, object_type=event.resource_type, object_id=event.resource_id, details=dict(event.details or {}), ) def _publish_audit_platform_event( session: Session, item: AuditRecordRef, ) -> None: trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None) emit_platform_event( session, PlatformEvent( type=item.action, module_id=_module_id_for_audit_action(item.action), payload={ "audit_log_id": item.id, "scope": item.scope, "details": dict(item.details or {}), }, correlation_id=trace.get("correlation_id"), causation_id=trace.get("causation_id"), actor=EventActorRef(type="user", id=item.user_id) if item.user_id else ( EventActorRef(type="api_key", id=item.api_key_id) if item.api_key_id else None ), tenant=EventTenantRef(id=item.tenant_id) if item.tenant_id else None, resource=EventObjectRef(type=item.object_type, id=item.object_id) if item.object_type else None, classification="internal", ) ) def audit_event( session: Session, *, tenant_id: str | None, action: str, scope: str = "tenant", user_id: str | None = None, api_key_id: str | None = None, object_type: str | None = None, object_id: str | None = None, details: dict[str, Any] | None = None, correlation_id: str | None = None, causation_id: str | None = None, commit: bool = False, ) -> AuditRecordRef: """Persist one audit event. The function deliberately accepts primitive IDs so it can be used from API handlers, CLI commands and worker code without coupling the lower-level services to FastAPI request objects. """ if scope not in {"tenant", "system"}: raise ValueError(f"Unsupported audit scope: {scope}") traced_details, trace = _trace_details( _sanitize_details(details or {}), correlation_id=correlation_id, causation_id=causation_id, ) stored_details = _restore_trace_after_policy( sanitize_audit_details_for_policy(session, traced_details), trace, ) item = _audit_recorder().record_event(session, AuditEvent( event_type=action, action=action, scope=scope, tenant_id=tenant_id, user_id=user_id, api_key_id=api_key_id, resource_type=object_type, resource_id=object_id, details=stored_details, )) record_change( session, module_id=AUDIT_MODULE_ID, collection=AUDIT_SYSTEM_EVENTS_COLLECTION if scope == "system" else AUDIT_TENANT_EVENTS_COLLECTION, resource_type="audit_log", resource_id=item.id, operation="created", tenant_id=None if scope == "system" else tenant_id, actor_type="user" if user_id else ("api_key" if api_key_id else None), actor_id=user_id or api_key_id, payload={"scope": scope, "action": action, "object_type": object_type, "object_id": object_id}, ) _publish_audit_platform_event(session, item) if commit: session.commit() return item def audit_from_principal( session: Session, principal: ApiPrincipal, *, action: str, scope: str = "tenant", object_type: str | None = None, object_id: str | None = None, details: dict[str, Any] | None = None, correlation_id: str | None = None, causation_id: str | None = None, commit: bool = False, ) -> AuditRecordRef: return audit_event( session, tenant_id=principal.tenant_id, user_id=principal.user.id, api_key_id=principal.api_key.id if principal.api_key else None, action=action, scope=scope, object_type=object_type, object_id=object_id, details=details, correlation_id=correlation_id, causation_id=causation_id, commit=commit, )