presseportale/dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md
Kevin Adametz 0ab53bfa5e auth: Firmenrechte nur aus nachgewiesener Adresse, 2FA auf allen Login-Wegen (Audit Block 1, Deploy A)
Audit vom 11.09. gegen HEAD geprüft, Entwicklungsplan dazu unter
dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md. Dieser Commit ist Deploy A:
die sechs kleinen Punkte aus Block 1 ohne Schemaänderung.

F01: Der Fortify-Endpunkt PUT /user/profile-information tauschte die
E-Mail sofort und setzte die Verifizierung zurück; der Login-Sync vergab
danach Firmenrechte allein nach E-Mail-Treffer. Feature abgeschaltet
(die Kunden-UI hat den pending_email-Prozess), und der Sync verlangt
jetzt ein verifiziertes, aktives Konto – für alle Aufrufer.

F02: Google-Login übergibt Konten mit bestätigtem TOTP in die Challenge,
wie Passwort- und Magic-Link-Login.

F03: showRecoveryCodes ist gesperrt; ein Client-Update konnte die
Recovery-Codes entschlüsseln lassen.

F08: Nutzer-Detailroute unter can:users:manage (persistente Livewire-
Middleware), Gutschriften autorisieren zusätzlich in der Action.

F09: Gate::before greift nur für aktive Super-Admins; deaktivierte
fallen auf die Gates mit is_active-Prüfung zurück.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:08:55 +00:00

20 KiB
Raw Blame History

Entwicklungsplan zum Audit vom 11.09.2026

Stand: 18.09.2026 (Entscheidungen getroffen, Umsetzung läuft) Grundlage: AUDIT.md (geprüfter Commit 5d0570b), abgeglichen mit HEAD 24c1bbf auf technik/betriebsfixes-2026-09 Zweck: Nur die Befunde, die am aktuellen Code belegt sind, in eine abarbeitbare Reihenfolge bringen. Jeder Punkt hat einen konkreten Fix, einen Regressionstest und eine Aufwandsklasse.


1. Ergebnis des Abgleichs

Der Audit hält. Von 28 Befunden sind 25 am aktuellen Stand bestätigt, zwei nur teilweise (F08, F17), einer ist zwar richtig, aber praktisch ohne Relevanz (F21). Kein Befund wurde durch die Commits seit dem 11.09. behoben, obwohl mehrere der betroffenen Dateien angefasst wurden (ContactAccessService, ManualInvoiceService, PressReleaseService, NewsroomDetailPageData, press-kits/show).

Was über den Audit hinausgeht und bei der Prüfung aufgefallen ist:

  • F05 ist schlimmer als beschrieben. Auf einer Kopie der Live-Daten liefert der Newsletter-Versand an alle 59.705 berechtigten Nutzer genau 500 eindeutige Empfänger, davon 480 doppelt. Die übrigen 59.205 werden nie erreicht. Der Grund ist chunkById hinter einer created_at-Sortierung; Laravel entfernt nur eine vorhandene id-Sortierung, nicht die führende.
  • F15 hat einen zweiten Fehler durch die Mail-Sperrliste vom 15.09. Ist ein Empfänger gesperrt, bricht GateOutgoingMail den Versand ab, der Job wertet den Rückgabewert nicht aus und markiert den Empfänger als „gesendet“. Nach einer Pause durch den Circuit Breaker landen Empfänger endgültig auf „Failed“, und queue:retry holt sie nicht nach.
  • F04 hat eine Fundstelle, die der Audit nicht kennt. Die öffentliche Meldungsseite (ReleaseDetailPageData.php:76-80) fällt auf Contact::withoutGlobalScopes() zurück und zeigt damit gelöschte Kontakte mit Name, Mail und Telefon öffentlich an.
  • F04 im Kundenpanel ist ein sichtbarer Bug. Nach „Kontakt löschen“ im Pressefach bleibt der Kontakt in der Liste und ist weiter editierbar (press-kits/show.blade.php:273-306).
  • F22 ist gemessen. LOWER(email) auf companies (114.100 Zeilen) ergibt einen Vollscan mit 108.424 geschätzten Zeilen; der direkte Vergleich nutzt companies_email_idx mit 1 Zeile. Die Abfrage läuft bei jedem Login. Die Kollation ist bereits utf8mb4_unicode_ci, LOWER() bringt semantisch nichts.

