diff --git a/srv/tesm-license/VERSION b/srv/tesm-license/VERSION index c813fe1..3c43790 100644 --- a/srv/tesm-license/VERSION +++ b/srv/tesm-license/VERSION @@ -1 +1 @@ -1.2.5 +1.2.6 diff --git a/srv/tesm-license/app.py b/srv/tesm-license/app.py index a22b86d..485a22f 100644 --- a/srv/tesm-license/app.py +++ b/srv/tesm-license/app.py @@ -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(): diff --git a/srv/tesm-license/create_db.py b/srv/tesm-license/create_db.py index 6326fc9..d56b9f9 100644 --- a/srv/tesm-license/create_db.py +++ b/srv/tesm-license/create_db.py @@ -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) diff --git a/srv/tesm-license/create_master_license.py b/srv/tesm-license/create_master_license.py index 8ad515d..99aef23 100644 --- a/srv/tesm-license/create_master_license.py +++ b/srv/tesm-license/create_master_license.py @@ -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) diff --git a/srv/tesm-license/licensing.py b/srv/tesm-license/licensing.py index a0c03e7..e9f2de3 100644 --- a/srv/tesm-license/licensing.py +++ b/srv/tesm-license/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"), } diff --git a/srv/tesm-license/templates/licenses.html b/srv/tesm-license/templates/licenses.html index 45c3b4c..2f1473b 100644 --- a/srv/tesm-license/templates/licenses.html +++ b/srv/tesm-license/templates/licenses.html @@ -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 '' }}">