From f1fb06699f767317e3c828cd341c1f3fefe6dbba Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 18:55:40 +0200 Subject: [PATCH] Plan: Mitarbeiterverwaltung in acht Aufgaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aufgabe 1-7 sind ohne den neuen Mailserver pruefbar und koennen vollstaendig gebaut werden, bevor er steht. Aufgabe 8 ist der Nachweis gegen echte Hardware — und der zweite Punkt darin kann den Entwurf umwerfen: ob Nextcloud "0 B" als null oder als unbegrenzt auslegt, steht in keiner Dokumentation. Beim Vorabdurchgang drei eigene Fehler gefunden und behoben: StepResult hat kein failed(), und drei von vier Rueckrufen in NextcloudUsers trugen eine andere Signatur als der vierte. --- .../plans/2026-08-03-mitarbeiterverwaltung.md | 1947 +++++++++++++++++ 1 file changed, 1947 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-mitarbeiterverwaltung.md diff --git a/docs/superpowers/plans/2026-08-03-mitarbeiterverwaltung.md b/docs/superpowers/plans/2026-08-03-mitarbeiterverwaltung.md new file mode 100644 index 0000000..2f3c98b --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-mitarbeiterverwaltung.md @@ -0,0 +1,1947 @@ +# Mitarbeiterverwaltung im Userpanel — Umsetzungsplan + +> **Für agentische Arbeiter:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`, um diesen Plan Aufgabe für Aufgabe umzusetzen. Schritte benutzen Checkbox-Syntax (`- [ ]`). + +**Ziel:** Aus der Attrappe im Portal wird echte Verwaltung — der Inhaber legt Mitarbeiter an, lädt sie ein, ändert Rollen, sperrt und reaktiviert, und jede dieser Handlungen erreicht die Nextcloud des Kunden. + +**Architektur:** Die Seite fasst nie einen Gast an. Sie schreibt eine Absicht auf den Sitz und schickt einen Auftrag auf die `provisioning`-Warteschlange — die einzige, die Tunnel und Proxmox-Zugangsdaten hat. Der Auftrag spiegelt den Sitz nach Nextcloud und schreibt zurück, was wirklich passiert ist. Voraussetzung für alles ist ein Mailversand in der Kunden-Nextcloud, den es heute nicht gibt. + +**Tech Stack:** Laravel 13.8, Livewire 3 (klassenbasiert), Pest, Proxmox-Gastagent (`guestExec`), Nextcloud `occ`, Redis-Warteschlangen. + +**Entwurf:** `docs/superpowers/specs/2026-08-03-mitarbeiterverwaltung-design.md` + +## Global Constraints + +- **Nichts in `app/` schreibt `docker compose exec` selbst.** Jeder Gast-Befehl entsteht in `App\Support\NextcloudOcc`. Ein bestehender Test erzwingt das und nimmt nur diese eine Datei aus. +- **Kein Nextcloud-Benutzer wird je gelöscht.** `user:delete` darf in keiner Datei unter `app/` vorkommen — das wird in Aufgabe 7 testerzwungen. +- **Der Nextcloud-Benutzername wird einmal gesetzt und nie geändert.** Nextcloud kann Benutzer nicht umbenennen. +- **Aufträge werfen nicht weiter.** Ein nicht erreichbarer Gast darf den Bereitstellungs-Arbeiter nicht mitreißen, auf dem die bezahlte Kundenbereitstellung läuft. `tries = 1`. +- **Keine Zugangsdaten ins Log**, in keiner Form. +- **Absicht und Wirklichkeit stehen getrennt**: `seats.status` = was der Inhaber will, `seats.nc_state` = was in der Nextcloud ist. +- **R19:** jede Zeit, die ein Mensch liest, geht durch `->local()`. +- **R20:** Bearbeiten im Modal, nicht in der Zeile. **R23:** Bestätigen im Modal, nie `wire:confirm`. **R24:** Modal nie höher als der Bildschirm. +- **R22:** eine Prüfrunde, eine Fix-Runde, ein Re-Review. Danach wird geparkt. +- Sprachdateien immer **beide** (`lang/de/`, `lang/en/`). +- Tests laufen mit `docker compose exec -u 1000:1000 -T app php artisan test`. + +--- + +## Dateiaufstellung + +| Datei | Verantwortung | +|---|---| +| `app/Services/Mail/GuestMailConfig.php` | **neu** — welche Mailwerte eine Instanz bekommt, oder warum keine | +| `app/Support/NextcloudOcc.php` | **ändern** — eine Befehlsform, bei der der Wert erst IM Container eingesetzt wird | +| `app/Provisioning/Steps/Customer/ConfigureInstanceMail.php` | **neu** — trägt die Werte in den Gast | +| `app/Console/Commands/ConfigureInstanceMail.php` | **neu** — Bestand nachrüsten | +| `config/provisioning.php` | **ändern** — Schritt in `customer`, neue Pipeline `instance-mail` | +| `database/migrations/…_add_nextcloud_columns_to_seats.php` | **neu** — `nc_username`, `nc_state`, `nc_error`, `nc_synced_at` | +| `app/Models/Seat.php` | **ändern** — Zustände, Rollen→Gruppen | +| `app/Services/Nextcloud/NextcloudUsers.php` | **neu** — spricht occ: anlegen, einladen, Gruppen, Speicherplatz, sperren | +| `app/Provisioning/Jobs/SyncSeatToNextcloud.php` | **neu** — der Auftrag, der einen Sitz spiegelt | +| `app/Livewire/Users.php` | **ändern** — anlegen ≠ einladen, Ratelimit, entziehen ohne Löschen | +| `resources/views/livewire/users.blade.php` | **ändern** — Zustände, Fehler, Wiederholen | +| `lang/{de,en}/users.php` | **ändern** — neue Texte | + +--- + +## Task 1: Die Mailwerte einer Instanz — und wann es keine gibt + +**Files:** +- Create: `app/Services/Mail/GuestMailConfig.php` +- Create: `tests/Feature/Mail/GuestMailConfigTest.php` + +**Interfaces:** +- Consumes: `App\Support\Settings`, `App\Models\Mailbox`, `App\Models\Instance` +- Produces: `GuestMailConfig::for(Instance $instance): self`, `->available(): bool`, `->problem(): ?string`, `->values(): array`, `->password(): string` + +Diese Aufgabe fasst keinen Gast an. Sie beantwortet nur die Frage „welche Werte, oder warum keine" — damit sie ohne Proxmox prüfbar ist. + +- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben** + +`tests/Feature/Mail/GuestMailConfigTest.php`: + +```php +create([ + 'key' => 'instance-relay', + 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', + 'password' => 'geheim', + 'active' => true, + 'authenticates' => true, + ]); +} + +it('baut die Werte aus Server UND Postfach zusammen', function () { + versandbereit(); + $instance = Instance::factory()->create(['name' => 'Musterfirma']); + + $config = GuestMailConfig::for($instance); + + expect($config->available())->toBeTrue() + ->and($config->values())->toMatchArray([ + 'mail_smtpmode' => 'smtp', + 'mail_smtphost' => 'mail.clupilot.cloud', + 'mail_smtpport' => '587', + 'mail_smtpsecure' => 'tls', + 'mail_smtpauth' => 'true', + 'mail_smtpname' => 'noreply@clupilot.cloud', + 'mail_from_address' => 'noreply', + 'mail_domain' => 'clupilot.cloud', + ]) + ->and($config->password())->toBe('geheim'); +}); + +it('traegt das Passwort NICHT unter values()', function () { + // values() wandert in Befehle. Das Passwort geht einen eigenen Weg, damit + // niemand es versehentlich mit den uebrigen Werten mitschleift. + versandbereit(); + + expect(GuestMailConfig::for(Instance::factory()->create())->values()) + ->not->toHaveKey('mail_smtppassword'); +}); + +it('sagt ohne Mailserver, dass nichts geschrieben werden darf', function () { + Mailbox::factory()->create(['key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', 'password' => 'geheim', 'active' => true]); + Settings::set('mail.host', ''); + + $config = GuestMailConfig::for(Instance::factory()->create()); + + expect($config->available())->toBeFalse() + ->and($config->problem())->toBe('no_server'); +}); + +it('sagt ohne Absenderpostfach, dass nichts geschrieben werden darf', function () { + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + + $config = GuestMailConfig::for(Instance::factory()->create()); + + expect($config->available())->toBeFalse() + ->and($config->problem())->toBe('no_mailbox'); +}); + +it('weist ein Postfach ohne Zugangsdaten ab', function () { + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Mailbox::factory()->create(['key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', 'password' => null, 'active' => true, 'authenticates' => true]); + + expect(GuestMailConfig::for(Instance::factory()->create())->problem())->toBe('no_mailbox'); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Mail/GuestMailConfigTest.php` +Erwartet: FEHLSCHLAG, `Class "App\Services\Mail\GuestMailConfig" not found` + +- [ ] **Schritt 3: Die Klasse schreiben** + +`app/Services/Mail/GuestMailConfig.php`: + +```php + */ + private readonly array $values, + private readonly string $password, + ) {} + + public static function for(Instance $instance): self + { + $host = trim((string) Settings::get('mail.host', '')); + $port = (int) Settings::get('mail.port', 0); + + if ($host === '' || $port < 1) { + return new self('no_server', [], ''); + } + + $box = self::mailboxFor($instance); + + if ($box === null || ! $box->isConfigured()) { + return new self('no_mailbox', [], ''); + } + + // Die Adresse zerfaellt in die beiden Werte, die Nextcloud getrennt + // fuehrt: den linken Teil als Absender, den rechten als Maildomain. + [$local, $domain] = array_pad(explode('@', $box->address, 2), 2, ''); + + return new self(null, [ + 'mail_smtpmode' => 'smtp', + 'mail_smtphost' => $host, + 'mail_smtpport' => (string) $port, + 'mail_smtpsecure' => (string) Settings::get('mail.encryption', 'tls'), + 'mail_smtpauth' => 'true', + 'mail_smtpname' => $box->smtpUsername(), + 'mail_from_address' => $local, + 'mail_domain' => $domain, + ], (string) $box->password); + } + + /** + * Heute fuer jede Instanz dasselbe Konto. Siehe Klassenkopf — hier haengt + * die spaetere Fassung mit einem Konto je Kunde. + */ + private static function mailboxFor(Instance $instance): ?Mailbox + { + return Mailbox::findByKey(self::RELAY_KEY); + } + + public function available(): bool + { + return $this->problem === null; + } + + /** `no_server`, `no_mailbox` oder null. */ + public function problem(): ?string + { + return $this->problem; + } + + /** + * Alles ausser dem Passwort. Das geht einen eigenen Weg, damit es beim + * Bauen der Befehle nicht versehentlich mitwandert. + * + * @return array + */ + public function values(): array + { + return $this->values; + } + + public function password(): string + { + return $this->password; + } +} +``` + +- [ ] **Schritt 4: Laufen lassen, grün** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Mail/GuestMailConfigTest.php` +Erwartet: 5 grün + +- [ ] **Schritt 5: Festschreiben** + +```bash +git commit -F- -- app/Services/Mail/GuestMailConfig.php tests/Feature/Mail/GuestMailConfigTest.php <<'MSG' +Welche Mailwerte eine Kundeninstanz bekommt — und wann gar keine + +Server aus app_settings, Zugangsdaten aus dem Postfach, an einer Stelle +zusammengelegt. Fehlt eines von beiden, wird NICHTS geschrieben: eine halb +eingetragene Mailkonfiguration laesst Nextcloud bei jedem Versand still +scheitern. + +mailboxFor() ist die Naht, an der spaeter ein Konto je Kunde haengt. +MSG +``` + +--- + +## Task 2: Ein occ-Befehl, dessen Wert erst im Container entsteht + +**Files:** +- Modify: `app/Support/NextcloudOcc.php` +- Test: `tests/Feature/NextcloudOccTest.php` (anlegen, falls nicht vorhanden) + +**Interfaces:** +- Produces: `NextcloudOcc::commandExpandingEnv(string $arguments, array $env): string` + +`config:system:set mail_smtppassword --value=…` verlangt den Wert als Argument. Die bestehende `command()` setzt Umgebungswerte VOR dem `docker compose exec`, wo die äußere Shell sie einsetzt — der Wert stünde damit in der Prozessliste der ganzen VM, nicht nur des Containers. + +- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben** + +```php + 'geheim'], + ); + + // Das Geheimnis steht als Zuweisung da (die aeussere Shell reicht es + // durch), aber der occ-Aufruf traegt nur den VARIABLENNAMEN — eingesetzt + // wird er von der sh INNERHALB des Containers. + expect($befehl)->toContain("CLUPILOT_SMTP_PW='geheim'") + ->and($befehl)->toContain('-e CLUPILOT_SMTP_PW') + ->and($befehl)->toContain('sh -c') + // Entscheidend: hinter `php occ` steht der Name, nicht der Wert. + ->and(substr($befehl, strpos($befehl, 'sh -c')))->not->toContain('geheim'); +}); + +it('haelt sich an dasselbe Verzeichnis und denselben Benutzer wie command()', function () { + $befehl = NextcloudOcc::commandExpandingEnv('status', []); + + expect($befehl)->toStartWith('cd '.NextcloudOcc::DIRECTORY) + ->and($befehl)->toContain('-u '.NextcloudOcc::USER); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/NextcloudOccTest.php` +Erwartet: FEHLSCHLAG, `Call to undefined method … ::commandExpandingEnv()` + +- [ ] **Schritt 3: Die Methode schreiben** + +An `app/Support/NextcloudOcc.php` anfügen: + +```php + /** + * Wie command(), aber der Wert wird erst von der Shell IM Container + * eingesetzt. + * + * command() taugt fuer `--password-from-env`, wo occ selbst die + * Umgebungsvariable liest. `config:system:set` kann das nicht: es will den + * Wert als Argument. Setzte die aeussere Shell ihn ein, stuende das + * Passwort in der Prozessliste der KUNDEN-VM — dort, wo jeder mit einer + * Shell auf der Maschine `ps` ausfuehren kann. So steht es nur in der des + * Containers. + * + * EHRLICHERWEISE ist das Hygiene, kein Schutz: Nextcloud legt + * `mail_smtppassword` anschliessend im Klartext in config/config.php ab. + * Wer auf der Maschine eine Shell hat, liest es dort. Die eigentliche + * Eingrenzung liegt woanders — das Versandkonto kann nur senden, und der + * Versandport nimmt nur die eigenen Hostadressen an. Diese Methode senkt + * die Gelegenheit, sie beseitigt sie nicht. + * + * @param array $env + */ + public static function commandExpandingEnv(string $arguments, array $env): string + { + $assignments = ''; + $forwards = ''; + + foreach ($env as $name => $value) { + $assignments .= $name.'='.escapeshellarg($value).' '; + $forwards .= '-e '.$name.' '; + } + + return 'cd '.self::DIRECTORY.' && '.$assignments + .'docker compose exec -T -u '.self::USER.' '.$forwards + .'app sh -c '.escapeshellarg('php occ '.$arguments); + } +``` + +- [ ] **Schritt 4: Laufen lassen, grün** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/NextcloudOccTest.php` +Erwartet: 2 grün + +- [ ] **Schritt 5: Die bestehende Regel prüfen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test --filter=DeploymentRunsAsTheAppUser` +Erwartet: grün — die Regel „nichts ausser NextcloudOcc schreibt `docker compose exec`" gilt weiter. + +- [ ] **Schritt 6: Festschreiben** + +```bash +git commit -F- -- app/Support/NextcloudOcc.php tests/Feature/NextcloudOccTest.php <<'MSG' +Eine occ-Befehlsform, deren Wert erst im Container eingesetzt wird + +config:system:set will den Wert als Argument. Setzte ihn die aeussere Shell +ein, stuende das SMTP-Passwort in der Prozessliste der Kunden-VM. Mit `sh -c` +im Container steht dort nur der Variablenname. + +Im Kopfkommentar steht ausdruecklich, dass das Hygiene ist und kein Schutz: +Nextcloud legt den Wert danach im Klartext in config.php ab. +MSG +``` + +--- + +## Task 3: Der Bereitstellungsschritt, der den Versand einträgt + +**Files:** +- Create: `app/Provisioning/Steps/Customer/ConfigureInstanceMail.php` +- Modify: `config/provisioning.php` +- Create: `tests/Feature/Provisioning/ConfigureInstanceMailTest.php` + +**Interfaces:** +- Consumes: `GuestMailConfig::for()`, `NextcloudOcc::commandExpandingEnv()`, `CustomerStep::guest()` +- Produces: Schrittschlüssel `configure_instance_mail`; Pipeline `instance-mail` + +- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben** + +```php +create([ + 'pipeline' => 'instance-mail', + 'context' => ['instance_id' => $instance->id, 'node' => 'pve', 'vmid' => 201], + ]); +} + +function versandbereitFuerSchritt(): void +{ + Settings::set('mail.host', 'mail.clupilot.cloud'); + Settings::set('mail.port', 587); + Settings::set('mail.encryption', 'tls'); + Mailbox::factory()->create([ + 'key' => 'instance-relay', 'address' => 'noreply@clupilot.cloud', + 'username' => 'noreply@clupilot.cloud', 'password' => 'geheim', + 'active' => true, 'authenticates' => true, + ]); +} + +it('traegt jeden Wert einzeln in den Gast', function () { + versandbereitFuerSchritt(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = Instance::factory()->create(); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('config:system:set mail_smtphost --value=') + ->and($befehle)->toContain('mail.clupilot.cloud') + ->and($befehle)->toContain('config:system:set mail_domain --value=') + ->and($befehle)->toContain('clupilot.cloud'); +}); + +it('schreibt das Passwort NIE als Klartext-Argument in den Docker-Aufruf', function () { + // Die Zusicherung aus Aufgabe 2, hier am ECHTEN Befehl geprueft — nicht + // am Baustein allein. + versandbereitFuerSchritt(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz(Instance::factory()->create())); + + foreach ($pve->guestCommands as $befehl) { + if (! str_contains($befehl, 'mail_smtppassword')) { + continue; + } + // Hinter `sh -c` darf das Geheimnis nicht stehen. + expect(substr($befehl, strpos($befehl, 'sh -c')))->not->toContain('geheim'); + } +}); + +it('schreibt GAR NICHTS, wenn der Mailserver fehlt', function () { + Settings::set('mail.host', ''); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + + $ergebnis = app(ConfigureInstanceMail::class)->execute(laufMitInstanz(Instance::factory()->create())); + + expect($pve->guestCommands)->toBe([]) + ->and($ergebnis->type)->toBe(\App\Provisioning\StepResult::FAIL) + ->and($ergebnis->reason)->toBe('no_server'); +}); + +it('laesst sich zweimal fahren, ohne dass sich etwas aendert', function () { + versandbereitFuerSchritt(); + $pve = new FakeProxmoxClient; + app()->instance(ProxmoxClient::class, $pve); + $instance = Instance::factory()->create(); + + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + $ersteRunde = count($pve->guestCommands); + app(ConfigureInstanceMail::class)->execute(laufMitInstanz($instance)); + + // config:system:set ist von sich aus wiederholbar — derselbe Wert zweimal + // geschrieben ist derselbe Wert. Der Schritt darf deshalb ohne Merker + // erneut laufen; das ist bei einer Nachruestung ueber den Bestand der + // Normalfall, nicht die Ausnahme. + expect(count($pve->guestCommands))->toBe($ersteRunde * 2); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Provisioning/ConfigureInstanceMailTest.php` +Erwartet: FEHLSCHLAG, Klasse nicht gefunden + +- [ ] **Schritt 3: Den Schritt schreiben** + +`app/Provisioning/Steps/Customer/ConfigureInstanceMail.php`: + +```php +instance($run); + + if ($instance === null) { + return StepResult::fail('instance_missing'); + } + + $config = GuestMailConfig::for($instance); + + if (! $config->available()) { + return StepResult::fail($config->problem() ?? 'mail_unavailable'); + } + + $pve = $this->pve->forHost($instance->host); + + foreach ($config->values() as $schluessel => $wert) { + $this->guest($pve, $run, NextcloudOcc::command( + 'config:system:set '.escapeshellarg($schluessel).' --value='.escapeshellarg($wert) + )); + } + + // Das Passwort getrennt und ueber die Form, die den Wert erst im + // Container einsetzt — siehe NextcloudOcc::commandExpandingEnv(). + $this->guest($pve, $run, NextcloudOcc::commandExpandingEnv( + 'config:system:set mail_smtppassword --value="$CLUPILOT_SMTP_PW"', + ['CLUPILOT_SMTP_PW' => $config->password()], + )); + + return StepResult::advance(); + } +} +``` + +- [ ] **Schritt 4: In die Pipelines eintragen** + +In `config/provisioning.php`, in der `customer`-Pipeline **direkt nach** `Customer\ConfigureNextcloud::class` einfügen: + +```php + // Ohne diesen Schritt verschickt die Kundeninstanz ueberhaupt + // keine Mail — auch nicht die Einladung an einen Mitarbeiter, die + // Nextcloud selbst verschickt. + Customer\ConfigureInstanceMail::class, +``` + +Und als eigene Pipeline für die Nachrüstung, neben `host-firewall`: + +```php + /* + | Einer bestehenden Instanz den Mailversand nachtragen. Ein Schritt, + | derselbe wie im Aufbau — ein zweiter Weg, dieselben Werte zu + | schreiben, wuerde driften. + */ + 'instance-mail' => [ + Customer\ConfigureInstanceMail::class, + ], +``` + +- [ ] **Schritt 5: Laufen lassen, grün** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Provisioning/ConfigureInstanceMailTest.php` +Erwartet: 4 grün + +- [ ] **Schritt 6: Die Pipeline-Prüfungen laufen lassen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test --filter=Pipeline` +Erwartet: grün — bestehende Prüfungen über die Schrittfolge müssen den neuen Schritt vertragen. Schlagen sie fehl, weil sie eine feste Schrittzahl erwarten, wird die Zahl dort mitgezogen. + +- [ ] **Schritt 7: Festschreiben** + +```bash +git commit -F- -- app/Provisioning/Steps/Customer/ConfigureInstanceMail.php config/provisioning.php tests/Feature/Provisioning/ConfigureInstanceMailTest.php <<'MSG' +Die Kundeninstanz lernt Mail verschicken + +Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein +"Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die +Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort +dort entsteht, wo niemand sonst es sieht. + +Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert +mit Grund. +MSG +``` + +--- + +## Task 4: Bestehende Instanzen nachrüsten + +**Files:** +- Create: `app/Console/Commands/ConfigureInstanceMail.php` +- Create: `tests/Feature/Console/ConfigureInstanceMailCommandTest.php` + +**Interfaces:** +- Consumes: Pipeline `instance-mail` aus Aufgabe 3 +- Produces: `clupilot:configure-instance-mail` mit `--dry-run` und `--instance=` + +Vorbild ist `app/Console/Commands/RefreshHostFirewall.php` — gleiche Form, gleiche Begründung: ein Loch, das sich endgültig schließt, wird von Hand geschlossen und nicht von einem Zeitplan überdeckt. + +- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben** + +```php +count(2)->create(['status' => 'active']); + Instance::factory()->create(['status' => 'closed']); + + $this->artisan('clupilot:configure-instance-mail')->assertSuccessful(); + + expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(2); +}); + +it('faengt unter --dry-run gar nichts an', function () { + Instance::factory()->count(3)->create(['status' => 'active']); + + $this->artisan('clupilot:configure-instance-mail', ['--dry-run' => true])->assertSuccessful(); + + expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(0); +}); + +it('nimmt mit --instance genau eine', function () { + $eine = Instance::factory()->create(['status' => 'active']); + Instance::factory()->create(['status' => 'active']); + + $this->artisan('clupilot:configure-instance-mail', ['--instance' => $eine->uuid])->assertSuccessful(); + + expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(1); +}); + +it('ueberspringt eine Instanz ohne Host und sagt es', function () { + Instance::factory()->create(['status' => 'active', 'host_id' => null]); + + $this->artisan('clupilot:configure-instance-mail') + ->expectsOutputToContain('kein Host') + ->assertSuccessful(); + + expect(ProvisioningRun::where('pipeline', 'instance-mail')->count())->toBe(0); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Console/ConfigureInstanceMailCommandTest.php` +Erwartet: FEHLSCHLAG, Befehl unbekannt + +- [ ] **Schritt 3: Den Befehl schreiben** + +`app/Console/Commands/ConfigureInstanceMail.php`: + +```php +option('dry-run'); + $gestartet = 0; + + /** @var array Grund => Anzahl */ + $uebersprungen = []; + + $query = Instance::query()->where('status', 'active'); + + if ($uuid = $this->option('instance')) { + $query->where('uuid', $uuid); + } + + foreach ($query->with('host')->get() as $instance) { + if ($instance->host === null) { + $this->warn("{$instance->uuid}: kein Host — uebersprungen"); + $uebersprungen['kein Host'] = ($uebersprungen['kein Host'] ?? 0) + 1; + + continue; + } + + if (blank($instance->vmid)) { + $this->warn("{$instance->uuid}: keine VMID — uebersprungen"); + $uebersprungen['keine VMID'] = ($uebersprungen['keine VMID'] ?? 0) + 1; + + continue; + } + + if ($dryRun) { + $this->line("{$instance->uuid}: wuerde nachgetragen"); + $gestartet++; + + continue; + } + + $run = ProvisioningRun::create([ + 'pipeline' => 'instance-mail', + 'subject_type' => Instance::class, + 'subject_id' => $instance->id, + 'status' => ProvisioningRun::STATUS_RUNNING, + 'current_step' => 0, + 'context' => [ + 'instance_id' => $instance->id, + 'node' => $instance->host->node ?? 'pve', + 'vmid' => (int) $instance->vmid, + ], + ]); + + AdvanceRunJob::dispatch($run->id); + $this->info("{$instance->uuid}: Lauf {$run->id} gestartet"); + $gestartet++; + } + + $this->newLine(); + $this->line($dryRun ? "{$gestartet} Instanz(en) waeren nachgetragen worden." : "{$gestartet} Lauf/Laeufe gestartet."); + + foreach ($uebersprungen as $grund => $anzahl) { + $this->line("uebersprungen ({$grund}): {$anzahl}"); + } + + return self::SUCCESS; + } +} +``` + +- [ ] **Schritt 4: Laufen lassen, grün** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Console/ConfigureInstanceMailCommandTest.php` +Erwartet: 4 grün + +- [ ] **Schritt 5: Festschreiben** + +```bash +git commit -F- -- app/Console/Commands/ConfigureInstanceMail.php tests/Feature/Console/ConfigureInstanceMailCommandTest.php <<'MSG' +Bestehende Instanzen bekommen den Mailversand nachgetragen + +Ohne diesen Lauf bliebe die Mitarbeiterverwaltung fuer jeden Altkunden tot: +die Einladung geht von SEINER Nextcloud aus, und die kann bis heute nichts +verschicken. Ein Befehl, kein Zeitplan — dieselbe Begruendung wie bei +clupilot:refresh-host-firewall. +MSG +``` + +--- + +## Task 5: Das Datenmodell — Absicht und Wirklichkeit getrennt + +**Files:** +- Create: `database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php` +- Modify: `app/Models/Seat.php` +- Create: `tests/Feature/Seats/SeatModelTest.php` + +**Interfaces:** +- Produces: `Seat::STATE_NONE|STATE_PENDING|STATE_SYNCED|STATE_FAILED`, `Seat::GROUPS` (Rolle → Nextcloud-Gruppe), `Seat::isReadonly(): bool` + +- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben** + +```php +create()->nc_state)->toBe(Seat::STATE_NONE); +}); + +it('bildet jede Rolle auf genau eine Nextcloud-Gruppe ab', function () { + // Keine Rolle ohne Gruppe: eine Rolle, die im Portal waehlbar ist und im + // Gast nichts bewirkt, ist genau die Attrappe, die hier abgeschafft wird. + foreach (Seat::ROLES as $rolle) { + expect(Seat::GROUPS)->toHaveKey($rolle) + ->and(Seat::GROUPS[$rolle])->not->toBe(''); + } +}); + +it('fuehrt owner und admin in die Admin-Gruppe', function () { + expect(Seat::GROUPS['owner'])->toBe('admin') + ->and(Seat::GROUPS['admin'])->toBe('admin'); +}); + +it('erkennt die Rolle, die keinen Speicherplatz bekommt', function () { + expect(Seat::factory()->create(['role' => 'readonly'])->isReadonly())->toBeTrue() + ->and(Seat::factory()->create(['role' => 'member'])->isReadonly())->toBeFalse(); +}); + +it('verknuepft den Inhaber-Sitz mit dem bestehenden Admin-Konto', function () { + // Die Wanderung. Der owner-Sitz eines Kunden, dessen Instanz ein + // nc_admin_ref traegt, IST dieses Konto — die Bereitstellung hat es + // angelegt. Stuende er auf 'none', boete das Panel dem Inhaber an, sich + // selbst einzuladen, und der Auftrag traefe auf einen Benutzer, den es + // laengst gibt. + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(['nc_admin_ref' => 'admin', 'status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'owner']); + + // Die Wanderung lief beim Anlegen der Testdatenbank; hier wird die + // Nachziehmethode geprueft, die sie benutzt. + $sitz->linkToInstanceAdmin(); + + expect($sitz->fresh()->nc_username)->toBe('admin') + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/SeatModelTest.php` +Erwartet: FEHLSCHLAG, `Undefined constant … STATE_NONE` + +- [ ] **Schritt 3: Die Wanderung schreiben** + +`database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php`: + +```php +string('nc_username')->nullable()->after('email'); + $table->string('nc_state', 16)->default('none')->after('status'); + $table->text('nc_error')->nullable()->after('nc_state'); + $table->timestamp('nc_synced_at')->nullable()->after('nc_error'); + }); + + // Bestandssitze sind ehrlich beschrieben: angelegt, nie eingeladen — + // denn eingeladen hat sie nie jemand, resend() war eine Attrappe. + // EINE Ausnahme: der Inhaber-Sitz IST das Admin-Konto der Instanz. + Seat::query()->where('role', 'owner')->with('customer')->chunkById(100, function ($sitze) { + foreach ($sitze as $sitz) { + $sitz->linkToInstanceAdmin(); + } + }); + } + + public function down(): void + { + Schema::table('seats', function (Blueprint $table) { + $table->dropColumn(['nc_username', 'nc_state', 'nc_error', 'nc_synced_at']); + }); + } +}; +``` + +- [ ] **Schritt 4: Das Modell erweitern** + +In `app/Models/Seat.php`: + +```php + public const ROLES = ['owner', 'admin', 'member', 'readonly']; + + /** + * Welche Nextcloud-Gruppe eine Rolle bedeutet. + * + * Rollen sind Gruppen im Gast, damit weitere spaeter eine Zeile sind und + * kein Umbau. `owner` und `admin` teilen sich `admin` — der Unterschied + * zwischen beiden ist eine CluPilot-Angelegenheit (der letzte owner darf + * nicht entfernt werden), keine Nextcloud-Angelegenheit. + */ + public const GROUPS = [ + 'owner' => 'admin', + 'admin' => 'admin', + 'member' => 'mitarbeiter', + 'readonly' => 'nur-lesen', + ]; + + public const STATE_NONE = 'none'; + public const STATE_PENDING = 'pending'; + public const STATE_SYNCED = 'synced'; + public const STATE_FAILED = 'failed'; + + protected $fillable = [ + 'customer_id', 'email', 'name', 'role', 'status', 'invited_at', + 'nc_username', 'nc_state', 'nc_error', 'nc_synced_at', + ]; + + protected function casts(): array + { + return ['invited_at' => 'datetime', 'nc_synced_at' => 'datetime']; + } + + /** + * Die Rolle, die keinen eigenen Speicherplatz bekommt — und die einzige, + * bei der ein eigener Wert am Konto richtig ist. Siehe NextcloudUsers. + */ + public function isReadonly(): bool + { + return $this->role === 'readonly'; + } + + /** + * Verknuepft einen Inhaber-Sitz mit dem Admin-Konto seiner Instanz. + * + * Dieses Konto existiert in der Nextcloud tatsaechlich — CreateCustomerAdmin + * hat es beim Aufbau angelegt. Ein zweites Admin-Konto fuer dieselbe Person + * waere eines zu viel. + */ + public function linkToInstanceAdmin(): void + { + $ref = $this->customer?->instances() + ->whereIn('status', ['active', 'cancellation_scheduled']) + ->latest('id')->first()?->nc_admin_ref; + + if (blank($ref)) { + return; + } + + $this->forceFill([ + 'nc_username' => $ref, + 'nc_state' => self::STATE_SYNCED, + 'nc_synced_at' => now(), + ])->save(); + } +``` + +- [ ] **Schritt 5: Wandern und laufen lassen** + +Ausführen: +```bash +docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/SeatModelTest.php +``` +Erwartet: 5 grün + +- [ ] **Schritt 6: Festschreiben** + +```bash +git commit -F- -- database/migrations/2026_08_03_180000_add_nextcloud_columns_to_seats.php app/Models/Seat.php tests/Feature/Seats/SeatModelTest.php <<'MSG' +Ein Sitz fuehrt Absicht und Wirklichkeit getrennt + +status = was der Inhaber will, nc_state = was in der Nextcloud ist. Ein +einzelnes Feld muesste luegen — und "fehlgeschlagen" liesse sich gar nicht +sagen. + +Rollen werden Nextcloud-Gruppen. Der Inhaber-Sitz wird mit dem Admin-Konto +verknuepft, das die Bereitstellung laengst angelegt hat. +MSG +``` + +--- + +## Task 6: Der Dienst, der occ spricht + +**Files:** +- Create: `app/Services/Nextcloud/NextcloudUsers.php` +- Create: `tests/Feature/Nextcloud/NextcloudUsersTest.php` + +**Interfaces:** +- Consumes: `NextcloudOcc::command()`, `ProxmoxClient::guestExec()`, `Seat::GROUPS` +- Produces: `exists(Instance,string): bool`, `create(Instance,Seat): bool`, `invite(Instance,Seat): bool`, `applyRole(Instance,Seat): bool`, `disable(Instance,Seat): bool`, `enable(Instance,Seat): bool` — alle geben `false` zurück statt zu werfen + +Diese Klasse ist der Griff, nicht die Entscheidung. Wer wann welchen Griff zieht, steht im Auftrag der nächsten Aufgabe. + +- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben** + +```php +instance(ProxmoxClient::class, $pve); + + return [$pve, Instance::factory()->create(['status' => 'active', 'vmid' => 201])]; +} + +it('legt einen Benutzer mit erzeugtem Passwort an, das niemand sieht', function () { + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['email' => 'anna@firma.tld', 'name' => 'Anna', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->invite($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + // --generate-password: Nextcloud erzeugt es, NIEMAND bekommt es zu sehen. + // --email: dorthin geht der Link, an dem der Mitarbeiter sein eigenes setzt. + expect($befehle)->toContain('--generate-password') + ->and($befehle)->toContain('--email') + ->and($befehle)->toContain('anna@firma.tld'); +}); + +it('verschickt bei einem bestehenden Benutzer nur die Willkommensmail neu', function () { + [$pve, $instance] = gastBereit(); + $pve->guestScripts['user:info'] = ['exitcode' => 0, 'out-data' => 'user_id: anna@firma.tld']; + $sitz = Seat::factory()->create(['email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->invite($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + // Wiederholbar: ein zweiter Lauf nach einem Absturz legt keinen zweiten + // Benutzer an. Genau wie CreateCustomerAdmin es tut. + expect($befehle)->toContain('user:welcome --reset-password') + ->and($befehle)->not->toContain('user:add'); +}); + +it('setzt bei readonly einen eigenen Speicherplatz von null', function () { + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['role' => 'readonly', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->applyRole($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('group:adduser') + ->and($befehle)->toContain('nur-lesen') + ->and($befehle)->toContain('files quota') + ->and($befehle)->toContain('0 B'); +}); + +it('LOESCHT den eigenen Speicherplatz, wenn readonly verlassen wird', function () { + // Die Falle aus ApplyStorageQuota: "An account with an explicit quota stops + // following the default". Ein Konto, das mit einem festen Wert aus der + // Rolle herauskommt, waere bei der naechsten Paketaenderung stumm + // ausgenommen — und niemand merkte es, bis der Kunde fragt, warum sein + // Mitarbeiter weniger Platz hat als bezahlt. + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['role' => 'member', 'nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->applyRole($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('files quota --delete') + ->and($befehle)->not->toContain('0 B'); +}); + +it('wirft Sitzungen beim Sperren SOFORT hinaus', function () { + // user:disable allein laesst laufende Sitzungen bis zu fuenf Minuten + // weiterleben. Bei einem Mitarbeiter, der gerade gegangen ist, sind fuenf + // Minuten fuenf zu viel. + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['nc_username' => 'anna@firma.tld']); + + app(NextcloudUsers::class)->disable($instance, $sitz); + + $befehle = implode("\n", $pve->guestCommands); + + expect($befehle)->toContain('user:disable') + ->and($befehle)->toContain('user:auth-tokens:delete'); +}); + +it('gibt false zurueck statt zu werfen, wenn der Gast nicht antwortet', function () { + [$pve, $instance] = gastBereit(); + $pve->guestThrows[201] = new RuntimeException('guest agent unreachable'); + $sitz = Seat::factory()->create(['nc_username' => 'anna@firma.tld']); + + expect(app(NextcloudUsers::class)->disable($instance, $sitz))->toBeFalse(); +}); + +it('fuehrt gar nichts aus, wenn der Benutzername keiner ist', function () { + // Der Name wandert in eine Wurzel-Shell im Gast. Dieselbe Regel wie bei + // HostFirewall: ein Dienst, der eine Shell fuettert, darf sich nicht + // darauf verlassen, dass sein Aufrufer sauber war. + [$pve, $instance] = gastBereit(); + $sitz = Seat::factory()->create(['nc_username' => 'anna; rm -rf /']); + + expect(app(NextcloudUsers::class)->disable($instance, $sitz))->toBeFalse() + ->and($pve->guestCommands)->toBe([]); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Nextcloud/NextcloudUsersTest.php` +Erwartet: FEHLSCHLAG, Klasse nicht gefunden + +- [ ] **Schritt 3: Den Dienst schreiben** + +`app/Services/Nextcloud/NextcloudUsers.php`: + +```php +nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat) { + $vorhanden = (int) ($pve->guestExec( + $node, $vmid, NextcloudOcc::command('user:info '.escapeshellarg($user)) + )['exitcode'] ?? 1) === 0; + + // Wiederholbar nach einem Absturz: ein zweiter Lauf legt keinen + // zweiten Benutzer an, sondern schickt die Willkommensmail erneut. + // Genau wie CreateCustomerAdmin es tut. + return $vorhanden + ? ['user:welcome --reset-password '.escapeshellarg($user)] + : [ + 'user:add --generate-password' + .' --email='.escapeshellarg((string) $seat->email) + .' --display-name='.escapeshellarg((string) ($seat->name ?: $seat->email)) + .' --group='.escapeshellarg(Seat::GROUPS[$seat->role] ?? 'mitarbeiter') + .' '.escapeshellarg($user), + ]; + }); + } + + /** Gruppe setzen — und bei readonly der Speicherplatz. */ + public function applyRole(Instance $instance, Seat $seat): bool + { + $user = (string) $seat->nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + $ziel = Seat::GROUPS[$seat->role] ?? 'mitarbeiter'; + + return $this->run($instance, function ($pve, $node, $vmid) use ($user, $seat, $ziel) { + $befehle = []; + + // Aus jeder anderen bekannten Gruppe heraus, in die eine hinein. + foreach (array_unique(array_values(Seat::GROUPS)) as $gruppe) { + if ($gruppe !== $ziel) { + $befehle[] = 'group:removeuser '.escapeshellarg($gruppe).' '.escapeshellarg($user); + } + } + + $befehle[] = 'group:adduser '.escapeshellarg($ziel).' '.escapeshellarg($user); + + // Der Speicherplatz. Siehe ApplyStorageQuota: ein Konto mit + // EIGENEM Wert folgt der Vorgabe der Instanz nicht mehr. Fuer + // readonly ist genau das gewollt; beim VERLASSEN der Rolle muss + // der eigene Wert deshalb WEG, nicht ueberschrieben werden. + $befehle[] = $seat->isReadonly() + ? 'user:setting '.escapeshellarg($user).' files quota '.escapeshellarg('0 B') + : 'user:setting '.escapeshellarg($user).' files quota --delete'; + + return $befehle; + }); + } + + public function disable(Instance $instance, Seat $seat): bool + { + $user = (string) $seat->nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + return $this->run($instance, fn ($pve, $node, $vmid) => [ + 'user:disable '.escapeshellarg($user), + // user:disable allein laesst laufende Sitzungen bis zu fuenf + // Minuten weiterleben. Bei jemandem, der gerade gegangen ist, + // sind fuenf Minuten fuenf zu viel. + 'user:auth-tokens:delete '.escapeshellarg($user), + ]); + } + + public function enable(Instance $instance, Seat $seat): bool + { + $user = (string) $seat->nc_username; + + if (! $this->isWellFormed($user, $seat)) { + return false; + } + + return $this->run($instance, fn ($pve, $node, $vmid) => ['user:enable '.escapeshellarg($user)]); + } + + /** + * Nextcloud laesst Buchstaben, Ziffern und `-_.@` in Kennungen zu. Alles + * andere ist entweder ein Fehler weiter oben oder ein Versuch — beides + * will man sehen, und keines darf in eine Shell. + */ + private function isWellFormed(string $user, Seat $seat): bool + { + if ($user !== '' && preg_match('/^[A-Za-z0-9._@-]+$/', $user) === 1) { + return true; + } + + report(new RuntimeException( + "NextcloudUsers: abgewiesene Kennung fuer Sitz [{$seat->uuid}] — nichts ausgefuehrt." + )); + + return false; + } + + /** + * Der Verbindungsaufbau steht EINMAL hier, nicht in jeder Methode. Der + * Rueckruf bekommt den fertigen Client mit — er braucht ihn, weil `invite()` + * erst nachsehen muss, ob es den Benutzer schon gibt, bevor es entscheidet, + * welchen Befehl es baut. + * + * @param callable(\App\Services\Proxmox\ProxmoxClient, string, int): array $bauen + */ + private function run(Instance $instance, callable $bauen): bool + { + if ($instance->host === null || blank($instance->vmid)) { + return false; + } + + $node = $instance->host->node ?? 'pve'; + $vmid = (int) $instance->vmid; + + try { + $pve = $this->pve->forHost($instance->host); + $ok = true; + + foreach ($bauen($pve, $node, $vmid) as $argumente) { + $ergebnis = $pve->guestExec($node, $vmid, NextcloudOcc::command($argumente)); + $ok = ((int) ($ergebnis['exitcode'] ?? 1) === 0) && $ok; + } + + return $ok; + } catch (Throwable $e) { + // Ein abgeschalteter Gast wirft, statt einen Fehlercode zu liefern. + // Nie mit Zugangsdaten, nie mit Stacktrace an den Kunden. + Log::warning('nextcloud user command failed', [ + 'instance' => $instance->uuid, 'error' => $e->getMessage(), + ]); + + return false; + } + } +} +``` + +- [ ] **Schritt 4: Laufen lassen, grün** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Nextcloud/NextcloudUsersTest.php` +Erwartet: 7 grün + +- [ ] **Schritt 5: Festschreiben** + +```bash +git commit -F- -- app/Services/Nextcloud/NextcloudUsers.php tests/Feature/Nextcloud/NextcloudUsersTest.php <<'MSG' +Der Griff, mit dem ein Sitz in der Nextcloud wirksam wird + +Anlegen, einladen, Gruppe, sperren, freigeben. Keine Methode wirft — ein nicht +erreichbarer Gast gibt false zurueck, statt den Arbeiter mitzureissen, auf dem +die bezahlte Bereitstellung laeuft. + +Zwei Fallen sind hier eingebaut statt umgangen: user:disable allein laesst +Sitzungen fuenf Minuten weiterleben (deshalb auth-tokens:delete daneben), und +ein Konto mit eigenem Speicherplatz folgt der Paketvorgabe nicht mehr +(deshalb --delete beim Verlassen von readonly, kein Ueberschreiben). +MSG +``` + +--- + +## Task 7: Der Auftrag, das Ratelimit und die Seite + +**Files:** +- Create: `app/Provisioning/Jobs/SyncSeatToNextcloud.php` +- Modify: `app/Livewire/Users.php` +- Modify: `resources/views/livewire/users.blade.php` +- Modify: `lang/de/users.php`, `lang/en/users.php` +- Create: `tests/Feature/Seats/SyncSeatToNextcloudTest.php` +- Modify: `tests/Feature/…` bestehende Sitz-Prüfungen + +**Interfaces:** +- Consumes: `NextcloudUsers`, `Seat::STATE_*` +- Produces: `SyncSeatToNextcloud::dispatch(string $seatUuid, string $action)` mit `action ∈ {invite, role, disable, enable}` + +- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben** + +```php +instance(ProxmoxClient::class, new FakeProxmoxClient); + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201]); + $sitz = Seat::factory()->for($customer)->create([ + 'email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld', + 'nc_state' => Seat::STATE_PENDING, + ]); + + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(\App\Services\Nextcloud\NextcloudUsers::class)); + + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED) + ->and($sitz->fresh()->nc_error)->toBeNull() + ->and($sitz->fresh()->nc_synced_at)->not->toBeNull(); +}); + +it('schreibt den Fehlschlag mit Grund an den Sitz', function () { + $pve = new FakeProxmoxClient; + $pve->guestThrows[201] = new RuntimeException('guest agent unreachable'); + app()->instance(ProxmoxClient::class, $pve); + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201]); + $sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']); + + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(\App\Services\Nextcloud\NextcloudUsers::class)); + + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) + ->and($sitz->fresh()->nc_error)->not->toBeNull(); +}); + +it('laeuft auf der Bereitstellungs-Warteschlange, nirgends sonst', function () { + // Nur dieser Arbeiter hat Tunnel und Proxmox-Zugangsdaten. Auf der + // Standard-Warteschlange erreicht der Auftrag keinen einzigen Gast. + $auftrag = new SyncSeatToNextcloud('egal', 'invite'); + + expect($auftrag->connection)->toBe('provisioning') + ->and($auftrag->queue)->toBe('provisioning'); +}); + +it('legt beim Anlegen KEINEN Nextcloud-Benutzer an', function () { + // Anlegen ist nicht Einladen. Der Inhaber soll sein Team vorbereiten + // koennen, ohne dass jemand eine Mail bekommt. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'anna@firma.tld') + ->set('inviteName', 'Anna') + ->call('addSeat'); + + Queue::assertNothingPushed(); + expect(Seat::where('email', 'anna@firma.tld')->first()->nc_state)->toBe(Seat::STATE_NONE); +}); + +it('schickt erst beim Einladen einen Auftrag los', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); + + Queue::assertPushed(SyncSeatToNextcloud::class); + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING) + ->and($sitz->fresh()->nc_username)->toBe($sitz->email); +}); + +it('haelt den Benutzernamen fest, auch wenn die Mailadresse sich aendert', function () { + // Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich + // spaeter aendert, behaelt seinen Anmeldenamen. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['email' => 'alt@firma.tld', 'nc_username' => 'alt@firma.tld']); + + $sitz->update(['email' => 'neu@firma.tld']); + Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); + + expect($sitz->fresh()->nc_username)->toBe('alt@firma.tld'); +}); + +it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitze = Seat::factory()->for($customer)->count(11)->create(['role' => 'member']); + + $seite = Livewire::actingAs($user)->test(Users::class); + + foreach ($sitze->take(10) as $sitz) { + $seite->call('sendInvite', $sitz->uuid); + } + + $seite->call('sendInvite', $sitze->last()->uuid); + + // Keine stumme Verweigerung: die Meldung nennt die echte Restzeit. + $seite->assertDispatched('notify'); + expect($sitze->last()->fresh()->nc_state)->toBe(Seat::STATE_NONE); +}); + +it('entzieht, ohne die Zeile zu loeschen', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member', 'nc_username' => 'anna@firma.tld']); + + Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid); + + // Der Datensatz bleibt. Ein Fehlgriff im Userpanel darf die Arbeit eines + // Menschen nicht vernichten — und in einem Produkt, das mit + // Nachvollziehbarkeit verkauft wird, gaebe es danach nichts mehr zu zeigen. + expect(Seat::find($sitz->id))->not->toBeNull() + ->and($sitz->fresh()->status)->toBe('revoked'); + Queue::assertPushed(SyncSeatToNextcloud::class); +}); + +it('nennt in keiner Datei unter app/ das Loeschen eines Benutzers', function () { + // Testerzwungene Regel: kein Nextcloud-Benutzer wird je geloescht, und + // keine Datei. Wer das aendern will, muss diese Pruefung anfassen und + // dabei ueber die Folgen stolpern. + $treffer = []; + + foreach (\Symfony\Component\Finder\Finder::create()->files()->in(app_path())->name('*.php') as $datei) { + if (str_contains($datei->getContents(), 'user:delete')) { + $treffer[] = $datei->getRelativePathname(); + } + } + + expect($treffer)->toBe([]); +}); +``` + +- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/SyncSeatToNextcloudTest.php` +Erwartet: FEHLSCHLAG, Klasse und Methoden fehlen + +- [ ] **Schritt 3: Den Auftrag schreiben** + +`app/Provisioning/Jobs/SyncSeatToNextcloud.php`: + +```php +onConnection('provisioning'); + $this->onQueue('provisioning'); + } + + /** Auch was an handle() vorbeifliegt, muss am Sitz sichtbar werden. */ + public function failed(?Throwable $e): void + { + Seat::query()->where('uuid', $this->seatUuid)->update([ + 'nc_state' => Seat::STATE_FAILED, + 'nc_error' => 'unexpected', + ]); + } + + public function handle(NextcloudUsers $users): void + { + $seat = Seat::query()->with('customer')->where('uuid', $this->seatUuid)->first(); + + if ($seat === null) { + return; + } + + // Erneut geprueft, nicht der Seite geglaubt: eine geschlossene Instanz + // kann ihre VMID auf demselben Host weiterverliehen haben — dieselbe + // Falle, die in IssueInstanceAdminAccess schon beschrieben steht. + $instance = $seat->customer?->instances() + ->whereIn('status', ['active', 'cancellation_scheduled']) + ->latest('id')->first(); + + if ($instance === null || $instance->host === null || blank($instance->vmid)) { + $this->record($seat, false, 'no_instance'); + + return; + } + + $ok = match ($this->action) { + 'invite' => $users->invite($instance, $seat) && $users->applyRole($instance, $seat), + 'role' => $users->applyRole($instance, $seat), + 'disable' => $users->disable($instance, $seat), + 'enable' => $users->enable($instance, $seat), + default => false, + }; + + $this->record($seat, $ok, $ok ? null : 'guest_failed'); + } + + private function record(Seat $seat, bool $ok, ?string $grund): void + { + $seat->forceFill([ + 'nc_state' => $ok ? Seat::STATE_SYNCED : Seat::STATE_FAILED, + 'nc_error' => $grund, + 'nc_synced_at' => $ok ? now() : $seat->nc_synced_at, + ])->save(); + } +} +``` + +- [ ] **Schritt 4: `app/Livewire/Users.php` umbauen** + +Die bestehende `invite()` wird zu **`addSeat()`** — sie legt nur an, schickt nichts los und lässt `nc_state` auf `none`. Die Sitzplatzgrenze, die Sperre gegen doppelte Adressen und die Transaktion mit `lockForUpdate()` bleiben **unverändert**. + +Neu daneben: + +```php + /** + * Einladen — der zweite, getrennte Schritt. + * + * Anlegen und Einladen sind ausdruecklich zwei Vorgaenge: ein Inhaber + * soll sein Team vorbereiten und die Einladungen spaeter verschicken + * koennen, etwa alle am ersten Arbeitstag. + */ + public function sendInvite(string $uuid): void + { + $customer = $this->requireCustomer(); + + if ($customer === null) { + return; + } + + $seat = $customer->seats()->where('uuid', $uuid)->first(); + + if ($seat === null) { + return; + } + + if (($warten = $this->rateLimited($customer, $seat)) !== null) { + $this->dispatch('notify', message: __('users.too_many_invites', ['minutes' => $warten])); + + return; + } + + // Einmal gesetzt, nie wieder geaendert: Nextcloud kann Benutzer nicht + // umbenennen. Ein Sitz, dessen Adresse sich spaeter aendert, behaelt + // seinen Anmeldenamen. + if (blank($seat->nc_username)) { + $seat->nc_username = $seat->email; + } + + $seat->forceFill([ + 'nc_username' => $seat->nc_username, + 'status' => 'invited', + 'invited_at' => now(), + 'nc_state' => Seat::STATE_PENDING, + 'nc_error' => null, + ])->save(); + + SyncSeatToNextcloud::dispatch($seat->uuid, 'invite'); + + $this->dispatch('notify', message: __('users.invite_sent')); + } + + /** + * Zwei Grenzen, beide aus dem Betrieb heraus gefordert: eine je Kunde + * gegen den Rundumschlag, eine je Sitz gegen das wiederholte Draufdruecken + * an derselben Zeile. + * + * Gibt die Restzeit in Minuten zurueck, oder null wenn frei. Eine stumme + * Verweigerung waere schlimmer als die Grenze selbst. + */ + private function rateLimited(Customer $customer, Seat $seat): ?int + { + foreach ([ + ['seat-invite:customer:'.$customer->id, 10], + ['seat-invite:seat:'.$seat->id, 3], + ] as [$schluessel, $grenze]) { + if (RateLimiter::tooManyAttempts($schluessel, $grenze)) { + return (int) ceil(RateLimiter::availableIn($schluessel) / 60); + } + } + + RateLimiter::increment('seat-invite:customer:'.$customer->id, 3600); + RateLimiter::increment('seat-invite:seat:'.$seat->id, 3600); + + return null; + } +``` + +`setRole()` bekommt nach dem `update()` einen Auftrag: + +```php + $seat->update(['role' => $role]); + $this->queueSync($seat, 'role'); +``` + +`suspend()` und `revoke()` ebenso — und `revoke()` **löscht nicht mehr**: + +```php + // Nicht loeschen. Der Zugang ist zu, die Arbeit bleibt dort, wo + // sein Team sie braucht. Wer wirklich loeschen will, tut das in + // der Nextcloud, wo Nextcloud danach fragt, was mit den Dateien + // geschehen soll. + $seat->update(['status' => 'revoked']); +``` + +Dazu der gemeinsame Helfer: + +```php + /** + * Ein Sitz, der nie in der Nextcloud war, braucht keinen Auftrag — es + * gaebe dort nichts zu aendern. + */ + private function queueSync(Seat $seat, string $action): void + { + if ($seat->nc_state === Seat::STATE_NONE) { + return; + } + + $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save(); + SyncSeatToNextcloud::dispatch($seat->uuid, $action); + } +``` + +- [ ] **Schritt 5: Die Ansicht um die Zustände erweitern** + +In `resources/views/livewire/users.blade.php` bekommt jede Zeile eine Zustandsspalte: + +```blade + + @if ($seat->nc_state === \App\Models\Seat::STATE_NONE) + {{ __('users.state_none') }} + @elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING) + {{ __('users.state_pending') }} + @elseif ($seat->nc_state === \App\Models\Seat::STATE_FAILED) + {{ __('users.state_failed') }} +