Befundtabelle

ID Audit Prüfung an HEAD Eigene Einordnung Block
F01 P1 bestätigt, Route aktiv, Kette vollständig kritisch, sofort 1
F02 P1 bestätigt mittel 1
F03 P1 bestätigt, showRecoveryCodes ohne #[Locked] mittel, Fix klein 1
F04 P1 bestätigt, plus zwei Fundstellen mehr, eine falsch benannt mittel (PII öffentlich, Panel-Bug) 3
F05 P1 bestätigt und auf Live-Kopie bewiesen kritisch, vor der nächsten Kampagne 2
F06 P1 bestätigt, aber latent: kein Datensatz hat grandfathered_until niedrig heute, Fix klein 5
F07 P1 bestätigt, content_hash wird nur für Duplikate genutzt mittel, Fenster: Admin-/Korrektur-Bearbeitung im Status Review 4
F08 P2 teilweise: ohne Snapshot kein Aufruf möglich, nur Stale-Session-Fall niedrig, Fix klein 1
F09 P2 bestätigt per Tinker niedrig bis mittel, Einzeiler 1
F10 P2 bestätigt, keinerlei Entzugslogik, Pivot ohne Herkunft mittel, macht F01 dauerhaft 1
F11 P2 bestätigt, nur negative per_page unbegrenzt, status[] gibt 500 niedrig bis mittel 3
F12 P2 bestätigt, Canonical-Arbeit vom 18.09. hat die Report-Controller nicht erfasst mittel-niedrig 3
F13 P2 bestätigt, bis 1 h Stale mittel 3
F14 P2 bestätigt mittel, zusammen mit F05 2
F15 P2 bestätigt, plus Sperrlisten-Fehler mittel 2
F16 P2 bestätigt, aktuell 4 fällige Fälle, alle nicht abrechenbar niedrig 5
F17 P2 teilweise: nur der Score-Job kann 60 s reißen niedrig bis mittel, Einzeiler 4
F18 P2 bestätigt mittel (verpasster Cron = Monat ohne Kontingent) 4
F19 P2 bestätigt, kein Isolatable niedrig, Einzeiler 4
F20 P2 bestätigt, Runbook verspricht Alarm, der nie kommt niedrig bis mittel, Einzeiler 4
F21 P2 bestätigt, Importer ist stillgelegt nicht aufgenommen –
F22 P2 bestätigt und gemessen mittel, Einzeiler je Stelle 3
F23 P2 bestätigt niedrig bis mittel 3
F24 P2 bestätigt klein 6
F25 P2 bestätigt; die irreführenden Tests werden in den Blöcken 2 und 3 ersetzt die breite MySQL-/Worker-Matrix ist aufgeschoben 6
F26 P2 bestätigt, kein Fokus-Plugin geladen niedrig 6
F27 P3 bestätigt klein 6
F28 P3 bestätigt, Link auf dev/frontend/hub-flux/PROGRESS.md ist tot klein 6

Abschnitt 10 des Audits (unbelegte Beobachtungen) wurde nicht geprüft und ist nicht im Plan. Die drei übrigen Dateien im Ordner sind keine Audit-Befunde: Bildkennzeichnung ist eine eigene Spezifikation mit Feedback-Audit (noch nicht umgesetzt), Kontakterreichbarkeit ist seit den Commits ada41cb ff. umgesetzt.


2. Aufwandsskala und Arbeitsregeln

Kürzel Bedeutung
S eine Stunde bis ein halber Tag
M ein bis drei Tage
L länger oder abhängig von einer Entscheidung

Für jeden Punkt gilt: zuerst der Regressionstest, der am heutigen Code rot ist, dann der Fix, dann php artisan test --compact für die betroffene Datei, am Blockende die gesamte Suite und PHPStan. Kein Block wird ohne Deploy abgeschlossen. Blöcke 1 und 2 sind voneinander unabhängig und können parallel laufen; Block 1 hat Vorrang, weil er ausnutzbar ist.


Block 1 — Identität und Rechte (F01, F10, F03, F02, F09, F08)

