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
+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],