Close 12 audit-driven platform-compliance gaps on a single branch. - D4 dispatch: app/integrations/dispatch_client.py participant `legacyhub`, emits LegacyhubDocumentIndexed + AssetDerivativeReady after the indexing commit (idempotent uuid5), http_inbox route (reindex/tombstone) with audit-based dedupe; docs/dispatch-contract.md. Celery+Redis stays intra-module. - D2 SSO: app/integrations/identity.py validates X-TeamHub-* + role/scope mapper; security.py adds trusted-header enforcement (AUTH_REQUIRE_IDENTITY) and a scope check on /search; docker-compose.teamhub.yml (external teamhub_net + internal db net, api not host-published); RUNBOOK network/firewall section. - Asset standard: SearchHit/Citation carry asset_id/owner_module; buckets renamed teamhub-legacyhub-* (+quarantine/tmp/exports); purge-by-asset_id with legal-hold guard (app/indexing/projection.py); OCR-markdown derivative event. - audit_log model + Alembic 0003 + record_audit on writes (same transaction). - Secret masking: app/common/json_logger.py recursive mask wired into structlog (+ensure_ascii=False); event payloads redacted before persistence. - Service X-API-Key mandatory on ingest endpoints (defence-in-depth). - Port: host API 8000->8050 (collision with SalesHUB/MailHUB resolved), container still listens on 8000. - Config: no plaintext secret defaults; fail-loud in non-dev (no value leak). - Docs drift: README PG 5440, layered-auth note, 5173 removed from CORS; ingest/folder gated by ENABLE_FOLDER_INGEST (410 by default). - ADRs: layers mapping, shared-core extraction, UI locale (RU-first). Tests: 78 passing (ruff, compileall, pytest, tsc, vite build, compose config). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
275 lines
9.8 KiB
Python
275 lines
9.8 KiB
Python
"""Dispatch participant client (D4 / 08_DECISIONS D3-D4, INTER_MODULE_CONTRACT §2).
|
|
|
|
LegacyHUB joins the shared platform bus as participant ``legacyhub``. This module
|
|
publishes events (``LegacyhubDocumentIndexed``, ``AssetDerivativeReady``) to the
|
|
``dispatch-api`` over HTTP with an ``X-API-Key``, and handles inbound command
|
|
events delivered to the module's HTTP inbox.
|
|
|
|
Design notes:
|
|
- Publishing is best-effort and happens AFTER the DB commit; a dispatch outage
|
|
never fails document processing. Event ids are deterministic (uuid5 over the
|
|
business key) so re-emits are idempotent for downstream consumers.
|
|
- The body carries JSON only - asset ids and object-storage refs, never binary
|
|
payloads, presigned URLs, or local filesystem paths (10 §7).
|
|
- Internal Celery+Redis stays the intra-module queue; this is the inter-module
|
|
channel and the two are not conflated (D3).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app import __version__
|
|
from app.config import settings
|
|
from app.logging_config import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Stable namespace for deterministic (idempotent) event ids.
|
|
_DISPATCH_NAMESPACE = uuid.UUID("6f1c8d2e-2a4b-5c6d-8e9f-0a1b2c3d4e5f")
|
|
|
|
EVENT_DOCUMENT_INDEXED = "LegacyhubDocumentIndexed"
|
|
EVENT_ASSET_DERIVATIVE_READY = "AssetDerivativeReady"
|
|
|
|
|
|
def deterministic_event_id(*parts: Any) -> str:
|
|
return str(uuid.uuid5(_DISPATCH_NAMESPACE, ":".join(str(p) for p in parts)))
|
|
|
|
|
|
def build_envelope(
|
|
message_type: str,
|
|
card_uid: str,
|
|
body: dict[str, Any],
|
|
*,
|
|
event_id: str | None = None,
|
|
message_id: str | None = None,
|
|
created_at: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Build the dispatch JSON envelope (INTER_MODULE_CONTRACT §2.4)."""
|
|
return {
|
|
"message_id": message_id or str(uuid.uuid4()),
|
|
"event_id": event_id or str(uuid.uuid4()),
|
|
"card_uid": str(card_uid),
|
|
"message_type": message_type,
|
|
"participant_code": settings.dispatch_participant_code,
|
|
"created_at": created_at or datetime.now(tz=UTC).isoformat(),
|
|
"body": body,
|
|
}
|
|
|
|
|
|
def publish(
|
|
message_type: str,
|
|
card_uid: str,
|
|
body: dict[str, Any],
|
|
*,
|
|
event_id: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
"""Publish one envelope to dispatch-api. Best-effort; returns the envelope on
|
|
success, ``None`` when disabled or on a (logged) transport failure."""
|
|
if not settings.dispatch_enabled:
|
|
logger.info("dispatch.disabled", message_type=message_type, card_uid=str(card_uid))
|
|
return None
|
|
if not settings.dispatch_api_url:
|
|
logger.warning("dispatch.no_url", message_type=message_type)
|
|
return None
|
|
|
|
envelope = build_envelope(message_type, card_uid, body, event_id=event_id)
|
|
try:
|
|
res = httpx.post(
|
|
f"{settings.dispatch_api_url.rstrip('/')}/messages",
|
|
json=envelope,
|
|
headers={"X-API-Key": settings.dispatch_api_key},
|
|
timeout=settings.dispatch_timeout_seconds,
|
|
)
|
|
res.raise_for_status()
|
|
except Exception as exc: # noqa: BLE001 - dispatch must never break ingestion
|
|
logger.warning("dispatch.publish_failed", message_type=message_type, error=str(exc))
|
|
return None
|
|
|
|
logger.info("dispatch.published", message_type=message_type, event_id=envelope["event_id"])
|
|
return envelope
|
|
|
|
|
|
def publish_document_indexed(
|
|
document_id: uuid.UUID | str, *, asset_metadata: dict[str, Any] | None = None
|
|
) -> dict[str, Any] | None:
|
|
body: dict[str, Any] = {"document_id": str(document_id)}
|
|
card_uid = str(document_id)
|
|
if asset_metadata:
|
|
for key in ("asset_id", "owner_module", "owner_record_type", "owner_record_id", "manifest_version"):
|
|
if asset_metadata.get(key):
|
|
body[key] = asset_metadata[key]
|
|
if asset_metadata.get("asset_id"):
|
|
card_uid = str(asset_metadata["asset_id"])
|
|
event_id = deterministic_event_id(EVENT_DOCUMENT_INDEXED, card_uid)
|
|
return publish(EVENT_DOCUMENT_INDEXED, card_uid, body, event_id=event_id)
|
|
|
|
|
|
def publish_derivative_ready(
|
|
asset_metadata: dict[str, Any],
|
|
*,
|
|
derivative_type: str = "ocr_markdown",
|
|
content_type: str = "text/markdown",
|
|
) -> dict[str, Any] | None:
|
|
asset_id = asset_metadata.get("asset_id")
|
|
if not asset_id:
|
|
return None
|
|
body = {
|
|
"event_type": EVENT_ASSET_DERIVATIVE_READY, # canonical body-level type (10 §7)
|
|
"asset_id": asset_id,
|
|
"owner_module": asset_metadata.get("owner_module"),
|
|
"owner_record_type": asset_metadata.get("owner_record_type"),
|
|
"owner_record_id": asset_metadata.get("owner_record_id"),
|
|
"derivative_type": derivative_type,
|
|
"content_type": content_type,
|
|
"source_asset_sha256": asset_metadata.get("sha256"),
|
|
"generator": {"pipeline": "legacyhub", "version": __version__},
|
|
}
|
|
event_id = deterministic_event_id(EVENT_ASSET_DERIVATIVE_READY, asset_id, derivative_type)
|
|
return publish(EVENT_ASSET_DERIVATIVE_READY, str(asset_id), body, event_id=event_id)
|
|
|
|
|
|
def emit_after_indexing(document_id: uuid.UUID) -> None:
|
|
"""Emit indexing events after a document reaches INDEXING_COMPLETED.
|
|
|
|
Called at the tail of the pipeline (after commit). No-op when dispatch is
|
|
disabled. Always best-effort.
|
|
"""
|
|
if not settings.dispatch_enabled:
|
|
return
|
|
from app.ingestion.knowledge_ingest import ( # noqa: PLC0415
|
|
load_asset_ingest_options,
|
|
load_asset_metadata_for_document,
|
|
)
|
|
|
|
meta = load_asset_metadata_for_document(document_id) or {}
|
|
publish_document_indexed(document_id, asset_metadata=meta or None)
|
|
|
|
if meta.get("asset_id"):
|
|
options = load_asset_ingest_options(document_id)
|
|
if options.get("emit_events", True):
|
|
publish_derivative_ready(meta)
|
|
|
|
|
|
# ---- Inbound (HTTP inbox) ----
|
|
|
|
def handle_inbox_message(envelope: dict[str, Any]) -> dict[str, Any]:
|
|
"""Process one inbound dispatch envelope idempotently.
|
|
|
|
Idempotency: an ``audit_log`` row keyed on ``event_id`` marks a processed
|
|
event; a repeat delivery is acknowledged without reprocessing (at-least-once
|
|
delivery, 10 §7).
|
|
"""
|
|
from sqlalchemy import select # noqa: PLC0415
|
|
|
|
from app.db.audit import record_audit # noqa: PLC0415
|
|
from app.db.models import AuditLog # noqa: PLC0415
|
|
from app.db.session import session_scope # noqa: PLC0415
|
|
|
|
event_id = str(envelope.get("event_id") or "")
|
|
message_type = str(envelope.get("message_type") or "")
|
|
body = envelope.get("body") or {}
|
|
|
|
with session_scope() as db:
|
|
already = db.execute(
|
|
select(AuditLog).where(
|
|
AuditLog.action == "dispatch.inbox.processed",
|
|
AuditLog.entity_id == event_id,
|
|
)
|
|
).first()
|
|
if already is not None:
|
|
return {"status": "confirmed", "event_id": event_id, "duplicate": True}
|
|
|
|
handled = _handle_inbox(message_type, body)
|
|
record_audit(
|
|
db,
|
|
action="dispatch.inbox.processed",
|
|
actor=f"participant:{envelope.get('participant_code')}",
|
|
entity_type="dispatch_event",
|
|
entity_id=event_id,
|
|
details={"message_type": message_type, "handled": handled},
|
|
)
|
|
return {"status": "confirmed", "event_id": event_id, "handled": handled}
|
|
|
|
|
|
def _handle_inbox(message_type: str, body: dict[str, Any]) -> bool:
|
|
"""Return True if the message triggered an action."""
|
|
if message_type in ("ReindexRequested", "LegacyhubReindexRequested"):
|
|
return _enqueue_reindex(body)
|
|
if message_type in ("AssetTombstoned", "AssetDeleted"):
|
|
return _purge_projection(body)
|
|
logger.info("dispatch.inbox.ignored", message_type=message_type)
|
|
return False
|
|
|
|
|
|
def _purge_projection(body: dict[str, Any]) -> bool:
|
|
asset_id = body.get("asset_id")
|
|
if not asset_id:
|
|
logger.warning("dispatch.tombstone.no_asset_id")
|
|
return False
|
|
from app.indexing.projection import purge_projection_by_asset_id # noqa: PLC0415
|
|
|
|
result = purge_projection_by_asset_id(str(asset_id))
|
|
return bool(result.get("purged"))
|
|
|
|
|
|
def _enqueue_reindex(body: dict[str, Any]) -> bool:
|
|
from sqlalchemy import select # noqa: PLC0415
|
|
|
|
from app.db.models import AssetIngestJob # noqa: PLC0415
|
|
from app.db.session import session_scope # noqa: PLC0415
|
|
|
|
document_id = body.get("document_id")
|
|
asset_id = body.get("asset_id")
|
|
if document_id is None and asset_id is not None:
|
|
with session_scope() as db:
|
|
job = (
|
|
db.execute(
|
|
select(AssetIngestJob)
|
|
.where(AssetIngestJob.asset_id == str(asset_id))
|
|
.order_by(AssetIngestJob.created_at.desc())
|
|
)
|
|
.scalars()
|
|
.first()
|
|
)
|
|
document_id = str(job.document_id) if job is not None else None
|
|
if document_id is None:
|
|
logger.warning("dispatch.reindex.unresolved", asset_id=asset_id)
|
|
return False
|
|
|
|
from app.db.audit import record_audit # noqa: PLC0415
|
|
from app.db.session import session_scope # noqa: PLC0415
|
|
|
|
with session_scope() as db:
|
|
record_audit(
|
|
db,
|
|
action="asset.reindex",
|
|
actor="dispatch",
|
|
entity_type="document",
|
|
entity_id=str(document_id),
|
|
details={"asset_id": asset_id},
|
|
)
|
|
|
|
from app.workers.tasks import process_document # noqa: PLC0415
|
|
|
|
process_document.delay(str(document_id), str(uuid.uuid4()))
|
|
logger.info("dispatch.reindex.enqueued", document_id=str(document_id))
|
|
return True
|
|
|
|
|
|
__all__ = [
|
|
"EVENT_ASSET_DERIVATIVE_READY",
|
|
"EVENT_DOCUMENT_INDEXED",
|
|
"build_envelope",
|
|
"deterministic_event_id",
|
|
"emit_after_indexing",
|
|
"handle_inbox_message",
|
|
"publish",
|
|
"publish_derivative_ready",
|
|
"publish_document_indexed",
|
|
]
|