Compare commits

1 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
9 changed files with 215 additions and 37 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.5
1.2.6
+115 -29
View File
@@ -804,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)
@@ -812,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
@@ -1887,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
@@ -2160,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.
@@ -2168,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
@@ -2197,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.
@@ -2226,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()
@@ -2642,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]
@@ -2663,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))
@@ -2674,6 +2735,7 @@ 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(),
)
@@ -2706,10 +2768,20 @@ 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"
license_file = _issue_license_from_ticket(conn, ticket_id, current_user.username, as_trial=as_trial)
conn.close()
flash("Lizenz ausgestellt.", "success")
return redirect(url_for("license_detail", license_id=license_file["license_id"]))
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")
@@ -2981,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["ticket_id"]:
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"
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 = _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 = _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"
)
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}",
@@ -3073,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"])
@@ -3094,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)
@@ -3112,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)
@@ -3130,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():
+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>
+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>
<span class="pill {{ 'online' if ticket.status == 'open' else 'offline' }}">
{{ 'Offen' if ticket.status == 'open' else 'Gesperrt' }}
</span>
<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,6 +41,7 @@
Ü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_ticket %}
<div class="flex gap-2" style="margin-top:14px;">
@@ -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;">
+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>