fix: Abgleich der Firmenrechte entzieht Bestandskonten ohne E-Mail-Bestätigung nicht mehr pauschal alle Rechte — memberships:resync-auto mit Probelauf und Notbremse

Auf Live meldete memberships:resync-auto nach v1.4.0 „vorher 19607, nachher 9":
syncMembershipsForEmail() entzog Konten ohne email_verified_at alle
automatischen Rechte, und 19.597 davon gehören Bestandskonten aus dem
Altsystem. Jetzt wird nur entzogen, was keine E-Mail-Grundlage mehr hat —
unabhängig vom Kontostatus (S-03 bleibt wirksam); neue Rechte weiterhin nur
für aktive, verifizierte Konten (F01).

memberships:resync-auto läuft in einer Transaktion, kennt --dry-run und rollt
zurück, wenn mehr als 5 % (mindestens 50) der Rechte wegfallen würden
(--force zum Erzwingen). Probelauf gegen die Live-Kopie: 19.607 → 19.316.
Wiederherstellung: storage/app/private/restore-company-user-auto-2026-10-07.sql
(nicht versioniert, INSERT IGNORE, idempotent).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-07 16:22:25 +00:00
parent 8045ef18cc
commit af72810296
4 changed files with 136 additions and 24 deletions

View file

@ -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

View file

@ -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;
}

View file

@ -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`).

View file

@ -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);
});