diff --git a/README.md b/README.md index 5998478..28924d1 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # TESM-Lizenzserver -Ausgabe und Verwaltung der Lizenzen fuer TESM: Kunde -> Ticket -> Lizenz, -Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand ueber +Ausgabe und Verwaltung der Lizenzen für TESM: Kunde -> Ticket -> Lizenz, +Aktivierung online und offline, Kundenportal, Selbstbedienung, Versand über MS Graph und eine Hierarchie aus Unter-Lizenzservern. -**Dieses Repository enthaelt genau das, was zum Installieren und Betreiben des -Lizenzservers gebraucht wird** -- nichts darueber hinaus: +**Dieses Repository enthält genau das, was zum Installieren und Betreiben des +Lizenzservers gebraucht wird** -- nichts darüber hinaus: ``` apps/tesm-license die Anwendung packages/tesm-core gemeinsamer Kern (Anmeldung, Rechte, Sicherung, nginx, ...) -packages/tesm-licensing Lizenzprotokoll, gemeinsame Haelfte -packages/tesm-licensing-server Lizenzprotokoll, Serverhaelfte: ausstellen, erneuern, antworten +packages/tesm-licensing Lizenzprotokoll, gemeinsame Hälfte +packages/tesm-licensing-server Lizenzprotokoll, Serverhälfte: ausstellen, erneuern, antworten deploy/ install.sh, update.sh, release.sh, systemd, privilegierter Helfer docs/ Betrieb und Sicherheit ``` @@ -28,7 +28,7 @@ Die verwaltete Anwendung liegt hier **nicht** mit drin. | Zweig | Inhalt | |---|---| | `main` | dieser Neubau (Version 2.0.0) | -| `SONNET5` | der Stand davor, unveraendert aufbewahrt | +| `SONNET5` | der Stand davor, unverändert aufbewahrt | --- @@ -41,18 +41,18 @@ sudo -u tesm-license tesm-license-admin bootstrap sudo -u tesm-license tesm-license-admin create-admin ``` -**`init-key` erzeugt den Signaturschluessel dieses Servers. Sichern Sie ihn -sofort.** Ohne ihn laesst sich keine ausgestellte Lizenz mehr erneuern, und -Kunden muessten einen Ausstellerwechsel bestaetigen. +**`init-key` erzeugt den Signaturschlüssel dieses Servers. Sichern Sie ihn +sofort.** Ohne ihn lässt sich keine ausgestellte Lizenz mehr erneuern, und +Kunden müssten einen Ausstellerwechsel bestätigen. Zielzustand: **Port 443 mit TLS, Port 80 leitet dorthin um** -- ausser dem ACME-Pfad, der erreichbar bleiben muss, sonst kann Let's Encrypt nie erneuern. -Sitzt ein eigener Reverse Proxy davor, der TLS beendet, laeuft der Dienst ohne +Sitzt ein eigener Reverse Proxy davor, der TLS beendet, läuft der Dienst ohne `--https` schlicht auf Port 80. Der Lizenzserver darf neben TESM auf demselben Host laufen; nginx unterscheidet die beiden am Servernamen, und jede Installation hat ihren eigenen `SITE_KEY` -fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie. +für nginx-Site, logrotate, Zertifikate und Sitzungscookie. ### Aktualisieren @@ -60,7 +60,7 @@ fuer nginx-Site, logrotate, Zertifikate und Sitzungscookie. sudo bash deploy/update.sh --app tesm-license ``` -Vor jedem Update entsteht ein vollstaendiges Backup, danach laeuft ein +Vor jedem Update entsteht ein vollständiges Backup, danach läuft ein Health-Check mit automatischem Rollback. --- @@ -69,16 +69,16 @@ Health-Check mit automatischem Rollback. ```bash sudo -u tesm-license tesm-license-admin status -sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel +sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel sudo -u tesm-license tesm-license-admin bootstrap # Grunddaten sudo -u tesm-license tesm-license-admin create-admin -sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand uebernehmen +sudo -u tesm-license tesm-license-admin migrate-legacy # Altbestand übernehmen ``` `migrate-legacy` liest die SQLite-Datei eines alten Lizenzservers **nur -lesend**, uebernimmt dessen Signaturschluessel und stellt die Lizenzen mit ihrer -restlichen Laufzeit neu aus. Wird der Schluessel nicht uebernommen, muessen -Kunden den Ausstellerwechsel einmal bestaetigen. +lesend**, übernimmt dessen Signaturschlüssel und stellt die Lizenzen mit ihrer +restlichen Laufzeit neu aus. Wird der Schlüssel nicht übernommen, müssen +Kunden den Ausstellerwechsel einmal bestätigen. --- @@ -101,14 +101,14 @@ genau einmal. (`tesm/license-file/v2\0`) -- eine Signatur aus einem Kontext ist in einem anderen nicht verwendbar. * Kanonisches JSON, damit dieselbe Aussage genau eine Signatur hat. -* Nonce gegen Wiedereinspielung, `seq` gegen das Zurueckrollen auf eine aeltere +* Nonce gegen Wiedereinspielung, `seq` gegen das Zurückrollen auf eine ältere Lizenz. * Aussteller wird beim ersten Kontakt gemerkt (TOFU); ein Wechsel muss - ausdruecklich bestaetigt werden. -* Der Fingerabdruck der Maschine enthaelt **keinen** Hostnamen -- Umbenennen - brach im Vorgaenger die Bindung. -* Offline-Aktivierung ueber einen kurzen Code in beide Richtungen; die Lizenz - bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurueckkommt. + ausdrücklich bestätigt werden. +* Der Fingerabdruck der Maschine enthält **keinen** Hostnamen -- Umbenennen + brach im Vorgänger die Bindung. +* Offline-Aktivierung über einen kurzen Code in beide Richtungen; die Lizenz + bleibt auf dem Server als "Aktivierung offen" markiert, bis sie zurückkommt. * Keine Lizenz ohne `master_endpoint`: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. @@ -130,8 +130,8 @@ Entwicklungsrepository, ebenso die Tests. * **Keine doppelte `licensing.py`.** Ein Paket, das beide Anwendungen installieren -- statt zweier Dateien, die byte-identisch bleiben sollen. -* **Kein root fuer die Anwendung.** Ein einziger allowlisted Helfer mit - Argumentpruefung. -* **CSRF-Schutz ueberhaupt.** Der Vorgaenger hatte keinen. -* **Rechte werden serverseitig geprueft**, nicht nur im Template ausgeblendet. +* **Kein root für die Anwendung.** Ein einziger allowlisted Helfer mit + Argumentprüfung. +* **CSRF-Schutz überhaupt.** Der Vorgänger hatte keinen. +* **Rechte werden serverseitig geprüft**, nicht nur im Template ausgeblendet. * **Verschachtelte Datenbankverbindungen sind ein Fehler**, kein Deadlock. diff --git a/apps/tesm-license/pyproject.toml b/apps/tesm-license/pyproject.toml index bb86d7e..b25a166 100644 --- a/apps/tesm-license/pyproject.toml +++ b/apps/tesm-license/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-license" -version = "2.0.0" +version = "2.0.2" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" requires-python = ">=3.11" dependencies = [ diff --git a/apps/tesm-license/src/tesm_license/__init__.py b/apps/tesm-license/src/tesm_license/__init__.py index a8262ec..0879abd 100644 --- a/apps/tesm-license/src/tesm_license/__init__.py +++ b/apps/tesm-license/src/tesm_license/__init__.py @@ -23,7 +23,7 @@ from .permissions import PERMISSIONS, ROLE_PRESETS from .schema import LICENSE_MIGRATIONS from .settings_spec import LICENSE_SERVER_SETTINGS -__version__ = "2.0.0" +__version__ = "2.0.2" APP_KEY = "tesm-license" APP_NAME = "TESM-Lizenzserver" diff --git a/apps/tesm-license/src/tesm_license/navigation.py b/apps/tesm-license/src/tesm_license/navigation.py index 8960fba..13ddebb 100644 --- a/apps/tesm-license/src/tesm_license/navigation.py +++ b/apps/tesm-license/src/tesm_license/navigation.py @@ -86,7 +86,7 @@ NAVIGATION = NavTree( ), NavItem( key="logs_audit", - label="Aenderungen", + label="Audit", icon="history", endpoint="logs.audit_view", permissions=("logs_audit.view", "logs_audit.export"), diff --git a/apps/tesm-license/src/tesm_license/templates/license/backup.html b/apps/tesm-license/src/tesm_license/templates/license/backup.html index 237ed10..32eaa7f 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/backup.html +++ b/apps/tesm-license/src/tesm_license/templates/license/backup.html @@ -1,26 +1,26 @@ {% extends "tesm_core/base.html" %} {% from "tesm_core/macros.html" import field, alert, pill %} -{% block page_title %}Sicherung und Uebertragung{% endblock %} -{% block page_sub %}Verschluesselter Export · zweistufiger Import{% endblock %} +{% block page_title %}Sicherung und Übertragung{% endblock %} +{% block page_sub %}Verschlüsselter Export · zweistufiger Import{% endblock %} {% block content %} -{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschluesselt (AES-256-GCM). Ohne -diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertuer.', 'warning', +{{ alert('Der Export wird mit Argon2id aus Ihrer Passphrase verschlüsselt (AES-256-GCM). Ohne +diese Passphrase ist die Datei nicht wiederherstellbar -- es gibt keine Hintertür.', 'warning', 'Passphrase') }} -{{ alert('Der Signaturschluessel (' ~ signing_key_path ~ ') ist bewusst -nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehoert in eine +{{ alert('Der Signaturschlüssel (' ~ signing_key_path ~ ') ist bewusst +nicht Teil dieser Sicherung. Er ist der Kern des Vertrauens und gehört in eine getrennte, bewusst verwahrte Sicherung -- nicht in dieselbe Datei wie die Kundendaten. Ohne ihn -ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'danger', -'Wichtig: Schluessel getrennt sichern') }} +ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr prüfbar.', 'danger', +'Wichtig: Schlüssel getrennt sichern') }}
{{ icon('download', size=17) }} Sicherung erstellen
-
Verlangt eine frische Passwortbestaetigung
+
Verlangt eine frische Passwortbestätigung
@@ -40,7 +40,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan {% if category.admin_only %}{{ pill('nur Admin', 'accent') }}{% endif %}
{{ category.hint }}
{% if not allowed %} -
Dafuer fehlt Ihnen das Recht.
+
Dafür fehlt Ihnen das Recht.
{% endif %} @@ -66,8 +66,8 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
-
{{ icon('upload', size=17) }} Geoeffnete Sicherung
-
Gueltig fuer {{ preview_ttl }} Minuten
+
{{ icon('upload', size=17) }} Geöffnete Sicherung
+
Gültig für {{ preview_ttl }} Minuten
@@ -75,7 +75,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
- + {% for entry in preview.summary %} @@ -89,17 +89,17 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
KategorieEintraege
KategorieEinträge
- {{ alert('Bereits vorhandene Lizenzen werden nie ueberschrieben -- ihr Inhalt ist - kryptographisch festgelegt. Kunden werden ueber ihren Namen zusammengefuehrt.', + {{ alert('Bereits vorhandene Lizenzen werden nie überschrieben -- ihr Inhalt ist + kryptographisch festgelegt. Kunden werden über ihren Namen zusammengeführt.', 'info') }}
@@ -108,7 +108,7 @@ ist nach einer Wiederherstellung keine ausgestellte Lizenz mehr pruefbar.', 'dan
{{ icon('upload', size=17) }} Sicherung einspielen
-
Erst oeffnen und pruefen, dann uebernehmen
+
Erst öffnen und prüfen, dann übernehmen
diff --git a/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html b/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html index c2d2261..9672e14 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/customer_detail.html @@ -70,7 +70,7 @@ {% if licenses %}
- + {% for entry in licenses %} @@ -119,7 +119,7 @@
{{ alert('Diese Daten verwaltet der Sub-Lizenzserver selbst. Sie sind hier rein - informativ und werden bei jedem Bericht vollstaendig ersetzt.', 'info') }} + informativ und werden bei jedem Bericht vollständig ersetzt.', 'info') }} {% for entry in reported.customers %}
@@ -193,7 +193,7 @@
Angelegt
{{ customer.created_at | dt }} von {{ customer.created_by or '-' }}
-
Geaendert
{{ customer.updated_at | dt }}
+
Geändert
{{ customer.updated_at | dt }}
Portal-Zugang
{{ portal_url }} {% else %}{% endif %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/customers.html b/apps/tesm-license/src/tesm_license/templates/license/customers.html index 734a508..a2dee5e 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/customers.html +++ b/apps/tesm-license/src/tesm_license/templates/license/customers.html @@ -7,7 +7,7 @@ {{ field('name', 'Firmenname', value=customer.name if customer else '', required=True) }} {{ field('contact_email', 'E-Mail', value=customer.contact_email if customer else '', type='email', required=not customer, - hint_text='Pflicht bei der Neuanlage -- ueber diese Adresse laeuft das Kundenportal.') }} + hint_text='Pflicht bei der Neuanlage -- über diese Adresse läuft das Kundenportal.') }} {{ field('contact_person', 'Ansprechpartner', value=customer.contact_person if customer else '') }} {{ field('contact_phone', 'Telefon', value=customer.contact_phone if customer else '') }} {{ field('street', 'Strasse', value=customer.street if customer else '') }} @@ -72,7 +72,7 @@ {% block dialogs %} {% if current_user.has('customers.create') %} - {% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- fuer Rechnungen weiterverwendbar', 'wide') %} + {% call modal('customer-new', 'Kunde anlegen', 'Adresse in Einzelfeldern -- für Rechnungen weiterverwendbar', 'wide') %}
diff --git a/apps/tesm-license/src/tesm_license/templates/license/dashboard.html b/apps/tesm-license/src/tesm_license/templates/license/dashboard.html index 05ae415..f3afea0 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/dashboard.html +++ b/apps/tesm-license/src/tesm_license/templates/license/dashboard.html @@ -1,7 +1,7 @@ {% extends "tesm_core/base.html" %} {% from "tesm_core/macros.html" import pill, empty_state, alert, stat %} -{% block page_title %}Uebersicht{% endblock %} +{% block page_title %}Übersicht{% endblock %} {% block page_sub %} {% if summary %} {{ summary.counts.customers }} Kunden · {{ summary.counts.licenses_active }} aktive Lizenzen @@ -24,13 +24,13 @@
{% if summary.counts.tickets_blocked %} - {{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- fuer diese Kunden + {{ alert(summary.counts.tickets_blocked ~ ' Ticket(s) sind gesperrt -- für diese Kunden lassen sich keine neuen Lizenzen ausstellen.', 'warning') }} {% endif %} {% for key, label, kind, hint in [ ('red', 'Kritisch', 'danger', 'Weniger als 30 Tage Restlaufzeit oder bereits abgelaufen'), - ('orange', 'Bald faellig', 'warning', '30 bis 90 Tage Restlaufzeit'), + ('orange', 'Bald fällig', 'warning', '30 bis 90 Tage Restlaufzeit'), ('green', 'In Ordnung', 'ok', 'Mehr als 90 Tage oder unbefristet')] %} {% set entries = summary.buckets[key] %} {% if entries %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/license_detail.html b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html index aa1088d..92e2cd3 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/license_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/license_detail.html @@ -26,13 +26,13 @@ {% block content %} {% if state.revision_pending %} {{ alert('Es wurde eine neue Revision signiert, die der Kunde noch nicht abgeholt hat. - Eine erreichbare Installation holt sie beim naechsten Heartbeat; eine Installation ohne - Verbindung braucht dafuer den Offline-Weg -- der Kunde findet ihn in seinem Portal.', + Eine erreichbare Installation holt sie beim nächsten Heartbeat; eine Installation ohne + Verbindung braucht dafür den Offline-Weg -- der Kunde findet ihn in seinem Portal.', 'warning', 'Revision ausstehend') }} {% endif %} {% if license.status == 'revoked' %} - {{ alert('Diese Lizenz ist widerrufen. Der Client erfaehrt das beim naechsten Heartbeat und + {{ alert('Diese Lizenz ist widerrufen. Der Client erfährt das beim nächsten Heartbeat und schaltet seine lizenzpflichtigen Funktionen ab.' ~ ((' Grund: ' ~ license.revoke_reason) if license.revoke_reason else ''), 'danger', 'Widerrufen') }} {% endif %} @@ -72,7 +72,7 @@ {% else %}keine{% endif %}
Ausgestellt
{{ license.issued_at | dt }} von {{ license.created_by or '-' }}
-
Laeuft ab
+
Läuft ab
{% if license.lifetime %}{{ pill('unbefristet', 'online') }} {% else %} @@ -105,9 +105,9 @@
-
{{ icon('refresh', size=17) }} Nachtraeglich anpassen
+
{{ icon('refresh', size=17) }} Nachträglich anpassen
- Signiert eine neue Revision. Der Client erhaelt sie beim naechsten Heartbeat -- + Signiert eine neue Revision. Der Client erhält sie beim nächsten Heartbeat -- ohne erneute Aktivierung.
@@ -126,9 +126,9 @@
{% endif %} - {{ field('extend_days', 'Laufzeit verlaengern (Tage)', type='number', + {{ field('extend_days', 'Laufzeit verlängern (Tage)', type='number', placeholder='0', - hint_text='Wird auf das aktuelle Ablaufdatum aufgeschlagen.') }} + hint_text='Wird auf das aktülle Ablaufdatum aufgeschlagen.') }}
LizenzTypZustandLaeuft abHost
LizenzTypZustandLäuft abHost
- + {% for entry in deliveries %} @@ -170,7 +170,7 @@
{{ icon('file-text', size=17) }} Signiertes Dokument
-
Genau so, wie es signiert wurde -- jede Aenderung bricht die Signatur
+
Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur
{{ icon('chevron-down', size=16) }}
@@ -185,7 +185,7 @@
{{ icon('mail', size=17) }} Per E-Mail versenden
- {% if mail_ready %}Ueber Microsoft Graph{% else %}Nicht konfiguriert{% endif %} + {% if mail_ready %}Über Microsoft Graph{% else %}Nicht konfiguriert{% endif %}
@@ -193,7 +193,7 @@ data-no-guard>
- {{ field('recipient', 'Empfaenger', value=license.contact_email, type='email', + {{ field('recipient', 'Empfänger', value=license.contact_email, type='email', disabled=not mail_ready) }}
diff --git a/apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html b/apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html index f2fa05b..1f85c1c 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html +++ b/apps/tesm-license/src/tesm_license/templates/license/selfservice_offline.html @@ -9,9 +9,9 @@
{{ alert('In Ihrer TESM-Installation unter Verwaltung -- Lizenz auf "Lizenz aktivieren" - klicken. Erscheint dort ein Anfragecode, fuegen Sie ihn hier ein. Sie bekommen einen - Antwortcode zurueck, den Sie in TESM eintragen. Beide Codes sind signiert und lassen - sich unterwegs nicht veraendern.', 'info', 'So geht es') }} + klicken. Erscheint dort ein Anfragecode, fügen Sie ihn hier ein. Sie bekommen einen + Antwortcode zurück, den Sie in TESM eintragen. Beide Codes sind signiert und lassen + sich unterwegs nicht verändern.', 'info', 'So geht es') }} {% if response_code %}
@@ -35,7 +35,7 @@
@@ -44,7 +44,7 @@ {% endif %}

- Zurueck zur Uebersicht + Zurück zur Übersicht

diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_mail.html b/apps/tesm-license/src/tesm_license/templates/license/settings_mail.html index 30e5860..87db40c 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/settings_mail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_mail.html @@ -4,7 +4,7 @@ {% block page_title %}E-Mail-Versand{% endblock %} {% block page_sub %} Microsoft Graph (App-only) · - {% if ready %}einsatzbereit{% else %}nicht vollstaendig konfiguriert{% endif %} + {% if ready %}einsatzbereit{% else %}nicht vollständig konfiguriert{% endif %} {% endblock %} {% block topbar_extra %} @@ -19,8 +19,8 @@ {% endblock %} {% block content %} -{{ alert('Der Versand laeuft ueber den Client-Credentials-Flow von Microsoft Graph -- kein SMTP, -kein zusaetzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung +{{ alert('Der Versand läuft über den Client-Credentials-Flow von Microsoft Graph -- kein SMTP, +kein zusätzliches Paket. Die Anwendung braucht in Azure die Anwendungsberechtigung Mail.Send. Der Verbindungstest holt nur ein Token und verschickt bewusst keine echte Nachricht.', 'info') }} @@ -46,7 +46,7 @@ bewusst keine echte Nachricht.', 'info') }}
Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %} - {{ field('client_secret', 'Neues Secret (leer = unveraendert)', type='password', + {{ field('client_secret', 'Neues Secret (leer = unverändert)', type='password', autocomplete='new-password', disabled=not can_secrets) }} {% if not can_secrets %}

@@ -63,17 +63,17 @@ bewusst keine echte Nachricht.', 'info') }}

-
{{ icon('info', size=17) }} Wofuer der Versand genutzt wird
+
{{ icon('info', size=17) }} Wofür der Versand genutzt wird
  • Lizenzdatei an den Kunden senden (mit Anhang).
  • -
  • Zugangslink fuer das Kundenportal.
  • +
  • Zugangslink für das Kundenportal.

- Das Secret liegt AES-256-GCM-verschluesselt in der Datenbank, gebunden an seinen - Verwendungszweck. Ohne die Schluesseldatei dieser Installation ist es nicht lesbar. + Das Secret liegt AES-256-GCM-verschlüsselt in der Datenbank, gebunden an seinen + Verwendungszweck. Ohne die Schlüsseldatei dieser Installation ist es nicht lesbar.

diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_system.html b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html index b26887d..1511b55 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/settings_system.html +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_system.html @@ -6,8 +6,8 @@ {% block content %} {% if not sysops_available %} - {{ alert('Systemhelfer nicht verfuegbar -- Hostname und Zeitzone koennen hier nur angezeigt, - nicht geaendert werden.', 'info') }} + {{ alert('Systemhelfer nicht verfügbar -- Hostname und Zeitzone können hier nur angezeigt, + nicht geändert werden.', 'info') }} {% endif %}
@@ -16,7 +16,7 @@
{{ icon('users', size=17) }} Kundenportal und Self-Service
-
Zwei unabhaengige Zeitfenster: Link und Sitzung
+
Zwei unabhängige Zeitfenster: Link und Sitzung
@@ -24,10 +24,10 @@
{{ checkbox('portal_enabled', 'Kundenportal aktiv', checked=values.portal_enabled) }}
- {{ field('portal_link_minutes', 'Zugangslink gueltig (Minuten)', + {{ field('portal_link_minutes', 'Zugangslink gültig (Minuten)', value=values.portal_link_minutes, type='number', hint_text=specs['portal_link_minutes'].hint) }} - {{ field('portal_session_minutes', 'Portalsitzung gueltig (Minuten)', + {{ field('portal_session_minutes', 'Portalsitzung gültig (Minuten)', value=values.portal_session_minutes, type='number', hint_text=specs['portal_session_minutes'].hint) }}
@@ -35,7 +35,7 @@ {{ checkbox('self_service_enabled', 'Self-Service-Link je Ticket aktiv', checked=values.self_service_enabled, hint_text=specs['self_service_enabled'].hint) }} - {{ checkbox('allow_customer_revoke', 'Kunden duerfen ihre Lizenz selbst freigeben', + {{ checkbox('allow_customer_revoke', 'Kunden dürfen ihre Lizenz selbst freigeben', checked=values.allow_customer_revoke, hint_text=specs['allow_customer_revoke'].hint) }}
@@ -60,6 +60,9 @@ selected=values.log_rotate_interval) }} {{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep, type='number') }} + {{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)', + value=values.session_idle_minutes, type='number', + hint_text=specs['session_idle_minutes'].hint) }} {{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)', value=values.trash_retention_days, type='number') }}
@@ -105,7 +108,7 @@
{{ icon('sliders', size=17) }} Navigationsreihenfolge
-
Gilt fuer alle Benutzer
+
Gilt für alle Benutzer
diff --git a/apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html b/apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html index c2e5e46..9a9deb2 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html +++ b/apps/tesm-license/src/tesm_license/templates/license/settings_vendor.html @@ -6,7 +6,7 @@ {% block content %} {{ alert('Diese Angaben werden in jede Lizenzdatei eingebettet und beim Kunden auf der -Lizenzseite angezeigt -- inklusive Logo. Der oeffentliche Endpunkt entscheidet zusaetzlich, +Lizenzseite angezeigt -- inklusive Logo. Der öffentliche Endpunkt entscheidet zusätzlich, welche Basis-URL in kundengerichteten Links steht.', 'info') }}
@@ -26,18 +26,18 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }} disabled=not current_user.has('settings_vendor.edit')) }}
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }} - {{ field('master_endpoint', 'Oeffentlicher Endpunkt', value=values.master_endpoint, + {{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint, placeholder='https://lizenz.firma.de', disabled=not current_user.has('settings_vendor.edit'), hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in - jede Lizenz eingebettet und fuer alle kundengerichteten Links verwendet -- statt - des Host-Headers der aktuellen Anfrage, der bei internen Aufrufen falsch waere.', + 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.', extra_class='field--full') }}
Logo (maximal {{ max_logo_kb }} KB)
{% if values.vendor_logo_base64 %} - Aktuelles Logo {% else %}

Kein Logo hinterlegt.

@@ -45,7 +45,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }} - Ein zu grosses Logo wird abgelehnt, die uebrigen Angaben werden trotzdem gespeichert. + Ein zu grosses Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}

Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine - Aenderung 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.

diff --git a/apps/tesm-license/src/tesm_license/templates/license/signing_key.html b/apps/tesm-license/src/tesm_license/templates/license/signing_key.html index 0baf53f..e2d8d90 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/signing_key.html +++ b/apps/tesm-license/src/tesm_license/templates/license/signing_key.html @@ -1,34 +1,34 @@ {% extends "tesm_core/base.html" %} {% from "tesm_core/macros.html" import alert %} -{% block page_title %}Signaturschluessel{% endblock %} -{% block page_sub %}Schluessel-ID {{ key_id }}{% endblock %} +{% block page_title %}Signaturschlüssel{% endblock %} +{% block page_sub %}Schlüssel-ID {{ key_id }}{% endblock %} {% block breadcrumb %} {% endblock %} {% block content %} -{{ alert('Der oeffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit. -Ihn hier anzuzeigen erlaubt es, ihn unabhaengig zu vergleichen, bevor ein Kunde ihn als -vertrauenswuerdig hinterlegt.', 'info') }} +{{ alert('Der öffentliche Teil ist kein Geheimnis -- er reist in jeder ausgestellten Lizenz mit. +Ihn hier anzuzeigen erlaubt es, ihn unabhängig zu vergleichen, bevor ein Kunde ihn als +vertrauenswürdig hinterlegt.', 'info') }} {% if health.problems %} {% for problem in health.problems %}{{ alert(problem, 'danger') }}{% endfor %} {% endif %}
-
{{ icon('key', size=17) }} Oeffentlicher Schluessel
+
{{ icon('key', size=17) }} Öffentlicher Schlüssel
Erzeugt {{ created_at | dt }}
-
Schluessel-ID
{{ key_id }}
+
Schlüssel-ID
{{ key_id }}
Verfahren
Ed25519 (EdDSA)
{{ public_key }}
{{ alert('Der private Teil liegt ausschliesslich auf diesem Host (Modus 0600) und ist aus dem - Deployment-Abgleich ausgeschlossen. Ginge er verloren, waere jede - ausgestellte Lizenz nicht mehr pruefbar -- bitte getrennt sichern.', 'warning') }} + Deployment-Abgleich ausgeschlossen. Ginge er verloren, wäre jede + ausgestellte Lizenz nicht mehr prüfbar -- bitte getrennt sichern.', 'warning') }}
{% endblock %} diff --git a/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html index 1d75ca1..04cfec8 100644 --- a/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html +++ b/apps/tesm-license/src/tesm_license/templates/license/ticket_detail.html @@ -17,11 +17,11 @@ {% block content %} {% if endpoint_missing %} - {{ alert(endpoint_missing, 'danger', 'Keine Ausstellung moeglich') }} + {{ alert(endpoint_missing, 'danger', 'Keine Ausstellung möglich') }} {% endif %} {% if ticket.status == 'blocked' %} {{ alert('Dieses Ticket ist gesperrt. Es lassen sich keine neuen Lizenzen ausstellen -- weder - hier noch ueber den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gueltig.', + hier noch über den Self-Service des Kunden. Bereits ausgestellte Lizenzen bleiben gültig.', 'danger', 'Gesperrt') }} {% endif %} @@ -58,7 +58,7 @@
ZeitWegEmpfaengerDurchDetail
ZeitWegEmpfängerDurchDetail
{{ entry.issued_at | date }} {% if entry.status == 'revoked' %}freigegeben - {% elif entry.status == 'deactivated' %}abgeloest + {% elif entry.status == 'deactivated' %}abgelöst {% else %}{{ entry.status }}{% endif %} {{ entry.hostname or '–' | safe }}
- + {% for entry in licenses %} @@ -115,7 +115,7 @@
-
{{ icon('link', size=17) }} Self-Service fuer den Kunden
+
{{ icon('link', size=17) }} Self-Service für den Kunden
Ohne Anmeldung nutzbar – die Berechtigung ist der Besitz dieses Links
@@ -128,7 +128,7 @@

Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und - neu ausstellen. Der Link enthaelt 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.

@@ -143,7 +143,7 @@
{% elif ticket.role != 'customer' %} - {{ alert('Fuer Lizenzserver-Tickets ist der Self-Service vollstaendig gesperrt -- diese + {{ alert('Für Lizenzserver-Tickets ist der Self-Service vollständig gesperrt -- diese Lizenzen werden ausschliesslich hier verwaltet.', 'info') }} {% endif %} @@ -158,8 +158,8 @@
- {{ alert('Aenderungen wirken auf die naechste Ausstellung. Kunde und Rolle sind - unveraenderlich.', 'info') }} + {{ alert('Änderungen wirken auf die nächste Ausstellung. Kunde und Rolle sind + unveränderlich.', 'info') }}
- Module (nur bei „Individuell“) + Module (nur bei „Individüll“)
{% for module in modules %} {{ checkbox('modules', module_labels[module], value=module) }} @@ -65,7 +65,7 @@ {{ field('valid_days', 'Laufzeit (Tage)', value=default_valid_days, type='number') }}
{{ checkbox('lifetime', 'Unbefristet', - hint_text='Nicht kombinierbar mit einer Testlizenz -- das waere eine + hint_text='Nicht kombinierbar mit einer Testlizenz -- das wäre eine permanente Lizenz ohne Module.') }} {{ textarea_field('notes', 'Interne Notiz', rows=2) }}
@@ -77,15 +77,15 @@
{{ icon('share', size=17) }} Lizenzserver-Lizenz
- Erzeugt eine neue Vertrauensgrenze -- der Empfaenger verwaltet danach eigene Kunden + Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
- {{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver koennen + {{ alert('Ein so lizenzierter Lizenzserver darf alles, was ein Lizenzserver können soll -- ausser selbst weitere Lizenzserver-Lizenzen auszustellen. Er meldet seinen - Bestand automatisch hierher zurueck. Module sind fuer diese Rolle bedeutungslos und - werden ignoriert. Self-Service und Kundenportal sind fuer solche Tickets gesperrt.', + Bestand automatisch hierher zurück. Module sind für diese Rolle bedeutungslos und + werden ignoriert. Self-Service und Kundenportal sind für solche Tickets gesperrt.', 'warning') }}
LizenzAusgestelltZustandLaeuft abHost
LizenzAusgestelltZustandLäuft abHost
{% if not tickets %} - {{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket fuer einen Kunden an.', 'ticket') }} + {{ empty_state('Keine Tickets', 'Legen Sie das erste Ticket für einen Kunden an.', 'ticket') }} {% endif %}
{% endblock %} diff --git a/deploy/systemd/tesm.service b/deploy/systemd/tesm.service index 51cb663..a3b557e 100644 --- a/deploy/systemd/tesm.service +++ b/deploy/systemd/tesm.service @@ -59,7 +59,20 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK LockPersonality=yes MemoryDenyWriteExecute=no SystemCallArchitectures=native -SystemCallFilter=@system-service +# "@mount" zusaetzlich zu @system-service: die Dateifreigaben werden ueber +# mount.cifs eingebunden, und @system-service enthaelt mount/umount2 nicht. +# Ohne diese Ergaenzung scheitert jede Einbindung mit "mount error(1): +# Operation not permitted" -- gemessen, und zwar unabhaengig von Rechten, +# Zugangsdaten und Mount-Optionen: aus einer normalen Root-Shell gelingt +# derselbe Aufruf sofort. Der Umweg ueber nsenter in den Namensraum des Wirts +# ist keine Alternative, weil "RestrictNamespaces=yes" setns verbietet. +# +# Eingebunden wird damit im Mount-Namensraum *dieses* Dienstes. Das ist genau +# richtig: nur er liest die Dateien, andere Prozesse haben dort nichts zu +# suchen, und mit dem Dienst verschwinden die Einbindungen wieder. Die +# Aufraeumaufgabe fuer abgelaufene Einbindungen laeuft deshalb im Webprozess -- +# der Ueberwachungsdienst koennte sie nicht einmal sehen. +SystemCallFilter=@system-service @mount SystemCallErrorNumber=EPERM # Bewusst **kein** einschraenkendes CapabilityBoundingSet: sudo braucht # CAP_SETUID/CAP_SETGID, und der Helfer dahinter braucht Root. Was hier fehlt, diff --git a/docs/BETRIEB.md b/docs/BETRIEB.md index 6cdf02f..a5b3a99 100644 --- a/docs/BETRIEB.md +++ b/docs/BETRIEB.md @@ -2,7 +2,7 @@ Dieses Dokument richtet sich an die Person, die die Anlage installiert, aktualisiert und im Fehlerfall wieder zum Laufen bringt. Es beschreibt, was auf -dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird. +dem Host tatsächlich passiert -- nicht, wie die Oberfläche bedient wird. --- @@ -11,7 +11,7 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird. | Zweck | Pfad | |---|---| | Anwendung (Code, venv, statische Dateien) | `/srv//` | -| Instanzdaten (Datenbank, Schluessel, Lizenz) | `/srv//data/` | +| Instanzdaten (Datenbank, Schlüssel, Lizenz) | `/srv//data/` | | Protokolle | `/var/log//` | | Umgebungsdatei | `/etc/tesm/.env` | | Zertifikate (selbstsigniert) | `/etc/tesm/certs//` | @@ -20,13 +20,13 @@ dem Host tatsaechlich passiert -- nicht, wie die Oberflaeche bedient wird. | Privilegierter Helfer | `/usr/local/lib/tesm/tesm-helper` | | Verwaltungswerkzeug | `/usr/local/bin/-admin` | -`` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusaetzlichen +`` ist `tesm` bzw. `tesm-license` -- oder, bei einer zusätzlichen Instanz, `tesm-`. Dieser Name (der **SITE_KEY**) benennt alles, was -ausserhalb von `/srv/` liegt. Deshalb koennen zwei Installationen +ausserhalb von `/srv/` liegt. Deshalb können zwei Installationen derselben Anwendung nebeneinander laufen, ohne sich gegenseitig die -nginx-Site, die netplan-Datei oder das Sitzungscookie zu ueberschreiben. +nginx-Site, die netplan-Datei oder das Sitzungscookie zu überschreiben. -Alles, was ein Mensch verlieren wuerde, liegt unter `data/`. Das Verzeichnis +Alles, was ein Mensch verlieren würde, liegt unter `data/`. Das Verzeichnis wird von `install.sh` nie angefasst. --- @@ -43,10 +43,10 @@ Wichtige Optionen: | Option | Wirkung | |---|---| | `--https` | Port 443 mit TLS, Port 80 leitet dorthin um. Ohne `--domain` entsteht ein selbstsigniertes Zertifikat. | -| `--domain ` | Oeffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. | -| `--acme-email ` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestaetigen. | -| `--instance ` | Zusaetzliche Instanz neben der Hauptinstallation. | -| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur fuer Testinstanzen sinnvoll). | +| `--domain ` | Öffentlicher Name. Setzt `server_name`; die Site ist dann **nicht** mehr die Vorgabe-Site. | +| `--acme-email ` | Erst zusammen mit `--domain` wird ein Let's-Encrypt-Zertifikat angefordert (und HSTS gesetzt). Ohne die Adresse bleibt es selbstsigniert -- interne Namen wie `lizenz.firma.local` kann keine CA bestätigen. | +| `--instance ` | Zusätzliche Instanz neben der Hauptinstallation. | +| `--port` / `--http-port` / `--https-port` | Abweichende Ports (nur für Testinstanzen sinnvoll). | | `--skip-packages` | Keine Systempakete installieren. | Danach: @@ -55,17 +55,17 @@ Danach: sudo -u tesm tesm-admin create-admin ``` -Beim Lizenzserver zusaetzlich, **in dieser Reihenfolge**: +Beim Lizenzserver zusätzlich, **in dieser Reihenfolge**: ```bash -sudo -u tesm-license tesm-license-admin init-key # Signaturschluessel -- sofort sichern! +sudo -u tesm-license tesm-license-admin init-key # Signaturschlüssel -- sofort sichern! sudo -u tesm-license tesm-license-admin bootstrap # eigene Master-Lizenz sudo -u tesm-license tesm-license-admin create-admin ``` -> **Der Signaturschluessel ist nicht wiederherstellbar.** Geht +> **Der Signaturschlüssel ist nicht wiederherstellbar.** Geht > `data/master_signing_key.json` verloren, ist keine ausgestellte Lizenz mehr -> pruefbar. Sichern Sie ihn getrennt vom Server. +> prüfbar. Sichern Sie ihn getrennt vom Server. ### Zwei Anwendungen auf einem Host @@ -78,7 +78,7 @@ sudo bash deploy/install.sh --app tesm-license --https --domain lizenz.firma.loc Die Installation **ohne** Domain wird zur Vorgabe-Site (`default_server`) und beantwortet alles, was zu keinem Namen passt. Zwei namenlose Sites auf -demselben Port weist nginx ab -- richtig so, und beim Uebernehmen sofort +demselben Port weist nginx ab -- richtig so, und beim Übernehmen sofort sichtbar. --- @@ -90,14 +90,14 @@ sudo bash deploy/install.sh --app tesm ``` Derselbe Befehl. Das Skript erkennt an der vorhandenen Datenbank, dass es eine -Aktualisierung ist, und tut dann zusaetzlich: +Aktualisierung ist, und tut dann zusätzlich: -1. **Vollstaendige Sicherung** nach `/srv/-backup-` -- - unabhaengig davon, ob sich das Schema geaendert hat. +1. **Vollständige Sicherung** nach `/srv/-backup-` -- + unabhängig davon, ob sich das Schema geändert hat. 2. Dateiabgleich; `data/` bleibt unangetastet. -3. Schema-Migrationen beim Start (versioniert, mit Pruefsumme). -4. **Gesundheitspruefung.** Antwortet die Anwendung nicht, wird der alte Stand - automatisch zurueckgeholt; die fehlgeschlagene Version bleibt unter +3. Schema-Migrationen beim Start (versioniert, mit Prüfsumme). +4. **Gesundheitsprüfung.** Antwortet die Anwendung nicht, wird der alte Stand + automatisch zurückgeholt; die fehlgeschlagene Version bleibt unter `/srv/-failed-` zur Analyse liegen. Eine bereits eingerichtete HTTPS-Konfiguration bleibt erhalten, auch ohne @@ -114,7 +114,7 @@ Drei Wege dorthin: **a) Bei der Installation** -- siehe oben, `--https`. -**b) Nachtraeglich ueber die Kommandozeile:** +**b) Nachträglich über die Kommandozeile:** ```bash sudo -u tesm tesm-admin web-setup --https --self-signed --apply @@ -124,42 +124,42 @@ sudo -u tesm tesm-admin web-setup --https --domain tesm.firma.de \ `--print-config` zeigt die Datei, ohne etwas zu schreiben. -**c) In der Oberflaeche** unter *Einstellungen -> Webserver und TLS*. +**c) In der Oberfläche** unter *Einstellungen -> Webserver und TLS*. Danach muss `COOKIE_SECURE` in `/etc/tesm/.env` auf `1` stehen (alle drei Wege erledigen das selbst) und der Dienst neu gestartet werden. ### Die Site-Datei wird immer neu erzeugt -Sie ist **kein** Ort fuer Handarbeit. Jede Uebernahme schreibt sie vollstaendig -aus der Vorlage neu. Was frueher von Hand hineingeschrieben wurde -- Domain, +Sie ist **kein** Ort für Handarbeit. Jede Übernahme schreibt sie vollständig +aus der Vorlage neu. Was früher von Hand hineingeschrieben wurde -- Domain, Ports, Zertifikatspfade, HSTS, Upload-Grenze -- ist heute Einstellung in der Anwendung. -Der Grund steht im Vorgaengerprojekt: dort wurde die Datei nur angelegt, wenn -sie fehlte. Ein einmal falscher `alias`-Pfad ueberlebte dadurch jahrelang jedes -Update, und die Oberflaeche kam die ganze Zeit ohne Design. +Der Grund steht im Vorgängerprojekt: dort wurde die Datei nur angelegt, wenn +sie fehlte. Ein einmal falscher `alias`-Pfad überlebte dadurch jahrelang jedes +Update, und die Oberfläche kam die ganze Zeit ohne Design. ### Selbstsigniert oder Let's Encrypt? -* **Oeffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS +* **Öffentlich erreichbar, echter DNS-Name:** Let's Encrypt. Nur dann ist HSTS sinnvoll. * **Geschlossenes Netz:** selbstsigniert. Browser zeigen eine Warnung, die - Verbindung ist trotzdem verschluesselt und das Sitzungscookie darf `Secure` + Verbindung ist trotzdem verschlüsselt und das Sitzungscookie darf `Secure` tragen. **HSTS hier nicht einschalten** -- der Browser merkt sich die Vorgabe - monatelang und die Warnung laesst sich dann nicht mehr wegklicken. + monatelang und die Warnung lässt sich dann nicht mehr wegklicken. -Die Erneuerung von Let's Encrypt laeuft ueber `certbot certonly --webroot` -(bewusst nicht ueber das nginx-Plugin, das die erzeugte Site umschreiben -wuerde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar. +Die Erneuerung von Let's Encrypt läuft über `certbot certonly --webroot` +(bewusst nicht über das nginx-Plugin, das die erzeugte Site umschreiben +würde). Der ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar. --- ## 5. Dienste ```bash -systemctl status tesm.service # Weboberflaeche (gunicorn) -systemctl status tesm-monitor.service # Ueberwachungsschleife (nur TESM) +systemctl status tesm.service # Weboberfläche (gunicorn) +systemctl status tesm-monitor.service # Überwachungsschleife (nur TESM) systemctl status tesm-license.service journalctl -u tesm -n 100 -f ``` @@ -179,8 +179,8 @@ Die Anwendung hat keine. Alles, was Root braucht, geht durch **einen** Pfad: /usr/local/lib/tesm/tesm-helper [argumente] ``` -Der Helfer gehoert `root`, ist per sudoers ausschliesslich fuer genau diesen -Pfad mit `NOPASSWD` freigegeben und prueft jedes Argument gegen eine +Der Helfer gehört `root`, ist per sudoers ausschliesslich für genau diesen +Pfad mit `NOPASSWD` freigegeben und prüft jedes Argument gegen eine Positivliste. Er reicht nie etwas an eine Shell weiter (kein `eval`, kein `sh -c`). @@ -191,16 +191,16 @@ sudo /usr/local/lib/tesm/tesm-helper grep -n ')$' /usr/local/lib/tesm/tesm-helper ``` -Regel fuer Aenderungen: **Jedes neue Verb braucht eine Argumentpruefung.** +Regel für Änderungen: **Jedes neue Verb braucht eine Argumentprüfung.** `tests/test_repo_hygiene.py` erzwingt, dass jedes der Anwendung bekannte Verb -im Helfer vorkommt und dass jede Pfadpruefung `..` abweist. +im Helfer vorkommt und dass jede Pfadprüfung `..` abweist. --- ## 7. Sicherung und Wiederherstellung -In der Oberflaeche unter *Sicherung* -- verschluesselte Ausfuhr (Argon2id + -AES-256-GCM) mit einem selbst gewaehlten Kennwort. Der Import zeigt zuerst eine +In der Oberfläche unter *Sicherung* -- verschlüsselte Ausfuhr (Argon2id + +AES-256-GCM) mit einem selbst gewählten Kennwort. Der Import zeigt zuerst eine Vorschau, bevor irgendetwas geschrieben wird. Auf dem Host reicht: @@ -214,10 +214,10 @@ sudo systemctl start tesm Unverzichtbar: * `data/app.db` -- alle Daten -* `data/secret.key` -- ohne sie sind alle Sitzungen ungueltig +* `data/secret.key` -- ohne sie sind alle Sitzungen ungültig * `data/data.keys` -- ohne sie sind **alle gespeicherten Geheimnisse verloren** * `data/license.json` und `data/license_key.json` -* Lizenzserver zusaetzlich: `data/master_signing_key.json` +* Lizenzserver zusätzlich: `data/master_signing_key.json` --- @@ -230,17 +230,17 @@ curl -s http://127.0.0.1:5000/gesundheit ``` Antwortet gunicorn, aber der Browser nicht, liegt es an nginx. -**nginx laesst sich nicht neu laden.** +**nginx lässt sich nicht neu laden.** ```bash sudo nginx -t ``` -Die Uebernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst -zurueck. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft +Die Übernahme aus der Anwendung nimmt eine abgelehnte Konfiguration selbst +zurück. Eine von Hand bearbeitete Datei tut das nicht -- in dem Fall hilft `install.sh`, das eine unbrauchbare Site durch die Vorlage ersetzt. -**Anmeldung schlaegt ohne Fehlermeldung fehl.** +**Anmeldung schlägt ohne Fehlermeldung fehl.** Fast immer `COOKIE_SECURE=1` ohne HTTPS. Der Browser bekommt dann ein Cookie, -das er ueber HTTP nie zuruecksendet. +das er über HTTP nie zurücksendet. ```bash grep COOKIE_SECURE /etc/tesm/tesm.env ``` @@ -250,38 +250,38 @@ Der Reverse Proxy reicht den Host falsch durch. In der Site-Datei muss `proxy_set_header Host $http_host;` stehen -- **nicht** `$host`, das verwirft den Port. -**Statische Dateien fehlen, die Oberflaeche ist unformatiert.** +**Statische Dateien fehlen, die Oberfläche ist unformatiert.** ```bash grep alias /etc/nginx/sites-available/tesm ls /srv/tesm/static /srv/tesm/static-core ``` -Beide `alias`-Pfade muessen in das Installationsverzeichnis zeigen. +Beide `alias`-Pfade müssen in das Installationsverzeichnis zeigen. -**Zwei Instanzen stoeren sich.** +**Zwei Instanzen stören sich.** `SITE_KEY` in beiden `/etc/tesm/*.env` vergleichen -- er muss sich unterscheiden. Sonst teilen sich beide nginx-Site und Sitzungscookie. -**Die Wartung meldet "Der SSH-Host-Schluessel ist unbekannt".** +**Die Wartung meldet "Der SSH-Host-Schlüssel ist unbekannt".** Kein Fehler, sondern die Absicht: ein Hintergrundauftrag darf keinen fremden -Schluessel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf -der Detailseite des Geraets (*Clients -> Geraet -> SSH-Host-Schluessel*): -erst **Auslesen**, dann den Fingerprint mit einer unabhaengigen Quelle -vergleichen, dann **Freigeben**. Die Wartungsuebersicht zeigt in einer eigenen -Spalte, fuer welche Geraete das noch offen ist. Fuer Switche liegt dieselbe +Schlüssel akzeptieren. Die Freigabe erfolgt von Hand, in zwei Schritten, auf +der Detailseite des Geräts (*Clients -> Gerät -> SSH-Host-Schlüssel*): +erst **Auslesen**, dann den Fingerprint mit einer unabhängigen Quelle +vergleichen, dann **Freigeben**. Die Wartungsübersicht zeigt in einer eigenen +Spalte, für welche Geräte das noch offen ist. Für Switche liegt dieselbe Freigabe auf der Switch-Detailseite. -Der Schluessel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM -verbindet sich immer ueber die hinterlegte IP-Adresse. Aendert sich die IP -eines Geraets, ist der Schluessel erneut freizugeben. +Der Schlüssel wird nach **IP und Port** gespeichert, nicht nach Name -- TESM +verbindet sich immer über die hinterlegte IP-Adresse. Ändert sich die IP +eines Geräts, ist der Schlüssel erneut freizugeben. -**Die Pruefkette des Aenderungsprotokolls ist gebrochen.** +**Die Prüfkette des Änderungsprotokolls ist gebrochen.** Unter *Diagnose* sichtbar. Das Protokoll ist verkettet gehasht; ein Bruch heisst, dass jemand direkt in die Datenbank geschrieben hat. Der Eintrag, ab dem es nicht mehr stimmt, wird genannt. --- -## 9. Zurueck auf eine aeltere Version +## 9. Zurück auf eine ältere Version ```bash sudo systemctl stop tesm @@ -290,6 +290,6 @@ sudo cp -a /srv/tesm-backup- /srv/tesm sudo systemctl start tesm ``` -Schema-Migrationen laufen nur vorwaerts. Ein Ruecksprung ueber eine -Schema-Aenderung hinweg braucht deshalb auch die Datenbank aus der Sicherung -- +Schema-Migrationen laufen nur vorwärts. Ein Rücksprung über eine +Schema-Änderung hinweg braucht deshalb auch die Datenbank aus der Sicherung -- genau das liegt im Backup-Verzeichnis. diff --git a/docs/SICHERHEIT.md b/docs/SICHERHEIT.md index feed727..68104f0 100644 --- a/docs/SICHERHEIT.md +++ b/docs/SICHERHEIT.md @@ -1,6 +1,6 @@ # Sicherheit -Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind. +Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind. --- @@ -9,35 +9,35 @@ Was geschuetzt wird, womit, und wo bewusst Grenzen gezogen sind. | | | |---|---| | Speicherung | Argon2id (argon2-cffi) | -| Altbestand | bcrypt wird noch geprueft und bei der naechsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt | -| Zweiter Faktor | TOTP, optional erzwingbar fuer Administratoren | +| 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 | | Sperre je Konto | nach `LOGIN_MAX_ATTEMPTS` Versuchen | -| Sperre je Adresse | zusaetzlich `LOGIN_MAX_ATTEMPTS_PER_IP` -- sonst laesst sich die Kontosperre umgehen, indem man viele Konten je einmal probiert | -| Verzeichnisdienst | LDAP nur ueber TLS, Zertifikat wird geprueft. Ohne gueltiges Zertifikat keine Anmeldung -- kein Schalter, der das abschaltet | +| 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 | Nach einer Anmeldung wird die Sitzungskennung neu vergeben (gegen Session Fixation) und das CSRF-Token gewechselt. -### Erneute Bestaetigung ("sudo-Modus") +### Erneute Bestätigung ("sudo-Modus") -Alles, was Geheimnisse offenlegt oder Daten aus dem System heraustraegt, +Alles, was Geheimnisse offenlegt oder Daten aus dem System herausträgt, verlangt eine frische Passworteingabe: Klartextanzeige von Zugangsdaten, -Ausfuhr, Herunterladen eines Lizenzbundles, Schluesselrotation, -2FA-Aenderungen, Deaktivierung einer Lizenz. +Ausfuhr, Herunterladen eines Lizenzbundles, Schlüsselrotation, +2FA-Änderungen, Deaktivierung einer Lizenz. -Ein uebernommenes Sitzungscookie allein reicht dafuer nicht. +Ein übernommenes Sitzungscookie allein reicht dafür nicht. --- ## 2. Sitzungen -Serverseitig in SQLite, nicht im Cookie. Das Cookie traegt nur eine +Serverseitig in SQLite, nicht im Cookie. Das Cookie trägt nur eine Zufallskennung. * `HttpOnly`, `SameSite=Lax`, `Secure` sobald HTTPS eingerichtet ist -* Leerlaufzeit **und** absolute Hoechstdauer -* Der Cookiename enthaelt den `SITE_KEY` -- zwei Installationen auf einem Host - koennen sich die Sitzung nicht gegenseitig ueberschreiben (Cookies +* Leerlaufzeit **und** absolute Höchstdauer +* Der Cookiename enthält den `SITE_KEY` -- zwei Installationen auf einem Host + können sich die Sitzung nicht gegenseitig überschreiben (Cookies unterscheiden keine Ports) * Aktive Sitzungen sind einsehbar und einzeln beendbar @@ -45,22 +45,22 @@ Zufallskennung. ## 3. CSRF -Drei voneinander unabhaengige Schichten: +Drei voneinander unabhängige Schichten: 1. `SameSite=Lax` am Sitzungscookie. 2. Ein Token je Sitzung, in jedem Formular, in konstanter Zeit verglichen. -3. Origin-/Referer-Pruefung gegen den Host der Anfrage. +3. Origin-/Referer-Prüfung gegen den Host der Anfrage. Die dritte Schicht akzeptiert den Host mit und ohne Port sowie die Standardports -- **nicht** aber beliebige Ports: ein anderer Dienst auf demselben Rechner ist ein anderer Ursprung. -Der Vorgaenger hatte gar keinen CSRF-Schutz. Eine praeparierte Seite konnte im -Namen eines angemeldeten Administrators Geraete loeschen oder Benutzer anlegen. +Der Vorgänger hatte gar keinen CSRF-Schutz. Eine präparierte Seite konnte im +Namen eines angemeldeten Administrators Geräte löschen oder Benutzer anlegen. -Ausnahmen (`@csrf.exempt`) gibt es nur fuer Maschinenschnittstellen, die sich -**nicht** ueber Cookies autorisieren. Eine cookie-autorisierte Route ohne -CSRF-Schutz waere eine Luecke. +Ausnahmen (`@csrf.exempt`) gibt es nur für Maschinenschnittstellen, die sich +**nicht** über Cookies autorisieren. Eine cookie-autorisierte Route ohne +CSRF-Schutz wäre eine Lücke. --- @@ -69,75 +69,75 @@ CSRF-Schutz waere eine Luecke. Bei jeder Antwort: * `Content-Security-Policy` mit einem Nonce je Anfrage. **Kein** - `unsafe-inline` fuer Skripte -- deshalb gibt es im ganzen Projekt kein - Inline-Script ohne Nonce, und JavaScript haengt ueber + `unsafe-inline` für Skripte -- deshalb gibt es im ganzen Projekt kein + Inline-Script ohne Nonce, und JavaScript hängt über `data-behavior`-Attribute am DOM. `style-src` erlaubt `unsafe-inline`, weil einzelne berechnete Breiten (Fortschrittsbalken) als Style-Attribut gesetzt - werden; Style-Attribute fuehren keinen Code aus. + werden; Style-Attribute führen keinen Code aus. * `X-Content-Type-Options: nosniff` * `X-Frame-Options: DENY`, `frame-ancestors 'none'` * `Referrer-Policy: same-origin` * `Permissions-Policy` -- Kamera, Mikrofon, Ort, Zahlung, USB abgeschaltet * `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy` -* `Strict-Transport-Security` nur, wenn TLS tatsaechlich terminiert wird +* `Strict-Transport-Security` nur, wenn TLS tatsächlich terminiert wird -> HSTS gehoert **nicht** zu einem selbstsignierten Zertifikat. Der Browser -> merkt sich die Vorgabe monatelang; die Warnung laesst sich danach nicht mehr +> HSTS gehört **nicht** zu einem selbstsignierten Zertifikat. Der Browser +> merkt sich die Vorgabe monatelang; die Warnung lässt sich danach nicht mehr > wegklicken. `install.sh` setzt HSTS deshalb nur zusammen mit Let's Encrypt. --- ## 5. Geheimnisse im Ruhezustand -Ein Schluesselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600): +Ein Schlüsselspeicher mit AES-256-GCM (`data/data.keys`, Modus 0600): -* Versionierte Schluessel, rotierbar. Chiffrate tragen ihre Schluesselkennung +* Versionierte Schlüssel, rotierbar. Chiffrate tragen ihre Schlüsselkennung (`v1...`) und bleiben nach einer Rotation lesbar. -* Der Verwendungszweck geht als AAD in die Verschluesselung ein. Ein Chiffrat - aus einem Kontext laesst sich in einem anderen nicht entschluesseln. -* Beim Start wird geprueft, ob die Rechte auf Schluessel- und Lizenzdateien +* Der Verwendungszweck geht als AAD in die Verschlüsselung ein. Ein Chiffrat + aus einem Kontext lässt sich in einem anderen nicht entschlüsseln. +* Beim Start wird geprüft, ob die Rechte auf Schlüssel- und Lizenzdateien noch stimmen; Abweichungen erscheinen unter *Diagnose*. -Passwoerter fuer Mounts gehen ueber `stdin` an den privilegierten Helfer, der +Passwörter für Mounts gehen über `stdin` an den privilegierten Helfer, der sie in eine 0600-Datei schreibt -- nie als Kommandozeilenargument (Prozessliste) -und nie ueber die Umgebung (`/proc//environ`). +und nie über die Umgebung (`/proc//environ`). --- ## 6. Rechtetrennung auf dem Host -Der Webprozess laeuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch +Der Webprozess läuft als `tesm` bzw. `tesm-license`. Root-Aktionen gehen durch genau einen allowlisted Helfer (`/usr/local/lib/tesm/tesm-helper`), der per -sudoers ausschliesslich fuer diesen Pfad freigegeben ist. +sudoers ausschliesslich für diesen Pfad freigegeben ist. Regeln, die im Repository durch Tests erzwungen werden: * Jedes Verb, das die Anwendung kennt, existiert im Helfer. * Kein `eval`, kein `bash -c`, kein `sh -c`. -* Jede Pfadpruefung weist `..` ab -- sonst laesst sich eine Positivliste ueber +* Jede Pfadprüfung weist `..` ab -- sonst lässt sich eine Positivliste über den Basisnamen unterlaufen (`/etc/nginx/sites-available/../../../root/x` hat - den zulaessigen Basisnamen `x`). + den zulässigen Basisnamen `x`). * Der Instanzname ist auf `tesm[-]` begrenzt; er landet in Pfaden. * Keine sudoers-Regel ausser der einen. `NOPASSWD: ALL` gibt es nicht. Die systemd-Units haben `NoNewPrivileges=yes`, `ProtectSystem=strict`, `PrivateTmp=yes` und eine enge `RestrictAddressFamilies`-Liste. -Der Vorgaenger liess die komplette Flask-Anwendung als root laufen. Eine -einzige Luecke in irgendeinem Pfad haette den Host bedeutet. +Der Vorgänger liess die komplette Flask-Anwendung als root laufen. Eine +einzige Lücke in irgendeinem Pfad hätte den Host bedeutet. --- -## 7. Aenderungsprotokoll +## 7. Änderungsprotokoll -Verkettet gehasht: jeder Eintrag enthaelt den Hash seines Vorgaengers. Ein -nachtraegliches Aendern oder Loeschen faellt auf und wird unter *Diagnose* +Verkettet gehasht: jeder Eintrag enthält den Hash seines Vorgängers. Ein +nachträgliches Ändern oder Löschen fällt auf und wird unter *Diagnose* angezeigt, mitsamt dem Eintrag, ab dem die Kette bricht. -Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdruecklich +Protokolliert wird, wer was wann von welcher Adresse getan hat -- ausdrücklich auch Lesezugriffe auf Geheimnisse (`credential.secret_revealed`). -Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen. +Ältere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen. --- @@ -145,20 +145,20 @@ Aeltere Tage wandern in JSONL-Archive und lassen sich als ZIP herunterladen. Siehe [ARCHITEKTUR.md](ARCHITEKTUR.md), Abschnitt Lizenzierung. Kurz: getrennte Signaturkontexte, kanonisches JSON, Nonce gegen Wiedereinspielung, -Frischefenster, `seq` gegen Rueckstufung, Antwort an die Anfrage gebunden, +Frischefenster, `seq` gegen Rückstufung, Antwort an die Anfrage gebunden, Aussteller-Pinning. -Das Auslieferungsbundle enthaelt den privaten Clientschluessel und ist damit +Das Auslieferungsbundle enthält den privaten Clientschlüssel und ist damit selbst ein Geheimnis: eigenes Recht (`licenses.export`), frische -Passwortbestaetigung, `Cache-Control: no-store`, Eintrag im Zustell- und im -Aenderungsprotokoll. +Passwortbestätigung, `Cache-Control: no-store`, Eintrag im Zustell- und im +Änderungsprotokoll. --- ## 9. Ein- und Ausfuhr -Verschluesselte Umschlaege (Argon2id-Ableitung + AES-256-GCM) mit einem selbst -gewaehlten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben +Verschlüsselte Umschläge (Argon2id-Ableitung + AES-256-GCM) mit einem selbst +gewählten Kennwort. Der Import zeigt eine Vorschau, bevor etwas geschrieben wird; die Vorschau liegt in der Datenbank, nicht im Prozessspeicher -- deshalb braucht der Dienst kein `--workers 1` mehr. @@ -166,11 +166,11 @@ braucht der Dienst kein `--workers 1` mehr. ## 10. Bewusst nicht getan -* **Kein Schalter, der die LDAP-Zertifikatspruefung abschaltet.** Wer ihn +* **Kein Schalter, der die LDAP-Zertifikatsprüfung abschaltet.** Wer ihn einmal setzt, setzt ihn dauerhaft. -* **Kein CSRF-Freibrief fuer cookie-autorisierte Routen.** -* **Kein `unsafe-inline` fuer Skripte**, auch nicht "voruebergehend". -* **Keine Rechteausweitung**, auch nicht fuer Administratoren untereinander. +* **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. * **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung nicht brechen. * **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt. @@ -179,5 +179,5 @@ braucht der Dienst kein `--workers 1` mehr. ## 11. Wenn Sie eine Schwachstelle finden -Bitte nicht oeffentlich melden. Wenden Sie sich an die im Anbieterprofil des +Bitte nicht öffentlich melden. Wenden Sie sich an die im Anbieterprofil des Lizenzservers hinterlegte Adresse. diff --git a/packages/tesm-core/pyproject.toml b/packages/tesm-core/pyproject.toml index 1be1f58..7849ca5 100644 --- a/packages/tesm-core/pyproject.toml +++ b/packages/tesm-core/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "tesm-core" -version = "2.0.0" +version = "2.0.2" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" requires-python = ">=3.11" dependencies = [ diff --git a/packages/tesm-core/src/tesm_core/__init__.py b/packages/tesm-core/src/tesm_core/__init__.py index ed00273..e1d5516 100644 --- a/packages/tesm-core/src/tesm_core/__init__.py +++ b/packages/tesm-core/src/tesm_core/__init__.py @@ -8,7 +8,7 @@ Design-System. from __future__ import annotations -__version__ = "2.0.0" +__version__ = "2.0.2" from .app_factory import create_base_app from .config import CoreConfig, SecurityConfig, load_core_config diff --git a/packages/tesm-core/src/tesm_core/app_factory.py b/packages/tesm-core/src/tesm_core/app_factory.py index d6feb2b..91fd9ba 100644 --- a/packages/tesm-core/src/tesm_core/app_factory.py +++ b/packages/tesm-core/src/tesm_core/app_factory.py @@ -43,6 +43,11 @@ from .views import hostsettings as hostsettings_views from .views import logs as logs_views from .views import trash as trash_views +#: Rechteinhaber, in dieser Reihenfolge. Steht an einer Stelle, weil die +#: Fusszeile in zwei Vorlagen auftaucht (Anwendungsrahmen und Anmeldeseiten) +#: und zwei Texte fuer dieselbe Aussage auseinanderlaufen. +COPYRIGHT_NOTICE = "Tim Eertmoed · WiS IT-Solutions GmbH" + CORE_SETTINGS: tuple[SettingSpec, ...] = ( SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"), SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"), @@ -56,6 +61,18 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = ( minimum=1, maximum=3650, ), + SettingSpec( + "session_idle_minutes", + 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 " + "Einbindung.", + section="system", + minimum=5, + maximum=1440, + ), SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"), SettingSpec("hostname", "", "str", "Hostname", section="system"), SettingSpec( @@ -117,7 +134,7 @@ def create_base_app( #: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die #: Funktionsliste von TESM waere dort schlicht falsch. auth_tagline: str = "", - app_version: str = "2.0.0", + app_version: str = "2.0.2", built_in_trusted_keys: tuple[str, ...] = (), enable_license: bool = True, template_folder: str | None = None, @@ -192,11 +209,25 @@ def create_base_app( ) app.extensions["tesm"] = extension + def _leerlauf(conn: Any) -> int: + """Leerlaufgrenze aus den Einstellungen -- ueber die offene Verbindung. + + Bewusst mit der Verbindung, die die Sitzungsverwaltung schon haelt: eine + zweite zu oeffnen waere eine verschachtelte Datenbankverbindung, und die + gilt hier als Fehler. + """ + try: + wert = int(settings.get(conn, "session_idle_minutes") or 0) + except (TypeError, ValueError): + wert = 0 + return wert if wert > 0 else config.security.session_idle_minutes + app.session_interface = SqliteSessionInterface( database, cookie_name=app.config["SESSION_COOKIE_NAME"], idle_minutes=config.security.session_idle_minutes, absolute_hours=config.security.session_absolute_hours, + idle_lookup=_leerlauf, ) # -- Schema und Startzustand ------------------------------------------ # @@ -264,6 +295,7 @@ def create_base_app( "auth_tagline": auth_tagline, "app_version": app_version, "current_year": datetime.now(timezone.utc).year, + "copyright_notice": COPYRIGHT_NOTICE, "current_user": current_user, "nav_items": nav_items, "palette_entries": palette, diff --git a/packages/tesm-core/src/tesm_core/auth/directory.py b/packages/tesm-core/src/tesm_core/auth/directory.py index 77fb828..c4766e7 100644 --- a/packages/tesm-core/src/tesm_core/auth/directory.py +++ b/packages/tesm-core/src/tesm_core/auth/directory.py @@ -42,7 +42,7 @@ AAD_LDAP_BIND = "service_accounts.ldap_bind" #: userAccountControl-Bit fuer "Konto deaktiviert". ACCOUNTDISABLE = 0x0002 -#: OID fuer rekursive Gruppenauflösung in AD. +#: OID fuer rekursive Gruppenaufloesung in AD. MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941" Status = Literal["ok", "invalid", "unavailable", "disabled"] @@ -349,7 +349,7 @@ def _attr_int(entry: Any, name: str) -> int | None: def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]: - """Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN.""" + """Rekursive Gruppenaufloesung ueber LDAP_MATCHING_RULE_IN_CHAIN.""" try: connection.search( search_base=base, @@ -359,7 +359,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("Gruppenauflösung fehlgeschlagen: %s", exc) + logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc) return () diff --git a/packages/tesm-core/src/tesm_core/config.py b/packages/tesm-core/src/tesm_core/config.py index 0d6fde9..9f7856a 100644 --- a/packages/tesm-core/src/tesm_core/config.py +++ b/packages/tesm-core/src/tesm_core/config.py @@ -45,7 +45,7 @@ class SecurityConfig: cookie_secure: bool = True cookie_samesite: str = "Lax" #: Leerlauf-Zeitfenster einer Sitzung. - session_idle_minutes: int = 60 + session_idle_minutes: int = 30 #: Harte Obergrenze unabhaengig von Aktivitaet. session_absolute_hours: int = 12 #: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt. @@ -147,7 +147,7 @@ def load_core_config( security = SecurityConfig( cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True), cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax", - session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60), + session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 30), session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12), reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15), login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8), diff --git a/packages/tesm-core/src/tesm_core/licensing_client.py b/packages/tesm-core/src/tesm_core/licensing_client.py index f54de86..1b93b88 100644 --- a/packages/tesm-core/src/tesm_core/licensing_client.py +++ b/packages/tesm-core/src/tesm_core/licensing_client.py @@ -48,6 +48,10 @@ SETTING_STATUS = "license_activation_status" SETTING_PENDING = "license_pending_request" SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at" SETTING_HEARTBEAT_ERROR = "license_heartbeat_error" + +#: Ab wann die Restlaufzeit als *Hinweis* erscheint (gruen). Unterhalb von +#: ``EXPIRY_WARNING_DAYS`` uebernimmt die Warnung (orange), nach Ablauf Rot. +EXPIRY_HINT_DAYS = 90 SETTING_EXPORT_GRACE = "license_export_grace" STATUS_INACTIVE = "inactive" @@ -596,14 +600,22 @@ class LicenseManager: message = "Verbindung zum Lizenzserver bestaetigt." self.settings.set(conn, SETTING_PENDING, "", actor=actor) - audit.log( - conn, - actor=actor, - action=f"license.{action}", - target=str(request_payload["license_id"]), - detail=f"Transport: {transport}", - severity="notice", - ) + # Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein + # Ereignis, sondern der Normalzustand. Im Auditlog stand dadurch + # praktisch nur noch er, und die Eintraege, um die es dort geht -- + # Aktivierung, Deaktivierung, Widerruf --, gingen darin unter. Der + # Zeitpunkt des letzten Heartbeats steht ohnehin in den Einstellungen + # und auf der Lizenzseite. Ein *fehlgeschlagener* Heartbeat wird + # weiterhin protokolliert (siehe die Fehlerpfade oben). + if action != "heartbeat": + audit.log( + conn, + actor=actor, + action=f"license.{action}", + target=str(request_payload["license_id"]), + detail=f"Transport: {transport}", + severity="notice", + ) self.reload(conn) return {"ok": True, "transport": transport, "code": "", "message": message} @@ -704,6 +716,19 @@ class LicenseManager: except LicenseError as exc: with self.database.transaction(conn): self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system") + # Der *erfolgreiche* Heartbeat wird nicht protokolliert -- er + # ist der Normalzustand und hat das Auditlog zugeschuettet. + # Ein Fehlschlag dagegen ist genau das, was dort stehen soll: + # er kann bedeuten, dass die Lizenz demnaechst in die + # Kulanzzeit laeuft. + audit.log( + conn, + actor="system", + action="license.heartbeat_failed", + target=self._state.license_id or "", + detail=str(exc)[:500], + severity="warning", + ) return {"ok": False, "message": str(exc)} # ------------------------------------------------------------------ # @@ -761,6 +786,17 @@ class LicenseManager: "dot_class": "status-pill__dot--pulse", "title": "Bitte rechtzeitig verlaengern.", } + # 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", + "css_class": "", + "dot": "1", + "dot_class": "", + "title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.", + } if state.last_heartbeat_at: try: last = lic.parse_iso(state.last_heartbeat_at) diff --git a/packages/tesm-core/src/tesm_core/security/sessions.py b/packages/tesm-core/src/tesm_core/security/sessions.py index b9a01f1..7c1c111 100644 --- a/packages/tesm-core/src/tesm_core/security/sessions.py +++ b/packages/tesm-core/src/tesm_core/security/sessions.py @@ -88,11 +88,24 @@ class SqliteSessionInterface(SessionInterface): cookie_name: str, idle_minutes: int, absolute_hours: int, + idle_lookup: Any = None, ) -> None: self.database = database self.cookie_name = cookie_name self.idle_minutes = idle_minutes self.absolute_hours = absolute_hours + #: Optionale Nachschlagefunktion fuer die Leerlaufgrenze. Damit ist die + #: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und + #: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene. + self._idle_lookup = idle_lookup + + def _idle(self, conn: Any) -> int: + if self._idle_lookup is None: + return self.idle_minutes + try: + return int(self._idle_lookup(conn)) or self.idle_minutes + except Exception: # pragma: no cover - Einstellung unlesbar + return self.idle_minutes # -- Flask-Schnittstelle ------------------------------------------- # @@ -146,7 +159,7 @@ class SqliteSessionInterface(SessionInterface): with self.database.transaction(conn): conn.execute( "UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?", - (_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid), + (_iso(now), _iso(now + timedelta(minutes=self._idle(conn))), sid), ) return session @@ -190,7 +203,7 @@ class SqliteSessionInterface(SessionInterface): session.get("user_id"), _iso(now), _iso(now), - _iso(now + timedelta(minutes=self.idle_minutes)), + _iso(now + timedelta(minutes=self._idle(conn))), _iso(now + timedelta(hours=self.absolute_hours)), session.get("_ip", ""), session.get("_ua", ""), diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css index b79c77d..da61bc6 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/css/components.css @@ -408,6 +408,15 @@ background: var(--danger-soft); color: var(--danger); } + /* Gefuellt statt getoent: "kritisch" muss sich von "Warnung" unterscheiden, + und beide sind rot. */ + .pill--critical { + background: var(--danger); + color: var(--surface-0); + font-weight: 700; + } + .pill--critical::before { background: currentColor; } + .pill--warning, .pill--unknown, .pill--orange { background: var(--warning-soft); color: var(--warning); @@ -436,6 +445,22 @@ background: var(--success); flex-shrink: 0; } + /* Der Knopf sitzt *in* der Pille: kein Rahmen, keine Flaeche, nur das + Symbol in der Farbe der Pille. Ein Standardknopf wuerde sie sprengen. */ + .status-pill__action { display: inline-flex; } + .status-pill__action button { + display: inline-flex; + align-items: center; + padding: 0; + margin-left: 2px; + border: 0; + background: none; + color: inherit; + opacity: 0.65; + cursor: pointer; + } + .status-pill__action button:hover { opacity: 1; } + .status-pill__dot--pulse { animation: pulse-ring 2.4s var(--ease) infinite; } .status-pill--danger { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); color: var(--danger); } .status-pill--danger .status-pill__dot { background: var(--danger); } @@ -655,6 +680,37 @@ } .modal__title { font-size: var(--fs-md); font-weight: 640; flex: 1; } .modal__sub { font-size: var(--fs-xs); color: var(--text-faint); margin-top: 2px; } + /* Eine Datei-Vorschau soll die ganze Flaeche des Dialogs nutzen; der + uebliche Innenabstand des Dialogkoerpers wuerde sie einrahmen. */ + .modal__body--flush { padding: 0; max-height: none; } + .modal__caption { + padding: 0 var(--sp-5) var(--sp-2); + color: var(--text-muted); + font-family: var(--font-mono); + font-size: var(--fs-xs); + word-break: break-all; + } + .file-preview { + display: block; + width: 100%; + height: min(70dvh, 760px); + border: 0; + background: var(--surface-0); + } + /* Der Dateiname ist ein Knopf (er oeffnet die Vorschau), soll aber wie ein + Link aussehen -- ein Knopf mit Rahmen mitten in einer Tabellenzeile waere + unruhig. */ + .file-name { + background: none; + border: 0; + padding: 0; + color: var(--accent); + font: inherit; + text-align: left; + cursor: pointer; + } + .file-name:hover { text-decoration: underline; } + .modal__body { padding: var(--sp-5); overflow-y: auto; @@ -1035,6 +1091,16 @@ .warning-text { color: var(--warning); } .accent-text { color: var(--accent); } .mono { font-family: var(--font-mono); font-size: 0.94em; } + /* Ein Inline-SVG ist im Reset display:block (base.css). In einem Knopf + stoert das nicht -- .btn ist inline-flex. In einem einfachen Link reisst + es die Beschriftung in die naechste Zeile. Fuer solche Links deshalb + ausdruecklich eine Zeile. */ + .link-icon { + display: inline-flex; + align-items: center; + gap: 6px; + } + .nowrap { white-space: nowrap; } .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .break-all { overflow-wrap: anywhere; } diff --git a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js index d329862..ca478bf 100644 --- a/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js +++ b/packages/tesm-core/src/tesm_core/static/tesm_core/js/app.js @@ -28,7 +28,7 @@ function store(key, value) { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { - /* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */ + /* Privatmodus o. Ae. -- Einstellungen sind dann nur fluechtig. */ } } @@ -212,13 +212,21 @@ function initDialogs() { event.preventDefault(); const dialog = document.getElementById(opener.dataset.dialogOpen); if (dialog) { - // Felder aus data-fill-* des Auslösers in das Formular uebernehmen. + // Felder aus data-fill-* des Ausloesers in das Formular uebernehmen. Object.entries(opener.dataset).forEach(([key, value]) => { if (!key.startsWith("fill")) return; const name = key.slice(4); const target = dialog.querySelector(`[data-fill-target="${name}"]`); if (!target) return; - if ("value" in target) target.value = value; + // Wohin der Wert gehoert, haengt am Element: ein Rahmen will die + // Quelle, ein Link das Ziel, ein Feld einen Wert. Ohne diese + // Unterscheidung landete die URL als *Beschriftung* im + // Herunterladen-Knopf -- und weil das href dabei "#" blieb, lud ein + // Klick die Seite selbst als "Herunterladen.htm" herunter. + const als = target.dataset.fillAs; + if (als === "src") target.src = value; + else if (als === "href") target.href = value; + else if ("value" in target) target.value = value; else target.textContent = value; }); openDialog(dialog); diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html index d396fe1..1ceb947 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/_group_picker.html @@ -1,15 +1,15 @@ -{# Auswahlfeld fuer eine Verzeichnisgruppe. +{# Auswahlfeld für eine Verzeichnisgruppe. - Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt - Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten + Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthält + Kommas und Leerzeichen und ein Tippfehler fällt erst bei der nächsten Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt. Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei - beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer - Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein - eingefuegter DN besser als gar keine Zuordnung. + beide gefüllt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer + Umgebung ohne Dienstkonto ist die Suche nicht möglich, und dann ist ein + eingefügter DN besser als gar keine Zuordnung. - Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel), + Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kürzel), optional name_value / dn_value und required. #} {% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='', diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/account.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/account.html index c330720..b898060 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/account.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/account.html @@ -20,7 +20,7 @@
{% if current_user.is_directory_account %} {{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder - Anmeldung uebernommen.', 'info') }} + Anmeldung übernommen.', 'info') }} {% endif %}
{{ field('first_name', 'Vorname', value=profile.first_name, @@ -48,7 +48,7 @@
{% if current_user.is_admin %} - {{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }} + {{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }} {% elif permissions %}
{% for permission in permissions %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html index 74fbc0e..3992050 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/account_security.html @@ -35,26 +35,26 @@
-
{{ icon('lock', size=17) }} Passwort aendern
+
{{ icon('lock', size=17) }} Passwort ändern
{% if current_user.is_directory_account %}
- {{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }} + {{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geändert.', 'info') }}
{% else %}
- {{ field('current_password', 'Aktuelles Passwort', type='password', required=True, + {{ field('current_password', 'Aktülles Passwort', type='password', required=True, autocomplete='current-password') }} {{ field('new_password', 'Neues Passwort', type='password', required=True, autocomplete='new-password', hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }} {{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True, autocomplete='new-password') }} - {{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }} + {{ alert('Nach der Änderung werden alle anderen Sitzungen beendet.', 'info') }}
- +
{% endif %}
@@ -70,7 +70,7 @@
{% if pending_secret %} - {{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }} + {{ alert('Richten Sie den Eintrag in Ihrer App ein und bestätigen Sie mit einem aktüllen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
Geheimnis
{{ pending_secret_grouped }} @@ -87,7 +87,7 @@ - + {% elif totp_enabled %}
@@ -100,7 +100,7 @@
@@ -114,10 +114,10 @@
{% else %} {% if require_totp_for_admins and current_user.is_admin %} - {{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }} + {{ alert('In dieser Installation ist ein zweiter Faktor für Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }} {% endif %}

- Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird. + Ein zweiter Faktor schützt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.

diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/base.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/base.html index 9b98231..f1fd151 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/base.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/base.html @@ -1,6 +1,6 @@ {# ========================================================================== - Grundgeruest aller angemeldeten Seiten. - Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra, + Grundgerüst aller angemeldeten Seiten. + Blöcke: extra_head, page_title, page_sub, breadcrumb, topbar_extra, content, dialogs, scripts ========================================================================== #} @@ -23,9 +23,9 @@
Datei vorhanden
{{ license_state.present | yes_no }}
-
Signatur gueltig
{{ license_state.valid | yes_no }}
+
Signatur gültig
{{ license_state.valid | yes_no }}
Aussteller gepinnt
{{ license_state.issuer_pinned | yes_no }}
Rolle
{{ license_state.role }}
Module
{{ license_state.modules | sort | join(', ') or '–' | safe }}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html index ebca1bb..1d0b9f7 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/error.html @@ -7,11 +7,11 @@
{{ description }}
{% if request_id %} -

Vorgangsnummer fuer die Fehlersuche: {{ request_id }}

+

Vorgangsnummer für die Fehlersuche: {{ request_id }}

{% endif %}
{% endblock %} diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html index aacb4d3..2f1e572 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/group_detail.html @@ -20,8 +20,8 @@ {{ icon('lock', size=18) }}
Systemgruppe
- Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte - nicht bei jedem Neustart auf die Vorlage zurueckgesetzt – + Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte + nicht bei jedem Neustart auf die Vorlage zurückgesetzt – Anpassungen bleiben erhalten. {% if current_user.has('groups.edit') %} @@ -49,8 +49,8 @@ {% if group.preset_key and current_user.is_admin %} {% endif %} @@ -58,7 +58,7 @@
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system, - hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }} + hint_text='Systemgruppen können nicht umbenannt werden.' if group.is_system else '') }} {{ textarea_field('description', 'Beschreibung', value=group.description, rows=2, extra_class='field--full') }}
diff --git a/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html b/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html index e4204d9..0945367 100644 --- a/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html +++ b/packages/tesm-core/src/tesm_core/templates/tesm_core/groups.html @@ -2,7 +2,7 @@ {% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %} {% block page_title %}Gruppen und Rechte{% endblock %} -{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %} +{% block page_sub %}{{ groups | length }} Gruppen · Rechte addieren sich über alle Gruppen eines Benutzers{% endblock %} {% block topbar_extra %} {% if current_user.has('groups.create') %} @@ -13,9 +13,9 @@ {% endblock %} {% block content %} -{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer -alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine -neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }} +{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt für +alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter über eine +neue Gruppe selbst höherstuft.', 'info', 'Eskalationsschutz') }}
{% for group in groups %} @@ -37,9 +37,9 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
+ data-confirm="Gruppe {{ group.name }} löschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehörigen Rechte." + data-confirm-title="Gruppe löschen" data-confirm-ok="Löschen" + aria-label="Löschen">{{ icon('trash', size=15) }}
{% endif %}
@@ -67,7 +67,7 @@ neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }} {% block dialogs %} {% if current_user.has('groups.create') %} - {% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %} + {% call modal('group-new', 'Gruppe anlegen', 'Rechte können anschliessend feiner eingestellt werden', 'wide') %}