Compare commits

..
1 Commits
Author SHA1 Message Date
alientim 11a55b20f8 Version 2.7.1: Signaturverzeichnis ueberlebt die Archivierung 2026-09-11 21:36:24 +02:00
8 changed files with 163 additions and 28 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm" name = "tesm"
version = "2.7.0" version = "2.7.1"
description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen" description = "TESM -- Verwaltung und Ueberwachung von PoE-Endgeraeten und Aruba-Switchen"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -348,6 +348,19 @@ def save_form(token: str):
beschreibung, beschreibung,
severity="notice", 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} return {"message": f"Gespeichert als {wohin}.", "path": wohin, "sha256": pruefsumme}
+64
View File
@@ -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, ...] = ( TESM_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1), Migration(version=1, name="baseline", sql=V1),
Migration(version=2, name="seed-dhcp-options", fn=seed_dhcp_options), 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=4, name="fileshare-pdf-rechte", sql=V4),
Migration(version=5, name="terminal-getrennt", sql=V5), Migration(version=5, name="terminal-getrennt", sql=V5),
Migration(version=6, name="wartung-eigener-bereich", sql=V6), Migration(version=6, name="wartung-eigener-bereich", sql=V6),
Migration(version=7, name="signaturverzeichnis", sql=V7, fn=uebernehme_signaturen),
) )
+73 -21
View File
@@ -620,6 +620,55 @@ def pruefsumme(pfad: Path) -> str:
return kessel.hexdigest() return kessel.hexdigest()
def signaturdetail_lesen(detail: str) -> dict[str, Any]:
"""Zerlegt die Protokollzeile einer Unterschrift.
Aufbau: ``<Freigabe>:<Pfad> sha256=<Summe> -- angegeben unterschrieben
von: <Namen>``. 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]: def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> dict[str, Any]:
"""Ist diese Datei die, die unterschrieben wurde? """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 * ``unbekannt`` -- kein Eintrag. Kein Verdacht: die meisten Dateien in
einer Freigabe sind nie durch TESM gegangen. einer Freigabe sind nie durch TESM gegangen.
Gesucht wird nur in der Datenbank. Ausgelagerte Protokolltage (Archiv) Nachgeschlagen wird im Signaturverzeichnis (``document_signatures``) und
bleiben aussen vor -- ein Dateidialog darf nicht minutenlang suchen. 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) ziel = resolve_path(mount, relative)
if not ziel.is_file(): if not ziel.is_file():
@@ -643,37 +696,36 @@ def signaturstand(conn: sqlite3.Connection, mount: "Mount", relative: str) -> di
zeile = Database.one( zeile = Database.one(
conn, 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", "ORDER BY id DESC LIMIT 1",
(SIGNATUR_AKTION, f"%sha256={hash_}%"), (hash_,),
) )
if zeile is not None: if zeile is not None:
return { return _stand("unveraendert", hash_, zeile)
"status": "unveraendert",
"sha256": hash_,
"zeit": str(zeile["ts"]),
"konto": str(zeile["actor"]),
"namen": _namen_aus_detail(str(zeile["detail"])),
}
# Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort. # Nichts mit dieser Pruefsumme -- aber vielleicht etwas zu diesem Ort.
zeile = Database.one( zeile = Database.one(
conn, conn,
"SELECT ts, actor, detail FROM audit_log WHERE action=? AND detail LIKE ? " "SELECT signed_at, actor, names FROM document_signatures WHERE share_label=? "
"ORDER BY id DESC LIMIT 1", "AND path=? ORDER BY id DESC LIMIT 1",
(SIGNATUR_AKTION, f"{mount.label}:{relative} sha256=%"), (mount.label, relative),
) )
if zeile is not None: if zeile is not None:
return { return _stand("veraendert", hash_, zeile)
"status": "veraendert",
"sha256": hash_,
"zeit": str(zeile["ts"]),
"konto": str(zeile["actor"]),
"namen": _namen_aus_detail(str(zeile["detail"])),
}
return {"status": "unbekannt", "sha256": hash_} 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]: def _namen_aus_detail(detail: str) -> list[str]:
"""Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten.""" """Die angegebenen Namen aus dem Protokolltext -- ohne sie zu deuten."""
marke = "angegeben unterschrieben von: " marke = "angegeben unterschrieben von: "
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" 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" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -148,7 +148,7 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.7.0", app_version: str = "2.7.1",
built_in_trusted_keys: tuple[str, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
@@ -905,10 +905,16 @@ export function initFilePreview(wurzel = document) {
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt" // Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr. // oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert. // Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
// Einmal "-ausgefuellt" reicht: wer eine ausgefuellte Fassung noch einmal // Der Zusatz sagt, was in der Datei steht: unterschrieben schlaegt
// oeffnet und speichert, bekam sonst "...-ausgefuellt-ausgefuellt". // ausgefuellt. Und einmal reicht -- wer eine Fassung noch einmal oeffnet
// und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
const rumpfname = datei.replace(/\.pdf$/i, ""); 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; ersetzenFeld.checked = false;
ersetzenKasten?.classList.remove("is-gefragt"); ersetzenKasten?.classList.remove("is-gefragt");
}; };
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.7.0" version = "2.7.1"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]