Compare commits

2 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 286a3464f1 Eigenes Recht fuer Tickets (tickets.view/.create/.edit)
Tickets liefen im Rechtebaum komplett ueber licenses.view/.create/.edit
mit, obwohl Tickets ein eigener, gleichberechtigter Navigationspunkt
neben Kunden und Lizenzen ist -- analog zu TESM, wo Clients/Switche/
Zugangsdaten trotz enger fachlicher Verwandtschaft jeweils eigene
Rechte haben. Ergaenzt die im vorigen Release (v1.2.4) behobene
Lizenz-Rechte-Luecke um diese zweite, gleich gelagerte Luecke.

- Neues Rechtepaar tickets.view/tickets.create/tickets.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_manage_tickets.
- Nav-Sichtbarkeit sowie tickets_list()/ticket_new()/ticket_edit()/
  ticket_delete() auf das neue Recht umgestellt.
- ticket_detail(): GET-Zugriff jetzt ueber can_manage_tickets. POST
  jetzt PRO SUB-AKTION geprueft statt eines einzigen Gates:
  'toggle_block' (Sperren/Entsperren) -> tickets.edit (neu, Ticket-
  Domain); 'issue_license' (erstellt eine LIZENZ) bleibt bewusst
  unveraendert an licenses.edit gebunden, damit sich 'Tickets
  verwalten' und 'Lizenzen ausstellen' unabhaengig voneinander
  vergeben lassen, ohne dass irgendjemand automatisch die eine
  Faehigkeit durch die andere verliert.
- tickets.html/ticket_detail.html: can_edit-Variable entsprechend in
  can_edit_ticket/can_issue_license aufgeteilt.
- Migration (_migrated_tickets_permission_v1, einmalig geguarded):
  jede Gruppe mit licenses.view/.create/.edit bekommt automatisch das
  gespiegelte tickets.view/.create/.edit dazu -- niemand verliert beim
  Update stillschweigend eine Faehigkeit, die vorher implizit ueber
  licenses.* mitlief.
- Getestet gegen isolierte Test-DB: Backfill korrekt (Gruppe mit voller
  Lizenz-Berechtigung bekommt volle Ticket-Berechtigung, Gruppe mit nur
  .view bekommt nur tickets.view), idempotent, keine Duplikate.
  PERMISSIONS/PERMISSION_LABELS/NAV_ITEMS erneut vollstaendig
  konsistent (keine Nav-Keys mehr ohne Rechte-Entsprechung), app.py
  syntaktisch fehlerfrei kompiliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:33:33 +02:00
alientimandClaude Sonnet 5 619673ac76 Eigenes Recht fuer die Lizenzseite (settings_license.view/.edit)
Die Lizenzseite (Einstellungen -> Lizenz) hatte im Rechtebaum keinen
eigenen Eintrag -- sie lief komplett ueber settings_system.view/.edit
mit, genau wie im TESM-Client (siehe dortiger Fix). Damit konnte
'Lizenz verwalten' nicht unabhaengig von 'Systemeinstellungen
verwalten' vergeben/eingeschraenkt werden.

- Neues Rechtepaar settings_license.view/settings_license.edit
  (PERMISSIONS/PERMISSION_LABELS), Property can_view_settings_license.
- Nav-Sichtbarkeit und alle /settings/license*-Routen auf das neue
  Recht umgestellt (view-Route: can_view_settings_license; alle
  mutierenden Routen -upload/-activate(/confirm)/-deactivate(/confirm):
  settings_license.edit). Das 'auch ohne aktive Lizenz sichtbar
  bleiben'-Verhalten bleibt unveraendert (andere Pruefung, license_active()).
- settings_license.html: can_edit auf settings_license.edit umgestellt.
- Migration (_migrated_settings_license_permission_v1, einmalig
  geguarded): jede Gruppe mit settings_system.view/.edit bekommt
  automatisch settings_license.view/.edit dazu -- niemand verliert beim
  Update stillschweigend Zugriff, den er vorher implizit hatte.
- Getestet gegen isolierte Test-DB (create_db.py-Frischinstallation):
  Migration korrekt (inkl. Gruppe mit nur .view, die korrekt KEIN .edit
  bekommt), idempotent bei zweitem Lauf, keine Duplikate. PERMISSIONS/
  PERMISSION_LABELS/NAV_ITEMS strukturell konsistent geprueft, app.py
  syntaktisch fehlerfrei kompiliert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 21:11:02 +02:00
