diff --git a/app/Console/Commands/ResyncAutomaticMemberships.php b/app/Console/Commands/ResyncAutomaticMemberships.php index 880db690..4b894030 100644 --- a/app/Console/Commands/ResyncAutomaticMemberships.php +++ b/app/Console/Commands/ResyncAutomaticMemberships.php @@ -15,11 +15,23 @@ use Illuminate\Support\Facades\DB; * Massen-Updates per Query-Builder. * * Manuelle (`manual`) und API-Mitgliedschaften (`api`) bleiben unberührt. + * + * Sicherung (nach dem Abgleich vom 07.10.2026, v1.4.1): Der Lauf geschieht in + * einer Transaktion. `--dry-run` rollt immer zurück und zeigt nur die Zahlen. + * Würde mehr als MAX_REVOKE_SHARE der Rechte (mindestens MAX_REVOKE_ABSOLUTE) + * wegfallen, rollt das Kommando zurück, solange nicht `--force` gesetzt ist — + * ein solcher Einbruch deutet auf einen Fehler, nicht auf veraltete Rechte. */ class ResyncAutomaticMemberships extends Command { + private const MAX_REVOKE_SHARE = 0.05; + + private const MAX_REVOKE_ABSOLUTE = 50; + protected $signature = 'memberships:resync-auto - {--user= : Nur diesen User (ID) abgleichen}'; + {--user= : Nur diesen User (ID) abgleichen} + {--dry-run : Nur zählen, nichts speichern} + {--force : Auch bei auffällig vielen Entzügen speichern}'; protected $description = 'Gleicht automatisch vergebene Firmenrechte mit den aktuellen Kontakt- und Firmenadressen ab.'; @@ -39,23 +51,50 @@ class ResyncAutomaticMemberships extends Command return self::SUCCESS; } - $bar = $this->output->createProgressBar($userIds->count()); + DB::beginTransaction(); - foreach ($userIds->chunk(200) as $chunk) { - User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void { - $contactAccess->syncMembershipsForEmail($user); - $bar->advance(); - }); + try { + $bar = $this->output->createProgressBar($userIds->count()); + + foreach ($userIds->chunk(200) as $chunk) { + User::query()->whereIn('id', $chunk->all())->get()->each(function (User $user) use ($contactAccess, $bar): void { + $contactAccess->syncMembershipsForEmail($user); + $bar->advance(); + }); + } + + $bar->finish(); + $this->newLine(); + + $after = $this->countAutomaticGrants(); + $revoked = max(0, $before - $after); + $summary = "Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}."; + + if ($this->option('dry-run')) { + DB::rollBack(); + $this->info($summary.' Probelauf — nichts gespeichert.'); + + return self::SUCCESS; + } + + $limit = max(self::MAX_REVOKE_ABSOLUTE, (int) ceil($before * self::MAX_REVOKE_SHARE)); + + if ($revoked > $limit && ! $this->option('force')) { + DB::rollBack(); + $this->error($summary." Es würden {$revoked} Rechte wegfallen (Grenze {$limit}) — zurückgerollt. Ursache prüfen, nur bei Absicht mit --force wiederholen."); + + return self::FAILURE; + } + + DB::commit(); + $this->info($summary); + + return self::SUCCESS; + } catch (\Throwable $e) { + DB::rollBack(); + + throw $e; } - - $bar->finish(); - $this->newLine(); - - $after = $this->countAutomaticGrants(); - - $this->info("Abgeglichen: {$userIds->count()} User. Automatische Rechte vorher {$before}, nachher {$after}."); - - return self::SUCCESS; } private function countAutomaticGrants(): int diff --git a/app/Services/Auth/ContactAccessService.php b/app/Services/Auth/ContactAccessService.php index ecb91fd1..1cf3e931 100644 --- a/app/Services/Auth/ContactAccessService.php +++ b/app/Services/Auth/ContactAccessService.php @@ -129,12 +129,7 @@ class ContactAccessService { $email = mb_strtolower(trim((string) $user->email)); - // Ohne nachgewiesene Adresse gibt es keine Grundlage für automatische - // Rechte — vorhandene fallen weg (Sicherheitsaudit 07.10.2026, S-03: - // der Abgleich läuft jetzt auch außerhalb des Logins). - if (! $user->is_active || ! $user->hasVerifiedEmail() || $email === '') { - $this->revokeStaleAutomaticGrants($user, collect()); - + if ($email === '') { return; } @@ -142,9 +137,15 @@ class ContactAccessService $companies = $this->companiesFor($email); $companyIds = $this->companyIdsFrom($contacts, $companies); + // Entzogen wird nur, was keine E-Mail-Grundlage mehr hat — unabhängig + // vom Kontostatus (S-03). Bestandskonten aus dem Altsystem haben kein + // `email_verified_at`; ihre noch begründeten Rechte bleiben. Eine + // erste Fassung entzog Unverifizierten pauschal alles und nahm beim + // Abgleich am 07.10.2026 so 19.598 begründete Rechte weg (v1.4.1). $this->revokeStaleAutomaticGrants($user, $companyIds); - if ($companyIds->isEmpty()) { + // Neue Rechte nur für aktive Konten mit nachgewiesener Adresse (F01). + if (! $user->is_active || ! $user->hasVerifiedEmail() || $companyIds->isEmpty()) { return; } diff --git a/dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md b/dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md index 98b978c3..d65dd4a5 100644 --- a/dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md +++ b/dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md @@ -78,7 +78,7 @@ Alles zusammen geht als ein Release raus. Vorgeschlagen ist `v1.4.0`: neue Funkt *Nach dem Deploy:* -8. `php artisan memberships:resync-auto`. Die Ausgabe nennt die Zahl der automatischen Rechte vorher und nachher; sie mit Schritt 6 vergleichen. +8. Erst `php artisan memberships:resync-auto --dry-run`, danach ohne Option. Zu erwarten sind laut Probelauf mit der Live-Kopie rund 291 von 19.607 Rechten. Seit v1.4.1 rollt das Kommando zurück, wenn mehr als 5 % (mindestens 50) wegfallen würden; dann gilt: nicht mit `--force` erzwingen, sondern die Ursache klären. 9. Die üblichen Prüfungen aus Abschnitt D, Punkt 17. 10. **Smoke-Test der Neuerungen:** - Als Admin anmelden. Es erscheint „Anmeldung bestätigen“; dort einen Passkey bzw. die App einrichten oder bestätigen. @@ -88,6 +88,17 @@ Alles zusammen geht als ein Release raus. Vorgeschlagen ist `v1.4.0`: neue Funkt *Rollback (Abschnitt E):* `bash dev/betrieb/deploy.sh v1.3.2`. Die Tabelle `passkeys` bleibt dabei stehen; der alte Code nutzt sie nicht. Ein Rollback entfernt auch die Admin-Pflicht. Soll nur die Pflicht weg, reicht der Notfallschalter `AUTH_REQUIRE_STRONG_ADMIN=false` mit anschließendem `php artisan config:cache`. +**Vorfall 07.10.2026: Abgleich mit v1.4.0 entzieht 19.598 begründete Firmenrechte (behoben in v1.4.1).** + +- **Was passiert ist:** `memberships:resync-auto` meldete auf Live „vorher 19607, nachher 9“. +- **Ursache:** `syncMembershipsForEmail()` entzog in v1.4.0 Konten ohne `email_verified_at` pauschal alle automatischen Rechte. 19.597 der 19.607 Rechte gehören solchen Bestandskonten aus dem Altsystem. Die Tests deckten nur verifizierte Konten ab. +- **Fix v1.4.1:** + - Entzogen wird nur, was keine E-Mail-Grundlage mehr hat, unabhängig vom Kontostatus. Neue Rechte gibt es weiterhin nur für aktive, verifizierte Konten. + - Das Kommando läuft in einer Transaktion, hat `--dry-run` und rollt bei auffällig vielen Entzügen zurück. + - Regressionstests sind ergänzt. + - Probelauf gegen die Live-Kopie: 19.607 → 19.316. +- **Wiederherstellung:** `storage/app/private/restore-company-user-auto-2026-10-07.sql`, erzeugt aus dem Live-Backup vor dem Deploy. Sie spielt nur `INSERT IGNORE` der 19.607 automatischen Rechte ein und darf mehrfach laufen. Lokal in einer zurückgerollten Transaktion geprüft: 19.607 wiederhergestellt, zweiter Lauf 0. + **Vorfall 07.10.2026: lokale Datenbank geleert (nicht durch diese Umsetzung).** - **Was das Binärlog zeigt:** Um 09:44 UTC (`binlog.000009`, Pos. 566161486) wurden alle Tabellen von `pressekonto` auf `global-mysql` gelöscht, durch ein `migrate:fresh` (`RefreshDatabase`) eines frischen Demo-Projekts. In diesem Projekt wurden die Vorlagen aus `dev/git-deploy-monitoring` getestet (Commit `2215826`). diff --git a/tests/Feature/Auth/AutoMembershipRevocationTest.php b/tests/Feature/Auth/AutoMembershipRevocationTest.php index b5ad6780..65fe934f 100644 --- a/tests/Feature/Auth/AutoMembershipRevocationTest.php +++ b/tests/Feature/Auth/AutoMembershipRevocationTest.php @@ -140,3 +140,64 @@ test('the resync command removes stale automatic grants left by bulk updates', f expect(membershipOf($user, $company))->toBeNull() ->and(membershipOf($keeper, $keptCompany)?->granted_via)->toBe('auto'); }); + +/* + * Regression v1.4.1: Bestandskonten aus dem Altsystem haben kein + * `email_verified_at`. Der Abgleich am 07.10.2026 entzog ihnen pauschal alle + * automatischen Rechte (19.598 von 19.607), obwohl die Grundlage bestand. + */ +test('legacy accounts without verified email keep automatic access whose basis still exists', function () { + /** @var TestCase $this */ + $legacy = User::factory()->unverified()->create(['email' => 'altkunde@firma.test', 'is_active' => true]); + $company = Company::factory()->presseecho()->create(); + $contact = Contact::factory()->for($company)->create(['email' => 'altkunde@firma.test', 'portal' => $company->portal->value]); + $legacy->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'auto']); + + $this->artisan('memberships:resync-auto')->assertSuccessful(); + app(ContactAccessService::class)->syncMembershipsForEmail($legacy); + + expect(membershipOf($legacy, $company)?->granted_via)->toBe('auto'); + + // Fällt die Grundlage weg, endet das Recht auch für sie (S-03). + $contact->delete(); + + expect(membershipOf($legacy, $company))->toBeNull(); +}); + +test('inactive or unverified accounts do not receive new automatic access', function (array $attributes) { + $user = User::factory()->create(['email' => 'neu-ohne-nachweis@firma.test', ...$attributes]); + $company = Company::factory()->presseecho()->create(); + Contact::factory()->for($company)->create(['email' => 'neu-ohne-nachweis@firma.test', 'portal' => $company->portal->value]); + + app(ContactAccessService::class)->syncMembershipsForEmail($user); + + expect(membershipOf($user, $company))->toBeNull(); +})->with([ + 'unverified' => [['email_verified_at' => null]], + 'inactive' => [['is_active' => false]], +]); + +test('the resync command rolls back a suspicious mass revocation unless forced', function () { + /** @var TestCase $this */ + $company = Company::factory()->presseecho()->create(); + $users = User::factory()->count(60)->create(); + + foreach ($users as $user) { + $user->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'auto']); + } + + $count = fn (): int => DB::table('company_user')->where('granted_via', 'auto')->count(); + + $this->artisan('memberships:resync-auto', ['--dry-run' => true]) + ->expectsOutputToContain('vorher 60, nachher 0. Probelauf') + ->assertSuccessful(); + expect($count())->toBe(60); + + $this->artisan('memberships:resync-auto') + ->expectsOutputToContain('zurückgerollt') + ->assertFailed(); + expect($count())->toBe(60); + + $this->artisan('memberships:resync-auto', ['--force' => true])->assertSuccessful(); + expect($count())->toBe(0); +});