Lizenzserver 2.8.12: Ticket als Anspruch, Lizenz als Urkunde daraus

This commit is contained in:
alientim committed 2026-09-16 22:00:59 +02:00
1 parent b984e22074
commit 641ea22b3c
8 files changed
+250 -22

No files matched your search

+1 -1
View File
@@ -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 = [
@@ -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))
+26 -6
View File
@@ -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),
)
@@ -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
),
@@ -16,7 +16,13 @@
{% else %}
<div class="grid grid--4">
{{ 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',
@@ -101,6 +101,8 @@
</div>
</section>
{# 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' %}
<section class="card">
<div class="card__header">
@@ -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 %}
</div>
</div>
@@ -57,8 +59,11 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="as_trial" value="1">
<button class="btn" type="submit"
{% if has_open_license or ticket.status == 'blocked' %}disabled{% endif %}
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket selbst bleibt unverändert."
{# Eine laufende **Test**lizenz sperrt diesen Knopf, eine
Volllizenz nicht: ansehen und bekommen sind zweierlei,
und ein Ticket darf beides tragen. #}
{% if has_open_trial or ticket.status == 'blocked' %}disabled{% endif %}
data-confirm="Testlizenz ausstellen? {{ trial_days }} Tage Laufzeit mit allen Modulen. Das Ticket bleibt unverändert und offen -- eine Testlizenz löst es nicht ein."
data-confirm-title="Testlizenz" data-confirm-ok="Ausstellen"
data-confirm-style="primary">
Als Testlizenz ({{ trial_days }} Tage, alle Module)
@@ -62,7 +62,10 @@ Ausstellung -- bereits ausgestellte Lizenzen bleiben unverändert.', 'info') }}
{% else %}{{ ticket.valid_days }} Tage{% endif %}
</td>
<td>
{# Dieselbe Rechnung wie auf der Startseite, sonst widersprechen
sich die beiden Seiten: dort "0 offen", hier dreimal "offen". #}
{% if ticket.status == 'blocked' %}{{ pill('gesperrt', 'danger') }}
{% elif ticket.open_license_id %}{{ pill('eingelöst', '') }}
{% else %}{{ pill('offen', 'online') }}{% endif %}
</td>
<td class="fs-sm">