Compare commits

...
7 Commits
53 changed files with 1655 additions and 336 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-license" name = "tesm-license"
version = "2.7.3" version = "2.8.1"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -180,7 +180,7 @@ def _reencrypt_secrets(conn: Any, keystore: Any) -> int:
def _status_pills(user: Any) -> list[dict[str, str]]: 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 [] return []
from tesm_core.extension import db from tesm_core.extension import db
@@ -24,14 +24,14 @@ def _allowed_categories() -> list[transfer.Category]:
for category in transfer.CATEGORIES: for category in transfer.CATEGORIES:
if category.admin_only and not current_user.is_admin: if category.admin_only and not current_user.is_admin:
continue continue
if category.sensitive and not current_user.has("backup.secrets"): if category.sensitive and not current_user.has("backup_export.secrets"):
continue continue
allowed.append(category) allowed.append(category)
return allowed return allowed
@bp.get("/") @bp.get("/")
@permission_required("backup.view", "backup.export", "backup.execute", mode="any") @permission_required("backup_export.read", "backup_import.read", mode="any")
def overview(): def overview():
conn = db() conn = db()
preview = None preview = None
@@ -50,13 +50,13 @@ def overview():
preview=preview, preview=preview,
min_passphrase=transfer.MIN_PASSPHRASE_LENGTH, min_passphrase=transfer.MIN_PASSPHRASE_LENGTH,
preview_ttl=transfer.PREVIEW_TTL_MINUTES, 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"]), signing_key_path=str(core().registry["master_key_path"]),
) )
@bp.post("/export") @bp.post("/export")
@permission_required("backup.export") @permission_required("backup_export.export")
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS). # Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
def export(): def export():
conn = db() conn = db()
@@ -95,7 +95,7 @@ def export():
@bp.post("/import/preview") @bp.post("/import/preview")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_preview(): def import_preview():
conn = db() conn = db()
@@ -132,7 +132,7 @@ def import_preview():
@bp.post("/import/apply") @bp.post("/import/apply")
@permission_required("backup.execute") @permission_required("backup_import.execute")
@reauth_required @reauth_required
def import_apply(): def import_apply():
extension = core() extension = core()
@@ -191,7 +191,7 @@ def import_apply():
@bp.post("/import/discard") @bp.post("/import/discard")
@permission_required("backup.execute") @permission_required("backup_import.execute")
def import_discard(): def import_discard():
token = session.pop(PREVIEW_SESSION_KEY, "") token = session.pop(PREVIEW_SESSION_KEY, "")
if token: if token:
@@ -29,13 +29,13 @@ def _form() -> dict[str, str]:
@bp.get("/") @bp.get("/")
@permission_required("customers.view") @permission_required("customers.read")
def overview(): def overview():
return render_template("license/customers.html", customers=issuance.list_customers(db())) return render_template("license/customers.html", customers=issuance.list_customers(db()))
@bp.get("/<int:customer_id>") @bp.get("/<int:customer_id>")
@permission_required("customers.view") @permission_required("customers.read")
def detail(customer_id: int): def detail(customer_id: int):
conn = db() conn = db()
customer = issuance.get_customer(conn, customer_id) customer = issuance.get_customer(conn, customer_id)
@@ -48,7 +48,7 @@ def detail(customer_id: int):
] ]
reported = ( reported = (
reporting.reported_for_customer(conn, customer_id) reporting.reported_for_customer(conn, customer_id)
if current_user.has("reports.view") if current_user.has("reports.read")
else {"customers": [], "source_license_id": ""} else {"customers": [], "source_license_id": ""}
) )
return render_template( return render_template(
@@ -83,7 +83,7 @@ def create():
@bp.post("/<int:customer_id>") @bp.post("/<int:customer_id>")
@permission_required("customers.edit") @permission_required("customers.write")
def update(customer_id: int): def update(customer_id: int):
try: try:
with transaction() as conn: with transaction() as conn:
@@ -21,7 +21,7 @@ bp = Blueprint("dashboard", __name__)
def index(): def index():
conn = db() conn = db()
extension = core() 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 # Leere Huelle statt 403: die Startseite soll fuer jeden erreichbar
# bleiben, auch fuer ein Konto ohne Lizenzrechte. # bleiben, auch fuer ein Konto ohne Lizenzrechte.
return render_template("license/dashboard.html", summary=None, sources=[]) return render_template("license/dashboard.html", summary=None, sources=[])
@@ -30,7 +30,7 @@ def index():
return render_template( return render_template(
"license/dashboard.html", "license/dashboard.html",
summary=summary, 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"), own_role=(extension.license.state.role if extension.license else "master"),
type_labels=dict(), type_labels=dict(),
) )
@@ -42,7 +42,7 @@ bp = Blueprint("licenses", __name__, url_prefix="/licenses")
@bp.get("/") @bp.get("/")
@permission_required("licenses.view") @permission_required("licenses.read")
def overview(): def overview():
conn = db() conn = db()
rows = [ rows = [
@@ -65,7 +65,7 @@ def overview():
@bp.get("/<license_id>") @bp.get("/<license_id>")
@permission_required("licenses.view") @permission_required("licenses.read")
def detail(license_id: str): def detail(license_id: str):
conn = db() conn = db()
row = issuance.get_license(conn, license_id) row = issuance.get_license(conn, license_id)
@@ -100,7 +100,7 @@ def detail(license_id: str):
@bp.post("/from-ticket/<ticket_id>") @bp.post("/from-ticket/<ticket_id>")
@permission_required("licenses.create") @permission_required("license_issue.execute")
def issue(ticket_id: str): def issue(ticket_id: str):
conn = db() conn = db()
as_trial = request.form.get("as_trial") == "1" as_trial = request.form.get("as_trial") == "1"
@@ -182,7 +182,7 @@ def download(license_id: str):
@bp.post("/<license_id>/resign") @bp.post("/<license_id>/resign")
@permission_required("licenses.edit") @permission_required("license_renew.execute")
@reauth_required @reauth_required
def resign(license_id: str): def resign(license_id: str):
"""Signiert eine neue Revision -- z. B. nach einer Modulerweiterung. """Signiert eine neue Revision -- z. B. nach einer Modulerweiterung.
@@ -241,7 +241,7 @@ def resign(license_id: str):
@bp.post("/<license_id>/revoke") @bp.post("/<license_id>/revoke")
@permission_required("licenses.edit") @permission_required("license_revoke.execute")
def revoke(license_id: str): def revoke(license_id: str):
conn = db() conn = db()
try: try:
@@ -369,7 +369,7 @@ def send_mail(license_id: str):
@bp.route("/offline-code", methods=["GET", "POST"]) @bp.route("/offline-code", methods=["GET", "POST"])
@permission_required("licenses.execute") @permission_required("license_activation.execute")
def manual_code(): def manual_code():
"""Verarbeitet einen Offline-Code des Kunden. """Verarbeitet einen Offline-Code des Kunden.
@@ -429,7 +429,7 @@ def manual_code():
@bp.get("/sub-servers") @bp.get("/sub-servers")
@permission_required("reports.view") @permission_required("reports.read")
def reports(): def reports():
conn = db() conn = db()
sources = reporting.reporting_sources(conn) sources = reporting.reporting_sources(conn)
@@ -441,7 +441,7 @@ def reports():
@bp.get("/sub-servers/<license_id>") @bp.get("/sub-servers/<license_id>")
@permission_required("reports.view") @permission_required("reports.read")
def report_detail(license_id: str): def report_detail(license_id: str):
conn = db() conn = db()
row = issuance.get_license(conn, license_id) row = issuance.get_license(conn, license_id)
@@ -42,7 +42,7 @@ MAX_UPLOAD_BYTES = 256 * 1024
@bp.get("/") @bp.get("/")
@permission_required("settings_license.view") @permission_required("settings_license.read")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -69,7 +69,7 @@ def overview():
@bp.post("/install") @bp.post("/install")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def upload(): def upload():
extension = core() extension = core()
manager = extension.license manager = extension.license
@@ -130,26 +130,26 @@ def _handshake(action: str):
@bp.post("/activate") @bp.post("/activate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def activate(): def activate():
return _handshake("activate") return _handshake("activate")
@bp.post("/deactivate") @bp.post("/deactivate")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
@reauth_required @reauth_required
def deactivate(): def deactivate():
return _handshake("deactivate") return _handshake("deactivate")
@bp.post("/heartbeat") @bp.post("/heartbeat")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def heartbeat(): def heartbeat():
return _handshake("heartbeat") return _handshake("heartbeat")
@bp.post("/offline-response") @bp.post("/offline-response")
@permission_required("settings_license.edit") @permission_required("settings_license.write")
def offline_response(): def offline_response():
manager = core().license manager = core().license
if manager is None: if manager is None:
@@ -167,7 +167,7 @@ def offline_response():
@bp.get("/signing-key") @bp.get("/signing-key")
@permission_required("settings_license.view") @permission_required("settings_license.read")
def signing_key_info(): def signing_key_info():
"""Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden. """Zeigt den oeffentlichen Signaturschluessel -- zum Pinnen beim Kunden.
@@ -35,7 +35,7 @@ LOGO_MAGIC = {
@bp.get("/system") @bp.get("/system")
@permission_required("settings_system.view") @permission_required("settings_system.read")
def system(): def system():
extension = core() extension = core()
conn = db() conn = db()
@@ -85,7 +85,7 @@ def _timezone_choices() -> list[str]:
@bp.post("/system") @bp.post("/system")
@permission_required("settings_system.edit") @permission_required("settings_system.write")
def system_save(): def system_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -179,7 +179,7 @@ def set_timezone():
@bp.get("/vendor") @bp.get("/vendor")
@permission_required("settings_vendor.view") @permission_required("settings_vendor.read")
def vendor(): def vendor():
extension = core() extension = core()
conn = db() conn = db()
@@ -201,7 +201,7 @@ def vendor():
@bp.post("/vendor") @bp.post("/vendor")
@permission_required("settings_vendor.edit") @permission_required("settings_vendor.write")
def vendor_save(): def vendor_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -251,7 +251,7 @@ def vendor_save():
@bp.post("/vendor/remove-logo") @bp.post("/vendor/remove-logo")
@permission_required("settings_vendor.edit") @permission_required("settings_vendor.write")
def vendor_logo_remove(): def vendor_logo_remove():
with transaction() as conn: with transaction() as conn:
core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username) core().settings.set(conn, "vendor_logo_base64", "", actor=current_user.username)
@@ -265,7 +265,7 @@ def vendor_logo_remove():
@bp.get("/email") @bp.get("/email")
@permission_required("settings_mail.view") @permission_required("settings_mail.read")
def mail_settings(): def mail_settings():
extension = core() extension = core()
conn = db() conn = db()
@@ -280,7 +280,7 @@ def mail_settings():
@bp.post("/email") @bp.post("/email")
@permission_required("settings_mail.edit") @permission_required("settings_mail.write")
def mail_save(): def mail_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -25,7 +25,7 @@ bp = Blueprint("tickets", __name__, url_prefix="/tickets")
@bp.get("/") @bp.get("/")
@permission_required("tickets.view") @permission_required("tickets.read")
def overview(): def overview():
conn = db() conn = db()
tickets = issuance.list_tickets(conn) tickets = issuance.list_tickets(conn)
@@ -113,7 +113,7 @@ def _endpoint_missing() -> str:
@bp.get("/<ticket_id>") @bp.get("/<ticket_id>")
@permission_required("tickets.view") @permission_required("tickets.read")
def detail(ticket_id: str): def detail(ticket_id: str):
conn = db() conn = db()
ticket = issuance.get_ticket(conn, ticket_id) ticket = issuance.get_ticket(conn, ticket_id)
@@ -150,7 +150,7 @@ def detail(ticket_id: str):
@bp.post("/<ticket_id>") @bp.post("/<ticket_id>")
@permission_required("tickets.edit") @permission_required("tickets.write")
def update(ticket_id: str): def update(ticket_id: str):
conn = db() conn = db()
if issuance.get_ticket(conn, ticket_id) is None: if issuance.get_ticket(conn, ticket_id) is None:
@@ -187,7 +187,7 @@ def update(ticket_id: str):
@bp.post("/<ticket_id>/lock") @bp.post("/<ticket_id>/lock")
@permission_required("tickets.edit") @permission_required("tickets.write")
def toggle_block(ticket_id: str): def toggle_block(ticket_id: str):
conn = db() conn = db()
ticket = issuance.get_ticket(conn, ticket_id) ticket = issuance.get_ticket(conn, ticket_id)
@@ -34,35 +34,35 @@ NAVIGATION = NavTree(
label="Kunden", label="Kunden",
icon="building", icon="building",
endpoint="customers.overview", endpoint="customers.overview",
permissions=("customers.view",), permissions=("customers.read",),
), ),
NavItem( NavItem(
key="tickets", key="tickets",
label="Tickets", label="Tickets",
icon="ticket", icon="ticket",
endpoint="tickets.overview", endpoint="tickets.overview",
permissions=("tickets.view",), permissions=("tickets.read",),
), ),
NavItem( NavItem(
key="licenses", key="licenses",
label="Lizenzen", label="Lizenzen",
icon="shield", icon="shield",
endpoint="licenses.overview", endpoint="licenses.overview",
permissions=("licenses.view",), permissions=("licenses.read",),
), ),
NavItem( NavItem(
key="offline_code", key="offline_code",
label="Offline-Aktivierung", label="Offline-Aktivierung",
icon="transfer", icon="transfer",
endpoint="licenses.manual_code", endpoint="licenses.manual_code",
permissions=("licenses.execute",), permissions=("license_activation.execute",),
), ),
NavItem( NavItem(
key="reports", key="reports",
label="Sub-Lizenzserver", label="Sub-Lizenzserver",
icon="share", icon="share",
endpoint="licenses.reports", endpoint="licenses.reports",
permissions=("reports.view",), permissions=("reports.read",),
condition=_is_master, condition=_is_master,
), ),
NavItem( NavItem(
@@ -75,21 +75,21 @@ NAVIGATION = NavTree(
label="Laufend", label="Laufend",
icon="terminal", icon="terminal",
endpoint="logs.live", endpoint="logs.live",
permissions=("logs_live.view",), permissions=("logs_live.read",),
), ),
NavItem( NavItem(
key="logs_history", key="logs_history",
label="Verlauf", label="Verlauf",
icon="clock", icon="clock",
endpoint="logs.history", endpoint="logs.history",
permissions=("logs_history.view", "logs_history.export"), permissions=("logs_history.read", "logs_history.export"),
), ),
NavItem( NavItem(
key="logs_audit", key="logs_audit",
label="Audit", label="Audit",
icon="history", icon="history",
endpoint="logs.audit_view", 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", label="Benutzer",
icon="users", icon="users",
endpoint="admin.users", endpoint="admin.users",
permissions=("users.view",), permissions=("users.read",),
), ),
NavItem( NavItem(
key="groups", key="groups",
label="Gruppen und Rechte", label="Gruppen und Rechte",
icon="shield-users", icon="shield-users",
endpoint="admin.groups", endpoint="admin.groups",
permissions=("groups.view",), permissions=("groups.read",),
), ),
NavItem( NavItem(
key="settings_vendor", key="settings_vendor",
label="Anbieterdaten", label="Anbieterdaten",
icon="building", icon="building",
endpoint="settings.vendor", endpoint="settings.vendor",
permissions=("settings_vendor.view",), permissions=("settings_vendor.read",),
), ),
NavItem( NavItem(
key="settings_mail", key="settings_mail",
label="E-Mail-Versand", label="E-Mail-Versand",
icon="mail", icon="mail",
endpoint="settings.mail_settings", endpoint="settings.mail_settings",
permissions=("settings_mail.view",), permissions=("settings_mail.read",),
), ),
NavItem( NavItem(
key="settings_system", key="settings_system",
label="System", label="System",
icon="sliders", icon="sliders",
endpoint="settings.system", endpoint="settings.system",
permissions=("settings_system.view",), permissions=("settings_system.read",),
), ),
NavItem( NavItem(
key="settings_network", key="settings_network",
label="Netzwerk", label="Netzwerk",
icon="network", icon="network",
endpoint="hostsettings.network", endpoint="hostsettings.network",
permissions=("settings_network.view",), permissions=("settings_network.read",),
), ),
NavItem( NavItem(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
icon="server", icon="server",
endpoint="hostsettings.web", endpoint="hostsettings.web",
permissions=("settings_web.view",), permissions=("settings_web.read",),
), ),
NavItem( NavItem(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst", label="Verzeichnisdienst",
icon="users", icon="users",
endpoint="hostsettings.directory_settings", endpoint="hostsettings.directory_settings",
permissions=("settings_directory.view",), permissions=("settings_directory.read",),
), ),
NavItem( NavItem(
key="settings_license", key="settings_license",
label="Eigene Lizenz", label="Eigene Lizenz",
icon="key", icon="key",
endpoint="own_license.overview", endpoint="own_license.overview",
permissions=("settings_license.view",), permissions=("settings_license.read",),
), ),
NavItem( NavItem(
key="backup", key="backup",
label="Sicherung", label="Sicherung",
icon="transfer", icon="transfer",
endpoint="backup.overview", endpoint="backup.overview",
permissions=("backup.view", "backup.export", "backup.execute"), permissions=("backup_export.read", "backup_import.read"),
), ),
NavItem( NavItem(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
icon="trash", icon="trash",
endpoint="trash.overview", endpoint="trash.overview",
permissions=("trash.view",), permissions=("trash.read",),
), ),
NavItem( NavItem(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
icon="info", icon="info",
endpoint="diagnostics.overview", endpoint="diagnostics.overview",
permissions=("diagnostics.view",), permissions=("diagnostics.read",),
), ),
), ),
), ),
+118 -37
View File
@@ -28,7 +28,7 @@ A = Action
CUSTOMERS = Resource( CUSTOMERS = Resource(
key="customers", key="customers",
label="Kunden", label="Kunden",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Stammdaten der Kundeninstanzen", hint="Stammdaten der Kundeninstanzen",
action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."}, action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."},
) )
@@ -36,32 +36,71 @@ CUSTOMERS = Resource(
TICKETS = Resource( TICKETS = Resource(
key="tickets", key="tickets",
label="Tickets", label="Tickets",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE), actions=(A.READ, A.CREATE, A.WRITE, A.DELETE),
hint="Dauerhafte Ansprüche auf eine Lizenz", hint="Dauerhafte Ansprüche auf eine Lizenz",
action_hints={ action_hints={
A.EDIT: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die " A.WRITE: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
"nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.", "nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
A.DELETE: "Nur möglich, wenn keine offene Lizenz mehr existiert.", 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( LICENSES = Resource(
key="licenses", key="licenses",
label="Lizenzen", label="Lizenzen",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE, A.EXECUTE, A.EXPORT), actions=(A.READ, A.DELETE, A.EXPORT),
hint="Konkrete, signierte Lizenzdateien", hint="Ausgestellte Lizenzen: Laufzeit, Zustand, Datei",
action_hints={ action_hints={
A.CREATE: "Aus einem Ticket eine Lizenz ausstellen.", A.DELETE: "Eintrag entfernen (nur ohne offene Aktivierung).",
A.EDIT: "Lizenz widerrufen oder eine neue Revision signieren.",
A.EXECUTE: "Offline-Aktivierungscodes verarbeiten.",
A.EXPORT: "Lizenzdatei herunterladen oder per E-Mail versenden.", 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( PORTAL = Resource(
key="portal", key="portal",
label="Kundenportal", label="Kundenportal",
actions=(A.VIEW, A.EXECUTE), actions=(A.READ, A.EXECUTE),
hint="Zugangslinks für Kunden", hint="Zugangslinks für Kunden",
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."}, action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
) )
@@ -69,14 +108,14 @@ PORTAL = Resource(
REPORTS = Resource( REPORTS = Resource(
key="reports", key="reports",
label="Berichte der Sub-Lizenzserver", label="Berichte der Sub-Lizenzserver",
actions=(A.VIEW,), actions=(A.READ,),
hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen", hint="Von untergeordneten Lizenzservern gemeldete Kunden, Tickets und Lizenzen",
) )
MAIL = Resource( MAIL = Resource(
key="settings_mail", key="settings_mail",
label="E-Mail-Versand", 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)", hint="Microsoft Graph (App-only)",
action_hints={ action_hints={
A.EXECUTE: "Verbindungstest ausführen.", A.EXECUTE: "Verbindungstest ausführen.",
@@ -87,7 +126,7 @@ MAIL = Resource(
VENDOR = Resource( VENDOR = Resource(
key="settings_vendor", key="settings_vendor",
label="Anbieterdaten", label="Anbieterdaten",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit", hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
) )
@@ -96,7 +135,17 @@ LICENSING_AREA = Area(
label="Lizenzierung", label="Lizenzierung",
icon="key", icon="key",
hint="Kunden, Tickets, Lizenzen. Ohne diesen Zugang bleibt der gesamte Bereich verborgen.", 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( PERMISSIONS = PermissionTree(
@@ -108,12 +157,38 @@ 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
),
)
ROLE_PRESETS: tuple[RolePreset, ...] = ( ROLE_PRESETS: tuple[RolePreset, ...] = (
RolePreset( RolePreset(
key="viewer", key="viewer",
name="Benutzer", name="Benutzer",
description="Standardgruppe: nur das laufende Protokoll lesen.", description="Standardgruppe: nur das laufende Protokoll lesen.",
grants=(("logs_live", (A.VIEW,)),), grants=(("logs_live", (A.READ,)),),
is_default=True, is_default=True,
), ),
RolePreset( RolePreset(
@@ -123,10 +198,13 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
grants=( grants=(
("customers", "*"), ("customers", "*"),
("tickets", "*"), ("tickets", "*"),
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)), ("licenses", (A.READ, A.EXPORT)),
("license_issue", "*"),
("license_renew", "*"),
("license_revoke", "*"),
("portal", "*"), ("portal", "*"),
("logs_live", (A.VIEW,)), ("logs_live", (A.READ,)),
("logs_audit", (A.VIEW,)), ("logs_audit", (A.READ,)),
), ),
), ),
RolePreset( RolePreset(
@@ -134,11 +212,12 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Support", name="Support",
description="Lesen und Offline-Aktivierungen verarbeiten.", description="Lesen und Offline-Aktivierungen verarbeiten.",
grants=( grants=(
("customers", (A.VIEW,)), ("customers", (A.READ,)),
("tickets", (A.VIEW,)), ("tickets", (A.READ,)),
("licenses", (A.VIEW, A.EXECUTE)), ("licenses", (A.READ,)),
("reports", (A.VIEW,)), ("license_activation", "*"),
("logs_live", (A.VIEW,)), ("reports", (A.READ,)),
("logs_live", (A.READ,)),
), ),
), ),
RolePreset( RolePreset(
@@ -146,16 +225,16 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
name="Revision", name="Revision",
description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.", description="Nur lesen – inklusive Änderungsprotokoll und Diagnose.",
grants=( grants=(
("customers", (A.VIEW,)), ("customers", (A.READ,)),
("tickets", (A.VIEW,)), ("tickets", (A.READ,)),
("licenses", (A.VIEW,)), ("licenses", (A.READ,)),
("reports", (A.VIEW,)), ("reports", (A.READ,)),
("users", (A.VIEW,)), ("users", (A.READ,)),
("groups", (A.VIEW,)), ("groups", (A.READ,)),
("logs_live", (A.VIEW,)), ("logs_live", (A.READ,)),
("logs_history", (A.VIEW, A.EXPORT)), ("logs_history", (A.READ, A.EXPORT)),
("logs_audit", (A.VIEW, A.EXPORT)), ("logs_audit", (A.READ, A.EXPORT)),
("diagnostics", (A.VIEW,)), ("diagnostics", (A.READ,)),
), ),
), ),
RolePreset( RolePreset(
@@ -166,18 +245,20 @@ ROLE_PRESETS: tuple[RolePreset, ...] = (
("users", "*"), ("users", "*"),
("groups", "*"), ("groups", "*"),
("settings_system", "*"), ("settings_system", "*"),
("settings_nav", "*"),
("settings_license", "*"), ("settings_license", "*"),
("settings_directory", (A.VIEW, A.EDIT, A.EXECUTE)), ("settings_directory", (A.READ, A.WRITE, A.EXECUTE)),
("settings_web", "*"), ("settings_web", "*"),
("settings_network", "*"), ("settings_network", "*"),
("settings_mail", (A.VIEW, A.EDIT, A.EXECUTE)), ("settings_mail", (A.READ, A.WRITE, A.EXECUTE)),
("settings_vendor", "*"), ("settings_vendor", "*"),
("backup", (A.VIEW, A.EXECUTE, A.EXPORT)), ("backup", (A.READ, A.EXECUTE, A.EXPORT)),
("trash", "*"), ("trash", "*"),
("diagnostics", (A.VIEW,)), ("diagnostics", (A.READ,)),
("logs_audit", (A.VIEW,)), ("logs_audit", (A.READ,)),
), ),
), ),
FUNKTIONEN_VOLLZUGRIFF,
) )
__all__ = ["PERMISSIONS", "ROLE_PRESETS"] __all__ = ["PERMISSIONS", "ROLE_PRESETS"]
@@ -210,7 +210,21 @@ 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)
LICENSE_MIGRATIONS: tuple[Migration, ...] = ( LICENSE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True), Migration(version=1, name="baseline", sql=V1, allow_integrity_error=True),
Migration(version=2, name="revision-timestamp", sql=V2), Migration(version=2, name="revision-timestamp", sql=V2),
Migration(version=3, name="funktionsgruppe-fuer-admins", fn=uebergang_funktionsgruppe),
) )
@@ -24,6 +24,7 @@ import sqlite3
from typing import Any, Callable, Iterable from typing import Any, Callable, Iterable
from tesm_core.db import Database from tesm_core.db import Database
from tesm_core.rbac.legacy_permissions import umgeschrieben
from tesm_core.transfer import ( from tesm_core.transfer import (
MIN_PASSPHRASE_LENGTH, MIN_PASSPHRASE_LENGTH,
PREVIEW_TTL_MINUTES, PREVIEW_TTL_MINUTES,
@@ -153,6 +154,10 @@ def apply_import(
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
kryptographisch relevant ist (Self-Service-Link bzw. signierte Lizenz-ID). 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} wanted = {key for key in categories if key in CATEGORY_BY_KEY}
report: dict[str, dict[str, int]] = {} report: dict[str, dict[str, int]] = {}
stamp = _stamp() stamp = _stamp()
@@ -332,9 +337,16 @@ def apply_import(
group_id = int(existing["id"]) group_id = int(existing["id"])
bump("groups", "updated") bump("groups", "updated")
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,)) 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( conn.executemany(
"INSERT OR IGNORE INTO group_permissions (group_id, permission) VALUES (?,?)", "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: if "users" in wanted:
@@ -393,6 +405,18 @@ def apply_import(
set_setting(str(key), value) set_setting(str(key), value)
bump("settings", "updated") 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 return report
@@ -171,22 +171,22 @@
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack"> <div class="card__body stack">
{{ field('name', 'Firmenname', value=customer.name, required=True, {{ 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', {{ 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, {{ 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, {{ 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"> <div class="form-grid">
{{ field('street', 'Strasse', value=customer.street, {{ 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, {{ 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, {{ 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, {{ field('city', 'Ort', value=customer.city,
disabled=not current_user.has('customers.edit')) }} disabled=not current_user.has('customers.write')) }}
</div> </div>
{{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3, {{ textarea_field('notes', 'Notizen', value=customer.notes, rows=3,
extra_class='field--full') }} extra_class='field--full') }}
@@ -200,7 +200,7 @@
{{ icon('copy', size=13) }}</button></dd> {{ icon('copy', size=13) }}</button></dd>
</dl> </dl>
</div> </div>
{% if current_user.has('customers.edit') %} {% if current_user.has('customers.write') %}
<div class="card__footer modal__footer--split"> <div class="card__footer modal__footer--split">
{% if current_user.has('customers.delete') %} {% if current_user.has('customers.delete') %}
<button class="btn btn--sm btn--ghost danger-text" type="submit" <button class="btn btn--sm btn--ghost danger-text" type="submit"
@@ -101,7 +101,7 @@
</div> </div>
</section> </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"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -204,13 +204,13 @@
</section> </section>
{% endif %} {% 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"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div> <div class="flex-1"><div class="card__title">{{ icon('alert', size=17) }} Lebenszyklus</div></div>
</div> </div>
<div class="card__body stack stack--tight"> <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" <button class="btn btn--sm btn--danger btn--block" type="button"
data-dialog-open="revoke-dialog">Widerrufen</button> data-dialog-open="revoke-dialog">Widerrufen</button>
<p class="fs-xs faint"> <p class="fs-xs faint">
@@ -239,7 +239,7 @@
{% endblock %} {% endblock %}
{% block dialogs %} {% 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') %} {% call modal('revoke-dialog', 'Lizenz widerrufen', license.customer_name, 'narrow') %}
<form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}"> <form method="post" action="{{ url_for('licenses.revoke', license_id=license.license_id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -6,7 +6,7 @@
{% block topbar_extra %} {% block topbar_extra %}
{{ search_box('licenses-table', 'Kunde, Lizenz-ID, Host ...') }} {{ 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') }}"> <a class="btn btn--sm" href="{{ url_for('licenses.manual_code') }}">
{{ icon('transfer', size=15) }} Offline-Code {{ icon('transfer', size=15) }} Offline-Code
</a> </a>
@@ -89,7 +89,7 @@
</dd> </dd>
</dl> </dl>
</div> </div>
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<div class="card__footer"> <div class="card__footer">
{% if not state.activated %} {% if not state.activated %}
<form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard> <form method="post" action="{{ url_for('own_license.activate') }}" data-no-guard>
@@ -151,7 +151,7 @@
</section> </section>
{% endif %} {% endif %}
{% if current_user.has('settings_license.edit') %} {% if current_user.has('settings_license.write') %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <div class="flex-1">
@@ -33,16 +33,16 @@ bewusst keine echte Nachricht.', 'info') }}
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack"> <div class="card__body stack">
{{ checkbox('graph_enabled', 'E-Mail-Versand aktiv', checked=values.graph_enabled, {{ 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"> <div class="form-grid">
{{ field('graph_tenant_id', 'Verzeichnis-ID (Tenant)', value=values.graph_tenant_id, {{ 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, {{ 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', {{ 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', {{ 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> </div>
<fieldset class="fieldset"> <fieldset class="fieldset">
<legend>Client-Secret {% if has_secret %}{{ pill('gesetzt', 'online') }}{% else %}{{ pill('fehlt', 'warning') }}{% endif %}</legend> <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 %} {% endif %}
</fieldset> </fieldset>
</div> </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> <div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
{% endif %} {% endif %}
</form> </form>
@@ -19,16 +19,16 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
<div class="card__body stack"> <div class="card__body stack">
<div class="form-grid"> <div class="form-grid">
{{ field('vendor_name', 'Anbietername', value=values.vendor_name, {{ 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', {{ 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, {{ 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> </div>
{{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }} {{ textarea_field('vendor_address', 'Adresse', value=values.vendor_address, rows=3) }}
{{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint, {{ field('master_endpoint', 'Öffentlicher Endpunkt', value=values.master_endpoint,
placeholder='https://lizenz.firma.de', 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 hint_text='Basis-URL, unter der Kundeninstanzen diesen Server erreichen. Wird in
jede Lizenz eingebettet und für alle kundengerichteten Links verwendet -- statt 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.', 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> <p class="faint fs-sm">Kein Logo hinterlegt.</p>
{% endif %} {% endif %}
<input class="input" type="file" name="vendor_logo" accept="image/png,image/jpeg,image/svg+xml" <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"> <span class="field__hint">
Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert. Ein zu großes Logo wird abgelehnt, die übrigen Angaben werden trotzdem gespeichert.
</span> </span>
</div> </div>
</fieldset> </fieldset>
</div> </div>
{% if current_user.has('settings_vendor.edit') %} {% if current_user.has('settings_vendor.write') %}
<div class="card__footer modal__footer--split"> <div class="card__footer modal__footer--split">
{% if values.vendor_logo_base64 %} {% if values.vendor_logo_base64 %}
<button class="btn btn--sm btn--ghost" type="submit" <button class="btn btn--sm btn--ghost" type="submit"
@@ -41,7 +41,7 @@
</div> </div>
</div> </div>
<div class="card__body row row--tight"> <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) }}" <form method="post" action="{{ url_for('licenses.issue', ticket_id=ticket.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -163,7 +163,7 @@
<label class="field"> <label class="field">
<span class="field__label">Lizenztyp</span> <span class="field__label">Lizenztyp</span>
<select class="select" name="type" id="ticket-type" <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 %} {% for value in types %}
<option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}> <option value="{{ value }}" {% if value == ticket.type %}selected{% endif %}>
{{ type_labels.get(value, value) }} {{ type_labels.get(value, value) }}
@@ -178,15 +178,15 @@
{% for module in modules %} {% for module in modules %}
{{ checkbox('modules', module_labels[module], value=module, {{ checkbox('modules', module_labels[module], value=module,
checked=module in ticket_modules, checked=module in ticket_modules,
disabled=not current_user.has('tickets.edit')) }} disabled=not current_user.has('tickets.write')) }}
{% endfor %} {% endfor %}
</div> </div>
</fieldset> </fieldset>
{% endif %} {% endif %}
{{ field('valid_days', 'Laufzeit (Tage)', value=ticket.valid_days, type='number', {{ 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, {{ 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) }} {{ textarea_field('notes', 'Interne Notiz', value=ticket.notes, rows=2) }}
<div class="divider"></div> <div class="divider"></div>
<dl class="kv fs-xs"> <dl class="kv fs-xs">
@@ -195,7 +195,7 @@
<dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd> <dt>Angelegt</dt><dd>{{ ticket.created_at | dt }} von {{ ticket.created_by or '-' }}</dd>
</dl> </dl>
</div> </div>
{% if current_user.has('tickets.edit') %} {% if current_user.has('tickets.write') %}
<div class="card__footer"> <div class="card__footer">
<button class="btn btn--primary" type="submit">Speichern</button> <button class="btn btn--primary" type="submit">Speichern</button>
</div> </div>
@@ -203,13 +203,13 @@
</form> </form>
</section> </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"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div> <div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Sperre und Löschung</div></div>
</div> </div>
<div class="card__body stack stack--tight"> <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) }}" <form method="post" action="{{ url_for('tickets.toggle_block', ticket_id=ticket.id) }}"
data-no-guard> data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
+5
View File
@@ -11,6 +11,7 @@ Was geschützt wird, womit, und wo bewusst Grenzen gezogen sind.
| Speicherung | Argon2id (argon2-cffi) | | Speicherung | Argon2id (argon2-cffi) |
| Altbestand | bcrypt wird noch geprüft und bei der nächsten erfolgreichen Anmeldung stillschweigend auf Argon2id umgestellt | | 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 | | 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 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 | | 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 | | 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 CSRF-Freibrief für cookie-autorisierte Routen.**
* **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend". * **Kein `unsafe-inline` für Skripte**, auch nicht "vorübergehend".
* **Keine Rechteausweitung**, auch nicht für Administratoren untereinander. * **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 * **Kein Hostname im Lizenz-Fingerabdruck** -- Umbenennen darf die Bindung
nicht brechen. nicht brechen.
* **Keine von Hand gepflegte nginx-Site.** Sie wird immer neu erzeugt. * **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] [project]
name = "tesm-core" name = "tesm-core"
version = "2.7.3" version = "2.8.1"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -148,7 +148,11 @@ def create_base_app(
#: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide #: Wortmarke, ohne Zeile darunter; die Marke selbst zeigen beide
#: Anwendungen. #: Anwendungen.
auth_tagline: str = "", auth_tagline: str = "",
app_version: str = "2.7.3", #: 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, ...] = (), built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True, enable_license: bool = True,
template_folder: str | None = None, template_folder: str | None = None,
+44 -1
View File
@@ -28,7 +28,7 @@ import sqlite3
from collections import defaultdict from collections import defaultdict
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Iterable from typing import Any, Collection, Iterable, Mapping, Sequence
from .db import Database from .db import Database
@@ -116,6 +116,49 @@ def log(
return int(cursor.lastrowid or 0) 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]: def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``.""" """Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
sql = "SELECT * FROM audit_log ORDER BY id" sql = "SELECT * FROM audit_log ORDER BY id"
@@ -423,18 +423,24 @@ def _apply_group_mappings(
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg. haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
""" """
if not group_dns:
return
mappings = Database.all( 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: if not mappings:
return return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns} 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 = { wanted = {
int(row["app_group_id"]) int(row["app_group_id"])
for row in mappings for row in gruppenzuordnungen
if str(row["ad_group_dn"]).lower() in lowered if str(row["ad_group_dn"]).lower() in lowered
} }
@@ -452,6 +458,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]]: 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,)) return Database.all(conn, "SELECT group_id AS id FROM user_groups WHERE user_id=?", (user_id,))
@@ -77,25 +77,40 @@ class User:
@property @property
def effective_permissions(self) -> frozenset[str]: 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: if self._effective is None:
tree = self._tree tree = self._tree
self._effective = ( if tree is None:
frozenset(tree.effective(self.granted)) if tree is not None else frozenset() 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 return self._effective
def has(self, *permissions: str) -> bool: def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles.""" """True, wenn **alle** angegebenen Rechte greifen.
if self.is_admin:
return True 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: if not permissions:
return True return True
return all(permission in self.effective_permissions for permission in permissions) return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool: def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions effective = self.effective_permissions
return any(permission in effective for permission in permissions) return any(permission in effective for permission in permissions)
@@ -359,15 +359,11 @@ def logout(conn: sqlite3.Connection) -> None:
current_app.logger.exception( current_app.logger.exception(
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook) "Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
) )
with core().database.transaction(conn): # Kein Protokolleintrag fuer das Abmelden: eine Abmeldung aendert
audit.log( # nichts. Sie stand als "Info" in jeder zweiten Zeile und verdraengte
conn, # die Eintraege, um die es geht. Wer wissen will, wer wann da war,
actor=user.username, # liest die Sitzungsuebersicht; fehlgeschlagene Anmeldungen und
actor_id=user.id, # Sperren stehen weiterhin jedes Mal im Protokoll.
action="auth.logout",
target=user.username,
ip=client_ip(),
)
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined] session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
g.user = ANONYMOUS g.user = ANONYMOUS
+144 -2
View File
@@ -141,11 +141,16 @@ CREATE TABLE IF NOT EXISTS service_accounts (
updated_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE, ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL, 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 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); 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, ...] = ( 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=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS), 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),
) )
+33 -11
View File
@@ -50,6 +50,12 @@ class Migration:
sql: str = "" sql: str = ""
fn: MigrationFn | None = None fn: MigrationFn | None = None
allow_integrity_error: bool = False 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=()) tags: tuple[str, ...] = field(default=())
@property @property
@@ -175,17 +181,33 @@ class MigrationRunner:
record = known.get(migration.version) record = known.get(migration.version)
if record is not None: if record is not None:
if record["checksum"] != migration.checksum: if record["checksum"] != migration.checksum:
logger.warning( if migration.geaendert_weil:
"Migration %s/%s (%s) wurde nachträglich geändert " # Mit Begruendung ist es eine Notiz, keine Warnung:
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden " # manchmal muss der Text einer laengst angewendeten
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue " # Migration mit, damit eine **frische** Installation
"Versionsnummer.", # denselben Stand erreicht. Wer das Protokoll liest,
component, # soll den Unterschied zu einem Versehen sehen.
migration.version, logger.info(
migration.name, "Migration %s/%s (%s) wurde absichtlich geändert: %s "
record["checksum"], "Auf dieser Installation ist sie längst angewendet und "
migration.checksum, "wird nicht erneut ausgeführt.",
) component,
migration.version,
migration.name,
migration.geaendert_weil,
)
else:
logger.warning(
"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,
migration.name,
record["checksum"],
migration.checksum,
)
continue continue
try: try:
+47 -23
View File
@@ -16,10 +16,10 @@ A = Action
USERS = Resource( USERS = Resource(
key="users", key="users",
label="Benutzer", 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", hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={ action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung ändern.", A.WRITE: "Stammdaten, Sperre und Gruppenzuordnung ändern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).", A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
}, },
) )
@@ -27,32 +27,43 @@ USERS = Resource(
GROUPS = Resource( GROUPS = Resource(
key="groups", key="groups",
label="Gruppen und Rechte", 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", hint="Rechtematrix pflegen",
action_hints={ 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( SETTINGS_SYSTEM = Resource(
key="settings_system", key="settings_system",
label="Systemeinstellungen", label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation", hint="Hostname, Zeitzone, Protokollrotation, Sitzungsdauer",
action_hints={A.EXECUTE: "Systemaktionen auslösen (Dienste, Netzwerk übernehmen)."}, 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( SETTINGS_LICENSE = Resource(
key="settings_license", key="settings_license",
label="Lizenz", label="Lizenz",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren", hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
) )
SETTINGS_DIRECTORY = Resource( SETTINGS_DIRECTORY = Resource(
key="settings_directory", key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)", 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", hint="Anbindung und Gruppen-Zuordnung",
action_hints={ action_hints={
A.EXECUTE: "Verbindungstest ausführen.", A.EXECUTE: "Verbindungstest ausführen.",
@@ -63,7 +74,7 @@ SETTINGS_DIRECTORY = Resource(
SETTINGS_WEB = Resource( SETTINGS_WEB = Resource(
key="settings_web", key="settings_web",
label="Webserver und TLS", label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE), actions=(A.READ, A.WRITE, A.EXECUTE),
hint="Domain, Zertifikat, HSTS", hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."}, action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
) )
@@ -71,29 +82,40 @@ SETTINGS_WEB = Resource(
SETTINGS_NETWORK = Resource( SETTINGS_NETWORK = Resource(
key="settings_network", key="settings_network",
label="Netzwerkeinstellungen", label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT), actions=(A.READ, A.WRITE),
hint="IP-Adresse, Gateway, DNS des Hosts", hint="IP-Adresse, Gateway, DNS des Hosts",
) )
BACKUP = Resource( #: Sichern und Einspielen sind zwei Zeilen, weil es zwei Vorgaenge mit sehr
key="backup", #: verschiedenen Folgen sind: eine Sicherung zu erzeugen aendert nichts, eine
label="Sicherung und Übertragung", #: einzuspielen aendert alles. Sie liefen unter einem Recht, und wer eine
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS), #: Sicherung ziehen durfte, durfte damit auch eine fremde einspielen.
hint="Verschlüsselter Export und Import", BACKUP_EXPORT = Resource(
key="backup_export",
label="Sicherung erstellen",
actions=(A.READ, A.EXPORT, A.SECRETS),
hint="Verschlüsselter Export des Datenstands",
action_hints={ action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.", A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.", 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( TRASH = Resource(
key="trash", key="trash",
label="Papierkorb", label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE), actions=(A.READ, A.WRITE, A.DELETE),
hint="Gelöschtes wiederherstellen oder endgültig entfernen", hint="Gelöschtes wiederherstellen oder endgültig entfernen",
action_hints={ action_hints={
A.EDIT: "Einträge wiederherstellen.", A.WRITE: "Einträge wiederherstellen.",
A.DELETE: "Einträge endgültig entfernen.", A.DELETE: "Einträge endgültig entfernen.",
}, },
) )
@@ -101,20 +123,20 @@ TRASH = Resource(
DIAGNOSTICS = Resource( DIAGNOSTICS = Resource(
key="diagnostics", key="diagnostics",
label="Diagnose", label="Diagnose",
actions=(A.VIEW,), actions=(A.READ,),
hint="Schemastand, Protokollintegrität, Schlüsselrechte", hint="Schemastand, Protokollintegrität, Schlüsselrechte",
) )
LOGS_LIVE = Resource( LOGS_LIVE = Resource(
key="logs_live", key="logs_live",
label="Laufendes Protokoll", label="Laufendes Protokoll",
actions=(A.VIEW,), actions=(A.READ,),
) )
LOGS_HISTORY = Resource( LOGS_HISTORY = Resource(
key="logs_history", key="logs_history",
label="Protokollverlauf", label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT), actions=(A.READ, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle", hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."}, action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
) )
@@ -122,7 +144,7 @@ LOGS_HISTORY = Resource(
LOGS_AUDIT = Resource( LOGS_AUDIT = Resource(
key="logs_audit", key="logs_audit",
label="Änderungsprotokoll", label="Änderungsprotokoll",
actions=(A.VIEW, A.EXPORT), actions=(A.READ, A.EXPORT),
hint="Wer hat wann was geändert", hint="Wer hat wann was geändert",
) )
@@ -139,11 +161,13 @@ def administration_area(*extra: Resource) -> Area:
USERS, USERS,
GROUPS, GROUPS,
SETTINGS_SYSTEM, SETTINGS_SYSTEM,
SETTINGS_NAV,
SETTINGS_LICENSE, SETTINGS_LICENSE,
SETTINGS_DIRECTORY, SETTINGS_DIRECTORY,
SETTINGS_WEB, SETTINGS_WEB,
SETTINGS_NETWORK, SETTINGS_NETWORK,
BACKUP, BACKUP_EXPORT,
BACKUP_IMPORT,
TRASH, TRASH,
DIAGNOSTICS, DIAGNOSTICS,
*extra, *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",
]
+119 -28
View File
@@ -1,6 +1,14 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen. """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 das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber: 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. rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein 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 Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren. eingestellten Einzelrechte zu verlieren.
@@ -29,28 +37,55 @@ from typing import Iterable, Mapping, Sequence
class Action(str, Enum): 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" CREATE = "create"
EDIT = "edit" WRITE = "write"
DELETE = "delete" DELETE = "delete"
EXECUTE = "execute" EXECUTE = "execute"
EXPORT = "export" EXPORT = "export"
SECRETS = "secrets" SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix. #: Kuerzel, englischer Name, deutsche Beschriftung, Erklaerung.
ACTION_META: dict[Action, tuple[str, str, str]] = { #:
Action.VIEW: ("R", "Ansehen", "Datensätze und Seiten dieses Bereichs lesen."), #: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
Action.CREATE: ("C", "Anlegen", "Neue Datensätze erstellen."), #: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensätze ändern – ohne Löschen."), #: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
Action.DELETE: ("D", "Loeschen", "Datensätze löschen bzw. endgültig entfernen."), ACTION_META: dict[Action, tuple[str, str, str, str]] = {
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen auslösen (Neustart, Dienste, Jobs, Import)."), Action.READ: ("R", "Read", "ansehen", "Datensätze und Seiten dieses Bereichs ansehen."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."), 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: ( Action.SECRETS: (
"S", "S",
"Geheimnisse", "Secrets",
"Klartext",
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.", "Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
), ),
} }
@@ -58,9 +93,12 @@ ACTION_META: dict[Action, tuple[str, str, str]] = {
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus). #: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT}) 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, ...]] = { 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 = "" hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint). #: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict) 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, #: Aktionen, die hier **keine** frische Passwortbestaetigung verlangen,
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als #: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
#: stilles Weglassen in der Route: eine Ausnahme von einer #: stilles Weglassen in der Route: eine Ausnahme von einer
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss. #: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
ohne_wiederanmeldung: tuple[Action, ...] = () 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: def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}" return f"{self.key}.{action.value}"
@@ -101,8 +160,9 @@ class Area:
hint: str = "" hint: str = ""
@property @property
def view_key(self) -> str: def read_key(self) -> str:
return f"{self.key}.view" """Der Kill-Switch des Bereichs. Ohne ihn greift keine Zeile darunter."""
return f"{self.key}.read"
def columns(self) -> tuple[Action, ...]: def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen.""" """Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
@@ -128,6 +188,7 @@ class PermissionTree:
self._parent: dict[str, str] = {} self._parent: dict[str, str] = {}
self._all: list[str] = [] self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {} self._resources: dict[str, tuple[Area, Resource]] = {}
self._verwaltung: frozenset[str] | None = None
seen_keys: set[str] = set() seen_keys: set[str] = set()
for area in self.areas: for area in self.areas:
@@ -135,30 +196,57 @@ class PermissionTree:
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}") raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
seen_keys.add(area.key) seen_keys.add(area.key)
self._by_key[area.key] = area self._by_key[area.key] = area
self._all.append(area.view_key) self._all.append(area.read_key)
for resource in area.resources: for resource in area.resources:
if resource.key in seen_keys: if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}") raise ValueError(f"Doppelter Ressourcenschlüssel: {resource.key}")
seen_keys.add(resource.key) seen_keys.add(resource.key)
if Action.VIEW not in resource.actions: if Action.READ not in resource.actions:
raise ValueError( raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten – " f"Ressource {resource.key} muss die Aktion 'read' anbieten – "
"sie ist Voraussetzung für jede andere Aktion." "sie ist Voraussetzung für jede andere Aktion."
) )
self._resources[resource.key] = (area, resource) self._resources[resource.key] = (area, resource)
for permission in resource.permissions(): for permission in resource.permissions():
self._all.append(permission) self._all.append(permission)
self._parent[permission] = area.view_key self._parent[permission] = area.read_key
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]: def all_keys(self) -> tuple[str, ...]:
return tuple(self._all) 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: def area(self, key: str) -> Area | None:
return self._by_key.get(key) 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) return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None: def resource(self, key: str) -> tuple[Area, Resource] | None:
@@ -185,7 +273,7 @@ class PermissionTree:
def label_for(self, permission: str) -> str: def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs.""" """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" return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".") resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key) entry = self._resources.get(resource_key)
@@ -196,7 +284,10 @@ class PermissionTree:
action = Action(action_value) action = Action(action_value)
except ValueError: except ValueError:
return permission 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]: def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte. """Wirft unbekannte Rechte weg und ergaenzt implizierte.
@@ -204,7 +295,7 @@ class PermissionTree:
Regeln: Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles * Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring). 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 * Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern. Bearbeiter wuerde sich wundern.
@@ -224,13 +315,13 @@ class PermissionTree:
result.add(candidate) result.add(candidate)
for area in self.areas: for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()): 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) return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]: def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen.""" """Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions) 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 { return {
key key
for key in granted for key in granted
@@ -29,7 +29,7 @@ from typing import Any, Iterable, Sequence
from ..db import Database from ..db import Database
from ..security import passwords from ..security import passwords
from .model import Action, PermissionTree, RolePreset from .model import ACTION_META, Action, PermissionTree, RolePreset
class PermissionDenied(Exception): class PermissionDenied(Exception):
@@ -124,7 +124,20 @@ def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
def guard_no_amplification( def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht" actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None: ) -> 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): if getattr(actor, "is_admin", False):
return return
missing = sorted(p for p in added if not actor.has(p)) missing = sorted(p for p in added if not actor.has(p))
@@ -315,6 +328,49 @@ def ensure_presets(
return created return created
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.
"""
zeile = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if zeile is None:
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))
else:
gruppe = int(zeile["id"])
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: def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value( value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1" conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
@@ -556,14 +612,20 @@ def update_user(
) )
fields: dict[str, Any] = {} 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 ( for column, value in (
("first_name", first_name), ("first_name", first_name),
("last_name", last_name), ("last_name", last_name),
("email", email), ("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() fields[column] = value.strip()
changes.append(f"{column} geändert") changes.append(
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
)
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]): if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
if not getattr(actor, "is_admin", False): if not getattr(actor, "is_admin", False):
@@ -677,7 +739,7 @@ def matrix_state(
granted = set(permissions) granted = set(permissions)
state: dict[str, dict[str, bool]] = {} state: dict[str, dict[str, bool]] = {}
for area in tree.areas: 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: for resource in area.resources:
state[resource.key] = { state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions action.value: resource.permission(action) in granted for action in resource.actions
@@ -690,7 +752,7 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
granted = set(permissions) granted = set(permissions)
lines: list[str] = [] lines: list[str] = []
for area in tree.areas: for area in tree.areas:
if area.view_key not in granted: if area.read_key not in granted:
continue continue
parts: list[str] = [] parts: list[str] = []
for resource in area.resources: for resource in area.resources:
@@ -706,12 +768,9 @@ def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> l
return lines 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_LETTERS: dict[Action, str] = {
Action.VIEW: "R", action: meta[0] for action, meta in ACTION_META.items()
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
} }
@@ -58,6 +58,30 @@ class SettingSpec:
return str(raw) return str(raw)
def serialize(self, value: Any) -> str: 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": if self.kind == "bool":
return "1" if value else "0" return "1" if value else "0"
if self.kind == "json": if self.kind == "json":
@@ -67,6 +91,45 @@ class SettingSpec:
return "" if value is None else str(value) 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: class SettingsStore:
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung.""" """Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
@@ -140,16 +203,24 @@ class SettingsStore:
def set_many( def set_many(
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = "" self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
) -> list[str]: ) -> list["Aenderung"]:
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck.""" """Setzt mehrere Werte und gibt die Aenderungen zurueck.
changed: list[str] = []
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)) current = self.get_many(conn, list(values))
for key, value in values.items(): for key, value in values.items():
spec = self.spec(key) spec = self.spec(key)
if spec.serialize(current.get(key)) == spec.serialize(value): if spec.serialize(current.get(key)) == spec.serialize(value):
continue continue
self.set(conn, key, value, actor=actor) self.set(conn, key, value, actor=actor)
changed.append(key) changed.append(Aenderung(key, current.get(key), value, geheim=spec.kind == "secret"))
return changed return changed
def delete(self, conn: sqlite3.Connection, key: str) -> None: def delete(self, conn: sqlite3.Connection, key: str) -> None:
@@ -1010,7 +1010,25 @@
text-align: center; text-align: center;
} }
.matrix thead th:first-child { text-align: left; } .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 { .matrix tr.matrix__area > td {
background: var(--surface-2); background: var(--surface-2);
font-weight: 640; font-weight: 640;
@@ -1036,6 +1054,46 @@
} }
.matrix__resource-label { display: flex; flex-direction: column; gap: 1px; } .matrix__resource-label { display: flex; flex-direction: column; gap: 1px; }
.matrix__resource-label small { color: var(--text-faint); font-size: var(--fs-xs); } .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 { .matrix__legend {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1456,18 +1514,23 @@
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz. Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger "touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
zieht statt die Seite zu scrollen. */ 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 { .viewer__unterschrift {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
container-type: inline-size; /* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
border: 1px dashed transparent; groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
outline: 1px dashed transparent;
outline-offset: 1px;
} }
.viewer__unterschrift:hover, .viewer__unterschrift:hover,
.viewer__unterschrift.is-fassend { .viewer__unterschrift.is-fassend {
border-color: var(--accent); outline-color: var(--accent);
cursor: grabbing; cursor: grabbing;
} }
@@ -1483,11 +1546,23 @@
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
anderes als die Datei. */ anderes als die Datei. */
.viewer__unterschrifttext { .viewer__unterschrifttext {
font-size: 5.6cqw; /* 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; line-height: 1.15;
color: #111; color: #111;
border-top: 1px solid rgba(0, 0, 0, 0.35); border-top: 1px solid rgba(0, 0, 0, 0.85);
padding-top: 1px;
pointer-events: none; pointer-events: none;
white-space: nowrap; white-space: nowrap;
width: max-content; width: max-content;
@@ -1581,3 +1656,76 @@
color: var(--danger); color: var(--danger);
background: rgba(201, 42, 42, 0.12); 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;
}
@@ -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) => { $$("[data-area-switch]", table).forEach((master) => {
const areaKey = master.dataset.areaSwitch; const areaKey = master.dataset.areaSwitch;
syncArea(areaKey); syncArea(areaKey);
@@ -451,8 +501,12 @@ function initPermissionMatrix() {
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return; if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
const row = box.closest("tr"); const row = box.closest("tr");
if (!row) return; if (!row) return;
const viewBox = $('input[data-action="view"]', row); // "read", nicht "view": die Aktion heisst seit 2.8.0 wie das, was sie
if (box.dataset.action === "view") { // 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. // "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
if (!box.checked) { if (!box.checked) {
$$('input[type="checkbox"][name]', row).forEach((other) => { $$('input[type="checkbox"][name]', row).forEach((other) => {
@@ -469,6 +523,30 @@ function initPermissionMatrix() {
master.dispatchEvent(new Event("change")); 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();
}
}); });
} }
@@ -297,9 +297,9 @@ async function nachbearbeiten(
const textbreite = Math.max( const textbreite = Math.max(
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse)) ...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
); );
let unten = eintrag.y - 2; let unten = eintrag.y - UNTERSCHRIFT.textY;
for (const zeile of zeilen) { for (const zeile of zeilen) {
unten -= groesse * 1.15; unten -= groesse * UNTERSCHRIFT.zeile;
seite.drawText(zeile, { seite.drawText(zeile, {
x: eintrag.x, x: eintrag.x,
y: unten, y: unten,
@@ -308,11 +308,14 @@ async function nachbearbeiten(
color: PDFLib.rgb(0.1, 0.1, 0.1), 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({ seite.drawLine({
start: { x: eintrag.x, y: eintrag.y - 1 }, start: { x: eintrag.x, y: eintrag.y - UNTERSCHRIFT.strichY },
end: { x: eintrag.x + textbreite, y: eintrag.y - 1 }, end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
thickness: 0.5, thickness: UNTERSCHRIFT.strich,
color: PDFLib.rgb(0.4, 0.4, 0.4), color: PDFLib.rgb(0.15, 0.15, 0.15),
}); });
} }
} }
@@ -339,12 +342,77 @@ async function nachbearbeiten(
return dokument.save(); 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. /** Schriftgroesse des Namenszugs unter einer Unterschrift dieser Breite.
* *
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht * Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe * Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
* Rechnung benutzt die Vorschau (als ``cqw``), damit auf dem Schirm steht, * Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
* was spaeter im PDF steht. * Schirm steht, was spaeter im PDF steht.
*/ */
function schriftgroesse(breite) { function schriftgroesse(breite) {
return Math.max(5, Math.min(10, breite / 18)); return Math.max(5, Math.min(10, breite / 18));
@@ -634,6 +702,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
const MAX_BREITE = 150; const MAX_BREITE = 150;
const MAX_HOEHE = 50; const MAX_HOEHE = 50;
const unterschriften = []; const unterschriften = [];
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
const neuzeichnen = new Set();
let wartendeUnterschrift = null; let wartendeUnterschrift = null;
let zeugenzeile = () => ""; let zeugenzeile = () => "";
@@ -681,11 +751,20 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
const bild = document.createElement("img"); const bild = document.createElement("img");
bild.src = eintrag.vorschau; bild.src = eintrag.vorschau;
bild.alt = ""; 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"); const text = document.createElement("div");
text.className = "viewer__unterschrifttext"; text.className = "viewer__unterschrifttext";
text.textContent = `${eintrag.name} | ${eintrag.zeit}`; const namenszeile = document.createElement("div");
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
text.append(namenszeile);
if (zeuge && eintrag === unterschriften[0]) { if (zeuge && eintrag === unterschriften[0]) {
text.textContent += ` / In TESM abgelegt durch ${zeuge}`; const zeugenzeile = document.createElement("div");
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
text.append(zeugenzeile);
} }
const griff = document.createElement("button"); const griff = document.createElement("button");
griff.type = "button"; griff.type = "button";
@@ -702,16 +781,52 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
seitenEl.append(marke); seitenEl.append(marke);
const zeichnen = () => { 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.left = `${(eintrag.x / blick.width) * 100}%`;
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`; marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
marke.style.width = `${(eintrag.breite / blick.width) * 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(); zeichnen();
weg.addEventListener("click", (event) => { weg.addEventListener("click", (event) => {
event.stopPropagation(); event.stopPropagation();
const stelle = unterschriften.indexOf(eintrag); const stelle = unterschriften.indexOf(eintrag);
if (stelle >= 0) unterschriften.splice(stelle, 1); if (stelle >= 0) unterschriften.splice(stelle, 1);
neuzeichnen.delete(zeichnen);
marke.remove(); marke.remove();
}); });
@@ -739,8 +854,8 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
} else { } else {
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt); eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
eintrag.y = Math.max( eintrag.y = Math.max(
// Platz fuer die Textzeilen unter der Zeichnung. // Platz fuer die beiden Textzeilen unter der Zeichnung.
schriftgroesse(eintrag.breite) * 2.5, UNTERSCHRIFT.textY + 2 * UNTERSCHRIFT.zeile * schriftgroesse(eintrag.breite),
start.y - (zug.clientY - startY) / proPunkt start.y - (zug.clientY - startY) / proPunkt
); );
} }
@@ -763,6 +878,18 @@ async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true }
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler)); 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 { return {
hinein: () => { hinein: () => {
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2); viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
@@ -901,6 +1028,7 @@ export function initFilePreview(wurzel = document) {
leiste.hidden = art === "text"; leiste.hidden = art === "text";
if (speichern) speichern.hidden = art !== "pdf"; if (speichern) speichern.hidden = art !== "pdf";
if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf"; if (unterschreibenKnopf) unterschreibenKnopf.hidden = art !== "pdf";
if (speichern) speichern.hidden = art !== "pdf";
if (unterschriftPanel) unterschriftPanel.hidden = true; if (unterschriftPanel) unterschriftPanel.hidden = true;
}; };
@@ -942,6 +1070,15 @@ export function initFilePreview(wurzel = document) {
if (!adresse) return; if (!adresse) return;
try { try {
const stand = await window.tesm.api(adresse); 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") { if (stand.status === "unveraendert") {
pdfBehaelter.classList.add("viewer__pdf--unveraendert"); pdfBehaelter.classList.add("viewer__pdf--unveraendert");
if (pruefzeile) { if (pruefzeile) {
@@ -47,9 +47,16 @@
</div> </div>
</div> </div>
<div class="card__body"> <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 %} {% if current_user.is_admin %}
{{ alert('Sie sind Administrator und dürfen unabhängig von Gruppen alles.', 'warning') }} {{ alert('Als Administrator haben Sie den Verwaltungsbereich immer –
{% elif permissions %} Benutzer, Gruppen, Einstellungen, Sicherung, Papierkorb, Diagnose.
Alles Weitere steht unten und kommt aus Ihren Gruppen.', 'info') }}
{% endif %}
{% if permissions %}
<div class="row row--tight"> <div class="row row--tight">
{% for permission in permissions %} {% for permission in permissions %}
<span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span> <span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span>
@@ -23,7 +23,7 @@
Name und Rechte sind gesperrt. Anders als im Vorgänger werden die Rechte 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; <strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt &ndash;
Anpassungen bleiben erhalten. 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) }}"> <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 {{ icon('unlock', size=14) }} Rechte freischalten
</a> </a>
@@ -94,7 +94,7 @@
<div class="card__title">{{ icon('users', size=17) }} Mitglieder</div> <div class="card__title">{{ icon('users', size=17) }} Mitglieder</div>
<div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div> <div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div>
</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> <a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a>
{% endif %} {% endif %}
</div> </div>
@@ -44,8 +44,11 @@
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a> <a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zurücksetzen</a>
</form> </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"> <div class="table-wrap">
<table class="table table--compact" data-behavior="sortable"> <table class="table table--compact table--stapelbar" data-behavior="sortable">
<thead> <thead>
<tr> <tr>
<th class="sortable">Zeit</th> <th class="sortable">Zeit</th>
@@ -60,12 +63,18 @@
<tbody> <tbody>
{% for row in rows %} {% for row in rows %}
<tr> <tr>
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td> <td class="fs-sm nowrap" data-spalte="Zeit" data-sort-value="{{ row.ts }}">
<td class="fs-sm fw-600">{{ row.actor }}</td> {{ row.ts | dt }}
<td class="mono fs-xs">{{ row.action }}</td> </td>
<td class="fs-sm">{{ row.target }}</td> <td class="fs-sm fw-600" data-spalte="Benutzer">{{ row.actor }}</td>
<td class="fs-sm muted break-all">{{ row.detail | audit_detail }}</td> <td class="mono fs-xs" data-spalte="Aktion">{{ row.action }}</td>
<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 {# Ampel: Info grün, Hinweis gelb, Warnung rot. Vorher war Info
grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos grau und Hinweis blau -- dabei war nicht zu sehen, was harmlos
ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine ist und was Aufmerksamkeit braucht. "kritisch" bekommt eine
@@ -76,7 +85,7 @@
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }} {% elif row.severity == 'notice' %}{{ pill('Hinweis', 'warning') }}
{% else %}{{ pill('Info', 'online') }}{% endif %} {% else %}{{ pill('Info', 'online') }}{% endif %}
</td> </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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -162,8 +162,25 @@
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '&ndash;' | safe }}</dd> <dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '&ndash;' | safe }}</dd>
{%- endmacro %} {%- 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') -%} {% 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"> <div class="table-wrap">
<table class="matrix" data-behavior="permission-matrix"> <table class="matrix" data-behavior="permission-matrix">
{% for area in tree.areas %} {% for area in tree.areas %}
@@ -172,8 +189,20 @@
<tr> <tr>
<th style="min-width:240px">{{ area.label }}</th> <th style="min-width:240px">{{ area.label }}</th>
{% for action in columns %} {% for action in columns %}
<th title="{{ action_meta[action][1] }} &ndash; {{ action_meta[action][2] }}"> <th title="{{ action_meta[action][1] }} ({{ action_meta[action][2] }}) &ndash;
{{ action_meta[action][0] }} {{ 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> </th>
{% endfor %} {% endfor %}
</tr> </tr>
@@ -182,9 +211,9 @@
<tr class="matrix__area"> <tr class="matrix__area">
<td class="matrix__killswitch"> <td class="matrix__killswitch">
<label class="check"> <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 }}" 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 %}> {% if disabled %}disabled{% endif %}>
<span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span> <span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span>
</label> </label>
@@ -192,23 +221,48 @@
{% for action in columns %}<td></td>{% endfor %} {% for action in columns %}<td></td>{% endfor %}
</tr> </tr>
{% for resource in area.resources %} {% 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> <td>
<span class="matrix__resource-label"> <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 %} {% 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> </span>
</td> </td>
{% for action in columns %} {% for action in columns %}
<td> <td>
{% if resource.has(action) %} {% 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 }}" <input type="checkbox" name="{{ field_prefix }}"
value="{{ resource.permission(action) }}" value="{{ resource.permission(action) }}"
data-action="{{ action.value }}" data-action="{{ action.value }}"
{% if state.get(resource.key, {}).get(action.value) %}checked{% endif %} {% if state.get(resource.key, {}).get(action.value) %}checked{% endif %}
{% if disabled %}disabled{% 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> </label>
{% else %} {% else %}
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das {# Bewusst *kein* Eingabefeld: ein deaktiviertes Kästchen hat das
@@ -229,7 +283,7 @@
</div> </div>
<div class="matrix__legend mt-3"> <div class="matrix__legend mt-3">
{% for action, meta in action_meta.items() %} {% 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 %} {% endfor %}
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen &ndash; <span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen &ndash;
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span> die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
@@ -119,7 +119,13 @@ niemals hier gespeichert.', 'info') }}
<tr> <tr>
<td class="fs-sm">{{ entry.ad_group_name }} <td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td> <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"> <td class="actions">
<form method="post" data-no-guard <form method="post" data-no-guard
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}"> 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') }} 'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }}
{% endif %} {% 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') }}"> <form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body"> <div class="card__body">
<div class="form-grid"> <div class="form-grid">
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }} {{ 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"> <label class="field">
<span class="field__label"> <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> </span>
<select class="select" name="app_group_id" required> <select class="select" name="ziel" required>
{% for group in groups %} {% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option> <option value="gruppe:{{ group.id }}">{{ group.name }}</option>
{% endfor %} {% endfor %}
<option value="administrator">Administrator (Kennzeichen)</option>
</select> </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> </label>
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@ zurückgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
{% endif %} {% endif %}
</section> </section>
{% if current_user.has('settings_network.edit') and interfaces %} {% if current_user.has('settings_network.write') and interfaces %}
<section class="card"> <section class="card">
<div class="card__header"> <div class="card__header">
<div class="flex-1"> <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="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="fs-sm"><span data-relative-time="{{ entry.deleted_at }}">{{ entry.deleted_at | dt }}</span></td>
<td class="actions"> <td class="actions">
{% if current_user.has('trash.edit') %} {% if current_user.has('trash.write') %}
<form method="post" style="display:inline" data-no-guard <form method="post" style="display:inline" data-no-guard
action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}"> action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}"> <input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
@@ -79,7 +79,7 @@
{% else %}<span class="faint">noch nie</span>{% endif %} {% else %}<span class="faint">noch nie</span>{% endif %}
</td> </td>
<td class="actions"> <td class="actions">
{% if current_user.has('users.edit') %} {% if current_user.has('users.write') %}
<button class="btn btn--sm btn--ghost" type="button" <button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten"> data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }} {{ icon('pencil', size=15) }}
@@ -137,7 +137,18 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</div> </div>
</fieldset> </fieldset>
{% if current_user.is_admin %} {% 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 %} {% endif %}
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }} {{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
</div> </div>
@@ -180,7 +191,7 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if current_user.has('users.edit') %} {% if current_user.has('users.write') %}
{% for user in users %} {% for user in users %}
{% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username, {% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username,
'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %} 'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %}
@@ -211,7 +222,8 @@ Administratorkonto muss immer bestehen bleiben.', 'info') }}
</fieldset> </fieldset>
<div class="stack stack--tight"> <div class="stack stack--tight">
{% if current_user.is_admin and user.id != current_user.id %} {% 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 %} {% elif user.is_admin %}
{{ pill('Administrator', 'accent') }} {{ pill('Administrator', 'accent') }}
{% endif %} {% endif %}
+1 -1
View File
@@ -326,7 +326,7 @@ def audit_detail(text: Any) -> Markup:
if not roh or not MITSCHRIFT.search(roh): if not roh or not MITSCHRIFT.search(roh):
return Markup(escape(roh)) return Markup(escape(roh))
try: 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 except Exception: # noqa: BLE001 - ausserhalb einer Anfrage
erlaubt = False erlaubt = False
if not erlaubt: if not erlaubt:
@@ -48,7 +48,7 @@ def _int_list(name: str) -> list[int]:
@bp.get("/users") @bp.get("/users")
@permission_required("users.view") @permission_required("users.read")
def users(): def users():
conn = db() conn = db()
return render_template( return render_template(
@@ -95,7 +95,7 @@ def user_create():
@bp.post("/users/<int:user_id>") @bp.post("/users/<int:user_id>")
@permission_required("users.edit") @permission_required("users.write")
def user_update(user_id: int): def user_update(user_id: int):
conn = db() conn = db()
try: try:
@@ -164,7 +164,7 @@ def user_delete(user_id: int):
@bp.post("/users/<int:user_id>/end-sessions") @bp.post("/users/<int:user_id>/end-sessions")
@permission_required("users.edit") @permission_required("users.write")
def user_revoke_sessions(user_id: int): def user_revoke_sessions(user_id: int):
conn = db() conn = db()
target = rbac.get_user(conn, user_id) target = rbac.get_user(conn, user_id)
@@ -239,7 +239,7 @@ def directory_adopt():
@bp.get("/groups") @bp.get("/groups")
@permission_required("groups.view") @permission_required("groups.read")
def groups(): def groups():
conn = db() conn = db()
tree = core().permissions tree = core().permissions
@@ -255,7 +255,7 @@ def groups():
@bp.get("/groups/<int:group_id>") @bp.get("/groups/<int:group_id>")
@permission_required("groups.view") @permission_required("groups.read")
def group_detail(group_id: int): def group_detail(group_id: int):
conn = db() conn = db()
group = rbac.get_group(conn, group_id) 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", "WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE",
(group_id,), (group_id,),
), ),
can_edit=current_user.has("groups.edit"), can_edit=current_user.has("groups.write"),
unlocked=request.args.get("unlocked") == "1", unlocked=request.args.get("unlocked") == "1",
) )
@@ -302,7 +302,7 @@ def group_create():
@bp.post("/groups/<int:group_id>") @bp.post("/groups/<int:group_id>")
@permission_required("groups.edit") @permission_required("groups.write")
def group_update(group_id: int): def group_update(group_id: int):
conn = db() conn = db()
try: try:
@@ -427,9 +427,15 @@ def nav_order():
if submitted: if submitted:
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted) child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
with transaction() as conn: 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_order", order, actor=current_user.username)
extension.settings.set(conn, "nav_child_order", child_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") flash("Reihenfolge der Navigation gespeichert.", "success")
return redirect(request.referrer or url_for("index")) return redirect(request.referrer or url_for("index"))
@@ -28,7 +28,7 @@ bp = Blueprint("diagnostics", __name__, url_prefix="/administration/diagnostics"
@bp.get("/") @bp.get("/")
@permission_required("diagnostics.view") @permission_required("diagnostics.read")
def overview(): def overview():
extension = core() extension = core()
conn = db() conn = db()
@@ -65,7 +65,7 @@ def _license_hint(feature: str) -> str:
@bp.get("/network") @bp.get("/network")
@permission_required("settings_network.view") @permission_required("settings_network.read")
def network(): def network():
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
@@ -81,7 +81,7 @@ def network():
@bp.post("/network") @bp.post("/network")
@permission_required("settings_network.edit") @permission_required("settings_network.write")
def network_apply(): def network_apply():
extension = core() extension = core()
sysops = extension.sysops sysops = extension.sysops
@@ -148,7 +148,7 @@ def network_apply():
@bp.post("/network/confirm") @bp.post("/network/confirm")
@permission_required("settings_network.edit") @permission_required("settings_network.write")
def network_confirm(): def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None: if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
flash("Es liegt keine Änderung zur Bestätigung vor.", "info") flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
@@ -222,7 +222,7 @@ def _web_config() -> webserver.WebConfig:
@bp.get("/web") @bp.get("/web")
@permission_required("settings_web.view") @permission_required("settings_web.read")
def web(): def web():
extension = core() extension = core()
config = _web_config() config = _web_config()
@@ -241,7 +241,7 @@ def web():
@bp.post("/web") @bp.post("/web")
@permission_required("settings_web.edit") @permission_required("settings_web.write")
def web_save(): def web_save():
extension = core() extension = core()
conn = db() conn = db()
@@ -415,7 +415,7 @@ def web_self_signed():
@bp.get("/directory") @bp.get("/directory")
@permission_required("settings_directory.view") @permission_required("settings_directory.read")
def directory_settings(): def directory_settings():
extension = core() extension = core()
conn = db() conn = db()
@@ -436,7 +436,7 @@ def directory_settings():
@bp.post("/directory") @bp.post("/directory")
@permission_required("settings_directory.edit") @permission_required("settings_directory.write")
def directory_save(): def directory_save():
problem = _license_hint("directory") problem = _license_hint("directory")
if problem: if problem:
@@ -490,7 +490,7 @@ def directory_test():
@bp.get("/directory/group-search") @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(): def directory_group_search():
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder. """Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
@@ -505,21 +505,30 @@ def directory_group_search():
@bp.post("/directory/mapping") @bp.post("/directory/mapping")
@permission_required("settings_directory.edit") @permission_required("settings_directory.write")
def directory_mapping_add(): def directory_mapping_add():
conn = db() conn = db()
dn = (request.form.get("ad_group_dn") or "").strip() dn = (request.form.get("ad_group_dn") or "").strip()
name = (request.form.get("ad_group_name") or "").strip() name = (request.form.get("ad_group_name") or "").strip()
group_id = request.form.get("app_group_id") # Ein Feld, zwei Arten von Ziel: "gruppe:<id>" oder "administrator". Zwei
if not dn or not group_id: # Felder waeren zwei Zustaende, von denen einer immer falsch ist.
flash("Verzeichnisgruppe und Zielgruppe sind beide erforderlich.", "danger") 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")) return redirect(url_for("hostsettings.directory_settings"))
with transaction(): with transaction():
conn.execute( conn.execute(
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id, created_at) " "INSERT INTO ldap_group_mappings "
"VALUES (?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET " "(ad_group_dn, ad_group_name, app_group_id, grants_admin, created_at) "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id", "VALUES (?,?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
(dn, name or dn, int(group_id)), "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( audit.log(
conn, conn,
@@ -527,15 +536,22 @@ def directory_mapping_add():
actor_id=current_user.id, actor_id=current_user.id,
action="settings.directory_mapping_added", action="settings.directory_mapping_added",
target=name or dn, target=name or dn,
detail=(
"Ziel: Administrator (Kennzeichen)"
if kennzeichen
else f"Ziel: Gruppe {gruppe}"
),
ip=client_ip(), 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") flash("Zuordnung gespeichert.", "success")
return redirect(url_for("hostsettings.directory_settings")) return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/directory/mapping/<int:mapping_id>/delete") @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): def directory_mapping_delete(mapping_id: int):
with transaction() as conn: with transaction() as conn:
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,)) conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
@@ -39,7 +39,7 @@ def _live_log():
@bp.get("/live") @bp.get("/live")
@permission_required("logs_live.view") @permission_required("logs_live.read")
def live(): def live():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip() needle = (request.args.get("q") or "").strip()
@@ -53,7 +53,7 @@ def live():
@bp.get("/live/content") @bp.get("/live/content")
@permission_required("logs_live.view") @permission_required("logs_live.read")
@fragment @fragment
def live_fragment(): def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES) lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
@@ -64,7 +64,7 @@ def live_fragment():
@bp.get("/history") @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(): def history():
extension = core() extension = core()
rotated = [] rotated = []
@@ -83,7 +83,7 @@ def history():
@bp.get("/history/file/<path:name>") @bp.get("/history/file/<path:name>")
@permission_required("logs_history.view") @permission_required("logs_history.read")
def history_file(name: str): def history_file(name: str):
extension = core() extension = core()
target = (extension.config.log_dir / name).resolve() target = (extension.config.log_dir / name).resolve()
@@ -102,7 +102,7 @@ def history_file(name: str):
@bp.get("/history/archive/<day>") @bp.get("/history/archive/<day>")
@permission_required("logs_history.view") @permission_required("logs_history.read")
def history_archive(day: str): def history_archive(day: str):
if len(day) != 10 or day.count("-") != 2: if len(day) != 10 or day.count("-") != 2:
abort(404) abort(404)
@@ -177,7 +177,7 @@ def history_purge():
@bp.get("/audit") @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(): def audit_view():
conn = db() conn = db()
page = max(1, int(request.args.get("page") or 1)) page = max(1, int(request.args.get("page") or 1))
@@ -146,7 +146,7 @@ def _purge_expired(conn: sqlite3.Connection) -> int:
@bp.get("/") @bp.get("/")
@permission_required("trash.view") @permission_required("trash.read")
def overview(): def overview():
conn = db() conn = db()
removed = 0 removed = 0
@@ -192,7 +192,7 @@ def _provider_or_404(key: str) -> TrashProvider:
@bp.post("/<kind>/<ident>/restore") @bp.post("/<kind>/<ident>/restore")
@permission_required("trash.edit") @permission_required("trash.write")
def restore(kind: str, ident: str): def restore(kind: str, ident: str):
extension = core() extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"): if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing-server" name = "tesm-licensing-server"
version = "2.7.3" version = "2.7.7"
description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten" description = "Serverhaelfte des TESM-Lizenzprotokolls: ausstellen, erneuern, antworten"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["tesm-licensing==2.7.3", "cryptography>=41"] dependencies = ["tesm-licensing==2.7.7", "cryptography>=41"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-licensing" name = "tesm-licensing"
version = "2.7.3" version = "2.7.7"
description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver" description = "Ed25519-basiertes Lizenz- und Aktivierungsprotokoll fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["cryptography>=42"] dependencies = ["cryptography>=42"]