Compare commits

4 Commits
Author SHA1 Message Date
alientimandClaude Sonnet 5 ed2ed00a4e Sub-Lizenzserver: Lizenzen fuer Lizenzserver ausstellbar (role='license_server')
Der Master kann jetzt ueber den normalen Kunde->Ticket->Lizenz-Flow
Lizenzen ausstellen, die eine ANDERE Lizenzserver-Instanz voll
funktionsfaehig machen (Kunden/Tickets/Lizenzen fuer eigene Kunden
ausstellen, Benutzer/Gruppen/Einstellungen verwalten) -- mit genau
einer strukturellen Einschraenkung: der so lizenzierte Sub-Lizenzserver
kann selbst KEINE weiteren Lizenzserver-Lizenzen ausstellen. Die
eigene Lizenz DIESES Masters bleibt weiterhin ausschliesslich per
create_master_license.py erzeugbar, nie ueber die Ticket-UI.

- licensing.py: neues, signiertes role-Feld (master/license_server/
  customer), siehe separates TESM-Release fuer die synchronisierte
  Kopie (harte Invariante: beide Dateien bleiben identisch).
- create_master_license.py: setzt role='master' beim Selbstausstellen.
- Neue STRUKTURELLE Sperre can_issue_license_server_licenses(): True
  NUR wenn die eigene, aktive Lizenz dieser Instanz role=='master' ist
  -- haengt an der Instanz-Rolle, nicht an einer vergebbaren Permission,
  daher von lokalen Admins eines Sub-Lizenzservers nicht aufhebbar.
  Kombiniert mit current_user.is_admin an jeder Aufrufstelle (nur echte
  Admins duerfen es je nutzen, wie gewuenscht -- kein delegierbares
  Recht).
- license_tickets.role-Spalte (Migration, Default 'customer').
  license_tickets.role bestimmt, welche Rolle eine daraus ausgestellte
  Lizenz traegt -- nach Anlage unveraendert wie customer_id.
- ticket_new(): neue, nur fuer can_issue_license_server_licenses()+Admin
  sichtbare Option 'Dies ist eine Lizenzserver-Lizenz'. Jeder andere
  Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin) wird
  serverseitig still auf 'customer' zurueckgestuft. Module werden fuer
  role!='customer' erzwungen auf [] (TESM-Client-Konzept, fuer einen
  Lizenzserver bedeutungslos).
- Self-Service (unauthentifiziert, nur ueber die Ticket-ID) ist fuer
  role!='customer'-Tickets komplett gesperrt (404) -- Lizenzserver-
  Lizenzen sind ausschliesslich admin-verwaltet, kein Kundenweg. Trial-
  Ausstellung ('30 Tage, alle Module') ebenfalls nur fuer role=customer
  moeglich (ValueError sonst, sauber abgefangen statt 500er).
- license_send_email(): ueberspringt Self-Service-/Kundenportal-Hinweise
  fuer role='license_server'-Lizenzen (waeren ohnehin blockiert).
- UI: 'Lizenzserver'-Badge auf Tickets-/Lizenzen-Liste und Ticket-
  Detailseite.

