From 9d5eac1db2ce8b8bddcc7d2bdc2720b08b027c08 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Sun, 13 Sep 2026 00:19:54 +0200 Subject: [PATCH] Version 2.8.7: drei Standardgruppen, Module abschaltbar --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/settings.py | 81 +++++++++++- apps/tesm/src/tesm/cli.py | 22 +++- apps/tesm/src/tesm/permissions.py | 81 ++---------- apps/tesm/src/tesm/schema.py | 120 ++++++++++++++++++ .../tesm/src/tesm/templates/tesm/license.html | 11 +- .../tesm/templates/tesm/settings_system.html | 30 ++++- 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 ++++++++++++-- 11 files changed, 425 insertions(+), 94 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index af24bd6..5f169df 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.6" +version = "2.8.7" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/settings.py b/apps/tesm/src/tesm/blueprints/settings.py index 7394e88..c76afac 100644 --- a/apps/tesm/src/tesm/blueprints/settings.py +++ b/apps/tesm/src/tesm/blueprints/settings.py @@ -18,6 +18,8 @@ from tesm_core.auth.service import client_ip from tesm_core.extension import core, db, transaction from tesm_core.licensing_client import LicenseError +import tesm_licensing as lic + bp = Blueprint("settings", __name__, url_prefix="/administration") @@ -46,6 +48,28 @@ def _license_hint(feature: str) -> str: return "" +def _module_rows(conn: Any) -> list[dict[str, Any]]: + """Die drei Module mit ihrem Zustand -- lizenziert, benutzt. + + Zwei verschiedene Fragen, die die Seite auseinanderhalten muss: was die + Lizenz erlaubt (dagegen hilft kein Schalter) und was dieses Haus benutzen + will (dafuer ist der Schalter da). + """ + manager = core().license + if manager is None: + return [] + state = manager.reload(conn) + return [ + { + "key": name, + "label": lic.MODULE_LABELS.get(name, name), + "licensed": name in state.modules, + "enabled": name not in state.modules_off, + } + for name in lic.ALL_MODULES + ] + + def _current_timezone() -> str: sysops = core().sysops if sysops is None: @@ -94,11 +118,16 @@ def system(): hostname=(sysops.read_command(["hostname"]).stdout.strip() if sysops else ""), timezone=_current_timezone(), 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 {}, license_hint=_license_hint("monitor_interval"), sysops_available=bool(sysops and sysops.available), + module_rows=_module_rows(conn), ) @@ -167,6 +196,56 @@ def system_save(): return redirect(url_for("settings.system")) +@bp.post("/system/modules") +@permission_required("settings_system.write") +def modules_save(): + """Module ein- und ausschalten. + + Abgeschaltet heisst wirklich abgeschaltet: der Punkt verschwindet aus der + Navigation, und die Routen antworten mit 404 -- dieselbe Sperre, die auch + eine fehlende Lizenz zieht. Daten bleiben unangetastet; wer das Modul + wieder einschaltet, findet alles vor wie zuvor. + """ + extension = core() + manager = extension.license + if manager is None: + flash("Diese Installation nutzt keine Lizenzierung.", "info") + return redirect(url_for("settings.system")) + + aus = [name for name in lic.ALL_MODULES if request.form.get(f"module_{name}") != "1"] + with transaction() as conn: + eingeschaltet, abgeschaltet = manager.module_abschalten( + conn, aus, actor=current_user.username + ) + if eingeschaltet or abgeschaltet: + teile = [] + if abgeschaltet: + teile.append( + "abgeschaltet: " + + ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(abgeschaltet)) + ) + if eingeschaltet: + teile.append( + "eingeschaltet: " + + ", ".join(lic.MODULE_LABELS.get(n, n) for n in sorted(eingeschaltet)) + ) + audit.log( + conn, + actor=current_user.username, + actor_id=current_user.id, + action="settings.modules_changed", + detail="; ".join(teile), + ip=client_ip(), + severity="notice", + ) + + if eingeschaltet or abgeschaltet: + flash("Module gespeichert.", "success") + else: + flash("Keine Änderungen.", "info") + return redirect(url_for("settings.system")) + + @bp.post("/system/hostname") @permission_required("settings_system.execute") def set_hostname(): diff --git a/apps/tesm/src/tesm/cli.py b/apps/tesm/src/tesm/cli.py index 5725ab4..c9fef01 100644 --- a/apps/tesm/src/tesm/cli.py +++ b/apps/tesm/src/tesm/cli.py @@ -69,13 +69,33 @@ def cmd_create_admin(args: argparse.Namespace) -> int: ) user_id = int(cursor.lastrowid or 0) action = "angelegt" + # Der Vollzugriff gehoert dazu. Seit 2.8.0 gibt das + # Administratorkennzeichen den Verwaltungsbereich und sonst + # nichts; Funktionsrechte kommen ueber Gruppen. Ein Konto, das + # jemand an der Kommandozeile anlegt, ist aber der Notausgang -- + # es entsteht, wenn sonst niemand mehr hineinkommt, und dann + # nuetzt eine Verwaltung ohne Geraete, Freigaben und Protokolle + # wenig. Wer das nicht will, nimmt die Gruppe in einem Klick + # wieder weg. + from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS + + gruppe = rbac.funktionsgruppe_anlegen( + conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF + ) + conn.execute( + "INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)", + (user_id, gruppe), + ) audit.log( conn, actor="cli", actor_id=user_id, action="user.admin_created", target=username, - detail=f"über die Kommandozeile {action}", + detail=( + f"über die Kommandozeile {action}, Gruppe " + f"{FUNKTIONEN_VOLLZUGRIFF.name} zugewiesen" + ), severity="warning", ) print(f"Administrator {username!r} {action}.") diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py index f602fcb..ff75ed9 100644 --- a/apps/tesm/src/tesm/permissions.py +++ b/apps/tesm/src/tesm/permissions.py @@ -388,6 +388,15 @@ FUNKTIONEN_VOLLZUGRIFF = RolePreset( ) +#: Die Gruppen, die eine frische Installation mitbringt -- drei, nicht sieben. +#: +#: Vorher standen hier zusaetzlich Betrieb, Support, Netzwerk und Revision. +#: Gut gemeint, aber sie beantworten eine Frage, die jeder Betrieb anders +#: beantwortet: welche Rolle es gibt, weiss der Kunde, nicht die Anwendung. +#: Was bleibt, sind die drei, ohne die man nicht anfangen kann -- eine +#: Standardgruppe fuer jeden neuen Nutzer, eine fuer die Verwaltung und eine, +#: die alle Funktionen buendelt. Weitere legt man in der Matrix an, und die +#: Matrix zeigt seit 2.8.0 je Funktion eine Zeile. ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="viewer", @@ -400,78 +409,6 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( ), is_default=True, ), - RolePreset( - key="operations", - name="Betrieb", - description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.", - grants=( - ("devices", "*"), - ("device_restart_poe", "*"), - ("device_restart_ssh", "*"), - ("device_restart_rpc", "*"), - ("device_console", "*"), - ("switches", (A.READ, A.CREATE, A.WRITE)), - ("switch_console", "*"), - ("hostkeys", "*"), - ("credentials", (A.READ,)), - ("servicing_update", "*"), - ("servicing_reboot", "*"), - ("servicing_tool", "*"), - ("logs_live", (A.READ,)), - ("logs_history", (A.READ,)), - ("trash", (A.READ, A.WRITE)), - ), - ), - RolePreset( - key="support", - name="Support", - description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.", - grants=( - ("devices", (A.READ,)), - # Nur der PoE-Weg: der wirkt auch bei einem haengenden Geraet und - # unterbricht nichts Laufendes. SSH und RPC fahren ein arbeitendes - # System herunter -- das gehoert nicht in die erste Hilfe. - ("device_restart_poe", "*"), - ("switches", (A.READ,)), - ("logs_live", (A.READ,)), - ), - ), - RolePreset( - key="network", - name="Netzwerk", - description="DHCP und Host-Netzwerkeinstellungen.", - grants=( - ("dhcp_subnets", "*"), - ("dhcp_options", "*"), - ("dhcp_reservations", "*"), - ("dhcp_config", "*"), - ("dhcp_service", "*"), - ("settings_network", "*"), - ("logs_dhcp", (A.READ,)), - ("devices", (A.READ,)), - ("switches", (A.READ,)), - ), - ), - RolePreset( - key="auditor", - name="Revision", - description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.", - grants=( - ("devices", (A.READ,)), - ("switches", (A.READ,)), - ("credentials", (A.READ,)), - ("dhcp_subnets", (A.READ,)), - ("dhcp_options", (A.READ,)), - ("dhcp_reservations", (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,)), - ("trash", (A.READ,)), - ), - ), RolePreset( key="administration", name="Administration", diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index fbf04ec..e94a7e6 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -23,6 +23,8 @@ Frische Zeitlinie ab Version 1 -- kein Nachbau der 35 gewachsenen from __future__ import annotations +from typing import Any + from tesm_core.migrations import Migration V1 = """ @@ -393,6 +395,123 @@ def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def] rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF) +#: Die vier Vorlagen, die es bis 2.8.6 gab und ab 2.8.7 nicht mehr. +#: +#: Stehen hier als eingefrorene Kopie, weil eine Migration ihre eigenen Daten +#: mitbringen muss: in ``permissions.py`` sind sie weg, und trotzdem muss +#: dieser Schritt noch erkennen koennen, ob eine vorgefundene Gruppe wirklich +#: unveraendert die ausgelieferte ist. +ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = ( + ( + "operations", + "Betrieb", + ( + ("devices", "*"), + ("device_restart_poe", "*"), + ("device_restart_ssh", "*"), + ("device_restart_rpc", "*"), + ("device_console", "*"), + ("switches", ("read", "create", "write")), + ("switch_console", "*"), + ("hostkeys", "*"), + ("credentials", ("read",)), + ("servicing_update", "*"), + ("servicing_reboot", "*"), + ("servicing_tool", "*"), + ("logs_live", ("read",)), + ("logs_history", ("read",)), + ("trash", ("read", "write")), + ), + ), + ( + "support", + "Support", + ( + ("devices", ("read",)), + ("device_restart_poe", "*"), + ("switches", ("read",)), + ("logs_live", ("read",)), + ), + ), + ( + "network", + "Netzwerk", + ( + ("dhcp_subnets", "*"), + ("dhcp_options", "*"), + ("dhcp_reservations", "*"), + ("dhcp_config", "*"), + ("dhcp_service", "*"), + ("settings_network", "*"), + ("logs_dhcp", ("read",)), + ("devices", ("read",)), + ("switches", ("read",)), + ), + ), + ( + "auditor", + "Revision", + ( + ("devices", ("read",)), + ("switches", ("read",)), + ("credentials", ("read",)), + ("dhcp_subnets", ("read",)), + ("dhcp_options", ("read",)), + ("dhcp_reservations", ("read",)), + ("users", ("read",)), + ("groups", ("read",)), + ("logs_live", ("read",)), + ("logs_history", ("read", "export")), + ("logs_audit", ("read", "export")), + ("diagnostics", ("read",)), + ("trash", ("read",)), + ), + ), +) + + +def eingefrorene_vorlagen( + beschreibung: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...], +) -> list[Any]: + """Baut aus der Kopie oben wieder ``RolePreset``-Objekte. + + Die Aktionen stehen dort als Text und nicht als ``Action``, damit diese + Migration auch dann noch lesbar bleibt, wenn das Enum sich weiterentwickelt. + """ + from tesm_core.rbac.model import Action, RolePreset + + vorlagen = [] + for key, name, grants in beschreibung: + gewandelt = tuple( + (ressource, aktionen if aktionen == "*" else tuple(Action(a) for a in aktionen)) + for ressource, aktionen in grants + ) + vorlagen.append(RolePreset(key=key, name=name, description="", grants=gewandelt)) + return vorlagen + + +def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def] + """Aus sieben Standardgruppen werden drei -- ohne jemandem etwas wegzunehmen. + + Was eine Anwendung an Rollen mitbringt, ist eine Vermutung darueber, wie + ein Betrieb organisiert ist. Sieben davon sind sechs zu viele: wer sie + nicht braucht, muss sie trotzdem jedes Mal ueberlesen, und in der + Rechtematrix stand fuer jede eine Spalte. Es bleiben "Benutzer", + "Administration" und "Funktionen (Vollzugriff)". + + Weggeraeumt wird nur, was unberuehrt ist -- keine Mitglieder, Rechte + unveraendert. Alles andere bleibt stehen, wo es steht. Und auch das + Weggeraeumte liegt nur im Papierkorb. + """ + from tesm_core.rbac import service as rbac + + from .permissions import PERMISSIONS + + rbac.unberuehrte_vorlagen_entfernen( + conn, PERMISSIONS, eingefrorene_vorlagen(ABGELEGTE_VORLAGEN) + ) + + TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="baseline", sql=V1), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), @@ -412,4 +531,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = ( ), Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen), Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe), + Migration(version=9, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen), ) diff --git a/apps/tesm/src/tesm/templates/tesm/license.html b/apps/tesm/src/tesm/templates/tesm/license.html index e4406b0..28bbcec 100644 --- a/apps/tesm/src/tesm/templates/tesm/license.html +++ b/apps/tesm/src/tesm/templates/tesm/license.html @@ -60,9 +60,18 @@
{% if state.modules %} {% for module in modules %} - {% if module in state.modules %}{{ pill(module_labels[module], 'online') }} + {% if module in state.modules and module in state.modules_off %} + {{ pill(module_labels[module] ~ ' (abgeschaltet)', 'warning') }} + {% elif module in state.modules %}{{ pill(module_labels[module], 'online') }} {% else %}{{ pill(module_labels[module], '') }}{% endif %} {% endfor %} + {% if state.modules_off %} +
+ Abgeschaltet wurde in + Verwaltung → System; + die Lizenz ist davon unberührt. +
+ {% endif %} {% else %}keine Zusatzmodule{% endif %}
Lizenzserver
diff --git a/apps/tesm/src/tesm/templates/tesm/settings_system.html b/apps/tesm/src/tesm/templates/tesm/settings_system.html index e5c776f..19c5f10 100644 --- a/apps/tesm/src/tesm/templates/tesm/settings_system.html +++ b/apps/tesm/src/tesm/templates/tesm/settings_system.html @@ -51,6 +51,34 @@ + {% if module_rows %} +
+
+
+
{{ icon('puzzle', size=17) }} Module
+
+ Die Lizenz sagt, was erlaubt ist – hier steht, was dieses Haus benutzt. + Ein abgeschaltetes Modul verschwindet aus der Navigation, und seine Seiten + antworten wie ohne Lizenz. Bestehende Daten bleiben unverändert stehen und + sind nach dem Wiedereinschalten wieder da. +
+
+
+
+ +
+ {% for modul in module_rows %} + {{ checkbox('module_' ~ modul.key, modul.label, checked=modul.enabled, + hint_text=('In der Lizenz enthalten.' if modul.licensed + else 'Nicht lizenziert – bleibt aus, bis die Lizenz es + freischaltet. Der Haken gilt dann sofort.')) }} + {% endfor %} +
+ +
+
+ {% endif %} +
@@ -189,7 +217,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()) }} {% 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 (?,?)", [