Rechtesystem: Löschen überall in Ändern falten, Gruppen-Tabelle transponieren

- "Ändern" deckt jetzt ausnahmslos überall Löschen mit ab (auch bei
  Clients) — es gibt kein eigenes *.delete-Recht mehr. Betroffen:
  devices/switches/credentials/users/groups.
- "D" in der Rechtetabelle bedeutet ausschließlich Dashboard-Neustart
  (devices.restart, PoE-Neustart über das Dashboard-Popup) und existiert
  nur bei Clients. Automatische Neustarts bei Ausfall (poe.sh) bleiben
  davon komplett unabhängig.
- Gruppen-Rechtetabelle transponiert: Kategorien jetzt als Zeilen,
  Rechte R(ead)/W(rite)/E(dit)/D(ashboard-Neustart) nebeneinander als
  Spalten statt untereinander. Deckt sich besser mit dem Legenden-Format
  und braucht weniger vertikalen Platz.
- Neue, einmalig laufende Migration (_migrated_permission_delete_fold_v2)
  überführt bestehende *.delete-Grants verlustfrei in *.edit, bevor die
  veralteten Rechte-Zeilen entfernt werden.
- Routen (delete_device/delete_switch/delete_credential/delete_user/
  delete_group) und die zugehörigen can_delete-Template-Variablen prüfen
  jetzt einheitlich *.edit statt *.delete.
- README-Rechtesystem-Abschnitt und Legende an das neue Tabellen-Layout
  angepasst.
- Live getestet: Kill-Switch, alle 5 Delete-Foldings (mit/ohne Recht),
  devices.restart-Gating unabhängig von devices.edit, Eskalationsschutz
  (Admin-Löschschutz, Systemgruppen-Schutz) — alles bestätigt korrekt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 19:49:28 +02:00
