Implement durable event ingestion service
This commit is contained in:
parent
254ed92027
commit
be85ce7c43
6 changed files with 293 additions and 0 deletions
126
audit_core/ingestion.py
Normal file
126
audit_core/ingestion.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""Authenticated, idempotent HTTP ingestion for user-engine outbox events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from wsgiref.simple_server import make_server
|
||||
|
||||
from audit_core.interface import AuditEvent
|
||||
|
||||
MAX_BODY_BYTES = 256 * 1024
|
||||
_SECRET_FRAGMENTS = ("password", "secret", "token", "credential", "private_key")
|
||||
|
||||
|
||||
class SQLiteEventStore:
|
||||
def __init__(self, path: str) -> None:
|
||||
self.db = sqlite3.connect(path, check_same_thread=False)
|
||||
self.db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id TEXT PRIMARY KEY, payload_hash TEXT NOT NULL,
|
||||
accepted_at TEXT NOT NULL, record TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
self.db.commit()
|
||||
|
||||
def accept(self, event: AuditEvent, payload_hash: str) -> tuple[bool, str]:
|
||||
existing = self.db.execute(
|
||||
"SELECT payload_hash FROM events WHERE event_id = ?", (event.event_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing[0] != payload_hash:
|
||||
raise ValueError("event_id_conflict")
|
||||
return True, f"audit:{event.event_id}"
|
||||
with self.db:
|
||||
self.db.execute(
|
||||
"INSERT INTO events VALUES (?, ?, ?, ?)",
|
||||
(event.event_id, payload_hash, datetime.now().astimezone().isoformat(),
|
||||
json.dumps(event.as_record(), sort_keys=True)),
|
||||
)
|
||||
return False, f"audit:{event.event_id}"
|
||||
|
||||
|
||||
class IngestionApplication:
|
||||
def __init__(self, store: SQLiteEventStore, bearer_token: str) -> None:
|
||||
if not bearer_token:
|
||||
raise ValueError("bearer token is required")
|
||||
self.store = store
|
||||
self.token = bearer_token
|
||||
|
||||
def __call__(self, environ, start_response):
|
||||
path = environ.get("PATH_INFO", "")
|
||||
if path in ("/healthz", "/readyz"):
|
||||
return self._json(start_response, HTTPStatus.OK, {"status": "ok"})
|
||||
if path != "/v1/events" or environ.get("REQUEST_METHOD") != "POST":
|
||||
return self._json(start_response, HTTPStatus.NOT_FOUND, {"error": "not_found"})
|
||||
supplied = str(environ.get("HTTP_AUTHORIZATION", ""))
|
||||
if not hmac.compare_digest(supplied, f"Bearer {self.token}"):
|
||||
return self._json(start_response, HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"})
|
||||
try:
|
||||
length = int(environ.get("CONTENT_LENGTH") or 0)
|
||||
if length <= 0 or length > MAX_BODY_BYTES:
|
||||
raise ValueError("invalid_size")
|
||||
raw = environ["wsgi.input"].read(length)
|
||||
payload = json.loads(raw)
|
||||
event = normalize(payload, environ.get("HTTP_IDEMPOTENCY_KEY"))
|
||||
duplicate, reference = self.store.accept(
|
||||
event, hashlib.sha256(raw).hexdigest()
|
||||
)
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
||||
return self._json(start_response, HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
||||
return self._json(start_response, HTTPStatus.OK if duplicate else HTTPStatus.ACCEPTED,
|
||||
{"status": "duplicate" if duplicate else "accepted",
|
||||
"reference": reference})
|
||||
|
||||
@staticmethod
|
||||
def _json(start_response, status: HTTPStatus, payload: dict[str, Any]):
|
||||
body = json.dumps(payload).encode()
|
||||
start_response(f"{status.value} {status.phrase}", [
|
||||
("Content-Type", "application/json"), ("Content-Length", str(len(body)))
|
||||
])
|
||||
return [body]
|
||||
|
||||
|
||||
def normalize(payload: dict[str, Any], idempotency_key: str | None) -> AuditEvent:
|
||||
required = ("id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data")
|
||||
if not isinstance(payload, dict) or any(not payload.get(key) for key in required):
|
||||
raise ValueError("invalid_event")
|
||||
if idempotency_key != payload["id"]:
|
||||
raise ValueError("idempotency_key_mismatch")
|
||||
if payload["source"] != "user-engine":
|
||||
raise ValueError("source_not_allowed")
|
||||
datetime.fromisoformat(str(payload["occurred_at"]).replace("Z", "+00:00"))
|
||||
if _contains_secret(payload["data"]):
|
||||
raise ValueError("secret_shaped_field")
|
||||
return AuditEvent(
|
||||
event_id=str(payload["id"]), observed_at=str(payload["occurred_at"]),
|
||||
tenant=str(payload["tenant"]), scope="tenant", source="user-engine",
|
||||
action=str(payload["type"]), resource=str(payload["subject"]),
|
||||
outcome="recorded", actor=None,
|
||||
details={"correlation_id": str(payload["correlation_id"]), "data": payload["data"]},
|
||||
)
|
||||
|
||||
|
||||
def _contains_secret(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return any(any(fragment in str(key).lower() for fragment in _SECRET_FRAGMENTS)
|
||||
or _contains_secret(item) for key, item in value.items())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_secret(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
app = IngestionApplication(
|
||||
SQLiteEventStore(os.environ.get("AUDIT_CORE_DATABASE_PATH", "/data/audit-core.db")),
|
||||
os.environ["AUDIT_CORE_INGEST_TOKEN"].strip(),
|
||||
)
|
||||
with make_server(os.environ.get("AUDIT_CORE_HOST", "0.0.0.0"),
|
||||
int(os.environ.get("AUDIT_CORE_HTTP_PORT", "8080")), app) as server:
|
||||
server.serve_forever()
|
||||
Loading…
Add table
Add a link
Reference in a new issue