Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48ce56fe7d | ||
|
|
294d47684b | ||
|
|
e589b4aadb | ||
|
|
f6b0bbaad1 | ||
|
|
c3c6eae72d | ||
|
|
a1987aeb93 | ||
|
|
1d67c36ed2 | ||
|
|
e68f22fd88 | ||
|
|
2d234571d2 | ||
|
|
fafda84bc7 | ||
|
|
cdc3b2cd6e | ||
|
|
35247c895b | ||
|
|
e5ae10814f | ||
|
|
37a200e983 | ||
|
|
de9c0d4fbd | ||
|
|
724052ae28 | ||
|
|
e16236fa65 | ||
|
|
c52b3626b2 | ||
|
|
1ac5dc1637 | ||
|
|
f2d5324449 | ||
|
|
342abb0206 | ||
|
|
8dd8df9e9f |
No files matched your search
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-license"
|
name = "tesm-license"
|
||||||
version = "2.3.2"
|
version = "2.8.3"
|
||||||
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
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ def _status_pills(user: Any) -> list[dict[str, str]]:
|
|||||||
def _startup_tasks(app: Flask) -> None:
|
def _startup_tasks(app: Flask) -> None:
|
||||||
extension = core(app)
|
extension = core(app)
|
||||||
if not extension.config.web_process:
|
if not extension.config.web_process:
|
||||||
logger.info("Startaufgaben uebersprungen -- kein Webprozess.")
|
logger.info("Startaufgaben übersprungen – kein Webprozess.")
|
||||||
return
|
return
|
||||||
|
|
||||||
from .services import activation
|
from .services import activation
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ def _handle(action: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if request.content_length and request.content_length > MAX_BODY_BYTES:
|
if request.content_length and request.content_length > MAX_BODY_BYTES:
|
||||||
return jsonify({"error": "too_large", "message": "Anfrage zu gross."}), 413
|
return jsonify({"error": "too_large", "message": "Anfrage zu groß."}), 413
|
||||||
|
|
||||||
payload = request.get_json(silent=True)
|
payload = request.get_json(silent=True)
|
||||||
if not isinstance(payload, dict):
|
if not isinstance(payload, dict):
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
from ..services import transfer
|
from ..services import transfer
|
||||||
from .common import log_event
|
from .common import log_event
|
||||||
|
|
||||||
bp = Blueprint("backup", __name__, url_prefix="/sicherung")
|
bp = Blueprint("backup", __name__, url_prefix="/administration/backup")
|
||||||
|
|
||||||
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
MAX_IMPORT_BYTES = 64 * 1024 * 1024
|
||||||
PREVIEW_SESSION_KEY = "_import_token"
|
PREVIEW_SESSION_KEY = "_import_token"
|
||||||
@@ -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,25 +50,25 @@ 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")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def export():
|
def export():
|
||||||
conn = db()
|
conn = db()
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
passphrase = request.form.get("passphrase") or ""
|
passphrase = request.form.get("passphrase") or ""
|
||||||
if passphrase != (request.form.get("passphrase_repeat") or ""):
|
if passphrase != (request.form.get("passphrase_repeat") or ""):
|
||||||
flash("Die beiden Passphrasen stimmen nicht ueberein.", "danger")
|
flash("Die beiden Passphrasen stimmen nicht überein.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -94,18 +94,18 @@ def export():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/vorschau")
|
@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()
|
||||||
upload = request.files.get("backup_file")
|
upload = request.files.get("backup_file")
|
||||||
if upload is None or not upload.filename:
|
if upload is None or not upload.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
raw = upload.stream.read(MAX_IMPORT_BYTES + 1)
|
||||||
if len(raw) > MAX_IMPORT_BYTES:
|
if len(raw) > MAX_IMPORT_BYTES:
|
||||||
flash("Die Datei ist zu gross.", "danger")
|
flash("Die Datei ist zu groß.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -124,28 +124,28 @@ def import_preview():
|
|||||||
scope="sicherung",
|
scope="sicherung",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Datei geoeffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
f"Datei geöffnet. Sie haben {transfer.PREVIEW_TTL_MINUTES} Minuten Zeit, die "
|
||||||
"Uebernahme zu bestaetigen.",
|
"Übernahme zu bestätigen.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/uebernehmen")
|
@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()
|
||||||
conn = db()
|
conn = db()
|
||||||
token = session.get(PREVIEW_SESSION_KEY, "")
|
token = session.get(PREVIEW_SESSION_KEY, "")
|
||||||
if not token:
|
if not token:
|
||||||
flash("Es liegt keine geoeffnete Sicherung vor.", "danger")
|
flash("Es liegt keine geöffnete Sicherung vor.", "danger")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
allowed = {category.key for category in _allowed_categories()}
|
allowed = {category.key for category in _allowed_categories()}
|
||||||
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
chosen = [key for key in request.form.getlist("categories") if key in allowed]
|
||||||
if not chosen:
|
if not chosen:
|
||||||
flash("Es wurde keine Kategorie ausgewaehlt.", "warning")
|
flash("Es wurde keine Kategorie ausgewählt.", "warning")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -172,7 +172,7 @@ def import_apply():
|
|||||||
action="backup.imported",
|
action="backup.imported",
|
||||||
detail="; ".join(
|
detail="; ".join(
|
||||||
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
f"{key}: {counts['created']} neu, {counts['updated']} aktualisiert, "
|
||||||
f"{counts['skipped']} uebersprungen"
|
f"{counts['skipped']} übersprungen"
|
||||||
for key, counts in report.items()
|
for key, counts in report.items()
|
||||||
),
|
),
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
@@ -182,16 +182,16 @@ def import_apply():
|
|||||||
lines = [
|
lines = [
|
||||||
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
f"{transfer.CATEGORY_BY_KEY[key].label}: {counts['created']} neu, "
|
||||||
f"{counts['updated']} aktualisiert"
|
f"{counts['updated']} aktualisiert"
|
||||||
+ (f", {counts['skipped']} uebersprungen" if counts["skipped"] else "")
|
+ (f", {counts['skipped']} übersprungen" if counts["skipped"] else "")
|
||||||
for key, counts in report.items()
|
for key, counts in report.items()
|
||||||
if key in transfer.CATEGORY_BY_KEY
|
if key in transfer.CATEGORY_BY_KEY
|
||||||
]
|
]
|
||||||
flash("Uebernahme abgeschlossen. " + " | ".join(lines), "success")
|
flash("Übernahme abgeschlossen. " + " – ".join(lines), "success")
|
||||||
return redirect(url_for("backup.overview"))
|
return redirect(url_for("backup.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/import/verwerfen")
|
@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:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from tesm_core.extension import db, transaction
|
|||||||
from ..services import issuance, reporting
|
from ..services import issuance, reporting
|
||||||
from .common import customer_facing_url, log_event
|
from .common import customer_facing_url, log_event
|
||||||
|
|
||||||
bp = Blueprint("customers", __name__, url_prefix="/kunden")
|
bp = Blueprint("customers", __name__, url_prefix="/customers")
|
||||||
|
|
||||||
FIELDS = (
|
FIELDS = (
|
||||||
"name",
|
"name",
|
||||||
@@ -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(
|
||||||
@@ -61,7 +61,7 @@ def detail(customer_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("customers.create")
|
@permission_required("customers.create")
|
||||||
def create():
|
def create():
|
||||||
try:
|
try:
|
||||||
@@ -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:
|
||||||
@@ -96,14 +96,14 @@ def update(customer_id: int):
|
|||||||
severity="notice",
|
severity="notice",
|
||||||
scope="kunde",
|
scope="kunde",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.",
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.",
|
||||||
"success" if changes else "info")
|
"success" if changes else "info")
|
||||||
except issuance.IssuanceError as exc:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("customers.detail", customer_id=customer_id))
|
return redirect(url_for("customers.detail", customer_id=customer_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<int:customer_id>/loeschen")
|
@bp.post("/<int:customer_id>/delete")
|
||||||
@permission_required("customers.delete")
|
@permission_required("customers.delete")
|
||||||
def delete(customer_id: int):
|
def delete(customer_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -119,7 +119,7 @@ def delete(customer_id: int):
|
|||||||
severity="warning",
|
severity="warning",
|
||||||
scope="kunde",
|
scope="kunde",
|
||||||
)
|
)
|
||||||
flash("Kunde geloescht.", "success")
|
flash("Kunde gelöscht.", "success")
|
||||||
return redirect(url_for("customers.overview"))
|
return redirect(url_for("customers.overview"))
|
||||||
except issuance.IssuanceError as exc:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ from ..services import issuance, reporting
|
|||||||
bp = Blueprint("dashboard", __name__)
|
bp = Blueprint("dashboard", __name__)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/uebersicht")
|
@bp.get("/overview")
|
||||||
@login_required
|
@login_required
|
||||||
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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -38,18 +38,18 @@ from .common import (
|
|||||||
vendor_info,
|
vendor_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
bp = Blueprint("licenses", __name__, url_prefix="/lizenzen")
|
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 = [
|
||||||
{**row, **issuance.display_state(row)}
|
{**row, **issuance.display_state(row)}
|
||||||
for row in issuance.list_licenses(
|
for row in issuance.list_licenses(
|
||||||
conn,
|
conn,
|
||||||
customer_id=request.args.get("kunde", type=int),
|
customer_id=request.args.get("customer", type=int),
|
||||||
ticket_id=request.args.get("ticket"),
|
ticket_id=request.args.get("ticket"),
|
||||||
status=request.args.get("status"),
|
status=request.args.get("status"),
|
||||||
)
|
)
|
||||||
@@ -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)
|
||||||
@@ -99,8 +99,8 @@ def detail(license_id: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aus-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"
|
||||||
@@ -124,7 +124,7 @@ def issue(ticket_id: str):
|
|||||||
|
|
||||||
if result.already_existed:
|
if result.already_existed:
|
||||||
flash(
|
flash(
|
||||||
"Zu diesem Ticket war bereits eine Lizenz offen -- sie wird angezeigt statt eine "
|
"Zu diesem Ticket war bereits eine Lizenz offen – sie wird angezeigt statt eine "
|
||||||
"zweite auszustellen.",
|
"zweite auszustellen.",
|
||||||
"info",
|
"info",
|
||||||
)
|
)
|
||||||
@@ -140,9 +140,9 @@ def issue(ticket_id: str):
|
|||||||
return redirect(url_for("licenses.detail", license_id=result.license_id))
|
return redirect(url_for("licenses.detail", license_id=result.license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<license_id>/datei")
|
@bp.get("/<license_id>/file")
|
||||||
@permission_required("licenses.export")
|
@permission_required("licenses.export")
|
||||||
@reauth_required
|
# Die frische Bestaetigung verlangt das Recht selbst (SENSITIVE_ACTIONS).
|
||||||
def download(license_id: str):
|
def download(license_id: str):
|
||||||
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
"""Laedt das vollstaendige Auslieferungs-Bundle herunter.
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ def download(license_id: str):
|
|||||||
channel="download",
|
channel="download",
|
||||||
recipient=current_user.username,
|
recipient=current_user.username,
|
||||||
actor=current_user.username,
|
actor=current_user.username,
|
||||||
detail="Vollstaendiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
|
detail="Vollständiges Bundle" if "client_key" in bundle else "Nur Lizenzdokument",
|
||||||
)
|
)
|
||||||
log_event("license.downloaded", target=license_id[:12], severity="notice")
|
log_event("license.downloaded", target=license_id[:12], severity="notice")
|
||||||
return Response(
|
return Response(
|
||||||
@@ -181,8 +181,8 @@ def download(license_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/neu-signieren")
|
@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.
|
||||||
@@ -202,7 +202,7 @@ def resign(license_id: str):
|
|||||||
try:
|
try:
|
||||||
days = int(request.form["extend_days"])
|
days = int(request.form["extend_days"])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash("Die Verlaengerung muss eine Zahl sein.", "danger")
|
flash("Die Verlängerung muss eine Zahl sein.", "danger")
|
||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
if days > 0:
|
if days > 0:
|
||||||
document = json.loads(str(row["license_document"]))
|
document = json.loads(str(row["license_document"]))
|
||||||
@@ -212,7 +212,7 @@ def resign(license_id: str):
|
|||||||
changes["expires_at"] = lic.iso(base + timedelta(days=days))
|
changes["expires_at"] = lic.iso(base + timedelta(days=days))
|
||||||
|
|
||||||
if not changes:
|
if not changes:
|
||||||
flash("Es wurde keine Aenderung angegeben.", "info")
|
flash("Es wurde keine Änderung angegeben.", "info")
|
||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
key = signing_key()
|
key = signing_key()
|
||||||
@@ -233,15 +233,15 @@ def resign(license_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
flash(
|
||||||
f"Neue Revision {updated['seq']} signiert. Der Client erhaelt sie beim naechsten "
|
f"Neue Revision {updated['seq']} signiert. Der Client erhält sie beim nächsten "
|
||||||
"Heartbeat.",
|
"Heartbeat.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/widerrufen")
|
@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:
|
||||||
@@ -258,14 +258,14 @@ def revoke(license_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
flash(
|
||||||
"Lizenz widerrufen. Das Ticket bleibt offen -- eine neue Lizenz kann sofort "
|
"Lizenz widerrufen. Das Ticket bleibt offen – eine neue Lizenz kann sofort "
|
||||||
"ausgestellt werden.",
|
"ausgestellt werden.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/loeschen")
|
@bp.post("/<license_id>/delete")
|
||||||
@permission_required("licenses.delete")
|
@permission_required("licenses.delete")
|
||||||
def delete(license_id: str):
|
def delete(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -277,7 +277,7 @@ def delete(license_id: str):
|
|||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
log_event("license.deleted", target=license_id[:12], severity="warning")
|
log_event("license.deleted", target=license_id[:12], severity="warning")
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash("Lizenz geloescht.", "success")
|
flash("Lizenz gelöscht.", "success")
|
||||||
return redirect(url_for("licenses.overview"))
|
return redirect(url_for("licenses.overview"))
|
||||||
|
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ def delete(license_id: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<license_id>/versenden")
|
@bp.post("/<license_id>/send")
|
||||||
@permission_required("licenses.export")
|
@permission_required("licenses.export")
|
||||||
def send_mail(license_id: str):
|
def send_mail(license_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -295,7 +295,7 @@ def send_mail(license_id: str):
|
|||||||
abort(404)
|
abort(404)
|
||||||
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
|
recipient = (request.form.get("recipient") or str(row["contact_email"] or "")).strip()
|
||||||
if not recipient:
|
if not recipient:
|
||||||
flash("Es ist keine Empfaengeradresse hinterlegt.", "danger")
|
flash("Es ist keine Empfängeradresse hinterlegt.", "danger")
|
||||||
return redirect(url_for("licenses.detail", license_id=license_id))
|
return redirect(url_for("licenses.detail", license_id=license_id))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -315,7 +315,7 @@ def send_mail(license_id: str):
|
|||||||
"",
|
"",
|
||||||
f"Lizenz-ID: {license_id}",
|
f"Lizenz-ID: {license_id}",
|
||||||
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
|
f"Typ: {type_labels().get(str(row['type']), str(row['type']))}",
|
||||||
f"Gueltig bis: {row['expires_at']}"
|
f"Gültig bis: {row['expires_at']}"
|
||||||
+ (" (unbefristet)" if row["lifetime"] else ""),
|
+ (" (unbefristet)" if row["lifetime"] else ""),
|
||||||
]
|
]
|
||||||
if is_customer and row["ticket_id"]:
|
if is_customer and row["ticket_id"]:
|
||||||
@@ -329,7 +329,7 @@ def send_mail(license_id: str):
|
|||||||
"Ihre Stammdaten pflegen:",
|
"Ihre Stammdaten pflegen:",
|
||||||
customer_facing_url("portal.request_link"),
|
customer_facing_url("portal.request_link"),
|
||||||
]
|
]
|
||||||
lines += ["", "Freundliche Gruesse", vendor.get("name") or "Ihr Lizenzgeber"]
|
lines += ["", "Freundliche Grüße", vendor.get("name") or "Ihr Lizenzgeber"]
|
||||||
if vendor.get("phone"):
|
if vendor.get("phone"):
|
||||||
lines.append(f"Telefon: {vendor['phone']}")
|
lines.append(f"Telefon: {vendor['phone']}")
|
||||||
if vendor.get("email"):
|
if vendor.get("email"):
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -403,7 +403,7 @@ def manual_code():
|
|||||||
if outcome.ok:
|
if outcome.ok:
|
||||||
response_code = lic.encode_code(outcome.response)
|
response_code = lic.encode_code(outcome.response)
|
||||||
ok = True
|
ok = True
|
||||||
message = "Antwortcode erzeugt. Bitte an den Kunden zuruecksenden."
|
message = "Antwortcode erzeugt. Bitte an den Kunden zurücksenden."
|
||||||
log_event(
|
log_event(
|
||||||
f"license.offline_{payload.get('action')}",
|
f"license.offline_{payload.get('action')}",
|
||||||
target=str(payload.get("license_id") or "")[:12],
|
target=str(payload.get("license_id") or "")[:12],
|
||||||
@@ -428,8 +428,8 @@ def manual_code():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sub-lizenzserver")
|
@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)
|
||||||
@@ -440,8 +440,8 @@ def reports():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sub-lizenzserver/<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)
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ from .. import is_master_edition
|
|||||||
from ..services import master_key
|
from ..services import master_key
|
||||||
from .common import can_issue_license_server_licenses, log_event, signing_key
|
from .common import can_issue_license_server_licenses, log_event, signing_key
|
||||||
|
|
||||||
bp = Blueprint("own_license", __name__, url_prefix="/eigene-lizenz")
|
bp = Blueprint("own_license", __name__, url_prefix="/own-license")
|
||||||
|
|
||||||
MAX_UPLOAD_BYTES = 256 * 1024
|
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()
|
||||||
@@ -68,8 +68,8 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/einspielen")
|
@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
|
||||||
@@ -79,11 +79,11 @@ def upload():
|
|||||||
|
|
||||||
upload_file = request.files.get("license_file")
|
upload_file = request.files.get("license_file")
|
||||||
if upload_file is None or not upload_file.filename:
|
if upload_file is None or not upload_file.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
raw = upload_file.stream.read(MAX_UPLOAD_BYTES + 1)
|
||||||
if len(raw) > MAX_UPLOAD_BYTES:
|
if len(raw) > MAX_UPLOAD_BYTES:
|
||||||
flash("Die Datei ist zu gross fuer eine Lizenzdatei.", "danger")
|
flash("Die Datei ist zu groß für eine Lizenzdatei.", "danger")
|
||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -129,27 +129,27 @@ def _handshake(action: str):
|
|||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aktivieren")
|
@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("/deaktivieren")
|
@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-antwort")
|
@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:
|
||||||
@@ -166,8 +166,8 @@ def offline_response():
|
|||||||
return redirect(url_for("own_license.overview"))
|
return redirect(url_for("own_license.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/signaturschluessel")
|
@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.
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ RATE_WINDOW_MINUTES = 15
|
|||||||
|
|
||||||
GENERIC_ANSWER = (
|
GENERIC_ANSWER = (
|
||||||
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
|
"Falls diese E-Mail-Adresse bei uns hinterlegt ist, wurde soeben eine Nachricht mit "
|
||||||
"einem {minutes} Minuten gueltigen Zugangslink verschickt."
|
"einem {minutes} Minuten gültigen Zugangslink verschickt."
|
||||||
)
|
)
|
||||||
|
|
||||||
EDITABLE_FIELDS = (
|
EDITABLE_FIELDS = (
|
||||||
@@ -150,13 +150,13 @@ def request_link():
|
|||||||
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
|
f"Guten Tag{(' ' + str(customer['contact_person'])) if customer['contact_person'] else ''},",
|
||||||
"",
|
"",
|
||||||
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
|
"mit dem folgenden Link erreichen Sie Ihre Daten im Kundenportal.",
|
||||||
f"Der Link ist {minutes} Minuten gueltig und laesst sich nur einmal verwenden:",
|
f"Der Link ist {minutes} Minuten gültig und lässt sich nur einmal verwenden:",
|
||||||
"",
|
"",
|
||||||
access_url,
|
access_url,
|
||||||
"",
|
"",
|
||||||
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
|
"Wenn Sie diesen Zugang nicht angefragt haben, ignorieren Sie die Nachricht.",
|
||||||
"",
|
"",
|
||||||
"Freundliche Gruesse",
|
"Freundliche Grüße",
|
||||||
vendor.get("name") or "Ihr Lizenzgeber",
|
vendor.get("name") or "Ihr Lizenzgeber",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
@@ -185,7 +185,7 @@ def access(token: str):
|
|||||||
expires = _parse(row["expires_at"]) if row is not None else None
|
expires = _parse(row["expires_at"]) if row is not None else None
|
||||||
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
|
valid = row is not None and row["used_at"] is None and expires is not None and _now() < expires
|
||||||
if not valid:
|
if not valid:
|
||||||
flash("Dieser Link ist ungueltig oder abgelaufen.", "danger")
|
flash("Dieser Link ist ungültig oder abgelaufen.", "danger")
|
||||||
return redirect(url_for("portal.request_link"))
|
return redirect(url_for("portal.request_link"))
|
||||||
|
|
||||||
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
|
minutes = int(extension.settings.get(conn, "portal_session_minutes") or 60)
|
||||||
@@ -247,7 +247,7 @@ def me():
|
|||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Aenderungen.", "success")
|
flash("Ihre Angaben wurden gespeichert." if changes else "Keine Änderungen.", "success")
|
||||||
except issuance.IssuanceError as exc:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("portal.me"))
|
return redirect(url_for("portal.me"))
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from .common import (
|
|||||||
vendor_info,
|
vendor_info,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Die Adresse steht in verschickten Mails und in Tickets -- sie bleibt
|
||||||
|
# deutsch, waehrend der Rest der Anwendung auf englische Pfade wechselt.
|
||||||
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
|
bp = Blueprint("selfservice", __name__, url_prefix="/lizenz")
|
||||||
|
|
||||||
RATE_BUCKET = "selfservice"
|
RATE_BUCKET = "selfservice"
|
||||||
@@ -60,7 +62,7 @@ def _rate_guard() -> None:
|
|||||||
with transaction():
|
with transaction():
|
||||||
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
ratelimit.record(conn, RATE_BUCKET, client_ip() or "unbekannt")
|
||||||
if verdict.blocked:
|
if verdict.blocked:
|
||||||
abort(429, description="Zu viele Anfragen. Bitte spaeter erneut versuchen.")
|
abort(429, description="Zu viele Anfragen. Bitte später erneut versuchen.")
|
||||||
|
|
||||||
|
|
||||||
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
|
def _needs_offline(conn, ticket_id: str): # type: ignore[no-untyped-def]
|
||||||
@@ -139,8 +141,8 @@ def issue(ticket_id: str):
|
|||||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||||
if issuance.ticket_has_open_license(conn, ticket_id):
|
if issuance.ticket_has_open_license(conn, ticket_id):
|
||||||
flash(
|
flash(
|
||||||
"Es existiert bereits eine gueltige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
|
"Es existiert bereits eine gültige Lizenz. Geben Sie diese zuerst frei, wenn Sie "
|
||||||
"auf ein anderes System wechseln moechten.",
|
"auf ein anderes System wechseln möchten.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
return redirect(url_for("selfservice.overview", ticket_id=ticket_id))
|
||||||
@@ -236,12 +238,12 @@ def offline(ticket_id: str):
|
|||||||
_log(
|
_log(
|
||||||
"selfservice.offline_rejected",
|
"selfservice.offline_rejected",
|
||||||
ticket,
|
ticket,
|
||||||
"Code gehoert nicht zu diesem Vorgang",
|
"Code gehört nicht zu diesem Vorgang",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
"Dieser Code gehoert nicht zu Ihrem Vorgang. Bitte pruefen Sie, ob Sie ihn "
|
"Dieser Code gehört nicht zu Ihrem Vorgang. Bitte prüfen Sie, ob Sie ihn "
|
||||||
"vollstaendig kopiert haben.",
|
"vollständig kopiert haben.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
|
return redirect(url_for("selfservice.offline", ticket_id=ticket_id))
|
||||||
@@ -297,7 +299,7 @@ def revoke(ticket_id: str, license_id: str):
|
|||||||
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
|
_log("license.revoked_self_service", ticket, license_id[:12], severity="warning")
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
flash(
|
||||||
"Die Lizenz wurde freigegeben. Sie koennen jetzt eine neue Lizenz fuer Ihr neues System "
|
"Die Lizenz wurde freigegeben. Sie können jetzt eine neue Lizenz für Ihr neues System "
|
||||||
"ausstellen.",
|
"ausstellen.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from tesm_core.extension import core, db, transaction
|
|||||||
from ..services import mail
|
from ..services import mail
|
||||||
from .common import log_event, master_endpoint, vendor_info
|
from .common import log_event, master_endpoint, vendor_info
|
||||||
|
|
||||||
bp = Blueprint("settings", __name__, url_prefix="/einstellungen")
|
bp = Blueprint("settings", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
MAX_LOGO_BYTES = 200 * 1024
|
MAX_LOGO_BYTES = 200 * 1024
|
||||||
LOGO_MIME = {
|
LOGO_MIME = {
|
||||||
@@ -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()
|
||||||
@@ -54,6 +54,8 @@ def system():
|
|||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
specs={spec.key: spec for spec in extension.settings.specs()},
|
specs={spec.key: spec for spec in extension.settings.specs()},
|
||||||
@@ -66,8 +68,13 @@ def system():
|
|||||||
else ""
|
else ""
|
||||||
),
|
),
|
||||||
timezones=_timezone_choices(),
|
timezones=_timezone_choices(),
|
||||||
nav_items=extension.nav.items,
|
# Nicht ``nav_items``: dieser Name gehoert dem Seitenrahmen. Wer ihn hier
|
||||||
|
# belegt, ueberschreibt die gefilterte Navigation aus dem Kontext --
|
||||||
|
# und die Seitenleiste zeigte dann jeden Punkt, auch den, den dieser
|
||||||
|
# Benutzer nicht darf und den die Lizenz nicht freischaltet.
|
||||||
|
nav_baum=extension.nav.items,
|
||||||
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
nav_order=extension.settings.get(conn, "nav_order") or extension.nav.default_order(),
|
||||||
|
nav_child_order=extension.settings.get(conn, "nav_child_order") or {},
|
||||||
sysops_available=bool(sysops and sysops.available),
|
sysops_available=bool(sysops and sysops.available),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,7 +89,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()
|
||||||
@@ -97,6 +104,8 @@ def system_save():
|
|||||||
"trash_retention_days",
|
"trash_retention_days",
|
||||||
"log_rotate_interval",
|
"log_rotate_interval",
|
||||||
"log_rotate_keep",
|
"log_rotate_keep",
|
||||||
|
"session_idle_minutes",
|
||||||
|
"session_absolute_hours",
|
||||||
):
|
):
|
||||||
spec = extension.settings.spec(key)
|
spec = extension.settings.spec(key)
|
||||||
if spec.kind == "bool":
|
if spec.kind == "bool":
|
||||||
@@ -116,7 +125,7 @@ def system_save():
|
|||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Einstellungen gespeichert." if changed else "Keine Aenderungen.",
|
flash("Einstellungen gespeichert." if changed else "Keine Änderungen.",
|
||||||
"success" if changed else "info")
|
"success" if changed else "info")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
@@ -128,10 +137,10 @@ def set_hostname():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
hostname = (request.form.get("hostname") or "").strip()
|
hostname = (request.form.get("hostname") or "").strip()
|
||||||
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
if not hostname or len(hostname) > 63 or not hostname.replace("-", "").isalnum():
|
||||||
flash("Der Hostname ist ungueltig.", "danger")
|
flash("Der Hostname ist ungültig.", "danger")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_hostname(hostname)
|
result = sysops.set_hostname(hostname)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -142,7 +151,7 @@ def set_hostname():
|
|||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/system/zeitzone")
|
@bp.post("/system/timezone")
|
||||||
@permission_required("settings_system.execute")
|
@permission_required("settings_system.execute")
|
||||||
def set_timezone():
|
def set_timezone():
|
||||||
import time
|
import time
|
||||||
@@ -153,7 +162,7 @@ def set_timezone():
|
|||||||
flash("Unbekannte Zeitzone.", "danger")
|
flash("Unbekannte Zeitzone.", "danger")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("settings.system"))
|
return redirect(url_for("settings.system"))
|
||||||
result = sysops.set_timezone(timezone)
|
result = sysops.set_timezone(timezone)
|
||||||
if result.ok:
|
if result.ok:
|
||||||
@@ -173,8 +182,8 @@ def set_timezone():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/anbieter")
|
@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()
|
||||||
@@ -195,8 +204,8 @@ def vendor():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/anbieter")
|
@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()
|
||||||
@@ -222,7 +231,7 @@ def vendor_save():
|
|||||||
if suffix not in LOGO_MIME:
|
if suffix not in LOGO_MIME:
|
||||||
logo_problem = "Erlaubt sind PNG, JPG und SVG."
|
logo_problem = "Erlaubt sind PNG, JPG und SVG."
|
||||||
elif len(data) > MAX_LOGO_BYTES:
|
elif len(data) > MAX_LOGO_BYTES:
|
||||||
logo_problem = f"Das Logo ist zu gross (maximal {MAX_LOGO_BYTES // 1024} KB)."
|
logo_problem = f"Das Logo ist zu groß (maximal {MAX_LOGO_BYTES // 1024} KB)."
|
||||||
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
|
elif not any(head.startswith(magic) for magic in LOGO_MAGIC):
|
||||||
logo_problem = "Die Datei ist kein Bild."
|
logo_problem = "Die Datei ist kein Bild."
|
||||||
else:
|
else:
|
||||||
@@ -240,13 +249,13 @@ def vendor_save():
|
|||||||
log_event(
|
log_event(
|
||||||
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
|
"settings.vendor_saved", detail=", ".join(changed), severity="notice", scope="anbieter"
|
||||||
)
|
)
|
||||||
flash("Anbieterdaten gespeichert." if changed else "Keine Aenderungen.",
|
flash("Anbieterdaten gespeichert." if changed else "Keine Änderungen.",
|
||||||
"success" if changed else "info")
|
"success" if changed else "info")
|
||||||
return redirect(url_for("settings.vendor"))
|
return redirect(url_for("settings.vendor"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/anbieter/logo-entfernen")
|
@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)
|
||||||
@@ -260,7 +269,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()
|
||||||
@@ -275,7 +284,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()
|
||||||
@@ -292,7 +301,7 @@ def mail_save():
|
|||||||
mail.store_secret(conn, request.form.get("client_secret"))
|
mail.store_secret(conn, request.form.get("client_secret"))
|
||||||
changed.append("client_secret")
|
changed.append("client_secret")
|
||||||
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
|
log_event("settings.mail_saved", detail=", ".join(changed), severity="notice", scope="mail")
|
||||||
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Aenderungen.",
|
flash("E-Mail-Einstellungen gespeichert." if changed else "Keine Änderungen.",
|
||||||
"success" if changed else "info")
|
"success" if changed else "info")
|
||||||
return redirect(url_for("settings.mail_settings"))
|
return redirect(url_for("settings.mail_settings"))
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -45,7 +45,7 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/neu")
|
@bp.get("/new")
|
||||||
@permission_required("tickets.create")
|
@permission_required("tickets.create")
|
||||||
def new():
|
def new():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -60,11 +60,11 @@ def new():
|
|||||||
type_labels=type_labels(),
|
type_labels=type_labels(),
|
||||||
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
default_valid_days=int(extension.settings.get(conn, "default_valid_days") or 365),
|
||||||
may_create_license_server=can_issue_license_server_licenses(),
|
may_create_license_server=can_issue_license_server_licenses(),
|
||||||
preselected_customer=request.args.get("kunde", type=int),
|
preselected_customer=request.args.get("customer", type=int),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/neu")
|
@bp.post("/new")
|
||||||
@permission_required("tickets.create")
|
@permission_required("tickets.create")
|
||||||
def create():
|
def create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -106,14 +106,14 @@ def _endpoint_missing() -> str:
|
|||||||
if master_endpoint():
|
if master_endpoint():
|
||||||
return ""
|
return ""
|
||||||
return (
|
return (
|
||||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||||
"Solange er fehlt, koennen keine Lizenzen ausgestellt werden: der Endpunkt wird "
|
"Solange er fehlt, können keine Lizenzen ausgestellt werden: der Endpunkt wird "
|
||||||
"in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
"in die Lizenz signiert und lässt sich später nicht nachtragen."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@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:
|
||||||
@@ -176,9 +176,9 @@ def update(ticket_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
flash(
|
||||||
"Gespeichert. Die Aenderung wirkt auf die naechste Ausstellung."
|
"Gespeichert. Die Änderung wirkt auf die nächste Ausstellung."
|
||||||
if changes
|
if changes
|
||||||
else "Keine Aenderungen.",
|
else "Keine Änderungen.",
|
||||||
"success" if changes else "info",
|
"success" if changes else "info",
|
||||||
)
|
)
|
||||||
except (issuance.IssuanceError, ValueError) as exc:
|
except (issuance.IssuanceError, ValueError) as exc:
|
||||||
@@ -186,8 +186,8 @@ def update(ticket_id: str):
|
|||||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<ticket_id>/sperre")
|
@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)
|
||||||
@@ -205,7 +205,7 @@ def toggle_block(ticket_id: str):
|
|||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash(
|
flash(
|
||||||
"Ticket gesperrt -- es lassen sich keine neuen Lizenzen mehr ausstellen."
|
"Ticket gesperrt – es lassen sich keine neuen Lizenzen mehr ausstellen."
|
||||||
if new_status == "blocked"
|
if new_status == "blocked"
|
||||||
else "Ticket entsperrt.",
|
else "Ticket entsperrt.",
|
||||||
"success",
|
"success",
|
||||||
@@ -213,7 +213,7 @@ def toggle_block(ticket_id: str):
|
|||||||
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
return redirect(url_for("tickets.detail", ticket_id=ticket_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<ticket_id>/loeschen")
|
@bp.post("/<ticket_id>/delete")
|
||||||
@permission_required("tickets.delete")
|
@permission_required("tickets.delete")
|
||||||
def delete(ticket_id: str):
|
def delete(ticket_id: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -231,7 +231,7 @@ def delete(ticket_id: str):
|
|||||||
scope="ticket",
|
scope="ticket",
|
||||||
)
|
)
|
||||||
reporting.report_soon(core())
|
reporting.report_soon(core())
|
||||||
flash("Ticket und seine Lizenzhistorie geloescht.", "success")
|
flash("Ticket und seine Lizenzhistorie gelöscht.", "success")
|
||||||
return redirect(url_for("tickets.overview"))
|
return redirect(url_for("tickets.overview"))
|
||||||
except issuance.IssuanceError as exc:
|
except issuance.IssuanceError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
|
|||||||
@@ -42,22 +42,22 @@ def cmd_init_key(args: argparse.Namespace) -> int:
|
|||||||
path = master_key_path(extension.config)
|
path = master_key_path(extension.config)
|
||||||
if path.exists() and not args.force:
|
if path.exists() and not args.force:
|
||||||
key = master_key.load(path)
|
key = master_key.load(path)
|
||||||
print(f"Signaturschluessel existiert bereits: {key.key_id} ({path})")
|
print(f"Signaturschlüssel existiert bereits: {key.key_id} ({path})")
|
||||||
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unpruefbar.")
|
print("Ein Austausch macht ALLE bereits ausgestellten Lizenzen unprüfbar.")
|
||||||
return 0
|
return 0
|
||||||
if path.exists() and args.force:
|
if path.exists() and args.force:
|
||||||
print(
|
print(
|
||||||
"ABBRUCH: --force auf einen bestehenden Signaturschluessel wird nicht unterstuetzt.\n"
|
"ABBRUCH: --force auf einen bestehenden Signaturschlüssel wird nicht unterstützt.\n"
|
||||||
"Ein neuer Schluessel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
|
"Ein neuer Schlüssel entwertet jede ausgestellte Lizenz. Bitte die Datei bewusst\n"
|
||||||
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
|
"von Hand beiseite legen, wenn das wirklich gewollt ist.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
key = master_key.ensure(path)
|
key = master_key.ensure(path)
|
||||||
print(f"Signaturschluessel erzeugt: {key.key_id}")
|
print(f"Signaturschlüssel erzeugt: {key.key_id}")
|
||||||
print(f"Datei: {path}")
|
print(f"Datei: {path}")
|
||||||
print("Bitte SOFORT sichern -- ohne sie ist keine ausgestellte Lizenz mehr pruefbar.")
|
print("Bitte SOFORT sichern – ohne sie ist keine ausgestellte Lizenz mehr prüfbar.")
|
||||||
print(f"Oeffentlicher Schluessel (zum Pinnen beim Kunden):\n{key.public_key}")
|
print(f"Öffentlicher Schlüssel (zum Pinnen beim Kunden):\n{key.public_key}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -65,11 +65,11 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
|
|||||||
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe."""
|
"""Stellt die eigene Lizenz dieser Wurzelinstanz aus -- nur in der Master-Ausgabe."""
|
||||||
if not is_master_edition():
|
if not is_master_edition():
|
||||||
for zeile in (
|
for zeile in (
|
||||||
"Diese Ausgabe ist fuer Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
|
"Diese Ausgabe ist für Sub-Lizenzserver und kann keine Master-Lizenz erzeugen.",
|
||||||
"",
|
"",
|
||||||
"Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt",
|
"Ein Sub-Lizenzserver bekommt seine Lizenz vom Master-Lizenzserver und spielt",
|
||||||
"sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --",
|
"sie unter 'Eigene Lizenz' ein. Danach stellt er selbst Kundenlizenzen aus --",
|
||||||
"mit seinem eigenen Signaturschluessel, der beim Installieren entstanden ist.",
|
"mit seinem eigenen Signaturschlüssel, der beim Installieren entstanden ist.",
|
||||||
"",
|
"",
|
||||||
"Die Master-Ausgabe liegt im Repository tesm-license-master.",
|
"Die Master-Ausgabe liegt im Repository tesm-license-master.",
|
||||||
):
|
):
|
||||||
@@ -83,10 +83,10 @@ def cmd_bootstrap(args: argparse.Namespace) -> int:
|
|||||||
app, extension, days=int(args.days), name=args.name
|
app, extension, days=int(args.days), name=args.name
|
||||||
)
|
)
|
||||||
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
|
print(f"Master-Lizenz ausgestellt: {document['license_id']}")
|
||||||
print(f"Gueltig bis: {document['expires_at']}")
|
print(f"Gültig bis: {document['expires_at']}")
|
||||||
print(f"Datei: {extension.config.license_path}")
|
print(f"Datei: {extension.config.license_path}")
|
||||||
if state is not None:
|
if state is not None:
|
||||||
print(f"Zustand: gueltig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
|
print(f"Zustand: gültig={state.valid}, aktiviert={state.activated}, Rolle={state.role}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ def cmd_create_admin(args: argparse.Namespace) -> int:
|
|||||||
return 2
|
return 2
|
||||||
password = args.password or getpass.getpass("Passwort: ")
|
password = args.password or getpass.getpass("Passwort: ")
|
||||||
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
|
if not args.password and password != getpass.getpass("Passwort wiederholen: "):
|
||||||
print("Die Passwoerter stimmen nicht ueberein.", file=sys.stderr)
|
print("Die Passwörter stimmen nicht überein.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
problems = extension.password_policy.check(password, username=username)
|
problems = extension.password_policy.check(password, username=username)
|
||||||
if problems and not args.force:
|
if problems and not args.force:
|
||||||
@@ -180,7 +180,7 @@ def cmd_status(args: argparse.Namespace) -> int:
|
|||||||
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
|
+ ("unversehrt" if chain["ok"] else f"GEBROCHEN bei {chain['broken_at']}")
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
"Signaturschluessel: "
|
"Signaturschlüssel: "
|
||||||
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
|
+ (str(key_health.get("key_id")) if key_health.get("present") else "FEHLT")
|
||||||
)
|
)
|
||||||
for problem in key_health.get("problems", []):
|
for problem in key_health.get("problems", []):
|
||||||
@@ -253,17 +253,17 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
except legacy_import.LegacyImportError as exc:
|
except legacy_import.LegacyImportError as exc:
|
||||||
if args.adopt_key:
|
if args.adopt_key:
|
||||||
raise
|
raise
|
||||||
print(f"Hinweis: alter Signaturschluessel nicht lesbar ({exc}).")
|
print(f"Hinweis: alter Signaturschlüssel nicht lesbar ({exc}).")
|
||||||
|
|
||||||
if key_path.is_file():
|
if key_path.is_file():
|
||||||
existing = master_key.load(key_path)
|
existing = master_key.load(key_path)
|
||||||
report.key_id = existing.key_id
|
report.key_id = existing.key_id
|
||||||
if legacy_public and existing.public_key == legacy_public:
|
if legacy_public and existing.public_key == legacy_public:
|
||||||
print(f"Signaturschluessel stimmt bereits ueberein ({existing.key_id}).")
|
print(f"Signaturschlüssel stimmt bereits überein ({existing.key_id}).")
|
||||||
elif not legacy_public:
|
elif not legacy_public:
|
||||||
issuer_changes = True
|
issuer_changes = True
|
||||||
print(
|
print(
|
||||||
f"Es wird mit dem vorhandenen Signaturschluessel {existing.key_id} "
|
f"Es wird mit dem vorhandenen Signaturschlüssel {existing.key_id} "
|
||||||
"gearbeitet."
|
"gearbeitet."
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -271,7 +271,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
# ausgestellte Lizenz waere danach unpruefbar.
|
# ausgestellte Lizenz waere danach unpruefbar.
|
||||||
issuer_changes = True
|
issuer_changes = True
|
||||||
print(
|
print(
|
||||||
f"Diese Installation hat einen eigenen Signaturschluessel "
|
f"Diese Installation hat einen eigenen Signaturschlüssel "
|
||||||
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
|
f"({existing.key_id}); der alte war {lic.key_id(legacy_public)}. "
|
||||||
"Es wird mit dem vorhandenen weitergearbeitet."
|
"Es wird mit dem vorhandenen weitergearbeitet."
|
||||||
)
|
)
|
||||||
@@ -279,12 +279,12 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
|
legacy_private, legacy_public = legacy_import.read_legacy_key(root)
|
||||||
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
|
report.key_id = legacy_import.adopt_key(legacy_private, legacy_public, key_path)
|
||||||
report.key_adopted = True
|
report.key_adopted = True
|
||||||
print(f"Signaturschluessel uebernommen: {report.key_id}")
|
print(f"Signaturschlüssel übernommen: {report.key_id}")
|
||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
"Diese Installation hat noch keinen Signaturschluessel. Mit --adopt-key "
|
"Diese Installation hat noch keinen Signaturschlüssel. Mit --adopt-key "
|
||||||
"wird der alte uebernommen -- dann bleibt die Anbieteridentitaet erhalten "
|
"wird der alte übernommen – dann bleibt die Anbieteridentität erhalten "
|
||||||
"und Kunden muessen keinen Ausstellerwechsel bestaetigen.",
|
"und Kunden müssen keinen Ausstellerwechsel bestätigen.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
@@ -312,7 +312,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
# Uebernahme.
|
# Uebernahme.
|
||||||
for einstellung, wert in uebernommen.items():
|
for einstellung, wert in uebernommen.items():
|
||||||
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
|
gekuerzt = wert if len(wert) <= 60 else wert[:57] + "..."
|
||||||
print(f"Uebernommen: {einstellung} = {gekuerzt}")
|
print(f"Übernommen: {einstellung} = {gekuerzt}")
|
||||||
|
|
||||||
vendor = {
|
vendor = {
|
||||||
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
"name": str(extension.settings.get(conn, "vendor_name") or "Eigenbetrieb"),
|
||||||
@@ -326,7 +326,7 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
print(
|
print(
|
||||||
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
"Es ist kein Endpunkt hinterlegt, und die alte Installation nennt "
|
||||||
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
"auch keinen. Bitte unter Einstellungen -> Anbieter eintragen: ohne "
|
||||||
"ihn koennen uebernommene Lizenzen sich spaeter nicht aktivieren.",
|
"ihn können übernommene Lizenzen sich später nicht aktivieren.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
@@ -347,10 +347,10 @@ def cmd_migrate_legacy(args: argparse.Namespace) -> int:
|
|||||||
result.key_id = report.key_id
|
result.key_id = report.key_id
|
||||||
if issuer_changes:
|
if issuer_changes:
|
||||||
result.warnings.append(
|
result.warnings.append(
|
||||||
"Die Lizenzen sind mit einem anderen Schluessel signiert als "
|
"Die Lizenzen sind mit einem anderen Schlüssel signiert als "
|
||||||
"frueher. Kunden, die den alten Aussteller gepinnt haben, muessen "
|
"früher. Kunden, die den alten Aussteller gepinnt haben, müssen "
|
||||||
"beim Einspielen einmal 'Wechsel des Ausstellers ausdruecklich "
|
"beim Einspielen einmal 'Wechsel des Ausstellers ausdrücklich "
|
||||||
"zulassen' bestaetigen."
|
"zulassen' bestätigen."
|
||||||
)
|
)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
|
# Nichts festschreiben -- der Lauf war nur zur Ansicht.
|
||||||
@@ -385,7 +385,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
)
|
)
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
init_key = sub.add_parser("init-key", help="Signaturschluessel erzeugen (einmalig)")
|
init_key = sub.add_parser("init-key", help="Signaturschlüssel erzeugen (einmalig)")
|
||||||
init_key.add_argument("--force", action="store_true")
|
init_key.add_argument("--force", action="store_true")
|
||||||
init_key.set_defaults(func=cmd_init_key)
|
init_key.set_defaults(func=cmd_init_key)
|
||||||
|
|
||||||
@@ -401,7 +401,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
|
bootstrap.add_argument("--name", default="Lizenzserver (Eigenbetrieb)")
|
||||||
bootstrap.set_defaults(func=cmd_bootstrap)
|
bootstrap.set_defaults(func=cmd_bootstrap)
|
||||||
|
|
||||||
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zuruecksetzen")
|
admin = sub.add_parser("create-admin", help="Administratorkonto anlegen oder zurücksetzen")
|
||||||
admin.add_argument("--username")
|
admin.add_argument("--username")
|
||||||
admin.add_argument("--password")
|
admin.add_argument("--password")
|
||||||
admin.add_argument("--force", action="store_true")
|
admin.add_argument("--force", action="store_true")
|
||||||
@@ -411,16 +411,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
status.add_argument("--json", action="store_true")
|
status.add_argument("--json", action="store_true")
|
||||||
status.set_defaults(func=cmd_status)
|
status.set_defaults(func=cmd_status)
|
||||||
|
|
||||||
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufraeumen")
|
house = sub.add_parser("housekeeping", help="Laufzeitdaten aufräumen")
|
||||||
house.set_defaults(func=cmd_housekeeping)
|
house.set_defaults(func=cmd_housekeeping)
|
||||||
|
|
||||||
migrate = sub.add_parser(
|
migrate = sub.add_parser(
|
||||||
"migrate-legacy",
|
"migrate-legacy",
|
||||||
help="Lizenzserver der Vorgaengerfassung uebernehmen",
|
help="Lizenzserver der Vorgängerfassung übernehmen",
|
||||||
description=(
|
description=(
|
||||||
"Uebernimmt Signaturschluessel, Kunden, Tickets und Lizenzen aus einer alten "
|
"Übernimmt Signaturschlüssel, Kunden, Tickets und Lizenzen aus einer alten "
|
||||||
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
|
"Installation. Die alte Installation wird nur gelesen. Jede Lizenz wird im "
|
||||||
"neuen Format neu signiert -- die Kunden brauchen danach eine neue "
|
"neuen Format neu signiert – die Kunden brauchen danach eine neue "
|
||||||
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
|
"Lizenzdatei, weil die alte Signatur nicht mehr gilt."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -430,13 +430,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
migrate.add_argument(
|
migrate.add_argument(
|
||||||
"--adopt-key",
|
"--adopt-key",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Den alten Signaturschluessel uebernehmen. Nur moeglich, solange diese "
|
help="Den alten Signaturschlüssel übernehmen. Nur möglich, solange diese "
|
||||||
"Installation noch keinen eigenen hat -- ein vorhandener wird nie ueberschrieben.",
|
"Installation noch keinen eigenen hat – ein vorhandener wird nie überschrieben.",
|
||||||
)
|
)
|
||||||
migrate.add_argument(
|
migrate.add_argument(
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Nur anzeigen, was passieren wuerde -- nichts schreiben",
|
help="Nur anzeigen, was passieren würde – nichts schreiben",
|
||||||
)
|
)
|
||||||
migrate.set_defaults(func=cmd_migrate_legacy)
|
migrate.set_defaults(func=cmd_migrate_legacy)
|
||||||
|
|
||||||
|
|||||||
@@ -28,41 +28,41 @@ def _is_master(_user: Any) -> bool:
|
|||||||
|
|
||||||
NAVIGATION = NavTree(
|
NAVIGATION = NavTree(
|
||||||
[
|
[
|
||||||
NavItem(key="index", label="Uebersicht", icon="grid", endpoint="dashboard.index"),
|
NavItem(key="index", label="Übersicht", icon="grid", endpoint="dashboard.index"),
|
||||||
NavItem(
|
NavItem(
|
||||||
key="customers",
|
key="customers",
|
||||||
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",),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -28,58 +28,97 @@ 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 moeglich, solange kein Ticket am Kunden haengt."},
|
action_hints={A.DELETE: "Nur möglich, solange kein Ticket am Kunden hängt."},
|
||||||
)
|
)
|
||||||
|
|
||||||
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 Anspruechte auf eine Lizenz",
|
hint="Dauerhafte Ansprüche auf eine Lizenz",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Parameter aendern und Ticket sperren/entsperren. Wirkt erst auf die "
|
A.WRITE: "Parameter ändern und Ticket sperren/entsperren. Wirkt erst auf die "
|
||||||
"naechste Ausstellung -- bereits ausgestellte Lizenzen bleiben unveraendert.",
|
"nächste Ausstellung – bereits ausgestellte Lizenzen bleiben unverändert.",
|
||||||
A.DELETE: "Nur moeglich, 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 fuer Kunden",
|
hint="Zugangslinks für Kunden",
|
||||||
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
|
action_hints={A.EXECUTE: "Zugangslink erzeugen und versenden."},
|
||||||
)
|
)
|
||||||
|
|
||||||
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 ausfuehren.",
|
A.EXECUTE: "Verbindungstest ausführen.",
|
||||||
A.SECRETS: "Client-Secret setzen.",
|
A.SECRETS: "Client-Secret setzen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -87,8 +126,8 @@ 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 oeffentlicher Endpunkt -- reisen in jeder Lizenz mit",
|
hint="Name, Kontakt, Logo und öffentlicher Endpunkt – reisen in jeder Lizenz mit",
|
||||||
)
|
)
|
||||||
|
|
||||||
LICENSING_AREA = Area(
|
LICENSING_AREA = Area(
|
||||||
@@ -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,76 +157,70 @@ PERMISSIONS = PermissionTree(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Alle Funktionsrechte ausserhalb der Verwaltung, in einer Gruppe.
|
||||||
|
#:
|
||||||
|
#: Seit 2.8.0 heisst das Administratorkennzeichen "Verwaltung" und nicht
|
||||||
|
#: "alles": Geraete, Neustarts, DHCP und Freigaben kommen ueber Gruppen. Diese
|
||||||
|
#: Vorlage ist die Gruppe, die man sich dafuer zuweist -- und die Migration
|
||||||
|
#: gibt sie bestehenden Administratoren, damit nach dem Update nichts fehlt,
|
||||||
|
#: was vorher ging. Entfernen kann man sie mit einem Klick.
|
||||||
|
#:
|
||||||
|
#: Die Liste entsteht aus dem Baum und nicht aus Tippen: eine neue Zeile in der
|
||||||
|
#: Matrix ist sonst genau die, die hier vergessen wird.
|
||||||
|
FUNKTIONEN_VOLLZUGRIFF = RolePreset(
|
||||||
|
key="functions",
|
||||||
|
name="Funktionen (Vollzugriff)",
|
||||||
|
description=(
|
||||||
|
"Alle Rechte ausserhalb der Verwaltung. Gedacht fuer Administratoren, "
|
||||||
|
"die nicht nur einstellen, sondern auch bedienen."
|
||||||
|
),
|
||||||
|
grants=tuple(
|
||||||
|
(resource.key, "*")
|
||||||
|
for area in PERMISSIONS.areas
|
||||||
|
if area.key != PermissionTree.VERWALTUNGSBEREICH
|
||||||
|
for resource in area.resources
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Wie in TESM: drei Gruppen ab Werk, nicht sechs.
|
||||||
|
#:
|
||||||
|
#: Auch "Vertrieb" ist hier entfallen, obwohl es die Rolle ist, die den
|
||||||
|
#: Lizenzserver taeglich benutzt -- gerade deshalb: wer Lizenzen ausstellt,
|
||||||
|
#: schneidet die Gruppe ohnehin auf sein Haus zu (darf Vertrieb widerrufen?
|
||||||
|
#: Tickets loeschen? in die Protokolle sehen?). Eine mitgelieferte Antwort auf
|
||||||
|
#: diese Frage wird entweder uebernommen, ohne sie zu pruefen, oder sie wird
|
||||||
|
#: umgebaut. "Funktionen (Vollzugriff)" ist der ehrlichere Startpunkt.
|
||||||
ROLE_PRESETS: tuple[RolePreset, ...] = (
|
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(
|
|
||||||
key="sales",
|
|
||||||
name="Vertrieb",
|
|
||||||
description="Kunden und Tickets pflegen, Lizenzen ausstellen und versenden.",
|
|
||||||
grants=(
|
|
||||||
("customers", "*"),
|
|
||||||
("tickets", "*"),
|
|
||||||
("licenses", (A.VIEW, A.CREATE, A.EDIT, A.EXPORT)),
|
|
||||||
("portal", "*"),
|
|
||||||
("logs_live", (A.VIEW,)),
|
|
||||||
("logs_audit", (A.VIEW,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="support",
|
|
||||||
name="Support",
|
|
||||||
description="Lesen und Offline-Aktivierungen verarbeiten.",
|
|
||||||
grants=(
|
|
||||||
("customers", (A.VIEW,)),
|
|
||||||
("tickets", (A.VIEW,)),
|
|
||||||
("licenses", (A.VIEW, A.EXECUTE)),
|
|
||||||
("reports", (A.VIEW,)),
|
|
||||||
("logs_live", (A.VIEW,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
|
||||||
key="auditor",
|
|
||||||
name="Revision",
|
|
||||||
description="Nur lesen -- inklusive Aenderungsprotokoll und Diagnose.",
|
|
||||||
grants=(
|
|
||||||
("customers", (A.VIEW,)),
|
|
||||||
("tickets", (A.VIEW,)),
|
|
||||||
("licenses", (A.VIEW,)),
|
|
||||||
("reports", (A.VIEW,)),
|
|
||||||
("users", (A.VIEW,)),
|
|
||||||
("groups", (A.VIEW,)),
|
|
||||||
("logs_live", (A.VIEW,)),
|
|
||||||
("logs_history", (A.VIEW, A.EXPORT)),
|
|
||||||
("logs_audit", (A.VIEW, A.EXPORT)),
|
|
||||||
("diagnostics", (A.VIEW,)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
RolePreset(
|
RolePreset(
|
||||||
key="administration",
|
key="administration",
|
||||||
name="Administration",
|
name="Administration",
|
||||||
description="Benutzer, Gruppen und Einstellungen -- ohne Admin-Kennzeichen.",
|
description="Benutzer, Gruppen und Einstellungen – ohne Admin-Kennzeichen.",
|
||||||
grants=(
|
grants=(
|
||||||
("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"]
|
||||||
@@ -27,6 +27,8 @@ Aenderungen gegenueber dem Vorgaenger:
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from tesm_core.migrations import Migration
|
from tesm_core.migrations import Migration
|
||||||
|
|
||||||
V1 = """
|
V1 = """
|
||||||
@@ -210,7 +212,92 @@ CREATE INDEX IF NOT EXISTS idx_licenses_revision ON licenses(revision_at);
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def uebergang_funktionsgruppe(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Wie in TESM: das Kennzeichen heisst ab 2.8.0 "Verwaltung".
|
||||||
|
|
||||||
|
Ohne diesen Schritt koennte ein bestehender Administrator nach dem Update
|
||||||
|
keine Lizenz mehr ausstellen -- und wuerde das fuer einen Fehler halten.
|
||||||
|
"""
|
||||||
|
from tesm_core.rbac import service as rbac
|
||||||
|
|
||||||
|
from .permissions import FUNKTIONEN_VOLLZUGRIFF, PERMISSIONS
|
||||||
|
|
||||||
|
rbac.funktionsgruppe_fuer_administratoren(conn, PERMISSIONS, FUNKTIONEN_VOLLZUGRIFF)
|
||||||
|
|
||||||
|
|
||||||
|
#: Die drei Vorlagen, die es bis 2.8.2 gab -- eingefrorene Kopie, siehe TESM.
|
||||||
|
ABGELEGTE_VORLAGEN: tuple[tuple[str, str, tuple[tuple[str, Any], ...]], ...] = (
|
||||||
|
(
|
||||||
|
"sales",
|
||||||
|
"Vertrieb",
|
||||||
|
(
|
||||||
|
("customers", "*"),
|
||||||
|
("tickets", "*"),
|
||||||
|
("licenses", ("read", "export")),
|
||||||
|
("license_issue", "*"),
|
||||||
|
("license_renew", "*"),
|
||||||
|
("license_revoke", "*"),
|
||||||
|
("portal", "*"),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_audit", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"support",
|
||||||
|
"Support",
|
||||||
|
(
|
||||||
|
("customers", ("read",)),
|
||||||
|
("tickets", ("read",)),
|
||||||
|
("licenses", ("read",)),
|
||||||
|
("license_activation", "*"),
|
||||||
|
("reports", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"auditor",
|
||||||
|
"Revision",
|
||||||
|
(
|
||||||
|
("customers", ("read",)),
|
||||||
|
("tickets", ("read",)),
|
||||||
|
("licenses", ("read",)),
|
||||||
|
("reports", ("read",)),
|
||||||
|
("users", ("read",)),
|
||||||
|
("groups", ("read",)),
|
||||||
|
("logs_live", ("read",)),
|
||||||
|
("logs_history", ("read", "export")),
|
||||||
|
("logs_audit", ("read", "export")),
|
||||||
|
("diagnostics", ("read",)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def uebergang_standardgruppen(conn) -> None: # type: ignore[no-untyped-def]
|
||||||
|
"""Wie in TESM: unberuehrte Vorlagen in den Papierkorb, benutzte bleiben."""
|
||||||
|
from tesm_core.rbac import service as rbac
|
||||||
|
from tesm_core.rbac.model import Action, RolePreset
|
||||||
|
|
||||||
|
from .permissions import PERMISSIONS
|
||||||
|
|
||||||
|
vorlagen = [
|
||||||
|
RolePreset(
|
||||||
|
key=key,
|
||||||
|
name=name,
|
||||||
|
description="",
|
||||||
|
grants=tuple(
|
||||||
|
(ressource, a if a == "*" else tuple(Action(x) for x in a))
|
||||||
|
for ressource, a in grants
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for key, name, grants in ABGELEGTE_VORLAGEN
|
||||||
|
]
|
||||||
|
rbac.unberuehrte_vorlagen_entfernen(conn, PERMISSIONS, vorlagen)
|
||||||
|
|
||||||
|
|
||||||
LICENSE_MIGRATIONS: tuple[Migration, ...] = (
|
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),
|
||||||
|
Migration(version=4, name="standardgruppen-aufraeumen", fn=uebergang_standardgruppen),
|
||||||
)
|
)
|
||||||
@@ -132,7 +132,7 @@ def _verify(
|
|||||||
seen_nonce=seen,
|
seen_nonce=seen,
|
||||||
)
|
)
|
||||||
except lic.SignatureInvalid:
|
except lic.SignatureInvalid:
|
||||||
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungueltig.")
|
return None, _error(400, "invalid_signature", "Signatur der Anfrage ist ungültig.")
|
||||||
except lic.ReplayDetected as exc:
|
except lic.ReplayDetected as exc:
|
||||||
return None, _error(409, "replay", str(exc))
|
return None, _error(409, "replay", str(exc))
|
||||||
except lic.ProtocolViolation as exc:
|
except lic.ProtocolViolation as exc:
|
||||||
@@ -150,7 +150,7 @@ def process_activate(
|
|||||||
) -> Outcome:
|
) -> Outcome:
|
||||||
row, error = _verify(conn, request, expected_action="activate")
|
row, error = _verify(conn, request, expected_action="activate")
|
||||||
if error is not None or row is None:
|
if error is not None or row is None:
|
||||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
return error or _error(400, "bad_request", "Ungültige Anfrage.")
|
||||||
|
|
||||||
fingerprint = str(request["fingerprint"])
|
fingerprint = str(request["fingerprint"])
|
||||||
hostname = str(request.get("hostname") or "")
|
hostname = str(request.get("hostname") or "")
|
||||||
@@ -202,7 +202,7 @@ def process_activate(
|
|||||||
target=str(row["license_id"]),
|
target=str(row["license_id"]),
|
||||||
detail=(
|
detail=(
|
||||||
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
|
f"Fingerprint {fingerprint[:16]}..., Host {hostname or 'unbekannt'}"
|
||||||
+ (f", verdraengt {len(superseded)} Lizenz(en)" if superseded else "")
|
+ (f", verdrängt {len(superseded)} Lizenz(en)" if superseded else "")
|
||||||
),
|
),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
@@ -211,7 +211,7 @@ def process_activate(
|
|||||||
request,
|
request,
|
||||||
master_private_key=master_private_key,
|
master_private_key=master_private_key,
|
||||||
status="ok",
|
status="ok",
|
||||||
detail="Aktivierung bestaetigt.",
|
detail="Aktivierung bestätigt.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ def process_deactivate(
|
|||||||
) -> Outcome:
|
) -> Outcome:
|
||||||
row, error = _verify(conn, request, expected_action="deactivate")
|
row, error = _verify(conn, request, expected_action="deactivate")
|
||||||
if error is not None or row is None:
|
if error is not None or row is None:
|
||||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
return error or _error(400, "bad_request", "Ungültige Anfrage.")
|
||||||
|
|
||||||
fingerprint = str(request["fingerprint"])
|
fingerprint = str(request["fingerprint"])
|
||||||
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
|
if str(row["status"]) != "active" or str(row["fingerprint"] or "") != fingerprint:
|
||||||
@@ -249,7 +249,7 @@ def process_deactivate(
|
|||||||
request,
|
request,
|
||||||
master_private_key=master_private_key,
|
master_private_key=master_private_key,
|
||||||
status="ok",
|
status="ok",
|
||||||
detail="Deaktivierung bestaetigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
|
detail="Deaktivierung bestätigt. Die Lizenz kann auf einer anderen Maschine aktiviert werden.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ def process_heartbeat(
|
|||||||
) -> Outcome:
|
) -> Outcome:
|
||||||
row, error = _verify(conn, request, expected_action="heartbeat")
|
row, error = _verify(conn, request, expected_action="heartbeat")
|
||||||
if error is not None or row is None:
|
if error is not None or row is None:
|
||||||
return error or _error(400, "bad_request", "Ungueltige Anfrage.")
|
return error or _error(400, "bad_request", "Ungültige Anfrage.")
|
||||||
|
|
||||||
fingerprint = str(request["fingerprint"])
|
fingerprint = str(request["fingerprint"])
|
||||||
hostname = str(request.get("hostname") or "")
|
hostname = str(request.get("hostname") or "")
|
||||||
@@ -306,7 +306,7 @@ def process_heartbeat(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Bericht von Lizenz %s verworfen -- Rolle ist %s, nicht license_server",
|
"Bericht von Lizenz %s verworfen | Rolle ist %s, nicht license_server",
|
||||||
row["license_id"],
|
row["license_id"],
|
||||||
row["role"],
|
row["role"],
|
||||||
)
|
)
|
||||||
@@ -322,7 +322,7 @@ def process_heartbeat(
|
|||||||
request,
|
request,
|
||||||
master_private_key=master_private_key,
|
master_private_key=master_private_key,
|
||||||
status="ok",
|
status="ok",
|
||||||
detail="Verbindung bestaetigt.",
|
detail="Verbindung bestätigt.",
|
||||||
license_update=update,
|
license_update=update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ def validate_email(value: str, *, required: bool) -> str:
|
|||||||
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
|
# sollen weiter bearbeitbar bleiben, statt ausgesperrt zu werden.
|
||||||
return ""
|
return ""
|
||||||
if not EMAIL_RE.match(text):
|
if not EMAIL_RE.match(text):
|
||||||
raise IssuanceError(f"{text!r} ist keine gueltige E-Mail-Adresse.")
|
raise IssuanceError(f"{text!r} ist keine gültige E-Mail-Adresse.")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ def delete_customer(conn: sqlite3.Connection, customer_id: int) -> None:
|
|||||||
)
|
)
|
||||||
if int(tickets or 0):
|
if int(tickets or 0):
|
||||||
raise IssuanceError(
|
raise IssuanceError(
|
||||||
f"An diesem Kunden haengen noch {tickets} Ticket(s). Bitte zuerst entfernen."
|
f"An diesem Kunden hängen noch {tickets} Ticket(s). Bitte zuerst entfernen."
|
||||||
)
|
)
|
||||||
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
|
conn.execute("DELETE FROM license_customers WHERE id=?", (customer_id,))
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ def create_ticket(
|
|||||||
raise IssuanceError("Unbekannter Lizenztyp.")
|
raise IssuanceError("Unbekannter Lizenztyp.")
|
||||||
if role not in lic.LICENSE_ROLES or role == "master":
|
if role not in lic.LICENSE_ROLES or role == "master":
|
||||||
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
|
# "master" entsteht ausschliesslich lokal per Bootstrap-Skript.
|
||||||
raise IssuanceError("Unzulaessige Rolle.")
|
raise IssuanceError("Unzulässige Rolle.")
|
||||||
|
|
||||||
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
|
# Trial + unbefristet ist ein sinnloser Zustand: eine permanente Lizenz ohne
|
||||||
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
|
# Module. Diese Regel wird an drei Stellen geprueft (hier, beim Bearbeiten
|
||||||
@@ -290,7 +290,7 @@ def update_ticket(
|
|||||||
if license_type != str(ticket["type"]):
|
if license_type != str(ticket["type"]):
|
||||||
changes.append(f"Typ: {ticket['type']} -> {license_type}")
|
changes.append(f"Typ: {ticket['type']} -> {license_type}")
|
||||||
if new_modules != str(ticket["modules"]):
|
if new_modules != str(ticket["modules"]):
|
||||||
changes.append("Module geaendert")
|
changes.append("Module geändert")
|
||||||
if int(valid_days) != int(ticket["valid_days"]):
|
if int(valid_days) != int(ticket["valid_days"]):
|
||||||
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
|
changes.append(f"Laufzeit: {ticket['valid_days']} -> {valid_days} Tage")
|
||||||
if bool(lifetime) != bool(ticket["lifetime"]):
|
if bool(lifetime) != bool(ticket["lifetime"]):
|
||||||
@@ -428,7 +428,7 @@ def issue_from_ticket(
|
|||||||
role = str(ticket["role"])
|
role = str(ticket["role"])
|
||||||
if as_trial and role != "customer":
|
if as_trial and role != "customer":
|
||||||
raise IssuanceError(
|
raise IssuanceError(
|
||||||
"Eine Testlizenz gibt es nur fuer Kundeninstanzen, nicht fuer Lizenzserver."
|
"Eine Testlizenz gibt es nur für Kundeninstanzen, nicht für Lizenzserver."
|
||||||
)
|
)
|
||||||
|
|
||||||
if as_trial:
|
if as_trial:
|
||||||
@@ -452,9 +452,9 @@ def issue_from_ticket(
|
|||||||
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
|
# hier, an der einzigen Stelle, durch die jede Ausstellung laeuft.
|
||||||
if not str(master_endpoint or "").strip():
|
if not str(master_endpoint or "").strip():
|
||||||
raise IssuanceError(
|
raise IssuanceError(
|
||||||
"Es ist kein oeffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
"Es ist kein öffentlicher Endpunkt hinterlegt (Einstellungen -> Anbieter). "
|
||||||
"Ohne ihn koennte sich diese Lizenz nie online aktivieren -- der Endpunkt "
|
"Ohne ihn könnte sich diese Lizenz nie online aktivieren – der Endpunkt "
|
||||||
"wird in die Lizenz signiert und laesst sich spaeter nicht nachtragen."
|
"wird in die Lizenz signiert und lässt sich später nicht nachtragen."
|
||||||
)
|
)
|
||||||
|
|
||||||
customer = get_customer(conn, int(ticket["customer_id"]))
|
customer = get_customer(conn, int(ticket["customer_id"]))
|
||||||
@@ -550,7 +550,7 @@ def delivery_bundle(conn: sqlite3.Connection, license_id: str, *, keystore: Any)
|
|||||||
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
|
bundle["client_key"] = keystore.decrypt(encrypted, aad=client_key_aad(license_id))
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise IssuanceError(
|
raise IssuanceError(
|
||||||
"Der Client-Schluessel dieser Lizenz konnte nicht entschluesselt werden. "
|
"Der Client-Schlüssel dieser Lizenz konnte nicht entschlüsselt werden. "
|
||||||
"Bitte eine neue Lizenz ausstellen."
|
"Bitte eine neue Lizenz ausstellen."
|
||||||
) from exc
|
) from exc
|
||||||
return bundle
|
return bundle
|
||||||
@@ -580,7 +580,7 @@ def delete_license(conn: sqlite3.Connection, license_id: str) -> None:
|
|||||||
raise IssuanceError("Diese Lizenz existiert nicht.")
|
raise IssuanceError("Diese Lizenz existiert nicht.")
|
||||||
if str(row["status"]) not in {"revoked", "issued"}:
|
if str(row["status"]) not in {"revoked", "issued"}:
|
||||||
raise IssuanceError(
|
raise IssuanceError(
|
||||||
"Eine aktive Lizenz kann nicht geloescht werden. Bitte zuerst widerrufen."
|
"Eine aktive Lizenz kann nicht gelöscht werden. Bitte zuerst widerrufen."
|
||||||
)
|
)
|
||||||
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
|
conn.execute("DELETE FROM licenses WHERE license_id=?", (license_id,))
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class Report:
|
|||||||
|
|
||||||
def as_text(self) -> str:
|
def as_text(self) -> str:
|
||||||
lines = [
|
lines = [
|
||||||
f"Signaturschluessel uebernommen: {'ja' if self.key_adopted else 'nein'}"
|
f"Signaturschlüssel übernommen: {'ja' if self.key_adopted else 'nein'}"
|
||||||
+ (f" ({self.key_id})" if self.key_id else ""),
|
+ (f" ({self.key_id})" if self.key_id else ""),
|
||||||
f"Kunden : {self.customers}",
|
f"Kunden : {self.customers}",
|
||||||
f"Tickets : {self.tickets}",
|
f"Tickets : {self.tickets}",
|
||||||
@@ -69,7 +69,7 @@ class Report:
|
|||||||
]
|
]
|
||||||
if self.reissued:
|
if self.reissued:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Neu signiert -- diese Kunden brauchen eine neue Lizenzdatei:")
|
lines.append("Neu signiert – diese Kunden brauchen eine neue Lizenzdatei:")
|
||||||
lines.extend(f" * {entry}" for entry in self.reissued)
|
lines.extend(f" * {entry}" for entry in self.reissued)
|
||||||
if self.skipped:
|
if self.skipped:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -95,7 +95,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
|||||||
"""Oeffnet die alte Datenbank **nur lesend**."""
|
"""Oeffnet die alte Datenbank **nur lesend**."""
|
||||||
path = Path(root) / "sqlite.db"
|
path = Path(root) / "sqlite.db"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgaengerfassung.")
|
raise LegacyImportError(f"Unter {path} liegt keine Datenbank der Vorgängerfassung.")
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
@@ -107,7 +107,7 @@ def open_legacy(root: Path) -> sqlite3.Connection:
|
|||||||
if missing:
|
if missing:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
f"Das sieht nicht nach einem Lizenzserver aus -- es fehlen: {', '.join(sorted(missing))}"
|
f"Das sieht nicht nach einem Lizenzserver aus – es fehlen: {', '.join(sorted(missing))}"
|
||||||
)
|
)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ def _standard_b64(wert: str, feld: str) -> str:
|
|||||||
"""
|
"""
|
||||||
roh = wert.strip()
|
roh = wert.strip()
|
||||||
if not roh:
|
if not roh:
|
||||||
raise LegacyImportError(f"Im alten Signaturschluessel fehlt {feld!r}.")
|
raise LegacyImportError(f"Im alten Signaturschlüssel fehlt {feld!r}.")
|
||||||
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
|
# Fehlende Polsterung ergaenzen -- der Vorgaenger schrieb sie mit, andere
|
||||||
# Werkzeuge lassen sie weg.
|
# Werkzeuge lassen sie weg.
|
||||||
gepolstert = roh + "=" * (-len(roh) % 4)
|
gepolstert = roh + "=" * (-len(roh) % 4)
|
||||||
@@ -138,29 +138,29 @@ def _standard_b64(wert: str, feld: str) -> str:
|
|||||||
continue
|
continue
|
||||||
if len(rohbytes) != 32:
|
if len(rohbytes) != 32:
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
f"{feld} im alten Signaturschluessel ist {len(rohbytes)} Byte lang, "
|
f"{feld} im alten Signaturschlüssel ist {len(rohbytes)} Byte lang, "
|
||||||
"erwartet werden 32 (Ed25519)."
|
"erwartet werden 32 (Ed25519)."
|
||||||
)
|
)
|
||||||
return base64.b64encode(rohbytes).decode("ascii")
|
return base64.b64encode(rohbytes).decode("ascii")
|
||||||
raise LegacyImportError(f"{feld} im alten Signaturschluessel ist kein Base64.")
|
raise LegacyImportError(f"{feld} im alten Signaturschlüssel ist kein Base64.")
|
||||||
|
|
||||||
|
|
||||||
def read_legacy_key(root: Path) -> tuple[str, str]:
|
def read_legacy_key(root: Path) -> tuple[str, str]:
|
||||||
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
"""Liest den alten Signaturschluessel. Rueckgabe: ``(privat, oeffentlich)``."""
|
||||||
path = Path(root) / "master_signing_key.json"
|
path = Path(root) / "master_signing_key.json"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel {path} fehlt.")
|
raise LegacyImportError(f"Der alte Signaturschlüssel {path} fehlt.")
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unlesbar: {exc}") from exc
|
raise LegacyImportError(f"Der alte Signaturschlüssel ist unlesbar: {exc}") from exc
|
||||||
|
|
||||||
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
|
# Die Vorgaengerfassung nannte die Felder "private"/"public" und kodierte
|
||||||
# sie URL-sicher.
|
# sie URL-sicher.
|
||||||
private = str(data.get("private") or data.get("private_key") or "")
|
private = str(data.get("private") or data.get("private_key") or "")
|
||||||
public = str(data.get("public") or data.get("public_key") or "")
|
public = str(data.get("public") or data.get("public_key") or "")
|
||||||
if not private or not public:
|
if not private or not public:
|
||||||
raise LegacyImportError("Der alte Signaturschluessel ist unvollstaendig.")
|
raise LegacyImportError("Der alte Signaturschlüssel ist unvollständig.")
|
||||||
private = _standard_b64(private, "private")
|
private = _standard_b64(private, "private")
|
||||||
public = _standard_b64(public, "public")
|
public = _standard_b64(public, "public")
|
||||||
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
|
# Alles, was aus der Protokollschicht kommt, wird hier zu einer Meldung im
|
||||||
@@ -170,11 +170,11 @@ def read_legacy_key(root: Path) -> tuple[str, str]:
|
|||||||
try:
|
try:
|
||||||
passt = licsrv.public_from_private(private) == public
|
passt = licsrv.public_from_private(private) == public
|
||||||
except (ValueError, TypeError) as exc:
|
except (ValueError, TypeError) as exc:
|
||||||
raise LegacyImportError(f"Der alte Signaturschluessel ist unbrauchbar: {exc}") from exc
|
raise LegacyImportError(f"Der alte Signaturschlüssel ist unbrauchbar: {exc}") from exc
|
||||||
if not passt:
|
if not passt:
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
"Im alten Signaturschluessel passen privater und oeffentlicher Teil nicht "
|
"Im alten Signaturschlüssel passen privater und öffentlicher Teil nicht "
|
||||||
"zueinander. Die Datei ist beschaedigt."
|
"zueinander. Die Datei ist beschädigt."
|
||||||
)
|
)
|
||||||
return private, public
|
return private, public
|
||||||
|
|
||||||
@@ -190,16 +190,16 @@ def adopt_key(private: str, public: str, target: Path) -> str:
|
|||||||
|
|
||||||
if Path(target).is_file():
|
if Path(target).is_file():
|
||||||
raise LegacyImportError(
|
raise LegacyImportError(
|
||||||
f"Unter {target} liegt bereits ein Signaturschluessel. Er wird nicht "
|
f"Unter {target} liegt bereits ein Signaturschlüssel. Er wird nicht "
|
||||||
"ueberschrieben -- das wuerde jede damit ausgestellte Lizenz unbrauchbar "
|
"überschrieben – das würde jede damit ausgestellte Lizenz unbrauchbar "
|
||||||
"machen. Fuer eine Uebernahme bitte eine frische Installation verwenden."
|
"machen. Für eine Übernahme bitte eine frische Installation verwenden."
|
||||||
)
|
)
|
||||||
payload = {
|
payload = {
|
||||||
"private_key": private,
|
"private_key": private,
|
||||||
"public_key": public,
|
"public_key": public,
|
||||||
"key_id": lic.key_id(public),
|
"key_id": lic.key_id(public),
|
||||||
"created_at": _now(),
|
"created_at": _now(),
|
||||||
"origin": "Uebernommen aus der Vorgaengerfassung.",
|
"origin": "Übernommen aus der Vorgängerfassung.",
|
||||||
}
|
}
|
||||||
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
write_private_file(Path(target), json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||||
return lic.key_id(public)
|
return lic.key_id(public)
|
||||||
@@ -309,7 +309,7 @@ def import_all(
|
|||||||
)
|
)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
customer_map[int(row["id"])] = int(existing["id"])
|
customer_map[int(row["id"])] = int(existing["id"])
|
||||||
report.warnings.append(f"Kunde {name!r} war schon vorhanden -- nicht doppelt angelegt.")
|
report.warnings.append(f"Kunde {name!r} war schon vorhanden – nicht doppelt angelegt.")
|
||||||
continue
|
continue
|
||||||
new_id = issuance.create_customer(
|
new_id = issuance.create_customer(
|
||||||
conn,
|
conn,
|
||||||
@@ -331,7 +331,7 @@ def import_all(
|
|||||||
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
|
for row in legacy.execute("SELECT * FROM license_tickets ORDER BY created_at"):
|
||||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||||
if customer_id is None:
|
if customer_id is None:
|
||||||
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht uebernommen")
|
report.skipped.append(f"Ticket {str(row['id'])[:12]} -- Kunde nicht übernommen")
|
||||||
continue
|
continue
|
||||||
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
|
if Database.one(conn, "SELECT id FROM license_tickets WHERE id=?", (row["id"],)):
|
||||||
continue
|
continue
|
||||||
@@ -348,7 +348,7 @@ def import_all(
|
|||||||
int(row["lifetime"] or 0),
|
int(row["lifetime"] or 0),
|
||||||
str(row["status"] or "open"),
|
str(row["status"] or "open"),
|
||||||
str(row["role"] or "customer"),
|
str(row["role"] or "customer"),
|
||||||
"Aus der Vorgaengerfassung uebernommen.",
|
"Aus der Vorgängerfassung übernommen.",
|
||||||
str(row["created_by"] or actor),
|
str(row["created_by"] or actor),
|
||||||
str(row["created_at"] or _now()),
|
str(row["created_at"] or _now()),
|
||||||
_now(),
|
_now(),
|
||||||
@@ -361,7 +361,7 @@ def import_all(
|
|||||||
license_id = str(row["license_id"] or "")
|
license_id = str(row["license_id"] or "")
|
||||||
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
customer_id = customer_map.get(int(row["customer_id"] or 0))
|
||||||
if not license_id or customer_id is None:
|
if not license_id or customer_id is None:
|
||||||
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht uebernommen")
|
report.skipped.append(f"Lizenz {license_id[:12] or '?'} -- Kunde nicht übernommen")
|
||||||
continue
|
continue
|
||||||
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
|
if Database.one(conn, "SELECT id FROM licenses WHERE license_id=?", (license_id,)):
|
||||||
continue
|
continue
|
||||||
@@ -371,7 +371,7 @@ def import_all(
|
|||||||
modules = _modules(row["modules"])
|
modules = _modules(row["modules"])
|
||||||
|
|
||||||
if not reissue:
|
if not reissue:
|
||||||
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezaehlt")
|
report.skipped.append(f"{customer['name']} / {license_id[:12]} -- nur gezählt")
|
||||||
report.licenses += 1
|
report.licenses += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -383,7 +383,7 @@ def import_all(
|
|||||||
if not lifetime and valid_days <= 0:
|
if not lifetime and valid_days <= 0:
|
||||||
report.skipped.append(
|
report.skipped.append(
|
||||||
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
|
f"{customer['name']} / {license_id[:12]} -- abgelaufen am "
|
||||||
f"{str(row['expires_at'])[:10]}, nicht uebernommen"
|
f"{str(row['expires_at'])[:10]}, nicht übernommen"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -444,7 +444,7 @@ def import_all(
|
|||||||
if report.reissued:
|
if report.reissued:
|
||||||
report.warnings.append(
|
report.warnings.append(
|
||||||
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
|
"Die neu signierten Lizenzen sind noch nicht aktiviert: die Bindung an eine "
|
||||||
"Maschine gilt nur fuer die alte Datei. Nach dem Einspielen aktiviert der "
|
"Maschine gilt nur für die alte Datei. Nach dem Einspielen aktiviert der "
|
||||||
"Kunde erneut."
|
"Kunde erneut."
|
||||||
)
|
)
|
||||||
return report
|
return report
|
||||||
@@ -92,7 +92,7 @@ def _secret(conn: sqlite3.Connection) -> str:
|
|||||||
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
|
return core().keystore.decrypt(str(value), aad=AAD_GRAPH_SECRET)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
raise MailError(
|
raise MailError(
|
||||||
"Das Client-Secret konnte nicht entschluesselt werden -- bitte neu eintragen."
|
"Das Client-Secret konnte nicht entschlüsselt werden – bitte neu eintragen."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
@@ -162,7 +162,7 @@ def fetch_token(conn: sqlite3.Connection) -> str:
|
|||||||
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||||
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
|
"""Holt nur ein Token -- verschickt bewusst keine echte Nachricht."""
|
||||||
if not is_enabled(conn):
|
if not is_enabled(conn):
|
||||||
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollstaendig konfiguriert."}
|
return {"ok": False, "message": "Der E-Mail-Versand ist nicht vollständig konfiguriert."}
|
||||||
try:
|
try:
|
||||||
fetch_token(conn)
|
fetch_token(conn)
|
||||||
except MailError as exc:
|
except MailError as exc:
|
||||||
|
|||||||
@@ -48,22 +48,22 @@ class MasterKey:
|
|||||||
def load(path: Path) -> MasterKey:
|
def load(path: Path) -> MasterKey:
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise MasterKeyError(
|
raise MasterKeyError(
|
||||||
f"Der Signaturschluessel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
|
f"Der Signaturschlüssel {path} fehlt. Ohne ihn kann keine Lizenz ausgestellt "
|
||||||
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
|
"werden. Erzeugen Sie ihn mit 'tesm-license-admin init-key'."
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
raise MasterKeyError(f"Der Signaturschluessel ist unlesbar: {exc}") from exc
|
raise MasterKeyError(f"Der Signaturschlüssel ist unlesbar: {exc}") from exc
|
||||||
|
|
||||||
private = str(data.get("private_key") or "")
|
private = str(data.get("private_key") or "")
|
||||||
public = str(data.get("public_key") or "")
|
public = str(data.get("public_key") or "")
|
||||||
if not private or not public:
|
if not private or not public:
|
||||||
raise MasterKeyError("Der Signaturschluessel ist unvollstaendig.")
|
raise MasterKeyError("Der Signaturschlüssel ist unvollständig.")
|
||||||
if licsrv.public_from_private(private) != public:
|
if licsrv.public_from_private(private) != public:
|
||||||
raise MasterKeyError(
|
raise MasterKeyError(
|
||||||
"Der oeffentliche Teil des Signaturschluessels passt nicht zum privaten. "
|
"Der öffentliche Teil des Signaturschlüssels passt nicht zum privaten. "
|
||||||
"Die Datei ist beschaedigt oder wurde manipuliert."
|
"Die Datei ist beschädigt oder wurde manipuliert."
|
||||||
)
|
)
|
||||||
return MasterKey(
|
return MasterKey(
|
||||||
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
|
private_key=private, public_key=public, created_at=str(data.get("created_at") or "")
|
||||||
@@ -87,7 +87,7 @@ def ensure(path: Path) -> MasterKey:
|
|||||||
}
|
}
|
||||||
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
write_private_file(path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Neuer Signaturschluessel erzeugt (%s). Diese Datei ist unwiederbringlich -- "
|
"Neuer Signaturschlüssel erzeugt (%s). Diese Datei ist unwiederbringlich – "
|
||||||
"bitte sofort gesichert ablegen: %s",
|
"bitte sofort gesichert ablegen: %s",
|
||||||
lic.key_id(public),
|
lic.key_id(public),
|
||||||
path,
|
path,
|
||||||
@@ -98,7 +98,7 @@ def ensure(path: Path) -> MasterKey:
|
|||||||
def health(path: Path) -> dict[str, object]:
|
def health(path: Path) -> dict[str, object]:
|
||||||
"""Zustand fuer die Diagnoseseite."""
|
"""Zustand fuer die Diagnoseseite."""
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return {"present": False, "problems": ["Signaturschluessel fehlt"]}
|
return {"present": False, "problems": ["Signaturschlüssel fehlt"]}
|
||||||
problems = check_private_file(path)
|
problems = check_private_file(path)
|
||||||
try:
|
try:
|
||||||
key = load(path)
|
key = load(path)
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -41,11 +42,11 @@ APP_TAG = "tesm-license"
|
|||||||
|
|
||||||
CATEGORIES: tuple[Category, ...] = (
|
CATEGORIES: tuple[Category, ...] = (
|
||||||
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
|
Category("customers", "Kunden", "Stammdaten der Kundeninstanzen"),
|
||||||
Category("tickets", "Tickets", "Anspruechte inklusive Self-Service-Schluessel", sensitive=True),
|
Category("tickets", "Tickets", "Ansprüche inklusive Self-Service-Schlüssel", sensitive=True),
|
||||||
Category(
|
Category(
|
||||||
"licenses",
|
"licenses",
|
||||||
"Lizenzen",
|
"Lizenzen",
|
||||||
"Ausgestellte Lizenzen mit signiertem Dokument und verschluesseltem Client-Schluessel",
|
"Ausgestellte Lizenzen mit signiertem Dokument und verschlüsseltem Client-Schlüssel",
|
||||||
sensitive=True,
|
sensitive=True,
|
||||||
),
|
),
|
||||||
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
Category("users", "Benutzer", "Lokale Konten mit Passwort-Hash", admin_only=True),
|
||||||
@@ -153,6 +154,10 @@ def apply_import(
|
|||||||
zusammengefuehrt; Tickets und Lizenzen ueber ihre ID, weil die zugleich
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ VENDOR_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"master_endpoint",
|
"master_endpoint",
|
||||||
"",
|
"",
|
||||||
"str",
|
"str",
|
||||||
"Oeffentlicher Endpunkt dieses Lizenzservers",
|
"Öffentlicher Endpunkt dieses Lizenzservers",
|
||||||
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen -- z. B. "
|
hint="Basis-URL, unter der Kundeninstanzen diesen Server erreichen – z. B. "
|
||||||
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
|
"https://lizenz.firma.de. Wird in jede Lizenz eingebettet und fuer alle "
|
||||||
"kundengerichteten Links verwendet.",
|
"kundengerichteten Links verwendet.",
|
||||||
section="vendor",
|
section="vendor",
|
||||||
@@ -38,8 +38,8 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"portal_link_minutes",
|
"portal_link_minutes",
|
||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Gueltigkeit des Zugangslinks (Minuten)",
|
"Gültigkeit des Zugangslinks (Minuten)",
|
||||||
hint="Der Link ist zusaetzlich nur einmal verwendbar.",
|
hint="Der Link ist zusätzlich nur einmal verwendbar.",
|
||||||
section="portal",
|
section="portal",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
maximum=1440,
|
maximum=1440,
|
||||||
@@ -48,9 +48,9 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"portal_session_minutes",
|
"portal_session_minutes",
|
||||||
60,
|
60,
|
||||||
"int",
|
"int",
|
||||||
"Gueltigkeit der Portalsitzung (Minuten)",
|
"Gültigkeit der Portalsitzung (Minuten)",
|
||||||
hint="Zweites, unabhaengiges Zeitfenster: eines fuer das Anklicken des Links, "
|
hint="Zweites, unabhängiges Zeitfenster: eines für das Anklicken des Links, "
|
||||||
"eines fuer die daraus entstehende Sitzung.",
|
"eines für die daraus entstehende Sitzung.",
|
||||||
section="portal",
|
section="portal",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
maximum=1440,
|
maximum=1440,
|
||||||
@@ -64,7 +64,7 @@ PORTAL_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"bool",
|
"bool",
|
||||||
"Self-Service-Link je Ticket aktiv",
|
"Self-Service-Link je Ticket aktiv",
|
||||||
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
|
hint="Erlaubt dem Kunden, seine Lizenz selbst herunterzuladen, zu widerrufen und "
|
||||||
"neu auszustellen -- ohne Anmeldung, allein ueber den unratbaren Ticket-Link.",
|
"neu auszustellen – ohne Anmeldung, allein über den unratbaren Ticket-Link.",
|
||||||
section="portal",
|
section="portal",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -83,8 +83,8 @@ DEFAULT_TICKET_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"allow_customer_revoke",
|
"allow_customer_revoke",
|
||||||
True,
|
True,
|
||||||
"bool",
|
"bool",
|
||||||
"Kunden duerfen ihre Lizenz selbst widerrufen",
|
"Kunden dürfen ihre Lizenz selbst widerrufen",
|
||||||
hint="Notwendig fuer einen Systemwechsel ohne Support-Anruf.",
|
hint="Notwendig für einen Systemwechsel ohne Support-Anruf.",
|
||||||
section="licensing",
|
section="licensing",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{% if current_user.has('tickets.create') %}
|
{% if current_user.has('tickets.create') %}
|
||||||
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', kunde=customer.id) }}">
|
<a class="btn btn--primary btn--sm" href="{{ url_for('tickets.new', customer=customer.id) }}">
|
||||||
{{ icon('plus', size=15) }} Ticket anlegen
|
{{ icon('plus', size=15) }} Ticket anlegen
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -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">
|
||||||
@@ -170,7 +170,7 @@
|
|||||||
<summary class="card__header" style="cursor:pointer">
|
<summary class="card__header" style="cursor:pointer">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
<div class="card__title">{{ icon('file-text', size=17) }} Signiertes Dokument</div>
|
||||||
<div class="card__sub">Genau so, wie es signiert wurde -- jede Änderung bricht die Signatur</div>
|
<div class="card__sub">Genau so, wie es signiert wurde – jede Änderung bricht die Signatur</div>
|
||||||
</div>
|
</div>
|
||||||
{{ icon('chevron-down', size=16) }}
|
{{ icon('chevron-down', size=16) }}
|
||||||
</summary>
|
</summary>
|
||||||
@@ -204,17 +204,17 @@
|
|||||||
</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">
|
||||||
Das Ticket bleibt offen -- eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
Das Ticket bleibt offen – eine neue Lizenz kann sofort ausgestellt werden. Genau so
|
||||||
läuft ein Systemwechsel.
|
läuft ein Systemwechsel.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="auth-footnote">
|
<p class="auth-footnote">
|
||||||
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung -- unabhängig davon, ob die
|
Aus Datenschutzgründen bekommen Sie immer dieselbe Rückmeldung – unabhängig davon, ob die
|
||||||
Adresse bei uns bekannt ist.
|
Adresse bei uns bekannt ist.
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
{% if response_code %}
|
{% if response_code %}
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field__label">Antwortcode -- in TESM eintragen</span>
|
<span class="field__label">Antwortcode – in TESM eintragen</span>
|
||||||
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
|
<pre class="log-view" id="offline-answer" style="max-height:16dvh">{{ response_code }}</pre>
|
||||||
<div class="row row--end mt-2">
|
<div class="row row--end mt-2">
|
||||||
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
|
<button class="btn btn--sm" type="button" data-copy="#offline-answer">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="fs-sm muted mt-2">
|
<p class="fs-sm muted mt-2">
|
||||||
Tragen Sie ihn in TESM unter Verwaltung -- Lizenz bei
|
Tragen Sie ihn in TESM unter Verwaltung → Lizenz bei
|
||||||
„Antwortcode des Lizenzservers“ ein.
|
„Antwortcode des Lizenzservers“ ein.
|
||||||
{% if summary.hostname %}
|
{% if summary.hostname %}
|
||||||
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
|
Die Anfrage kam von <span class="mono">{{ summary.hostname }}</span>.
|
||||||
|
|||||||
@@ -33,16 +33,16 @@ bewusst keine echte Nachricht.', 'info') }}
|
|||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
<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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "tesm_core/base.html" %}
|
{% extends "tesm_core/base.html" %}
|
||||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert %}
|
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, nav_order_card %}
|
||||||
|
|
||||||
{% block page_title %}Systemeinstellungen{% endblock %}
|
{% block page_title %}Systemeinstellungen{% endblock %}
|
||||||
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
{% block page_sub %}Portal, Self-Service, Protokolle, Host{% endblock %}
|
||||||
@@ -60,9 +60,6 @@
|
|||||||
selected=values.log_rotate_interval) }}
|
selected=values.log_rotate_interval) }}
|
||||||
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
{{ field('log_rotate_keep', 'Aufbewahrte Rotationen', value=values.log_rotate_keep,
|
||||||
type='number') }}
|
type='number') }}
|
||||||
{{ field('session_idle_minutes', 'Automatische Abmeldung (Minuten)',
|
|
||||||
value=values.session_idle_minutes, type='number',
|
|
||||||
hint_text=specs['session_idle_minutes'].hint) }}
|
|
||||||
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
{{ field('trash_retention_days', 'Papierkorb-Aufbewahrung (Tage)',
|
||||||
value=values.trash_retention_days, type='number') }}
|
value=values.trash_retention_days, type='number') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -73,6 +70,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stack">
|
<div class="stack">
|
||||||
|
{# Eigene Karte: die Abmeldung stand zwischen Protokollrotation und
|
||||||
|
Papierkorb -- drei Dinge ohne Zusammenhang. Wer die Sitzungsdauer sucht,
|
||||||
|
sucht sie nicht unter "Protokolle". #}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('clock', size=17) }} Sitzungen</div>
|
||||||
|
<div class="card__sub">Wie lange eine Anmeldung gilt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('settings.system_save') }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
||||||
|
<div class="card__body">
|
||||||
|
<div class="form-grid">
|
||||||
|
{{ field('session_idle_minutes', 'Automatische Abmeldung nach Leerlauf (Minuten)',
|
||||||
|
value=values.session_idle_minutes, type='number',
|
||||||
|
attrs='min="5" max="1440" step="1"',
|
||||||
|
hint_text=specs['session_idle_minutes'].hint) }}
|
||||||
|
{{ field('session_absolute_hours', 'Längstmögliche Sitzungsdauer (Stunden)',
|
||||||
|
value=values.session_absolute_hours, type='number',
|
||||||
|
attrs='min="1" max="168" step="1"',
|
||||||
|
hint_text=specs['session_absolute_hours'].hint) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="card__header">
|
<div class="card__header">
|
||||||
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Host</div></div>
|
||||||
@@ -104,33 +129,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<section class="card">
|
{{ nav_order_card(nav_baum, nav_order, nav_child_order,
|
||||||
<div class="card__header">
|
url_for('admin.nav_order'), csrf_field_name, csrf_token(),
|
||||||
<div class="flex-1">
|
sub='Gilt für alle Benutzer') }}
|
||||||
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
|
||||||
<div class="card__sub">Gilt für alle Benutzer</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="{{ url_for('admin.nav_order') }}">
|
|
||||||
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
|
|
||||||
<div class="card__body">
|
|
||||||
<ol class="stack stack--tight">
|
|
||||||
{% for key in nav_order %}
|
|
||||||
{% set item = nav_items | selectattr('key', 'equalto', key) | list | first %}
|
|
||||||
{% if item %}
|
|
||||||
<li class="row row--tight">
|
|
||||||
<input type="hidden" name="order" value="{{ item.key }}">
|
|
||||||
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
<div class="card__footer">
|
|
||||||
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 grosses 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"
|
||||||
@@ -75,7 +75,7 @@ welche Basis-URL in kundengerichteten Links steht.', 'info') }}
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<p class="fs-sm muted">
|
<p class="fs-sm muted">
|
||||||
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
|
Die Angaben werden zum Zeitpunkt der Ausstellung in die Lizenz kopiert und signiert. Eine
|
||||||
Änderung hier wirkt auf neue Lizenzen -- bestehende behalten ihren Stand, bis eine neue
|
Änderung hier wirkt auf neue Lizenzen – bestehende behalten ihren Stand, bis eine neue
|
||||||
Revision signiert wird.
|
Revision signiert wird.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
|
<div class="card__title">{{ icon('shield', size=17) }} Lizenz ausstellen</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
{% if has_open_license %}
|
{% if has_open_license %}
|
||||||
Es existiert bereits eine offene Lizenz -- widerrufen Sie sie zuerst.
|
Es existiert bereits eine offene Lizenz – widerrufen Sie sie zuerst.
|
||||||
{% else %}
|
{% else %}
|
||||||
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
|
Erzeugt eine signierte Lizenzdatei mit den Werten dieses Tickets.
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -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() }}">
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="fs-xs faint">
|
<p class="fs-xs faint">
|
||||||
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
|
Damit kann der Kunde seine Lizenz herunterladen, bei einem Systemwechsel freigeben und
|
||||||
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar -- behandeln Sie
|
neu ausstellen. Der Link enthält 192 Bit Zufall und ist nicht ratbar – behandeln Sie
|
||||||
ihn wie ein Passwort.
|
ihn wie ein Passwort.
|
||||||
</p>
|
</p>
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -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() }}">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<span class="field__hint">
|
<span class="field__hint">
|
||||||
Der Kunde ist nach dem Anlegen unveränderlich -- der Self-Service-Link ist an ihn
|
Der Kunde ist nach dem Anlegen unveränderlich – der Self-Service-Link ist an ihn
|
||||||
gebunden.
|
gebunden.
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
<div class="card__title">{{ icon('share', size=17) }} Lizenzserver-Lizenz</div>
|
||||||
<div class="card__sub">
|
<div class="card__sub">
|
||||||
Erzeugt eine neue Vertrauensgrenze -- der Empfänger verwaltet danach eigene Kunden
|
Erzeugt eine neue Vertrauensgrenze – der Empfänger verwaltet danach eigene Kunden
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<div class="card__body">
|
<div class="card__body">
|
||||||
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
|
<ol class="stack stack--tight fs-sm muted" style="list-style:decimal;padding-left:1.2em">
|
||||||
<li>Ticket anlegen (dieser Schritt).</li>
|
<li>Ticket anlegen (dieser Schritt).</li>
|
||||||
<li>Auf der Ticketseite eine Lizenz ausstellen -- optional als 30-Tage-Testlizenz mit
|
<li>Auf der Ticketseite eine Lizenz ausstellen – optional als 30-Tage-Testlizenz mit
|
||||||
allen Modulen.</li>
|
allen Modulen.</li>
|
||||||
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
|
<li>Lizenzdatei herunterladen oder per E-Mail versenden.</li>
|
||||||
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
|
<li>Der Kunde spielt sie ein und aktiviert sie; die Aktivierung bindet sie an seine
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
<p class="fs-sm muted">
|
<p class="fs-sm muted">
|
||||||
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
|
Der Self-Service-Link des Tickets erlaubt dem Kunden, seine Lizenz selbst
|
||||||
herunterzuladen und bei einem Systemwechsel freizugeben -- ohne Support-Anruf.
|
herunterzuladen und bei einem Systemwechsel freizugeben – ohne Support-Anruf.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
|
|||||||
+10
-1
@@ -168,6 +168,8 @@ LOG_DIR="/var/log/$NAME"
|
|||||||
SERVICE_USER="$APP"
|
SERVICE_USER="$APP"
|
||||||
UNIT="$NAME.service"
|
UNIT="$NAME.service"
|
||||||
MONITOR_UNIT="$NAME-monitor.service"
|
MONITOR_UNIT="$NAME-monitor.service"
|
||||||
|
CLEANUP_UNIT="$NAME-cleanup.service"
|
||||||
|
CLEANUP_TIMER="$NAME-cleanup.timer"
|
||||||
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
NGINX_SITE="/etc/nginx/sites-available/$NAME"
|
||||||
ENV_PREFIX="TESM_"
|
ENV_PREFIX="TESM_"
|
||||||
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
[[ "$APP" == "tesm-license" ]] && ENV_PREFIX="TESM_LICENSE_"
|
||||||
@@ -539,6 +541,11 @@ render_unit "$ROOT/deploy/systemd/$APP.service" "/etc/systemd/system/$UNIT"
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
render_unit "$ROOT/deploy/systemd/tesm-monitor.service" "/etc/systemd/system/$MONITOR_UNIT"
|
||||||
fi
|
fi
|
||||||
|
# Aufraeumen lief bisher nur von Hand -- also praktisch nie. Auf einer
|
||||||
|
# laufenden Installation lagen deshalb hunderte abgelaufene Sitzungszeilen in
|
||||||
|
# der Datenbank, und das Aenderungsprotokoll waere nie archiviert worden.
|
||||||
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.service" "/etc/systemd/system/$CLEANUP_UNIT"
|
||||||
|
render_unit "$ROOT/deploy/systemd/$APP-cleanup.timer" "/etc/systemd/system/$CLEANUP_TIMER"
|
||||||
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
[[ $UNITS_CHANGED -eq 1 ]] && systemctl daemon-reload && info "Units neu geladen" \
|
||||||
|| info "Units unveraendert"
|
|| info "Units unveraendert"
|
||||||
|
|
||||||
@@ -611,6 +618,8 @@ systemctl enable --now "$UNIT" >/dev/null
|
|||||||
if [[ "$APP" == "tesm" ]]; then
|
if [[ "$APP" == "tesm" ]]; then
|
||||||
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
systemctl enable --now "$MONITOR_UNIT" >/dev/null
|
||||||
fi
|
fi
|
||||||
|
# Der Timer, nicht der Dienst: der Dienst laeuft einmal und ist fertig.
|
||||||
|
systemctl enable --now "$CLEANUP_TIMER" >/dev/null 2>&1 && info "Aufraeumen: taeglich 03:30" || info "Aufraeum-Timer nicht aktiviert -- 'systemctl status $CLEANUP_TIMER' zeigt warum"
|
||||||
|
|
||||||
# -- Health-Check und Rollback ----------------------------------------------
|
# -- Health-Check und Rollback ----------------------------------------------
|
||||||
step "Gesundheitspruefung"
|
step "Gesundheitspruefung"
|
||||||
@@ -620,7 +629,7 @@ for attempt in $(seq 1 20); do
|
|||||||
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
# pruefen ging schon einmal schief -- auf einem gemeinsamen Port antwortete
|
||||||
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
# eine andere Instanz, und ein toter Dienst galt als gesund.
|
||||||
if systemctl is-active --quiet "$UNIT" &&
|
if systemctl is-active --quiet "$UNIT" &&
|
||||||
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/gesundheit" >/dev/null 2>&1; then
|
curl -fsS --max-time 3 "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
|
||||||
HEALTHY=1
|
HEALTHY=1
|
||||||
info "Antwort nach ${attempt}x"
|
info "Antwort nach ${attempt}x"
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||||
|
After=tesm.service
|
||||||
|
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||||
|
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||||
|
# Hochfahren.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=tesm
|
||||||
|
Group=tesm
|
||||||
|
WorkingDirectory=/srv/tesm
|
||||||
|
Environment=TESM_BASE_DIR=/srv/tesm
|
||||||
|
Environment=TESM_DATA_DIR=/srv/tesm/data
|
||||||
|
Environment=TESM_LOG_DIR=/var/log/tesm
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/srv/tesm/venv/bin/tesm-admin housekeeping
|
||||||
|
|
||||||
|
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||||
|
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
LockPersonality=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0027
|
||||||
|
ReadWritePaths=/srv/tesm/data /var/log/tesm
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM -- taegliches Aufraeumen
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||||
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
|
#
|
||||||
|
# Vorher lief das nur, wenn jemand "tesm-admin housekeeping" aufrief oder in
|
||||||
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
|
RandomizedDelaySec=15m
|
||||||
|
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM-Lizenzserver -- Aufraeumen: Sitzungen, Ratenereignisse, Protokollarchiv
|
||||||
|
After=tesm-license.service
|
||||||
|
# Ohne die laufende Anwendung ist nichts kaputt -- der Befehl arbeitet direkt
|
||||||
|
# auf der Datenbank. "After" sorgt nur fuer eine sinnvolle Reihenfolge beim
|
||||||
|
# Hochfahren.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=tesm-license
|
||||||
|
Group=tesm-license
|
||||||
|
WorkingDirectory=/srv/tesm-license
|
||||||
|
Environment=TESM_LICENSE_BASE_DIR=/srv/tesm-license
|
||||||
|
Environment=TESM_LICENSE_DATA_DIR=/srv/tesm-license/data
|
||||||
|
Environment=TESM_LICENSE_LOG_DIR=/var/log/tesm-license
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
ExecStart=/srv/tesm-license/venv/bin/tesm-license-admin housekeeping
|
||||||
|
|
||||||
|
# Hier darf voll gehaertet werden: dieser Aufruf fasst nur die Datenbank und
|
||||||
|
# das Protokollverzeichnis an, keine Systemdateien und keinen Helfer per sudo.
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictNamespaces=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
LockPersonality=yes
|
||||||
|
SystemCallArchitectures=native
|
||||||
|
UMask=0027
|
||||||
|
ReadWritePaths=/srv/tesm-license/data /var/log/tesm-license
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=TESM-Lizenzserver -- taegliches Aufraeumen
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
# Nachts, wenn niemand arbeitet: abgelaufene Sitzungen, alte Ratenereignisse
|
||||||
|
# und die Archivierung des Aenderungsprotokolls.
|
||||||
|
#
|
||||||
|
# Vorher lief das nur, wenn jemand "tesm-license-admin housekeeping" aufrief oder in
|
||||||
|
# der Diagnose darauf klickte -- also praktisch nie. Gemessen auf einer
|
||||||
|
# Installation nach vier Wochen: 747 Sitzungszeilen, 682 davon abgelaufen.
|
||||||
|
OnCalendar=*-*-* 03:30:00
|
||||||
|
# Streuung, damit nicht jede Installation im Netz zur selben Sekunde loslegt.
|
||||||
|
RandomizedDelaySec=15m
|
||||||
|
# Nachgeholt, wenn die Maschine nachts aus war.
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
+1
-1
@@ -446,7 +446,7 @@ Unverzichtbar:
|
|||||||
**Die Anwendung antwortet nicht.**
|
**Die Anwendung antwortet nicht.**
|
||||||
```bash
|
```bash
|
||||||
journalctl -u tesm -n 200 --no-pager
|
journalctl -u tesm -n 200 --no-pager
|
||||||
curl -s http://127.0.0.1:5000/gesundheit
|
curl -s http://127.0.0.1:5000/health
|
||||||
```
|
```
|
||||||
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
Antwortet gunicorn, aber der Browser nicht, liegt es an nginx.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-core"
|
name = "tesm-core"
|
||||||
version = "2.3.2"
|
version = "2.8.3"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import secrets
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Sequence
|
from typing import Any, Sequence
|
||||||
|
|
||||||
from flask import Blueprint, Flask, g, render_template, request
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from flask import Blueprint, Flask, g, redirect, render_template, request
|
||||||
from werkzeug.exceptions import HTTPException
|
from werkzeug.exceptions import HTTPException
|
||||||
|
|
||||||
from . import audit, logging_setup, ui
|
from . import audit, legacy_urls, logging_setup, ui
|
||||||
from .config import CoreConfig
|
from .config import CoreConfig
|
||||||
from .core_schema import CORE_MIGRATIONS
|
from .core_schema import CORE_MIGRATIONS
|
||||||
from .db import Database
|
from .db import Database
|
||||||
@@ -57,18 +59,29 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Aufbewahrung im Papierkorb (Tage)",
|
"Aufbewahrung im Papierkorb (Tage)",
|
||||||
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
|
hint="Nach dieser Frist werden Einträge beim nächsten Aufruf endgültig entfernt.",
|
||||||
section="system",
|
section="system",
|
||||||
minimum=1,
|
minimum=1,
|
||||||
maximum=3650,
|
maximum=3650,
|
||||||
),
|
),
|
||||||
|
SettingSpec(
|
||||||
|
"session_absolute_hours",
|
||||||
|
12,
|
||||||
|
"int",
|
||||||
|
"Längstmögliche Sitzungsdauer (Stunden)",
|
||||||
|
hint="Danach ist Schluss, auch bei ständiger Arbeit – eine Sitzung soll nicht "
|
||||||
|
"ewig leben. Die eingebundenen Dateifreigaben enden mit ihr.",
|
||||||
|
section="system",
|
||||||
|
minimum=1,
|
||||||
|
maximum=168,
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"session_idle_minutes",
|
"session_idle_minutes",
|
||||||
30,
|
30,
|
||||||
"int",
|
"int",
|
||||||
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
"Automatische Abmeldung nach Leerlauf (Minuten)",
|
||||||
hint="Jede Anfrage setzt den Zaehler zurueck -- gezaehlt wird nur Untaetigkeit. "
|
hint="Jede Anfrage setzt den Zähler zurück – gezählt wird nur Untätigkeit. "
|
||||||
"Gilt auch fuer die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
"Gilt auch für die eingebundenen Dateifreigaben: mit der Sitzung endet die "
|
||||||
"Einbindung.",
|
"Einbindung.",
|
||||||
section="system",
|
section="system",
|
||||||
minimum=5,
|
minimum=5,
|
||||||
@@ -89,6 +102,14 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
),
|
),
|
||||||
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
|
||||||
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
|
||||||
|
SettingSpec(
|
||||||
|
"modules_disabled",
|
||||||
|
[],
|
||||||
|
"json",
|
||||||
|
"Vor Ort abgeschaltete Module",
|
||||||
|
hint="Die Lizenz sagt, was erlaubt ist; das hier sagt, was benutzt wird.",
|
||||||
|
section="system",
|
||||||
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"license_server_ca",
|
"license_server_ca",
|
||||||
"",
|
"",
|
||||||
@@ -101,8 +122,8 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
SettingSpec("web_hsts_enabled", False, "bool", "HSTS senden", section="web"),
|
||||||
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
SettingSpec("web_redirect_http", True, "bool", "HTTP auf HTTPS umleiten", section="web"),
|
||||||
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
SettingSpec("web_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
|
||||||
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
|
SettingSpec("web_key_path", "", "str", "Privater Schlüssel (Pfad)", section="web"),
|
||||||
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
|
SettingSpec("web_acme_email", "", "str", "E-Mail für Let's Encrypt", section="web"),
|
||||||
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
|
||||||
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
|
||||||
# im Regelbetrieb bleibt es bei 80 und 443.
|
# im Regelbetrieb bleibt es bei 80 und 443.
|
||||||
@@ -113,7 +134,7 @@ CORE_SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
|
||||||
),
|
),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
|
"web_max_body_mb", 128, "int", "Maximale Anfragegröße (MB)", section="web",
|
||||||
minimum=1, maximum=8192,
|
minimum=1, maximum=8192,
|
||||||
),
|
),
|
||||||
*directory.SETTINGS,
|
*directory.SETTINGS,
|
||||||
@@ -135,7 +156,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.3.2",
|
#: Beide Anwendungen geben ihre eigene Paketversion mit -- und die laeuft
|
||||||
|
#: absichtlich auseinander: was sich nicht geaendert hat, bekommt keine
|
||||||
|
#: neue Nummer. Eine feste Zahl an dieser Stelle wuerde irgendwann eine
|
||||||
|
#: falsche Version in die Fussnote schreiben.
|
||||||
|
app_version: str = "unbekannt",
|
||||||
built_in_trusted_keys: tuple[str, ...] = (),
|
built_in_trusted_keys: tuple[str, ...] = (),
|
||||||
enable_license: bool = True,
|
enable_license: bool = True,
|
||||||
template_folder: str | None = None,
|
template_folder: str | None = None,
|
||||||
@@ -147,6 +172,10 @@ def create_base_app(
|
|||||||
static_folder=static_folder or "static",
|
static_folder=static_folder or "static",
|
||||||
static_url_path="/assets",
|
static_url_path="/assets",
|
||||||
)
|
)
|
||||||
|
# Umlaute bleiben Umlaute -- auch in JSON. Flask wuerde sonst nach ASCII
|
||||||
|
# ausweichen ("ü"), und genau darin stehen die Flash-Meldungen in der
|
||||||
|
# Seite: eine Meldung mit "ue" war lesbar, eine mit "ü" wurde zu Zahlen.
|
||||||
|
app.json.ensure_ascii = False
|
||||||
|
|
||||||
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
|
||||||
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
|
||||||
@@ -230,12 +259,21 @@ def create_base_app(
|
|||||||
wert = 0
|
wert = 0
|
||||||
return wert if wert > 0 else config.security.session_idle_minutes
|
return wert if wert > 0 else config.security.session_idle_minutes
|
||||||
|
|
||||||
|
def _gesamtdauer(conn: Any) -> int:
|
||||||
|
"""Gesamtdauer aus den Einstellungen -- ueber dieselbe offene Verbindung."""
|
||||||
|
try:
|
||||||
|
wert = int(settings.get(conn, "session_absolute_hours") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
wert = 0
|
||||||
|
return wert if wert > 0 else config.security.session_absolute_hours
|
||||||
|
|
||||||
app.session_interface = SqliteSessionInterface(
|
app.session_interface = SqliteSessionInterface(
|
||||||
database,
|
database,
|
||||||
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
cookie_name=app.config["SESSION_COOKIE_NAME"],
|
||||||
idle_minutes=config.security.session_idle_minutes,
|
idle_minutes=config.security.session_idle_minutes,
|
||||||
absolute_hours=config.security.session_absolute_hours,
|
absolute_hours=config.security.session_absolute_hours,
|
||||||
idle_lookup=_leerlauf,
|
idle_lookup=_leerlauf,
|
||||||
|
absolute_lookup=_gesamtdauer,
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- Schema und Startzustand ------------------------------------------ #
|
# -- Schema und Startzustand ------------------------------------------ #
|
||||||
@@ -336,14 +374,24 @@ def create_base_app(
|
|||||||
# -- Fehlerseiten ------------------------------------------------------ #
|
# -- Fehlerseiten ------------------------------------------------------ #
|
||||||
@app.errorhandler(HTTPException)
|
@app.errorhandler(HTTPException)
|
||||||
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
|
||||||
|
if error.code == 404:
|
||||||
|
# Vor der Fehlerseite: war das eine Adresse aus der Zeit vor 2.5.0?
|
||||||
|
# Erst hier gefragt, damit die Tabelle nie eine bestehende Route
|
||||||
|
# verdeckt -- auf dem Lizenzserver ist /lizenz/<ticket> echt.
|
||||||
|
ziel = legacy_urls.neue_adresse(request.path)
|
||||||
|
if ziel is not None:
|
||||||
|
parameter = legacy_urls.neue_parameter(request.args.to_dict(flat=True))
|
||||||
|
if parameter:
|
||||||
|
ziel = ziel + "?" + urlencode(parameter)
|
||||||
|
return redirect(ziel)
|
||||||
if error.code == 413:
|
if error.code == 413:
|
||||||
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
# "Request Entity Too Large" allein hilft niemandem weiter: die
|
||||||
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
# Zahl steht in einer Einstellung, und wer hochlaedt, soll sie
|
||||||
# sehen. Vorher stand hier nur der nackte Status.
|
# sehen. Vorher stand hier nur der nackte Status.
|
||||||
grenze = uploads.limit_mb()
|
grenze = uploads.limit_mb()
|
||||||
error.description = (
|
error.description = (
|
||||||
f"Diese Datei ist groesser als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
f"Diese Datei ist größer als erlaubt. Zurzeit sind {grenze} MB je Upload "
|
||||||
"eingestellt; aendern kann das ein Administrator unter "
|
"eingestellt; ändern kann das ein Administrator unter "
|
||||||
'"Einstellungen -> Webserver und TLS".'
|
'"Einstellungen -> Webserver und TLS".'
|
||||||
)
|
)
|
||||||
if _wants_json():
|
if _wants_json():
|
||||||
@@ -399,7 +447,7 @@ def create_base_app(
|
|||||||
with app.app_context():
|
with app.app_context():
|
||||||
trash_views.register_core_providers()
|
trash_views.register_core_providers()
|
||||||
|
|
||||||
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
|
app.add_url_rule("/health", "health", _health, methods=["GET"])
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -133,14 +176,14 @@ def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[
|
|||||||
"ok": False,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"broken_at": data["id"],
|
||||||
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
|
"reason": "Vorgänger-Hash passt nicht – eine Zeile wurde entfernt oder eingefügt.",
|
||||||
}
|
}
|
||||||
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"checked": checked,
|
"checked": checked,
|
||||||
"broken_at": data["id"],
|
"broken_at": data["id"],
|
||||||
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
|
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash | Eintrag geändert.",
|
||||||
}
|
}
|
||||||
expected_prev = str(data["hash"])
|
expected_prev = str(data["hash"])
|
||||||
checked += 1
|
checked += 1
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import re
|
|||||||
import socket
|
import socket
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import ssl
|
import ssl
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
@@ -59,20 +60,20 @@ SETTINGS: tuple[SettingSpec, ...] = (
|
|||||||
section="ldap",
|
section="ldap",
|
||||||
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
|
||||||
),
|
),
|
||||||
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
|
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat prüfen", section="ldap"),
|
||||||
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
|
||||||
SettingSpec(
|
SettingSpec(
|
||||||
"ldap_allow_insecure",
|
"ldap_allow_insecure",
|
||||||
False,
|
False,
|
||||||
"bool",
|
"bool",
|
||||||
"Unverschluesselte Verbindung ausdruecklich erlauben",
|
"Unverschlüsselte Verbindung ausdrücklich erlauben",
|
||||||
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
|
hint="Nur für Testumgebungen. Das Passwort geht dabei im Klartext über das Netz.",
|
||||||
section="ldap",
|
section="ldap",
|
||||||
),
|
),
|
||||||
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
|
||||||
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
|
||||||
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
|
||||||
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
|
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis übernehmen", section="ldap"),
|
||||||
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -123,7 +124,7 @@ def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
|
|||||||
try:
|
try:
|
||||||
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
|
||||||
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
|
||||||
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
|
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschlüsselt werden")
|
||||||
return str(row.get("username") or ""), ""
|
return str(row.get("username") or ""), ""
|
||||||
return str(row.get("username") or ""), secret
|
return str(row.get("username") or ""), secret
|
||||||
|
|
||||||
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
|
|||||||
|
|
||||||
|
|
||||||
def _server(config: dict[str, Any]):
|
def _server(config: dict[str, Any]):
|
||||||
|
"""Das Server-Objekt -- so sparsam wie moeglich.
|
||||||
|
|
||||||
|
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
|
||||||
|
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
|
||||||
|
Schema*: bei einem Active Directory sind das Tausende Attribut- und
|
||||||
|
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
|
||||||
|
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
|
||||||
|
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
|
||||||
|
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
|
||||||
|
|
||||||
|
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
|
||||||
|
Unterschied zwischen "geht sofort" und "dauert manchmal".
|
||||||
|
"""
|
||||||
from ldap3 import Server
|
from ldap3 import Server
|
||||||
|
|
||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
|
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
|
||||||
return Server(
|
return Server(
|
||||||
str(config["ldap_host"]),
|
str(config["ldap_host"]),
|
||||||
port=int(config["ldap_port"]),
|
port=int(config["ldap_port"]),
|
||||||
use_ssl=security == "ldaps",
|
use_ssl=security == "ldaps",
|
||||||
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
|
||||||
get_info="ALL",
|
get_info="DSA" if braucht_basis else "NO_INFO",
|
||||||
connect_timeout=int(config["ldap_timeout"]),
|
connect_timeout=int(config["ldap_timeout"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
|
||||||
|
#:
|
||||||
|
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
|
||||||
|
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
|
||||||
|
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
|
||||||
|
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
|
||||||
|
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
|
||||||
|
#: ein sofortiger zweiter Versuch klappte.
|
||||||
|
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def _transiente_fehler() -> tuple[type[Exception], ...]:
|
||||||
|
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
|
||||||
|
global TRANSIENTE_FEHLER
|
||||||
|
if TRANSIENTE_FEHLER:
|
||||||
|
return TRANSIENTE_FEHLER
|
||||||
|
from ldap3.core.exceptions import (
|
||||||
|
LDAPResponseTimeoutError,
|
||||||
|
LDAPSocketOpenError,
|
||||||
|
LDAPSocketReceiveError,
|
||||||
|
LDAPSocketSendError,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRANSIENTE_FEHLER = (
|
||||||
|
LDAPSocketOpenError,
|
||||||
|
LDAPSocketReceiveError,
|
||||||
|
LDAPSocketSendError,
|
||||||
|
LDAPResponseTimeoutError,
|
||||||
|
)
|
||||||
|
return TRANSIENTE_FEHLER
|
||||||
|
|
||||||
|
|
||||||
|
def mit_wiederholung(aufbau: Any) -> Any:
|
||||||
|
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
|
||||||
|
|
||||||
|
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
|
||||||
|
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
|
||||||
|
ist die Regel aus 1.2.8, und sie ist richtig.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return aufbau()
|
||||||
|
except _transiente_fehler() as fehler:
|
||||||
|
logger.info(
|
||||||
|
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
|
||||||
|
type(fehler).__name__,
|
||||||
|
)
|
||||||
|
return aufbau()
|
||||||
|
|
||||||
|
|
||||||
def _connect(config: dict[str, Any], user: str, password: str):
|
def _connect(config: dict[str, Any], user: str, password: str):
|
||||||
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
|
||||||
from ldap3 import Connection
|
from ldap3 import Connection
|
||||||
@@ -182,7 +246,7 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
security = str(config["ldap_security"])
|
security = str(config["ldap_security"])
|
||||||
if security == "none" and not config["ldap_allow_insecure"]:
|
if security == "none" and not config["ldap_allow_insecure"]:
|
||||||
raise PermissionError(
|
raise PermissionError(
|
||||||
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
|
"Unverschlüsselte Verzeichnisverbindungen sind deaktiviert. "
|
||||||
"Nutzen Sie LDAPS oder StartTLS."
|
"Nutzen Sie LDAPS oder StartTLS."
|
||||||
)
|
)
|
||||||
connection = Connection(
|
connection = Connection(
|
||||||
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
|
|||||||
auto_bind=False,
|
auto_bind=False,
|
||||||
raise_exceptions=False,
|
raise_exceptions=False,
|
||||||
receive_timeout=int(config["ldap_timeout"]),
|
receive_timeout=int(config["ldap_timeout"]),
|
||||||
|
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
|
||||||
|
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
|
||||||
|
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
|
||||||
|
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
|
||||||
|
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
|
||||||
|
auto_referrals=False,
|
||||||
)
|
)
|
||||||
if security == "starttls":
|
if security == "starttls":
|
||||||
if not connection.open():
|
if not connection.open():
|
||||||
@@ -249,12 +319,23 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
bind_user, bind_password = bind_credentials(conn)
|
bind_user, bind_password = bind_credentials(conn)
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if str(config["ldap_security"]) == "none":
|
if str(config["ldap_security"]) == "none":
|
||||||
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
|
warnings.append("Unverschlüsselte Verzeichnisverbindung in Benutzung")
|
||||||
if not config["ldap_verify_cert"]:
|
if not config["ldap_verify_cert"]:
|
||||||
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
|
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
|
||||||
|
|
||||||
|
begonnen = time.monotonic()
|
||||||
|
zeiten: list[str] = []
|
||||||
|
|
||||||
|
def gemessen(name: str, seit: float) -> float:
|
||||||
|
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
|
||||||
|
jetzt = time.monotonic()
|
||||||
|
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
|
||||||
|
return jetzt
|
||||||
|
|
||||||
try:
|
try:
|
||||||
service_conn, bound = _connect(config, bind_user, bind_password)
|
service_conn, bound = mit_wiederholung(
|
||||||
|
lambda: _connect(config, bind_user, bind_password)
|
||||||
|
)
|
||||||
except (PermissionError, ConnectionError) as exc:
|
except (PermissionError, ConnectionError) as exc:
|
||||||
logger.error("Verzeichnisdienst: %s", exc)
|
logger.error("Verzeichnisdienst: %s", exc)
|
||||||
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
|
||||||
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
warnings=tuple(warnings),
|
warnings=tuple(warnings),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
marke = gemessen("Dienstbindung", begonnen)
|
||||||
try:
|
try:
|
||||||
base = _base_dn(service_conn, config)
|
base = _base_dn(service_conn, config)
|
||||||
|
marke = gemessen("Basis-DN", marke)
|
||||||
if not base:
|
if not base:
|
||||||
return DirectoryResult(
|
return DirectoryResult(
|
||||||
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
|
||||||
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
attributes=list(dict.fromkeys(wanted)),
|
attributes=list(dict.fromkeys(wanted)),
|
||||||
size_limit=2,
|
size_limit=2,
|
||||||
)
|
)
|
||||||
|
marke = gemessen("Benutzersuche", marke)
|
||||||
entries = list(service_conn.entries)
|
entries = list(service_conn.entries)
|
||||||
if len(entries) != 1:
|
if len(entries) != 1:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
|
|
||||||
entry = entries[0]
|
entry = entries[0]
|
||||||
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
|
|
||||||
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
|
||||||
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
|
||||||
user_conn, user_bound = _connect(config, dn, password)
|
#
|
||||||
|
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
|
||||||
|
# einem Domain-Controller in einem anderen Netz schlug der erste
|
||||||
|
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
|
||||||
|
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
|
||||||
|
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
|
||||||
|
# den Sperrzaehler des Kontos zweimal hoch.
|
||||||
|
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
|
||||||
|
marke = gemessen("Passwortpruefung", marke)
|
||||||
try:
|
try:
|
||||||
if not user_bound:
|
if not user_bound:
|
||||||
|
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
|
||||||
return DirectoryResult("invalid", warnings=tuple(warnings))
|
return DirectoryResult("invalid", warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
group_dns: tuple[str, ...] = ()
|
group_dns: tuple[str, ...] = ()
|
||||||
if config["ldap_group_sync"]:
|
if config["ldap_group_sync"]:
|
||||||
group_dns = _resolve_groups(service_conn, base, dn)
|
group_dns = _resolve_groups(service_conn, base, dn)
|
||||||
|
marke = gemessen("Gruppen", marke)
|
||||||
|
|
||||||
profile = DirectoryProfile(
|
profile = DirectoryProfile(
|
||||||
username=_attr(entry, attribute) or username,
|
username=_attr(entry, attribute) or username,
|
||||||
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
|
|||||||
email=_attr(entry, email_attribute),
|
email=_attr(entry, email_attribute),
|
||||||
group_dns=group_dns,
|
group_dns=group_dns,
|
||||||
)
|
)
|
||||||
|
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
|
||||||
|
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
|
||||||
|
# Verbindung ist, die Suche oder die Gruppen.
|
||||||
|
logger.info(
|
||||||
|
"Verzeichnisanmeldung %s: %s, gesamt %dms",
|
||||||
|
username,
|
||||||
|
", ".join(zeiten),
|
||||||
|
int((time.monotonic() - begonnen) * 1000),
|
||||||
|
)
|
||||||
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
|
||||||
finally:
|
finally:
|
||||||
try:
|
try:
|
||||||
@@ -359,7 +463,7 @@ def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]
|
|||||||
)
|
)
|
||||||
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
return tuple(str(entry.entry_dn) for entry in connection.entries)
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("Gruppenaufloesung fehlgeschlagen: %s", exc)
|
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
|
|
||||||
@@ -423,18 +527,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 +562,71 @@ def _apply_group_mappings(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _kennzeichen_setzen(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
user_id: int,
|
||||||
|
mappings: list[dict[str, Any]],
|
||||||
|
gruppen: set[str],
|
||||||
|
) -> None:
|
||||||
|
"""Setzt oder entzieht das Administratorkennzeichen nach dem Verzeichnis.
|
||||||
|
|
||||||
|
Wirkung wie bei Gruppen: **ersetzend**. Wer aus der zugeordneten
|
||||||
|
AD-Gruppe herausfaellt, verliert das Kennzeichen bei der naechsten
|
||||||
|
Anmeldung -- sonst waere ein Austritt im Verzeichnis eine Aenderung, die
|
||||||
|
hier nie ankommt.
|
||||||
|
|
||||||
|
Zwei Grenzen, beide absichtlich:
|
||||||
|
|
||||||
|
* Nur **Verzeichniskonten**. Ein lokales Konto (etwa das, das
|
||||||
|
``tesm-admin create-admin`` anlegt) bleibt unberuehrt -- es ist der
|
||||||
|
Notausgang, wenn das Verzeichnis nicht erreichbar ist.
|
||||||
|
* Nie den letzten entsperrten Administrator herunterstufen. Ein
|
||||||
|
Tippfehler im DN wuerde sonst beim naechsten Login die Anlage
|
||||||
|
verschliessen, und niemand koennte ihn korrigieren.
|
||||||
|
"""
|
||||||
|
zuordnungen = [row for row in mappings if int(row["grants_admin"] or 0) == 1]
|
||||||
|
if not zuordnungen:
|
||||||
|
return
|
||||||
|
soll = any(str(row["ad_group_dn"]).lower() in gruppen for row in zuordnungen)
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT is_admin, auth_source FROM users WHERE id=?", (user_id,)
|
||||||
|
)
|
||||||
|
if zeile is None or str(zeile["auth_source"]) != "ldap":
|
||||||
|
return
|
||||||
|
ist = bool(zeile["is_admin"])
|
||||||
|
if ist == soll:
|
||||||
|
return
|
||||||
|
if not soll:
|
||||||
|
uebrig = Database.value(
|
||||||
|
conn,
|
||||||
|
"SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 "
|
||||||
|
"AND deleted_at IS NULL AND id<>?",
|
||||||
|
(user_id,),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
if not uebrig:
|
||||||
|
logger.warning(
|
||||||
|
"Administratorkennzeichen bleibt bei Benutzer %s: es waere das letzte.",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET is_admin=?, updated_at=? WHERE id=?", (int(soll), _now(), user_id)
|
||||||
|
)
|
||||||
|
audit.log(
|
||||||
|
conn,
|
||||||
|
actor="system",
|
||||||
|
action="auth.admin_flag_changed",
|
||||||
|
target=str(user_id),
|
||||||
|
detail=(
|
||||||
|
"Administrator: "
|
||||||
|
+ ("nein -> ja" if soll else "ja -> nein")
|
||||||
|
+ " (aus der Verzeichniszuordnung)"
|
||||||
|
),
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _user_group_ids(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
|
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)
|
||||||
|
|
||||||
@@ -220,6 +235,37 @@ def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def _verlangt_wiederanmeldung(permission: str) -> bool:
|
||||||
|
"""Fragt das Rechtemodell -- ausserhalb einer Anwendung nie."""
|
||||||
|
try:
|
||||||
|
from ..extension import core
|
||||||
|
|
||||||
|
return bool(core().permissions.verlangt_wiederanmeldung(permission))
|
||||||
|
except Exception: # noqa: BLE001 - ohne Modell keine zusaetzliche Huerde
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _wiederanmeldung_frisch() -> bool:
|
||||||
|
from .service import reauth_is_fresh # zyklischen Import vermeiden
|
||||||
|
|
||||||
|
return reauth_is_fresh()
|
||||||
|
|
||||||
|
|
||||||
|
def _bitte_bestaetigen(permission: str) -> Any:
|
||||||
|
"""Zur Passwortbestaetigung schicken -- oder es einem Skript sagen.
|
||||||
|
|
||||||
|
Ein Aufruf per fetch bekaeme sonst die Anmeldeseite als Antwort und wuesste
|
||||||
|
nichts damit anzufangen.
|
||||||
|
"""
|
||||||
|
if is_automatic(request) or (
|
||||||
|
request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html
|
||||||
|
):
|
||||||
|
abort(403, description="Bitte bestätigen Sie zur Sicherheit Ihr Passwort.")
|
||||||
|
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||||
|
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||||
|
return redirect(url_for("auth.reauth"))
|
||||||
|
|
||||||
|
|
||||||
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
|
||||||
"""Erzwingt Rechte serverseitig.
|
"""Erzwingt Rechte serverseitig.
|
||||||
|
|
||||||
@@ -237,7 +283,15 @@ def permission_required(*permissions: str, mode: str = "all") -> Callable[..., A
|
|||||||
else current_user.has(*permissions)
|
else current_user.has(*permissions)
|
||||||
)
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
|
return _deny("Für diese Seite fehlt Ihnen die Berechtigung.")
|
||||||
|
# Geheimnisse und Exporte verlangen eine frische Bestaetigung --
|
||||||
|
# das steht im Rechtemodell und wird deshalb hier abgefragt, nicht
|
||||||
|
# in jeder Route einzeln. Vorher hing es daran, dass jemand an den
|
||||||
|
# Dekorator dachte: dieselbe Lizenz herunterzuladen fragte nach,
|
||||||
|
# sie per Mail zu verschicken nicht.
|
||||||
|
heikel = [p for p in permissions if _verlangt_wiederanmeldung(p)]
|
||||||
|
if heikel and not _wiederanmeldung_frisch():
|
||||||
|
return _bitte_bestaetigen(heikel[0])
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
wrapper._required_permissions = permissions # type: ignore[attr-defined]
|
||||||
@@ -279,7 +333,7 @@ def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
|
|||||||
return _deny("Anmeldung erforderlich.")
|
return _deny("Anmeldung erforderlich.")
|
||||||
if not reauth_is_fresh():
|
if not reauth_is_fresh():
|
||||||
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
session["_after_reauth"] = request.full_path if request.query_string else request.path
|
||||||
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
|
flash("Bitte bestätigen Sie zur Sicherheit Ihr Passwort.", "info")
|
||||||
return redirect(url_for("auth.reauth"))
|
return redirect(url_for("auth.reauth"))
|
||||||
return view(*args, **kwargs)
|
return view(*args, **kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ def login():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
|
@bp.route("/login/second-factor", methods=["GET", "POST"])
|
||||||
def second_factor():
|
def second_factor():
|
||||||
conn = db()
|
conn = db()
|
||||||
user_row = service.pending_totp_user(conn)
|
user_row = service.pending_totp_user(conn)
|
||||||
@@ -116,7 +116,7 @@ def logout_get():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/bestaetigen", methods=["GET", "POST"])
|
@bp.route("/confirm", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def reauth():
|
def reauth():
|
||||||
target = _safe_next(session.get("_after_reauth"))
|
target = _safe_next(session.get("_after_reauth"))
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ def authenticate(
|
|||||||
if ip_verdict.blocked:
|
if ip_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
|
"Zu viele Anmeldeversuche von dieser Adresse. Bitte später erneut versuchen.",
|
||||||
retry_after=ip_verdict.retry_after_seconds,
|
retry_after=ip_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ def authenticate(
|
|||||||
if user_verdict.blocked:
|
if user_verdict.blocked:
|
||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
|
"Dieses Konto ist nach mehreren Fehlversuchen vorübergehend gesperrt.",
|
||||||
retry_after=user_verdict.retry_after_seconds,
|
retry_after=user_verdict.retry_after_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -248,7 +248,7 @@ def _verify_credentials(
|
|||||||
return AuthOutcome(
|
return AuthOutcome(
|
||||||
"directory_unavailable",
|
"directory_unavailable",
|
||||||
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
|
||||||
"Bitte versuchen Sie es spaeter erneut.",
|
"Bitte versuchen Sie es später erneut.",
|
||||||
)
|
)
|
||||||
if result.status != "ok" or result.profile is None:
|
if result.status != "ok" or result.profile is None:
|
||||||
return AuthOutcome("invalid", GENERIC_FAILURE)
|
return AuthOutcome("invalid", GENERIC_FAILURE)
|
||||||
@@ -257,6 +257,24 @@ def _verify_credentials(
|
|||||||
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _ist_erstanmeldung(user_row: dict[str, Any]) -> bool:
|
||||||
|
"""Meldet sich dieses Konto zum ersten Mal ueberhaupt an?
|
||||||
|
|
||||||
|
Das Aenderungsprotokoll soll Aenderungen zeigen. Eine taegliche Anmeldung
|
||||||
|
ist keine -- sie erzeugte aber jedes Mal eine Zeile und schob damit echte
|
||||||
|
Eintraege aus dem Blickfeld. Interessant ist genau die erste: mit ihr
|
||||||
|
entsteht das Konto aus dem Verzeichnisdienst und betritt jemand die
|
||||||
|
Anwendung, der vorher nicht darin war.
|
||||||
|
|
||||||
|
Nicht betroffen: fehlgeschlagene Anmeldungen und Sperren. Die stehen
|
||||||
|
weiterhin **jedes Mal** im Protokoll -- dort liegt der sicherheitsrelevante
|
||||||
|
Teil, und Haeufung ist dort das Signal. Wer wann zuletzt angemeldet war,
|
||||||
|
steht ausserdem unveraendert am Konto und in der Sitzungsuebersicht.
|
||||||
|
"""
|
||||||
|
return not str(user_row.get("last_login_at") or "").strip()
|
||||||
|
|
||||||
|
|
||||||
def complete_login(
|
def complete_login(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
user_row: dict[str, Any],
|
user_row: dict[str, Any],
|
||||||
@@ -275,20 +293,25 @@ def complete_login(
|
|||||||
"""
|
"""
|
||||||
from ..security import csrf
|
from ..security import csrf
|
||||||
|
|
||||||
|
# Vor dem UPDATE lesen -- danach steht ueberall ein Zeitstempel.
|
||||||
|
erstanmeldung = _ist_erstanmeldung(user_row)
|
||||||
|
|
||||||
with core().database.transaction(conn):
|
with core().database.transaction(conn):
|
||||||
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
|
||||||
)
|
)
|
||||||
|
if erstanmeldung:
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=str(user_row["username"]),
|
actor=str(user_row["username"]),
|
||||||
actor_id=int(user_row["id"]),
|
actor_id=int(user_row["id"]),
|
||||||
action="auth.login",
|
action="auth.first_login",
|
||||||
target=str(user_row["username"]),
|
target=str(user_row["username"]),
|
||||||
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
|
detail=f"Erste Anmeldung | Quelle: {user_row.get('auth_source') or 'local'}",
|
||||||
ip=ip,
|
ip=ip,
|
||||||
request_id=getattr(g, "request_id", ""),
|
request_id=getattr(g, "request_id", ""),
|
||||||
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
session.clear()
|
session.clear()
|
||||||
@@ -336,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
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
port = getattr(args, flag, None)
|
port = getattr(args, flag, None)
|
||||||
if port:
|
if port:
|
||||||
if not 1 <= int(port) <= 65535:
|
if not 1 <= int(port) <= 65535:
|
||||||
print(f"Ungueltiger Port: {port}", file=sys.stderr)
|
print(f"Ungültiger Port: {port}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
values[key] = int(port)
|
values[key] = int(port)
|
||||||
|
|
||||||
@@ -93,11 +93,11 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# -- Zertifikat --------------------------------------------------- #
|
# -- Zertifikat --------------------------------------------------- #
|
||||||
config = _config_from_settings(extension, conn)
|
config = _config_from_settings(extension, conn)
|
||||||
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
|
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unverändert."}
|
||||||
|
|
||||||
if args.acme:
|
if args.acme:
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
cert = webserver.request_certificate(
|
cert = webserver.request_certificate(
|
||||||
sysops,
|
sysops,
|
||||||
@@ -118,7 +118,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
if args.self_signed and not args.acme:
|
if args.self_signed and not args.acme:
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
suggested = webserver.local_names()
|
suggested = webserver.local_names()
|
||||||
cert = webserver.create_self_signed(
|
cert = webserver.create_self_signed(
|
||||||
@@ -144,7 +144,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
if args.https:
|
if args.https:
|
||||||
if not (config.cert_path and config.key_path):
|
if not (config.cert_path and config.key_path):
|
||||||
print(
|
print(
|
||||||
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
|
"HTTPS verlangt Zertifikat und Schlüssel – bitte --self-signed "
|
||||||
"oder --acme angeben.",
|
"oder --acme angeben.",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
@@ -172,7 +172,7 @@ def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
|
print("Der Systemhelfer ist nicht verfügbar.", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
result = webserver.apply_config(sysops, rendered)
|
result = webserver.apply_config(sysops, rendered)
|
||||||
print(result["message"])
|
print(result["message"])
|
||||||
@@ -194,7 +194,7 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
help="nginx und TLS einrichten (Port 80/443)",
|
help="nginx und TLS einrichten (Port 80/443)",
|
||||||
description=(
|
description=(
|
||||||
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
|
||||||
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
|
"Die Datei wird immer vollständig neu geschrieben – eine von Hand "
|
||||||
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -207,20 +207,20 @@ def add_web_setup(sub: Any, runner: Any) -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--acme",
|
"--acme",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
|
help="Zertifikat per Let's Encrypt anfordern (Domain muss öffentlich zeigen)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
|
parser.add_argument("--domain", help="Öffentlicher Name dieser Installation")
|
||||||
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
|
parser.add_argument("--acme-email", help="Kontaktadresse für Let's Encrypt")
|
||||||
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-redirect",
|
"--no-redirect",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
|
||||||
)
|
)
|
||||||
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
|
parser.add_argument("--max-body-mb", type=int, help="Obergrenze für Uploads")
|
||||||
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
|
||||||
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
|
||||||
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
|
parser.add_argument("--apply", action="store_true", help="nginx schreiben, prüfen, neu laden")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -175,11 +175,11 @@ def load_core_config(
|
|||||||
|
|
||||||
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
bind = (_env(env_prefix, "BIND") or default_bind).strip()
|
||||||
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
|
||||||
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
|
raise ValueError(f"Ungültige Bind-Adresse: {bind!r}")
|
||||||
|
|
||||||
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
|
||||||
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
|
||||||
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
|
raise ValueError(f"Ungültiger SITE_KEY: {site_key!r}")
|
||||||
|
|
||||||
return CoreConfig(
|
return CoreConfig(
|
||||||
app_key=app_key,
|
app_key=app_key,
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
@@ -80,8 +80,8 @@ class Database:
|
|||||||
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
|
||||||
if self._depth() > 0:
|
if self._depth() > 0:
|
||||||
raise NestedConnectionError(
|
raise NestedConnectionError(
|
||||||
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
"Es läuft bereits eine Schreibtransaktion auf dieser Datenbank. "
|
||||||
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
|
"Reiche die bestehende Verbindung durch, statt eine neue zu öffnen "
|
||||||
"(sonst blockiert SQLite sich selbst)."
|
"(sonst blockiert SQLite sich selbst)."
|
||||||
)
|
)
|
||||||
return self._raw_connect()
|
return self._raw_connect()
|
||||||
@@ -119,6 +119,29 @@ class Database:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def dauerhaft(self, conn: sqlite3.Connection) -> Iterator[sqlite3.Connection]:
|
||||||
|
"""Fuer Schreibvorgaenge, die einen Stromausfall ueberstehen muessen.
|
||||||
|
|
||||||
|
Im Alltag laeuft die Datenbank mit ``synchronous=NORMAL``: schnell, und
|
||||||
|
bei einem Stromausfall gehen hoechstens die letzten Sekunden verloren --
|
||||||
|
die Datei bleibt heil. Fuer einen Messwert ist das genau richtig.
|
||||||
|
|
||||||
|
Fuer eine Unterschrift nicht. Dort liegt die Datei anschliessend auf der
|
||||||
|
Freigabe, und fehlte der Eintrag dazu, waere sie "unbekannt" statt
|
||||||
|
nachweisbar. Ein fsync kostet ein paar Millisekunden -- bei ein paar
|
||||||
|
Unterschriften am Tag ist das keine Diskussion wert.
|
||||||
|
|
||||||
|
Umschliesst die Transaktion, nicht umgekehrt: ``PRAGMA synchronous``
|
||||||
|
wirkt auf den naechsten Commit, und gesetzt werden kann es nur
|
||||||
|
ausserhalb einer laufenden Transaktion.
|
||||||
|
"""
|
||||||
|
conn.execute("PRAGMA synchronous=FULL")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
conn.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# Transaktionen
|
# Transaktionen
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ def core(app: Flask | None = None) -> TesmCore:
|
|||||||
try:
|
try:
|
||||||
return target.extensions["tesm"] # type: ignore[return-value]
|
return target.extensions["tesm"] # type: ignore[return-value]
|
||||||
except KeyError as exc: # pragma: no cover - Programmierfehler
|
except KeyError as exc: # pragma: no cover - Programmierfehler
|
||||||
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
|
raise RuntimeError("tesm_core ist für diese Anwendung nicht initialisiert") from exc
|
||||||
|
|
||||||
|
|
||||||
def db() -> sqlite3.Connection:
|
def db() -> sqlite3.Connection:
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def check_private_file(path: Path) -> list[str]:
|
|||||||
except OSError:
|
except OSError:
|
||||||
return problems
|
return problems
|
||||||
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
|
||||||
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
problems.append(f"{path} ist für Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
|
||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
@@ -110,11 +110,11 @@ class Keystore:
|
|||||||
created_at=str(entry.get("created_at", "")),
|
created_at=str(entry.get("created_at", "")),
|
||||||
)
|
)
|
||||||
if len(key.material) != _KEY_BYTES:
|
if len(key.material) != _KEY_BYTES:
|
||||||
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
|
raise ValueError(f"Schlüssel {key.key_id} hat die falsche Länge")
|
||||||
self._keys[key.key_id] = key
|
self._keys[key.key_id] = key
|
||||||
self._active = str(raw.get("active") or "")
|
self._active = str(raw.get("active") or "")
|
||||||
if self._active not in self._keys:
|
if self._active not in self._keys:
|
||||||
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
|
raise ValueError("Aktiver Schlüssel fehlt im Schlüsselbund")
|
||||||
return
|
return
|
||||||
self._active = "1"
|
self._active = "1"
|
||||||
self._keys = {
|
self._keys = {
|
||||||
@@ -160,7 +160,7 @@ class Keystore:
|
|||||||
def drop_key(self, key_id: str) -> None:
|
def drop_key(self, key_id: str) -> None:
|
||||||
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
|
||||||
if key_id == self._active:
|
if key_id == self._active:
|
||||||
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
|
raise ValueError("Der aktive Schlüssel kann nicht entfernt werden")
|
||||||
self._keys.pop(key_id, None)
|
self._keys.pop(key_id, None)
|
||||||
self._persist()
|
self._persist()
|
||||||
|
|
||||||
@@ -183,14 +183,14 @@ class Keystore:
|
|||||||
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
|
||||||
key = self._keys.get(key_id)
|
key = self._keys.get(key_id)
|
||||||
if key is None:
|
if key is None:
|
||||||
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
|
raise DecryptionError(f"Schlüssel {key_id} ist nicht im Schlüsselbund")
|
||||||
try:
|
try:
|
||||||
plain = AESGCM(key.material).decrypt(
|
plain = AESGCM(key.material).decrypt(
|
||||||
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
|
||||||
)
|
)
|
||||||
except (InvalidTag, ValueError) as exc:
|
except (InvalidTag, ValueError) as exc:
|
||||||
raise DecryptionError(
|
raise DecryptionError(
|
||||||
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
|
"Chiffrat konnte nicht entschlüsselt werden (falscher Schlüssel, "
|
||||||
"manipulierte Daten oder falscher Verwendungszweck)"
|
"manipulierte Daten oder falscher Verwendungszweck)"
|
||||||
) from exc
|
) from exc
|
||||||
return plain.decode("utf-8")
|
return plain.decode("utf-8")
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
"""Alte, deutsche Adressen auf die neuen englischen umlenken.
|
||||||
|
|
||||||
|
Mit 2.5.0 folgen die Adressen der Navigation und sind englisch:
|
||||||
|
``/monitoring/host-keys`` statt ``/hostschluessel``. Das ist eine gute
|
||||||
|
Aenderung und eine unangenehme zugleich -- Lesezeichen, offene Browsertabs und
|
||||||
|
ein Link in einer Mail zeigen weiter auf die alte Adresse. Ein offener Tab ist
|
||||||
|
dabei der unangenehmste Fall: die Statusuebersicht fragt alle 20 Sekunden ihre
|
||||||
|
Kacheln ab und bekam nach dem Update jedes Mal einen 404.
|
||||||
|
|
||||||
|
Deshalb diese Tabelle. Sie greift **nur, wenn sonst ein 404 herauskaeme** --
|
||||||
|
also nie vor einer bestehenden Route. Das ist keine Feinheit: auf dem
|
||||||
|
Lizenzserver ist ``/lizenz/<ticket>`` eine echte, weiterhin gueltige Adresse
|
||||||
|
(sie steht in verschickten Mails), waehrend dieselbe Wurzel in TESM zur
|
||||||
|
Lizenzseite fuehrte. Wer zuerst das Routing fragt, muss sich nicht zwischen
|
||||||
|
beidem entscheiden.
|
||||||
|
|
||||||
|
Die Tabelle ist bewusst vollstaendig und nicht nur auf Wurzeln gekuerzt: eine
|
||||||
|
Umleitung von ``/dateifreigaben/x/vorschau`` auf eine Uebersichtsseite waere
|
||||||
|
zwar kein 404 mehr, aber auch nicht das, was jemand angeklickt hat.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
#: Alte Wurzel -> (neue Wurzel, Uebersetzung der Segmente darunter).
|
||||||
|
WURZELN: dict[str, tuple[str, Mapping[str, str]]] = {
|
||||||
|
"/dateifreigaben": (
|
||||||
|
"/fileshares",
|
||||||
|
{
|
||||||
|
"herunterladen": "download",
|
||||||
|
"vorschau": "preview",
|
||||||
|
"hochladen": "upload",
|
||||||
|
"formular": "form",
|
||||||
|
"ordner": "folder",
|
||||||
|
"umbenennen": "rename",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"freigeben": "release",
|
||||||
|
"zuordnungen": "mappings",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/hostschluessel": (
|
||||||
|
"/monitoring/host-keys",
|
||||||
|
{"pruefen": "probe", "freigeben": "trust", "verwerfen": "forget", "verwaist": "orphaned"},
|
||||||
|
),
|
||||||
|
"/zugangsdaten": (
|
||||||
|
"/monitoring/credentials",
|
||||||
|
{"neu": "new", "loeschen": "delete", "anzeigen": "reveal"},
|
||||||
|
),
|
||||||
|
"/switche": (
|
||||||
|
"/monitoring/switches",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"kommando": "command",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/clients": (
|
||||||
|
"/monitoring/clients",
|
||||||
|
{
|
||||||
|
"neu": "new",
|
||||||
|
"aktiv": "active",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"neustart": "restart",
|
||||||
|
"hostkey": "host-key",
|
||||||
|
"pruefen": "probe",
|
||||||
|
"freigeben": "trust",
|
||||||
|
"verwerfen": "forget",
|
||||||
|
"konsole": "console",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/wartung": (
|
||||||
|
"/maintenance",
|
||||||
|
{
|
||||||
|
"paket-nachinstallieren": "install-package",
|
||||||
|
"auftrag": "job",
|
||||||
|
"ausgabe": "output",
|
||||||
|
"aktualisieren": "update",
|
||||||
|
"neustarten": "restart",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/protokolle": (
|
||||||
|
"/logs",
|
||||||
|
{
|
||||||
|
"kea": "dhcp",
|
||||||
|
"neustarts": "restarts",
|
||||||
|
"laufend": "live",
|
||||||
|
"inhalt": "content",
|
||||||
|
"verlauf": "history",
|
||||||
|
"datei": "file",
|
||||||
|
"archiv": "archive",
|
||||||
|
"archiv-export": "archive-export",
|
||||||
|
"archiv-loeschen": "archive-delete",
|
||||||
|
"aenderungen": "audit",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/verwaltung": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"benutzer": "users",
|
||||||
|
"neu": "new",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"sitzungen-beenden": "end-sessions",
|
||||||
|
"verzeichnis-suche": "directory-search",
|
||||||
|
"verzeichnis-uebernehmen": "directory-import",
|
||||||
|
"gruppen": "groups",
|
||||||
|
"vorlage-anwenden": "apply-preset",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/einstellungen": (
|
||||||
|
"/administration",
|
||||||
|
{
|
||||||
|
"zeitzone": "timezone",
|
||||||
|
"pruefung-jetzt": "check-now",
|
||||||
|
"netzwerk": "network",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"webserver": "web",
|
||||||
|
"uebernehmen": "apply",
|
||||||
|
"zertifikat": "certificate",
|
||||||
|
"selbstsigniert": "self-signed",
|
||||||
|
"verzeichnisdienst": "directory",
|
||||||
|
"gruppensuche": "group-search",
|
||||||
|
"zuordnung": "mapping",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"anbieter": "vendor",
|
||||||
|
"logo-entfernen": "remove-logo",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/sicherung": (
|
||||||
|
"/administration/backup",
|
||||||
|
{"vorschau": "preview", "uebernehmen": "apply", "verwerfen": "discard"},
|
||||||
|
),
|
||||||
|
"/papierkorb": (
|
||||||
|
"/administration/trash",
|
||||||
|
{"wiederherstellen": "restore", "endgueltig-loeschen": "purge", "leeren": "empty"},
|
||||||
|
),
|
||||||
|
"/diagnose": (
|
||||||
|
"/administration/diagnostics",
|
||||||
|
{"aufraeumen": "cleanup", "schluessel-rotieren": "rotate-keys"},
|
||||||
|
),
|
||||||
|
"/eigene-lizenz": (
|
||||||
|
"/own-license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"signaturschluessel": "signing-key",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenzen": (
|
||||||
|
"/licenses",
|
||||||
|
{
|
||||||
|
"aus-ticket": "from-ticket",
|
||||||
|
"datei": "file",
|
||||||
|
"neu-signieren": "resign",
|
||||||
|
"widerrufen": "revoke",
|
||||||
|
"loeschen": "delete",
|
||||||
|
"versenden": "send",
|
||||||
|
"sub-lizenzserver": "sub-servers",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/lizenz": (
|
||||||
|
"/administration/license",
|
||||||
|
{
|
||||||
|
"einspielen": "install",
|
||||||
|
"aktivieren": "activate",
|
||||||
|
"deaktivieren": "deactivate",
|
||||||
|
"offline-antwort": "offline-response",
|
||||||
|
"serverzertifikat": "server-certificate",
|
||||||
|
"aussteller": "issuer",
|
||||||
|
"entfernen": "remove",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/kunden": ("/customers", {"neu": "new", "loeschen": "delete"}),
|
||||||
|
"/konto": (
|
||||||
|
"/account",
|
||||||
|
{
|
||||||
|
"stammdaten": "profile",
|
||||||
|
"profilbild": "avatar",
|
||||||
|
"sicherheit": "security",
|
||||||
|
"passwort": "password",
|
||||||
|
"zwei-faktor": "two-factor",
|
||||||
|
"starten": "start",
|
||||||
|
"bestaetigen": "confirm",
|
||||||
|
"deaktivieren": "disable",
|
||||||
|
"codes-erneuern": "recovery-codes",
|
||||||
|
"sitzungen": "sessions",
|
||||||
|
"beenden": "end",
|
||||||
|
"alle-beenden": "end-all",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"/uebersicht": ("/overview", {"kacheln": "tiles"}),
|
||||||
|
"/gesundheit": ("/health", {}),
|
||||||
|
"/bestaetigen": ("/confirm", {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Abfrageparameter, die mit umbenannt wurden.
|
||||||
|
PARAMETER = {
|
||||||
|
"freigabe": "share",
|
||||||
|
"pfad": "path",
|
||||||
|
"aktion": "action",
|
||||||
|
"benutzer": "actor",
|
||||||
|
"stufe": "severity",
|
||||||
|
"seite": "page",
|
||||||
|
"methode": "method",
|
||||||
|
"kunde": "customer",
|
||||||
|
"freischalten": "unlocked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#: Wurzeln, unter denen eine unbekannte Unterseite **nicht** umgelenkt wird.
|
||||||
|
#: ``/lizenz/<ticket>`` ist auf dem Lizenzserver eine echte Adresse; ein
|
||||||
|
#: abgelaufenes oder falsches Ticket gehoert dort auf die Fehlerseite und nicht
|
||||||
|
#: in die Verwaltung.
|
||||||
|
STRENG = {"/lizenz"}
|
||||||
|
|
||||||
|
|
||||||
|
def neue_adresse(pfad: str) -> str | None:
|
||||||
|
"""Uebersetzt eine alte Adresse -- oder ``None``, wenn es keine ist."""
|
||||||
|
for alt, (neu, segmente) in WURZELN.items():
|
||||||
|
if pfad != alt and not pfad.startswith(alt + "/"):
|
||||||
|
continue
|
||||||
|
rest = pfad[len(alt) :]
|
||||||
|
teile = rest.split("/")
|
||||||
|
if alt in STRENG:
|
||||||
|
erstes = next((teil for teil in teile if teil), "")
|
||||||
|
if erstes and erstes not in segmente:
|
||||||
|
return None
|
||||||
|
return neu + "/".join(segmente.get(teil, teil) for teil in teile)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def neue_parameter(args: Mapping[str, str]) -> dict[str, str]:
|
||||||
|
return {PARAMETER.get(name, name): wert for name, wert in args.items()}
|
||||||
@@ -34,7 +34,7 @@ import urllib.request
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable, Iterable
|
||||||
|
|
||||||
import tesm_licensing as lic
|
import tesm_licensing as lic
|
||||||
|
|
||||||
@@ -54,6 +54,21 @@ SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
|
|||||||
EXPIRY_HINT_DAYS = 90
|
EXPIRY_HINT_DAYS = 90
|
||||||
SETTING_EXPORT_GRACE = "license_export_grace"
|
SETTING_EXPORT_GRACE = "license_export_grace"
|
||||||
|
|
||||||
|
#: Module, die der Betreiber vor Ort abgeschaltet hat (Liste von Namen).
|
||||||
|
#:
|
||||||
|
#: Die Lizenz sagt, *was erlaubt ist*; sie sagt nicht, was ein Haus auch
|
||||||
|
#: benutzen will. Wer kein DHCP ueber TESM fahren will, weil ein anderer Server
|
||||||
|
#: das tut, soll den Punkt nicht wegdruecken muessen -- und vor allem soll
|
||||||
|
#: niemand versehentlich eine fremde Kea-Konfiguration ueberschreiben koennen.
|
||||||
|
#: Abschalten ist deshalb mehr als Aufraeumen der Navigation: die Sperre sitzt
|
||||||
|
#: an derselben Stelle wie die Lizenzpruefung, also vor jeder Route.
|
||||||
|
#: Bewusst **ohne** das Praefix ``license_``: Sicherungen lassen alles mit
|
||||||
|
#: diesem Praefix draussen, weil Lizenz- und Aktivierungszustand zur Maschine
|
||||||
|
#: gehoeren und nicht in ein Backup. Der Schalter ist aber keine Lizenzangabe,
|
||||||
|
#: sondern eine Entscheidung des Hauses -- die soll eine Wiederherstellung
|
||||||
|
#: mitbringen.
|
||||||
|
SETTING_MODULES_OFF = "modules_disabled"
|
||||||
|
|
||||||
STATUS_INACTIVE = "inactive"
|
STATUS_INACTIVE = "inactive"
|
||||||
STATUS_PENDING = "pending"
|
STATUS_PENDING = "pending"
|
||||||
STATUS_ACTIVE = "active"
|
STATUS_ACTIVE = "active"
|
||||||
@@ -68,10 +83,10 @@ API_PATHS = {
|
|||||||
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
|
||||||
LICENSED_FEATURES: dict[str, str] = {
|
LICENSED_FEATURES: dict[str, str] = {
|
||||||
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
|
||||||
"nginx": "Webserver-Einstellungen aendern",
|
"nginx": "Webserver-Einstellungen ändern",
|
||||||
"export": "Daten exportieren",
|
"export": "Daten exportieren",
|
||||||
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
|
"trash_restore": "Papierkorb wiederherstellen und endgültig löschen",
|
||||||
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
|
"monitor_interval": "Prüfintervall der Überwachung ändern",
|
||||||
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +100,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"""
|
"""
|
||||||
text = str(exc)
|
text = str(exc)
|
||||||
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
|
||||||
detail = "Das Zertifikat konnte nicht geprueft werden"
|
detail = "Das Zertifikat konnte nicht geprüft werden"
|
||||||
if "self-signed" in text or "self signed" in text:
|
if "self-signed" in text or "self signed" in text:
|
||||||
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
|
||||||
return (
|
return (
|
||||||
@@ -94,7 +109,7 @@ def describe_transport_error(exc: BaseException, endpoint: str) -> str:
|
|||||||
"einer anerkannten Stelle."
|
"einer anerkannten Stelle."
|
||||||
)
|
)
|
||||||
if "Name or service not known" in text or "nodename nor servname" in text:
|
if "Name or service not known" in text or "nodename nor servname" in text:
|
||||||
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
|
return f"Der Name im Endpunkt ({endpoint}) lässt sich nicht auflösen."
|
||||||
if "Connection refused" in text:
|
if "Connection refused" in text:
|
||||||
return f"{endpoint} nimmt keine Verbindung an."
|
return f"{endpoint} nimmt keine Verbindung an."
|
||||||
if "timed out" in text or "timeout" in text.lower():
|
if "timed out" in text or "timeout" in text.lower():
|
||||||
@@ -121,6 +136,8 @@ class LicenseState:
|
|||||||
last_heartbeat_at: str = ""
|
last_heartbeat_at: str = ""
|
||||||
heartbeat_error: str = ""
|
heartbeat_error: str = ""
|
||||||
pending_action: str = ""
|
pending_action: str = ""
|
||||||
|
#: Vor Ort abgeschaltet -- unabhaengig davon, was die Lizenz erlaubt.
|
||||||
|
modules_off: frozenset[str] = frozenset()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def role(self) -> str:
|
def role(self) -> str:
|
||||||
@@ -128,8 +145,14 @@ class LicenseState:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def modules(self) -> frozenset[str]:
|
def modules(self) -> frozenset[str]:
|
||||||
|
"""Was die Lizenz freischaltet -- ohne Ruecksicht auf den Schalter."""
|
||||||
return self.status.modules if self.status else frozenset()
|
return self.status.modules if self.status else frozenset()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def modules_in_use(self) -> frozenset[str]:
|
||||||
|
"""Was tatsaechlich laeuft: lizenziert **und** nicht abgeschaltet."""
|
||||||
|
return self.modules - self.modules_off
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def customer_name(self) -> str:
|
def customer_name(self) -> str:
|
||||||
return str((self.status.customer if self.status else {}).get("name") or "")
|
return str((self.status.customer if self.status else {}).get("name") or "")
|
||||||
@@ -179,6 +202,7 @@ class LicenseManager:
|
|||||||
"""Liest Lizenzdatei und Status neu ein."""
|
"""Liest Lizenzdatei und Status neu ein."""
|
||||||
with self._lock:
|
with self._lock:
|
||||||
state = LicenseState()
|
state = LicenseState()
|
||||||
|
state.modules_off = self.abgeschaltete_module(conn)
|
||||||
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
|
||||||
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
|
||||||
pending = self._pending(conn)
|
pending = self._pending(conn)
|
||||||
@@ -239,9 +263,51 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def module_active(self, name: str) -> bool:
|
def module_active(self, name: str) -> bool:
|
||||||
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
|
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) in Betrieb?
|
||||||
|
|
||||||
|
Zwei Bedingungen, und beide muessen gelten: die Lizenz muss es
|
||||||
|
freischalten, und der Betreiber darf es nicht abgeschaltet haben. Diese
|
||||||
|
eine Funktion fragen die Navigation, jede Route und die Hintergrund-
|
||||||
|
aufgaben -- darum sitzt der Schalter hier und nicht in einer Vorlage.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
self.active()
|
||||||
|
and name in self._state.modules
|
||||||
|
and name not in self._state.modules_off
|
||||||
|
)
|
||||||
|
|
||||||
|
def module_licensed(self, name: str) -> bool:
|
||||||
|
"""Schaltet die Lizenz das Modul frei? (Ohne den Schalter vor Ort.)"""
|
||||||
return self.active() and name in self._state.modules
|
return self.active() and name in self._state.modules
|
||||||
|
|
||||||
|
def abgeschaltete_module(self, conn: sqlite3.Connection) -> frozenset[str]:
|
||||||
|
"""Liest die Liste aus den Einstellungen."""
|
||||||
|
gespeichert = self.settings.get(conn, SETTING_MODULES_OFF) or []
|
||||||
|
if isinstance(gespeichert, str): # Altbestand: ein einzelner Name
|
||||||
|
gespeichert = [gespeichert]
|
||||||
|
return frozenset(str(name) for name in gespeichert)
|
||||||
|
|
||||||
|
def module_abschalten(
|
||||||
|
self, conn: sqlite3.Connection, namen: Iterable[str], *, actor: str = ""
|
||||||
|
) -> tuple[frozenset[str], frozenset[str]]:
|
||||||
|
"""Setzt die Liste der abgeschalteten Module neu.
|
||||||
|
|
||||||
|
Gibt ``(eingeschaltet, abgeschaltet)`` zurueck -- also nur das, was sich
|
||||||
|
wirklich geaendert hat; der Aufrufer schreibt daraus seinen
|
||||||
|
Protokolleintrag.
|
||||||
|
|
||||||
|
Der Zustand wird hier nicht neu eingelesen, sondern nur an dieser einen
|
||||||
|
Stelle nachgezogen: ein voller Neuaufbau wuerde die Lizenzdatei erneut
|
||||||
|
pruefen, obwohl sich an ihr nichts geaendert hat -- und der Schalter ist
|
||||||
|
das Einzige, was jetzt anders ist.
|
||||||
|
"""
|
||||||
|
vorher = self.abgeschaltete_module(conn)
|
||||||
|
nachher = frozenset(str(name) for name in namen)
|
||||||
|
self.settings.set(conn, SETTING_MODULES_OFF, sorted(nachher), actor=actor)
|
||||||
|
with self._lock:
|
||||||
|
self._state.modules_off = nachher
|
||||||
|
return vorher - nachher, nachher - vorher
|
||||||
|
|
||||||
def feature_allowed(self, feature: str) -> bool:
|
def feature_allowed(self, feature: str) -> bool:
|
||||||
"""Lizenzpflichtige Einzelfunktion.
|
"""Lizenzpflichtige Einzelfunktion.
|
||||||
|
|
||||||
@@ -257,8 +323,8 @@ class LicenseManager:
|
|||||||
def require(self, feature: str) -> None:
|
def require(self, feature: str) -> None:
|
||||||
if not self.feature_allowed(feature):
|
if not self.feature_allowed(feature):
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
|
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gültige, "
|
||||||
"aktivierte Lizenz nicht moeglich."
|
"aktivierte Lizenz nicht möglich."
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@@ -284,7 +350,7 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
payload = json.loads(text)
|
payload = json.loads(text)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
|
raise LicenseError("Die Datei ist kein gültiges JSON.") from exc
|
||||||
|
|
||||||
try:
|
try:
|
||||||
document, client_key = lic.split_bundle(payload)
|
document, client_key = lic.split_bundle(payload)
|
||||||
@@ -294,20 +360,20 @@ class LicenseManager:
|
|||||||
try:
|
try:
|
||||||
lic.verify_license(document) # innere Konsistenz
|
lic.verify_license(document) # innere Konsistenz
|
||||||
except lic.LicensingError as exc:
|
except lic.LicensingError as exc:
|
||||||
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
|
raise LicenseError(f"Die Lizenzdatei ist ungültig: {exc}") from exc
|
||||||
|
|
||||||
master_pubkey = str(document.get("master_pubkey") or "")
|
master_pubkey = str(document.get("master_pubkey") or "")
|
||||||
known = self.trusted_keys(conn)
|
known = self.trusted_keys(conn)
|
||||||
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
if known and master_pubkey not in set(known) and not allow_new_issuer:
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
|
||||||
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
f"(Schlüssel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
|
||||||
"Wechsel des Ausstellers ausdruecklich bestaetigen."
|
"Wechsel des Ausstellers ausdrücklich bestätigen."
|
||||||
)
|
)
|
||||||
|
|
||||||
if not client_key and not self.key_path.is_file():
|
if not client_key and not self.key_path.is_file():
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
|
"Der Datei fehlt der Client-Schlüssel. Bitte das vollständige "
|
||||||
"Lizenz-Bundle des Lizenzservers verwenden."
|
"Lizenz-Bundle des Lizenzservers verwenden."
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -332,7 +398,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
action="license.issuer_pinned",
|
action="license.issuer_pinned",
|
||||||
target=lic.key_id(master_pubkey),
|
target=lic.key_id(master_pubkey),
|
||||||
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
|
detail="Trust on first use" if not known else "Aussteller-Wechsel bestätigt",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -397,11 +463,11 @@ class LicenseManager:
|
|||||||
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not self._state.valid:
|
if not self._state.valid:
|
||||||
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
|
raise LicenseError("Es ist keine gültige Lizenz installiert.")
|
||||||
key = self.client_key()
|
key = self.client_key()
|
||||||
if not key:
|
if not key:
|
||||||
raise LicenseError(
|
raise LicenseError(
|
||||||
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
"Der Client-Schlüssel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
|
||||||
)
|
)
|
||||||
return lic.build_client_request(
|
return lic.build_client_request(
|
||||||
action, self._state.document, key, hostname=hostname, report=report
|
action, self._state.document, key, hostname=hostname, report=report
|
||||||
@@ -442,8 +508,8 @@ class LicenseManager:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
reason = (
|
reason = (
|
||||||
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
|
"In dieser Lizenz ist kein Lizenzserver hinterlegt – sie kann sich nur "
|
||||||
"ueber den Offline-Weg aktivieren."
|
"über den Offline-Weg aktivieren."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
|
||||||
@@ -475,7 +541,7 @@ class LicenseManager:
|
|||||||
"code": code,
|
"code": code,
|
||||||
"message": (
|
"message": (
|
||||||
(reason + " " if reason else "")
|
(reason + " " if reason else "")
|
||||||
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
|
+ "Der Offline-Weg steht offen: übermitteln Sie den angezeigten Code an "
|
||||||
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -527,7 +593,7 @@ class LicenseManager:
|
|||||||
current_seq=current_seq,
|
current_seq=current_seq,
|
||||||
)
|
)
|
||||||
except lic.LicensingError as exc:
|
except lic.LicensingError as exc:
|
||||||
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
|
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gültig: {exc}") from exc
|
||||||
|
|
||||||
status = str(response.get("status") or "")
|
status = str(response.get("status") or "")
|
||||||
detail = str(response.get("detail") or "")
|
detail = str(response.get("detail") or "")
|
||||||
@@ -597,7 +663,7 @@ class LicenseManager:
|
|||||||
actor=actor,
|
actor=actor,
|
||||||
)
|
)
|
||||||
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
|
||||||
message = "Verbindung zum Lizenzserver bestaetigt."
|
message = "Verbindung zum Lizenzserver bestätigt."
|
||||||
|
|
||||||
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
|
||||||
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
# Ein erfolgreicher Heartbeat laeuft alle paar Minuten und ist kein
|
||||||
@@ -648,7 +714,7 @@ class LicenseManager:
|
|||||||
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if not url.startswith(("https://", "http://")):
|
if not url.startswith(("https://", "http://")):
|
||||||
raise ValueError("Ungueltiger Endpunkt")
|
raise ValueError("Ungültiger Endpunkt")
|
||||||
data = json.dumps(payload).encode("utf-8")
|
data = json.dumps(payload).encode("utf-8")
|
||||||
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
|
||||||
url,
|
url,
|
||||||
@@ -748,7 +814,7 @@ class LicenseManager:
|
|||||||
}
|
}
|
||||||
if not state.valid:
|
if not state.valid:
|
||||||
return {
|
return {
|
||||||
"label": "Lizenz ungueltig",
|
"label": "Lizenz ungültig",
|
||||||
"css_class": "status-pill--danger",
|
"css_class": "status-pill--danger",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
@@ -773,29 +839,29 @@ class LicenseManager:
|
|||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": (
|
"title": (
|
||||||
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
|
"Die Kulanzfrist läuft noch, Funktionen bleiben vorerst verfügbar."
|
||||||
if status.grace_active
|
if status.grace_active
|
||||||
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
else "Lizenzpflichtige Funktionen sind abgeschaltet."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if status.expiring_soon:
|
if status.expiring_soon:
|
||||||
return {
|
return {
|
||||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||||
"css_class": "status-pill--warning",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "status-pill__dot--pulse",
|
"dot_class": "status-pill__dot--pulse",
|
||||||
"title": "Bitte rechtzeitig verlaengern.",
|
"title": "Bitte rechtzeitig verlängern.",
|
||||||
}
|
}
|
||||||
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
# Zwischen der Warnschwelle und 90 Tagen: gruen, rein als Hinweis. Wer
|
||||||
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
# eine Verlaengerung im Voraus plant, will das Datum sehen, ohne dass es
|
||||||
# nach einem Problem aussieht.
|
# nach einem Problem aussieht.
|
||||||
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
if status.days_left is not None and 0 < status.days_left <= EXPIRY_HINT_DAYS:
|
||||||
return {
|
return {
|
||||||
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
|
"label": f"Lizenz läuft in {status.days_left} Tagen ab",
|
||||||
"css_class": "",
|
"css_class": "",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Nur ein Hinweis -- es ist nichts eingeschraenkt.",
|
"title": "Nur ein Hinweis – es ist nichts eingeschränkt.",
|
||||||
}
|
}
|
||||||
if state.last_heartbeat_at:
|
if state.last_heartbeat_at:
|
||||||
try:
|
try:
|
||||||
@@ -809,6 +875,6 @@ class LicenseManager:
|
|||||||
"css_class": "status-pill--warning",
|
"css_class": "status-pill--warning",
|
||||||
"dot": "1",
|
"dot": "1",
|
||||||
"dot_class": "",
|
"dot_class": "",
|
||||||
"title": "Rein informativ -- es wird nichts abgeschaltet.",
|
"title": "Rein informativ – es wird nichts abgeschaltet.",
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
@@ -94,7 +94,7 @@ def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False)
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
|
||||||
root.warning("Konnte app.log nicht oeffnen: %s", exc)
|
root.warning("Konnte app.log nicht öffnen: %s", exc)
|
||||||
|
|
||||||
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
|
||||||
access = logging.getLogger(ACCESS_LOGGER)
|
access = logging.getLogger(ACCESS_LOGGER)
|
||||||
|
|||||||
@@ -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,10 +181,26 @@ 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:
|
||||||
|
if migration.geaendert_weil:
|
||||||
|
# Mit Begruendung ist es eine Notiz, keine Warnung:
|
||||||
|
# manchmal muss der Text einer laengst angewendeten
|
||||||
|
# Migration mit, damit eine **frische** Installation
|
||||||
|
# denselben Stand erreicht. Wer das Protokoll liest,
|
||||||
|
# soll den Unterschied zu einem Versehen sehen.
|
||||||
|
logger.info(
|
||||||
|
"Migration %s/%s (%s) wurde absichtlich geändert: %s "
|
||||||
|
"Auf dieser Installation ist sie längst angewendet und "
|
||||||
|
"wird nicht erneut ausgeführt.",
|
||||||
|
component,
|
||||||
|
migration.version,
|
||||||
|
migration.name,
|
||||||
|
migration.geaendert_weil,
|
||||||
|
)
|
||||||
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
|
"Migration %s/%s (%s) wurde nachträglich geändert "
|
||||||
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
|
"(Prüfsumme %s != %s). Bereits angewendete Migrationen werden "
|
||||||
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
|
"nicht erneut ausgeführt – neue Änderungen brauchen eine neue "
|
||||||
"Versionsnummer.",
|
"Versionsnummer.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
@@ -210,7 +232,7 @@ class MigrationRunner:
|
|||||||
raise
|
raise
|
||||||
logger.error(
|
logger.error(
|
||||||
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
|
||||||
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
|
"angewendet werden. Der Start läuft weiter; die Daten müssen "
|
||||||
"manuell bereinigt werden.",
|
"manuell bereinigt werden.",
|
||||||
component,
|
component,
|
||||||
migration.version,
|
migration.version,
|
||||||
|
|||||||
@@ -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 aendern.",
|
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,35 +27,46 @@ 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 ausloesen (Dienste, Netzwerk uebernehmen)."},
|
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 ausfuehren.",
|
A.EXECUTE: "Verbindungstest ausführen.",
|
||||||
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -63,67 +74,78 @@ 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 uebernehmen, Zertifikat anfordern."},
|
action_hints={A.EXECUTE: "Konfiguration übernehmen, Zertifikat anfordern."},
|
||||||
)
|
)
|
||||||
|
|
||||||
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 Uebertragung",
|
#: 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="Verschluesselter 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 einschliessen.",
|
A.SECRETS: "Zugangsdaten in die Sicherung einschließen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BACKUP_IMPORT = Resource(
|
||||||
|
key="backup_import",
|
||||||
|
label="Sicherung einspielen",
|
||||||
|
actions=(A.READ, A.EXECUTE),
|
||||||
|
hint="Prüfen, übernehmen, verwerfen",
|
||||||
|
action_hints={A.EXECUTE: "Eine geprüfte Sicherung anwenden."},
|
||||||
|
)
|
||||||
|
|
||||||
TRASH = Resource(
|
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="Geloeschtes wiederherstellen oder endgueltig entfernen",
|
hint="Gelöschtes wiederherstellen oder endgültig entfernen",
|
||||||
action_hints={
|
action_hints={
|
||||||
A.EDIT: "Eintraege wiederherstellen.",
|
A.WRITE: "Einträge wiederherstellen.",
|
||||||
A.DELETE: "Eintraege endgueltig entfernen.",
|
A.DELETE: "Einträge endgültig entfernen.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
DIAGNOSTICS = Resource(
|
DIAGNOSTICS = Resource(
|
||||||
key="diagnostics",
|
key="diagnostics",
|
||||||
label="Diagnose",
|
label="Diagnose",
|
||||||
actions=(A.VIEW,),
|
actions=(A.READ,),
|
||||||
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
|
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."},
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGS_AUDIT = Resource(
|
LOGS_AUDIT = Resource(
|
||||||
key="logs_audit",
|
key="logs_audit",
|
||||||
label="Aenderungsprotokoll",
|
label="Änderungsprotokoll",
|
||||||
actions=(A.VIEW, A.EXPORT),
|
actions=(A.READ, A.EXPORT),
|
||||||
hint="Wer hat wann was geaendert",
|
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",
|
||||||
|
]
|
||||||
@@ -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,38 +37,68 @@ 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", "Datensaetze und Seiten dieses Bereichs lesen."),
|
#: Beides hat seinen Ort: die Matrix fuehrt die Spalte unter ihrem englischen
|
||||||
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
|
#: Namen (das ist das Vokabular, und es steht so auch im Schluessel), die
|
||||||
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
|
#: Kleinschrift darunter und jeder Satz in der Oberflaeche bleiben deutsch.
|
||||||
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
|
ACTION_META: dict[Action, tuple[str, str, str, str]] = {
|
||||||
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (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",
|
||||||
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
|
"Klartext",
|
||||||
|
"Passwörter und Schlüssel im Klartext sehen oder mitnehmen.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
#: 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,6 +112,32 @@ 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,
|
||||||
|
#: obwohl sie in SENSITIVE_ACTIONS stehen. Bewusst hier und nicht als
|
||||||
|
#: stilles Weglassen in der Route: eine Ausnahme von einer
|
||||||
|
#: Sicherheitsregel gehoert dorthin, wo man sie sieht und begruenden muss.
|
||||||
|
ohne_wiederanmeldung: tuple[Action, ...] = ()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def short_label(self) -> str:
|
||||||
|
"""Beschriftung ohne den Namen der Hauptzeile.
|
||||||
|
|
||||||
|
Unter "Clients" steht "Neustart über PoE" und nicht "Clients: Neustart
|
||||||
|
über PoE" -- das Wort stand sonst siebenmal untereinander. Ausserhalb
|
||||||
|
der Matrix (Protokoll, Meldungen) bleibt der vollstaendige Name.
|
||||||
|
"""
|
||||||
|
if self.parent and ": " in self.label:
|
||||||
|
return self.label.split(": ", 1)[1]
|
||||||
|
return self.label
|
||||||
|
|
||||||
def permission(self, action: Action) -> str:
|
def permission(self, action: Action) -> str:
|
||||||
return f"{self.key}.{action.value}"
|
return f"{self.key}.{action.value}"
|
||||||
@@ -96,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."""
|
||||||
@@ -123,45 +188,92 @@ 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:
|
||||||
if area.key in seen_keys:
|
if area.key in seen_keys:
|
||||||
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
|
raise ValueError(f"Doppelter Bereichsschlüssel: {area.key}")
|
||||||
seen_keys.add(area.key)
|
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 Ressourcenschluessel: {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 fuer 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:
|
||||||
return self._resources.get(key)
|
return self._resources.get(key)
|
||||||
|
|
||||||
|
def verlangt_wiederanmeldung(self, permission: str) -> bool:
|
||||||
|
"""Braucht dieses Recht eine frische Passwortbestaetigung?
|
||||||
|
|
||||||
|
Die Antwort steht im Modell (``SENSITIVE_ACTIONS``) und nicht in der
|
||||||
|
Route -- sonst vergisst sie die naechste, die dazukommt.
|
||||||
|
"""
|
||||||
|
resource_key, _, action_value = permission.rpartition(".")
|
||||||
|
entry = self._resources.get(resource_key)
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
_, resource = entry
|
||||||
|
try:
|
||||||
|
action = Action(action_value)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
if action in resource.ohne_wiederanmeldung:
|
||||||
|
return False
|
||||||
|
return action in SENSITIVE_ACTIONS
|
||||||
|
|
||||||
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)
|
||||||
@@ -172,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.
|
||||||
@@ -180,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.
|
||||||
@@ -200,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))
|
||||||
@@ -132,7 +145,7 @@ def guard_no_amplification(
|
|||||||
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
labels = ", ".join(tree.label_for(p) for p in missing[:6])
|
||||||
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
f"Sie können kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -160,7 +173,7 @@ def create_group(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
|
||||||
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
|
"Stellen Sie sie wieder her oder löschen Sie sie endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
|
||||||
|
|
||||||
@@ -205,7 +218,7 @@ def update_group(
|
|||||||
|
|
||||||
if name is not None and name.strip() != group.name:
|
if name is not None and name.strip() != group.name:
|
||||||
if group.is_system:
|
if group.is_system:
|
||||||
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
|
raise PermissionDenied("Systemgruppen können nicht umbenannt werden.")
|
||||||
clean = name.strip()
|
clean = name.strip()
|
||||||
if not clean:
|
if not clean:
|
||||||
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
raise ValidationError("Der Gruppenname darf nicht leer sein.")
|
||||||
@@ -224,13 +237,13 @@ def update_group(
|
|||||||
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
|
||||||
(description.strip(), _now(), group_id),
|
(description.strip(), _now(), group_id),
|
||||||
)
|
)
|
||||||
changes.append("Beschreibung geaendert")
|
changes.append("Beschreibung geändert")
|
||||||
|
|
||||||
if permissions is not None:
|
if permissions is not None:
|
||||||
if group.is_system and not unlock_system:
|
if group.is_system and not unlock_system:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
"Die Rechte dieser Systemgruppe sind gesperrt. "
|
||||||
"Sie muessen sie zuerst ausdruecklich freischalten."
|
"Sie müssen sie zuerst ausdrücklich freischalten."
|
||||||
)
|
)
|
||||||
wanted = tree.sanitize(permissions)
|
wanted = tree.sanitize(permissions)
|
||||||
added = sorted(set(wanted) - set(group.permissions))
|
added = sorted(set(wanted) - set(group.permissions))
|
||||||
@@ -240,7 +253,7 @@ def update_group(
|
|||||||
_write_permissions(conn, group_id, wanted)
|
_write_permissions(conn, group_id, wanted)
|
||||||
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
|
||||||
if added:
|
if added:
|
||||||
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
|
changes.append("Rechte hinzugefügt: " + ", ".join(tree.label_for(p) for p in added))
|
||||||
if removed:
|
if removed:
|
||||||
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
|
||||||
|
|
||||||
@@ -252,7 +265,7 @@ def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
|
|||||||
if group is None:
|
if group is None:
|
||||||
raise ValidationError("Diese Gruppe existiert nicht.")
|
raise ValidationError("Diese Gruppe existiert nicht.")
|
||||||
if group.is_system:
|
if group.is_system:
|
||||||
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
|
raise PermissionDenied("Systemgruppen können nicht gelöscht werden.")
|
||||||
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -315,6 +328,113 @@ def ensure_presets(
|
|||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
||||||
|
def unberuehrte_vorlagen_entfernen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
|
||||||
|
) -> list[str]:
|
||||||
|
"""Legt Vorlagengruppen in den Papierkorb -- aber nur unberuehrte.
|
||||||
|
|
||||||
|
Wird gebraucht, wenn eine Vorlage aus der Auslieferung verschwindet. Auf
|
||||||
|
einer frischen Installation entsteht sie dann gar nicht erst; auf einer
|
||||||
|
bestehenden steht sie in der Datenbank, und eine Migration, die sie einfach
|
||||||
|
loescht, nimmt einem Kunden womoeglich eine Gruppe weg, mit der er
|
||||||
|
arbeitet.
|
||||||
|
|
||||||
|
Darum diese fuenf Bedingungen, und alle fuenf muessen zutreffen:
|
||||||
|
|
||||||
|
* Die Gruppe stammt **aus der Auslieferung**: ihr ``preset_key`` ist der der
|
||||||
|
Vorlage. Diese Kennung setzt nur :func:`ensure_presets`; eine selbst
|
||||||
|
angelegte Gruppe hat sie nicht, auch wenn sie zufaellig denselben Namen
|
||||||
|
traegt. Ueber den Namen wird bewusst *nicht* gesucht -- sonst raeumte
|
||||||
|
dieser Schritt jemandem seine eigene Gruppe "Betrieb" weg.
|
||||||
|
* Sie heisst noch so, wie sie ausgeliefert wurde. Ein anderer Name heisst:
|
||||||
|
jemand hat sie uebernommen.
|
||||||
|
* Die Gruppe hat **keine Mitglieder**. Wer Mitglieder hat, wird benutzt.
|
||||||
|
* Ihre Rechte sind **unveraendert** die der Vorlage. Jede Abweichung heisst:
|
||||||
|
jemand hat sie angepasst, also ist sie seine, nicht unsere.
|
||||||
|
* Sie ist keine Systemgruppe.
|
||||||
|
|
||||||
|
Geloescht wird weich: die Gruppe liegt im Papierkorb und kommt mit einem
|
||||||
|
Klick zurueck. Gibt die Namen der einsortierten Gruppen zurueck.
|
||||||
|
"""
|
||||||
|
entfernt: list[str] = []
|
||||||
|
gruppen = list_groups(conn)
|
||||||
|
for preset in presets:
|
||||||
|
gruppe = next(
|
||||||
|
(
|
||||||
|
g
|
||||||
|
for g in gruppen
|
||||||
|
if g.preset_key
|
||||||
|
and g.preset_key == preset.key
|
||||||
|
and g.name.lower() == preset.name.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if gruppe is None or gruppe.is_system or gruppe.member_count:
|
||||||
|
continue
|
||||||
|
if set(gruppe.permissions) != set(tree.expand_preset(preset)):
|
||||||
|
continue
|
||||||
|
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), gruppe.id))
|
||||||
|
entfernt.append(gruppe.name)
|
||||||
|
return entfernt
|
||||||
|
|
||||||
|
|
||||||
|
def funktionsgruppe_anlegen(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
|
) -> int:
|
||||||
|
"""Legt die Vollzugriffsgruppe an, falls sie fehlt, und gibt ihre Nummer.
|
||||||
|
|
||||||
|
Getrennt von :func:`funktionsgruppe_fuer_administratoren`, weil das zwei
|
||||||
|
verschiedene Dinge sind: hier entsteht nur die Gruppe, dort bekommt sie
|
||||||
|
**jeder** Administrator. Wer ein einzelnes Konto aufnehmen will -- etwa
|
||||||
|
das, das an der Kommandozeile entsteht --, braucht das erste ohne das
|
||||||
|
zweite; sonst faengt sich ein Administrator, der die Gruppe bewusst
|
||||||
|
abgelegt hat, sie beim naechsten Aufruf wieder ein.
|
||||||
|
"""
|
||||||
|
zeile = Database.one(
|
||||||
|
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
|
||||||
|
)
|
||||||
|
if zeile is not None:
|
||||||
|
return int(zeile["id"])
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
|
||||||
|
"created_at, updated_at) VALUES (?,?,0,0,?,?,?)",
|
||||||
|
(preset.name, preset.description, preset.key, _now(), _now()),
|
||||||
|
)
|
||||||
|
gruppe = int(cursor.lastrowid or 0)
|
||||||
|
_write_permissions(conn, gruppe, tree.expand_preset(preset))
|
||||||
|
return gruppe
|
||||||
|
|
||||||
|
|
||||||
|
def funktionsgruppe_fuer_administratoren(
|
||||||
|
conn: sqlite3.Connection, tree: PermissionTree, preset: RolePreset
|
||||||
|
) -> int:
|
||||||
|
"""Legt die Vollzugriffsgruppe an und gibt sie allen Administratoren.
|
||||||
|
|
||||||
|
Der Uebergang zu 2.8.0: bis dahin gab das Kennzeichen ``is_admin`` jede
|
||||||
|
Pruefung frei. Danach gibt es den Verwaltungsbereich, und alles andere
|
||||||
|
kommt ueber Gruppen. Ohne diesen Schritt fehlt einem bestehenden
|
||||||
|
Administrator nach dem Update die halbe Anwendung -- und er haelt das fuer
|
||||||
|
einen Fehler, nicht fuer eine Absicht.
|
||||||
|
|
||||||
|
Auf einer frischen Installation gibt es beim Migrieren noch kein Konto;
|
||||||
|
dann entsteht nur die Gruppe, und wer sie will, weist sie sich zu. Genau so
|
||||||
|
ist die neue Trennung gedacht.
|
||||||
|
|
||||||
|
Gibt die Nummer der Gruppe zurueck.
|
||||||
|
"""
|
||||||
|
gruppe = funktionsgruppe_anlegen(conn, tree, preset)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
|
||||||
|
[
|
||||||
|
(int(row["id"]), gruppe)
|
||||||
|
for row in Database.all(
|
||||||
|
conn, "SELECT id FROM users WHERE is_admin=1 AND deleted_at IS NULL"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return gruppe
|
||||||
|
|
||||||
|
|
||||||
def default_group_id(conn: sqlite3.Connection) -> int | None:
|
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"
|
||||||
@@ -410,7 +530,7 @@ def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
|
|||||||
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
|
||||||
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
|
"Administratorkonten können nur von Administratoren bearbeitet werden."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -420,8 +540,8 @@ def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) ->
|
|||||||
return
|
return
|
||||||
if count_active_admins(conn, exclude_id=user_id) == 0:
|
if count_active_admins(conn, exclude_id=user_id) == 0:
|
||||||
raise PermissionDenied(
|
raise PermissionDenied(
|
||||||
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
|
f"{action} nicht möglich: es muss mindestens ein entsperrtes "
|
||||||
"Administratorkonto uebrig bleiben."
|
"Administratorkonto übrig bleiben."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -482,12 +602,12 @@ def create_user(
|
|||||||
if existing["deleted_at"]:
|
if existing["deleted_at"]:
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
|
||||||
"wieder her oder loeschen Sie es endgueltig."
|
"wieder her oder löschen Sie es endgültig."
|
||||||
)
|
)
|
||||||
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
|
||||||
|
|
||||||
if is_admin and not getattr(actor, "is_admin", False):
|
if is_admin and not getattr(actor, "is_admin", False):
|
||||||
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
|
raise PermissionDenied("Nur Administratoren können weitere Administratoren anlegen.")
|
||||||
|
|
||||||
if auth_source == "local":
|
if auth_source == "local":
|
||||||
problems = policy.check(password, username=clean)
|
problems = policy.check(password, username=clean)
|
||||||
@@ -552,24 +672,30 @@ def update_user(
|
|||||||
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
# ueberschrieben -- die Bearbeitung waere eine Illusion.
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
|
||||||
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
|
"aus dem Verzeichnis übernommen und können hier nicht geändert werden."
|
||||||
)
|
)
|
||||||
|
|
||||||
fields: dict[str, Any] = {}
|
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} geaendert")
|
changes.append(
|
||||||
|
f"{beschriftungen[column]}: {alt or '(leer)'} -> {value.strip() or '(leer)'}"
|
||||||
|
)
|
||||||
|
|
||||||
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
|
if 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):
|
||||||
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
|
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator ändern.")
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
|
raise PermissionDenied("Sie können Ihr eigenes Admin-Kennzeichen nicht ändern.")
|
||||||
if not is_admin:
|
if not is_admin:
|
||||||
guard_last_admin(conn, user_id, action="Herabstufen")
|
guard_last_admin(conn, user_id, action="Herabstufen")
|
||||||
fields["is_admin"] = int(bool(is_admin))
|
fields["is_admin"] = int(bool(is_admin))
|
||||||
@@ -577,7 +703,7 @@ def update_user(
|
|||||||
|
|
||||||
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
|
raise PermissionDenied("Sie können Ihr eigenes Konto nicht sperren.")
|
||||||
if is_locked:
|
if is_locked:
|
||||||
guard_last_admin(conn, user_id, action="Sperren")
|
guard_last_admin(conn, user_id, action="Sperren")
|
||||||
fields["is_locked"] = int(bool(is_locked))
|
fields["is_locked"] = int(bool(is_locked))
|
||||||
@@ -595,7 +721,7 @@ def update_user(
|
|||||||
after = {int(gid) for gid in group_ids}
|
after = {int(gid) for gid in group_ids}
|
||||||
if before != after:
|
if before != after:
|
||||||
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
|
||||||
changes.append("Gruppenzuordnung geaendert")
|
changes.append("Gruppenzuordnung geändert")
|
||||||
|
|
||||||
return changes
|
return changes
|
||||||
|
|
||||||
@@ -613,7 +739,7 @@ def set_password(
|
|||||||
raise ValidationError("Dieses Konto existiert nicht.")
|
raise ValidationError("Dieses Konto existiert nicht.")
|
||||||
if str(row.get("auth_source")) == "ldap":
|
if str(row.get("auth_source")) == "ldap":
|
||||||
raise ValidationError(
|
raise ValidationError(
|
||||||
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
|
"Verzeichniskonten haben hier kein Passwort – es wird im Verzeichnisdienst gepflegt."
|
||||||
)
|
)
|
||||||
problems = policy.check(new_password, username=str(row["username"]))
|
problems = policy.check(new_password, username=str(row["username"]))
|
||||||
if problems:
|
if problems:
|
||||||
@@ -631,7 +757,7 @@ def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> s
|
|||||||
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
raise ValidationError("Dieses Konto existiert nicht (mehr).")
|
||||||
guard_target_account(actor, target)
|
guard_target_account(actor, target)
|
||||||
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
if int(target["id"]) == int(getattr(actor, "id", 0)):
|
||||||
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
|
raise PermissionDenied("Sie können Ihr eigenes Konto nicht löschen.")
|
||||||
guard_last_admin(conn, user_id, action="Loeschen")
|
guard_last_admin(conn, user_id, action="Loeschen")
|
||||||
|
|
||||||
if str(target.get("auth_source")) == "ldap":
|
if str(target.get("auth_source")) == "ldap":
|
||||||
@@ -677,7 +803,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 +816,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 +832,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",
|
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ def protect() -> None:
|
|||||||
if not expected or not provided:
|
if not expected or not provided:
|
||||||
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
|
||||||
if not hmac.compare_digest(str(expected), str(provided)):
|
if not hmac.compare_digest(str(expected), str(provided)):
|
||||||
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
|
abort(403, description="Das Sicherheitstoken des Formulars ist ungültig. Bitte Seite neu laden.")
|
||||||
|
|
||||||
|
|
||||||
def exempt(view):
|
def exempt(view):
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ class PasswordPolicy:
|
|||||||
if classes < self.require_classes:
|
if classes < self.require_classes:
|
||||||
problems.append(
|
problems.append(
|
||||||
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
|
||||||
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
|
"(Klein-/Großbuchstaben, Ziffern, Sonderzeichen)."
|
||||||
)
|
)
|
||||||
if normalized.lower() in _FORBIDDEN:
|
if normalized.lower() in _FORBIDDEN:
|
||||||
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
|
problems.append("Dieses Passwort ist zu häufig und damit unsicher.")
|
||||||
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
if username and len(username) >= 3 and username.lower() in normalized.lower():
|
||||||
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
|
||||||
if len(set(normalized)) <= 3 and normalized:
|
if len(set(normalized)) <= 3 and normalized:
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
idle_minutes: int,
|
idle_minutes: int,
|
||||||
absolute_hours: int,
|
absolute_hours: int,
|
||||||
idle_lookup: Any = None,
|
idle_lookup: Any = None,
|
||||||
|
absolute_lookup: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.database = database
|
self.database = database
|
||||||
self.cookie_name = cookie_name
|
self.cookie_name = cookie_name
|
||||||
@@ -172,6 +173,16 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
#: Zeit zur Laufzeit einstellbar, ohne den Dienst neu zu starten -- und
|
||||||
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
#: ohne eine zweite Datenbankverbindung, denn sie bekommt die offene.
|
||||||
self._idle_lookup = idle_lookup
|
self._idle_lookup = idle_lookup
|
||||||
|
self._absolute_lookup = absolute_lookup
|
||||||
|
|
||||||
|
def _absolute(self, conn: Any) -> int:
|
||||||
|
"""Gesamtdauer in Stunden -- einstellbar wie die Leerlaufgrenze."""
|
||||||
|
if self._absolute_lookup is None:
|
||||||
|
return self.absolute_hours
|
||||||
|
try:
|
||||||
|
return int(self._absolute_lookup(conn)) or self.absolute_hours
|
||||||
|
except Exception: # pragma: no cover - Einstellung unlesbar
|
||||||
|
return self.absolute_hours
|
||||||
|
|
||||||
def _idle(self, conn: Any) -> int:
|
def _idle(self, conn: Any) -> int:
|
||||||
if self._idle_lookup is None:
|
if self._idle_lookup is None:
|
||||||
@@ -280,6 +291,9 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
|
||||||
with self.database.session() as conn, self.database.transaction(conn):
|
with self.database.session() as conn, self.database.transaction(conn):
|
||||||
if session.new or not session.sid:
|
if session.new or not session.sid:
|
||||||
|
# Eine vorhandene ID nicht wegwerfen: nach einem Wechsel beim
|
||||||
|
# Anmelden steht sie schon, und andere haben sie sich gemerkt.
|
||||||
|
if not session.sid:
|
||||||
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO user_sessions "
|
"INSERT INTO user_sessions "
|
||||||
@@ -292,7 +306,7 @@ class SqliteSessionInterface(SessionInterface):
|
|||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now),
|
_iso(now),
|
||||||
_iso(now + timedelta(minutes=self._idle(conn))),
|
_iso(now + timedelta(minutes=self._idle(conn))),
|
||||||
_iso(now + timedelta(hours=self.absolute_hours)),
|
_iso(now + timedelta(hours=self._absolute(conn))),
|
||||||
session.get("_ip", ""),
|
session.get("_ip", ""),
|
||||||
session.get("_ua", ""),
|
session.get("_ua", ""),
|
||||||
payload,
|
payload,
|
||||||
@@ -371,7 +385,12 @@ def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "p
|
|||||||
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
|
||||||
(_iso(_now()), reason, old_sid),
|
(_iso(_now()), reason, old_sid),
|
||||||
)
|
)
|
||||||
session.sid = ""
|
# Die neue ID entsteht **jetzt** und nicht erst beim Speichern der Antwort.
|
||||||
|
# Zwischen beidem laufen die Anmeldehaken, und einer davon bindet die
|
||||||
|
# Dateifreigaben ein und schreibt sich die Sitzung dazu. Mit einer leeren
|
||||||
|
# Sitzung hielt der Aufraeumer diese Einbindungen fuer verwaist und loeste
|
||||||
|
# sie binnen Minuten wieder.
|
||||||
|
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
|
||||||
session.new = True
|
session.new = True
|
||||||
session.modified = True
|
session.modified = True
|
||||||
return old_sid
|
return old_sid
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -847,6 +847,25 @@
|
|||||||
flex: 1 1 12rem;
|
flex: 1 1 12rem;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
/* Die Entscheidung steht rechts und der Haken darueber: er gehoert zum
|
||||||
|
Speichern, nicht zu den Feldern davor. */
|
||||||
|
.viewer__saveaktionen {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.viewer__saveknoepfe {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
/* Nach "gibt es schon": der Haken ist die Antwort -- also faellt er auf. */
|
||||||
|
.viewer__saveaktionen .check.is-gefragt {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
border-radius: var(--r-xs);
|
||||||
|
}
|
||||||
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
/* Eigener Name: ".field--inline" gibt es schon und steht dort fuer
|
||||||
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
"waagerecht" -- diese Regel haette sie app-weit umgestellt. */
|
||||||
.viewer__feld {
|
.viewer__feld {
|
||||||
@@ -991,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;
|
||||||
@@ -1017,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;
|
||||||
@@ -1315,3 +1392,340 @@
|
|||||||
.hide-sm { display: none !important; }
|
.hide-sm { display: none !important; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Sortierbare Liste (Navigationsreihenfolge) --------------------------- */
|
||||||
|
/* Symbol und Beschriftung stehen in einer Zeile -- so wie im Menue selbst.
|
||||||
|
Vorher brach die Beschriftung unter das Symbol, und die Liste sah aus wie
|
||||||
|
etwas anderes als das, was sie sortiert. */
|
||||||
|
|
||||||
|
.reorder {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: var(--surface-2);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row > .flex-1 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__row.is-dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__grip {
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__pos {
|
||||||
|
min-width: 1.6em;
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__moves {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder__group {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder--child {
|
||||||
|
margin: 4px 0 4px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reorder--child .reorder__row {
|
||||||
|
background: transparent;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Unterschrift --------------------------------------------------------- */
|
||||||
|
/* Die Zeichenflaeche braucht "touch-action: none": sonst scrollt ein iPad die
|
||||||
|
Seite, statt einen Strich zu zeichnen -- und es sieht aus, als reagierte das
|
||||||
|
Feld nicht. */
|
||||||
|
|
||||||
|
.viewer__sign {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signfelder {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signpad {
|
||||||
|
width: 100%;
|
||||||
|
height: 160px;
|
||||||
|
border: 1px dashed var(--border-strong, var(--border));
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: #fff;
|
||||||
|
touch-action: none;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__signaktionen {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen {
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdfscroll--setzen .page {
|
||||||
|
outline: 2px dashed var(--accent, #c8791f);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die gesetzte Unterschrift liegt auf der Seite -- in Prozent, damit sie beim
|
||||||
|
Zoomen an ihrer Stelle bleibt. Sie nimmt Zeiger an: verschieben mit dem
|
||||||
|
Block, Groesse mit dem Griff unten rechts, entfernen mit dem Kreuz.
|
||||||
|
"touch-action: none" ist auf dem iPad die Bedingung dafuer, dass ein Finger
|
||||||
|
zieht statt die Seite zu scrollen. */
|
||||||
|
/* Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
Ankerpunkt -- dieselbe Kante, an der pdf-lib das Bild setzt. Der Text
|
||||||
|
haengt darunter und darf die Hoehe nicht mitbestimmen. */
|
||||||
|
.viewer__unterschrift {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
/* "outline" statt "border": ein Rahmen haette die Marke um zwei Pixel
|
||||||
|
groesser gemacht und die Bildkante vom Ankerpunkt weggeschoben. */
|
||||||
|
outline: 1px dashed transparent;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift:hover,
|
||||||
|
.viewer__unterschrift.is-fassend {
|
||||||
|
outline-color: var(--accent);
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dieselbe Rechnung wie im PDF (Breite durch 18): auf dem Schirm steht, was
|
||||||
|
spaeter in der Datei steht. */
|
||||||
|
/* "width: max-content": der Strich ist so lang wie der Text darunter -- so
|
||||||
|
wie im PDF. Waere er so breit wie der Block, zeigte die Vorschau etwas
|
||||||
|
anderes als die Datei. */
|
||||||
|
.viewer__unterschrifttext {
|
||||||
|
/* Unter der Zeichnung, nicht in ihr: "top: 100%" setzt die Oberkante des
|
||||||
|
Strichs auf die Bildkante -- genau dort zieht ihn auch pdf-lib. Abstand,
|
||||||
|
Strichstaerke und Innenabstand rechnet das Skript in Punkten. */
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
/* Helvetica: dieselbe Schrift, die das PDF benutzt (StandardFonts.Helvetica).
|
||||||
|
Mit der Hausschrift sah die Vorschau anders aus als das Ergebnis. Die
|
||||||
|
Groesse setzt das Skript je Unterschrift -- hier steht nur ein Rueckfall. */
|
||||||
|
font-family: Helvetica, Arial, sans-serif;
|
||||||
|
/* Die tatsaechliche Groesse setzt das Skript in Pixeln (Punkte mal
|
||||||
|
Pixel-je-Punkt der Seite). Dieser Wert gilt nur, solange sie noch nicht
|
||||||
|
gerechnet wurde -- geerbt wuerde sonst die Schriftgroesse der Seite. */
|
||||||
|
font-size: 8px;
|
||||||
|
line-height: 1.15;
|
||||||
|
color: #111;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.85);
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
position: absolute;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
cursor: pointer;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschriftgriff {
|
||||||
|
right: -8px;
|
||||||
|
bottom: -8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__unterschrift:hover .viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschrift:hover .viewer__unterschriftweg,
|
||||||
|
.viewer__unterschrift.is-fassend .viewer__unterschriftgriff {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf einem Tastbildschirm gibt es kein "hover" -- dort stehen die Knoepfe
|
||||||
|
immer, sonst kaeme man nie an sie heran. */
|
||||||
|
@media (hover: none) {
|
||||||
|
.viewer__unterschriftgriff,
|
||||||
|
.viewer__unterschriftweg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Ergebnis der Signaturpruefung ---------------------------------------- */
|
||||||
|
/* Der Schleier liegt ueber der Seite und nimmt keine Klicks an: ein
|
||||||
|
unterschriebenes Dokument ist flach, aber bei einem Irrtum soll die
|
||||||
|
Kennzeichnung nicht auch noch die Bedienung blockieren. */
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after,
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--unveraendert::after {
|
||||||
|
background: rgba(47, 158, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert {
|
||||||
|
box-shadow: inset 0 0 0 3px var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pdf--veraendert::after {
|
||||||
|
background: rgba(201, 42, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung {
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--gut {
|
||||||
|
color: var(--success);
|
||||||
|
background: rgba(47, 158, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer__pruefung--schlecht {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(201, 42, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Tabelle, die schmal zu Bloecken wird -------------------------------- */
|
||||||
|
/* Eine Protokollzeile besteht aus sieben Angaben, von denen eine lang ist:
|
||||||
|
das Detail. Auf einem breiten Schirm passt das nebeneinander. Wird es eng,
|
||||||
|
ist eine Spalte von zwei Zentimetern fuer eine Pruefsumme keine Loesung --
|
||||||
|
dann steht jede Angabe auf ihrer eigenen Zeile, beschriftet. */
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
/* Zeilenumbrueche aus dem Eintrag bleiben stehen: die Pruefsumme steht
|
||||||
|
unter dem lesbaren Teil, nicht mitten darin. Und ein Hash ohne Trennstelle
|
||||||
|
darf notfalls hart umbrechen. */
|
||||||
|
white-space: pre-line;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 52ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.table--stapelbar thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar,
|
||||||
|
.table--stapelbar tbody,
|
||||||
|
.table--stapelbar tr,
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar tr {
|
||||||
|
padding: var(--sp-3) 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 8.5rem 1fr;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: baseline;
|
||||||
|
border: 0;
|
||||||
|
padding: 2px var(--sp-3);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar td::before {
|
||||||
|
content: attr(data-spalte);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--fs-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table--stapelbar .td--detail {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leere Zellen brauchen keine Zeile. */
|
||||||
|
.table--stapelbar td:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -- Dateiliste: Symbol und Name in einer Zeile -------------------------- */
|
||||||
|
/* Das Symbol ist ein Block-SVG; ohne diese Regel stand der Name darunter und
|
||||||
|
eine Ordnerzeile sah doppelt so hoch aus wie eine Dateizeile. */
|
||||||
|
|
||||||
|
.file-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-cell > svg {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
@@ -109,7 +109,7 @@ export function showToast(message, type = "info", { timeout = 6000 } = {}) {
|
|||||||
const close = document.createElement("button");
|
const close = document.createElement("button");
|
||||||
close.type = "button";
|
close.type = "button";
|
||||||
close.className = "toast__close";
|
close.className = "toast__close";
|
||||||
close.setAttribute("aria-label", "Meldung schliessen");
|
close.setAttribute("aria-label", "Meldung schließen");
|
||||||
close.textContent = "×";
|
close.textContent = "×";
|
||||||
toast.append(body, close);
|
toast.append(body, close);
|
||||||
toastStack.appendChild(toast);
|
toastStack.appendChild(toast);
|
||||||
@@ -293,7 +293,7 @@ function initConfirmations() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
pending = trigger;
|
pending = trigger;
|
||||||
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
|
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestätigen";
|
||||||
textNode.textContent = trigger.dataset.confirm;
|
textNode.textContent = trigger.dataset.confirm;
|
||||||
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
|
||||||
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
|
||||||
@@ -440,6 +440,56 @@ function initPermissionMatrix() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* -- Ganze Zeile, ganze Spalte, Filter -----------------------------
|
||||||
|
Bei 37 Zeilen und bis zu sieben Spalten je Bereich sind das ueber
|
||||||
|
zweihundert Kaestchen. Ohne diese drei Hilfen setzt man oben den
|
||||||
|
Bereichsschalter und laesst es dabei -- und die feine Aufteilung
|
||||||
|
haette nichts gebracht. */
|
||||||
|
// Nur was man sieht: eine vom Filter ausgeblendete Zeile darf ein
|
||||||
|
// Sammelknopf nicht mitnehmen. Sonst setzt man "alle Write" waehrend nach
|
||||||
|
// "Neustart" gefiltert ist und aendert dabei Zeilen, die gar nicht auf dem
|
||||||
|
// Schirm stehen -- und merkt es erst nach dem Speichern.
|
||||||
|
const sichtbar = (box) => !box.disabled && !box.closest("tr").hidden;
|
||||||
|
const felder = (bereich) =>
|
||||||
|
$$('input[type="checkbox"][name][data-action]', bereich).filter(sichtbar);
|
||||||
|
|
||||||
|
table.addEventListener("click", (event) => {
|
||||||
|
const ziel = event.target;
|
||||||
|
if (!(ziel instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
const zeilenknopf = ziel.closest("[data-matrix-row]");
|
||||||
|
if (zeilenknopf) {
|
||||||
|
event.preventDefault();
|
||||||
|
const row = zeilenknopf.closest("tr");
|
||||||
|
if (!row) return;
|
||||||
|
const kaestchen = felder(row);
|
||||||
|
// Erst wenn alles gesetzt ist, nimmt der Knopf weg -- sonst waere
|
||||||
|
// ein zweiter Klick auf eine halb gesetzte Zeile ein Ruecksprung.
|
||||||
|
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||||
|
kaestchen.forEach((box) => {
|
||||||
|
box.checked = !alleGesetzt;
|
||||||
|
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const spaltenknopf = ziel.closest("[data-matrix-column]");
|
||||||
|
if (spaltenknopf) {
|
||||||
|
event.preventDefault();
|
||||||
|
const aktion = spaltenknopf.dataset.matrixColumn;
|
||||||
|
const bereich = spaltenknopf.dataset.area;
|
||||||
|
const kaestchen = $$(
|
||||||
|
`tr[data-area="${bereich}"] input[data-action="${aktion}"][name]`,
|
||||||
|
table
|
||||||
|
).filter(sichtbar);
|
||||||
|
const alleGesetzt = kaestchen.length > 0 && kaestchen.every((box) => box.checked);
|
||||||
|
kaestchen.forEach((box) => {
|
||||||
|
box.checked = !alleGesetzt;
|
||||||
|
box.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$$("[data-area-switch]", table).forEach((master) => {
|
$$("[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();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,14 +575,14 @@ function initAutoRefresh() {
|
|||||||
nameField.value = entry.name || "";
|
nameField.value = entry.name || "";
|
||||||
dnField.value = entry.dn || "";
|
dnField.value = entry.dn || "";
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
show(`Uebernommen: ${entry.name || entry.dn}`);
|
show(`Übernommen: ${entry.name || entry.dn}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const search = async () => {
|
const search = async () => {
|
||||||
const term = query.value.trim();
|
const term = query.value.trim();
|
||||||
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
show("Suche laeuft ...");
|
show("Suche läuft ...");
|
||||||
results.innerHTML = "";
|
results.innerHTML = "";
|
||||||
try {
|
try {
|
||||||
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
|
||||||
@@ -532,10 +610,10 @@ function initAutoRefresh() {
|
|||||||
results.appendChild(row);
|
results.appendChild(row);
|
||||||
});
|
});
|
||||||
results.hidden = false;
|
results.hidden = false;
|
||||||
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
|
show(`${entries.length} Treffer – zum Übernehmen anklicken.`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.hidden = true;
|
results.hidden = true;
|
||||||
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
|
show("Die Suche ist fehlgeschlagen. Läuft die Verbindung zum Verzeichnis?");
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -720,7 +798,7 @@ function initClipboard() {
|
|||||||
timeout: 2500,
|
timeout: 2500,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
|
showToast("Kopieren nicht möglich – bitte manuell markieren.", "warning");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -856,6 +934,81 @@ function initTileFilters() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -- Reihenfolge aendern -------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Liste, deren Reihenfolge man aendern kann -- ohne Bibliothek.
|
||||||
|
*
|
||||||
|
* Die Reihenfolge steckt in der Reihenfolge der versteckten Felder: das
|
||||||
|
* Formular schickt sie als wiederholtes Feld, und der Server liest sie als
|
||||||
|
* Liste. Verschiebt man einen Eintrag, verschiebt sich das Feld mit ihm.
|
||||||
|
*
|
||||||
|
* Zwei Wege, weil es zwei Arten von Geraeten gibt: Ziehen mit der Maus und
|
||||||
|
* zwei Knoepfe je Zeile. Die Knoepfe sind nicht die Notloesung -- auf einem
|
||||||
|
* Tablet sind sie der einzige Weg, der zuverlaessig funktioniert, und mit der
|
||||||
|
* Tastatur ebenso.
|
||||||
|
*/
|
||||||
|
function initReorder() {
|
||||||
|
$$('[data-behavior="reorder"]').forEach((liste) => {
|
||||||
|
// Nur die eigenen Zeilen: Gruppen enthalten wiederum eine sortierbare
|
||||||
|
// Liste, und deren Zeilen gehoeren der inneren Liste, nicht dieser.
|
||||||
|
const zeilen = () => Array.from(liste.querySelectorAll(":scope > [data-reorder-item]"));
|
||||||
|
|
||||||
|
const nummerieren = () => {
|
||||||
|
zeilen().forEach((zeile, index) => {
|
||||||
|
const nummer = zeile.querySelector("[data-reorder-position-text]");
|
||||||
|
if (nummer) nummer.textContent = String(index + 1);
|
||||||
|
const hoch = zeile.querySelector('[data-reorder-move="up"]');
|
||||||
|
const runter = zeile.querySelector('[data-reorder-move="down"]');
|
||||||
|
if (hoch) hoch.disabled = index === 0;
|
||||||
|
if (runter) runter.disabled = index === zeilen().length - 1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
liste.addEventListener("click", (event) => {
|
||||||
|
const knopf = event.target.closest("[data-reorder-move]");
|
||||||
|
if (!knopf) return;
|
||||||
|
const zeile = knopf.closest("[data-reorder-item]");
|
||||||
|
if (!zeile || zeile.parentElement !== liste) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (knopf.dataset.reorderMove === "up") {
|
||||||
|
const davor = zeile.previousElementSibling;
|
||||||
|
if (davor) liste.insertBefore(zeile, davor);
|
||||||
|
} else {
|
||||||
|
const danach = zeile.nextElementSibling;
|
||||||
|
if (danach) liste.insertBefore(danach, zeile);
|
||||||
|
}
|
||||||
|
nummerieren();
|
||||||
|
zeile.querySelector(`[data-reorder-move="${knopf.dataset.reorderMove}"]`)?.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
let gezogen = null;
|
||||||
|
liste.addEventListener("dragstart", (event) => {
|
||||||
|
const zeile = event.target.closest("[data-reorder-item]");
|
||||||
|
if (!zeile || zeile.parentElement !== liste) return;
|
||||||
|
gezogen = zeile;
|
||||||
|
gezogen.classList.add("is-dragging");
|
||||||
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
||||||
|
});
|
||||||
|
liste.addEventListener("dragend", () => {
|
||||||
|
if (gezogen) gezogen.classList.remove("is-dragging");
|
||||||
|
gezogen = null;
|
||||||
|
nummerieren();
|
||||||
|
});
|
||||||
|
liste.addEventListener("dragover", (event) => {
|
||||||
|
if (!gezogen) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const ziel = event.target.closest("[data-reorder-item]");
|
||||||
|
if (!ziel || ziel === gezogen || ziel.parentElement !== liste) return;
|
||||||
|
const kasten = ziel.getBoundingClientRect();
|
||||||
|
const obereHaelfte = event.clientY < kasten.top + kasten.height / 2;
|
||||||
|
liste.insertBefore(gezogen, obereHaelfte ? ziel : ziel.nextElementSibling);
|
||||||
|
});
|
||||||
|
|
||||||
|
nummerieren();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* -- Start ---------------------------------------------------------------- */
|
/* -- Start ---------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -893,6 +1046,7 @@ function boot() {
|
|||||||
initPalette();
|
initPalette();
|
||||||
initSubmitGuards();
|
initSubmitGuards();
|
||||||
initTileFilters();
|
initTileFilters();
|
||||||
|
initReorder();
|
||||||
initLogTails();
|
initLogTails();
|
||||||
unsavedGuard.init();
|
unsavedGuard.init();
|
||||||
initViewer();
|
initViewer();
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ async function fotoAlsJpeg(datei) {
|
|||||||
const daten = await new Promise((fertig, schiefgegangen) => {
|
const daten = await new Promise((fertig, schiefgegangen) => {
|
||||||
const leser = new FileReader();
|
const leser = new FileReader();
|
||||||
leser.onload = () => fertig(String(leser.result));
|
leser.onload = () => fertig(String(leser.result));
|
||||||
leser.onerror = () => schiefgegangen(new Error("Die Datei liess sich nicht lesen."));
|
leser.onerror = () => schiefgegangen(new Error("Die Datei ließ sich nicht lesen."));
|
||||||
leser.readAsDataURL(datei);
|
leser.readAsDataURL(datei);
|
||||||
});
|
});
|
||||||
bild = await new Promise((fertig, schiefgegangen) => {
|
bild = await new Promise((fertig, schiefgegangen) => {
|
||||||
@@ -207,7 +207,7 @@ async function pdflibLaden(adresse) {
|
|||||||
skript.onload = () => fertig(window.PDFLib);
|
skript.onload = () => fertig(window.PDFLib);
|
||||||
skript.onerror = () => {
|
skript.onerror = () => {
|
||||||
pdflibGeladen = null;
|
pdflibGeladen = null;
|
||||||
schiefgegangen(new Error("pdf-lib liess sich nicht laden."));
|
schiefgegangen(new Error("pdf-lib ließ sich nicht laden."));
|
||||||
};
|
};
|
||||||
document.head.append(skript);
|
document.head.append(skript);
|
||||||
});
|
});
|
||||||
@@ -215,47 +215,304 @@ async function pdflibLaden(adresse) {
|
|||||||
return pdflibGeladen;
|
return pdflibGeladen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stempelt die gewaehlten Fotos in die Bildfelder.
|
/** Setzt die Fotos ein und schliesst das Formular auf Wunsch ab.
|
||||||
*
|
*
|
||||||
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
|
* Eingabe sind die Bytes, die pdf.js nach dem Ausfuellen geschrieben hat --
|
||||||
* die Formularwerte bleiben also erhalten.
|
* die Feldwerte bleiben also erhalten.
|
||||||
|
*
|
||||||
|
* Das Foto wird das **Aussehen des Bildknopfes** und nicht in die Seite
|
||||||
|
* gestempelt: so bleibt der Knopf erhalten, das Bild ist trotzdem zu sehen,
|
||||||
|
* und beim naechsten Oeffnen laesst es sich austauschen. Genau das tut
|
||||||
|
* Acrobat mit "buttonImportIcon".
|
||||||
|
*
|
||||||
|
* ``abschliessen`` rechnet danach alle Felder flach in die Seite: heraus kommt
|
||||||
|
* ein PDF ohne Formular -- nichts mehr zu verstellen.
|
||||||
*/
|
*/
|
||||||
async function fotosEinsetzen(bytes, bilder, adresse) {
|
async function nachbearbeiten(
|
||||||
|
bytes,
|
||||||
|
bilder,
|
||||||
|
adresse,
|
||||||
|
{ abschliessen = false, unterschriften = [], zeuge = "" } = {}
|
||||||
|
) {
|
||||||
const PDFLib = await pdflibLaden(adresse);
|
const PDFLib = await pdflibLaden(adresse);
|
||||||
const dokument = await PDFLib.PDFDocument.load(bytes);
|
const dokument = await PDFLib.PDFDocument.load(bytes);
|
||||||
const formular = dokument.getForm();
|
const formular = dokument.getForm();
|
||||||
|
|
||||||
for (const eintrag of bilder) {
|
for (const eintrag of bilder) {
|
||||||
const seite = dokument.getPage(eintrag.seite - 1);
|
|
||||||
const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
|
const jpeg = await dokument.embedJpg(eintrag.foto.bytes);
|
||||||
|
const knopf = eintrag.feldname ? formular.getFieldMaybe(eintrag.feldname) : null;
|
||||||
|
if (knopf && typeof knopf.setImage === "function") {
|
||||||
|
knopf.setImage(jpeg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Kein Knopf gefunden: dann eben in die Seite. Damit nichts darueber
|
||||||
|
// liegt, muss das Feld weichen.
|
||||||
|
const seite = dokument.getPage(eintrag.seite - 1);
|
||||||
const [x1, y1, x2, y2] = eintrag.rechteck;
|
const [x1, y1, x2, y2] = eintrag.rechteck;
|
||||||
const kastenBreite = Math.abs(x2 - x1);
|
const kastenBreite = Math.abs(x2 - x1);
|
||||||
const kastenHoehe = Math.abs(y2 - y1);
|
const kastenHoehe = Math.abs(y2 - y1);
|
||||||
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
|
// Einpassen statt verzerren: das Seitenverhaeltnis des Fotos bleibt.
|
||||||
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
|
const faktor = Math.min(kastenBreite / jpeg.width, kastenHoehe / jpeg.height);
|
||||||
const breite = jpeg.width * faktor;
|
|
||||||
const hoehe = jpeg.height * faktor;
|
|
||||||
seite.drawImage(jpeg, {
|
seite.drawImage(jpeg, {
|
||||||
x: Math.min(x1, x2) + (kastenBreite - breite) / 2,
|
x: Math.min(x1, x2) + (kastenBreite - jpeg.width * faktor) / 2,
|
||||||
y: Math.min(y1, y2) + (kastenHoehe - hoehe) / 2,
|
y: Math.min(y1, y2) + (kastenHoehe - jpeg.height * faktor) / 2,
|
||||||
width: breite,
|
width: jpeg.width * faktor,
|
||||||
height: hoehe,
|
height: jpeg.height * faktor,
|
||||||
});
|
});
|
||||||
// Den Knopf aus der fertigen Kopie nehmen: seine Flaeche liegt ueber dem
|
if (knopf) {
|
||||||
// Seiteninhalt und wuerde das Foto verdecken. Zu tun haette er dort auch
|
|
||||||
// nichts mehr -- seine Aktion fuehrt kein Browser aus, und das Bild steht
|
|
||||||
// jetzt in der Seite. Die Vorlage bleibt unberuehrt.
|
|
||||||
try {
|
try {
|
||||||
const feld = formular.getFieldMaybe(eintrag.feldname);
|
formular.removeField(knopf);
|
||||||
if (feld) formular.removeField(feld);
|
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
// Bleibt der Knopf liegen, ist das Foto verdeckt -- aber die Datei ist
|
console.warn("Bildknopf ließ sich nicht entfernen", fehler);
|
||||||
// heil. Das ist der bessere von zwei schlechten Ausgaengen.
|
|
||||||
console.warn("Bildknopf liess sich nicht entfernen", fehler);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unterschriften: Bild und darunter der angegebene Name mit Zeitpunkt. Das
|
||||||
|
// Bild allein waere eine Zeichnung ohne Aussage -- auf Papier steht neben
|
||||||
|
// der Unterschrift auch der Name in Druckbuchstaben.
|
||||||
|
if (unterschriften.length) {
|
||||||
|
const schrift = await dokument.embedFont(PDFLib.StandardFonts.Helvetica);
|
||||||
|
for (const eintrag of unterschriften) {
|
||||||
|
const seite = dokument.getPage(eintrag.seite - 1);
|
||||||
|
const png = await dokument.embedPng(eintrag.bytes);
|
||||||
|
const breite = eintrag.breite;
|
||||||
|
const hoehe = (png.height / png.width) * breite;
|
||||||
|
seite.drawImage(png, { x: eintrag.x, y: eintrag.y, width: breite, height: hoehe });
|
||||||
|
const zeilen = [`${eintrag.name} | ${eintrag.zeit}`];
|
||||||
|
// Der Zeugenvermerk gehoert zum Dokument, nicht zu jeder Unterschrift:
|
||||||
|
// unter drei Unterschriften stand dreimal derselbe Satz. Sichtbar
|
||||||
|
// erscheint er deshalb nur unter der ersten -- fest steht er ohnehin in
|
||||||
|
// den Dokumenteigenschaften und im Aenderungsprotokoll.
|
||||||
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
|
zeilen.push(`In TESM abgelegt durch ${zeuge}`);
|
||||||
|
}
|
||||||
|
// Die Schrift waechst mit der Unterschrift: wer sie kleiner zieht, will
|
||||||
|
// den ganzen Block kleiner haben, nicht eine Zeichnung mit einer
|
||||||
|
// Bildunterschrift in Originalgroesse daneben.
|
||||||
|
const groesse = schriftgroesse(breite);
|
||||||
|
// Der Strich ist so lang wie die laengste Zeile darunter -- nicht so
|
||||||
|
// breit wie die Zeichnung. Eine kurze Unterschrift ueber einem langen
|
||||||
|
// Namen sah sonst aus wie zwei Dinge, die nicht zusammengehoeren.
|
||||||
|
const textbreite = Math.max(
|
||||||
|
...zeilen.map((zeile) => schrift.widthOfTextAtSize(zeile, groesse))
|
||||||
|
);
|
||||||
|
let unten = eintrag.y - UNTERSCHRIFT.textY;
|
||||||
|
for (const zeile of zeilen) {
|
||||||
|
unten -= groesse * UNTERSCHRIFT.zeile;
|
||||||
|
seite.drawText(zeile, {
|
||||||
|
x: eintrag.x,
|
||||||
|
y: unten,
|
||||||
|
size: groesse,
|
||||||
|
font: schrift,
|
||||||
|
color: PDFLib.rgb(0.1, 0.1, 0.1),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Kraeftig genug, um neben den Linien eines Formulars als eigener Strich
|
||||||
|
// erkennbar zu bleiben: ein halber Punkt in hellem Grau ging zwischen
|
||||||
|
// den Tabellenlinien schlicht unter.
|
||||||
|
seite.drawLine({
|
||||||
|
start: { x: eintrag.x, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
|
end: { x: eintrag.x + textbreite, y: eintrag.y - UNTERSCHRIFT.strichY },
|
||||||
|
thickness: UNTERSCHRIFT.strich,
|
||||||
|
color: PDFLib.rgb(0.15, 0.15, 0.15),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unterschriften.length) {
|
||||||
|
// In den Dokumenteigenschaften -- dort sucht man so etwas, und dort steht
|
||||||
|
// es einmal statt unter jeder Unterschrift. Jeder Betrachter zeigt das
|
||||||
|
// an, auch ausserhalb von TESM.
|
||||||
|
const namen = unterschriften.map((eintrag) => eintrag.name);
|
||||||
|
const wann = unterschriften[unterschriften.length - 1].zeit;
|
||||||
|
dokument.setAuthor(namen.join(", "));
|
||||||
|
dokument.setSubject(
|
||||||
|
`Unterschrieben am ${wann}` + (zeuge ? ` | in TESM abgelegt durch ${zeuge}` : "")
|
||||||
|
);
|
||||||
|
dokument.setProducer("TESM");
|
||||||
|
dokument.setModificationDate(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abschliessen) {
|
||||||
|
// Alle Felder werden Teil der Seite. Danach gibt es kein Formular mehr --
|
||||||
|
// gewollt: so kann niemand mehr etwas verstellen.
|
||||||
|
formular.flatten();
|
||||||
|
}
|
||||||
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.
|
||||||
|
*
|
||||||
|
* Ein Achtzehntel: bei den 150 Punkt der vollen Breite sind das gut acht
|
||||||
|
* Punkt -- die uebliche Groesse einer Unterschriftszeile auf Papier. Dieselbe
|
||||||
|
* Rechnung benutzt die Vorschau -- in Pixeln umgerechnet --, damit auf dem
|
||||||
|
* Schirm steht, was spaeter im PDF steht.
|
||||||
|
*/
|
||||||
|
function schriftgroesse(breite) {
|
||||||
|
return Math.max(5, Math.min(10, breite / 18));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die gezeichnete Unterschrift als PNG -- auf das Gezeichnete beschnitten.
|
||||||
|
*
|
||||||
|
* Ungeschnitten waere jede Unterschrift so gross wie die Zeichenflaeche, mit
|
||||||
|
* viel Luft ringsum. Im Dokument sieht das aus wie ein verrutschter Stempel.
|
||||||
|
*/
|
||||||
|
function unterschriftAlsPng(leinwand, kasten) {
|
||||||
|
const rand = 6;
|
||||||
|
const links = Math.max(0, Math.floor(kasten.x1 - rand));
|
||||||
|
const oben = Math.max(0, Math.floor(kasten.y1 - rand));
|
||||||
|
const breite = Math.min(leinwand.width - links, Math.ceil(kasten.x2 - kasten.x1 + rand * 2));
|
||||||
|
const hoehe = Math.min(leinwand.height - oben, Math.ceil(kasten.y2 - kasten.y1 + rand * 2));
|
||||||
|
const ziel = document.createElement("canvas");
|
||||||
|
ziel.width = Math.max(1, breite);
|
||||||
|
ziel.height = Math.max(1, hoehe);
|
||||||
|
ziel
|
||||||
|
.getContext("2d")
|
||||||
|
.drawImage(leinwand, links, oben, ziel.width, ziel.height, 0, 0, ziel.width, ziel.height);
|
||||||
|
// Ueber eine data:-Adresse und nicht ueber blob: -- die Sicherheitsregeln
|
||||||
|
// der Anwendung erlauben blob: nicht, und ein stilles "img-src" waere ein
|
||||||
|
// Loch, das man fuer eine Unterschrift nicht aufmacht.
|
||||||
|
const adresse = ziel.toDataURL("image/png");
|
||||||
|
const roh = atob(adresse.slice(adresse.indexOf(",") + 1));
|
||||||
|
const bytes = new Uint8Array(roh.length);
|
||||||
|
for (let i = 0; i < roh.length; i++) bytes[i] = roh.charCodeAt(i);
|
||||||
|
return { bytes, vorschau: adresse, breite: ziel.width, hoehe: ziel.height };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zeichenflaeche fuer die Unterschrift -- Finger, Stift oder Maus.
|
||||||
|
*
|
||||||
|
* ``touch-action: none`` steht im Stylesheet: ohne das scrollt ein iPad die
|
||||||
|
* Seite, statt einen Strich zu zeichnen.
|
||||||
|
*/
|
||||||
|
function unterschriftPad(leinwand) {
|
||||||
|
const stift = leinwand.getContext("2d");
|
||||||
|
let kasten = null;
|
||||||
|
let zeichnet = false;
|
||||||
|
|
||||||
|
const punkt = (event) => {
|
||||||
|
const rechteck = leinwand.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: ((event.clientX - rechteck.left) / rechteck.width) * leinwand.width,
|
||||||
|
y: ((event.clientY - rechteck.top) / rechteck.height) * leinwand.height,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const merken = ({ x, y }) => {
|
||||||
|
kasten = kasten
|
||||||
|
? {
|
||||||
|
x1: Math.min(kasten.x1, x),
|
||||||
|
y1: Math.min(kasten.y1, y),
|
||||||
|
x2: Math.max(kasten.x2, x),
|
||||||
|
y2: Math.max(kasten.y2, y),
|
||||||
|
}
|
||||||
|
: { x1: x, y1: y, x2: x, y2: y };
|
||||||
|
};
|
||||||
|
|
||||||
|
stift.lineWidth = 2.5;
|
||||||
|
stift.lineCap = "round";
|
||||||
|
stift.lineJoin = "round";
|
||||||
|
stift.strokeStyle = "#111";
|
||||||
|
|
||||||
|
leinwand.addEventListener("pointerdown", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
leinwand.setPointerCapture(event.pointerId);
|
||||||
|
zeichnet = true;
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.beginPath();
|
||||||
|
stift.moveTo(p.x, p.y);
|
||||||
|
});
|
||||||
|
leinwand.addEventListener("pointermove", (event) => {
|
||||||
|
if (!zeichnet) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const p = punkt(event);
|
||||||
|
merken(p);
|
||||||
|
stift.lineTo(p.x, p.y);
|
||||||
|
stift.stroke();
|
||||||
|
});
|
||||||
|
const beenden = () => {
|
||||||
|
zeichnet = false;
|
||||||
|
};
|
||||||
|
leinwand.addEventListener("pointerup", beenden);
|
||||||
|
leinwand.addEventListener("pointercancel", beenden);
|
||||||
|
leinwand.addEventListener("pointerleave", beenden);
|
||||||
|
|
||||||
|
return {
|
||||||
|
leeren: () => {
|
||||||
|
stift.clearRect(0, 0, leinwand.width, leinwand.height);
|
||||||
|
kasten = null;
|
||||||
|
},
|
||||||
|
leer: () => kasten === null,
|
||||||
|
alsPng: () => (kasten ? unterschriftAlsPng(leinwand, kasten) : null),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/* -- PDF ------------------------------------------------------------------- */
|
/* -- PDF ------------------------------------------------------------------- */
|
||||||
|
|
||||||
let pdfjsGeladen = null;
|
let pdfjsGeladen = null;
|
||||||
@@ -304,8 +561,13 @@ async function pdfjsLaden(basis) {
|
|||||||
return pdfjsGeladen;
|
return pdfjsGeladen;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zeigt ein PDF mit ausfuellbaren Formularfeldern. */
|
/** Zeigt ein PDF -- mit ausfuellbaren Feldern, sofern das erlaubt ist.
|
||||||
async function pdfBetrachter(behaelter, seiten, url, basis) {
|
*
|
||||||
|
* Ohne ``formulare`` bleibt es bei der Darstellung aus dem Dokument: pdf.js
|
||||||
|
* malt dann die Marken, die im PDF stehen (etwa rotes Kreuz und gruener
|
||||||
|
* Haken), statt Bedienelemente des Browsers einzusetzen.
|
||||||
|
*/
|
||||||
|
async function pdfBetrachter(behaelter, seiten, url, basis, { formulare = true } = {}) {
|
||||||
const { lib, komponenten } = await pdfjsLaden(basis);
|
const { lib, komponenten } = await pdfjsLaden(basis);
|
||||||
const adressen = pdfjsAdressen(basis);
|
const adressen = pdfjsAdressen(basis);
|
||||||
const eventBus = new komponenten.EventBus();
|
const eventBus = new komponenten.EventBus();
|
||||||
@@ -315,8 +577,11 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
viewer: seiten,
|
viewer: seiten,
|
||||||
eventBus,
|
eventBus,
|
||||||
linkService,
|
linkService,
|
||||||
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern.
|
// Der Kern der Sache: Formularfelder werden zu echten Eingabefeldern --
|
||||||
annotationMode: lib.AnnotationMode.ENABLE_FORMS,
|
// wer nicht ausfuellen darf, bekommt die Darstellung aus dem Dokument.
|
||||||
|
annotationMode: formulare
|
||||||
|
? lib.AnnotationMode.ENABLE_FORMS
|
||||||
|
: lib.AnnotationMode.ENABLE,
|
||||||
textLayerMode: 1,
|
textLayerMode: 1,
|
||||||
});
|
});
|
||||||
linkService.setViewer(viewer);
|
linkService.setViewer(viewer);
|
||||||
@@ -364,6 +629,7 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
const bilder = new Map();
|
const bilder = new Map();
|
||||||
|
|
||||||
const bildknoepfeAufsetzen = async (nurSeite) => {
|
const bildknoepfeAufsetzen = async (nurSeite) => {
|
||||||
|
if (!formulare) return;
|
||||||
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
|
for (let nummer = 1; nummer <= dokument.numPages; nummer++) {
|
||||||
if (nurSeite && nummer !== nurSeite) continue;
|
if (nurSeite && nummer !== nurSeite) continue;
|
||||||
const seite = await dokument.getPage(nummer);
|
const seite = await dokument.getPage(nummer);
|
||||||
@@ -424,6 +690,206 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
});
|
});
|
||||||
await bildknoepfeAufsetzen();
|
await bildknoepfeAufsetzen();
|
||||||
|
|
||||||
|
/* Unterschriften: gezeichnet wird ausserhalb, gesetzt wird hier. Die Stelle
|
||||||
|
bestimmt ein Tipp ins Dokument -- ein festes Feld gibt es in den wenigsten
|
||||||
|
Vorlagen, und "unten rechts" ist bei einem Formular mit drei
|
||||||
|
Unterschriftszeilen die falsche Annahme. */
|
||||||
|
// Ein menschliches Mass: rund fuenf Zentimeter breit, hoechstens knapp zwei
|
||||||
|
// hoch. Ohne die Hoehengrenze wird aus einem hohen Gekritzel ein Stempel,
|
||||||
|
// der die halbe Seite einnimmt -- das Seitenverhaeltnis kommt aus dem
|
||||||
|
// Zuschnitt der Zeichnung, nicht aus einer Vorlage. Nach dem Setzen laesst
|
||||||
|
// sich beides mit dem Griff nachstellen, nach oben bis zu dieser Grenze.
|
||||||
|
const MAX_BREITE = 150;
|
||||||
|
const MAX_HOEHE = 50;
|
||||||
|
const unterschriften = [];
|
||||||
|
//: Je gesetzte Unterschrift eine Funktion, die ihre Marke neu vermisst.
|
||||||
|
const neuzeichnen = new Set();
|
||||||
|
let wartendeUnterschrift = null;
|
||||||
|
let zeugenzeile = () => "";
|
||||||
|
|
||||||
|
const platzieren = async (event) => {
|
||||||
|
if (!wartendeUnterschrift) return;
|
||||||
|
const seitenEl = event.target.closest(".page");
|
||||||
|
if (!seitenEl) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const nummer = Number(seitenEl.dataset.pageNumber || 0);
|
||||||
|
if (!nummer) return;
|
||||||
|
const rechteck = seitenEl.getBoundingClientRect();
|
||||||
|
const anteilX = (event.clientX - rechteck.left) / rechteck.width;
|
||||||
|
const anteilY = (event.clientY - rechteck.top) / rechteck.height;
|
||||||
|
const seite = await dokument.getPage(nummer);
|
||||||
|
const blick = seite.getViewport({ scale: 1 });
|
||||||
|
let breite = Math.min(MAX_BREITE, blick.width * 0.3);
|
||||||
|
let hoehe = (wartendeUnterschrift.hoehe / wartendeUnterschrift.breite) * breite;
|
||||||
|
if (hoehe > MAX_HOEHE) {
|
||||||
|
breite *= MAX_HOEHE / hoehe;
|
||||||
|
hoehe = MAX_HOEHE;
|
||||||
|
}
|
||||||
|
const eintrag = {
|
||||||
|
...wartendeUnterschrift,
|
||||||
|
seite: nummer,
|
||||||
|
breite,
|
||||||
|
// Der Tipp meint die Mitte der Unterschrift, nicht ihre Ecke.
|
||||||
|
x: Math.max(4, anteilX * blick.width - breite / 2),
|
||||||
|
y: Math.max(24, (1 - anteilY) * blick.height - hoehe / 2),
|
||||||
|
};
|
||||||
|
unterschriften.push(eintrag);
|
||||||
|
behaelter.classList.remove("viewer__pdfscroll--setzen");
|
||||||
|
wartendeUnterschrift = null;
|
||||||
|
|
||||||
|
// Sichtbar machen, wo sie gelandet ist -- und beweglich: der erste Tipp
|
||||||
|
// trifft selten genau die Linie, und der Textblock gehoert mit auf die
|
||||||
|
// richtige Stelle.
|
||||||
|
marke_setzen(seitenEl, eintrag, blick, zeugenzeile());
|
||||||
|
behaelter.dispatchEvent(new CustomEvent("unterschrieben", { detail: eintrag }));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Baut die bewegliche Marke einer gesetzten Unterschrift. */
|
||||||
|
const marke_setzen = (seitenEl, eintrag, blick, zeuge) => {
|
||||||
|
const marke = document.createElement("div");
|
||||||
|
marke.className = "viewer__unterschrift";
|
||||||
|
const bild = document.createElement("img");
|
||||||
|
bild.src = eintrag.vorschau;
|
||||||
|
bild.alt = "";
|
||||||
|
// Genau der Aufbau der spaeteren Datei: eine Zeile mit Namen und
|
||||||
|
// Zeitpunkt, darunter -- als eigene Zeile -- der Zeugenvermerk. In der
|
||||||
|
// Vorschau standen beide hintereinander, getrennt durch einen
|
||||||
|
// Schraegstrich; beim Positionieren sah der Block dadurch breiter und
|
||||||
|
// flacher aus als das Ergebnis.
|
||||||
|
const text = document.createElement("div");
|
||||||
|
text.className = "viewer__unterschrifttext";
|
||||||
|
const namenszeile = document.createElement("div");
|
||||||
|
namenszeile.textContent = `${eintrag.name} | ${eintrag.zeit}`;
|
||||||
|
text.append(namenszeile);
|
||||||
|
if (zeuge && eintrag === unterschriften[0]) {
|
||||||
|
const zeugenzeile = document.createElement("div");
|
||||||
|
zeugenzeile.textContent = `In TESM abgelegt durch ${zeuge}`;
|
||||||
|
text.append(zeugenzeile);
|
||||||
|
}
|
||||||
|
const griff = document.createElement("button");
|
||||||
|
griff.type = "button";
|
||||||
|
griff.className = "viewer__unterschriftgriff";
|
||||||
|
griff.title = "Größe ändern";
|
||||||
|
griff.setAttribute("aria-label", "Unterschrift vergroessern oder verkleinern");
|
||||||
|
const weg = document.createElement("button");
|
||||||
|
weg.type = "button";
|
||||||
|
weg.className = "viewer__unterschriftweg";
|
||||||
|
weg.textContent = "x";
|
||||||
|
weg.title = "Unterschrift entfernen";
|
||||||
|
weg.setAttribute("aria-label", "Unterschrift entfernen");
|
||||||
|
marke.append(bild, text, griff, weg);
|
||||||
|
seitenEl.append(marke);
|
||||||
|
|
||||||
|
const zeichnen = () => {
|
||||||
|
// Die Marke ist genau die Zeichnung: ihre Unterkante liegt auf dem
|
||||||
|
// Ankerpunkt, so wie im PDF. Der Text haengt darunter (siehe
|
||||||
|
// Gestaltung: absolut, "top: 100%") und zaehlt nicht zur Hoehe --
|
||||||
|
// sonst saesse der Block in der Vorschau um die Texthoehe zu hoch.
|
||||||
|
marke.style.left = `${(eintrag.x / blick.width) * 100}%`;
|
||||||
|
marke.style.bottom = `${(eintrag.y / blick.height) * 100}%`;
|
||||||
|
marke.style.width = `${(eintrag.breite / blick.width) * 100}%`;
|
||||||
|
// Alles Weitere wird gerechnet, nicht vom Stylesheet abgeleitet:
|
||||||
|
// Punkte mal Pixel-je-Punkt der gerade dargestellten Seite. Ueber
|
||||||
|
// Container-Einheiten ("cqw") ging das auch -- aber nur dort, wo der
|
||||||
|
// Browser sie kennt; wo nicht, erbte der Block die Schriftgroesse der
|
||||||
|
// Seite, und der Text war um die Haelfte zu breit.
|
||||||
|
const proPunkt = seitenEl.getBoundingClientRect().width / blick.width;
|
||||||
|
const groesse = schriftgroesse(eintrag.breite);
|
||||||
|
const inPixeln = groesse * proPunkt;
|
||||||
|
const oben = UNTERSCHRIFT.strichY - UNTERSCHRIFT.strich / 2;
|
||||||
|
text.style.fontSize = `${inPixeln}px`;
|
||||||
|
text.style.lineHeight = String(UNTERSCHRIFT.zeile);
|
||||||
|
text.style.marginTop = `${oben * proPunkt}px`;
|
||||||
|
text.style.borderTopWidth = `${UNTERSCHRIFT.strich * proPunkt}px`;
|
||||||
|
// Rahmenbreiten rundet der Browser auf ganze Geraetepixel: aus 1,21
|
||||||
|
// wird 1. Mit dem gerechneten Wert sass die erste Zeile deshalb einen
|
||||||
|
// Fuenftel Punkt zu hoch -- also mit dem tatsaechlich benutzten rechnen.
|
||||||
|
const strich =
|
||||||
|
(parseFloat(getComputedStyle(text).borderTopWidth) ||
|
||||||
|
UNTERSCHRIFT.strich * proPunkt) / proPunkt;
|
||||||
|
// Oberkante des Strichs, die Strichstaerke, dann so viel Luft, dass die
|
||||||
|
// erste Grundlinie dort sitzt, wo pdf-lib sie setzt: textY plus einen
|
||||||
|
// Zeilenabstand unter der Bildkante.
|
||||||
|
const innen =
|
||||||
|
UNTERSCHRIFT.textY +
|
||||||
|
UNTERSCHRIFT.zeile * groesse -
|
||||||
|
oben -
|
||||||
|
strich -
|
||||||
|
grundlinie(inPixeln) * groesse;
|
||||||
|
text.style.paddingTop = `${Math.max(0, innen) * proPunkt}px`;
|
||||||
|
};
|
||||||
|
// Beim Zoomen aendert sich Pixel-je-Punkt -- dann neu rechnen.
|
||||||
|
neuzeichnen.add(zeichnen);
|
||||||
|
zeichnen();
|
||||||
|
|
||||||
|
weg.addEventListener("click", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
const stelle = unterschriften.indexOf(eintrag);
|
||||||
|
if (stelle >= 0) unterschriften.splice(stelle, 1);
|
||||||
|
neuzeichnen.delete(zeichnen);
|
||||||
|
marke.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verschieben und Skalieren teilen sich einen Zeiger: beim Griff aendert
|
||||||
|
// sich die Breite, sonst die Lage.
|
||||||
|
const fassen = (event) => {
|
||||||
|
if (event.target === weg) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const skalieren = event.target === griff;
|
||||||
|
const rechteck = seitenEl.getBoundingClientRect();
|
||||||
|
const proPunkt = rechteck.width / blick.width;
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startY = event.clientY;
|
||||||
|
const startBreite = eintrag.breite;
|
||||||
|
const start = { x: eintrag.x, y: eintrag.y };
|
||||||
|
const obergrenze = Math.min(MAX_BREITE, blick.width * 0.3);
|
||||||
|
marke.setPointerCapture(event.pointerId);
|
||||||
|
marke.classList.add("is-fassend");
|
||||||
|
|
||||||
|
const bewegen = (zug) => {
|
||||||
|
if (skalieren) {
|
||||||
|
const breite = startBreite + (zug.clientX - startX) / proPunkt;
|
||||||
|
eintrag.breite = Math.max(40, Math.min(obergrenze, breite));
|
||||||
|
} else {
|
||||||
|
eintrag.x = Math.max(2, start.x + (zug.clientX - startX) / proPunkt);
|
||||||
|
eintrag.y = Math.max(
|
||||||
|
// Platz fuer die beiden Textzeilen unter der Zeichnung.
|
||||||
|
UNTERSCHRIFT.textY + 2 * UNTERSCHRIFT.zeile * schriftgroesse(eintrag.breite),
|
||||||
|
start.y - (zug.clientY - startY) / proPunkt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
zeichnen();
|
||||||
|
};
|
||||||
|
const loslassen = () => {
|
||||||
|
marke.classList.remove("is-fassend");
|
||||||
|
marke.removeEventListener("pointermove", bewegen);
|
||||||
|
marke.removeEventListener("pointerup", loslassen);
|
||||||
|
marke.removeEventListener("pointercancel", loslassen);
|
||||||
|
};
|
||||||
|
marke.addEventListener("pointermove", bewegen);
|
||||||
|
marke.addEventListener("pointerup", loslassen);
|
||||||
|
marke.addEventListener("pointercancel", loslassen);
|
||||||
|
};
|
||||||
|
marke.addEventListener("pointerdown", fassen);
|
||||||
|
};
|
||||||
|
|
||||||
|
behaelter.addEventListener("click", (event) => {
|
||||||
|
platzieren(event).catch((fehler) => console.error("Unterschrift", fehler));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Nach dem Zoomen stehen die Marken anteilig richtig, aber die Schrift
|
||||||
|
// haengt an der Pixelgroesse der Seite -- die ist jetzt eine andere.
|
||||||
|
eventBus.on("scalechanging", () => {
|
||||||
|
for (const zeichnen of neuzeichnen) {
|
||||||
|
try {
|
||||||
|
zeichnen();
|
||||||
|
} catch (fehler) {
|
||||||
|
console.error("Unterschrift neu vermessen", fehler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hinein: () => {
|
hinein: () => {
|
||||||
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
viewer.currentScale = Math.min(MAX_ZOOM, viewer.currentScale * 1.2);
|
||||||
@@ -439,11 +905,23 @@ async function pdfBetrachter(behaelter, seiten, url, basis) {
|
|||||||
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
* pdf.js schreibt die Feldwerte; gewaehlte Fotos kommen danach mit
|
||||||
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
* pdf-lib hinein -- pdf.js kann nichts in ein PDF zeichnen.
|
||||||
*/
|
*/
|
||||||
speichern: async (pdflibAdresse) => {
|
speichern: async (pdflibAdresse, { abschliessen = false, zeuge = "" } = {}) => {
|
||||||
const bytes = await dokument.saveDocument();
|
const bytes = await dokument.saveDocument();
|
||||||
if (!bilder.size) return bytes;
|
// Ohne Bild, ohne Unterschrift und ohne Abschluss braucht es pdf-lib nicht.
|
||||||
return fotosEinsetzen(bytes, [...bilder.values()], pdflibAdresse);
|
if (!bilder.size && !abschliessen && !unterschriften.length) return bytes;
|
||||||
|
return nachbearbeiten(bytes, [...bilder.values()], pdflibAdresse, {
|
||||||
|
abschliessen,
|
||||||
|
unterschriften,
|
||||||
|
zeuge,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
/** Wartet der Betrachter auf den Tipp, wohin die Unterschrift soll? */
|
||||||
|
unterschriftAnnehmen: (daten, zeuge = "") => {
|
||||||
|
wartendeUnterschrift = daten;
|
||||||
|
zeugenzeile = () => zeuge;
|
||||||
|
behaelter.classList.add("viewer__pdfscroll--setzen");
|
||||||
|
},
|
||||||
|
unterschriften: () => unterschriften.map(({ name, zeit }) => ({ name, zeit })),
|
||||||
/** Wurde ueberhaupt etwas eingetragen? */
|
/** Wurde ueberhaupt etwas eingetragen? */
|
||||||
veraendert: () => {
|
veraendert: () => {
|
||||||
const s = dokument.annotationStorage;
|
const s = dokument.annotationStorage;
|
||||||
@@ -480,9 +958,49 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const ordnerFeld = stueck("ordner");
|
const ordnerFeld = stueck("ordner");
|
||||||
const nameFeld = stueck("name");
|
const nameFeld = stueck("name");
|
||||||
const ersetzenFeld = stueck("ersetzen");
|
const ersetzenFeld = stueck("ersetzen");
|
||||||
|
const ersetzenKasten = stueck("ersetzenfeld");
|
||||||
|
const pruefzeile = stueck("pruefung");
|
||||||
|
// Die Ordner der geoeffneten Freigabe kommen mit der Seite; die einer
|
||||||
|
// anderen holt das Auswahlfeld bei Bedarf nach.
|
||||||
|
const eigeneOrdner = () => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rumpf.dataset.viewerOrdnerliste || "[]");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
const jetzt = stueck("jetzt");
|
const jetzt = stueck("jetzt");
|
||||||
const abbrechen = stueck("abbrechen");
|
const abbrechen = stueck("abbrechen");
|
||||||
|
const modusFeld = stueck("modus");
|
||||||
|
const zielFeld = stueck("ziel");
|
||||||
|
const unterschreibenKnopf = stueck("unterschreiben");
|
||||||
|
const unterschriftPanel = stueck("unterschriftpanel");
|
||||||
|
const vornameFeld = stueck("vorname");
|
||||||
|
const nachnameFeld = stueck("nachname");
|
||||||
|
const padLeinwand = stueck("signpad");
|
||||||
|
const padLeeren = stueck("signleeren");
|
||||||
|
const padSetzen = stueck("signsetzen");
|
||||||
|
const padAbbrechen = stueck("signabbrechen");
|
||||||
|
const padHinweis = stueck("signhinweis");
|
||||||
const basis = rumpf.dataset.viewerPdfjs || "";
|
const basis = rumpf.dataset.viewerPdfjs || "";
|
||||||
|
// Der Anmeldename, nicht der volle Name: er ist eindeutig, und genau das
|
||||||
|
// soll die Zeile belegen.
|
||||||
|
const konto = rumpf.dataset.viewerKonto || "";
|
||||||
|
const bezeugen = rumpf.dataset.viewerZeuge === "1";
|
||||||
|
// Die Uhr des Servers, nicht die des Tablets: eine verstellte Uhr am Geraet
|
||||||
|
// soll nicht das Datum unter einer Unterschrift bestimmen.
|
||||||
|
const serverStart = Date.parse(rumpf.dataset.viewerZeit || "") || Date.now();
|
||||||
|
const geladenSeit = Date.now();
|
||||||
|
const jetztText = () => {
|
||||||
|
const zeitpunkt = new Date(serverStart + (Date.now() - geladenSeit));
|
||||||
|
return zeitpunkt.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
// Ein Format, das dieser Browser nicht kann (HEIC ausserhalb von Safari),
|
||||||
// soll nicht als leere Flaeche enden.
|
// soll nicht als leere Flaeche enden.
|
||||||
@@ -490,7 +1008,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
if (bildBuehne.hidden || !bild.getAttribute("src")) return;
|
if (bildBuehne.hidden || !bild.getAttribute("src")) return;
|
||||||
if (hinweis) {
|
if (hinweis) {
|
||||||
hinweis.textContent =
|
hinweis.textContent =
|
||||||
"Dieses Bildformat kann Ihr Browser nicht anzeigen -- bitte herunterladen.";
|
"Dieses Bildformat kann Ihr Browser nicht anzeigen – bitte herunterladen.";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -509,9 +1027,13 @@ export function initFilePreview(wurzel = document) {
|
|||||||
rahmen.hidden = art !== "text";
|
rahmen.hidden = art !== "text";
|
||||||
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 (speichern) speichern.hidden = art !== "pdf";
|
||||||
|
if (unterschriftPanel) unterschriftPanel.hidden = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const aufraeumen = () => {
|
const aufraeumen = () => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
bild.removeAttribute("src");
|
bild.removeAttribute("src");
|
||||||
bilder.zuruecksetzen();
|
bilder.zuruecksetzen();
|
||||||
rahmen.src = "about:blank";
|
rahmen.src = "about:blank";
|
||||||
@@ -529,6 +1051,61 @@ export function initFilePreview(wurzel = document) {
|
|||||||
aktuell = null;
|
aktuell = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschrieben und unveraendert? Die Antwort kommt aus dem
|
||||||
|
Aenderungsprotokoll: dort steht die Pruefsumme der abgelegten Fassung.
|
||||||
|
Stimmt sie, bekommt die Seite einen gruenen Schleier -- man soll es sehen,
|
||||||
|
bevor man liest, nicht danach. */
|
||||||
|
|
||||||
|
const pruefungZuruecksetzen = () => {
|
||||||
|
pdfBehaelter.classList.remove("viewer__pdf--unveraendert", "viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = true;
|
||||||
|
pruefzeile.textContent = "";
|
||||||
|
pruefzeile.className = "viewer__pruefung";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pruefen = async (adresse) => {
|
||||||
|
pruefungZuruecksetzen();
|
||||||
|
if (!adresse) return;
|
||||||
|
try {
|
||||||
|
const stand = await window.tesm.api(adresse);
|
||||||
|
if (stand.status === "unveraendert" || stand.status === "veraendert") {
|
||||||
|
// Ein unterschriebenes Dokument ist abgeschlossen. Wer daraus eine
|
||||||
|
// neue Fassung machen koennte, koennte auch eine Unterschrift unter
|
||||||
|
// einen veraenderten Text setzen -- also gibt es hier nichts mehr zu
|
||||||
|
// bearbeiten. Der Server weist es zusaetzlich ab.
|
||||||
|
if (unterschreibenKnopf) unterschreibenKnopf.hidden = true;
|
||||||
|
if (speichern) speichern.hidden = true;
|
||||||
|
if (zeile) zeile.hidden = true;
|
||||||
|
}
|
||||||
|
if (stand.status === "unveraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--unveraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--gut";
|
||||||
|
const namen = (stand.namen || []).join(", ");
|
||||||
|
pruefzeile.textContent =
|
||||||
|
`Unverändert seit der Unterschrift${namen ? ` von ${namen}` : ""}` +
|
||||||
|
` | Prüfsumme stimmt mit dem Änderungsprotokoll überein.`;
|
||||||
|
}
|
||||||
|
} else if (stand.status === "veraendert") {
|
||||||
|
pdfBehaelter.classList.add("viewer__pdf--veraendert");
|
||||||
|
if (pruefzeile) {
|
||||||
|
pruefzeile.hidden = false;
|
||||||
|
pruefzeile.className = "viewer__pruefung viewer__pruefung--schlecht";
|
||||||
|
pruefzeile.textContent =
|
||||||
|
"Diese Datei wurde nach der Unterschrift verändert – die Prüfsumme " +
|
||||||
|
"weicht vom Änderungsprotokoll ab.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fehler) {
|
||||||
|
// Keine Aussage ist besser als eine falsche: ohne Antwort bleibt die
|
||||||
|
// Seite schlicht ungekennzeichnet.
|
||||||
|
console.error("Signaturpruefung", fehler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const oeffnen = async (ausloeser) => {
|
const oeffnen = async (ausloeser) => {
|
||||||
aufraeumen();
|
aufraeumen();
|
||||||
const art = ausloeser.dataset.fillKind || "text";
|
const art = ausloeser.dataset.fillKind || "text";
|
||||||
@@ -544,8 +1121,11 @@ export function initFilePreview(wurzel = document) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
if (hinweis) hinweis.textContent = "PDF wird geladen ...";
|
||||||
|
pruefen(ausloeser.dataset.fillPruefung || "");
|
||||||
try {
|
try {
|
||||||
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis);
|
pdf = await pdfBetrachter(pdfScroll, pdfSeiten, quelle, basis, {
|
||||||
|
formulare: rumpf.dataset.viewerFormulare === "1",
|
||||||
|
});
|
||||||
if (hinweis) hinweis.textContent = "";
|
if (hinweis) hinweis.textContent = "";
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
|
// Kein weisses Feld: sagen, was los ist, und auf die einfache Anzeige
|
||||||
@@ -558,7 +1138,7 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Browsers die einzige Spur, die man hat.
|
// Browsers die einzige Spur, die man hat.
|
||||||
const grund = String(fehler?.message || fehler || "").slice(0, 120);
|
const grund = String(fehler?.message || fehler || "").slice(0, 120);
|
||||||
hinweis.textContent =
|
hinweis.textContent =
|
||||||
"Die Formularansicht liess sich nicht laden -- es bleibt bei der einfachen Anzeige." +
|
"Die Formularansicht ließ sich nicht laden – es bleibt bei der einfachen Anzeige." +
|
||||||
(grund ? ` (${grund})` : "");
|
(grund ? ` (${grund})` : "");
|
||||||
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
|
// Die Leiste traegt den Hinweis. "zeigen('text')" hat sie gerade
|
||||||
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
|
// verborgen -- ohne diese Zeile blieb der Fehlschlag unsichtbar, und
|
||||||
@@ -592,18 +1172,92 @@ export function initFilePreview(wurzel = document) {
|
|||||||
if (!aktuell) return;
|
if (!aktuell) return;
|
||||||
const teile = aktuell.pfad.split("/");
|
const teile = aktuell.pfad.split("/");
|
||||||
const datei = teile.pop() || "formular.pdf";
|
const datei = teile.pop() || "formular.pdf";
|
||||||
ordnerFeld.value = teile.join("/");
|
ordnerFuellen(eigeneOrdner(), teile.join("/"));
|
||||||
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
// Ohne Endung: die haengt der Server an. Eine Datei, die auf ".pdf.txt"
|
||||||
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
// oder auf gar nichts endet, oeffnet auf einer Freigabe nichts mehr.
|
||||||
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
// Der Zusatz haelt die Vorlage heil, auch wenn jemand blind speichert.
|
||||||
nameFeld.value = datei.replace(/\.pdf$/i, "") + "-ausgefuellt";
|
// Der Zusatz sagt, was in der Datei steht: unterschrieben schlaegt
|
||||||
|
// ausgefuellt. Und einmal reicht -- wer eine Fassung noch einmal oeffnet
|
||||||
|
// und speichert, bekam sonst "...-ausgefuellt-ausgefuellt".
|
||||||
|
const rumpfname = datei.replace(/\.pdf$/i, "");
|
||||||
|
const zusatz = pdf?.unterschriften().length ? "-unterschrieben" : "-ausgefuellt";
|
||||||
|
const bereits = new RegExp(`${zusatz}$`, "i").test(rumpfname);
|
||||||
|
// Eine unterschriebene Fassung aus einer ausgefuellten: der alte Zusatz
|
||||||
|
// haette sonst zweimal dran gestanden ("-ausgefuellt-unterschrieben").
|
||||||
|
const blank = rumpfname.replace(/-(ausgefuellt|unterschrieben)$/i, "");
|
||||||
|
nameFeld.value = bereits ? rumpfname : `${blank}${zusatz}`;
|
||||||
ersetzenFeld.checked = false;
|
ersetzenFeld.checked = false;
|
||||||
|
ersetzenKasten?.classList.remove("is-gefragt");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Unterschreiben: Name tippen, zeichnen, ins Dokument setzen. */
|
||||||
|
|
||||||
|
const pad = padLeinwand ? unterschriftPad(padLeinwand) : null;
|
||||||
|
|
||||||
|
const panelZeigen = (offen) => {
|
||||||
|
if (!unterschriftPanel) return;
|
||||||
|
unterschriftPanel.hidden = !offen;
|
||||||
|
if (offen) {
|
||||||
|
pad?.leeren();
|
||||||
|
if (padHinweis) padHinweis.textContent = "";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gesetzt heisst fertig zum Ablegen: die Zeile geht von selbst auf, statt
|
||||||
|
// dass man den Knopf daneben sucht.
|
||||||
|
pdfScroll?.addEventListener("unterschrieben", () => {
|
||||||
|
if (!zeile || !zeile.hidden) return;
|
||||||
|
speichern?.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
unterschreibenKnopf?.addEventListener("click", () => {
|
||||||
|
if (!pdf) return;
|
||||||
|
panelZeigen(unterschriftPanel?.hidden !== false);
|
||||||
|
});
|
||||||
|
|
||||||
|
padLeeren?.addEventListener("click", () => pad?.leeren());
|
||||||
|
padAbbrechen?.addEventListener("click", () => panelZeigen(false));
|
||||||
|
|
||||||
|
padSetzen?.addEventListener("click", () => {
|
||||||
|
if (!pdf || !pad) return;
|
||||||
|
const name = [vornameFeld?.value, nachnameFeld?.value]
|
||||||
|
.map((wert) => (wert || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
// Beides ist noetig, und beides wird hier gesagt statt stumm abgelehnt.
|
||||||
|
if (!name) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte Vor- und Nachnamen eintragen.";
|
||||||
|
vornameFeld?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const zeichnung = pad.alsPng();
|
||||||
|
if (!zeichnung) {
|
||||||
|
if (padHinweis) padHinweis.textContent = "Bitte im Feld unterschreiben.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pdf.unterschriftAnnehmen(
|
||||||
|
{ ...zeichnung, name, zeit: jetztText() },
|
||||||
|
bezeugen ? konto : ""
|
||||||
|
);
|
||||||
|
panelZeigen(false);
|
||||||
|
window.tesm.showToast(
|
||||||
|
"Jetzt auf die Stelle im Dokument tippen – danach lässt sie sich noch "
|
||||||
|
+ "verschieben und in der Größe anpassen.",
|
||||||
|
"info"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
speichern?.addEventListener("click", () => {
|
speichern?.addEventListener("click", () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
vorschlagen();
|
vorschlagen();
|
||||||
zeile.hidden = false;
|
zeile.hidden = false;
|
||||||
|
if (modusFeld && pdf.unterschriften().length) {
|
||||||
|
// Mit Unterschrift gibt es nur einen Weg -- und das Feld soll nicht
|
||||||
|
// etwas anderes behaupten.
|
||||||
|
modusFeld.value = "fertig";
|
||||||
|
modusFeld.disabled = true;
|
||||||
|
}
|
||||||
nameFeld.focus();
|
nameFeld.focus();
|
||||||
nameFeld.select();
|
nameFeld.select();
|
||||||
});
|
});
|
||||||
@@ -612,6 +1266,46 @@ export function initFilePreview(wurzel = document) {
|
|||||||
zeile.hidden = true;
|
zeile.hidden = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Zielfreigabe: die Ordnerliste gehoert zur gewaehlten Freigabe, nicht zur
|
||||||
|
geoeffneten. Geholt wird sie erst bei Bedarf -- alle Listen im
|
||||||
|
Seitenaufbau mitzuschicken waere Ballast fuer einen seltenen Fall. */
|
||||||
|
/** Fuellt die Ordnerauswahl -- erster Eintrag ist immer die Wurzel. */
|
||||||
|
const ordnerFuellen = (ordner, ausgewaehlt = "") => {
|
||||||
|
if (!ordnerFeld) return;
|
||||||
|
const wurzel = document.createElement("option");
|
||||||
|
wurzel.value = "";
|
||||||
|
wurzel.textContent = "Wurzelverzeichnis der Freigabe";
|
||||||
|
const eintraege = [wurzel];
|
||||||
|
for (const name of ordner) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = name;
|
||||||
|
option.textContent = name;
|
||||||
|
eintraege.push(option);
|
||||||
|
}
|
||||||
|
// Der Ordner der Vorlage kann tiefer liegen, als die Liste reicht.
|
||||||
|
if (ausgewaehlt && !ordner.includes(ausgewaehlt)) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = ausgewaehlt;
|
||||||
|
option.textContent = ausgewaehlt;
|
||||||
|
eintraege.splice(1, 0, option);
|
||||||
|
}
|
||||||
|
ordnerFeld.replaceChildren(...eintraege);
|
||||||
|
ordnerFeld.value = ausgewaehlt;
|
||||||
|
};
|
||||||
|
|
||||||
|
zielFeld?.addEventListener("change", async () => {
|
||||||
|
const vorlage = zielFeld.dataset.urlVorlage || "";
|
||||||
|
if (!vorlage) return;
|
||||||
|
try {
|
||||||
|
const antwort = await window.tesm.api(vorlage.replace("__token__", zielFeld.value));
|
||||||
|
ordnerFuellen(antwort.folders || []);
|
||||||
|
} catch (fehler) {
|
||||||
|
// Kein Beinbruch: dann bleibt es bei der Wurzel.
|
||||||
|
console.error("Ordnerliste", fehler);
|
||||||
|
ordnerFuellen([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
jetzt?.addEventListener("click", async () => {
|
jetzt?.addEventListener("click", async () => {
|
||||||
if (!pdf || !aktuell) return;
|
if (!pdf || !aktuell) return;
|
||||||
const ziel = speichern.dataset.url;
|
const ziel = speichern.dataset.url;
|
||||||
@@ -619,11 +1313,20 @@ export function initFilePreview(wurzel = document) {
|
|||||||
const vorher = jetzt.textContent;
|
const vorher = jetzt.textContent;
|
||||||
jetzt.textContent = "Speichern ...";
|
jetzt.textContent = "Speichern ...";
|
||||||
try {
|
try {
|
||||||
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "");
|
const gesetzt = pdf.unterschriften();
|
||||||
|
const bytes = await pdf.speichern(rumpf.dataset.viewerPdflib || "", {
|
||||||
|
// Unterschrieben heisst abgeschlossen: ein Dokument, in dem nach der
|
||||||
|
// Unterschrift noch Felder aenderbar waeren, ist keine Unterschrift
|
||||||
|
// wert.
|
||||||
|
abschliessen: modusFeld?.value === "fertig" || gesetzt.length > 0,
|
||||||
|
zeuge: bezeugen ? konto : "",
|
||||||
|
});
|
||||||
const daten = new FormData();
|
const daten = new FormData();
|
||||||
daten.append("pfad", aktuell.pfad);
|
daten.append("path", aktuell.pfad);
|
||||||
daten.append("ordner", ordnerFeld.value.trim());
|
daten.append("ordner", ordnerFeld.value.trim());
|
||||||
daten.append("name", nameFeld.value.trim());
|
daten.append("name", nameFeld.value.trim());
|
||||||
|
if (zielFeld?.value) daten.append("ziel", zielFeld.value);
|
||||||
|
if (gesetzt.length) daten.append("unterschriften", JSON.stringify(gesetzt));
|
||||||
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
if (ersetzenFeld.checked) daten.append("ersetzen", "1");
|
||||||
daten.append(
|
daten.append(
|
||||||
"datei",
|
"datei",
|
||||||
@@ -639,6 +1342,12 @@ export function initFilePreview(wurzel = document) {
|
|||||||
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
// Zeile bleibt offen: meist ist es ein Name, der schon vergeben ist.
|
||||||
console.error("Formular speichern", fehler);
|
console.error("Formular speichern", fehler);
|
||||||
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
window.tesm.showToast(fehler.message || "Speichern fehlgeschlagen.", "danger");
|
||||||
|
// Genau dieser Fall hat eine Antwort auf der Zeile -- dann zeigen wir
|
||||||
|
// auch darauf, statt es bei einer Meldung zu belassen.
|
||||||
|
if (fehler.status === 409) {
|
||||||
|
ersetzenKasten?.classList.add("is-gefragt");
|
||||||
|
ersetzenFeld?.focus();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
jetzt.disabled = false;
|
jetzt.disabled = false;
|
||||||
jetzt.textContent = vorher;
|
jetzt.textContent = vorher;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class Result:
|
|||||||
UNAVAILABLE = Result(
|
UNAVAILABLE = Result(
|
||||||
ok=False,
|
ok=False,
|
||||||
stderr=(
|
stderr=(
|
||||||
"Systemaktionen sind in dieser Umgebung nicht verfuegbar "
|
"Systemaktionen sind in dieser Umgebung nicht verfügbar "
|
||||||
"(Helfer nicht installiert oder kein Linux-Host)."
|
"(Helfer nicht installiert oder kein Linux-Host)."
|
||||||
),
|
),
|
||||||
code=127,
|
code=127,
|
||||||
@@ -132,16 +132,16 @@ class SysOps:
|
|||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
|
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
|
||||||
"kann der Dienst den Helfer nicht aufrufen -- keine Systemaktion "
|
"kann der Dienst den Helfer nicht aufrufen – keine Systemaktion "
|
||||||
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
|
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
|
||||||
"'systemctl daemon-reload && systemctl restart' ausfuehren.",
|
"'systemctl daemon-reload && systemctl restart' ausführen.",
|
||||||
result.code,
|
result.code,
|
||||||
)
|
)
|
||||||
if not result.ok and "a password is required" in result.output.lower():
|
if not result.ok and "a password is required" in result.output.lower():
|
||||||
return Result(
|
return Result(
|
||||||
False,
|
False,
|
||||||
"",
|
"",
|
||||||
"sudo verlangt ein Passwort. Die sudoers-Regel fuer den Helfer fehlt oder "
|
"sudo verlangt ein Passwort. Die sudoers-Regel für den Helfer fehlt oder "
|
||||||
"greift nicht.",
|
"greift nicht.",
|
||||||
result.code,
|
result.code,
|
||||||
)
|
)
|
||||||
@@ -177,7 +177,7 @@ class SysOps:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return Result(False, stderr=f"Zeitlimit ueberschritten ({verb}).", code=124)
|
return Result(False, stderr=f"Zeitlimit überschritten ({verb}).", code=124)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return Result(False, stderr=str(exc), code=126)
|
return Result(False, stderr=str(exc), code=126)
|
||||||
return Result(
|
return Result(
|
||||||
@@ -210,7 +210,7 @@ class SysOps:
|
|||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
return Result(False, stderr="Zeitlimit ueberschritten.", code=124)
|
return Result(False, stderr="Zeitlimit überschritten.", code=124)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
return Result(False, stderr=str(exc), code=126)
|
return Result(False, stderr=str(exc), code=126)
|
||||||
return Result(
|
return Result(
|
||||||
|
|||||||
@@ -136,24 +136,24 @@ def validate_static(
|
|||||||
try:
|
try:
|
||||||
prefix_length = int(prefix)
|
prefix_length = int(prefix)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValueError("Die Praefixlaenge muss eine Zahl sein (z. B. 24).") from exc
|
raise ValueError("Die Präfixlänge muss eine Zahl sein (z. B. 24).") from exc
|
||||||
if not 1 <= prefix_length <= 32:
|
if not 1 <= prefix_length <= 32:
|
||||||
raise ValueError("Die Praefixlaenge muss zwischen 1 und 32 liegen.")
|
raise ValueError("Die Präfixlänge muss zwischen 1 und 32 liegen.")
|
||||||
try:
|
try:
|
||||||
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
|
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"{address!r} ist keine gueltige IPv4-Adresse.") from exc
|
raise ValueError(f"{address!r} ist keine gültige IPv4-Adresse.") from exc
|
||||||
|
|
||||||
gateway_clean = (gateway or "").strip()
|
gateway_clean = (gateway or "").strip()
|
||||||
if gateway_clean:
|
if gateway_clean:
|
||||||
try:
|
try:
|
||||||
gateway_ip = ipaddress.ip_address(gateway_clean)
|
gateway_ip = ipaddress.ip_address(gateway_clean)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"{gateway_clean!r} ist keine gueltige Gateway-Adresse.") from exc
|
raise ValueError(f"{gateway_clean!r} ist keine gültige Gateway-Adresse.") from exc
|
||||||
if gateway_ip not in interface_ip.network:
|
if gateway_ip not in interface_ip.network:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
|
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
|
||||||
"So waere der Host nach der Aenderung nicht mehr erreichbar."
|
"So wäre der Host nach der Änderung nicht mehr erreichbar."
|
||||||
)
|
)
|
||||||
|
|
||||||
servers: list[str] = []
|
servers: list[str] = []
|
||||||
@@ -163,7 +163,7 @@ def validate_static(
|
|||||||
try:
|
try:
|
||||||
ipaddress.ip_address(token)
|
ipaddress.ip_address(token)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError(f"{token!r} ist keine gueltige DNS-Adresse.") from exc
|
raise ValueError(f"{token!r} ist keine gültige DNS-Adresse.") from exc
|
||||||
servers.append(token)
|
servers.append(token)
|
||||||
|
|
||||||
return str(interface_ip.ip), prefix_length, gateway_clean, servers
|
return str(interface_ip.ip), prefix_length, gateway_clean, servers
|
||||||
@@ -185,7 +185,7 @@ def render_netplan(
|
|||||||
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
|
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
|
||||||
"""
|
"""
|
||||||
lines = [
|
lines = [
|
||||||
"# Von TESM verwaltet. Diese Datei enthaelt ausschliesslich das unten",
|
"# Von TESM verwaltet. Diese Datei enthält ausschließlich das unten",
|
||||||
"# genannte Interface; alle anderen bleiben bei ihrer",
|
"# genannte Interface; alle anderen bleiben bei ihrer",
|
||||||
"# Distributionskonfiguration.",
|
"# Distributionskonfiguration.",
|
||||||
"network:",
|
"network:",
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def validate_domain(value: str) -> str:
|
|||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
if not DOMAIN_RE.match(text):
|
if not DOMAIN_RE.match(text):
|
||||||
raise ValueError(f"{text!r} ist kein gueltiger Domainname.")
|
raise ValueError(f"{text!r} ist kein gültiger Domainname.")
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
@@ -122,8 +122,8 @@ def render(config: WebConfig) -> str:
|
|||||||
]
|
]
|
||||||
|
|
||||||
lines: list[str] = [
|
lines: list[str] = [
|
||||||
f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung",
|
f"# Von TESM erzeugt ({config.app_key}). Änderungen bitte in der Anwendung",
|
||||||
"# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.",
|
"# vornehmen – diese Datei wird bei jeder Übernahme neu geschrieben.",
|
||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bo
|
|||||||
f" client_max_body_size {body};",
|
f" client_max_body_size {body};",
|
||||||
f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren"
|
f" # {uploads.NGINX_ZUSCHLAG_MB} MB mehr als die Anwendung erlaubt, damit deren"
|
||||||
" eigene,",
|
" eigene,",
|
||||||
" # erklaerende Fehlerseite gewinnt statt der generischen 413 von nginx.",
|
" # erklärende Fehlerseite gewinnt statt der generischen 413 von nginx.",
|
||||||
"",
|
"",
|
||||||
" location /assets/ {",
|
" location /assets/ {",
|
||||||
f" alias {config.static_root.rstrip('/')}/;",
|
f" alias {config.static_root.rstrip('/')}/;",
|
||||||
@@ -256,10 +256,10 @@ def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
|
|||||||
if not result.ok:
|
if not result.ok:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.",
|
"message": "nginx hat die Konfiguration abgelehnt – der alte Stand gilt weiter.",
|
||||||
"detail": result.output,
|
"detail": result.output,
|
||||||
}
|
}
|
||||||
return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output}
|
return {"ok": True, "message": "Konfiguration übernommen.", "detail": result.output}
|
||||||
|
|
||||||
|
|
||||||
def request_certificate(
|
def request_certificate(
|
||||||
@@ -274,13 +274,13 @@ def request_certificate(
|
|||||||
if not domain:
|
if not domain:
|
||||||
return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
|
return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
|
||||||
if not email:
|
if not email:
|
||||||
return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."}
|
return {"ok": False, "message": "Für Let's Encrypt ist eine E-Mail-Adresse nötig."}
|
||||||
result = sysops.certbot_issue(domain, email, webroot)
|
result = sysops.certbot_issue(domain, email, webroot)
|
||||||
if not result.ok:
|
if not result.ok:
|
||||||
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
|
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": f"Zertifikat fuer {domain} ausgestellt.",
|
"message": f"Zertifikat für {domain} ausgestellt.",
|
||||||
"detail": result.output,
|
"detail": result.output,
|
||||||
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
|
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
|
||||||
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
|
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
|
||||||
@@ -303,9 +303,9 @@ def create_self_signed(
|
|||||||
"""
|
"""
|
||||||
name = (common_name or "").strip().lower().rstrip(".")
|
name = (common_name or "").strip().lower().rstrip(".")
|
||||||
if not name:
|
if not name:
|
||||||
return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."}
|
return {"ok": False, "message": "Es fehlt ein Name für das Zertifikat."}
|
||||||
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
|
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
|
||||||
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."}
|
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulässig."}
|
||||||
|
|
||||||
cleaned: list[str] = []
|
cleaned: list[str] = []
|
||||||
for entry in alt_names:
|
for entry in alt_names:
|
||||||
@@ -328,8 +328,8 @@ def create_self_signed(
|
|||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"message": (
|
"message": (
|
||||||
f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine "
|
f"Selbstsigniertes Zertifikat für {name} erzeugt. Browser zeigen dafür eine "
|
||||||
"Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen."
|
"Warnung – für ein öffentlich erreichbares System bitte Let's Encrypt nutzen."
|
||||||
),
|
),
|
||||||
"cert_path": paths[0],
|
"cert_path": paths[0],
|
||||||
"key_path": paths[1],
|
"key_path": paths[1],
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@
|
|||||||
<div class="palette__hint">
|
<div class="palette__hint">
|
||||||
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
<span><kbd>↑</kbd><kbd>↓</kbd> wählen</span>
|
||||||
<span><kbd>Enter</kbd> öffnen</span>
|
<span><kbd>Enter</kbd> öffnen</span>
|
||||||
<span><kbd>Esc</kbd> schliessen</span>
|
<span><kbd>Esc</kbd> schließen</span>
|
||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
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 –
|
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurückgesetzt –
|
||||||
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, freischalten=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>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -26,26 +26,29 @@
|
|||||||
{{ icon('search', size=15) }}
|
{{ icon('search', size=15) }}
|
||||||
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
|
||||||
</span>
|
</span>
|
||||||
<select class="select" name="aktion" style="width:auto;max-width:240px">
|
<select class="select" name="action" style="width:auto;max-width:240px">
|
||||||
<option value="">alle Aktionen</option>
|
<option value="">alle Aktionen</option>
|
||||||
{% for action in actions %}
|
{% for action in actions %}
|
||||||
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option>
|
<option value="{{ action }}" {% if action == filters.action %}selected{% endif %}>{{ action }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<select class="select" name="stufe" style="width:auto">
|
<select class="select" name="severity" style="width:auto">
|
||||||
<option value="">alle Stufen</option>
|
<option value="">alle Stufen</option>
|
||||||
{% for level in ['info', 'notice', 'warning', 'critical'] %}
|
{% for level in ['info', 'notice', 'warning', 'critical'] %}
|
||||||
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option>
|
<option value="{{ level }}" {% if level == filters.severity %}selected{% endif %}>{{ level }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
|
<input class="input" type="text" name="actor" value="{{ filters.actor }}"
|
||||||
placeholder="Benutzer" style="width:150px">
|
placeholder="Benutzer" style="width:150px">
|
||||||
<button class="btn btn--sm" type="submit">Filtern</button>
|
<button class="btn btn--sm" type="submit">Filtern</button>
|
||||||
<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 '–' | safe }}</td>
|
<td class="mono fs-xs faint" data-spalte="Herkunft">{{ row.ip or '–' | safe }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -88,10 +97,10 @@
|
|||||||
<span class="fs-sm muted">Seite {{ page }}</span>
|
<span class="fs-sm muted">Seite {{ page }}</span>
|
||||||
<span class="row row--tight">
|
<span class="row row--tight">
|
||||||
{% if page > 1 %}
|
{% if page > 1 %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurück</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page-1, **filters) }}">Zurück</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if rows | length == per_page %}
|
{% if rows | length == per_page %}
|
||||||
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
|
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', page=page+1, **filters) }}">Weiter</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,8 +162,25 @@
|
|||||||
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '–' | safe }}</dd>
|
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '–' | 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] }} – {{ action_meta[action][2] }}">
|
<th title="{{ action_meta[action][1] }} ({{ action_meta[action][2] }}) –
|
||||||
{{ 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] }} –
|
||||||
|
{{ 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,9 +283,75 @@
|
|||||||
</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] }} – {{ meta[2] }}</span>
|
<span><b>{{ meta[0] }}</b> {{ meta[1] }} ({{ meta[2] }}) – {{ meta[3] }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
<span><span class="matrix__na"></span> Für diese Zeile nicht vorgesehen –
|
||||||
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
die Spalte gehört zu einer anderen Zeile desselben Bereichs.</span>
|
||||||
</div>
|
</div>
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# --------------------------------------------------------------------------
|
||||||
|
Navigationsreihenfolge. Die Reihenfolge der versteckten Felder ist die
|
||||||
|
Reihenfolge -- verschiebt sich eine Zeile, verschiebt sich das Feld mit ihr.
|
||||||
|
Ziehen mit der Maus oder die zwei Knoepfe je Zeile; auf einem Tablet sind
|
||||||
|
die Knoepfe der verlaessliche Weg.
|
||||||
|
-------------------------------------------------------------------------- #}
|
||||||
|
{% macro _reorder_row(item, field_name, label) -%}
|
||||||
|
<li class="reorder__row" data-reorder-item draggable="true">
|
||||||
|
<span class="reorder__grip" aria-hidden="true">{{ icon('menu', size=14) }}</span>
|
||||||
|
<span class="reorder__pos" data-reorder-position-text>1</span>
|
||||||
|
<input type="hidden" name="{{ field_name }}" value="{{ item.key }}">
|
||||||
|
<span class="flex-1">{{ icon(item.icon, size=15) }} {{ item.label }}</span>
|
||||||
|
<span class="reorder__moves">
|
||||||
|
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="up"
|
||||||
|
aria-label="{{ label }} nach oben">{{ icon('chevron-up', size=14) }}</button>
|
||||||
|
<button class="btn btn--icon btn--sm" type="button" data-reorder-move="down"
|
||||||
|
aria-label="{{ label }} nach unten">{{ icon('chevron-down', size=14) }}</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro nav_order_card(items, order, child_order, action, csrf_field, csrf_value,
|
||||||
|
sub='Gilt für alle Benutzer; jeder sieht weiter nur, was er darf') -%}
|
||||||
|
<section class="card">
|
||||||
|
<div class="card__header">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="card__title">{{ icon('sliders', size=17) }} Navigationsreihenfolge</div>
|
||||||
|
<div class="card__sub">{{ sub }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ action }}">
|
||||||
|
<input type="hidden" name="{{ csrf_field }}" value="{{ csrf_value }}">
|
||||||
|
<div class="card__body">
|
||||||
|
<ol class="reorder" data-behavior="reorder">
|
||||||
|
{% for key in order %}
|
||||||
|
{% set item = items | selectattr('key', 'equalto', key) | list | first %}
|
||||||
|
{% if item %}
|
||||||
|
{{ _reorder_row(item, 'order', item.label) }}
|
||||||
|
{% if item.children %}
|
||||||
|
<li class="reorder__group">
|
||||||
|
<ol class="reorder reorder--child" data-behavior="reorder">
|
||||||
|
{% set kinder = child_order.get(item.key) or (item.children | map(attribute='key') | list) %}
|
||||||
|
{% for child_key in kinder %}
|
||||||
|
{% set child = item.children | selectattr('key', 'equalto', child_key) | list | first %}
|
||||||
|
{% if child %}
|
||||||
|
{{ _reorder_row(child, 'child_order_' ~ item.key, item.label ~ ' / ' ~ child.label) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ol>
|
||||||
|
<p class="fs-xs faint mt-3">
|
||||||
|
Zeilen ziehen oder mit den Pfeilen verschieben, dann speichern. Untergeordnete
|
||||||
|
Punkte werden innerhalb ihrer Gruppe sortiert.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="card__footer">
|
||||||
|
<button class="btn btn--sm" type="submit">Reihenfolge speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{%- endmacro %}
|
||||||
@@ -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() }}">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
|
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
|
||||||
|
|
||||||
{% block page_title %}Benutzer{% endblock %}
|
{% block page_title %}Benutzer{% endblock %}
|
||||||
{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschliesslich aus Gruppen{% endblock %}
|
{% block page_sub %}{{ users | length }} Konten · Rechte kommen ausschließlich aus Gruppen{% endblock %}
|
||||||
|
|
||||||
{% block topbar_extra %}
|
{% block topbar_extra %}
|
||||||
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
|
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes:
|
|||||||
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
|
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
|
||||||
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
|
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist "
|
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben – sie ist "
|
||||||
"der einzige Schutz dieser Datei."
|
"der einzige Schutz dieser Datei."
|
||||||
)
|
)
|
||||||
salt = secrets.token_bytes(KDF_SALT_BYTES)
|
salt = secrets.token_bytes(KDF_SALT_BYTES)
|
||||||
@@ -116,21 +116,21 @@ def unseal(
|
|||||||
try:
|
try:
|
||||||
envelope = json.loads(text)
|
envelope = json.loads(text)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise TransferError("Die Datei ist kein gueltiges JSON.") from exc
|
raise TransferError("Die Datei ist kein gültiges JSON.") from exc
|
||||||
if not isinstance(envelope, dict):
|
if not isinstance(envelope, dict):
|
||||||
raise TransferError("Die Datei enthaelt kein Objekt.")
|
raise TransferError("Die Datei enthält kein Objekt.")
|
||||||
if envelope.get("app") != app_tag:
|
if envelope.get("app") != app_tag:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})."
|
f"Diese Sicherung gehört zu einer anderen Anwendung ({envelope.get('app')!r})."
|
||||||
)
|
)
|
||||||
if int(envelope.get("format") or 0) != FORMAT_VERSION:
|
if int(envelope.get("format") or 0) != FORMAT_VERSION:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, "
|
f"Nicht unterstütztes Sicherungsformat ({envelope.get('format')!r}, "
|
||||||
f"erwartet {FORMAT_VERSION})."
|
f"erwartet {FORMAT_VERSION})."
|
||||||
)
|
)
|
||||||
kdf = envelope.get("kdf") or {}
|
kdf = envelope.get("kdf") or {}
|
||||||
if kdf.get("name") != "argon2id":
|
if kdf.get("name") != "argon2id":
|
||||||
raise TransferError("Unbekanntes Schluesselableitungsverfahren.")
|
raise TransferError("Unbekanntes Schlüsselableitungsverfahren.")
|
||||||
|
|
||||||
header = {
|
header = {
|
||||||
key: envelope[key]
|
key: envelope[key]
|
||||||
@@ -153,7 +153,7 @@ def unseal(
|
|||||||
)
|
)
|
||||||
except (InvalidTag, KeyError, ValueError) as exc:
|
except (InvalidTag, KeyError, ValueError) as exc:
|
||||||
raise TransferError(
|
raise TransferError(
|
||||||
"Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt."
|
"Die Datei konnte nicht geöffnet werden. Passphrase falsch oder Datei beschädigt."
|
||||||
) from exc
|
) from exc
|
||||||
return json.loads(plain.decode("utf-8")), header
|
return json.loads(plain.decode("utf-8")), header
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ ICONS: dict[str, str] = {
|
|||||||
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
|
||||||
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
|
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
|
||||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||||
|
"chevron-up": '<path d="m6 15 6-6 6 6"/>',
|
||||||
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
|
"sun": '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5 19 19M19 5l-1.5 1.5M6.5 17.5 5 19"/>',
|
||||||
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
|
"moon": '<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/>',
|
||||||
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
"monitor": '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||||
@@ -196,7 +197,7 @@ def yes_no(value: Any) -> str:
|
|||||||
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
|
#: Tippfehler. Uebersetzt wird an genau einer Stelle, damit die beiden
|
||||||
#: Anwendungen nicht auseinanderlaufen.
|
#: Anwendungen nicht auseinanderlaufen.
|
||||||
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
|
ISSUER_SOURCES: dict[str, tuple[str, str]] = {
|
||||||
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch uebernommen"),
|
"tofu": ("Erstkontakt", "beim ersten Einspielen automatisch übernommen"),
|
||||||
"admin-confirmed": (
|
"admin-confirmed": (
|
||||||
"Bestaetigt",
|
"Bestaetigt",
|
||||||
"Ausstellerwechsel von einem Administrator freigegeben",
|
"Ausstellerwechsel von einem Administrator freigegeben",
|
||||||
@@ -325,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:
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ from ..extension import core, db, transaction
|
|||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
from ..security import passwords, sessions as session_store, totp
|
from ..security import passwords, sessions as session_store, totp
|
||||||
|
|
||||||
bp = Blueprint("account", __name__, url_prefix="/konto")
|
bp = Blueprint("account", __name__, url_prefix="/account")
|
||||||
|
|
||||||
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
|
||||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||||
@@ -54,6 +54,19 @@ def _avatar_dir() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
|
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
|
||||||
|
"""Protokolliert eine Aenderung am **eigenen** Konto.
|
||||||
|
|
||||||
|
Bewusst sparsam benutzt. Das Aenderungsprotokoll beantwortet die Frage
|
||||||
|
"wer hat woran etwas geaendert" -- und wer sein eigenes Passwort setzt,
|
||||||
|
ein Profilbild tauscht oder seine Anschrift korrigiert, aendert nichts,
|
||||||
|
was jemand anderen betrifft. Solche Zeilen fuellten das Protokoll und
|
||||||
|
schoben die Eintraege aus dem Blick, um die es geht.
|
||||||
|
|
||||||
|
Was hier trotzdem stehen bleibt: alles rund um den zweiten Faktor. Ob ein
|
||||||
|
Konto mit oder ohne zweiten Faktor geschuetzt ist, aendert die Staerke der
|
||||||
|
Anmeldung -- das ist die eine Selbstbedienung, nach der man nach einem
|
||||||
|
Vorfall sucht.
|
||||||
|
"""
|
||||||
audit.log(
|
audit.log(
|
||||||
conn,
|
conn,
|
||||||
actor=current_user.username,
|
actor=current_user.username,
|
||||||
@@ -80,13 +93,13 @@ def overview():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/stammdaten")
|
@bp.post("/profile")
|
||||||
@login_required
|
@login_required
|
||||||
def update_profile():
|
def update_profile():
|
||||||
if current_user.is_directory_account:
|
if current_user.is_directory_account:
|
||||||
flash(
|
flash(
|
||||||
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
|
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
|
||||||
"Anmeldung uebernommen.",
|
"Anmeldung übernommen.",
|
||||||
"info",
|
"info",
|
||||||
)
|
)
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
@@ -101,17 +114,16 @@ def update_profile():
|
|||||||
current_user.id,
|
current_user.id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
_audit(conn, "account.profile_updated")
|
|
||||||
flash("Stammdaten gespeichert.", "success")
|
flash("Stammdaten gespeichert.", "success")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/profilbild")
|
@bp.post("/avatar")
|
||||||
@login_required
|
@login_required
|
||||||
def upload_avatar():
|
def upload_avatar():
|
||||||
upload = request.files.get("avatar")
|
upload = request.files.get("avatar")
|
||||||
if upload is None or not upload.filename:
|
if upload is None or not upload.filename:
|
||||||
flash("Keine Datei ausgewaehlt.", "danger")
|
flash("Keine Datei ausgewählt.", "danger")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
suffix = Path(upload.filename).suffix.lower()
|
suffix = Path(upload.filename).suffix.lower()
|
||||||
@@ -127,7 +139,7 @@ def upload_avatar():
|
|||||||
|
|
||||||
data = upload.stream.read(MAX_AVATAR_BYTES + 1)
|
data = upload.stream.read(MAX_AVATAR_BYTES + 1)
|
||||||
if len(data) > MAX_AVATAR_BYTES:
|
if len(data) > MAX_AVATAR_BYTES:
|
||||||
flash("Das Bild ist zu gross (maximal 2 MB).", "danger")
|
flash("Das Bild ist zu groß (maximal 2 MB).", "danger")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
|
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
|
||||||
@@ -142,14 +154,13 @@ def upload_avatar():
|
|||||||
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
|
||||||
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
|
||||||
)
|
)
|
||||||
_audit(conn, "account.avatar_updated")
|
|
||||||
if previous:
|
if previous:
|
||||||
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
|
||||||
flash("Profilbild aktualisiert.", "success")
|
flash("Profilbild aktualisiert.", "success")
|
||||||
return redirect(url_for("account.overview"))
|
return redirect(url_for("account.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/profilbild/<path:filename>")
|
@bp.get("/avatar/<path:filename>")
|
||||||
@login_required
|
@login_required
|
||||||
def avatar(filename: str):
|
def avatar(filename: str):
|
||||||
safe = secure_filename(filename)
|
safe = secure_filename(filename)
|
||||||
@@ -163,7 +174,7 @@ def avatar(filename: str):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sicherheit")
|
@bp.get("/security")
|
||||||
@login_required
|
@login_required
|
||||||
def security():
|
def security():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -199,12 +210,12 @@ def security():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/passwort")
|
@bp.post("/password")
|
||||||
@login_required
|
@login_required
|
||||||
def change_password():
|
def change_password():
|
||||||
conn = db()
|
conn = db()
|
||||||
if current_user.is_directory_account:
|
if current_user.is_directory_account:
|
||||||
flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info")
|
flash("Verzeichniskonten ändern ihr Passwort im Verzeichnisdienst.", "info")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
current = request.form.get("current_password") or ""
|
current = request.form.get("current_password") or ""
|
||||||
@@ -215,7 +226,7 @@ def change_password():
|
|||||||
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
|
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
if new != repeat:
|
if new != repeat:
|
||||||
flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger")
|
flash("Die beiden neuen Passwörter stimmen nicht überein.", "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
if passwords.constant_time_equals(current, new):
|
if passwords.constant_time_equals(current, new):
|
||||||
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
|
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
|
||||||
@@ -229,36 +240,30 @@ def change_password():
|
|||||||
revoked = session_store.revoke_all_for_user(
|
revoked = session_store.revoke_all_for_user(
|
||||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
|
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="password-change"
|
||||||
)
|
)
|
||||||
_audit(
|
|
||||||
conn,
|
|
||||||
"account.password_changed",
|
|
||||||
f"{revoked} weitere Sitzungen beendet",
|
|
||||||
severity="notice",
|
|
||||||
)
|
|
||||||
except rbac.ValidationError as exc:
|
except rbac.ValidationError as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success")
|
flash(f"Passwort geändert. {revoked} weitere Sitzung(en) wurden beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/starten")
|
@bp.post("/two-factor/start")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_start():
|
def totp_start():
|
||||||
session["_totp_setup"] = totp.generate_secret()
|
session["_totp_setup"] = totp.generate_secret()
|
||||||
flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info")
|
flash("Scannen Sie den Code und bestätigen Sie mit einem gültigen Zahlencode.", "info")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/bestaetigen")
|
@bp.post("/two-factor/confirm")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_confirm():
|
def totp_confirm():
|
||||||
secret = session.get("_totp_setup", "")
|
secret = session.get("_totp_setup", "")
|
||||||
if not secret:
|
if not secret:
|
||||||
flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger")
|
flash("Es läuft keine Einrichtung. Bitte neu starten.", "danger")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
if not totp.verify_code(secret, request.form.get("code") or ""):
|
if not totp.verify_code(secret, request.form.get("code") or ""):
|
||||||
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
|
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
|
||||||
@@ -295,13 +300,13 @@ def totp_confirm():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/deaktivieren")
|
@bp.post("/two-factor/disable")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def totp_disable():
|
def totp_disable():
|
||||||
if core().config.security.require_totp_for_admins and current_user.is_admin:
|
if core().config.security.require_totp_for_admins and current_user.is_admin:
|
||||||
flash(
|
flash(
|
||||||
"Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
|
"Für Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
|
||||||
"Installation verpflichtend.",
|
"Installation verpflichtend.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
@@ -317,7 +322,7 @@ def totp_disable():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/zwei-faktor/codes-erneuern")
|
@bp.post("/two-factor/recovery-codes")
|
||||||
@login_required
|
@login_required
|
||||||
@reauth_required
|
@reauth_required
|
||||||
def recovery_codes():
|
def recovery_codes():
|
||||||
@@ -341,7 +346,7 @@ def recovery_codes():
|
|||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/<sid>/beenden")
|
@bp.post("/sessions/<sid>/end")
|
||||||
@login_required
|
@login_required
|
||||||
def revoke_session(sid: str):
|
def revoke_session(sid: str):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -350,18 +355,16 @@ def revoke_session(sid: str):
|
|||||||
abort(404)
|
abort(404)
|
||||||
with transaction():
|
with transaction():
|
||||||
session_store.revoke(conn, sid, reason="self")
|
session_store.revoke(conn, sid, reason="self")
|
||||||
_audit(conn, "account.session_revoked", sid[:12])
|
|
||||||
flash("Sitzung beendet.", "success")
|
flash("Sitzung beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/alle-beenden")
|
@bp.post("/sessions/end-all")
|
||||||
@login_required
|
@login_required
|
||||||
def revoke_other_sessions():
|
def revoke_other_sessions():
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
count = session_store.revoke_all_for_user(
|
count = session_store.revoke_all_for_user(
|
||||||
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
|
conn, current_user.id, except_sid=getattr(session, "sid", ""), reason="self"
|
||||||
)
|
)
|
||||||
_audit(conn, "account.sessions_revoked", f"{count} Sitzungen")
|
|
||||||
flash(f"{count} weitere Sitzung(en) beendet.", "success")
|
flash(f"{count} weitere Sitzung(en) beendet.", "success")
|
||||||
return redirect(url_for("account.security"))
|
return redirect(url_for("account.security"))
|
||||||
@@ -16,7 +16,7 @@ from ..rbac import service as rbac
|
|||||||
from ..rbac.model import ACTION_META, Action
|
from ..rbac.model import ACTION_META, Action
|
||||||
from ..security import sessions as session_store
|
from ..security import sessions as session_store
|
||||||
|
|
||||||
bp = Blueprint("admin", __name__, url_prefix="/verwaltung")
|
bp = Blueprint("admin", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
|
|
||||||
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
|
||||||
@@ -47,8 +47,8 @@ def _int_list(name: str) -> list[int]:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/benutzer")
|
@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(
|
||||||
@@ -60,7 +60,7 @@ def users():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/neu")
|
@bp.post("/users/new")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def user_create():
|
def user_create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -94,8 +94,8 @@ def user_create():
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<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:
|
||||||
@@ -120,7 +120,7 @@ def user_update(user_id: int):
|
|||||||
policy=core().password_policy,
|
policy=core().password_policy,
|
||||||
must_change=True,
|
must_change=True,
|
||||||
)
|
)
|
||||||
changes.append("Passwort zurueckgesetzt")
|
changes.append("Passwort zurückgesetzt")
|
||||||
session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
|
session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
|
||||||
target = rbac.get_user(conn, user_id) or {}
|
target = rbac.get_user(conn, user_id) or {}
|
||||||
if changes:
|
if changes:
|
||||||
@@ -131,13 +131,13 @@ def user_update(user_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>/loeschen")
|
@bp.post("/users/<int:user_id>/delete")
|
||||||
@permission_required("users.delete")
|
@permission_required("users.delete")
|
||||||
def user_delete(user_id: int):
|
def user_delete(user_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -153,7 +153,7 @@ def user_delete(user_id: int):
|
|||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
"Konto endgueltig entfernt (Verzeichniskonto)."
|
"Konto endgültig entfernt (Verzeichniskonto)."
|
||||||
if mode == "endgueltig"
|
if mode == "endgueltig"
|
||||||
else "Konto in den Papierkorb verschoben.",
|
else "Konto in den Papierkorb verschoben.",
|
||||||
"success",
|
"success",
|
||||||
@@ -163,8 +163,8 @@ def user_delete(user_id: int):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden")
|
@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)
|
||||||
@@ -184,7 +184,7 @@ def user_revoke_sessions(user_id: int):
|
|||||||
return redirect(url_for("admin.users"))
|
return redirect(url_for("admin.users"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/benutzer/verzeichnis-suche")
|
@bp.get("/users/directory-search")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def directory_search():
|
def directory_search():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -200,7 +200,7 @@ def directory_search():
|
|||||||
return jsonify({"results": results})
|
return jsonify({"results": results})
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/benutzer/verzeichnis-uebernehmen")
|
@bp.post("/users/directory-import")
|
||||||
@permission_required("users.create")
|
@permission_required("users.create")
|
||||||
def directory_adopt():
|
def directory_adopt():
|
||||||
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
|
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
|
||||||
@@ -238,8 +238,8 @@ def directory_adopt():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/gruppen")
|
@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
|
||||||
@@ -254,8 +254,8 @@ def groups():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/gruppen/<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,12 +274,12 @@ 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("freischalten") == "1",
|
unlocked=request.args.get("unlocked") == "1",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/neu")
|
@bp.post("/groups/new")
|
||||||
@permission_required("groups.create")
|
@permission_required("groups.create")
|
||||||
def group_create():
|
def group_create():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -301,8 +301,8 @@ def group_create():
|
|||||||
return redirect(url_for("admin.groups"))
|
return redirect(url_for("admin.groups"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<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:
|
||||||
@@ -328,13 +328,13 @@ def group_update(group_id: int):
|
|||||||
"; ".join(changes),
|
"; ".join(changes),
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
|
flash("Änderungen gespeichert." if changes else "Keine Änderungen.", "success" if changes else "info")
|
||||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>/loeschen")
|
@bp.post("/groups/<int:group_id>/delete")
|
||||||
@permission_required("groups.delete")
|
@permission_required("groups.delete")
|
||||||
def group_delete(group_id: int):
|
def group_delete(group_id: int):
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -354,7 +354,7 @@ def group_delete(group_id: int):
|
|||||||
return redirect(url_for("admin.groups"))
|
return redirect(url_for("admin.groups"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden")
|
@bp.post("/groups/<int:group_id>/apply-preset")
|
||||||
@admin_required
|
@admin_required
|
||||||
def group_apply_preset(group_id: int):
|
def group_apply_preset(group_id: int):
|
||||||
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
|
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
|
||||||
@@ -364,7 +364,7 @@ def group_apply_preset(group_id: int):
|
|||||||
abort(404)
|
abort(404)
|
||||||
preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
|
preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
|
||||||
if preset is None:
|
if preset is None:
|
||||||
flash("Fuer diese Gruppe ist keine Vorlage hinterlegt.", "info")
|
flash("Für diese Gruppe ist keine Vorlage hinterlegt.", "info")
|
||||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||||
tree = core().permissions
|
tree = core().permissions
|
||||||
with transaction():
|
with transaction():
|
||||||
@@ -377,7 +377,7 @@ def group_apply_preset(group_id: int):
|
|||||||
unlock_system=True,
|
unlock_system=True,
|
||||||
)
|
)
|
||||||
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
|
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
|
||||||
flash(f"Rechte der Vorlage {preset.name} uebernommen.", "success")
|
flash(f"Rechte der Vorlage {preset.name} übernommen.", "success")
|
||||||
return redirect(url_for("admin.group_detail", group_id=group_id))
|
return redirect(url_for("admin.group_detail", group_id=group_id))
|
||||||
|
|
||||||
|
|
||||||
@@ -386,7 +386,7 @@ def group_apply_preset(group_id: int):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/sitzungen")
|
@bp.get("/sessions")
|
||||||
@admin_required
|
@admin_required
|
||||||
def sessions():
|
def sessions():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -399,7 +399,7 @@ def sessions():
|
|||||||
return render_template("tesm_core/sessions.html", sessions=rows)
|
return render_template("tesm_core/sessions.html", sessions=rows)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/sitzungen/<sid>/beenden")
|
@bp.post("/sessions/<sid>/end")
|
||||||
@admin_required
|
@admin_required
|
||||||
def session_revoke(sid: str):
|
def session_revoke(sid: str):
|
||||||
with transaction() as conn:
|
with transaction() as conn:
|
||||||
@@ -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"))
|
||||||
|
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ from ..keystore import check_private_file
|
|||||||
from ..security import ratelimit
|
from ..security import ratelimit
|
||||||
from ..security import sessions as session_store
|
from ..security import sessions as session_store
|
||||||
|
|
||||||
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose")
|
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()
|
||||||
@@ -82,13 +82,13 @@ def overview():
|
|||||||
"Debug": "ja" if config.debug else "nein",
|
"Debug": "ja" if config.debug else "nein",
|
||||||
"Cookie Secure": "ja" if config.security.cookie_secure else "nein",
|
"Cookie Secure": "ja" if config.security.cookie_secure else "nein",
|
||||||
"HSTS": "ja" if config.security.hsts_enabled else "nein",
|
"HSTS": "ja" if config.security.hsts_enabled else "nein",
|
||||||
"2FA-Pflicht fuer Admins": "ja" if config.security.require_totp_for_admins else "nein",
|
"2FA-Pflicht für Admins": "ja" if config.security.require_totp_for_admins else "nein",
|
||||||
},
|
},
|
||||||
now=datetime.now(timezone.utc),
|
now=datetime.now(timezone.utc),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/aufraeumen")
|
@bp.post("/cleanup")
|
||||||
@admin_required
|
@admin_required
|
||||||
def housekeeping():
|
def housekeeping():
|
||||||
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
|
||||||
@@ -109,14 +109,14 @@ def housekeeping():
|
|||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
|
f"Aufgeräumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
|
||||||
f"{len(archived)} archivierte Protokolltage.",
|
f"{len(archived)} archivierte Protokolltage.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("diagnostics.overview"))
|
return redirect(url_for("diagnostics.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/schluessel-rotieren")
|
@bp.post("/rotate-keys")
|
||||||
@admin_required
|
@admin_required
|
||||||
def rotate_keys():
|
def rotate_keys():
|
||||||
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
|
||||||
@@ -139,12 +139,12 @@ def rotate_keys():
|
|||||||
actor_id=current_user.id,
|
actor_id=current_user.id,
|
||||||
action="system.data_key_rotated",
|
action="system.data_key_rotated",
|
||||||
target=new_id,
|
target=new_id,
|
||||||
detail=f"{migrated} Werte neu verschluesselt",
|
detail=f"{migrated} Werte neu verschlüsselt",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
flash(
|
flash(
|
||||||
f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
|
f"Neuer Datenschlüssel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
|
||||||
"wurden neu verschluesselt.",
|
"wurden neu verschlüsselt.",
|
||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
return redirect(url_for("diagnostics.overview"))
|
return redirect(url_for("diagnostics.overview"))
|
||||||
@@ -29,7 +29,7 @@ from ..licensing_client import LicenseError
|
|||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
from ..system import hostnet, webserver
|
from ..system import hostnet, webserver
|
||||||
|
|
||||||
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen")
|
bp = Blueprint("hostsettings", __name__, url_prefix="/administration")
|
||||||
|
|
||||||
PENDING_NETWORK_KEY = "pending_network_revert"
|
PENDING_NETWORK_KEY = "pending_network_revert"
|
||||||
|
|
||||||
@@ -64,8 +64,8 @@ def _license_hint(feature: str) -> str:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/netzwerk")
|
@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
|
||||||
@@ -80,13 +80,13 @@ def network():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/netzwerk")
|
@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
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
|
|
||||||
interface = (request.form.get("interface") or "").strip()
|
interface = (request.form.get("interface") or "").strip()
|
||||||
@@ -127,7 +127,7 @@ def network_apply():
|
|||||||
sysops.write_netplan(previous.stdout if previous.ok else "")
|
sysops.write_netplan(previous.stdout if previous.ok else "")
|
||||||
sysops.apply_netplan()
|
sysops.apply_netplan()
|
||||||
flash(
|
flash(
|
||||||
f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
|
f"Übernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
@@ -140,21 +140,21 @@ def network_apply():
|
|||||||
_start_revert_timer(extension)
|
_start_revert_timer(extension)
|
||||||
_audit("settings.network_applied", interface, severity="warning")
|
_audit("settings.network_applied", interface, severity="warning")
|
||||||
flash(
|
flash(
|
||||||
f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- "
|
f"Übernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestätigen – "
|
||||||
"sonst wird automatisch zurueckgerollt.",
|
"sonst wird automatisch zurückgerollt.",
|
||||||
"warning",
|
"warning",
|
||||||
)
|
)
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/netzwerk/bestaetigen")
|
@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 Aenderung zur Bestaetigung vor.", "info")
|
flash("Es liegt keine Änderung zur Bestätigung vor.", "info")
|
||||||
else:
|
else:
|
||||||
_audit("settings.network_confirmed", severity="notice")
|
_audit("settings.network_confirmed", severity="notice")
|
||||||
flash("Netzwerkeinstellungen bestaetigt.", "success")
|
flash("Netzwerkeinstellungen bestätigt.", "success")
|
||||||
return redirect(url_for("hostsettings.network"))
|
return redirect(url_for("hostsettings.network"))
|
||||||
|
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ def _start_revert_timer(extension: Any) -> None:
|
|||||||
conn,
|
conn,
|
||||||
actor="system",
|
actor="system",
|
||||||
action="settings.network_auto_reverted",
|
action="settings.network_auto_reverted",
|
||||||
detail="Keine Bestaetigung innerhalb der Frist -- automatisch zurueckgerollt.",
|
detail="Keine Bestätigung innerhalb der Frist – automatisch zurückgerollt.",
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -221,8 +221,8 @@ def _web_config() -> webserver.WebConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/webserver")
|
@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()
|
||||||
@@ -240,8 +240,8 @@ def web():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver")
|
@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()
|
||||||
@@ -281,7 +281,7 @@ def web_save():
|
|||||||
cert_path = (request.form.get("web_cert_path") or "").strip()
|
cert_path = (request.form.get("web_cert_path") or "").strip()
|
||||||
key_path = (request.form.get("web_key_path") or "").strip()
|
key_path = (request.form.get("web_key_path") or "").strip()
|
||||||
if https_enabled and not (cert_path and key_path):
|
if https_enabled and not (cert_path and key_path):
|
||||||
flash("Fuer HTTPS werden Zertifikat und privater Schluessel gebraucht.", "danger")
|
flash("Für HTTPS werden Zertifikat und privater Schlüssel gebraucht.", "danger")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
values = {
|
values = {
|
||||||
@@ -298,11 +298,11 @@ def web_save():
|
|||||||
with transaction():
|
with transaction():
|
||||||
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
changed = extension.settings.set_many(conn, values, actor=current_user.username)
|
||||||
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
|
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
|
||||||
flash("Gespeichert. Uebernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
|
flash("Gespeichert. Übernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/uebernehmen")
|
@bp.post("/web/apply")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_apply():
|
def web_apply():
|
||||||
problem = _license_hint("nginx")
|
problem = _license_hint("nginx")
|
||||||
@@ -311,7 +311,7 @@ def web_apply():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
sysops = core().sysops
|
sysops = core().sysops
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
result = webserver.apply_config(sysops, webserver.render(_web_config()))
|
result = webserver.apply_config(sysops, webserver.render(_web_config()))
|
||||||
@@ -327,7 +327,7 @@ def web_apply():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/zertifikat")
|
@bp.post("/web/certificate")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_certificate():
|
def web_certificate():
|
||||||
problem = _license_hint("nginx")
|
problem = _license_hint("nginx")
|
||||||
@@ -338,7 +338,7 @@ def web_certificate():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
conn = db()
|
conn = db()
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
config = _web_config()
|
config = _web_config()
|
||||||
@@ -364,7 +364,7 @@ def web_certificate():
|
|||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/webserver/selbstsigniert")
|
@bp.post("/web/self-signed")
|
||||||
@permission_required("settings_web.execute")
|
@permission_required("settings_web.execute")
|
||||||
def web_self_signed():
|
def web_self_signed():
|
||||||
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
|
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
|
||||||
@@ -381,7 +381,7 @@ def web_self_signed():
|
|||||||
sysops = extension.sysops
|
sysops = extension.sysops
|
||||||
conn = db()
|
conn = db()
|
||||||
if sysops is None or not sysops.available:
|
if sysops is None or not sysops.available:
|
||||||
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
|
flash("Systemaktionen sind in dieser Umgebung nicht verfügbar.", "warning")
|
||||||
return redirect(url_for("hostsettings.web"))
|
return redirect(url_for("hostsettings.web"))
|
||||||
|
|
||||||
config = _web_config()
|
config = _web_config()
|
||||||
@@ -414,8 +414,8 @@ def web_self_signed():
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verzeichnisdienst")
|
@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()
|
||||||
@@ -435,8 +435,8 @@ def directory_settings():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst")
|
@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:
|
||||||
@@ -454,8 +454,8 @@ def directory_save():
|
|||||||
|
|
||||||
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
|
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
|
||||||
flash(
|
flash(
|
||||||
"Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort "
|
"Eine unverschlüsselte Verbindung muss ausdrücklich erlaubt werden – das Passwort "
|
||||||
"geht dabei im Klartext ueber das Netz.",
|
"geht dabei im Klartext über das Netz.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
@@ -481,7 +481,7 @@ def directory_save():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst/test")
|
@bp.post("/directory/test")
|
||||||
@permission_required("settings_directory.execute")
|
@permission_required("settings_directory.execute")
|
||||||
def directory_test():
|
def directory_test():
|
||||||
result = directory.test_connection(db())
|
result = directory.test_connection(db())
|
||||||
@@ -489,8 +489,8 @@ def directory_test():
|
|||||||
return redirect(url_for("hostsettings.directory_settings"))
|
return redirect(url_for("hostsettings.directory_settings"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verzeichnisdienst/gruppensuche")
|
@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.
|
||||||
|
|
||||||
@@ -504,22 +504,31 @@ def directory_group_search():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verzeichnisdienst/zuordnung")
|
@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("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen")
|
@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,))
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from ..auth.service import client_ip
|
|||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
from ..security.sessions import fragment
|
from ..security.sessions import fragment
|
||||||
|
|
||||||
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
|
bp = Blueprint("logs", __name__, url_prefix="/logs")
|
||||||
|
|
||||||
MAX_TAIL_LINES = 2000
|
MAX_TAIL_LINES = 2000
|
||||||
|
|
||||||
@@ -38,8 +38,8 @@ def _live_log():
|
|||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/laufend")
|
@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()
|
||||||
@@ -52,8 +52,8 @@ def live():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/laufend/inhalt")
|
@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)
|
||||||
@@ -63,8 +63,8 @@ def live_fragment():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf")
|
@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 = []
|
||||||
@@ -82,8 +82,8 @@ def history():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf/datei/<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()
|
||||||
@@ -101,8 +101,8 @@ def history_file(name: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/verlauf/archiv/<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)
|
||||||
@@ -112,7 +112,7 @@ def history_archive(day: str):
|
|||||||
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
|
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verlauf/archiv-export")
|
@bp.post("/history/archive-export")
|
||||||
@permission_required("logs_history.export")
|
@permission_required("logs_history.export")
|
||||||
def history_export():
|
def history_export():
|
||||||
"""Packt die archivierten Audit-Tage in ein ZIP.
|
"""Packt die archivierten Audit-Tage in ein ZIP.
|
||||||
@@ -152,7 +152,7 @@ def history_export():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/verlauf/archiv-loeschen")
|
@bp.post("/history/archive-delete")
|
||||||
@permission_required("logs_history.delete")
|
@permission_required("logs_history.delete")
|
||||||
def history_purge():
|
def history_purge():
|
||||||
removed = 0
|
removed = 0
|
||||||
@@ -176,20 +176,20 @@ def history_purge():
|
|||||||
return redirect(url_for("logs.history"))
|
return redirect(url_for("logs.history"))
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/aenderungen")
|
@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("seite") or 1))
|
page = max(1, int(request.args.get("page") or 1))
|
||||||
per_page = 100
|
per_page = 100
|
||||||
return render_template(
|
return render_template(
|
||||||
"tesm_core/logs_audit.html",
|
"tesm_core/logs_audit.html",
|
||||||
rows=audit.search(
|
rows=audit.search(
|
||||||
conn,
|
conn,
|
||||||
query=(request.args.get("q") or "").strip(),
|
query=(request.args.get("q") or "").strip(),
|
||||||
action=(request.args.get("aktion") or "").strip(),
|
action=(request.args.get("action") or "").strip(),
|
||||||
actor=(request.args.get("benutzer") or "").strip(),
|
actor=(request.args.get("actor") or "").strip(),
|
||||||
severity=(request.args.get("stufe") or "").strip(),
|
severity=(request.args.get("severity") or "").strip(),
|
||||||
limit=per_page,
|
limit=per_page,
|
||||||
offset=(page - 1) * per_page,
|
offset=(page - 1) * per_page,
|
||||||
),
|
),
|
||||||
@@ -200,14 +200,14 @@ def audit_view():
|
|||||||
per_page=per_page,
|
per_page=per_page,
|
||||||
filters={
|
filters={
|
||||||
"q": request.args.get("q") or "",
|
"q": request.args.get("q") or "",
|
||||||
"aktion": request.args.get("aktion") or "",
|
"action": request.args.get("action") or "",
|
||||||
"benutzer": request.args.get("benutzer") or "",
|
"actor": request.args.get("actor") or "",
|
||||||
"stufe": request.args.get("stufe") or "",
|
"severity": request.args.get("severity") or "",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/aenderungen/export")
|
@bp.get("/audit/export")
|
||||||
@permission_required("logs_audit.export")
|
@permission_required("logs_audit.export")
|
||||||
def audit_export():
|
def audit_export():
|
||||||
conn = db()
|
conn = db()
|
||||||
@@ -235,7 +235,7 @@ def audit_export():
|
|||||||
mimetype="text/csv; charset=utf-8",
|
mimetype="text/csv; charset=utf-8",
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": (
|
"Content-Disposition": (
|
||||||
f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"'
|
f'attachment; filename="{core().config.app_key}-änderungsprotokoll.csv"'
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ from ..db import Database
|
|||||||
from ..extension import core, db, transaction
|
from ..extension import core, db, transaction
|
||||||
from ..rbac import service as rbac
|
from ..rbac import service as rbac
|
||||||
|
|
||||||
bp = Blueprint("trash", __name__, url_prefix="/papierkorb")
|
bp = Blueprint("trash", __name__, url_prefix="/administration/trash")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -100,7 +100,7 @@ def register_core_providers() -> None:
|
|||||||
list_fn=_users_in_trash,
|
list_fn=_users_in_trash,
|
||||||
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
|
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
|
||||||
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
|
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
|
||||||
hint="Verzeichniskonten landen nie hier -- sie legen sich bei der naechsten "
|
hint="Verzeichniskonten landen nie hier – sie legen sich bei der nächsten "
|
||||||
"Anmeldung von selbst wieder an.",
|
"Anmeldung von selbst wieder an.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -112,7 +112,7 @@ def register_core_providers() -> None:
|
|||||||
list_fn=_groups_in_trash,
|
list_fn=_groups_in_trash,
|
||||||
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
|
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
|
||||||
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
|
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
|
||||||
hint="Beim endgueltigen Loeschen verschwinden auch Mitgliedschaften und Rechte.",
|
hint="Beim endgültigen Löschen verschwinden auch Mitgliedschaften und Rechte.",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ def _purge_expired(conn: sqlite3.Connection) -> int:
|
|||||||
|
|
||||||
|
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@permission_required("trash.view")
|
@permission_required("trash.read")
|
||||||
def overview():
|
def overview():
|
||||||
conn = db()
|
conn = db()
|
||||||
removed = 0
|
removed = 0
|
||||||
@@ -158,11 +158,11 @@ def overview():
|
|||||||
conn,
|
conn,
|
||||||
actor="system",
|
actor="system",
|
||||||
action="trash.auto_purged",
|
action="trash.auto_purged",
|
||||||
detail=f"{removed} abgelaufene Eintraege",
|
detail=f"{removed} abgelaufene Einträge",
|
||||||
severity="notice",
|
severity="notice",
|
||||||
)
|
)
|
||||||
if removed:
|
if removed:
|
||||||
flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info")
|
flash(f"{removed} abgelaufene Einträge wurden endgültig entfernt.", "info")
|
||||||
|
|
||||||
sections = []
|
sections = []
|
||||||
for provider in providers().values():
|
for provider in providers().values():
|
||||||
@@ -191,14 +191,14 @@ def _provider_or_404(key: str) -> TrashProvider:
|
|||||||
return provider
|
return provider
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<ident>/wiederherstellen")
|
@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"):
|
||||||
flash(
|
flash(
|
||||||
"Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, "
|
"Das Wiederherstellen aus dem Papierkorb erfordert eine gültige, "
|
||||||
"aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.",
|
"aktivierte Lizenz. Ansehen bleibt jederzeit möglich.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
@@ -222,13 +222,13 @@ def restore(kind: str, ident: str):
|
|||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/<kind>/<ident>/endgueltig-loeschen")
|
@bp.post("/<kind>/<ident>/purge")
|
||||||
@permission_required("trash.delete")
|
@permission_required("trash.delete")
|
||||||
def purge(kind: str, ident: str):
|
def purge(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"):
|
||||||
flash(
|
flash(
|
||||||
"Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.",
|
"Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.",
|
||||||
"danger",
|
"danger",
|
||||||
)
|
)
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
@@ -246,18 +246,18 @@ def purge(kind: str, ident: str):
|
|||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash("Eintrag endgueltig entfernt.", "success")
|
flash("Eintrag endgültig entfernt.", "success")
|
||||||
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
|
||||||
flash(str(exc), "danger")
|
flash(str(exc), "danger")
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/leeren")
|
@bp.post("/empty")
|
||||||
@permission_required("trash.delete")
|
@permission_required("trash.delete")
|
||||||
def purge_all():
|
def purge_all():
|
||||||
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"):
|
||||||
flash("Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "danger")
|
flash("Das endgültige Löschen erfordert eine gültige, aktivierte Lizenz.", "danger")
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
|
|
||||||
kind = request.form.get("kind") or ""
|
kind = request.form.get("kind") or ""
|
||||||
@@ -274,9 +274,9 @@ def purge_all():
|
|||||||
actor_id=current_user.id,
|
actor_id=current_user.id,
|
||||||
action="trash.emptied",
|
action="trash.emptied",
|
||||||
target=kind or "alle",
|
target=kind or "alle",
|
||||||
detail=f"{removed} Eintraege",
|
detail=f"{removed} Einträge",
|
||||||
ip=client_ip(),
|
ip=client_ip(),
|
||||||
severity="warning",
|
severity="warning",
|
||||||
)
|
)
|
||||||
flash(f"{removed} Eintraege endgueltig entfernt.", "success")
|
flash(f"{removed} Einträge endgültig entfernt.", "success")
|
||||||
return redirect(url_for("trash.overview"))
|
return redirect(url_for("trash.overview"))
|
||||||
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing-server"
|
name = "tesm-licensing-server"
|
||||||
version = "2.3.2"
|
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.3.2", "cryptography>=41"]
|
dependencies = ["tesm-licensing==2.7.7", "cryptography>=41"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
@@ -86,7 +86,7 @@ def _validate_issue_args(
|
|||||||
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
|
# Dritte und letzte Absicherung derselben Regel (UI + Service pruefen
|
||||||
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
|
# vorher schon): eine unbefristete Trial-Lizenz waere eine permanente
|
||||||
# Lizenz ohne Module -- ein sinnloser Zustand.
|
# Lizenz ohne Module -- ein sinnloser Zustand.
|
||||||
raise ValueError("Trial-Lizenzen koennen nicht unbefristet sein")
|
raise ValueError("Trial-Lizenzen können nicht unbefristet sein")
|
||||||
if role != "customer" and modules:
|
if role != "customer" and modules:
|
||||||
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
|
raise ValueError("Lizenzserver-Lizenzen tragen keine TESM-Module")
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ def verify_client_request(
|
|||||||
if not isinstance(request, Mapping):
|
if not isinstance(request, Mapping):
|
||||||
raise ProtocolViolation("Anfrage ist kein Objekt")
|
raise ProtocolViolation("Anfrage ist kein Objekt")
|
||||||
if int(request.get("fmt") or 0) != FORMAT_VERSION:
|
if int(request.get("fmt") or 0) != FORMAT_VERSION:
|
||||||
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
|
raise ProtocolViolation("Nicht unterstützte Protokollversion")
|
||||||
|
|
||||||
action = str(request.get("action") or "")
|
action = str(request.get("action") or "")
|
||||||
if action not in ACTIONS:
|
if action not in ACTIONS:
|
||||||
@@ -214,7 +214,7 @@ def verify_client_request(
|
|||||||
if not verify(
|
if not verify(
|
||||||
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
|
payload, str(request.get("signature") or ""), license_public_key, SIG_CONTEXT_REQUEST
|
||||||
):
|
):
|
||||||
raise SignatureInvalid("Signatur der Anfrage ist ungueltig")
|
raise SignatureInvalid("Signatur der Anfrage ist ungültig")
|
||||||
|
|
||||||
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
|
# Frische und Replay erst *nach* der Signaturpruefung: unsignierte Muellanfragen
|
||||||
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
|
# sollen keine Nonce-Eintraege in der Datenbank erzeugen koennen.
|
||||||
@@ -229,7 +229,7 @@ def verify_client_request(
|
|||||||
raise ProtocolViolation("Bericht ist kein Objekt")
|
raise ProtocolViolation("Bericht ist kein Objekt")
|
||||||
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
|
total = sum(len(report.get(key) or ()) for key in ("customers", "tickets", "licenses"))
|
||||||
if total > MAX_REPORT_ROWS:
|
if total > MAX_REPORT_ROWS:
|
||||||
raise ProtocolViolation("Bericht ueberschreitet die Groessengrenze")
|
raise ProtocolViolation("Bericht überschreitet die Größengrenze")
|
||||||
|
|
||||||
|
|
||||||
def build_master_response(
|
def build_master_response(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "tesm-licensing"
|
name = "tesm-licensing"
|
||||||
version = "2.3.2"
|
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"]
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ def _b64d(value: str, *, field: str) -> bytes:
|
|||||||
try:
|
try:
|
||||||
return base64.b64decode(value, validate=True)
|
return base64.b64decode(value, validate=True)
|
||||||
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
|
except (binascii.Error, ValueError, TypeError) as exc: # pragma: no cover - defensiv
|
||||||
raise ValueError(f"{field} ist kein gueltiges Base64") from exc
|
raise ValueError(f"{field} ist kein gültiges Base64") from exc
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ def decode_code(code: str) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
|
data = json.loads(base64.urlsafe_b64decode(cleaned.encode("ascii")).decode("utf-8"))
|
||||||
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
except (binascii.Error, ValueError, UnicodeDecodeError) as exc:
|
||||||
raise ValueError("Code ist unlesbar oder beschaedigt") from exc
|
raise ValueError("Code ist unlesbar oder beschädigt") from exc
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise ValueError("Code enthaelt kein Objekt")
|
raise ValueError("Code enthält kein Objekt")
|
||||||
return data
|
return data
|
||||||
@@ -161,7 +161,7 @@ def split_bundle(bundle: Mapping[str, Any]) -> tuple[dict[str, Any], str]:
|
|||||||
eingebettetem ``license_privkey``.
|
eingebettetem ``license_privkey``.
|
||||||
"""
|
"""
|
||||||
if not isinstance(bundle, Mapping):
|
if not isinstance(bundle, Mapping):
|
||||||
raise InvalidLicenseFile("Lizenzdatei enthaelt kein Objekt")
|
raise InvalidLicenseFile("Lizenzdatei enthält kein Objekt")
|
||||||
|
|
||||||
inner = bundle.get("license")
|
inner = bundle.get("license")
|
||||||
if isinstance(inner, Mapping):
|
if isinstance(inner, Mapping):
|
||||||
@@ -194,10 +194,10 @@ def verify_license(
|
|||||||
|
|
||||||
missing = [name for name in _PAYLOAD_FIELDS if name not in document]
|
missing = [name for name in _PAYLOAD_FIELDS if name not in document]
|
||||||
if missing:
|
if missing:
|
||||||
raise InvalidLicenseFile(f"Lizenzdokument unvollstaendig: {', '.join(missing)}")
|
raise InvalidLicenseFile(f"Lizenzdokument unvollständig: {', '.join(missing)}")
|
||||||
if int(document.get("fmt") or 0) != FORMAT_VERSION:
|
if int(document.get("fmt") or 0) != FORMAT_VERSION:
|
||||||
raise InvalidLicenseFile(
|
raise InvalidLicenseFile(
|
||||||
f"Nicht unterstuetzte Formatversion: {document.get('fmt')!r}"
|
f"Nicht unterstützte Formatversion: {document.get('fmt')!r}"
|
||||||
f" (erwartet {FORMAT_VERSION})"
|
f" (erwartet {FORMAT_VERSION})"
|
||||||
)
|
)
|
||||||
if document.get("type") not in LICENSE_TYPES:
|
if document.get("type") not in LICENSE_TYPES:
|
||||||
@@ -207,17 +207,17 @@ def verify_license(
|
|||||||
|
|
||||||
master_pubkey = str(document.get("master_pubkey") or "")
|
master_pubkey = str(document.get("master_pubkey") or "")
|
||||||
if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
|
if key_id(master_pubkey) != str(document.get("master_key_id") or ""):
|
||||||
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schluessel")
|
raise InvalidLicenseFile("master_key_id passt nicht zum eingebetteten Schlüssel")
|
||||||
|
|
||||||
payload = {k: v for k, v in document.items() if k != "signature"}
|
payload = {k: v for k, v in document.items() if k != "signature"}
|
||||||
if not verify(
|
if not verify(
|
||||||
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
|
payload, str(document.get("signature") or ""), master_pubkey, SIG_CONTEXT_LICENSE
|
||||||
):
|
):
|
||||||
raise SignatureInvalid("Signatur der Lizenzdatei ist ungueltig")
|
raise SignatureInvalid("Signatur der Lizenzdatei ist ungültig")
|
||||||
|
|
||||||
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
|
if trusted_master_keys and master_pubkey not in set(trusted_master_keys):
|
||||||
raise UntrustedIssuer(
|
raise UntrustedIssuer(
|
||||||
"Die Lizenz wurde nicht von einem vertrauenswuerdigen Lizenzserver ausgestellt"
|
"Die Lizenz wurde nicht von einem vertrauenswürdigen Lizenzserver ausgestellt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ def license_status(
|
|||||||
lifetime = bool(document.get("lifetime"))
|
lifetime = bool(document.get("lifetime"))
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
if not issuer_pinned:
|
if not issuer_pinned:
|
||||||
warnings.append("Aussteller nicht gepinnt -- nur innere Konsistenz geprueft")
|
warnings.append("Aussteller nicht gepinnt – nur innere Konsistenz geprüft")
|
||||||
|
|
||||||
issued_at: datetime | None = None
|
issued_at: datetime | None = None
|
||||||
expires_at: datetime | None = None
|
expires_at: datetime | None = None
|
||||||
@@ -248,7 +248,7 @@ def license_status(
|
|||||||
try:
|
try:
|
||||||
parsed = parse_iso(str(raw))
|
parsed = parse_iso(str(raw))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
warnings.append(f"Feld {name} ist kein gueltiges Datum")
|
warnings.append(f"Feld {name} ist kein gültiges Datum")
|
||||||
continue
|
continue
|
||||||
if name == "issued_at":
|
if name == "issued_at":
|
||||||
issued_at = parsed
|
issued_at = parsed
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ def build_client_request(
|
|||||||
if action not in ACTIONS:
|
if action not in ACTIONS:
|
||||||
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
|
raise ProtocolViolation(f"Unbekannte Aktion: {action!r}")
|
||||||
if not client_private_key:
|
if not client_private_key:
|
||||||
raise ProtocolViolation("Kein Client-Schluessel vorhanden -- Lizenz unvollstaendig importiert")
|
raise ProtocolViolation("Kein Client-Schlüssel vorhanden | Lizenz unvollständig importiert")
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"fmt": FORMAT_VERSION,
|
"fmt": FORMAT_VERSION,
|
||||||
@@ -130,22 +130,22 @@ def verify_master_response(
|
|||||||
if not isinstance(response, Mapping):
|
if not isinstance(response, Mapping):
|
||||||
raise ProtocolViolation("Antwort ist kein Objekt")
|
raise ProtocolViolation("Antwort ist kein Objekt")
|
||||||
if int(response.get("fmt") or 0) != FORMAT_VERSION:
|
if int(response.get("fmt") or 0) != FORMAT_VERSION:
|
||||||
raise ProtocolViolation("Nicht unterstuetzte Protokollversion")
|
raise ProtocolViolation("Nicht unterstützte Protokollversion")
|
||||||
|
|
||||||
payload = {k: v for k, v in response.items() if k != "signature"}
|
payload = {k: v for k, v in response.items() if k != "signature"}
|
||||||
if not verify(
|
if not verify(
|
||||||
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
|
payload, str(response.get("signature") or ""), master_public_key, SIG_CONTEXT_RESPONSE
|
||||||
):
|
):
|
||||||
raise SignatureInvalid("Signatur der Antwort ist ungueltig")
|
raise SignatureInvalid("Signatur der Antwort ist ungültig")
|
||||||
|
|
||||||
if str(response.get("license_id") or "") != expected_license_id:
|
if str(response.get("license_id") or "") != expected_license_id:
|
||||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Lizenz")
|
raise ProtocolViolation("Antwort gehört zu einer anderen Lizenz")
|
||||||
if str(response.get("fingerprint") or "") != expected_fingerprint:
|
if str(response.get("fingerprint") or "") != expected_fingerprint:
|
||||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Maschine")
|
raise ProtocolViolation("Antwort gehört zu einer anderen Maschine")
|
||||||
if str(response.get("action") or "") != expected_action:
|
if str(response.get("action") or "") != expected_action:
|
||||||
raise ProtocolViolation("Antwort gehoert zu einer anderen Aktion")
|
raise ProtocolViolation("Antwort gehört zu einer anderen Aktion")
|
||||||
if str(response.get("nonce") or "") != expected_nonce:
|
if str(response.get("nonce") or "") != expected_nonce:
|
||||||
raise ReplayDetected("Antwort gehoert nicht zu dieser Anfrage")
|
raise ReplayDetected("Antwort gehört nicht zu dieser Anfrage")
|
||||||
if str(response.get("status") or "") not in RESPONSE_STATES:
|
if str(response.get("status") or "") not in RESPONSE_STATES:
|
||||||
raise ProtocolViolation("Unbekannter Antwortstatus")
|
raise ProtocolViolation("Unbekannter Antwortstatus")
|
||||||
|
|
||||||
@@ -156,4 +156,4 @@ def verify_master_response(
|
|||||||
if not isinstance(update, Mapping):
|
if not isinstance(update, Mapping):
|
||||||
raise ProtocolViolation("license_update ist kein Objekt")
|
raise ProtocolViolation("license_update ist kein Objekt")
|
||||||
if int(update.get("seq") or 0) < int(current_seq or 0):
|
if int(update.get("seq") or 0) < int(current_seq or 0):
|
||||||
raise ReplayDetected("license_update ist aelter als die installierte Lizenz")
|
raise ReplayDetected("license_update ist älter als die installierte Lizenz")
|
||||||
Reference in new issue
Block a user