From 11a55b20f88ae9b8bf2f2885e4894d73d0c272fb Mon Sep 17 00:00:00 2001 From: Tim Eertmoed Date: Fri, 11 Sep 2026 21:36:24 +0200 Subject: [PATCH] Version 2.7.1: Signaturverzeichnis ueberlebt die Archivierung --- apps/tesm/pyproject.toml | 2 +- apps/tesm/src/tesm/blueprints/fileshare.py | 13 +++ apps/tesm/src/tesm/schema.py | 64 +++++++++++++ apps/tesm/src/tesm/services/fileshare.py | 94 ++++++++++++++----- packages/tesm-core/pyproject.toml | 2 +- .../tesm-core/src/tesm_core/app_factory.py | 2 +- .../tesm_core/static/tesm_core/js/viewer.js | 12 ++- packages/tesm-licensing/pyproject.toml | 2 +- 8 files changed, 163 insertions(+), 28 deletions(-) diff --git a/apps/tesm/pyproject.toml b/apps/tesm/pyproject.toml index baf0985..c87d071 100644 --- a/apps/tesm/pyproject.toml +++ b/apps/tesm/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm" -version = "2.7.0" +version = "2.7.1" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm/src/tesm/blueprints/fileshare.py b/apps/tesm/src/tesm/blueprints/fileshare.py index 3da68c2..132a23f 100644 --- a/apps/tesm/src/tesm/blueprints/fileshare.py +++ b/apps/tesm/src/tesm/blueprints/fileshare.py @@ -348,6 +348,19 @@ def save_form(token: str): beschreibung, severity="notice", ) + if unterschriften: + # Zusaetzlich ins Signaturverzeichnis: das Protokoll wird irgendwann + # archiviert, die Frage "ist diese Datei noch die unterschriebene?" + # stellt sich aber noch Jahre spaeter. + with transaction() as conn: + service.signatur_merken( + conn, + sha256=pruefsumme, + label=ziel_mount.label, + pfad=wohin, + actor=current_user.username, + namen=unterschriften, + ) return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme} diff --git a/apps/tesm/src/tesm/schema.py b/apps/tesm/src/tesm/schema.py index 6277d45..d5b7396 100644 --- a/apps/tesm/src/tesm/schema.py +++ b/apps/tesm/src/tesm/schema.py @@ -308,6 +308,69 @@ INSERT OR IGNORE INTO group_permissions (group_id, permission) """ +# Die Pruefung "unveraendert seit der Unterschrift" hing am Aenderungsprotokoll +# -- und das wird ab 20.000 Zeilen tageweise in Dateien ausgelagert. Mit dem +# ausgelagerten Tag waere die Pruefsumme aus der Datenbank verschwunden und +# jede aeltere unterschriebene Fassung waere still zu "unbekannt" geworden. +# Schlimmer noch: auch eine **veraenderte** Fassung, denn ohne Vergleichswert +# gibt es keinen Widerspruch. +# +# Deshalb eine eigene, winzige Tabelle: eine Zeile je Unterschrift, nie +# archiviert. Sie ist ein Verzeichnis, keine Wahrheit -- die Wahrheit bleibt +# das Aenderungsprotokoll mit seiner Hash-Kette, auch im Archiv. +V7 = """ +CREATE TABLE IF NOT EXISTS document_signatures ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sha256 TEXT NOT NULL, + share_label TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL DEFAULT '', + signed_at TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + names TEXT NOT NULL DEFAULT '', + audit_id INTEGER +); + +CREATE INDEX IF NOT EXISTS idx_document_signatures_sha + ON document_signatures(sha256); + +CREATE INDEX IF NOT EXISTS idx_document_signatures_ort + ON document_signatures(share_label, path); +""" + + +def uebernehme_signaturen(conn) -> None: # type: ignore[no-untyped-def] + """Traegt die bereits protokollierten Unterschriften ins Verzeichnis ein. + + Ohne diesen Schritt waeren alle vor dem Update unterschriebenen Fassungen + ab sofort "unbekannt" -- richtig waere das nicht, die Eintraege stehen ja + noch da. + """ + from .services import fileshare as service + + zeilen = conn.execute( + "SELECT id, ts, actor, detail FROM audit_log WHERE action='fileshare.form_signed' " + "ORDER BY id" + ).fetchall() + for zeile in zeilen: + detail = str(zeile["detail"] or "") + gelesen = service.signaturdetail_lesen(detail) + if not gelesen["sha256"]: + continue + conn.execute( + "INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, " + "names, audit_id) VALUES (?,?,?,?,?,?,?)", + ( + gelesen["sha256"], + gelesen["label"], + gelesen["pfad"], + str(zeile["ts"]), + str(zeile["actor"] or ""), + ", ".join(gelesen["namen"]), + int(zeile["id"]), + ), + ) + + TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=1, name="baseline", sql=V1), Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), @@ -315,4 +378,5 @@ TESM_MIGRATIONS: tuple[Migration, ...] = ( Migration(version=4, name="fileshare-pdf-rechte", sql=V4), Migration(version=5, name="terminal-getrennt", sql=V5), Migration(version=6, name="wartung-eigener-bereich", sql=V6), + Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen), ) diff --git a/apps/tesm/src/tesm/services/fileshare.py b/apps/tesm/src/tesm/services/fileshare.py index 4da6bcf..168777d 100644 --- a/apps/tesm/src/tesm/services/fileshare.py +++ b/apps/tesm/src/tesm/services/fileshare.py @@ -620,6 +620,55 @@ def pruefsumme(pfad: Path) -> str: return kessel.hexdigest() +def signaturdetail_lesen(detail: str) -> dict[str, Any]: + """Zerlegt die Protokollzeile einer Unterschrift. + + Aufbau: ``: sha256= -- angegeben unterschrieben + von: ``. Gelesen wird sie an zwei Stellen -- beim Uebernehmen + alter Eintraege und wenn das Verzeichnis nichts weiss. + """ + ort, _, rest = detail.partition(" sha256=") + label, _, pfad = ort.partition(":") + summe = rest.split(" ", 1)[0].strip() if rest else "" + return { + "sha256": summe, + "label": label.strip(), + "pfad": pfad.strip(), + "namen": _namen_aus_detail(detail), + } + + +def signatur_merken( + conn: sqlite3.Connection, + *, + sha256: str, + label: str, + pfad: str, + actor: str, + namen: Iterable[str], + audit_id: int | None = None, +) -> None: + """Haelt eine Unterschrift im Verzeichnis fest. + + Das Aenderungsprotokoll bleibt der Beleg -- mit Hash-Kette und, nach der + Archivierung, in einer Datei. Dieses Verzeichnis ist nur der schnelle Weg + dorthin und wird nie ausgelagert; eine Zeile misst ein paar hundert Byte. + """ + conn.execute( + "INSERT INTO document_signatures (sha256, share_label, path, signed_at, actor, " + "names, audit_id) VALUES (?,?,?,?,?,?,?)", + ( + sha256, + label, + pfad, + _iso(_now()), + actor, + ", ".join(namen), + audit_id, + ), + ) + + def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]: """Ist diese Datei die, die unterschrieben wurde? @@ -633,8 +682,12 @@ def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> di * ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in einer Freigabe sind nie durch TESM gegangen. - Gesucht wird nur in der Datenbank. Ausgelagerte Protokolltage (Archiv) - bleiben aussen vor -- ein Dateidialog darf nicht minutenlang suchen. + Nachgeschlagen wird im Signaturverzeichnis (``document_signatures``) und + nicht im Aenderungsprotokoll: das wird ab 20.000 Zeilen tageweise in + Dateien ausgelagert, und mit dem ausgelagerten Tag waere die Pruefsumme aus + der Datenbank verschwunden. Eine aeltere unterschriebene Fassung waere + still zu "unbekannt" geworden -- und eine **veraenderte** ebenso, weil ohne + Vergleichswert niemand widerspricht. Das Verzeichnis wird nie archiviert. """ ziel = resolve_path(mount, relative) if not ziel.is_file(): @@ -643,37 +696,36 @@ def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> di zeile = Database.one( conn, - "SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? " + "SELECT signed_at, actor, names FROM document_signatures WHERE sha256=? " "ORDER BY id DESC LIMIT 1", - (SIGNATUR_AKTION, f"%sha256={hash_}%"), + (hash_,), ) if zeile is not None: - return { - "status": "unveraendert", - "sha256": hash_, - "zeit": str(zeile["ts"]), - "konto": str(zeile["actor"]), - "namen": _namen_aus_detail(str(zeile["detail"])), - } + return _stand("unveraendert", hash_, zeile) # Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort. zeile = Database.one( conn, - "SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? " - "ORDER BY id DESC LIMIT 1", - (SIGNATUR_AKTION, f"{mount.label}:{relative} sha256=%"), + "SELECT signed_at, actor, names FROM document_signatures WHERE share_label=? " + "AND path=? ORDER BY id DESC LIMIT 1", + (mount.label, relative), ) if zeile is not None: - return { - "status": "veraendert", - "sha256": hash_, - "zeit": str(zeile["ts"]), - "konto": str(zeile["actor"]), - "namen": _namen_aus_detail(str(zeile["detail"])), - } + return _stand("veraendert", hash_, zeile) return {"status": "unbekannt", "sha256": hash_} +def _stand(status: str, hash_: str, zeile: Any) -> dict[str, Any]: + namen = [teil.strip() for teil in str(zeile["names"] or "").split(",") if teil.strip()] + return { + "status": status, + "sha256": hash_, + "zeit": str(zeile["signed_at"]), + "konto": str(zeile["actor"] or ""), + "namen": namen, + } + + def _namen_aus_detail(detail: str) -> list[str]: """Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten.""" marke = "angegeben unterschrieben von: " diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index dbe60fe..4259a29 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.7.0" +version = "2.7.1" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index 57d893e..9ad0785 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -148,7 +148,7 @@ def create_base_app( #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Anwendungen. auth_tagline: str = "", - app_version: str = "2.7.0", + app_version: str = "2.7.1", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js index 8cd0b16..8e85231 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/viewer.js @@ -905,10 +905,16 @@ export function initFilePreview(wurzel = document) { // Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt" // oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr. // Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert. - // Einmal "-ausgefuellt" reicht: wer eine ausgefuellte Fassung noch einmal - // oeffnet und speichert, bekam sonst "...-ausgefuellt-ausgefuellt". + // Der Zusatz sagt, was in der Datei steht: unterschrieben schlaegt + // ausgefuellt. Und einmal reicht -- wer eine Fassung noch einmal oeffnet + // und speichert, bekam sonst "...-ausgefuellt-ausgefuellt". const rumpfname = datei.replace(/\.pdf$/i, ""); - nameFeld.value = /-ausgefuellt$/i.test(rumpfname) ? rumpfname : `${rumpfname}-ausgefuellt`; + const zusatz = pdf?.unterschriften().length ? "-unterschrieben" : "-ausgefuellt"; + const bereits = new RegExp(`${zusatz}$`, "i").test(rumpfname); + // Eine unterschriebene Fassung aus einer ausgefuellten: der alte Zusatz + // haette sonst zweimal dran gestanden ("-ausgefuellt-unterschrieben"). + const blank = rumpfname.replace(/-(ausgefuellt|unterschrieben)$/i, ""); + nameFeld.value = bereits ? rumpfname : `${blank}${zusatz}`; ersetzenFeld.checked = false; ersetzenKasten?.classList.remove("is-gefragt"); }; diff --git a/packages/tesm-licensing/pyproject.toml b/packages/tesm-licensing/pyproject.toml index 066d6ac..46e139e 100644 --- a/packages/tesm-licensing/pyproject.toml +++ b/packages/tesm-licensing/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-licensing" -version = "2.7.0" +version = "2.7.1" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = ["cryptography>=42"]