TESM-Lizenzserver 2.0.0 -- Neubau

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>
This commit is contained in:
2026-09-02 18:01:23 +02:00
co-authored by Claude Opus 5
parent c130c85b17
commit f7805a2180
211 changed files with 31099 additions and 14759 deletions
+211
View File
@@ -0,0 +1,211 @@
"""Gemeinsame Testfixtures.
Jeder Test bekommt eine frische Instanz in einem temporaeren Verzeichnis --
eigene Datenbank, eigene Schluessel, eigenes Protokollverzeichnis. Nichts
beruehrt eine echte Installation.
"""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any, Iterator
import pytest
@pytest.fixture()
def instance_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
base = tmp_path / "instance"
monkeypatch.setenv("TESM_BASE_DIR", str(base))
monkeypatch.setenv("TESM_LICENSE_BASE_DIR", str(tmp_path / "instance-license"))
# Tests laufen ueber http:// -- ein Secure-Cookie wuerde nie gesetzt.
monkeypatch.setenv("TESM_COOKIE_SECURE", "0")
monkeypatch.setenv("TESM_LICENSE_COOKIE_SECURE", "0")
monkeypatch.setenv("TESM_DEBUG", "0")
monkeypatch.setenv("TESM_LICENSE_DEBUG", "0")
return base
@pytest.fixture()
def core_app(instance_env: Path) -> Iterator[Any]:
"""Minimale App auf Basis von ``tesm_core`` -- ohne Fachlogik der beiden Apps."""
from flask import render_template_string
from tesm_core import load_core_config
from tesm_core.app_factory import create_base_app
from tesm_core.auth.models import login_required
from tesm_core.nav import NavItem, NavTree
from tesm_core.rbac.common import administration_area, observability_area
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
demo = Area(
key="demo",
label="Demo",
icon="grid",
resources=(
Resource(
key="widgets",
label="Widgets",
actions=(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.SECRETS),
),
),
)
tree = PermissionTree([demo, administration_area(), observability_area()])
nav = NavTree(
[
NavItem(key="index", label="Start", icon="grid", endpoint="index"),
NavItem(
key="widgets", label="Widgets", icon="cpu", endpoint="widgets",
permissions=("widgets.view",),
),
NavItem(
key="administration",
label="Verwaltung",
icon="sliders",
children=(
NavItem(
key="users", label="Benutzer", icon="users", endpoint="admin.users",
permissions=("users.view",),
),
NavItem(
key="groups", label="Gruppen", icon="shield-users", endpoint="admin.groups",
permissions=("groups.view",),
),
NavItem(
key="trash", label="Papierkorb", icon="trash", endpoint="trash.overview",
permissions=("trash.view",),
),
NavItem(
key="diagnostics", label="Diagnose", icon="info",
endpoint="diagnostics.overview", permissions=("diagnostics.view",),
),
),
),
]
)
presets = (
RolePreset(
key="viewer",
name="Benutzer",
description="Nur lesen",
grants=(("widgets", (Action.VIEW,)),),
is_default=True,
),
)
config = load_core_config(
app_key="tesm", app_name="TESM Test", env_prefix="TESM_", default_base=instance_env
)
app = create_base_app(
config=config,
permissions=tree,
nav=nav,
role_presets=presets,
app_short="TT",
app_edition="Test",
enable_license=False,
)
@app.get("/")
def index(): # type: ignore[no-untyped-def]
return render_template_string(
'{% extends "tesm_core/base.html" %}{% block page_title %}Start{% endblock %}'
"{% block content %}<p>Start</p>{% endblock %}"
)
@app.get("/widgets")
@login_required
def widgets(): # type: ignore[no-untyped-def]
return render_template_string(
'{% extends "tesm_core/base.html" %}{% block page_title %}Widgets{% endblock %}'
"{% block content %}<p>Widgets</p>{% endblock %}"
)
app.config.update(TESTING=True)
yield app
@pytest.fixture()
def client(core_app: Any) -> Any:
return core_app.test_client()
@pytest.fixture()
def make_user(core_app: Any):
"""Erzeugt ein Konto direkt in der Datenbank."""
def factory(
username: str = "tester",
password: str = "Sicher-Passwort-2026",
*,
is_admin: bool = False,
permissions: tuple[str, ...] = (),
is_locked: bool = False,
) -> dict[str, Any]:
from tesm_core.db import Database
from tesm_core.extension import core
from tesm_core.security import passwords
extension = core(core_app)
with extension.database.session() as conn, extension.database.transaction(conn):
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, is_locked, auth_source, "
"created_at, updated_at) VALUES (?,?,?,?,'local',datetime('now'),datetime('now'))",
(username, passwords.hash_password(password), int(is_admin), int(is_locked)),
)
user_id = int(cursor.lastrowid or 0)
if permissions:
group = conn.execute(
"INSERT INTO groups (name, description, created_at, updated_at) "
"VALUES (?,'Testgruppe',datetime('now'),datetime('now'))",
(f"grp-{username}",),
)
group_id = int(group.lastrowid or 0)
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, p) for p in extension.permissions.sanitize(permissions)],
)
conn.execute(
"INSERT INTO user_groups (user_id, group_id) VALUES (?,?)", (user_id, group_id)
)
row = Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
assert row is not None
return {**row, "password": password}
return factory
@pytest.fixture()
def login(client: Any):
def do_login(username: str, password: str) -> Any:
return client.post(
"/login",
data={"username": username, "password": password, "csrf_token": _token(client)},
follow_redirects=True,
)
return do_login
def _token(client: Any) -> str:
"""Holt ein CSRF-Token aus der Anmeldeseite."""
page = client.get("/login")
body = page.get_data(as_text=True)
marker = 'name="csrf_token" value="'
start = body.index(marker) + len(marker)
return body[start : body.index('"', start)]
@pytest.fixture()
def csrf_token(client: Any):
def get(path: str = "/login") -> str:
page = client.get(path)
body = page.get_data(as_text=True)
marker = 'name="csrf_token" value="'
if marker not in body:
marker = 'data-csrf="'
start = body.index(marker) + len(marker)
return body[start : body.index('"', start)]
return get