4 changed files with 126 additions and 37 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.3
1.2.5
+121 -32
View File
@@ -212,6 +212,10 @@ PERMISSIONS = {
"label": "Kunden",
"rows": {"view": "customers.view", "create": "customers.create", "edit": "customers.edit"},
},
"tickets": {
"label": "Tickets",
"rows": {"view": "tickets.view", "create": "tickets.create", "edit": "tickets.edit"},
},
"licenses": {
"label": "Lizenzen",
"rows": {"view": "licenses.view", "create": "licenses.create", "edit": "licenses.edit"},
@@ -251,6 +255,10 @@ PERMISSIONS = {
"label": "Systemeinstellungen",
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
},
"settings_license": {
"label": "Lizenz",
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
},
"settings_importexport": {
"label": "Im-/Export",
"rows": {
@@ -275,6 +283,8 @@ PERMISSION_LABELS = {
"licensing_group.view": "Lizenzierung-Bereich anzeigen",
"customers.view": "Kunden lesen", "customers.create": "Kunden anlegen",
"customers.edit": "Kunden ändern (inkl. Löschen)",
"tickets.view": "Tickets lesen", "tickets.create": "Tickets anlegen",
"tickets.edit": "Tickets ändern (Eckdaten, Sperren/Entsperren, Löschen)",
"licenses.view": "Lizenzen und Aktivierungsstatus lesen",
"licenses.create": "Lizenzen ausstellen",
"licenses.edit": "Lizenzen ändern (Deaktivieren/Widerrufen, erneut versenden)",
@@ -288,6 +298,8 @@ PERMISSION_LABELS = {
"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_license.view": "Lizenzstatus ansehen",
"settings_license.edit": "Lizenzdatei hochladen sowie Lizenz aktivieren/deaktivieren (online oder per Code)",
"settings_importexport.view": "Im-/Export-Seite ansehen",
"settings_importexport.edit": "Daten importieren",
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
@@ -349,7 +361,7 @@ def _nav_key_visible(key, user):
if key == "index":
return True
if key == "tickets":
return user.has_permission("licenses.view")
return user.has_permission("tickets.view")
if key == "licenses":
return user.has_permission("licenses.view")
if key == "customers":
@@ -361,10 +373,11 @@ def _nav_key_visible(key, user):
if key == "settings_system":
return user.can_view_settings_system
if key == "settings_license":
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht --
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand
# jemals eine erste Lizenz hochladen/aktivieren.
return user.can_view_settings_system
# Eigenes Recht (settings_license.view), unabhängig von
# settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
# bleiben (das ist eine andere Prüfung, siehe license_active()),
# sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
return user.can_view_settings_license
if key == "settings_importexport":
return user.can_view_settings_importexport
if key == "settings_ldap":
@@ -537,6 +550,12 @@ class User(UserMixin):
["customers.view", "customers.create", "customers.edit"]
)
@property
def can_manage_tickets(self):
return self.has_any_permission(
["tickets.view", "tickets.create", "tickets.edit"]
)
@property
def can_manage_licenses(self):
return self.has_any_permission(
@@ -559,6 +578,10 @@ class User(UserMixin):
def can_view_settings_system(self):
return self.has_permission("settings_system.view")
@property
def can_view_settings_license(self):
return self.has_permission("settings_license.view")
@property
def can_view_settings_importexport(self):
return self.has_any_permission([
@@ -877,6 +900,64 @@ def _ensure_schema():
conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"]))
conn.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets,))
_migration_key_license_perm = "_migrated_settings_license_permission_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_license_perm,)).fetchone():
# "Lizenz" (settings_license) hatte bislang KEIN eigenes Recht --
# die Seite lief komplett über settings_system.view/.edit mit.
# Backfill: jede Gruppe, die settings_system.view/.edit bereits
# hatte, bekommt automatisch das neue, gespiegelte
# settings_license.view/.edit dazu -- ohne diesen Schritt würden
# bestehende Gruppen beim Update stillschweigend den Zugriff auf
# die Lizenzseite verlieren, den sie vorher implizit hatten.
_system_to_license = {
"settings_system.view": "settings_license.view",
"settings_system.edit": "settings_license.edit",
}
for system_perm, license_perm in _system_to_license.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (system_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], license_perm),
)
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_license_perm,)
)
_migration_key_tickets_perm = "_migrated_tickets_permission_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_tickets_perm,)).fetchone():
# "Tickets" hatte bislang KEIN eigenes Recht -- Ticketverwaltung
# (anlegen/bearbeiten/sperren/loeschen) lief komplett ueber
# licenses.view/.create/.edit mit, obwohl Tickets ein eigener,
# gleichberechtigter Navigationspunkt neben Kunden und Lizenzen
# ist (genau wie bei TESM Clients/Switche/Zugangsdaten trotz enger
# fachlicher Verwandtschaft jeweils eigene Rechte haben). Backfill:
# jede Gruppe mit licenses.view/.create/.edit bekommt automatisch
# das gespiegelte tickets.view/.create/.edit dazu -- das
# Ausstellen einer Lizenz AUS einem Ticket heraus bleibt bewusst
# weiterhin an licenses.edit gebunden (siehe ticket_detail()),
# nur Sperren/Entsperren/Bearbeiten/Loeschen des Tickets selbst
# wandert auf das neue, unabhaengige Recht.
_license_to_ticket = {
"licenses.view": "tickets.view",
"licenses.create": "tickets.create",
"licenses.edit": "tickets.edit",
}
for license_perm, ticket_perm in _license_to_ticket.items():
rows = conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?", (license_perm,)
).fetchall()
for row in rows:
conn.execute(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?, ?)",
(row["group_id"], ticket_perm),
)
conn.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, '1')", (_migration_key_tickets_perm,)
)
_migration_key_old_logs = "_cleaned_up_legacy_rpi_log_files_v1"
if not conn.execute("SELECT 1 FROM settings WHERE key=?", (_migration_key_old_logs,)).fetchone():
for stale_log in glob.glob("/var/log/rpi-*.log"):
@@ -2528,7 +2609,7 @@ def ticket_new():
Lizenz mit bestimmten Eckdaten. Erstellt bewusst noch KEINE Lizenz;
das übernimmt ein separater Schritt auf der Ticket-Detailseite (durch
Admin ODER Kunde per Self-Service)."""
if not current_user.has_permission("licenses.create"):
if not current_user.has_permission("tickets.create"):
flash("Keine Berechtigung, Tickets anzulegen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
@@ -2599,7 +2680,7 @@ def ticket_new():
@app.route("/tickets/<ticket_id>", methods=["GET", "POST"])
@login_required
def ticket_detail(ticket_id):
if not current_user.can_manage_licenses:
if not current_user.can_manage_tickets:
flash("Keine Berechtigung.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
@@ -2610,10 +2691,14 @@ def ticket_detail(ticket_id):
return redirect(url_for("index"))
if request.method == "POST":
if not current_user.has_permission("licenses.edit"):
flash("Keine Berechtigung.", "danger")
elif "issue_license" in request.form:
if not license_active():
if "issue_license" in request.form:
# Ausstellen erzeugt eine LIZENZ (nicht nur eine Ticket-Aenderung)
# -- bewusst weiterhin ueber das Lizenz-Recht gegated, nicht ueber
# tickets.edit, damit sich "Tickets verwalten" und "Lizenzen
# ausstellen" unabhaengig voneinander vergeben lassen.
if not current_user.has_permission("licenses.edit"):
flash("Keine Berechtigung, Lizenzen auszustellen.", "danger")
elif not license_active():
flash("Der Lizenzserver selbst hat keine gültige Lizenz -- Ausstellen ist deaktiviert.", "danger")
elif ticket["status"] != "open":
flash("Ticket ist gesperrt.", "danger")
@@ -2626,15 +2711,18 @@ def ticket_detail(ticket_id):
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
elif "toggle_block" in request.form:
new_status = "blocked" if ticket["status"] == "open" else "open"
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
conn.commit()
customer = _customer_row(conn, ticket["customer_id"])
log_action(
"ticket.block" if new_status == "blocked" else "ticket.unblock",
customer["name"] if customer else ticket_id,
)
flash("Ticket gesperrt." if new_status == "blocked" else "Ticket entsperrt.", "success")
if not current_user.has_permission("tickets.edit"):
flash("Keine Berechtigung, Tickets zu sperren/entsperren.", "danger")
else:
new_status = "blocked" if ticket["status"] == "open" else "open"
conn.execute("UPDATE license_tickets SET status=? WHERE id=?", (new_status, ticket_id))
conn.commit()
customer = _customer_row(conn, ticket["customer_id"])
log_action(
"ticket.block" if new_status == "blocked" else "ticket.unblock",
customer["name"] if customer else ticket_id,
)
flash("Ticket gesperrt." if new_status == "blocked" else "Ticket entsperrt.", "success")
conn.close()
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@@ -2654,7 +2742,8 @@ def ticket_detail(ticket_id):
master_licensed=license_active(),
self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id),
portal_request_url=_customer_facing_url("portal_request"),
can_edit=current_user.has_permission("licenses.edit"),
can_edit_ticket=current_user.has_permission("tickets.edit"),
can_issue_license=current_user.has_permission("licenses.edit"),
)
@@ -2670,7 +2759,7 @@ def ticket_edit(ticket_id):
hier bewusst nicht vorgesehen (das Self-Service-Ticket ist an genau
diesen Kunden gebunden, u.a. weil er ueber dessen Portal-Token
erreichbar ist)."""
if not current_user.has_permission("licenses.edit"):
if not current_user.has_permission("tickets.edit"):
flash("Keine Berechtigung, Tickets zu bearbeiten.", "danger")
return redirect(url_for("tickets_list"))
conn = get_db_connection()
@@ -2723,7 +2812,7 @@ def ticket_delete(ticket_id):
System verschwinden). Etwaige bereits widerrufene/deaktivierte
Lizenzen darunter sind reine Historie und werden mitgeloescht, sonst
blieben verwaiste Zeilen mit einer toten ticket_id zurueck."""
if not current_user.has_permission("licenses.edit"):
if not current_user.has_permission("tickets.edit"):
flash("Keine Berechtigung, Tickets zu löschen.", "danger")
return redirect(url_for("tickets_list"))
conn = get_db_connection()
@@ -3675,7 +3764,7 @@ def tickets_list():
Dashboards auf eine rein informative Kacheluebersicht (siehe index())
lebt die eigentliche Verwaltung hier, eigenstaendig ueber die
Navigation erreichbar."""
if not current_user.has_permission("licenses.view"):
if not current_user.has_permission("tickets.view"):
flash("Keine Berechtigung, Tickets anzusehen.", "danger")
return redirect(url_for("index"))
conn = get_db_connection()
@@ -3708,8 +3797,8 @@ def tickets_list():
return render_template(
"tickets.html", tickets=tickets_view, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES,
can_create=current_user.has_permission("licenses.create"),
can_edit=current_user.has_permission("licenses.edit"),
can_create=current_user.has_permission("tickets.create"),
can_edit=current_user.has_permission("tickets.edit"),
)
@@ -4699,7 +4788,7 @@ def settings():
@app.route("/settings/license")
@login_required
def settings_license():
if not current_user.can_view_settings_system:
if not current_user.can_view_settings_license:
flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
return redirect(url_for("index"))
pending_raw = get_setting("license_pending_request", "")
@@ -4722,7 +4811,7 @@ def settings_license():
@app.route("/settings/license/upload", methods=["POST"])
@login_required
def license_upload():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
return redirect(url_for("settings_license"))
uploaded = request.files.get("license_file")
@@ -4815,7 +4904,7 @@ def _license_handshake_confirm(action, success_setting_fn, success_log_action, s
@app.route("/settings/license/activate", methods=["POST"])
@login_required
def license_activate():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license"))
if not _license_state.get("file") or not _license_state.get("valid"):
@@ -4832,7 +4921,7 @@ def license_activate():
@app.route("/settings/license/activate/confirm", methods=["POST"])
@login_required
def license_activate_confirm():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license"))
@@ -4846,7 +4935,7 @@ def license_activate_confirm():
@app.route("/settings/license/deactivate", methods=["POST"])
@login_required
def license_deactivate():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license"))
if not _license_state.get("file") or not license_activated():
@@ -4866,7 +4955,7 @@ def license_deactivate():
@app.route("/settings/license/deactivate/confirm", methods=["POST"])
@login_required
def license_deactivate_confirm():
if not current_user.has_permission("settings_system.edit"):
if not current_user.has_permission("settings_license.edit"):
flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license"))
@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% set active_page = "settings_license" %}
{% set can_edit = current_user.has_permission('settings_system.edit') %}
{% set can_edit = current_user.has_permission('settings_license.edit') %}
{% block page_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}
@@ -27,7 +27,7 @@
der Kunde fordert ihn selbst über <a href="{{ portal_request_url }}" target="_blank" rel="noopener">{{ portal_request_url }}</a> an (zeitlich begrenzter Zugang, keine dauerhafte Freigabe).
</div>
{% if can_edit %}
{% if can_edit_ticket %}
<div class="flex gap-2" style="margin-top:14px;">
<button type="button" class="btn btn-secondary" style="flex:1;" data-open-modal="editTicketModal">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
@@ -69,7 +69,7 @@
Es gibt bereits eine aktive oder ausgestellte Lizenz zu diesem Ticket (siehe unten) — erst widerrufen, dann kann
eine neue erstellt werden.
</p>
{% elif can_edit %}
{% elif can_issue_license %}
<form method="post" style="margin-bottom:10px;">
<input type="hidden" name="issue_license" value="regular">
<button type="submit" class="btn btn-primary btn-block">
@@ -112,7 +112,7 @@
</div>
{% if can_edit %}
{% if can_edit_ticket %}
<div class="modal-overlay" id="editTicketModal">
<div class="modal" style="max-width:520px;">
<form method="post" action="{{ url_for('ticket_edit', ticket_id=ticket.id) }}">