auth: automatische Firmenzuordnung beim Wegfall der E-Mail-Grundlage entfernen statt senken (Block 7.13, U01, E7)
- Login-Sync löscht auto-Pivots ohne aktuellen Treffer; member wäre die Teamrolle mit Schreibrecht auf alle Firmen-PMs, keine Leserolle - hochgestufte manuelle Mitgliedschaften tragen granted_via=auto-upgrade und fallen auf member zurück; manual und owner bleiben unberührt - company-memberships:audit --classify stuft nur manual-Zeilen um - Plan: E2 revidiert, E7 entschieden, 1.5 und 7.13 nachgezogen Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
c26bdff681
commit
5122624687
4 changed files with 92 additions and 26 deletions
|
|
@ -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']);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<int, int> $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']);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Volt\Volt;
|
||||
|
|
@ -334,7 +338,7 @@ test('the membership sync ignores unverified and deactivated users regardless of
|
|||
expect($inactive->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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue