From 641ea22b3cbcebf2b8c1596e39c700e9e6eb5272 Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Wed, 16 Sep 2026 22:00:59 +0200 Subject: [PATCH] Lizenzserver 2.8.12: Ticket als Anspruch, Lizenz als Urkunde daraus --- apps/tesm-license/pyproject.toml | 2 +- .../src/tesm_license/blueprints/tickets.py | 43 ++++- apps/tesm-license/src/tesm_license/schema.py | 32 +++- .../src/tesm_license/services/issuance.py | 173 +++++++++++++++++- .../templates/license/dashboard.html | 8 +- .../templates/license/license_detail.html | 2 + .../templates/license/ticket_detail.html | 9 +- .../templates/license/tickets.html | 3 + 8 files changed, 250 insertions(+), 22 deletions(-) diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index c27fc4f..d98ef7e 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.8.11" +version = "2.8.12" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm-license/src/tesm_license/blueprints/tickets.py b/apps/tesm-license/src/tesm_license/blueprints/tickets.py index f001da1..0236ebb 100644 --- a/apps/tesm-license/src/tesm_license/blueprints/tickets.py +++ b/apps/tesm-license/src/tesm_license/blueprints/tickets.py @@ -18,6 +18,7 @@ from .common import ( master_endpoint, module_labels, role_labels, + signing_key, type_labels, ) @@ -131,7 +132,10 @@ def detail(ticket_id: str): ticket=ticket, ticket_modules=json.loads(str(ticket["modules"] or "[]")), licenses=licenses, + # Zwei Spuren, zwei Auskuenfte: eine laufende Testlizenz darf die + # richtige Ausstellung nicht sperren und umgekehrt. has_open_license=issuance.ticket_has_open_license(conn, ticket_id), + has_open_trial=issuance.ticket_has_open_license(conn, ticket_id, trial=True), modules=lic.ALL_MODULES, module_labels=module_labels(), types=lic.LICENSE_TYPES, @@ -166,6 +170,20 @@ def update(ticket_id: str): lifetime=request.form.get("lifetime") == "1", notes=request.form.get("notes", ""), ) + # Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus. Wird der + # Anspruch laenger, muss die Urkunde nachziehen -- sonst stuende die + # Vereinbarung an einer Stelle und gaelte an einer anderen, und der + # Kunde merkte es an dem Tag, an dem seine Installation stehenbleibt. + # + # Nur verlaengern, nie verkuerzen: eine ausgestellte und signierte + # Zusage einseitig zu beschneiden waere etwas anderes als eine + # Korrektur. Eine Verkuerzung wirkt auf die naechste Ausstellung. + nachgezogen = None + if changes: + with transaction(): + nachgezogen = issuance.lizenz_dem_ticket_nachziehen( + conn, ticket_id, master_private_key=signing_key().private_key + ) if changes: log_event( "ticket.updated", @@ -175,12 +193,25 @@ def update(ticket_id: str): scope="ticket", ) reporting.report_soon(core()) - flash( - "Gespeichert. Die Änderung wirkt auf die nächste Ausstellung." - if changes - else "Keine Änderungen.", - "success" if changes else "info", - ) + if nachgezogen is not None: + log_event( + "license.resigned", + target=str(nachgezogen["license_id"])[:12], + detail=f"Revision {nachgezogen['seq']}: Laufzeit dem Ticket angeglichen", + severity="notice", + ) + flash( + "Gespeichert. Die laufende Lizenz wurde mitverlängert (Revision " + f"{nachgezogen['seq']}) – der Client erhält sie beim nächsten Heartbeat.", + "success", + ) + else: + flash( + "Gespeichert. Die Änderung wirkt auf die nächste Ausstellung." + if changes + else "Keine Änderungen.", + "success" if changes else "info", + ) except (issuance.IssuanceError, ValueError) as exc: flash(str(exc), "danger") return redirect(url_for("tickets.detail", ticket_id=ticket_id)) diff --git a/apps/tesm-license/src/tesm_license/schema.py b/apps/tesm-license/src/tesm_license/schema.py index 486b7ce..d4eb61c 100644 --- a/apps/tesm-license/src/tesm_license/schema.py +++ b/apps/tesm-license/src/tesm_license/schema.py @@ -105,12 +105,17 @@ CREATE INDEX IF NOT EXISTS idx_licenses_customer ON licenses(customer_id); CREATE INDEX IF NOT EXISTS idx_licenses_status ON licenses(status); CREATE INDEX IF NOT EXISTS idx_licenses_fingerprint ON licenses(fingerprint); --- Hoechstens eine offene Lizenz pro Ticket -- auf Datenbankebene, nicht nur im --- Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei Anfragen) sauber --- abgefangen: der verlierende INSERT scheitert und der Aufrufer gibt die --- bereits erzeugte Lizenz zurueck. -CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket - ON licenses(ticket_id) WHERE status IN ('issued','active'); +-- Hoechstens eine offene Lizenz **je Art** pro Ticket -- auf Datenbankebene, +-- nicht nur im Python-Code. Damit ist der Doppelklick-Fall (zwei Tabs, zwei +-- Anfragen) sauber abgefangen: der verlierende INSERT scheitert und der +-- Aufrufer gibt die bereits erzeugte Lizenz zurueck. +-- +-- "Je Art", weil ein Ticket beides tragen darf: eine Testlizenz zum Ansehen +-- und die richtige Lizenz. Vorher war es ein Index ueber ticket_id allein -- +-- und damit schloss ein dreissigtaegiger Test die Ausstellung aus. +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art + ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END) + WHERE status IN ('issued','active'); -- Verbrauchte Nonces des Aktivierungsprotokolls. Verhindert Replay. CREATE TABLE IF NOT EXISTS license_nonces ( @@ -295,9 +300,24 @@ def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def] rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen) +#: Ein Ticket darf eine Testlizenz **und** eine Volllizenz tragen. +#: +#: Der alte Index stand ueber ``ticket_id`` allein: sobald irgendeine Lizenz +#: dieses Tickets lief, ging keine zweite mehr. Damit schloss ein +#: dreissigtaegiger Blick ins Produkt die richtige Ausstellung aus -- und wer +#: zuerst ausstellte, konnte nichts mehr testen lassen. Die Regel bleibt, sie +#: zaehlt nur die Art mit. +V5 = """ +DROP INDEX IF EXISTS idx_one_open_license_per_ticket; +CREATE UNIQUE INDEX IF NOT EXISTS idx_one_open_license_per_ticket_art + ON licenses(ticket_id, CASE WHEN type = 'trial' THEN 1 ELSE 0 END) + WHERE status IN ('issued','active'); +""" + LICENSE_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True), Migration(version=2, name="revision-timestamp", sql=V2), Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe), Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen), + Migration(version=5, name="testlizenz-neben-volllizenz", sql=V5), ) diff --git a/apps/tesm-license/src/tesm_license/services/issuance.py b/apps/tesm-license/src/tesm_license/services/issuance.py index 955004f..0544b6d 100644 --- a/apps/tesm-license/src/tesm_license/services/issuance.py +++ b/apps/tesm-license/src/tesm_license/services/issuance.py @@ -7,7 +7,7 @@ import re import secrets import sqlite3 from dataclasses import dataclass -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from typing import Any, Iterable, Sequence import tesm_licensing as lic @@ -16,6 +16,28 @@ from tesm_core.db import Database TICKET_TYPES = lic.LICENSE_TYPES TICKET_STATES = ("open", "blocked") + +#: Wann ist ein Ticket wirklich offen? +#: +#: Ein Ticket ist ein **Anspruch** auf eine Lizenz. Ist sie ausgestellt und +#: gilt sie, ist der Anspruch eingeloest -- offen ist dann nichts mehr. Die +#: Kachel auf der Startseite zaehlte trotzdem jedes Ticket mit ``status='open'`` +#: und meldete "3 offene Tickets", waehrend zu allen dreien laengst eine aktive +#: Lizenz gehoerte. Wer danach sucht, sucht umsonst. +#: +#: Abgeleitet statt gespeichert, und das ist Absicht: wird eine Lizenz spaeter +#: widerrufen, ist der Anspruch von selbst wieder offen -- genau dann soll neu +#: ausgestellt werden. Ein dritter Ticketzustand muesste an derselben Stelle +#: von Hand zurueckgedreht werden, und wer das einmal vergisst, hat ein Ticket, +#: das erledigt aussieht und keines ist. +#: Eine **Testlizenz zaehlt nicht**: sie ist ein Blick ins Produkt, kein +#: eingeloester Anspruch. Jedes Ticket darf eine laufen lassen und bleibt +#: trotzdem offen -- sonst muesste man sich zwischen "ausprobieren" und +#: "bekommen" entscheiden. +OHNE_GUELTIGE_LIZENZ = ( + "NOT EXISTS (SELECT 1 FROM licenses l " + "WHERE l.ticket_id = t.id AND l.status IN ('issued','active') AND l.type <> 'trial')" +) LICENSE_STATES = ("issued", "active", "deactivated", "revoked") #: Ampel-Schwellen der Anzeige. Bewusst getrennt von der Kulanzlogik in @@ -321,11 +343,22 @@ def set_ticket_status(conn: sqlite3.Connection, ticket_id: str, status: str) -> ) -def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool: +def ticket_has_open_license( + conn: sqlite3.Connection, ticket_id: str, *, trial: bool = False +) -> bool: + """Laeuft zu diesem Ticket schon eine Lizenz -- und zwar dieser Art? + + Zwei Spuren, absichtlich getrennt: **eine** Testlizenz und **eine** + Volllizenz je Ticket. Vorher war es eine gemeinsame Sperre, und damit + schloss ein dreissigtaegiger Blick ins Produkt die richtige Ausstellung + aus -- oder umgekehrt. Wer testen laesst, will danach ausstellen. + """ + vergleich = "=" if trial else "<>" return ( Database.value( conn, - "SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active')", + "SELECT COUNT(*) FROM licenses WHERE ticket_id=? AND status IN ('issued','active') " + f"AND type {vergleich} 'trial'", (ticket_id,), default=0, ) @@ -333,8 +366,15 @@ def ticket_has_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool: ) > 0 +def ticket_has_any_open_license(conn: sqlite3.Connection, ticket_id: str) -> bool: + """Irgendeine laufende Lizenz -- Test oder Voll. Fuers Loeschen zaehlt beides.""" + return ticket_has_open_license(conn, ticket_id) or ticket_has_open_license( + conn, ticket_id, trial=True + ) + + def delete_ticket(conn: sqlite3.Connection, ticket_id: str) -> None: - if ticket_has_open_license(conn, ticket_id): + if ticket_has_any_open_license(conn, ticket_id): raise IssuanceError( "Zu diesem Ticket existiert noch eine offene Lizenz. Bitte zuerst widerrufen." ) @@ -431,6 +471,17 @@ def issue_from_ticket( "Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver." ) + # Zwei Spuren je Ticket: eine Testlizenz **und** eine Volllizenz. Vorher + # sperrten sie sich gegenseitig -- ein dreissigtaegiger Blick ins Produkt + # schloss die richtige Ausstellung aus, und wer zuerst ausstellte, konnte + # nichts mehr testen lassen. + if ticket_has_open_license(conn, ticket_id, trial=as_trial): + raise IssuanceError( + "Für dieses Ticket läuft bereits eine Testlizenz." + if as_trial + else "Für dieses Ticket läuft bereits eine Lizenz." + ) + if as_trial: license_type = "trial" modules = sorted(lic.ALL_MODULES) @@ -507,10 +558,11 @@ def issue_from_ticket( # Der partielle Unique-Index hat zugeschlagen: ein zweiter Klick oder ein # zweiter Tab war schneller. Wir geben die tatsaechlich erzeugte Lizenz # zurueck statt einen Fehler zu zeigen. + vergleich = "=" if as_trial else "<>" existing = Database.one( conn, "SELECT license_id, license_document FROM licenses " - "WHERE ticket_id=? AND status IN ('issued','active')", + f"WHERE ticket_id=? AND status IN ('issued','active') AND type {vergleich} 'trial'", (ticket_id,), ) if existing is None: @@ -538,6 +590,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any) row = get_license(conn, license_id) if row is None: raise IssuanceError("Diese Lizenz existiert nicht.") + document = json.loads(str(row["license_document"])) bundle: dict[str, Any] = { "kind": "tesm-license-bundle", @@ -585,12 +638,44 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None: conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,)) +def ticket_grenze(conn: sqlite3.Connection, license_id: str) -> str | None: + """Bis wann darf diese Lizenz laufen? ``None`` heisst: unbegrenzt. + + Das Ticket ist der Anspruch, die Lizenz die Urkunde daraus -- und eine + Urkunde darf nicht mehr zusagen als der Anspruch hergibt. Vorher liess + sich eine Lizenz aus einem 25-Tage-Ticket beliebig weit verlaengern, ohne + dass am Ticket etwas davon zu sehen war: die Vereinbarung stand dann an + einer Stelle und galt an einer anderen. + + Gerechnet wird ab der **Ausstellung** der Lizenz, nicht ab heute: wer ein + Ticket ueber 90 Tage hat, bekommt 90 Tage ab Ausstellung, nicht 90 weitere. + + ``None`` bei unbefristetem Ticket -- und auch dann, wenn zur Lizenz gar + kein Ticket gehoert (Altbestand, Wurzelinstanz): wo es keinen Anspruch + gibt, gibt es nichts zu begrenzen. + """ + row = get_license(conn, license_id) + if row is None or not row["ticket_id"]: + return None + ticket = get_ticket(conn, str(row["ticket_id"])) + if ticket is None or bool(ticket["lifetime"]): + return None + # Eine Testlizenz lebt nach ihren eigenen Regeln und nicht nach dem Ticket. + if str(row["type"]) == "trial": + return None + document = json.loads(str(row["license_document"])) + return lic.iso( + lic.parse_iso(str(document["issued_at"])) + timedelta(days=int(ticket["valid_days"])) + ) + + def resign_license( conn: sqlite3.Connection, license_id: str, *, master_private_key: str, changes: dict[str, Any], + ueber_ticket: bool = False, ) -> dict[str, Any]: """Signiert eine neue Revision -- z. B. nach einer Modulerweiterung. @@ -600,6 +685,18 @@ def resign_license( row = get_license(conn, license_id) if row is None: raise IssuanceError("Diese Lizenz existiert nicht.") + + # Geprueft wird hier und nicht in der Route: es ist die eine Stelle, durch + # die jede Neusignatur laeuft -- auch die aus einer Ticketverlaengerung, + # die deshalb ausdruecklich vorbeidarf (``ueber_ticket``). + grenze = ticket_grenze(conn, license_id) + ziel = str(changes.get("expires_at") or "") + if grenze and ziel and not ueber_ticket and ziel > grenze: + raise IssuanceError( + "So lange deckt das Ticket diese Lizenz nicht: es reicht bis " + f"{lic.parse_iso(grenze).strftime('%d.%m.%Y')}. Verlängern Sie zuerst das " + "Ticket -- die Verlängerung wirkt dann auf diese Lizenz." + ) document = json.loads(str(row["license_document"])) updated = licsrv.resign_license( document, master_private_key_b64=master_private_key, changes=changes @@ -717,6 +814,56 @@ def display_state(row: dict[str, Any], *, now: datetime | None = None) -> dict[s } +def lizenz_dem_ticket_nachziehen( + conn: sqlite3.Connection, ticket_id: str, *, master_private_key: str +) -> dict[str, Any] | None: + """Zieht die laufende Lizenz eines Tickets auf dessen neue Laufzeit nach. + + Gibt die neue Revision zurueck, oder ``None``, wenn es nichts zu tun gab. + + **Nur verlaengern, nie verkuerzen.** Eine ausgestellte und signierte + Urkunde nachtraeglich zu beschneiden waere eine Zusage, die man einseitig + zurueckzieht -- und der Kunde erfuehre es erst, wenn seine Installation + stehenbleibt. Wird ein Ticket verkuerzt, gilt das fuer die naechste + Ausstellung. + """ + offene = Database.one( + conn, + "SELECT license_id FROM licenses WHERE ticket_id=? AND status IN ('issued','active') " + "AND type <> 'trial' LIMIT 1", + (ticket_id,), + ) + if offene is None: + return None + license_id = str(offene["license_id"]) + grenze = ticket_grenze(conn, license_id) + if grenze is None: + # Das Ticket ist unbefristet geworden -- dann auch die Lizenz. + row = get_license(conn, license_id) + if row is None or bool(row["lifetime"]): + return None + ticket = get_ticket(conn, ticket_id) + if ticket is None or not bool(ticket["lifetime"]): + return None + return resign_license( + conn, + license_id, + master_private_key=master_private_key, + changes={"lifetime": True}, + ueber_ticket=True, + ) + row = get_license(conn, license_id) + if row is None or bool(row["lifetime"]) or str(row["expires_at"]) >= grenze: + return None + return resign_license( + conn, + license_id, + master_private_key=master_private_key, + changes={"expires_at": grenze}, + ueber_ticket=True, + ) + + def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]: rows = list_licenses(conn) buckets: dict[str, list[dict[str, Any]]] = {"red": [], "orange": [], "green": []} @@ -734,7 +881,21 @@ def dashboard_summary(conn: sqlite3.Connection) -> dict[str, Any]: ), "tickets_open": int( Database.value( - conn, "SELECT COUNT(*) FROM license_tickets WHERE status='open'", default=0 + conn, + "SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' " + f"AND {OHNE_GUELTIGE_LIZENZ}", + default=0, + ) + or 0 + ), + # Zur Einordnung unter der Kachel: was schon erledigt ist, soll man + # sehen, ohne die Liste zu oeffnen. + "tickets_redeemed": int( + Database.value( + conn, + "SELECT COUNT(*) FROM license_tickets t WHERE t.status='open' " + f"AND NOT {OHNE_GUELTIGE_LIZENZ}", + default=0, ) or 0 ), diff --git a/apps/tesm-license/src/tesm_license/templates/license/dashboard.html b/apps/tesm-license/src/tesm_license/templates/license/dashboard.html index f3afea0..40e3c8a 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/dashboard.html +++ b/apps/tesm-license/src/tesm_license/templates/license/dashboard.html @@ -16,7 +16,13 @@ {% else %}
{{ stat('Kunden', summary.counts.customers, href=url_for('customers.overview')) }} - {{ stat('Offene Tickets', summary.counts.tickets_open, href=url_for('tickets.overview')) }} + {# "Offen" heisst: noch nicht eingeloest. Ein Ticket, zu dem eine gueltige + Lizenz gehoert, ist erledigt -- es hier mitzuzaehlen schickte jemanden + auf die Suche nach Arbeit, die es nicht gibt. #} + {{ stat('Offene Tickets', summary.counts.tickets_open, + hint_text=(summary.counts.tickets_redeemed ~ ' eingelöst') + if summary.counts.tickets_redeemed else '', + href=url_for('tickets.overview')) }} {{ stat('Aktive Lizenzen', summary.counts.licenses_active, kind='ok', href=url_for('licenses.overview', status='active')) }} {{ stat('Widerrufen', summary.counts.licenses_revoked, kind='muted', diff --git a/apps/tesm-license/src/tesm_license/templates/license/license_detail.html b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html index 6153daf..312a96b 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/license_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html @@ -101,6 +101,8 @@
+ {# Die Laufzeit haengt am Ticket: weiter als dessen Anspruch reicht, geht + sie nicht. Das steht hier, damit es nicht erst die Fehlermeldung sagt. #} {% if current_user.has('license_renew.execute') and license.status != 'revoked' %}
diff --git a/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html index f60ff08..b4cd5cf 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html @@ -36,6 +36,8 @@ Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst. {% else %} Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets. + Die Laufzeit kommt aus dem Ticket und kann später nur mit ihm + verlängert werden. {% endif %}
@@ -57,8 +59,11 @@