Fix-Runde 1: Host-Sperren gehen an jeden Betreiber mit hosts.manage

Koordinator-Entscheidung statt neuer Einstellung: der Empfaengerkreis ist
per Definition richtig, weil es genau die Menschen sind, die eine
Host-Sperre in der Konsole ueberhaupt aufheben duerfen, und er pflegt sich
bei jedem Rollenwechsel von selbst mit. notifyHostManagers() nutzt Spaties
eigenen Operator::permission()-Scope (dieselbe Pruefung wie authorize()
an anderer Stelle, nur als Mengenabfrage) - kein Empfaenger heisst keine
Mail, kein Fehler, keine Drossel-Markierung; jede Adresse einzeln in ihrem
eigenen try/catch, damit ein abgelehntes Postfach nicht die uebrigen
Betreiber um ihre Meldung bringt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 14:47:54 +02:00
parent 2d979052d0
commit 17a07d1a68
2 changed files with 85 additions and 5 deletions

View File

@ -5,6 +5,7 @@ namespace App\Services\Security;
use App\Mail\SecurityBlockMail;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Operator;
use App\Models\SecurityBlock;
use App\Support\ProvisioningSettings;
use App\Support\Settings;
@ -63,7 +64,7 @@ class BlockAddress
return null;
}
return $this->createBlock(
$block = $this->createBlock(
ip: $ip,
attempts: $attempts,
reason: 'host_ssh',
@ -71,6 +72,10 @@ class BlockAddress
hostId: $host->id,
firewallHost: $host,
);
$this->notifyHostManagers($host, $block);
return $block;
}
/**
@ -80,10 +85,8 @@ class BlockAddress
* eigenen Spalte: nach einer Stunde interessiert er niemanden mehr, und
* eine Spalte dafür wäre außerhalb dieses einen Fensters immer bedeutungslos.
*
* Für Host-Sperren gibt es hier absichtlich noch kein Gegenstück: im Repo
* findet sich kein Muster, wie eine Betreiber-Meldung ihren Empfänger
* sucht (keine Konsolen-Rolle "bekommt Systemmails", keine Adresse dafür
* in den Einstellungen) das wäre eine eigene Entwurfsentscheidung.
* Für Host-Sperren siehe `notifyHostManagers()` weiter unten andere
* Empfängerlogik (Betreiber statt Kunde), gleiche Drossel-Idee.
*
* Zustellung ist nicht die Bedingung für Schutz: die Sperre steht bereits,
* bevor hier auch nur versucht wird zu verschicken, und die Drossel wird
@ -112,6 +115,55 @@ class BlockAddress
}
}
/**
* Benachrichtigt jeden AKTIVEN Betreiber mit `hosts.manage` höchstens
* einmal je Host und Stunde (`security.notified.host.<id>`, dieselbe
* Drossel-Idee wie bei Instanzen, eigener Schlüssel).
*
* Nicht an eine feste Adresse und nicht an eine neue Einstellung: genau
* diese Berechtigung entscheidet schon, wer eine Host-Sperre in der
* Konsole überhaupt aufheben darf (siehe `HostDetail`, `HostTerminal`,
* `ConfirmRemoveHost`, alle hinter `hosts.manage`) eine Mail an jemand
* anderen wäre Lärm, eine an jeden mit dieser Berechtigung ist per
* Definition der richtige Empfängerkreis, und er pflegt sich von selbst
* bei jedem Rollenwechsel mit (Koordinator-Entscheidung, siehe Bericht).
*
* Kein Empfänger ist kein Fehler: eine frische Installation ohne Admin
* neben dem Owner, oder eine, in der `hosts.manage` gerade niemandem
* zugewiesen ist, bekommt schlicht keine Mail die Sperre selbst steht
* trotzdem, unabhängig davon.
*/
private function notifyHostManagers(Host $host, SecurityBlock $block): void
{
$recipients = Operator::query()
->whereNull('disabled_at')
->permission('hosts.manage')
->pluck('email');
if ($recipients->isEmpty()) {
return;
}
$key = 'security.notified.host.'.$host->id;
$last = Settings::get($key);
if ($last !== null && now()->subHour()->lt($last)) {
return;
}
Settings::set($key, now()->toIso8601String());
// Einzeln umschlossen: ein Postfach, das ablehnt, darf die übrigen
// Betreiber nicht auch noch um ihre Meldung bringen.
foreach ($recipients as $email) {
try {
Mail::to($email)->queue(new SecurityBlockMail($block));
} catch (Throwable $e) {
report($e);
}
}
}
/** Läuft für diese Adresse an diesem Subjekt schon eine Sperre? */
private function hasActiveBlock(string $ip, ?int $instanceId, ?int $hostId): bool
{

View File

@ -3,6 +3,7 @@
// tests/Feature/Security/SecurityBlockMailTest.php
use App\Mail\SecurityBlockMail;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Mailbox;
use App\Models\SecurityBlock;
@ -67,3 +68,30 @@ it('geht standardmaessig aus dem System-Postfach und folgt der Wegwahl', functio
expect((new SecurityBlockMail($block))->envelope()->from->address)->toBe($info->address);
});
// ---- Host-Sperren: an jeden aktiven Betreiber mit hosts.manage (Koordinator-Entscheidung, Fix-Runde 1) ----
it('schickt bei einer Host-Sperre an jeden Betreiber, der Hosts verwalten darf', function () {
Mail::fake();
// Owner und Admin haben hosts.manage (seed_roles_and_permissions), Support
// nicht — siehe database/migrations/2026_07_25_133900_seed_roles_and_permissions.php.
$darf = operator('Admin');
$darfNicht = operator('Support');
$host = Host::factory()->create();
app(BlockAddress::class)->forHost($host, '203.0.113.7', 12);
// Beide Haelften: der Zustaendige bekommt sie, der Unzustaendige nicht.
Mail::assertQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($darf->email));
Mail::assertNotQueued(SecurityBlockMail::class, fn ($m) => $m->hasTo($darfNicht->email));
});
it('sperrt auch dann, wenn es keinen zustaendigen Betreiber gibt', function () {
Mail::fake();
// Keine Betreiber mit hosts.manage — kein Empfaenger, kein Fehler, aber
// sehr wohl eine Sperre. Schutz haengt nicht an Zustellung.
$host = Host::factory()->create();
expect(app(BlockAddress::class)->forHost($host, '203.0.113.7', 12))->not->toBeNull();
Mail::assertNothingQueued();
});