Der Kern: Firmenrechte entstehen aus einer E-Mail-Übereinstimmung, die nie auf Verifikation geprüft wird, und werden nie wieder entzogen. Alles andere in diesem Block sind Nebenwege, die dieselbe Regel unterlaufen.

Nr. Arbeitspaket Aufwand Abhängigkeit
1.1 Fortify-Feature updateProfileInformation in config/fortify.php:150 abschalten. Die Kunden-UI nutzt den Endpunkt nicht; sie hat den pending_email-Prozess. Test: PUT /user/profile-information liefert 404. S E1
1.2 ContactAccessService::syncMembershipsForEmail() verlangt hasVerifiedEmail() und is_active. Gilt für alle Aufrufer, nicht nur den Login-Listener. Test: unverifizierter Nutzer mit passender Firmenadresse erhält keinen Pivot; nach Verifikation schon. S keine
1.3 Herkunft am Pivot speichern: Spalte company_user.granted_via (auto, manual, owner). Migration klein, Tabelle klein. Bestehende Zeilen: manual, wenn sie nicht durch den Sync erklärbar sind (siehe 1.4). S E2
1.4 Bestandsprüfung als Befehl mit --dry-run: alle responsible-Pivots, deren Nutzer-Mail heute weder zur Firma noch zu einem Kontakt der Firma passt. Ausgabe als Liste, keine Löschung. Danach Entscheidung je Zeile. S 1.3
1.5 Entzug beim Login-Sync: auto-Pivots mit responsible, die keinen aktuellen E-Mail-Treffer mehr haben, auf member senken. manual und owner bleiben unberührt. Test: Kontakt löschen oder Adresse ändern, erneut anmelden, Schreibrecht ist weg. M 1.3, E2
1.6 security.blade.php: #[Locked] auf showRecoveryCodes; die Codes nur im Render nach einer Action ausgeben, nicht aus einer Property. Test: set('showRecoveryCodes', true) scheitert. S keine
1.7 Erneuter Nachweis vor disableTwoFactorAuthentication(), regenerateRecoveryCodes() und updateEmail(): aktueller TOTP-Code oder Recovery-Code. Das funktioniert auch für Konten ohne Passwort. Test: ohne Code 422, mit Code Erfolg. M E3
1.8 GoogleController.php:50: 2FA-Zweig aus MagicLinkConsumeController.php:73-81 übernehmen; danach die drei Login-Abschlüsse in einen Service ziehen. Test in GoogleLoginTest: Konto mit TOTP endet als Gast auf der Challenge. S keine
1.9 AppServiceProvider.php:125: Gate::before nur für is_super_admin && is_active. Test: deaktivierter Superadmin mit Session bekommt 403 auf Admin- und Kundenroute. S keine
1.10 routes/admin.php:101-104: Nutzer-Detailroute unter can:users:manage stellen, zusätzlich authorize() in beiden Grant-Actions. Test: Rechteentzug bei offener Komponente, danach grantCredits scheitert. S keine

Entscheidungen

  • E1 Fortify-Profiländerung abschalten (empfohlen) oder auf den pending_email-Prozess umbiegen. Abschalten ist ein Einzeiler und schließt den Einstieg vollständig; niemand nutzt den Endpunkt.
  • E2 Entzugsregel: auto-vergebene responsible ohne aktuellen Treffer werden beim nächsten Login auf member gesenkt (empfohlen), oder komplett entfernt. Absenken ist reversibel und verliert keine manuellen Rechte.
  • E3 Nachweisform für Sicherheitsaktionen: TOTP-/Recovery-Code (empfohlen, funktioniert für alle Kontotypen) oder Passwort mit Sonderweg für passwortlose Konten.

Abnahme: Die Kette aus F01 (fremde Adresse setzen, neu anmelden, zurückwechseln) ist als Test in ContactAccessTest abgebildet und hinterlässt keinen Pivot. Alle Login-Wege (Passwort, Magic Link, Google) landen bei aktivem TOTP auf der Challenge. Ergebnis von 1.4 ist gesichtet.


Block 2 — Newsletter (F05, F14, F15)

Vor der nächsten Kampagne. Der Versand erreicht heute nur die ersten 500 und verschickt 480 davon doppelt.

