Compare commits

..
16 Commits
Author SHA1 Message Date
alientim 48ce56fe7d Version 2.8.7: drei Standardgruppen, Module abschaltbar 2026-09-13 00:19:55 +02:00
alientim 294d47684b Verzeichnisanmeldung: Wiederholversuch, weniger Runden, Zeiten 2026-09-12 23:46:53 +02:00
alientim e589b4aadb Verzeichniskonfiguration aus 1.2.8 uebernehmen; Sicherungen setzten Schalter falsch 2026-09-12 19:26:43 +02:00
alientim f6b0bbaad1 Version 2.8.0: Rechtekonzept -- eine Zeile je Funktion, Administrator heisst Verwaltung 2026-09-12 13:27:46 +02:00
alientim c3c6eae72d Version 2.7.7: Schriftgroesse der Vorschau wird gerechnet 2026-09-12 01:34:04 +02:00
alientim a1987aeb93 Version 2.7.6: die Vorschau der Unterschrift zeigt das Ergebnis 2026-09-12 01:12:06 +02:00
alientim 1d67c36ed2 Version 2.7.5: jede Aenderung nennt alt und neu 2026-09-12 00:43:10 +02:00
alientim e68f22fd88 Version 2.7.5: die Ordnerzeile sagt, was im Ordner liegt 2026-09-12 00:30:36 +02:00
alientim 2d234571d2 Version 2.7.4: Unterschriften in der Sicherung, unterschrieben ist abgeschlossen 2026-09-12 00:26:24 +02:00
alientim fafda84bc7 Version 2.7.3: Zeugenvermerk einmal und in den Dokumenteigenschaften 2026-09-11 23:45:19 +02:00
alientim cdc3b2cd6e Zahlen im Kommentar geradegerueckt 2026-09-11 23:28:29 +02:00
alientim 35247c895b Version 2.7.2: Umlaute in der Oberflaeche, Einbindungen an der Sitzung 2026-09-11 23:24:29 +02:00
alientim e5ae10814f Version 2.7.1: Signaturverzeichnis ueberlebt die Archivierung 2026-09-11 21:36:25 +02:00
alientim 37a200e983 Version 2.7.0: Pruefung beim Oeffnen, Unterschrift in menschlichem Mass 2026-09-11 21:23:12 +02:00
alientim de9c0d4fbd Version 2.6.1: die Speichernzeile fuehrt zur Entscheidung 2026-09-11 21:03:25 +02:00
alientim 724052ae28 Version 2.6.0: Unterschreiben mit dem Finger, Ablage in eine andere Freigabe 2026-09-11 20:41:48 +02:00
91 changed files with 3235 additions and 756 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.5.2"
version = "2.8.3"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11"
dependencies = [
@@ -180,7 +180,7 @@ def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
def _status_pills(user: Any) -> list[dict[str, str]]:
if not user.is_authenticated or not user.has_any(("licenses.view",)):
if not user.is_authenticated or not user.has_any(("licenses.read",)):
return []
from tesm_core.extension import db
@@ -204,7 +204,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
def _startup_tasks(app: Flask) -> None:
extension = core(app)
if not extension.config.web_process:
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
logger.info("Startaufgaben übersprungen – kein Webprozess.")
return
from .services import activation
@@ -59,7 +59,7 @@ def _handle(action: str):
)
if request.content_length and request.content_length > MAX_BODY_BYTES:
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413
return jsonify({"error": "too_large", "message": "Anfrage zu groß."}), 413
payload = request.get_json(silent=True)
if not isinstance(payload, dict):
@@ -24,14 +24,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin:
continue
if category.sensitive and not current_user.has("backup.secrets"):
if category.sensitive and not current_user.has("backup_export.secrets"):
continue
allowed.append(category)
return allowed
@bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any")
@permission_required("backup_export.read", "backup_import.read", mode="any")
def overview():
conn = db()
preview = None
@@ -50,25 +50,25 @@ def overview():
preview=preview,
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES,
can_secrets=current_user.has("backup.secrets"),
can_secrets=current_user.has("backup_export.secrets"),
signing_key_path=str(core().registry["master_key_path"]),
)
@bp.post("/export")
@permission_required("backup.export")
@permission_required("backup_export.export")
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export():
conn = db()
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview"))
passphrase = request.form.get("passphrase") or ""
if passphrase != (request.form.get("passphrase_repeat") or ""):
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
return redirect(url_for("backup.overview"))
try:
@@ -95,17 +95,17 @@ def export():
@bp.post("/import/preview")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_preview():
conn = db()
upload = request.files.get("backup_file")
if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger")
flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("backup.overview"))
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
if len(raw) > MAX_IMPORT_BYTES:
flash("Die Datei ist zu gross.", "danger")
flash("Die Datei ist zu groß.", "danger")
return redirect(url_for("backup.overview"))
try:
@@ -124,28 +124,28 @@ def import_preview():
scope="sicherung",
)
flash(
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Uebernahme zu bestaetigen.",
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
"Übernahme zu bestätigen.",
"success",
)
return redirect(url_for("backup.overview"))
@bp.post("/import/apply")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
@reauth_required
def import_apply():
extension = core()
conn = db()
token = session.get(PREVIEW_SESSION_KEY, "")
if not token:
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
return redirect(url_for("backup.overview"))
allowed = {category.key for category in _allowed_categories()}
chosen = [key for key in request.form.getlist("categories") if key in allowed]
if not chosen:
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
flash("Es wurde keine Kategorie ausgewählt.", "warning")
return redirect(url_for("backup.overview"))
try:
@@ -172,7 +172,7 @@ def import_apply():
action="backup.imported",
detail="; ".join(
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
f"{counts['skipped']} uebersprungen"
f"{counts['skipped']} übersprungen"
for key, counts in report.items()
),
ip=client_ip(),
@@ -182,16 +182,16 @@ def import_apply():
lines = [
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
f"{counts['updated']} aktualisiert"
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
for key, counts in report.items()
if key in transfer.CATEGORY_BY_KEY
]
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
return redirect(url_for("backup.overview"))
@bp.post("/import/discard")
@permission_required("backup.execute")
@permission_required("backup_import.execute")
def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "")
if token:
@@ -29,13 +29,13 @@ def _form() -> dict[str, str]:
@bp.get("/")
@permission_required("customers.view")
@permission_required("customers.read")
def overview():
return render_template("license/customers.html", customers=issuance.list_customers(db()))
@bp.get("/<int:customer_id>")
@permission_required("customers.view")
@permission_required("customers.read")
def detail(customer_id: int):
conn = db()
customer = issuance.get_customer(conn, customer_id)
@@ -48,7 +48,7 @@ def detail(customer_id: int):
]
reported = (
reporting.reported_for_customer(conn, customer_id)
if current_user.has("reports.view")
if current_user.has("reports.read")
else {"customers": [], "source_license_id": ""}
)
return render_template(
@@ -83,7 +83,7 @@ def create():
@bp.post("/<int:customer_id>")
@permission_required("customers.edit")
@permission_required("customers.write")
def update(customer_id: int):
try:
with transaction() as conn:
@@ -96,7 +96,7 @@ def update(customer_id: int):
severity="notice",
scope="kunde",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
flash("Änderungen gespeichert." if changes else "Keine Änderungen.",
"success" if changes else "info")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
@@ -119,7 +119,7 @@ def delete(customer_id: int):
severity="warning",
scope="kunde",
)
flash("Kunde geloescht.", "success")
flash("Kunde gelöscht.", "success")
return redirect(url_for("customers.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
@@ -21,7 +21,7 @@ bp = Blueprint("dashboard", __name__)
def index():
conn = db()
extension = core()
if not current_user.has("licenses.view"):
if not current_user.has("licenses.read"):
# Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
# bleiben, auch fuer ein Konto ohne Lizenzrechte.
return render_template("license/dashboard.html", summary=None, sources=[])
@@ -30,7 +30,7 @@ def index():
return render_template(
"license/dashboard.html",
summary=summary,
sources=reporting.reporting_sources(conn) if current_user.has("reports.view") else [],
sources=reporting.reporting_sources(conn) if current_user.has("reports.read") else [],
own_role=(extension.license.state.role if extension.license else "master"),
type_labels=dict(),
)
@@ -42,7 +42,7 @@ bp = Blueprint("licenses", __name__, url_prefix="/licenses")
@bp.get("/")
@permission_required("licenses.view")
@permission_required("licenses.read")
def overview():
conn = db()
rows = [
@@ -65,7 +65,7 @@ def overview():
@bp.get("/<license_id>")
@permission_required("licenses.view")
@permission_required("licenses.read")
def detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
@@ -100,7 +100,7 @@ def detail(license_id: str):
@bp.post("/from-ticket/<ticket_id>")
@permission_required("licenses.create")
@permission_required("license_issue.execute")
def issue(ticket_id: str):
conn = db()
as_trial = request.form.get("as_trial") == "1"
@@ -124,7 +124,7 @@ def issue(ticket_id: str):
if result.already_existed:
flash(
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine "
"Zu diesem Ticket war bereits eine Lizenz offen – sie wird angezeigt statt eine "
"zweite auszustellen.",
"info",
)
@@ -166,7 +166,7 @@ def download(license_id: str):
channel="download",
recipient=current_user.username,
actor=current_user.username,
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
detail="Vollständiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
)
log_event("license.downloaded", target=license_id[:12], severity="notice")
return Response(
@@ -182,7 +182,7 @@ def download(license_id: str):
@bp.post("/<license_id>/resign")
@permission_required("licenses.edit")
@permission_required("license_renew.execute")
@reauth_required
def resign(license_id: str):
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
@@ -202,7 +202,7 @@ def resign(license_id: str):
try:
days = int(request.form["extend_days"])
except ValueError:
flash("Die Verlaengerung muss eine Zahl sein.", "danger")
flash("Die Verlängerung muss eine Zahl sein.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
if days > 0:
document = json.loads(str(row["license_document"]))
@@ -212,7 +212,7 @@ def resign(license_id: str):
changes["expires_at"] = lic.iso(base + timedelta(days=days))
if not changes:
flash("Es wurde keine Aenderung angegeben.", "info")
flash("Es wurde keine Änderung angegeben.", "info")
return redirect(url_for("licenses.detail", license_id=license_id))
key = signing_key()
@@ -233,7 +233,7 @@ def resign(license_id: str):
)
reporting.report_soon(core())
flash(
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten "
f"Neue Revision {updated['seq']} signiert. Der Client erhält sie beim nächsten "
"Heartbeat.",
"success",
)
@@ -241,7 +241,7 @@ def resign(license_id: str):
@bp.post("/<license_id>/revoke")
@permission_required("licenses.edit")
@permission_required("license_revoke.execute")
def revoke(license_id: str):
conn = db()
try:
@@ -258,7 +258,7 @@ def revoke(license_id: str):
)
reporting.report_soon(core())
flash(
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort "
"Lizenz widerrufen. Das Ticket bleibt offen – eine neue Lizenz kann sofort "
"ausgestellt werden.",
"success",
)
@@ -277,7 +277,7 @@ def delete(license_id: str):
return redirect(url_for("licenses.detail", license_id=license_id))
log_event("license.deleted", target=license_id[:12], severity="warning")
reporting.report_soon(core())
flash("Lizenz geloescht.", "success")
flash("Lizenz gelöscht.", "success")
return redirect(url_for("licenses.overview"))
@@ -295,7 +295,7 @@ def send_mail(license_id: str):
abort(404)
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
if not recipient:
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger")
flash("Es ist keine Empfängeradresse hinterlegt.", "danger")
return redirect(url_for("licenses.detail", license_id=license_id))
try:
@@ -315,7 +315,7 @@ def send_mail(license_id: str):
"",
f"Lizenz-ID: {license_id}",
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
f"Gueltig bis: {row['expires_at']}"
f"Gültig bis: {row['expires_at']}"
+ (" (unbefristet)" if row["lifetime"] else ""),
]
if is_customer and row["ticket_id"]:
@@ -329,7 +329,7 @@ def send_mail(license_id: str):
"Ihre Stammdaten pflegen:",
customer_facing_url("portal.request_link"),
]
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"]
lines += ["", "Freundliche Grüße", vendor.get("name") or "Ihr Lizenzgeber"]
if vendor.get("phone"):
lines.append(f"Telefon: {vendor['phone']}")
if vendor.get("email"):
@@ -369,7 +369,7 @@ def send_mail(license_id: str):
@bp.route("/offline-code", methods=["GET", "POST"])
@permission_required("licenses.execute")
@permission_required("license_activation.execute")
def manual_code():
"""Verarbeitet einen Offline-Code des Kunden.
@@ -403,7 +403,7 @@ def manual_code():
if outcome.ok:
response_code = lic.encode_code(outcome.response)
ok = True
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
message = "Antwortcode erzeugt. Bitte an den Kunden zurücksenden."
log_event(
f"license.offline_{payload.get('action')}",
target=str(payload.get("license_id") or "")[:12],
@@ -429,7 +429,7 @@ def manual_code():
@bp.get("/sub-servers")
@permission_required("reports.view")
@permission_required("reports.read")
def reports():
conn = db()
sources = reporting.reporting_sources(conn)
@@ -441,7 +441,7 @@ def reports():
@bp.get("/sub-servers/<license_id>")
@permission_required("reports.view")
@permission_required("reports.read")
def report_detail(license_id: str):
conn = db()
row = issuance.get_license(conn, license_id)
@@ -42,7 +42,7 @@ MAX_UPLOAD_BYTES = 256 * 1024
@bp.get("/")
@permission_required("settings_license.view")
@permission_required("settings_license.read")
def overview():
extension = core()
conn = db()
@@ -69,7 +69,7 @@ def overview():
@bp.post("/install")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def upload():
extension = core()
manager = extension.license
@@ -79,11 +79,11 @@ def upload():
upload_file = request.files.get("license_file")
if upload_file is None or not upload_file.filename:
flash("Keine Datei ausgewaehlt.", "danger")
flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("own_license.overview"))
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
if len(raw) > MAX_UPLOAD_BYTES:
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
return redirect(url_for("own_license.overview"))
try:
@@ -130,26 +130,26 @@ def _handshake(action: str):
@bp.post("/activate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def activate():
return _handshake("activate")
@bp.post("/deactivate")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
@reauth_required
def deactivate():
return _handshake("deactivate")
@bp.post("/heartbeat")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def heartbeat():
return _handshake("heartbeat")
@bp.post("/offline-response")
@permission_required("settings_license.edit")
@permission_required("settings_license.write")
def offline_response():
manager = core().license
if manager is None:
@@ -167,7 +167,7 @@ def offline_response():
@bp.get("/signing-key")
@permission_required("settings_license.view")
@permission_required("settings_license.read")
def signing_key_info():
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
@@ -45,7 +45,7 @@ RATE_WINDOW_MINUTES = 15
GENERIC_ANSWER = (
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
"einem {minutes} Minuten gültigen Zugangslink verschickt."
)
EDITABLE_FIELDS = (
@@ -150,13 +150,13 @@ def request_link():
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
"",
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
f"Der Link ist {minutes} Minuten gültig und lässt sich nur einmal verwenden:",
"",
access_url,
"",
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
"",
"Freundliche Gruesse",
"Freundliche Grüße",
vendor.get("name") or "Ihr Lizenzgeber",
]
),
@@ -185,7 +185,7 @@ def access(token: str):
expires = _parse(row["expires_at"]) if row is not None else None
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
if not valid:
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
flash("Dieser Link ist ungültig oder abgelaufen.", "danger")
return redirect(url_for("portal.request_link"))
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
@@ -247,7 +247,7 @@ def me():
ip=client_ip(),
severity="notice",
)
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Änderungen.", "success")
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
return redirect(url_for("portal.me"))
@@ -62,7 +62,7 @@ def _rate_guard() -> None:
with transaction():
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
if verdict.blocked:
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.")
abort(429, description="Zu viele Anfragen. Bitte später erneut versuchen.")
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
@@ -141,8 +141,8 @@ def issue(ticket_id: str):
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
if issuance.ticket_has_open_license(conn, ticket_id):
flash(
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
"auf ein anderes System wechseln moechten.",
"Es existiert bereits eine gültige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
"auf ein anderes System wechseln möchten.",
"warning",
)
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
@@ -238,12 +238,12 @@ def offline(ticket_id: str):
_log(
"selfservice.offline_rejected",
ticket,
"Code gehoert nicht zu diesem Vorgang",
"Code gehört nicht zu diesem Vorgang",
severity="warning",
)
flash(
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
"vollstaendig kopiert haben.",
"Dieser Code gehört nicht zu Ihrem Vorgang. Bitte prüfen Sie, ob Sie ihn "
"vollständig kopiert haben.",
"danger",
)
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
@@ -299,7 +299,7 @@ def revoke(ticket_id: str, license_id: str):
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
reporting.report_soon(core())
flash(
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System "
"Die Lizenz wurde freigegeben. Sie können jetzt eine neue Lizenz für Ihr neues System "
"ausstellen.",
"success",
)
@@ -35,7 +35,7 @@ LOGO_MAGIC = {
@bp.get("/system")
@permission_required("settings_system.view")
@permission_required("settings_system.read")
def system():
extension = core()
conn = db()
@@ -68,7 +68,11 @@ def system():
else ""
),
timezones=_timezone_choices(),
nav_items=extension.nav.items,
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
nav_baum=extension.nav.items,
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
sysops_available=bool(sysops and sysops.available),
@@ -85,7 +89,7 @@ def _timezone_choices() -> list[str]:
@bp.post("/system")
@permission_required("settings_system.edit")
@permission_required("settings_system.write")
def system_save():
extension = core()
conn = db()
@@ -121,7 +125,7 @@ def system_save():
ip=client_ip(),
severity="notice",
)
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info")
return redirect(url_for("settings.system"))
@@ -133,10 +137,10 @@ def set_hostname():
sysops = extension.sysops
hostname = (request.form.get("hostname") or "").strip()
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
flash("Der Hostname ist ungueltig.", "danger")
flash("Der Hostname ist ungültig.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_hostname(hostname)
if result.ok:
@@ -158,7 +162,7 @@ def set_timezone():
flash("Unbekannte Zeitzone.", "danger")
return redirect(url_for("settings.system"))
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("settings.system"))
result = sysops.set_timezone(timezone)
if result.ok:
@@ -179,7 +183,7 @@ def set_timezone():
@bp.get("/vendor")
@permission_required("settings_vendor.view")
@permission_required("settings_vendor.read")
def vendor():
extension = core()
conn = db()
@@ -201,7 +205,7 @@ def vendor():
@bp.post("/vendor")
@permission_required("settings_vendor.edit")
@permission_required("settings_vendor.write")
def vendor_save():
extension = core()
conn = db()
@@ -227,7 +231,7 @@ def vendor_save():
if suffix not in LOGO_MIME:
logo_problem = "Erlaubt sind PNG, JPG und SVG."
elif len(data) > MAX_LOGO_BYTES:
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)."
logo_problem = f"Das Logo ist zu groß (maximal {MAX_LOGO_BYTES // 1024} KB)."
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
logo_problem = "Die Datei ist kein Bild."
else:
@@ -245,13 +249,13 @@ def vendor_save():
log_event(
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
)
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.",
flash("Anbieterdaten gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info")
return redirect(url_for("settings.vendor"))
@bp.post("/vendor/remove-logo")
@permission_required("settings_vendor.edit")
@permission_required("settings_vendor.write")
def vendor_logo_remove():
with transaction() as conn:
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
@@ -265,7 +269,7 @@ def vendor_logo_remove():
@bp.get("/email")
@permission_required("settings_mail.view")
@permission_required("settings_mail.read")
def mail_settings():
extension = core()
conn = db()
@@ -280,7 +284,7 @@ def mail_settings():
@bp.post("/email")
@permission_required("settings_mail.edit")
@permission_required("settings_mail.write")
def mail_save():
extension = core()
conn = db()
@@ -297,7 +301,7 @@ def mail_save():
mail.store_secret(conn, request.form.get("client_secret"))
changed.append("client_secret")
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.",
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Änderungen.",
"success" if changed else "info")
return redirect(url_for("settings.mail_settings"))
@@ -25,7 +25,7 @@ bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/")
@permission_required("tickets.view")
@permission_required("tickets.read")
def overview():
conn = db()
tickets = issuance.list_tickets(conn)
@@ -106,14 +106,14 @@ def _endpoint_missing() -> str:
if master_endpoint():
return ""
return (
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Solange er fehlt, können keine Lizenzen ausgestellt werden: der Endpunkt wird "
"in die Lizenz signiert und lässt sich später nicht nachtragen."
)
@bp.get("/<ticket_id>")
@permission_required("tickets.view")
@permission_required("tickets.read")
def detail(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
@@ -150,7 +150,7 @@ def detail(ticket_id: str):
@bp.post("/<ticket_id>")
@permission_required("tickets.edit")
@permission_required("tickets.write")
def update(ticket_id: str):
conn = db()
if issuance.get_ticket(conn, ticket_id) is None:
@@ -176,9 +176,9 @@ def update(ticket_id: str):
)
reporting.report_soon(core())
flash(
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
if changes
else "Keine Aenderungen.",
else "Keine Änderungen.",
"success" if changes else "info",
)
except (issuance.IssuanceError, ValueError) as exc:
@@ -187,7 +187,7 @@ def update(ticket_id: str):
@bp.post("/<ticket_id>/lock")
@permission_required("tickets.edit")
@permission_required("tickets.write")
def toggle_block(ticket_id: str):
conn = db()
ticket = issuance.get_ticket(conn, ticket_id)
@@ -205,7 +205,7 @@ def toggle_block(ticket_id: str):
)
reporting.report_soon(core())
flash(
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen."
"Ticket gesperrt – es lassen sich keine neuen Lizenzen mehr ausstellen."
if new_status == "blocked"
else "Ticket entsperrt.",
"success",
@@ -231,7 +231,7 @@ def delete(ticket_id: str):
scope="ticket",
)
reporting.report_soon(core())
flash("Ticket und seine Lizenzhistorie geloescht.", "success")
flash("Ticket und seine Lizenzhistorie gelöscht.", "success")
return redirect(url_for("tickets.overview"))
except issuance.IssuanceError as exc:
flash(str(exc), "danger")
+36 -36
View File
@@ -42,22 +42,22 @@ def cmd_init_key(args: argparse.Namespace) -> int:
path = master_key_path(extension.config)
if path.exists() and not args.force:
key = master_key.load(path)
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
print(f"Signaturschlüssel existiert bereits: {key.key_id} ({path})")
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unprüfbar.")
return 0
if path.exists() and args.force:
print(
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
"ABBRUCH: --force auf einen bestehenden Signaturschlüssel wird nicht unterstützt.\n"
"Ein neuer Schlüssel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
file=sys.stderr,
)
return 2
key = master_key.ensure(path)
print(f"Signaturschluessel erzeugt: {key.key_id}")
print(f"Signaturschlüssel erzeugt: {key.key_id}")
print(f"Datei: {path}")
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
print("Bitte SOFORT sichern – ohne sie ist keine ausgestellte Lizenz mehr prüfbar.")
print(f"Öffentlicher Schlüssel (zum Pinnen beim Kunden):\n{key.public_key}")
return 0
@@ -65,11 +65,11 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe."""
if not is_master_edition():
for zeile in (
"Diese Ausgabe ist fuer Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
"Diese Ausgabe ist für Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
"",
"Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt",
"sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --",
"mit seinem eigenen Signaturschluessel, der beim Installieren entstanden ist.",
"mit seinem eigenen Signaturschlüssel, der beim Installieren entstanden ist.",
"",
"Die Master-Ausgabe liegt im Repository tesm-license-master.",
):
@@ -83,10 +83,10 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
app, extension, days=int(args.days), name=args.name
)
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
print(f"Gueltig bis: {document['expires_at']}")
print(f"Gültig bis: {document['expires_at']}")
print(f"Datei: {extension.config.license_path}")
if state is not None:
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
print(f"Zustand: gültig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
return 0
@@ -98,7 +98,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
return 2
password = args.password or getpass.getpass("Passwort: ")
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
return 2
problems = extension.password_policy.check(password, username=username)
if problems and not args.force:
@@ -180,7 +180,7 @@ def cmd_status(args: argparse.Namespace) -> int:
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
)
print(
"Signaturschluessel: "
"Signaturschlüssel: "
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
)
for problem in key_health.get("problems", []):
@@ -253,17 +253,17 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
except legacy_import.LegacyImportError as exc:
if args.adopt_key:
raise
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
print(f"Hinweis: alter Signaturschlüssel nicht lesbar ({exc}).")
if key_path.is_file():
existing = master_key.load(key_path)
report.key_id = existing.key_id
if legacy_public and existing.public_key == legacy_public:
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).")
print(f"Signaturschlüssel stimmt bereits überein ({existing.key_id}).")
elif not legacy_public:
issuer_changes = True
print(
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
f"Es wird mit dem vorhandenen Signaturschlüssel {existing.key_id} "
"gearbeitet."
)
else:
@@ -271,7 +271,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
# ausgestellte Lizenz waere danach unpruefbar.
issuer_changes = True
print(
f"Diese Installation hat einen eigenen Signaturschluessel "
f"Diese Installation hat einen eigenen Signaturschlüssel "
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
"Es wird mit dem vorhandenen weitergearbeitet."
)
@@ -279,12 +279,12 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
report.key_adopted = True
print(f"Signaturschluessel uebernommen: {report.key_id}")
print(f"Signaturschlüssel übernommen: {report.key_id}")
else:
print(
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
"Diese Installation hat noch keinen Signaturschlüssel. Mit --adopt-key "
"wird der alte übernommen – dann bleibt die Anbieteridentität erhalten "
"und Kunden müssen keinen Ausstellerwechsel bestätigen.",
file=sys.stderr,
)
return 2
@@ -312,7 +312,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
# Uebernahme.
for einstellung, wert in uebernommen.items():
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
print(f"Uebernommen: {einstellung} = {gekuerzt}")
print(f"Übernommen: {einstellung} = {gekuerzt}")
vendor = {
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
@@ -326,7 +326,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
print(
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
"ihn können übernommene Lizenzen sich später nicht aktivieren.",
file=sys.stderr,
)
return 2
@@ -347,10 +347,10 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
result.key_id = report.key_id
if issuer_changes:
result.warnings.append(
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
"zulassen' bestaetigen."
"Die Lizenzen sind mit einem anderen Schlüssel signiert als "
"früher. Kunden, die den alten Aussteller gepinnt haben, müssen "
"beim Einspielen einmal 'Wechsel des Ausstellers ausdrücklich "
"zulassen' bestätigen."
)
if args.dry_run:
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
@@ -385,7 +385,7 @@ def build_parser() -> argparse.ArgumentParser:
)
sub = parser.add_subparsers(dest="command", required=True)
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)")
init_key = sub.add_parser("init-key", help="Signaturschlüssel erzeugen (einmalig)")
init_key.add_argument("--force", action="store_true")
init_key.set_defaults(func=cmd_init_key)
@@ -401,7 +401,7 @@ def build_parser() -> argparse.ArgumentParser:
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
bootstrap.set_defaults(func=cmd_bootstrap)
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
admin.add_argument("--username")
admin.add_argument("--password")
admin.add_argument("--force", action="store_true")
@@ -411,16 +411,16 @@ def build_parser() -> argparse.ArgumentParser:
status.add_argument("--json", action="store_true")
status.set_defaults(func=cmd_status)
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen")
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufräumen")
house.set_defaults(func=cmd_housekeeping)
migrate = sub.add_parser(
"migrate-legacy",
help="Lizenzserver der Vorgaengerfassung uebernehmen",
help="Lizenzserver der Vorgängerfassung übernehmen",
description=(
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten "
"Übernimmt Signaturschlüssel, Kunden, Tickets und Lizenzen aus einer alten "
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
"neuen Format neu signiert -- die Kunden brauchen danach eine neue "
"neuen Format neu signiert – die Kunden brauchen danach eine neue "
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
),
)
@@ -430,13 +430,13 @@ def build_parser() -> argparse.ArgumentParser:
migrate.add_argument(
"--adopt-key",
action="store_true",
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
help="Den alten Signaturschlüssel übernehmen. Nur möglich, solange diese "
"Installation noch keinen eigenen hat – ein vorhandener wird nie überschrieben.",
)
migrate.add_argument(
"--dry-run",
action="store_true",
help="Nur anzeigen, was passieren wuerde -- nichts schreiben",
help="Nur anzeigen, was passieren würde – nichts schreiben",
)
migrate.set_defaults(func=cmd_migrate_legacy)
@@ -34,35 +34,35 @@ NAVIGATION = NavTree(
label="Kunden",
icon="building",
endpoint="customers.overview",
permissions=("customers.view",),
permissions=("customers.read",),
),
NavItem(
key="tickets",
label="Tickets",
icon="ticket",
endpoint="tickets.overview",
permissions=("tickets.view",),
permissions=("tickets.read",),
),
NavItem(
key="licenses",
label="Lizenzen",
icon="shield",
endpoint="licenses.overview",
permissions=("licenses.view",),
permissions=("licenses.read",),
),
NavItem(
key="offline_code",
label="Offline-Aktivierung",
icon="transfer",
endpoint="licenses.manual_code",
permissions=("licenses.execute",),
permissions=("license_activation.execute",),
),
NavItem(
key="reports",
label="Sub-Lizenzserver",
icon="share",
endpoint="licenses.reports",
permissions=("reports.view",),
permissions=("reports.read",),
condition=_is_master,
),
NavItem(
@@ -75,21 +75,21 @@ NAVIGATION = NavTree(
label="Laufend",
icon="terminal",
endpoint="logs.live",
permissions=("logs_live.view",),
permissions=("logs_live.read",),
),
NavItem(
key="logs_history",
label="Verlauf",
icon="clock",
endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"),
permissions=("logs_history.read", "logs_history.export"),
),
NavItem(
key="logs_audit",
label="Audit",
icon="history",
endpoint="logs.audit_view",
permissions=("logs_audit.view", "logs_audit.export"),
permissions=("logs_audit.read", "logs_audit.export"),
),
),
),
@@ -103,84 +103,84 @@ NAVIGATION = NavTree(
label="Benutzer",
icon="users",
endpoint="admin.users",
permissions=("users.view",),
permissions=("users.read",),
),
NavItem(
key="groups",
label="Gruppen und Rechte",
icon="shield-users",
endpoint="admin.groups",
permissions=("groups.view",),
permissions=("groups.read",),
),
NavItem(
key="settings_vendor",
label="Anbieterdaten",
icon="building",
endpoint="settings.vendor",
permissions=("settings_vendor.view",),
permissions=("settings_vendor.read",),
),
NavItem(
key="settings_mail",
label="E-Mail-Versand",
icon="mail",
endpoint="settings.mail_settings",
permissions=("settings_mail.view",),
permissions=("settings_mail.read",),
),
NavItem(
key="settings_system",
label="System",
icon="sliders",
endpoint="settings.system",
permissions=("settings_system.view",),
permissions=("settings_system.read",),
),
NavItem(
key="settings_network",
label="Netzwerk",
icon="network",
endpoint="hostsettings.network",
permissions=("settings_network.view",),
permissions=("settings_network.read",),
),
NavItem(
key="settings_web",
label="Webserver und TLS",
icon="server",
endpoint="hostsettings.web",
permissions=("settings_web.view",),
permissions=("settings_web.read",),
),
NavItem(
key="settings_directory",
label="Verzeichnisdienst",
icon="users",
endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",),
permissions=("settings_directory.read",),
),
NavItem(
key="settings_license",
label="Eigene Lizenz",
icon="key",
endpoint="own_license.overview",
permissions=("settings_license.view",),
permissions=("settings_license.read",),
),
NavItem(
key="backup",
label="Sicherung",
icon="transfer",
endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"),
permissions=("backup_export.read", "backup_import.read"),
),
NavItem(
key="trash",
label="Papierkorb",
icon="trash",
endpoint="trash.overview",
permissions=("trash.view",),
permissions=("trash.read",),
),
NavItem(
key="diagnostics",
label="Diagnose",
icon="info",
endpoint="diagnostics.overview",
permissions=("diagnostics.view",),
permissions=("diagnostics.read",),
),
),
),
+112 -69
View File
@@ -28,58 +28,97 @@ A = Action
CUSTOMERS = Resource(
key="customers",
label="Kunden",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Stammdaten der Kundeninstanzen",
action_hints={A.DELETE: "Nur moeglich, solange kein Ticket am Kunden haengt."},
action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."},
)
TICKETS = Resource(
key="tickets",
label="Tickets",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Dauerhafte Anspruechte auf eine Lizenz",
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Dauerhafte Ansprüche auf eine Lizenz",
action_hints={
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
A.DELETE: "Nur moeglich, wenn keine offene Lizenz mehr existiert.",
A.WRITE: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
"nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
A.DELETE: "Nur möglich, wenn keine offene Lizenz mehr existiert.",
},
)
#: Aus einer Zeile wurden fuenf. "Aendern" hiess hier **widerrufen oder neu
#: signieren** -- zwei Eingriffe mit entgegengesetzter Wirkung unter einem
#: Haken: der eine verlaengert einem Kunden die Lizenz, der andere nimmt sie
#: ihm. Wer eine Laufzeit verlaengern durfte, konnte damit jede Lizenz
#: abschalten. Genau das soll die Matrix trennen koennen.
LICENSES = Resource(
key="licenses",
label="Lizenzen",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT),
hint="Konkrete, signierte Lizenzdateien",
actions=(A.READ, A.DELETE, A.EXPORT),
hint="Ausgestellte Lizenzen: Laufzeit, Zustand, Datei",
action_hints={
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.",
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
A.DELETE: "Eintrag entfernen (nur ohne offene Aktivierung).",
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.",
},
)
LICENSE_ISSUE = Resource(
key="license_issue",
parent="licenses",
label="Lizenz ausstellen",
actions=(A.READ, A.EXECUTE),
hint="Aus einem Ticket eine neue Lizenz signieren",
action_hints={A.EXECUTE: "Ausstellen und signieren."},
)
LICENSE_RENEW = Resource(
key="license_renew",
parent="licenses",
label="Lizenz erneuern",
actions=(A.READ, A.EXECUTE),
hint="Neue Revision einer bestehenden Lizenz signieren",
action_hints={A.EXECUTE: "Erneuern – die alte Revision verliert ihre Gültigkeit."},
)
LICENSE_REVOKE = Resource(
key="license_revoke",
parent="licenses",
label="Lizenz widerrufen",
actions=(A.READ, A.EXECUTE),
hint="Eine ausgestellte Lizenz ungültig machen",
action_hints={A.EXECUTE: "Widerrufen. Der Kunde verliert den Zugang."},
)
LICENSE_ACTIVATION = Resource(
key="license_activation",
parent="licenses",
label="Aktivierung von Hand",
actions=(A.READ, A.EXECUTE),
hint="Offline-Codes für Kunden ohne Internetzugang",
action_hints={A.EXECUTE: "Code prüfen und Antwortcode erzeugen."},
)
PORTAL = Resource(
key="portal",
label="Kundenportal",
actions=(A.VIEW, A.EXECUTE),
hint="Zugangslinks fuer Kunden",
actions=(A.READ, A.EXECUTE),
hint="Zugangslinks für Kunden",
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
)
REPORTS = Resource(
key="reports",
label="Berichte der Sub-Lizenzserver",
actions=(A.VIEW,),
actions=(A.READ,),
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
)
MAIL = Resource(
key="settings_mail",
label="E-Mail-Versand",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Microsoft Graph (App-only)",
action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.",
A.EXECUTE: "Verbindungstest ausführen.",
A.SECRETS: "Client-Secret setzen.",
},
)
@@ -87,8 +126,8 @@ MAIL = Resource(
VENDOR = Resource(
key="settings_vendor",
label="Anbieterdaten",
actions=(A.VIEW, A.EDIT),
hint="Name, Kontakt, Logo und oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
actions=(A.READ, A.WRITE),
hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
)
LICENSING_AREA = Area(
@@ -96,7 +135,17 @@ LICENSING_AREA = Area(
label="Lizenzierung",
icon="key",
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.",
resources=(CUSTOMERS, TICKETS, LICENSES, PORTAL, REPORTS),
resources=(
CUSTOMERS,
TICKETS,
LICENSES,
LICENSE_ISSUE,
LICENSE_RENEW,
LICENSE_REVOKE,
LICENSE_ACTIVATION,
PORTAL,
REPORTS,
),
)
PERMISSIONS = PermissionTree(
@@ -108,76 +157,70 @@ PERMISSIONS = PermissionTree(
)
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
#:
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
#: was vorher ging. Entfernen kann man sie mit einem Klick.
#:
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
#: Matrix ist sonst genau die, die hier vergessen wird.
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
key="functions",
name="Funktionen (Vollzugriff)",
description=(
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
"die nicht nur einstellen, sondern auch bedienen."
),
grants=tuple(
(resource.key, "*")
for area in PERMISSIONS.areas
if area.key != PermissionTree.VERWALTUNGSBEREICH
for resource in area.resources
),
)
#: Wie in TESM: drei Gruppen ab Werk, nicht sechs.
#:
#: Auch "Vertrieb" ist hier entfallen, obwohl es die Rolle ist, die den
#: Lizenzserver taeglich benutzt -- gerade deshalb: wer Lizenzen ausstellt,
#: schneidet die Gruppe ohnehin auf sein Haus zu (darf Vertrieb widerrufen?
#: Tickets loeschen? in die Protokolle sehen?). Eine mitgelieferte Antwort auf
#: diese Frage wird entweder uebernommen, ohne sie zu pruefen, oder sie wird
#: umgebaut. "Funktionen (Vollzugriff)" ist der ehrlichere Startpunkt.
ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset(
key="viewer",
name="Benutzer",
description="Standardgruppe: nur das laufende Protokoll lesen.",
grants=(("logs_live", (A.VIEW,)),),
grants=(("logs_live", (A.READ,)),),
is_default=True,
),
RolePreset(
key="sales",
name="Vertrieb",
description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.",
grants=(
("customers", "*"),
("tickets", "*"),
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)),
("portal", "*"),
("logs_live", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
),
),
RolePreset(
key="support",
name="Support",
description="Lesen und Offline-Aktivierungen verarbeiten.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW, A.EXECUTE)),
("reports", (A.VIEW,)),
("logs_live", (A.VIEW,)),
),
),
RolePreset(
key="auditor",
name="Revision",
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
grants=(
("customers", (A.VIEW,)),
("tickets", (A.VIEW,)),
("licenses", (A.VIEW,)),
("reports", (A.VIEW,)),
("users", (A.VIEW,)),
("groups", (A.VIEW,)),
("logs_live", (A.VIEW,)),
("logs_history", (A.VIEW, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)),
("diagnostics", (A.VIEW,)),
),
),
RolePreset(
key="administration",
name="Administration",
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
grants=(
("users", "*"),
("groups", "*"),
("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"),
("settings_network", "*"),
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)),
("settings_mail", (A.READ, A.WRITE, A.EXECUTE)),
("settings_vendor", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)),
("backup", (A.READ, A.EXECUTE, A.EXPORT)),
("trash", "*"),
("diagnostics", (A.VIEW,)),
("logs_audit", (A.VIEW,)),
("diagnostics", (A.READ,)),
("logs_audit", (A.READ,)),
),
),
FUNKTIONEN_VOLLZUGRIFF,
)
__all__ = ["PERMISSIONS", "ROLE_PRESETS"]
@@ -27,6 +27,8 @@ Aenderungen gegenueber dem Vorgaenger:
from __future__ import annotations
from typing import Any
from tesm_core.migrations import Migration
V1 = """
@@ -210,7 +212,92 @@ CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
"""
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
"""Wie in TESM: das Kennzeichen heisst ab 2.8.0 "Verwaltung".
Ohne diesen Schritt koennte ein bestehender Administrator nach dem Update
keine Lizenz mehr ausstellen -- und wuerde das fuer einen Fehler halten.
"""
from tesm_core.rbac import service as rbac
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
#: Die drei Vorlagen, die es bis 2.8.2 gab -- eingefrorene Kopie, siehe TESM.
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
(
"sales",
"Vertrieb",
(
("customers", "*"),
("tickets", "*"),
("licenses", ("read", "export")),
("license_issue", "*"),
("license_renew", "*"),
("license_revoke", "*"),
("portal", "*"),
("logs_live", ("read",)),
("logs_audit", ("read",)),
),
),
(
"support",
"Support",
(
("customers", ("read",)),
("tickets", ("read",)),
("licenses", ("read",)),
("license_activation", "*"),
("reports", ("read",)),
("logs_live", ("read",)),
),
),
(
"auditor",
"Revision",
(
("customers", ("read",)),
("tickets", ("read",)),
("licenses", ("read",)),
("reports", ("read",)),
("users", ("read",)),
("groups", ("read",)),
("logs_live", ("read",)),
("logs_history", ("read", "export")),
("logs_audit", ("read", "export")),
("diagnostics", ("read",)),
),
),
)
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
"""Wie in TESM: unberuehrte Vorlagen in den Papierkorb, benutzte bleiben."""
from tesm_core.rbac import service as rbac
from tesm_core.rbac.model import Action, RolePreset
from .permissions import PERMISSIONS
vorlagen = [
RolePreset(
key=key,
name=name,
description="",
grants=tuple(
(ressource, a if a == "*" else tuple(Action(x) for x in a))
for ressource, a in grants
),
)
for key, name, grants in ABGELEGTE_VORLAGEN
]
rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen)
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
Migration(version=2, name="revision-timestamp", sql=V2),
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
)
@@ -132,7 +132,7 @@ def _verify(
seen_nonce=seen,
)
except lic.SignatureInvalid:
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.")
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungültig.")
except lic.ReplayDetected as exc:
return None, _error(409, "replay", str(exc))
except lic.ProtocolViolation as exc:
@@ -150,7 +150,7 @@ def process_activate(
) -> Outcome:
row, error = _verify(conn, request, expected_action="activate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
@@ -202,7 +202,7 @@ def process_activate(
target=str(row["license_id"]),
detail=(
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "")
+ (f", verdrängt {len(superseded)} Lizenz(en)" if superseded else "")
),
severity="notice",
)
@@ -211,7 +211,7 @@ def process_activate(
request,
master_private_key=master_private_key,
status="ok",
detail="Aktivierung bestaetigt.",
detail="Aktivierung bestätigt.",
)
@@ -220,7 +220,7 @@ def process_deactivate(
) -> Outcome:
row, error = _verify(conn, request, expected_action="deactivate")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"])
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
@@ -249,7 +249,7 @@ def process_deactivate(
request,
master_private_key=master_private_key,
status="ok",
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
detail="Deaktivierung bestätigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
)
@@ -258,7 +258,7 @@ def process_heartbeat(
) -> Outcome:
row, error = _verify(conn, request, expected_action="heartbeat")
if error is not None or row is None:
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
return error or _error(400, "bad_request", "Ungültige Anfrage.")
fingerprint = str(request["fingerprint"])
hostname = str(request.get("hostname") or "")
@@ -306,7 +306,7 @@ def process_heartbeat(
)
else:
logger.warning(
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server",
"Bericht von Lizenz %s verworfen | Rolle ist %s, nicht license_server",
row["license_id"],
row["role"],
)
@@ -322,7 +322,7 @@ def process_heartbeat(
request,
master_private_key=master_private_key,
status="ok",
detail="Verbindung bestaetigt.",
detail="Verbindung bestätigt.",
license_update=update,
)
@@ -48,7 +48,7 @@ def validate_email(value: str, *, required: bool) -> str:
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
return ""
if not EMAIL_RE.match(text):
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.")
raise IssuanceError(f"{text!r} ist keine gültige E-Mail-Adresse.")
return text
@@ -137,7 +137,7 @@ def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
)
if int(tickets or 0):
raise IssuanceError(
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen."
f"An diesem Kunden hängen noch {tickets} Ticket(s). Bitte zuerst entfernen."
)
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
@@ -224,7 +224,7 @@ def create_ticket(
raise IssuanceError("Unbekannter Lizenztyp.")
if role not in lic.LICENSE_ROLES or role == "master":
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
raise IssuanceError("Unzulaessige Rolle.")
raise IssuanceError("Unzulässige Rolle.")
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
@@ -290,7 +290,7 @@ def update_ticket(
if license_type != str(ticket["type"]):
changes.append(f"Typ: {ticket['type']} -> {license_type}")
if new_modules != str(ticket["modules"]):
changes.append("Module geaendert")
changes.append("Module geändert")
if int(valid_days) != int(ticket["valid_days"]):
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
if bool(lifetime) != bool(ticket["lifetime"]):
@@ -428,7 +428,7 @@ def issue_from_ticket(
role = str(ticket["role"])
if as_trial and role != "customer":
raise IssuanceError(
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver."
"Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver."
)
if as_trial:
@@ -452,9 +452,9 @@ def issue_from_ticket(
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
if not str(master_endpoint or "").strip():
raise IssuanceError(
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
"Ohne ihn könnte sich diese Lizenz nie online aktivieren – der Endpunkt "
"wird in die Lizenz signiert und lässt sich später nicht nachtragen."
)
customer = get_customer(conn, int(ticket["customer_id"]))
@@ -550,7 +550,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
except Exception as exc: # noqa: BLE001
raise IssuanceError(
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. "
"Der Client-Schlüssel dieser Lizenz konnte nicht entschlüsselt werden. "
"Bitte eine neue Lizenz ausstellen."
) from exc
return bundle
@@ -580,7 +580,7 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
raise IssuanceError("Diese Lizenz existiert nicht.")
if str(row["status"]) not in {"revoked", "issued"}:
raise IssuanceError(
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen."
"Eine aktive Lizenz kann nicht gelöscht werden. Bitte zuerst widerrufen."
)
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
@@ -61,7 +61,7 @@ class Report:
def as_text(self) -> str:
lines = [
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}"
f"Signaturschlüssel übernommen: {'ja' if self.key_adopted else 'nein'}"
+ (f" ({self.key_id})" if self.key_id else ""),
f"Kunden : {self.customers}",
f"Tickets : {self.tickets}",
@@ -69,7 +69,7 @@ class Report:
]
if self.reissued:
lines.append("")
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:")
lines.append("Neu signiert – diese Kunden brauchen eine neue Lizenzdatei:")
lines.extend(f" * {entry}" for entry in self.reissued)
if self.skipped:
lines.append("")
@@ -95,7 +95,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
"""Oeffnet die alte Datenbank **nur lesend**."""
path = Path(root) / "sqlite.db"
if not path.is_file():
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.")
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgängerfassung.")
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error as exc:
@@ -107,7 +107,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
if missing:
conn.close()
raise LegacyImportError(
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}"
f"Das sieht nicht nach einem Lizenzserver aus – es fehlen: {', '.join(sorted(missing))}"
)
return conn
@@ -123,7 +123,7 @@ def _standard_b64(wert: str, feld: str) -> str:
"""
roh = wert.strip()
if not roh:
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
raise LegacyImportError(f"Im alten Signaturschlüssel fehlt {feld!r}.")
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
# Werkzeuge lassen sie weg.
gepolstert = roh + "=" * (-len(roh) % 4)
@@ -138,29 +138,29 @@ def _standard_b64(wert: str, feld: str) -> str:
continue
if len(rohbytes) != 32:
raise LegacyImportError(
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
f"{feld} im alten Signaturschlüssel ist {len(rohbytes)} Byte lang, "
"erwartet werden 32 (Ed25519)."
)
return base64.b64encode(rohbytes).decode("ascii")
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
raise LegacyImportError(f"{feld} im alten Signaturschlüssel ist kein Base64.")
def read_legacy_key(root: Path) -> tuple[str, str]:
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
path = Path(root) / "master_signing_key.json"
if not path.is_file():
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
raise LegacyImportError(f"Der alte Signaturschlüssel {path} fehlt.")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
raise LegacyImportError(f"Der alte Signaturschlüssel ist unlesbar: {exc}") from exc
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
# sie URL-sicher.
private = str(data.get("private") or data.get("private_key") or "")
public = str(data.get("public") or data.get("public_key") or "")
if not private or not public:
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
raise LegacyImportError("Der alte Signaturschlüssel ist unvollständig.")
private = _standard_b64(private, "private")
public = _standard_b64(public, "public")
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
@@ -170,11 +170,11 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
try:
passt = licsrv.public_from_private(private) == public
except (ValueError, TypeError) as exc:
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
raise LegacyImportError(f"Der alte Signaturschlüssel ist unbrauchbar: {exc}") from exc
if not passt:
raise LegacyImportError(
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
"zueinander. Die Datei ist beschaedigt."
"Im alten Signaturschlüssel passen privater und öffentlicher Teil nicht "
"zueinander. Die Datei ist beschädigt."
)
return private, public
@@ -190,16 +190,16 @@ def adopt_key(private: str, public: str, target: Path) -> str:
if Path(target).is_file():
raise LegacyImportError(
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
f"Unter {target} liegt bereits ein Signaturschlüssel. Er wird nicht "
"überschrieben – das würde jede damit ausgestellte Lizenz unbrauchbar "
"machen. Für eine Übernahme bitte eine frische Installation verwenden."
)
payload = {
"private_key": private,
"public_key": public,
"key_id": lic.key_id(public),
"created_at": _now(),
"origin": "Uebernommen aus der Vorgaengerfassung.",
"origin": "Übernommen aus der Vorgängerfassung.",
}
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
return lic.key_id(public)
@@ -309,7 +309,7 @@ def import_all(
)
if existing is not None:
customer_map[int(row["id"])] = int(existing["id"])
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.")
report.warnings.append(f"Kunde {name!r} war schon vorhanden – nicht doppelt angelegt.")
continue
new_id = issuance.create_customer(
conn,
@@ -331,7 +331,7 @@ def import_all(
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
customer_id = customer_map.get(int(row["customer_id"] or 0))
if customer_id is None:
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen")
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht übernommen")
continue
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
continue
@@ -348,7 +348,7 @@ def import_all(
int(row["lifetime"] or 0),
str(row["status"] or "open"),
str(row["role"] or "customer"),
"Aus der Vorgaengerfassung uebernommen.",
"Aus der Vorgängerfassung übernommen.",
str(row["created_by"] or actor),
str(row["created_at"] or _now()),
_now(),
@@ -361,7 +361,7 @@ def import_all(
license_id = str(row["license_id"] or "")
customer_id = customer_map.get(int(row["customer_id"] or 0))
if not license_id or customer_id is None:
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen")
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht übernommen")
continue
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
continue
@@ -371,7 +371,7 @@ def import_all(
modules = _modules(row["modules"])
if not reissue:
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt")
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezählt")
report.licenses += 1
continue
@@ -383,7 +383,7 @@ def import_all(
if not lifetime and valid_days <= 0:
report.skipped.append(
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
f"{str(row['expires_at'])[:10]}, nicht uebernommen"
f"{str(row['expires_at'])[:10]}, nicht übernommen"
)
continue
@@ -444,7 +444,7 @@ def import_all(
if report.reissued:
report.warnings.append(
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der "
"Maschine gilt nur für die alte Datei. Nach dem Einspielen aktiviert der "
"Kunde erneut."
)
return report
@@ -92,7 +92,7 @@ def _secret(conn: sqlite3.Connection) -> str:
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
except Exception as exc: # noqa: BLE001
raise MailError(
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen."
"Das Client-Secret konnte nicht entschlüsselt werden – bitte neu eintragen."
) from exc
@@ -162,7 +162,7 @@ def fetch_token(conn: sqlite3.Connection) -> str:
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
if not is_enabled(conn):
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."}
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollständig konfiguriert."}
try:
fetch_token(conn)
except MailError as exc:
@@ -48,22 +48,22 @@ class MasterKey:
def load(path: Path) -> MasterKey:
if not path.is_file():
raise MasterKeyError(
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
f"Der Signaturschlüssel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc
raise MasterKeyError(f"Der Signaturschlüssel ist unlesbar: {exc}") from exc
private = str(data.get("private_key") or "")
public = str(data.get("public_key") or "")
if not private or not public:
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
raise MasterKeyError("Der Signaturschlüssel ist unvollständig.")
if licsrv.public_from_private(private) != public:
raise MasterKeyError(
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
"Die Datei ist beschaedigt oder wurde manipuliert."
"Der öffentliche Teil des Signaturschlüssels passt nicht zum privaten. "
"Die Datei ist beschädigt oder wurde manipuliert."
)
return MasterKey(
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
@@ -87,7 +87,7 @@ def ensure(path: Path) -> MasterKey:
}
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
logger.warning(
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
"Neuer Signaturschlüssel erzeugt (%s). Diese Datei ist unwiederbringlich – "
"bitte sofort gesichert ablegen: %s",
lic.key_id(public),
path,
@@ -98,7 +98,7 @@ def ensure(path: Path) -> MasterKey:
def health(path: Path) -> dict[str, object]:
"""Zustand fuer die Diagnoseseite."""
if not path.is_file():
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
return {"present": False, "problems": ["Signaturschlüssel fehlt"]}
problems = check_private_file(path)
try:
key = load(path)
@@ -24,6 +24,7 @@ import sqlite3
from typing import Any, Callable, Iterable
from tesm_core.db import Database
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES,
@@ -41,11 +42,11 @@ APP_TAG = "tesm-license"
CATEGORIES: tuple[Category, ...] = (
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True),
Category("tickets", "Tickets", "Ansprüche inklusive Self-Service-Schlüssel", sensitive=True),
Category(
"licenses",
"Lizenzen",
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
"Ausgestellte Lizenzen mit signiertem Dokument und verschlüsseltem Client-Schlüssel",
sensitive=True,
),
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
@@ -153,6 +154,10 @@ def apply_import(
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID).
"""
#: Merkt, ob das Paket alte Rechteschluessel mitbringt -- dann stammt es
#: von vor 2.8.0, und ein Administrator konnte dort noch alles.
alte_fassung: set[bool] = set()
wanted = {key for key in categories if key in CATEGORY_BY_KEY}
report: dict[str, dict[str, int]] = {}
stamp = _stamp()
@@ -332,9 +337,16 @@ def apply_import(
group_id = int(existing["id"])
bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
# Wie in TESM: eine Sicherung aus 2.7.x nennt customers.view und
# tickets.edit. Unbekannte Schluessel wirft sanitize() still weg --
# die wiederhergestellte Gruppe waere leer, ohne Meldung.
mitgebracht = [str(recht) for recht in entry.get("permissions", []) or []]
neue = umgeschrieben(mitgebracht)
if sorted(set(mitgebracht)) != neue:
alte_fassung.add(True)
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, str(permission)) for permission in entry.get("permissions", [])],
[(group_id, permission) for permission in neue],
)
if "users" in wanted:
@@ -393,6 +405,18 @@ def apply_import(
set_setting(str(key), value)
bump("settings", "updated")
if alte_fassung and "users" in wanted:
# Wie die Migration: aus einer Sicherung von vor 2.8.0 kommen
# Administratoren, die alles konnten. Ohne die Gruppe koennten sie
# nach dem Einlesen keine Lizenz mehr ausstellen.
from tesm_core.rbac import service as rbac
from ..permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
rbac.funktionsgruppe_fuer_administratoren(
conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF
)
return report
@@ -25,8 +25,8 @@ VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
"master_endpoint",
"",
"str",
"Oeffentlicher Endpunkt dieses Lizenzservers",
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
"Öffentlicher Endpunkt dieses Lizenzservers",
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen – z. B. "
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
"kundengerichteten Links verwendet.",
section="vendor",
@@ -38,8 +38,8 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"portal_link_minutes",
30,
"int",
"Gueltigkeit des Zugangslinks (Minuten)",
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
"Gültigkeit des Zugangslinks (Minuten)",
hint="Der Link ist zusätzlich nur einmal verwendbar.",
section="portal",
minimum=5,
maximum=1440,
@@ -48,9 +48,9 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"portal_session_minutes",
60,
"int",
"Gueltigkeit der Portalsitzung (Minuten)",
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
"eines fuer die daraus entstehende Sitzung.",
"Gültigkeit der Portalsitzung (Minuten)",
hint="Zweites, unabhängiges Zeitfenster: eines für das Anklicken des Links, "
"eines für die daraus entstehende Sitzung.",
section="portal",
minimum=5,
maximum=1440,
@@ -64,7 +64,7 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
"bool",
"Self-Service-Link je Ticket aktiv",
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.",
"neu auszustellen – ohne Anmeldung, allein über den unratbaren Ticket-Link.",
section="portal",
),
)
@@ -83,8 +83,8 @@ DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
"allow_customer_revoke",
True,
"bool",
"Kunden duerfen ihre Lizenz selbst widerrufen",
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
"Kunden dürfen ihre Lizenz selbst widerrufen",
hint="Notwendig für einen Systemwechsel ohne Support-Anruf.",
section="licensing",
),
)
@@ -171,22 +171,22 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ field('name', 'Firmenname', value=customer.name, required=True,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('contact_email', 'E-Mail', value=customer.contact_email, type='email',
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('contact_person', 'Ansprechpartner', value=customer.contact_person,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('contact_phone', 'Telefon', value=customer.contact_phone,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
<div class="form-grid">
{{ field('street', 'Strasse', value=customer.street,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('house_number', 'Nr.', value=customer.house_number,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('postal_code', 'PLZ', value=customer.postal_code,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
{{ field('city', 'Ort', value=customer.city,
disabled=not current_user.has('customers.edit')) }}
disabled=not current_user.has('customers.write')) }}
</div>
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
extra_class='field--full') }}
@@ -200,7 +200,7 @@
{{ icon('copy', size=13) }}</button></dd>
</dl>
</div>
{% if current_user.has('customers.edit') %}
{% if current_user.has('customers.write') %}
<div class="card__footer modal__footer--split">
{% if current_user.has('customers.delete') %}
<button class="btn btn--sm btn--ghost danger-text" type="submit"
@@ -101,7 +101,7 @@
</div>
</section>
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% if current_user.has('license_renew.execute') and license.status != 'revoked' %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -170,7 +170,7 @@
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
<div class="card__sub">Genau so, wie es signiert wurde – jede Änderung bricht die Signatur</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
@@ -204,17 +204,17 @@
</section>
{% endif %}
{% if current_user.has('licenses.edit') or current_user.has('licenses.delete') %}
{% if current_user.has('license_revoke.execute') or current_user.has('licenses.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
<button class="btn btn--sm btn--danger btn--block" type="button"
data-dialog-open="revoke-dialog">Widerrufen</button>
<p class="fs-xs faint">
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
Das Ticket bleibt offen – eine neue Lizenz kann sofort ausgestellt werden. Genau so
läuft ein Systemwechsel.
</p>
{% endif %}
@@ -239,7 +239,7 @@
{% endblock %}
{% block dialogs %}
{% if current_user.has('licenses.edit') and license.status != 'revoked' %}
{% if current_user.has('license_revoke.execute') and license.status != 'revoked' %}
{% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -6,7 +6,7 @@
{% block topbar_extra %}
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }}
{% if current_user.has('licenses.execute') %}
{% if current_user.has('license_activation.execute') %}
<a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
{{ icon('transfer', size=15) }} Offline-Code
</a>
@@ -89,7 +89,7 @@
</dd>
</dl>
</div>
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<div class="card__footer">
{% if not state.activated %}
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
@@ -151,7 +151,7 @@
</section>
{% endif %}
{% if current_user.has('settings_license.edit') %}
{% if current_user.has('settings_license.write') %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -22,7 +22,7 @@
</form>
<p class="auth-footnote">
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung – unabhängig davon, ob die
Adresse bei uns bekannt ist.
</p>
{% endblock %}
@@ -15,7 +15,7 @@
{% if response_code %}
<div class="field">
<span class="field__label">Antwortcode -- in TESM eintragen</span>
<span class="field__label">Antwortcode – in TESM eintragen</span>
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
<div class="row row--end mt-2">
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
@@ -23,7 +23,7 @@
</button>
</div>
<p class="fs-sm muted mt-2">
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei
Tragen Sie ihn in TESM unter Verwaltung → Lizenz bei
&bdquo;Antwortcode des Lizenzservers&ldquo; ein.
{% if summary.hostname %}
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
@@ -33,16 +33,16 @@ bewusst keine echte Nachricht.', 'info') }}
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled,
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
<div class="form-grid">
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id,
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_client_id', 'Anwendungs-ID (Client)', value=values.graph_client_id,
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_sender', 'Absender-Postfach', value=values.graph_sender, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
{{ field('graph_reply_to', 'Antwortadresse', value=values.graph_reply_to, type='email',
disabled=not current_user.has('settings_mail.edit')) }}
disabled=not current_user.has('settings_mail.write')) }}
</div>
<fieldset class="fieldset">
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend>
@@ -55,7 +55,7 @@ bewusst keine echte Nachricht.', 'info') }}
{% endif %}
</fieldset>
</div>
{% if current_user.has('settings_mail.edit') %}
{% if current_user.has('settings_mail.write') %}
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
{% endif %}
</form>
@@ -129,7 +129,7 @@
</section>
{% if current_user.is_admin %}
{{ nav_order_card(nav_items, nav_order, nav_child_order,
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
url_for('admin.nav_order'), csrf_field_name, csrf_token(),
sub='Gilt für alle Benutzer') }}
{% endif %}
@@ -19,16 +19,16 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="card__body stack">
<div class="form-grid">
{{ field('vendor_name', 'Anbietername', value=values.vendor_name,
disabled=not current_user.has('settings_vendor.edit')) }}
disabled=not current_user.has('settings_vendor.write')) }}
{{ field('vendor_email', 'E-Mail', value=values.vendor_email, type='email',
disabled=not current_user.has('settings_vendor.edit')) }}
disabled=not current_user.has('settings_vendor.write')) }}
{{ field('vendor_phone', 'Telefon', value=values.vendor_phone,
disabled=not current_user.has('settings_vendor.edit')) }}
disabled=not current_user.has('settings_vendor.write')) }}
</div>
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
placeholder='https://lizenz.firma.de',
disabled=not current_user.has('settings_vendor.edit'),
disabled=not current_user.has('settings_vendor.write'),
hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt
des Host-Headers der aktüllen Anfrage, der bei internen Aufrufen falsch wäre.',
@@ -43,14 +43,14 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<p class="faint fs-sm">Kein Logo hinterlegt.</p>
{% endif %}
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml"
{% if not current_user.has('settings_vendor.edit') %}disabled{% endif %}>
{% if not current_user.has('settings_vendor.write') %}disabled{% endif %}>
<span class="field__hint">
Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
</span>
</div>
</fieldset>
</div>
{% if current_user.has('settings_vendor.edit') %}
{% if current_user.has('settings_vendor.write') %}
<div class="card__footer modal__footer--split">
{% if values.vendor_logo_base64 %}
<button class="btn btn--sm btn--ghost" type="submit"
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="divider"></div>
<p class="fs-sm muted">
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
Änderung hier wirkt auf neue Lizenzen – bestehende behalten ihren Stand, bis eine neue
Revision signiert wird.
</p>
</div>
@@ -33,7 +33,7 @@
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
<div class="card__sub">
{% if has_open_license %}
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst.
Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
{% else %}
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
{% endif %}
@@ -41,7 +41,7 @@
</div>
</div>
<div class="card__body row row--tight">
{% if current_user.has('licenses.create') %}
{% if current_user.has('license_issue.execute') %}
<form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -128,7 +128,7 @@
</div>
<p class="fs-xs faint">
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar – behandeln Sie
ihn wie ein Passwort.
</p>
<div class="divider"></div>
@@ -163,7 +163,7 @@
<label class="field">
<span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type"
{% if not current_user.has('tickets.edit') %}disabled{% endif %}>
{% if not current_user.has('tickets.write') %}disabled{% endif %}>
{% for value in types %}
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
{{ type_labels.get(value, value) }}
@@ -178,15 +178,15 @@
{% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module,
checked=module in ticket_modules,
disabled=not current_user.has('tickets.edit')) }}
disabled=not current_user.has('tickets.write')) }}
{% endfor %}
</div>
</fieldset>
{% endif %}
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number',
disabled=not current_user.has('tickets.edit')) }}
disabled=not current_user.has('tickets.write')) }}
{{ checkbox('lifetime', 'Unbefristet', checked=ticket.lifetime,
disabled=not current_user.has('tickets.edit')) }}
disabled=not current_user.has('tickets.write')) }}
{{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
<div class="divider"></div>
<dl class="kv fs-xs">
@@ -195,7 +195,7 @@
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
</dl>
</div>
{% if current_user.has('tickets.edit') %}
{% if current_user.has('tickets.write') %}
<div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button>
</div>
@@ -203,13 +203,13 @@
</form>
</section>
{% if current_user.has('tickets.edit') or current_user.has('tickets.delete') %}
{% if current_user.has('tickets.write') or current_user.has('tickets.delete') %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
</div>
<div class="card__body stack stack--tight">
{% if current_user.has('tickets.edit') %}
{% if current_user.has('tickets.write') %}
<form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -32,7 +32,7 @@
{% endfor %}
</select>
<span class="field__hint">
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
Der Kunde ist nach dem Anlegen unveränderlich – der Self-Service-Link ist an ihn
gebunden.
</span>
</label>
@@ -77,7 +77,7 @@
<div class="flex-1">
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
<div class="card__sub">
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
Erzeugt eine neue Vertrauensgrenze – der Empfänger verwaltet danach eigene Kunden
</div>
</div>
</div>
@@ -103,7 +103,7 @@
<div class="card__body">
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
<li>Ticket anlegen (dieser Schritt).</li>
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit
<li>Auf der Ticketseite eine Lizenz ausstellen – optional als 30-Tage-Testlizenz mit
allen Modulen.</li>
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
@@ -112,7 +112,7 @@
<div class="divider"></div>
<p class="fs-sm muted">
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf.
herunterzuladen und bei einem Systemwechsel freizugeben – ohne Support-Anruf.
</p>
</div>
<div class="card__footer">
+9
View File
@@ -168,6 +168,8 @@ LOG_DIR="/var/log/$NAME"
SERVICE_USER="$APP"
UNIT="$NAME.service"
MONITOR_UNIT="$NAME-monitor.service"
CLEANUP_UNIT="$NAME-cleanup.service"
CLEANUP_TIMER="$NAME-cleanup.timer"
NGINX_SITE="/etc/nginx/sites-available/$NAME"
ENV_PREFIX="TESM_"
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
@@ -539,6 +541,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
if [[ "$APP" == "tesm" ]]; then
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
fi
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|| info "Units unveraendert"
@@ -611,6 +618,8 @@ systemctl enable --now "$UNIT" >/dev/null
if [[ "$APP" == "tesm" ]]; then
systemctl enable --now "$MONITOR_UNIT" >/dev/null
fi
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
# -- Health-Check und Rollback ----------------------------------------------
step "Gesundheitspruefung"
+34
View File
@@ -0,0 +1,34 @@
[Unit]
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
After=tesm.service
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
# Hochfahren.
[Service]
Type=oneshot
User=tesm
Group=tesm
WorkingDirectory=/srv/tesm
Environment=TESM_BASE_DIR=/srv/tesm
Environment=TESM_DATA_DIR=/srv/tesm/data
Environment=TESM_LOG_DIR=/var/log/tesm
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm/venv/bin/tesm-admin housekeeping
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
ReadWritePaths=/srv/tesm/data /var/log/tesm
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=TESM -- taegliches Aufraeumen
[Timer]
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
# und die Archivierung des Aenderungsprotokolls.
#
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
OnCalendar=*-*-* 03:30:00
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
RandomizedDelaySec=15m
# Nachgeholt, wenn die Maschine nachts aus war.
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,34 @@
[Unit]
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
After=tesm-license.service
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
# Hochfahren.
[Service]
Type=oneshot
User=tesm-license
Group=tesm-license
WorkingDirectory=/srv/tesm-license
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX
LockPersonality=yes
SystemCallArchitectures=native
UMask=0027
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=TESM-Lizenzserver -- taegliches Aufraeumen
[Timer]
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
# und die Archivierung des Aenderungsprotokolls.
#
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
OnCalendar=*-*-* 03:30:00
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
RandomizedDelaySec=15m
# Nachgeholt, wenn die Maschine nachts aus war.
Persistent=true
[Install]
WantedBy=timers.target
+5
View File
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
| Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt |
| Zweiter Faktor | TOTP, optional erzwingbar für Administratoren |
| Rechte | Eine Zeile je Funktion; das Administratorkennzeichen gibt den Verwaltungsbereich, nicht alles |
| Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen |
| Sperre je Adresse | zusätzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst lässt sich die Kontosperre umgehen, indem man viele Konten je einmal probiert |
| Verzeichnisdienst | LDAP nur über TLS, Zertifikat wird geprüft. Ohne gültiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet |
@@ -171,6 +172,10 @@ braucht der Dienst kein `--workers 1` mehr.
* **Kein CSRF-Freibrief für cookie-autorisierte Routen.**
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander.
Ein echter Administrator darf sich Funktionsrechte über eine Gruppe geben --
das ist gewollt und hinterlässt eine Zeile im Änderungsprotokoll. Was es
nicht gibt, ist der stille Weg: ein Kennzeichen, das ohne Eintrag alles
aufschließt.
* **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt.
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-core"
version = "2.5.2"
version = "2.8.3"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = [
+27 -11
View File
@@ -59,7 +59,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
30,
"int",
"Aufbewahrung im Papierkorb (Tage)",
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
section="system",
minimum=1,
maximum=3650,
@@ -68,8 +68,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
"session_absolute_hours",
12,
"int",
"Laengstmoegliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei staendiger Arbeit -- eine Sitzung soll nicht "
"Längstmögliche Sitzungsdauer (Stunden)",
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
section="system",
minimum=1,
@@ -80,8 +80,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
30,
"int",
"Automatische Abmeldung nach Leerlauf (Minuten)",
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
"Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
"Einbindung.",
section="system",
minimum=5,
@@ -102,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"modules_disabled",
[],
"json",
"Vor Ort abgeschaltete Module",
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
section="system",
),
SettingSpec(
"license_server_ca",
"",
@@ -114,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail für Let's Encrypt", section="web"),
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
# im Regelbetrieb bleibt es bei 80 und 443.
@@ -126,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
),
SettingSpec(
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
"web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
minimum=1, maximum=8192,
),
*directory.SETTINGS,
@@ -148,7 +156,11 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen.
auth_tagline: str = "",
app_version: str = "2.5.2",
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
#: falsche Version in die Fussnote schreiben.
app_version: str = "unbekannt",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
@@ -160,6 +172,10 @@ def create_base_app(
static_folder=static_folder or "static",
static_url_path="/assets",
)
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
app.json.ensure_ascii = False
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
@@ -374,8 +390,8 @@ def create_base_app(
# sehen. Vorher stand hier nur der nackte Status.
grenze = uploads.limit_mb()
error.description = (
f"Diese Datei ist groesser als erlaubt. Zurzeit sind {grenze} MB je Upload "
"eingestellt; aendern kann das ein Administrator unter "
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
"eingestellt; ändern kann das ein Administrator unter "
'"Einstellungen -> Webserver und TLS".'
)
if _wants_json():
+46 -3
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"
@@ -133,14 +176,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
"reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
}
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
return {
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
}
expected_prev = str(data["hash"])
checked += 1
@@ -26,6 +26,7 @@ import re
import socket
import sqlite3
import ssl
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
section="ldap",
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
SettingSpec(
"ldap_allow_insecure",
False,
"bool",
"Unverschluesselte Verbindung ausdruecklich erlauben",
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
"Unverschlüsselte Verbindung ausdrücklich erlauben",
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
section="ldap",
),
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
)
@@ -123,7 +124,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
try:
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
return str(row.get("username") or ""), ""
return str(row.get("username") or ""), secret
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
def _server(config: dict[str, Any]):
"""Das Server-Objekt -- so sparsam wie moeglich.
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
Schema*: bei einem Active Directory sind das Tausende Attribut- und
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
Unterschied zwischen "geht sofort" und "dauert manchmal".
"""
from ldap3 import Server
security = str(config["ldap_security"])
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
return Server(
str(config["ldap_host"]),
port=int(config["ldap_port"]),
use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL",
get_info="DSA" if braucht_basis else "NO_INFO",
connect_timeout=int(config["ldap_timeout"]),
)
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
#:
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
#: ein sofortiger zweiter Versuch klappte.
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
def _transiente_fehler() -> tuple[type[Exception], ...]:
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
global TRANSIENTE_FEHLER
if TRANSIENTE_FEHLER:
return TRANSIENTE_FEHLER
from ldap3.core.exceptions import (
LDAPResponseTimeoutError,
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
)
TRANSIENTE_FEHLER = (
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
LDAPResponseTimeoutError,
)
return TRANSIENTE_FEHLER
def mit_wiederholung(aufbau: Any) -> Any:
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
ist die Regel aus 1.2.8, und sie ist richtig.
"""
try:
return aufbau()
except _transiente_fehler() as fehler:
logger.info(
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
type(fehler).__name__,
)
return aufbau()
def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
security = str(config["ldap_security"])
if security == "none" and not config["ldap_allow_insecure"]:
raise PermissionError(
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
"Nutzen Sie LDAPS oder StartTLS."
)
connection = Connection(
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
auto_bind=False,
raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]),
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
auto_referrals=False,
)
if security == "starttls":
if not connection.open():
@@ -249,12 +319,23 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
bind_user, bind_password = bind_credentials(conn)
warnings: list[str] = []
if str(config["ldap_security"]) == "none":
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
begonnen = time.monotonic()
zeiten: list[str] = []
def gemessen(name: str, seit: float) -> float:
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
jetzt = time.monotonic()
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
return jetzt
try:
service_conn, bound = _connect(config, bind_user, bind_password)
service_conn, bound = mit_wiederholung(
lambda: _connect(config, bind_user, bind_password)
)
except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
warnings=tuple(warnings),
)
marke = gemessen("Dienstbindung", begonnen)
try:
base = _base_dn(service_conn, config)
marke = gemessen("Basis-DN", marke)
if not base:
return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
attributes=list(dict.fromkeys(wanted)),
size_limit=2,
)
marke = gemessen("Benutzersuche", marke)
entries = list(service_conn.entries)
if len(entries) != 1:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0]
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password)
#
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
# einem Domain-Controller in einem anderen Netz schlug der erste
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
# den Sperrzaehler des Kontos zweimal hoch.
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
marke = gemessen("Passwortpruefung", marke)
try:
if not user_bound:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings))
finally:
try:
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn)
marke = gemessen("Gruppen", marke)
profile = DirectoryProfile(
username=_attr(entry, attribute) or username,
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
email=_attr(entry, email_attribute),
group_dns=group_dns,
)
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
# Verbindung ist, die Suche oder die Gruppen.
logger.info(
"Verzeichnisanmeldung %s: %s, gesamt %dms",
username,
", ".join(zeiten),
int((time.monotonic() - begonnen) * 1000),
)
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally:
try:
@@ -359,7 +463,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
)
return tuple(str(entry.entry_dn) for entry in connection.entries)
except Exception as exc: # noqa: BLE001
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
return ()
@@ -423,18 +527,24 @@ def _apply_group_mappings(
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
"""
if not group_dns:
return
mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
conn, "SELECT ad_group_dn, app_group_id, grants_admin FROM ldap_group_mappings"
)
if not mappings:
return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns}
# Das Kennzeichen zuerst -- es haengt an keiner Gruppe und muss auch dann
# ausgewertet werden, wenn das Konto in keiner zugeordneten Gruppe steht.
_kennzeichen_setzen(conn, user_id, mappings, lowered)
if not group_dns:
return
gruppenzuordnungen = [row for row in mappings if row["app_group_id"] is not None]
if not gruppenzuordnungen:
return
mapped_group_ids = {int(row["app_group_id"]) for row in gruppenzuordnungen}
wanted = {
int(row["app_group_id"])
for row in mappings
for row in gruppenzuordnungen
if str(row["ad_group_dn"]).lower() in lowered
}
@@ -452,6 +562,71 @@ def _apply_group_mappings(
)
def _kennzeichen_setzen(
conn: sqlite3.Connection,
user_id: int,
mappings: list[dict[str, Any]],
gruppen: set[str],
) -> None:
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
hier nie ankommt.
Zwei Grenzen, beide absichtlich:
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
* Nie den letzten entsperrten Administrator herunterstufen. Ein
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
verschliessen, und niemand koennte ihn korrigieren.
"""
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
if not zuordnungen:
return
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
zeile = Database.one(
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
)
if zeile is None or str(zeile["auth_source"]) != "ldap":
return
ist = bool(zeile["is_admin"])
if ist == soll:
return
if not soll:
uebrig = Database.value(
conn,
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
"AND deleted_at IS NULL AND id<>?",
(user_id,),
default=0,
)
if not uebrig:
logger.warning(
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
user_id,
)
return
conn.execute(
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
)
audit.log(
conn,
actor="system",
action="auth.admin_flag_changed",
target=str(user_id),
detail=(
"Administrator: "
+ ("nein -> ja" if soll else "ja -> nein")
+ " (aus der Verzeichniszuordnung)"
),
severity="warning",
)
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
+28 -13
View File
@@ -77,25 +77,40 @@ class User:
@property
def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
"""Rechte nach Anwendung der Bereichs-Kill-Switches.
Ein echter Administrator hat den Verwaltungsbereich immer dabei -- auch
ohne Gruppe und ohne dass jemand ihn angehakt haette. Sonst koennte
sich das letzte Administratorkonto aus der eigenen Anlage aussperren,
indem es seine Gruppe verliert.
"""
if self._effective is None:
tree = self._tree
self._effective = (
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
)
if tree is None:
self._effective = frozenset()
else:
eigene = set(tree.effective(self.granted))
if self.is_admin:
eigene |= set(tree.verwaltungsrechte())
self._effective = frozenset(eigene)
return self._effective
def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
if self.is_admin:
return True
"""True, wenn **alle** angegebenen Rechte greifen.
Das Administratorkennzeichen gibt seit 2.8.0 **den Verwaltungsbereich**
frei und nicht mehr jede Pruefung: Benutzer, Gruppen, Einstellungen,
Sicherung, Papierkorb, Diagnose. Funktionsrechte -- Geraete, Neustarts,
DHCP, Freigaben -- kommen ueber Gruppen, und die darf sich ein
Administrator selbst zuweisen. Der Unterschied ist nicht, *ob* er sie
bekommen kann, sondern dass es dann eine Zeile im
Aenderungsprotokoll gibt und in der Matrix zu sehen ist.
"""
if not permissions:
return True
return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions
return any(permission in effective for permission in permissions)
@@ -245,9 +260,9 @@ def _bitte_bestaetigen(permission: str) -> Any:
if is_automatic(request) or (
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
):
abort(403, description="Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.")
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
@@ -268,7 +283,7 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
else current_user.has(*permissions)
)
if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
@@ -318,7 +333,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
return _deny("Anmeldung erforderlich.")
if not reauth_is_fresh():
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
return view(*args, **kwargs)
@@ -151,7 +151,7 @@ def authenticate(
if ip_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
retry_after=ip_verdict.retry_after_seconds,
)
@@ -165,7 +165,7 @@ def authenticate(
if user_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
"Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
retry_after=user_verdict.retry_after_seconds,
)
@@ -248,7 +248,7 @@ def _verify_credentials(
return AuthOutcome(
"directory_unavailable",
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
"Bitte versuchen Sie es spaeter erneut.",
"Bitte versuchen Sie es später erneut.",
)
if result.status != "ok" or result.profile is None:
return AuthOutcome("invalid", GENERIC_FAILURE)
@@ -308,7 +308,7 @@ def complete_login(
actor_id=int(user_row["id"]),
action="auth.first_login",
target=str(user_row["username"]),
detail=f"Erste Anmeldung -- Quelle: {user_row.get('auth_source') or 'local'}",
detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
ip=ip,
request_id=getattr(g, "request_id", ""),
severity="notice",
@@ -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
+12 -12
View File
@@ -83,7 +83,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
port = getattr(args, flag, None)
if port:
if not 1 <= int(port) <= 65535:
print(f"Ungueltiger Port: {port}", file=sys.stderr)
print(f"Ungültiger Port: {port}", file=sys.stderr)
return 2
values[key] = int(port)
@@ -93,11 +93,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
# -- Zertifikat --------------------------------------------------- #
config = _config_from_settings(extension, conn)
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
if args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2
cert = webserver.request_certificate(
sysops,
@@ -118,7 +118,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.self_signed and not args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2
suggested = webserver.local_names()
cert = webserver.create_self_signed(
@@ -144,7 +144,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
if args.https:
if not (config.cert_path and config.key_path):
print(
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
"HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
"oder --acme angeben.",
file=sys.stderr,
)
@@ -172,7 +172,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
return 0
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
return 2
result = webserver.apply_config(sysops, rendered)
print(result["message"])
@@ -194,7 +194,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
help="nginx und TLS einrichten (Port 80/443)",
description=(
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
"Die Datei wird immer vollständig neu geschrieben – eine von Hand "
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
),
)
@@ -207,20 +207,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
parser.add_argument(
"--acme",
action="store_true",
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
)
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
parser.add_argument(
"--no-redirect",
action="store_true",
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
)
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
parser.add_argument(
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
)
+2 -2
View File
@@ -175,11 +175,11 @@ def load_core_config(
bind = (_env(env_prefix, "BIND") or default_bind).strip()
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
return CoreConfig(
app_key=app_key,
+144 -2
View File
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
updated_at TEXT NOT NULL
);
-- Eine Verzeichnisgruppe zeigt entweder auf eine lokale Gruppe **oder** auf
-- das Administratorkennzeichen (grants_admin). Darum ist app_group_id
-- nullbar: "Domänen-Admins" soll echte Administratoren erzeugen koennen und
-- nicht nur Mitglieder der Gruppe "Administration".
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
);
"""
@@ -186,8 +191,145 @@ CREATE INDEX IF NOT EXISTS idx_jobs_kind_state ON jobs(kind, state);
CREATE INDEX IF NOT EXISTS idx_jobs_created ON jobs(created_at);
"""
def benenne_rechte_um(conn) -> None: # type: ignore[no-untyped-def]
"""Schreibt bestehende Gruppenrechte auf das neue Vokabular um.
``devices.view`` wird ``devices.read``, ``devices.edit`` wird
``devices.write``. Ohne diesen Schritt stehen in den Gruppen nur noch
Schluessel, die der Rechtebaum nicht kennt -- und
:meth:`PermissionTree.sanitize` wirft Unbekanntes **still** weg. Nach dem
Update waeren alle Gruppen leer und niemand kaeme mehr an seine Seiten:
kein Fehler, den man sieht, sondern einer, den man erst merkt, wenn jemand
anruft.
Im Kern und nicht in den Anwendungen, weil die Tabelle beiden gehoert.
Idempotent: was schon neu heisst, wird nicht angefasst.
"""
from .rbac.legacy_permissions import UMBENANNTE_AKTIONEN
for alt, neu in UMBENANNTE_AKTIONEN.items():
# Erst die Dubletten weg, die beim Umschreiben entstehen wuerden: haette
# eine Gruppe (aus welchem Grund auch immer) beide Schluessel, brach der
# eindeutige Index. Lieber vorher aufraeumen als in der Migration
# scheitern.
conn.execute(
"DELETE FROM group_permissions WHERE permission LIKE ? AND EXISTS ("
" SELECT 1 FROM group_permissions AS andere"
" WHERE andere.group_id = group_permissions.group_id"
" AND andere.permission = substr(group_permissions.permission, 1,"
" length(group_permissions.permission) - ?) || ?)",
(f"%.{alt}", len(alt), neu),
)
conn.execute(
"UPDATE group_permissions"
" SET permission = substr(permission, 1, length(permission) - ?) || ?"
" WHERE permission LIKE ?",
(len(alt), neu, f"%.{alt}"),
)
def zuordnung_auf_kennzeichen(conn) -> None: # type: ignore[no-untyped-def]
"""Erlaubt einer Verzeichnisgruppe, das Administratorkennzeichen zu setzen.
Bisher konnte eine Zuordnung nur auf eine lokale Gruppe zeigen. "Domänen-
Admins" landete deshalb in der Gruppe *Administration* -- und wurde damit
gerade **kein** echter Administrator: die Gruppe pflegt Benutzer und
Rechte, hat aber kein Kennzeichen und kann sich keines geben.
Zwei Aenderungen an der Tabelle, und SQLite kann Spalten nicht
umdefinieren: also neu anlegen und umkopieren. ``app_group_id`` wird
nullbar (eine Zuordnung auf das Kennzeichen zeigt auf keine Gruppe), und
``grants_admin`` sagt, dass genau das gemeint ist.
"""
spalten = {
str(zeile[1]) for zeile in conn.execute("PRAGMA table_info(ldap_group_mappings)")
}
if "grants_admin" in spalten:
return
conn.execute(
"""
CREATE TABLE ldap_group_mappings_neu (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER REFERENCES groups(id) ON DELETE CASCADE,
grants_admin INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)
"""
)
conn.execute(
"INSERT INTO ldap_group_mappings_neu "
"(id, ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"SELECT id, ad_group_dn, ad_group_name, app_group_id, 0, created_at "
" FROM ldap_group_mappings"
)
conn.execute("DROP TABLE ldap_group_mappings")
conn.execute("ALTER TABLE ldap_group_mappings_neu RENAME TO ldap_group_mappings")
def teile_rechte_auf(conn) -> None: # type: ignore[no-untyped-def]
"""Verteilt ein altes Sammelrecht auf die Zeilen, die daraus wurden.
Additiv: wer ``dhcp.edit`` hatte, bekommt alle fuenf DHCP-Zeilen, und das
alte Recht verschwindet. Ohne diesen Schritt stuende in der Gruppe ein
Schluessel, den der Baum nicht kennt -- und weg waere er still, denn genau
das tut :meth:`PermissionTree.sanitize` mit Unbekanntem.
Laeuft nach ``benenne_rechte_um``: die Tabelle nennt beide Schreibweisen,
wo es darauf ankommt, und ein zweiter Lauf findet nichts mehr zu tun.
"""
from .rbac.legacy_permissions import (
ERWEITERTE_RECHTE,
GETEILTE_RECHTE,
neue_schluessel,
)
def gruppen_mit(recht: str) -> list[int]:
return [
int(zeile[0])
for zeile in conn.execute(
"SELECT DISTINCT group_id FROM group_permissions WHERE permission=?",
(recht,),
).fetchall()
]
# Geteilt: das alte Recht gibt es nicht mehr, also ersetzen.
for altes, neue in GETEILTE_RECHTE.items():
gruppen = gruppen_mit(altes)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, recht) for gid in gruppen for recht in neue],
)
conn.execute("DELETE FROM group_permissions WHERE permission=?", (altes,))
# Erweitert: das Recht bleibt und bekommt dazu, was bisher an ihm hing.
# Loeschen waere hier falsch -- es ist keines von gestern.
for recht, dazu in ERWEITERTE_RECHTE.items():
gruppen = gruppen_mit(recht)
if not gruppen:
continue
conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)",
[(gid, weiteres) for gid in gruppen for weiteres in dazu],
)
assert neue_schluessel # in derselben Tabelle nachgeschlagen wie beim Import
CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE),
Migration(
version=1,
name="baseline",
sql=V1_BASELINE,
geaendert_weil=(
"Das Grundschema legt ldap_group_mappings jetzt mit grants_admin und nullbarer app_group_id an -- eine neue Installation soll nicht erst core/6 brauchen, um die Tabelle wieder umzubauen."
),
),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS),
Migration(version=4, name="rechte-read-write", fn=benenne_rechte_um),
Migration(version=5, name="rechte-je-funktion", fn=teile_rechte_auf),
Migration(version=6, name="ldap-kennzeichen", fn=zuordnung_auf_kennzeichen),
)
+25 -2
View File
@@ -80,8 +80,8 @@ class Database:
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
if self._depth() > 0:
raise NestedConnectionError(
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
"Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
"(sonst blockiert SQLite sich selbst)."
)
return self._raw_connect()
@@ -119,6 +119,29 @@ class Database:
finally:
conn.close()
@contextmanager
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
Unterschriften am Tag ist das keine Diskussion wert.
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
ausserhalb einer laufenden Transaktion.
"""
conn.execute("PRAGMA synchronous=FULL")
try:
yield conn
finally:
conn.execute("PRAGMA synchronous=NORMAL")
# ------------------------------------------------------------------ #
# Transaktionen
# ------------------------------------------------------------------ #
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
try:
return target.extensions["tesm"] # type: ignore[return-value]
except KeyError as exc: # pragma: no cover - Programmierfehler
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
def db() -> sqlite3.Connection:
+6 -6
View File
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
except OSError:
return problems
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
return problems
@@ -110,11 +110,11 @@ class Keystore:
created_at=str(entry.get("created_at", "")),
)
if len(key.material) != _KEY_BYTES:
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
self._keys[key.key_id] = key
self._active = str(raw.get("active") or "")
if self._active not in self._keys:
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
return
self._active = "1"
self._keys = {
@@ -160,7 +160,7 @@ class Keystore:
def drop_key(self, key_id: str) -> None:
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
if key_id == self._active:
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
self._keys.pop(key_id, None)
self._persist()
@@ -183,14 +183,14 @@ class Keystore:
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
key = self._keys.get(key_id)
if key is None:
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
try:
plain = AESGCM(key.material).decrypt(
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
)
except (InvalidTag, ValueError) as exc:
raise DecryptionError(
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
"Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
"manipulierte Daten oder falscher Verwendungszweck)"
) from exc
return plain.decode("utf-8")
@@ -34,7 +34,7 @@ import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from typing import Any, Callable, Iterable
import tesm_licensing as lic
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
EXPIRY_HINT_DAYS = 90
SETTING_EXPORT_GRACE = "license_export_grace"
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
#:
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
#: mitbringen.
SETTING_MODULES_OFF = "modules_disabled"
STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
@@ -68,10 +83,10 @@ API_PATHS = {
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
LICENSED_FEATURES: dict[str, str] = {
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
"nginx": "Webserver-Einstellungen aendern",
"nginx": "Webserver-Einstellungen ändern",
"export": "Daten exportieren",
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
"trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
"monitor_interval": "Prüfintervall der Überwachung ändern",
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
}
@@ -85,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"""
text = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
detail = "Das Zertifikat konnte nicht geprueft werden"
detail = "Das Zertifikat konnte nicht geprüft werden"
if "self-signed" in text or "self signed" in text:
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
return (
@@ -94,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"einer anerkannten Stelle."
)
if "Name or service not known" in text or "nodename nor servname" in text:
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
if "Connection refused" in text:
return f"{endpoint} nimmt keine Verbindung an."
if "timed out" in text or "timeout" in text.lower():
@@ -121,6 +136,8 @@ class LicenseState:
last_heartbeat_at: str = ""
heartbeat_error: str = ""
pending_action: str = ""
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
modules_off: frozenset[str] = frozenset()
@property
def role(self) -> str:
@@ -128,8 +145,14 @@ class LicenseState:
@property
def modules(self) -> frozenset[str]:
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
return self.status.modules if self.status else frozenset()
@property
def modules_in_use(self) -> frozenset[str]:
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
return self.modules - self.modules_off
@property
def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "")
@@ -179,6 +202,7 @@ class LicenseManager:
"""Liest Lizenzdatei und Status neu ein."""
with self._lock:
state = LicenseState()
state.modules_off = self.abgeschaltete_module(conn)
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn)
@@ -239,9 +263,51 @@ class LicenseManager:
)
def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
"""
return (
self.active()
and name in self._state.modules
and name not in self._state.modules_off
)
def module_licensed(self, name: str) -> bool:
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
return self.active() and name in self._state.modules
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
"""Liest die Liste aus den Einstellungen."""
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
gespeichert = [gespeichert]
return frozenset(str(name) for name in gespeichert)
def module_abschalten(
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
) -> tuple[frozenset[str], frozenset[str]]:
"""Setzt die Liste der abgeschalteten Module neu.
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
Protokolleintrag.
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
das Einzige, was jetzt anders ist.
"""
vorher = self.abgeschaltete_module(conn)
nachher = frozenset(str(name) for name in namen)
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
with self._lock:
self._state.modules_off = nachher
return vorher - nachher, nachher - vorher
def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion.
@@ -257,8 +323,8 @@ class LicenseManager:
def require(self, feature: str) -> None:
if not self.feature_allowed(feature):
raise LicenseError(
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
"aktivierte Lizenz nicht moeglich."
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
"aktivierte Lizenz nicht möglich."
)
# ------------------------------------------------------------------ #
@@ -284,7 +350,7 @@ class LicenseManager:
try:
payload = json.loads(text)
except ValueError as exc:
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
try:
document, client_key = lic.split_bundle(payload)
@@ -294,20 +360,20 @@ class LicenseManager:
try:
lic.verify_license(document) # innere Konsistenz
except lic.LicensingError as exc:
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
master_pubkey = str(document.get("master_pubkey") or "")
known = self.trusted_keys(conn)
if known and master_pubkey not in set(known) and not allow_new_issuer:
raise LicenseError(
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdruecklich bestaetigen."
f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdrücklich bestätigen."
)
if not client_key and not self.key_path.is_file():
raise LicenseError(
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
"Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
"Lizenz-Bundle des Lizenzservers verwenden."
)
@@ -332,7 +398,7 @@ class LicenseManager:
actor=actor,
action="license.issuer_pinned",
target=lic.key_id(master_pubkey),
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
severity="notice",
)
@@ -397,11 +463,11 @@ class LicenseManager:
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
) -> dict[str, Any]:
if not self._state.valid:
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
raise LicenseError("Es ist keine gültige Lizenz installiert.")
key = self.client_key()
if not key:
raise LicenseError(
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
"Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
)
return lic.build_client_request(
action, self._state.document, key, hostname=hostname, report=report
@@ -442,8 +508,8 @@ class LicenseManager:
)
else:
reason = (
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
"ueber den Offline-Weg aktivieren."
"In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
"über den Offline-Weg aktivieren."
)
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
@@ -475,7 +541,7 @@ class LicenseManager:
"code": code,
"message": (
(reason + " " if reason else "")
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
+ "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
),
}
@@ -527,7 +593,7 @@ class LicenseManager:
current_seq=current_seq,
)
except lic.LicensingError as exc:
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
status = str(response.get("status") or "")
detail = str(response.get("detail") or "")
@@ -597,7 +663,7 @@ class LicenseManager:
actor=actor,
)
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
message = "Verbindung zum Lizenzserver bestaetigt."
message = "Verbindung zum Lizenzserver bestätigt."
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
@@ -648,7 +714,7 @@ class LicenseManager:
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
) -> dict[str, Any]:
if not url.startswith(("https://", "http://")):
raise ValueError("Ungueltiger Endpunkt")
raise ValueError("Ungültiger Endpunkt")
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
url,
@@ -748,7 +814,7 @@ class LicenseManager:
}
if not state.valid:
return {
"label": "Lizenz ungueltig",
"label": "Lizenz ungültig",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
@@ -773,29 +839,29 @@ class LicenseManager:
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": (
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
"Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
if status.grace_active
else "Lizenzpflichtige Funktionen sind abgeschaltet."
),
}
if status.expiring_soon:
return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.",
"title": "Bitte rechtzeitig verlängern.",
}
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
# nach einem Problem aussieht.
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
"css_class": "",
"dot": "1",
"dot_class": "",
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
}
if state.last_heartbeat_at:
try:
@@ -809,6 +875,6 @@ class LicenseManager:
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "",
"title": "Rein informativ -- es wird nichts abgeschaltet.",
"title": "Rein informativ – es wird nichts abgeschaltet.",
}
return None
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
except OSError:
pass
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
root.warning("Konnte app.log nicht oeffnen: %s", exc)
root.warning("Konnte app.log nicht öffnen: %s", exc)
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
access = logging.getLogger(ACCESS_LOGGER)
+26 -4
View File
@@ -50,6 +50,12 @@ class Migration:
sql: str = ""
fn: MigrationFn | None = None
allow_integrity_error: bool = False
#: Begruendung, falls der Text einer bereits angewendeten Migration
#: absichtlich geaendert wurde -- etwa weil eine frische Installation
#: mit der neuen Schreibweise starten muss. Ohne Begruendung warnt der
#: Lauf, und das soll er auch: ein stiller Umbau einer angewendeten
#: Migration ist fast immer ein Versehen.
geaendert_weil: str = ""
tags: tuple[str, ...] = field(default=())
@property
@@ -175,10 +181,26 @@ class MigrationRunner:
record = known.get(migration.version)
if record is not None:
if record["checksum"] != migration.checksum:
if migration.geaendert_weil:
# Mit Begruendung ist es eine Notiz, keine Warnung:
# manchmal muss der Text einer laengst angewendeten
# Migration mit, damit eine **frische** Installation
# denselben Stand erreicht. Wer das Protokoll liest,
# soll den Unterschied zu einem Versehen sehen.
logger.info(
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
"Auf dieser Installation ist sie längst angewendet und "
"wird nicht erneut ausgeführt.",
component,
migration.version,
migration.name,
migration.geaendert_weil,
)
else:
logger.warning(
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
"Migration %s/%s (%s) wurde nachträglich geändert "
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
"Versionsnummer.",
component,
migration.version,
@@ -210,7 +232,7 @@ class MigrationRunner:
raise
logger.error(
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
"angewendet werden. Der Start läuft weiter; die Daten müssen "
"manuell bereinigt werden.",
component,
migration.version,
+55 -31
View File
@@ -16,10 +16,10 @@ A = Action
USERS = Resource(
key="users",
label="Benutzer",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
},
)
@@ -27,35 +27,46 @@ USERS = Resource(
GROUPS = Resource(
key="groups",
label="Gruppen und Rechte",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Rechtematrix pflegen",
action_hints={
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
A.WRITE: "Rechte vergeben – nur Rechte, die man selbst besitzt.",
},
)
SETTINGS_SYSTEM = Resource(
key="settings_system",
label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."},
)
#: Die Reihenfolge des Menues lief unter "Systemaktionen auslösen" mit -- also
#: unter demselben Recht wie das Aendern des Hostnamens. Es ist aber das
#: harmloseste, was man in der Verwaltung tun kann, und das haeufigste.
SETTINGS_NAV = Resource(
key="settings_nav",
parent="settings_system",
label="Navigation",
actions=(A.READ, A.WRITE),
hint="Reihenfolge der Menüpunkte und ihrer Unterpunkte",
)
SETTINGS_LICENSE = Resource(
key="settings_license",
label="Lizenz",
actions=(A.VIEW, A.EDIT),
actions=(A.READ, A.WRITE),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
)
SETTINGS_DIRECTORY = Resource(
key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
actions=(A.READ, A.WRITE, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung",
action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.",
A.EXECUTE: "Verbindungstest ausführen.",
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
},
)
@@ -63,58 +74,69 @@ SETTINGS_DIRECTORY = Resource(
SETTINGS_WEB = Resource(
key="settings_web",
label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
)
SETTINGS_NETWORK = Resource(
key="settings_network",
label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT),
actions=(A.READ, A.WRITE),
hint="IP-Adresse, Gateway, DNS des Hosts",
)
BACKUP = Resource(
key="backup",
label="Sicherung und Übertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschluesselter Export und Import",
#: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
#: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
#: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
#: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
BACKUP_EXPORT = Resource(
key="backup_export",
label="Sicherung erstellen",
actions=(A.READ, A.EXPORT, A.SECRETS),
hint="Verschlüsselter Export des Datenstands",
action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
},
)
BACKUP_IMPORT = Resource(
key="backup_import",
label="Sicherung einspielen",
actions=(A.READ, A.EXECUTE),
hint="Prüfen, übernehmen, verwerfen",
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
)
TRASH = Resource(
key="trash",
label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE),
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
actions=(A.READ, A.WRITE, A.DELETE),
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
action_hints={
A.EDIT: "Eintraege wiederherstellen.",
A.DELETE: "Eintraege endgueltig entfernen.",
A.WRITE: "Einträge wiederherstellen.",
A.DELETE: "Einträge endgültig entfernen.",
},
)
DIAGNOSTICS = Resource(
key="diagnostics",
label="Diagnose",
actions=(A.VIEW,),
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
actions=(A.READ,),
hint="Schemastand, Protokollintegrität, Schlüsselrechte",
)
LOGS_LIVE = Resource(
key="logs_live",
label="Laufendes Protokoll",
actions=(A.VIEW,),
actions=(A.READ,),
)
LOGS_HISTORY = Resource(
key="logs_history",
label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT),
actions=(A.READ, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
)
@@ -122,8 +144,8 @@ LOGS_HISTORY = Resource(
LOGS_AUDIT = Resource(
key="logs_audit",
label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT),
hint="Wer hat wann was geaendert",
actions=(A.READ, A.EXPORT),
hint="Wer hat wann was geändert",
)
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
USERS,
GROUPS,
SETTINGS_SYSTEM,
SETTINGS_NAV,
SETTINGS_LICENSE,
SETTINGS_DIRECTORY,
SETTINGS_WEB,
SETTINGS_NETWORK,
BACKUP,
BACKUP_EXPORT,
BACKUP_IMPORT,
TRASH,
DIAGNOSTICS,
*extra,
@@ -0,0 +1,171 @@
"""Alte Rechteschluessel auf neue -- an einer Stelle, nicht an dreien.
Mit 2.8.0 heisst das Aktionsvokabular durchgehend wie das, was es tut:
``view`` wurde ``read``, ``edit`` wurde ``write``. Danach steht in der Matrix
"Write" und im Aenderungsprotokoll ``write``, nicht zweierlei.
Drei Stellen brauchen die Zuordnung, und darum steht sie hier und nicht dort:
* die **Migration**, die bestehende Gruppenzeilen umschreibt,
* der **Import**, dem eine Sicherung aus 2.7.x untergeschoben wird -- ohne die
Zuordnung landen dort lauter unbekannte Schluessel, und
:meth:`PermissionTree.sanitize` wirft Unbekanntes absichtlich **still** weg.
Aus einer Sicherung wuerden Gruppen ohne Rechte.
* der **Test**, der prueft, dass kein alter Schluessel mehr im Code steht.
Warum nicht dreimal dieselbe Ersetzung tippen: weil die dritte Kopie beim
naechsten Umbau vergessen wird. Genau so entstand das Muster bei den Adressen
(:mod:`tesm_core.legacy_urls`).
"""
from __future__ import annotations
#: Aktion alt -> neu. Die Ressource bleibt, wie sie heisst.
UMBENANNTE_AKTIONEN: dict[str, str] = {
"view": "read",
"edit": "write",
}
#: Ein altes Recht, das in mehrere neue zerfaellt -- ein Recht je Funktion.
#:
#: Die Aufteilung ist **additiv**: wer das alte Recht hatte, bekommt alle
#: neuen. Niemand verliert etwas; ab jetzt laesst sich jedes Stueck einzeln
#: vergeben. Der umgekehrte Weg waere auch denkbar gewesen -- nur das
#: Harmloseste uebernehmen --, waere aber ein Update, das Leuten Rechte
#: wegnimmt, ohne dass jemand es entschieden hat.
#:
#: Die Schluessel stehen fuer beide Anwendungen in einer Tabelle. Auf einer
#: TESM-Datenbank tut die Zeile fuer ``licenses.write`` nichts, weil sie dort
#: in keiner Gruppe steht -- und umgekehrt. Zwei getrennte Tabellen waeren
#: zwei Stellen, die man pflegen muss.
GETEILTE_RECHTE: dict[str, tuple[str, ...]] = {
# Drei Neustartwege mit drei verschiedenen Folgen.
"devices.execute": (
"device_restart_poe.read",
"device_restart_poe.execute",
"device_restart_ssh.read",
"device_restart_ssh.execute",
"device_restart_rpc.read",
"device_restart_rpc.execute",
),
# Die Switch-Konsole war "Switch ausfuehren".
"switches.execute": ("switch_console.read", "switch_console.execute"),
# Vier Funktionen: aktualisieren, neu starten, Werkzeug installieren --
# und die Client-Konsole, die jetzt bei den Clients steht.
"maintenance.read": (
"servicing_update.read",
"servicing_reboot.read",
"servicing_tool.read",
"device_console.read",
),
"maintenance.execute": (
"servicing_update.read",
"servicing_update.execute",
"servicing_reboot.read",
"servicing_reboot.execute",
"servicing_tool.read",
"servicing_tool.execute",
"device_console.read",
"device_console.execute",
),
# Elf Funktionen an einem Recht.
"dhcp.read": (
"dhcp_subnets.read",
"dhcp_options.read",
"dhcp_reservations.read",
"dhcp_config.read",
"dhcp_service.read",
),
"dhcp.write": (
"dhcp_subnets.read",
"dhcp_subnets.create",
"dhcp_subnets.write",
"dhcp_subnets.delete",
"dhcp_options.read",
"dhcp_options.write",
"dhcp_reservations.read",
"dhcp_reservations.create",
"dhcp_reservations.write",
"dhcp_reservations.delete",
"dhcp_config.read",
"dhcp_config.execute",
),
"dhcp.execute": ("dhcp_service.read", "dhcp_service.execute"),
# Sichern und Einspielen.
"backup.read": ("backup_export.read", "backup_import.read"),
"backup.export": ("backup_export.read", "backup_export.export"),
"backup.secrets": ("backup_export.read", "backup_export.secrets"),
"backup.execute": ("backup_import.read", "backup_import.execute"),
# Lizenzserver: "aendern" hiess widerrufen **oder** erneuern -- zwei
# Eingriffe mit entgegengesetzter Wirkung unter einem Haken.
"licenses.create": ("license_issue.read", "license_issue.execute"),
"licenses.write": (
"license_renew.read",
"license_renew.execute",
"license_revoke.read",
"license_revoke.execute",
),
"licenses.execute": ("license_activation.read", "license_activation.execute"),
}
#: Ein Recht, das **bleibt** und bisher noch etwas anderes mitgab.
#:
#: Der Unterschied zu :data:`GETEILTE_RECHTE` ist keine Formsache: dort ist der
#: alte Schluessel verschwunden und muss ersetzt werden, hier gilt er weiter
#: und bekommt nur Gesellschaft. Beides in eine Tabelle zu werfen hiesse, in
#: der Migration ein Recht zu loeschen, das es noch gibt -- und im Test nicht
#: mehr pruefen zu koennen, welcher Fall gemeint war.
ERWEITERTE_RECHTE: dict[str, tuple[str, ...]] = {
# Die Navigationsreihenfolge lief unter "Systemaktionen ausloesen" mit.
"settings_system.execute": ("settings_nav.read", "settings_nav.write"),
# Einen Host-Schluessel freizugeben hing an den Stammdaten.
"devices.write": ("hostkeys.read", "hostkeys.execute"),
"switches.write": ("hostkeys.read", "hostkeys.execute"),
# Umbenennen war auch "Freigaben zuordnen".
"fileshare.write": (
"fileshare_mappings.read",
"fileshare_mappings.create",
"fileshare_mappings.write",
"fileshare_mappings.delete",
),
# Ausgefuellt ablegen war auch "unterschreiben".
"fileshare_pdf.create": ("fileshare_signature.read", "fileshare_signature.execute"),
}
def neue_schluessel(alt: str) -> tuple[str, ...]:
"""Was aus einem alten Rechteschluessel wird -- eins oder mehrere.
Unbekanntes kommt unveraendert zurueck: ob ein Schluessel gilt, entscheidet
der Rechtebaum, nicht diese Tabelle. Sie benennt nur um.
"""
if alt in GETEILTE_RECHTE:
return GETEILTE_RECHTE[alt]
ressource, punkt, aktion = alt.rpartition(".")
if not punkt:
return (alt,)
neu = UMBENANNTE_AKTIONEN.get(aktion)
selbst = f"{ressource}.{neu}" if neu else alt
# Ein Recht, das bleibt, nimmt mit, was es bisher mitgab -- sonst verliert
# jemand beim Update genau die Funktion, die vorher an diesem Haken hing.
return (selbst, *ERWEITERTE_RECHTE.get(selbst, ()))
def umgeschrieben(rechte: object) -> list[str]:
"""Eine ganze Liste umschreiben, ohne Dubletten und in fester Reihenfolge."""
if not isinstance(rechte, (list, tuple, set, frozenset)):
return []
ergebnis: set[str] = set()
for recht in rechte:
ergebnis.update(neue_schluessel(str(recht)))
return sorted(ergebnis)
__all__ = [
"UMBENANNTE_AKTIONEN",
"GETEILTE_RECHTE",
"ERWEITERTE_RECHTE",
"neue_schluessel",
"umgeschrieben",
]
+123 -32
View File
@@ -1,6 +1,14 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
Das Vokabular heisst seit 2.8.0 durchgehend wie das, was es tut: **Read,
Create, Write, Delete, Execute, Export, Secrets**. Bis dahin hiessen zwei
Aktionen anders (``view``, ``edit``), waehrend die Matrix "Bearbeiten" zeigte
und die Kurzfassung "U" -- dieselbe Aktion in drei Schreibweisen. Der
Kill-Switch eines Bereichs heisst entsprechend ``<bereich>.read``.
:mod:`tesm_core.rbac.legacy_permissions` schreibt alte Schluessel um; die
Migration ``core/4`` tut das fuer bestehende Gruppen.
Das Modell vor 2.0 kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
@@ -15,7 +23,7 @@ Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
``<bereich>.read``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren.
@@ -29,38 +37,68 @@ from typing import Iterable, Mapping, Sequence
class Action(str, Enum):
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen.
VIEW = "view"
Die Namen sind die englischen, weil sie in jedem Rechtesystem dieselben
sind und keiner Uebersetzung bedarf: wer ``write`` liest, weiss es. Zwei
hiessen bis 2.7.x anders (``view``, ``edit``) -- in der Matrix stand
"Bearbeiten" und im Protokoll ``edit``, also zweierlei fuer dasselbe.
:mod:`tesm_core.rbac.legacy_permissions` schreibt die alten Schluessel um.
"""
READ = "read"
CREATE = "create"
EDIT = "edit"
WRITE = "write"
DELETE = "delete"
EXECUTE = "execute"
EXPORT = "export"
SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
ACTION_META: dict[Action, tuple[str, str, str]] = {
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
#: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
#:
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
Action.CREATE: ("C", "Create", "anlegen", "Neue Datensätze anlegen."),
Action.WRITE: (
"W",
"Write",
"ändern",
"Bestehende Datensätze ändern – ohne Anlegen, ohne Löschen.",
),
Action.DELETE: ("D", "Delete", "löschen", "Datensätze löschen bzw. endgültig entfernen."),
Action.EXECUTE: (
"X",
"Execute",
"auslösen",
"Aktionen auslösen (Neustart, Dienste, Aufträge, Import).",
),
Action.EXPORT: (
"E",
"Export",
"mitnehmen",
"Daten aus dem System heraustragen (Datei-Download).",
),
Action.SECRETS: (
"S",
"Geheimnisse",
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
"Secrets",
"Klartext",
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
),
}
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
#: ``read`` ist Voraussetzung fuer alles andere in derselben Zeile. Umgekehrt
#: gilt nichts: wer anlegen darf, darf damit nicht aendern, und wer aendern
#: darf, nicht anlegen. Das ist die Trennung, die man in einer
#: Verwaltungsanwendung am haeufigsten braucht.
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
action: (Action.READ,) for action in Action if action is not Action.READ
}
@@ -74,12 +112,33 @@ class Resource:
hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict)
#: Zu welcher Zeile diese gehoert -- rein fuer die Darstellung.
#:
#: Aus einer Zeile "Clients" sind sechs geworden (Stammdaten, drei
#: Neustartwege, Konsole, Host-Schluessel). Untereinander und alle gleich
#: eingerueckt liest man sie als sechs unabhaengige Dinge; eingerueckt
#: unter ihrer Hauptzeile sieht man, dass sie zusammengehoeren. Auf die
#: Rechte hat das keine Wirkung: jede Zeile bleibt einzeln vergebbar, und
#: die Hauptzeile ist **nicht** Voraussetzung fuer ihre Unterzeilen.
parent: str = ""
#: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = ()
@property
def short_label(self) -> str:
"""Beschriftung ohne den Namen der Hauptzeile.
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
"""
if self.parent and ": " in self.label:
return self.label.split(": ", 1)[1]
return self.label
def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}"
@@ -101,8 +160,9 @@ class Area:
hint: str = ""
@property
def view_key(self) -> str:
return f"{self.key}.view"
def read_key(self) -> str:
"""Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
return f"{self.key}.read"
def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
@@ -128,37 +188,65 @@ class PermissionTree:
self._parent: dict[str, str] = {}
self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {}
self._verwaltung: frozenset[str] | None = None
seen_keys: set[str] = set()
for area in self.areas:
if area.key in seen_keys:
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
seen_keys.add(area.key)
self._by_key[area.key] = area
self._all.append(area.view_key)
self._all.append(area.read_key)
for resource in area.resources:
if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
seen_keys.add(resource.key)
if Action.VIEW not in resource.actions:
if Action.READ not in resource.actions:
raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
"sie ist Voraussetzung fuer jede andere Aktion."
f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
"sie ist Voraussetzung für jede andere Aktion."
)
self._resources[resource.key] = (area, resource)
for permission in resource.permissions():
self._all.append(permission)
self._parent[permission] = area.view_key
self._parent[permission] = area.read_key
# ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]:
return tuple(self._all)
#: Der Bereich, den ein echter Administrator immer ganz hat.
#:
#: Nicht "alles": das Kennzeichen ``is_admin`` gab bisher jede Pruefung
#: frei, auch Geraete, DHCP und Dateifreigaben. Ein Administrator ist aber
#: jemand, der die **Anlage** verwaltet -- Benutzer, Rechte,
#: Einstellungen --, und nicht automatisch jemand, der Maschinen neu
#: startet oder in Freigaben liest. Diese Rechte holt er sich ueber eine
#: Gruppe, und dann steht es im Aenderungsprotokoll.
VERWALTUNGSBEREICH = "administration"
def verwaltungsrechte(self) -> frozenset[str]:
"""Alle Rechte des Verwaltungsbereichs, samt seinem Kill-Switch.
Einmal gerechnet und gemerkt: die Antwort haengt nur am Baum, und der
entsteht beim Import.
"""
if self._verwaltung is None:
bereich = self._by_key.get(self.VERWALTUNGSBEREICH)
if bereich is None:
self._verwaltung = frozenset()
else:
rechte = {bereich.read_key}
for resource in bereich.resources:
rechte.update(resource.permissions())
self._verwaltung = frozenset(rechte)
return self._verwaltung
def area(self, key: str) -> Area | None:
return self._by_key.get(key)
def parent_view_key(self, permission: str) -> str | None:
def parent_read_key(self, permission: str) -> str | None:
return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None:
@@ -185,7 +273,7 @@ class PermissionTree:
def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key:
if permission.endswith(".read") and permission[:-5] in self._by_key:
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
@@ -196,7 +284,10 @@ class PermissionTree:
action = Action(action_value)
except ValueError:
return permission
return f"{resource.label}: {ACTION_META[action][1]}"
# Die deutsche Beschriftung, nicht der englische Name: dieser Text
# landet in Saetzen ("Sie koennen kein Recht vergeben ...") und im
# Aenderungsprotokoll, und die sind deutsch.
return f"{resource.label}: {ACTION_META[action][2]}"
def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
@@ -204,7 +295,7 @@ class PermissionTree:
Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring).
* Jede Aktion impliziert ``view`` derselben Zeile.
* Jede Aktion impliziert ``read`` derselben Zeile -- aber nur diese.
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern.
@@ -224,13 +315,13 @@ class PermissionTree:
result.add(candidate)
for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()):
result.add(area.view_key)
result.add(area.read_key)
return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions)
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
active = {key for key in granted if key.endswith(".read") and key[:-5] in self._by_key}
return {
key
for key in granted
+155 -32
View File
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
from ..db import Database
from ..security import passwords
from .model import Action, PermissionTree, RolePreset
from .model import ACTION_META, Action, PermissionTree, RolePreset
class PermissionDenied(Exception):
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None:
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt.
Ein echter Administrator darf es -- und das ist seit 2.8.0 die bewusst
offene Tuer: sein Kennzeichen gibt ihm den Verwaltungsbereich, die
Funktionen holt er sich ueber eine Gruppe, auch fuer sich selbst. Der
Unterschied zu vorher ist nicht, *ob* er an die Funktionen kommt, sondern
dass es dann eine Zeile im Aenderungsprotokoll gibt und in der Matrix zu
sehen ist, statt unsichtbar im Kennzeichen zu stecken.
Fuer alle anderen bleibt die Regel dicht -- auch fuer die Gruppe
"Administration", die Benutzer und Gruppen pflegt: sie kann keiner Gruppe
ein Recht geben, das sie nicht selbst hat, und sich damit nicht ueber eine
neue Gruppe hochstufen.
"""
if getattr(actor, "is_admin", False):
return
missing = sorted(p for p in added if not actor.has(p))
@@ -132,7 +145,7 @@ def guard_no_amplification(
labels = ", ".join(tree.label_for(p) for p in missing[:6])
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
raise PermissionDenied(
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
)
@@ -160,7 +173,7 @@ def create_group(
if existing["deleted_at"]:
raise ValidationError(
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
"Stellen Sie sie wieder her oder löschen Sie sie endgültig."
)
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
@@ -205,7 +218,7 @@ def update_group(
if name is not None and name.strip() != group.name:
if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
clean = name.strip()
if not clean:
raise ValidationError("Der Gruppenname darf nicht leer sein.")
@@ -224,13 +237,13 @@ def update_group(
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
(description.strip(), _now(), group_id),
)
changes.append("Beschreibung geaendert")
changes.append("Beschreibung geändert")
if permissions is not None:
if group.is_system and not unlock_system:
raise PermissionDenied(
"Die Rechte dieser Systemgruppe sind gesperrt. "
"Sie muessen sie zuerst ausdruecklich freischalten."
"Sie müssen sie zuerst ausdrücklich freischalten."
)
wanted = tree.sanitize(permissions)
added = sorted(set(wanted) - set(group.permissions))
@@ -240,7 +253,7 @@ def update_group(
_write_permissions(conn, group_id, wanted)
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
if added:
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
if removed:
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
@@ -252,7 +265,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
if group is None:
raise ValidationError("Diese Gruppe existiert nicht.")
if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
@@ -315,6 +328,113 @@ def ensure_presets(
return created
def unberuehrte_vorlagen_entfernen(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
arbeitet.
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
jemand hat sie uebernommen.
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
jemand hat sie angepasst, also ist sie seine, nicht unsere.
* Sie ist keine Systemgruppe.
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
"""
entfernt: list[str] = []
gruppen = list_groups(conn)
for preset in presets:
gruppe = next(
(
g
for g in gruppen
if g.preset_key
and g.preset_key == preset.key
and g.name.lower() == preset.name.lower()
),
None,
)
if gruppe is None or gruppe.is_system or gruppe.member_count:
continue
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
continue
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
entfernt.append(gruppe.name)
return entfernt
def funktionsgruppe_anlegen(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
abgelegt hat, sie beim naechsten Aufruf wieder ein.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is not None:
return int(zeile["id"])
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
(preset.name, preset.description, preset.key, _now(), _now()),
)
gruppe = int(cursor.lastrowid or 0)
_write_permissions(conn, gruppe, tree.expand_preset(preset))
return gruppe
def funktionsgruppe_fuer_administratoren(
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
) -> int:
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
einen Fehler, nicht fuer eine Absicht.
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
ist die neue Trennung gedacht.
Gibt die Nummer der Gruppe zurueck.
"""
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[
(int(row["id"]), gruppe)
for row in Database.all(
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
)
],
)
return gruppe
def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
@@ -410,7 +530,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
raise PermissionDenied(
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
"Administratorkonten können nur von Administratoren bearbeitet werden."
)
@@ -420,8 +540,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
return
if count_active_admins(conn, exclude_id=user_id) == 0:
raise PermissionDenied(
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
"Administratorkonto uebrig bleiben."
f"{action} nicht möglich: es muss mindestens ein entsperrtes "
"Administratorkonto übrig bleiben."
)
@@ -482,12 +602,12 @@ def create_user(
if existing["deleted_at"]:
raise ValidationError(
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
"wieder her oder loeschen Sie es endgueltig."
"wieder her oder löschen Sie es endgültig."
)
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
if is_admin and not getattr(actor, "is_admin", False):
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
if auth_source == "local":
problems = policy.check(password, username=clean)
@@ -552,24 +672,30 @@ def update_user(
# ueberschrieben -- die Bearbeitung waere eine Illusion.
raise ValidationError(
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
"aus dem Verzeichnis übernommen und können hier nicht geändert werden."
)
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} geaendert")
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):
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
if not is_admin:
guard_last_admin(conn, user_id, action="Herabstufen")
fields["is_admin"] = int(bool(is_admin))
@@ -577,7 +703,7 @@ def update_user(
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
if is_locked:
guard_last_admin(conn, user_id, action="Sperren")
fields["is_locked"] = int(bool(is_locked))
@@ -595,7 +721,7 @@ def update_user(
after = {int(gid) for gid in group_ids}
if before != after:
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
changes.append("Gruppenzuordnung geaendert")
changes.append("Gruppenzuordnung geändert")
return changes
@@ -613,7 +739,7 @@ def set_password(
raise ValidationError("Dieses Konto existiert nicht.")
if str(row.get("auth_source")) == "ldap":
raise ValidationError(
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
"Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
)
problems = policy.check(new_password, username=str(row["username"]))
if problems:
@@ -631,7 +757,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target)
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
guard_last_admin(conn, user_id, action="Loeschen")
if str(target.get("auth_source")) == "ldap":
@@ -677,7 +803,7 @@ def matrix_state(
granted = set(permissions)
state: dict[str, dict[str, bool]] = {}
for area in tree.areas:
state[area.key] = {"view": area.view_key in granted}
state[area.key] = {"read": area.read_key in granted}
for resource in area.resources:
state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions
@@ -690,7 +816,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
granted = set(permissions)
lines: list[str] = []
for area in tree.areas:
if area.view_key not in granted:
if area.read_key not in granted:
continue
parts: list[str] = []
for resource in area.resources:
@@ -706,12 +832,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
return lines
#: Abgeleitet und nicht abgeschrieben: die Buchstaben stehen in ACTION_META.
#: Die zweite Liste hier hatte "U" fuer Bearbeiten, waehrend die Matrix "W"
#: zeigte -- dieselbe Aktion, zwei Kuerzel, je nachdem wo man hinsah.
ACTION_LETTERS: dict[Action, str] = {
Action.VIEW: "R",
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
action: meta[0] for action, meta in ACTION_META.items()
}
@@ -112,7 +112,7 @@ def protect() -> None:
if not expected or not provided:
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
if not hmac.compare_digest(str(expected), str(provided)):
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
def exempt(view):
@@ -65,10 +65,10 @@ class PasswordPolicy:
if classes < self.require_classes:
problems.append(
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
"(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
)
if normalized.lower() in _FORBIDDEN:
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
problems.append("Dieses Passwort ist zu häufig und damit unsicher.")
if username and len(username) >= 3 and username.lower() in normalized.lower():
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
if len(set(normalized)) <= 3 and normalized:
@@ -58,6 +58,30 @@ class SettingSpec:
return str(raw)
def serialize(self, value: Any) -> str:
"""Ein Wert als Text fuer die Datenbank.
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
beim Einspielen ein eingeschalteter -- auch aus
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
und aus der Navigationsreihenfolge damit eine Zeichenkette.
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
Wert eine Sicherung.
"""
if isinstance(value, str):
if self.kind == "bool":
value = self.parse(value)
elif self.kind == "json":
try:
value = json.loads(value)
except ValueError:
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
pass
if self.kind == "bool":
return "1" if value else "0"
if self.kind == "json":
@@ -67,6 +91,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 +203,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:
@@ -847,6 +847,25 @@
flex: 1 1 12rem;
margin: 0;
}
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
Speichern, nicht zu den Feldern davor. */
.viewer__saveaktionen {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--sp-2);
}
.viewer__saveknoepfe {
display: flex;
gap: var(--sp-2);
}
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
.viewer__saveaktionen .check.is-gefragt {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: var(--r-xs);
}
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
.viewer__feld {
@@ -991,7 +1010,25 @@
text-align: center;
}
.matrix thead th:first-child { text-align: left; }
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 62px; text-align: center; }
/* Kuerzel, englischer Name, deutsche Beschriftung -- untereinander. Nur das
Kuerzel allein war eine Spalte, die man nachschlagen musste; die Legende
steht unter einer Tabelle, die man dafuer erst zu Ende scrollt. */
.matrix__spalte { display: flex; flex-direction: column; align-items: center; line-height: 1.15; }
.matrix__spalte b {
font-family: var(--font-mono);
font-size: var(--fs-sm);
color: var(--text);
font-weight: 650;
}
.matrix__spalte span { font-size: var(--fs-xs); color: var(--text-muted); text-transform: none; }
.matrix__spalte small {
font-size: 10px;
color: var(--text-faint);
text-transform: none;
letter-spacing: 0;
font-weight: 400;
}
.matrix td:not(:first-child), .matrix th:not(:first-child) { width: 72px; text-align: center; }
.matrix tr.matrix__area > td {
background: var(--surface-2);
font-weight: 640;
@@ -1017,6 +1054,46 @@
}
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__zeile { display: flex; align-items: baseline; gap: var(--sp-2); }
.matrix__zeile b { font-weight: 600; }
/* Unterzeile: gehoert zur Zeile darueber. Ohne Einrueckung liest man sechs
unabhaengige Dinge, wo sechs Teile eines Ganzen stehen. */
.matrix tr.matrix__unterzeile > td:first-child { padding-left: var(--sp-6); }
.matrix tr.matrix__unterzeile > td:first-child .matrix__zeile b { font-weight: 500; }
/* Was eine Aktion in dieser Zeile bedeutet -- in der Zeile, nicht im
Titelattribut: mit dem Finger gibt es kein "hover". */
.matrix__bedeutung { color: var(--text-faint); font-size: var(--fs-xs); }
.matrix__bedeutung b {
font-family: var(--font-mono);
color: var(--text-muted);
font-weight: 500;
}
.matrix__alle {
border: 1px solid var(--border);
background: transparent;
color: var(--text-muted);
border-radius: var(--r-sm);
font-size: 10px;
line-height: 1;
padding: 2px 4px;
cursor: pointer;
}
.matrix__alle:hover { color: var(--accent); border-color: var(--accent); }
.matrix__filter {
display: flex;
align-items: baseline;
gap: var(--sp-3);
margin-bottom: var(--sp-3);
flex-wrap: wrap;
}
.matrix__filter-hint { color: var(--text-faint); font-size: var(--fs-xs); }
/* Eine ausgefilterte Zeile ist versteckt, nicht entfernt: ihre Kaestchen
bleiben im Formular, sonst loescht ein Filter beim Speichern Rechte. */
.matrix tr[hidden] { display: none; }
.matrix__legend {
display: flex;
flex-wrap: wrap;
@@ -1384,3 +1461,271 @@
background: transparent;
border-style: dashed;
}
/* -- Unterschrift --------------------------------------------------------- */
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
Feld nicht. */
.viewer__sign {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid var(--border);
background: var(--surface-2);
}
.viewer__signfelder {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.viewer__signpad {
width: 100%;
height: 160px;
border: 1px dashed var(--border-strong, var(--border));
border-radius: var(--r-sm);
background: #fff;
touch-action: none;
cursor: crosshair;
}
.viewer__signaktionen {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
}
.viewer__pdfscroll--setzen {
cursor: crosshair;
}
.viewer__pdfscroll--setzen .page {
outline: 2px dashed var(--accent, #c8791f);
outline-offset: -2px;
}
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
zieht statt die Seite zu scrollen. */
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
haengt darunter und darf die Hoehe nicht mitbestimmen. */
.viewer__unterschrift {
position: absolute;
z-index: 3;
cursor: grab;
touch-action: none;
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
outline: 1px dashed transparent;
outline-offset: 1px;
}
.viewer__unterschrift:hover,
.viewer__unterschrift.is-fassend {
outline-color: var(--accent);
cursor: grabbing;
}
.viewer__unterschrift img {
display: block;
width: 100%;
pointer-events: none;
}
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
spaeter in der Datei steht. */
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
anderes als die Datei. */
.viewer__unterschrifttext {
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
position: absolute;
top: 100%;
left: 0;
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
font-family: Helvetica, Arial, sans-serif;
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
font-size: 8px;
line-height: 1.15;
color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.85);
pointer-events: none;
white-space: nowrap;
width: max-content;
}
.viewer__unterschriftgriff,
.viewer__unterschriftweg {
position: absolute;
width: 16px;
height: 16px;
padding: 0;
border: 1px solid var(--accent);
border-radius: 3px;
background: var(--surface-1);
color: var(--accent);
font-size: 11px;
line-height: 1;
opacity: 0;
cursor: nwse-resize;
}
.viewer__unterschriftweg {
cursor: pointer;
top: -8px;
right: -8px;
}
.viewer__unterschriftgriff {
right: -8px;
bottom: -8px;
}
.viewer__unterschrift:hover .viewer__unterschriftgriff,
.viewer__unterschrift:hover .viewer__unterschriftweg,
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
opacity: 1;
}
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
immer, sonst kaeme man nie an sie heran. */
@media (hover: none) {
.viewer__unterschriftgriff,
.viewer__unterschriftweg {
opacity: 1;
}
}
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
Kennzeichnung nicht auch noch die Bedienung blockieren. */
.viewer__pdf--unveraendert {
box-shadow: inset 0 0 0 3px var(--success);
}
.viewer__pdf--unveraendert::after,
.viewer__pdf--veraendert::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.viewer__pdf--unveraendert::after {
background: rgba(47, 158, 68, 0.1);
}
.viewer__pdf--veraendert {
box-shadow: inset 0 0 0 3px var(--danger);
}
.viewer__pdf--veraendert::after {
background: rgba(201, 42, 42, 0.08);
}
.viewer__pruefung {
margin: 0;
padding: 6px 12px;
font-size: var(--fs-xs);
border-top: 1px solid var(--border);
}
.viewer__pruefung--gut {
color: var(--success);
background: rgba(47, 158, 68, 0.12);
}
.viewer__pruefung--schlecht {
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;
}
@@ -109,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
const close = document.createElement("button");
close.type = "button";
close.className = "toast__close";
close.setAttribute("aria-label", "Meldung schliessen");
close.setAttribute("aria-label", "Meldung schließen");
close.textContent = "×";
toast.append(body, close);
toastStack.appendChild(toast);
@@ -293,7 +293,7 @@ function initConfirmations() {
event.preventDefault();
event.stopPropagation();
pending = trigger;
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
textNode.textContent = trigger.dataset.confirm;
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
@@ -440,6 +440,56 @@ function initPermissionMatrix() {
});
};
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
haette nichts gebracht. */
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
// Schirm stehen -- und merkt es erst nach dem Speichern.
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
const felder = (bereich) =>
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
table.addEventListener("click", (event) => {
const ziel = event.target;
if (!(ziel instanceof HTMLElement)) return;
const zeilenknopf = ziel.closest("[data-matrix-row]");
if (zeilenknopf) {
event.preventDefault();
const row = zeilenknopf.closest("tr");
if (!row) return;
const kaestchen = felder(row);
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
return;
}
const spaltenknopf = ziel.closest("[data-matrix-column]");
if (spaltenknopf) {
event.preventDefault();
const aktion = spaltenknopf.dataset.matrixColumn;
const bereich = spaltenknopf.dataset.area;
const kaestchen = $$(
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
table
).filter(sichtbar);
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
kaestchen.forEach((box) => {
box.checked = !alleGesetzt;
box.dispatchEvent(new Event("change", { bubbles: true }));
});
}
});
$$("[data-area-switch]", table).forEach((master) => {
const areaKey = master.dataset.areaSwitch;
syncArea(areaKey);
@@ -451,8 +501,12 @@ function initPermissionMatrix() {
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
const row = box.closest("tr");
if (!row) return;
const viewBox = $('input[data-action="view"]', row);
if (box.dataset.action === "view") {
// "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
// tut. Mit dem alten Namen fand diese Zeile das Kaestchen nicht mehr --
// die Matrix haette sich weiter bedienen lassen und dabei die Regel
// "jede Aktion setzt Ansehen voraus" stillschweigend fallen gelassen.
const viewBox = $('input[data-action="read"]', row);
if (box.dataset.action === "read") {
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
if (!box.checked) {
$$('input[type="checkbox"][name]', row).forEach((other) => {
@@ -469,6 +523,30 @@ function initPermissionMatrix() {
master.dispatchEvent(new Event("change"));
}
});
const suche = $("[data-matrix-filter]");
const zaehler = $("[data-matrix-count]");
if (suche) {
const zeilen = $$("tr[data-suchtext]", table);
const filtern = () => {
const begriff = suche.value.trim().toLowerCase();
let sichtbar = 0;
zeilen.forEach((row) => {
const passt = !begriff || (row.dataset.suchtext || "").includes(begriff);
// "hidden" und nicht aus dem Formular nehmen: ein Filter, der
// Kaestchen entfernt, loescht beim Speichern Rechte.
row.hidden = !passt;
if (passt) sichtbar += 1;
});
if (zaehler) {
zaehler.textContent = begriff
? `${sichtbar} von ${zeilen.length} Zeilen`
: `${zeilen.length} Zeilen`;
}
};
suche.addEventListener("input", filtern);
filtern();
}
});
}
@@ -497,14 +575,14 @@ function initAutoRefresh() {
nameField.value = entry.name || "";
dnField.value = entry.dn || "";
results.hidden = true;
show(`Uebernommen: ${entry.name || entry.dn}`);
show(`Übernommen: ${entry.name || entry.dn}`);
};
const search = async () => {
const term = query.value.trim();
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
button.disabled = true;
show("Suche laeuft ...");
show("Suche läuft ...");
results.innerHTML = "";
try {
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
@@ -532,10 +610,10 @@ function initAutoRefresh() {
results.appendChild(row);
});
results.hidden = false;
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
} catch (error) {
results.hidden = true;
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
show("Die Suche ist fehlgeschlagen. Läuft die Verbindung zum Verzeichnis?");
} finally {
button.disabled = false;
}
@@ -720,7 +798,7 @@ function initClipboard() {
timeout: 2500,
});
} catch {
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
}
});
}
@@ -169,7 +169,7 @@ async function fotoAlsJpeg(datei) {
const daten = await new Promise((fertig, schiefgegangen) => {
const leser = new FileReader();
leser.onload = () => fertig(String(leser.result));
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen."));
leser.onerror = () => schiefgegangen(new Error("Die Datei ließ sich nicht lesen."));
leser.readAsDataURL(datei);
});
bild = await new Promise((fertig, schiefgegangen) => {
@@ -207,7 +207,7 @@ async function pdflibLaden(adresse) {
skript.onload = () => fertig(window.PDFLib);
skript.onerror = () => {
pdflibGeladen = null;
schiefgegangen(new Error("pdf-lib liess sich nicht laden."));
schiefgegangen(new Error("pdf-lib ließ sich nicht laden."));
};
document.head.append(skript);
});
@@ -228,7 +228,12 @@ async function pdflibLaden(adresse) {
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
* ein PDF ohne Formular -- nichts mehr zu verstellen.
*/
async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } = {}) {
async function nachbearbeiten(
bytes,
bilder,
adresse,
{ abschliessen = false, unterschriften = [], zeuge = "" } = {}
) {
const PDFLib = await pdflibLaden(adresse);
const dokument = await PDFLib.PDFDocument.load(bytes);
const formular = dokument.getForm();
@@ -258,11 +263,77 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
try {
formular.removeField(knopf);
} catch (fehler) {
console.warn("Bildknopf liess sich nicht entfernen", fehler);
console.warn("Bildknopf ließ sich nicht entfernen", fehler);
}
}
}
// Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das
// Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben
// der Unterschrift auch der Name in Druckbuchstaben.
if (unterschriften.length) {
const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica);
for (const eintrag of unterschriften) {
const seite = dokument.getPage(eintrag.seite - 1);
const png = await dokument.embedPng(eintrag.bytes);
const breite = eintrag.breite;
const hoehe = (png.height / png.width) * breite;
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
const zeilen = [`${eintrag.name} | ${eintrag.zeit}`];
// Der Zeugenvermerk gehoert zum Dokument, nicht zu jeder Unterschrift:
// unter drei Unterschriften stand dreimal derselbe Satz. Sichtbar
// erscheint er deshalb nur unter der ersten -- fest steht er ohnehin in
// den Dokumenteigenschaften und im Aenderungsprotokoll.
if (zeuge && eintrag === unterschriften[0]) {
zeilen.push(`In TESM abgelegt durch ${zeuge}`);
}
// Die Schrift waechst mit der Unterschrift: wer sie kleiner zieht, will
// den ganzen Block kleiner haben, nicht eine Zeichnung mit einer
// Bildunterschrift in Originalgroesse daneben.
const groesse = schriftgroesse(breite);
// Der Strich ist so lang wie die laengste Zeile darunter -- nicht so
// breit wie die Zeichnung. Eine kurze Unterschrift ueber einem langen
// Namen sah sonst aus wie zwei Dinge, die nicht zusammengehoeren.
const textbreite = Math.max(
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
);
let unten = eintrag.y - UNTERSCHRIFT.textY;
for (const zeile of zeilen) {
unten -= groesse * UNTERSCHRIFT.zeile;
seite.drawText(zeile, {
x: eintrag.x,
y: unten,
size: groesse,
font: schrift,
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 - UNTERSCHRIFT.strichY },
end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
thickness: UNTERSCHRIFT.strich,
color: PDFLib.rgb(0.15, 0.15, 0.15),
});
}
}
if (unterschriften.length) {
// In den Dokumenteigenschaften -- dort sucht man so etwas, und dort steht
// es einmal statt unter jeder Unterschrift. Jeder Betrachter zeigt das
// an, auch ausserhalb von TESM.
const namen = unterschriften.map((eintrag) => eintrag.name);
const wann = unterschriften[unterschriften.length - 1].zeit;
dokument.setAuthor(namen.join(", "));
dokument.setSubject(
`Unterschrieben am ${wann}` + (zeuge ? ` | in TESM abgelegt durch ${zeuge}` : "")
);
dokument.setProducer("TESM");
dokument.setModificationDate(new Date());
}
if (abschliessen) {
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
// gewollt: so kann niemand mehr etwas verstellen.
@@ -271,6 +342,177 @@ async function nachbearbeiten(bytes, bilder, adresse, { abschliessen = false } =
return dokument.save();
}
/** Masse des Textblocks unter einer Unterschrift -- in PDF-Punkten.
*
* Die Vorschau rechnet dieselben Zahlen in Pixel um. Standen sie zweimal im
* Code, liefen sie auseinander: die Marke haengt mit ihrer **Unterkante** am
* Ankerpunkt, das PDF setzt dort die **Unterkante des Bildes** und schreibt
* den Text darunter. Beim Setzen sass der Block deshalb um die Texthoehe
* hoeher als in der gespeicherten Datei.
*/
const UNTERSCHRIFT = {
//: Mitte des Strichs unter der Bildkante.
strichY: 1.5,
//: Staerke des Strichs.
strich: 0.9,
//: Bildkante bis zur ersten Grundlinie, vor dem Zeilenabstand.
textY: 2,
//: Zeilenabstand als Vielfaches der Schriftgroesse.
zeile: 1.15,
//: Oberkante der Zeilenbox bis zur Grundlinie, in "em" -- nur ein
//: Rueckfall: gewoehnlich misst "grundlinie()" das am echten Text nach.
//: (Helvetica/Arial: Ascender 0,905 plus halber Durchschuss.)
grundlinie: 0.9215,
};
//: Gemessene Grundlinien, je dargestellter Schriftgroesse in Pixeln.
const grundlinien = new Map();
/** Abstand von der Oberkante der Zeilenbox zur Grundlinie, in "em".
*
* Gemessen statt geraten, und bei genau der Pixelgroesse, die auch auf dem
* Schirm steht. Zwei Gruende: welche Datei hinter "Helvetica" steckt,
* entscheidet das Geraet -- auf einem iPad eine andere als auf einem
* Windows-Rechner, mit anderen Oberlaengen. Und der Browser rundet die
* Schriftmasse auf ganze Pixel: bei acht Punkt Schrift verschiebt das die
* Zeile um einen halben Pixel gegenueber der Rechnung. pdf-lib rundet nicht.
*
* Eine leere Inline-Box sitzt auf der Grundlinie -- ihre Lage verraet also,
* wo der Text steht.
*/
function grundlinie(px) {
const schluessel = px.toFixed(1);
const gemerkt = grundlinien.get(schluessel);
if (gemerkt !== undefined) return gemerkt;
let anteil = UNTERSCHRIFT.grundlinie;
try {
const probe = document.createElement("div");
probe.className = "viewer__unterschrifttext";
probe.style.cssText =
"position:absolute;left:-9999px;top:0;visibility:hidden;border:0;padding:0;";
probe.style.fontSize = `${px}px`;
probe.style.lineHeight = String(UNTERSCHRIFT.zeile);
const lot = document.createElement("span");
lot.style.cssText = "display:inline-block;width:0;height:0;";
probe.append(document.createTextNode("Hg"), lot);
document.body.append(probe);
const oben = probe.getBoundingClientRect().top;
const auf = lot.getBoundingClientRect().top;
probe.remove();
if (auf > oben) anteil = (auf - oben) / px;
} catch (fehler) {
console.warn("Grundlinie nicht messbar -- Rueckfall auf Helvetica", fehler);
}
grundlinien.set(schluessel, anteil);
return anteil;
}
/** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
*
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
* Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
* Schirm steht, was spaeter im PDF steht.
*/
function schriftgroesse(breite) {
return Math.max(5, Math.min(10, breite / 18));
}
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
*
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
*/
function unterschriftAlsPng(leinwand, kasten) {
const rand = 6;
const links = Math.max(0, Math.floor(kasten.x1 - rand));
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
const ziel = document.createElement("canvas");
ziel.width = Math.max(1, breite);
ziel.height = Math.max(1, hoehe);
ziel
.getContext("2d")
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
// Loch, das man fuer eine Unterschrift nicht aufmacht.
const adresse = ziel.toDataURL("image/png");
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
const bytes = new Uint8Array(roh.length);
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
}
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
*
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
* Seite, statt einen Strich zu zeichnen.
*/
function unterschriftPad(leinwand) {
const stift = leinwand.getContext("2d");
let kasten = null;
let zeichnet = false;
const punkt = (event) => {
const rechteck = leinwand.getBoundingClientRect();
return {
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
};
};
const merken = ({ x, y }) => {
kasten = kasten
? {
x1: Math.min(kasten.x1, x),
y1: Math.min(kasten.y1, y),
x2: Math.max(kasten.x2, x),
y2: Math.max(kasten.y2, y),
}
: { x1: x, y1: y, x2: x, y2: y };
};
stift.lineWidth = 2.5;
stift.lineCap = "round";
stift.lineJoin = "round";
stift.strokeStyle = "#111";
leinwand.addEventListener("pointerdown", (event) => {
event.preventDefault();
leinwand.setPointerCapture(event.pointerId);
zeichnet = true;
const p = punkt(event);
merken(p);
stift.beginPath();
stift.moveTo(p.x, p.y);
});
leinwand.addEventListener("pointermove", (event) => {
if (!zeichnet) return;
event.preventDefault();
const p = punkt(event);
merken(p);
stift.lineTo(p.x, p.y);
stift.stroke();
});
const beenden = () => {
zeichnet = false;
};
leinwand.addEventListener("pointerup", beenden);
leinwand.addEventListener("pointercancel", beenden);
leinwand.addEventListener("pointerleave", beenden);
return {
leeren: () => {
stift.clearRect(0, 0, leinwand.width, leinwand.height);
kasten = null;
},
leer: () => kasten === null,
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
};
}
/* -- PDF ------------------------------------------------------------------- */
let pdfjsGeladen = null;
@@ -448,6 +690,206 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
});
await bildknoepfeAufsetzen();
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
Unterschriftszeilen die falsche Annahme. */
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp zwei
// hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein Stempel,
// der die halbe Seite einnimmt -- das Seitenverhaeltnis kommt aus dem
// Zuschnitt der Zeichnung, nicht aus einer Vorlage. Nach dem Setzen laesst
// sich beides mit dem Griff nachstellen, nach oben bis zu dieser Grenze.
const MAX_BREITE = 150;
const MAX_HOEHE = 50;
const unterschriften = [];
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
const neuzeichnen = new Set();
let wartendeUnterschrift = null;
let zeugenzeile = () => "";
const platzieren = async (event) => {
if (!wartendeUnterschrift) return;
const seitenEl = event.target.closest(".page");
if (!seitenEl) return;
event.preventDefault();
const nummer = Number(seitenEl.dataset.pageNumber || 0);
if (!nummer) return;
const rechteck = seitenEl.getBoundingClientRect();
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
const seite = await dokument.getPage(nummer);
const blick = seite.getViewport({ scale: 1 });
let breite = Math.min(MAX_BREITE, blick.width * 0.3);
let hoehe = (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
if (hoehe > MAX_HOEHE) {
breite *= MAX_HOEHE / hoehe;
hoehe = MAX_HOEHE;
}
const eintrag = {
...wartendeUnterschrift,
seite: nummer,
breite,
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
x: Math.max(4, anteilX * blick.width - breite / 2),
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
};
unterschriften.push(eintrag);
behaelter.classList.remove("viewer__pdfscroll--setzen");
wartendeUnterschrift = null;
// Sichtbar machen, wo sie gelandet ist -- und beweglich: der erste Tipp
// trifft selten genau die Linie, und der Textblock gehoert mit auf die
// richtige Stelle.
marke_setzen(seitenEl, eintrag, blick, zeugenzeile());
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
};
/** Baut die bewegliche Marke einer gesetzten Unterschrift. */
const marke_setzen = (seitenEl, eintrag, blick, zeuge) => {
const marke = document.createElement("div");
marke.className = "viewer__unterschrift";
const bild = document.createElement("img");
bild.src = eintrag.vorschau;
bild.alt = "";
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
// Vorschau standen beide hintereinander, getrennt durch einen
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
// flacher aus als das Ergebnis.
const text = document.createElement("div");
text.className = "viewer__unterschrifttext";
const namenszeile = document.createElement("div");
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
text.append(namenszeile);
if (zeuge && eintrag === unterschriften[0]) {
const zeugenzeile = document.createElement("div");
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
text.append(zeugenzeile);
}
const griff = document.createElement("button");
griff.type = "button";
griff.className = "viewer__unterschriftgriff";
griff.title = "Größe ändern";
griff.setAttribute("aria-label", "Unterschrift vergroessern oder verkleinern");
const weg = document.createElement("button");
weg.type = "button";
weg.className = "viewer__unterschriftweg";
weg.textContent = "x";
weg.title = "Unterschrift entfernen";
weg.setAttribute("aria-label", "Unterschrift entfernen");
marke.append(bild, text, griff, weg);
seitenEl.append(marke);
const zeichnen = () => {
// Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
// Ankerpunkt, so wie im PDF. Der Text haengt darunter (siehe
// Gestaltung: absolut, "top: 100%") und zaehlt nicht zur Hoehe --
// sonst saesse der Block in der Vorschau um die Texthoehe zu hoch.
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
// Alles Weitere wird gerechnet, nicht vom Stylesheet abgeleitet:
// Punkte mal Pixel-je-Punkt der gerade dargestellten Seite. Ueber
// Container-Einheiten ("cqw") ging das auch -- aber nur dort, wo der
// Browser sie kennt; wo nicht, erbte der Block die Schriftgroesse der
// Seite, und der Text war um die Haelfte zu breit.
const proPunkt = seitenEl.getBoundingClientRect().width / blick.width;
const groesse = schriftgroesse(eintrag.breite);
const inPixeln = groesse * proPunkt;
const oben = UNTERSCHRIFT.strichY - UNTERSCHRIFT.strich / 2;
text.style.fontSize = `${inPixeln}px`;
text.style.lineHeight = String(UNTERSCHRIFT.zeile);
text.style.marginTop = `${oben * proPunkt}px`;
text.style.borderTopWidth = `${UNTERSCHRIFT.strich * proPunkt}px`;
// Rahmenbreiten rundet der Browser auf ganze Geraetepixel: aus 1,21
// wird 1. Mit dem gerechneten Wert sass die erste Zeile deshalb einen
// Fuenftel Punkt zu hoch -- also mit dem tatsaechlich benutzten rechnen.
const strich =
(parseFloat(getComputedStyle(text).borderTopWidth) ||
UNTERSCHRIFT.strich * proPunkt) / proPunkt;
// Oberkante des Strichs, die Strichstaerke, dann so viel Luft, dass die
// erste Grundlinie dort sitzt, wo pdf-lib sie setzt: textY plus einen
// Zeilenabstand unter der Bildkante.
const innen =
UNTERSCHRIFT.textY +
UNTERSCHRIFT.zeile * groesse -
oben -
strich -
grundlinie(inPixeln) * groesse;
text.style.paddingTop = `${Math.max(0, innen) * proPunkt}px`;
};
// Beim Zoomen aendert sich Pixel-je-Punkt -- dann neu rechnen.
neuzeichnen.add(zeichnen);
zeichnen();
weg.addEventListener("click", (event) => {
event.stopPropagation();
const stelle = unterschriften.indexOf(eintrag);
if (stelle >= 0) unterschriften.splice(stelle, 1);
neuzeichnen.delete(zeichnen);
marke.remove();
});
// Verschieben und Skalieren teilen sich einen Zeiger: beim Griff aendert
// sich die Breite, sonst die Lage.
const fassen = (event) => {
if (event.target === weg) return;
event.preventDefault();
event.stopPropagation();
const skalieren = event.target === griff;
const rechteck = seitenEl.getBoundingClientRect();
const proPunkt = rechteck.width / blick.width;
const startX = event.clientX;
const startY = event.clientY;
const startBreite = eintrag.breite;
const start = { x: eintrag.x, y: eintrag.y };
const obergrenze = Math.min(MAX_BREITE, blick.width * 0.3);
marke.setPointerCapture(event.pointerId);
marke.classList.add("is-fassend");
const bewegen = (zug) => {
if (skalieren) {
const breite = startBreite + (zug.clientX - startX) / proPunkt;
eintrag.breite = Math.max(40, Math.min(obergrenze, breite));
} else {
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
eintrag.y = Math.max(
// Platz fuer die beiden Textzeilen unter der Zeichnung.
UNTERSCHRIFT.textY + 2 * UNTERSCHRIFT.zeile * schriftgroesse(eintrag.breite),
start.y - (zug.clientY - startY) / proPunkt
);
}
zeichnen();
};
const loslassen = () => {
marke.classList.remove("is-fassend");
marke.removeEventListener("pointermove", bewegen);
marke.removeEventListener("pointerup", loslassen);
marke.removeEventListener("pointercancel", loslassen);
};
marke.addEventListener("pointermove", bewegen);
marke.addEventListener("pointerup", loslassen);
marke.addEventListener("pointercancel", loslassen);
};
marke.addEventListener("pointerdown", fassen);
};
behaelter.addEventListener("click", (event) => {
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
});
// Nach dem Zoomen stehen die Marken anteilig richtig, aber die Schrift
// haengt an der Pixelgroesse der Seite -- die ist jetzt eine andere.
eventBus.on("scalechanging", () => {
for (const zeichnen of neuzeichnen) {
try {
zeichnen();
} catch (fehler) {
console.error("Unterschrift neu vermessen", fehler);
}
}
});
return {
hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -463,12 +905,23 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
*/
speichern: async (pdflibAdresse, { abschliessen = false } = {}) => {
speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
const bytes = await dokument.saveDocument();
// Ohne Bild und ohne Abschluss braucht es pdf-lib gar nicht.
if (!bilder.size && !abschliessen) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, { abschliessen });
// Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, {
abschliessen,
unterschriften,
zeuge,
});
},
/** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */
unterschriftAnnehmen: (daten, zeuge = "") => {
wartendeUnterschrift = daten;
zeugenzeile = () => zeuge;
behaelter.classList.add("viewer__pdfscroll--setzen");
},
unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })),
/** Wurde ueberhaupt etwas eingetragen? */
veraendert: () => {
const s = dokument.annotationStorage;
@@ -505,10 +958,49 @@ export function initFilePreview(wurzel = document) {
const ordnerFeld = stueck("ordner");
const nameFeld = stueck("name");
const ersetzenFeld = stueck("ersetzen");
const ersetzenKasten = stueck("ersetzenfeld");
const pruefzeile = stueck("pruefung");
// Die Ordner der geoeffneten Freigabe kommen mit der Seite; die einer
// anderen holt das Auswahlfeld bei Bedarf nach.
const eigeneOrdner = () => {
try {
return JSON.parse(rumpf.dataset.viewerOrdnerliste || "[]");
} catch {
return [];
}
};
const jetzt = stueck("jetzt");
const abbrechen = stueck("abbrechen");
const modusFeld = stueck("modus");
const zielFeld = stueck("ziel");
const unterschreibenKnopf = stueck("unterschreiben");
const unterschriftPanel = stueck("unterschriftpanel");
const vornameFeld = stueck("vorname");
const nachnameFeld = stueck("nachname");
const padLeinwand = stueck("signpad");
const padLeeren = stueck("signleeren");
const padSetzen = stueck("signsetzen");
const padAbbrechen = stueck("signabbrechen");
const padHinweis = stueck("signhinweis");
const basis = rumpf.dataset.viewerPdfjs || "";
// Der Anmeldename, nicht der volle Name: er ist eindeutig, und genau das
// soll die Zeile belegen.
const konto = rumpf.dataset.viewerKonto || "";
const bezeugen = rumpf.dataset.viewerZeuge === "1";
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
// soll nicht das Datum unter einer Unterschrift bestimmen.
const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now();
const geladenSeit = Date.now();
const jetztText = () => {
const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit));
return zeitpunkt.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
// soll nicht als leere Flaeche enden.
@@ -516,7 +1008,7 @@ export function initFilePreview(wurzel = document) {
if (bildBuehne.hidden || !bild.getAttribute("src")) return;
if (hinweis) {
hinweis.textContent =
"Dieses Bildformat kann Ihr Browser nicht anzeigen -- bitte herunterladen.";
"Dieses Bildformat kann Ihr Browser nicht anzeigen – bitte herunterladen.";
}
});
@@ -535,9 +1027,13 @@ export function initFilePreview(wurzel = document) {
rahmen.hidden = art !== "text";
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;
};
const aufraeumen = () => {
pruefungZuruecksetzen();
bild.removeAttribute("src");
bilder.zuruecksetzen();
rahmen.src = "about:blank";
@@ -555,6 +1051,61 @@ export function initFilePreview(wurzel = document) {
aktuell = null;
};
/* Unterschrieben und unveraendert? Die Antwort kommt aus dem
Aenderungsprotokoll: dort steht die Pruefsumme der abgelegten Fassung.
Stimmt sie, bekommt die Seite einen gruenen Schleier -- man soll es sehen,
bevor man liest, nicht danach. */
const pruefungZuruecksetzen = () => {
pdfBehaelter.classList.remove("viewer__pdf--unveraendert", "viewer__pdf--veraendert");
if (pruefzeile) {
pruefzeile.hidden = true;
pruefzeile.textContent = "";
pruefzeile.className = "viewer__pruefung";
}
};
const pruefen = async (adresse) => {
pruefungZuruecksetzen();
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) {
pruefzeile.hidden = false;
pruefzeile.className = "viewer__pruefung viewer__pruefung--gut";
const namen = (stand.namen || []).join(", ");
pruefzeile.textContent =
`Unverändert seit der Unterschrift${namen ? ` von ${namen}` : ""}` +
` | Prüfsumme stimmt mit dem Änderungsprotokoll überein.`;
}
} else if (stand.status === "veraendert") {
pdfBehaelter.classList.add("viewer__pdf--veraendert");
if (pruefzeile) {
pruefzeile.hidden = false;
pruefzeile.className = "viewer__pruefung viewer__pruefung--schlecht";
pruefzeile.textContent =
"Diese Datei wurde nach der Unterschrift verändert – die Prüfsumme " +
"weicht vom Änderungsprotokoll ab.";
}
}
} catch (fehler) {
// Keine Aussage ist besser als eine falsche: ohne Antwort bleibt die
// Seite schlicht ungekennzeichnet.
console.error("Signaturpruefung", fehler);
}
};
const oeffnen = async (ausloeser) => {
aufraeumen();
const art = ausloeser.dataset.fillKind || "text";
@@ -570,6 +1121,7 @@ export function initFilePreview(wurzel = document) {
return;
}
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
pruefen(ausloeser.dataset.fillPruefung || "");
try {
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
formulare: rumpf.dataset.viewerFormulare === "1",
@@ -586,7 +1138,7 @@ export function initFilePreview(wurzel = document) {
// Browsers die einzige Spur, die man hat.
const grund = String(fehler?.message || fehler || "").slice(0, 120);
hinweis.textContent =
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." +
"Die Formularansicht ließ sich nicht laden – es bleibt bei der einfachen Anzeige." +
(grund ? ` (${grund})` : "");
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
@@ -620,18 +1172,92 @@ export function initFilePreview(wurzel = document) {
if (!aktuell) return;
const teile = aktuell.pfad.split("/");
const datei = teile.pop() || "formular.pdf";
ordnerFeld.value = teile.join("/");
ordnerFuellen(eigeneOrdner(), teile.join("/"));
// 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.
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-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, "");
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");
};
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
const panelZeigen = (offen) => {
if (!unterschriftPanel) return;
unterschriftPanel.hidden = !offen;
if (offen) {
pad?.leeren();
if (padHinweis) padHinweis.textContent = "";
vornameFeld?.focus();
}
};
// Gesetzt heisst fertig zum Ablegen: die Zeile geht von selbst auf, statt
// dass man den Knopf daneben sucht.
pdfScroll?.addEventListener("unterschrieben", () => {
if (!zeile || !zeile.hidden) return;
speichern?.click();
});
unterschreibenKnopf?.addEventListener("click", () => {
if (!pdf) return;
panelZeigen(unterschriftPanel?.hidden !== false);
});
padLeeren?.addEventListener("click", () => pad?.leeren());
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
padSetzen?.addEventListener("click", () => {
if (!pdf || !pad) return;
const name = [vornameFeld?.value, nachnameFeld?.value]
.map((wert) => (wert || "").trim())
.filter(Boolean)
.join(" ");
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
if (!name) {
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
vornameFeld?.focus();
return;
}
const zeichnung = pad.alsPng();
if (!zeichnung) {
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
return;
}
pdf.unterschriftAnnehmen(
{ ...zeichnung, name, zeit: jetztText() },
bezeugen ? konto : ""
);
panelZeigen(false);
window.tesm.showToast(
"Jetzt auf die Stelle im Dokument tippen – danach lässt sie sich noch "
+ "verschieben und in der Größe anpassen.",
"info"
);
});
speichern?.addEventListener("click", () => {
if (!pdf || !aktuell) return;
vorschlagen();
zeile.hidden = false;
if (modusFeld && pdf.unterschriften().length) {
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
// etwas anderes behaupten.
modusFeld.value = "fertig";
modusFeld.disabled = true;
}
nameFeld.focus();
nameFeld.select();
});
@@ -640,6 +1266,46 @@ export function initFilePreview(wurzel = document) {
zeile.hidden = true;
});
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
/** Fuellt die Ordnerauswahl -- erster Eintrag ist immer die Wurzel. */
const ordnerFuellen = (ordner, ausgewaehlt = "") => {
if (!ordnerFeld) return;
const wurzel = document.createElement("option");
wurzel.value = "";
wurzel.textContent = "Wurzelverzeichnis der Freigabe";
const eintraege = [wurzel];
for (const name of ordner) {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
eintraege.push(option);
}
// Der Ordner der Vorlage kann tiefer liegen, als die Liste reicht.
if (ausgewaehlt && !ordner.includes(ausgewaehlt)) {
const option = document.createElement("option");
option.value = ausgewaehlt;
option.textContent = ausgewaehlt;
eintraege.splice(1, 0, option);
}
ordnerFeld.replaceChildren(...eintraege);
ordnerFeld.value = ausgewaehlt;
};
zielFeld?.addEventListener("change", async () => {
const vorlage = zielFeld.dataset.urlVorlage || "";
if (!vorlage) return;
try {
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
ordnerFuellen(antwort.folders || []);
} catch (fehler) {
// Kein Beinbruch: dann bleibt es bei der Wurzel.
console.error("Ordnerliste", fehler);
ordnerFuellen([]);
}
});
jetzt?.addEventListener("click", async () => {
if (!pdf || !aktuell) return;
const ziel = speichern.dataset.url;
@@ -647,13 +1313,20 @@ export function initFilePreview(wurzel = document) {
const vorher = jetzt.textContent;
jetzt.textContent = "Speichern ...";
try {
const gesetzt = pdf.unterschriften();
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
abschliessen: modusFeld?.value === "fertig",
// Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der
// Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift
// wert.
abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0,
zeuge: bezeugen ? konto : "",
});
const daten = new FormData();
daten.append("path", aktuell.pfad);
daten.append("ordner", ordnerFeld.value.trim());
daten.append("name", nameFeld.value.trim());
if (zielFeld?.value) daten.append("ziel", zielFeld.value);
if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt));
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
daten.append(
"datei",
@@ -669,6 +1342,12 @@ export function initFilePreview(wurzel = document) {
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
console.error("Formular speichern", fehler);
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
// Genau dieser Fall hat eine Antwort auf der Zeile -- dann zeigen wir
// auch darauf, statt es bei einer Meldung zu belassen.
if (fehler.status === 409) {
ersetzenKasten?.classList.add("is-gefragt");
ersetzenFeld?.focus();
}
} finally {
jetzt.disabled = false;
jetzt.textContent = vorher;
+6 -6
View File
@@ -78,7 +78,7 @@ class Result:
UNAVAILABLE = Result(
ok=False,
stderr=(
"Systemaktionen sind in dieser Umgebung nicht verfuegbar "
"Systemaktionen sind in dieser Umgebung nicht verfügbar "
"(Helfer nicht installiert oder kein Linux-Host)."
),
code=127,
@@ -132,16 +132,16 @@ class SysOps:
False,
"",
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
"kann der Dienst den Helfer nicht aufrufen -- keine Systemaktion "
"kann der Dienst den Helfer nicht aufrufen – keine Systemaktion "
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
"'systemctl daemon-reload && systemctl restart' ausfuehren.",
"'systemctl daemon-reload && systemctl restart' ausführen.",
result.code,
)
if not result.ok and "a password is required" in result.output.lower():
return Result(
False,
"",
"sudo verlangt ein Passwort. Die sudoers-Regel fuer den Helfer fehlt oder "
"sudo verlangt ein Passwort. Die sudoers-Regel für den Helfer fehlt oder "
"greift nicht.",
result.code,
)
@@ -177,7 +177,7 @@ class SysOps:
check=False,
)
except subprocess.TimeoutExpired:
return Result(False, stderr=f"Zeitlimit ueberschritten ({verb}).", code=124)
return Result(False, stderr=f"Zeitlimit überschritten ({verb}).", code=124)
except OSError as exc:
return Result(False, stderr=str(exc), code=126)
return Result(
@@ -210,7 +210,7 @@ class SysOps:
check=False,
)
except subprocess.TimeoutExpired:
return Result(False, stderr="Zeitlimit ueberschritten.", code=124)
return Result(False, stderr="Zeitlimit überschritten.", code=124)
except OSError as exc:
return Result(False, stderr=str(exc), code=126)
return Result(
@@ -136,24 +136,24 @@ def validate_static(
try:
prefix_length = int(prefix)
except (TypeError, ValueError) as exc:
raise ValueError("Die Praefixlaenge muss eine Zahl sein (z. B. 24).") from exc
raise ValueError("Die Präfixlänge muss eine Zahl sein (z. B. 24).") from exc
if not 1 <= prefix_length <= 32:
raise ValueError("Die Praefixlaenge muss zwischen 1 und 32 liegen.")
raise ValueError("Die Präfixlänge muss zwischen 1 und 32 liegen.")
try:
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
except ValueError as exc:
raise ValueError(f"{address!r} ist keine gueltige IPv4-Adresse.") from exc
raise ValueError(f"{address!r} ist keine gültige IPv4-Adresse.") from exc
gateway_clean = (gateway or "").strip()
if gateway_clean:
try:
gateway_ip = ipaddress.ip_address(gateway_clean)
except ValueError as exc:
raise ValueError(f"{gateway_clean!r} ist keine gueltige Gateway-Adresse.") from exc
raise ValueError(f"{gateway_clean!r} ist keine gültige Gateway-Adresse.") from exc
if gateway_ip not in interface_ip.network:
raise ValueError(
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
"So waere der Host nach der Aenderung nicht mehr erreichbar."
"So wäre der Host nach der Änderung nicht mehr erreichbar."
)
servers: list[str] = []
@@ -163,7 +163,7 @@ def validate_static(
try:
ipaddress.ip_address(token)
except ValueError as exc:
raise ValueError(f"{token!r} ist keine gueltige DNS-Adresse.") from exc
raise ValueError(f"{token!r} ist keine gültige DNS-Adresse.") from exc
servers.append(token)
return str(interface_ip.ip), prefix_length, gateway_clean, servers
@@ -185,7 +185,7 @@ def render_netplan(
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
"""
lines = [
"# Von TESM verwaltet. Diese Datei enthaelt ausschliesslich das unten",
"# Von TESM verwaltet. Diese Datei enthält ausschließlich das unten",
"# genannte Interface; alle anderen bleiben bei ihrer",
"# Distributionskonfiguration.",
"network:",
@@ -94,7 +94,7 @@ def validate_domain(value: str) -> str:
if not text:
return ""
if not DOMAIN_RE.match(text):
raise ValueError(f"{text!r} ist kein gueltiger Domainname.")
raise ValueError(f"{text!r} ist kein gültiger Domainname.")
return text
@@ -122,8 +122,8 @@ def render(config: WebConfig) -> str:
]
lines: list[str] = [
f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung",
"# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.",
f"# Von TESM erzeugt ({config.app_key}). Änderungen bitte in der Anwendung",
"# vornehmen – diese Datei wird bei jeder Übernahme neu geschrieben.",
"",
]
@@ -200,7 +200,7 @@ def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bo
f" client_max_body_size {body};",
f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren"
" eigene,",
" # erklaerende Fehlerseite gewinnt statt der generischen 413 von nginx.",
" # erklärende Fehlerseite gewinnt statt der generischen 413 von nginx.",
"",
" location /assets/ {",
f" alias {config.static_root.rstrip('/')}/;",
@@ -256,10 +256,10 @@ def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
if not result.ok:
return {
"ok": False,
"message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.",
"message": "nginx hat die Konfiguration abgelehnt – der alte Stand gilt weiter.",
"detail": result.output,
}
return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output}
return {"ok": True, "message": "Konfiguration übernommen.", "detail": result.output}
def request_certificate(
@@ -274,13 +274,13 @@ def request_certificate(
if not domain:
return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
if not email:
return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."}
return {"ok": False, "message": "Für Let's Encrypt ist eine E-Mail-Adresse nötig."}
result = sysops.certbot_issue(domain, email, webroot)
if not result.ok:
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
return {
"ok": True,
"message": f"Zertifikat fuer {domain} ausgestellt.",
"message": f"Zertifikat für {domain} ausgestellt.",
"detail": result.output,
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
@@ -303,9 +303,9 @@ def create_self_signed(
"""
name = (common_name or "").strip().lower().rstrip(".")
if not name:
return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."}
return {"ok": False, "message": "Es fehlt ein Name für das Zertifikat."}
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."}
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulässig."}
cleaned: list[str] = []
for entry in alt_names:
@@ -328,8 +328,8 @@ def create_self_signed(
return {
"ok": True,
"message": (
f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine "
"Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen."
f"Selbstsigniertes Zertifikat für {name} erzeugt. Browser zeigen dafür eine "
"Warnung – für ein öffentlich erreichbares System bitte Let's Encrypt nutzen."
),
"cert_path": paths[0],
"key_path": paths[1],
@@ -47,9 +47,16 @@
</div>
</div>
<div class="card__body">
{# Auch fuer einen Administrator die Liste zeigen: sein Kennzeichen gibt
den Verwaltungsbereich, der Rest kommt aus Gruppen -- und genau das
soll er hier ablesen koennen. "Darf alles" war bis 2.7.x richtig und
haette danach das Gegenteil dessen behauptet, was gilt. #}
{% if current_user.is_admin %}
{{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }}
{% elif permissions %}
{{ alert('Als Administrator haben Sie den Verwaltungsbereich immer –
Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb, Diagnose.
Alles Weitere steht unten und kommt aus Ihren Gruppen.', 'info') }}
{% endif %}
{% if permissions %}
<div class="row row--tight">
{% for permission in permissions %}
<span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span>
@@ -181,7 +181,7 @@
<div class="palette__hint">
<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> wählen</span>
<span><kbd>Enter</kbd> öffnen</span>
<span><kbd>Esc</kbd> schliessen</span>
<span><kbd>Esc</kbd> schließen</span>
</div>
</dialog>
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
@@ -23,7 +23,7 @@
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten.
{% if current_user.has('groups.edit') %}
{% if current_user.has('groups.write') %}
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, unlocked=1) }}">
{{ icon('unlock', size=14) }} Rechte freischalten
</a>
@@ -94,7 +94,7 @@
<div class="card__title">{{ icon('users', size=17) }} Mitglieder</div>
<div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div>
</div>
{% if current_user.has('users.view') %}
{% if current_user.has('users.read') %}
<a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a>
{% endif %}
</div>
@@ -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>
@@ -162,8 +162,25 @@
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '&ndash;' | safe }}</dd>
{%- endmacro %}
{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt -#}
{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt.
Seit 2.8.0 hat jede Funktion ihre eigene Zeile -- 37 statt 21. Das ist der
Sinn der Sache, macht die Tabelle aber lang, und eine lange Tabelle
verleitet dazu, oben den Bereichsschalter zu setzen und fertig. Drei
Hilfen dagegen: Unterzeilen stehen eingerueckt unter ihrer Hauptzeile, die
Funktion steht in Kleinschrift daneben, und ein Filterfeld blendet aus,
wonach man nicht sucht. -#}
{% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%}
{% if not disabled %}
<div class="matrix__filter">
<label class="field field--inline">
<span class="field__label">Zeile suchen</span>
<input type="search" class="input input--sm" data-matrix-filter
placeholder="z. B. Neustart, DHCP, Sicherung" autocomplete="off">
</label>
<span class="matrix__filter-hint" data-matrix-count></span>
</div>
{% endif %}
<div class="table-wrap">
<table class="matrix" data-behavior="permission-matrix">
{% for area in tree.areas %}
@@ -172,8 +189,20 @@
<tr>
<th style="min-width:240px">{{ area.label }}</th>
{% for action in columns %}
<th title="{{ action_meta[action][1] }} &ndash; {{ action_meta[action][2] }}">
{{ action_meta[action][0] }}
<th title="{{ action_meta[action][1] }} ({{ action_meta[action][2] }}) &ndash;
{{ action_meta[action][3] }}">
<span class="matrix__spalte">
<b>{{ action_meta[action][0] }}</b>
<span>{{ action_meta[action][1] }}</span>
<small>{{ action_meta[action][2] }}</small>
{% if not disabled %}
<button type="button" class="matrix__alle" data-matrix-column="{{ action.value }}"
data-area="{{ area.key }}" title="Ganze Spalte in diesem Bereich"
aria-label="Alle {{ action_meta[action][1] }}-Rechte dieses Bereichs umschalten">
alle
</button>
{% endif %}
</span>
</th>
{% endfor %}
</tr>
@@ -182,9 +211,9 @@
<tr class="matrix__area">
<td class="matrix__killswitch">
<label class="check">
<input type="checkbox" name="{{ field_prefix }}" value="{{ area.view_key }}"
<input type="checkbox" name="{{ field_prefix }}" value="{{ area.read_key }}"
data-area-switch="{{ area.key }}"
{% if state.get(area.key, {}).get('view') %}checked{% endif %}
{% if state.get(area.key, {}).get('read') %}checked{% endif %}
{% if disabled %}disabled{% endif %}>
<span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span>
</label>
@@ -192,23 +221,48 @@
{% for action in columns %}<td></td>{% endfor %}
</tr>
{% for resource in area.resources %}
<tr data-area="{{ area.key }}">
<tr data-area="{{ area.key }}"
data-suchtext="{{ (resource.label ~ ' ' ~ resource.hint) | lower }}"
{% if resource.parent %}class="matrix__unterzeile"{% endif %}>
<td>
<span class="matrix__resource-label">
{{ resource.label }}
<span class="matrix__zeile">
<b>{{ resource.short_label }}</b>
{% if not disabled %}
{# Ganze Zeile: bei 37 Zeilen mit bis zu sieben Spalten
sind das ueber 200 Kaestchen. Wer einer Rolle eine
Funktion ganz geben will, soll nicht siebenmal
klicken. Hinter der Beschriftung, nicht davor -- vorn
gelesen ergab "alle Clients" einen falschen Satz. #}
<button type="button" class="matrix__alle" data-matrix-row
title="Ganze Zeile an- oder abwählen"
aria-label="Alle Rechte dieser Zeile umschalten">alle</button>
{% endif %}
</span>
{% if resource.hint %}<small>{{ resource.hint }}</small>{% endif %}
{# Was eine Aktion in dieser Zeile bedeutet, steht in der
Zeile -- nicht in einem Titelattribut, das man nur mit der
Maus findet, und nicht in einer Legende unter einer
Tabelle, die man dafuer erst zu Ende scrollt. #}
{% for action in resource.actions if resource.action_hints.get(action) %}
<small class="matrix__bedeutung">
<b>{{ action_meta[action][0] }}</b>
{{ action_meta[action][1] }} &ndash;
{{ resource.action_hints.get(action) }}
</small>
{% endfor %}
</span>
</td>
{% for action in columns %}
<td>
{% if resource.has(action) %}
<label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][2] }}">
<label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][3] }}">
<input type="checkbox" name="{{ field_prefix }}"
value="{{ resource.permission(action) }}"
data-action="{{ action.value }}"
{% if state.get(resource.key, {}).get(action.value) %}checked{% endif %}
{% if disabled %}disabled{% endif %}>
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
<span class="sr-only">{{ resource.label }} {{ action_meta[action][2] }}</span>
</label>
{% else %}
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
@@ -229,7 +283,7 @@
</div>
<div class="matrix__legend mt-3">
{% for action, meta in action_meta.items() %}
<span><b>{{ meta[0] }}</b> {{ meta[1] }} &ndash; {{ meta[2] }}</span>
<span><b>{{ meta[0] }}</b> {{ meta[1] }} ({{ meta[2] }}) &ndash; {{ meta[3] }}</span>
{% endfor %}
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen &ndash;
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
@@ -119,7 +119,13 @@ niemals hier gespeichert.', 'info') }}
<tr>
<td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td>{{ pill(entry.group_name or 'unbekannt', 'accent') }}</td>
<td>
{% if entry.grants_admin %}
{{ pill('Administrator (Kennzeichen)', 'warning') }}
{% else %}
{{ pill(entry.group_name or 'unbekannt', 'accent') }}
{% endif %}
</td>
<td class="actions">
<form method="post" data-no-guard
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
@@ -140,22 +146,35 @@ niemals hier gespeichert.', 'info') }}
'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }}
{% endif %}
{% if current_user.has('settings_directory.edit') %}
{% if current_user.has('settings_directory.write') %}
<form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }}
{# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. #}
{# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld.
Das Kennzeichen steht mit in derselben Liste, weil es dieselbe
Frage beantwortet: "was wird dieses Konto hier?". Vorher gab es
nur Gruppen, und "Domänen-Admins" landete deshalb in der Gruppe
*Administration* -- die Benutzer und Rechte pflegt, aber kein
Administratorkennzeichen hat und sich keines geben kann. #}
<label class="field">
<span class="field__label">
Gruppe in dieser Anwendung<span class="req" aria-hidden="true">*</span>
Ziel in dieser Anwendung<span class="req" aria-hidden="true">*</span>
</span>
<select class="select" name="app_group_id" required>
<select class="select" name="ziel" required>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
<option value="gruppe:{{ group.id }}">{{ group.name }}</option>
{% endfor %}
<option value="administrator">Administrator (Kennzeichen)</option>
</select>
<span class="field__hint">
„Administrator (Kennzeichen)“ macht Mitglieder dieser Verzeichnisgruppe zu
echten Administratoren: sie haben immer den ganzen Verwaltungsbereich.
Funktionsrechte kommen weiter über Gruppen. Wer aus der Verzeichnisgruppe
austritt, verliert das Kennzeichen bei der nächsten Anmeldung.
</span>
</label>
</div>
</div>
@@ -58,7 +58,7 @@ zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
{% endif %}
</section>
{% if current_user.has('settings_network.edit') and interfaces %}
{% if current_user.has('settings_network.write') and interfaces %}
<section class="card">
<div class="card__header">
<div class="flex-1">
@@ -41,7 +41,7 @@ gültige, aktivierte Lizenz. Abgelaufene Einträge werden beim Aufruf dieser Sei
<td class="muted fs-sm">{{ entry.detail }}</td>
<td class="fs-sm"><span data-relative-time="{{ entry.deleted_at }}">{{ entry.deleted_at | dt }}</span></td>
<td class="actions">
{% if current_user.has('trash.edit') %}
{% if current_user.has('trash.write') %}
<form method="post" style="display:inline" data-no-guard
action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -2,7 +2,7 @@
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
{% block page_title %}Benutzer{% endblock %}
{% block page_sub %}{{ users | length }} Konten &middot; Rechte kommen ausschliesslich aus Gruppen{% endblock %}
{% block page_sub %}{{ users | length }} Konten &middot; Rechte kommen ausschließlich aus Gruppen{% endblock %}
{% block topbar_extra %}
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
@@ -79,7 +79,7 @@
{% else %}<span class="faint">noch nie</span>{% endif %}
</td>
<td class="actions">
{% if current_user.has('users.edit') %}
{% if current_user.has('users.write') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
@@ -137,7 +137,18 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</div>
</fieldset>
{% if current_user.is_admin %}
{{ checkbox('is_admin', 'Administrator (darf alles, unabhängig von Gruppen)') }}
{# Das Kennzeichen heisst seit 2.8.0 "Verwaltung" und nicht "alles":
Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen.
Stand hier weiter "darf alles", waere die Beschriftung eine
falsche Zusage -- und niemand wuerde die Gruppe dazugeben. #}
{{ checkbox('is_admin',
'Administrator – hat immer den ganzen Verwaltungsbereich') }}
<p class="fs-xs faint">
Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb und Diagnose gelten
für ein Administratorkonto immer, auch ohne Gruppe. Alles andere – Geräte,
Neustarts, DHCP, Dateifreigaben, Protokolle – kommt über Gruppen; die
Gruppe „Funktionen (Vollzugriff)“ gibt es dafür schon.
</p>
{% endif %}
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
</div>
@@ -180,7 +191,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endif %}
{% endif %}
{% if current_user.has('users.edit') %}
{% if current_user.has('users.write') %}
{% for user in users %}
{% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username,
'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %}
@@ -211,7 +222,8 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</fieldset>
<div class="stack stack--tight">
{% if current_user.is_admin and user.id != current_user.id %}
{{ checkbox('is_admin', 'Administrator', checked=user.is_admin) }}
{{ checkbox('is_admin', 'Administrator – hat immer den ganzen Verwaltungsbereich',
checked=user.is_admin) }}
{% elif user.is_admin %}
{{ pill('Administrator', 'accent') }}
{% endif %}
+7 -7
View File
@@ -84,7 +84,7 @@ def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes:
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
raise TransferError(
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist "
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben – sie ist "
"der einzige Schutz dieser Datei."
)
salt = secrets.token_bytes(KDF_SALT_BYTES)
@@ -116,21 +116,21 @@ def unseal(
try:
envelope = json.loads(text)
except ValueError as exc:
raise TransferError("Die Datei ist kein gueltiges JSON.") from exc
raise TransferError("Die Datei ist kein gültiges JSON.") from exc
if not isinstance(envelope, dict):
raise TransferError("Die Datei enthaelt kein Objekt.")
raise TransferError("Die Datei enthält kein Objekt.")
if envelope.get("app") != app_tag:
raise TransferError(
f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})."
f"Diese Sicherung gehört zu einer anderen Anwendung ({envelope.get('app')!r})."
)
if int(envelope.get("format") or 0) != FORMAT_VERSION:
raise TransferError(
f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, "
f"Nicht unterstütztes Sicherungsformat ({envelope.get('format')!r}, "
f"erwartet {FORMAT_VERSION})."
)
kdf = envelope.get("kdf") or {}
if kdf.get("name") != "argon2id":
raise TransferError("Unbekanntes Schluesselableitungsverfahren.")
raise TransferError("Unbekanntes Schlüsselableitungsverfahren.")
header = {
key: envelope[key]
@@ -153,7 +153,7 @@ def unseal(
)
except (InvalidTag, KeyError, ValueError) as exc:
raise TransferError(
"Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt."
"Die Datei konnte nicht geöffnet werden. Passphrase falsch oder Datei beschädigt."
) from exc
return json.loads(plain.decode("utf-8")), header
+2 -2
View File
@@ -197,7 +197,7 @@ def yes_no(value: Any) -> str:
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
#: Anwendungen nicht auseinanderlaufen.
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"),
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch übernommen"),
"admin-confirmed": (
"Bestaetigt",
"Ausstellerwechsel von einem Administrator freigegeben",
@@ -326,7 +326,7 @@ def audit_detail(text: Any) -> Markup:
if not roh or not MITSCHRIFT.search(roh):
return Markup(escape(roh))
try:
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.view"))
erlaubt = bool(current_user.is_authenticated and current_user.has("logs_history.read"))
except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
erlaubt = False
if not erlaubt:
@@ -99,7 +99,7 @@ def update_profile():
if current_user.is_directory_account:
flash(
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
"Anmeldung uebernommen.",
"Anmeldung übernommen.",
"info",
)
return redirect(url_for("account.overview"))
@@ -123,7 +123,7 @@ def update_profile():
def upload_avatar():
upload = request.files.get("avatar")
if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger")
flash("Keine Datei ausgewählt.", "danger")
return redirect(url_for("account.overview"))
suffix = Path(upload.filename).suffix.lower()
@@ -139,7 +139,7 @@ def upload_avatar():
data = upload.stream.read(MAX_AVATAR_BYTES + 1)
if len(data) > MAX_AVATAR_BYTES:
flash("Das Bild ist zu gross (maximal 2 MB).", "danger")
flash("Das Bild ist zu groß (maximal 2 MB).", "danger")
return redirect(url_for("account.overview"))
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
@@ -215,7 +215,7 @@ def security():
def change_password():
conn = db()
if current_user.is_directory_account:
flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info")
flash("Verzeichniskonten ändern ihr Passwort im Verzeichnisdienst.", "info")
return redirect(url_for("account.security"))
current = request.form.get("current_password") or ""
@@ -226,7 +226,7 @@ def change_password():
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
return redirect(url_for("account.security"))
if new != repeat:
flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger")
flash("Die beiden neuen Passwörter stimmen nicht überein.", "danger")
return redirect(url_for("account.security"))
if passwords.constant_time_equals(current, new):
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
@@ -244,7 +244,7 @@ def change_password():
flash(str(exc), "danger")
return redirect(url_for("account.security"))
flash(f"Passwort geaendert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
flash(f"Passwort geändert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
return redirect(url_for("account.security"))
@@ -253,7 +253,7 @@ def change_password():
@reauth_required
def totp_start():
session["_totp_setup"] = totp.generate_secret()
flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info")
flash("Scannen Sie den Code und bestätigen Sie mit einem gültigen Zahlencode.", "info")
return redirect(url_for("account.security"))
@@ -263,7 +263,7 @@ def totp_start():
def totp_confirm():
secret = session.get("_totp_setup", "")
if not secret:
flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger")
flash("Es läuft keine Einrichtung. Bitte neu starten.", "danger")
return redirect(url_for("account.security"))
if not totp.verify_code(secret, request.form.get("code") or ""):
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
@@ -306,7 +306,7 @@ def totp_confirm():
def totp_disable():
if core().config.security.require_totp_for_admins and current_user.is_admin:
flash(
"Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
"Für Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
"Installation verpflichtend.",
"danger",
)
+20 -14
View File
@@ -48,7 +48,7 @@ def _int_list(name: str) -> list[int]:
@bp.get("/users")
@permission_required("users.view")
@permission_required("users.read")
def users():
conn = db()
return render_template(
@@ -95,7 +95,7 @@ def user_create():
@bp.post("/users/<int:user_id>")
@permission_required("users.edit")
@permission_required("users.write")
def user_update(user_id: int):
conn = db()
try:
@@ -120,7 +120,7 @@ def user_update(user_id: int):
policy=core().password_policy,
must_change=True,
)
changes.append("Passwort zurueckgesetzt")
changes.append("Passwort zurückgesetzt")
session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
target = rbac.get_user(conn, user_id) or {}
if changes:
@@ -131,7 +131,7 @@ def user_update(user_id: int):
"; ".join(changes),
severity="notice",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.users"))
@@ -153,7 +153,7 @@ def user_delete(user_id: int):
severity="warning",
)
flash(
"Konto endgueltig entfernt (Verzeichniskonto)."
"Konto endgültig entfernt (Verzeichniskonto)."
if mode == "endgueltig"
else "Konto in den Papierkorb verschoben.",
"success",
@@ -164,7 +164,7 @@ def user_delete(user_id: int):
@bp.post("/users/<int:user_id>/end-sessions")
@permission_required("users.edit")
@permission_required("users.write")
def user_revoke_sessions(user_id: int):
conn = db()
target = rbac.get_user(conn, user_id)
@@ -239,7 +239,7 @@ def directory_adopt():
@bp.get("/groups")
@permission_required("groups.view")
@permission_required("groups.read")
def groups():
conn = db()
tree = core().permissions
@@ -255,7 +255,7 @@ def groups():
@bp.get("/groups/<int:group_id>")
@permission_required("groups.view")
@permission_required("groups.read")
def group_detail(group_id: int):
conn = db()
group = rbac.get_group(conn, group_id)
@@ -274,7 +274,7 @@ def group_detail(group_id: int):
"WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE",
(group_id,),
),
can_edit=current_user.has("groups.edit"),
can_edit=current_user.has("groups.write"),
unlocked=request.args.get("unlocked") == "1",
)
@@ -302,7 +302,7 @@ def group_create():
@bp.post("/groups/<int:group_id>")
@permission_required("groups.edit")
@permission_required("groups.write")
def group_update(group_id: int):
conn = db()
try:
@@ -328,7 +328,7 @@ def group_update(group_id: int):
"; ".join(changes),
severity="notice",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.group_detail", group_id=group_id))
@@ -364,7 +364,7 @@ def group_apply_preset(group_id: int):
abort(404)
preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
if preset is None:
flash("Fuer diese Gruppe ist keine Vorlage hinterlegt.", "info")
flash("Für diese Gruppe ist keine Vorlage hinterlegt.", "info")
return redirect(url_for("admin.group_detail", group_id=group_id))
tree = core().permissions
with transaction():
@@ -377,7 +377,7 @@ def group_apply_preset(group_id: int):
unlock_system=True,
)
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
flash(f"Rechte der Vorlage {preset.name} uebernommen.", "success")
flash(f"Rechte der Vorlage {preset.name} übernommen.", "success")
return redirect(url_for("admin.group_detail", group_id=group_id))
@@ -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"))
@@ -28,7 +28,7 @@ bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics"
@bp.get("/")
@permission_required("diagnostics.view")
@permission_required("diagnostics.read")
def overview():
extension = core()
conn = db()
@@ -82,7 +82,7 @@ def overview():
"Debug": "ja" if config.debug else "nein",
"Cookie Secure": "ja" if config.security.cookie_secure else "nein",
"HSTS": "ja" if config.security.hsts_enabled else "nein",
"2FA-Pflicht fuer Admins": "ja" if config.security.require_totp_for_admins else "nein",
"2FA-Pflicht für Admins": "ja" if config.security.require_totp_for_admins else "nein",
},
now=datetime.now(timezone.utc),
)
@@ -109,7 +109,7 @@ def housekeeping():
severity="notice",
)
flash(
f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
f"Aufgeräumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
f"{len(archived)} archivierte Protokolltage.",
"success",
)
@@ -139,12 +139,12 @@ def rotate_keys():
actor_id=current_user.id,
action="system.data_key_rotated",
target=new_id,
detail=f"{migrated} Werte neu verschluesselt",
detail=f"{migrated} Werte neu verschlüsselt",
severity="notice",
)
flash(
f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
"wurden neu verschluesselt.",
f"Neuer Datenschlüssel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
"wurden neu verschlüsselt.",
"success",
)
return redirect(url_for("diagnostics.overview"))
@@ -65,7 +65,7 @@ def _license_hint(feature: str) -> str:
@bp.get("/network")
@permission_required("settings_network.view")
@permission_required("settings_network.read")
def network():
extension = core()
sysops = extension.sysops
@@ -81,12 +81,12 @@ def network():
@bp.post("/network")
@permission_required("settings_network.edit")
@permission_required("settings_network.write")
def network_apply():
extension = core()
sysops = extension.sysops
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("hostsettings.network"))
interface = (request.form.get("interface") or "").strip()
@@ -127,7 +127,7 @@ def network_apply():
sysops.write_netplan(previous.stdout if previous.ok else "")
sysops.apply_netplan()
flash(
f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
f"Übernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
"danger",
)
return redirect(url_for("hostsettings.network"))
@@ -140,21 +140,21 @@ def network_apply():
_start_revert_timer(extension)
_audit("settings.network_applied", interface, severity="warning")
flash(
f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- "
"sonst wird automatisch zurueckgerollt.",
f"Übernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestätigen – "
"sonst wird automatisch zurückgerollt.",
"warning",
)
return redirect(url_for("hostsettings.network"))
@bp.post("/network/confirm")
@permission_required("settings_network.edit")
@permission_required("settings_network.write")
def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
flash("Es liegt keine Aenderung zur Bestaetigung vor.", "info")
flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
else:
_audit("settings.network_confirmed", severity="notice")
flash("Netzwerkeinstellungen bestaetigt.", "success")
flash("Netzwerkeinstellungen bestätigt.", "success")
return redirect(url_for("hostsettings.network"))
@@ -173,7 +173,7 @@ def _start_revert_timer(extension: Any) -> None:
conn,
actor="system",
action="settings.network_auto_reverted",
detail="Keine Bestaetigung innerhalb der Frist -- automatisch zurueckgerollt.",
detail="Keine Bestätigung innerhalb der Frist – automatisch zurückgerollt.",
severity="warning",
)
@@ -222,7 +222,7 @@ def _web_config() -> webserver.WebConfig:
@bp.get("/web")
@permission_required("settings_web.view")
@permission_required("settings_web.read")
def web():
extension = core()
config = _web_config()
@@ -241,7 +241,7 @@ def web():
@bp.post("/web")
@permission_required("settings_web.edit")
@permission_required("settings_web.write")
def web_save():
extension = core()
conn = db()
@@ -281,7 +281,7 @@ def web_save():
cert_path = (request.form.get("web_cert_path") or "").strip()
key_path = (request.form.get("web_key_path") or "").strip()
if https_enabled and not (cert_path and key_path):
flash("Fuer HTTPS werden Zertifikat und privater Schluessel gebraucht.", "danger")
flash("Für HTTPS werden Zertifikat und privater Schlüssel gebraucht.", "danger")
return redirect(url_for("hostsettings.web"))
values = {
@@ -298,7 +298,7 @@ def web_save():
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
flash("Gespeichert. Uebernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
flash("Gespeichert. Übernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
return redirect(url_for("hostsettings.web"))
@@ -311,7 +311,7 @@ def web_apply():
return redirect(url_for("hostsettings.web"))
sysops = core().sysops
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("hostsettings.web"))
result = webserver.apply_config(sysops, webserver.render(_web_config()))
@@ -338,7 +338,7 @@ def web_certificate():
sysops = extension.sysops
conn = db()
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("hostsettings.web"))
config = _web_config()
@@ -381,7 +381,7 @@ def web_self_signed():
sysops = extension.sysops
conn = db()
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
return redirect(url_for("hostsettings.web"))
config = _web_config()
@@ -415,7 +415,7 @@ def web_self_signed():
@bp.get("/directory")
@permission_required("settings_directory.view")
@permission_required("settings_directory.read")
def directory_settings():
extension = core()
conn = db()
@@ -436,7 +436,7 @@ def directory_settings():
@bp.post("/directory")
@permission_required("settings_directory.edit")
@permission_required("settings_directory.write")
def directory_save():
problem = _license_hint("directory")
if problem:
@@ -454,8 +454,8 @@ def directory_save():
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
flash(
"Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort "
"geht dabei im Klartext ueber das Netz.",
"Eine unverschlüsselte Verbindung muss ausdrücklich erlaubt werden – das Passwort "
"geht dabei im Klartext über das Netz.",
"danger",
)
return redirect(url_for("hostsettings.directory_settings"))
@@ -490,7 +490,7 @@ def directory_test():
@bp.get("/directory/group-search")
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
@permission_required("settings_directory.write", "fileshare.write", mode="any")
def directory_group_search():
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
@@ -505,21 +505,30 @@ def directory_group_search():
@bp.post("/directory/mapping")
@permission_required("settings_directory.edit")
@permission_required("settings_directory.write")
def directory_mapping_add():
conn = db()
dn = (request.form.get("ad_group_dn") or "").strip()
name = (request.form.get("ad_group_name") or "").strip()
group_id = request.form.get("app_group_id")
if not dn or not group_id:
flash("Verzeichnisgruppe und Zielgruppe sind beide erforderlich.", "danger")
# Ein Feld, zwei Arten von Ziel: "gruppe:<id>" oder "administrator". Zwei
# Felder waeren zwei Zustaende, von denen einer immer falsch ist.
ziel = (request.form.get("ziel") or request.form.get("app_group_id") or "").strip()
kennzeichen = ziel == "administrator"
gruppe: int | None = None
if not kennzeichen:
nummer = ziel.split(":", 1)[1] if ziel.startswith("gruppe:") else ziel
gruppe = int(nummer) if nummer.isdigit() else None
if not dn or (gruppe is None and not kennzeichen):
flash("Verzeichnisgruppe und Ziel sind beide erforderlich.", "danger")
return redirect(url_for("hostsettings.directory_settings"))
with transaction():
conn.execute(
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id, created_at) "
"VALUES (?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
(dn, name or dn, int(group_id)),
"INSERT INTO ldap_group_mappings "
"(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"VALUES (?,?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id, "
"grants_admin=excluded.grants_admin",
(dn, name or dn, gruppe, int(kennzeichen)),
)
audit.log(
conn,
@@ -527,15 +536,22 @@ def directory_mapping_add():
actor_id=current_user.id,
action="settings.directory_mapping_added",
target=name or dn,
detail=(
"Ziel: Administrator (Kennzeichen)"
if kennzeichen
else f"Ziel: Gruppe {gruppe}"
),
ip=client_ip(),
severity="notice",
# Eine Zuordnung, die Administratoren erzeugt, ist mehr als eine
# Notiz wert: sie entscheidet, wer die Anlage verwaltet.
severity="warning" if kennzeichen else "notice",
)
flash("Zuordnung gespeichert.", "success")
return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/directory/mapping/<int:mapping_id>/delete")
@permission_required("settings_directory.edit")
@permission_required("settings_directory.write")
def directory_mapping_delete(mapping_id: int):
with transaction() as conn:
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
@@ -39,7 +39,7 @@ def _live_log():
@bp.get("/live")
@permission_required("logs_live.view")
@permission_required("logs_live.read")
def live():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip()
@@ -53,7 +53,7 @@ def live():
@bp.get("/live/content")
@permission_required("logs_live.view")
@permission_required("logs_live.read")
@fragment
def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
@@ -64,7 +64,7 @@ def live_fragment():
@bp.get("/history")
@permission_required("logs_history.view", "logs_history.export", mode="any")
@permission_required("logs_history.read", "logs_history.export", mode="any")
def history():
extension = core()
rotated = []
@@ -83,7 +83,7 @@ def history():
@bp.get("/history/file/<path:name>")
@permission_required("logs_history.view")
@permission_required("logs_history.read")
def history_file(name: str):
extension = core()
target = (extension.config.log_dir / name).resolve()
@@ -102,7 +102,7 @@ def history_file(name: str):
@bp.get("/history/archive/<day>")
@permission_required("logs_history.view")
@permission_required("logs_history.read")
def history_archive(day: str):
if len(day) != 10 or day.count("-") != 2:
abort(404)
@@ -177,7 +177,7 @@ def history_purge():
@bp.get("/audit")
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
@permission_required("logs_audit.read", "logs_audit.export", mode="any")
def audit_view():
conn = db()
page = max(1, int(request.args.get("page") or 1))
@@ -235,7 +235,7 @@ def audit_export():
mimetype="text/csv; charset=utf-8",
headers={
"Content-Disposition": (
f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"'
f'attachment; filename="{core().config.app_key}-änderungsprotokoll.csv"'
)
},
)
+13 -13
View File
@@ -100,7 +100,7 @@ def register_core_providers() -> None:
list_fn=_users_in_trash,
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
hint="Verzeichniskonten landen nie hier -- sie legen sich bei der naechsten "
hint="Verzeichniskonten landen nie hier – sie legen sich bei der nächsten "
"Anmeldung von selbst wieder an.",
)
)
@@ -112,7 +112,7 @@ def register_core_providers() -> None:
list_fn=_groups_in_trash,
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
hint="Beim endgueltigen Loeschen verschwinden auch Mitgliedschaften und Rechte.",
hint="Beim endgültigen Löschen verschwinden auch Mitgliedschaften und Rechte.",
)
)
@@ -146,7 +146,7 @@ def _purge_expired(conn: sqlite3.Connection) -> int:
@bp.get("/")
@permission_required("trash.view")
@permission_required("trash.read")
def overview():
conn = db()
removed = 0
@@ -158,11 +158,11 @@ def overview():
conn,
actor="system",
action="trash.auto_purged",
detail=f"{removed} abgelaufene Eintraege",
detail=f"{removed} abgelaufene Einträge",
severity="notice",
)
if removed:
flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info")
flash(f"{removed} abgelaufene Einträge wurden endgültig entfernt.", "info")
sections = []
for provider in providers().values():
@@ -192,13 +192,13 @@ def _provider_or_404(key: str) -> TrashProvider:
@bp.post("/<kind>/<ident>/restore")
@permission_required("trash.edit")
@permission_required("trash.write")
def restore(kind: str, ident: str):
extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash(
"Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, "
"aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.",
"Das Wiederherstellen aus dem Papierkorb erfordert eine gültige, "
"aktivierte Lizenz. Ansehen bleibt jederzeit möglich.",
"danger",
)
return redirect(url_for("trash.overview"))
@@ -228,7 +228,7 @@ def purge(kind: str, ident: str):
extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash(
"Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.",
"Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.",
"danger",
)
return redirect(url_for("trash.overview"))
@@ -246,7 +246,7 @@ def purge(kind: str, ident: str):
ip=client_ip(),
severity="warning",
)
flash("Eintrag endgueltig entfernt.", "success")
flash("Eintrag endgültig entfernt.", "success")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("trash.overview"))
@@ -257,7 +257,7 @@ def purge(kind: str, ident: str):
def purge_all():
extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash("Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "danger")
flash("Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.", "danger")
return redirect(url_for("trash.overview"))
kind = request.form.get("kind") or ""
@@ -274,9 +274,9 @@ def purge_all():
actor_id=current_user.id,
action="trash.emptied",
target=kind or "alle",
detail=f"{removed} Eintraege",
detail=f"{removed} Einträge",
ip=client_ip(),
severity="warning",
)
flash(f"{removed} Eintraege endgueltig entfernt.", "success")
flash(f"{removed} Einträge endgültig entfernt.", "success")
return redirect(url_for("trash.overview"))
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing-server"
version = "2.5.2"
version = "2.7.7"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.5.2", "cryptography>=41"]
dependencies = ["tesm-licensing==2.7.7", "cryptography>=41"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -86,7 +86,7 @@ def _validate_issue_args(
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
# Lizenz ohne Module -- ein sinnloser Zustand.
raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein")
raise ValueError("Trial-Lizenzen können nicht unbefristet sein")
if role != "customer" and modules:
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
@@ -195,7 +195,7 @@ def verify_client_request(
if not isinstance(request, Mapping):
raise ProtocolViolation("Anfrage ist kein Objekt")
if int(request.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
raise ProtocolViolation("Nicht unterstützte Protokollversion")
action = str(request.get("action") or "")
if action not in ACTIONS:
@@ -214,7 +214,7 @@ def verify_client_request(
if not verify(
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
):
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
raise SignatureInvalid("Signatur der Anfrage ist ungültig")
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
@@ -229,7 +229,7 @@ def verify_client_request(
raise ProtocolViolation("Bericht ist kein Objekt")
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
if total > MAX_REPORT_ROWS:
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
raise ProtocolViolation("Bericht überschreitet die Größengrenze")
def build_master_response(
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "tesm-licensing"
version = "2.5.2"
version = "2.7.7"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11"
dependencies = ["cryptography>=42"]
@@ -42,7 +42,7 @@ def _b64d(value: str, *, field: str) -> bytes:
try:
return base64.b64decode(value, validate=True)
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
raise ValueError(f"{field} ist kein gültiges Base64") from exc
@@ -96,7 +96,7 @@ def decode_code(code: str) -> dict[str, Any]:
try:
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
raise ValueError("Code ist unlesbar oder beschaedigt") from exc
raise ValueError("Code ist unlesbar oder beschädigt") from exc
if not isinstance(data, dict):
raise ValueError("Code enthaelt kein Objekt")
raise ValueError("Code enthält kein Objekt")
return data
@@ -161,7 +161,7 @@ def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
eingebettetem ``license_privkey``.
"""
if not isinstance(bundle, Mapping):
raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt")
raise InvalidLicenseFile("Lizenzdatei enthält kein Objekt")
inner = bundle.get("license")
if isinstance(inner, Mapping):
@@ -194,10 +194,10 @@ def verify_license(
missing = [name for name in _PAYLOAD_FIELDS if name not in document]
if missing:
raise InvalidLicenseFile(f"Lizenzdokument unvollstaendig: {', '.join(missing)}")
raise InvalidLicenseFile(f"Lizenzdokument unvollständig: {', '.join(missing)}")
if int(document.get("fmt") or 0) != FORMAT_VERSION:
raise InvalidLicenseFile(
f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}"
f"Nicht unterstützte Formatversion: {document.get('fmt')!r}"
f" (erwartet {FORMAT_VERSION})"
)
if document.get("type") not in LICENSE_TYPES:
@@ -207,17 +207,17 @@ def verify_license(
master_pubkey = str(document.get("master_pubkey") or "")
if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schluessel")
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schlüssel")
payload = {k: v for k, v in document.items() if k != "signature"}
if not verify(
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
):
raise SignatureInvalid("Signatur der Lizenzdatei ist ungueltig")
raise SignatureInvalid("Signatur der Lizenzdatei ist ungültig")
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
raise UntrustedIssuer(
"Die Lizenz wurde nicht von einem vertrauenswuerdigen Lizenzserver ausgestellt"
"Die Lizenz wurde nicht von einem vertrauenswürdigen Lizenzserver ausgestellt"
)
@@ -236,7 +236,7 @@ def license_status(
lifetime = bool(document.get("lifetime"))
warnings: list[str] = []
if not issuer_pinned:
warnings.append("Aussteller nicht gepinnt -- nur innere Konsistenz geprueft")
warnings.append("Aussteller nicht gepinnt – nur innere Konsistenz geprüft")
issued_at: datetime | None = None
expires_at: datetime | None = None
@@ -248,7 +248,7 @@ def license_status(
try:
parsed = parse_iso(str(raw))
except ValueError:
warnings.append(f"Feld {name} ist kein gueltiges Datum")
warnings.append(f"Feld {name} ist kein gültiges Datum")
continue
if name == "issued_at":
issued_at = parsed
@@ -77,7 +77,7 @@ def build_client_request(
if action not in ACTIONS:
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
if not client_private_key:
raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert")
raise ProtocolViolation("Kein Client-Schlüssel vorhanden | Lizenz unvollständig importiert")
payload: dict[str, Any] = {
"fmt": FORMAT_VERSION,
@@ -130,22 +130,22 @@ def verify_master_response(
if not isinstance(response, Mapping):
raise ProtocolViolation("Antwort ist kein Objekt")
if int(response.get("fmt") or 0) != FORMAT_VERSION:
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
raise ProtocolViolation("Nicht unterstützte Protokollversion")
payload = {k: v for k, v in response.items() if k != "signature"}
if not verify(
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
):
raise SignatureInvalid("Signatur der Antwort ist ungueltig")
raise SignatureInvalid("Signatur der Antwort ist ungültig")
if str(response.get("license_id") or "") != expected_license_id:
raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz")
raise ProtocolViolation("Antwort gehört zu einer anderen Lizenz")
if str(response.get("fingerprint") or "") != expected_fingerprint:
raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine")
raise ProtocolViolation("Antwort gehört zu einer anderen Maschine")
if str(response.get("action") or "") != expected_action:
raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion")
raise ProtocolViolation("Antwort gehört zu einer anderen Aktion")
if str(response.get("nonce") or "") != expected_nonce:
raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage")
raise ReplayDetected("Antwort gehört nicht zu dieser Anfrage")
if str(response.get("status") or "") not in RESPONSE_STATES:
raise ProtocolViolation("Unbekannter Antwortstatus")
@@ -156,4 +156,4 @@ def verify_master_response(
if not isinstance(update, Mapping):
raise ProtocolViolation("license_update ist kein Objekt")
if int(update.get("seq") or 0) < int(current_seq or 0):
raise ReplayDetected("license_update ist aelter als die installierte Lizenz")
raise ReplayDetected("license_update ist älter als die installierte Lizenz")