From 0d99760d891c03fc3e953a4f0920ae82b1018245 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Sat, 12 Sep 2026 13:27:45 +0200 Subject: [PATCH] Version 2.8.0: Rechtekonzept -- eine Zeile je Funktion, Administrator heisst Verwaltung --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/__init__.py | 6 +- apps/tesm/src/tesm/blueprints/backup.py | 16 +- apps/tesm/src/tesm/blueprints/credentials.py | 4 +- apps/tesm/src/tesm/blueprints/dashboard.py | 8 +- apps/tesm/src/tesm/blueprints/devices.py | 26 +- apps/tesm/src/tesm/blueprints/dhcp.py | 27 +- apps/tesm/src/tesm/blueprints/fileshare.py | 27 +- apps/tesm/src/tesm/blueprints/hostkeys.py | 18 +- apps/tesm/src/tesm/blueprints/license.py | 14 +- apps/tesm/src/tesm/blueprints/logs.py | 4 +- apps/tesm/src/tesm/blueprints/maintenance.py | 15 +- apps/tesm/src/tesm/blueprints/settings.py | 4 +- apps/tesm/src/tesm/blueprints/switches.py | 14 +- apps/tesm/src/tesm/blueprints/terminal.py | 10 +- apps/tesm/src/tesm/navigation.py | 50 +-- apps/tesm/src/tesm/permissions.py | 376 ++++++++++++++---- apps/tesm/src/tesm/schema.py | 53 ++- apps/tesm/src/tesm/services/restart.py | 53 ++- apps/tesm/src/tesm/services/transfer.py | 31 +- .../tesm/templates/tesm/_dashboard_tiles.html | 4 +- .../src/tesm/templates/tesm/credentials.html | 4 +- .../tesm/templates/tesm/device_detail.html | 14 +- .../tesm/src/tesm/templates/tesm/devices.html | 6 +- apps/tesm/src/tesm/templates/tesm/dhcp.html | 22 +- .../src/tesm/templates/tesm/fileshare.html | 18 +- .../templates/tesm/fileshare_mappings.html | 4 +- .../src/tesm/templates/tesm/hostkeys.html | 2 +- .../tesm/src/tesm/templates/tesm/license.html | 6 +- .../tesm/templates/tesm/logs_restarts.html | 2 +- .../src/tesm/templates/tesm/maintenance.html | 6 +- .../tesm/templates/tesm/settings_system.html | 2 +- .../tesm/templates/tesm/switch_detail.html | 10 +- .../src/tesm/templates/tesm/switches.html | 6 +- docs/SICHERHEIT.md | 5 + packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/auth/directory.py | 81 +++- .../tesm-core/src/tesm_core/auth/models.py | 33 +- .../tesm-core/src/tesm_core/core_schema.py | 146 ++++++- .../tesm-core/src/tesm_core/migrations.py | 44 +- .../tesm-core/src/tesm_core/rbac/common.py | 70 ++-- .../src/tesm_core/rbac/legacy_permissions.py | 171 ++++++++ .../tesm-core/src/tesm_core/rbac/model.py | 147 +++++-- .../tesm-core/src/tesm_core/rbac/service.py | 75 +++- .../static/tesm_core/css/components.css | 60 ++- .../src/tesm_core/static/tesm_core/js/app.js | 82 +++- .../templates/tesm_core/account.html | 11 +- .../templates/tesm_core/group_detail.html | 4 +- .../tesm_core/templates/tesm_core/macros.html | 74 +++- .../tesm_core/settings_directory.html | 31 +- .../templates/tesm_core/settings_network.html | 2 +- .../tesm_core/templates/tesm_core/trash.html | 2 +- .../tesm_core/templates/tesm_core/users.html | 20 +- packages/tesm-core/src/tesm_core/ui.py | 2 +- .../tesm-core/src/tesm_core/views/admin.py | 14 +- .../src/tesm_core/views/diagnostics.py | 2 +- .../src/tesm_core/views/hostsettings.py | 52 ++- .../tesm-core/src/tesm_core/views/logs.py | 12 +- .../tesm-core/src/tesm_core/views/trash.py | 4 +- 59 files changed, 1612 insertions(+), 398 deletions(-) create mode 100644 packages/tesm-core/src/tesm_core/rbac/legacy_permissions.py diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index 92ebfb6..d09fd02 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.7.8" +version = "2.8.0" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/__init__.py b/apps/tesm/src/tesm/__init__.py index fa649da..280e5e9 100644 --- a/apps/tesm/src/tesm/__init__.py +++ b/apps/tesm/src/tesm/__init__.py @@ -19,7 +19,7 @@ from tesm_core.extension import core from tesm_core.views import trash as trash_views from .navigation import NAVIGATION -from .permissions import PERMISSIONS, ROLE_PRESETS +from .permissions import DHCP_PAGE, PERMISSIONS, ROLE_PRESETS from .schema import TESM_MIGRATIONS from .settings_spec import TESM_SETTINGS @@ -205,7 +205,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]: # Timer bis zum naechsten Durchlauf, mit Knopf fuer "sofort pruefen". Der # Knopf gehoert in die Pille, weil er genau die Angabe daneben zuruecksetzt. - if user.has_any(("devices.view", "monitoring.view")): + if user.has_any(("devices.read", "monitoring.read")): intervall = int(extension.settings.get(verbindung, "monitor_interval_seconds") or 300) faellig = monitor.next_check_at(verbindung, intervall) if faellig is not None: @@ -237,7 +237,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]: } ) - if user.has_any(("dhcp.view", "dhcp.edit")) and extension.license is not None: + if user.has_any(DHCP_PAGE) and extension.license is not None: if extension.license.module_active("dhcp") and extension.sysops is not None: state = extension.sysops.service_state(kea.SERVICE_UNIT) if state["installed"] == "ja": diff --git a/apps/tesm/src/tesm/blueprints/backup.py b/apps/tesm/src/tesm/blueprints/backup.py index 4dcb02c..4d95857 100644 --- a/apps/tesm/src/tesm/blueprints/backup.py +++ b/apps/tesm/src/tesm/blueprints/backup.py @@ -37,14 +37,14 @@ def _allowed_categories() -> list[transfer.Category]: for category in transfer.CATEGORIES: if category.admin_only and not current_user.is_admin: continue - if category.sensitive and not current_user.has("backup.secrets"): + if category.sensitive and not current_user.has("backup_export.secrets"): continue allowed.append(category) return allowed @bp.get("/") -@permission_required("backup.view", "backup.export", "backup.execute", mode="any") +@permission_required("backup_export.read", "backup_import.read", mode="any") def overview(): extension = core() conn = db() @@ -72,12 +72,12 @@ def overview(): min_passphrase=transfer.MIN_PASSPHRASE_LENGTH, preview_ttl=transfer.PREVIEW_TTL_MINUTES, license_hint=license_hint, - can_secrets=current_user.has("backup.secrets"), + can_secrets=current_user.has("backup_export.secrets"), ) @bp.post("/export") -@permission_required("backup.export") +@permission_required("backup_export.export") # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS). def export(): extension = core() @@ -96,7 +96,7 @@ def export(): return redirect(url_for("backup.overview")) include_secrets = ( - request.form.get("include_secrets") == "1" and current_user.has("backup.secrets") + request.form.get("include_secrets") == "1" and current_user.has("backup_export.secrets") ) passphrase = request.form.get("passphrase") or "" repeat = request.form.get("passphrase_repeat") or "" @@ -130,7 +130,7 @@ def export(): @bp.post("/import/preview") -@permission_required("backup.execute") +@permission_required("backup_import.execute") @reauth_required def import_preview(): conn = db() @@ -162,7 +162,7 @@ def import_preview(): @bp.post("/import/apply") -@permission_required("backup.execute") +@permission_required("backup_import.execute") @reauth_required def import_apply(): extension = core() @@ -226,7 +226,7 @@ def import_apply(): @bp.post("/import/discard") -@permission_required("backup.execute") +@permission_required("backup_import.execute") def import_discard(): token = session.pop(PREVIEW_SESSION_KEY, "") if token: diff --git a/apps/tesm/src/tesm/blueprints/credentials.py b/apps/tesm/src/tesm/blueprints/credentials.py index 5286308..bcdf1b5 100644 --- a/apps/tesm/src/tesm/blueprints/credentials.py +++ b/apps/tesm/src/tesm/blueprints/credentials.py @@ -29,7 +29,7 @@ def _audit(conn: Any, action: str, target: str, detail: str = "", severity: str @bp.get("/") -@permission_required("credentials.view") +@permission_required("credentials.read") def overview(): conn = db() return render_template( @@ -62,7 +62,7 @@ def create(): @bp.post("/") -@permission_required("credentials.edit") +@permission_required("credentials.write") def update(credential_id: int): try: with transaction() as conn: diff --git a/apps/tesm/src/tesm/blueprints/dashboard.py b/apps/tesm/src/tesm/blueprints/dashboard.py index ba1e178..98ffe0c 100644 --- a/apps/tesm/src/tesm/blueprints/dashboard.py +++ b/apps/tesm/src/tesm/blueprints/dashboard.py @@ -43,7 +43,7 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: counts = monitor.dashboard_counts(conn) rows = inventory.list_devices(conn, only_active=not authenticated) - detailed = authenticated and current_user.has("devices.view") + detailed = authenticated and current_user.has("devices.read") tiles: list[dict[str, Any]] = [] for row in rows: entry: dict[str, Any] = { @@ -67,8 +67,12 @@ def _tiles(*, authenticated: bool) -> dict[str, Any]: "credential_category": str(row["credential_category"] or ""), # Nicht nur PoE: ein Geraet kann auch ueber SSH oder RPC # neu startbar sein. Die Kachel zeigt den bevorzugten Weg. + # Mit Rechtefilter: die Kachel zeigt nur Wege, die + # dieser Benutzer auch ausloesen darf. "restart_methods": restart.available( - row, maintenance_active=_maintenance_active() + row, + maintenance_active=_maintenance_active(), + darf=current_user.has, ), } ) diff --git a/apps/tesm/src/tesm/blueprints/devices.py b/apps/tesm/src/tesm/blueprints/devices.py index 6750705..756c134 100644 --- a/apps/tesm/src/tesm/blueprints/devices.py +++ b/apps/tesm/src/tesm/blueprints/devices.py @@ -60,7 +60,7 @@ def _form_values() -> dict[str, Any]: @bp.get("/") -@permission_required("devices.view") +@permission_required("devices.read") def overview(): conn = db() devices = inventory.list_devices(conn) @@ -138,7 +138,7 @@ def create(): @bp.post("/") -@permission_required("devices.edit") +@permission_required("devices.write") def update(device_id: int): try: with transaction() as conn: @@ -162,7 +162,7 @@ def update(device_id: int): @bp.post("//active") -@permission_required("devices.edit") +@permission_required("devices.write") def toggle_active(device_id: int): active = request.form.get("active") == "1" with transaction() as conn: @@ -194,7 +194,12 @@ def delete(device_id: int): @bp.post("//restart") -@permission_required("devices.execute") +# Die Seite darf sehen, wer Clients sehen darf; welcher Weg offensteht, +# entscheidet das Recht des Weges -- geprueft in restart.resolve() ueber +# "darf". Ein Weg, den der Benutzer nicht ausloesen darf, existiert fuer ihn +# nicht, und die Antwort ist dieselbe wie bei einem technisch unmoeglichen: +# "dieser Weg steht nicht zur Verfuegung". +@permission_required("devices.read") def restart_device(device_id: int): """Startet ein Geraet neu -- ueber den Weg, der dafuer gewaehlt wurde. @@ -214,9 +219,12 @@ def restart_device(device_id: int): dict(device), (request.form.get("method") or "").strip(), maintenance_active=maintenance_active, + darf=current_user.has, ) if method is None: - options = restart.available(dict(device), maintenance_active=maintenance_active) + options = restart.available( + dict(device), maintenance_active=maintenance_active, darf=current_user.has + ) if options: flash( "Der gewählte Weg steht für dieses Gerät nicht zur Verfügung. " @@ -326,7 +334,7 @@ def _restart_as_job(extension: Any, conn: Any, device: Any, method: Any): # typ @bp.get("/") -@permission_required("devices.view") +@permission_required("devices.read") def detail(device_id: int): conn = db() device = inventory.get_device(conn, device_id) @@ -361,7 +369,7 @@ def detail(device_id: int): @bp.post("//host-key/probe") -@permission_required("devices.edit") +@permission_required("devices.write") def probe_host_key(device_id: int): """Liest den Host-Schluessel des Clients, ohne sich anzumelden. @@ -391,7 +399,7 @@ def probe_host_key(device_id: int): @bp.post("//host-key/trust") -@permission_required("devices.edit") +@permission_required("devices.write") @reauth_required def trust_host_key(device_id: int): conn = db() @@ -425,7 +433,7 @@ def trust_host_key(device_id: int): @bp.post("//host-key/forget") -@permission_required("devices.edit") +@permission_required("devices.write") def forget_host_key(device_id: int): conn = db() device = inventory.get_device(conn, device_id) diff --git a/apps/tesm/src/tesm/blueprints/dhcp.py b/apps/tesm/src/tesm/blueprints/dhcp.py index 9bf09ba..fec558d 100644 --- a/apps/tesm/src/tesm/blueprints/dhcp.py +++ b/apps/tesm/src/tesm/blueprints/dhcp.py @@ -21,6 +21,7 @@ from tesm_core.db import Database from tesm_core.extension import core, db, transaction from ..services import kea +from ..permissions import DHCP_PAGE bp = Blueprint("dhcp", __name__, url_prefix="/dhcp") @@ -53,7 +54,7 @@ def _settings_values() -> dict[str, Any]: @bp.get("/") -@permission_required("dhcp.view", "dhcp.edit", "dhcp.execute", mode="any") +@permission_required(*DHCP_PAGE, mode="any") def overview(): extension = core() conn = db() @@ -106,7 +107,7 @@ def overview(): @bp.post("/settings") -@permission_required("dhcp.edit") +@permission_required("dhcp_subnets.write") def save_settings(): extension = core() conn = db() @@ -123,7 +124,7 @@ def save_settings(): @bp.post("/subnet") -@permission_required("dhcp.edit") +@permission_required("dhcp_subnets.create") def add_subnet(): extension = core() conn = db() @@ -159,7 +160,7 @@ def add_subnet(): @bp.post("/subnet/") -@permission_required("dhcp.edit") +@permission_required("dhcp_subnets.write") def update_subnet(subnet_id: int): extension = core() conn = db() @@ -217,7 +218,7 @@ def update_subnet(subnet_id: int): @bp.post("/subnet//delete") -@permission_required("dhcp.edit") +@permission_required("dhcp_subnets.delete") def delete_subnet(subnet_id: int): with transaction() as conn: conn.execute("DELETE FROM dhcp_subnets WHERE id=?", (subnet_id,)) @@ -232,7 +233,7 @@ def delete_subnet(subnet_id: int): @bp.post("/option/value") -@permission_required("dhcp.edit") +@permission_required("dhcp_options.write") def set_option_value(): conn = db() option_id = int(request.form.get("option_id") or 0) @@ -271,7 +272,7 @@ def _device_option_values(conn) -> dict[int, list[dict[str, Any]]]: # type: ign @bp.post("/option/device") -@permission_required("dhcp.edit") +@permission_required("dhcp_options.write") def set_device_option_value(): """Setzt eine Option fuer ein einzelnes Geraet. @@ -319,7 +320,7 @@ def set_device_option_value(): @bp.post("/option/new") -@permission_required("dhcp.edit") +@permission_required("dhcp_options.write") def add_custom_option(): conn = db() try: @@ -350,7 +351,7 @@ def add_custom_option(): @bp.post("/option//delete") -@permission_required("dhcp.edit") +@permission_required("dhcp_options.write") def delete_custom_option(option_id: int): conn = db() option = Database.one(conn, "SELECT * FROM dhcp_options WHERE id=?", (option_id,)) @@ -375,7 +376,7 @@ def delete_custom_option(option_id: int): @bp.post("/reservation") -@permission_required("dhcp.edit") +@permission_required("dhcp_reservations.create") def add_reservation(): conn = db() mac = kea.normalize_mac(request.form.get("mac", "")) @@ -447,7 +448,7 @@ def add_reservation(): @bp.post("/reservation//delete") -@permission_required("dhcp.edit") +@permission_required("dhcp_reservations.delete") def delete_reservation(reservation_id: int): with transaction() as conn: conn.execute("DELETE FROM dhcp_reservations WHERE id=?", (reservation_id,)) @@ -515,13 +516,13 @@ def _write_and_redirect(): @bp.post("/write") -@permission_required("dhcp.edit") +@permission_required("dhcp_config.execute") def write_config(): return _write_and_redirect() @bp.post("/service/") -@permission_required("dhcp.execute") +@permission_required("dhcp_service.execute") def service_action(action: str): allowed = {"install", "start", "stop"} if action not in allowed: diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py index 4c7e37f..034cf9e 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -84,7 +84,7 @@ def _mount_or_404(token: str) -> service.Mount: @bp.get("/") -@permission_required("fileshare.view") +@permission_required("fileshare.read") def browse(): conn = db() mounts = service.doppelte_zusammenfassen( @@ -148,7 +148,7 @@ def download(token: str): @bp.get("//preview") -@permission_required("fileshare.view") +@permission_required("fileshare.read") def preview(token: str): mount = _mount_or_404(token) relative = request.args.get("path") or "" @@ -221,7 +221,7 @@ def upload(token: str): @bp.get("//signature") -@permission_required("fileshare.view") +@permission_required("fileshare.read") def signature_state(token: str): """Passt die Datei noch zu ihrem Eintrag im Aenderungsprotokoll? @@ -237,7 +237,7 @@ def signature_state(token: str): @bp.get("//folders") -@permission_required("fileshare.view") +@permission_required("fileshare.read") def folders(token: str): """Die Ordner **dieser** Freigabe als Vorschlagsliste. @@ -299,7 +299,7 @@ def save_form(token: str): # Formularfelder, ist sie weiter veraenderbar -- dafuer braucht es # "PDF-Formulare: Bearbeiten". Ein umgebogenes Feld im Browser hilft damit # niemandem weiter. - if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.edit"): + if service.hat_formularfelder(daten) and not current_user.has("fileshare_pdf.write"): return { "error": "Sie dürfen ausgefüllte Formulare nur abgeschlossen speichern " "(Felder gesperrt). Bitte im Auswahlfeld 'Abgeschlossen' wählen." @@ -351,6 +351,11 @@ def save_form(token: str): wohin = ziel.relative_to(ziel_mount.path.resolve()).as_posix() pruefsumme = hashlib.sha256(daten).hexdigest() unterschriften = _unterschriften(request.form.get("unterschriften") or "") + # Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand + # mehr bearbeiten kann, mit Namen im Dokument und Pruefsumme im Protokoll. + # Wer Dateien ablegen darf, darf das nicht automatisch. + if unterschriften and not current_user.has("fileshare_signature.execute"): + return {"error": "Zum Unterschreiben fehlt die Berechtigung."}, 403 # Zwei Angaben, die man nicht verwechseln darf, und deshalb stehen beide # da: der Name **in** der Unterschrift ist eine Angabe des @@ -412,7 +417,7 @@ def make_directory(token: str): @bp.post("//rename") -@permission_required("fileshare.edit") +@permission_required("fileshare.write") def rename(token: str): mount = _mount_or_404(token) relative = request.form.get("path") or "" @@ -446,7 +451,7 @@ def delete(token: str): @bp.post("//release") -@permission_required("fileshare.view") +@permission_required("fileshare.read") def release(token: str): mount = _mount_or_404(token) with transaction() as conn: @@ -462,7 +467,7 @@ def release(token: str): @bp.get("/mappings") -@permission_required("fileshare.edit") +@permission_required("fileshare_mappings.read") def mappings(): from tesm_core.auth import directory @@ -476,7 +481,7 @@ def mappings(): @bp.post("/mappings") -@permission_required("fileshare.edit") +@permission_required("fileshare_mappings.create") def mapping_add(): try: with transaction() as conn: @@ -506,7 +511,7 @@ def mapping_add(): @bp.post("/mappings/") -@permission_required("fileshare.edit") +@permission_required("fileshare_mappings.write") def mapping_update(mapping_id: int): try: with transaction() as conn: @@ -536,7 +541,7 @@ def mapping_update(mapping_id: int): @bp.post("/mappings//delete") -@permission_required("fileshare.edit") +@permission_required("fileshare_mappings.delete") def mapping_delete(mapping_id: int): with transaction() as conn: entfernt = service.get_mapping(conn, mapping_id) diff --git a/apps/tesm/src/tesm/blueprints/hostkeys.py b/apps/tesm/src/tesm/blueprints/hostkeys.py index 48055f4..51cb3cb 100644 --- a/apps/tesm/src/tesm/blueprints/hostkeys.py +++ b/apps/tesm/src/tesm/blueprints/hostkeys.py @@ -36,8 +36,12 @@ bp = Blueprint("hostkeys", __name__, url_prefix="/monitoring/host-keys") KIND_DEVICE = "client" KIND_SWITCH = "switch" -#: Welches Recht welche Art von Ziel freigibt. -EDIT_RIGHT = {KIND_DEVICE: "devices.edit", KIND_SWITCH: "switches.edit"} +#: Freigeben ist eine eigene Zeile: es war das Recht fuer Stammdaten +#: ("Gerät ändern"), obwohl es der Vorgang ist, mit dem man bestaetigt, dass +#: ein Geraet wirklich das ist, fuer das es sich ausgibt. Die Art des Ziels +#: entscheidet nicht mehr mit -- Schluessel ist Schluessel. +FREIGABE_RECHT = "hostkeys.execute" +EDIT_RIGHT = {KIND_DEVICE: FREIGABE_RECHT, KIND_SWITCH: FREIGABE_RECHT} def _audit(action: str, target: str, detail: str = "", severity: str = "info") -> None: @@ -112,7 +116,7 @@ def _orphans(conn: Any, targets: list[dict[str, Any]]) -> list[dict[str, Any]]: @bp.get("/") -@permission_required("devices.view", "switches.view", mode="any") +@permission_required("hostkeys.read") def overview(): conn = db() targets = _targets(conn) @@ -143,7 +147,7 @@ def _require(kind: str) -> None: @bp.post("///probe") -@permission_required("devices.view", "switches.view", mode="any") +@permission_required("hostkeys.read") def probe(kind: str, target_id: int): conn = db() _require(kind) @@ -170,7 +174,7 @@ def probe(kind: str, target_id: int): @bp.post("///trust") -@permission_required("devices.view", "switches.view", mode="any") +@permission_required("hostkeys.read") @reauth_required def trust(kind: str, target_id: int): conn = db() @@ -204,7 +208,7 @@ def trust(kind: str, target_id: int): @bp.post("///forget") -@permission_required("devices.view", "switches.view", mode="any") +@permission_required("hostkeys.read") def forget(kind: str, target_id: int): conn = db() _require(kind) @@ -223,7 +227,7 @@ def forget(kind: str, target_id: int): @bp.post("/orphaned/forget") -@permission_required("devices.edit", "switches.edit", mode="any") +@permission_required("hostkeys.execute") def forget_orphan(): """Entfernt eine Freigabe, zu der es kein Ziel mehr gibt.""" host = (request.form.get("host") or "").strip() diff --git a/apps/tesm/src/tesm/blueprints/license.py b/apps/tesm/src/tesm/blueprints/license.py index fde0498..2e845d2 100644 --- a/apps/tesm/src/tesm/blueprints/license.py +++ b/apps/tesm/src/tesm/blueprints/license.py @@ -43,7 +43,7 @@ def _audit(action: str, target: str = "", detail: str = "", severity: str = "inf @bp.get("/") -@permission_required("settings_license.view") +@permission_required("settings_license.read") def overview(): extension = core() conn = db() @@ -70,7 +70,7 @@ def overview(): @bp.post("/install") -@permission_required("settings_license.edit") +@permission_required("settings_license.write") def upload(): extension = core() manager = extension.license @@ -111,13 +111,13 @@ def upload(): @bp.post("/activate") -@permission_required("settings_license.edit") +@permission_required("settings_license.write") def activate(): return _handshake("activate") @bp.post("/deactivate") -@permission_required("settings_license.edit") +@permission_required("settings_license.write") @reauth_required def deactivate(): """Gibt die Lizenz frei, damit sie auf einer anderen Maschine genutzt werden kann. @@ -129,7 +129,7 @@ def deactivate(): @bp.post("/heartbeat") -@permission_required("settings_license.edit") +@permission_required("settings_license.write") def heartbeat(): return _handshake("heartbeat") @@ -159,7 +159,7 @@ def _handshake(action: str): @bp.post("/offline-response") -@permission_required("settings_license.edit") +@permission_required("settings_license.write") def offline_response(): extension = core() manager = extension.license @@ -178,7 +178,7 @@ def offline_response(): @bp.post("/server-certificate") -@permission_required("settings_license.edit") +@permission_required("settings_license.write") def server_certificate(): """Hinterlegt das Zertifikat des Lizenzservers. diff --git a/apps/tesm/src/tesm/blueprints/logs.py b/apps/tesm/src/tesm/blueprints/logs.py index b837969..e70e574 100644 --- a/apps/tesm/src/tesm/blueprints/logs.py +++ b/apps/tesm/src/tesm/blueprints/logs.py @@ -19,7 +19,7 @@ MAX_TAIL_LINES = 2000 @bp.get("/dhcp") -@permission_required("logs_dhcp.view") +@permission_required("logs_dhcp.read") def dhcp(): extension = core() if extension.license is not None and not extension.license.module_active("dhcp"): @@ -39,7 +39,7 @@ def dhcp(): @bp.get("/restarts") -@permission_required("devices.view") +@permission_required("devices.read") def restarts(): """Alle Neustarts -- ueber den PoE-Port und per Befehl aus der Wartung. diff --git a/apps/tesm/src/tesm/blueprints/maintenance.py b/apps/tesm/src/tesm/blueprints/maintenance.py index 79afc82..4113edf 100644 --- a/apps/tesm/src/tesm/blueprints/maintenance.py +++ b/apps/tesm/src/tesm/blueprints/maintenance.py @@ -23,6 +23,7 @@ from tesm_core.auth.service import client_ip from tesm_core.db import Database from tesm_core.extension import core, db, transaction +from ..permissions import SERVICING_PAGE from ..services import inventory, maintenance as service, ssh, windowsops, monitor bp = Blueprint("maintenance", __name__, url_prefix="/maintenance") @@ -75,7 +76,7 @@ def _query_devices(conn) -> list[dict]: # type: ignore[no-untyped-def] @bp.get("/") -@permission_required("maintenance.view", "maintenance.execute", mode="any") +@permission_required(*SERVICING_PAGE, mode="any") def overview(): conn = db() return render_template( @@ -106,7 +107,7 @@ def _windows_tool_missing(conn) -> bool: # type: ignore[no-untyped-def] @bp.post("/install-package") -@permission_required("maintenance.execute") +@permission_required("servicing_tool.execute") def install_windows_tool(): """Installiert das Paket, das der Fehlermeldung nach fehlt. @@ -139,7 +140,7 @@ def install_windows_tool(): @bp.get("/status") -@permission_required("maintenance.view", "maintenance.execute", mode="any") +@permission_required(*SERVICING_PAGE, mode="any") def status(): """Zustand der Auftraege -- aus der Datenbank, nicht aus dem Prozessspeicher.""" conn = db() @@ -166,7 +167,7 @@ def status(): @bp.get("/job/") -@permission_required("maintenance.view", "maintenance.execute", mode="any") +@permission_required(*SERVICING_PAGE, mode="any") def job_detail(job_id: str): conn = db() job = service.get_job(conn, job_id) @@ -176,7 +177,7 @@ def job_detail(job_id: str): @bp.get("/job//output") -@permission_required("maintenance.view", "maintenance.execute", mode="any") +@permission_required(*SERVICING_PAGE, mode="any") @fragment def job_output(job_id: str): conn = db() @@ -277,14 +278,14 @@ def _start(kind: str) -> None: @bp.post("/update") -@permission_required("maintenance.execute") +@permission_required("servicing_update.execute") def start_update(): _start(service.JOB_KIND_UPDATE) return redirect(url_for("maintenance.overview")) @bp.post("/restart") -@permission_required("maintenance.execute") +@permission_required("servicing_reboot.execute") def start_reboot(): _start(service.JOB_KIND_REBOOT) return redirect(url_for("maintenance.overview")) diff --git a/apps/tesm/src/tesm/blueprints/settings.py b/apps/tesm/src/tesm/blueprints/settings.py index 2793e49..7394e88 100644 --- a/apps/tesm/src/tesm/blueprints/settings.py +++ b/apps/tesm/src/tesm/blueprints/settings.py @@ -65,7 +65,7 @@ def _timezone_choices() -> list[str]: @bp.get("/system") -@permission_required("settings_system.view") +@permission_required("settings_system.read") def system(): extension = core() conn = db() @@ -103,7 +103,7 @@ def system(): @bp.post("/system") -@permission_required("settings_system.edit") +@permission_required("settings_system.write") def system_save(): extension = core() conn = db() diff --git a/apps/tesm/src/tesm/blueprints/switches.py b/apps/tesm/src/tesm/blueprints/switches.py index 28a1407..c6ba12a 100644 --- a/apps/tesm/src/tesm/blueprints/switches.py +++ b/apps/tesm/src/tesm/blueprints/switches.py @@ -47,7 +47,7 @@ def _form_values() -> dict[str, Any]: @bp.get("/") -@permission_required("switches.view") +@permission_required("switches.read") def overview(): conn = db() return render_template( @@ -79,7 +79,7 @@ def create(): @bp.post("/") -@permission_required("switches.edit") +@permission_required("switches.write") def update(switch_id: int): try: with transaction() as conn: @@ -115,7 +115,7 @@ def delete(switch_id: int): @bp.get("/") -@permission_required("switches.view") +@permission_required("switches.read") def detail(switch_id: int): conn = db() switch = inventory.get_switch(conn, switch_id) @@ -152,7 +152,7 @@ def detail(switch_id: int): @bp.post("//host-key/probe") -@permission_required("switches.edit") +@permission_required("switches.write") def probe_host_key(switch_id: int): conn = db() switch = inventory.get_switch(conn, switch_id) @@ -177,7 +177,7 @@ def probe_host_key(switch_id: int): @bp.post("//host-key/trust") -@permission_required("switches.edit") +@permission_required("switches.write") @reauth_required def trust_host_key(switch_id: int): """Gibt einen Host-Schluessel ausdruecklich frei. @@ -216,7 +216,7 @@ def trust_host_key(switch_id: int): @bp.post("//host-key/forget") -@permission_required("switches.edit") +@permission_required("switches.write") def forget_host_key(switch_id: int): conn = db() switch = inventory.get_switch(conn, switch_id) @@ -235,7 +235,7 @@ def forget_host_key(switch_id: int): @bp.post("//command") -@permission_required("switches.execute") +@permission_required("switch_console.execute") @reauth_required def run_command(switch_id: int): """Fuehrt ein Kommando aus der Positivliste aus. diff --git a/apps/tesm/src/tesm/blueprints/terminal.py b/apps/tesm/src/tesm/blueprints/terminal.py index f111d70..26bceb3 100644 --- a/apps/tesm/src/tesm/blueprints/terminal.py +++ b/apps/tesm/src/tesm/blueprints/terminal.py @@ -12,7 +12,7 @@ Zwei Ziele, ein Ablauf: * **Switch** -- ``switches.execute``. Interaktive ArubaOS-Sitzung ueber :class:`~tesm.services.switchcli.SwitchCli`. -* **Client** -- ``maintenance.execute`` und das lizenzpflichtige Modul +* **Client** -- ``device_console.execute`` und das lizenzpflichtige Modul ``maintenance``. Ein Kommando je ``exec``-Kanal ueber :class:`~tesm.services.shellcli.ShellSession`. @@ -167,7 +167,7 @@ def _render_console(target: Target): # type: ignore[no-untyped-def] @bp.get("/monitoring/switches//console") -@permission_required("switches.execute") +@permission_required("switch_console.execute") @reauth_required def switch_console(switch_id: int): target = _load_target(db(), KIND_SWITCH, switch_id) @@ -177,7 +177,7 @@ def switch_console(switch_id: int): @bp.get("/monitoring/clients//console") -@permission_required("maintenance.execute") +@permission_required("device_console.execute") @reauth_required def device_console(device_id: int): if not _client_module_active(): @@ -225,7 +225,9 @@ def _handle_socket(ws, kind: str, target_id: int) -> None: # type: ignore[no-un # -- Berechtigungen: dieselben Pruefungen wie in der HTTP-Route. Ein # WebSocket umgeht Dekoratoren, also stehen sie hier ausdruecklich. - required = "switches.execute" if kind == KIND_SWITCH else "maintenance.execute" + required = ( + "switch_console.execute" if kind == KIND_SWITCH else "device_console.execute" + ) if not current_user.is_authenticated or not current_user.has(required): _send(ws, "error", "Keine Berechtigung für diese Konsole.") return diff --git a/apps/tesm/src/tesm/navigation.py b/apps/tesm/src/tesm/navigation.py index 4fc847e..f44fa31 100644 --- a/apps/tesm/src/tesm/navigation.py +++ b/apps/tesm/src/tesm/navigation.py @@ -13,6 +13,8 @@ from typing import Any from tesm_core.extension import core from tesm_core.nav import NavItem, NavTree +from .permissions import DHCP_PAGE, SERVICING_PAGE + def _module(name: str): """Bedingung: Vollmodul lizenziert?""" @@ -36,7 +38,7 @@ def _has_fileshare_mounts(user: Any) -> bool: return False if not _module("fileshare")(user): return False - if user.has("fileshare.edit"): + if user.has("fileshare.write"): return True from .services import fileshare @@ -56,28 +58,28 @@ NAVIGATION = NavTree( label="Clients", icon="cpu", endpoint="devices.overview", - permissions=("devices.view",), + permissions=("devices.read",), ), NavItem( key="switches", label="Switche", icon="switch", endpoint="switches.overview", - permissions=("switches.view",), + permissions=("switches.read",), ), NavItem( key="credentials", label="Zugangsdaten", icon="key", endpoint="credentials.overview", - permissions=("credentials.view",), + permissions=("credentials.read",), ), NavItem( key="hostkeys", label="Host-Schlüssel", icon="shield", endpoint="hostkeys.overview", - permissions=("devices.view", "switches.view"), + permissions=("devices.read", "switches.read"), ), ), ), @@ -86,7 +88,7 @@ NAVIGATION = NavTree( label="Wartung", icon="wrench", endpoint="maintenance.overview", - permissions=("maintenance.view", "maintenance.execute"), + permissions=SERVICING_PAGE, condition=_module("maintenance"), ), NavItem( @@ -94,7 +96,7 @@ NAVIGATION = NavTree( label="DHCP-Server", icon="network", endpoint="dhcp.overview", - permissions=("dhcp.view", "dhcp.edit", "dhcp.execute"), + permissions=DHCP_PAGE, condition=_module("dhcp"), ), NavItem( @@ -108,14 +110,14 @@ NAVIGATION = NavTree( label="Meine Freigaben", icon="folder", endpoint="fileshare.browse", - permissions=("fileshare.view",), + permissions=("fileshare.read",), ), NavItem( key="fileshare_mappings", label="Zuordnungen", icon="link", endpoint="fileshare.mappings", - permissions=("fileshare.edit",), + permissions=("fileshare.write",), ), ), ), @@ -129,28 +131,28 @@ NAVIGATION = NavTree( label="Laufend", icon="terminal", endpoint="logs.live", - permissions=("logs_live.view",), + permissions=("logs_live.read",), ), NavItem( key="logs_history", label="Verlauf", icon="clock", endpoint="logs.history", - permissions=("logs_history.view", "logs_history.export"), + permissions=("logs_history.read", "logs_history.export"), ), NavItem( key="logs_audit", label="Audit", icon="history", endpoint="logs.audit_view", - permissions=("logs_audit.view", "logs_audit.export"), + permissions=("logs_audit.read", "logs_audit.export"), ), NavItem( key="logs_dhcp", label="DHCP", icon="network", endpoint="tesmlogs.dhcp", - permissions=("logs_dhcp.view",), + permissions=("logs_dhcp.read",), condition=_module("dhcp"), ), NavItem( @@ -158,7 +160,7 @@ NAVIGATION = NavTree( label="Neustarts", icon="power", endpoint="tesmlogs.restarts", - permissions=("devices.view",), + permissions=("devices.read",), ), ), ), @@ -172,42 +174,42 @@ NAVIGATION = NavTree( label="Benutzer", icon="users", endpoint="admin.users", - permissions=("users.view",), + permissions=("users.read",), ), NavItem( key="groups", label="Gruppen und Rechte", icon="shield-users", endpoint="admin.groups", - permissions=("groups.view",), + permissions=("groups.read",), ), NavItem( key="settings_system", label="System", icon="sliders", endpoint="settings.system", - permissions=("settings_system.view",), + permissions=("settings_system.read",), ), NavItem( key="settings_network", label="Netzwerk", icon="network", endpoint="hostsettings.network", - permissions=("settings_network.view",), + permissions=("settings_network.read",), ), NavItem( key="settings_web", label="Webserver und TLS", icon="server", endpoint="hostsettings.web", - permissions=("settings_web.view",), + permissions=("settings_web.read",), ), NavItem( key="settings_directory", label="Verzeichnisdienst", icon="users", endpoint="hostsettings.directory_settings", - permissions=("settings_directory.view",), + permissions=("settings_directory.read",), ), NavItem( key="settings_license", @@ -216,28 +218,28 @@ NAVIGATION = NavTree( endpoint="license.overview", # Bewusst immer erreichbar, auch ohne gueltige Lizenz -- # sonst liesse sich eine neue Lizenz nie einspielen. - permissions=("settings_license.view",), + permissions=("settings_license.read",), ), NavItem( key="backup", label="Sicherung", icon="transfer", endpoint="backup.overview", - permissions=("backup.view", "backup.export", "backup.execute"), + permissions=("backup_export.read", "backup_import.read"), ), NavItem( key="trash", label="Papierkorb", icon="trash", endpoint="trash.overview", - permissions=("trash.view",), + permissions=("trash.read",), ), NavItem( key="diagnostics", label="Diagnose", icon="info", endpoint="diagnostics.overview", - permissions=("diagnostics.view",), + permissions=("diagnostics.read",), ), ), ), diff --git a/apps/tesm/src/tesm/permissions.py b/apps/tesm/src/tesm/permissions.py index bf3bcd0..f602fcb 100644 --- a/apps/tesm/src/tesm/permissions.py +++ b/apps/tesm/src/tesm/permissions.py @@ -30,67 +30,219 @@ A = Action DEVICES = Resource( key="devices", label="Clients", - actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), - hint="Überwachte PoE-Endgeräte", + actions=(A.READ, A.CREATE, A.WRITE, A.DELETE), + hint="Stammdaten überwachter PoE-Endgeräte: Name, MAC, IP, Switch und Port", action_hints={ - A.EXECUTE: "PoE-Neustart eines Geräts manuell auslösen. Der automatische " - "Neustart bei Ausfall ist davon unabhängig und läuft immer.", A.DELETE: "Gerät in den Papierkorb legen.", }, ) +#: Drei Wege, drei Rechte -- bisher hingen alle an ``devices.execute``. +#: +#: Wer jemandem den PoE-Neustart erlauben wollte (der Weg, der auch bei einem +#: haengenden Geraet noch wirkt und nichts Laufendes unterbricht), erlaubte ihm +#: damit zugleich, ein arbeitendes System ueber SSH herunterzufahren. Das sind +#: drei verschiedene Eingriffe mit drei verschiedenen Folgen -- also drei +#: Zeilen. Die Reihenfolge ist die der Rangfolge in ``services.restart``. +DEVICE_RESTART_POE = Resource( + key="device_restart_poe", + parent="devices", + label="Clients: Neustart über PoE", + actions=(A.READ, A.EXECUTE), + hint="Portzyklus am Switch – wirkt in Sekunden, auch wenn das Gerät hängt", + action_hints={ + A.READ: "Den Knopf sehen.", + A.EXECUTE: "Neustart auslösen. Bewusst nicht lizenzpflichtig: ein hängendes " + "Gerät soll man auch ohne gültige Lizenz von Hand neu starten können.", + }, +) + +DEVICE_RESTART_SSH = Resource( + key="device_restart_ssh", + parent="devices", + label="Clients: Neustart über SSH", + actions=(A.READ, A.EXECUTE), + hint="Sauberes Herunterfahren eines Linux-Geräts (Modul Wartung)", + action_hints={ + A.EXECUTE: "Neustartauftrag anlegen. Dauert Minuten und setzt ein " + "antwortendes Gerät samt freigegebenem Host-Schlüssel voraus.", + }, +) + +DEVICE_RESTART_RPC = Resource( + key="device_restart_rpc", + parent="devices", + label="Clients: Neustart über RPC", + actions=(A.READ, A.EXECUTE), + hint="Windows-Gerät ohne SSH und ohne Host-Schlüssel (Modul Wartung)", + action_hints={A.EXECUTE: "Neustartauftrag anlegen (wie 'shutdown /m')."}, +) + +#: Die Client-Konsole lief unter ``maintenance.execute`` mit -- demselben Recht +#: wie die Paketaktualisierung. Eine Sitzung auf einem Geraet ist aber etwas +#: anderes als ein Auftrag, den der Server abarbeitet: hier tippt ein Mensch. +DEVICE_CONSOLE = Resource( + key="device_console", + parent="devices", + label="Clients: Konsole", + actions=(A.READ, A.EXECUTE), + hint="Browser-Terminal auf einem Client (Modul Wartung)", + action_hints={ + A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; " + "jede Sitzung wird vollständig mitgeschrieben.", + }, +) + SWITCHES = Resource( key="switches", label="Switche", - actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE), - hint="Aruba-Switche und ihre Erreichbarkeit", + actions=(A.READ, A.CREATE, A.WRITE, A.DELETE), + hint="Aruba-Switche, ihre Erreichbarkeit und Ports", +) + +SWITCH_CONSOLE = Resource( + key="switch_console", + parent="switches", + label="Switche: Konsole", + actions=(A.READ, A.EXECUTE), + hint="Kommandos auf dem Switch ausführen", action_hints={ - A.EXECUTE: "Browser-Terminal öffnen und Kommandos auf dem Switch ausführen. " - "Verlangt zusätzlich eine frische Passwortbestätigung; jede Sitzung wird " - "vollständig protokolliert.", + A.EXECUTE: "Sitzung öffnen. Verlangt eine frische Passwortbestätigung; " + "jede Sitzung wird vollständig mitgeschrieben.", + }, +) + +#: Einen geaenderten Host-Schluessel freizugeben hing an "Gerät ändern" bzw. +#: "Switch ändern" -- also an einem Recht fuer Stammdaten. Es ist aber der +#: Vorgang, mit dem man sagt: "dieses Geraet ist wirklich das, fuer das es sich +#: ausgibt". Wer Namen und Standort pflegt, muss das nicht entscheiden duerfen. +HOSTKEYS = Resource( + key="hostkeys", + label="Host-Schlüssel", + actions=(A.READ, A.EXECUTE), + hint="Ein Gerät meldet sich mit einem anderen SSH-Schlüssel als beim letzten Mal", + action_hints={ + A.READ: "Offene und freigegebene Schlüssel sehen.", + A.EXECUTE: "Einen geänderten Schlüssel freigeben.", }, ) CREDENTIALS = Resource( key="credentials", label="Zugangsdaten", - actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.SECRETS), + actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.SECRETS), hint="Wiederverwendbare SSH-Logins", action_hints={ - A.VIEW: "Namen und Benutzer sehen – nicht das Passwort.", + A.READ: "Namen und Benutzer sehen – nicht das Passwort.", A.SECRETS: "Passwort im Klartext anzeigen. Verlangt eine frische " "Passwortbestätigung und wird protokolliert.", }, ) -MAINTENANCE = Resource( - key="maintenance", - # Nicht "Wartung": so heisst der Bereich. Die Zeile sagt, was sie erlaubt. - label="Aktualisierung und Neustart", - actions=(A.VIEW, A.EXECUTE), - hint="Paketaktualisierung und Neustart per SSH (lizenzpflichtiges Modul)", - action_hints={A.EXECUTE: "Aktualisierung oder Neustart auf ausgewählten Geräten starten."}, +#: Aus einer Zeile ("Aktualisierung und Neustart") wurden drei. Sie deckte +#: vier Funktionen ab: Pakete aktualisieren, reihenweise neu starten, das +#: Windows-Werkzeug installieren -- und die Client-Konsole, die jetzt bei den +#: Clients steht, wo sie hingehoert. +SERVICING_UPDATE = Resource( + key="servicing_update", + label="Paketaktualisierung", + actions=(A.READ, A.EXECUTE), + hint="Pakete auf ausgewählten Geräten aktualisieren (lizenzpflichtiges Modul)", + action_hints={A.EXECUTE: "Auftrag starten."}, ) -DHCP = Resource( - key="dhcp", - label="DHCP-Server", - actions=(A.VIEW, A.EDIT, A.EXECUTE), - hint="DHCP: Subnetze, Optionen, Reservierungen (lizenzpflichtiges Modul)", +SERVICING_REBOOT = Resource( + key="servicing_reboot", + label="Neustart als Auftrag", + actions=(A.READ, A.EXECUTE), + hint="Reihenweise Neustarts mit Wartezeit und Rückmeldung", + action_hints={A.EXECUTE: "Auftrag starten."}, +) + +SERVICING_TOOL = Resource( + key="servicing_tool", + label="Windows-Werkzeug installieren", + actions=(A.READ, A.EXECUTE), + hint="Voraussetzung für den RPC-Weg auf einem Windows-Gerät", + action_hints={A.EXECUTE: "Installation auslösen."}, +) + +#: Die Wartungsseite zeigt alle drei Aufgaben. Sie oeffnet, wer **eine** davon +#: sehen darf -- und zeigt dann nur die. Als Konstante, damit die vier Routen +#: dieselbe Antwort geben; einzeln getippt liefen sie irgendwann auseinander. +SERVICING_PAGE: tuple[str, ...] = ( + "servicing_update.read", + "servicing_reboot.read", + "servicing_tool.read", +) + +#: Elf Funktionen hingen an ``dhcp.edit``: Grundeinstellungen, Subnetze, +#: Optionen, Geraeteoptionen, eigene Optionen, Reservierungen und das +#: Schreiben der Dienstkonfiguration. Wer eine Reservierung eintragen sollte, +#: durfte damit auch Subnetze loeschen und die Konfiguration ueberschreiben. +DHCP_SUBNETS = Resource( + key="dhcp_subnets", + label="DHCP: Subnetze", + actions=(A.READ, A.CREATE, A.WRITE, A.DELETE), + hint="Bereiche, Gateway, DNS, Reihenfolge (lizenzpflichtiges Modul)", +) + +DHCP_OPTIONS = Resource( + key="dhcp_options", + parent="dhcp_subnets", + label="DHCP: Optionen", + actions=(A.READ, A.WRITE), + hint="Globale Optionen, Optionen je Gerät und eigene Optionen", + action_hints={A.WRITE: "Werte setzen, eigene Optionen anlegen und entfernen."}, +) + +DHCP_RESERVATIONS = Resource( + key="dhcp_reservations", + parent="dhcp_subnets", + label="DHCP: Reservierungen", + actions=(A.READ, A.CREATE, A.WRITE, A.DELETE), + hint="Feste Adressen zu einer MAC", +) + +DHCP_CONFIG = Resource( + key="dhcp_config", + parent="dhcp_subnets", + label="DHCP: Konfiguration schreiben", + actions=(A.READ, A.EXECUTE), + hint="Den bearbeiteten Stand in die Dienstkonfiguration übernehmen", action_hints={ - A.EDIT: "Konfiguration ändern und schreiben.", - A.EXECUTE: "Dienst installieren, starten oder stoppen.", + A.EXECUTE: "Schreiben und neu laden. Bis dahin ändert sich am laufenden " + "Dienst nichts – Bearbeiten und Wirksamwerden sind zwei Schritte.", }, ) +DHCP_SERVICE = Resource( + key="dhcp_service", + parent="dhcp_subnets", + label="DHCP: Dienst", + actions=(A.READ, A.EXECUTE), + hint="Kea installieren, starten, stoppen", + action_hints={A.EXECUTE: "Dienstzustand ändern."}, +) + +#: Die DHCP-Seite zeigt alle Abschnitte; jeder Abschnitt haengt an seiner +#: eigenen Zeile. Geoeffnet wird sie, wer einen davon sehen darf. +DHCP_PAGE: tuple[str, ...] = ( + "dhcp_subnets.read", + "dhcp_options.read", + "dhcp_reservations.read", + "dhcp_config.read", + "dhcp_service.read", +) + FILESHARE = Resource( key="fileshare", label="Dateifreigaben", - actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXPORT), + actions=(A.READ, A.CREATE, A.WRITE, A.DELETE, A.EXPORT), hint="SMB-Freigaben aus dem Verzeichnisdienst (lizenzpflichtiges Modul)", action_hints={ A.CREATE: "Ordner anlegen und Dateien hochladen.", - A.EDIT: "Umbenennen und verschieben.", + A.WRITE: "Umbenennen und verschieben.", # Genau die Bedeutung aus der Legende: Daten aus dem System # heraustragen. Ansehen und Mitnehmen sind zweierlei -- eine Vorschau # bleibt im Haus, eine heruntergeladene Datei nicht. @@ -109,20 +261,47 @@ FILESHARE = Resource( #: soll deshalb nicht automatisch veraenderbare Formulare ablegen duerfen. FILESHARE_PDF = Resource( key="fileshare_pdf", + parent="fileshare", label="PDF-Formulare", - actions=(A.VIEW, A.CREATE, A.EDIT), + actions=(A.READ, A.CREATE, A.WRITE), hint="Formulare in der Vorschau ausfüllen und in der Freigabe ablegen", action_hints={ - A.VIEW: "In der Vorschau ausfüllen (ohne Speichern).", + A.READ: "In der Vorschau ausfüllen (ohne Speichern).", A.CREATE: "Ausgefüllt speichern – abgeschlossen, Felder gesperrt.", - A.EDIT: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.", + A.WRITE: "Ausgefüllt speichern – weiter ausfüllbar, inklusive Bild.", }, ) +#: Unterschreiben ist kein Aendern: am Ende steht eine Fassung, die niemand +#: mehr bearbeiten kann, mit Namen und Zeitpunkt im Dokument und einer +#: Pruefsumme im Aenderungsprotokoll. Wer Dateien umbenennen darf, soll das +#: nicht automatisch duerfen. +FILESHARE_SIGNATURE = Resource( + key="fileshare_signature", + parent="fileshare", + label="Unterschriften", + actions=(A.READ, A.EXECUTE), + hint="Mit dem Finger unterschreiben; das Ergebnis ist unveränderbar", + action_hints={ + A.READ: "Unterschriebene Dateien öffnen und die Prüfung sehen.", + A.EXECUTE: "Unterschreiben und abgeschlossen ablegen.", + }, +) + +#: Welche Verzeichnisfreigabe unter welchem Namen erscheint, ist Administration +#: und kein Dateibetrieb -- es lief unter demselben Recht wie "umbenennen". +FILESHARE_MAPPINGS = Resource( + key="fileshare_mappings", + parent="fileshare", + label="Freigabe-Zuordnungen", + actions=(A.READ, A.CREATE, A.WRITE, A.DELETE), + hint="Verzeichnisgruppe, UNC-Pfad und Anzeigename einer Freigabe", +) + LOGS_DHCP = Resource( key="logs_dhcp", label="DHCP-Protokoll", - actions=(A.VIEW,), + actions=(A.READ,), ) MONITORING_AREA = Area( @@ -131,7 +310,17 @@ MONITORING_AREA = Area( icon="cpu", hint="Geräte, Switche und Zugangsdaten. Ohne diesen Zugang bleibt der " "gesamte Bereich verborgen.", - resources=(DEVICES, SWITCHES, CREDENTIALS), + resources=( + DEVICES, + DEVICE_RESTART_POE, + DEVICE_RESTART_SSH, + DEVICE_RESTART_RPC, + DEVICE_CONSOLE, + SWITCHES, + SWITCH_CONSOLE, + HOSTKEYS, + CREDENTIALS, + ), ) #: Wartung ueberwacht nichts, sie greift ein -- und sie steht seit 2.5.0 als @@ -144,21 +333,21 @@ MAINTENANCE_AREA = Area( icon="wrench", hint="Paketpflege und Neustarts. Ohne diesen Zugang bleibt der Bereich " "verborgen – auch wenn einzelne Rechte darunter gesetzt sind.", - resources=(MAINTENANCE,), + resources=(SERVICING_UPDATE, SERVICING_REBOOT, SERVICING_TOOL), ) NETWORK_AREA = Area( key="network", label="Netzwerkdienste", icon="network", - resources=(DHCP,), + resources=(DHCP_SUBNETS, DHCP_OPTIONS, DHCP_RESERVATIONS, DHCP_CONFIG, DHCP_SERVICE), ) FILES_AREA = Area( key="files", label="Dateifreigaben", icon="folder", - resources=(FILESHARE, FILESHARE_PDF), + resources=(FILESHARE, FILESHARE_PDF, FILESHARE_SIGNATURE, FILESHARE_MAPPINGS), ) PERMISSIONS = PermissionTree( @@ -173,15 +362,41 @@ PERMISSIONS = PermissionTree( ) +#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe. +#: +#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht +#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese +#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration +#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt, +#: was vorher ging. Entfernen kann man sie mit einem Klick. +#: +#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der +#: Matrix ist sonst genau die, die hier vergessen wird. +FUNKTIONEN_VOLLZUGRIFF = RolePreset( + key="functions", + name="Funktionen (Vollzugriff)", + description=( + "Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, " + "die nicht nur einstellen, sondern auch bedienen." + ), + grants=tuple( + (resource.key, "*") + for area in PERMISSIONS.areas + if area.key != PermissionTree.VERWALTUNGSBEREICH + for resource in area.resources + ), +) + + ROLE_PRESETS: tuple[RolePreset, ...] = ( RolePreset( key="viewer", name="Benutzer", description="Standardgruppe: Geräte und Switche ansehen, laufendes Protokoll lesen.", grants=( - ("devices", (A.VIEW,)), - ("switches", (A.VIEW,)), - ("logs_live", (A.VIEW,)), + ("devices", (A.READ,)), + ("switches", (A.READ,)), + ("logs_live", (A.READ,)), ), is_default=True, ), @@ -191,12 +406,20 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( description="Geräte und Switche pflegen, Neustarts auslösen, Wartung fahren.", grants=( ("devices", "*"), - ("switches", (A.VIEW, A.CREATE, A.EDIT, A.EXECUTE)), - ("credentials", (A.VIEW,)), - ("maintenance", "*"), - ("logs_live", (A.VIEW,)), - ("logs_history", (A.VIEW,)), - ("trash", (A.VIEW, A.EDIT)), + ("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( @@ -204,9 +427,13 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( name="Support", description="Erste Hilfe am Telefon: Status sehen, PoE-Neustart auslösen.", grants=( - ("devices", (A.VIEW, A.EXECUTE)), - ("switches", (A.VIEW,)), - ("logs_live", (A.VIEW,)), + ("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( @@ -214,11 +441,15 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( name="Netzwerk", description="DHCP und Host-Netzwerkeinstellungen.", grants=( - ("dhcp", "*"), + ("dhcp_subnets", "*"), + ("dhcp_options", "*"), + ("dhcp_reservations", "*"), + ("dhcp_config", "*"), + ("dhcp_service", "*"), ("settings_network", "*"), - ("logs_dhcp", (A.VIEW,)), - ("devices", (A.VIEW,)), - ("switches", (A.VIEW,)), + ("logs_dhcp", (A.READ,)), + ("devices", (A.READ,)), + ("switches", (A.READ,)), ), ), RolePreset( @@ -226,17 +457,19 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( name="Revision", description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.", grants=( - ("devices", (A.VIEW,)), - ("switches", (A.VIEW,)), - ("credentials", (A.VIEW,)), - ("dhcp", (A.VIEW,)), - ("users", (A.VIEW,)), - ("groups", (A.VIEW,)), - ("logs_live", (A.VIEW,)), - ("logs_history", (A.VIEW, A.EXPORT)), - ("logs_audit", (A.VIEW, A.EXPORT)), - ("diagnostics", (A.VIEW,)), - ("trash", (A.VIEW,)), + ("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( @@ -247,16 +480,25 @@ ROLE_PRESETS: tuple[RolePreset, ...] = ( ("users", "*"), ("groups", "*"), ("settings_system", "*"), + ("settings_nav", "*"), ("settings_license", "*"), - ("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)), + ("settings_directory", (A.READ, A.WRITE, A.EXECUTE)), ("settings_web", "*"), ("settings_network", "*"), - ("backup", (A.VIEW, A.EXECUTE, A.EXPORT)), + ("backup_export", (A.READ, A.EXPORT)), + ("backup_import", "*"), ("trash", "*"), - ("diagnostics", (A.VIEW,)), - ("logs_audit", (A.VIEW,)), + ("diagnostics", (A.READ,)), + ("logs_audit", (A.READ,)), ), ), + FUNKTIONEN_VOLLZUGRIFF, ) -__all__ = ["PERMISSIONS", "ROLE_PRESETS", "LOGS_DHCP"] +__all__ = [ + "PERMISSIONS", + "ROLE_PRESETS", + "LOGS_DHCP", + "SERVICING_PAGE", + "DHCP_PAGE", +] diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index d5b7396..fbf04ec 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -257,19 +257,19 @@ CREATE INDEX IF NOT EXISTS idx_restart_events_method ON restart_events(method, t V4 = """ INSERT OR IGNORE INTO group_permissions (group_id, permission) SELECT group_id, 'fileshare.export' FROM group_permissions - WHERE permission = 'fileshare.view'; + WHERE permission = 'fileshare.read'; INSERT OR IGNORE INTO group_permissions (group_id, permission) - SELECT group_id, 'fileshare_pdf.view' FROM group_permissions - WHERE permission = 'fileshare.view'; + SELECT group_id, 'fileshare_pdf.read' FROM group_permissions + WHERE permission = 'fileshare.read'; INSERT OR IGNORE INTO group_permissions (group_id, permission) SELECT group_id, 'fileshare_pdf.create' FROM group_permissions WHERE permission = 'fileshare.create'; INSERT OR IGNORE INTO group_permissions (group_id, permission) - SELECT group_id, 'fileshare_pdf.edit' FROM group_permissions - WHERE permission = 'fileshare.edit'; + SELECT group_id, 'fileshare_pdf.write' FROM group_permissions + WHERE permission = 'fileshare.write'; """ @@ -297,13 +297,21 @@ DELETE FROM settings WHERE key = 'terminal_enabled'; # Bewusst nicht "alle, die ein Wartungsrecht haben": wer den Bereich # Ueberwachung nicht hatte, kam auch vorher nicht an die Wartung. Ein Update # darf Zugang erhalten, aber keinen neuen schaffen. +# +# Die Bedingung nennt absichtlich keine festen Schluessel, sondern Praefixe: +# Wer von einer Fassung vor diesem Schritt direkt auf 2.8 geht, hat vorher die +# Migrationen des Kerns durchlaufen (core laeuft vor der Anwendung) -- die +# Rechte heissen dann schon "read"/"write" und die Wartungszeilen "servicing_". +# Mit ausgeschriebenen Schluesseln fand dieser Schritt in genau diesem Fall +# nichts und der Bereich waere doch weg. Ein Ueberrest ist die Zeile nicht: +# eine angewendete Migration laeuft nie wieder. V6 = """ INSERT OR IGNORE INTO group_permissions (group_id, permission) - SELECT DISTINCT group_id, 'servicing.view' + SELECT DISTINCT group_id, 'servicing.read' FROM group_permissions - WHERE permission IN ('maintenance.view', 'maintenance.execute') + WHERE (permission LIKE 'maintenance.%' OR permission LIKE 'servicing%') AND group_id IN ( - SELECT group_id FROM group_permissions WHERE permission = 'monitoring.view' + SELECT group_id FROM group_permissions WHERE permission = 'monitoring.read' ); """ @@ -371,12 +379,37 @@ def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def] ) +def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def] + """Bestehende Administratoren behalten ihre Funktionen -- ueber eine Gruppe. + + Steht hier und nicht im Kern, weil erst die Anwendung weiss, welche Zeilen + ausserhalb der Verwaltung es gibt. Laeuft nach den Migrationen des Kerns + (die Rechte heissen dann schon read/write und sind aufgeteilt). + """ + from tesm_core.rbac import service as rbac + + from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS + + rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF) + + TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="baseline", sql=V1), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), Migration(version=3, name="restart-events", sql=V3), - Migration(version=4, name="fileshare-pdf-rechte", sql=V4), + Migration( + version=4, + name="fileshare-pdf-rechte", + sql=V4, + geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.", + ), Migration(version=5, name="terminal-getrennt", sql=V5), - Migration(version=6, name="wartung-eigener-bereich", sql=V6), + Migration( + version=6, + name="wartung-eigener-bereich", + sql=V6, + geaendert_weil="Die Rechteschluessel heissen seit 2.8.0 read/write. Auf einer frischen Installation laufen die Migrationen des Kerns zuerst, also muss dieser Schritt die neuen Namen setzen -- mit den alten blieben sie ungueltig.", + ), Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen), + Migration(version=8, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe), ) diff --git a/apps/tesm/src/tesm/services/restart.py b/apps/tesm/src/tesm/services/restart.py index 245dc28..81cac1a 100644 --- a/apps/tesm/src/tesm/services/restart.py +++ b/apps/tesm/src/tesm/services/restart.py @@ -22,7 +22,7 @@ etwa bei einem Geraet, das schreibend auf eine Platte zugreift. from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import Any, Callable METHOD_POE = "poe" METHOD_SSH = "ssh" @@ -40,6 +40,16 @@ class Method: #: Braucht dieser Weg das lizenzpflichtige Modul "Wartung"? licensed: bool = False + @property + def recht(self) -> str: + """Das Recht, das genau diesen Weg freigibt. + + Am Weg und nicht in der Route: die Liste der Wege wird an vier Stellen + gebraucht (Dashboard, Geraeteliste, Detailseite, Route), und ein Recht, + das man an vier Stellen von Hand dazuschreibt, fehlt an der fuenften. + """ + return f"device_restart_{self.key}.execute" + @property def is_poe(self) -> bool: return self.key == METHOD_POE @@ -78,12 +88,22 @@ DEFINITIONS: dict[str, Method] = { } -def available(device: Any, *, maintenance_active: bool = True) -> list[Method]: +def available( + device: Any, + *, + maintenance_active: bool = True, + darf: Callable[[str], bool] | None = None, +) -> list[Method]: """Alle Wege, die bei diesem Geraet tatsaechlich zu etwas fuehren. Ein Weg, der nur *fast* geht -- Switch zugeordnet, aber kein Port --, gilt als nicht verfuegbar. Ein Knopf, der verlaesslich nichts bewirkt, ist schlimmer als kein Knopf. + + ``darf`` filtert zusaetzlich nach dem Recht des jeweiligen Weges (in der + Anwendung ``current_user.has``). Damit verschwindet der Knopf fuer den, der + ihn nicht druecken darf -- und zwar an allen Stellen zugleich, weil alle + dieselbe Liste benutzen. Ohne ``darf`` ist die Antwort rein technisch. """ def field(name: str) -> Any: @@ -102,7 +122,10 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]: # der PoE-Weg, und der heisst dort "Einschalten": er schaltet den Port # wieder ein, statt ein laufendes System neu zu starten. if str(field("state") or "") == "offline": - return [DEFINITIONS[POWER_ON]] if hat_poe else [] + einschalten = [DEFINITIONS[POWER_ON]] if hat_poe else [] + if darf is not None: + einschalten = [entry for entry in einschalten if darf(entry.recht)] + return einschalten category = str(field("credential_category") or "") has_login = bool(field("credential_id")) @@ -112,23 +135,39 @@ def available(device: Any, *, maintenance_active: bool = True) -> list[Method]: elif category == "linux": methods.append(DEFINITIONS[METHOD_SSH]) + if darf is not None: + methods = [entry for entry in methods if darf(entry.recht)] return sorted(methods, key=lambda entry: PREFERENCE.index(entry.key)) -def preferred(device: Any, *, maintenance_active: bool = True) -> Method | None: +def preferred( + device: Any, + *, + maintenance_active: bool = True, + darf: Callable[[str], bool] | None = None, +) -> Method | None: """Der Weg, der vorausgewaehlt wird -- oder ``None``, wenn es keinen gibt.""" - options = available(device, maintenance_active=maintenance_active) + options = available(device, maintenance_active=maintenance_active, darf=darf) return options[0] if options else None -def resolve(device: Any, requested: str, *, maintenance_active: bool = True) -> Method | None: +def resolve( + device: Any, + requested: str, + *, + maintenance_active: bool = True, + darf: Callable[[str], bool] | None = None, +) -> Method | None: """Prueft eine Anforderung gegen das, was tatsaechlich moeglich ist. Die Oberflaeche darf vorschlagen; entscheiden tut der Server. Ein Weg, der fuer dieses Geraet nicht offensteht, wird nicht "irgendwie" ersetzt -- sonst startet jemand ein Geraet anders neu, als er dachte. """ - options = {entry.key: entry for entry in available(device, maintenance_active=maintenance_active)} + options = { + entry.key: entry + for entry in available(device, maintenance_active=maintenance_active, darf=darf) + } if not options: return None if not requested: diff --git a/apps/tesm/src/tesm/services/transfer.py b/apps/tesm/src/tesm/services/transfer.py index 645096e..7195afb 100644 --- a/apps/tesm/src/tesm/services/transfer.py +++ b/apps/tesm/src/tesm/services/transfer.py @@ -14,6 +14,7 @@ from typing import Any, Callable, Iterable from tesm_core.db import Database from tesm_core.keystore import Keystore +from tesm_core.rbac.legacy_permissions import umgeschrieben from tesm_core.transfer import ( MIN_PASSPHRASE_LENGTH, PREVIEW_TTL_MINUTES, @@ -254,6 +255,9 @@ def apply_import( """ from .inventory import ValidationError, aad_for_credential, normalize_mac + #: Merkt, ob das Paket alte Rechteschluessel mitbringt (siehe unten). + alte_fassung: set[bool] = set() + def mac_or_none(value: Any) -> str | None: """MAC normalisieren, ohne den ganzen Import zu sprengen. @@ -574,9 +578,21 @@ def apply_import( group_id = int(existing["id"]) bump("groups", "updated") conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) + # Eine Sicherung aus 2.7.x nennt devices.view und devices.edit. + # Ohne die Umschreibung landen lauter unbekannte Schluessel in der + # Gruppe, und sanitize() wirft Unbekanntes still weg: die + # wiederhergestellte Gruppe waere leer, ohne eine Zeile Meldung. + mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []] + neue = umgeschrieben(mitgebracht) + if sorted(set(mitgebracht)) != neue: + # Das Paket kennt die alten Schluessel -- es stammt also von + # vor 2.8.0. Ein Versionsfeld gibt es im Umschlag nicht, aber + # die Schluessel selbst sind das verlaesslichere Kennzeichen: + # sie stehen genau dann drin, wenn die Sicherung aelter ist. + alte_fassung.add(True) conn.executemany( "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)", - [(group_id, str(permission)) for permission in entry.get("permissions", [])], + [(group_id, permission) for permission in neue], ) if "users" in wanted: @@ -683,4 +699,17 @@ def apply_import( set_setting(str(key), value) bump("settings", "updated") + if alte_fassung and "users" in wanted: + # Dieselbe Ueberlegung wie in der Migration: in der Welt, aus der + # diese Sicherung kommt, konnte ein Administrator alles. Ohne die + # Gruppe fehlt ihm nach dem Einlesen die halbe Anwendung -- und er + # haelt das fuer einen Fehler der Wiederherstellung. + from tesm_core.rbac import service as rbac + + from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS + + rbac.funktionsgruppe_fuer_administratoren( + conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF + ) + return report diff --git a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html index 0efecd2..7207793 100644 --- a/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html +++ b/apps/tesm/src/tesm/templates/tesm/_dashboard_tiles.html @@ -84,7 +84,7 @@ Details - {% if current_user.has('devices.execute') and tile.restart_methods %} + {% if tile.restart_methods %} {% set method = tile.restart_methods[0] %}
@@ -99,7 +99,7 @@
{% endif %} - {% if current_user.has('devices.edit') and tile.state == 'disabled' %} + {% if current_user.has('devices.write') and tile.state == 'disabled' %}
diff --git a/apps/tesm/src/tesm/templates/tesm/credentials.html b/apps/tesm/src/tesm/templates/tesm/credentials.html index 5d8e47a..9696228 100644 --- a/apps/tesm/src/tesm/templates/tesm/credentials.html +++ b/apps/tesm/src/tesm/templates/tesm/credentials.html @@ -79,7 +79,7 @@ davon abhängt, welche Geräte auf der Wartungsseite erscheinen.', 'info') }} data-confirm-style="primary">{{ icon('eye', size=15) }}
{% endif %} - {% if current_user.has('credentials.edit') %} + {% if current_user.has('credentials.write') %} {% endif %} - {% if current_user.has('fileshare.edit') %} + {% if current_user.has('fileshare.write') %} + {% endif %} + {% if current_user.has('fileshare_pdf.create') %} @@ -80,7 +80,7 @@ Einbindungen.', 'info') }} {# Bearbeiten statt neu anlegen: bei einem Tippfehler im UNC-Pfad oder im Anzeigenamen soll man ihn korrigieren können. Sonst legt man daneben einen zweiten Eintrag an und vergisst, den ersten zu löschen. #} -{% if current_user.has('fileshare.edit') %} +{% if current_user.has('fileshare.write') %} {% for entry in mappings %} {% call modal('mapping-edit-' ~ entry.id, entry.label, 'Zuordnung ändern') %} diff --git a/apps/tesm/src/tesm/templates/tesm/hostkeys.html b/apps/tesm/src/tesm/templates/tesm/hostkeys.html index ac39d80..0cabd87 100644 --- a/apps/tesm/src/tesm/templates/tesm/hostkeys.html +++ b/apps/tesm/src/tesm/templates/tesm/hostkeys.html @@ -160,7 +160,7 @@ wird nie stillschweigend übernommen: das ist der Fall, der bei einem Angriff au {{ row.fingerprint }} {{ row.added_at | dt }} von {{ row.added_by }} - {% if current_user.has('devices.edit') or current_user.has('switches.edit') %} + {% if current_user.has('devices.write') or current_user.has('switches.write') %} diff --git a/apps/tesm/src/tesm/templates/tesm/license.html b/apps/tesm/src/tesm/templates/tesm/license.html index a91d65f..e4406b0 100644 --- a/apps/tesm/src/tesm/templates/tesm/license.html +++ b/apps/tesm/src/tesm/templates/tesm/license.html @@ -76,7 +76,7 @@ - {% if current_user.has('settings_license.edit') %} + {% if current_user.has('settings_license.write') %} - {% if current_user.has('switches.edit') %} + {% if current_user.has('switches.write') %} @@ -53,7 +53,7 @@ Vergleichen Sie den Fingerprint mit einer unabhängigen Quelle (Konsolenzugang am Switch, Inventardokumentation), bevor Sie ihn freigeben.