Nr. Arbeitspaket Aufwand Abhängigkeit
2.1 CampaignSender.php:39: Empfängerquery vor chunkById mit reorder() auf reine ID-Reihenfolge stellen. Die Sortierung ist eine UI-Sortierung der Vorschau, keine Versandreihenfolge. Test: 1.001 Nutzer mit gemischten created_at, jede ID genau einmal in den Empfängerzeilen. S E4
2.2 Unique-Index (newsletter_campaign_id, user_id) auf newsletter_campaign_recipients. Tabelle ist klein, kein FULLTEXT, ALTER ist sofort. S 2.1
2.3 Kampagnenstart atomar: UPDATE … SET status='sending' WHERE id=? AND status='draft', nur bei einer betroffenen Zeile weitermachen. Empfänger mit insertOrIgnore. Test: zwei Instanzen derselben Kampagne, nur eine startet. S 2.2
2.4 Versandjob: atomarer Claim (UPDATE … SET status='sending' WHERE id=? AND status='pending'), Abschluss von Empfängerstatus und Zähler in einer Transaktion. $tries und $backoff am Job setzen statt am Worker. M keine
2.5 Rückgabewert von Mail::send() auswerten: Bei Abbruch durch die Sperrliste Empfänger auf suppressed statt sent, Zähler nicht erhöhen. Neuer Status-Wert im Enum. Test: gesperrte Adresse endet nicht als „gesendet“. S 2.4
2.6 Befehl newsletter:resume {campaign}: alle pending-Empfänger einer sending-Kampagne neu einplanen, failed optional zurücksetzen. Ersetzt den unwirksamen queue:retry-Hinweis des Circuit Breakers. S 2.4

Entscheidung

  • E4 Versandreihenfolge: keine fachliche Bedeutung (empfohlen, dann reicht 2.1) oder „neueste zuerst“ als Anforderung (dann Cursor über created_at, id, Aufwand M).

Abnahme: Test über die 500er-Grenze in NewsletterCampaignTest; Doppelstart erzeugt keine zweite Empfängerzeile; gesperrte Adressen erscheinen in der Statistik als gesperrt.


Block 3 — Sichtbarkeit, Löschung, Cache, Eingaben (F04, F13, F22, F12, F11, F23)

Nr. Arbeitspaket Aufwand Abhängigkeit
3.1 LOWER(email) entfernen in ContactAccessService.php:132,146, User.php:186, ImportNewsletterBounces.php:131,174. B-Tree-Index auf contacts.email (6 MB, FULLTEXT vorhanden, Rebuild dauert Sekunden). Test: Groß-/Kleinschreibung und Leerzeichen bleiben korrekt; EXPLAIN auf dem Dev-Dump zeigt ref. S keine
3.2 ReleaseDetailPageData.php:76-80 und press-kits/show.blade.php:273-306: withoutGlobalScopes() durch withoutGlobalScope(PortalScope::class) ersetzen. Test: gelöschter Kontakt erscheint weder öffentlich noch im Pressefach. S keine
3.3 Dasselbe für API v1 (PressReleaseController.php:36,238,257, CompanyController.php:17,34), Vorschau (PressReleasePreviewController.php:33-42), Medien-Controller und PressRelease.php:194,207,219,430,440,730. Den Test PressReleasePreviewTest.php:237-247 auf delete() umstellen. Test je Modell: PM, Firma, Kontakt. M 3.2
3.4 Firmen-Observer oder Aufruf in companies/edit.blade.php: WebCaches-Flush bei delete() und bei Wechsel von is_active. Kontakt-Speichern und -Löschen im Pressefach flushen den Newsroom. NewsroomDetailPageData.php:41 prüft vor dem Cache-Treffer auch is_active und deleted_at. Test: Cache warm, Firma deaktivieren, nächster Aufruf 404. GoneTombstoneTest.php:162 so umbauen, dass der Cache vorher warm ist. S keine
3.5 Gemeinsamer URL-Resolver für Meldeverfahren (PressReleaseReportController.php:325, ReportFormController.php:102): Host gegen config/domains.php prüfen, Portal und Sprache aus Host und Pfad, dann Slug. Bei Duplikaten die kanonische PM bevorzugen (canonical_press_release_id is null desc, wie in RedirectPortalOnlyPagesFromHub.php:73). Fremder Host: kein Treffer. Test: gleicher Slug in beiden Portalen und beiden Sprachen. M keine
3.6 API v1: per_page als `integer min:1 max:100, statusalsin:über das Enum,languagealsin:de,en, category_idzusätzlichis_activeund Portal-Match. Test:per_page=-1, status[]=x` liefern 422.
3.7 PressReleaseAttachmentStorage.php:66 und ImageService.php:85: Rückgabewert von put() prüfen, bei false Exception. Logo-Tausch in press-kits/show.blade.php:153 und companies/edit.blade.php:157: erst speichern, dann DB, dann altes Logo löschen. Test mit gemocktem Storage, der false liefert. S keine

