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
+52 -34
View File
@@ -5,19 +5,57 @@
{% block content %}
{# Rendert den zweistufigen Rechte-Baum: eine Zeile pro Top-Level-Bereich
(Geräte/Logs/Einstellungen) mit eigenem "Bereich anzeigen"-Kästchen,
darunter eingerückt die Unterpunkte mit ihren jeweiligen Einzelrechten.
readonly=true zeigt nur den aktuellen Stand (Admin/Systemgruppen bzw.
fehlendes groups.edit), sonst ist der Baum Teil eines <form>. #}
{% macro permission_tree(checked_keys, readonly) %}
{% for group_key, group in permission_catalog.items() %}
<div class="permission-area">
<label class="check-row permission-area-toggle" {% if readonly %}style="cursor:default; color:var(--text-faint);"{% endif %}>
<input type="checkbox" name="permissions" value="{{ group['view_key'] }}"
{% if group['view_key'] in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}>
<strong>{{ group['label'] }}</strong>
</label>
<div class="permission-area-children">
{% for child_key, child in group['children'].items() %}
<div>
<div class="permission-group-title">{{ child['label'] }}</div>
<div class="check-list">
{% for key, label in child['items'].items() %}
<label class="check-row" {% if readonly %}style="cursor:default; color:var(--text-faint);"{% endif %}>
<input type="checkbox" name="permissions" value="{{ key }}"
{% if key in checked_keys %}checked{% endif %}
{% if readonly %}disabled{% endif %}>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% endmacro %}
<div class="section-head">
<div>
<h2 style="font-size:16px;">Gruppen</h2>
<div class="hint">
Über Gruppen lassen sich einzelne Verwaltungsrechte für Devices und Switches gezielt vergeben.
Über Gruppen lassen sich Rechte für jeden Bereich der App granular vergeben.
Ein Benutzer kann mehreren Gruppen angehören — die Rechte addieren sich. Auf „Rechte“ klicken,
um eine Gruppe aufzuklappen und die Berechtigungen im Detail zu sehen bzw. zu ändern.
„Admin“ und „Benutzer“ sind feste Systemgruppen und nicht veränderbar.
</div>
</div>
{% if current_user.has_permission('groups.create') %}
<button type="button" class="btn btn-primary" data-open-modal="addGroupModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
Neue Gruppe
</button>
{% endif %}
</div>
<div class="table-wrap">
@@ -49,45 +87,32 @@
</tr>
<tr class="group-detail-row hidden" id="detail-admin">
<td colspan="3">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
{% for cat_key, cat in permission_catalog.items() %}
<div>
<div class="permission-group-title">{{ cat['label'] }}</div>
<div class="check-list">
{% for key, label in cat['items'].items() %}
<label class="check-row" style="cursor:default; color:var(--text-faint);">
<input type="checkbox" checked disabled>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{{ permission_tree(admin_virtual_group.permissions, true) }}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Admins dürfen immer alles — diese Rechte sind fest und nicht änderbar.</p>
</td>
</tr>
</tbody>
{% for g in groups %}
{% set can_edit_this = current_user.has_permission('groups.edit') and not g.is_system %}
<!-- Jede Gruppe (Haupt- + Detail-Zeile) in einem eigenen <tbody>, damit
die Sortierung beide Zeilen gemeinsam verschiebt. -->
<tbody data-sort-name="{{ g.name|lower }}" data-sort-members="{{ g.member_names|length }}">
<tr>
<td class="cell-name">
{{ g.name }}
{% if g.is_default %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
{% if g.is_system %}<span class="pill user" style="white-space:nowrap;">Standard</span>{% endif %}
</td>
<td class="text-dim">{{ g.member_names|length }}</td>
<td>
<div class="row-actions">
<button class="icon-btn" title="Rechte anzeigen/bearbeiten" onclick="toggleDetail('detail-{{ g.id }}')">
<button class="icon-btn" title="Rechte anzeigen{{ '/bearbeiten' if can_edit_this else '' }}" onclick="toggleDetail('detail-{{ g.id }}')">
<svg id="chev-{{ g.id }}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>
</button>
<button class="icon-btn" title="Mitglieder verwalten" data-open-modal="membersModal{{ loop.index }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M2.5 20c0-3.6 2.9-6 6.5-6s6.5 2.4 6.5 6"/><circle cx="17.5" cy="8.5" r="2.4"/><path d="M15.8 14.2c2.7.3 4.7 2.4 4.7 5.3"/></svg>
</button>
{% if not g.is_default %}
{% if current_user.has_permission('groups.delete') and not g.is_default and not g.is_system %}
<form method="post" data-confirm="Gruppe „{{ g.name }}“ wirklich löschen? Mitglieder verlieren die zugehörigen Rechte.">
<input type="hidden" name="delete_group" value="{{ g.id }}">
<button type="submit" class="icon-btn" style="color:var(--danger);" title="Löschen">
@@ -100,26 +125,13 @@
</tr>
<tr class="group-detail-row hidden" id="detail-{{ g.id }}">
<td colspan="3">
{% if can_edit_this %}
<form method="post">
<input type="hidden" name="save_group" value="1">
<input type="hidden" name="permissions_submitted" value="1">
<input type="hidden" name="group_id" value="{{ g.id }}">
<input type="hidden" name="name" value="{{ g.name }}">
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:24px;">
{% for cat_key, cat in permission_catalog.items() %}
<div>
<div class="permission-group-title">{{ cat['label'] }}</div>
<div class="check-list">
{% for key, label in cat['items'].items() %}
<label class="check-row">
<input type="checkbox" name="permissions" value="{{ key }}" {% if key in g.permissions %}checked{% endif %}>
{{ label }}
</label>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{{ permission_tree(g.permissions, false) }}
<div class="flex" style="justify-content:flex-end; margin-top:16px;">
<button type="submit" class="btn btn-primary btn-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg>
@@ -127,6 +139,12 @@
</button>
</div>
</form>
{% else %}
{{ permission_tree(g.permissions, true) }}
{% if g.is_system %}
<p class="text-faint" style="font-size:11.5px; margin:12px 0 0;">Die Standardgruppe „Benutzer“ ist eine Systemgruppe — ihre Rechte sind fest und nicht änderbar.</p>
{% endif %}
{% endif %}
</td>
</tr>
</tbody>