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_superuserbleibt exklusiv internes Personal, unverändert.ist_organisationsadmingilt nur innerhalb der eigenen Organisation und kann über Self-Service nie aufis_superuserhinauswirken —POST /organisation/kontenliestorganization_idundis_superusernie aus dem Formular, sondern setztorganization_idfest auf die Session des anfragenden Kontos undis_superuserimmer aufFalse. Live mit einem gezielten Manipulationsversuch geprüft (zusätzliches, von der Route nicht deklariertesis_superuser=on-Feld im POST-Body mitgeschickt): blieb wirkungslos,is_superuserweiterhinFalse.- Neue Dependency
require_org_admin(is_superuser OR ist_organisationsadmin) statt der bestehendenrequire_admin(is_superuserallein) 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üris_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.