Vollständig granulares, an der Navbar gespiegeltes Rechtesystem

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 18:28:42 +02:00
co-authored by Claude Sonnet 5
parent 52b14b8ef7
commit 4b7403f476
11 changed files with 580 additions and 173 deletions
+72 -54
View File
@@ -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.
+395 -73
View File
@@ -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/<mac>", 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],
+14 -5
View File
@@ -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()
+21
View File
@@ -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;
+3 -3
View File
@@ -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 %}<div class="topbar-sub">{{ credentials|length }} Zugangsdaten</div>{% endblock %}
+1 -1
View File
@@ -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') %}
+52 -34
View File
@@ -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 <form>. #}
{% macro permission_tree(checked_keys, readonly) %}
{% for group_key, group in permission_catalog.items() %}
<div class="permission-area">
<label class="check-row permission-area-toggle" {% if readonly %}style="cursor:default; color:var(--text-faint);"{% endif %}>
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
{% if group['view_key'] in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}>
<strong>{{ group['label'] }}</strong>
</label>
<div class="permission-area-children">
{% for child_key, child in group['children'].items() %}
<div>
<div class="permission-group-title">{{ child['label'] }}</div>
<div class="check-list">
{% for key, label in child['items'].items() %}
<label class="check-row" {% if readonly %}style="cursor:default; color:var(--text-faint);"{% endif %}>
<input type="checkbox" name="permissions" value="{{ key }}"
{% if key in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endmacro %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Gruppen</h2>
<div class="hint">
Ü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.
</div>
</div>
{% if current_user.has_permission('groups.create') %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Gruppe
</button>
{% endif %}
</div>
<div class="table-wrap">
@@ -49,45 +87,32 @@
</tr>
<tr class="group-detail-row hidden" id="detail-admin">
<td colspan="3">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
{% for cat_key, cat in permission_catalog.items() %}
<div>
<div class="permission-group-title">{{ cat['label'] }}</div>
<div class="check-list">
{% for key, label in cat['items'].items() %}
<label class="check-row" style="cursor:default; color:var(--text-faint);">
<input type="checkbox" checked disabled>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{{ permission_tree(admin_virtual_group.permissions, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</td>
</tr>
</tbody>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
<!-- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit
die Sortierung beide Zeilen gemeinsam verschiebt. -->
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
{{ g.name }}
{% if g.is_default %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
{% if g.is_system %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
</td>
<td class="text-dim">{{ g.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen/bearbeiten" onclick="toggleDetail('detail-{{ g.id }}')">
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
</button>
{% if not g.is_default %}
{% if current_user.has_permission('groups.delete') and not g.is_default and not g.is_system %}
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
<input type="hidden" name="delete_group" value="{{ g.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
@@ -100,26 +125,13 @@
</tr>
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
<td colspan="3">
{% if can_edit_this %}
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
{% for cat_key, cat in permission_catalog.items() %}
<div>
<div class="permission-group-title">{{ cat['label'] }}</div>
<div class="check-list">
{% for key, label in cat['items'].items() %}
<label class="check-row">
<input type="checkbox" name="permissions" value="{{ key }}" {% if key in g.permissions %}checked{% endif %}>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{{ permission_tree(g.permissions, false) }}
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
@@ -127,6 +139,12 @@
</button>
</div>
</form>
{% else %}
{{ permission_tree(g.permissions, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
{% endif %}
</td>
</tr>
</tbody>
+1 -1
View File
@@ -39,7 +39,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-close-modal>Schließen</button>
{% if current_user.has_permission('devices.toggle') %}
{% if current_user.has_permission('devices.edit') %}
<button type="button" class="btn btn-success" id="activateButton" style="display:none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L20 7"/></svg>
Aktivieren
+8
View File
@@ -11,6 +11,7 @@
<div class="hint">Wie oft sollen Geräte auf Erreichbarkeit geprüft werden?</div>
</div>
</div>
{% if current_user.has_permission('settings_system.edit') %}
<form method="post">
<div class="field">
<label for="interval">Intervall (Minuten)</label>
@@ -22,5 +23,12 @@
Speichern & Service neustarten
</button>
</form>
{% else %}
<div class="field">
<label>Intervall (Minuten)</label>
<input type="number" value="{{ interval }}" disabled>
<div class="field-hint">Nur Lesezugriff — für Änderungen fehlt das Recht „Systemeinstellungen ändern“.</div>
</div>
{% endif %}
</div>
{% endblock %}
@@ -33,6 +33,7 @@
<div class="hint">Ein zuvor exportiertes Bundle einlesen. Bestehende Einträge mit gleichem Namen/Hostname/MAC werden aktualisiert, neue werden angelegt.</div>
</div>
</div>
{% if current_user.has_permission('settings_importexport.edit') %}
<form method="post" action="{{ url_for('import_data') }}" enctype="multipart/form-data"
data-confirm="Import wirklich starten? Bestehende Einträge mit gleichem Namen/Hostname/MAC werden überschrieben.">
<div class="field">
@@ -48,6 +49,9 @@
Import starten
</button>
</form>
{% else %}
<p class="text-faint" style="font-size:12.5px;">Für den Import fehlt das Recht „Im-/Export ändern“.</p>
{% endif %}
</div>
</div>
+9 -2
View File
@@ -10,10 +10,12 @@
<h2 style="font-size:16px;">Benutzer</h2>
<div class="hint">Benutzerkonten mit Zugriff auf den PoE Manager. Die Gruppe bestimmt, welche Verwaltungsrechte ein Benutzer hat.</div>
</div>
{% if current_user.has_permission('users.create') %}
<button type="button" class="btn btn-primary" data-open-modal="userModal" onclick="document.getElementById('userForm').reset();">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neuer Benutzer
</button>
{% endif %}
</div>
<div class="table-wrap">
@@ -39,8 +41,10 @@
<span class="text-dim">{{ u['group_names'] or '—' }}</span>
{% endif %}
</td>
{% set may_touch_target = current_user.is_admin or not u['is_admin'] %}
<td>
<div class="row-actions">
{% if current_user.has_permission('users.edit') and may_touch_target %}
<button class="icon-btn" title="Bearbeiten"
onclick="openEditModal({{ u['id'] }}, '{{ u['username'] }}', '{{ u['first_name'] or '' }}', '{{ u['last_name'] or '' }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 013 3L7 19l-4 1 1-4z"/></svg>
@@ -49,11 +53,14 @@
onclick="openGroupModal({{ u['id'] }}, '{{ 'admin' if u['is_admin'] else (u['group_id'] or '') }}')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="2.5"/><circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="19" r="2.5"/><path d="M8.2 10.7l7.6-4.4M8.2 13.3l7.6 4.4"/></svg>
</button>
{% endif %}
{% if current_user.has_permission('users.delete') and may_touch_target %}
<form method="post" data-confirm="Willst du „{{ u['username'] }}“ wirklich löschen?">
<button type="submit" name="delete_user" value="{{ u['id'] }}" class="icon-btn" style="color:var(--danger);" title="Löschen">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0l-1 14a2 2 0 01-2 2H7a2 2 0 01-2-2L4 6"/></svg>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
@@ -82,7 +89,7 @@
<label>Gruppe</label>
<select name="group_id">
{% for g in all_groups %}<option value="{{ g['id'] }}" {% if g['is_default'] %}selected{% endif %}>{{ g['name'] }}</option>{% endfor %}
<option value="admin">Admin</option>
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
</div>
</div>
@@ -135,7 +142,7 @@
<select name="group_id" id="group_select">
<option value="">Keine Gruppe</option>
{% for g in all_groups %}<option value="{{ g['id'] }}">{{ g['name'] }}</option>{% endfor %}
<option value="admin">Admin</option>
{% if current_user.is_admin %}<option value="admin">Admin</option>{% endif %}
</select>
<div class="field-hint">Ersetzt die bisherige Gruppen-/Rollenzuordnung dieses Benutzers.</div>
</div>