Add module event producer coverage
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Module Matrix / module-matrix (push) Has been cancelled

This commit is contained in:
2026-07-10 18:40:38 +02:00
parent 83784ea19d
commit c61abe154c
10 changed files with 332 additions and 26 deletions

View File

@@ -7,7 +7,15 @@ from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.change_sequence import record_change
from govoplan_core.core.events import current_event_trace, normalize_trace_id
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
current_event_trace,
normalize_trace_id,
publish_platform_event,
)
from govoplan_core.privacy.retention import sanitize_audit_details_for_policy
from govoplan_audit.backend.db.models import AuditLog
@@ -34,6 +42,27 @@ SENSITIVE_DETAIL_KEYS = {
"build_token",
}
_ACTION_MODULE_PREFIXES = {
"api_key": "access",
"configuration_change": "access",
"configuration_package": "access",
"group": "access",
"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:
@@ -135,6 +164,34 @@ def _restore_trace_after_policy(details: dict[str, Any], trace: dict[str, str])
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 _publish_audit_platform_event(item: AuditLog) -> 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,
*,
@@ -194,6 +251,7 @@ def audit_event(
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

View File

@@ -6,6 +6,7 @@ from typing import Any, Iterable, Sequence
from sqlalchemy import BigInteger, DateTime, Index, Integer, JSON, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, Session, mapped_column
from govoplan_core.core.events import EventActorRef, EventObjectRef, EventTenantRef, PlatformEvent, publish_platform_event
from govoplan_core.db.base import Base, utcnow
WATERMARK_PREFIX = "seq:"
@@ -95,9 +96,42 @@ def record_change(
payload=payload or {},
)
session.add(entry)
_publish_change_event(entry)
return entry
def _publish_change_event(entry: ChangeSequenceEntry) -> None:
event_type = _change_event_type(entry.module_id, entry.resource_type, entry.operation)
publish_platform_event(
PlatformEvent(
type=event_type,
module_id=entry.module_id,
payload={
"collection": entry.collection,
"operation": entry.operation,
"payload": dict(entry.payload or {}),
},
actor=EventActorRef(type=entry.actor_type, id=entry.actor_id) if entry.actor_type else None,
tenant=EventTenantRef(id=entry.tenant_id) if entry.tenant_id else None,
resource=EventObjectRef(type=entry.resource_type, id=entry.resource_id),
classification="internal",
)
)
def _change_event_type(module_id: str, resource_type: str, operation: str) -> str:
resource = _event_segment(resource_type)
module = _event_segment(module_id)
if resource.startswith(f"{module}."):
resource = resource[len(module) + 1:]
return ".".join(item for item in (module, resource, _event_segment(operation)) if item)
def _event_segment(value: str) -> str:
compact = "".join(character if character.isalnum() else "." for character in value.casefold())
return ".".join(part for part in compact.split(".") if part)
def max_sequence_id(
session: Session,
*,

View File

@@ -168,5 +168,26 @@ class EventBus:
handler(traced)
_default_event_bus = EventBus()
_current_event_bus: ContextVar[EventBus | None] = ContextVar("govoplan_event_bus", default=None)
def platform_event_bus() -> EventBus:
return _current_event_bus.get() or _default_event_bus
@contextmanager
def event_bus_context(bus: EventBus):
token = _current_event_bus.set(bus)
try:
yield bus
finally:
_current_event_bus.reset(token)
def publish_platform_event(event: PlatformEvent) -> None:
platform_event_bus().publish(event)
def _compact_dict(value: Mapping[str, Any]) -> dict[str, Any]:
return {key: item for key, item in value.items() if item is not None}