Entwurf: Mitarbeiterverwaltung im Userpanel (Projekt A)

Die Sitzverwaltung im Portal ist vollstaendig gebaut und erreicht die
Nextcloud nie — resend() traegt seit jeher "Invite delivery is mocked for
now.". Das Modul wird verkauft und tut nichts.

Beim Nachsehen kam der groessere Fund: die Kunden-Nextcloud hat ueberhaupt
keinen Mailversand eingerichtet. Keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung und wird zuerst
gebaut — ueber eine EIGENE Absenderdomain (clupilot.cloud), damit der Ruf der
Domain, ueber die Rechnungen und Sicherheitsmeldungen gehen, nicht am
Mailaufkommen der Kunden haengt.

Tragende Entscheidung: niemand kennt ein fremdes Passwort. Nextcloud erzeugt
es selbst (user:add --generate-password --email) und mailt dem Mitarbeiter
einen Link, an dem er sein eigenes setzt. Ein Wechselzwang beim ersten Login
ist in Nextcloud fuer einen EINZELNEN Benutzer nicht moeglich — das steht so
im Entwurf, statt es zu umschreiben.

Anlegen und Einladen sind getrennt, wie gewuenscht. Entziehen sperrt und
loescht nichts. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde.

Eine technische Unsicherheit ist benannt statt angenommen: ob Speicherplatz
0 B "nichts" oder "unbegrenzt" heisst, bekommt einen eigenen Nachweisschritt
im Plan.
claude/nice-moser-521659
nexxo 2026-08-03 18:14:43 +02:00
parent 1886bf2076
commit d257deae01
1 changed files with 380 additions and 0 deletions

View File