Abnahme: Kein withoutGlobalScopes() ohne Argument mehr außerhalb von Admin-Verwaltung und Importern (Architekturtest in Pest, der die Trefferliste einfriert). Login-Pfad ohne Vollscan.


Block 4 — KI-Prüfung und Scheduler (F07, F18, F20, F17, F19)

Nr. Arbeitspaket Aufwand Abhängigkeit
4.1 ClassifyPressRelease und ScorePressRelease bekommen den content_hash zum Zeitpunkt der Einplanung im Konstruktor. Vor dem Speichern Vergleich mit dem aktuellen Hash; bei Abweichung Ergebnis verwerfen, ins ki_audits protokollieren, neuen Lauf einplanen. Hash ebenfalls in ki_audits speichern. M keine
4.2 PressReleaseService::publish() und PublishScheduledPressReleases prüfen, dass classified_at nach der letzten Inhaltsänderung liegt oder der Audit-Hash zum aktuellen passt; sonst nicht veröffentlichen, sondern neu prüfen. Wenn der Re-Check einer terminierten PM rot wird, Kunde und Admin benachrichtigen statt still überspringen. Test: Text während der Analyse ändern, altes Ergebnis darf nicht freigeben. M 4.1
4.3 Kontingent periodisieren: Spalte users.press_release_quota_period (YYYY-MM). consumePublishSlot() und pressReleaseQuotaRemaining() setzen den Zähler auf null, wenn die Periode nicht dem aktuellen Monat entspricht. Der Cron wird zum Aufräumer, nicht zur einzigen Wahrheit. Test: Periode wechselt ohne Cron, doppelter Reset löscht nichts. M E5
4.4 PublishScheduledPressReleases.php:117: FAILURE, wenn $failed > 0. Fachliche Ablehnungen (Blacklist, rot) bleiben SUCCESS. Test auf Exit-Code. S keine
4.5 ScorePressRelease: public int $timeout = 120. retry_after in config/queue.php auf 150. Runbook ergänzen. S keine
4.6 AggregatePressReleaseViews implements Isolatable, Schedule mit --isolated. S keine

Entscheidung

  • E5 Kontingent bleibt an den Kalendermonat gebunden (empfohlen, entspricht dem heutigen Verhalten) oder wechselt auf die Stripe-Abo-Periode. Nur Ersteres ist in diesem Plan.

Block 5 — MAN-Abrechnung (F06, F16)

Beide latent. Aufnehmen, weil die Fixes klein sind und der Fehler beim ersten Datensatz mit Vertragsende ohne Vorwarnung eintritt.

Nr. Arbeitspaket Aufwand Abhängigkeit
5.1 ManualInvoiceService::duePaymentOptions() und die Sperr-Nachprüfung berücksichtigen grandfathered_until: keine Rechnung für eine Periode, die nach dem Vertragsende beginnt. Test: gestern beendet, heute endend, unbefristet. S E6
5.2 Ablauf-Befehl im Scheduler: Vereinbarungen mit überschrittenem grandfathered_until auf beendet setzen und GrandfatheringTerminated senden. Heute passiert das nur manuell. S 5.1
5.3 GenerateManualInvoices: nicht abrechenbare Vereinbarungen aus dem Fenster ausschließen (Zustandsmarke billing_hold_reason an der Vereinbarung) und in Seiten weiterlaufen, bis kein abrechenbarer Kandidat mehr da ist. Die Admin-Zahlungsseite zeigt die Hold-Fälle bereits; nur die Marke fehlt. Test: 50 unvollständige vor einer vollständigen. M keine

