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:
+395
-73
@@ -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],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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') %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user