Compare commits

...
3 Commits
13 changed files with 229 additions and 36 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-license"
version = "2.7.3"
version = "2.7.5"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.7.3"
version = "2.7.5"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
@@ -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.3",
app_version: str = "2.7.5",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
+44 -1
View File
@@ -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"
@@ -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
@@ -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):
@@ -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:
@@ -1486,7 +1486,7 @@
font-size: 5.6cqw;
line-height: 1.15;
color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.35);
border-top: 1px solid rgba(0, 0, 0, 0.85);
padding-top: 1px;
pointer-events: none;
white-space: nowrap;
@@ -1581,3 +1581,76 @@
color: var(--danger);
background: rgba(201, 42, 42, 0.12);
}
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
.table--stapelbar .td--detail {
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
darf notfalls hart umbrechen. */
white-space: pre-line;
overflow-wrap: anywhere;
max-width: 52ch;
}
@media (max-width: 1100px) {
.table--stapelbar thead {
display: none;
}
.table--stapelbar,
.table--stapelbar tbody,
.table--stapelbar tr,
.table--stapelbar td {
display: block;
width: auto;
}
.table--stapelbar tr {
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.table--stapelbar td {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: var(--sp-2);
align-items: baseline;
border: 0;
padding: 2px var(--sp-3);
white-space: normal;
}
.table--stapelbar td::before {
content: attr(data-spalte);
color: var(--text-muted);
font-size: var(--fs-xs);
}
.table--stapelbar .td--detail {
max-width: none;
}
/* Leere Zellen brauchen keine Zeile. */
.table--stapelbar td:empty {
display: none;
}
}
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
.file-cell {
display: flex;
align-items: center;
gap: 6px;
}
.file-cell > svg {
flex: 0 0 auto;
}
@@ -308,11 +308,14 @@ async function nachbearbeiten(
color: PDFLib.rgb(0.1, 0.1, 0.1),
});
}
// Kraeftig genug, um neben den Linien eines Formulars als eigener Strich
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
// den Tabellenlinien schlicht unter.
seite.drawLine({
start: { x: eintrag.x, y: eintrag.y - 1 },
end: { x: eintrag.x + textbreite, y: eintrag.y - 1 },
thickness: 0.5,
color: PDFLib.rgb(0.4, 0.4, 0.4),
start: { x: eintrag.x, y: eintrag.y - 1.5 },
end: { x: eintrag.x + textbreite, y: eintrag.y - 1.5 },
thickness: 0.9,
color: PDFLib.rgb(0.15, 0.15, 0.15),
});
}
}
@@ -901,6 +904,7 @@ export function initFilePreview(wurzel = document) {
leiste.hidden = art === "text";
if (speichern) speichern.hidden = art !== "pdf";
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
if (speichern) speichern.hidden = art !== "pdf";
if (unterschriftPanel) unterschriftPanel.hidden = true;
};
@@ -942,6 +946,15 @@ export function initFilePreview(wurzel = document) {
if (!adresse) return;
try {
const stand = await window.tesm.api(adresse);
if (stand.status === "unveraendert" || stand.status === "veraendert") {
// Ein unterschriebenes Dokument ist abgeschlossen. Wer daraus eine
// neue Fassung machen koennte, koennte auch eine Unterschrift unter
// einen veraenderten Text setzen -- also gibt es hier nichts mehr zu
// bearbeiten. Der Server weist es zusaetzlich ab.
if (unterschreibenKnopf) unterschreibenKnopf.hidden = true;
if (speichern) speichern.hidden = true;
if (zeile) zeile.hidden = true;
}
if (stand.status === "unveraendert") {
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
if (pruefzeile) {
@@ -44,8 +44,11 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
</form>
{# "table--stapelbar": schmal wird aus jeder Zeile ein kleiner Block. Vorher
quetschte sich das Detail mit Pruefsumme in eine Spalte von zwei
Zentimetern und lief ueber zehn Zeilen -- lesbar war das nicht. #}
<div class="table-wrap">
<table class="table table--compact" data-behavior="sortable">
<table class="table table--compact table--stapelbar" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Zeit</th>
@@ -60,12 +63,18 @@
<tbody>
{% for row in rows %}
<tr>
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
<td class="fs-sm fw-600">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td>
<td>
<td class="fs-sm nowrap" data-spalte="Zeit" data-sort-value="{{ row.ts }}">
{{ row.ts | dt }}
</td>
<td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
<td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
<td class="fs-sm" data-spalte="Ziel">{{ row.target }}</td>
{# Ohne die Minuszeichen stuende bei "white-space: pre-line" die
Einrueckung der Vorlage als leere erste Zeile in der Zelle. #}
<td class="fs-sm muted break-all td--detail" data-spalte="Detail">
{{- row.detail | audit_detail -}}
</td>
<td data-spalte="Stufe">
{# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
@@ -76,7 +85,7 @@
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
{% else %}{{ pill('Info', 'online') }}{% endif %}
</td>
<td class="mono fs-xs faint">{{ row.ip or '&ndash;' | safe }}</td>
<td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
@@ -427,9 +427,15 @@ def nav_order():
if submitted:
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
with transaction() as conn:
vorher = extension.settings.get(conn, "nav_order") or extension.nav.default_order()
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
_audit(conn, "system.nav_order_changed", severity="info")
_audit(
conn,
"system.nav_order_changed",
detail=f"{', '.join(vorher)} -> {', '.join(order)}",
severity="info",
)
flash("Reihenfolge der Navigation gespeichert.", "success")
return redirect(request.referrer or url_for("index"))
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.7.3"
version = "2.7.5"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.7.3", "cryptography>=41"]
dependencies = ["tesm-licensing==2.7.5", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.7.3"
version = "2.7.5"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]