Zum Hauptinhalt springen

Audit-Log / Änderungshistorie — Architektur-Design (OP-AUDIT-1)

Status: in Umsetzung — Architektur festgezurrt (#182) · Slice 1 ✅ (#183: Abwesenheits-/Wunschfrei- Historie interim im DO-Blob, entsperrt die OP-OPT-7-Fairness) · D1-Pfad gebaut (#184) · D1-DB provisioniert + migriert (#185: taktano-audit, EU weur) · D1 ✅ aktiv (#186: Binding gesetzt, Dual-Write + Backfill live, §7**)** · Chronik-Lesepfad ✅ (Slice 3, v0.152.0: GET /audit/liste + read-only Panel) · offen: breite Event-Erfassung (Slice 2) + rollenbasierte Sicht/Filter/Suche (§5/§8). #182/#183 2026-06-24, #184/#185 2026-06-25. Verbindlicher Entwurf; Abweichungen beim Bau hier nachziehen. · Bezug: OP-AUDIT-1 (Owning) · OP-TENANT (E-TENANT-1=C) · OP-OPT-7 · OP-ACCESS-1 · OP-COMPLIANCE-1 · OP-DATA-1 · Zielgruppe: IT-Dev/Architektur · CISO/Security · True North: nur indirekt — manipulationssichere Historie sichert die Datenbasis der drei Leitfragen, beantwortet selbst keine davon direkt

Kernaussage. Jede relevante Änderung wird manipulationssicher protokolliert — append-only + Hash-Chain („nicht reverse-engineerbar"). Finaler Zuschnitt (E-TENANT-1 = C): primär je Tenant in DO-SQLite, die zentrale D1 (taktano-audit, EU) dient nur als Tamper-Anker (Chain-Kopf je Tenant, keine Events/PII zentral; heute interim noch Dual-Write voller Events). Mit Retention — Fundament für Compliance/Audit-Bereitschaft (DE/AT/CH) und Point-in-Time-Restore.

1. Zweck & Abgrenzung

Lückenlose, unveränderliche Historie aller relevanten Änderungen — wer · was · wann · vorher→nachher — als Grundlage für Nachvollziehbarkeit, Fairness-Auswertungen (OP-OPT-7), Zugangs-/Billing-Nachweise (OP-ACCESS-1) und Zertifizierung/Audit (OP-COMPLIANCE-1, ISO 27001).

Abgrenzung (wichtig):

  • Audit-Log ≠ Betriebs-Logs (OP-LOG-1/OP-OBS-1). Audit = fachliche Compliance-Historie (langlebig, abfragbar, rollenbasiert). Betriebs-Logs = Diagnose/Telemetrie (OTel). Getrennt halten; gemeinsame trace_id zur Korrelation.
  • Audit-Log ≠ Auftrags-Protokoll. auftraege[].protokoll (Auftrags-Chronik) bleibt als auftragsnahe Anzeige; das Audit-Log verallgemeinert es übergreifend (alle Ressourcen).
  • Derived State nie speichern (CLAUDE.md): das Audit-Log speichert Fakten/Ereignisse, keine ableitbaren Aggregate (Fairness-Zahlen werden daraus berechnet, nicht gespeichert).

2. Speicher-Entscheidung

Verfeinerung 2026-06-27 (OP-TENANT, docs/architektur/Mandantenfaehigkeit.md §4). Mit der Tenant-Isolations- Entscheidung „Daten pro Tenant komplett getrennt" wandert das Audit-Log primär in die DO-SQLite je Tenant (append-only Tabelle, kein UPDATE/DELETE im Code, Hash-Chain für Tamper-Evidenz statt physischer Gewaltenteilung) — volle Isolation + Audit-PITR je Tenant. E-TENANT-1 ✅ entschieden 06-27 = (C) zentraler Hash-Anker: die unten beschriebene D1 (taktano-audit, heute live, #186) wird nicht weggeworfen, sondern umgewidmetnicht zum vollen Event-Sink, sondern zum Tamper-Anker: zentral liegt nur der Chain-Kopf je Tenant ({tenant_id, seq, head_hash, ts}, Tabelle audit_anchors), keine Events, keine PII. Off-box-Manipulationsnachweis ohne zentrale Datenhaltung; Cross-Tenant- Reporting bleibt Fan-out (voller Sink = Upgrade C→A nur bei echtem Bedarf). Der heutige D1-Dual-Write schreibt interim noch volle Events — der End-Zustand reduziert die zentrale Schreibmenge auf den Anker. Umsetzung = OP-AUDIT-1/OP-TENANT-Folge-Slices (T-3/T-4). Der folgende Abschnitt beschreibt den Stand vor dieser Verfeinerung (D1-Hybrid).

2.1 Ursprüngliche Entscheidung (Stand vor 06-27): D1 (Cloudflare SQLite, EU) — Hybrid

Ursprüngliche Begründung (Stand vor #184–#186): Damals war alles ein DO-Blob (leitstand.state) und D1 noch nicht verdrahtet — inzwischen ist D1 aktiv; der Ist-Zustand steht im Status-Header oben.

Entscheidung: Das Audit-Log lebt in D1, nicht im DO-Blob.

  • Warum nicht DO-Blob: Audit-Einträge wachsen schneller als Geschäftsdaten (jede Aktion), sind aber nur selten nötig (Historie/Suche). Im Blob ⇒ unbegrenztes Wachstum (RISK-1), O(n)-Laden, keine Indizes/Suche, ~Storage-Limit. Unveränderlichkeit + Abfragbarkeit sind Kernanforderungen.
  • Warum D1: append-only-freundlich (INSERT-only), Indizes + optional FTS5-Suche, vom Live-State entkoppelt, EU-Datenresidenz (DSGVO DE/AT/CH), unabhängig exportierbar (Audit-Export), kostengünstig.
  • Hybrid: Der DO behält die Live-Geschäftsfakten (auftraege, leistungsnachweise …); D1 hält nur das Audit-Log. Der DO schreibt nach jeder persistierten Mutation fire-and-forget nach D1 (blockt den Broadcast nicht).
  • Schema-Konvention: folgt OP-DATA-1 (Drizzle als Single Source → generierte SQL-Migrationen + CI-Gate), sobald das aufgesetzt ist; bis dahin SQL-Migrationsdateien unter server/migrations/.

3. Event-Modell

Jedes Ereignis ist ein unveränderlicher Eintrag. Event-Typen (Katalog, erweiterbar):

TypAuslöserRessource
stammdaten.geaendertMitarbeiter/Skill/Bucht editiertmitarbeiter·skill·arbeitsplatz
abwesenheit.hinzugefuegt / .entferntWunschfrei/Urlaub/Bereitschaft add/remove (Fairness-Treiber)mitarbeiter
konfig.geaendertTeilschritt-/Leistungs-/Prozess-Edit (OP-R3-3)teilschritt·leistung
auftrag.erstellt / .statusAuftrag angelegt / Lifecycle-Übergangauftrag
auftrag.unterbrochenPreemption-Hebelauftrag·teilschritt
umparken / startphysische Bewegung / Schritt-Startauftrag·bucht
mangel.erfasstQS-Mangelauftrag
nacharbeit.eingeplant / mangel.behobenNacharbeit eingeplant / durch Nacharbeit behoben (OP-UX-3)auftrag
infraKosten.hinzufuegen / .aktualisieren / .entfernenInfra-/Cloud-Kostenposten erfasst/geändert/gelöscht (OP-COST-1 Sicht B) — Actor = Access-E-Mail (sonst unbekannt bis OP-COST-3 scharf)infra_kosten
plan.autoevent-getriebene Re-Optimierung (mit Auslöser, z. B. „meldeVerzoegerung → Plan X")plan
bereitschaft.geoeffnetBereitschafts-Vorschlag übernommen (OP-OPT-7)plan·mitarbeiter
zugang.stufe / overrideZahlungs-Stufenwechsel / Admin-Override (OP-ACCESS-1)org

Akteur: Mensch (Cloudflare Access-Identität interim → Better Auth, OP-AUTH-1) oder system (automatische Aktionen). Bei system der Auslöser als trigger (Kausalkette nachvollziehbar).

4. D1-Schema (Entwurf)

CREATE TABLE audit_entries (
id TEXT PRIMARY KEY, -- ULID/uuid (zeit-sortierbar)
tenant_id TEXT NOT NULL, -- Mandant (Isolation; heute 1, Design multi-tenant)
ts INTEGER NOT NULL, -- Ereigniszeit (ms seit Epoch)
event_type TEXT NOT NULL, -- s. Katalog
actor_id TEXT, -- Akteur (NULL/"system" = automatisch)
actor_role TEXT, -- Rolle zum Zeitpunkt (für Sicht-Regeln)
resource_type TEXT, -- auftrag·mitarbeiter·…
resource_id TEXT,
summary TEXT NOT NULL, -- sprechend, PII-arm (Selbsterklärbarkeit)
delta_json TEXT, -- vorher→nachher, PII-gescrubbt
trigger TEXT, -- Auslöser (z. B. "meldeVerzoegerung", "sevdesk-sync")
trace_id TEXT -- Korrelation mit OP-LOG-1/OP-OBS-1
);
CREATE INDEX ix_audit_tenant_ts ON audit_entries (tenant_id, ts);
CREATE INDEX ix_audit_tenant_type_ts ON audit_entries (tenant_id, event_type, ts);
CREATE INDEX ix_audit_tenant_res_ts ON audit_entries (tenant_id, resource_type, resource_id, ts);
CREATE INDEX ix_audit_tenant_actor_ts ON audit_entries (tenant_id, actor_id, ts);
-- Freitext-Suche (späterer Slice): CREATE VIRTUAL TABLE audit_fts USING fts5(summary, delta_json, content='audit_entries');

Unveränderlichkeit: nur INSERT aus dem Laufzeitpfad — kein UPDATE/DELETE (außer dem Retention-Purge, §6). Korrekturen = neuer Eintrag (kompensierend), nie Überschreiben.

5. Sicht & Suche — „nicht reverse-engineerbar" (Bezug OP-SEC-1)

Rollenbasierte Sicht: je Rolle erlaubte event_types + Detailtiefe (manche sehen nur summary, andere delta_json). Konfigurierbar (G-1-artig).

Verbindliche Eigenschaft — kein Such-/Existenz-Oracle: Niemand darf über das Audit-Log auf Existenz/Inhalt/Anzahl/Timing unberechtigter Einträge schließen.

  • Server filtert immer zuerst auf tenant_id + rollen-erlaubte event_types; Queries laufen ausschließlich über die autorisierte Teilmenge.
  • Counts/Pagination nur über die autorisierte Teilmenge — nie „X weitere (verborgene) Treffer".
  • Kein Unterschied zwischen „nichts gefunden" und „nicht berechtigt" (uniforme Leerantwort); keine Timing-/Fehlercode-Differenz, die Rückschlüsse erlaubt.
  • Volltextsuche (späterer Slice) indexiert/sucht nur autorisierte Zeilen.

6. Retention, PII & Datenschutz (DE/AT/CH)

  • PII-Minimierung: IDs + sprechende, PII-arme summary; delta_json gescrubbt (keine Klartext-PII — vgl. OP-LOG-1-Scrubber). Kunde = nur Name + CRM-Link (Datenmodell-Regel).
  • Retention: konfigurierbare Aufbewahrungsfrist (Default an gesetzliche Audit-/Steuer-Fristen orientiert), danach Purge (der einzige erlaubte DELETE-Pfad, als Job). EU-Residenz (D1-EU).
  • Recht auf Löschung (DSGVO) vs. Audit-Integrität: abwägen — i. d. R. Pseudonymisierung des Akteur-Bezugs statt Eintrag-Löschung; im Build-Slice mit Datenschutz final klären (RISK-3).

7. Integration

  • Schreibpfad:gebaut (#184, dormant) → aktiv (#186, DB-Binding gesetzt): Helper d1InsertAudit im DO schreibt jeden Abwesenheits-Historien-Eintrag fire-and-forget nach D1 (INSERT OR IGNORE, Fehler blocken den Broadcast nie) — nur wenn das DB-Binding existiert (sonst No-op). protokolliereAbwesenheit schreibt dual (Blob + D1); der Blob bleibt in diesem Slice die Quelle der synchronen Fairness. Robusteres Retry/Pufferung bei D1-Ausfall = Folge-Slice.
  • Backfill:d1BackfillFromBlob spiegelt die Interim-Blob-Historie einmalig nach D1 (idempotent, nur wenn D1 leer), sobald das Binding da ist.
  • Schema/Migration:server/migrations/0001_audit_entries.sql (Spalte ausloeser statt SQLite-Keyword trigger).
  • Akteur: aus Cloudflare-Access-Headern (interim) → Better Auth (OP-AUTH-1); heute 'system'.
  • Lesepfad:erledigt (Slice 3, v0.152.0 / OP-REDESIGN-1 Phase 4E): read-only Chronik-Panel (Verwaltung-Sub-Tab, chronik.component.ts) → server-seitig gefilterte D1-Query. Konkret: GET /audit/liste (DO-onRequest, admin-gegated via kostenAdminFuer — eigenes Gate, nicht der breitere Assistent-Kreis) liefert die jüngsten Events (SELECT id, ts, event_type, actor_id, resource_type, resource_id, summary … WHERE tenant_id='default' ORDER BY ts DESC LIMIT n über den (tenant_id, ts)-Index), nur die menschenlesbare summary (PII-arm) — delta_json bleibt in der zugriffsgeschützten D1. DTO AuditEintragDTO/AuditListe (Server + Client-Spiegel), Client svc.ladeAuditVerlauf(). Ohne D1-Binding (lokal/dev) → ehrlicher dormant-Zustand (keine Fake-Zeilen). Noch offen (Folge-Slice): rollenbasierte Sicht-Regeln/Filter/Suche (§5, nutzt das heute stets NULL geschriebene actor_role) + Paginierung.
  • D1-Aktivierung (✅ erledigt — #186 setzt das Binding; beim Merge auf main geht der dormante Pfad live):
    1. erledigt (#185): D1-DB angelegt — taktano-audit, EU-Region weur, database_id = 6a080ffd-9cad-4c8e-80df-314beebdab9b (via MCP d1_database_create). ✅ Konto bestätigt (#186): das per MCP verbundene CF-Konto ist das Deploy-Konto (CLOUDFLARE_ACCOUNT_ID) → die database_id löst beim Deploy auf.
    2. erledigt (#185): Schema/Migration angewandt — audit_entries + 4 Indizes in der Remote-DB (0001_audit_entries.sql, via MCP-Query verifiziert: Tabelle + ix_audit_tenant_ts|_type_ts|_res_ts|_actor_ts).
    3. erledigt (#186): Binding in server/wrangler.toml gesetzt — [[d1_databases]] mit binding = "DB", database_name = "taktano-audit", database_id = "6a080ffd-9cad-4c8e-80df-314beebdab9b", migrations_dir = "migrations". Beim Merge auf main deployt der strikte CD-Job (ci.yml) den Worker mit DB-Binding → env.DB gesetzt → Dual-Write + Backfill live.
    4. Vorbedingung Deploy bestätigt (#186): der CI-CLOUDFLARE_API_TOKEN (deploy-Job) hat D1-Rechte und gehört zum selben Konto wie die DB (1.) — beide Punkte nutzerseitig bestätigt, bevor das Binding gesetzt wurde. Hinweis: Migration 0001 ist remote bereits angewandt und idempotent (CREATE … IF NOT EXISTS); wrangler deploy wendet ohnehin keine D1-Migrationen an → kein Doppel-Apply.

8. Build-Slices (Vorschlag)

  1. Fairness-/Zugangs-Historie: abwesenheit.hinzugefuegt/.entfernt (+ bereitschaft.geoeffnet) und zugang.stufe/override erfassen → entsperrt OP-OPT-7-Fairness (wer hatte wie viel frei) und OP-ACCESS-1-Nachweis. Kleinster nutzbarer D1-Einstieg.
    • Gebaut (#183, interim im DO-Blob): abwesenheit.hinzugefuegt/.entfernt als append-only AbwesenheitHistorieEintrag[] (schemaVersion 11 + Backfill aus aktuellen Abwesenheiten), protokolliereAbwesenheit an den Add/Remove-Handlern, und die OP-OPT-7-Fairness je Mitarbeiter abgeleitet (fairnessAusHistorie, pure + test:fairness; in der Mitarbeiter-Karte sichtbar). Bewusst als Interim markiert (niedrig-volumig) — wandert mit der D1-Verdrahtung um.
    • D1-Pfad aktiv (seit #186, Aktivierungs-Protokoll §7): Schema (migrations/0001_audit_entries.sql), guarded Dual-Write (d1InsertAudit) + Backfill (d1BackfillFromBlob), Env.DB? optional; der Blob bleibt in diesem Slice die Quelle der synchronen Fairness. (Historie: gebaut zunächst dormant in #184, DB taktano-audit [EU weur, database_id 6a080ffd-…] provisioniert + migriert in #185, Binding gesetzt in #186.)
    • Offen: bereitschaft.geoeffnet (on-call gezogen) + zugang.stufe/override — sobald deren Quell-Events existieren (OP-ACCESS-1 noch nicht gebaut); Akteur-Identität (heute 'system', bis OP-AUTH-1); Read-Path/Panel (Slice 3); Lese-Umstellung der Fairness auf D1 (async + Cache).
  2. Breite Event-Erfassung: restliche Typen aus DO-Mutationen (logAudit flächendeckend).
  3. Audit-Panel + Rollen + Suche: read-only Sicht, rollenbasierte Sicht-Regeln (§5), Filter (Typ/Datum/Akteur/Ressource), später FTS-Volltext.

Hinweis zur Reihenfolge: Wird Slice 1 vor dem D1-Wiring gebraucht, kann die Abwesenheits-Historie übergangsweise als kleine append-only Liste im DO-Blob starten (niedrig-volumig) und später nach D1 migriert werden — bewusst als Interim markieren.

9. Bezüge

OP-SEC-1 (nicht-reverse-engineerbar · IDOR · Mandanten-Isolation) · OP-LOG-1 (getrennt von Betriebs-Logs, trace_id) · OP-OBS-1 (OTel) · OP-ACCESS-1 (Stufenwechsel/Override) · OP-OPT-7 (Fairness-Historie) · OP-AUTH-1 (Akteur/Mandant) · OP-DATA-1 (Drizzle-Migrationen) · OP-COMPLIANCE-1 (ISO/Audit-Nachweise) · docs/betrieb/Risikoregister.md (RISK-1/2/3/6).


↩ Zurück zur Doku-Landkarte · Lesepfade · Register (alle OPs)