Compare commits

2 Commits
Author SHA1 Message Date
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
alientimandClaude Sonnet 5 20abadb0e7 Dashboard erfordert Login, Magic-Links nutzen konfigurierten Endpunkt statt IP (v1.2.3)
## Dashboard nur noch intern

'/' (das Kachel-Dashboard) verlangt jetzt @login_required -- ein anonymer
Besuch landet zuerst auf der Login-Seite (ueber Flask-Logins bereits
konfigurierten login_manager.login_view), statt innerhalb der Dashboard-
Huelle einen 'bitte anmelden'-Platzhalter zu zeigen. Anders als bei TESM
selbst (dessen Geraete-Dashboard bewusst auch ohne Login als Status-Board
sichtbar bleibt) ist der Lizenzserver ein reines internes Verwaltungs-
werkzeug.

## Magic-Links zeigen den konfigurierten Lizenzserver-Endpunkt, nicht die IP

Self-Service-Link, Kundenportal-Anfrage/-Zugang und der in 'Lizenz per
E-Mail senden' eingebettete Link wurden bisher per url_for(...,
_external=True) gebaut -- das nutzt den Host-Header der GERADE laufenden
Anfrage, zeigt also z.B. die interne IP, ueber die zufaellig der Admin
gerade eingeloggt ist, statt der unter Einstellungen -> Lizenz ->
Anbieter & Lizenzserver gepflegten oeffentlichen Domain. Neue Hilfs-
funktion _customer_facing_url() baut diese Links jetzt stattdessen aus
dem admin-gepflegten Lizenzserver-Endpunkt (_get_master_endpoint(),
derselbe Wert, der auch in jede Lizenzdatei eingebettet wird) -- faellt
auf die alte, request-basierte externe URL zurueck, wenn der Endpunkt
noch nicht konfiguriert ist (frische Installation).

Verifiziert gegen eine echte Kopie der Live-Datenbank: mit konfiguriertem
Endpunkt zeigt der Self-Service-Link jetzt https://tesml.int.eertmoed.net/...
statt der IP, unabhaengig davon, ueber welchen Host der Admin gerade
zugreift; ohne konfigurierten Endpunkt bleibt der bisherige Fallback
erhalten.

## Ausserdem live behoben (kein Code-Fix, reine Konfiguration)

