From 4b7403f4764531985a89c6f737927d126d662e26 Mon Sep 17 00:00:00 2001 From: alientim Date: Mon, 10 Aug 2026 18:28:42 +0200 Subject: [PATCH] =?UTF-8?q?Vollst=C3=A4ndig=20granulares,=20an=20der=20Nav?= =?UTF-8?q?bar=20gespiegeltes=20Rechtesystem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kompletter Umbau des Permission-Systems weg von groben is_admin-Gates hin zu einem zweistufigen, an die Sidebar-Struktur gespiegelten Rechtebaum: Geräte (devices_group.view) ├─ Clients Lesen/Schreiben/Ändern/Löschen (+ PoE-Neustart) ├─ Switche Lesen/Schreiben/Ändern/Löschen └─ Zugangsdaten Lesen/Schreiben/Ändern/Löschen (jetzt eigene Rechte, vorher an switches.* gekoppelt) Logs (logs_group.view) ├─ Live Lesen └─ Änderungen Lesen Einstellungen (settings_group.view) ├─ Benutzer Lesen/Schreiben/Ändern/Löschen ├─ Gruppen Lesen/Schreiben/Ändern/Löschen ├─ Systemeinstellungen Lesen/Ändern └─ Im-/Export Lesen (Export)/Ändern (Import) - User.has_permission() ist jetzt hierarchisch: das "Bereich anzeigen"-Recht einer Top-Level-Gruppe wirkt als Kill-Switch für alle Kind-Rechte darunter, auch wenn ein Kind-Recht einzeln noch gesetzt ist. Mit Testgruppe verifiziert (devices.view ohne devices_group.view -> /devices liefert 302, "Geräte" verschwindet komplett aus der Sidebar; nach Zurücksetzen sofort wieder 200). - devices.toggle entfällt, ist jetzt Teil von devices.edit (Ändern). - Neue eigenständige credentials.*-Rechte statt Kopplung an switches.*. - Benutzer- und Gruppenverwaltung sind jetzt ebenfalls granular/delegierbar (users.*/groups.*) statt fest is_admin-exklusiv — dafür neue, fest einprogrammierte Eskalationsschranken: Admin-Konten anlegen/ändern/ löschen sowie Admin-Zuweisung bleiben unabhängig von delegierten Rechten echten Admins vorbehalten (mit Testgruppe verifiziert: Anlegen als Admin, Bearbeiten/Löschen bestehender Admin-Konten und Zuweisen zu "admin" wurden alle korrekt blockiert, normale Benutzerverwaltung funktioniert). - "Admin" (virtuell) und "Benutzer" (Standardgruppe, neues is_system-Flag) sind jetzt echte Systemgruppen: weder umbenennbar noch in ihren Rechten änderbar, auch nicht durch Admins über die UI — Mitgliedschaft bleibt frei verwaltbar. Mit direktem POST verifiziert: Umbenennen/Löschen/Rechte-Reset von "Benutzer" werden blockiert, Mitgliederverwaltung funktioniert weiter. - groups.html zeigt den Baum jetzt als 3 Zeilen (Geräte/Logs/Einstellungen) mit eingerückten Unterpunkten statt einer flachen Liste von Kategorien mit wiederholtem Bereichsnamen im Label. - Migration in _ensure_schema() (Altrechte übertragen, neue Bereichs-Rechte für bestehende Gruppen nachtragen) läuft jetzt über einen Einmal-Guard in der settings-Tabelle — lief anfangs bei jedem Neustart erneut und hat damit den Kill-Switch-Mechanismus untergraben (ein deaktiviertes Bereichs-Recht wäre bei jedem Neustart automatisch wieder gesetzt worden, solange irgendein Kind-Recht noch aktiv war); im Test entdeckt und behoben. - create_db.py synchronisiert (is_system-Spalte, neuer Rechtesatz für Frischinstallationen). - README: Rechtesystem-Abschnitt komplett neu beschrieben (Baum, Kill-Switch, Systemgruppen, Eskalationsschutz). Co-Authored-By: Claude Sonnet 5 --- README.md | 126 +++-- srv/poe_manager/app.py | 468 +++++++++++++++--- srv/poe_manager/create_db.py | 19 +- srv/poe_manager/static/css/style.css | 21 + srv/poe_manager/templates/credentials.html | 6 +- srv/poe_manager/templates/devices.html | 2 +- srv/poe_manager/templates/groups.html | 86 ++-- srv/poe_manager/templates/index.html | 2 +- srv/poe_manager/templates/settings.html | 8 + .../templates/settings_import_export.html | 4 + srv/poe_manager/templates/users.html | 11 +- 11 files changed, 580 insertions(+), 173 deletions(-) diff --git a/README.md b/README.md index 148db91..777de79 100644 --- a/README.md +++ b/README.md @@ -101,66 +101,84 @@ Die App ermöglicht: ## Rechtesystem (Gruppen & Berechtigungen) -Admins dürfen wie bisher alles, inklusive Benutzer-/Gruppen-/ -Settingsverwaltung — das bleibt exklusiv Admins vorbehalten. Zusätzlich gibt -es **Gruppen**: eine Gruppe bündelt einzelne Verwaltungsrechte, die dann -normalen Benutzern zugewiesen werden können, ohne sie zu Admins zu machen. -Ein Benutzer kann nur einer Gruppe/Rolle gleichzeitig zugeordnet sein -(über den Button „Gruppe zuweisen“, analog zur Switch-Zuordnung bei -Devices) — technisch sind mehrere Gruppen pro Benutzer möglich (Rechte -würden sich addieren), die UI bildet aber bewusst nur eine 1:1-Zuordnung ab. +Admins dürfen immer alles. Zusätzlich gibt es **Gruppen** mit einem +**vollständig granularen, an der Navbar gespiegelten Rechtebaum** — jeder +Bereich der App (nicht nur Devices/Switches) lässt sich einzeln freischalten. +Ein Benutzer kann mehreren Gruppen angehören, die Rechte addieren sich +(Vereinigung, nicht Schnittmenge). -Auf der **Gruppen**-Seite (nur für Admins) werden zur Übersicht immer auch -die beiden Systemrollen mit aufgeführt: -- **Admin** — eine feste, nicht editierbare Zeile mit allen Rechten; - Mitgliedschaft wird direkt hier verwaltet (Button „Mitglieder verwalten“), - intern über den `is_admin`-Schalter der Benutzer. Mindestens ein Admin - muss immer bestehen bleiben (serverseitig erzwungen). -- **Benutzer** — die **Standardgruppe**, mit der alle Ansichtsrechte - (`devices.view`, `switches.view`) vorbelegt sind. Jeder neu angelegte, - nicht-admin Benutzer wird ihr automatisch zugeordnet; sie kann nicht - gelöscht werden. +### Zweistufiger Rechtebaum -Jede Gruppe lässt sich über „Rechte anzeigen/bearbeiten“ aufklappen (wie ein -Akkordeon) und zeigt dort die volle Checkbox-Liste; Mitglieder werden über -einen eigenen Button/Modal verwaltet (nur die Anzahl steht in der Tabelle). +Der Baum hat genau zwei Ebenen, exakt gespiegelt an den drei Sidebar-Gruppen: -Verfügbare Rechte: +``` +Geräte (devices_group.view — Bereich an/aus) + ├─ Clients Lesen · Schreiben (Anlegen) · Ändern (inkl. Akt./Deakt.) · Löschen · PoE-Neustart + ├─ Switche Lesen · Schreiben · Ändern · Löschen + └─ Zugangsdaten Lesen · Schreiben · Ändern · Löschen +Logs (logs_group.view — Bereich an/aus) + ├─ Live Lesen + └─ Änderungen Lesen +Einstellungen (settings_group.view — Bereich an/aus) + ├─ Benutzer Lesen · Schreiben · Ändern · Löschen + ├─ Gruppen Lesen · Schreiben · Ändern · Löschen + ├─ Systemeinstellungen Lesen · Ändern + └─ Im-/Export Lesen (Export) · Ändern (Import) +``` -| Bereich | Recht | Bedeutung | -|----------|---------------------|-----------------------------------------------| -| Devices | `devices.view` | Devices-Seite ansehen | -| Devices | `devices.toggle` | Geräte aktivieren/deaktivieren | -| Devices | `devices.create` | Geräte anlegen | -| Devices | `devices.edit` | Geräte bearbeiten (inkl. Switch-Zuordnung) | -| Devices | `devices.delete` | Geräte löschen | -| Devices | `devices.restart` | PoE-Neustart/Aktivieren über das Dashboard | -| Switches | `switches.view` | Switches- und Zugangsdaten-Seite ansehen | -| Switches | `switches.create` | Switche und Zugangsdaten anlegen | -| Switches | `switches.edit` | Switche und Zugangsdaten bearbeiten | -| Switches | `switches.delete` | Switche und Zugangsdaten löschen | +Das jeweilige „Bereich an/aus“-Recht (`devices_group.view` / +`logs_group.view` / `settings_group.view`) wirkt als **Kill-Switch**: ist es +für eine Gruppe nicht gesetzt, greift kein einziges Recht darunter mehr — +selbst wenn z.B. `devices.view` einzeln noch angehakt ist. So lässt sich ein +ganzer Bereich mit einem Klick sperren, ohne jedes Unterrecht einzeln +zurücknehmen zu müssen (`User.has_permission()` in `app.py`). -Sowohl das Anzeigen der Devices-/Switches-Seiten als auch jede einzelne -Aktion (Buttons, Toggle-Switches, Formulare) ist an das jeweilige Recht -gekoppelt — im Frontend ausgeblendet **und** im Backend serverseitig -durchgesetzt, unabhängig vom Frontend. +Auf der **Gruppen**-Seite wird der Baum als Akkordeon pro Gruppe angezeigt: +eine Zeile pro Top-Level-Bereich mit eigenem Kästchen, darunter eingerückt +die Unterpunkte mit ihren Einzelrechten — ohne den Bereichsnamen in jedem +Unterpunkt zu wiederholen. -**Bewusst ohne granulare Rechte, exklusiv Admins vorbehalten:** Benutzer, -Gruppen, Systemeinstellungen (Prüfintervall), Im-/Export, Änderungslog, -Live-Log und der manuelle „Jetzt prüfen“-Trigger. Für diese Bereiche gibt es -keine sinnvolle Teilmenge unterhalb von „Admin“ — Gruppenverwaltung steuert -direkt das Rechtesystem selbst (Delegation wäre eine Privilegien-Eskalation), -Systemeinstellungen/Import-Export/manuelle Prüfung wirken sich global aus, -und das Änderungslog protokolliert alle Benutzer systemweit. Live-Log und -`/get_log` waren zwischenzeitlich nur über `@login_required` statt echter -Admin-Prüfung abgesichert (die Sidebar blendete den Punkt zwar korrekt aus, -die Route selbst nicht) — inzwischen serverseitig nachgezogen. +### Systemgruppen -Datenmodell: `groups` (inkl. `is_default`-Flag), `group_permissions` -(Gruppe → Recht), `user_groups` (Benutzer → Gruppe). Bestehende Datenbanken -werden beim App-Start automatisch migriert (`_ensure_schema()` in `app.py`, -inkl. Nachrüsten der Standardgruppe und Zuordnung bestehender Benutzer ohne -Gruppe) — kein manuelles Migrations-Skript nötig. +Zwei Gruppen sind fest und **weder umbenennbar noch in ihren Rechten +änderbar** (auch nicht durch Admins über die UI) — Mitgliedschaft bleibt bei +beiden frei verwaltbar: +- **Admin** — virtuell (kein echter `groups`-Datensatz), intern über den + `is_admin`-Schalter der Benutzer gesteuert, immer alle Rechte. Mindestens + ein Admin muss bestehen bleiben (serverseitig erzwungen). +- **Benutzer** — die Standardgruppe (`is_system`-Flag), der jeder neu + angelegte Nicht-Admin automatisch zugeordnet wird. Fester Rechtesatz: + Geräte-Bereich + Clients/Switche lesen, Logs-Bereich + Live-Log lesen. + +Alle anderen Gruppen sind vom jeweiligen Rechteinhaber (`groups.edit` bzw. +`groups.create`/`groups.delete`) frei konfigurierbar. + +### Eskalationsschutz + +Da jetzt auch **Benutzer-** und **Gruppenverwaltung** delegierbar sind (z.B. +eine Gruppe mit nur `users.edit`, ohne Admin zu sein), gelten zusätzlich zu +den granularen Rechten diese fest einprogrammierten Schranken, unabhängig +davon, was eine Gruppe an Rechten hat: +- Ein Benutzer als Admin anlegen/dazu machen (`group_id=admin` bei + Anlegen/Zuweisen) bleibt echten Admins vorbehalten. +- Ein bestehendes Admin-Konto bearbeiten, löschen oder ihm die Gruppe ändern + bleibt echten Admins vorbehalten. +- Wer Admin ist, wird ausschließlich über „Admin-Mitglieder verwalten“ auf + der Gruppen-Seite gesteuert — bleibt admin-exklusiv, unabhängig von + `groups.edit`. + +Sowohl das Anzeigen jeder Seite als auch jede einzelne Aktion (Buttons, +Formulare) ist an das jeweilige Recht gekoppelt — im Frontend ausgeblendet +**und** im Backend serverseitig durchgesetzt, unabhängig vom Frontend. + +Datenmodell: `groups` (inkl. `is_default`- und `is_system`-Flag), +`group_permissions` (Gruppe → Recht), `user_groups` (Benutzer → Gruppe). +Bestehende Datenbanken werden beim App-Start automatisch migriert +(`_ensure_schema()` in `app.py`, u.a. `devices.toggle` → `devices.edit`, +`switches.*` → gespiegelte `credentials.*`, nachträgliches Setzen der neuen +Bereichs-Rechte für bereits vergebene Unterrechte) — läuft **nur einmalig** +über einen Guard in der `settings`-Tabelle, damit ein bewusst deaktiviertes +Bereichs-Recht nicht bei jedem Neustart automatisch wieder gesetzt wird. ## Zugangsdaten (wiederverwendbare SSH-Logins) @@ -179,7 +197,7 @@ am Switch) werden beim ersten Start automatisch migriert. Jede Anlage, Bearbeitung, Löschung sowie jedes Aktivieren/Deaktivieren von Geräten, Switchen, Zugangsdaten, Benutzern und Gruppen wird in der Tabelle `audit_log` protokolliert (Zeitpunkt, Benutzer, Aktion, Ziel, Details) — -einsehbar unter **Logs → Änderungslog** (nur für Admins). Zusätzlich merken +einsehbar unter **Logs → Änderungen** (Recht `logs_activity.view`). Zusätzlich merken sich Geräte und Switche direkt am Datensatz (`last_modified_by`, `last_modified_at`), wer sie zuletzt geändert hat, damit man das nicht erst im Log nachschlagen muss. diff --git a/srv/poe_manager/app.py b/srv/poe_manager/app.py index fe00e4a..fd03721 100644 --- a/srv/poe_manager/app.py +++ b/srv/poe_manager/app.py @@ -123,31 +123,122 @@ login_manager.init_app(app) # normale Benutzer lassen sich einzelne Verwaltungsrechte über Gruppen # gezielt vergeben, ohne sie zu Admins zu machen. # --------------------------------------------------------------------------- +# Zweistufig verschachtelt, gespiegelt an der Navbar-Struktur: jeder +# Top-Level-Bereich (Geräte/Logs/Einstellungen) hat ein eigenes "Bereich +# anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte mit jeweils +# eigenen Lesen/Schreiben/Ändern/Löschen-Rechten. Der Bereichs-Name wird +# dadurch nicht in jedem Unterpunkt wiederholt (siehe groups.html: eine +# Zeile pro Top-Level-Bereich, Unterpunkte darunter eingerückt). PERMISSIONS = { - "devices": { - "label": "Devices", - "items": { - "devices.view": "Devices-Seite ansehen", - "devices.toggle": "Geräte aktivieren/deaktivieren", - "devices.create": "Geräte anlegen", - "devices.edit": "Geräte bearbeiten (inkl. Switch-Zuordnung)", - "devices.delete": "Geräte löschen", - "devices.restart": "PoE-Neustart über das Dashboard", + "devices_group": { + "label": "Geräte", + "view_key": "devices_group.view", + "children": { + "devices": { + "label": "Clients", + "items": { + "devices.view": "Lesen", + "devices.create": "Schreiben (Anlegen)", + "devices.edit": "Ändern (inkl. Aktivieren/Deaktivieren)", + "devices.delete": "Löschen", + "devices.restart": "PoE-Neustart über das Dashboard", + }, + }, + "switches": { + "label": "Switche", + "items": { + "switches.view": "Lesen", + "switches.create": "Schreiben (Anlegen)", + "switches.edit": "Ändern", + "switches.delete": "Löschen", + }, + }, + "credentials": { + "label": "Zugangsdaten", + "items": { + "credentials.view": "Lesen", + "credentials.create": "Schreiben (Anlegen)", + "credentials.edit": "Ändern", + "credentials.delete": "Löschen", + }, + }, }, }, - "switches": { - "label": "Switches", - "items": { - "switches.view": "Switches-Seite ansehen", - "switches.create": "Switche anlegen", - "switches.edit": "Switche bearbeiten", - "switches.delete": "Switche löschen", + "logs_group": { + "label": "Logs", + "view_key": "logs_group.view", + "children": { + "logs_live": { + "label": "Live", + "items": {"logs_live.view": "Lesen"}, + }, + "logs_activity": { + "label": "Änderungen", + "items": {"logs_activity.view": "Lesen"}, + }, + }, + }, + "settings_group": { + "label": "Einstellungen", + "view_key": "settings_group.view", + "children": { + "users": { + "label": "Benutzer", + "items": { + "users.view": "Lesen", + "users.create": "Schreiben (Anlegen)", + "users.edit": "Ändern", + "users.delete": "Löschen", + }, + }, + "groups": { + "label": "Gruppen", + "items": { + "groups.view": "Lesen", + "groups.create": "Schreiben (Anlegen)", + "groups.edit": "Ändern", + "groups.delete": "Löschen", + }, + }, + "settings_system": { + "label": "Systemeinstellungen", + "items": { + "settings_system.view": "Lesen", + "settings_system.edit": "Ändern", + }, + }, + "settings_importexport": { + "label": "Im-/Export", + "items": { + "settings_importexport.view": "Lesen (Export)", + "settings_importexport.edit": "Ändern (Import)", + }, + }, }, }, } -ALL_PERMISSION_KEYS = [key for cat in PERMISSIONS.values() for key in cat["items"]] + +# Aus der verschachtelten Struktur abgeleitet: alle vergebbaren Keys, sowie +# eine Zuordnung jedes Kind-Rechts zum "Bereich anzeigen"-Recht seines +# Top-Level-Bereichs. Ist das übergeordnete Recht einer Gruppe nicht +# gesetzt, greift KEIN darunterliegendes Recht mehr — auch wenn es einzeln +# gewährt wurde (Kill-Switch, siehe User.has_permission). So lässt sich +# z.B. der gesamte "Geräte"-Bereich für eine Gruppe mit einem Klick sperren, +# ohne jedes Kind-Recht einzeln zurücknehmen zu müssen. +ALL_PERMISSION_KEYS = [] +PERMISSION_PARENT_GROUP = {} +for _group in PERMISSIONS.values(): + ALL_PERMISSION_KEYS.append(_group["view_key"]) + for _child in _group["children"].values(): + for _key in _child["items"]: + ALL_PERMISSION_KEYS.append(_key) + PERMISSION_PARENT_GROUP[_key] = _group["view_key"] + DEFAULT_GROUP_NAME = "Benutzer" -DEFAULT_GROUP_PERMISSIONS = ["devices.view", "switches.view"] +DEFAULT_GROUP_PERMISSIONS = [ + "devices_group.view", "devices.view", "switches.view", + "logs_group.view", "logs_live.view", +] # --------------------------------------------------------------------------- # Navbar — die Reihenfolge der TOP-LEVEL-Punkte ist admin-konfigurierbar @@ -184,10 +275,22 @@ def _nav_key_visible(key, user): return True if key == "devices": return user.can_view_devices - if key in ("switches", "credentials"): + if key == "switches": return user.can_manage_switches - if key in ("users", "groups", "settings_system", "settings_importexport", "logs_live", "logs_activity"): - return user.is_admin + if key == "credentials": + return user.can_manage_credentials + if key == "users": + return user.can_manage_users + if key == "groups": + return user.can_manage_groups + if key == "settings_system": + return user.can_view_settings_system + if key == "settings_importexport": + return user.can_view_settings_importexport + if key == "logs_live": + return user.can_view_live_log + if key == "logs_activity": + return user.can_view_activity_log return False @@ -227,17 +330,64 @@ class User(UserMixin): self.avatar_filename = avatar_filename def has_permission(self, key): - return self.is_admin or key in self.permissions + """Admins dürfen immer alles. Sonst muss das Recht selbst gesetzt + sein UND — falls es ein übergeordnetes Bereichs-Recht gibt (siehe + PERMISSION_PARENT_GROUP) — dieses ebenfalls: das übergeordnete Recht + wirkt als Kill-Switch für den ganzen Bereich, selbst wenn ein + einzelnes Kind-Recht noch gesetzt ist.""" + if self.is_admin: + return True + if key not in self.permissions: + return False + parent = PERMISSION_PARENT_GROUP.get(key) + return parent is None or parent in self.permissions + + def has_any_permission(self, keys): + return any(self.has_permission(k) for k in keys) @property def can_view_devices(self): - return self.is_admin or "devices.view" in self.permissions + return self.has_permission("devices.view") @property def can_manage_switches(self): - return self.is_admin or bool(self.permissions & { - "switches.view", "switches.create", "switches.edit", "switches.delete", - }) + return self.has_any_permission( + ["switches.view", "switches.create", "switches.edit", "switches.delete"] + ) + + @property + def can_manage_credentials(self): + return self.has_any_permission( + ["credentials.view", "credentials.create", "credentials.edit", "credentials.delete"] + ) + + @property + def can_view_live_log(self): + return self.has_permission("logs_live.view") + + @property + def can_view_activity_log(self): + return self.has_permission("logs_activity.view") + + @property + def can_manage_users(self): + return self.has_any_permission( + ["users.view", "users.create", "users.edit", "users.delete"] + ) + + @property + def can_manage_groups(self): + return self.has_any_permission( + ["groups.view", "groups.create", "groups.edit", "groups.delete"] + ) + + @property + def can_view_settings_system(self): + return self.has_permission("settings_system.view") + + @property + def can_view_settings_importexport(self): + return self.has_permission("settings_importexport.view") @property def full_name(self): @@ -267,6 +417,9 @@ def _ensure_schema(): Sorgt außerdem dafür, dass die Standardgruppe 'Benutzer' existiert und jeder Benutzer ohne Gruppe ihr zugeordnet ist.""" conn = get_db_connection() + # Für den Migrations-Einmal-Guard weiter unten (settings existiert normalerweise + # schon durch create_db.py, hier nur zur Absicherung defensiv angelegt). + conn.execute("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)") conn.execute("CREATE TABLE IF NOT EXISTS groups (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL)") conn.execute(""" CREATE TABLE IF NOT EXISTS group_permissions ( @@ -286,11 +439,17 @@ def _ensure_schema(): ) """) - # Migration: 'is_default'-Spalte nachrüsten, falls die Tabelle noch aus - # einer älteren Version ohne diese Spalte stammt. + # Migration: 'is_default'-/'is_system'-Spalte nachrüsten, falls die + # Tabelle noch aus einer älteren Version ohne diese Spalten stammt. + # is_system sperrt Name UND Rechte einer Gruppe komplett gegen Änderungen + # über die UI (aktuell nur die Standardgruppe 'Benutzer') — getrennt von + # is_default (steuert nur die automatische Zuordnung neuer Benutzer), + # damit künftig auch andere, nicht-default Systemgruppen denkbar wären. existing_cols = {row["name"] for row in conn.execute("PRAGMA table_info(groups)").fetchall()} if "is_default" not in existing_cols: conn.execute("ALTER TABLE groups ADD COLUMN is_default INTEGER DEFAULT 0") + if "is_system" not in existing_cols: + conn.execute("ALTER TABLE groups ADD COLUMN is_system INTEGER DEFAULT 0") # Migration: Vor-/Nachname + Profilbild für Benutzer nachrüsten. user_cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)").fetchall()} @@ -360,23 +519,100 @@ def _ensure_schema(): (cur.lastrowid, sw["hostname"]), ) - # Standardgruppe 'Benutzer' sicherstellen. Die View-Rechte werden nur bei - # der *erstmaligen* Erzeugung gesetzt — spätere Anpassungen durch einen - # Admin (z.B. ein Recht wieder entziehen) bleiben so über Neustarts hinweg - # erhalten, statt bei jedem Start erneut hineinkopiert zu werden. - cur = conn.execute("INSERT OR IGNORE INTO groups (name, is_default) VALUES (?, 1)", (DEFAULT_GROUP_NAME,)) - if cur.rowcount > 0: - default_group_id = cur.lastrowid - conn.executemany( - "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - [(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS], - ) - else: - conn.execute("UPDATE groups SET is_default=1 WHERE name=? AND is_default=0", (DEFAULT_GROUP_NAME,)) + # Migration (Einführung des granularen, an die Navbar gespiegelten + # Rechtebaums): alte Permission-Keys auf die neuen übertragen, damit + # bestehende Gruppen durch das Update keinen Zugriff verlieren. + # - devices.toggle ist jetzt Teil von devices.edit (Ändern). + # - Zugangsdaten hatten bisher keine eigenen Rechte, sondern hingen an + # switches.* — bestehende Gruppen bekommen die entsprechenden + # credentials.*-Rechte gespiegelt, künftig sind beide unabhängig. + # - Jede Gruppe, die bereits ein Kind-Recht in einem Bereich hatte, + # bekommt automatisch auch das neue übergeordnete "Bereich + # sichtbar"-Recht (devices_group.view/logs_group.view/ + # settings_group.view), da Kind-Rechte jetzt ohne dieses Recht wirkungslos + # wären (siehe User.has_permission). + # WICHTIG: Läuft nur EINMALIG (Guard über settings), nicht bei jedem + # Start — sonst würde das übergeordnete "Bereich sichtbar"-Recht bei + # jedem Neustart automatisch wieder gesetzt, sobald irgendein Kind-Recht + # noch aktiv ist. Das würde den eigentlichen Zweck des Kill-Switches + # zunichtemachen (ein Admin könnte den ganzen Bereich nie dauerhaft + # sperren, ohne zuvor jedes einzelne Kind-Recht zu entfernen). + _migration_key = "_migrated_permission_hierarchy_v1" + if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key,)).fetchone(): + toggle_groups = conn.execute( + "SELECT DISTINCT group_id FROM group_permissions WHERE permission='devices.toggle'" + ).fetchall() + for row in toggle_groups: + conn.execute( + "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, 'devices.edit')", + (row["group_id"],), + ) + conn.execute("DELETE FROM group_permissions WHERE permission='devices.toggle'") - default_group_id = conn.execute( + _switch_to_cred = { + "switches.view": "credentials.view", + "switches.create": "credentials.create", + "switches.edit": "credentials.edit", + "switches.delete": "credentials.delete", + } + for switch_perm, cred_perm in _switch_to_cred.items(): + rows = conn.execute( + "SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (switch_perm,) + ).fetchall() + for row in rows: + conn.execute( + "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", + (row["group_id"], cred_perm), + ) + + _parent_backfill = [ + ("devices_group.view", [ + "devices.view", "devices.create", "devices.edit", "devices.delete", "devices.restart", + "switches.view", "switches.create", "switches.edit", "switches.delete", + "credentials.view", "credentials.create", "credentials.edit", "credentials.delete", + ]), + ("logs_group.view", ["logs_live.view", "logs_activity.view"]), + ("settings_group.view", [ + "users.view", "users.create", "users.edit", "users.delete", + "groups.view", "groups.create", "groups.edit", "groups.delete", + "settings_system.view", "settings_system.edit", + "settings_importexport.view", "settings_importexport.edit", + ]), + ] + for parent_key, child_keys in _parent_backfill: + placeholders = ",".join("?" * len(child_keys)) + rows = conn.execute( + f"SELECT DISTINCT group_id FROM group_permissions WHERE permission IN ({placeholders})", + child_keys, + ).fetchall() + for row in rows: + conn.execute( + "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", + (row["group_id"], parent_key), + ) + + conn.execute( + "INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,) + ) + + # Standardgruppe 'Benutzer' — SYSTEMGRUPPE: Name und Rechte sind fix und + # werden bei jedem Start auf den vorgegebenen Satz zurückgesetzt (nicht + # nur bei Erstanlage). Das ist unbedenklich, weil is_system die Gruppe in + # der UI ohnehin komplett gegen Umbenennen/Rechteänderung sperrt — + # Mitgliedschaft bleibt weiterhin frei verwaltbar. + cur = conn.execute( + "INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", + (DEFAULT_GROUP_NAME,), + ) + default_group_id = cur.lastrowid if cur.rowcount > 0 else conn.execute( "SELECT id FROM groups WHERE name=?", (DEFAULT_GROUP_NAME,) ).fetchone()["id"] + conn.execute("UPDATE groups SET is_default=1, is_system=1 WHERE id=?", (default_group_id,)) + conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default_group_id,)) + conn.executemany( + "INSERT INTO group_permissions (group_id, permission) VALUES (?, ?)", + [(default_group_id, p) for p in DEFAULT_GROUP_PERMISSIONS], + ) # Jeden nicht-admin Benutzer ohne jegliche Gruppenzugehörigkeit der # Standardgruppe zuordnen (Migrationsfall: bestehende Benutzer sollen @@ -839,13 +1075,16 @@ def run_check_now(): @app.route("/settings", methods=["GET", "POST"]) @login_required def settings(): - if not current_user.is_admin: - flash("Nur Admins dürfen die Einstellungen ändern!", "danger") + if request.method == "GET" and not current_user.can_view_settings_system: + flash("Keine Berechtigung, die Systemeinstellungen anzusehen.", "danger") return redirect(url_for("index")) interval = int(get_setting("interval", 5)) if request.method == "POST": + if not current_user.has_permission("settings_system.edit"): + flash("Keine Berechtigung, die Systemeinstellungen zu ändern.", "danger") + return redirect(url_for("settings")) new_interval = int(request.form["interval"]) set_setting("interval", new_interval) set_setting("check_interval", new_interval * 60) @@ -861,8 +1100,8 @@ def settings(): @app.route("/settings/import-export") @login_required def settings_import_export(): - if not current_user.is_admin: - flash("Nur Admins dürfen Daten importieren/exportieren!", "danger") + if not current_user.can_view_settings_importexport: + flash("Keine Berechtigung für Im-/Export.", "danger") return redirect(url_for("index")) return render_template("settings_import_export.html") @@ -893,8 +1132,8 @@ def save_nav_order(): @app.route("/settings/export", methods=["POST"]) @login_required def export_data(): - if not current_user.is_admin: - flash("Nur Admins dürfen Daten exportieren!", "danger") + if not current_user.has_permission("settings_importexport.view"): + flash("Keine Berechtigung, Daten zu exportieren.", "danger") return redirect(url_for("index")) passphrase = request.form.get("export_passphrase", "") @@ -951,8 +1190,8 @@ def export_data(): @app.route("/settings/import", methods=["POST"]) @login_required def import_data(): - if not current_user.is_admin: - flash("Nur Admins dürfen Daten importieren!", "danger") + if not current_user.has_permission("settings_importexport.edit"): + flash("Keine Berechtigung, Daten zu importieren.", "danger") return redirect(url_for("index")) passphrase = request.form.get("import_passphrase", "") @@ -1159,7 +1398,9 @@ def devices(): @app.route("/devices/toggle/", methods=["POST"]) @login_required def toggle_device(mac): - if not current_user.has_permission("devices.toggle"): + # Aktivieren/Deaktivieren zählt jetzt zu "Ändern" (devices.edit) statt + # eines eigenen devices.toggle-Rechts. + if not current_user.has_permission("devices.edit"): return {"success": False, "msg": "Keine Berechtigung."}, 403 conn = get_db_connection() @@ -1193,6 +1434,8 @@ def _resolve_credential_choice(conn): """ choice = request.form.get("credential_choice", "") if choice == "new": + if not current_user.has_permission("credentials.create"): + return None, "Keine Berechtigung, neue Zugangsdaten anzulegen." name = request.form.get("new_credential_name", "").strip() username = request.form.get("new_credential_username", "").strip() password = request.form.get("new_credential_password", "") @@ -1307,21 +1550,21 @@ def delete_switch(hostname): # --------------------------------------------------------------------------- # Zugangsdaten (Credentials) — wiederverwendbare SSH-Logins für Switche, -# damit nicht jeder Switch sein eigenes Login braucht. Gleiche Berechtigungen -# wie Switch-Verwaltung (switches.*), da inhaltlich untrennbar davon. +# damit nicht jeder Switch sein eigenes Login braucht. Eigene credentials.*- +# Rechte, unabhängig von switches.* (bewusst entkoppelt). # --------------------------------------------------------------------------- @app.route("/credentials", methods=["GET", "POST"]) @login_required def credentials(): - if not current_user.can_manage_switches: + if not current_user.can_manage_credentials: flash("Keine Berechtigung für die Zugangsdatenverwaltung.", "danger") return redirect(url_for("index")) conn = get_db_connection() if request.method == "POST" and "add_credential" in request.form: - if not current_user.has_permission("switches.create"): + if not current_user.has_permission("credentials.create"): flash("Keine Berechtigung, Zugangsdaten anzulegen.", "danger") return redirect(url_for("credentials")) name = request.form.get("name", "").strip() @@ -1342,7 +1585,7 @@ def credentials(): flash("Name, Username und Passwort müssen ausgefüllt sein!", "danger") elif request.method == "POST" and "edit_credential" in request.form: - if not current_user.has_permission("switches.edit"): + if not current_user.has_permission("credentials.edit"): flash("Keine Berechtigung, Zugangsdaten zu bearbeiten.", "danger") return redirect(url_for("credentials")) cred_id = request.form.get("credential_id") @@ -1370,7 +1613,7 @@ def credentials(): flash("Name und Username müssen ausgefüllt sein!", "danger") elif request.method == "POST" and "delete_credential" in request.form: - if not current_user.has_permission("switches.delete"): + if not current_user.has_permission("credentials.delete"): flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger") return redirect(url_for("credentials")) cred_id = request.form.get("delete_credential") @@ -1626,7 +1869,7 @@ def _ssh_terminal_inner(ws): @app.route("/get_log") @login_required def get_log(): - if not current_user.is_admin: + if not current_user.can_view_live_log: return "Keine Berechtigung.", 403 latest_log = _latest_log_file() if not latest_log: @@ -1647,8 +1890,8 @@ def get_log(): @app.route("/logs") @login_required def logs(): - if not current_user.is_admin: - flash("Nur Admins dürfen das Live-Log einsehen.", "danger") + if not current_user.can_view_live_log: + flash("Keine Berechtigung, das Live-Log einzusehen.", "danger") return redirect(url_for("index")) interval = int(get_setting("interval", 5)) latest_log = _latest_log_file() @@ -1672,8 +1915,8 @@ def logs(): def activity_log(): """Änderungslog: wer hat was geändert (Aktivieren/Deaktivieren, Anlegen, Bearbeiten, Löschen). Bewusst OHNE PoE-Neustarts — die stehen im Live-Log.""" - if not current_user.is_admin: - flash("Nur Admins dürfen das Änderungslog einsehen.", "danger") + if not current_user.can_view_activity_log: + flash("Keine Berechtigung, das Änderungslog einzusehen.", "danger") return redirect(url_for("index")) conn = get_db_connection() @@ -1727,22 +1970,31 @@ def restart_device(mac): @app.route("/users", methods=["GET", "POST"]) @login_required def users(): - if not current_user.is_admin: - flash("Nur Admins dürfen Benutzer verwalten!", "danger") + if request.method == "GET" and not current_user.can_manage_users: + flash("Keine Berechtigung, die Benutzerverwaltung anzusehen.", "danger") return redirect(url_for("index")) conn = get_db_connection() if request.method == "POST": if "add_user" in request.form: + if not current_user.has_permission("users.create"): + flash("Keine Berechtigung, Benutzer anzulegen.", "danger") + return redirect(url_for("users")) + username = request.form["username"].strip() password = request.form["password"].strip() first_name = request.form.get("first_name", "").strip() or None last_name = request.form.get("last_name", "").strip() or None # Die Gruppen-Auswahl entscheidet auch über die Rolle: Auswahl # "admin" macht den Benutzer zum Admin, jede andere Auswahl ist - # eine normale Gruppe (oder keine). + # eine normale Gruppe (oder keine). Nur echte Admins dürfen + # jemanden direkt als Admin anlegen (Eskalationsschutz) — + # delegierte "Benutzer anlegen"-Rechte reichen dafür nicht. group_choice = request.form.get("group_id") or "" + if group_choice == "admin" and not current_user.is_admin: + flash("Nur Admins dürfen andere Benutzer zu Admins machen.", "danger") + return redirect(url_for("users")) is_admin = 1 if group_choice == "admin" else 0 if username and password: @@ -1767,9 +2019,22 @@ def users(): flash("Username und Passwort dürfen nicht leer sein!", "danger") elif "edit_user" in request.form: + if not current_user.has_permission("users.edit"): + flash("Keine Berechtigung, Benutzer zu bearbeiten.", "danger") + return redirect(url_for("users")) + # Nur Stammdaten + optional Passwort — Gruppe/Rolle wird # ausschließlich über "Gruppe zuweisen" geändert (s.u.). user_id = request.form["user_id"] + # Admin-Konten dürfen nur von echten Admins bearbeitet werden + # (u.a. Passwort-Reset) — verhindert, dass ein delegiertes + # "Benutzer bearbeiten"-Recht zur Übernahme eines Admin-Kontos + # missbraucht wird. + target_admin = conn.execute("SELECT is_admin FROM users WHERE id=?", (user_id,)).fetchone() + if target_admin and target_admin["is_admin"] and not current_user.is_admin: + flash("Nur Admins dürfen Admin-Konten bearbeiten.", "danger") + return redirect(url_for("users")) + username = request.form.get("username", "").strip() first_name = request.form.get("first_name", "").strip() or None last_name = request.form.get("last_name", "").strip() or None @@ -1793,11 +2058,24 @@ def users(): flash("Username darf nicht leer sein!", "danger") elif "assign_group" in request.form: + if not current_user.has_permission("users.edit"): + flash("Keine Berechtigung, Gruppen zuzuweisen.", "danger") + return redirect(url_for("users")) + user_id = request.form["user_id"] choice = request.form.get("group_id") or "" - # Mindestens ein Admin muss bestehen bleiben. + # Sowohl das Zuweisen ZUR Admin-Gruppe als auch das Ändern der + # Gruppe eines BESTEHENDEN Admins bleibt echten Admins + # vorbehalten (Eskalationsschutz) — ein delegiertes + # "Gruppe zuweisen"-Recht darf niemanden zum Admin machen oder + # einem Admin die Rechte entziehen. target = conn.execute("SELECT username, is_admin FROM users WHERE id=?", (user_id,)).fetchone() + if (choice == "admin" or (target and target["is_admin"])) and not current_user.is_admin: + flash("Nur Admins dürfen Admin-Zuweisungen ändern.", "danger") + return redirect(url_for("users")) + + # Mindestens ein Admin muss bestehen bleiben. if target and target["is_admin"] and choice != "admin": admin_count = conn.execute("SELECT COUNT(*) AS n FROM users WHERE is_admin=1").fetchone()["n"] if admin_count <= 1: @@ -1820,8 +2098,17 @@ def users(): flash("Gruppe zugewiesen!", "success") elif "delete_user" in request.form: + if not current_user.has_permission("users.delete"): + flash("Keine Berechtigung, Benutzer zu löschen.", "danger") + return redirect(url_for("users")) + user_id = request.form["delete_user"] - target = conn.execute("SELECT username FROM users WHERE id=?", (user_id,)).fetchone() + target = conn.execute("SELECT username, is_admin FROM users WHERE id=?", (user_id,)).fetchone() + # Admin-Konten dürfen nur von echten Admins gelöscht werden. + if target and target["is_admin"] and not current_user.is_admin: + flash("Nur Admins dürfen Admin-Konten löschen.", "danger") + return redirect(url_for("users")) + conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,)) conn.execute("DELETE FROM users WHERE id=?", (user_id,)) conn.commit() @@ -1851,14 +2138,17 @@ def users(): @app.route("/groups", methods=["GET", "POST"]) @login_required def groups(): - if not current_user.is_admin: - flash("Nur Admins dürfen Gruppen verwalten!", "danger") + if request.method == "GET" and not current_user.can_manage_groups: + flash("Keine Berechtigung, die Gruppenverwaltung anzusehen.", "danger") return redirect(url_for("index")) conn = get_db_connection() if request.method == "POST": if "add_group" in request.form: + if not current_user.has_permission("groups.create"): + flash("Keine Berechtigung, Gruppen anzulegen.", "danger") + return redirect(url_for("groups")) name = request.form.get("name", "").strip() if name: try: @@ -1872,6 +2162,10 @@ def groups(): flash("Gruppenname darf nicht leer sein!", "danger") elif "save_group" in request.form: + if not current_user.has_permission("groups.edit"): + flash("Keine Berechtigung, Gruppen zu bearbeiten.", "danger") + return redirect(url_for("groups")) + group_id = request.form.get("group_id") name = request.form.get("name", "").strip() @@ -1880,6 +2174,22 @@ def groups(): conn.close() return redirect(url_for("groups")) + # Systemgruppen (aktuell nur die Standardgruppe 'Benutzer') sind + # in Name und Rechten fix — nicht mal für Admins änderbar über + # die UI. Mitgliedschaft (members_submitted) bleibt frei + # verwaltbar, nur permissions_submitted bzw. eine Umbenennung + # werden blockiert. + target_group = conn.execute("SELECT name, is_system FROM groups WHERE id=?", (group_id,)).fetchone() + is_system = bool(target_group["is_system"]) if target_group else False + if is_system and "permissions_submitted" in request.form: + flash("Die Rechte der Systemgruppe können nicht geändert werden.", "danger") + conn.close() + return redirect(url_for("groups")) + if is_system and target_group and name != target_group["name"]: + flash("Systemgruppen können nicht umbenannt werden.", "danger") + conn.close() + return redirect(url_for("groups")) + # Die Gruppenkarte (Name + Rechte) und das Mitglieder-Modal sind # zwei getrennte Formulare, damit das Speichern des einen nicht # versehentlich den Stand des anderen zurücksetzt. Die verstecken @@ -1917,6 +2227,12 @@ def groups(): flash("Eine Gruppe mit diesem Namen existiert bereits!", "danger") elif "assign_admins" in request.form: + # Bleibt bewusst admin-exklusiv, unabhängig von groups.edit — + # wer Admin ist/wird, darf nicht über ein delegierbares Recht + # gesteuert werden können. + if not current_user.is_admin: + flash("Nur Admins dürfen Admin-Mitgliedschaften ändern.", "danger") + return redirect(url_for("groups")) selected_admins = {int(x) for x in request.form.getlist("members") if x.isdigit()} if not selected_admins: flash("Es muss mindestens ein Admin bestehen bleiben.", "danger") @@ -1938,10 +2254,13 @@ def groups(): flash("Admin-Zuweisung aktualisiert.", "success") elif "delete_group" in request.form: + if not current_user.has_permission("groups.delete"): + flash("Keine Berechtigung, Gruppen zu löschen.", "danger") + return redirect(url_for("groups")) group_id = request.form.get("delete_group") - target = conn.execute("SELECT name, is_default FROM groups WHERE id=?", (group_id,)).fetchone() - if target and target["is_default"]: - flash(f"Die Standardgruppe '{target['name']}' kann nicht gelöscht werden.", "danger") + target = conn.execute("SELECT name, is_default, is_system FROM groups WHERE id=?", (group_id,)).fetchone() + if target and (target["is_default"] or target["is_system"]): + flash(f"Die Systemgruppe '{target['name']}' kann nicht gelöscht werden.", "danger") else: conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) conn.execute("DELETE FROM user_groups WHERE group_id=?", (group_id,)) @@ -1953,7 +2272,9 @@ def groups(): conn.close() return redirect(url_for("groups")) - group_rows = conn.execute("SELECT id, name, is_default FROM groups ORDER BY is_default DESC, name ASC").fetchall() + group_rows = conn.execute( + "SELECT id, name, is_default, is_system FROM groups ORDER BY is_default DESC, name ASC" + ).fetchall() all_users = conn.execute("SELECT id, username FROM users WHERE is_admin=0 ORDER BY username ASC").fetchall() groups_data = [] @@ -1971,6 +2292,7 @@ def groups(): "id": g["id"], "name": g["name"], "is_default": bool(g["is_default"]), + "is_system": bool(g["is_system"]), "permissions": {p["permission"] for p in perm_rows}, "members": {m["id"] for m in member_rows}, "member_names": [m["username"] for m in member_rows], diff --git a/srv/poe_manager/create_db.py b/srv/poe_manager/create_db.py index c24cdc3..0f80c76 100644 --- a/srv/poe_manager/create_db.py +++ b/srv/poe_manager/create_db.py @@ -68,7 +68,8 @@ c.execute(""" CREATE TABLE IF NOT EXISTS groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, - is_default INTEGER DEFAULT 0 + is_default INTEGER DEFAULT 0, + is_system INTEGER DEFAULT 0 ); """) @@ -119,14 +120,22 @@ CREATE TABLE IF NOT EXISTS audit_log ( c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("interval", "5")) c.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", ("check_interval", "300")) -# Standardgruppe 'Benutzer' mit allen Ansichtsrechten — jeder neu angelegte -# (nicht-admin) Benutzer landet automatisch darin (siehe app.py: users()). -cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default) VALUES (?, 1)", ("Benutzer",)) +# Standardgruppe 'Benutzer' — Systemgruppe (is_system), Name und Rechte +# sind über die UI fest und nicht änderbar. Jeder neu angelegte (nicht-admin) +# Benutzer landet automatisch darin (siehe app.py: users()). Der genaue +# Rechtesatz muss mit DEFAULT_GROUP_PERMISSIONS in app.py übereinstimmen. +cur = c.execute("INSERT OR IGNORE INTO groups (name, is_default, is_system) VALUES (?, 1, 1)", ("Benutzer",)) if cur.rowcount > 0: default_group_id = cur.lastrowid c.executemany( "INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)", - [(default_group_id, "devices.view"), (default_group_id, "switches.view")], + [ + (default_group_id, "devices_group.view"), + (default_group_id, "devices.view"), + (default_group_id, "switches.view"), + (default_group_id, "logs_group.view"), + (default_group_id, "logs_live.view"), + ], ) conn.commit() diff --git a/srv/poe_manager/static/css/style.css b/srv/poe_manager/static/css/style.css index 13c861d..0c904bd 100644 --- a/srv/poe_manager/static/css/style.css +++ b/srv/poe_manager/static/css/style.css @@ -1043,6 +1043,27 @@ select { margin-bottom: 8px; } +/* Zweistufiger Rechte-Baum (Gruppen-Detailansicht): eine Zeile pro + Top-Level-Bereich (Geräte/Logs/Einstellungen) mit eigenem "Bereich + anzeigen"-Kästchen, darunter eingerückt die Unterpunkte. */ +.permission-area { margin-bottom: 22px; } +.permission-area:last-child { margin-bottom: 0; } +.permission-area-toggle { + font-size: 14.5px; + padding-bottom: 8px; + margin-bottom: 14px; + border-bottom: 1px solid var(--border-soft); +} +.permission-area-toggle strong { font-weight: 700; } +.permission-area-children { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + padding-left: 22px; + border-left: 2px solid var(--border-soft); + margin-left: 5px; +} + .check-list { display: flex; flex-direction: column; gap: 9px; } .check-row { display: flex; diff --git a/srv/poe_manager/templates/credentials.html b/srv/poe_manager/templates/credentials.html index 053fc30..caecf1a 100644 --- a/srv/poe_manager/templates/credentials.html +++ b/srv/poe_manager/templates/credentials.html @@ -1,8 +1,8 @@ {% extends "base.html" %} {% set active_page = "credentials" %} -{% set can_create = current_user.has_permission('switches.create') %} -{% set can_edit = current_user.has_permission('switches.edit') %} -{% set can_delete = current_user.has_permission('switches.delete') %} +{% set can_create = current_user.has_permission('credentials.create') %} +{% set can_edit = current_user.has_permission('credentials.edit') %} +{% set can_delete = current_user.has_permission('credentials.delete') %} {% block page_title %}Zugangsdaten{% endblock %} {% block page_sub %}
{{ credentials|length }} Zugangsdaten
{% endblock %} diff --git a/srv/poe_manager/templates/devices.html b/srv/poe_manager/templates/devices.html index 411c240..3069677 100644 --- a/srv/poe_manager/templates/devices.html +++ b/srv/poe_manager/templates/devices.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% set active_page = "devices" %} -{% set can_toggle = current_user.has_permission('devices.toggle') %} +{% set can_toggle = current_user.has_permission('devices.edit') %} {% set can_create = current_user.has_permission('devices.create') %} {% set can_edit = current_user.has_permission('devices.edit') %} {% set can_delete = current_user.has_permission('devices.delete') %} diff --git a/srv/poe_manager/templates/groups.html b/srv/poe_manager/templates/groups.html index aa72f24..cadeb71 100644 --- a/srv/poe_manager/templates/groups.html +++ b/srv/poe_manager/templates/groups.html @@ -5,19 +5,57 @@ {% block content %} +{# Rendert den zweistufigen Rechte-Baum: eine Zeile pro Top-Level-Bereich + (Geräte/Logs/Einstellungen) mit eigenem "Bereich anzeigen"-Kästchen, + darunter eingerückt die Unterpunkte mit ihren jeweiligen Einzelrechten. + readonly=true zeigt nur den aktuellen Stand (Admin/Systemgruppen bzw. + fehlendes groups.edit), sonst ist der Baum Teil eines
. #} +{% macro permission_tree(checked_keys, readonly) %} + {% for group_key, group in permission_catalog.items() %} +
+ +
+ {% for child_key, child in group['children'].items() %} +
+
{{ child['label'] }}
+
+ {% for key, label in child['items'].items() %} + + {% endfor %} +
+
+ {% endfor %} +
+
+ {% endfor %} +{% endmacro %} +

Gruppen

- Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben. + Über Gruppen lassen sich Rechte für jeden Bereich der App granular vergeben. Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken, um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern. + „Admin“ und „Benutzer“ sind feste Systemgruppen und nicht veränderbar.
+ {% if current_user.has_permission('groups.create') %} + {% endif %}
@@ -49,45 +87,32 @@ -
- {% for cat_key, cat in permission_catalog.items() %} -
-
{{ cat['label'] }}
-
- {% for key, label in cat['items'].items() %} - - {% endfor %} -
-
- {% endfor %} -
+ {{ permission_tree(admin_virtual_group.permissions, true) }}

Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.

{% for g in groups %} + {% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %} {{ g.name }} - {% if g.is_default %}Standard{% endif %} + {% if g.is_system %}Standard{% endif %} {{ g.member_names|length }}
- - {% if not g.is_default %} + {% if current_user.has_permission('groups.delete') and not g.is_default and not g.is_system %}
+ {% else %} + {{ permission_tree(g.permissions, true) }} + {% if g.is_system %} +

Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.

+ {% endif %} + {% endif %} diff --git a/srv/poe_manager/templates/index.html b/srv/poe_manager/templates/index.html index aa924e4..4180e81 100644 --- a/srv/poe_manager/templates/index.html +++ b/srv/poe_manager/templates/index.html @@ -39,7 +39,7 @@
+ {% if current_user.has_permission('settings_system.edit') %}
@@ -22,5 +23,12 @@ Speichern & Service neustarten + {% else %} +
+ + +
Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.
+
+ {% endif %}
{% endblock %} diff --git a/srv/poe_manager/templates/settings_import_export.html b/srv/poe_manager/templates/settings_import_export.html index a30a3b9..897aeed 100644 --- a/srv/poe_manager/templates/settings_import_export.html +++ b/srv/poe_manager/templates/settings_import_export.html @@ -33,6 +33,7 @@
Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.
+ {% if current_user.has_permission('settings_importexport.edit') %}
@@ -48,6 +49,9 @@ Import starten + {% else %} +

Für den Import fehlt das Recht „Im-/Export ändern“.

+ {% endif %}
diff --git a/srv/poe_manager/templates/users.html b/srv/poe_manager/templates/users.html index 486aad0..e5d99c4 100644 --- a/srv/poe_manager/templates/users.html +++ b/srv/poe_manager/templates/users.html @@ -10,10 +10,12 @@

Benutzer

Benutzerkonten mit Zugriff auf den PoE Manager. Die Gruppe bestimmt, welche Verwaltungsrechte ein Benutzer hat.
+ {% if current_user.has_permission('users.create') %} + {% endif %}
@@ -39,8 +41,10 @@ {{ u['group_names'] or '—' }} {% endif %} + {% set may_touch_target = current_user.is_admin or not u['is_admin'] %}
+ {% if current_user.has_permission('users.edit') and may_touch_target %} + {% endif %} + {% if current_user.has_permission('users.delete') and may_touch_target %}
+ {% endif %}
@@ -82,7 +89,7 @@
@@ -135,7 +142,7 @@
Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.