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, EUweur) · 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_idzur 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, keinUPDATE/DELETEim 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 umgewidmet — nicht zum vollen Event-Sink, sondern zum Tamper-Anker: zentral liegt nur der Chain-Kopf je Tenant ({tenant_id, seq, head_hash, ts}, Tabelleaudit_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):
| Typ | Auslöser | Ressource |
|---|---|---|
stammdaten.geaendert | Mitarbeiter/Skill/Bucht editiert | mitarbeiter·skill·arbeitsplatz |
abwesenheit.hinzugefuegt / .entfernt | Wunschfrei/Urlaub/Bereitschaft add/remove (Fairness-Treiber) | mitarbeiter |
konfig.geaendert | Teilschritt-/Leistungs-/Prozess-Edit (OP-R3-3) | teilschritt·leistung |
auftrag.erstellt / .status | Auftrag angelegt / Lifecycle-Übergang | auftrag |
auftrag.unterbrochen | Preemption-Hebel | auftrag·teilschritt |
umparken / start | physische Bewegung / Schritt-Start | auftrag·bucht |
mangel.erfasst | QS-Mangel | auftrag |
nacharbeit.eingeplant / mangel.behoben | Nacharbeit eingeplant / durch Nacharbeit behoben (OP-UX-3) | auftrag |
infraKosten.hinzufuegen / .aktualisieren / .entfernen | Infra-/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.auto | event-getriebene Re-Optimierung (mit Auslöser, z. B. „meldeVerzoegerung → Plan X") | plan |
bereitschaft.geoeffnet | Bereitschafts-Vorschlag übernommen (OP-OPT-7) | plan·mitarbeiter |
zugang.stufe / override | Zahlungs-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-erlaubteevent_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_jsongescrubbt (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): Helperd1InsertAuditim DO schreibt jeden Abwesenheits-Historien-Eintrag fire-and-forget nach D1 (INSERT OR IGNORE, Fehler blocken den Broadcast nie) — nur wenn dasDB-Binding existiert (sonst No-op).protokolliereAbwesenheitschreibt dual (Blob + D1); der Blob bleibt in diesem Slice die Quelle der synchronen Fairness. Robusteres Retry/Pufferung bei D1-Ausfall = Folge-Slice. - Backfill: ✅
d1BackfillFromBlobspiegelt 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(Spalteausloeserstatt SQLite-Keywordtrigger). - 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 viakostenAdminFuer— 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 menschenlesbaresummary(PII-arm) —delta_jsonbleibt in der zugriffsgeschützten D1. DTOAuditEintragDTO/AuditListe(Server + Client-Spiegel), Clientsvc.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 geschriebeneactor_role) + Paginierung. - D1-Aktivierung (✅ erledigt — #186 setzt das Binding; beim Merge auf
maingeht der dormante Pfad live):- ✅ erledigt (#185): D1-DB angelegt —
taktano-audit, EU-Regionweur,database_id = 6a080ffd-9cad-4c8e-80df-314beebdab9b(via MCPd1_database_create). ✅ Konto bestätigt (#186): das per MCP verbundene CF-Konto ist das Deploy-Konto (CLOUDFLARE_ACCOUNT_ID) → diedatabase_idlöst beim Deploy auf. - ✅ 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). - ✅ erledigt (#186): Binding in
server/wrangler.tomlgesetzt —[[d1_databases]]mitbinding = "DB",database_name = "taktano-audit",database_id = "6a080ffd-9cad-4c8e-80df-314beebdab9b",migrations_dir = "migrations". Beim Merge aufmaindeployt der strikte CD-Job (ci.yml) den Worker mit DB-Binding →env.DBgesetzt → Dual-Write + Backfill live. - ✅ 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: Migration0001ist remote bereits angewandt und idempotent (CREATE … IF NOT EXISTS);wrangler deploywendet ohnehin keine D1-Migrationen an → kein Doppel-Apply.
- ✅ erledigt (#185): D1-DB angelegt —
8. Build-Slices (Vorschlag)
- Fairness-/Zugangs-Historie:
abwesenheit.hinzugefuegt/.entfernt(+bereitschaft.geoeffnet) undzugang.stufe/overrideerfassen → 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/.entferntals append-onlyAbwesenheitHistorieEintrag[](schemaVersion11 + Backfill aus aktuellen Abwesenheiten),protokolliereAbwesenheitan 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, DBtaktano-audit[EUweur,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).
- ✅ Gebaut (#183, interim im DO-Blob):
- Breite Event-Erfassung: restliche Typen aus DO-Mutationen (
logAuditflächendeckend). - 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)