292 lines
9.4 KiB
Python
292 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,
|
|
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,
|
|
)
|