diff --git a/adr/0019-organisationsverwaltung-stammdaten-und-kontenrollen.md b/adr/0019-organisationsverwaltung-stammdaten-und-kontenrollen.md new file mode 100644 index 0000000..1ab9cbc --- /dev/null +++ b/adr/0019-organisationsverwaltung-stammdaten-und-kontenrollen.md @@ -0,0 +1,115 @@ +# 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. diff --git a/architecture/09-data-model-v1.md b/architecture/09-data-model-v1.md index 67b9bd9..bf3e0f1 100644 --- a/architecture/09-data-model-v1.md +++ b/architecture/09-data-model-v1.md @@ -37,12 +37,19 @@ Eine Organization beschreibt eine organisatorische Einheit innerhalb der Tuxflot Eine Organization besitzt eigene Geräte, Workspaces, Netzwerkprofile und Richtlinien. +Zusätzlich zu den fachlichen Beziehungen trägt eine Organization einfache Kontakt-/Stammdaten (siehe ADR-0019) — bewusst nur Adress-/Ansprechpartner-Angaben, keine Richtlinien- oder Sicherheitsfelder; Letztere bleiben Teil des weiterhin unspezifizierten `Network Profile`/`Secret Reference`-Themas. + Attribute: - id - name - created_at - updated_at +- kontakt_name (optional) +- adresse (optional) +- telefon (optional) +- kontakt_email (optional) +- notizen (optional) Beziehungen: @@ -498,6 +505,11 @@ Spalten: - name - created_at - updated_at +- kontakt_name (nullable) +- adresse (nullable) +- telefon (nullable) +- kontakt_email (nullable) +- notizen (nullable) ### organisationseinheiten