From 56974a43088de897f58ccf4ad6808c83fb06ae32 Mon Sep 17 00:00:00 2001 From: alientim Date: Sun, 23 Aug 2026 22:06:16 +0200 Subject: [PATCH] licensing.py: neues role-Feld fuer Lizenzserver-Lizenzen (Master-Feature) Rein synchronisierende Aenderung, um die harte Invariante 'licensing.py byte-identisch in beiden Repos' zu wahren -- der TESM-Client selbst nutzt role aktuell nicht aktiv (er ist immer 'customer'), aber das Sub-Lizenzserver-Feature auf dem Lizenzserver (siehe dortiges Release) braucht die Erweiterung in derselben Datei. - LICENSE_ROLES = ('master', 'license_server', 'customer'), neues role-Feld in der signierten Lizenzdatei-Payload (issue_license()). - license_status() liefert role mit, faellt bei fehlendem Feld (aeltere Lizenzdateien) auf 'customer' zurueck. - Verifiziert: role ist Teil der signierten Payload -- eine nachtraeg- liche Manipulation der Rolle macht die Signatur ungueltig. Co-Authored-By: Claude Sonnet 5 --- srv/tesm/VERSION | 2 +- srv/tesm/licensing.py | 46 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/srv/tesm/VERSION b/srv/tesm/VERSION index 3c43790..c04c650 100644 --- a/srv/tesm/VERSION +++ b/srv/tesm/VERSION @@ -1 +1 @@ -1.2.6 +1.2.7 diff --git a/srv/tesm/licensing.py b/srv/tesm/licensing.py index a0c03e7..e9f2de3 100644 --- a/srv/tesm/licensing.py +++ b/srv/tesm/licensing.py @@ -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"), }