126 lines
5.3 KiB
Python
126 lines
5.3 KiB
Python
"""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()
|