@ -0,0 +1,380 @@
# Mitarbeiterverwaltung im Userpanel — Entwurf
**Stand:** 2026-08-03
**Projekt A von dreien** (B = Frühwarnsystem, fertig; C = Postfächer in der Konsole, offen)
## Worum es geht
Der Inhaber soll aus dem Userpanel heraus Mitarbeiter anlegen, einladen und
verwalten. Mitarbeiter melden sich in der **Nextcloud-Instanz** an, nicht im
Portal — im Userpanel hat ein Mitarbeiter nichts zu suchen.
## Ausgangslage: es ist eine Attrappe
Die Sitzverwaltung im Portal ist vollständig gebaut — Tabelle, Rollen,
Sitzplatzgrenze aus dem Vertrag, Bestätigungs-Modals nach R23, Sperren gegen
das Entfernen des letzten Inhabers. Nur:
**Keine einzige dieser Aktionen erreicht je die Nextcloud.**
`app/Livewire/Users.php` schreibt Zeilen in `seats` und sonst nichts. Einladen
legt keinen Benutzer an. Aussetzen sperrt niemanden aus. Entziehen löscht eine
Zeile bei uns, während der Mitarbeiter weiterarbeitet. `resend()` trägt seit
jeher den Kommentar `// Invite delivery is mocked for now.` — es wird nie eine
Mail verschickt.
Das Modul wird verkauft. Es tut nichts.
### Der zweite Fund: die Kunden-Nextcloud kann gar nicht mailen
Weder die Bereitstellung noch `deploy/bootstrap/assets/docker-compose.yml`
setzen je einen Mailversand im Gast. Damit verschickt die Nextcloud eines
Kunden **überhaupt keine Mail** — keine Freigabe-Benachrichtigung, keine
Aktivitätsmeldung, kein „Passwort vergessen". Stumm, ohne Fehlermeldung.
Das ist die Voraussetzung für alles Weitere und wird deshalb zuerst gebaut.
## Die tragende Entscheidung: niemand kennt ein fremdes Passwort
Der Mitarbeiter bekommt sein Passwort **von seiner Nextcloud**, nicht von uns:
```
occ user:add --generate-password --email <mail> --display-name <name> <uid>
```
Nextcloud erzeugt ein Passwort, das **niemand** zu sehen bekommt, und schickt
dem Mitarbeiter eine Willkommensmail mit einem zeitlich begrenzten Link, an
dem er sein eigenes setzt. Weder der Inhaber noch der Betreiber noch CluPilot
kennen es je.
Damit erledigen sich drei Fragen auf einmal:
- **Passwort vergessen** — der Mitarbeiter benutzt „Passwort vergessen" in der
Nextcloud; der Inhaber hat zusätzlich einen Knopf „Erneut einladen".
- **Kurzzeitig gültig** — die Gültigkeit des Links regelt Nextcloud selbst.
- **Wie gebe ich es weiter** — gar nicht. Es gibt nichts weiterzugeben.
Verworfen wurde: ein von CluPilot erzeugtes Einmalpasswort mit Wechselzwang
beim ersten Login. **Nextcloud kann das nicht.** Eine Ablauffrist für
Passwörter gibt es nur instanzweit (`password_policy`), sie träfe also den
Inhaber und jeden anderen gleichermaßen. Einen Wechselzwang für einen
einzelnen Benutzer gibt es nicht.
---
## Teil 1: Mailversand in der Kunden-Nextcloud
### Eigene Absenderdomain
Die Kunden-Nextcloud verschickt über **`clupilot.cloud`**, nicht über
`clupilot.com`.
Der Grund ist Absenderruf: über `clupilot.com` laufen Rechnungen,
Bestellbestätigungen und Sicherheitsmeldungen an die Kunden. Wenn die Nextcloud
eines Kunden Freigabe-Benachrichtigungen am laufenden Band verschickt und
dabei in Sperrlisten gerät, darf das die Domain nicht mitreißen, über die das
Geschäft läuft. Zwei Domains, zwei Rufe, ein Ausfall bleibt einer.
### Was eingetragen wird
Ein neuer Bereitstellungsschritt schreibt in jeden Gast:
| Schlüssel | Wert |
|---|---|
| `mail_smtpmode` | `smtp` |
| `mail_smtphost` | aus dem CluPilot-Postfach |
| `mail_smtpport` | aus dem CluPilot-Postfach |
| `mail_smtpsecure` | aus `App\Services\Mail\MailTlsPolicy` des Postfachs |
| `mail_smtpauth` | `true` |
| `mail_smtpname` | Benutzername des Postfachs |
| `mail_smtppassword` | Passwort des Postfachs |
| `mail_from_address` | `noreply` |
| `mail_domain` | `clupilot.cloud` |
| `mail_sendmailmode` | entfällt |
Der Anzeigename ist der Cloud-Name des Kunden, damit eine Mail aus *seiner*
Cloud kommt und nicht aus einem Produkt, das er nicht kennt.
### Das Passwort darf nicht in die Prozessliste
`occ config:system:set mail_smtppassword --value=…` würde das Passwort als
Befehlszeilen-Argument in den Gast tragen. Jeder, der dort `ps` ausführen kann,
liest es mit — und das ist auf einer Kundenmaschine niemand, dem wir das
SMTP-Passwort unserer Versandinfrastruktur anvertrauen wollen.
Deshalb geht die ganze Mailkonfiguration als **JSON über die Standardeingabe**
hinein:
```
occ config:import < /dev/stdin
```
Die Zeile wird in `App\Support\NextcloudOcc` gebaut, wie jeder andere
occ-Aufruf auch. Nichts in `app/` schreibt `docker compose exec` selbst — das
ist eine bestehende, testerzwungene Regel dieses Projekts.
### Bestand nachrüsten
Ein Artisan-Befehl `clupilot:configure-instance-mail` fährt denselben Schritt
über alle aktiven Instanzen. Niemand muss neu bereitgestellt werden. Der
Befehl ist wiederholbar: dieselben Werte zweimal zu schreiben ändert nichts.
### Neues CluPilot-Postfach
Ein `Mailbox`-Datensatz mit dem Schlüssel `instance-relay` und der Adresse
`noreply@clupilot.cloud`. Er erscheint in der Postfachverwaltung der Konsole
wie die anderen auch.
**Nicht** in `MailCatalogue`: die Einladungsmail ist keine CluPilot-Mail mehr.
Sie entsteht in der Nextcloud des Kunden. Auf der Seite „Wegwahl je Mailart"
taucht sie deshalb bewusst nicht auf — was dort steht, verschickt CluPilot
selbst.
---
## Teil 2: Anlegen ist nicht Einladen
Zwei getrennte Vorgänge, ausdrücklich so gewünscht:
| Vorgang | Wirkung bei uns | Wirkung in der Nextcloud |
|---|---|---|
| **Anlegen** | Sitz mit Name, Mail, Rolle | keine |
| **Einladen** | Zustand → „wird eingerichtet" | `user:add --generate-password --email` |
| **Erneut einladen** | Zustand bleibt | `user:welcome --reset-password` |
Ein Inhaber kann also sein Team vorbereiten und die Einladungen später
verschicken — etwa alle am ersten Arbeitstag.
### Der Nextcloud-Benutzername
`nc_username` wird beim **ersten** Einladen gesetzt und danach **nie wieder
geändert**. Nextcloud kann Benutzer nicht umbenennen; ein Sitz, dessen
Mailadresse sich später ändert, behält seinen Anmeldenamen.
Als Benutzername dient die Mailadresse. Nextcloud lässt `@`, `.`, `-` und `_`
in Benutzerkennungen zu, und die Adresse ist innerhalb eines Kunden ohnehin
eindeutig — das erzwingt die bestehende Prüfung in `Users::invite()`.
### Der Inhaber-Sitz ist der bestehende Admin
Der Sitz mit der Rolle `owner` wird **nicht** neu angelegt. Sein
`nc_username` ist `Instance::nc_admin_ref` — das Konto, das die Bereitstellung
in `CreateCustomerAdmin` erzeugt hat. Ein zweites Admin-Konto für dieselbe
Person wäre ein Konto zu viel.
---
## Teil 3: Rollen sind Nextcloud-Gruppen
Vier Rollen bleiben. Jede ist eine Gruppe im Gast, damit weitere später eine
Zeile sind und kein Umbau.
| Rolle | Nextcloud-Gruppe | Was sie bewirkt |
|---|---|---|
| `owner` | `admin` | Vollzugriff; ist das bestehende Admin-Konto |
| `admin` | `admin` | Vollzugriff, kann Benutzer verwalten |
| `member` | `mitarbeiter` | normaler Benutzer |
| `readonly` | `nur-lesen` | Speicherplatz `0 B` — kann nichts hochladen |
Rollenwechsel: `group:adduser` und `group:removeuser`. Wird `readonly`
**gesetzt**, geht der Speicherplatz auf `0 B`; wird sie **verlassen**, geht er
auf `default` zurück — nicht auf einen festen Wert, denn die Vorgabe der
Instanz hängt am gekauften Paket (`ApplyStorageQuota` setzt sie).
### Was `readonly` ehrlicherweise NICHT ist
Nextcloud kennt keine Rolle „nur lesen". Ein Speicherplatz von `0 B`
verhindert das **Hochladen und Anlegen** — mehr nicht. Was jemandem mit
Bearbeitungsrecht freigegeben wurde, kann er weiterhin ändern und löschen.
Die Oberfläche muss das genau so sagen: **„Kann nichts hochladen oder anlegen.
Was ihm freigegeben wird, kann er im Rahmen der Freigabe bearbeiten."** Eine
Rolle, die mehr verspricht als sie hält, ist schlimmer als keine.
### Offene technische Unsicherheit
Ob Nextcloud den Speicherplatzwert `0 B` als „null Bytes" und nicht als
„unbegrenzt" auslegt, ist aus der Dokumentation nicht zu belegen. Das ist
**kein Annahmerecht**: der Umsetzungsplan bekommt einen eigenen Nachweisschritt
gegen eine echte Instanz, bevor `readonly` als fertig gilt. Fällt der Nachweis
negativ aus, ist die Rückfallebene die Gruppe ohne Speicherplatzgrenze plus
ein deutlicher Hinweis in der Oberfläche, dass die Beschränkung über
Freigaberechte läuft.
---
## Teil 4: Aussetzen, reaktivieren, entziehen
| Knopf | Nextcloud |
|---|---|
| Aussetzen | `user:disable` **und** `user:auth-tokens:delete` |
| Reaktivieren | `user:enable` |
| Entziehen | `user:disable`, Sitz auf `revoked` |
`user:disable` allein beendet laufende Sitzungen erst binnen fünf Minuten.
`user:auth-tokens:delete` wirft sie sofort hinaus — bei einem Mitarbeiter, der
gerade gegangen ist, sind fünf Minuten fünf zu viel.
### Entziehen löscht nichts
**Kein Nextcloud-Benutzer wird je gelöscht, und keine Datei.** Entziehen ist
Sperren mit einem anderen Namen. Der Sitz bleibt als Zeile stehen, der Zugang
ist zu, die Arbeit des Mitarbeiters bleibt dort, wo sein Team sie braucht.
Das ist ausdrücklich entschieden und nicht bloß der bequemere Weg: ein Fehlgriff
im Userpanel darf nicht die Arbeit eines Menschen vernichten. Wer wirklich
löschen will, tut das in der Nextcloud selbst, wo Nextcloud danach fragt, was
mit den Dateien geschehen soll.
Der bestehende `revoke()`, der die Zeile mit `$seat->delete()` entfernt, wird
entsprechend umgebaut. Die Sitzplatzzählung (`usedSeats()`) zählt schon heute
`status != 'revoked'`, passt also unverändert.
---
## Teil 5: Ratelimit
Zwei Grenzen über Laravels `RateLimiter`:
- **10 Zugangsmails je Kunde und Stunde**
- **3 je Sitz und Stunde**
Betroffen sind „Einladen" und „Erneut einladen" — beides löst eine Mail aus.
Wer dagegenläuft, bekommt **„Zu viele Einladungen — in X Minuten wieder
möglich"** zu lesen, mit der echten Restzeit. Eine stumme Verweigerung wäre
schlimmer als die Grenze selbst.
---
## Teil 6: Der Sitz zeigt, was wirklich ist
Jede Nextcloud-Änderung läuft über die **Bereitstellungs-Warteschlange**
nur dieser Arbeiter hat den Tunnel und die Proxmox-Zugangsdaten. Die Seite
selbst erreicht keinen Gast, und sie darf es auch nicht versuchen.
Daraus folgt: **der Knopf wirkt nicht sofort.** Ohne sichtbaren Zustand drückt
der Inhaber wieder und wieder, weil nichts passiert — genau die Beschwerde,
die dieses Projekt schon zweimal hatte.
Deshalb führt der Sitz zwei Angaben nebeneinander:
- **`status`** — was der Inhaber WILL (`invited`, `active`, `suspended`,
`revoked`). Ändert sich sofort beim Klick.
- **`nc_state`** — was in der Nextcloud WIRKLICH ist (`none`, `pending`,
`synced`, `failed`). Ändert sich, wenn der Auftrag durch ist.
Absicht und Wirklichkeit getrennt zu führen ist der einzige Weg, „fehlgeschlagen"
überhaupt sagen zu können. Ein einzelnes Feld müsste lügen.
Dazu `nc_error` (Klartextgrund) und `nc_synced_at`.
### In der Oberfläche
| `nc_state` | Anzeige |
|---|---|
| `none` | „angelegt — noch nicht eingeladen" |
| `pending` | „wird eingerichtet …" |
| `synced` | der `status` selbst (aktiv / gesperrt / entzogen) |
| `failed` | **„fehlgeschlagen"** mit Grund und Knopf „Nochmal versuchen" |
---
## Fehlerbehandlung
Der Auftrag schlägt fehl, wenn die Instanz nicht `active` ist, kein Host
hängt, `vmid` fehlt, der Gastagent nicht antwortet oder occ einen Fehlercode
liefert. In jedem Fall:
1. `nc_state = failed`, `nc_error` = ein Grund in Klartext, kein Stacktrace.
2. `Log::warning` mit Sitz-UUID und Instanz-UUID, **nie** mit Zugangsdaten.
3. Der Auftrag wirft nicht weiter — ein hängender Gast darf den
Bereitstellungs-Arbeiter nicht mitreißen, auf dem die bezahlte
Kundenbereitstellung läuft.
`tries = 1`, wie bei `IssueInstanceAdminAccess`: ein blinder Wiederholungslauf
gegen einen nicht erreichbaren Gast bringt nichts, und der Inhaber hat einen
Knopf dafür.
### Wiederholbarkeit
Einladen prüft zuerst mit `user:info`, ob der Benutzer schon existiert — genau
wie `CreateCustomerAdmin` es tut. Ein zweiter Lauf nach einem Absturz legt
keinen zweiten Benutzer an, sondern schickt die Willkommensmail erneut.
---
## Datenmodell
Wanderung an `seats`:
| Spalte | Typ | Zweck |
|---|---|---|
| `nc_username` | `string`, nullable | einmalig gesetzt, nie geändert |
| `nc_state` | `string`, Vorgabe `none` | `none`/`pending`/`synced`/`failed` |
| `nc_error` | `text`, nullable | Klartextgrund des letzten Fehlschlags |
| `nc_synced_at` | `timestamp`, nullable | wann zuletzt erfolgreich |
Bestehende Sitze bekommen `nc_state = 'none'`. Sie sind damit ehrlich
beschrieben: angelegt, nie eingeladen — was der Wahrheit entspricht, denn
eingeladen hat sie nie jemand.
**Eine Ausnahme, und sie ist wichtig:** der `owner`-Sitz eines Kunden, dessen
Instanz ein `nc_admin_ref` trägt, bekommt in derselben Wanderung
`nc_username = nc_admin_ref` und `nc_state = 'synced'`. Dieser Zugang existiert
in der Nextcloud tatsächlich — die Bereitstellung hat ihn angelegt. Stünde er
auf `none`, böte das Userpanel dem Inhaber an, sich selbst einzuladen, und der
Auftrag träfe auf einen Benutzer, den es längst gibt.
---
## Sicherheitsgrenzen
- **Jede Aktion prüft, dass der Sitz zum angemeldeten Kunden gehört** — auf der
Seite UND im Auftrag. Ein Modal ist ohne die Middleware der Seite erreichbar
(R20); dieselbe Regel, die schon beim Frühwarnsystem galt.
- **Der Auftrag prüft erneut**, dass die Instanz aktiv ist und dem Kunden
gehört. Eine geschlossene Instanz kann ihre VMID auf demselben Host
weiterverliehen haben — dieselbe Falle, die in
`IssueInstanceAdminAccess` schon beschrieben steht.
- **Der letzte Inhaber bleibt.** Die bestehenden Sperren gegen das Entfernen
oder Herabstufen des letzten `owner` bleiben unverändert.
- **Ein Inhaber kann sich nicht selbst aussperren.** Die bestehende Prüfung in
`suspend()` bleibt.
- **Keine Zugangsdaten ins Log**, in keiner Form.
- **Bestätigung im Modal** (R23) für Aussetzen und Entziehen, kein
Browser-Dialog.
---
## Prüfung
- **Einheit:** Befehlsbau in `NextcloudOcc` — dass das SMTP-Passwort in
**keinem** erzeugten Befehl als Argument steht. Strukturell geprüft, am
erzeugten Befehl, nicht am Quelltext.
- **Aufträge:** gegen `FakeProxmoxClient` — angelegt, eingeladen, gesperrt,
reaktiviert, Rolle gewechselt; und für jeden Zweig der Fehlschlag mit
`nc_state = failed`.
- **Wiederholbarkeit:** zweimal einladen legt keinen zweiten Benutzer an.
- **Seite:** ein fremder Sitz lässt sich nicht anfassen (403); die Zeile
erneuert sich nach jeder Aktion — die Prüfung über den **ganzen** Weg,
Modal-Ereignis eingeschlossen, nicht nur der direkte Methodenaufruf.
- **Ratelimit:** die elfte Einladung derselben Stunde wird abgewiesen, mit
Restzeit in der Meldung.
- **Löschverbot:** kein Auftrag im Repo darf `user:delete` enthalten —
testerzwungen, wie die anderen Regeln dieses Projekts.
- **Nachweis gegen echte Hardware:** Speicherplatz `0 B` verhindert das
Hochladen. Eigener Schritt im Plan.
---
## Was nicht gebaut wird
- **Slack-Anbindung.** Eigenes Projekt, kein Nebensatz hier.
- **Die `.cloud`-Domain als Marketing-Auftritt.** Für diesen Bau genügt sie als
Absenderdomain.
- **Endgültiges Löschen von Benutzern.** Siehe Teil 4.
- **Selbst erstellte Rollen.** Die vier genügen für den Anfang; die
Gruppen-Abbildung ist so gebaut, dass weitere später hinzukommen können.
- **Ein Portalkonto für Mitarbeiter.** Sie melden sich in der Nextcloud an.
- **Instanzweiter Passwortablauf.** Er träfe den Inhaber mit.