co-authored by Claude Sonnet 5
parent 4b7403f476
commit 813b17d002
8 changed files with 291 additions and 136 deletions
+117 -56
View File
@@ -125,10 +125,20 @@ login_manager.init_app(app)
# ---------------------------------------------------------------------------
# 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).
# anzeigen"-Recht (view_key) und darunter mehrere Unterpunkte (Spalten in der
# Gruppen-Tabelle). Jeder Unterpunkt liefert "rows": eine Teilmenge der vier
# kanonischen Rechte-Zeilen (view/create/edit/delete = L/S/Ä/D, siehe
# PERMISSION_ROW_TYPES) — fehlt eine Zeile für einen Unterpunkt (z.B.
# "delete" bei Systemeinstellungen), wird die Zelle in der Tabelle grau und
# nicht setzbar dargestellt statt einfach zu fehlen, damit alle Spalten
# eines Bereichs bündig nebeneinander in einer kompakten Tabelle stehen.
PERMISSION_ROW_TYPES = [
("view", "R", "Lesen (Read)"),
("create", "W", "Schreiben/Anlegen (Write)"),
("edit", "E", "Ändern inkl. Löschen (Edit)"),
("extra", "D", "Dashboard-Neustart (nur bei Clients — kein Löschen-Recht)"),
]
PERMISSIONS = {
"devices_group": {
"label": "Geräte",
@@ -136,30 +146,33 @@ PERMISSIONS = {
"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",
# "Ändern" deckt Löschen überall mit ab, auch hier — es gibt
# nirgends ein eigenes Löschen-Recht. Die vierte Spalte (D)
# ist bei Clients KEIN Löschen, sondern das Recht, einen
# PoE-Neustart über das Dashboard-Popup auszulösen
# (automatische Neustarts bei Ausfall bleiben davon unberührt).
"rows": {
"view": "devices.view",
"create": "devices.create",
"edit": "devices.edit",
"extra": "devices.restart",
},
},
"switches": {
"label": "Switche",
"items": {
"switches.view": "Lesen",
"switches.create": "Schreiben (Anlegen)",
"switches.edit": "Ändern",
"switches.delete": "Löschen",
# Kein eigenes Löschen-Recht — "Ändern" deckt das mit ab.
"rows": {
"view": "switches.view",
"create": "switches.create",
"edit": "switches.edit",
},
},
"credentials": {
"label": "Zugangsdaten",
"items": {
"credentials.view": "Lesen",
"credentials.create": "Schreiben (Anlegen)",
"credentials.edit": "Ändern",
"credentials.delete": "Löschen",
"rows": {
"view": "credentials.view",
"create": "credentials.create",
"edit": "credentials.edit",
},
},
},
@@ -168,14 +181,8 @@ PERMISSIONS = {
"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"},
},
"logs_live": {"label": "Live", "rows": {"view": "logs_live.view"}},
"logs_activity": {"label": "Änderungen", "rows": {"view": "logs_activity.view"}},
},
},
"settings_group": {
@@ -184,40 +191,55 @@ PERMISSIONS = {
"children": {
"users": {
"label": "Benutzer",
"items": {
"users.view": "Lesen",
"users.create": "Schreiben (Anlegen)",
"users.edit": "Ändern",
"users.delete": "Löschen",
"rows": {
"view": "users.view",
"create": "users.create",
"edit": "users.edit",
},
},
"groups": {
"label": "Gruppen",
"items": {
"groups.view": "Lesen",
"groups.create": "Schreiben (Anlegen)",
"groups.edit": "Ändern",
"groups.delete": "Löschen",
"rows": {
"view": "groups.view",
"create": "groups.create",
"edit": "groups.edit",
},
},
"settings_system": {
"label": "Systemeinstellungen",
"items": {
"settings_system.view": "Lesen",
"settings_system.edit": "Ändern",
},
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
},
"settings_importexport": {
"label": "Im-/Export",
"items": {
"settings_importexport.view": "Lesen (Export)",
"settings_importexport.edit": "Ändern (Import)",
},
# L = Export (nur lesend), Ä = Import (verändert Bestand).
"rows": {"view": "settings_importexport.view", "edit": "settings_importexport.edit"},
},
},
},
}
# Menschenlesbare Einzel-Labels je Recht (Tooltips auf den Checkboxen in der
# Tabelle) — aus PERMISSIONS abgeleitet, damit es nur eine Quelle gibt.
PERMISSION_LABELS = {
"devices_group.view": "Geräte-Bereich anzeigen",
"logs_group.view": "Logs-Bereich anzeigen",
"settings_group.view": "Einstellungen-Bereich anzeigen",
"devices.view": "Clients lesen", "devices.create": "Clients anlegen",
"devices.edit": "Clients ändern (inkl. Aktivieren/Deaktivieren, Löschen)",
"devices.restart": "PoE-Neustart über das Dashboard ausführen (automatische Neustarts bei Ausfall bleiben unberührt)",
"switches.view": "Switche lesen", "switches.create": "Switche anlegen",
"switches.edit": "Switche ändern (inkl. Löschen)",
"credentials.view": "Zugangsdaten lesen", "credentials.create": "Zugangsdaten anlegen",
"credentials.edit": "Zugangsdaten ändern (inkl. Löschen)",
"logs_live.view": "Live-Log lesen", "logs_activity.view": "Änderungslog lesen",
"users.view": "Benutzer lesen", "users.create": "Benutzer anlegen",
"users.edit": "Benutzer ändern (inkl. Löschen)",
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
"groups.edit": "Gruppen ändern (inkl. Löschen)",
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern",
"settings_importexport.view": "Daten exportieren", "settings_importexport.edit": "Daten importieren",
}
# 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
@@ -230,7 +252,7 @@ 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"]:
for _key in _child["rows"].values():
ALL_PERMISSION_KEYS.append(_key)
PERMISSION_PARENT_GROUP[_key] = _group["view_key"]
@@ -352,13 +374,13 @@ class User(UserMixin):
@property
def can_manage_switches(self):
return self.has_any_permission(
["switches.view", "switches.create", "switches.edit", "switches.delete"]
["switches.view", "switches.create", "switches.edit"]
)
@property
def can_manage_credentials(self):
return self.has_any_permission(
["credentials.view", "credentials.create", "credentials.edit", "credentials.delete"]
["credentials.view", "credentials.create", "credentials.edit"]
)
@property
@@ -372,13 +394,13 @@ class User(UserMixin):
@property
def can_manage_users(self):
return self.has_any_permission(
["users.view", "users.create", "users.edit", "users.delete"]
["users.view", "users.create", "users.edit"]
)
@property
def can_manage_groups(self):
return self.has_any_permission(
["groups.view", "groups.create", "groups.edit", "groups.delete"]
["groups.view", "groups.create", "groups.edit"]
)
@property
@@ -595,6 +617,38 @@ def _ensure_schema():
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key,)
)
# Migration v2: "Löschen" als eigenes Recht wieder abgeschafft — "Ändern"
# deckt das Löschen jetzt überall mit ab (auch bei Clients, keine
# Ausnahme mehr). Gruppen, die bisher nur *.delete ohne *.edit hatten,
# würden sonst durch das Update ihr Löschen-Recht stillschweigend
# verlieren — deshalb erst *.edit nachtragen, dann die veralteten
# *.delete-Zeilen entfernen. Ebenfalls einmalig per Guard, aus demselben
# Grund wie bei v1 (kein Zurückschreiben bei jedem Neustart nötig, da
# *.delete als Recht komplett entfällt und nie wieder vorkommen kann).
_migration_key_v2 = "_migrated_permission_delete_fold_v2"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_v2,)).fetchone():
_delete_to_edit = {
"devices.delete": "devices.edit",
"switches.delete": "switches.edit",
"credentials.delete": "credentials.edit",
"users.delete": "users.edit",
"groups.delete": "groups.edit",
}
for delete_perm, edit_perm in _delete_to_edit.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (delete_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], edit_perm),
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (delete_perm,))
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_v2,)
)
# 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
@@ -1366,7 +1420,8 @@ def devices():
flash("Fehler beim Aktualisieren des Geräts!", "danger")
elif "delete_device" in request.form:
if not current_user.has_permission("devices.delete"):
# "Ändern" deckt Löschen mit ab (kein eigenes devices.delete-Recht).
if not current_user.has_permission("devices.edit"):
flash("Keine Berechtigung, Geräte zu löschen.", "danger")
return redirect(url_for("devices"))
@@ -1528,7 +1583,8 @@ def switches():
@app.route("/switches/delete/<hostname>", methods=["POST"])
@login_required
def delete_switch(hostname):
if not current_user.has_permission("switches.delete"):
# "Ändern" deckt Löschen mit ab (kein eigenes switches.delete-Recht).
if not current_user.has_permission("switches.edit"):
flash("Keine Berechtigung, Switche zu löschen.", "danger")
return redirect(url_for("switches"))
@@ -1613,7 +1669,8 @@ 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("credentials.delete"):
# "Ändern" deckt Löschen mit ab (kein eigenes credentials.delete-Recht).
if not current_user.has_permission("credentials.edit"):
flash("Keine Berechtigung, Zugangsdaten zu löschen.", "danger")
return redirect(url_for("credentials"))
cred_id = request.form.get("delete_credential")
@@ -2098,7 +2155,8 @@ def users():
flash("Gruppe zugewiesen!", "success")
elif "delete_user" in request.form:
if not current_user.has_permission("users.delete"):
# "Ändern" deckt Löschen mit ab (kein eigenes users.delete-Recht).
if not current_user.has_permission("users.edit"):
flash("Keine Berechtigung, Benutzer zu löschen.", "danger")
return redirect(url_for("users"))
@@ -2254,7 +2312,8 @@ def groups():
flash("Admin-Zuweisung aktualisiert.", "success")
elif "delete_group" in request.form:
if not current_user.has_permission("groups.delete"):
# "Ändern" deckt Löschen mit ab (kein eigenes groups.delete-Recht).
if not current_user.has_permission("groups.edit"):
flash("Keine Berechtigung, Gruppen zu löschen.", "danger")
return redirect(url_for("groups"))
group_id = request.form.get("delete_group")
@@ -2318,6 +2377,8 @@ def groups():
all_users=all_users,
all_users_all=all_users_all,
permission_catalog=PERMISSIONS,
permission_row_types=PERMISSION_ROW_TYPES,
permission_labels=PERMISSION_LABELS,
)