""" Manipulationssicheres, hash-verkettetes Audit-Log (siehe Konzept 4.7 / 6.1). Jeder Eintrag verkettet sich kryptographisch mit seinem Vorgaenger: entry_hash = sha256(prev_hash || ts || event_type || details_json) Nachtraegliches Aendern oder Herausloeschen eines Eintrags bricht die Kette ab dieser Stelle - erkennbar durch verify_chain(). Zusaetzlich verhindern DB-Trigger (0001_initial.sql) UPDATE/DELETE auf Anwendungsebene. """ from __future__ import annotations import hashlib import json from datetime import datetime, timezone from typing import Any import asyncio import aiosqlite GENESIS_HASH = "0" * 64 def _entry_hash(prev_hash: str, ts: str, event_type: str, details_json: str) -> str: payload = f"{prev_hash}|{ts}|{event_type}|{details_json}".encode() return hashlib.sha256(payload).hexdigest() # E1 (Umsetzungsauftrag_Sonnet5.md Teil E.1): Lesen von prev_hash und das # nachfolgende INSERT muessen atomar sein. Ohne Schutz koennen zwei # gleichzeitig laufende Aufrufe (z.B. zwei Sitzungen, die im selben Moment # starten/enden) denselben prev_hash lesen und beide darauf aufbauen -- die # Kette bricht dann dauerhaft, weil UPDATE/DELETE auf audit_log durch # DB-Trigger verboten sind (0001_initial.sql). Zwei Schutzschichten: # 1. Ein prozessweiter asyncio.Lock serialisiert alle Aufrufe innerhalb # dieses (einzigen) Event-Loops -- ausreichend, solange die Anwendung in # einem uvicorn-Worker mit einer globalen Verbindung laeuft (E.0/E.5). # 2. BEGIN IMMEDIATE/COMMIT als zweite, von der Prozesstopologie # unabhaengige Schutzschicht: falls doch einmal mehrere Verbindungen auf # dieselbe Datei schreiben, sorgt die IMMEDIATE-Schreibsperre dafuer, # dass SELECT und INSERT nicht auseinandergerissen werden. _write_lock = asyncio.Lock() async def write_audit_event( conn: aiosqlite.Connection, *, event_type: str, user_id: int | None, client_ip: str | None, details: dict[str, Any], ) -> None: """Schreibt einen Audit-Eintrag; haengt ihn an die bestehende Hash-Chain an. Muss innerhalb derselben Transaktion wie die fachliche Aktion laufen (oder zumindest unmittelbar danach), damit kein Ereignis unauditiert bleibt. Lesen des prev_hash und das INSERT sind durch einen Lock plus eine BEGIN-IMMEDIATE-Transaktion atomar (siehe E1 in Umsetzungsauftrag_Sonnet5.md). """ async with _write_lock: await conn.execute("BEGIN IMMEDIATE") try: cursor = await conn.execute( "SELECT entry_hash FROM audit_log ORDER BY id DESC LIMIT 1" ) row = await cursor.fetchone() prev_hash = row[0] if row else GENESIS_HASH ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") details_json = json.dumps(details, sort_keys=True, ensure_ascii=False) entry_hash = _entry_hash(prev_hash, ts, event_type, details_json) await conn.execute( "INSERT INTO audit_log (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash) " "VALUES (?, ?, ?, ?, ?, ?, ?)", (ts, user_id, client_ip, event_type, details_json, prev_hash, entry_hash), ) except BaseException: await conn.execute("ROLLBACK") raise else: await conn.execute("COMMIT") async def verify_chain(conn: aiosqlite.Connection) -> tuple[bool, int | None]: """Prueft die gesamte Audit-Log-Kette. Rueckgabe: (intakt?, erste kaputte id).""" prev_hash = GENESIS_HASH cursor = await conn.execute( "SELECT id, ts, event_type, details_json, prev_hash, entry_hash FROM audit_log ORDER BY id ASC" ) async for row in cursor: entry_id, ts, event_type, details_json, stored_prev, stored_entry = row if stored_prev != prev_hash: return False, entry_id expected = _entry_hash(prev_hash, ts, event_type, details_json) if expected != stored_entry: return False, entry_id prev_hash = stored_entry return True, None