diff --git a/packages/tesm-core/src/tesm_core/audit.py b/packages/tesm-core/src/tesm_core/audit.py index d430a64..9b320b4 100644 --- a/packages/tesm-core/src/tesm_core/audit.py +++ b/packages/tesm-core/src/tesm_core/audit.py @@ -28,7 +28,7 @@ import sqlite3 from collections import defaultdict from datetime import datetime, timezone from pathlib import Path -from typing import Any, Iterable +from typing import Any, Collection, Iterable, Mapping, Sequence from .db import Database @@ -116,6 +116,49 @@ def log( return int(cursor.lastrowid or 0) +def diff( + vorher: Mapping[str, Any] | None, + nachher: Mapping[str, Any] | None, + *, + felder: Sequence[tuple[str, str]] | None = None, + geheim: Collection[str] = (), +) -> str: + """Was sich geaendert hat: ``Feld: alt -> neu``, mit Komma getrennt. + + Damit steht im Protokoll nicht nur *dass* etwas geaendert wurde, sondern + auch *von was auf was* -- der Weg zurueck steht damit in der Zeile selbst. + + ``felder`` gibt Auswahl und Beschriftung vor (``[("ssh_port", "SSH-Port")]``); + ohne Angabe werden alle gemeinsamen Felder verglichen. Namen in ``geheim`` + werden nie im Klartext geschrieben -- dort steht nur, dass sie sich + geaendert haben. + """ + if vorher is None or nachher is None: + return "" + paare = felder or [(name, name) for name in sorted(set(vorher) & set(nachher))] + teile: list[str] = [] + for name, beschriftung in paare: + if name not in vorher or name not in nachher: + continue + alt, neu = vorher[name], nachher[name] + if alt == neu: + continue + if name in geheim: + teile.append(f"{beschriftung}: geaendert") + continue + teile.append(f"{beschriftung}: {_wert(alt)} -> {_wert(neu)}") + return ", ".join(teile) + + +def _wert(wert: Any) -> str: + if wert is None or wert == "": + return "(leer)" + if isinstance(wert, bool): + return "ein" if wert else "aus" + text = " ".join(str(wert).split()) + return text if len(text) <= 80 else text[:79] + "…" + + def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]: """Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``.""" sql = "SELECT * FROM audit_log ORDER BY id" diff --git a/packages/tesm-core/src/tesm_core/auth/service.py b/packages/tesm-core/src/tesm_core/auth/service.py index dd90cd6..df54772 100644 --- a/packages/tesm-core/src/tesm_core/auth/service.py +++ b/packages/tesm-core/src/tesm_core/auth/service.py @@ -359,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None: current_app.logger.exception( "Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook) ) - with core().database.transaction(conn): - audit.log( - conn, - actor=user.username, - actor_id=user.id, - action="auth.logout", - target=user.username, - ip=client_ip(), - ) + # Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert + # nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte + # die Eintraege, um die es geht. Wer wissen will, wer wann da war, + # liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und + # Sperren stehen weiterhin jedes Mal im Protokoll. session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined] g.user = ANONYMOUS diff --git a/packages/tesm-core/src/tesm_core/rbac/service.py b/packages/tesm-core/src/tesm_core/rbac/service.py index 01c4842..9e8a912 100644 --- a/packages/tesm-core/src/tesm_core/rbac/service.py +++ b/packages/tesm-core/src/tesm_core/rbac/service.py @@ -556,14 +556,20 @@ def update_user( ) fields: dict[str, Any] = {} + # Mit Werten, nicht nur "geaendert": aus dem Protokoll soll man den + # vorherigen Stand ablesen koennen, ohne eine Sicherung auszupacken. + beschriftungen = {"first_name": "Vorname", "last_name": "Nachname", "email": "E-Mail"} for column, value in ( ("first_name", first_name), ("last_name", last_name), ("email", email), ): - if value is not None and value.strip() != str(target.get(column) or ""): + alt = str(target.get(column) or "") + if value is not None and value.strip() != alt: fields[column] = value.strip() - changes.append(f"{column} geändert") + changes.append( + f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}" + ) if is_admin is not None and bool(is_admin) != bool(target["is_admin"]): if not getattr(actor, "is_admin", False): diff --git a/packages/tesm-core/src/tesm_core/settings_store.py b/packages/tesm-core/src/tesm_core/settings_store.py index 6e01881..8604067 100644 --- a/packages/tesm-core/src/tesm_core/settings_store.py +++ b/packages/tesm-core/src/tesm_core/settings_store.py @@ -67,6 +67,45 @@ class SettingSpec: return "" if value is None else str(value) +#: So lang darf ein Wert im Protokoll werden -- danach wird gekuerzt. Ein +#: JSON-Feld mit der halben Navigationsreihenfolge macht die Zeile unlesbar. +WERT_GRENZE = 80 + + +class Aenderung(str): + """Eine geaenderte Einstellung, lesbar als ``schluessel: alt -> neu``. + + Eine Zeichenkette und kein eigener Typ, weil die Aufrufer die Liste + weiterhin mit ``", ".join(...)`` in einen Protokolleintrag schreiben -- + so gibt es keine Stelle, die man beim Umbau vergessen kann. + """ + + key: str + alt: str + neu: str + + def __new__(cls, key: str, alt: Any, neu: Any, *, geheim: bool = False) -> "Aenderung": + alt_text = "(gesetzt)" if geheim and alt else _kurz(alt) + neu_text = "(geaendert)" if geheim else _kurz(neu) + selbst = super().__new__(cls, f"{key}: {alt_text} -> {neu_text}") + selbst.key = key + selbst.alt = alt_text + selbst.neu = neu_text + return selbst + + +def _kurz(wert: Any) -> str: + """Ein Wert als kurzer Text -- leer wird sichtbar, lang wird gekuerzt.""" + if wert is None or wert == "": + return "(leer)" + if isinstance(wert, bool): + return "ein" if wert else "aus" + text = " ".join(str(wert).split()) + if len(text) > WERT_GRENZE: + return text[: WERT_GRENZE - 1] + "…" + return text + + class SettingsStore: """Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung.""" @@ -140,16 +179,24 @@ class SettingsStore: def set_many( self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = "" - ) -> list[str]: - """Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck.""" - changed: list[str] = [] + ) -> list["Aenderung"]: + """Setzt mehrere Werte und gibt die Aenderungen zurueck. + + Jede Aenderung ist eine Zeichenkette der Form ``schluessel: alt -> neu`` + -- und traegt die Einzelteile als Attribute. Damit steht im + Aenderungsprotokoll, was sich geaendert hat, ohne dass jede + Einstellungsseite es einzeln zusammenbauen muss. Vorher stand dort nur + der Schluessel, und die Frage "was war vorher eingestellt?" liess sich + aus dem Protokoll nicht beantworten. + """ + changed: list[Aenderung] = [] current = self.get_many(conn, list(values)) for key, value in values.items(): spec = self.spec(key) if spec.serialize(current.get(key)) == spec.serialize(value): continue self.set(conn, key, value, actor=actor) - changed.append(key) + changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret")) return changed def delete(self, conn: sqlite3.Connection, key: str) -> None: diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html index 80be4d4..3c9405f 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/logs_audit.html @@ -69,8 +69,10 @@