Devlog - Küstencode Werkbank

Technische Notizen zur Küstencode Werkbank: wie einzelne Features entstanden sind, womit ich Probleme hatte, wie ich sie gelöst habe und woran ich gescheitert bin.

Verkettetes, verifizierbares Audit-Log (GoBD)

Die offene Login-Lücke (siehe „Auth & Security-Härtung") hatte eine unbequeme Nebenwirkung: Wenn Module bis dahin ungeschützt erreichbar waren, konnte ich für keinen einzigen Datensatz belastbar sagen, wer ihn wann geändert hat. Für ein Programm, das die Einnahmen-Überschuss-Rechnung fürs Finanzamt vorbereitet, ist das keine Kleinigkeit – die GoBD verlangen Nachvollziehbarkeit und Unveränderbarkeit. Also habe ich im selben Zug ein Änderungsprotokoll eingebaut.

Erfasst wird zentral über einen EF-Core-SaveChanges-Interceptor: eine Whitelist relevanter Felder, dazu wer (aus dem JWT), wann und was – nicht verstreut über einzelne Service-Methoden, sondern an einer Stelle, die sich nicht umgehen lässt. Das schützt aber nur den Weg über die Anwendung. Wer direkten Datenbankzugriff hat, kann Protokollzeilen genauso ändern wie alles andere – und bei einer self-hosted Software ist der Betreiber selbst genau die Person, deren spätere Manipulation die GoBD-Anforderung der Unveränderbarkeit verhindern soll.

Deshalb liegt über dem Protokoll eine SHA-256-Hashkette: jede Zeile hasht ihren eigenen Inhalt zusammen mit dem Hash der Vorgängerzeile. Die erste Zeile knüpft an einen festen Genesis-Wert an. Ein direkter Eingriff in eine Zeile bricht ihren Hash – und über die Verkettung auch die Hashes aller nachfolgenden Zeilen. Ein Append-only-Trigger auf DB-Ebene und ein SELECT … FOR UPDATE auf die jeweils letzte Zeile (damit parallele Schreiber die Kette nicht gabeln) sichern das ab.

Entscheidend war die Erkenntnis, dass eine gespeicherte Kette wertlos ist, solange sie niemand nachrechnet: Eine Verifikationsroutine läuft von Genesis bis zur letzten Zeile durch und meldet den ersten Bruch – wahlweise, weil der Inhalt einer Zeile nicht mehr zu ihrem Hash passt, oder weil die Verkettung zur Nachbarzeile nicht mehr stimmt (Zeile entfernt, eingefügt, umsortiert). Für Bestandssysteme, die vor Einführung der Kette schon Protokollzeilen hatten, war zusätzlich Arbeit nötig, damit ein Upgrade nicht fälschlich einen Manipulationsalarm auslöst.

Ehrlich zur Grenze: Eine schlüssellose Hashkette, die vollständig in derselben Datenbank liegt, erhöht den Aufwand einer Fälschung – sie macht sie nicht unmöglich. Wer Vollzugriff auf die DB hat und den (offenen) Quellcode kennt, kann die Kette ab der geänderten Stelle neu berechnen. Für ein self-hosted System, in dem ein Angreifer dafür erst Server und Container-Stack übernehmen müsste, halte ich dieses Restrisiko für vertretbar – und schreibe in der Verfahrensdokumentation bewusst „unbefugte und versehentliche Änderungen werden nachweisbar", nicht „manipulationssicher". Das eine Wort zu viel wäre gefährlicher als die fehlende Funktion.

Auth & Security-Härtung: vom Token-Bug in Rapport zur offenen Login-Lücke

Zweimal stieß ich in diesem Projekt auf dasselbe Grundproblem: Der Auth-Zustand wurde nicht zuverlässig geprüft bzw. weitergereicht – einmal harmlos, einmal nicht.

Der erste Fall, im April: Nach einem Neustart des Blazor-Circuits tauchten in Rapport bei der manuellen Zeiterfassung plötzlich keine Mitarbeiter mehr in der Liste auf (ec5c680). Ein erster Fixversuch (15ba2e1) hatte das Problem nicht wirklich behoben, es kam wieder – mehrere weitere Anläufe scheiterten am gleichen Tag. Erst durch zusätzliches Logging wurde die eigentliche Ursache sichtbar: Der Token ging zwischen dem HttpContext-Zugriff und dem Start des Blazor-Circuits verloren – zwei unterschiedliche Zeitpunkte im Lebenszyklus, die sich nicht ohne Weiteres synchron halten lassen. Die Lösung (f094c6d): den Token sofort im Konstruktor aus dem HttpContext lesen und in der Closure des Callbacks zwischenspeichern, statt ihn erst beim Circuit-Start erneut abzufragen. Dazu kam eine Anpassung am AuthStateProvider (f1793b8) sowie, als flankierende Maßnahme, ein Clearing des Browser-Caches bei Versionsupdates (eca6db7), damit veraltete Client-Assets nicht mit neuem Server-Code kollidieren.

Der zweite Fall, Monate später im September: Bei der Arbeit an der GoBD-Konformität (→ siehe „Verkettetes, verifizierbares Audit-Log (GoBD)" oben) fiel auf, dass Module unter bestimmten Bedingungen komplett ohne Login erreichbar waren. Ursache war eine IP-basierte Ausnahme in der AuthMiddleware, die interne Modul-Kommunikation im Docker-Netzwerk (private IP-Bereiche, Docker-Servicenamen) erkennen und ohne Token durchlassen sollte. Das Problem: Docker leitet auch echten Browser-Traffic von außen so weiter, dass er wie internes Netz aussieht – der IP-Check erlaubte also im Zweifel auch externen Zugriff ohne Login (3a3d73d). Die Ausnahme wurde vollständig entfernt, dazu kamen Auth-Regressionstests, die genau solche Lücken künftig automatisiert abfangen sollen (07bd665).

Die Wurzel dieser zweiten Lücke liegt allerdings noch weiter zurück: in der Entscheidung, Authentifizierung von Anfang an optional zu halten (→ siehe „Multi-User, Rollen und (abschaltbare) Authentifizierung" weiter unten).

Firmenstammdaten-Speichern zerschoss Daten: die Encrypt/Decrypt-Schleife

Zwei Bugs am selben Tag, beide in den Firmenstammdaten. Zuerst ließen sich mehrfache Bankverbindungen nicht mehr hinzufügen – Ursache war ein EF-Core-Tracking-Konflikt: Beim Speichern wurden bereits gespeicherte Bankverbindungen aus dem Change-Tracker gelöst (detached), blieben aber als veraltete Instanzen in der In-Memory-Collection der Firma stehen. Beim nächsten Speichern kollidierten sie mit neu hinzugefügten Einträgen (Id 0) – EF Core wirft dann „another instance with the same key value is already being tracked". Behoben durch explizites Leeren der Collection und Markieren als „nicht geladen", damit sie beim nächsten Laden sauber aus der Datenbank nachgezogen wird (af4d6a3).

Direkt am nächsten Morgen der zweite, unangenehmere Bug: Das Speichern der Firmenstammdaten schlug ohne verständliche Fehlermeldung fehl. Statt zu raten, ergänzte ich zunächst Logging für die Bankverbindungen (c1f0955) und für den Speichervorgang selbst (902920a) – das brachte aber noch keine klare Ursache zutage. Als Nächstes folgte eine clientseitige Prüfung der Feldlängen, um einzugrenzen, an welcher Stelle die Daten fehlerhaft ankamen (e80eead). Erst dadurch fiel eine überflüssige Verschlüsselungs-Schleife beim SMTP-Passwort auf: Bei jedem Speichern wurde der bereits verschlüsselte Wert entschlüsselt und wieder verschlüsselt – unabhängig davon, ob sich das Passwort überhaupt geändert hatte. Schlug das Entschlüsseln fehl (z. B. durch einen nicht mehr passenden DataProtection-Key), griff ein Fallback, der den Chiffretext fälschlich als Klartext behandelte und erneut verschlüsselte. Damit wurde der gespeicherte Wert bei jedem Speichern ein Stück länger, bis die Datenbankspalte irgendwann überlief und der Save scheiterte – ohne dass das für mich an der Oberfläche einen erkennbaren Grund hatte.

Die Lösung (b8b7a30): nur noch verschlüsseln, wenn tatsächlich ein neues Klartext-Passwort hereinkommt, erkennbar daran, dass es vom aktuell gespeicherten Wert abweicht. Der Encrypt/Decrypt-Roundtrip für unveränderte Passwörter entfällt komplett. Klassischer Fall: Das eigentliche Problem lag nicht dort, wo der Fehler sichtbar wurde, sondern eine Ebene tiefer, in einer Zeile, die niemand im Verdacht hatte.

Belegvorschau zeigte manche Dateien nicht an: drei Anläufe

Manche hochgeladenen Belege wurden in der Vorschau (Recepta, Belege-Details und -Bearbeiten) nicht angezeigt. Drei Anläufe, bis das Problem wirklich behoben war – und jeder Fix war für sich technisch korrekt, hat aber jeweils nur einen Teil des eigentlichen Problems getroffen.

Erster Versuch (7727480): Die Vorschau unterschied PDF von Bild anhand des gemeldeten Content-Type. Manche Browser melden bei PDF-Uploads aber einen leeren Content-Type. Fix: zusätzlich anhand der Dateiendung (.pdf) erkennen, sowohl beim Anzeigen als auch beim Upload-Validieren. Das behob einen Teil der Fälle, aber nicht alle.

Zweiter Versuch (48c9766): Der eigentliche Kern des Problems lag woanders. Die Vorschau lud die Datei bis dahin komplett über den Server-Code – Bytes wurden per SignalR-Nachricht an den Client geschickt und dort als Base64 in ein <img>- bzw. <embed>-Tag eingebettet. Bei größeren Dateien überschritt diese Nachricht das Nachrichten-Limit des Blazor-Server-Circuits. Das Ergebnis: keine Exception und keine Fehlermeldung – die Vorschau blieb einfach leer. Die Lösung: Die Vorschau lädt die Datei nicht mehr über den SignalR-Kanal, sondern direkt per URL gegen einen eigenen REST-Endpunkt (/api/recepta/files/{id}).

Dritter Versuch (6ec2f17): Der URL-Ansatz brachte ein neues, kleineres Problem mit sich. Der Datei-Endpunkt lieferte die Antwort mit Dateinamen aus, wodurch ASP.NET Core den Content-Disposition-Header standardmäßig auf „attachment" setzte – der Browser versuchte also, die Datei herunterzuladen, statt sie inline in <img>/<embed> darzustellen. Der Endpunkt bekam einen optionalen inline-Query-Parameter, der den Header stattdessen explizit auf „inline" setzt, wenn die Anfrage aus der Vorschau kommt.

Im Rückblick: Der erste Fix war nicht falsch, er hat nur einen zweiten, unabhängigen Bug maskiert, der erst danach sichtbar wurde. Das SignalR-Nachrichtenlimit hätte ich ohne den Umweg über die Content-Type-Erkennung vermutlich früher gefunden.

Skonto-Logik in Recepta: vom Feature zum Edge Case

Skonto (Skontoabzug bei früher Zahlung) habe ich zunächst einfach gehalten: ein Prozentsatz pro Beleg, berechnet als AmountGross × SkontoPercent / 100 (ff31180). Robust war das noch nicht.

Erstes Problem: Ob ein Beleg als „bezahlt" galt, wurde weiterhin über totalPaid >= amountGross geprüft – der Skontoabzug floss in die Statusberechnung gar nicht ein. Ein mit Skonto tatsächlich beglichener Beleg blieb also fälschlich auf „teilweise bezahlt" stehen. Die Korrektur führte einen Schwellenwert (amountGross − skontoAmount) ein. Zusätzlich kam ein manuell überschreibbares SkontoAmountOverride-Feld, das die Prozentrechnung bei Bedarf ersetzt (da8c2c1).

Zweites Problem, eine Woche später: Korrekturbelege (Gutschriften) haben einen negativen Bruttobetrag – und sowohl die neue Statuslogik als auch die Auswertung in Saldo prüften implizit auf positive Beträge (amountGross > 0). Bei negativen Beträgen griff der Vergleich nicht, und die Zahlungsquote in Saldo fiel unkontrolliert auf 1 (also 100%) zurück, was falsche Netto-/USt-/Brutto-Werte in der Buchung erzeugte. Der Fix musste an beiden Stellen vorzeichenbewusst werden: Schwellenwert und Zahlungsquote je nach Vorzeichen von amountGross gespiegelt, Beträge über Math.Abs() verglichen und am Ende wieder mit dem korrekten Vorzeichen versehen – inklusive korrekter Umbuchung von „Ausgabe" auf „Einnahme" für negative Belege (964c004).

Drittes Problem – zwei Wochen später: Die EÜR in Saldo summierte weiterhin die vollen, unrabattierten Netto-/USt-/Brutto-Beträge jedes Belegs – auch wenn ein Skontoabzug tatsächlich angewendet worden war. Das verzeichnete faktisch nicht geflossenes Geld in der Steuerauswertung. Der Fix rechnet den Skontoabzug anteilig auf Netto und USt herunter und zieht ihn direkt vom Brutto ab, wenn SkontoApplied gesetzt ist (8bfc8a6).

Der rote Faden: Ein Feature, das isoliert in Recepta korrekt aussah, hatte zwei Fehlerquellen, die erst in Verbindung mit Nachbarfällen sichtbar wurden – negative Belege und eine Auswertung in einem anderen Modul.

Acta-Projekte direkt zu Rechnungen machen

Bis dahin musste ich Stunden und Material aus einem Acta-Projekt manuell in eine Rechnung übertragen. Ziel war ein direkter Weg: Aus einem Projekt heraus sollte eine Rechnung entstehen, die die in Rapport erfassten Stunden und die über Recepta erfassten Materialkosten automatisch zusammenzieht.

Der größte Teil davon kam in einem einzigen, entsprechend großen Commit (ccbc4e4): eine Rollenverwaltung für Mitarbeiter (MitarbeiterRolle, u. a. für unterschiedliche Stundensätze – Facharbeiter vs. andere Profession), eine API-Anbindung von Faktura an Rapport, um Projektstunden nach Tätigkeitsart abzurufen, sowie die eigentliche Abrechnungslogik in Acta. Vier Module waren beteiligt: Host für die Mitarbeiterrollen, Rapport als Datenquelle für Stunden, Acta für die Projektsicht, Faktura für die entstehende Rechnung.

Danach zwei Bugfix-Runden. Die erste (5dd4f5a) ergänzte die Erfassung bereits berechneter Materialkosten pro Projekt. Die zweite (e2468fd) fügte eine eigene Tabelle für bereits abgerechnete Aufwände ein (ProjektBerechneterAufwand: Belegnummer, Lieferant, Netto, Brutto pro Projekt) – nötig, damit beim erneuten Erzeugen einer Rechnung aus demselben Projekt nicht versehentlich bereits abgerechnete Belege ein zweites Mal berücksichtigt werden. Mit dieser Runde war das Feature abgeschlossen, sichtbar an der gemeinsamen Versionsanhebung von Host, Faktura, Rapport und Acta.

Multi-User, Rollen und (abschaltbare) Authentifizierung

Bis Mitte Februar hatte die Werkbank kein echtes Nutzerkonzept – jeder Zugriff wurde gleich behandelt, ohne zu unterscheiden, wer da eigentlich handelte. Mit wachsender Modulanzahl baute ich eine Rollen- und Rechteverwaltung: Nicht jeder Nutzer sollte in jedem Modul alles tun dürfen.

Eine Anforderung machte den Auth-Flow von Anfang an komplizierter als ein einfaches Login-Gate: Nicht jede Installation sollte Login erzwingen müssen. Deshalb liegt der Schalter dafür in einer Datenbankeinstellung (WerkbankSettings.AuthEnabled, Standardwert false) statt fest im Code – Auth ist eine optionale Ebene, keine Grundvoraussetzung. Das hat direkte Konsequenzen für die Middleware: Sie muss bei jeder Anfrage zuerst diesen Zustand aus der Datenbank lesen, bevor sie überhaupt entscheiden kann, ob sie einen Token verlangt (517cc7f).

Den eigentlichen Umfang der Arbeit bildeten ein Rollenmodell und Einladungs-Flow für Teammitglieder, JWT-basierte Anmeldung mit Passwort-Reset, eine AuthMiddleware, die zwischen öffentlichen Pfaden und geschützten Bereichen unterscheidet, dazu ein WerkbankAuthStateProvider für den Blazor-seitigen Auth-Zustand. Kurz darauf liefen Multi-User, Authentifizierung und Autorisierung durchgängig, mit einer mobilen Ansicht parallel in Arbeit (9ee5148), die wenig später um Task-Karten erweitert wurde (0971da2). Zum Abschluss dieser Phase wurden Rapport und Recepta an das neue Auth-System angebunden (816c860), bevor der Branch gemerged wurde (4a6c697).

Ein Punkt aus der ursprünglichen Middleware-Implementierung dieser Phase sollte sich später als riskant erweisen – dazu mehr unter „Auth & Security-Härtung" weiter oben.

Vom monolithischen „Küstencode Faktura" zur modularen Werkbank-Suite

Das Projekt begann als einzelnes, monolithisches Rechnungsprogramm – „Küstencode Faktura", ohne Trennung zwischen Kernanwendung und einzelnen Fachbereichen. Als absehbar wurde, dass weitere eigenständige Bereiche (Zeiterfassung, Angebote, Projektverwaltung) dazukommen sollten, reichte diese Struktur nicht mehr: Jedes neue Feature hätte die bestehende Anwendung direkt vergrößert, statt als eigener, unabhängig wartbarer Baustein zu existieren.

Der erste sichtbare Schritt war die Umbenennung der Solution zu Kuestencode.Werkbank (5b45f06) und das Anlegen eines eigenen Host-Projekts (c733b18) – noch ohne die eigentliche Architekturänderung. Die kam kurz darauf mit dem Commit, der die Suite in ihrer heutigen Grundform begründet: Der Host läuft als Reverse Proxy, zu dem sich Module zur Laufzeit selbst registrieren, mit Health Checks, die es erlauben, Container im laufenden Betrieb herauszunehmen und wieder hinzuzufügen (d49f2b3).

Die Container-Trennung war damit nicht automatisch fertig durchdacht – sie brachte eigene Folgeprobleme mit sich, vor allem in der Kommunikation und Registrierung zwischen den jetzt getrennten Containern, die direkt im Anschluss behoben wurden (2ec66e4). Es folgten Aufräumarbeiten, die zu einer echten modularen Codebasis dazugehören, aber selten von allein passieren: saubere Trennung von Code-Behind und HTML (3de80c1) sowie eine sichtbare Modulregistrierung – jedes registrierte Modul bekommt eine Logo-Karte auf der Übersichtsseite, die Versionsnummer wird direkt aus dem Docker-Stack gelesen statt hart codiert (686e9bd).

Rapport entstand in den folgenden Tagen als erstes komplett neues Modul nach diesem Muster. Den Schlusspunkt dieser Phase markiert der Commit, der den alten monolithischen Modus vollständig aus Code und Dokumentation entfernte (42ee924) – der Zeitpunkt, an dem die modulare Architektur nicht mehr Option, sondern einzige Betriebsart war.

Diese Entscheidung ist auch die Grundlage für einen Vorteil, den ich heute auf der Projektseite bewerbe: Fällt ein Modul aus, bleiben die anderen nutzbar. Das war zum Zeitpunkt dieses Umbaus noch kein bewusstes Ziel, eher eine Konsequenz der Trennung – aber eine, die sich im Nachhinein als richtig erwiesen hat.