- {% if current_user.has('switches.edit') %} + {% if current_user.has('switches.write') %} @@ -78,7 +78,7 @@ {% endfor %} - {% if current_user.has('switches.edit') %} + {% if current_user.has('switches.write') %} @@ -95,7 +95,7 @@ - {% if current_user.has('switches.execute') %} + {% if current_user.has('switch_console.execute') %}
diff --git a/apps/tesm/src/tesm/templates/tesm/switches.html b/apps/tesm/src/tesm/templates/tesm/switches.html index de1e23e..443bebe 100644 --- a/apps/tesm/src/tesm/templates/tesm/switches.html +++ b/apps/tesm/src/tesm/templates/tesm/switches.html @@ -83,13 +83,13 @@ stillschweigend übernommen.', 'info', 'Host-Schlüssel') }} {% endif %} - {% if terminal_enabled and current_user.has('switches.execute') and switch.host_key_known %} + {% if terminal_enabled and current_user.has('switch_console.execute') and switch.host_key_known %} {{ icon('terminal', size=15) }} {% endif %} - {% if current_user.has('switches.edit') %} + {% if current_user.has('switches.write') %}
+ {# Auch fuer einen Administrator die Liste zeigen: sein Kennzeichen gibt + den Verwaltungsbereich, der Rest kommt aus Gruppen -- und genau das + soll er hier ablesen koennen. "Darf alles" war bis 2.7.x richtig und + haette danach das Gegenteil dessen behauptet, was gilt. #} {% if current_user.is_admin %} - {{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }} - {% elif permissions %} + {{ alert('Als Administrator haben Sie den Verwaltungsbereich immer – + Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb, Diagnose. + Alles Weitere steht unten und kommt aus Ihren Gruppen.', 'info') }} + {% endif %} + {% if permissions %}
{% for permission in permissions %} {{ tree.label_for(permission) }} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html index 709644f..c179742 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html @@ -23,7 +23,7 @@ Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte nicht bei jedem Neustart auf die Vorlage zurückgesetzt – Anpassungen bleiben erhalten. - {% if current_user.has('groups.edit') %} + {% if current_user.has('groups.write') %} {{ icon('unlock', size=14) }} Rechte freischalten @@ -94,7 +94,7 @@
{{ icon('users', size=17) }} Mitglieder
Zuordnung erfolgt in der Benutzerverwaltung
- {% if current_user.has('users.view') %} + {% if current_user.has('users.read') %} Benutzerverwaltung {% endif %}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html index ec13d48..039e7ef 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/macros.html @@ -162,8 +162,25 @@
{{ label }}
{{ value if value not in (None, '') else '–' | safe }}
{%- endmacro %} -{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt -#} +{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt. + + Seit 2.8.0 hat jede Funktion ihre eigene Zeile -- 37 statt 21. Das ist der + Sinn der Sache, macht die Tabelle aber lang, und eine lange Tabelle + verleitet dazu, oben den Bereichsschalter zu setzen und fertig. Drei + Hilfen dagegen: Unterzeilen stehen eingerueckt unter ihrer Hauptzeile, die + Funktion steht in Kleinschrift daneben, und ein Filterfeld blendet aus, + wonach man nicht sucht. -#} {% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%} +{% if not disabled %} +
+ + +
+{% endif %}
{% for area in tree.areas %} @@ -172,8 +189,20 @@ {% for action in columns %} - {% endfor %} @@ -182,9 +211,9 @@ {% endfor %} {% for resource in area.resources %} - + {% for action in columns %} - +
{{ area.label }} - {{ action_meta[action][0] }} + + + {{ action_meta[action][0] }} + {{ action_meta[action][1] }} + {{ action_meta[action][2] }} + {% if not disabled %} + + {% endif %} +
@@ -192,23 +221,48 @@ {% for action in columns %}
- {{ resource.label }} + + {{ resource.short_label }} + {% if not disabled %} + {# Ganze Zeile: bei 37 Zeilen mit bis zu sieben Spalten + sind das ueber 200 Kaestchen. Wer einer Rolle eine + Funktion ganz geben will, soll nicht siebenmal + klicken. Hinter der Beschriftung, nicht davor -- vorn + gelesen ergab "alle Clients" einen falschen Satz. #} + + {% endif %} + {% if resource.hint %}{{ resource.hint }}{% endif %} + {# Was eine Aktion in dieser Zeile bedeutet, steht in der + Zeile -- nicht in einem Titelattribut, das man nur mit der + Maus findet, und nicht in einer Legende unter einer + Tabelle, die man dafuer erst zu Ende scrollt. #} + {% for action in resource.actions if resource.action_hints.get(action) %} + + {{ action_meta[action][0] }} + {{ action_meta[action][1] }} – + {{ resource.action_hints.get(action) }} + + {% endfor %} {% if resource.has(action) %} -
{{ entry.ad_group_name }}
{{ entry.ad_group_dn }}
{{ pill(entry.group_name or 'unbekannt', 'accent') }} + {% if entry.grants_admin %} + {{ pill('Administrator (Kennzeichen)', 'warning') }} + {% else %} + {{ pill(entry.group_name or 'unbekannt', 'accent') }} + {% endif %} + @@ -140,22 +146,35 @@ niemals hier gespeichert.', 'info') }} 'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }} {% endif %} - {% if current_user.has('settings_directory.edit') %} + {% if current_user.has('settings_directory.write') %}
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }} - {# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. #} + {# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. + + Das Kennzeichen steht mit in derselben Liste, weil es dieselbe + Frage beantwortet: "was wird dieses Konto hier?". Vorher gab es + nur Gruppen, und "Domänen-Admins" landete deshalb in der Gruppe + *Administration* -- die Benutzer und Rechte pflegt, aber kein + Administratorkennzeichen hat und sich keines geben kann. #}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html index 77fae5a..cd560dd 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/settings_network.html @@ -58,7 +58,7 @@ zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning', {% endif %} -{% if current_user.has('settings_network.edit') and interfaces %} +{% if current_user.has('settings_network.write') and interfaces %}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html index 67d0c22..c3a8a62 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/trash.html @@ -41,7 +41,7 @@ gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Sei
{{ entry.detail }} {{ entry.deleted_at | dt }} - {% if current_user.has('trash.edit') %} + {% if current_user.has('trash.write') %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html index 9e9d6cb..03a957e 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/users.html @@ -79,7 +79,7 @@ {% else %}noch nie{% endif %} - {% if current_user.has('users.edit') %} + {% if current_user.has('users.write') %}