# ADR-0019: Organisations-Stammdaten + organisationsgebundene Kontenrolle **Status:** Beschlossen **Datum:** 20.08.2026 ## Kontext `/organisation` im Kundenportal erlaubte bislang ausschließlich das Ändern des Namens — die `organizations`-Tabelle auf anode hatte sonst keine Felder, es gab nichts, das eine Kundin dort „mit Informationen befüllen" konnte. Parallel dazu gab es für die Kontenverwaltung **keinen einzigen Self-Service-Weg**: Konten anlegen ging ausschließlich über den internen Bereich (`/admin/kunden/{id}/konten`). Eine Organisation, die eine weitere Kollegin einloggen lassen oder ein Passwort zurücksetzen wollte, brauchte dafür immer das Tuxflotte-Team. **Entscheidendes Sicherheitsdetail, das die Lösung bestimmt hat:** `benutzer.is_superuser` bedeutet im bestehenden Code nicht „Admin der eigenen Organisation", sondern „organisationsübergreifendes, internes Tuxflotte-Personal mit Zugriff auf `/admin/*`" (siehe das Formular-Label in `admin_kunden.py`: „Admin-Zugriff (organisationsübergreifend)"). Eine naive Self-Service-Kontenverwaltung, die dieses Flag wiederverwendet hätte, hätte einer Kundin ermöglicht, sich selbst (oder eine Kollegin) versehentlich zur internen Tuxflotte-Admin mit Zugriff auf *alle* Organisationen zu machen — kein Stilproblem, sondern eine echte Rechteausweitung über Organisationsgrenzen hinweg. Umfang bewusst begrenzt (mit dem User abgestimmt): Stammdaten = einfache Kontakt-/Adressdaten (Ansprechpartner, Adresse, Telefon, Kontakt-E-Mail, Notizfeld) — **keine** Richtlinien-/Sicherheitsfelder. Die in `03-organization-model.md` skizzierten schwereren Konzepte (Sicherheitsanforderungen, Softwarequellen, Netzwerkvorgaben, Branding) bleiben unangetastet; `Network Profile`/`Secret Reference` sind laut ADR-0001/`09-data-model-v1.md` weiterhin bewusst unspezifiziert. ## Entscheidung **Stammdaten:** `organizations` bekommt fünf neue, nullable Textspalten (`kontakt_name`, `adresse`, `telefon`, `kontakt_email`, `notizen`). Der bestehende `PATCH /api/v1/organizations/{id}`-Endpoint wird nur breiter (keine neue Route) — sowohl die Self-Service-Seite (`/organisation`) als auch der interne Admin-Bereich (neue „Organisationsdaten"-Sektion in `kunden_detail.html`) rufen denselben Endpoint auf und sehen dieselben Daten, statt zweier getrennter Wahrheiten. **Neue, organisationsgebundene Rolle `ist_organisationsadmin`** in `kundenplattform`s eigener `benutzer`-Tabelle, strikt getrennt von `is_superuser`: - `is_superuser` bleibt exklusiv internes Personal, unverändert. - `ist_organisationsadmin` gilt nur innerhalb der eigenen Organisation und kann über Self-Service **nie** auf `is_superuser` hinauswirken — `POST /organisation/konten` liest `organization_id` und `is_superuser` nie aus dem Formular, sondern setzt `organization_id` fest auf die Session des anfragenden Kontos und `is_superuser` immer auf `False`. Live mit einem gezielten Manipulationsversuch geprüft (zusätzliches, von der Route nicht deklariertes `is_superuser=on`-Feld im POST-Body mitgeschickt): blieb wirkungslos, `is_superuser` weiterhin `False`. - Neue Dependency `require_org_admin` (`is_superuser OR ist_organisationsadmin`) statt der bestehenden `require_admin` (`is_superuser` allein) für alle organisationsgebundenen Verwaltungsaktionen — internes Personal bleibt dadurch bewusst nicht ausgesperrt, falls Support nötig ist. - Bootstrap: internes Personal kann beim Anlegen des allerersten Kontos einer neuen Organisation (`/admin/kunden/{id}/konten`) direkt die neue Checkbox „Admin dieser Organisation" setzen, getrennt von der bestehenden Checkbox für `is_superuser` — ohne diesen Schritt hätte eine neue Organisation keinen Weg, selbst mit der Self-Service- Kontenverwaltung anzufangen. **Sichtbarkeit vs. Verwaltung:** Die Kolleg:innen-Liste ist für **jedes** eingeloggte Konto der Organisation sichtbar (Transparenz — wer hat Zugriff), Anlegen-/Löschen-Formulare werden aber nur für `is_superuser`/`ist_organisationsadmin` angezeigt und serverseitig über `require_org_admin` erzwungen (nicht nur über UI-Ausblendung). Live geprüft: ein Konto ohne Rechte bekommt bei einem direkten POST-Versuch `403`, unabhängig davon, was die UI anzeigt. **Selbstlöschung ausgeschlossen:** `POST /organisation/konten/{id}/loeschen` verweigert das Löschen des eigenen, gerade eingeloggten Kontos explizit (eigene Fehlermeldung) — verhindert, sich versehentlich aus der eigenen Organisation auszusperren, ohne dass eine komplexere „letzter Admin"- Prüfung nötig wäre. ## Konsequenzen `fetch_benutzer_fuer_organisation()` wurde von `admin_kunden.py` nach `auth.py` verschoben und wird jetzt von beiden Routern importiert (Reuse- Pattern wie `fetch_gruppen()` aus `routers/gruppen.py`, siehe ADR-0017) — keine doppelte SQL-Abfrage für dieselbe Kontenliste mehr. **Live gefundener und gefixter Bug, festgehalten als wiederkehrendes Muster:** `psycopg` liefert TEXT-Spalten über bestimmte Verbindungspfade teils als `bytes` statt `str` zurück — ein bereits bekanntes, nicht deterministisches Verhalten (siehe der bestehende Kommentar in `_row_to_user()`, das ursprünglich die E-Mail-Anzeige im Header verunstaltete). Dieselbe Eigenart traf jetzt die neue `fetch_benutzer_fuer_organisation()`: ein daraus weitergereichter `bytes`-Wert brachte eine nachgelagerte async-SQL-Abfrage (`user_manager.get_by_email()`) mit `operator does not exist: text = bytea` zum Absturz (`500`), als ein Org-Admin versuchte, ein fremdes Konto zu löschen. Zweifach behoben: `konto_loeschen()` lädt das Zielkonto jetzt über `user_manager.get(id)` (rein async, umgeht das Problem strukturell) statt über den E-Mail-Umweg, und `fetch_benutzer_fuer_organisation()` selbst bekam denselben defensiven `bytes`-Decode wie `_row_to_user()`, damit dieselbe Eigenart nicht an einer dritten Stelle erneut zuschlägt. Für künftigen Code, der Werte aus einer sync-`psycopg`-Verbindung in eine async-Abfrage weiterreicht, ist das ein bekanntes Risiko, keine Überraschung mehr. Details zum Datenmodell: `09-data-model-v1.md`, dort um die neuen Organization-Kontaktfelder ergänzt. `benutzer` (inkl. der neuen `ist_organisationsadmin`-Spalte) bleibt dort bewusst unerwähnt — die Tabelle lebt in `kundenplattform`s eigener, von `provisioning-server` getrennter Datenbank (siehe ADR-0011) und war schon vorher nicht Bestandteil dieses Dokuments. ## Nachtrag 26.08.2026: Self-Service-Passwortänderung Der ursprüngliche Umfang dieser ADR deckte Kontenverwaltung (Anlegen/ Löschen fremder Konten durch `require_org_admin`) ab, aber keine Möglichkeit für einen Nutzer, sein **eigenes** Passwort zu ändern — dafür war bislang ausschließlich das CLI-Skript `scripts/reset_password.py` (aus der Sicherheitshärtung vom 25.08.2026) zuständig, nicht per Self-Service erreichbar. Neue Route `POST /organisation/passwort`, neue Sektion „Mein Konto" auf derselben `/organisation`-Seite. Bewusst **ohne** `require_org_admin` — anders als Kontenverwaltung für Dritte braucht das eigene Passwort keine besondere Rolle, jedes eingeloggte Konto darf es ändern. Prüft das aktuelle Passwort über `user_manager.authenticate()` (gleiches Muster wie der Login in `auth.py`), validiert das neue Passwort explizit gegen die bestehende `UserManager.validate_password()`-Policy (≥12 Zeichen, nicht gleich der E-Mail-Adresse, siehe Sicherheitshärtung 25.08.2026) und zeigt Fehler inline auf derselben Seite statt als nackte `HTTPException` — dafür wurde die Kontext-Beschaffung aus `organisation_ansicht()` in eine gemeinsame Hilfsfunktion `_organisation_kontext()` ausgelagert. Bekannte, akzeptierte Einschränkung: die JWT-Session bleibt nach einer Passwortänderung bis zu ihrem regulären Ablauf gültig (kein Session-Revocation-Mechanismus vorhanden, war auch vorher schon so) — ein bereits eingeloggtes Gerät wird durch eine Passwortänderung nicht automatisch ausgeloggt. Live auf anode verifiziert (WLAN-Test-Fixture-Konto, Passwort danach auf den dokumentierten Fixture-Wert zurückgesetzt): falsches aktuelles Passwort, zu kurzes neues Passwort, nicht übereinstimmende Wiederholung liefern je `400` mit der passenden Inline-Fehlermeldung; der Erfolgsfall liefert `303`, das alte Passwort funktioniert danach nicht mehr, das neue sofort.