diff --git a/app/Console/Commands/AuditCompanyMemberships.php b/app/Console/Commands/AuditCompanyMemberships.php index 02ddc613..08af37f8 100644 --- a/app/Console/Commands/AuditCompanyMemberships.php +++ b/app/Console/Commands/AuditCompanyMemberships.php @@ -83,7 +83,7 @@ class AuditCompanyMemberships extends Command $before = (string) $this->option('before'); $candidates = $this->responsibleRows() ->filter(fn (object $row): bool => isset($explained[$row->company_id.':'.$row->user_id])) - ->where('granted_via', '!=', 'auto') + ->where('granted_via', 'manual') ->filter(fn (object $row): bool => $before === '' || (string) $row->created_at < $before) ->values(); @@ -112,7 +112,7 @@ class AuditCompanyMemberships extends Command ->where('company_id', $row->company_id) ->where('user_id', $row->user_id) ->where('role', 'responsible') - ->where('granted_via', '!=', 'auto') + ->where('granted_via', 'manual') ->update(['granted_via' => 'auto']); } } diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index 2a0e5a36..4d2e3d0d 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -109,10 +109,14 @@ class ContactAccessService * hat, ist unverifiziert und erhält nichts – der Login-Event feuert auch * vor der Verifizierungs-Weiche. Deaktivierte Konten ebenfalls nicht. * - * Gegenrichtung (Audit F10, E2): automatisch vergebene `responsible`-Rechte, + * Gegenrichtung (Audit F10/U01, E7): automatisch vergebene Zuordnungen, * deren E-Mail-Grundlage weggefallen ist (Adresse an Firma oder Kontakt - * getauscht, Kontakt gelöscht), fallen auf `member` zurück. Manuell oder - * aus dem Altbestand vergebene Rechte (`granted_via = manual`) bleiben. + * getauscht, Kontakt gelöscht, eigene Adresse geändert), werden entfernt – + * `member` wäre keine Leserolle, sondern die Teamrolle mit Schreibrecht + * auf alle Firmen-PMs. Eigene PMs bleiben über die Autorenschaft. Eine + * automatisch HOCHGESTUFTE manuelle Mitgliedschaft (`auto-upgrade`) fällt + * auf `member` zurück. Manuell oder aus dem Altbestand vergebene Rechte + * (`granted_via = manual`) bleiben. */ public function syncMembershipsForEmail(User $user): void { @@ -140,19 +144,23 @@ class ContactAccessService } /** - * Senkt automatisch vergebene `responsible`-Pivots ohne aktuellen - * E-Mail-Treffer auf `member`. Lesezugriff bleibt, Schreibzugriff geht. + * Entfernt automatisch vergebene `responsible`-Pivots ohne aktuellen + * E-Mail-Treffer (E7). Eine hochgestufte manuelle Mitgliedschaft geht auf + * ihren vorherigen Stand `member` zurück und gilt wieder als manuell. * * @param Collection $currentCompanyIds */ private function revokeStaleAutomaticGrants(User $user, Collection $currentCompanyIds): void { - DB::table('company_user') + $stale = DB::table('company_user') ->where('user_id', $user->id) ->where('role', 'responsible') - ->where('granted_via', 'auto') - ->whereNotIn('company_id', $currentCompanyIds->all()) - ->update(['role' => 'member', 'updated_at' => now()]); + ->whereNotIn('company_id', $currentCompanyIds->all()); + + (clone $stale)->where('granted_via', 'auto')->delete(); + + (clone $stale)->where('granted_via', 'auto-upgrade') + ->update(['role' => 'member', 'granted_via' => 'manual', 'updated_at' => now()]); } /** @@ -282,9 +290,11 @@ class ContactAccessService if ($linked === null) { $user->companies()->attach($companyId, ['role' => 'responsible', 'granted_via' => 'auto']); } elseif ($linked->pivot->role === 'member') { - // Nur den Read-only-Default hochstufen; owner/responsible bleiben. - // Die Hochstufung ist automatisch und damit wieder rücknehmbar. - $user->companies()->updateExistingPivot($companyId, ['role' => 'responsible', 'granted_via' => 'auto']); + // Nur den Team-Default hochstufen; owner/responsible bleiben. + // `auto-upgrade` merkt sich, dass darunter eine manuelle + // Mitgliedschaft liegt – die Rücknahme führt dorthin zurück + // statt die Zeile zu löschen. + $user->companies()->updateExistingPivot($companyId, ['role' => 'responsible', 'granted_via' => 'auto-upgrade']); } } diff --git a/dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md b/dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md index d1a271bd..7153e04b 100644 --- a/dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md +++ b/dev/2026-09-11-audit/ENTWICKLUNGSPLAN.md @@ -77,7 +77,7 @@ Der Kern: Firmenrechte entstehen aus einer E-Mail-Übereinstimmung, die nie auf | 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.5 | Entzug beim Login-Sync: `auto`-Pivots mit `responsible`, die keinen aktuellen E-Mail-Treffer mehr haben, entfernen (E7, revidiert E2: `member` ist keine Leserolle). Hochgestufte manuelle Mitgliedschaften (`auto-upgrade`) fallen auf `member` zurück; `manual` und `owner` bleiben unberührt. Test: Kontakt löschen oder Adresse ändern, erneut anmelden, Schreibrecht auf fremde Firmen-PMs ist weg. | M | 1.3, E7 | | 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 | @@ -87,7 +87,7 @@ Der Kern: Firmenrechte entstehen aus einer E-Mail-Übereinstimmung, die nie auf **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. +- **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. *Revidiert durch E7 am 18.09.2026: `member` ist die Teamrolle mit Schreibrecht auf alle Firmen-PMs; die Zuordnung wird entfernt.* - **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. @@ -225,7 +225,7 @@ Nicht aufgenommen aus Block 7: U05–U07, weil das Kampagnenmodul nicht in Betri | 7.10 | 7.1 | S | `--classify` zeigt vorher die betroffenen Zeilen (Anzahl, Stichprobe) und nimmt mit `--before=` nur Pivots vor dem Deploy. `ConfirmEmailChangeController` stößt den Sync an. | | 7.11 | 7.5 | S | `ReleaseDetailPageData` sortiert wie der Resolver (Erstquelle zuerst, dann ID). | | 7.12 | 7.4, 7.7 | S | Protokoll korrigieren: 40 Ausnahmen, `--classify` statt `--dry-run`, 1.8 teilweise, Rechnungslauf mit Kappe 500 pro Lauf, E6 Gleichheitsfall. | -| 7.13 | U01 | M | Nach E7. Empfehlung: automatische Zuordnung beim Wegfall der Grundlage entfernen statt senken. | +| 7.13 | U01 | M | Nach E7: automatische Zuordnung beim Wegfall der Grundlage entfernen statt senken; neuer Pivot-Wert `auto-upgrade` für hochgestufte manuelle Mitgliedschaften. | **E6, Gleichheitsfall:** Endet der Vertrag genau am letzten Tag der Periode (`grandfathered_until = current_period_end`), wird die Periode noch berechnet. Das ist die Regel „Rechnung, wenn die Periode nicht über das Vertragsende hinausreicht“. Bestätigt am 18.09.2026 mit E6. @@ -250,7 +250,7 @@ Tatsächlich umgesetzt am 18.09.2026 in fünf Commits auf `technik/betriebsfixes | C | Block 1, Punkte 1.3, 1.4, 1.5, 1.7 | `62202a4` | `company_user.granted_via` | | D | Block 3 und Block 4 | `f235156` | `contacts_email_idx`; `ki_audits.content_hash/discarded_at`; `users.press_release_quota_period` (COPY, Sekunden) | | E | Block 5 und Block 6 | `375f4df` | `user_payment_options.billing_hold_reason/-at` | -| F | Block 7 (ohne 7.13) | `235d392`, `1cd9fd1`, `ea68362` | `user_payment_options.grandfathering_expired_at/-notified_at`; Backfill `users.press_release_quota_period` (DML, Sekunden); Newsletter-Bereinigung angepasst (noch nicht live) | +| F | Block 7 | `235d392`, `1cd9fd1`, `ea68362`, 7.13 siehe Protokoll | `user_payment_options.grandfathering_expired_at/-notified_at`; Backfill `users.press_release_quota_period` (DML, Sekunden); Newsletter-Bereinigung angepasst (noch nicht live) | Migrationen berühren `press_releases` und `press_release_search` nicht. @@ -271,12 +271,12 @@ 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 | +| E2 | Auto-Rechte bei Wegfall der Grundlage senken oder entfernen | auf `member` senken – **revidiert durch E7** | | 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 | -| E7 | U01: automatische Zuordnung bei Wegfall der Grundlage | **offen.** Empfehlung: Zuordnung entfernen. `member` ist die normale Teamrolle mit PM-Schreibrechten; eigene PMs bleiben über die Autorenschaft erreichbar. Alternative Leserolle wäre eine neue Rolle (L). | +| E7 | U01: automatische Zuordnung bei Wegfall der Grundlage | Zuordnung entfernen (18.09.2026). `member` ist die normale Teamrolle mit PM-Schreibrechten; eigene PMs bleiben über die Autorenschaft, Eigentum, manuelle Rechte und weiterhin passende Adressen bleiben. Eine automatisch hochgestufte manuelle Mitgliedschaft fällt auf `member` zurück. | | E8 | U09: ungenutzte Fortify-2FA-Verwaltungsrouten | abschalten, folgt aus E3 | | E9 | U05–U07: Newsletter-Wiederaufnahme und Doppelversand | aufgeschoben, bis das Kampagnenmodul in Betrieb geht; bis dahin nur Abmeldung und Sperrliste live (Nutzer, 18.09.2026) | @@ -298,6 +298,6 @@ Am 18.09.2026 alle sechs nach Empfehlung entschieden. Umsetzung ab demselben Tag Gesamtsuite nach Block 7: 1.930 Tests grün, einer übersprungen; PHPStan ohne Meldung; Pint sauber. Ein Test (`ApiPortalResolutionTest`) brauchte wegen der neuen Kategorieprüfung eine gemeinsame Kategorie statt einer zufälligen. -Offen: 7.13 (U01) wartet auf E7; U05–U07 nach E9 aufgeschoben; 7.6 (Fokusführung) manuell im Browser abnehmen. +Offen: U05–U07 nach E9 aufgeschoben; 7.6 (Fokusführung) manuell im Browser abnehmen. Offen aus dem Plan: 6.6 (MySQL-/Worker-Integrationstests) bleibt aufgeschoben; 1.8 nur als Google-Zweig, nicht als Service-Zusammenführung; 4.2 hatte die Prüfung in `publish()` nicht (siehe Block 7, U02). Nachträglich zwei zufallsabhängige Tests festgezurrt (Kategorie-Portal in den API-Tests, Titel in der MAN-PDF-Adresse). Gesamtsuite nach allen Commits: 1.891 Tests grün, einer übersprungen; PHPStan ohne Meldung. diff --git a/tests/Feature/Auth/ContactAccessTest.php b/tests/Feature/Auth/ContactAccessTest.php index 2620ea7d..efb7ac8a 100644 --- a/tests/Feature/Auth/ContactAccessTest.php +++ b/tests/Feature/Auth/ContactAccessTest.php @@ -1,11 +1,15 @@ fresh()->accessibleCompanyIds())->not->toContain($company->id); }); -test('an auto-granted responsible role is lowered to member on the next login once the email basis is gone (Audit F10)', function () { +test('an auto-granted responsible role is removed on the next login once the email basis is gone (Audit F10/U01, E7)', function () { /** @var TestCase $this */ $user = User::factory()->create(['email' => 'wechsel@example.test', 'is_active' => true]); $user->assignRole('customer'); @@ -355,10 +359,62 @@ test('an auto-granted responsible role is lowered to member on the next login on Volt::test('auth.login')->set('email', 'wechsel@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); $fresh = User::query()->findOrFail($user->id); - $pivot = $fresh->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot; - expect($pivot->role)->toBe('member') + expect($fresh->companies()->withoutGlobalScopes()->whereKey($company->id)->exists())->toBeFalse() ->and($fresh->can('update', $company))->toBeFalse() - ->and($fresh->can('view', $company))->toBeTrue(); + ->and($fresh->can('view', $company))->toBeFalse(); +}); + +test('after the email basis is gone a non-author can no longer edit or correct the company press releases (Audit U01, E7)', function () { + /** @var TestCase $this */ + $author = User::factory()->create(['is_active' => true]); + $colleague = User::factory()->create(['email' => 'kollege@example.test', 'is_active' => true]); + $colleague->assignRole('customer'); + + $company = Company::factory()->presseecho()->create(['email' => 'kollege@example.test']); + $draft = PressRelease::factory()->forPortal(Portal::Presseecho)->for($company)->create(['user_id' => $author->id, 'status' => PressReleaseStatus::Draft]); + $published = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($company)->create(['user_id' => $author->id]); + $own = PressRelease::factory()->forPortal(Portal::Presseecho)->for($company)->create(['user_id' => $colleague->id, 'status' => PressReleaseStatus::Draft]); + + Volt::test('auth.login')->set('email', 'kollege@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); + + $linked = User::query()->findOrFail($colleague->id); + expect($linked->can('update', $draft))->toBeTrue() + ->and($linked->can('view', $published))->toBeTrue(); + + // Mitarbeiterwechsel: die Firma trägt eine andere Adresse. + $company->forceFill(['email' => 'nachfolger@example.test'])->save(); + auth()->logout(); + + Volt::test('auth.login')->set('email', 'kollege@example.test')->set('password', 'password')->call('login')->assertHasNoErrors(); + + $gone = User::query()->findOrFail($colleague->id); + expect($gone->can('update', $draft))->toBeFalse() + ->and($gone->can('submitForReview', $draft))->toBeFalse() + ->and($gone->can('delete', $draft))->toBeFalse() + ->and($gone->can('view', $published))->toBeFalse() + ->and($gone->can('update', $own))->toBeTrue(); + + // Die Korrektur-Action der Kundenseite hängt an `view` und findet die PM nicht mehr. + $this->actingAs($gone); + expect(fn () => Volt::test('customer.press-releases.show', ['id' => $published->id])) + ->toThrow(ModelNotFoundException::class); +}); + +test('an automatically upgraded manual membership falls back to member instead of being removed (E7)', function () { + /** @var TestCase $this */ + $user = User::factory()->create(['email' => 'team@example.test', 'is_active' => true]); + $company = Company::factory()->presseecho()->create(['email' => 'team@example.test']); + $user->companies()->attach($company->id, ['role' => 'member']); + + app(ContactAccessService::class)->syncMembershipsForEmail($user); + $pivot = $user->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot; + expect($pivot->role)->toBe('responsible')->and($pivot->granted_via)->toBe('auto-upgrade'); + + $company->forceFill(['email' => 'andere@example.test'])->save(); + app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh()); + + $pivot = $user->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot; + expect($pivot->role)->toBe('member')->and($pivot->granted_via)->toBe('manual'); }); test('a manually granted responsible role survives the login sync without an email match (Audit F10)', function () { @@ -423,7 +479,7 @@ test('a confirmed email change re-syncs company rights immediately (Audit 7.1)', $fresh = User::query()->findOrFail($user->id); expect($fresh->email)->toBe('neu@example.test') - ->and($fresh->companies()->withoutGlobalScopes()->whereKey($oldCompany->id)->first()->pivot->role)->toBe('member') + ->and($fresh->companies()->withoutGlobalScopes()->whereKey($oldCompany->id)->exists())->toBeFalse() ->and($fresh->companies()->withoutGlobalScopes()->whereKey($newCompany->id)->first()?->pivot->role)->toBe('responsible') ->and($fresh->can('update', $oldCompany))->toBeFalse() ->and($fresh->can('update', $newCompany))->toBeTrue();