From 48ce56fe7d945237b152e8b81ee646f87fe52794 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Sun, 13 Sep 2026 00:19:55 +0200 Subject: [PATCH] Version 2.8.7: drei Standardgruppen, Module abschaltbar --- apps/tesm-license/pyproject.toml | 2 +- .../src/tesm_license/blueprints/settings.py | 6 +- .../src/tesm_license/permissions.py | 54 ++--------- apps/tesm-license/src/tesm_license/schema.py | 73 +++++++++++++++ .../templates/license/settings_system.html | 2 +- packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 8 ++ .../src/tesm_core/licensing_client.py | 70 +++++++++++++- .../tesm-core/src/tesm_core/rbac/service.py | 92 ++++++++++++++++--- 9 files changed, 243 insertions(+), 66 deletions(-) diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index 45a1607..a5c1d69 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.8.2" +version = "2.8.3" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm-license/src/tesm_license/blueprints/settings.py b/apps/tesm-license/src/tesm_license/blueprints/settings.py index 274cd58..17b0914 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/settings.py +++ b/apps/tesm-license/src/tesm_license/blueprints/settings.py @@ -68,7 +68,11 @@ def system(): else "" ), timezones=_timezone_choices(), - nav_items=extension.nav.items, + # Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier + # belegt, ueberschreibt die gefilterte Navigation aus dem Kontext -- + # und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser + # Benutzer nicht darf und den die Lizenz nicht freischaltet. + nav_baum=extension.nav.items, nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(), nav_child_order=extension.settings.get(conn, "nav_child_order") or {}, sysops_available=bool(sysops and sysops.available), diff --git a/apps/tesm-license/src/tesm_license/permissions.py b/apps/tesm-license/src/tesm_license/permissions.py index 81bab0d..139e2e1 100644 --- a/apps/tesm-license/src/tesm_license/permissions.py +++ b/apps/tesm-license/src/tesm_license/permissions.py @@ -183,6 +183,14 @@ FUNKTIONEN_VOLLZUGRIFF = RolePreset( ) +#: Wie in TESM: drei Gruppen ab Werk, nicht sechs. +#: +#: Auch "Vertrieb" ist hier entfallen, obwohl es die Rolle ist, die den +#: Lizenzserver taeglich benutzt -- gerade deshalb: wer Lizenzen ausstellt, +#: schneidet die Gruppe ohnehin auf sein Haus zu (darf Vertrieb widerrufen? +#: Tickets loeschen? in die Protokolle sehen?). Eine mitgelieferte Antwort auf +#: diese Frage wird entweder uebernommen, ohne sie zu pruefen, oder sie wird +#: umgebaut. "Funktionen (Vollzugriff)" ist der ehrlichere Startpunkt. ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="viewer", @@ -191,52 +199,6 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( grants=(("logs_live", (A.READ,)),), is_default=True, ), - RolePreset( - key="sales", - name="Vertrieb", - description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.", - grants=( - ("customers", "*"), - ("tickets", "*"), - ("licenses", (A.READ, A.EXPORT)), - ("license_issue", "*"), - ("license_renew", "*"), - ("license_revoke", "*"), - ("portal", "*"), - ("logs_live", (A.READ,)), - ("logs_audit", (A.READ,)), - ), - ), - RolePreset( - key="support", - name="Support", - description="Lesen und Offline-Aktivierungen verarbeiten.", - grants=( - ("customers", (A.READ,)), - ("tickets", (A.READ,)), - ("licenses", (A.READ,)), - ("license_activation", "*"), - ("reports", (A.READ,)), - ("logs_live", (A.READ,)), - ), - ), - RolePreset( - key="auditor", - name="Revision", - description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.", - grants=( - ("customers", (A.READ,)), - ("tickets", (A.READ,)), - ("licenses", (A.READ,)), - ("reports", (A.READ,)), - ("users", (A.READ,)), - ("groups", (A.READ,)), - ("logs_live", (A.READ,)), - ("logs_history", (A.READ, A.EXPORT)), - ("logs_audit", (A.READ, A.EXPORT)), - ("diagnostics", (A.READ,)), - ), - ), RolePreset( key="administration", name="Administration", diff --git a/apps/tesm-license/src/tesm_license/schema.py b/apps/tesm-license/src/tesm_license/schema.py index 74e2d8d..486b7ce 100644 --- a/apps/tesm-license/src/tesm_license/schema.py +++ b/apps/tesm-license/src/tesm_license/schema.py @@ -27,6 +27,8 @@ Aenderungen gegenueber dem Vorgaenger: from __future__ import annotations +from typing import Any + from tesm_core.migrations import Migration V1 = """ @@ -223,8 +225,79 @@ def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def] rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF) +#: Die drei Vorlagen, die es bis 2.8.2 gab -- eingefrorene Kopie, siehe TESM. +ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = ( + ( + "sales", + "Vertrieb", + ( + ("customers", "*"), + ("tickets", "*"), + ("licenses", ("read", "export")), + ("license_issue", "*"), + ("license_renew", "*"), + ("license_revoke", "*"), + ("portal", "*"), + ("logs_live", ("read",)), + ("logs_audit", ("read",)), + ), + ), + ( + "support", + "Support", + ( + ("customers", ("read",)), + ("tickets", ("read",)), + ("licenses", ("read",)), + ("license_activation", "*"), + ("reports", ("read",)), + ("logs_live", ("read",)), + ), + ), + ( + "auditor", + "Revision", + ( + ("customers", ("read",)), + ("tickets", ("read",)), + ("licenses", ("read",)), + ("reports", ("read",)), + ("users", ("read",)), + ("groups", ("read",)), + ("logs_live", ("read",)), + ("logs_history", ("read", "export")), + ("logs_audit", ("read", "export")), + ("diagnostics", ("read",)), + ), + ), +) + + +def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def] + """Wie in TESM: unberuehrte Vorlagen in den Papierkorb, benutzte bleiben.""" + from tesm_core.rbac import service as rbac + from tesm_core.rbac.model import Action, RolePreset + + from .permissions import PERMISSIONS + + vorlagen = [ + RolePreset( + key=key, + name=name, + description="", + grants=tuple( + (ressource, a if a == "*" else tuple(Action(x) for x in a)) + for ressource, a in grants + ), + ) + for key, name, grants in ABGELEGTE_VORLAGEN + ] + rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen) + + LICENSE_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True), Migration(version=2, name="revision-timestamp", sql=V2), Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe), + Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen), ) diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_system.html b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html index e8560e3..4f4e9bd 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/settings_system.html +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html @@ -129,7 +129,7 @@ {% if current_user.is_admin %} - {{ nav_order_card(nav_items, nav_order, nav_child_order, + {{ nav_order_card(nav_baum, nav_order, nav_child_order, url_for('admin.nav_order'), csrf_field_name, csrf_token(), sub='Gilt für alle Benutzer') }} {% endif %} diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index bb8d0af..e8db57b 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.2" +version = "2.8.3" 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/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index 77fa1b4..b16118f 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = ( ), # -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- # # Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung. + SettingSpec( + "modules_disabled", + [], + "json", + "Vor Ort abgeschaltete Module", + hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.", + section="system", + ), SettingSpec( "license_server_ca", "", diff --git a/packages/tesm-core/src/tesm_core/licensing_client.py b/packages/tesm-core/src/tesm_core/licensing_client.py index fb073d9..9ef0505 100644 --- a/packages/tesm-core/src/tesm_core/licensing_client.py +++ b/packages/tesm-core/src/tesm_core/licensing_client.py @@ -34,7 +34,7 @@ import urllib.request from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path -from typing import Any, Callable +from typing import Any, Callable, Iterable import tesm_licensing as lic @@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error" EXPIRY_HINT_DAYS = 90 SETTING_EXPORT_GRACE = "license_export_grace" +#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen). +#: +#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch +#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server +#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll +#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen. +#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt +#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route. +#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit +#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine +#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe, +#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung +#: mitbringen. +SETTING_MODULES_OFF = "modules_disabled" + STATUS_INACTIVE = "inactive" STATUS_PENDING = "pending" STATUS_ACTIVE = "active" @@ -121,6 +136,8 @@ class LicenseState: last_heartbeat_at: str = "" heartbeat_error: str = "" pending_action: str = "" + #: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt. + modules_off: frozenset[str] = frozenset() @property def role(self) -> str: @@ -128,8 +145,14 @@ class LicenseState: @property def modules(self) -> frozenset[str]: + """Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter.""" return self.status.modules if self.status else frozenset() + @property + def modules_in_use(self) -> frozenset[str]: + """Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet.""" + return self.modules - self.modules_off + @property def customer_name(self) -> str: return str((self.status.customer if self.status else {}).get("name") or "") @@ -179,6 +202,7 @@ class LicenseManager: """Liest Lizenzdatei und Status neu ein.""" with self._lock: state = LicenseState() + state.modules_off = self.abgeschaltete_module(conn) state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "") state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "") pending = self._pending(conn) @@ -239,9 +263,51 @@ class LicenseManager: ) def module_active(self, name: str) -> bool: - """Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?""" + """Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb? + + Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es + freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese + eine Funktion fragen die Navigation, jede Route und die Hintergrund- + aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage. + """ + return ( + self.active() + and name in self._state.modules + and name not in self._state.modules_off + ) + + def module_licensed(self, name: str) -> bool: + """Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)""" return self.active() and name in self._state.modules + def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]: + """Liest die Liste aus den Einstellungen.""" + gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or [] + if isinstance(gespeichert, str): # Altbestand: ein einzelner Name + gespeichert = [gespeichert] + return frozenset(str(name) for name in gespeichert) + + def module_abschalten( + self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = "" + ) -> tuple[frozenset[str], frozenset[str]]: + """Setzt die Liste der abgeschalteten Module neu. + + Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich + wirklich geaendert hat; der Aufrufer schreibt daraus seinen + Protokolleintrag. + + Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen + Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut + pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist + das Einzige, was jetzt anders ist. + """ + vorher = self.abgeschaltete_module(conn) + nachher = frozenset(str(name) for name in namen) + self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor) + with self._lock: + self._state.modules_off = nachher + return vorher - nachher, nachher - vorher + def feature_allowed(self, feature: str) -> bool: """Lizenzpflichtige Einzelfunktion. diff --git a/packages/tesm-core/src/tesm_core/rbac/service.py b/packages/tesm-core/src/tesm_core/rbac/service.py index fe6bf8e..fc36693 100644 --- a/packages/tesm-core/src/tesm_core/rbac/service.py +++ b/packages/tesm-core/src/tesm_core/rbac/service.py @@ -328,6 +328,83 @@ def ensure_presets( return created +def unberuehrte_vorlagen_entfernen( + conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset] +) -> list[str]: + """Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte. + + Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf + einer frischen Installation entsteht sie dann gar nicht erst; auf einer + bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach + loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er + arbeitet. + + Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen: + + * Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der + Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst + angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen + traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte + dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg. + * Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst: + jemand hat sie uebernommen. + * Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt. + * Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst: + jemand hat sie angepasst, also ist sie seine, nicht unsere. + * Sie ist keine Systemgruppe. + + Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem + Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck. + """ + entfernt: list[str] = [] + gruppen = list_groups(conn) + for preset in presets: + gruppe = next( + ( + g + for g in gruppen + if g.preset_key + and g.preset_key == preset.key + and g.name.lower() == preset.name.lower() + ), + None, + ) + if gruppe is None or gruppe.is_system or gruppe.member_count: + continue + if set(gruppe.permissions) != set(tree.expand_preset(preset)): + continue + conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id)) + entfernt.append(gruppe.name) + return entfernt + + +def funktionsgruppe_anlegen( + conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset +) -> int: + """Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer. + + Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei + verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie + **jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa + das, das an der Kommandozeile entsteht --, braucht das erste ohne das + zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst + abgelegt hat, sie beim naechsten Aufruf wieder ein. + """ + zeile = Database.one( + conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,) + ) + if zeile is not None: + return int(zeile["id"]) + cursor = conn.execute( + "INSERT INTO groups (name, description, is_system, is_default, preset_key, " + "created_at, updated_at) VALUES (?,?,0,0,?,?,?)", + (preset.name, preset.description, preset.key, _now(), _now()), + ) + gruppe = int(cursor.lastrowid or 0) + _write_permissions(conn, gruppe, tree.expand_preset(preset)) + return gruppe + + def funktionsgruppe_fuer_administratoren( conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset ) -> int: @@ -345,20 +422,7 @@ def funktionsgruppe_fuer_administratoren( Gibt die Nummer der Gruppe zurueck. """ - zeile = Database.one( - conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,) - ) - if zeile is None: - cursor = conn.execute( - "INSERT INTO groups (name, description, is_system, is_default, preset_key, " - "created_at, updated_at) VALUES (?,?,0,0,?,?,?)", - (preset.name, preset.description, preset.key, _now(), _now()), - ) - gruppe = int(cursor.lastrowid or 0) - _write_permissions(conn, gruppe, tree.expand_preset(preset)) - else: - gruppe = int(zeile["id"]) - + gruppe = funktionsgruppe_anlegen(conn, tree, preset) conn.executemany( "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)", [