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 @@