platform-docs/adr/0019-organisationsverwaltung-stammdaten-und-kontenrollen.md
Thomas Stallinger 5eabdbd180 ADR-0019 Nachtrag: Self-Service-Passwortänderung
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:49:42 +02:00

8.2 KiB

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 kundenplattforms 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 kundenplattforms 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.