/etc/nginx/sites-available/tesm-license auf dem Master zeigte fuer
/static/ faelschlich auf /srv/tesm/static/ (ein laengst nicht mehr
existierendes Verzeichnis -- Ueberbleibsel aus der Zeit vor der
Repo-Trennung) statt auf /srv/tesm-license/static/, wodurch CSS/JS/
Bilder ueberall 404eten (nackte, ungestylte Seiten). install.sh schreibt
die nginx-Konfiguration bewusst nur bei einer FRISCHEN Installation
(um manuelle Anpassungen wie TLS-Zertifikate bei Updates nicht zu
ueberschreiben) -- das im Repo hinterlegte Template selbst war bereits
korrekt, nur die schon laenger bestehende Datei auf dem Master war es
nicht. Direkt auf dem Master korrigiert und nginx neu geladen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 17:34:39 +02:00
3 changed files with 81 additions and 19 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.2 1.2.4
+79 -17
View File
@@ -251,6 +251,10 @@ PERMISSIONS = {
"label": "Systemeinstellungen", "label": "Systemeinstellungen",
"rows": {"view": "settings_system.view", "edit": "settings_system.edit"}, "rows": {"view": "settings_system.view", "edit": "settings_system.edit"},
}, },
"settings_license": {
"label": "Lizenz",
"rows": {"view": "settings_license.view", "edit": "settings_license.edit"},
},
"settings_importexport": { "settings_importexport": {
"label": "Im-/Export", "label": "Im-/Export",
"rows": { "rows": {
@@ -288,6 +292,8 @@ PERMISSION_LABELS = {
"groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen", "groups.view": "Gruppen lesen", "groups.create": "Gruppen anlegen",
"groups.edit": "Gruppen ändern (inkl. Löschen)", "groups.edit": "Gruppen ändern (inkl. Löschen)",
"settings_system.view": "Systemeinstellungen lesen", "settings_system.edit": "Systemeinstellungen ändern", "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.view": "Im-/Export-Seite ansehen",
"settings_importexport.edit": "Daten importieren", "settings_importexport.edit": "Daten importieren",
"settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)", "settings_importexport.export": "Daten exportieren (Export-Datei enthält Passwörter im Klartext)",
@@ -361,10 +367,11 @@ def _nav_key_visible(key, user):
if key == "settings_system": if key == "settings_system":
return user.can_view_settings_system return user.can_view_settings_system
if key == "settings_license": if key == "settings_license":
# Immer erreichbar für jeden mit Systemeinstellungen-Leserecht -- # Eigenes Recht (settings_license.view), unabhängig von
# muss auch OHNE Lizenz sichtbar bleiben, sonst könnte niemand # settings_system -- muss auch OHNE eine aktive Lizenz sichtbar
# jemals eine erste Lizenz hochladen/aktivieren. # bleiben (das ist eine andere Prüfung, siehe license_active()),
return user.can_view_settings_system # sonst könnte niemand jemals eine erste Lizenz hochladen/aktivieren.
return user.can_view_settings_license
if key == "settings_importexport": if key == "settings_importexport":
return user.can_view_settings_importexport return user.can_view_settings_importexport
if key == "settings_ldap": if key == "settings_ldap":
@@ -559,6 +566,10 @@ class User(UserMixin):
def can_view_settings_system(self): def can_view_settings_system(self):
return self.has_permission("settings_system.view") return self.has_permission("settings_system.view")
@property
def can_view_settings_license(self):
return self.has_permission("settings_license.view")
@property @property
def can_view_settings_importexport(self): def can_view_settings_importexport(self):
return self.has_any_permission([ return self.has_any_permission([
@@ -877,6 +888,32 @@ def _ensure_schema():
conn.execute("UPDATE licenses SET ticket_id=? WHERE license_id=?", (ticket_id, lic["license_id"])) 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,)) 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_old_logs = "_cleaned_up_legacy_rpi_log_files_v1" _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(): 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"): for stale_log in glob.glob("/var/log/rpi-*.log"):
@@ -1977,6 +2014,23 @@ def _get_master_endpoint():
return get_setting("master_endpoint", "") return get_setting("master_endpoint", "")
def _customer_facing_url(endpoint, **values):
"""Baut eine externe URL fuer an KUNDEN gerichtete Magic-Links (Self-
Service, Kundenportal-Anfrage/-Zugang, Lizenz-per-E-Mail) auf Basis
des admin-gepflegten Lizenzserver-Endpunkts (siehe _get_master_endpoint(),
Einstellungen -> Lizenz -> Anbieter & Lizenzserver) -- NICHT per
url_for(..., _external=True), das stattdessen den Host-Header der
GERADE laufenden Anfrage verwendet und damit z.B. die interne IP
zeigt, ueber die zufaellig der Admin gerade eingeloggt ist, statt der
fuer Kunden eigentlich gedachten oeffentlichen Domain. Faellt auf die
normale request-basierte externe URL zurueck, wenn der Endpunkt noch
nicht konfiguriert ist (z.B. auf einer frischen Installation)."""
base = _get_master_endpoint().rstrip("/")
if not base:
return url_for(endpoint, _external=True, **values)
return base + url_for(endpoint, **values)
def _customer_row(conn, customer_id): def _customer_row(conn, customer_id):
return conn.execute("SELECT * FROM license_customers WHERE id=?", (customer_id,)).fetchone() return conn.execute("SELECT * FROM license_customers WHERE id=?", (customer_id,)).fetchone()
@@ -2635,8 +2689,8 @@ def ticket_detail(ticket_id):
license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES, license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES,
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
master_licensed=license_active(), master_licensed=license_active(),
self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True), self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id),
portal_request_url=url_for("portal_request", _external=True), portal_request_url=_customer_facing_url("portal_request"),
can_edit=current_user.has_permission("licenses.edit"), can_edit=current_user.has_permission("licenses.edit"),
) )
@@ -2876,12 +2930,12 @@ def license_send_email(license_id):
return redirect(url_for("license_detail", license_id=license_id)) return redirect(url_for("license_detail", license_id=license_id))
self_service_note = "" self_service_note = ""
if row["ticket_id"]: if row["ticket_id"]:
self_service_url = url_for("self_service", ticket_id=row["ticket_id"], _external=True) self_service_url = _customer_facing_url("self_service", ticket_id=row["ticket_id"])
self_service_note = ( self_service_note = (
f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, " f"\nÜber diesen Link können Sie Ihre Lizenz jederzeit selbst herunterladen, "
f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n" f"widerrufen oder (z.B. nach einem Systemwechsel) neu erstellen:\n{self_service_url}\n"
) )
portal_request_url = url_for("portal_request", _external=True) portal_request_url = _customer_facing_url("portal_request")
self_service_note += ( self_service_note += (
f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per " f"\nEine Übersicht all Ihrer Tickets/Lizenzen können Sie sich jederzeit unter folgendem Link per "
f"E-Mail zuschicken lassen:\n{portal_request_url}\n" f"E-Mail zuschicken lassen:\n{portal_request_url}\n"
@@ -3086,7 +3140,7 @@ def portal_request():
(now + timedelta(minutes=PORTAL_ACCESS_LINK_MINUTES)).strftime("%Y-%m-%d %H:%M:%S")), (now + timedelta(minutes=PORTAL_ACCESS_LINK_MINUTES)).strftime("%Y-%m-%d %H:%M:%S")),
) )
conn.commit() conn.commit()
access_url = url_for("portal_access", token=grant_token, _external=True) access_url = _customer_facing_url("portal_access", token=grant_token)
try: try:
_graph_send_mail( _graph_send_mail(
customer["contact_email"], customer["contact_email"],
@@ -3604,6 +3658,7 @@ def inject_license_topbar():
@app.route("/") @app.route("/")
@login_required
def index(): def index():
"""Dashboard des Master-Lizenzservers -- eine rein informative Kachel- """Dashboard des Master-Lizenzservers -- eine rein informative Kachel-
uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel- uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel-
@@ -3613,8 +3668,15 @@ def index():
mit status='active' -- alles andere (Tickets anlegen/bearbeiten/ mit status='active' -- alles andere (Tickets anlegen/bearbeiten/
loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und
lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite
(tickets_list()).""" (tickets_list()).
if not current_user.is_authenticated or not current_user.has_permission("licenses.view"):
@login_required (anders als bei TESM selbst, wo das Geraete-Dashboard
bewusst auch ohne Login als reines Status-Board sichtbar bleibt): der
Lizenzserver ist reines internes Verwaltungswerkzeug -- ein anonymer
Besuch auf '/' soll immer zuerst auf die Login-Seite treffen (via
login_manager.login_view), nicht auf einen 'bitte anmelden'-Platzhalter
innerhalb der Dashboard-Huelle."""
if not current_user.has_permission("licenses.view"):
return render_template("index.html", buckets=None) return render_template("index.html", buckets=None)
conn = get_db_connection() conn = get_db_connection()
active_rows = conn.execute(""" active_rows = conn.execute("""
@@ -4674,7 +4736,7 @@ def settings():
@app.route("/settings/license") @app.route("/settings/license")
@login_required @login_required
def settings_license(): 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") flash("Keine Berechtigung, die Lizenz anzusehen.", "danger")
return redirect(url_for("index")) return redirect(url_for("index"))
pending_raw = get_setting("license_pending_request", "") pending_raw = get_setting("license_pending_request", "")
@@ -4697,7 +4759,7 @@ def settings_license():
@app.route("/settings/license/upload", methods=["POST"]) @app.route("/settings/license/upload", methods=["POST"])
@login_required @login_required
def license_upload(): 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") flash("Keine Berechtigung, die Lizenz zu ändern.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
uploaded = request.files.get("license_file") uploaded = request.files.get("license_file")
@@ -4790,7 +4852,7 @@ def _license_handshake_confirm(action, success_setting_fn, success_log_action, s
@app.route("/settings/license/activate", methods=["POST"]) @app.route("/settings/license/activate", methods=["POST"])
@login_required @login_required
def license_activate(): 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") flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
if not _license_state.get("file") or not _license_state.get("valid"): if not _license_state.get("file") or not _license_state.get("valid"):
@@ -4807,7 +4869,7 @@ def license_activate():
@app.route("/settings/license/activate/confirm", methods=["POST"]) @app.route("/settings/license/activate/confirm", methods=["POST"])
@login_required @login_required
def license_activate_confirm(): 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") flash("Keine Berechtigung, die Lizenz zu aktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
@@ -4821,7 +4883,7 @@ def license_activate_confirm():
@app.route("/settings/license/deactivate", methods=["POST"]) @app.route("/settings/license/deactivate", methods=["POST"])
@login_required @login_required
def license_deactivate(): 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") flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
if not _license_state.get("file") or not license_activated(): if not _license_state.get("file") or not license_activated():
@@ -4841,7 +4903,7 @@ def license_deactivate():
@app.route("/settings/license/deactivate/confirm", methods=["POST"]) @app.route("/settings/license/deactivate/confirm", methods=["POST"])
@login_required @login_required
def license_deactivate_confirm(): 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") flash("Keine Berechtigung, die Lizenz zu deaktivieren.", "danger")
return redirect(url_for("settings_license")) return redirect(url_for("settings_license"))
@@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% set active_page = "settings_license" %} {% 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_title %}Lizenz{% endblock %}
{% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %} {% block page_sub %}<div class="topbar-sub">Lizenzstatus, Module und Aktivierung dieses Systems</div>{% endblock %}