{{ __('users.error_'.$seat->nc_error) }}

+ + @else + + {{ __('users.status_'.$seat->status) }} + + @endif + +``` + +Und bei der Rollenauswahl der ehrliche Text zu `readonly`: + +```blade +

{{ __('users.role_readonly_means') }}

+``` + +- [ ] **Schritt 6: Sprachdateien** + +`lang/de/users.php`: + +```php + 'state_none' => 'angelegt — noch nicht eingeladen', + 'state_pending' => 'wird eingerichtet …', + 'state_failed' => 'fehlgeschlagen', + 'retry' => 'Nochmal versuchen', + 'invite_sent' => 'Einladung verschickt. Der Mitarbeiter bekommt einen Link, an dem er sein Passwort selbst setzt.', + 'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.', + 'error_no_instance' => 'Die Cloud dieses Kunden ist gerade nicht erreichbar.', + 'error_guest_failed' => 'Die Cloud hat die Änderung nicht angenommen.', + 'error_unexpected' => 'Unerwarteter Fehler. Bitte noch einmal versuchen.', + 'role_readonly_means' => 'Kann nichts hochladen oder anlegen. Was ihm freigegeben wird, kann er im Rahmen der Freigabe bearbeiten.', +``` + +`lang/en/users.php` mit denselben Schlüsseln auf Englisch. + +- [ ] **Schritt 7: Laufen lassen, grün** + +Ausführen: +```bash +docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Seats/ +``` +Erwartet: alle grün + +- [ ] **Schritt 8: Die ganze Suite** + +Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test` +Erwartet: alles grün. Bestehende Prüfungen, die `invite()` aufrufen oder erwarten, dass `revoke()` die Zeile entfernt, werden mitgezogen — mit einer Notiz im Test, warum sich das Verhalten geändert hat. + +- [ ] **Schritt 9: Festschreiben** + +```bash +git commit -F- <<'MSG' +Aus der Attrappe wird Verwaltung + +Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf +die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und +der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und +Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder. + +Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde, +mit echter Restzeit in der Meldung statt stummer Verweigerung. + +Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis. +MSG +``` + +--- + +## Task 8: Der Nachweis gegen echte Hardware + +**Files:** +- Create: `docs/runbooks/mitarbeiter-nachweis.md` + +Diese Aufgabe schreibt **keinen** Code. Sie beweist zwei Dinge, die sich gegen einen Fake nicht beweisen lassen — und die den Entwurf umwerfen, wenn sie nicht stimmen. + +- [ ] **Schritt 1: Voraussetzung prüfen** + +Der Mailserver `mail.clupilot.cloud` muss stehen, das Postfach `instance-relay` in der Konsole angelegt und der Versandtest dort grün sein. + +Ausführen: `php artisan clupilot:configure-instance-mail --dry-run` +Erwartet: die Liste der Instanzen, die nachgetragen würden. + +- [ ] **Schritt 2: Eine Testinstanz nachrüsten** + +Ausführen: `php artisan clupilot:configure-instance-mail --instance=` +Erwartet: Lauf gestartet, Pipeline `instance-mail` in der Konsole grün. + +- [ ] **Schritt 3: Nachweis A — kommt die Einladung an?** + +Im Userpanel einen Mitarbeiter anlegen und einladen. Erwartet: eine Mail an dessen Adresse, Absender `noreply@clupilot.cloud`, mit einem Link zum Setzen des Passworts. Der Link führt in **die Nextcloud des Kunden**, nicht ins Portal. + +Kommt keine Mail: in der Nextcloud `occ config:system:get mail_smtphost` prüfen und im mailcow-Protokoll nachsehen, ob die Verbindung überhaupt ankam. Der häufigste Grund ist die Sperre auf Port 587 — die Hostadresse muss in der Zulassungsliste stehen. + +- [ ] **Schritt 4: Nachweis B — bedeutet `0 B` wirklich null?** + +**Das ist der Nachweis, der den Entwurf umwerfen kann.** Aus der Dokumentation ist nicht zu belegen, ob Nextcloud `0 B` als „null Bytes" oder als „unbegrenzt" auslegt. + +Einen Sitz auf `readonly` stellen, dann in der Nextcloud als dieser Benutzer anmelden und eine Datei hochladen versuchen. + +- **Erwartet:** der Upload wird abgewiesen. +- **Fällt es anders aus:** `readonly` liefert keine Beschränkung. Dann wird der Speicherplatzteil aus `NextcloudUsers::applyRole()` entfernt, die Rolle behält nur ihre Gruppe, und der Text `users.role_readonly_means` wird auf das reduziert, was dann noch stimmt — die Beschränkung läuft über Freigaberechte. **Nicht** stehen lassen und hoffen. + +- [ ] **Schritt 5: Das Ergebnis aufschreiben** + +`docs/runbooks/mitarbeiter-nachweis.md` mit beiden Ergebnissen, Datum, Nextcloud-Fassung. Ein Nachweis, den niemand aufschreibt, wird beim nächsten Zweifel erneut geführt. + +- [ ] **Schritt 6: Festschreiben** + +```bash +git commit -F- -- docs/runbooks/mitarbeiter-nachweis.md <<'MSG' +Nachweis gegen echte Hardware: Einladung und Speicherplatz + +Zwei Dinge, die sich gegen einen Fake nicht beweisen lassen. Der zweite kann +den Entwurf umwerfen: ob Nextcloud "0 B" als null oder als unbegrenzt +auslegt, steht in keiner Dokumentation. +MSG +``` + +--- + +## Selbstdurchsicht + +**Abdeckung des Entwurfs** + +| Anforderung | Aufgabe | +|---|---| +| Mailversand im Gast, eigene Absenderdomain | 1, 3 | +| Passwort nicht in der Prozessliste der VM | 2, 3 | +| Bestand nachrüsten | 4 | +| Anlegen ≠ Einladen | 7 | +| Nextcloud erzeugt das Passwort, niemand kennt es | 6 | +| Erneut einladen / Passwort zurücksetzen | 6 (`user:welcome --reset-password`) | +| Benutzername einmal gesetzt, nie geändert | 5, 7 | +| Inhaber-Sitz = bestehendes Admin-Konto | 5 | +| Rollen als Gruppen, vier Stück | 5, 6 | +| `readonly` ehrlich beschriftet + Nachweis | 6, 7, 8 | +| Speicherplatz beim Verlassen löschen, nicht setzen | 6 | +| Sperren sofort wirksam | 6 | +| Entziehen löscht nichts | 7 | +| Ratelimit 10/Kunde, 3/Sitz | 7 | +| Absicht/Wirklichkeit getrennt, Fehler sichtbar | 5, 7 | +| Sicherheitsgrenzen (fremder Sitz, erneute Prüfung im Auftrag) | 6, 7 | +| `user:delete` verboten | 7 | + +Keine Lücke. + +**Namensgleichheit über die Aufgaben** + +`GuestMailConfig::for()/available()/problem()/values()/password()` (1) → benutzt in 3. +`NextcloudOcc::commandExpandingEnv()` (2) → benutzt in 3. +`Seat::STATE_*`, `Seat::GROUPS`, `isReadonly()`, `linkToInstanceAdmin()` (5) → benutzt in 6, 7. +`NextcloudUsers::invite/applyRole/disable/enable` (6) → benutzt in 7. +`SyncSeatToNextcloud(string $seatUuid, string $action)` (7) → in sich geschlossen. + +**Offene Stelle, bewusst so** + +Aufgabe 8 hängt an Infrastruktur, die der Betreiber erst aufsetzt. Die Aufgaben 1–7 sind davon unabhängig prüfbar und können vollständig gebaut werden, bevor der Mailserver steht.