Files
govoplan-core/src/govoplan_core/audit/logging.py
2026-07-11 17:00:37 +02:00

288 lines
9.4 KiB
Python

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,
publish_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(item: AuditRecordRef) -> None:
trace = _compact_trace(item.details.get("_trace") if isinstance(item.details, Mapping) else None)
publish_platform_event(
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(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,
)