Entscheidung

  • E6 Verhalten am Vertragsende: letzte Teilperiode wird noch abgerechnet oder nicht. Empfehlung: keine Rechnung mehr, sobald current_period_end nach grandfathered_until liegt.

Block 6 — Entwicklung und Qualitätsgates (F24, F27, F28, F26, F25)

Nr. Arbeitspaket Aufwand Abhängigkeit
6.1 lint.yml: vendor/bin/pint --test statt Schreibmodus, contents: write entfernen, auskommentierten Commit-Schritt löschen. S keine
6.2 composer.json Script dev: npm run dev:all statt npm run dev. S keine
6.3 docs/README.md: Stand-Absatz und toten Link auf dev/archive/frontend/hub-flux/PROGRESS.md korrigieren, Hinweis „historisch, aktueller Einstieg ist dev/README.md“. Kein Umbau der Doku. S keine
6.4 Login-Modal in site-header.blade.php:190: Fokus beim Öffnen auf das erste Element, Rückgabe beim Schließen, Tab-Begrenzung. Ohne neue Abhängigkeit als kleine Alpine-Direktive; alternativ @alpinejs/focus (Abhängigkeit, Freigabe nötig). S keine
6.5 Architekturtest: withoutGlobalScopes() ohne Argument nur in einer Whitelist erlaubt (schließt Block 3 ab). S 3.3
6.6 Aufgeschoben: MySQL-/Worker-Integrationstests als zweite CI-Stufe. Sinnvoll erst nach Block 2 und 4, wenn es konkrete Sperr- und Timeout-Fälle zu beweisen gibt. L 2, 4

Nicht aufgenommen

  • F21 Legacy-Import. Befund korrekt (Vollladen der Pivot-Tabellen, kein Transaktionsschutz, Wiederanlauf ändert UUID und Slug). Der Importer läuft seit dem 16.07. nicht mehr und ist nicht im Scheduler. Falls das Altsystem noch einmal einen Delta-Lauf braucht: importRow() in DB::transaction und vorhandene PM über (legacy_portal, legacy_id) statt über die Importmap erkennen. Bis dahin steht nur diese Notiz.
  • Audit Abschnitt 10 (CSP, Proxy, Bildpixelgrenzen, Retention, PDF, Queue-Priorisierung). Nicht geprüft, nicht belegt. Einzelne Punkte können nach Block 6 als eigene Prüfaufträge folgen.
  • Große Volt-Dateien zerlegen. Richtig, aber kein Fehler. Läuft nebenbei, wenn ein Block ohnehin in press-kits/show oder press-releases/show arbeitet.

Reihenfolge und Deploys

Schritt Inhalt Deploy
1 Block 1, Punkte 1.1, 1.2, 1.6, 1.8, 1.9, 1.10 (alles S, kein Schema) Deploy A, Hotfix-Charakter
2 Block 2 komplett (Migration: ein Unique-Index auf kleiner Tabelle) Deploy B, vor der nächsten Kampagne
3 Block 1, Punkte 1.3, 1.4, 1.5, 1.7 (Migration granted_via, Bestandsprüfung) Deploy C
4 Block 3 Deploy D (Migration: Index contacts.email)
5 Block 4 Deploy E (Migration: quota_period, ki_audits.content_hash)
6 Block 5, Block 6 Deploy F

Migrationen in diesem Plan berühren press_releases nicht. press_release_search bleibt unangetastet. Für users (25 MB, FULLTEXT) ist die Spalte quota_period ein ALGORITHM=COPY von wenigen Sekunden; nach dem üblichen Fenster nachts einspielen.

Entscheidungen

Am 18.09.2026 alle sechs nach Empfehlung entschieden. Umsetzung ab demselben Tag.

Nr. Frage Entscheidung
E1 Fortify-Profiländerung abschalten oder umbiegen abschalten
E2 Auto-Rechte bei Wegfall der Grundlage senken oder entfernen auf member senken
E3 Nachweis vor Sicherheitsaktionen TOTP- oder Recovery-Code
E4 Newsletter-Versandreihenfolge fachlich relevant nein
E5 Kontingentperiode Kalendermonat
E6 Letzte MAN-Periode am Vertragsende keine Rechnung mehr

Umsetzungsprotokoll

Datum Punkte Commit Bemerkung