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:
parent
8045ef18cc
commit
af72810296
4 changed files with 136 additions and 24 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue