Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
4.3 KiB
Python
136 lines
4.3 KiB
Python
"""Gemeinsame Helfer der Lizenzserver-Blueprints."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
from urllib.parse import urljoin
|
|
|
|
from flask import url_for
|
|
from tesm_core import audit
|
|
from tesm_core.auth.models import current_user
|
|
from tesm_core.auth.service import client_ip
|
|
from tesm_core.extension import core, db, transaction
|
|
|
|
import tesm_licensing as lic
|
|
|
|
from ..services import master_key
|
|
|
|
|
|
def log_event(
|
|
action: str,
|
|
*,
|
|
target: str = "",
|
|
detail: str = "",
|
|
severity: str = "info",
|
|
scope: str = "lizenz",
|
|
) -> None:
|
|
"""Schreibt ins Aenderungsprotokoll **und** ins laufende Protokoll."""
|
|
extension = core()
|
|
with transaction() as conn:
|
|
audit.log(
|
|
conn,
|
|
actor=current_user.username or "system",
|
|
actor_id=getattr(current_user, "id", None) or None,
|
|
action=action,
|
|
target=target,
|
|
detail=detail,
|
|
ip=client_ip(),
|
|
severity=severity,
|
|
)
|
|
live = extension.registry.get("live_log")
|
|
if live is not None:
|
|
text = f"{action} {target}".strip() + (f" -- {detail}" if detail else "")
|
|
if severity in {"warning", "critical"}:
|
|
live.warning(scope, text)
|
|
else:
|
|
live.info(scope, text)
|
|
|
|
|
|
def signing_key() -> master_key.MasterKey:
|
|
"""Signaturschluessel dieser Installation (erzeugt ihn beim ersten Aufruf)."""
|
|
return master_key.ensure(core().registry["master_key_path"])
|
|
|
|
|
|
def vendor_info() -> dict[str, Any]:
|
|
"""Anbieterdaten, wie sie in jede Lizenz eingebettet werden."""
|
|
values = core().settings.get_many(
|
|
db(),
|
|
[
|
|
"vendor_name",
|
|
"vendor_email",
|
|
"vendor_phone",
|
|
"vendor_address",
|
|
"vendor_logo_base64",
|
|
],
|
|
)
|
|
return {
|
|
"name": str(values["vendor_name"] or ""),
|
|
"email": str(values["vendor_email"] or ""),
|
|
"phone": str(values["vendor_phone"] or ""),
|
|
"address": str(values["vendor_address"] or ""),
|
|
"logo_base64": str(values["vendor_logo_base64"] or ""),
|
|
}
|
|
|
|
|
|
def master_endpoint() -> str:
|
|
return str(core().settings.get(db(), "master_endpoint") or "").rstrip("/")
|
|
|
|
|
|
def customer_facing_url(endpoint: str, **values: Any) -> str:
|
|
"""Baut Links, die ein Kunde tatsaechlich oeffnen soll.
|
|
|
|
``url_for(..., _external=True)`` nimmt den Host-Header der aktuellen
|
|
Anfrage. Bei einem intern angemeldeten Administrator ist das oft eine interne
|
|
IP -- fuer den Browser oder das Mailprogramm eines Kunden unbrauchbar. Darum
|
|
kommt die Basis aus der Einstellung ``master_endpoint``; nur wenn die leer
|
|
ist (frische Installation), faellt es auf das Host-Header-Verhalten zurueck.
|
|
"""
|
|
base = master_endpoint()
|
|
if not base:
|
|
return url_for(endpoint, _external=True, **values)
|
|
return urljoin(base + "/", url_for(endpoint, **values).lstrip("/"))
|
|
|
|
|
|
def can_issue_license_server_licenses() -> bool:
|
|
"""Darf diese Instanz Lizenzen fuer weitere Lizenzserver ausstellen?
|
|
|
|
Das haengt an der Rolle der **eigenen** Lizenzdatei, nicht an einem
|
|
Gruppenrecht. Ein Gruppenrecht koennte sich ein lokaler Administrator eines
|
|
Sub-Lizenzservers selbst einraeumen; eine von woanders signierte Lizenzdatei
|
|
nicht. Zusaetzlich verlangt es echte Administratorrechte -- das Ausstellen
|
|
einer Lizenzserver-Lizenz erzeugt eine neue Vertrauensgrenze und ist damit
|
|
keine delegierbare Alltagsaufgabe.
|
|
"""
|
|
manager = core().license
|
|
if manager is None:
|
|
return bool(getattr(current_user, "is_admin", False))
|
|
return bool(
|
|
getattr(current_user, "is_admin", False)
|
|
and manager.active()
|
|
and manager.state.role == "master"
|
|
)
|
|
|
|
|
|
def module_labels() -> dict[str, str]:
|
|
return dict(lic.MODULE_LABELS)
|
|
|
|
|
|
def type_labels() -> dict[str, str]:
|
|
return dict(lic.LICENSE_TYPE_LABELS)
|
|
|
|
|
|
def role_labels() -> dict[str, str]:
|
|
return dict(lic.ROLE_LABELS)
|
|
|
|
|
|
def license_filename(license_id: str, customer_name: str) -> str:
|
|
safe = "".join(
|
|
char if char.isalnum() or char in "-_" else "-" for char in (customer_name or "kunde")
|
|
).strip("-")
|
|
return f"tesm-lizenz-{safe.lower() or 'kunde'}-{license_id[:8]}.json"
|
|
|
|
|
|
def bundle_bytes(bundle: dict[str, Any]) -> bytes:
|
|
return (json.dumps(bundle, indent=2, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|