Getestet: licensing.py isoliert (role signiert+verifiziert, Manipulation
bricht die Signatur, Altformat ohne role faellt auf 'customer' zurueck),
can_issue_license_server_licenses()-Logik (6 Kombinationen), Schema-
Migration (Alt-DB ohne role-Spalte, idempotent), Ende-zu-Ende Ticket->
Lizenz-Ausstellung gegen echte create_db.py-DB. ZUSAETZLICH gegen einen
echten, lokal laufenden Flask-Server (native Windows-Python, WSL-
Netzwerksandbox in dieser Umgebung unzuverlaessig) voll durchgespielt:
Checkbox erscheint nur fuer role=master, Ticket+Lizenz-Ausstellung mit
role=license_server funktioniert Ende-zu-Ende (signierte Datei, Module
leer, Self-Service liefert 404), UND der Sicherheitstest: nach Wechsel
der eigenen Lizenz auf role=license_server verschwindet die Checkbox
UND ein direkter manipulierter POST mit role=license_server wird
serverseitig zuverlaessig auf 'customer' zurueckgestuft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 22:08:19 +02:00
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
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
10 changed files with 370 additions and 78 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.2
1.2.6
+244 -44
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([
@@ -781,6 +804,7 @@ def _ensure_schema():
valid_days INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
@@ -789,6 +813,13 @@ def _ensure_schema():
ticket_cols = {row["name"] for row in conn.execute("PRAGMA table_info(license_tickets)").fetchall()}
if "lifetime" not in ticket_cols:
conn.execute("ALTER TABLE license_tickets ADD COLUMN lifetime INTEGER NOT NULL DEFAULT 0")
if "role" not in ticket_cols:
# Rolle der aus diesem Ticket auszustellenden Lizenz (siehe
# licensing.LICENSE_ROLES) -- "customer" (Default) fuer gewoehnliche
# TESM-Kundeninstanzen, "license_server" fuer einen damit
# auszustellenden Sub-Lizenzserver. Bestehende Tickets bleiben
# unveraendert "customer".
conn.execute("ALTER TABLE license_tickets ADD COLUMN role TEXT NOT NULL DEFAULT 'customer'")
# DB-seitiger Schutz gegen die Doppel-Ausstellung eines Tickets: ohne
# dieses Backstop koennten zwei nahezu gleichzeitige Anfragen (z.B. ein
@@ -877,6 +908,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"):
@@ -1806,6 +1895,24 @@ def module_licensed(module_key):
return license_active() and module_key in _license_state["status"]["modules"]
def can_issue_license_server_licenses():
"""STRUKTURELLE Sperre (siehe licensing.LICENSE_ROLES-Kommentar), keine
vergebbare Permission: True nur, wenn die EIGENE, gerade aktive Lizenz
dieser Instanz role=='master' ist -- also ausschließlich auf der
tatsächlichen Wurzel-Instanz (deren Lizenz per create_master_license.py
lokal erzeugt wurde), niemals auf einem per role='license_server'
lizenzierten Sub-Lizenzserver. Ein Sub-Lizenzserver liefert hier immer
False zurück, unabhängig davon, welche Gruppenrechte seine lokalen
Admins dort einrichten -- diese Sperre lässt sich also NICHT durch
lokale Rechtevergabe aufheben. Aufrufer müssen zusätzlich
current_user.is_admin selbst prüfen (siehe ticket_new()) -- diese
Funktion prüft nur die Instanz-Rolle, keine Benutzerrechte."""
return bool(
license_active()
and _license_state["status"]["role"] == "master"
)
def _require_module(module_key):
"""Serverseitiger Guard für die drei komplett ausgeblendeten Vollmodule
(dhcp/fileshare/maintenance) -- ein direkter URL-Aufruf ohne Lizenz
@@ -1977,6 +2084,23 @@ def _get_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):
return conn.execute("SELECT * FROM license_customers WHERE id=?", (customer_id,)).fetchone()
@@ -2062,7 +2186,7 @@ def _ticket_has_open_license(conn, ticket_id):
return bool(current and current["status"] in ("issued", "active"))
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False):
def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor, lifetime=False, role="customer"):
"""Legt ein neues Ticket an -- die dauerhafte, dem Kunden gehoerende
Berechtigung auf eine Lizenz mit diesen Eckdaten. Erzeugt für sich
genommen noch KEINE Lizenzdatei, siehe _issue_license_from_ticket.
@@ -2070,20 +2194,25 @@ def _create_ticket(conn, customer_id, license_type, modules, valid_days, actor,
aus diesem Ticket (Trial-Ausstellungen bleiben davon unberuehrt, siehe
_issue_license_from_ticket) -- der Wert wird trotzdem gespeichert,
damit ein spaeteres Zurückschalten auf eine normale Laufzeit nicht bei
0/NULL anfangen muss."""
0/NULL anfangen muss. role (siehe licensing.LICENSE_ROLES) bestimmt,
welche Rolle eine aus diesem Ticket ausgestellte Lizenz traegt --
Aufrufer MUESSEN role='license_server' bereits selbst gegen
can_issue_license_server_licenses() + current_user.is_admin geprueft
haben (siehe ticket_new()), diese Funktion vertraut dem Aufrufer."""
ticket_id = secrets.token_urlsafe(24)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute(
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, status, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, 'open', ?, ?)",
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), actor, now),
"INSERT INTO license_tickets (id, customer_id, type, modules, valid_days, lifetime, role, status, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?)",
(ticket_id, customer_id, license_type, json.dumps(sorted(modules)), valid_days, int(bool(lifetime)), role, actor, now),
)
conn.commit()
customer = _customer_row(conn, customer_id)
duration_label = "Lifetime" if lifetime else f"{valid_days} Tage"
role_label = " -- LIZENZSERVER" if role == "license_server" else ""
log_action_system(
"ticket.create", customer["name"] if customer else str(customer_id),
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label} (Akteur: {actor})",
f"{LICENSE_TYPE_LABELS.get(license_type, license_type)}, {duration_label}{role_label} (Akteur: {actor})",
)
return ticket_id
@@ -2099,7 +2228,14 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
Laufzeit) bleibt dabei unverändert, nur DIESE eine Ausstellung nutzt
Trial-Konditionen."""
ticket = _ticket_row(conn, ticket_id)
ticket_role = ticket["role"] if "role" in ticket.keys() else "customer"
if as_trial:
if ticket_role != "customer":
# Trial ("30 Tage, alle TESM-Module") ist ein reines
# Kunden-Konzept -- ALL_MODULES (dhcp/fileshare/maintenance)
# ist fuer eine Lizenzserver-Lizenz bedeutungslos. Klarer
# Fehler statt einer stillschweigend falsch befuellten Lizenz.
raise ValueError("Trial-Ausstellung ist nur fuer Kunden-Tickets moeglich.")
# Trial ist immer ein zeitlich kurzer Override, unabhaengig davon
# ob das Ticket selbst als Lifetime konfiguriert ist -- siehe
# Modul-Docstring oben.
@@ -2128,17 +2264,18 @@ def _issue_license_from_ticket(conn, ticket_id, actor, as_trial=False):
master_endpoint=_get_master_endpoint(),
vendor=_get_vendor_info(),
lifetime=lifetime,
role=ticket_role,
)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
try:
conn.execute(
"INSERT INTO licenses (license_id, customer_id, type, modules, issued_at, expires_at, "
"license_pubkey, license_file_json, status, role, ticket_id, lifetime, created_by, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', 'customer', ?, ?, ?, ?)",
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'issued', ?, ?, ?, ?, ?)",
(
license_file["license_id"], ticket["customer_id"], license_type, json.dumps(sorted(modules)),
license_file["issued_at"], license_file["expires_at"], license_pubkey,
json.dumps(license_file), ticket_id, int(lifetime), actor, now,
json.dumps(license_file), ticket_role, ticket_id, int(lifetime), actor, now,
),
)
conn.commit()
@@ -2511,7 +2648,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()
@@ -2544,12 +2681,31 @@ def ticket_new():
conn.close()
return redirect(url_for("ticket_new"))
# role='license_server' darf NUR ein echter Admin auf einer
# tatsaechlichen "master"-Instanz waehlen -- fest einprogrammiert,
# nicht ueber ein delegierbares Recht (siehe
# can_issue_license_server_licenses()-Docstring). Jeder andere
# Versuch (manipuliertes Formular, Sub-Lizenzserver, Nicht-Admin)
# wird still auf 'customer' zurueckgestuft statt nur die UI
# auszublenden -- Verteidigung in der Tiefe.
role = request.form.get("role", "customer")
if role == "license_server" and not (current_user.is_admin and can_issue_license_server_licenses()):
flash("Keine Berechtigung, Lizenzserver-Lizenzen auszustellen.", "danger")
role = "customer"
if role not in licensing.LICENSE_ROLES or role == "master":
role = "customer"
license_type = request.form.get("license_type", "standard")
if license_type not in licensing.LICENSE_TYPES:
flash("Ungültiger Lizenztyp.", "danger")
conn.close()
return redirect(url_for("ticket_new"))
if license_type == "enterprise":
if role != "customer":
# Module (dhcp/fileshare/maintenance) sind ein reines
# TESM-Client-Konzept -- fuer eine Lizenzserver-Lizenz
# bedeutungslos.
modules = []
elif license_type == "enterprise":
modules = list(licensing.ALL_MODULES)
elif license_type == "custom":
modules = [m for m in request.form.getlist("modules") if m in licensing.ALL_MODULES]
@@ -2565,7 +2721,10 @@ def ticket_new():
except ValueError:
valid_days = 365
ticket_id = _create_ticket(conn, customer_id, license_type, modules, valid_days, current_user.username, lifetime=lifetime)
ticket_id = _create_ticket(
conn, customer_id, license_type, modules, valid_days, current_user.username,
lifetime=lifetime, role=role,
)
conn.close()
flash("Ticket angelegt.", "success")
return redirect(url_for("ticket_detail", ticket_id=ticket_id))
@@ -2576,13 +2735,14 @@ def ticket_new():
"ticket_new.html", customers=customers_rows, license_types=licensing.LICENSE_TYPES,
all_modules=licensing.ALL_MODULES, module_labels=MODULE_LABELS, type_labels=LICENSE_TYPE_LABELS,
preselect_customer_id=request.args.get("customer_id", type=int),
can_issue_license_server=current_user.is_admin and can_issue_license_server_licenses(),
)
@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()
@@ -2593,10 +2753,14 @@ def ticket_detail(ticket_id):
return redirect(url_for("index"))
if request.method == "POST":
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.", "danger")
elif "issue_license" in request.form:
if not license_active():
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")
@@ -2604,11 +2768,24 @@ def ticket_detail(ticket_id):
flash("Dieses Ticket hat bereits eine aktive oder ausgestellte Lizenz.", "danger")
else:
as_trial = request.form.get("issue_license") == "trial"
try:
license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial)
except ValueError as exc:
# z.B. Trial-Override auf einem Lizenzserver-Ticket
# versucht (siehe _issue_license_from_ticket) -- im
# normalen UI-Fluss nicht erreichbar (Button wird für
# role != 'customer' gar nicht erst angezeigt), aber
# eine manipulierte Anfrage soll eine klare Meldung statt
# eines internen Serverfehlers bekommen.
flash(str(exc), "danger")
else:
conn.close()
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
elif "toggle_block" in request.form:
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()
@@ -2635,9 +2812,10 @@ def ticket_detail(ticket_id):
license_types=licensing.LICENSE_TYPES, all_modules=licensing.ALL_MODULES,
type_labels=LICENSE_TYPE_LABELS, module_labels=MODULE_LABELS,
master_licensed=license_active(),
self_service_url=url_for("self_service", ticket_id=ticket_id, _external=True),
portal_request_url=url_for("portal_request", _external=True),
can_edit=current_user.has_permission("licenses.edit"),
self_service_url=_customer_facing_url("self_service", ticket_id=ticket_id),
portal_request_url=_customer_facing_url("portal_request"),
can_edit_ticket=current_user.has_permission("tickets.edit"),
can_issue_license=current_user.has_permission("licenses.edit"),
)
@@ -2653,7 +2831,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()
@@ -2706,7 +2884,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()
@@ -2875,22 +3053,28 @@ def license_send_email(license_id):
flash("Für diesen Kunden ist keine E-Mail-Adresse hinterlegt.", "danger")
return redirect(url_for("license_detail", license_id=license_id))
self_service_note = ""
if row["role"] == "customer":
# Self-Service-Link und Kundenportal-Hinweis sind reine
# Kunden-Konzepte -- fuer eine Lizenzserver-Lizenz irrelevant (der
# Self-Service-Link wuerde dort ohnehin mit 404 abgewiesen, siehe
# self_service()).
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 = (
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"
)
portal_request_url = url_for("portal_request", _external=True)
portal_request_url = _customer_facing_url("portal_request")
self_service_note += (
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"
)
valid_until = "unbegrenzt (Lifetime)" if row["lifetime"] else row["expires_at"]
subject_prefix = "Ihre TESM-Lizenzserver-Lizenz" if row["role"] == "license_server" else "Ihre TESM-Lizenz"
try:
_graph_send_mail(
customer["contact_email"],
f"Ihre TESM-Lizenz ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})",
f"{subject_prefix} ({LICENSE_TYPE_LABELS.get(row['type'], row['type'])})",
f"Anbei Ihre Lizenzdatei. Bitte auf der Zielinstanz unter Einstellungen → Lizenz hochladen und aktivieren.\n\n"
f"Lizenz-ID: {license_id}\nTyp: {LICENSE_TYPE_LABELS.get(row['type'], row['type'])}\nGültig bis: {valid_until}\n"
f"{self_service_note}",
@@ -2967,7 +3151,15 @@ def license_delete(license_id):
def self_service(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
if not ticket or ticket["role"] != "customer":
# Self-Service ist ein KUNDENWEG (unauthentifiziert, Zugriff allein
# ueber die unratbare Ticket-ID) -- Lizenzserver-Lizenzen werden
# ausschliesslich bewusst durch einen Admin auf der Ticket-
# Detailseite ausgestellt/verwaltet, nie ueber diesen Weg. Behandelt
# eine Lizenzserver-Ticket-ID hier bewusst wie "nicht gefunden",
# nicht wie "keine Berechtigung" -- sonst liesse sich allein aus der
# Fehlermeldung ablesen, dass eine geratene/erlangte ID zu einem
# existierenden Lizenzserver-Ticket gehoert.
conn.close()
abort(404)
customer = _customer_row(conn, ticket["customer_id"])
@@ -2988,7 +3180,7 @@ def self_service(ticket_id):
def self_service_download(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
if not ticket or ticket["role"] != "customer":
conn.close()
abort(404)
current = _latest_license_for_ticket(conn, ticket_id)
@@ -3006,7 +3198,7 @@ def self_service_download(ticket_id):
def self_service_revoke(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
if not ticket or ticket["role"] != "customer":
conn.close()
abort(404)
current = _latest_license_for_ticket(conn, ticket_id)
@@ -3024,7 +3216,7 @@ def self_service_revoke(ticket_id):
def self_service_issue(ticket_id):
conn = get_db_connection()
ticket = _ticket_row(conn, ticket_id)
if not ticket:
if not ticket or ticket["role"] != "customer":
conn.close()
abort(404)
if not license_active():
@@ -3086,7 +3278,7 @@ def portal_request():
(now + timedelta(minutes=PORTAL_ACCESS_LINK_MINUTES)).strftime("%Y-%m-%d %H:%M:%S")),
)
conn.commit()
access_url = url_for("portal_access", token=grant_token, _external=True)
access_url = _customer_facing_url("portal_access", token=grant_token)
try:
_graph_send_mail(
customer["contact_email"],
@@ -3604,6 +3796,7 @@ def inject_license_topbar():
@app.route("/")
@login_required
def index():
"""Dashboard des Master-Lizenzservers -- eine rein informative Kachel-
uebersicht der gerade AKTIVEN Lizenzen (analog zur Geraete-Kachel-
@@ -3613,8 +3806,15 @@ def index():
mit status='active' -- alles andere (Tickets anlegen/bearbeiten/
loeschen, Lizenzen ausstellen/widerrufen/loeschen) ist Verwaltung und
lebt seit der Kachel-Umstellung auf der eigenen Tickets-Seite
(tickets_list())."""
if not current_user.is_authenticated or not current_user.has_permission("licenses.view"):
(tickets_list()).
@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)
conn = get_db_connection()
active_rows = conn.execute("""
@@ -3650,7 +3850,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()
@@ -3683,8 +3883,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"),
)
@@ -4674,7 +4874,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", "")
@@ -4697,7 +4897,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")
@@ -4790,7 +4990,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"):
@@ -4807,7 +5007,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"))
@@ -4821,7 +5021,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():
@@ -4841,7 +5041,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
View File
@@ -162,6 +162,7 @@ CREATE TABLE IF NOT EXISTS license_tickets (
valid_days INTEGER NOT NULL,
lifetime INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'open',
role TEXT NOT NULL DEFAULT 'customer',
created_by TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY (customer_id) REFERENCES license_customers(id)
@@ -74,6 +74,12 @@ def main():
master_public_key_b64=master_pub,
master_endpoint="",
vendor={"name": "", "phone": "", "email": "", "address": "", "logo_base64": ""},
# role="master": markiert dies als die Wurzel-Instanz -- NUR dieses
# Skript darf role="master" setzen (siehe LICENSE_ROLES in
# licensing.py). app.py bietet role="master" an keiner Stelle über
# die normale Ticket-/Ausstellungs-UI an; das ist die einzige Rolle,
# die selbst weitere role="license_server"-Lizenzen ausstellen darf.
role="master",
)
with open(LICENSE_PATH, "w", encoding="utf-8") as f:
json.dump(license_file, f, indent=2)
+45 -1
View File
@@ -39,6 +39,31 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,
LICENSE_TYPES = ("trial", "standard", "custom", "enterprise")
ALL_MODULES = ("dhcp", "fileshare", "maintenance")
# Rolle der lizenzierten Instanz, eingebettet in JEDE ausgestellte
# Lizenzdatei (signiert, siehe issue_license) -- NICHT nur DB-seitige
# Buchfuehrung wie das gleichnamige, bislang ungenutzte licenses.role-Feld
# in der Master-DB vorher. Ermoeglicht einer lizenzierten Instanz, anhand
# ihrer EIGENEN, geladenen Lizenz strukturell zu erkennen, was sie darf:
# "master" -- die Wurzel-Instanz. Wird AUSSCHLIESSLICH lokal per
# create_master_license.py erzeugt, NIE ueber die
# normale Ticket-/Ausstellungs-UI -- das waere sonst
# eine Moeglichkeit, sich selbst zur Wurzel zu
# erklaeren. Einzige Rolle, die weitere
# "license_server"-Lizenzen ausstellen darf.
# "license_server" -- ein vom Master (oder einer anderen "master"-Instanz)
# ausgestellter Lizenzserver ("Sub-Lizenzserver").
# Darf alles, was ein Lizenzserver koennen soll
# (Kunden/Tickets/Lizenzen fuer eigene TESM-Kunden
# ausstellen, Benutzer/Gruppen/Einstellungen
# verwalten) -- AUSSER selbst weitere
# "license_server"-Lizenzen ausstellen. Diese Sperre
# ist strukturell (haengt an der eigenen Rolle, nicht
# an einem vergebbaren Recht) und daher auch von einem
# lokalen Admin dieser Instanz nicht aufhebbar.
# "customer" -- eine gewoehnliche TESM-Kundeninstanz (Default,
# unveraendertes Verhalten).
LICENSE_ROLES = ("master", "license_server", "customer")
GRACE_PERIOD_DAYS = 30 # nach Ablauf, bevor lizenzpflichtige Funktionen tatsächlich abgeschaltet werden
EXPIRY_WARNING_DAYS = 30 # Vorlauf für die orange "läuft bald ab"-Anzeige
HEARTBEAT_WARNING_DAYS = 14 # Nichterreichbarkeits-Hinweis (rein informativ, schaltet nie etwas ab)
@@ -120,13 +145,23 @@ LIFETIME_EXPIRES_AT = "2099-12-31T00:00:00Z"
def issue_license(*, customer, license_type, modules, valid_days,
master_private_key_b64, master_public_key_b64,
master_endpoint, vendor, license_id=None, now=None, lifetime=False):
master_endpoint, vendor, license_id=None, now=None, lifetime=False,
role="customer"):
"""Vom MASTER aufgerufen: erzeugt eine neue, signierte Lizenz samt
frischem Pro-Lizenz-Schlüsselpaar.
lifetime=True ignoriert valid_days komplett und setzt stattdessen das
feste Sentinel-Datum LIFETIME_EXPIRES_AT -- siehe dortigen Kommentar.
role (siehe LICENSE_ROLES): "customer" (Default, unverändertes
Verhalten) für gewöhnliche TESM-Kundeninstanzen, "license_server" für
einen vom Aufrufer ausgestellten Sub-Lizenzserver. role="master" darf
NUR von create_master_license.py verwendet werden (lokal, ohne diese
Funktion über die normale Ticket-UI aufzurufen) -- diese Funktion prüft
das nicht selbst, das Verbot ist stattdessen dadurch durchgesetzt, dass
die App-seitige Ausstellungsfunktion role="master" gar nicht erst als
Option anbietet (siehe app.py can_issue_license_server_licenses()).
Rückgabe: (license_file, license_pubkey) -- license_file ist die
komplette, an den Kunden auszuhändigende Datei (inkl. dem PRIVATEN
Lizenzschlüssel); license_pubkey ist NUR für die Master-Datenbank
@@ -134,6 +169,8 @@ def issue_license(*, customer, license_type, modules, valid_days,
dieser Lizenz) und wird nicht an den Kunden weitergegeben."""
if license_type not in LICENSE_TYPES:
raise ValueError(f"Unbekannter Lizenztyp: {license_type!r} (erlaubt: {LICENSE_TYPES})")
if role not in LICENSE_ROLES:
raise ValueError(f"Unbekannte Rolle: {role!r} (erlaubt: {LICENSE_ROLES})")
unknown = set(modules) - set(ALL_MODULES)
if unknown:
raise ValueError(f"Unbekannte Module: {sorted(unknown)} (erlaubt: {ALL_MODULES})")
@@ -151,6 +188,7 @@ def issue_license(*, customer, license_type, modules, valid_days,
"master_pubkey": master_public_key_b64,
"master_endpoint": master_endpoint,
"vendor": vendor,
"role": role,
}
signature = sign_payload(payload, master_private_key_b64)
license_file = {**payload, "license_privkey": license_priv, "signature": signature}
@@ -190,6 +228,12 @@ def license_status(license_file: dict, now=None) -> dict:
"type": license_file.get("type"),
"customer": license_file.get("customer"),
"expires_at": license_file.get("expires_at"),
# .get(..., "customer"): aeltere, vor Einfuehrung von role signierte
# Lizenzdateien haben dieses Feld noch nicht -- fehlend bedeutet
# unveraendert "gewoehnliche Kundeninstanz", nie "master"/
# "license_server" (sonst koennte eine alte Datei rueckwirkend
# unbeabsichtigt privilegiert erscheinen).
"role": license_file.get("role", "customer"),
}
+4 -1
View File
@@ -45,7 +45,10 @@
data-sort-issued="{{ l.issued_at }}" data-sort-expires="{{ l.expires_at }}"
data-sort-status="{{ l.status }}" data-sort-heartbeat="{{ l.last_heartbeat_at or '' }}">
<tr>
<td class="cell-name">{{ l.customer_name }}</td>
<td class="cell-name">
{{ l.customer_name }}
{% if l.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</td>
<td class="mono text-dim">{{ l.license_id[:8] }}…</td>
<td>{{ type_labels.get(l.type, l.type) }}</td>
<td class="text-dim">{{ l.issued_at[:10] }}</td>
@@ -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 %}
+21 -3
View File
@@ -10,14 +10,29 @@
<div class="card card-pad">
<div class="section-head" style="margin-bottom:16px;">
<div><h2 style="font-size:16px;">Ticket</h2></div>
<div class="flex gap-2">
{% if ticket.role == 'license_server' %}
<span class="pill admin">Lizenzserver</span>
{% endif %}
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
</span>
</div>
</div>
{% if ticket.role == 'license_server' %}
<div class="notice-banner notice-banner--warning" style="margin-bottom:14px;">
Lizenzserver-Ticket: eine daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Sub-Lizenzserver
(darf eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten) — außer selbst weitere
Lizenzserver-Lizenzen ausstellen. Kein Self-Service-Zugang für diesen Ticket-Typ.
</div>
{% endif %}
<div class="field"><label>Kunde</label><input type="text" value="{{ customer.name if customer else '?' }}" disabled></div>
<div class="field"><label>Lizenztyp</label><input type="text" value="{{ type_labels.get(ticket.type, ticket.type) }}" disabled></div>
{% if ticket.role == 'customer' %}
<div class="field"><label>Module</label><input type="text" value="{{ modules_list|join(', ') if modules_list else 'keine' }}" disabled></div>
{% endif %}
<div class="field"><label>Gültigkeitsdauer</label><input type="text" value="{{ 'Lifetime (unbegrenzt)' if ticket.lifetime else ticket.valid_days ~ ' Tage' }}" disabled></div>
{% if ticket.role == 'customer' %}
<div class="field">
<label>Self-Service-Link (dieses Ticket) {{ hi.hint_icon("Diesen Link an den Kunden weitergeben -- damit kann er seine Lizenz selbst herunterladen, widerrufen und (mit diesen Eckdaten) neu erstellen, ohne Login.", "Self-Service-Link") }}</label>
<input type="text" class="mono" value="{{ self_service_url }}" readonly onclick="this.select()">
@@ -26,8 +41,9 @@
Übersicht über ALLE Tickets des Kunden {{ hi.hint_icon("Es gibt bewusst keinen dauerhaften Kundenportal-Link mehr -- der Kunde fordert sich unter " ~ portal_request_url ~ " bei Bedarf selbst einen zeitlich begrenzten Zugang an.", "Kundenportal") }}:
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>
{% endif %}
{% 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 +85,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">
@@ -77,11 +93,13 @@
{{ type_labels.get(ticket.type, ticket.type) }}-Lizenz erstellen ({{ 'Lifetime' if ticket.lifetime else ticket.valid_days ~ ' Tage' }})
</button>
</form>
{% if ticket.role == 'customer' %}
<form method="post">
<input type="hidden" name="issue_license" value="trial">
<button type="submit" class="btn btn-secondary btn-block">Stattdessen Trial erstellen (30 Tage, alle Module)</button>
</form>
{% endif %}
{% endif %}
</div>
<div class="card card-pad" style="grid-column: 1 / -1;">
@@ -112,7 +130,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) }}">
+18 -1
View File
@@ -35,6 +35,15 @@
<div class="hint" style="margin-bottom:14px;">Weitere Angaben (Anschrift, Ansprechpartner, Telefon) lassen sich anschließend unter <a href="{{ url_for('customers') }}">Kunden</a> ergänzen.</div>
</div>
{% if can_issue_license_server %}
<div class="field">
<label class="check-row">
<input type="checkbox" name="role" value="license_server" id="role_license_server" onchange="toggleLicenseServerRole()">
Dies ist eine Lizenzserver-Lizenz {{ hi.hint_icon("Für einen Sub-Lizenzserver, nicht für eine gewöhnliche TESM-Kundeninstanz. Die daraus ausgestellte Lizenz macht die Zielinstanz zu einem vollwertigen Lizenzserver -- eigene Kunden/Tickets/Lizenzen ausstellen, Benutzer/Gruppen/Einstellungen verwalten -- außer selbst weitere Lizenzserver-Lizenzen ausstellen (das bleibt strukturell dieser Instanz vorbehalten). Kein Self-Service-Zugang, Module sind bedeutungslos.", "Lizenzserver-Lizenz") }}
</label>
</div>
{% endif %}
<div class="field">
<label for="license_type">Lizenztyp {{ hi.hint_icon("Trial: zeitlich begrenzt, voller Zugriff. Standard: alle lizenzpflichtigen Basis-Funktionen, keine Zusatzmodule. Custom: Standard + einzeln wählbare Module. Enterprise: alles. Unabhängig vom hier gewählten Typ kann aus jedem Ticket zusätzlich jederzeit eine separate Trial-Lizenz erzeugt werden.", "Lizenztyp") }}</label>
<select name="license_type" id="license_type" onchange="toggleModuleFields()" required>
@@ -90,7 +99,12 @@ function toggleNewCustomerFields() {
}
function toggleModuleFields() {
const type = document.getElementById("license_type").value;
document.getElementById("moduleFields").classList.toggle("hidden", type !== "custom");
const roleBox = document.getElementById("role_license_server");
const isLicenseServer = roleBox && roleBox.checked;
// Module sind bei einer Lizenzserver-Lizenz bedeutungslos (siehe
// ticket_new() in app.py, erzwingt dort ohnehin modules=[]) -- Feld
// bleibt unabhaengig vom Lizenztyp ausgeblendet.
document.getElementById("moduleFields").classList.toggle("hidden", isLicenseServer || type !== "custom");
// Trial + Lifetime waere eine dauerhaft nie ablaufende Lizenz ohne
// jedes Modul -- serverseitig ohnehin unterbunden (siehe ticket_new()
// in app.py), hier nur als klares UI-Signal ausgegraut.
@@ -101,6 +115,9 @@ function toggleModuleFields() {
toggleLifetime();
}
}
function toggleLicenseServerRole() {
toggleModuleFields();
}
function toggleLifetime() {
const isLifetime = document.getElementById("lifetime").checked;
document.getElementById("validDaysField").classList.toggle("hidden", isLifetime);
+4 -1
View File
@@ -45,7 +45,10 @@
data-sort-status="{{ t.license.status if t.license else '' }}"
data-sort-heartbeat="{{ t.license.last_heartbeat_at if t.license and t.license.last_heartbeat_at else '' }}">
<tr>
<td class="cell-name">{{ t.customer_name }}</td>
<td class="cell-name">
{{ t.customer_name }}
{% if t.role == 'license_server' %}<span class="pill admin" style="margin-left:6px;">Lizenzserver</span>{% endif %}
</td>
<td>{{ type_labels.get(t.type, t.type) }}</td>
<td class="text-dim">{{ t.modules_list|join(', ') if t.modules_list else '—' }}</td>
<td class="text-dim">{{ 'Lifetime' if t.lifetime else t.valid_days ~ ' Tage' }}</td>