From 30c2c56656e63d57218689059beb277855a65b2c Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Sat, 12 Sep 2026 19:26:41 +0200 Subject: [PATCH] Verzeichniskonfiguration aus 1.2.8 uebernehmen; Sicherungen setzten Schalter falsch --- apps/tesm/pyproject.toml | 2 +- .../tesm/src/tesm/services/legacy_transfer.py | 216 ++++++++++++++++-- apps/tesm/src/tesm/services/transfer.py | 108 +++++++++ packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/settings_store.py | 24 ++ 5 files changed, 334 insertions(+), 18 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 3d36c57..b9005dc 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.8.1" +version = "2.8.2" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/services/legacy_transfer.py b/apps/tesm/src/tesm/services/legacy_transfer.py index ec3dc2b..1f3af41 100644 --- a/apps/tesm/src/tesm/services/legacy_transfer.py +++ b/apps/tesm/src/tesm/services/legacy_transfer.py @@ -16,8 +16,10 @@ eingelesen. Ein eigener Schreibpfad fuer Altdaten waere eine zweite Stelle, an der Fremdeingaben in die Datenbank gelangen -- und die zweite Stelle ist die, die niemand pflegt. -Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration und -die Freigabe-Zuordnungen. Was in der Datei sonst noch steht, wird nicht +Uebersetzt werden Clients, Switche, Zugangsdaten, die DHCP-Konfiguration, die +Freigabe-Zuordnungen und die Anbindung an das Verzeichnis (Server, Transport, +Basis-DN, Anmeldeattribut, Bind-Konto samt Passwort, und die Zuordnung +"diese AD-Gruppe wird Administrator"). Was in der Datei sonst noch steht, wird nicht stillschweigend verworfen, sondern im Bericht benannt -- damit sichtbar bleibt, was von Hand nachzuziehen ist. @@ -43,9 +45,7 @@ from typing import Any LEGACY_KDF_ITERATIONS = 390000 LEGACY_APP_TAG = "tesm" -#: Diese Abschnitte werden uebersetzt. "ldap" nur zum Teil -- daraus kommen die -#: Freigabe-Zuordnungen, waehrend Servereinstellungen und Gruppen-Zuordnungen -#: dort bleiben (siehe KNOWN_UNSUPPORTED). +#: Diese Abschnitte werden uebersetzt. SUPPORTED = ("credentials", "switches", "devices", "dhcp", "ldap") #: Was der alte Export sonst enthaelt. Wird gemeldet, nicht uebersetzt. @@ -56,13 +56,9 @@ KNOWN_UNSUPPORTED: dict[str, str] = { "Zweck. Konten neu anlegen." ), "groups": "Gruppen und Rechte: die Rechteschlüssel wurden neu geschnitten.", - # Nur noch der Teil, der wirklich nicht uebertragbar ist: Server, Bind-Konto - # und die Gruppen-Zuordnung (dort zeigen die Namen auf Gruppen, deren - # Rechteschluessel neu geschnitten wurden). Die Freigaben kommen mit. - "ldap": ( - "Verzeichnisdienst: Server, Bind-Konto und die Gruppen-Zuordnung von Hand " - "übertragen. Die Freigabe-Zuordnungen wurden übernommen." - ), + # Der Abschnitt "ldap" wird uebersetzt (siehe _verzeichnis) -- was dabei + # nicht mitkommt, benennt die Uebersetzung einzeln und genauer, als eine + # pauschale Zeile hier es koennte. "nginx": "Webserver: Domain, Ports und Zertifikat von Hand übertragen.", "logs": "Auditlog: bleibt bewusst beim alten System.", "known_hosts": ( @@ -285,6 +281,126 @@ def _devices(items: Any, credentials: list[dict[str, Any]], report: Report) -> l return ergebnis +def _verzeichnis(data: Any, report: Report) -> dict[str, Any]: + """Die Anbindung an das Verzeichnis -- so weit sie sich abbilden laesst. + + Zwei Stellen sind **nicht** 1:1, und beide werden benannt statt geraten: + + * **Transport.** Der Vorgaenger kannte ``ldap_use_ssl`` (an/aus). Aus "an" + wird LDAPS. Aus "aus" wird **StartTLS auf demselben Port** und nicht + "unverschluesselt": diese Anwendung verlangt fuer eine unverschluesselte + Verbindung einen ausdruecklichen Schalter, weil das Passwort dabei im + Klartext ueber das Netz geht. Diesen Schalter setzt eine Uebernahme + nicht fuer jemanden -- ein Active Directory beantwortet StartTLS auf 389 + ohnehin, und wenn nicht, sagt der Verbindungstest es sofort. + * **Standard- und Anmeldegruppe.** Es gibt sie hier nicht als + Einstellung: die Standardgruppe ist ein Kennzeichen an der Gruppe + selbst, und eine erzwungene Anmeldegruppe kennt diese Anwendung nicht. + Waren sie gesetzt, steht es im Bericht. + """ + if not isinstance(data, dict): + return {} + alt = {str(k): v for k, v in (data.get("settings") or {}).items()} + ergebnis: dict[str, Any] = {} + einstellungen: dict[str, Any] = {} + + def ja(schluessel: str) -> bool: + return str(alt.get(schluessel) or "").strip().lower() in {"1", "true", "yes", "on"} + + server = str(alt.get("ldap_server") or "").strip() + if server: + einstellungen["ldap_host"] = server + if str(alt.get("ldap_port") or "").strip().isdigit(): + einstellungen["ldap_port"] = int(str(alt["ldap_port"]).strip()) + if "ldap_enabled" in alt: + einstellungen["ldap_enabled"] = ja("ldap_enabled") + if str(alt.get("ldap_base_dn") or "").strip(): + einstellungen["ldap_base_dn"] = str(alt["ldap_base_dn"]).strip() + if str(alt.get("ldap_user_filter_attr") or "").strip(): + einstellungen["ldap_user_attribute"] = str(alt["ldap_user_filter_attr"]).strip() + + if "ldap_use_ssl" in alt: + if ja("ldap_use_ssl"): + einstellungen["ldap_security"] = "ldaps" + else: + einstellungen["ldap_security"] = "starttls" + report.note( + "Der Vorgänger hat unverschlüsselt verbunden. Übernommen als StartTLS auf " + "demselben Port – eine unverschlüsselte Verbindung verlangt hier einen " + "ausdrücklichen Schalter, den eine Übernahme nicht für Sie setzt. Bitte den " + "Verbindungstest ausführen." + ) + if ja("ldap_tls_skip_verify"): + einstellungen["ldap_verify_cert"] = False + report.note( + "Die Prüfung des Serverzertifikats war abgeschaltet und bleibt es – sonst " + "scheitert die Anmeldung nach dem Upgrade. Damit schützt die Verschlüsselung " + "nur gegen Mitlesen, nicht gegen einen Man-in-the-Middle: bitte ein " + "CA-Zertifikat hinterlegen und die Prüfung einschalten." + ) + if einstellungen: + ergebnis["settings"] = einstellungen + + for schluessel, text in ( + ( + "ldap_default_group", + "Standardgruppe {wert!r}: hier ist das ein Kennzeichen an der Gruppe selbst " + "(Verwaltung -> Gruppen). Bitte dort setzen.", + ), + ( + "ldap_required_login_group", + "Erzwungene Anmeldegruppe {wert!r}: diese Anwendung kennt sie nicht. Wer sich " + "anmelden darf, entscheidet die Gruppen-Zuordnung.", + ), + ): + wert = str(alt.get(schluessel) or "").strip() + if wert: + report.note(text.format(wert=wert)) + + benutzer = str(data.get("bind_username") or alt.get("ldap_bind_dn") or "").strip() + passwort = str(data.get("bind_password") or "") + if benutzer: + ergebnis["bind_username"] = benutzer + ergebnis["bind_password"] = passwort + if not passwort: + report.note( + f"Bind-Konto {benutzer!r} übernommen, aber ohne Passwort – es stand nicht in " + "der Datei. Unter Verwaltung -> Verzeichnisdienst nachtragen." + ) + + zuordnungen: list[dict[str, Any]] = [] + offen: list[str] = [] + for eintrag in _liste(data.get("group_mappings")): + dn = str(eintrag.get("ad_group_dn") or "").strip() + if not dn: + continue + name = str(eintrag.get("ad_group_name") or "").strip() or dn + ziel = str( + eintrag.get("app_group_name") or eintrag.get("app_group_id") or "" + ).strip() + # Der Vorgaenger kannte den Sonderwert "admin" (bzw. "Admin" im + # Export): diese AD-Gruppe macht Administratoren. Genau dafuer gibt es + # hier seit 2.8.0 ein Ziel -- das Administratorkennzeichen. + if ziel.lower() == "admin": + zuordnungen.append( + {"ad_group_dn": dn, "ad_group_name": name, "grants_admin": True} + ) + continue + if ziel: + offen.append(f"{name} -> {ziel}") + if zuordnungen: + ergebnis["group_mappings"] = zuordnungen + if offen: + report.note( + "Diese Gruppen-Zuordnungen zeigen auf lokale Gruppen, die nicht mit übernommen " + "werden (die Rechteschlüssel sind neu geschnitten): " + + ", ".join(sorted(offen)) + + ". Nach dem Anlegen der Gruppen unter Verwaltung -> Verzeichnisdienst " + "nachtragen." + ) + return ergebnis + + def _freigaben(data: Any, report: Report) -> list[dict[str, Any]]: """Freigabe-Zuordnungen aus dem Abschnitt "ldap" des alten Exports. @@ -441,6 +557,10 @@ def convert(legacy: dict[str, Any]) -> tuple[dict[str, Any], Report]: if freigaben: payload["fileshare"] = freigaben + verzeichnis = _verzeichnis(legacy.get("ldap"), report) + if verzeichnis: + payload["directory"] = verzeichnis + dhcp, settings = _dhcp(legacy.get("dhcp"), report) if dhcp: payload["dhcp"] = dhcp @@ -676,8 +796,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]: continue payload[abschnitt] = [dict(row) for row in conn.execute(sql).fetchall()] + entschluesseln_bind = _entschluessler(root, report) if "credentials" in vorhanden: - entschluesseln = _entschluessler(root, report) + entschluesseln = entschluesseln_bind payload["credentials"] = [ { "name": row["name"], @@ -693,8 +814,9 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]: else: report.note("Tabelle 'credentials' fehlt in der alten Datenbank.") - # Die Freigaben reisen im Abschnitt "ldap" -- genau dort, wo der alte - # Export sie auch hat. So liest die Uebersetzung beide Wege gleich. + # Der Abschnitt "ldap" -- genau so aufgebaut wie im alten Export, damit + # die Uebersetzung beide Wege gleich liest. + ldap: dict[str, Any] = {} if "ldap_fileshare_mappings" in vorhanden: freigaben = [ dict(row) @@ -704,10 +826,68 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]: ).fetchall() ] if freigaben: - payload["ldap"] = {"fileshare_mappings": freigaben} + ldap["fileshare_mappings"] = freigaben else: report.note("Tabelle 'ldap_fileshare_mappings' fehlt in der alten Datenbank.") + if "settings" in vorhanden: + # Nur anlegen, wenn etwas drinsteht: ein leerer Abschnitt sieht im + # Bericht wie eine Uebernahme aus und ist keine. + ldap_einstellungen = { + str(row["key"]): str(row["value"]) + for row in conn.execute( + "SELECT key, value FROM settings WHERE key LIKE 'ldap%'" + ).fetchall() + } + if ldap_einstellungen: + ldap["settings"] = ldap_einstellungen + + if "ldap_group_mappings" in vorhanden: + # Der Sonderwert "admin" steht in derselben Spalte wie eine + # Gruppen-Id; der Name dahinter ist das, was ueber die + # Systemgrenze traegt. + gruppen_da = "groups" in vorhanden + zuordnungen = [ + { + "ad_group_dn": str(row["ad_group_dn"]), + "ad_group_name": str(row["ad_group_name"]), + "app_group_name": str(row["ziel"] or ""), + } + for row in conn.execute( + "SELECT m.ad_group_dn, m.ad_group_name, CASE " + " WHEN m.app_group_id = 'admin' THEN 'admin' " + + (" ELSE g.name " if gruppen_da else " ELSE NULL ") + + "END AS ziel FROM ldap_group_mappings m" + + ( + " LEFT JOIN groups g ON g.id = m.app_group_id" + if gruppen_da + else "" + ) + ).fetchall() + ] + if zuordnungen: + ldap["group_mappings"] = zuordnungen + + # Das Bind-Konto: erst das Dienstkonto (so liest es auch der alte + # Export), sonst die beiden Einstellungen aus aelteren Fassungen. + if "service_accounts" in vorhanden: + konto = _eine( + conn, + "SELECT username, password FROM service_accounts WHERE purpose='ldap_bind'", + ) + if konto is not None: + ldap["bind_username"] = str(konto["username"] or "") + ldap["bind_password"] = entschluesseln_bind(str(konto["password"] or "")) + if not ldap.get("bind_username"): + aus_einstellungen = (ldap.get("settings") or {}).get("ldap_bind_dn") or "" + if aus_einstellungen: + ldap["bind_username"] = str(aus_einstellungen) + ldap["bind_password"] = entschluesseln_bind( + str((ldap.get("settings") or {}).get("ldap_bind_password") or "") + ) + if ldap: + payload["ldap"] = ldap + dhcp = _alt_dhcp(conn, vorhanden) if dhcp: payload["dhcp"] = dhcp @@ -723,6 +903,10 @@ def read_installation(root: Path) -> tuple[dict[str, Any], Report]: for abschnitt, inhalt in payload.items(): if abschnitt == "ldap" and isinstance(inhalt, dict): report.took("Freigaben", len(_liste(inhalt.get("fileshare_mappings")))) + report.took("Verzeichniseinstellungen", len(inhalt.get("settings") or {})) + report.took("Gruppen-Zuordnungen", len(_liste(inhalt.get("group_mappings")))) + if inhalt.get("bind_username"): + report.took("Bind-Konto", 1) continue report.took(abschnitt, len(inhalt) if isinstance(inhalt, list) else 1) return payload, report diff --git a/apps/tesm/src/tesm/services/transfer.py b/apps/tesm/src/tesm/services/transfer.py index 8dfe62e..4268004 100644 --- a/apps/tesm/src/tesm/services/transfer.py +++ b/apps/tesm/src/tesm/services/transfer.py @@ -62,6 +62,14 @@ CATEGORIES: tuple[Category, ...] = ( ), Category("dhcp", "DHCP-Konfiguration", "Subnetze, Optionen und manuelle Reservierungen"), Category("fileshare", "Freigabe-Zuordnungen", "Verzeichnisgruppe zu UNC-Pfad"), + Category( + "directory", + "Verzeichnisdienst", + "LDAP-Anbindung, Bind-Konto und Gruppen-Zuordnungen. Das Passwort des " + "Bind-Kontos nur mit \"Geheimnisse einschliessen\".", + admin_only=True, + sensitive=True, + ), Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True), Category("groups", "Gruppen und Rechte", "Rechtematrix", admin_only=True), Category("settings", "Einstellungen", "Laufzeiteinstellungen der Anwendung", admin_only=True), @@ -186,6 +194,49 @@ def build_export( } for row in Database.all(conn, "SELECT * FROM groups WHERE deleted_at IS NULL") ] + if "directory" in wanted: + # Die Anbindung an das Verzeichnis war das einzige Stueck Konfiguration, + # das man nach einer Wiederherstellung von Hand nachbauen musste -- + # inklusive Bind-Passwort, das dabei jemand aus einem Kennwortspeicher + # holen muss. Es gehoert zur Anlage wie die Netzwerkeinstellung. + from tesm_core.auth import directory as verzeichnis + + bind_benutzer, bind_passwort = verzeichnis.bind_credentials(conn) + gesetzt = { + str(row["key"]): str(row["value"]) + for row in Database.all(conn, "SELECT key, value FROM settings") + } + payload["directory"] = { + # Nur die Schluessel der Anbindung, und nur die, die wirklich + # gesetzt sind: ein Vorgabewert in der Datei sieht aus wie eine + # Entscheidung und ueberschreibt beim Einlesen eine echte. + "settings": { + spec.key: gesetzt[spec.key] for spec in verzeichnis.SETTINGS + if spec.key in gesetzt + }, + "bind_username": bind_benutzer, + # Ohne "Geheimnisse einschliessen" reist das Passwort nicht mit. + # Der Benutzername schon: er ist keine Geheimnis, und ohne ihn + # muesste man die halbe Anbindung doch wieder tippen. + "bind_password": bind_passwort if include_secrets else "", + # Die Zuordnung zeigt auf eine Gruppe **oder** auf das + # Administratorkennzeichen. Ueber eine Systemgrenze hinweg ist eine + # Gruppen-Id nichts wert -- der Name ist es. + "group_mappings": [ + { + "ad_group_dn": str(row["ad_group_dn"]), + "ad_group_name": str(row["ad_group_name"]), + "group_name": str(row["group_name"] or ""), + "grants_admin": bool(row["grants_admin"]), + } + for row in Database.all( + conn, + "SELECT m.ad_group_dn, m.ad_group_name, m.grants_admin, g.name AS group_name " + "FROM ldap_group_mappings m " + "LEFT JOIN groups g ON g.id = m.app_group_id AND g.deleted_at IS NULL", + ) + ], + } if "settings" in wanted: payload["settings"] = { str(row["key"]): str(row["value"]) @@ -704,6 +755,63 @@ def apply_import( ) bump("signatures", "created") + if "directory" in wanted: + from tesm_core.auth import directory as verzeichnis + + abschnitt = payload.get("directory") or {} + bekannt = {spec.key for spec in verzeichnis.SETTINGS} + for key, value in (abschnitt.get("settings") or {}).items(): + if str(key) not in bekannt: + # Ein fremder Schluessel in diesem Abschnitt gehoert nicht hier + # hin; die Kategorie "Einstellungen" nimmt alles andere. + bump("directory", "skipped") + continue + set_setting(str(key), value) + bump("directory", "updated") + + benutzer = str(abschnitt.get("bind_username") or "").strip() + passwort = str(abschnitt.get("bind_password") or "") + if benutzer: + # Ohne Passwort bleibt ein vorhandenes stehen -- genau dafuer nimmt + # store_bind_credentials None. Eine Sicherung ohne Geheimnisse darf + # das Passwort nicht loeschen. + verzeichnis.store_bind_credentials(conn, benutzer, passwort or None) + bump("directory", "updated") + + for eintrag in abschnitt.get("group_mappings") or []: + dn = str(eintrag.get("ad_group_dn") or "").strip() + if not dn: + bump("directory", "skipped") + continue + kennzeichen = bool(eintrag.get("grants_admin")) + gruppe = None + if not kennzeichen: + name = str(eintrag.get("group_name") or "").strip() + gruppe = Database.value( + conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (name,) + ) + if gruppe is None: + # Eine Zuordnung auf eine Gruppe, die es hier nicht gibt, + # waere eine Zuordnung ins Leere: beim naechsten Login + # bekaeme das Konto nichts, und niemand wuesste warum. + bump("directory", "skipped") + continue + conn.execute( + "INSERT INTO ldap_group_mappings " + "(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) " + "VALUES (?,?,?,?,?) ON CONFLICT(ad_group_dn) DO UPDATE SET " + "ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, " + "grants_admin=excluded.grants_admin", + ( + dn, + str(eintrag.get("ad_group_name") or dn), + int(gruppe) if gruppe is not None else None, + int(kennzeichen), + stamp, + ), + ) + bump("directory", "created") + if "settings" in wanted: for key, value in (payload.get("settings") or {}).items(): if str(key).startswith("license_"): diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 6ca9f45..cf9d87b 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.8.0" +version = "2.8.1" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/settings_store.py b/packages/tesm-core/src/tesm_core/settings_store.py index 8604067..1ccca64 100644 --- a/packages/tesm-core/src/tesm_core/settings_store.py +++ b/packages/tesm-core/src/tesm_core/settings_store.py @@ -58,6 +58,30 @@ class SettingSpec: return str(raw) def serialize(self, value: Any) -> str: + """Ein Wert als Text fuer die Datenbank. + + Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem + Code) -- oder als die Zeichenkette, die schon in der Datenbank stand. + Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag + ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht + leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde + beim Einspielen ein eingeschalteter -- auch aus + ``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert, + und aus der Navigationsreihenfolge damit eine Zeichenkette. + + Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss + durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein + Wert eine Sicherung. + """ + if isinstance(value, str): + if self.kind == "bool": + value = self.parse(value) + elif self.kind == "json": + try: + value = json.loads(value) + except ValueError: + # Kein JSON-Text, also ein Wert: dann wird er kodiert. + pass if self.kind == "bool": return "1" if value else "0" if self.kind == "json":