TESM-Lizenzserver 2.0.0 -- Neubau

Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert
im Zweig SONNET5 erhalten.

Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern),
packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd,
privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem
eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit,
damit sich jedes allein installieren laesst.

Die wichtigsten Unterschiede zum Vorgaenger:

* Keine doppelte licensing.py -- ein Paket, das beide Anwendungen
  installieren, statt zweier Dateien, die byte-identisch bleiben sollen.
* Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber
  einen einzigen Helfer mit Positivlisten fuer jedes Argument.
* CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen.
* Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet.
* Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird
  abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann.
* Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als
  "Aktivierung offen" markiert, bis sie zurueckkommt.
* Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung,
  seq gegen das Zurueckrollen auf eine aeltere Lizenz.
* Kein Hostname im Maschinen-Fingerabdruck.
* Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 18:01:23 +02:00
co-authored by Claude Opus 5
parent c130c85b17
commit f7805a2180
211 changed files with 31099 additions and 14759 deletions
@@ -0,0 +1,38 @@
"""tesm_core -- gemeinsames Anwendungsgeruest von TESM und TESM-Lizenzserver.
Enthaelt alles, was beide Anwendungen teilen: Konfiguration, Datenbankzugriff,
Migrationen, Schluesselverwaltung, Sitzungen, CSRF, Rechtesystem, Navigation,
Benutzer-/Gruppen-/Kontoverwaltung, Papierkorb, Diagnose und das
Design-System.
"""
from __future__ import annotations
__version__ = "2.0.0"
from .app_factory import create_base_app
from .config import CoreConfig, SecurityConfig, load_core_config
from .db import Database, NestedConnectionError
from .extension import TesmCore, core, db, transaction
from .migrations import Migration
from .nav import NavItem, NavTree
from .settings_store import SettingSpec, SettingsStore
__all__ = [
"CoreConfig",
"Database",
"Migration",
"NavItem",
"NavTree",
"NestedConnectionError",
"SecurityConfig",
"SettingSpec",
"SettingsStore",
"TesmCore",
"core",
"create_base_app",
"db",
"load_core_config",
"transaction",
"__version__",
]
@@ -0,0 +1,374 @@
"""Anwendungsfabrik: verdrahtet alle Bausteine zu einer lauffaehigen Flask-App.
Jede der beiden Anwendungen ruft ``create_base_app()`` und ergaenzt danach nur
noch ihre eigenen Blueprints. Alles, was beide teilen -- Datenbank,
Migrationen, Sitzungen, CSRF, Header, Protokollierung, Rechtesystem,
Navigation, Benutzer-/Gruppenverwaltung, Kontoseite, Diagnose -- entsteht hier
genau einmal.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone
from typing import Any, Sequence
from flask import Blueprint, Flask, g, render_template, request
from werkzeug.exceptions import HTTPException
from . import audit, logging_setup, ui
from .config import CoreConfig
from .core_schema import CORE_MIGRATIONS
from .db import Database
from .extension import TesmCore, close_db, core, db
from .keystore import Keystore, load_or_create_secret_key
from .licensing_client import LicenseManager
from .migrations import Migration
from .nav import NavTree
from .rbac import service as rbac
from .rbac.model import PermissionTree, RolePreset
from .security import csrf
from .security import sessions as session_store
from .security.sessions import SqliteSessionInterface
from .settings_store import SettingSpec, SettingsStore
from .sysops import SysOps
from .auth import directory
from .auth import routes as auth_routes
from .auth import service as auth_service
from .auth.models import ANONYMOUS, current_user
from .views import account as account_views
from .views import admin as admin_views
from .views import diagnostics as diagnostics_views
from .views import hostsettings as hostsettings_views
from .views import logs as logs_views
from .views import trash as trash_views
CORE_SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("nav_order", [], "json", "Reihenfolge der Navigation", section="intern"),
SettingSpec("nav_child_order", {}, "json", "Reihenfolge der Unterpunkte", section="intern"),
SettingSpec(
"trash_retention_days",
30,
"int",
"Aufbewahrung im Papierkorb (Tage)",
hint="Nach dieser Frist werden Eintraege beim naechsten Aufruf endgueltig entfernt.",
section="system",
minimum=1,
maximum=3650,
),
SettingSpec("timezone", "", "str", "Zeitzone des Hosts", section="system"),
SettingSpec("hostname", "", "str", "Hostname", section="system"),
SettingSpec(
"log_rotate_interval",
"weekly",
"str",
"Rotationsintervall der Protokolle",
section="system",
choices=(("daily", "taeglich"), ("weekly", "woechentlich"), ("monthly", "monatlich")),
),
SettingSpec(
"log_rotate_keep", 4, "int", "Aufbewahrte Rotationen", section="system", minimum=1, maximum=52
),
# -- Webserver/TLS (Host-Ebene, in beiden Anwendungen gleich) ----------- #
# Lizenzserver: eigenes Zertifikat statt abgeschalteter Pruefung.
SettingSpec(
"license_server_ca",
"",
"str",
"Zertifikat des Lizenzservers (PEM)",
section="license",
),
SettingSpec("web_domain", "", "str", "Domain", section="web"),
SettingSpec("web_https_enabled", False, "bool", "HTTPS aktiv", 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_cert_path", "", "str", "Zertifikat (Pfad)", section="web"),
SettingSpec("web_key_path", "", "str", "Privater Schluessel (Pfad)", section="web"),
SettingSpec("web_acme_email", "", "str", "E-Mail fuer Let's Encrypt", section="web"),
SettingSpec("web_acme_webroot", "", "str", "ACME-Webroot", section="web"),
# Abweichende Ports braucht nur eine Testinstanz neben der Produktion --
# im Regelbetrieb bleibt es bei 80 und 443.
SettingSpec(
"web_http_port", 80, "int", "HTTP-Port", section="web", minimum=1, maximum=65535
),
SettingSpec(
"web_https_port", 443, "int", "HTTPS-Port", section="web", minimum=1, maximum=65535
),
SettingSpec(
"web_max_body_mb", 128, "int", "Maximale Anfragegroesse (MB)", section="web",
minimum=1, maximum=8192,
),
*directory.SETTINGS,
)
def create_base_app(
*,
config: CoreConfig,
permissions: PermissionTree,
nav: NavTree,
role_presets: Sequence[RolePreset],
app_migrations: dict[str, Sequence[Migration]] | None = None,
settings_specs: Sequence[SettingSpec] = (),
import_name: str = "tesm_core",
app_short: str = "TESM",
app_edition: str = "",
#: Zeile unter der Wortmarke auf den Anmeldeseiten. Leer = kein
#: Seitenhintergrund. Der Lizenzserver setzt sie bewusst nicht: die
#: Funktionsliste von TESM waere dort schlicht falsch.
auth_tagline: str = "",
app_version: str = "2.0.0",
built_in_trusted_keys: tuple[str, ...] = (),
enable_license: bool = True,
template_folder: str | None = None,
static_folder: str | None = None,
) -> Flask:
app = Flask(
import_name,
template_folder=template_folder or "templates",
static_folder=static_folder or "static",
static_url_path="/assets",
)
# Templates und Design-System des Kerns liegen im Paket ``tesm_core`` und
# werden ueber einen eigenen Blueprint bereitgestellt. Dadurch kann jede
# Anwendung zusaetzlich ihre eigenen templates/static mitbringen, ohne dass
# sich Pfade in die Quere kommen.
core_bp = Blueprint(
"tesm_core",
"tesm_core",
template_folder="templates",
static_folder="static/tesm_core",
static_url_path="/core-assets",
)
app.register_blueprint(core_bp)
config.data_dir.mkdir(parents=True, exist_ok=True)
config.log_dir.mkdir(parents=True, exist_ok=True)
app.config.update(
SECRET_KEY=load_or_create_secret_key(config.secret_key_file),
# site_key statt app_key: Cookies unterscheiden keine Ports. Zwei
# Instanzen derselben Anwendung auf einem Host wuerden sich sonst
# gegenseitig die Sitzung ueberschreiben.
SESSION_COOKIE_NAME=f"{config.site_key.replace('-', '_')}_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SECURE=config.security.cookie_secure,
SESSION_COOKIE_SAMESITE=config.security.cookie_samesite,
MAX_CONTENT_LENGTH=config.security.max_content_length,
JSON_SORT_KEYS=False,
TRAP_HTTP_EXCEPTIONS=False,
PREFERRED_URL_SCHEME="https" if config.security.cookie_secure else "http",
TEMPLATES_AUTO_RELOAD=config.debug,
EXPLAIN_TEMPLATE_LOADING=False,
)
app.debug = config.debug
logging_setup.init_app(app, log_dir=config.log_dir, debug=config.debug)
database = Database(config.db_path)
settings = SettingsStore([*CORE_SETTINGS, *settings_specs])
keystore = Keystore(config.data_key_file)
extension = TesmCore(
config=config,
database=database,
keystore=keystore,
permissions=permissions,
nav=nav,
settings=settings,
role_presets=tuple(role_presets),
migrations={"core": CORE_MIGRATIONS, **(app_migrations or {})},
sysops=SysOps(app_key=config.app_key, site_key=config.site_key),
)
if enable_license:
extension.license = LicenseManager(
database=database,
settings=settings,
license_path=config.license_path,
key_path=config.license_key_path,
app_key=config.app_key,
built_in_trusted_keys=built_in_trusted_keys,
)
app.extensions["tesm"] = extension
app.session_interface = SqliteSessionInterface(
database,
cookie_name=app.config["SESSION_COOKIE_NAME"],
idle_minutes=config.security.session_idle_minutes,
absolute_hours=config.security.session_absolute_hours,
)
# -- Schema und Startzustand ------------------------------------------ #
applied = extension.run_migrations()
with database.session() as conn, database.transaction(conn):
created = rbac.ensure_presets(conn, permissions, extension.role_presets)
if applied or created:
audit.log(
conn,
actor="system",
action="system.startup_migration",
detail="; ".join([*applied, *(f"Gruppe {name} angelegt" for name in created)]),
severity="notice",
)
session_store.prune(conn)
if extension.license is not None:
with database.session() as conn:
extension.license.reload(conn)
# -- Request-Lebenszyklus --------------------------------------------- #
csrf.init_app(app)
ui.init_app(app)
app.teardown_appcontext(close_db)
@app.before_request
def _prepare_request() -> None:
g.csp_nonce = secrets.token_urlsafe(16)
auth_service.load_request_user()
@app.after_request
def _finish_request(response): # type: ignore[no-untyped-def]
hsts = ""
if config.security.hsts_enabled:
hsts = f"max-age={config.security.hsts_max_age}; includeSubDomains"
return csrf.apply_security_headers(
response, nonce=getattr(g, "csp_nonce", ""), hsts=hsts
)
# -- Template-Kontext -------------------------------------------------- #
from .rbac.model import ACTION_META
@app.context_processor
def _inject_context() -> dict[str, Any]:
user = getattr(g, "user", ANONYMOUS)
conn = db()
order = settings.get(conn, "nav_order") or nav.default_order()
child_order = settings.get(conn, "nav_child_order") or {}
pills: list[dict[str, str]] = []
if extension.license is not None and user.is_authenticated:
pill = extension.license.topbar_pill()
if pill:
pills.append(pill)
pills.extend(extension.registry.get("status_pill_builder", lambda _u: [])(user))
nav_items = nav.resolve(user, order=order, child_order=child_order)
palette = [
{**entry, "url": _safe_url(entry["endpoint"])}
for entry in nav.searchable(user)
if entry["endpoint"]
]
return {
"app_name": config.app_name,
"app_short": app_short,
"app_edition": app_edition,
"auth_tagline": auth_tagline,
"app_version": app_version,
"current_year": datetime.now(timezone.utc).year,
"current_user": current_user,
"nav_items": nav_items,
"palette_entries": palette,
"status_pills": pills,
"csp_nonce": getattr(g, "csp_nonce", ""),
"permission_tree": permissions,
"action_meta": ACTION_META,
"license_state": extension.license.state if extension.license else None,
"license_active": extension.license.active() if extension.license else True,
"module_active": (
extension.license.module_active if extension.license else (lambda _n: True)
),
"feature_allowed": (
extension.license.feature_allowed if extension.license else (lambda _n: True)
),
"migration_warning": bool(extension.registry.get("migration_warning")),
}
# -- Fehlerseiten ------------------------------------------------------ #
@app.errorhandler(HTTPException)
def _http_error(error: HTTPException): # type: ignore[no-untyped-def]
if _wants_json():
return {
"error": error.name,
"message": error.description,
"request_id": getattr(g, "request_id", ""),
}, error.code or 500
return (
render_template(
"tesm_core/error.html",
code=error.code or 500,
name=error.name,
description=error.description,
request_id=getattr(g, "request_id", ""),
back_url=request.referrer or "/",
),
error.code or 500,
)
@app.errorhandler(Exception)
def _unhandled(error: Exception): # type: ignore[no-untyped-def]
app.logger.exception("Unbehandelter Fehler: %s", error)
if _wants_json():
return {
"error": "Interner Fehler",
"message": "Der Vorgang konnte nicht abgeschlossen werden.",
"request_id": getattr(g, "request_id", ""),
}, 500
return (
render_template(
"tesm_core/error.html",
code=500,
name="Interner Fehler",
description=(
"Der Vorgang konnte nicht abgeschlossen werden. Der Fehler wurde "
"protokolliert."
),
request_id=getattr(g, "request_id", ""),
back_url=request.referrer or "/",
),
500,
)
# -- Gemeinsame Blueprints -------------------------------------------- #
app.register_blueprint(auth_routes.bp)
app.register_blueprint(admin_views.bp)
app.register_blueprint(account_views.bp)
app.register_blueprint(diagnostics_views.bp)
app.register_blueprint(trash_views.bp)
app.register_blueprint(hostsettings_views.bp)
app.register_blueprint(logs_views.bp)
with app.app_context():
trash_views.register_core_providers()
app.add_url_rule("/gesundheit", "health", _health, methods=["GET"])
return app
def _safe_url(endpoint: str) -> str:
from flask import url_for
try:
return url_for(endpoint)
except Exception: # noqa: BLE001 - Endpunkt existiert in dieser App nicht
return ""
def _wants_json() -> bool:
if request.path.startswith("/api/"):
return True
accept = request.accept_mimetypes
return bool(accept.accept_json and not accept.accept_html)
def _health() -> tuple[dict[str, Any], int]:
"""Gesundheitsendpunkt fuer install.sh und Monitoring.
Bewusst ohne Anmeldung, aber ohne jede verwertbare Information: nur
"laeuft die App und antwortet die Datenbank".
"""
extension = core()
try:
with extension.database.session() as conn:
conn.execute("SELECT 1").fetchone()
except Exception: # noqa: BLE001
return {"status": "degraded"}, 503
return {"status": "ok", "version": extension.config.app_key}, 200
+283
View File
@@ -0,0 +1,283 @@
"""Aenderungsprotokoll mit Hash-Kette.
Ein Audit-Log, das der Administrator selbst manipulieren kann, ist kein
Audit-Log. Der Vorgaenger schrieb einfache Zeilen in eine Tabelle -- wer
Schreibzugriff auf die SQLite-Datei hatte (auf dem Zielsystem: jeder root, und
die App lief als root), konnte Eintraege spurlos entfernen.
Hier bindet jede Zeile den Hash ihrer Vorgaengerin ein:
hash_n = sha256(hash_(n-1) || kanonische Felder von Zeile n)
Eine geloeschte oder geaenderte Zeile bricht die Kette an genau dieser Stelle.
``verify_chain()`` findet den Bruch; die Diagnoseseite zeigt ihn an. Das
verhindert Manipulation nicht, macht sie aber nachweisbar -- und genau darum
geht es bei einem Protokoll.
Archivierung: ab ``archive_threshold`` Zeilen werden die aeltesten vollstaendigen
Kalendertage in Dateien ``audit-JJJJ-MM-TT.jsonl`` ausgelagert, inklusive ihrer
Hashes, sodass die Kette ueber die Archivgrenze hinweg pruefbar bleibt.
"""
from __future__ import annotations
import hashlib
import json
import logging
import sqlite3
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable
from .db import Database
logger = logging.getLogger(__name__)
GENESIS = "0" * 64
ARCHIVE_THRESHOLD = 20000
ARCHIVE_TARGET = 15000
SEVERITIES = ("info", "notice", "warning", "critical")
def _canonical(row: dict[str, Any]) -> bytes:
payload = {
"ts": row.get("ts", ""),
"actor": row.get("actor", ""),
"actor_id": row.get("actor_id"),
"action": row.get("action", ""),
"target": row.get("target", ""),
"detail": row.get("detail", ""),
"ip": row.get("ip", ""),
"request_id": row.get("request_id", ""),
"severity": row.get("severity", "info"),
}
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
def compute_hash(prev_hash: str, row: dict[str, Any]) -> str:
return hashlib.sha256((prev_hash or GENESIS).encode("ascii") + _canonical(row)).hexdigest()
def log(
conn: sqlite3.Connection,
*,
actor: str,
action: str,
target: str = "",
detail: str = "",
actor_id: int | None = None,
ip: str = "",
request_id: str = "",
severity: str = "info",
ts: datetime | None = None,
) -> int:
"""Schreibt einen Protokolleintrag auf der uebergebenen Verbindung.
Wichtig: **immer** die bestehende Verbindung nutzen. Eine Protokollfunktion,
die selbst eine Verbindung oeffnet, blockiert die laufende Transaktion --
das war im Vorgaenger eine reale Deadlock-Ursache.
"""
if severity not in SEVERITIES:
severity = "info"
row = {
"ts": (ts or datetime.now(timezone.utc)).isoformat(timespec="seconds"),
"actor": actor or "system",
"actor_id": actor_id,
"action": action,
"target": target,
"detail": detail,
"ip": ip,
"request_id": request_id,
"severity": severity,
}
prev_hash = (
Database.value(conn, "SELECT hash FROM audit_log ORDER BY id DESC LIMIT 1") or GENESIS
)
row_hash = compute_hash(str(prev_hash), row)
cursor = conn.execute(
"INSERT INTO audit_log (ts, actor, actor_id, action, target, detail, ip, request_id, "
"severity, prev_hash, hash) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
row["ts"],
row["actor"],
row["actor_id"],
row["action"],
row["target"],
row["detail"],
row["ip"],
row["request_id"],
row["severity"],
prev_hash,
row_hash,
),
)
return int(cursor.lastrowid or 0)
def verify_chain(conn: sqlite3.Connection, *, limit: int | None = None) -> dict[str, Any]:
"""Prueft die Hash-Kette. Rueckgabe: ``{"ok", "checked", "broken_at", "reason"}``."""
sql = "SELECT * FROM audit_log ORDER BY id"
params: tuple[Any, ...] = ()
if limit:
sql = "SELECT * FROM (SELECT * FROM audit_log ORDER BY id DESC LIMIT ?) ORDER BY id"
params = (limit,)
checked = 0
expected_prev: str | None = None
for row in conn.execute(sql, params):
data = {key: row[key] for key in row.keys()}
if expected_prev is not None and data["prev_hash"] != expected_prev:
return {
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Vorgaenger-Hash passt nicht -- eine Zeile wurde entfernt oder eingefuegt.",
}
if compute_hash(str(data["prev_hash"]), data) != data["hash"]:
return {
"ok": False,
"checked": checked,
"broken_at": data["id"],
"reason": "Zeileninhalt passt nicht zum gespeicherten Hash -- Eintrag geaendert.",
}
expected_prev = str(data["hash"])
checked += 1
return {"ok": True, "checked": checked, "broken_at": None, "reason": ""}
def search(
conn: sqlite3.Connection,
*,
query: str = "",
action: str = "",
actor: str = "",
severity: str = "",
limit: int = 200,
offset: int = 0,
) -> list[dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if query:
clauses.append("(target LIKE ? OR detail LIKE ? OR action LIKE ? OR actor LIKE ?)")
params.extend([f"%{query}%"] * 4)
if action:
clauses.append("action = ?")
params.append(action)
if actor:
clauses.append("actor = ?")
params.append(actor)
if severity:
clauses.append("severity = ?")
params.append(severity)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.extend([limit, offset])
return Database.all(
conn,
f"SELECT * FROM audit_log {where} ORDER BY id DESC LIMIT ? OFFSET ?",
tuple(params),
)
def count(conn: sqlite3.Connection) -> int:
return int(Database.value(conn, "SELECT COUNT(*) FROM audit_log", default=0) or 0)
def distinct_actions(conn: sqlite3.Connection) -> list[str]:
return [
str(row["action"])
for row in Database.all(conn, "SELECT DISTINCT action FROM audit_log ORDER BY action")
]
def archive_old_rows(
conn: sqlite3.Connection,
archive_dir: Path,
*,
threshold: int = ARCHIVE_THRESHOLD,
target: int = ARCHIVE_TARGET,
) -> list[Path]:
"""Laagert ganze Kalendertage aus, bis die Tabelle unter ``target`` liegt.
Nur *vollstaendige* Tage werden ausgelagert -- der laufende Tag bleibt immer
in der Datenbank, damit die Auditseite nie halbe Tage zeigt.
"""
total = count(conn)
if total <= threshold:
return []
archive_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now(timezone.utc).date().isoformat()
written: list[Path] = []
while total > target:
# Nach Zeitstempel, nicht nach id: Eintraege koennen mit einem
# nachgereichten ts geschrieben worden sein (Import, Migration).
oldest_day = Database.value(conn, "SELECT MIN(substr(ts,1,10)) FROM audit_log")
if not oldest_day or str(oldest_day) >= today:
break
rows = Database.all(
conn,
"SELECT * FROM audit_log WHERE substr(ts,1,10)=? ORDER BY id",
(str(oldest_day),),
)
if not rows:
break
path = archive_dir / f"audit-{oldest_day}.jsonl"
with path.open("a", encoding="utf-8", newline="\n") as stream:
for row in rows:
stream.write(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n")
conn.execute("DELETE FROM audit_log WHERE substr(ts,1,10)=?", (str(oldest_day),))
written.append(path)
total -= len(rows)
logger.info("Auditlog: %s Zeilen von %s nach %s ausgelagert", len(rows), oldest_day, path)
return written
def archived_days(archive_dir: Path) -> list[dict[str, Any]]:
"""Liste der Archivdateien fuer die Verlaufsseite."""
if not archive_dir.exists():
return []
entries: list[dict[str, Any]] = []
for path in sorted(archive_dir.glob("audit-*.jsonl"), reverse=True):
try:
stat = path.stat()
except OSError:
continue
entries.append(
{
"day": path.stem.removeprefix("audit-"),
"path": path,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat(
timespec="seconds"
),
}
)
return entries
def read_archived_day(archive_dir: Path, day: str) -> list[dict[str, Any]]:
path = archive_dir / f"audit-{day}.jsonl"
if not path.is_file():
return []
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as stream:
for line in stream:
line = line.strip()
if not line:
continue
try:
rows.append(json.loads(line))
except ValueError:
continue
return rows
def summarize_by_day(rows: Iterable[dict[str, Any]]) -> list[tuple[str, int]]:
buckets: dict[str, int] = defaultdict(int)
for row in rows:
buckets[str(row.get("ts", ""))[:10]] += 1
return sorted(buckets.items(), reverse=True)
@@ -0,0 +1,27 @@
"""Authentisierung: Benutzerobjekt, Anmeldedienst, Verzeichnisanbindung."""
from __future__ import annotations
from .models import (
ANONYMOUS,
AnonymousUser,
Group,
User,
admin_required,
current_user,
login_required,
permission_required,
reauth_required,
)
__all__ = [
"ANONYMOUS",
"AnonymousUser",
"Group",
"User",
"admin_required",
"current_user",
"login_required",
"permission_required",
"reauth_required",
]
@@ -0,0 +1,581 @@
"""Anmeldung gegen Active Directory / LDAP (Same Sign-On).
"Same Sign-On", nicht "Single Sign-On": der Benutzer tippt sein
Verzeichnispasswort weiterhin auf der normalen Anmeldeseite ein; es wird bei
jeder Anmeldung live gegen das Verzeichnis geprueft, nie hier gespeichert.
Sicherheitsaenderungen gegenueber dem Vorgaenger:
* **Transportverschluesselung ist Pflicht** (LDAPS oder StartTLS). Der
Vorgaenger erlaubte unverschluesseltes LDAP auf Port 389 ohne Warnung -- das
Passwort ging im Klartext ueber das Netz. Wer es dennoch braucht, muss
``ldap_allow_insecure`` ausdruecklich setzen; das wird bei jeder Anmeldung
protokolliert.
* **Serverzertifikat wird geprueft.** Der Vorgaenger validierte es nicht --
LDAPS ohne Validierung schuetzt nur gegen passives Mitlesen, nicht gegen
einen Man-in-the-Middle.
* Das Bind-Passwort des Dienstkontos liegt AES-GCM-verschluesselt in
``service_accounts`` (an den Zweck gebunden), nicht Fernet-verschluesselt
ohne Kontext.
"""
from __future__ import annotations
import logging
import re
import socket
import sqlite3
import ssl
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Literal
from .. import audit
from ..db import Database
from ..extension import core
from ..settings_store import SettingSpec
logger = logging.getLogger(__name__)
SERVICE_PURPOSE = "ldap_bind"
AAD_LDAP_BIND = "service_accounts.ldap_bind"
#: userAccountControl-Bit fuer "Konto deaktiviert".
ACCOUNTDISABLE = 0x0002
#: OID fuer rekursive Gruppenauflösung in AD.
MATCHING_RULE_IN_CHAIN = "1.2.840.113556.1.4.1941"
Status = Literal["ok", "invalid", "unavailable", "disabled"]
SETTINGS: tuple[SettingSpec, ...] = (
SettingSpec("ldap_enabled", False, "bool", "Verzeichnisanmeldung aktiv", section="ldap"),
SettingSpec("ldap_host", "", "str", "Server (Hostname)", section="ldap"),
SettingSpec("ldap_port", 636, "int", "Port", section="ldap", minimum=1, maximum=65535),
SettingSpec(
"ldap_security",
"ldaps",
"str",
"Transport",
section="ldap",
choices=(("ldaps", "LDAPS (Port 636)"), ("starttls", "StartTLS (Port 389)"), ("none", "Unverschluesselt")),
),
SettingSpec("ldap_verify_cert", True, "bool", "Serverzertifikat pruefen", section="ldap"),
SettingSpec("ldap_ca_file", "", "str", "CA-Zertifikat (Pfad)", section="ldap"),
SettingSpec(
"ldap_allow_insecure",
False,
"bool",
"Unverschluesselte Verbindung ausdruecklich erlauben",
hint="Nur fuer Testumgebungen. Das Passwort geht dabei im Klartext ueber das Netz.",
section="ldap",
),
SettingSpec("ldap_base_dn", "", "str", "Basis-DN (leer = automatisch)", section="ldap"),
SettingSpec("ldap_user_attribute", "sAMAccountName", "str", "Anmeldeattribut", section="ldap"),
SettingSpec("ldap_email_attribute", "userPrincipalName", "str", "E-Mail-Attribut", section="ldap"),
SettingSpec("ldap_group_sync", True, "bool", "Gruppen aus dem Verzeichnis uebernehmen", section="ldap"),
SettingSpec("ldap_timeout", 8, "int", "Zeitlimit (Sekunden)", section="ldap", minimum=1, maximum=60),
)
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._\-@ ]{1,128}$")
@dataclass(frozen=True, slots=True)
class DirectoryProfile:
username: str
dn: str
first_name: str = ""
last_name: str = ""
email: str = ""
group_dns: tuple[str, ...] = ()
@dataclass(frozen=True, slots=True)
class DirectoryResult:
status: Status
profile: DirectoryProfile | None = None
message: str = ""
warnings: tuple[str, ...] = field(default=())
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def is_enabled(conn: sqlite3.Connection) -> bool:
settings = core().settings
if not settings.get(conn, "ldap_enabled"):
return False
return bool(settings.get(conn, "ldap_host"))
def bind_credentials(conn: sqlite3.Connection) -> tuple[str, str]:
"""Benutzer und Passwort des Bind-Dienstkontos (entschluesselt)."""
row = Database.one(
conn,
"SELECT username, password_encrypted FROM service_accounts WHERE purpose=?",
(SERVICE_PURPOSE,),
)
if row is None:
return "", ""
encrypted = str(row.get("password_encrypted") or "")
if not encrypted:
return str(row.get("username") or ""), ""
try:
secret = core().keystore.decrypt(encrypted, aad=AAD_LDAP_BIND)
except Exception: # noqa: BLE001 - Schluessel gewechselt/Datei kaputt
logger.error("Bind-Passwort des Verzeichnisdienstes konnte nicht entschluesselt werden")
return str(row.get("username") or ""), ""
return str(row.get("username") or ""), secret
def store_bind_credentials(conn: sqlite3.Connection, username: str, password: str | None) -> None:
"""Speichert das Dienstkonto. ``password=None`` laesst das bestehende unveraendert."""
existing = Database.one(
conn, "SELECT password_encrypted FROM service_accounts WHERE purpose=?", (SERVICE_PURPOSE,)
)
encrypted = str(existing.get("password_encrypted") or "") if existing else ""
if password:
encrypted = core().keystore.encrypt(password, aad=AAD_LDAP_BIND)
conn.execute(
"INSERT INTO service_accounts (purpose, username, password_encrypted, updated_at) "
"VALUES (?,?,?,?) ON CONFLICT(purpose) DO UPDATE SET username=excluded.username, "
"password_encrypted=excluded.password_encrypted, updated_at=excluded.updated_at",
(SERVICE_PURPOSE, username.strip(), encrypted, _now()),
)
# --------------------------------------------------------------------------- #
# Verbindung
# --------------------------------------------------------------------------- #
def _tls_object(config: dict[str, Any]):
from ldap3 import Tls
verify = bool(config["ldap_verify_cert"])
ca_file = str(config["ldap_ca_file"] or "") or None
return Tls(
validate=ssl.CERT_REQUIRED if verify else ssl.CERT_NONE,
version=ssl.PROTOCOL_TLS_CLIENT,
ca_certs_file=ca_file,
)
def _server(config: dict[str, Any]):
from ldap3 import Server
security = str(config["ldap_security"])
return Server(
str(config["ldap_host"]),
port=int(config["ldap_port"]),
use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL",
connect_timeout=int(config["ldap_timeout"]),
)
def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection
security = str(config["ldap_security"])
if security == "none" and not config["ldap_allow_insecure"]:
raise PermissionError(
"Unverschluesselte Verzeichnisverbindungen sind deaktiviert. "
"Nutzen Sie LDAPS oder StartTLS."
)
connection = Connection(
_server(config),
user=user or None,
password=password or None,
auto_bind=False,
raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]),
)
if security == "starttls":
if not connection.open():
raise ConnectionError("Verbindung zum Verzeichnisdienst fehlgeschlagen")
if not connection.start_tls():
raise ConnectionError(
f"StartTLS fehlgeschlagen: {connection.result.get('description', 'unbekannt')}"
)
if not connection.bind():
return connection, False
return connection, True
def _read_config(conn: sqlite3.Connection) -> dict[str, Any]:
settings = core().settings
return settings.get_many(conn, [spec.key for spec in SETTINGS])
def _base_dn(connection, config: dict[str, Any]) -> str:
configured = str(config["ldap_base_dn"] or "").strip()
if configured:
return configured
info = getattr(connection.server, "info", None)
naming = getattr(info, "other", {}).get("defaultNamingContext") if info else None
if isinstance(naming, list) and naming:
return str(naming[0])
if isinstance(naming, str):
return naming
contexts = getattr(info, "naming_contexts", None) if info else None
if contexts:
return str(contexts[0])
return ""
def _escape(value: str) -> str:
"""RFC-4515-Escaping fuer Filterwerte -- verhindert LDAP-Injection."""
replacements = {"\\": r"\5c", "*": r"\2a", "(": r"\28", ")": r"\29", "\x00": r"\00", "/": r"\2f"}
return "".join(replacements.get(char, char) for char in value)
def authenticate(conn: sqlite3.Connection, username: str, password: str) -> DirectoryResult:
"""Suchen-und-Binden gegen das Verzeichnis."""
try:
import ldap3 # noqa: F401
except ImportError:
return DirectoryResult("unavailable", message="Das Paket ldap3 ist nicht installiert.")
if not _USERNAME_RE.match(username or ""):
return DirectoryResult("invalid")
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return DirectoryResult("disabled")
bind_user, bind_password = bind_credentials(conn)
warnings: list[str] = []
if str(config["ldap_security"]) == "none":
warnings.append("Unverschluesselte Verzeichnisverbindung in Benutzung")
if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprueft")
try:
service_conn, bound = _connect(config, bind_user, bind_password)
except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
except (socket.error, ssl.SSLError, OSError) as exc:
logger.error("Verzeichnisdienst nicht erreichbar: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
if not bound:
detail = service_conn.result.get("description", "unbekannt") if service_conn.result else ""
logger.error("Bind des Dienstkontos fehlgeschlagen: %s", detail)
return DirectoryResult(
"unavailable",
message=f"Anmeldung des Dienstkontos fehlgeschlagen ({detail}).",
warnings=tuple(warnings),
)
try:
base = _base_dn(service_conn, config)
if not base:
return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
)
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
wanted = ["distinguishedName", "givenName", "sn", "userAccountControl", email_attribute, attribute]
service_conn.search(
search_base=base,
search_filter=f"(&(objectClass=user)({attribute}={_escape(username)}))",
attributes=list(dict.fromkeys(wanted)),
size_limit=2,
)
entries = list(service_conn.entries)
if len(entries) != 1:
return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0]
dn = str(entry.entry_dn)
control = _attr_int(entry, "userAccountControl")
if control is not None and control & ACCOUNTDISABLE:
return DirectoryResult("invalid", message="Konto im Verzeichnis deaktiviert.", warnings=tuple(warnings))
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password)
try:
if not user_bound:
return DirectoryResult("invalid", warnings=tuple(warnings))
finally:
try:
user_conn.unbind()
except Exception: # noqa: BLE001
pass
group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn)
profile = DirectoryProfile(
username=_attr(entry, attribute) or username,
dn=dn,
first_name=_attr(entry, "givenName"),
last_name=_attr(entry, "sn"),
email=_attr(entry, email_attribute),
group_dns=group_dns,
)
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally:
try:
service_conn.unbind()
except Exception: # noqa: BLE001
pass
def _attr(entry: Any, name: str) -> str:
try:
value = entry[name].value
except Exception: # noqa: BLE001
return ""
if isinstance(value, (list, tuple)):
return str(value[0]) if value else ""
return "" if value is None else str(value)
def _attr_int(entry: Any, name: str) -> int | None:
raw = _attr(entry, name)
try:
return int(raw)
except (TypeError, ValueError):
return None
def _resolve_groups(connection: Any, base: str, user_dn: str) -> tuple[str, ...]:
"""Rekursive Gruppenauflösung ueber LDAP_MATCHING_RULE_IN_CHAIN."""
try:
connection.search(
search_base=base,
search_filter=f"(member:{MATCHING_RULE_IN_CHAIN}:={_escape(user_dn)})",
attributes=["distinguishedName", "cn"],
size_limit=500,
)
return tuple(str(entry.entry_dn) for entry in connection.entries)
except Exception as exc: # noqa: BLE001
logger.warning("Gruppenauflösung fehlgeschlagen: %s", exc)
return ()
# --------------------------------------------------------------------------- #
# Schattenkonto
# --------------------------------------------------------------------------- #
def sync_account(conn: sqlite3.Connection, profile: DirectoryProfile) -> dict[str, Any]:
"""Legt das lokale Schattenkonto an bzw. aktualisiert es und mappt Gruppen."""
from ..rbac import service as rbac
row = rbac.find_user_by_username(conn, profile.username)
with core().database.transaction(conn):
if row is None:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
"last_name, email, created_at, updated_at) VALUES (?,?,0,'ldap',?,?,?,?,?)",
(
profile.username,
"", # Kein lokales Passwort -- Anmeldung laeuft immer ueber das Verzeichnis.
profile.first_name,
profile.last_name,
profile.email,
_now(),
_now(),
),
)
user_id = int(cursor.lastrowid or 0)
audit.log(
conn,
actor=profile.username,
actor_id=user_id,
action="auth.directory_account_created",
target=profile.username,
detail=profile.dn,
severity="notice",
)
else:
user_id = int(row["id"])
conn.execute(
"UPDATE users SET first_name=?, last_name=?, email=?, auth_source='ldap', "
"updated_at=? WHERE id=?",
(profile.first_name, profile.last_name, profile.email, _now(), user_id),
)
_apply_group_mappings(conn, user_id, profile.group_dns)
result = rbac.get_user(conn, user_id)
assert result is not None
return result
def _apply_group_mappings(
conn: sqlite3.Connection, user_id: int, group_dns: tuple[str, ...]
) -> None:
"""Setzt die Gruppen additiv aus den Verzeichnisgruppen.
Bewusst *ersetzend*: wer im Verzeichnis aus einer Gruppe entfernt wurde,
verliert das Recht bei der naechsten Anmeldung. Manuell in der Anwendung
zugewiesene Gruppen bleiben erhalten, wenn sie kein Verzeichnis-Mapping
haben -- sonst waere jede lokale Zuweisung nach dem naechsten Login weg.
"""
if not group_dns:
return
mappings = Database.all(
conn, "SELECT ad_group_dn, app_group_id FROM ldap_group_mappings"
)
if not mappings:
return
mapped_group_ids = {int(row["app_group_id"]) for row in mappings}
lowered = {dn.lower() for dn in group_dns}
wanted = {
int(row["app_group_id"])
for row in mappings
if str(row["ad_group_dn"]).lower() in lowered
}
current = {int(row["id"]) for row in _user_group_ids(conn, user_id)}
# Alles, was ueber ein Mapping kommt, wird neu gesetzt; alles andere bleibt.
keep = {gid for gid in current if gid not in mapped_group_ids}
final = keep | wanted
if final == current:
return
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[(user_id, gid) for gid in sorted(final)],
)
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,))
def search_directory_users(
conn: sqlite3.Connection, query: str, *, limit: int = 20
) -> list[dict[str, str]]:
"""Live-Suche fuer die Vorab-Zuweisung von Verzeichniskonten."""
if len(query.strip()) < 2:
return []
try:
import ldap3 # noqa: F401
except ImportError:
return []
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return []
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception: # noqa: BLE001
return []
if not bound:
return []
try:
base = _base_dn(connection, config)
attribute = str(config["ldap_user_attribute"] or "sAMAccountName")
email_attribute = str(config["ldap_email_attribute"] or "userPrincipalName")
escaped = _escape(query.strip())
connection.search(
search_base=base,
search_filter=(
f"(&(objectClass=user)(|({attribute}={escaped}*)"
f"(givenName={escaped}*)(sn={escaped}*)(displayName={escaped}*)))"
),
attributes=[attribute, "givenName", "sn", email_attribute],
size_limit=limit,
)
return [
{
"username": _attr(entry, attribute),
"first_name": _attr(entry, "givenName"),
"last_name": _attr(entry, "sn"),
"email": _attr(entry, email_attribute),
"dn": str(entry.entry_dn),
}
for entry in connection.entries
if _attr(entry, attribute)
]
except Exception: # noqa: BLE001
return []
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
def search_directory_groups(conn: sqlite3.Connection, query: str, *, limit: int = 20) -> list[dict[str, str]]:
"""Live-Suche nach Verzeichnisgruppen fuer die Mapping-Oberflaeche."""
if len(query.strip()) < 2:
return []
try:
import ldap3 # noqa: F401
except ImportError:
return []
config = _read_config(conn)
if not config.get("ldap_enabled") or not config.get("ldap_host"):
return []
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception: # noqa: BLE001
return []
if not bound:
return []
try:
base = _base_dn(connection, config)
escaped = _escape(query.strip())
connection.search(
search_base=base,
search_filter=f"(&(objectClass=group)(cn={escaped}*))",
attributes=["cn", "distinguishedName"],
size_limit=limit,
)
return [
{"name": _attr(entry, "cn"), "dn": str(entry.entry_dn)} for entry in connection.entries
]
except Exception: # noqa: BLE001
return []
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
def test_connection(conn: sqlite3.Connection) -> dict[str, Any]:
"""Verbindungstest fuer die Einstellungsseite -- ohne Anmeldung eines Benutzers."""
try:
import ldap3 # noqa: F401
except ImportError:
return {"ok": False, "message": "Das Paket ldap3 ist nicht installiert."}
config = _read_config(conn)
if not config.get("ldap_host"):
return {"ok": False, "message": "Es ist kein Server konfiguriert."}
bind_user, bind_password = bind_credentials(conn)
try:
connection, bound = _connect(config, bind_user, bind_password)
except Exception as exc: # noqa: BLE001
return {"ok": False, "message": str(exc)}
if not bound:
detail = connection.result.get("description", "unbekannt") if connection.result else ""
return {"ok": False, "message": f"Anmeldung des Dienstkontos fehlgeschlagen ({detail})."}
try:
base = _base_dn(connection, config)
return {
"ok": True,
"message": f"Verbindung erfolgreich. Basis-DN: {base or 'nicht ermittelbar'}",
"base_dn": base,
}
finally:
try:
connection.unbind()
except Exception: # noqa: BLE001
pass
@@ -0,0 +1,297 @@
"""Benutzerobjekt, Rechtepruefung und Zugriffsdekoratoren."""
from __future__ import annotations
import functools
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Sequence
from flask import abort, flash, g, redirect, request, session, url_for
from werkzeug.local import LocalProxy
from ..rbac.model import Action, PermissionTree
AUTH_SOURCES = ("local", "ldap")
@dataclass(frozen=True, slots=True)
class Group:
id: int
name: str
is_system: bool = False
is_default: bool = False
@dataclass(slots=True)
class User:
"""Angemeldeter Benutzer. Unveraenderlich fuer die Dauer eines Requests."""
id: int
username: str
is_admin: bool = False
is_locked: bool = False
auth_source: str = "local"
first_name: str = ""
last_name: str = ""
email: str = ""
avatar_filename: str = ""
totp_enabled: bool = False
must_change_password: bool = False
groups: tuple[Group, ...] = ()
granted: frozenset[str] = frozenset()
_tree: PermissionTree | None = field(default=None, repr=False, compare=False)
_effective: frozenset[str] | None = field(default=None, repr=False, compare=False)
# -- Anzeige ------------------------------------------------------- #
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
full = f"{self.first_name} {self.last_name}".strip()
return full or self.username
@property
def initials(self) -> str:
parts = [p for p in (self.first_name, self.last_name) if p]
if parts:
return "".join(p[0] for p in parts[:2]).upper()
return (self.username[:2] or "?").upper()
@property
def role_label(self) -> str:
if self.is_admin:
return "Administrator"
if self.groups:
return ", ".join(group.name for group in self.groups)
return "Ohne Gruppe"
@property
def is_directory_account(self) -> bool:
return self.auth_source == "ldap"
# -- Rechte -------------------------------------------------------- #
@property
def effective_permissions(self) -> frozenset[str]:
"""Rechte nach Anwendung der Bereichs-Kill-Switches."""
if self._effective is None:
tree = self._tree
self._effective = (
frozenset(tree.effective(self.granted)) if tree is not None else frozenset()
)
return self._effective
def has(self, *permissions: str) -> bool:
"""True, wenn **alle** angegebenen Rechte greifen. Admins duerfen immer alles."""
if self.is_admin:
return True
if not permissions:
return True
return all(permission in self.effective_permissions for permission in permissions)
def has_any(self, permissions: Iterable[str]) -> bool:
if self.is_admin:
return True
effective = self.effective_permissions
return any(permission in effective for permission in permissions)
def can(self, resource: str, action: Action | str) -> bool:
value = action.value if isinstance(action, Action) else str(action)
return self.has(f"{resource}.{value}")
def can_any(self, resource: str, *actions: Action | str) -> bool:
return self.has_any(
f"{resource}.{a.value if isinstance(a, Action) else a}" for a in actions
)
def as_audit_actor(self) -> str:
return self.username
class AnonymousUser:
"""Nicht angemeldet. Hat keine Rechte -- auch nicht ``view``."""
id = 0
username = ""
is_admin = False
is_locked = False
auth_source = "anonymous"
first_name = ""
last_name = ""
email = ""
avatar_filename = ""
totp_enabled = False
must_change_password = False
groups: tuple[Group, ...] = ()
granted: frozenset[str] = frozenset()
@property
def is_authenticated(self) -> bool:
return False
@property
def display_name(self) -> str:
return "Gast"
@property
def initials(self) -> str:
return "?"
@property
def role_label(self) -> str:
return "Nicht angemeldet"
@property
def is_directory_account(self) -> bool:
return False
@property
def effective_permissions(self) -> frozenset[str]:
return frozenset()
def has(self, *permissions: str) -> bool:
return False
def has_any(self, permissions: Iterable[str]) -> bool:
return False
def can(self, resource: str, action: Action | str) -> bool:
return False
def can_any(self, resource: str, *actions: Action | str) -> bool:
return False
def as_audit_actor(self) -> str:
return "anonym"
ANONYMOUS = AnonymousUser()
def _lookup_user() -> User | AnonymousUser:
return getattr(g, "user", ANONYMOUS)
#: Aktueller Benutzer -- in Templates und Views gleich benutzbar.
current_user: Any = LocalProxy(_lookup_user)
# --------------------------------------------------------------------------- #
# Dekoratoren
# --------------------------------------------------------------------------- #
def _deny(message: str) -> Any:
"""Nicht angemeldet -> Login; angemeldet aber ohne Recht -> 403."""
if not current_user.is_authenticated:
if request.method == "GET" and not request.headers.get("X-Requested-With"):
session["_after_login"] = request.full_path if request.query_string else request.path
if request.accept_mimetypes.accept_json and not request.accept_mimetypes.accept_html:
abort(401, description="Anmeldung erforderlich.")
flash("Bitte melden Sie sich an.", "info")
return redirect(url_for("auth.login"))
abort(403, description=message)
def login_required(view: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not current_user.is_authenticated:
return _deny("Anmeldung erforderlich.")
return view(*args, **kwargs)
return wrapper
def permission_required(*permissions: str, mode: str = "all") -> Callable[..., Any]:
"""Erzwingt Rechte serverseitig.
``mode="any"`` fuer Seiten, die mehrere unabhaengige Rechte akzeptieren --
wichtig, weil im Vorgaenger eine Seite mit reinem Export-Recht nicht
erreichbar war, obwohl das Recht vergeben war.
"""
def decorator(view: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
ok = (
current_user.has_any(permissions)
if mode == "any"
else current_user.has(*permissions)
)
if not ok:
return _deny("Fuer diese Seite fehlt Ihnen die Berechtigung.")
return view(*args, **kwargs)
wrapper._required_permissions = permissions # type: ignore[attr-defined]
return wrapper
return decorator
def admin_required(view: Callable[..., Any]) -> Callable[..., Any]:
"""Nur echte Administratoren -- nicht ueber Gruppenrechte delegierbar.
Fuer Grenzen, die einen lokalen Admin *nicht* ueberleben muessen, aber auch
nicht an eine Gruppe vergeben werden sollen (Rechtevergabe selbst,
Navigationsreihenfolge, Schluesselrotation).
"""
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
if not current_user.is_authenticated or not current_user.is_admin:
return _deny("Diese Aktion ist Administratoren vorbehalten.")
return view(*args, **kwargs)
return wrapper
def reauth_required(view: Callable[..., Any]) -> Callable[..., Any]:
"""Verlangt eine frische Passworteingabe ("sudo-Modus").
Vorgeschaltet bei allem, was Geheimnisse offenlegt oder Daten aus dem System
heraustraegt: Klartextpasswoerter, Export, Schluesselrotation, 2FA-Aenderung.
Ein uebernommenes Sitzungscookie allein reicht dafuer nicht mehr aus.
"""
@functools.wraps(view)
def wrapper(*args: Any, **kwargs: Any) -> Any:
from .service import reauth_is_fresh # zyklischen Import vermeiden
if not current_user.is_authenticated:
return _deny("Anmeldung erforderlich.")
if not reauth_is_fresh():
session["_after_reauth"] = request.full_path if request.query_string else request.path
flash("Bitte bestaetigen Sie zur Sicherheit Ihr Passwort.", "info")
return redirect(url_for("auth.reauth"))
return view(*args, **kwargs)
return wrapper
def build_user(
row: dict[str, Any],
*,
groups: Sequence[Group],
granted: Iterable[str],
tree: PermissionTree,
) -> User:
return User(
id=int(row["id"]),
username=str(row["username"]),
is_admin=bool(row.get("is_admin")),
is_locked=bool(row.get("is_locked")),
auth_source=str(row.get("auth_source") or "local"),
first_name=str(row.get("first_name") or ""),
last_name=str(row.get("last_name") or ""),
email=str(row.get("email") or ""),
avatar_filename=str(row.get("avatar_filename") or ""),
totp_enabled=bool(row.get("totp_secret")),
must_change_password=bool(row.get("must_change_password")),
groups=tuple(groups),
granted=frozenset(granted),
_tree=tree,
)
@@ -0,0 +1,135 @@
"""Anmelde-Routen: Login, zweiter Faktor, Abmeldung, erneute Bestaetigung."""
from __future__ import annotations
from urllib.parse import urlparse
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from ..extension import core, db
from . import directory, service
from .models import current_user, login_required
bp = Blueprint("auth", __name__)
def _safe_next(candidate: str | None, fallback_endpoint: str = "index") -> str:
"""Verhindert Open Redirects: nur pfad-relative Ziele derselben Anwendung."""
default = url_for(fallback_endpoint)
if not candidate:
return default
parsed = urlparse(candidate)
if parsed.scheme or parsed.netloc or not candidate.startswith("/"):
return default
return candidate
@bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(_safe_next(session.pop("_after_login", None)))
conn = db()
username = ""
if request.method == "POST":
username = (request.form.get("username") or "").strip()
password = request.form.get("password") or ""
outcome = service.authenticate(
conn, username, password, ip=service.client_ip()
)
if outcome.status == "needs_totp" and outcome.user_row:
service.start_totp_challenge(outcome.user_row)
return redirect(url_for("auth.second_factor"))
if outcome.ok and outcome.user_row:
user = service.complete_login(
conn,
outcome.user_row,
ip=service.client_ip(),
secret=password,
group_dns=outcome.group_dns,
)
flash(f"Willkommen, {user.display_name}.", "success")
if user.must_change_password:
flash("Bitte legen Sie zuerst ein neues Passwort fest.", "warning")
return redirect(url_for("account.security"))
return redirect(_safe_next(session.pop("_after_login", None)))
flash(outcome.message or service.GENERIC_FAILURE, "danger")
return render_template(
"tesm_core/login.html",
username=username,
directory_enabled=directory.is_enabled(conn),
public_dashboard_url=core().registry.get("public_dashboard_url", ""),
)
@bp.route("/login/zweiter-faktor", methods=["GET", "POST"])
def second_factor():
conn = db()
user_row = service.pending_totp_user(conn)
if user_row is None:
flash("Die Anmeldung ist abgelaufen. Bitte erneut versuchen.", "info")
return redirect(url_for("auth.login"))
if request.method == "POST":
outcome = service.verify_second_factor(
conn, user_row, request.form.get("code") or "", ip=service.client_ip()
)
if outcome.ok:
# Kein ``secret``: das Passwort liegt seit dem ersten Schritt nicht
# mehr vor, und es fuer den zweiten Schritt in der Sitzung
# zwischenzulagern waere ein schlechter Tausch. Freigaben, die die
# Anmeldung des Benutzers weiterreichen, binden sich bei aktivem
# zweitem Faktor deshalb nicht ein -- Freigaben mit hinterlegtem
# Konto schon.
user = service.complete_login(conn, user_row, ip=service.client_ip())
flash(f"Willkommen, {user.display_name}.", "success")
return redirect(_safe_next(session.pop("_after_login", None)))
flash(outcome.message, "danger")
return render_template("tesm_core/second_factor.html")
@bp.post("/logout")
@login_required
def logout():
service.logout(db())
flash("Sie wurden abgemeldet.", "info")
return redirect(url_for("auth.login"))
@bp.get("/logout")
def logout_get():
"""Abmeldung ueber einen Link -- fuer den Abbruch des 2FA-Schritts.
Aendert nur die eigene Sitzung und ist damit kein CSRF-relevanter Vorgang
mit Datenwirkung; die eigentliche Abmeldung im angemeldeten Bereich laeuft
trotzdem ueber POST mit Token.
"""
if current_user.is_authenticated:
service.logout(db())
else:
session.clear()
return redirect(url_for("auth.login"))
@bp.route("/bestaetigen", methods=["GET", "POST"])
@login_required
def reauth():
target = _safe_next(session.get("_after_reauth"))
if request.method == "POST":
password = request.form.get("password") or ""
if service.confirm_password(db(), current_user, password):
service.mark_reauth()
session.pop("_after_reauth", None)
return redirect(target)
flash("Das Passwort ist nicht korrekt.", "danger")
return render_template(
"tesm_core/reauth.html",
cancel_url=url_for("index"),
reauth_minutes=core().config.security.reauth_valid_minutes,
)
@@ -0,0 +1,499 @@
"""Anmeldung, Sitzungsaufbau, erneute Passwortbestaetigung.
Ablauf einer Anmeldung (bewusst in dieser Reihenfolge):
1. Ratenbegrenzung pro IP **und** pro Benutzername pruefen -- bevor irgendein
Hash berechnet wird.
2. Lokales Konto suchen. Existiert es mit ``auth_source='local'``, wird das
Passwort gegen den Argon2-Hash geprueft; existiert es nicht oder als
Verzeichniskonto, greift (falls konfiguriert) die LDAP-Pruefung.
3. Bei Erfolg: gesperrt? -> abweisen. 2FA aktiv? -> Zwischenschritt.
4. Sitzung rotieren (neue ID), Rechte laden, Erfolg protokollieren.
Bei jedem Fehlschlag wird dieselbe, nichts verratende Meldung ausgegeben und
dieselbe Rechenzeit verbraucht (``passwords.dummy_verify``) -- die Antwort
erlaubt keinen Rueckschluss darauf, ob ein Konto existiert.
"""
from __future__ import annotations
import logging
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from flask import current_app, g, request, session
from .. import audit
from ..db import Database
from ..extension import core, db
from ..rbac import service as rbac
from ..security import passwords, ratelimit
from ..security import sessions as session_store
from .models import ANONYMOUS, Group, User, build_user
logger = logging.getLogger(__name__)
SESSION_USER_KEY = "user_id"
SESSION_PENDING_KEY = "_pending_user_id"
SESSION_REAUTH_KEY = "_reauth_at"
BUCKET_LOGIN_USER = "login:user"
BUCKET_LOGIN_IP = "login:ip"
BUCKET_TOTP = "totp:user"
GENERIC_FAILURE = "Benutzername oder Passwort ist falsch."
Status = Literal["ok", "invalid", "locked", "rate_limited", "needs_totp", "directory_unavailable"]
@dataclass(frozen=True, slots=True)
class AuthOutcome:
status: Status
message: str = ""
user_row: dict[str, Any] | None = None
retry_after: int = 0
#: Verzeichnisgruppen der laufenden Anmeldung. Sie stehen nur hier zur
#: Verfuegung -- gespeichert werden sie bewusst nicht, denn beim naechsten
#: Mal koennen es andere sein.
group_dns: tuple[str, ...] = ()
@property
def ok(self) -> bool:
return self.status == "ok"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def client_ip() -> str:
"""Client-IP unter Beruecksichtigung vertrauenswuerdiger Proxy-Hops."""
hops = core().config.security.trusted_proxy_hops
if hops > 0:
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
# Der letzte vertrauenswuerdige Hop steht von rechts gezaehlt.
index = max(0, len(parts) - hops)
return parts[index]
return request.remote_addr or ""
# --------------------------------------------------------------------------- #
# Benutzer laden
# --------------------------------------------------------------------------- #
def load_user(conn: sqlite3.Connection, user_id: int) -> User | None:
row = Database.one(
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (user_id,)
)
if row is None or row.get("is_locked"):
return None
groups = [
Group(
id=int(entry["id"]),
name=str(entry["name"]),
is_system=bool(entry["is_system"]),
is_default=bool(entry["is_default"]),
)
for entry in rbac.user_groups(conn, user_id)
]
return build_user(
row,
groups=groups,
granted=rbac.granted_permissions(conn, user_id),
tree=core().permissions,
)
def load_request_user() -> None:
"""``before_request``-Hook: setzt ``g.user``."""
g.user = ANONYMOUS
user_id = session.get(SESSION_USER_KEY)
if not user_id:
return
user = load_user(db(), int(user_id))
if user is None:
# Konto geloescht, gesperrt oder Rechte entzogen -> Sitzung beenden.
session.clear()
return
g.user = user
# --------------------------------------------------------------------------- #
# Anmeldung
# --------------------------------------------------------------------------- #
def authenticate(
conn: sqlite3.Connection, username: str, password: str, *, ip: str
) -> AuthOutcome:
security = core().config.security
name = (username or "").strip()
if not name or not password:
return AuthOutcome("invalid", GENERIC_FAILURE)
ip_verdict = ratelimit.check(
conn,
BUCKET_LOGIN_IP,
ip or "unbekannt",
limit=security.login_max_attempts_per_ip,
window_minutes=security.login_lockout_minutes,
)
if ip_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Zu viele Anmeldeversuche von dieser Adresse. Bitte spaeter erneut versuchen.",
retry_after=ip_verdict.retry_after_seconds,
)
user_verdict = ratelimit.check(
conn,
BUCKET_LOGIN_USER,
name.lower(),
limit=security.login_max_attempts,
window_minutes=security.login_lockout_minutes,
)
if user_verdict.blocked:
return AuthOutcome(
"rate_limited",
"Dieses Konto ist nach mehreren Fehlversuchen voruebergehend gesperrt.",
retry_after=user_verdict.retry_after_seconds,
)
row = rbac.find_user_by_username(conn, name)
outcome = _verify_credentials(conn, row, name, password)
if not outcome.ok:
with core().database.transaction(conn):
ratelimit.record(conn, BUCKET_LOGIN_IP, ip or "unbekannt")
ratelimit.record(conn, BUCKET_LOGIN_USER, name.lower())
audit.log(
conn,
actor=name,
action="auth.login_failed",
target=name,
detail=outcome.status,
ip=ip,
severity="warning",
request_id=getattr(g, "request_id", ""),
)
return outcome
user_row = outcome.user_row or {}
if bool(user_row.get("is_locked")):
with core().database.transaction(conn):
audit.log(
conn,
actor=str(user_row.get("username") or name),
action="auth.login_locked",
target=str(user_row.get("username") or name),
ip=ip,
severity="warning",
)
return AuthOutcome("locked", "Dieses Konto ist gesperrt. Bitte wenden Sie sich an die IT.")
with core().database.transaction(conn):
ratelimit.clear(conn, BUCKET_LOGIN_USER, name.lower())
if str(user_row.get("totp_secret") or ""):
return AuthOutcome("needs_totp", "", user_row=user_row, group_dns=outcome.group_dns)
if core().config.security.require_totp_for_admins and bool(user_row.get("is_admin")):
# Kein harter Block: der Admin kommt herein, muss aber sofort 2FA
# einrichten (die Kontoseite fuehrt ihn dorthin).
logger.warning(
"Administrator %s hat keine 2FA, obwohl sie verlangt wird", user_row.get("username")
)
# ``outcome`` mitfuehren, nicht neu bauen: die Verzeichnisgruppen der
# laufenden Anmeldung stehen nur dort. Wer hier ein frisches AuthOutcome
# erzeugt, verliert sie lautlos -- und mit ihnen jede Freigabe, die daran
# haengt.
return AuthOutcome("ok", user_row=user_row, group_dns=outcome.group_dns)
def _verify_credentials(
conn: sqlite3.Connection, row: dict[str, Any] | None, name: str, password: str
) -> AuthOutcome:
from . import directory # lokaler Import: ldap3 ist optional
directory_enabled = directory.is_enabled(conn)
if row is not None and str(row.get("auth_source")) == "local":
if not passwords.verify_password(str(row.get("password_hash") or ""), password):
return AuthOutcome("invalid", GENERIC_FAILURE)
if passwords.needs_rehash(str(row.get("password_hash") or "")):
with core().database.transaction(conn):
conn.execute(
"UPDATE users SET password_hash=?, updated_at=? WHERE id=?",
(passwords.hash_password(password), _iso(_now()), row["id"]),
)
return AuthOutcome("ok", user_row=row)
if not directory_enabled:
passwords.dummy_verify()
return AuthOutcome("invalid", GENERIC_FAILURE)
result = directory.authenticate(conn, name, password)
if result.status == "unavailable":
return AuthOutcome(
"directory_unavailable",
"Der Verzeichnisdienst ist derzeit nicht erreichbar. "
"Bitte versuchen Sie es spaeter erneut.",
)
if result.status != "ok" or result.profile is None:
return AuthOutcome("invalid", GENERIC_FAILURE)
user_row = directory.sync_account(conn, result.profile)
return AuthOutcome("ok", user_row=user_row, group_dns=result.profile.group_dns)
def complete_login(
conn: sqlite3.Connection,
user_row: dict[str, Any],
*,
ip: str,
secret: str = "",
group_dns: tuple[str, ...] = (),
) -> User:
"""Baut die Sitzung auf. Rotiert die Sitzungs-ID (Session-Fixation).
``secret`` ist das Passwort dieser Anmeldung. Es wird **nicht** gespeichert
und nicht in die Sitzung geschrieben; es wird ausschliesslich an die
angemeldeten Haken weitergereicht und ist danach weg. Gebraucht wird es
genau von einem: der Einbindung von Dateifreigaben, die sich mit der
Anmeldung des Benutzers am Fileserver anmeldet.
"""
from ..security import csrf
with core().database.transaction(conn):
session_store.rotate(conn, session, reason="login") # type: ignore[arg-type]
conn.execute(
"UPDATE users SET last_login_at=? WHERE id=?", (_iso(_now()), user_row["id"])
)
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.login",
target=str(user_row["username"]),
detail=f"Quelle: {user_row.get('auth_source') or 'local'}",
ip=ip,
request_id=getattr(g, "request_id", ""),
)
session.clear()
session[SESSION_USER_KEY] = int(user_row["id"])
session[SESSION_REAUTH_KEY] = _iso(_now())
session["_ip"] = ip
session["_ua"] = (request.user_agent.string or "")[:200]
csrf.rotate_token()
user = load_user(conn, int(user_row["id"]))
if user is None: # pragma: no cover - direkt nach Pruefung praktisch unmoeglich
raise RuntimeError("Benutzer konnte nach der Anmeldung nicht geladen werden")
g.user = user
_run_login_hooks(conn, user, secret=secret, group_dns=group_dns)
return user
def _run_login_hooks(
conn: sqlite3.Connection, user: User, *, secret: str, group_dns: tuple[str, ...]
) -> None:
"""Ruft die angemeldeten Haken auf -- ohne die Anmeldung zu gefaehrden.
Ein Haken, der scheitert, darf niemanden aussperren: was hier passiert, ist
Zusatzarbeit (Freigaben einbinden), nicht die Anmeldung selbst. Deshalb
wird jeder Fehler protokolliert und geschluckt.
"""
for hook in core().login_hooks:
try:
hook(conn, user, secret, group_dns)
except Exception: # pragma: no cover - haengt am Fileserver
current_app.logger.exception(
"Anmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
)
def logout(conn: sqlite3.Connection) -> None:
user = getattr(g, "user", ANONYMOUS)
if user.is_authenticated:
# Vor dem Protokolleintrag, solange die Sitzung noch steht: die Haken
# raeumen auf, was zu ihr gehoert (z. B. eingebundene Freigaben).
for hook in core().logout_hooks:
try:
hook(conn, user)
except Exception: # pragma: no cover - haengt am Fileserver
current_app.logger.exception(
"Abmeldehaken %r fehlgeschlagen", getattr(hook, "__name__", hook)
)
with core().database.transaction(conn):
audit.log(
conn,
actor=user.username,
actor_id=user.id,
action="auth.logout",
target=user.username,
ip=client_ip(),
)
session.destroy() if hasattr(session, "destroy") else session.clear() # type: ignore[attr-defined]
g.user = ANONYMOUS
# --------------------------------------------------------------------------- #
# Zweiter Faktor
# --------------------------------------------------------------------------- #
def start_totp_challenge(user_row: dict[str, Any]) -> None:
session[SESSION_PENDING_KEY] = int(user_row["id"])
session["_pending_since"] = _iso(_now())
def pending_totp_user(conn: sqlite3.Connection) -> dict[str, Any] | None:
user_id = session.get(SESSION_PENDING_KEY)
if not user_id:
return None
started = session.get("_pending_since")
if started:
try:
if _now() - datetime.fromisoformat(str(started)) > timedelta(minutes=5):
session.pop(SESSION_PENDING_KEY, None)
return None
except ValueError:
pass
return Database.one(
conn, "SELECT * FROM users WHERE id=? AND deleted_at IS NULL", (int(user_id),)
)
def verify_second_factor(
conn: sqlite3.Connection, user_row: dict[str, Any], code: str, *, ip: str
) -> AuthOutcome:
from ..security import totp
security = core().config.security
subject = str(user_row["id"])
verdict = ratelimit.check(
conn, BUCKET_TOTP, subject, limit=8, window_minutes=security.login_lockout_minutes
)
if verdict.blocked:
return AuthOutcome(
"rate_limited",
"Zu viele Fehlversuche. Bitte warten Sie einen Moment.",
retry_after=verdict.retry_after_seconds,
)
cleaned = (code or "").strip()
if totp.verify_code(str(user_row.get("totp_secret") or ""), cleaned):
with core().database.transaction(conn):
ratelimit.clear(conn, BUCKET_TOTP, subject)
return AuthOutcome("ok", user_row=user_row)
if _consume_recovery_code(conn, int(user_row["id"]), cleaned, ip=ip):
return AuthOutcome("ok", user_row=user_row)
with core().database.transaction(conn):
ratelimit.record(conn, BUCKET_TOTP, subject)
audit.log(
conn,
actor=str(user_row["username"]),
actor_id=int(user_row["id"]),
action="auth.totp_failed",
target=str(user_row["username"]),
ip=ip,
severity="warning",
)
return AuthOutcome("invalid", "Der Code ist nicht korrekt.")
def _consume_recovery_code(
conn: sqlite3.Connection, user_id: int, code: str, *, ip: str
) -> bool:
candidates = Database.all(
conn,
"SELECT id, code_hash FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
(user_id,),
)
normalized = code.strip().lower()
for entry in candidates:
if passwords.verify_password(str(entry["code_hash"]), normalized):
with core().database.transaction(conn):
conn.execute(
"UPDATE user_recovery_codes SET used_at=? WHERE id=?",
(_iso(_now()), entry["id"]),
)
remaining = int(
Database.value(
conn,
"SELECT COUNT(*) FROM user_recovery_codes "
"WHERE user_id=? AND used_at IS NULL",
(user_id,),
default=0,
)
or 0
)
audit.log(
conn,
actor=str(
Database.value(conn, "SELECT username FROM users WHERE id=?", (user_id,))
or ""
),
actor_id=user_id,
action="auth.recovery_code_used",
detail=f"Verbleibende Codes: {remaining}",
ip=ip,
severity="notice",
)
return True
return False
# --------------------------------------------------------------------------- #
# Erneute Bestaetigung ("sudo-Modus")
# --------------------------------------------------------------------------- #
def reauth_is_fresh() -> bool:
stamp = session.get(SESSION_REAUTH_KEY)
if not stamp:
return False
try:
moment = datetime.fromisoformat(str(stamp))
except ValueError:
return False
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
window = timedelta(minutes=core().config.security.reauth_valid_minutes)
return _now() - moment <= window
def mark_reauth() -> None:
session[SESSION_REAUTH_KEY] = _iso(_now())
def confirm_password(conn: sqlite3.Connection, user: User, password: str) -> bool:
"""Prueft das Passwort des angemeldeten Benutzers erneut.
Fuer Verzeichniskonten laeuft die Pruefung gegen das Verzeichnis -- sonst
koennten LDAP-Nutzer den sudo-Modus nie erreichen.
"""
from . import directory
row = rbac.get_user(conn, user.id)
if row is None:
return False
if str(row.get("auth_source")) == "local":
return passwords.verify_password(str(row.get("password_hash") or ""), password)
if not directory.is_enabled(conn):
return False
return directory.authenticate(conn, user.username, password).status == "ok"
+230
View File
@@ -0,0 +1,230 @@
"""Der Befehl ``web-setup`` -- HTTPS einrichten, ohne nginx von Hand anzufassen.
Beide Anwendungen registrieren ihn; die Logik steht darum genau einmal hier.
Warum es diesen Befehl ueberhaupt gibt: die nginx-Site wird ausschliesslich aus
der Vorlage in :mod:`tesm_core.system.webserver` erzeugt -- das ist die
Konsequenz aus dem alias-Fehler des Vorgaengers, der jahrelang ueberlebte, weil
irgendwann jemand die Datei von Hand angelegt hatte. Ein Installationsskript,
das sich seine eigene TLS-Konfiguration zusammensetzt, waere derselbe Fehler in
neu. Stattdessen setzt dieser Befehl die Einstellungen und laesst die Anwendung
schreiben -- genau wie die Oberflaeche es tut.
Aufruf aus ``install.sh``:
tesm-admin web-setup --https --self-signed --hsts --apply
tesm-admin web-setup --https --domain tesm.example.org --acme-email a@b.c --apply
"""
from __future__ import annotations
import argparse
import sys
from typing import Any
from .system import webserver
def _config_from_settings(extension: Any, conn: Any) -> webserver.WebConfig:
values = extension.settings.get_many(
conn,
[
"web_domain",
"web_https_enabled",
"web_hsts_enabled",
"web_redirect_http",
"web_cert_path",
"web_key_path",
"web_acme_webroot",
"web_max_body_mb",
"web_http_port",
"web_https_port",
],
)
return webserver.WebConfig(
app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str(
extension.registry.get("static_root", extension.config.base_dir / "static")
),
domain=str(values["web_domain"] or ""),
https_enabled=bool(values["web_https_enabled"]),
hsts_enabled=bool(values["web_hsts_enabled"]),
redirect_http=bool(values["web_redirect_http"]),
cert_path=str(values["web_cert_path"] or ""),
key_path=str(values["web_key_path"] or ""),
acme_webroot=str(
values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"
),
websocket_path=str(extension.registry.get("websocket_path", "")),
max_body_mb=int(values["web_max_body_mb"] or 128),
http_port=int(values["web_http_port"] or 80),
https_port=int(values["web_https_port"] or 443),
)
def run_web_setup(app: Any, extension: Any, args: argparse.Namespace) -> int:
"""Setzt die Webserver-Einstellungen und schreibt optional die Site-Datei."""
sysops = extension.sysops
with app.app_context(), extension.database.session() as conn:
values: dict[str, Any] = {}
if args.domain is not None:
try:
values["web_domain"] = webserver.validate_domain(args.domain)
except ValueError as exc:
print(str(exc), file=sys.stderr)
return 2
if args.acme_email:
values["web_acme_email"] = args.acme_email.strip()
if args.max_body_mb:
values["web_max_body_mb"] = int(args.max_body_mb)
for flag, key in (("http_port", "web_http_port"), ("https_port", "web_https_port")):
port = getattr(args, flag, None)
if port:
if not 1 <= int(port) <= 65535:
print(f"Ungueltiger Port: {port}", file=sys.stderr)
return 2
values[key] = int(port)
with extension.database.transaction(conn):
if values:
extension.settings.set_many(conn, values, actor="install")
# -- Zertifikat --------------------------------------------------- #
config = _config_from_settings(extension, conn)
cert: dict[str, Any] = {"ok": True, "message": "Zertifikat unveraendert."}
if args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
cert = webserver.request_certificate(
sysops,
domain=config.domain,
email=str(extension.settings.get(conn, "web_acme_email") or ""),
webroot=config.acme_webroot,
)
if not cert["ok"] and args.self_signed:
# Kein oeffentliches Zertifikat bekommen -- lieber
# selbstsigniertes HTTPS als gar keins. Der Aufrufer erfaehrt
# beides.
print(f"Let's Encrypt fehlgeschlagen: {cert.get('detail', cert['message'])}")
print("Weiche auf ein selbstsigniertes Zertifikat aus.")
args.acme = False
elif not cert["ok"]:
print(cert.get("detail") or cert["message"], file=sys.stderr)
return 1
if args.self_signed and not args.acme:
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
suggested = webserver.local_names()
cert = webserver.create_self_signed(
sysops,
common_name=config.domain or (suggested[0] if suggested else "localhost"),
alt_names=[*suggested, "localhost"],
)
if not cert["ok"]:
print(cert.get("detail") or cert["message"], file=sys.stderr)
return 1
if cert.get("cert_path"):
with extension.database.transaction(conn):
extension.settings.set_many(
conn,
{"web_cert_path": cert["cert_path"], "web_key_path": cert["key_path"]},
actor="install",
)
print(cert["message"])
# -- HTTPS-Schalter ------------------------------------------------ #
config = _config_from_settings(extension, conn)
if args.https:
if not (config.cert_path and config.key_path):
print(
"HTTPS verlangt Zertifikat und Schluessel -- bitte --self-signed "
"oder --acme angeben.",
file=sys.stderr,
)
return 2
with extension.database.transaction(conn):
extension.settings.set_many(
conn,
{
"web_https_enabled": True,
"web_hsts_enabled": bool(args.hsts),
"web_redirect_http": not args.no_redirect,
},
actor="install",
)
config = _config_from_settings(extension, conn)
rendered = webserver.render(config)
if args.print_config:
print(rendered)
return 0
if not args.apply:
print("Einstellungen gespeichert. Mit --apply wird nginx neu geschrieben.")
return 0
if sysops is None or not sysops.available:
print("Der Systemhelfer ist nicht verfuegbar.", file=sys.stderr)
return 2
result = webserver.apply_config(sysops, rendered)
print(result["message"])
if not result["ok"]:
print(result.get("detail", ""), file=sys.stderr)
return 1
if config.https_enabled:
detail = f"HTTPS auf {config.https_port}, {config.http_port} leitet um"
else:
detail = f"HTTP auf {config.http_port}"
print(f"nginx: {config.server_name} -> {config.upstream} -- {detail}")
return 0
def add_web_setup(sub: Any, runner: Any) -> None:
"""Haengt ``web-setup`` an einen ``argparse``-Subparser."""
parser = sub.add_parser(
"web-setup",
help="nginx und TLS einrichten (Port 80/443)",
description=(
"Setzt die Webserver-Einstellungen und erzeugt daraus die nginx-Site. "
"Die Datei wird immer vollstaendig neu geschrieben -- eine von Hand "
"bearbeitete Site-Datei gibt es in diesem Projekt bewusst nicht."
),
)
parser.add_argument("--https", action="store_true", help="HTTPS auf Port 443 aktivieren")
parser.add_argument(
"--self-signed",
action="store_true",
help="Selbstsigniertes Zertifikat erzeugen (geschlossene Netze ohne Domain)",
)
parser.add_argument(
"--acme",
action="store_true",
help="Zertifikat per Let's Encrypt anfordern (Domain muss oeffentlich zeigen)",
)
parser.add_argument("--domain", help="Oeffentlicher Name dieser Installation")
parser.add_argument("--acme-email", help="Kontaktadresse fuer Let's Encrypt")
parser.add_argument("--hsts", action="store_true", help="HSTS-Header setzen")
parser.add_argument(
"--no-redirect",
action="store_true",
help="Port 80 nicht auf HTTPS umleiten (Sonderfall)",
)
parser.add_argument("--max-body-mb", type=int, help="Obergrenze fuer Uploads")
parser.add_argument("--http-port", type=int, help="Abweichender HTTP-Port (Standard 80)")
parser.add_argument("--https-port", type=int, help="Abweichender HTTPS-Port (Standard 443)")
parser.add_argument("--apply", action="store_true", help="nginx schreiben, pruefen, neu laden")
parser.add_argument(
"--print-config", action="store_true", help="Nur die erzeugte Datei ausgeben"
)
parser.set_defaults(func=runner)
__all__ = ["add_web_setup", "run_web_setup"]
+205
View File
@@ -0,0 +1,205 @@
"""Konfiguration aus Umgebungsvariablen -- typisiert, an einer Stelle.
Im Vorgaengerprojekt waren Pfade und Konstanten quer durch eine mehrere
tausend Zeilen lange ``app.py`` verstreut. Hier gibt es genau ein Objekt, das
beim Start gebaut, validiert und danach nicht mehr veraendert wird.
Namensraum: jede Anwendung bekommt ein eigenes Env-Praefix (``TESM_`` bzw.
``TESM_LICENSE_``), damit beide Dienste auf demselben Host ohne Kollisionen
laufen koennen -- inklusive der Pfade, die im Vorgaenger kollidierten
(netplan-Override!).
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field, replace
from pathlib import Path
def _env(prefix: str, name: str, default: str = "") -> str:
return os.environ.get(f"{prefix}{name}", default).strip()
def _env_bool(prefix: str, name: str, default: bool) -> bool:
raw = _env(prefix, name)
if not raw:
return default
return raw.lower() in {"1", "true", "yes", "on"}
def _env_int(prefix: str, name: str, default: int) -> int:
raw = _env(prefix, name)
try:
return int(raw) if raw else default
except ValueError:
return default
@dataclass(frozen=True, slots=True)
class SecurityConfig:
"""Alles, was das Sicherheitsverhalten steuert. Defaults sind die sichere Wahl."""
#: Cookie nur ueber HTTPS ausliefern. In Produktion immer True (nginx terminiert TLS).
cookie_secure: bool = True
cookie_samesite: str = "Lax"
#: Leerlauf-Zeitfenster einer Sitzung.
session_idle_minutes: int = 60
#: Harte Obergrenze unabhaengig von Aktivitaet.
session_absolute_hours: int = 12
#: Wie lange eine erneute Passwortabfrage ("sudo-Modus") gilt.
reauth_valid_minutes: int = 15
#: Fehlversuche bis zur temporaeren Sperre eines Kontos.
login_max_attempts: int = 8
login_lockout_minutes: int = 15
#: Fehlversuche pro IP im gleichen Zeitfenster (unabhaengig vom Konto).
login_max_attempts_per_ip: int = 30
#: Mindestlaenge lokaler Passwoerter.
password_min_length: int = 12
#: 2FA fuer Administratoren erzwingen.
require_totp_for_admins: bool = False
#: HSTS-Header setzen (nur sinnvoll, wenn TLS tatsaechlich terminiert wird).
hsts_enabled: bool = False
hsts_max_age: int = 15552000
#: Vertrauenswuerdige Proxy-Hops fuer X-Forwarded-For (0 = Header ignorieren).
trusted_proxy_hops: int = 1
#: Maximale Groesse eines Uploads in Bytes (None = unbegrenzt).
max_content_length: int | None = 64 * 1024 * 1024
@dataclass(frozen=True, slots=True)
class CoreConfig:
"""Basiskonfiguration, die beide Anwendungen teilen."""
app_key: str
"""Technischer Kurzname, z. B. ``tesm`` -- Namensraum fuer Pfade und Units."""
site_key: str
"""Name *dieser Installation* auf dem Host, z. B. ``tesm-opus``.
Weicht vom ``app_key`` ab, sobald neben der Hauptinstallation eine zweite
Instanz laeuft. Alles, was ausserhalb von ``/srv/<name>`` liegt und damit
zwischen Instanzen kollidieren koennte -- nginx-Site, netplan-Datei,
logrotate-Regel, Zertifikatsverzeichnis, Name des Sitzungscookies --, wird
ueber diesen Schluessel benannt und **nicht** ueber den ``app_key``.
Ohne diese Trennung wuerde eine Testinstanz beim Speichern der
Webserver-Einstellungen die Site-Datei der Produktion ueberschreiben, und
beide Instanzen wuerden sich auf demselben Host dasselbe Sitzungscookie
teilen (Cookies unterscheiden keine Ports).
"""
app_name: str
"""Anzeigename in der Oberflaeche."""
env_prefix: str
base_dir: Path
data_dir: Path
log_dir: Path
db_path: Path
secret_key_file: Path
data_key_file: Path
license_path: Path
license_key_path: Path
#: Adresse, auf der der Anwendungsserver lauscht -- exakt das, was nginx
#: als ``proxy_pass``-Ziel braucht. Kommt aus derselben Umgebungsdatei wie
#: der ``--bind``-Parameter von gunicorn, damit beide nie auseinanderlaufen.
#: Vorher stand hier ein fest verdrahtetes ``127.0.0.1:5000``; eine zweite
#: Instanz auf Port 5100 erzeugte damit eine nginx-Site, die auf die
#: *andere* Installation zeigte.
bind: str = "127.0.0.1:5000"
debug: bool = False
#: Instanz laeuft als echter Webprozess (gesetzt durch die systemd-Unit).
web_process: bool = False
security: SecurityConfig = field(default_factory=SecurityConfig)
#: Zusaetzliche, app-spezifische Werte.
extra: dict[str, str] = field(default_factory=dict)
@property
def is_production(self) -> bool:
return not self.debug
def path_in_data(self, name: str) -> Path:
return self.data_dir / name
def load_core_config(
*,
app_key: str,
app_name: str,
env_prefix: str,
default_base: Path | None = None,
default_bind: str = "127.0.0.1:5000",
) -> CoreConfig:
"""Liest die Konfiguration aus der Umgebung.
Ohne gesetzte Env-Vars laeuft alles in einem Unterverzeichnis
``instance/<app_key>`` relativ zum aktuellen Arbeitsverzeichnis -- damit ist
die Anwendung ohne jede Vorbereitung lokal startbar, ohne je nach
``/srv``/``/var/log`` zu greifen.
"""
base = Path(_env(env_prefix, "BASE_DIR") or (default_base or Path.cwd() / "instance" / app_key))
base = base.expanduser().resolve()
data_dir = Path(_env(env_prefix, "DATA_DIR") or base / "data").expanduser()
log_dir = Path(_env(env_prefix, "LOG_DIR") or base / "logs").expanduser()
security = SecurityConfig(
cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", True),
cookie_samesite=_env(env_prefix, "COOKIE_SAMESITE") or "Lax",
session_idle_minutes=_env_int(env_prefix, "SESSION_IDLE_MINUTES", 60),
session_absolute_hours=_env_int(env_prefix, "SESSION_ABSOLUTE_HOURS", 12),
reauth_valid_minutes=_env_int(env_prefix, "REAUTH_VALID_MINUTES", 15),
login_max_attempts=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS", 8),
login_lockout_minutes=_env_int(env_prefix, "LOGIN_LOCKOUT_MINUTES", 15),
login_max_attempts_per_ip=_env_int(env_prefix, "LOGIN_MAX_ATTEMPTS_PER_IP", 30),
password_min_length=_env_int(env_prefix, "PASSWORD_MIN_LENGTH", 12),
require_totp_for_admins=_env_bool(env_prefix, "REQUIRE_TOTP_FOR_ADMINS", False),
hsts_enabled=_env_bool(env_prefix, "HSTS_ENABLED", False),
hsts_max_age=_env_int(env_prefix, "HSTS_MAX_AGE", 15552000),
trusted_proxy_hops=_env_int(env_prefix, "TRUSTED_PROXY_HOPS", 1),
max_content_length=_env_int(env_prefix, "MAX_CONTENT_LENGTH", 64 * 1024 * 1024) or None,
)
debug = _env_bool(env_prefix, "DEBUG", False)
if debug:
# Lokale Entwicklung laeuft ueber http://127.0.0.1 -- ein Secure-Cookie
# wuerde dort nie gesetzt und die Anmeldung schlicht nicht funktionieren.
# dataclasses.replace, nicht __dict__: SecurityConfig hat slots und
# damit gar kein __dict__ -- der alte Ausdruck warf beim Start einen
# AttributeError, und zwar genau im Entwicklungsmodus, den das README
# empfiehlt.
security = replace(
security, cookie_secure=_env_bool(env_prefix, "COOKIE_SECURE", False)
)
bind = (_env(env_prefix, "BIND") or default_bind).strip()
if not re.fullmatch(r"[0-9A-Za-z_.\[\]:-]{1,80}:\d{1,5}", bind):
raise ValueError(f"Ungueltige Bind-Adresse: {bind!r}")
site_key = (_env(env_prefix, "SITE_KEY") or app_key).strip().lower()
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,39}", site_key):
raise ValueError(f"Ungueltiger SITE_KEY: {site_key!r}")
return CoreConfig(
app_key=app_key,
site_key=site_key,
bind=bind,
app_name=_env(env_prefix, "APP_NAME") or app_name,
env_prefix=env_prefix,
base_dir=base,
data_dir=data_dir,
log_dir=log_dir,
db_path=Path(_env(env_prefix, "DB_PATH") or data_dir / "app.db").expanduser(),
secret_key_file=Path(
_env(env_prefix, "SECRET_KEY_FILE") or data_dir / "secret.key"
).expanduser(),
data_key_file=Path(_env(env_prefix, "DATA_KEY_FILE") or data_dir / "data.keys").expanduser(),
license_path=Path(_env(env_prefix, "LICENSE_PATH") or data_dir / "license.json").expanduser(),
license_key_path=Path(
_env(env_prefix, "LICENSE_KEY_PATH") or data_dir / "license_key.json"
).expanduser(),
debug=debug,
web_process=_env(env_prefix, "WEB_PROCESS") == "1",
security=security,
)
@@ -0,0 +1,193 @@
"""Migrationen der Komponente ``core``: Benutzer, Gruppen, Sitzungen, Audit, Settings.
Diese Tabellen sind in beiden Anwendungen identisch. Neue Aenderungen kommen
als **neue** Migration mit neuer Versionsnummer dazu -- bestehende Migrationen
werden nie editiert (die Pruefsummenwarnung des Runners weist darauf hin).
"""
from __future__ import annotations
from .migrations import Migration
V1_BASELINE = """
-- Benutzerkonten. 'local' = eigenes Passwort, 'ldap' = Schattenkonto aus dem
-- Verzeichnisdienst (Passwort wird dort geprueft, nie hier gespeichert).
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL COLLATE NOCASE,
password_hash TEXT NOT NULL DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
is_locked INTEGER NOT NULL DEFAULT 0,
auth_source TEXT NOT NULL DEFAULT 'local',
first_name TEXT NOT NULL DEFAULT '',
last_name TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
avatar_filename TEXT NOT NULL DEFAULT '',
totp_secret TEXT NOT NULL DEFAULT '',
totp_confirmed_at TEXT,
must_change_password INTEGER NOT NULL DEFAULT 0,
password_changed_at TEXT,
last_login_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
-- Eindeutigkeit nur unter den *nicht* geloeschten Konten: ein Name im
-- Papierkorb blockiert die Neuanlage nicht dauerhaft, solange die Anwendung
-- den Konflikt bewusst behandelt.
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username_live
ON users(username) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_deleted ON users(deleted_at);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL COLLATE NOCASE,
description TEXT NOT NULL DEFAULT '',
is_system INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0,
preset_key TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_live
ON groups(name) WHERE deleted_at IS NULL;
CREATE TABLE IF NOT EXISTS group_permissions (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
permission TEXT NOT NULL,
PRIMARY KEY (group_id, permission)
);
CREATE TABLE IF NOT EXISTS user_groups (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, group_id)
);
CREATE INDEX IF NOT EXISTS idx_user_groups_group ON user_groups(group_id);
-- Einmal-Codes fuer den Verlust des Authenticators. Gespeichert wird nur ein
-- Argon2-Hash -- ein Datenbankleck gibt die Codes nicht her.
CREATE TABLE IF NOT EXISTS user_recovery_codes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_recovery_user ON user_recovery_codes(user_id);
CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
idle_expires_at TEXT NOT NULL,
absolute_expires_at TEXT NOT NULL,
reauth_at TEXT,
ip TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
data TEXT NOT NULL DEFAULT '{}',
revoked_at TEXT,
revoked_reason TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_user ON user_sessions(user_id, revoked_at);
CREATE INDEX IF NOT EXISTS idx_sessions_idle ON user_sessions(idle_expires_at);
-- Sliding-Window-Log der Ratenbegrenzung (Anmeldeversuche, Portal-Anfragen, API).
CREATE TABLE IF NOT EXISTS rate_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bucket TEXT NOT NULL,
subject TEXT NOT NULL,
ts TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_lookup ON rate_events(bucket, subject, ts);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT NOT NULL DEFAULT ''
);
-- Aenderungsprotokoll mit Hash-Kette: jede Zeile bindet den Hash ihrer
-- Vorgaengerin ein. Eine nachtraeglich geloeschte oder veraenderte Zeile bricht
-- die Kette und wird von der Pruefroutine gefunden.
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
actor TEXT NOT NULL,
actor_id INTEGER,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
request_id TEXT NOT NULL DEFAULT '',
severity TEXT NOT NULL DEFAULT 'info',
prev_hash TEXT NOT NULL DEFAULT '',
hash TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
-- Dienstkonten (z. B. LDAP-Bind). Das Passwort liegt AES-GCM-verschluesselt vor,
-- gebunden an den Verwendungszweck als AAD.
CREATE TABLE IF NOT EXISTS service_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
purpose TEXT NOT NULL UNIQUE,
username TEXT NOT NULL DEFAULT '',
password_encrypted TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS ldap_group_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ad_group_dn TEXT NOT NULL UNIQUE,
ad_group_name TEXT NOT NULL,
app_group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
"""
V2_TRUSTED_KEYS = """
-- Gepinnte Aussteller-Schluessel der Lizenzpruefung (Trust on First Use bzw.
-- im Build mitgeliefert). Der Vorgaenger vertraute allein dem in der
-- Lizenzdatei eingebetteten Schluessel.
CREATE TABLE IF NOT EXISTS trusted_issuer_keys (
key_id TEXT PRIMARY KEY,
public_key TEXT NOT NULL,
label TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT 'tofu',
added_at TEXT NOT NULL,
added_by TEXT NOT NULL DEFAULT ''
);
"""
V3_JOBS = """
-- Persistente Auftragsverfolgung fuer Hintergrundlaeufe (Wartung, Import,
-- Berichte). Im Vorgaenger lebte dieser Zustand ausschliesslich im
-- Prozessspeicher und war nach einem Dienst-Neustart weg -- inklusive der
-- Information, ob ein Lauf ueberhaupt stattgefunden hat.
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
subject TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
output TEXT NOT NULL DEFAULT '',
started_by TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
finished_at TEXT
);
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);
"""
CORE_MIGRATIONS: tuple[Migration, ...] = (
Migration(version=1, name="baseline", sql=V1_BASELINE),
Migration(version=2, name="trusted-issuer-keys", sql=V2_TRUSTED_KEYS),
Migration(version=3, name="jobs", sql=V3_JOBS),
)
+213
View File
@@ -0,0 +1,213 @@
"""Datenbankzugriff auf SQLite -- eine Verbindung pro Request, Schreibsperren strukturell.
Die schmerzhafteste Fehlerklasse des Vorgaengerprojekts war
``sqlite3.OperationalError: database is locked``: eine Hilfsfunktion oeffnete
still eine *zweite* Verbindung, waehrend die aeussere Funktion noch eine offene
Schreibtransaktion hielt. Das ist hier nicht mehr durch Disziplin, sondern
strukturell verhindert:
* Pro Request existiert genau **eine** Verbindung (in ``flask.g``).
* ``Database.transaction()`` zaehlt die Verschachtelungstiefe. Wer waehrend
einer offenen Schreibtransaktion ``Database.connect()`` aufruft, bekommt
sofort einen ``NestedConnectionError`` mit Klartext-Erklaerung -- nicht erst
nach 15 Sekunden Busy-Timeout einen Deadlock.
* Verschachtelte ``transaction()``-Aufrufe auf derselben Verbindung sind
erlaubt und werden zu einem einzigen Commit zusammengefasst (Savepoints),
damit Service-Funktionen sich gefahrlos gegenseitig aufrufen koennen.
Zusaetzlich: WAL-Modus (paralleles Lesen waehrend eines Schreibers),
``foreign_keys=ON`` (im Vorgaenger nie aktiviert -- Fremdschluessel waren
Dekoration) und ``busy_timeout=15000``.
"""
from __future__ import annotations
import os
import sqlite3
import threading
from collections.abc import Iterator, Mapping, Sequence
from contextlib import contextmanager
from pathlib import Path
from typing import Any
class NestedConnectionError(RuntimeError):
"""Zweite Verbindung waehrend einer offenen Schreibtransaktion."""
class Row(sqlite3.Row):
"""``sqlite3.Row`` mit ``dict``-artigem ``get`` und Mapping-Konvertierung."""
def get(self, key: str, default: Any = None) -> Any:
try:
return self[key]
except (IndexError, KeyError):
return default
def as_dict(self) -> dict[str, Any]:
return {key: self[key] for key in self.keys()}
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
return {key: row[key] for key in row.keys()}
def rows_to_dicts(rows: Sequence[sqlite3.Row]) -> list[dict[str, Any]]:
return [{key: row[key] for key in row.keys()} for row in rows]
class Database:
"""Zugriffsobjekt fuer eine SQLite-Datei."""
def __init__(self, path: Path | str, *, timeout: float = 15.0) -> None:
self.path = Path(path)
self.timeout = timeout
self._state = threading.local()
# ------------------------------------------------------------------ #
# Verbindungen
# ------------------------------------------------------------------ #
def _depth(self) -> int:
return int(getattr(self._state, "depth", 0))
def _set_depth(self, value: int) -> None:
self._state.depth = value
def connect(self) -> sqlite3.Connection:
"""Oeffnet eine neue Verbindung. Nicht aufrufen, solange eine Transaktion laeuft."""
if self._depth() > 0:
raise NestedConnectionError(
"Es laeuft bereits eine Schreibtransaktion auf dieser Datenbank. "
"Reiche die bestehende Verbindung durch, statt eine neue zu oeffnen "
"(sonst blockiert SQLite sich selbst)."
)
return self._raw_connect()
def _raw_connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
fresh = not self.path.exists()
conn = sqlite3.connect(
self.path,
timeout=self.timeout,
isolation_level=None, # Transaktionen steuern wir selbst, explizit.
check_same_thread=False,
)
conn.row_factory = Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.execute("PRAGMA busy_timeout=15000")
conn.execute("PRAGMA secure_delete=ON")
if fresh:
# Die Datei enthaelt Passwort-Hashes, verschluesselte Zugangsdaten und
# Sitzungen -- sie gehoert niemandem ausser dem Dienstbenutzer.
try:
os.chmod(self.path, 0o600)
except OSError: # pragma: no cover - z. B. Windows/NTFS
pass
return conn
@contextmanager
def session(self) -> Iterator[sqlite3.Connection]:
"""Kurzlebige Verbindung fuer Skripte und Hintergrund-Threads."""
conn = self.connect()
try:
yield conn
finally:
conn.close()
# ------------------------------------------------------------------ #
# Transaktionen
# ------------------------------------------------------------------ #
@contextmanager
def transaction(self, conn: sqlite3.Connection | None = None) -> Iterator[sqlite3.Connection]:
"""Schreibtransaktion mit ``BEGIN IMMEDIATE``.
``BEGIN IMMEDIATE`` nimmt die Schreibsperre sofort statt erst beim ersten
Schreibzugriff -- damit scheitert ein konkurrierender Schreiber sauber am
Busy-Timeout, statt mitten in der Transaktion in einen
``SQLITE_BUSY_SNAPSHOT`` zu laufen.
"""
own_connection = conn is None
if own_connection:
conn = self._raw_connect()
assert conn is not None
depth = self._depth()
savepoint = f"sp_{depth}"
self._set_depth(depth + 1)
try:
if depth == 0:
conn.execute("BEGIN IMMEDIATE")
else:
conn.execute(f"SAVEPOINT {savepoint}")
yield conn
except BaseException:
try:
if depth == 0:
conn.execute("ROLLBACK")
else:
conn.execute(f"ROLLBACK TO {savepoint}")
except sqlite3.Error: # pragma: no cover - Verbindung schon tot
pass
raise
else:
if depth == 0:
conn.execute("COMMIT")
else:
conn.execute(f"RELEASE {savepoint}")
finally:
self._set_depth(depth)
if own_connection:
conn.close()
@property
def in_transaction(self) -> bool:
return self._depth() > 0
# ------------------------------------------------------------------ #
# Bequeme Abfragehelfer (immer parametrisiert -- kein String-Basteln)
# ------------------------------------------------------------------ #
@staticmethod
def one(
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
) -> dict[str, Any] | None:
return row_to_dict(conn.execute(sql, params).fetchone())
@staticmethod
def all(
conn: sqlite3.Connection, sql: str, params: Sequence[Any] | Mapping[str, Any] = ()
) -> list[dict[str, Any]]:
return rows_to_dicts(conn.execute(sql, params).fetchall())
@staticmethod
def value(
conn: sqlite3.Connection,
sql: str,
params: Sequence[Any] | Mapping[str, Any] = (),
default: Any = None,
) -> Any:
row = conn.execute(sql, params).fetchone()
if row is None:
return default
return row[0]
@staticmethod
def column_exists(conn: sqlite3.Connection, table: str, column: str) -> bool:
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
return any(row["name"] == column for row in rows)
@staticmethod
def table_exists(conn: sqlite3.Connection, table: str) -> bool:
return (
conn.execute(
"SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name=?",
(table,),
).fetchone()
is not None
)
@@ -0,0 +1,120 @@
"""Die zentrale Erweiterung: haelt alles zusammen, was eine App braucht.
Statt globaler Modulvariablen (der Vorgaenger hatte ein Dutzend davon, u. a.
``_license_state`` und die DB-Pfade) haengt der gesamte Anwendungszustand an
einem Objekt in ``app.extensions["tesm"]``. Zugriff ueber ``core()``.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Sequence
from flask import Flask, current_app, g
from .config import CoreConfig
from .db import Database
from .keystore import Keystore
from .migrations import Migration, MigrationRunner
from .nav import NavTree
from .rbac.model import PermissionTree, RolePreset
from .security.passwords import PasswordPolicy
from .settings_store import SettingsStore
if TYPE_CHECKING: # pragma: no cover
from .licensing_client import LicenseManager
from .sysops import SysOps
@dataclass
class TesmCore:
"""Anwendungsweiter Zustand. Nach ``init_app`` unveraendert."""
config: CoreConfig
database: Database
keystore: Keystore
permissions: PermissionTree
nav: NavTree
settings: SettingsStore
role_presets: tuple[RolePreset, ...] = ()
migrations: dict[str, Sequence[Migration]] = field(default_factory=dict)
license: "LicenseManager | None" = None
sysops: "SysOps | None" = None
#: App-spezifische Erweiterungen (z. B. Papierkorb-Register, Job-Registry).
registry: dict[str, Any] = field(default_factory=dict)
#: Wird nach einer erfolgreichen Anmeldung aufgerufen:
#: ``(conn, user, secret, group_dns)``. ``secret`` ist das Passwort dieser
#: Anmeldung -- es wird nirgends gespeichert und ist nach dem Aufruf weg.
#: Ein Haken, der scheitert, sperrt niemanden aus (siehe
#: ``auth.service._run_login_hooks``).
login_hooks: list[Callable[..., None]] = field(default_factory=list)
#: Wird vor dem Verwerfen der Sitzung aufgerufen: ``(conn, user)``. Was die
#: Anmeldung aufgebaut hat, wird hier wieder abgebaut -- sonst bliebe es
#: bestehen, bis eine Zeitgrenze greift.
logout_hooks: list[Callable[..., None]] = field(default_factory=list)
@property
def password_policy(self) -> PasswordPolicy:
return PasswordPolicy(min_length=self.config.security.password_min_length)
@property
def audit_archive_dir(self) -> Path:
return self.config.log_dir / "audit"
def run_migrations(self) -> list[str]:
runner = MigrationRunner(self.database)
applied: list[str] = []
for component, migrations in self.migrations.items():
applied.extend(runner.run(component, migrations))
return applied
def migration_status(self) -> list[dict[str, object]]:
return MigrationRunner(self.database).status(list(self.migrations))
def core(app: Flask | None = None) -> TesmCore:
target = app or current_app
try:
return target.extensions["tesm"] # type: ignore[return-value]
except KeyError as exc: # pragma: no cover - Programmierfehler
raise RuntimeError("tesm_core ist fuer diese Anwendung nicht initialisiert") from exc
def db() -> sqlite3.Connection:
"""Die Verbindung dieses Requests. Genau eine pro Request, nie eine zweite.
Diese Funktion ist der einzige zugelassene Weg, im Request-Kontext an eine
Verbindung zu kommen. Wer stattdessen selbst ``Database.connect()`` aufruft,
riskiert genau den Deadlock, an dem der Vorgaenger mehrfach hing.
"""
connection = getattr(g, "_tesm_conn", None)
if connection is None:
connection = core().database.connect()
g._tesm_conn = connection
return connection
def close_db(_exception: BaseException | None = None) -> None:
connection = getattr(g, "_tesm_conn", None)
if connection is not None:
g._tesm_conn = None
connection.close()
def transaction() -> Any:
"""Kurzform fuer ``core().database.transaction(db())``."""
return core().database.transaction(db())
def with_transaction(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Dekorator: fuehrt eine Funktion innerhalb einer Schreibtransaktion aus."""
def wrapper(*args: Any, **kwargs: Any) -> Any:
with transaction() as conn:
return fn(conn, *args, **kwargs)
wrapper.__name__ = fn.__name__
wrapper.__doc__ = fn.__doc__
return wrapper
@@ -0,0 +1,212 @@
"""Schluesselverwaltung und Feldverschluesselung (AES-256-GCM, rotierbar).
Der Vorgaenger nutzte Fernet mit einem einzigen, nie rotierbaren Schluessel in
``fernet.key``. Fernet ist in Ordnung, aber:
* Ein Schluesselwechsel war nicht vorgesehen -- ein kompromittierter Schluessel
haette bedeutet, alle Zugangsdaten von Hand neu einzugeben.
* Es gab keine zugeordneten Daten (AAD), also keine kryptographische Bindung
eines Chiffrats an den Ort, an dem es steht. Ein Chiffrat aus Tabelle A liess
sich in Tabelle B einspielen.
Hier: ein versionierter Schluesselbund (``data.keys``, Modus 0600) mit
AES-256-GCM. Jedes Chiffrat traegt die Schluessel-ID im Klartext-Praefix, damit
alte Werte nach einer Rotation weiter lesbar bleiben. ``aad`` bindet ein
Chiffrat an seinen Verwendungszweck (z. B. ``credentials.password:42``).
Format: ``v1.<key_id>.<base64url(nonce)>.<base64url(ciphertext)>``
"""
from __future__ import annotations
import base64
import json
import os
import secrets
import stat
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
_PREFIX = "v1"
_NONCE_BYTES = 12
_KEY_BYTES = 32
class DecryptionError(Exception):
"""Chiffrat gehoert nicht zu diesem Schluesselbund oder wurde manipuliert."""
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64d(text: str) -> bytes:
padded = text + "=" * (-len(text) % 4)
return base64.urlsafe_b64decode(padded.encode("ascii"))
def write_private_file(path: Path, content: str) -> None:
"""Schreibt eine Datei mit Modus 0600, atomar (erst tmp, dann ersetzen)."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
handle = os.open(tmp, flags, 0o600)
try:
with os.fdopen(handle, "w", encoding="utf-8") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
except BaseException:
tmp.unlink(missing_ok=True)
raise
os.replace(tmp, path)
try:
os.chmod(path, 0o600)
except OSError: # pragma: no cover - Windows
pass
def check_private_file(path: Path) -> list[str]:
"""Meldet zu weite Dateirechte -- die Diagnoseseite zeigt das an."""
problems: list[str] = []
try:
mode = path.stat().st_mode
except OSError:
return problems
if mode & (stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH):
problems.append(f"{path} ist fuer Gruppe/Andere lesbar oder schreibbar (erwartet 0600)")
return problems
@dataclass(frozen=True, slots=True)
class _Key:
key_id: str
material: bytes
created_at: str
class Keystore:
"""Rotierbarer Schluesselbund fuer Feldverschluesselung."""
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._keys: dict[str, _Key] = {}
self._active: str = ""
self._load_or_create()
# ------------------------------------------------------------------ #
def _load_or_create(self) -> None:
if self.path.exists():
raw = json.loads(self.path.read_text(encoding="utf-8"))
for entry in raw.get("keys", []):
key = _Key(
key_id=str(entry["id"]),
material=_b64d(str(entry["key"])),
created_at=str(entry.get("created_at", "")),
)
if len(key.material) != _KEY_BYTES:
raise ValueError(f"Schluessel {key.key_id} hat die falsche Laenge")
self._keys[key.key_id] = key
self._active = str(raw.get("active") or "")
if self._active not in self._keys:
raise ValueError("Aktiver Schluessel fehlt im Schluesselbund")
return
self._active = "1"
self._keys = {
"1": _Key(
key_id="1",
material=secrets.token_bytes(_KEY_BYTES),
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
}
self._persist()
def _persist(self) -> None:
payload = {
"active": self._active,
"keys": [
{"id": key.key_id, "key": _b64e(key.material), "created_at": key.created_at}
for key in sorted(self._keys.values(), key=lambda k: int(k.key_id))
],
}
write_private_file(self.path, json.dumps(payload, indent=2, sort_keys=True) + "\n")
# ------------------------------------------------------------------ #
@property
def active_key_id(self) -> str:
return self._active
def key_ids(self) -> list[str]:
return sorted(self._keys, key=int)
def rotate(self) -> str:
"""Legt einen neuen aktiven Schluessel an. Alte bleiben zum Lesen erhalten."""
new_id = str(max(int(k) for k in self._keys) + 1)
self._keys[new_id] = _Key(
key_id=new_id,
material=secrets.token_bytes(_KEY_BYTES),
created_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
self._active = new_id
self._persist()
return new_id
def drop_key(self, key_id: str) -> None:
"""Entfernt einen alten Schluessel -- erst nach vollstaendiger Neuverschluesselung."""
if key_id == self._active:
raise ValueError("Der aktive Schluessel kann nicht entfernt werden")
self._keys.pop(key_id, None)
self._persist()
# ------------------------------------------------------------------ #
def encrypt(self, plaintext: str, *, aad: str) -> str:
key = self._keys[self._active]
nonce = secrets.token_bytes(_NONCE_BYTES)
ciphertext = AESGCM(key.material).encrypt(
nonce, plaintext.encode("utf-8"), aad.encode("utf-8")
)
return f"{_PREFIX}.{key.key_id}.{_b64e(nonce)}.{_b64e(ciphertext)}"
def decrypt(self, token: str, *, aad: str) -> str:
try:
prefix, key_id, nonce_b64, ct_b64 = token.split(".", 3)
except ValueError as exc:
raise DecryptionError("Chiffrat hat ein unbekanntes Format") from exc
if prefix != _PREFIX:
raise DecryptionError(f"Unbekannte Chiffrat-Version: {prefix!r}")
key = self._keys.get(key_id)
if key is None:
raise DecryptionError(f"Schluessel {key_id} ist nicht im Schluesselbund")
try:
plain = AESGCM(key.material).decrypt(
_b64d(nonce_b64), _b64d(ct_b64), aad.encode("utf-8")
)
except (InvalidTag, ValueError) as exc:
raise DecryptionError(
"Chiffrat konnte nicht entschluesselt werden (falscher Schluessel, "
"manipulierte Daten oder falscher Verwendungszweck)"
) from exc
return plain.decode("utf-8")
def needs_reencrypt(self, token: str) -> bool:
"""True, wenn das Chiffrat noch unter einem alten Schluessel liegt."""
parts = token.split(".", 3)
return len(parts) == 4 and parts[1] != self._active
def load_or_create_secret_key(path: Path) -> bytes:
"""Flask-Secret-Key: 64 Byte, Datei mit Modus 0600, beim ersten Start erzeugt."""
if path.exists():
raw = path.read_text(encoding="utf-8").strip()
if raw:
return _b64d(raw)
material = secrets.token_bytes(64)
write_private_file(path, _b64e(material) + "\n")
return material
@@ -0,0 +1,778 @@
"""Lizenzverwaltung auf Client-Seite (TESM) und fuer die eigene Lizenz des Servers.
Zustaendig fuer: Lizenzdatei laden und verifizieren, Aussteller pinnen,
Aktivierung/Deaktivierung/Heartbeat (online und offline), Modul-Freischaltung
und die Anzeige in der Topbar.
Wesentliche Aenderungen gegenueber dem Vorgaenger:
* **Vertrauensanker.** Die Signatur wird gegen gepinnte Aussteller-Schluessel
geprueft (Tabelle ``trusted_issuer_keys``). Beim ersten Import wird der
Schluessel per Trust-on-First-Use uebernommen und dabei protokolliert; jede
spaetere Lizenz eines *anderen* Ausstellers wird abgelehnt, bis ein
Administrator den neuen Schluessel ausdruecklich freigibt. Der Vorgaenger
pruefte ausschliesslich gegen den Schluessel *in derselben Datei* -- damit war
jede selbst signierte Datei "gueltig".
* **Trennung von Dokument und Client-Schluessel** (siehe ``tesm_licensing``):
``license.json`` (0640) und ``license_key.json`` (0600).
* **Ein Gate, eine Wahrheit.** Der Vorgaenger hatte eine Docstring, die mehr
behauptete als der Code durchsetzte (LDAP/NGINX waren nur im Template
gegated). Hier gibt es genau eine Funktion ``require_license()`` und eine
Tabelle in ``LICENSED_FEATURES`` -- was dort steht, wird serverseitig
durchgesetzt, ohne Ausnahme.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
import tesm_licensing as lic
from . import audit
from .db import Database
from .keystore import write_private_file
logger = logging.getLogger(__name__)
SETTING_STATUS = "license_activation_status"
SETTING_PENDING = "license_pending_request"
SETTING_LAST_HEARTBEAT = "license_last_heartbeat_at"
SETTING_HEARTBEAT_ERROR = "license_heartbeat_error"
SETTING_EXPORT_GRACE = "license_export_grace"
STATUS_INACTIVE = "inactive"
STATUS_PENDING = "pending"
STATUS_ACTIVE = "active"
API_PATHS = {
"activate": "/api/v1/licenses/activate",
"deactivate": "/api/v1/licenses/deactivate",
"heartbeat": "/api/v1/licenses/heartbeat",
}
#: Funktionen, die eine gueltige, aktivierte Lizenz brauchen. Diese Tabelle ist
#: die einzige Wahrheit -- Templates lesen sie, Routen erzwingen sie.
LICENSED_FEATURES: dict[str, str] = {
"directory": "Verzeichnisanmeldung (LDAP/AD) konfigurieren",
"nginx": "Webserver-Einstellungen aendern",
"export": "Daten exportieren",
"trash_restore": "Papierkorb wiederherstellen und endgueltig loeschen",
"monitor_interval": "Pruefintervall der Ueberwachung aendern",
"auto_poe_restart": "Automatischer PoE-Neustart bei Ausfall",
}
def describe_transport_error(exc: BaseException, endpoint: str) -> str:
"""Uebersetzt einen Netzfehler in etwas, mit dem man arbeiten kann.
"War nicht erreichbar" ist bei einem Zertifikatsfehler schlicht falsch --
der Server war sehr wohl erreichbar, nur nicht vertrauenswuerdig. Wer das
nicht weiss, sucht am falschen Ende.
"""
text = str(exc)
if "CERTIFICATE_VERIFY_FAILED" in text or "certificate verify failed" in text:
detail = "Das Zertifikat konnte nicht geprueft werden"
if "self-signed" in text or "self signed" in text:
detail = "Der Lizenzserver benutzt ein selbstsigniertes Zertifikat"
return (
f"{detail} ({endpoint}). Hinterlegen Sie sein Zertifikat unter "
"Einstellungen -> Lizenz, oder geben Sie dem Lizenzserver ein Zertifikat "
"einer anerkannten Stelle."
)
if "Name or service not known" in text or "nodename nor servname" in text:
return f"Der Name im Endpunkt ({endpoint}) laesst sich nicht aufloesen."
if "Connection refused" in text:
return f"{endpoint} nimmt keine Verbindung an."
if "timed out" in text or "timeout" in text.lower():
return f"{endpoint} hat nicht rechtzeitig geantwortet."
return f"Der Lizenzserver ({endpoint}) war nicht erreichbar: {text[:160]}"
class LicenseError(Exception):
"""Fehler beim Import oder bei der Aktivierung -- Text ist fuer Anwender gedacht."""
@dataclass(slots=True)
class LicenseState:
"""Zwischengespeicherter Lizenzzustand. Wird bei jeder Aenderung neu gebaut."""
present: bool = False
valid: bool = False
issuer_pinned: bool = False
activated: bool = False
document: dict[str, Any] = field(default_factory=dict)
status: lic.LicenseStatus | None = None
error: str = ""
warnings: tuple[str, ...] = ()
last_heartbeat_at: str = ""
heartbeat_error: str = ""
pending_action: str = ""
@property
def role(self) -> str:
return self.status.role if self.status else "customer"
@property
def modules(self) -> frozenset[str]:
return self.status.modules if self.status else frozenset()
@property
def customer_name(self) -> str:
return str((self.status.customer if self.status else {}).get("name") or "")
@property
def vendor(self) -> dict[str, Any]:
return dict(self.status.vendor) if self.status else {}
class LicenseManager:
"""Haelt den Lizenzzustand und fuehrt die Handshakes durch."""
def __init__(
self,
*,
database: Database,
settings: Any,
license_path: Path,
key_path: Path,
app_key: str,
built_in_trusted_keys: tuple[str, ...] = (),
) -> None:
self.database = database
self.settings = settings
self.license_path = Path(license_path)
self.key_path = Path(key_path)
self.app_key = app_key
self.built_in_trusted_keys = tuple(built_in_trusted_keys)
self._state = LicenseState()
self._lock = threading.RLock()
self._heartbeat_thread: threading.Thread | None = None
self._report_builder: Callable[[sqlite3.Connection], dict[str, Any]] | None = None
# ------------------------------------------------------------------ #
# Zustand
# ------------------------------------------------------------------ #
@property
def state(self) -> LicenseState:
return self._state
def trusted_keys(self, conn: sqlite3.Connection) -> list[str]:
rows = Database.all(conn, "SELECT public_key FROM trusted_issuer_keys")
return [*self.built_in_trusted_keys, *(str(row["public_key"]) for row in rows)]
def reload(self, conn: sqlite3.Connection) -> LicenseState:
"""Liest Lizenzdatei und Status neu ein."""
with self._lock:
state = LicenseState()
state.last_heartbeat_at = str(self.settings.get(conn, SETTING_LAST_HEARTBEAT) or "")
state.heartbeat_error = str(self.settings.get(conn, SETTING_HEARTBEAT_ERROR) or "")
pending = self._pending(conn)
state.pending_action = str(pending.get("action") or "") if pending else ""
if not self.license_path.is_file():
state.error = "Es ist keine Lizenzdatei installiert."
self._state = state
return state
try:
document = json.loads(self.license_path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
state.error = f"Die Lizenzdatei ist unlesbar: {exc}"
self._state = state
return state
if isinstance(document, dict) and "license" in document:
# Jemand hat das Auslieferungs-Bundle direkt hierher kopiert.
document, _ = lic.split_bundle(document)
state.present = True
state.document = document
trusted = self.trusted_keys(conn)
try:
lic.verify_license(document, trusted_master_keys=trusted or None)
state.valid = True
state.issuer_pinned = bool(trusted)
except lic.UntrustedIssuer as exc:
state.error = str(exc)
except lic.LicensingError as exc:
state.error = str(exc)
if state.valid:
state.status = lic.license_status(
document, issuer_pinned=state.issuer_pinned
)
state.warnings = state.status.warnings
state.activated = (
str(self.settings.get(conn, SETTING_STATUS) or STATUS_INACTIVE) == STATUS_ACTIVE
)
self._state = state
return state
# ------------------------------------------------------------------ #
# Gates
# ------------------------------------------------------------------ #
def active(self) -> bool:
"""Grundvoraussetzung fuer alles Lizenzpflichtige."""
state = self._state
return bool(
state.valid
and state.activated
and state.status is not None
and state.status.modules_active
)
def module_active(self, name: str) -> bool:
"""Ist ein Vollmodul (dhcp/fileshare/maintenance) freigeschaltet?"""
return self.active() and name in self._state.modules
def feature_allowed(self, feature: str) -> bool:
"""Lizenzpflichtige Einzelfunktion.
Ausnahme ``export``: bleibt erlaubt, wenn der lizenzlose Zustand durch
eine vom Kunden selbst ausgeloeste Deaktivierung entstanden ist
(Systemwechsel) -- sonst waeren die eigenen Daten eingeschlossen.
"""
if feature == "export" and not self.active():
with self.database.session() as conn:
return str(self.settings.get(conn, SETTING_EXPORT_GRACE) or "") == "1"
return self.active()
def require(self, feature: str) -> None:
if not self.feature_allowed(feature):
raise LicenseError(
f"{LICENSED_FEATURES.get(feature, feature)} ist ohne gueltige, "
"aktivierte Lizenz nicht moeglich."
)
# ------------------------------------------------------------------ #
# Import
# ------------------------------------------------------------------ #
def import_bundle(
self,
conn: sqlite3.Connection,
raw: bytes | str,
*,
actor: str,
allow_new_issuer: bool = False,
) -> LicenseState:
"""Nimmt eine hochgeladene Lizenzdatei an.
``allow_new_issuer`` ist nur fuer den ausdruecklichen Fall gedacht, dass
ein Administrator einen Aussteller-Wechsel bestaetigt (z. B. Wechsel des
Lizenzservers). Ohne dieses Kennzeichen wird eine Datei mit unbekanntem
Aussteller abgelehnt, sobald schon einer gepinnt ist.
"""
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
try:
payload = json.loads(text)
except ValueError as exc:
raise LicenseError("Die Datei ist kein gueltiges JSON.") from exc
try:
document, client_key = lic.split_bundle(payload)
except lic.LicensingError as exc:
raise LicenseError(str(exc)) from exc
try:
lic.verify_license(document) # innere Konsistenz
except lic.LicensingError as exc:
raise LicenseError(f"Die Lizenzdatei ist ungueltig: {exc}") from exc
master_pubkey = str(document.get("master_pubkey") or "")
known = self.trusted_keys(conn)
if known and master_pubkey not in set(known) and not allow_new_issuer:
raise LicenseError(
"Diese Lizenz wurde von einem anderen Lizenzserver ausgestellt "
f"(Schluessel {lic.key_id(master_pubkey)}). Ein Administrator muss den "
"Wechsel des Ausstellers ausdruecklich bestaetigen."
)
if not client_key and not self.key_path.is_file():
raise LicenseError(
"Der Datei fehlt der Client-Schluessel. Bitte das vollstaendige "
"Lizenz-Bundle des Lizenzservers verwenden."
)
# Reihenfolge: erst Schluessel pinnen, dann Dateien schreiben, dann
# Aktivierungsstatus zuruecksetzen -- ein Abbruch dazwischen hinterlaesst
# keinen halb gueltigen Zustand.
if master_pubkey not in set(known):
conn.execute(
"INSERT INTO trusted_issuer_keys (key_id, public_key, label, source, added_at, added_by) "
"VALUES (?,?,?,?,?,?) ON CONFLICT(key_id) DO NOTHING",
(
lic.key_id(master_pubkey),
master_pubkey,
str((document.get("vendor") or {}).get("name") or "Lizenzserver"),
"tofu" if not known else "admin-confirmed",
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor,
),
)
audit.log(
conn,
actor=actor,
action="license.issuer_pinned",
target=lic.key_id(master_pubkey),
detail="Trust on first use" if not known else "Aussteller-Wechsel bestaetigt",
severity="notice",
)
self.license_path.parent.mkdir(parents=True, exist_ok=True)
self.license_path.write_text(
json.dumps(document, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
try:
self.license_path.chmod(0o640)
except OSError:
pass
if client_key:
write_private_file(
self.key_path,
json.dumps({"license_id": document.get("license_id"), "client_key": client_key})
+ "\n",
)
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
audit.log(
conn,
actor=actor,
action="license.imported",
target=str(document.get("license_id") or ""),
detail=f"Typ {document.get('type')}, Rolle {document.get('role')}",
severity="notice",
)
return self.reload(conn)
def client_key(self) -> str:
if not self.key_path.is_file():
return ""
try:
data = json.loads(self.key_path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return ""
return str(data.get("client_key") or "")
# ------------------------------------------------------------------ #
# Handshake
# ------------------------------------------------------------------ #
def _pending(self, conn: sqlite3.Connection) -> dict[str, Any]:
raw = self.settings.get(conn, SETTING_PENDING) or ""
if not raw:
return {}
try:
return json.loads(raw) if isinstance(raw, str) else dict(raw)
except ValueError:
return {}
def _endpoint(self, action: str) -> str:
base = str(self._state.document.get("master_endpoint") or "").rstrip("/")
if not base:
return ""
return base + API_PATHS[action]
def build_request(
self, action: str, *, hostname: str = "", report: dict[str, Any] | None = None
) -> dict[str, Any]:
if not self._state.valid:
raise LicenseError("Es ist keine gueltige Lizenz installiert.")
key = self.client_key()
if not key:
raise LicenseError(
"Der Client-Schluessel dieser Lizenz fehlt. Bitte die Lizenzdatei erneut importieren."
)
return lic.build_client_request(
action, self._state.document, key, hostname=hostname, report=report
)
def handshake(
self,
conn: sqlite3.Connection,
action: str,
*,
actor: str,
hostname: str = "",
report: dict[str, Any] | None = None,
timeout: int = 12,
) -> dict[str, Any]:
"""Fuehrt eine Aktion durch. Rueckgabe beschreibt das Ergebnis fuer die UI.
``{"ok": bool, "transport": "online"|"offline", "code": str, "message": str}``
"""
request_payload = self.build_request(action, hostname=hostname, report=report)
endpoint = self._endpoint(action)
reason = ""
if endpoint:
try:
response = self._post_json(
endpoint,
request_payload,
timeout=timeout,
ca_pem=str(self.settings.get(conn, "license_server_ca") or ""),
)
except Exception as exc: # noqa: BLE001 - jeder Netzfehler fuehrt offline weiter
reason = describe_transport_error(exc, endpoint)
logger.info("Online-Handshake fehlgeschlagen (%s): %s", action, exc)
else:
return self._apply_response(
conn, action, request_payload, response, actor=actor, transport="online"
)
else:
reason = (
"In dieser Lizenz ist kein Lizenzserver hinterlegt -- sie kann sich nur "
"ueber den Offline-Weg aktivieren."
)
# Offline-Weg: Anfrage merken -- **mit** dem Code. Zwischen Anfrage und
# Antwort liegen in der Praxis eine E-Mail und ein Arbeitstag; der Code
# muss ein Neuladen der Seite ueberstehen.
code = lic.encode_code(request_payload)
self.settings.set(
conn,
SETTING_PENDING,
json.dumps(
{
"action": action,
"nonce": request_payload["nonce"],
"fingerprint": request_payload["fingerprint"],
"license_id": request_payload["license_id"],
"created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"code": code,
"reason": reason,
}
),
actor=actor,
)
if action == "activate":
self.settings.set(conn, SETTING_STATUS, STATUS_PENDING, actor=actor)
self.reload(conn)
return {
"ok": False,
"transport": "offline",
"code": code,
"message": (
(reason + " " if reason else "")
+ "Der Offline-Weg steht offen: uebermitteln Sie den angezeigten Code an "
"Ihren Ansprechpartner und tragen Sie die Antwort hier ein."
),
}
def submit_offline_response(
self, conn: sqlite3.Connection, code: str, *, actor: str
) -> dict[str, Any]:
"""Nimmt den Antwortcode des Lizenzservers an (Offline-Weg)."""
pending = self._pending(conn)
if not pending:
raise LicenseError("Es liegt keine offene Anfrage vor.")
try:
response = lic.decode_code(code)
except ValueError as exc:
raise LicenseError(f"Der Code ist unlesbar: {exc}") from exc
return self._apply_response(
conn,
str(pending.get("action") or ""),
{
"license_id": str(pending.get("license_id") or ""),
"fingerprint": str(pending.get("fingerprint") or ""),
"nonce": str(pending.get("nonce") or ""),
},
response,
actor=actor,
transport="offline",
)
def _apply_response(
self,
conn: sqlite3.Connection,
action: str,
request_payload: dict[str, Any],
response: dict[str, Any],
*,
actor: str,
transport: str,
) -> dict[str, Any]:
master_pubkey = str(self._state.document.get("master_pubkey") or "")
current_seq = int(self._state.document.get("seq") or 1)
try:
lic.verify_master_response(
response,
master_pubkey,
expected_license_id=str(request_payload["license_id"]),
expected_fingerprint=str(request_payload["fingerprint"]),
expected_action=action,
expected_nonce=str(request_payload["nonce"]),
current_seq=current_seq,
)
except lic.LicensingError as exc:
raise LicenseError(f"Die Antwort des Lizenzservers ist nicht gueltig: {exc}") from exc
status = str(response.get("status") or "")
detail = str(response.get("detail") or "")
update = response.get("license_update")
if isinstance(update, dict):
try:
lic.verify_license(update, trusted_master_keys=self.trusted_keys(conn) or None)
except lic.LicensingError as exc:
logger.warning("Mitgeliefertes Lizenz-Update verworfen: %s", exc)
else:
self.license_path.write_text(
json.dumps(update, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
audit.log(
conn,
actor=actor,
action="license.updated_by_master",
target=str(update.get("license_id") or ""),
detail=f"Revision {update.get('seq')}",
severity="notice",
)
if status == "revoked":
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
audit.log(
conn,
actor=actor,
action="license.revoked_by_master",
target=str(request_payload["license_id"]),
severity="warning",
)
self.reload(conn)
return {
"ok": False,
"transport": transport,
"code": "",
"message": "Diese Lizenz wurde vom Lizenzserver widerrufen.",
}
if status != "ok":
self.reload(conn)
return {
"ok": False,
"transport": transport,
"code": "",
"message": detail or f"Der Lizenzserver hat die Anfrage abgelehnt ({status}).",
}
if action == "activate":
self.settings.set(conn, SETTING_STATUS, STATUS_ACTIVE, actor=actor)
self.settings.set(conn, SETTING_EXPORT_GRACE, "", actor=actor)
message = "Die Lizenz ist jetzt aktiv."
elif action == "deactivate":
self.settings.set(conn, SETTING_STATUS, STATUS_INACTIVE, actor=actor)
# Der Kunde hat selbst deaktiviert (Systemwechsel): Export bleibt
# erlaubt, damit er seine Daten mitnehmen kann.
self.settings.set(conn, SETTING_EXPORT_GRACE, "1", actor=actor)
message = "Die Lizenz wurde deaktiviert und kann auf einem anderen System aktiviert werden."
else:
self.settings.set(
conn,
SETTING_LAST_HEARTBEAT,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor=actor,
)
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, "", actor=actor)
message = "Verbindung zum Lizenzserver bestaetigt."
self.settings.set(conn, SETTING_PENDING, "", actor=actor)
audit.log(
conn,
actor=actor,
action=f"license.{action}",
target=str(request_payload["license_id"]),
detail=f"Transport: {transport}",
severity="notice",
)
self.reload(conn)
return {"ok": True, "transport": transport, "code": "", "message": message}
@staticmethod
def _tls_context(ca_pem: str) -> Any:
"""Prueft gegen ein hinterlegtes Zertifikat -- oder gegen die Systemanker.
Nie ohne Pruefung: das Lizenzprotokoll ist zwar selbst signiert und
gegen Wiedereinspielung geschuetzt, aber das ist kein Grund, den
Transport offen zu lassen.
"""
import ssl
context = ssl.create_default_context()
pem = (ca_pem or "").strip()
if not pem:
return context
try:
# cadata nimmt PEM direkt entgegen -- keine temporaere Datei, die
# liegen bleiben oder mitgelesen werden koennte.
context.load_verify_locations(cadata=pem)
except (ssl.SSLError, ValueError) as exc:
raise ValueError(
"Das hinterlegte Zertifikat des Lizenzservers ist unlesbar: " + str(exc)
) from exc
return context
@staticmethod
def _post_json(
url: str, payload: dict[str, Any], *, timeout: int, ca_pem: str = ""
) -> dict[str, Any]:
if not url.startswith(("https://", "http://")):
raise ValueError("Ungueltiger Endpunkt")
data = json.dumps(payload).encode("utf-8")
request = urllib.request.Request( # noqa: S310 - Schema oben geprueft
url,
data=data,
headers={"Content-Type": "application/json", "Accept": "application/json"},
method="POST",
)
context = LicenseManager._tls_context(ca_pem) if url.startswith("https://") else None
try:
with urllib.request.urlopen( # noqa: S310
request, timeout=timeout, context=context
) as response:
body = response.read().decode("utf-8")
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(body)
except ValueError:
raise
if isinstance(parsed, dict) and "signature" in parsed:
return parsed # Auch abweisende Antworten sind signiert.
raise
return json.loads(body)
# ------------------------------------------------------------------ #
# Heartbeat im Hintergrund
# ------------------------------------------------------------------ #
def set_report_builder(self, builder: Callable[[sqlite3.Connection], dict[str, Any]]) -> None:
"""Nur der Lizenzserver setzt das: Bericht an den eigenen Master."""
self._report_builder = builder
def start_heartbeat(self, *, hostname: str = "") -> None:
if self._heartbeat_thread is not None:
return
def loop() -> None:
# Erster Lauf verzoegert: der Dienst soll erst sauber hochkommen.
time.sleep(90)
while True:
try:
self.run_heartbeat(hostname=hostname)
except Exception as exc: # noqa: BLE001
logger.warning("Heartbeat fehlgeschlagen: %s", exc)
time.sleep(lic.HEARTBEAT_INTERVAL_SECONDS)
thread = threading.Thread(target=loop, name="license-heartbeat", daemon=True)
thread.start()
self._heartbeat_thread = thread
logger.info("Lizenz-Heartbeat gestartet (Intervall %ss)", lic.HEARTBEAT_INTERVAL_SECONDS)
def run_heartbeat(self, *, hostname: str = "") -> dict[str, Any]:
with self.database.session() as conn:
self.reload(conn)
if not self._state.valid or not self._state.activated:
return {"ok": False, "message": "Keine aktive Lizenz."}
report = None
if self._report_builder is not None and self._state.role == "license_server":
report = self._report_builder(conn)
try:
with self.database.transaction(conn):
return self.handshake(
conn, "heartbeat", actor="system", hostname=hostname, report=report
)
except LicenseError as exc:
with self.database.transaction(conn):
self.settings.set(conn, SETTING_HEARTBEAT_ERROR, str(exc), actor="system")
return {"ok": False, "message": str(exc)}
# ------------------------------------------------------------------ #
# Anzeige
# ------------------------------------------------------------------ #
def topbar_pill(self) -> dict[str, str] | None:
"""Zustandsanzeige fuer die Topbar. Reihenfolge = Dringlichkeit."""
state = self._state
if not state.present:
return {
"label": "Keine Lizenz",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Es ist keine Lizenzdatei installiert.",
}
if not state.valid:
return {
"label": "Lizenz ungueltig",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": state.error,
}
if not state.activated:
return {
"label": "Lizenz nicht aktiviert",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Die Lizenz muss noch aktiviert werden.",
}
status = state.status
if status is None:
return None
if status.expired:
days = status.days_since_expiry or 0
return {
"label": f"Lizenz abgelaufen (seit {days} Tagen)",
"css_class": "status-pill--danger",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": (
"Die Kulanzfrist laeuft noch, Funktionen bleiben vorerst verfuegbar."
if status.grace_active
else "Lizenzpflichtige Funktionen sind abgeschaltet."
),
}
if status.expiring_soon:
return {
"label": f"Lizenz laeuft in {status.days_left} Tagen ab",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "status-pill__dot--pulse",
"title": "Bitte rechtzeitig verlaengern.",
}
if state.last_heartbeat_at:
try:
last = lic.parse_iso(state.last_heartbeat_at)
days = (lic.utcnow() - last).days
except ValueError:
days = 0
if days >= lic.HEARTBEAT_WARNING_DAYS:
return {
"label": f"Lizenzserver seit {days} Tagen nicht erreichbar",
"css_class": "status-pill--warning",
"dot": "1",
"dot_class": "",
"title": "Rein informativ -- es wird nichts abgeschaltet.",
}
return None
@@ -0,0 +1,136 @@
"""Protokollierung: strukturierte Zeilen, Vorgangsnummer, Rotation via logrotate.
Jede Anfrage bekommt eine ``request_id``. Sie steht in jeder Logzeile, in
Fehlerseiten und im Audit-Log -- damit ein Anruf "bei mir kam Fehler 500"
in Sekunden auffindbar ist. Der Vorgaenger loggte ohne Korrelations-ID.
Rotation macht ``logrotate`` mit ``copytruncate`` (siehe ``deploy/``), nicht
Python: mehrere Prozesse (Web, Monitor) schreiben in dasselbe Verzeichnis, und
keiner von ihnen kann ein Reopen-Signal verarbeiten.
"""
from __future__ import annotations
import json
import logging
import logging.handlers
import os
import sys
import time
import uuid
from pathlib import Path
from flask import Flask, g, has_request_context, request
ACCESS_LOGGER = "tesm.access"
class RequestFormatter(logging.Formatter):
"""Textformat fuer Menschen, mit Vorgangsnummer und Benutzer."""
def format(self, record: logging.LogRecord) -> str:
if has_request_context():
record.request_id = getattr(g, "request_id", "-")
record.actor = getattr(getattr(g, "user", None), "username", "-") or "-"
else:
record.request_id = getattr(record, "request_id", "-")
record.actor = getattr(record, "actor", "-")
return super().format(record)
class JsonFormatter(logging.Formatter):
"""Maschinenlesbares Format -- fuer die Weiterverarbeitung in einem SIEM."""
def format(self, record: logging.LogRecord) -> str:
payload = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z", time.localtime(record.created)),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
}
if has_request_context():
payload["request_id"] = getattr(g, "request_id", "")
payload["path"] = request.path
payload["method"] = request.method
payload["actor"] = getattr(getattr(g, "user", None), "username", "") or ""
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
for key, value in getattr(record, "extra_fields", {}).items():
payload[key] = value
return json.dumps(payload, ensure_ascii=False)
def init_app(app: Flask, *, log_dir: Path, debug: bool, json_logs: bool = False) -> None:
log_dir.mkdir(parents=True, exist_ok=True)
level = logging.DEBUG if debug else logging.INFO
root = logging.getLogger()
root.setLevel(level)
for handler in list(root.handlers):
root.removeHandler(handler)
formatter: logging.Formatter = (
JsonFormatter()
if json_logs
else RequestFormatter(
"%(asctime)s %(levelname)-7s [%(request_id)s] %(actor)s %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
stream = logging.StreamHandler(sys.stderr)
stream.setFormatter(formatter)
root.addHandler(stream)
try:
app_log = log_dir / "app.log"
file_handler = logging.FileHandler(app_log, encoding="utf-8", delay=True)
file_handler.setFormatter(formatter)
file_handler.setLevel(level)
root.addHandler(file_handler)
if app_log.exists():
try:
os.chmod(app_log, 0o640)
except OSError:
pass
except OSError as exc: # pragma: no cover - z. B. fehlende Rechte
root.warning("Konnte app.log nicht oeffnen: %s", exc)
# Zugriffsprotokoll separat: eine Zeile pro Anfrage, ohne Stacktraces.
access = logging.getLogger(ACCESS_LOGGER)
access.propagate = False
access.setLevel(logging.INFO)
try:
access_handler = logging.FileHandler(log_dir / "access.log", encoding="utf-8", delay=True)
access_handler.setFormatter(
JsonFormatter() if json_logs else logging.Formatter("%(asctime)s %(message)s")
)
access.addHandler(access_handler)
except OSError: # pragma: no cover
pass
logging.getLogger("werkzeug").setLevel(logging.WARNING)
@app.before_request
def _assign_request_id() -> None:
incoming = request.headers.get("X-Request-Id", "")
g.request_id = incoming[:64] if incoming.isascii() and incoming else uuid.uuid4().hex[:16]
g.request_started = time.perf_counter()
@app.after_request
def _log_access(response): # type: ignore[no-untyped-def]
if request.path.startswith("/static/"):
return response
duration = (time.perf_counter() - getattr(g, "request_started", time.perf_counter())) * 1000
actor = getattr(getattr(g, "user", None), "username", "") or "-"
access.info(
"%s %s %s %s %s %.1fms",
getattr(g, "request_id", "-"),
actor,
request.method,
request.full_path if request.query_string else request.path,
response.status_code,
duration,
)
response.headers.setdefault("X-Request-Id", getattr(g, "request_id", ""))
return response
@@ -0,0 +1,234 @@
"""Versionierte Schema-Migrationen.
Der Vorgaenger hatte eine ``_ensure_schema()``-Funktion, die bei jedem Start
rund 35 idempotente ``ALTER TABLE``-Blocks durchlief, plus Sentinel-Zeilen in
der ``settings``-Tabelle als Marker fuer Einmal-Migrationen. Das funktioniert,
ist aber nicht nachvollziehbar: welche Migration lief wann, in welcher
Reihenfolge, mit welchem Inhalt?
Hier: eine ``schema_migrations``-Tabelle mit ``(component, version)``, einer
Pruefsumme des Migrationsinhalts und Zeitstempel. Jede Migration laeuft genau
einmal, in einer eigenen Transaktion, in Versionsreihenfolge.
``component`` trennt die Namensraeume: ``core`` bringt Benutzer/Gruppen/
Sitzungen/Audit mit, jede Anwendung ihre Fachtabellen. Dadurch koennen beide
Anwendungen dieselben Core-Migrationen nutzen, ohne dass sich Versionsnummern
in die Quere kommen.
"""
from __future__ import annotations
import hashlib
import logging
import sqlite3
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timezone
from .db import Database
logger = logging.getLogger(__name__)
MigrationFn = Callable[[sqlite3.Connection], None]
@dataclass(frozen=True, slots=True)
class Migration:
"""Eine Schemaaenderung.
``sql`` wird als Skript ausgefuehrt, danach ggf. ``fn`` fuer Datenmigrationen,
die sich in reinem SQL nicht sinnvoll ausdruecken lassen.
``allow_integrity_error``: fuer nachtraeglich eingefuehrte Unique-Indizes.
Trifft so ein Index auf bereits vorhandene, verletzende Altdaten, darf der
Anwendungsstart nicht crashen -- er loggt und laeuft weiter. Genau dieses
Muster hat sich im Vorgaengerprojekt bewaehrt.
"""
version: int
name: str
sql: str = ""
fn: MigrationFn | None = None
allow_integrity_error: bool = False
tags: tuple[str, ...] = field(default=())
@property
def checksum(self) -> str:
payload = f"{self.version}:{self.name}:{self.sql}".encode("utf-8")
if self.fn is not None:
payload += f":fn={self.fn.__name__}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:16]
def split_statements(script: str) -> list[str]:
"""Zerlegt ein SQL-Skript in einzelne Anweisungen.
Warum nicht ``executescript``: das setzt in Pythons sqlite3-Treiber
**implizit ein COMMIT** ab, bevor es laeuft. Innerhalb einer laufenden
Transaktion zerreisst das genau die Atomizitaet, die eine Migration
braucht. Also: selbst zerlegen und Anweisung fuer Anweisung ausfuehren.
Der Splitter beruecksichtigt Zeichenketten, Zeilen- und Blockkommentare;
``BEGIN ... END``-Bloecke (Trigger) werden zusammengehalten.
"""
statements: list[str] = []
current: list[str] = []
index = 0
length = len(script)
depth = 0 # BEGIN/END-Tiefe fuer Trigger
while index < length:
char = script[index]
nxt = script[index + 1] if index + 1 < length else ""
if char == "-" and nxt == "-":
end = script.find("\n", index)
index = length if end == -1 else end + 1
continue
if char == "/" and nxt == "*":
end = script.find("*/", index + 2)
index = length if end == -1 else end + 2
continue
if char in "'\"":
quote = char
current.append(char)
index += 1
while index < length:
current.append(script[index])
if script[index] == quote:
if index + 1 < length and script[index + 1] == quote:
current.append(script[index + 1])
index += 2
continue
index += 1
break
index += 1
continue
if char == ";" and depth == 0:
statement = "".join(current).strip()
if statement:
statements.append(statement)
current = []
index += 1
continue
current.append(char)
upper_tail = "".join(current[-6:]).upper()
if upper_tail.endswith("BEGIN"):
depth += 1
elif upper_tail.endswith("END") and depth > 0:
depth -= 1
index += 1
tail = "".join(current).strip()
if tail:
statements.append(tail)
return statements
def run_script(conn: sqlite3.Connection, script: str) -> None:
for statement in split_statements(script):
conn.execute(statement)
_BOOTSTRAP = """
CREATE TABLE IF NOT EXISTS schema_migrations (
component TEXT NOT NULL,
version INTEGER NOT NULL,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL,
PRIMARY KEY (component, version)
);
"""
class MigrationRunner:
"""Fuehrt Migrationen mehrerer Komponenten gegen eine Datenbank aus."""
def __init__(self, database: Database) -> None:
self.database = database
def applied(self, conn: sqlite3.Connection, component: str) -> dict[int, dict[str, str]]:
rows = Database.all(
conn,
"SELECT version, name, checksum, applied_at FROM schema_migrations WHERE component=?",
(component,),
)
return {int(row["version"]): row for row in rows}
def run(self, component: str, migrations: Sequence[Migration]) -> list[str]:
"""Wendet fehlende Migrationen an. Rueckgabe: Namen der ausgefuehrten."""
ordered = sorted(migrations, key=lambda m: m.version)
versions = [m.version for m in ordered]
if len(set(versions)) != len(versions):
duplicates = sorted({v for v in versions if versions.count(v) > 1})
raise ValueError(f"Doppelte Migrationsversionen in {component}: {duplicates}")
executed: list[str] = []
with self.database.session() as conn:
run_script(conn, _BOOTSTRAP)
known = self.applied(conn, component)
for migration in ordered:
record = known.get(migration.version)
if record is not None:
if record["checksum"] != migration.checksum:
logger.warning(
"Migration %s/%s (%s) wurde nachtraeglich geaendert "
"(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
"nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
"Versionsnummer.",
component,
migration.version,
migration.name,
record["checksum"],
migration.checksum,
)
continue
try:
with self.database.transaction(conn):
if migration.sql.strip():
run_script(conn, migration.sql)
if migration.fn is not None:
migration.fn(conn)
conn.execute(
"INSERT INTO schema_migrations "
"(component, version, name, checksum, applied_at) VALUES (?,?,?,?,?)",
(
component,
migration.version,
migration.name,
migration.checksum,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
),
)
except sqlite3.IntegrityError:
if not migration.allow_integrity_error:
raise
logger.error(
"Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
"angewendet werden. Der Start laeuft weiter; die Daten muessen "
"manuell bereinigt werden.",
component,
migration.version,
migration.name,
)
continue
executed.append(f"{component}/{migration.version} {migration.name}")
logger.info("Migration angewendet: %s/%s %s", component, migration.version, migration.name)
return executed
def status(self, components: Sequence[str]) -> list[dict[str, object]]:
"""Uebersicht fuer die Diagnoseseite."""
with self.database.session() as conn:
run_script(conn, _BOOTSTRAP)
return Database.all(
conn,
"SELECT component, version, name, checksum, applied_at FROM schema_migrations "
f"WHERE component IN ({','.join('?' * len(components))}) "
"ORDER BY component, version",
tuple(components),
)
+160
View File
@@ -0,0 +1,160 @@
"""Navigation: Struktur, Sichtbarkeit, Reihenfolge.
Uebernommen vom Vorgaenger (das Muster war gut), aber mit zwei Aenderungen:
* Sichtbarkeit haengt an einer Liste von Rechten **plus** optional einer
Bedingung. Im Vorgaenger war das eine ``if/elif``-Kette in
``_nav_key_visible()`` -- jede neue Seite bedeutete einen Eingriff in diese
Funktion. Hier deklariert jeder Eintrag selbst, was er braucht.
* "Sichtbar, wenn *irgendeines* der Rechte greift" ist der Default. Im
Vorgaenger war eine Seite mit reinem Export-Recht unerreichbar, weil die
Navigation nur auf ``.view`` prueft.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Sequence
VisibilityFn = Callable[[Any], bool]
@dataclass(frozen=True, slots=True)
class NavItem:
key: str
label: str
icon: str
endpoint: str = ""
#: Sichtbar, wenn eines dieser Rechte greift (leer = immer sichtbar).
permissions: tuple[str, ...] = ()
#: Zusaetzliche Bedingung, z. B. Modul-Lizenz oder vorhandene Freigaben.
condition: VisibilityFn | None = field(default=None, compare=False)
children: tuple["NavItem", ...] = ()
badge: Callable[[], str] | None = field(default=None, compare=False)
hint: str = ""
@property
def is_group(self) -> bool:
return bool(self.children)
class NavTree:
"""Navigationsbaum mit laufzeitkonfigurierbarer Reihenfolge."""
def __init__(self, items: Sequence[NavItem]) -> None:
self.items: tuple[NavItem, ...] = tuple(items)
self._by_key: dict[str, NavItem] = {}
for item in self.items:
self._by_key[item.key] = item
for child in item.children:
self._by_key[child.key] = child
def item(self, key: str) -> NavItem | None:
return self._by_key.get(key)
def default_order(self) -> list[str]:
return [item.key for item in self.items]
def default_child_order(self, group_key: str) -> list[str]:
item = self._by_key.get(group_key)
return [child.key for child in item.children] if item else []
# ------------------------------------------------------------------ #
def visible(self, item: NavItem, user: Any) -> bool:
if item.permissions and not user.has_any(item.permissions):
return False
if item.condition is not None and not item.condition(user):
return False
return True
def resolve(
self,
user: Any,
*,
order: Sequence[str] | None = None,
child_order: dict[str, Sequence[str]] | None = None,
) -> list[dict[str, Any]]:
"""Liefert die gefilterte, sortierte Navigation fuer das Template."""
child_order = child_order or {}
result: list[dict[str, Any]] = []
for item in self._ordered(self.items, order):
if item.is_group:
children = [
child
for child in self._ordered(item.children, child_order.get(item.key))
if self.visible(child, user)
]
if not children:
continue
result.append(
{
"key": item.key,
"label": item.label,
"icon": item.icon,
"endpoint": "",
"hint": item.hint,
"children": [self._as_dict(child) for child in children],
}
)
continue
if not self.visible(item, user):
continue
result.append(self._as_dict(item))
return result
def _as_dict(self, item: NavItem) -> dict[str, Any]:
return {
"key": item.key,
"label": item.label,
"icon": item.icon,
"endpoint": item.endpoint,
"hint": item.hint,
"badge": item.badge() if item.badge else "",
"children": [],
}
@staticmethod
def _ordered(
items: Sequence[NavItem], order: Sequence[str] | None
) -> list[NavItem]:
if not order:
return list(items)
index = {key: position for position, key in enumerate(order)}
# Unbekannte Schluessel (neu dazugekommene Seiten) landen hinten, statt
# zu verschwinden -- der Vorgaenger verlor sie stillschweigend.
return sorted(items, key=lambda item: (index.get(item.key, len(index)), item.label))
# ------------------------------------------------------------------ #
def sanitize_order(self, keys: Iterable[str]) -> list[str]:
known = self.default_order()
chosen = [key for key in keys if key in known]
return chosen + [key for key in known if key not in chosen]
def sanitize_child_order(self, group_key: str, keys: Iterable[str]) -> list[str]:
known = self.default_child_order(group_key)
chosen = [key for key in keys if key in known]
return chosen + [key for key in known if key not in chosen]
def searchable(self, user: Any) -> list[dict[str, str]]:
"""Flache Liste aller sichtbaren Seiten -- Datenbasis der Befehlspalette."""
entries: list[dict[str, str]] = []
for item in self.items:
if item.is_group:
for child in item.children:
if self.visible(child, user):
entries.append(
{
"label": child.label,
"group": item.label,
"endpoint": child.endpoint,
"icon": child.icon,
}
)
elif self.visible(item, user):
entries.append(
{"label": item.label, "group": "", "endpoint": item.endpoint, "icon": item.icon}
)
return entries
@@ -0,0 +1,25 @@
"""Rechtesystem: Modell (Bereiche/Ressourcen/Aktionen) und Verwaltungsdienste."""
from __future__ import annotations
from .model import (
ACTION_META,
IMPLIED_BY,
SENSITIVE_ACTIONS,
Action,
Area,
PermissionTree,
Resource,
RolePreset,
)
__all__ = [
"ACTION_META",
"Action",
"Area",
"IMPLIED_BY",
"PermissionTree",
"Resource",
"RolePreset",
"SENSITIVE_ACTIONS",
]
@@ -0,0 +1,161 @@
"""Ressourcen, die beide Anwendungen teilen.
``tesm_core`` bringt Benutzer-, Gruppen-, Papierkorb-, Protokoll- und
Diagnoseseiten selbst mit. Damit deren Rechtepruefungen greifen, muessen die
zugehoerigen Ressourcenschluessel im Rechtebaum der Anwendung existieren --
darum stehen sie hier und werden von beiden Apps eingebunden statt zweimal
getippt.
"""
from __future__ import annotations
from .model import Action, Area, Resource
A = Action
USERS = Resource(
key="users",
label="Benutzer",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Konten anlegen, sperren, Gruppen zuordnen",
action_hints={
A.EDIT: "Stammdaten, Sperre und Gruppenzuordnung aendern.",
A.DELETE: "Konto in den Papierkorb legen (Verzeichniskonten sofort entfernen).",
},
)
GROUPS = Resource(
key="groups",
label="Gruppen und Rechte",
actions=(A.VIEW, A.CREATE, A.EDIT, A.DELETE),
hint="Rechtematrix pflegen",
action_hints={
A.EDIT: "Rechte vergeben -- nur Rechte, die man selbst besitzt.",
},
)
SETTINGS_SYSTEM = Resource(
key="settings_system",
label="Systemeinstellungen",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Hostname, Zeitzone, Protokollrotation, Navigation",
action_hints={A.EXECUTE: "Systemaktionen ausloesen (Dienste, Netzwerk uebernehmen)."},
)
SETTINGS_LICENSE = Resource(
key="settings_license",
label="Lizenz",
actions=(A.VIEW, A.EDIT),
hint="Lizenzdatei einspielen, aktivieren, deaktivieren",
)
SETTINGS_DIRECTORY = Resource(
key="settings_directory",
label="Verzeichnisdienst (LDAP/AD)",
actions=(A.VIEW, A.EDIT, A.EXECUTE, A.SECRETS),
hint="Anbindung und Gruppen-Zuordnung",
action_hints={
A.EXECUTE: "Verbindungstest ausfuehren.",
A.SECRETS: "Bind-Passwort des Dienstkontos setzen.",
},
)
SETTINGS_WEB = Resource(
key="settings_web",
label="Webserver und TLS",
actions=(A.VIEW, A.EDIT, A.EXECUTE),
hint="Domain, Zertifikat, HSTS",
action_hints={A.EXECUTE: "Konfiguration uebernehmen, Zertifikat anfordern."},
)
SETTINGS_NETWORK = Resource(
key="settings_network",
label="Netzwerkeinstellungen",
actions=(A.VIEW, A.EDIT),
hint="IP-Adresse, Gateway, DNS des Hosts",
)
BACKUP = Resource(
key="backup",
label="Sicherung und Uebertragung",
actions=(A.VIEW, A.EXECUTE, A.EXPORT, A.SECRETS),
hint="Verschluesselter Export und Import",
action_hints={
A.EXECUTE: "Eine Sicherung einspielen.",
A.EXPORT: "Sicherung erzeugen und herunterladen.",
A.SECRETS: "Zugangsdaten in die Sicherung einschliessen.",
},
)
TRASH = Resource(
key="trash",
label="Papierkorb",
actions=(A.VIEW, A.EDIT, A.DELETE),
hint="Geloeschtes wiederherstellen oder endgueltig entfernen",
action_hints={
A.EDIT: "Eintraege wiederherstellen.",
A.DELETE: "Eintraege endgueltig entfernen.",
},
)
DIAGNOSTICS = Resource(
key="diagnostics",
label="Diagnose",
actions=(A.VIEW,),
hint="Schemastand, Protokollintegritaet, Schluesselrechte",
)
LOGS_LIVE = Resource(
key="logs_live",
label="Laufendes Protokoll",
actions=(A.VIEW,),
)
LOGS_HISTORY = Resource(
key="logs_history",
label="Protokollverlauf",
actions=(A.VIEW, A.DELETE, A.EXPORT),
hint="Rotierte und archivierte Protokolle",
action_hints={A.DELETE: "Archivierte Tage vom Server entfernen."},
)
LOGS_AUDIT = Resource(
key="logs_audit",
label="Aenderungsprotokoll",
actions=(A.VIEW, A.EXPORT),
hint="Wer hat wann was geaendert",
)
def administration_area(*extra: Resource) -> Area:
"""Bereich "Verwaltung" -- Basis fuer beide Anwendungen."""
return Area(
key="administration",
label="Verwaltung",
icon="sliders",
hint="Benutzer, Rechte, Systemeinstellungen. Ohne diesen Zugang bleibt der "
"gesamte Verwaltungsbereich verborgen.",
resources=(
USERS,
GROUPS,
SETTINGS_SYSTEM,
SETTINGS_LICENSE,
SETTINGS_DIRECTORY,
SETTINGS_WEB,
SETTINGS_NETWORK,
BACKUP,
TRASH,
DIAGNOSTICS,
*extra,
),
)
def observability_area(*extra: Resource) -> Area:
"""Bereich "Protokolle"."""
return Area(
key="observability",
label="Protokolle",
icon="terminal",
resources=(LOGS_LIVE, LOGS_HISTORY, LOGS_AUDIT, *extra),
)
@@ -0,0 +1,246 @@
"""Rechtemodell: Bereiche, Ressourcen, Aktionen.
Das Vorgaengermodell kannte fuenf Spalten (R/W/E/D/X), wobei ``E`` ("Aendern")
das Loeschen **mit abdeckte** und ``D``/``X`` je nach Zeile etwas voellig anderes
bedeuteten (Dashboard-Neustart bzw. Export). Das war kompakt, aber:
* Wer jemandem erlauben wollte, Geraete zu pflegen, musste ihm zwangslaeufig
auch das Loeschen erlauben. Das ist genau die Trennung, die man in einer
Verwaltungsanwendung am haeufigsten braucht.
* Dieselbe Spalte bedeutete in verschiedenen Zeilen Verschiedenes -- eine
Rechtematrix, die man erklaeren muss, statt sie zu lesen.
Neues Modell: ein festes, ueberall gleich bedeutendes Aktionsvokabular. Welche
Aktionen eine Ressource ueberhaupt kennt, entscheidet die Ressource; die Matrix
rendert nur die Spalten, die im jeweiligen Bereich wirklich vorkommen.
Beibehalten wird der bewaehrte **Kill-Switch**: jeder Bereich hat ein
``<bereich>.view``-Recht. Ohne dieses greift kein einziges Recht innerhalb des
Bereichs -- unabhaengig davon, was einzeln angehakt ist. Das erlaubt es, einer
Gruppe einen ganzen Bereich mit einem Klick zu entziehen, ohne die fein
eingestellten Einzelrechte zu verlieren.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Iterable, Mapping, Sequence
class Action(str, Enum):
"""Aktionsvokabular. Bedeutung ist ueberall dieselbe -- keine Ausnahmen."""
VIEW = "view"
CREATE = "create"
EDIT = "edit"
DELETE = "delete"
EXECUTE = "execute"
EXPORT = "export"
SECRETS = "secrets"
#: Kuerzel und Erklaerung je Aktion -- Kopfzeile der Rechtematrix.
ACTION_META: dict[Action, tuple[str, str, str]] = {
Action.VIEW: ("R", "Ansehen", "Datensaetze und Seiten dieses Bereichs lesen."),
Action.CREATE: ("C", "Anlegen", "Neue Datensaetze erstellen."),
Action.EDIT: ("U", "Bearbeiten", "Bestehende Datensaetze aendern -- ohne Loeschen."),
Action.DELETE: ("D", "Loeschen", "Datensaetze loeschen bzw. endgueltig entfernen."),
Action.EXECUTE: ("X", "Ausfuehren", "Aktionen ausloesen (Neustart, Dienste, Jobs, Import)."),
Action.EXPORT: ("E", "Exportieren", "Daten aus dem System heraustragen (Datei-Download)."),
Action.SECRETS: (
"S",
"Geheimnisse",
"Passwoerter und Schluessel im Klartext sehen oder mitnehmen.",
),
}
#: Aktionen, die zusaetzlich eine frische Passworteingabe verlangen (sudo-Modus).
SENSITIVE_ACTIONS: frozenset[Action] = frozenset({Action.SECRETS, Action.EXPORT})
#: ``view`` ist Voraussetzung fuer alles andere in derselben Zeile.
IMPLIED_BY: dict[Action, tuple[Action, ...]] = {
action: (Action.VIEW,) for action in Action if action is not Action.VIEW
}
@dataclass(frozen=True, slots=True)
class Resource:
"""Eine Zeile der Rechtematrix."""
key: str
label: str
actions: tuple[Action, ...]
hint: str = ""
#: Abweichende Erklaerung einzelner Aktionen (z. B. was "Ausfuehren" hier meint).
action_hints: Mapping[Action, str] = field(default_factory=dict)
def permission(self, action: Action) -> str:
return f"{self.key}.{action.value}"
def has(self, action: Action) -> bool:
return action in self.actions
def permissions(self) -> tuple[str, ...]:
return tuple(self.permission(action) for action in self.actions)
@dataclass(frozen=True, slots=True)
class Area:
"""Ein Bereich (Gruppe von Ressourcen) mit eigenem Kill-Switch."""
key: str
label: str
icon: str
resources: tuple[Resource, ...]
hint: str = ""
@property
def view_key(self) -> str:
return f"{self.key}.view"
def columns(self) -> tuple[Action, ...]:
"""Nur die Aktionen, die in diesem Bereich tatsaechlich vorkommen."""
present = {action for resource in self.resources for action in resource.actions}
return tuple(action for action in Action if action in present)
def resource(self, key: str) -> Resource | None:
for resource in self.resources:
if resource.key == key:
return resource
return None
class PermissionTree:
"""Aus Bereichen abgeleitete Nachschlagestrukturen.
Wird beim Import einmal gebaut und danach nur gelesen.
"""
def __init__(self, areas: Sequence[Area]) -> None:
self.areas: tuple[Area, ...] = tuple(areas)
self._by_key: dict[str, Area] = {}
self._parent: dict[str, str] = {}
self._all: list[str] = []
self._resources: dict[str, tuple[Area, Resource]] = {}
seen_keys: set[str] = set()
for area in self.areas:
if area.key in seen_keys:
raise ValueError(f"Doppelter Bereichsschluessel: {area.key}")
seen_keys.add(area.key)
self._by_key[area.key] = area
self._all.append(area.view_key)
for resource in area.resources:
if resource.key in seen_keys:
raise ValueError(f"Doppelter Ressourcenschluessel: {resource.key}")
seen_keys.add(resource.key)
if Action.VIEW not in resource.actions:
raise ValueError(
f"Ressource {resource.key} muss die Aktion 'view' anbieten -- "
"sie ist Voraussetzung fuer jede andere Aktion."
)
self._resources[resource.key] = (area, resource)
for permission in resource.permissions():
self._all.append(permission)
self._parent[permission] = area.view_key
# ------------------------------------------------------------------ #
def all_keys(self) -> tuple[str, ...]:
return tuple(self._all)
def area(self, key: str) -> Area | None:
return self._by_key.get(key)
def parent_view_key(self, permission: str) -> str | None:
return self._parent.get(permission)
def resource(self, key: str) -> tuple[Area, Resource] | None:
return self._resources.get(key)
def label_for(self, permission: str) -> str:
"""Menschenlesbare Beschreibung eines Rechteschluessels -- fuer Audit-Logs."""
if permission.endswith(".view") and permission[:-5] in self._by_key:
return f"Bereich {self._by_key[permission[:-5]].label}: Zugang"
resource_key, _, action_value = permission.rpartition(".")
entry = self._resources.get(resource_key)
if entry is None:
return permission
_, resource = entry
try:
action = Action(action_value)
except ValueError:
return permission
return f"{resource.label}: {ACTION_META[action][1]}"
def sanitize(self, permissions: Iterable[str]) -> list[str]:
"""Wirft unbekannte Rechte weg und ergaenzt implizierte.
Regeln:
* Ein Recht, das nicht im Baum steht, wird verworfen (kein stilles
Weiterschleppen von Altrechten nach einem Refactoring).
* Jede Aktion impliziert ``view`` derselben Zeile.
* Existiert irgendein Recht eines Bereichs, wird der Kill-Switch des
Bereichs mitgesetzt -- sonst waere die Auswahl wirkungslos und der
Bearbeiter wuerde sich wundern.
"""
known = set(self._all)
result: set[str] = {p for p in permissions if p in known}
for permission in list(result):
resource_key, _, action_value = permission.rpartition(".")
if resource_key in self._resources:
try:
action = Action(action_value)
except ValueError:
continue
for implied in IMPLIED_BY.get(action, ()):
candidate = f"{resource_key}.{implied.value}"
if candidate in known:
result.add(candidate)
for area in self.areas:
if any(p in result for resource in area.resources for p in resource.permissions()):
result.add(area.view_key)
return sorted(result)
def effective(self, permissions: Iterable[str]) -> set[str]:
"""Rechte, die nach Anwendung der Kill-Switches tatsaechlich greifen."""
granted = set(permissions)
active = {key for key in granted if key.endswith(".view") and key[:-5] in self._by_key}
return {
key
for key in granted
if (parent := self._parent.get(key)) is None or parent in active
}
def expand_preset(self, preset: "RolePreset") -> list[str]:
return self.sanitize(preset.permissions(self))
@dataclass(frozen=True, slots=True)
class RolePreset:
"""Benannte Rechtevorlage -- wird beim ersten Start als Gruppe angelegt.
Der Vorgaenger kannte nur "Admin" und eine Standardgruppe "Benutzer"; jede
weitere Rolle musste jeder Kunde von Hand zusammenklicken. Vorlagen machen
aus der haeufigsten Aufgabe einen Startpunkt.
"""
key: str
name: str
description: str
#: ``(ressource, aktionen)``-Paare, ``"*"`` fuer alle Aktionen der Ressource.
grants: tuple[tuple[str, tuple[Action, ...] | str], ...]
is_default: bool = False
"""Standardgruppe: wird jedem neuen Nicht-Admin automatisch zugeordnet."""
def permissions(self, tree: PermissionTree) -> list[str]:
result: list[str] = []
for resource_key, actions in self.grants:
entry = tree.resource(resource_key)
if entry is None:
continue
_, resource = entry
wanted = resource.actions if actions == "*" else tuple(a for a in actions if resource.has(a)) # type: ignore[arg-type]
result.extend(resource.permission(action) for action in wanted)
return result
@@ -0,0 +1,717 @@
"""Benutzer- und Gruppenverwaltung inklusive Eskalationsschutz.
Die wichtigste inhaltliche Korrektur gegenueber dem Vorgaenger steckt in
``guard_no_amplification``:
Im Vorgaenger konnte jeder, der das Recht ``groups.edit`` besass, einer Gruppe
*beliebige* Rechte zuweisen -- also auch Rechte, die er selbst nicht hatte --
und sich anschliessend selbst in diese Gruppe setzen. Das Rechtesystem war
damit fuer jeden Gruppenverwalter effektiv offen. Hier gilt: wer selbst kein
Recht X hat, kann Recht X auch nicht vergeben. Entziehen ist immer erlaubt.
Echte Administratoren sind davon ausgenommen (sie haben ohnehin alles).
Weitere fest verdrahtete Grenzen, die kein Gruppenrecht aushebeln kann:
* Das Admin-Kennzeichen setzt oder entfernt nur ein echter Administrator.
* Ein bestehendes Administratorkonto bearbeitet, sperrt oder loescht nur ein
echter Administrator.
* Es muss immer mindestens ein entsperrtes Administratorkonto uebrig bleiben.
* Niemand kann sich selbst das Admin-Kennzeichen entziehen oder sich selbst
sperren (haeufigster Weg, sich komplett auszusperren).
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Sequence
from ..db import Database
from ..security import passwords
from .model import Action, PermissionTree, RolePreset
class PermissionDenied(Exception):
"""Verstoss gegen eine feste Sicherheitsgrenze -- wird als Klartext angezeigt."""
class ValidationError(Exception):
"""Eingabefehler, der dem Benutzer erklaert wird."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
# --------------------------------------------------------------------------- #
# Gruppen
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, slots=True)
class GroupRecord:
id: int
name: str
description: str
is_system: bool
is_default: bool
preset_key: str
permissions: tuple[str, ...]
member_count: int
deleted_at: str | None = None
def list_groups(
conn: sqlite3.Connection, *, include_deleted: bool = False
) -> list[GroupRecord]:
where = "" if include_deleted else "WHERE g.deleted_at IS NULL"
rows = Database.all(
conn,
f"""
SELECT g.*,
(SELECT COUNT(*) FROM user_groups ug
JOIN users u ON u.id = ug.user_id AND u.deleted_at IS NULL
WHERE ug.group_id = g.id) AS member_count
FROM groups g {where}
ORDER BY g.is_system DESC, g.name COLLATE NOCASE
""",
)
perms = _permissions_by_group(conn)
return [
GroupRecord(
id=int(row["id"]),
name=str(row["name"]),
description=str(row["description"] or ""),
is_system=bool(row["is_system"]),
is_default=bool(row["is_default"]),
preset_key=str(row["preset_key"] or ""),
permissions=tuple(perms.get(int(row["id"]), ())),
member_count=int(row["member_count"] or 0),
deleted_at=row["deleted_at"],
)
for row in rows
]
def _permissions_by_group(conn: sqlite3.Connection) -> dict[int, list[str]]:
result: dict[int, list[str]] = {}
for row in Database.all(conn, "SELECT group_id, permission FROM group_permissions"):
result.setdefault(int(row["group_id"]), []).append(str(row["permission"]))
for values in result.values():
values.sort()
return result
def get_group(conn: sqlite3.Connection, group_id: int) -> GroupRecord | None:
for group in list_groups(conn, include_deleted=True):
if group.id == group_id:
return group
return None
def group_permissions(conn: sqlite3.Connection, group_id: int) -> list[str]:
return [
str(row["permission"])
for row in Database.all(
conn,
"SELECT permission FROM group_permissions WHERE group_id=? ORDER BY permission",
(group_id,),
)
]
def guard_no_amplification(
actor: Any, tree: PermissionTree, added: Iterable[str], *, what: str = "Recht"
) -> None:
"""Verhindert, dass jemand Rechte vergibt, die er selbst nicht besitzt."""
if getattr(actor, "is_admin", False):
return
missing = sorted(p for p in added if not actor.has(p))
if missing:
labels = ", ".join(tree.label_for(p) for p in missing[:6])
more = "" if len(missing) <= 6 else f" (und {len(missing) - 6} weitere)"
raise PermissionDenied(
f"Sie koennen kein {what} vergeben, das Sie selbst nicht besitzen: {labels}{more}."
)
def create_group(
conn: sqlite3.Connection,
*,
name: str,
description: str,
permissions: Sequence[str],
tree: PermissionTree,
actor: Any,
preset_key: str = "",
is_system: bool = False,
is_default: bool = False,
) -> int:
clean_name = (name or "").strip()
if not clean_name:
raise ValidationError("Der Gruppenname darf nicht leer sein.")
if len(clean_name) > 64:
raise ValidationError("Der Gruppenname ist zu lang (maximal 64 Zeichen).")
existing = Database.one(
conn, "SELECT id, deleted_at FROM groups WHERE name=? COLLATE NOCASE", (clean_name,)
)
if existing is not None:
if existing["deleted_at"]:
raise ValidationError(
f"Eine Gruppe namens {clean_name!r} liegt im Papierkorb. "
"Stellen Sie sie wieder her oder loeschen Sie sie endgueltig."
)
raise ValidationError(f"Eine Gruppe namens {clean_name!r} existiert bereits.")
wanted = tree.sanitize(permissions)
guard_no_amplification(actor, tree, wanted)
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(clean_name, (description or "").strip(), int(is_system), int(is_default), preset_key, _now(), _now()),
)
group_id = int(cursor.lastrowid or 0)
_write_permissions(conn, group_id, wanted)
return group_id
def _write_permissions(conn: sqlite3.Connection, group_id: int, permissions: Sequence[str]) -> None:
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (group_id,))
conn.executemany(
"INSERT INTO group_permissions (group_id, permission) VALUES (?,?)",
[(group_id, permission) for permission in permissions],
)
def update_group(
conn: sqlite3.Connection,
group_id: int,
*,
name: str | None = None,
description: str | None = None,
permissions: Sequence[str] | None = None,
tree: PermissionTree,
actor: Any,
unlock_system: bool = False,
) -> list[str]:
"""Aendert eine Gruppe. Rueckgabe: Liste der Aenderungen fuer das Audit-Log."""
group = get_group(conn, group_id)
if group is None or group.deleted_at:
raise ValidationError("Diese Gruppe existiert nicht (mehr).")
changes: list[str] = []
if name is not None and name.strip() != group.name:
if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht umbenannt werden.")
clean = name.strip()
if not clean:
raise ValidationError("Der Gruppenname darf nicht leer sein.")
clash = Database.one(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(clean, group_id),
)
if clash is not None:
raise ValidationError(f"Eine Gruppe namens {clean!r} existiert bereits.")
conn.execute("UPDATE groups SET name=?, updated_at=? WHERE id=?", (clean, _now(), group_id))
changes.append(f"Name: {group.name} -> {clean}")
if description is not None and description.strip() != group.description:
conn.execute(
"UPDATE groups SET description=?, updated_at=? WHERE id=?",
(description.strip(), _now(), group_id),
)
changes.append("Beschreibung geaendert")
if permissions is not None:
if group.is_system and not unlock_system:
raise PermissionDenied(
"Die Rechte dieser Systemgruppe sind gesperrt. "
"Sie muessen sie zuerst ausdruecklich freischalten."
)
wanted = tree.sanitize(permissions)
added = sorted(set(wanted) - set(group.permissions))
removed = sorted(set(group.permissions) - set(wanted))
guard_no_amplification(actor, tree, added)
if added or removed:
_write_permissions(conn, group_id, wanted)
conn.execute("UPDATE groups SET updated_at=? WHERE id=?", (_now(), group_id))
if added:
changes.append("Rechte hinzugefuegt: " + ", ".join(tree.label_for(p) for p in added))
if removed:
changes.append("Rechte entfernt: " + ", ".join(tree.label_for(p) for p in removed))
return changes
def soft_delete_group(conn: sqlite3.Connection, group_id: int) -> None:
group = get_group(conn, group_id)
if group is None:
raise ValidationError("Diese Gruppe existiert nicht.")
if group.is_system:
raise PermissionDenied("Systemgruppen koennen nicht geloescht werden.")
conn.execute("UPDATE groups SET deleted_at=? WHERE id=?", (_now(), group_id))
def restore_group(conn: sqlite3.Connection, group_id: int) -> None:
group = get_group(conn, group_id)
if group is None:
raise ValidationError("Diese Gruppe existiert nicht.")
clash = Database.one(
conn,
"SELECT id FROM groups WHERE name=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(group.name, group_id),
)
if clash is not None:
raise ValidationError(
f"Es existiert bereits eine aktive Gruppe namens {group.name!r}. "
"Benennen Sie diese zuerst um."
)
conn.execute("UPDATE groups SET deleted_at=NULL WHERE id=?", (group_id,))
def purge_group(conn: sqlite3.Connection, group_id: int) -> None:
# Mitgliedschaften und Rechte haengen per ON DELETE CASCADE dran.
conn.execute("DELETE FROM groups WHERE id=?", (group_id,))
def ensure_presets(
conn: sqlite3.Connection, tree: PermissionTree, presets: Sequence[RolePreset]
) -> list[str]:
"""Legt fehlende Vorlagen-Gruppen an.
Wichtig -- Unterschied zum Vorgaenger: eine bereits existierende Gruppe wird
**nicht** bei jedem Start auf den Vorlagen-Rechtesatz zurueckgesetzt. Der
Vorgaenger tat das fuer die Standardgruppe "Benutzer", wodurch jede
Anpassung durch den Kunden beim naechsten Neustart verschwand. Wer die
Vorlage neu anwenden will, tut das ausdruecklich in der Oberflaeche.
"""
created: list[str] = []
for preset in presets:
row = Database.one(
conn, "SELECT id FROM groups WHERE name=? COLLATE NOCASE", (preset.name,)
)
if row is not None:
continue
permissions = tree.expand_preset(preset)
cursor = conn.execute(
"INSERT INTO groups (name, description, is_system, is_default, preset_key, "
"created_at, updated_at) VALUES (?,?,?,?,?,?,?)",
(
preset.name,
preset.description,
1 if preset.is_default else 0,
1 if preset.is_default else 0,
preset.key,
_now(),
_now(),
),
)
_write_permissions(conn, int(cursor.lastrowid or 0), permissions)
created.append(preset.name)
return created
def default_group_id(conn: sqlite3.Connection) -> int | None:
value = Database.value(
conn, "SELECT id FROM groups WHERE is_default=1 AND deleted_at IS NULL ORDER BY id LIMIT 1"
)
return int(value) if value is not None else None
# --------------------------------------------------------------------------- #
# Benutzer
# --------------------------------------------------------------------------- #
def granted_permissions(conn: sqlite3.Connection, user_id: int) -> list[str]:
"""Vereinigung der Rechte aller (nicht geloeschten) Gruppen des Benutzers."""
return [
str(row["permission"])
for row in Database.all(
conn,
"""
SELECT DISTINCT gp.permission
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL
JOIN group_permissions gp ON gp.group_id = g.id
WHERE ug.user_id = ?
""",
(user_id,),
)
]
def user_groups(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(
conn,
"SELECT g.id, g.name, g.is_system, g.is_default FROM user_groups ug "
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL "
"WHERE ug.user_id=? ORDER BY g.name COLLATE NOCASE",
(user_id,),
)
def list_users(
conn: sqlite3.Connection, *, include_deleted: bool = False
) -> list[dict[str, Any]]:
where = "" if include_deleted else "WHERE u.deleted_at IS NULL"
users = Database.all(
conn,
f"""
SELECT u.id, u.username, u.is_admin, u.is_locked, u.auth_source, u.first_name,
u.last_name, u.email, u.avatar_filename, u.last_login_at, u.created_at,
u.deleted_at, u.must_change_password,
CASE WHEN u.totp_secret <> '' THEN 1 ELSE 0 END AS totp_enabled
FROM users u {where}
ORDER BY u.username COLLATE NOCASE
""",
)
memberships: dict[int, list[str]] = {}
for row in Database.all(
conn,
"SELECT ug.user_id, g.name FROM user_groups ug "
"JOIN groups g ON g.id = ug.group_id AND g.deleted_at IS NULL",
):
memberships.setdefault(int(row["user_id"]), []).append(str(row["name"]))
for user in users:
user["group_names"] = sorted(memberships.get(int(user["id"]), []))
return users
def get_user(conn: sqlite3.Connection, user_id: int) -> dict[str, Any] | None:
return Database.one(conn, "SELECT * FROM users WHERE id=?", (user_id,))
def find_user_by_username(
conn: sqlite3.Connection, username: str, *, include_deleted: bool = False
) -> dict[str, Any] | None:
suffix = "" if include_deleted else " AND deleted_at IS NULL"
return Database.one(
conn,
f"SELECT * FROM users WHERE username=? COLLATE NOCASE{suffix}",
(username,),
)
def count_active_admins(conn: sqlite3.Connection, *, exclude_id: int | None = None) -> int:
sql = "SELECT COUNT(*) FROM users WHERE is_admin=1 AND is_locked=0 AND deleted_at IS NULL"
params: tuple[Any, ...] = ()
if exclude_id is not None:
sql += " AND id<>?"
params = (exclude_id,)
return int(Database.value(conn, sql, params, default=0) or 0)
def guard_target_account(actor: Any, target: dict[str, Any]) -> None:
"""Ein Administratorkonto darf nur ein echter Administrator anfassen."""
if bool(target.get("is_admin")) and not getattr(actor, "is_admin", False):
raise PermissionDenied(
"Administratorkonten koennen nur von Administratoren bearbeitet werden."
)
def guard_last_admin(conn: sqlite3.Connection, user_id: int, *, action: str) -> None:
row = get_user(conn, user_id)
if row is None or not row.get("is_admin"):
return
if count_active_admins(conn, exclude_id=user_id) == 0:
raise PermissionDenied(
f"{action} nicht moeglich: es muss mindestens ein entsperrtes "
"Administratorkonto uebrig bleiben."
)
def _assignable_groups(
conn: sqlite3.Connection, actor: Any, tree: PermissionTree, group_ids: Sequence[int]
) -> list[int]:
"""Prueft, ob der Bearbeiter diese Gruppen zuweisen darf (kein Rechtezuwachs)."""
if getattr(actor, "is_admin", False):
return [int(gid) for gid in group_ids]
result: list[int] = []
for gid in group_ids:
permissions = group_permissions(conn, int(gid))
guard_no_amplification(actor, tree, permissions, what="Gruppenrecht")
result.append(int(gid))
return result
def set_user_groups(
conn: sqlite3.Connection,
user_id: int,
group_ids: Sequence[int],
*,
actor: Any,
tree: PermissionTree,
) -> None:
allowed = _assignable_groups(conn, actor, tree, group_ids)
conn.execute("DELETE FROM user_groups WHERE user_id=?", (user_id,))
conn.executemany(
"INSERT OR IGNORE INTO user_groups (user_id, group_id) VALUES (?,?)",
[(user_id, gid) for gid in allowed],
)
def create_user(
conn: sqlite3.Connection,
*,
username: str,
password: str,
is_admin: bool,
first_name: str = "",
last_name: str = "",
email: str = "",
group_ids: Sequence[int] = (),
actor: Any,
tree: PermissionTree,
policy: passwords.PasswordPolicy,
auth_source: str = "local",
must_change_password: bool = False,
) -> int:
clean = (username or "").strip()
if not clean:
raise ValidationError("Der Benutzername darf nicht leer sein.")
if len(clean) > 64 or any(ch.isspace() for ch in clean):
raise ValidationError("Der Benutzername darf keine Leerzeichen enthalten (max. 64 Zeichen).")
existing = find_user_by_username(conn, clean, include_deleted=True)
if existing is not None:
if existing["deleted_at"]:
raise ValidationError(
f"Der Benutzername {clean!r} liegt im Papierkorb. Stellen Sie das Konto "
"wieder her oder loeschen Sie es endgueltig."
)
raise ValidationError(f"Der Benutzername {clean!r} ist bereits vergeben.")
if is_admin and not getattr(actor, "is_admin", False):
raise PermissionDenied("Nur Administratoren koennen weitere Administratoren anlegen.")
if auth_source == "local":
problems = policy.check(password, username=clean)
if problems:
raise ValidationError(" ".join(problems))
password_hash = passwords.hash_password(password)
else:
password_hash = "" # Verzeichniskonto: Passwort wird nie hier geprueft.
cursor = conn.execute(
"INSERT INTO users (username, password_hash, is_admin, auth_source, first_name, "
"last_name, email, must_change_password, password_changed_at, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?)",
(
clean,
password_hash,
int(is_admin),
auth_source,
first_name.strip(),
last_name.strip(),
email.strip(),
int(must_change_password),
_now() if auth_source == "local" else None,
_now(),
_now(),
),
)
user_id = int(cursor.lastrowid or 0)
targets = list(group_ids)
if not is_admin and not targets:
fallback = default_group_id(conn)
if fallback is not None:
targets = [fallback]
set_user_groups(conn, user_id, targets, actor=actor, tree=tree)
return user_id
def update_user(
conn: sqlite3.Connection,
user_id: int,
*,
actor: Any,
tree: PermissionTree,
first_name: str | None = None,
last_name: str | None = None,
email: str | None = None,
is_admin: bool | None = None,
is_locked: bool | None = None,
group_ids: Sequence[int] | None = None,
) -> list[str]:
target = get_user(conn, user_id)
if target is None or target["deleted_at"]:
raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target)
changes: list[str] = []
directory = str(target.get("auth_source")) == "ldap"
if directory and any(value is not None for value in (first_name, last_name, email)):
# Beim naechsten Login wuerden diese Felder ohnehin aus dem Verzeichnis
# ueberschrieben -- die Bearbeitung waere eine Illusion.
raise ValidationError(
"Name und E-Mail eines Verzeichniskontos werden bei jeder Anmeldung "
"aus dem Verzeichnis uebernommen und koennen hier nicht geaendert werden."
)
fields: dict[str, Any] = {}
for column, value in (
("first_name", first_name),
("last_name", last_name),
("email", email),
):
if value is not None and value.strip() != str(target.get(column) or ""):
fields[column] = value.strip()
changes.append(f"{column} geaendert")
if is_admin is not None and bool(is_admin) != bool(target["is_admin"]):
if not getattr(actor, "is_admin", False):
raise PermissionDenied("Das Admin-Kennzeichen kann nur ein Administrator aendern.")
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Admin-Kennzeichen nicht aendern.")
if not is_admin:
guard_last_admin(conn, user_id, action="Herabstufen")
fields["is_admin"] = int(bool(is_admin))
changes.append("Administrator: " + ("ja" if is_admin else "nein"))
if is_locked is not None and bool(is_locked) != bool(target["is_locked"]):
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht sperren.")
if is_locked:
guard_last_admin(conn, user_id, action="Sperren")
fields["is_locked"] = int(bool(is_locked))
changes.append("Gesperrt: " + ("ja" if is_locked else "nein"))
if fields:
assignments = ", ".join(f"{column}=?" for column in fields)
conn.execute(
f"UPDATE users SET {assignments}, updated_at=? WHERE id=?",
(*fields.values(), _now(), user_id),
)
if group_ids is not None:
before = {int(row["id"]) for row in user_groups(conn, user_id)}
after = {int(gid) for gid in group_ids}
if before != after:
set_user_groups(conn, user_id, sorted(after), actor=actor, tree=tree)
changes.append("Gruppenzuordnung geaendert")
return changes
def set_password(
conn: sqlite3.Connection,
user_id: int,
new_password: str,
*,
policy: passwords.PasswordPolicy,
must_change: bool = False,
) -> None:
row = get_user(conn, user_id)
if row is None:
raise ValidationError("Dieses Konto existiert nicht.")
if str(row.get("auth_source")) == "ldap":
raise ValidationError(
"Verzeichniskonten haben hier kein Passwort -- es wird im Verzeichnisdienst gepflegt."
)
problems = policy.check(new_password, username=str(row["username"]))
if problems:
raise ValidationError(" ".join(problems))
conn.execute(
"UPDATE users SET password_hash=?, must_change_password=?, password_changed_at=?, "
"updated_at=? WHERE id=?",
(passwords.hash_password(new_password), int(must_change), _now(), _now(), user_id),
)
def soft_delete_user(conn: sqlite3.Connection, user_id: int, *, actor: Any) -> str:
target = get_user(conn, user_id)
if target is None or target["deleted_at"]:
raise ValidationError("Dieses Konto existiert nicht (mehr).")
guard_target_account(actor, target)
if int(target["id"]) == int(getattr(actor, "id", 0)):
raise PermissionDenied("Sie koennen Ihr eigenes Konto nicht loeschen.")
guard_last_admin(conn, user_id, action="Loeschen")
if str(target.get("auth_source")) == "ldap":
# Ein Verzeichniskonto ist nur ein Schattenkonto: es legt sich bei der
# naechsten gueltigen Anmeldung von selbst wieder an. Ein Papierkorb
# waere hier bedeutungslos.
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
return "endgueltig"
conn.execute("UPDATE users SET deleted_at=?, updated_at=? WHERE id=?", (_now(), _now(), user_id))
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason='user-deleted' "
"WHERE user_id=? AND revoked_at IS NULL",
(_now(), user_id),
)
return "papierkorb"
def restore_user(conn: sqlite3.Connection, user_id: int) -> None:
row = get_user(conn, user_id)
if row is None:
raise ValidationError("Dieses Konto existiert nicht.")
clash = Database.one(
conn,
"SELECT id FROM users WHERE username=? COLLATE NOCASE AND id<>? AND deleted_at IS NULL",
(row["username"], user_id),
)
if clash is not None:
raise ValidationError(
f"Es existiert bereits ein aktives Konto namens {row['username']!r}."
)
conn.execute("UPDATE users SET deleted_at=NULL, updated_at=? WHERE id=?", (_now(), user_id))
def purge_user(conn: sqlite3.Connection, user_id: int) -> None:
conn.execute("DELETE FROM users WHERE id=?", (user_id,))
def matrix_state(
tree: PermissionTree, permissions: Iterable[str]
) -> dict[str, dict[str, bool]]:
"""Hilfsstruktur fuer das Rendering der Rechtematrix."""
granted = set(permissions)
state: dict[str, dict[str, bool]] = {}
for area in tree.areas:
state[area.key] = {"view": area.view_key in granted}
for resource in area.resources:
state[resource.key] = {
action.value: resource.permission(action) in granted for action in resource.actions
}
return state
def summarize_permissions(tree: PermissionTree, permissions: Iterable[str]) -> list[str]:
"""Kurzfassung fuer Listenansichten: ``Bereich: Ressource (RCUD)``."""
granted = set(permissions)
lines: list[str] = []
for area in tree.areas:
if area.view_key not in granted:
continue
parts: list[str] = []
for resource in area.resources:
letters = "".join(
ACTION_LETTERS[action]
for action in resource.actions
if resource.permission(action) in granted
)
if letters:
parts.append(f"{resource.label} ({letters})")
if parts:
lines.append(f"{area.label}: " + ", ".join(parts))
return lines
ACTION_LETTERS: dict[Action, str] = {
Action.VIEW: "R",
Action.CREATE: "C",
Action.EDIT: "U",
Action.DELETE: "D",
Action.EXECUTE: "X",
Action.EXPORT: "E",
Action.SECRETS: "S",
}
@@ -0,0 +1,7 @@
"""Sicherheitsbausteine: Passwoerter, Sitzungen, CSRF, Ratenbegrenzung, TOTP."""
from __future__ import annotations
from . import csrf, passwords, ratelimit, sessions, totp
__all__ = ["csrf", "passwords", "ratelimit", "sessions", "totp"]
@@ -0,0 +1,180 @@
"""CSRF-Schutz und Header-Haertung.
Der Vorgaenger hatte **keinen** CSRF-Schutz: jede Aenderung lief ueber ein
gewoehnliches POST-Formular ohne Token. Eine praeparierte Seite konnte damit im
Namen eines eingeloggten Admins Geraete loeschen oder Benutzer anlegen.
Hier drei Schichten:
1. ``SameSite=Lax`` am Sitzungscookie (blockt Cross-Site-POSTs bereits im Browser).
2. Ein Token pro Sitzung, das in jedem Formular mitgeschickt und in konstanter
Zeit verglichen wird.
3. Origin-/Referer-Pruefung als Rueckfallebene fuer Browser ohne SameSite.
Alle drei sind unabhaengig voneinander wirksam.
"""
from __future__ import annotations
import hmac
import secrets
from urllib.parse import urlparse
from flask import Flask, Response, abort, current_app, request, session
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
FIELD_NAME = "csrf_token"
HEADER_NAME = "X-CSRF-Token"
_SESSION_KEY = "_csrf"
class CsrfError(Exception):
"""Wird als HTTP 403 mit erklaerender Seite ausgeliefert."""
def token() -> str:
"""Aktuelles Token der Sitzung, bei Bedarf neu erzeugt."""
value = session.get(_SESSION_KEY)
if not value:
value = secrets.token_urlsafe(32)
session[_SESSION_KEY] = value
return str(value)
def rotate_token() -> str:
"""Neues Token -- nach Anmeldung/Abmeldung und Rechtewechsel."""
session.pop(_SESSION_KEY, None)
return token()
def _submitted_token() -> str:
for source in (request.form, request.args):
value = source.get(FIELD_NAME)
if value:
return value
header = request.headers.get(HEADER_NAME)
if header:
return header
if request.is_json:
try:
payload = request.get_json(silent=True) or {}
except Exception: # pragma: no cover - defensiv
payload = {}
if isinstance(payload, dict) and payload.get(FIELD_NAME):
return str(payload[FIELD_NAME])
return ""
def _origin_ok() -> bool:
"""Prueft Origin bzw. Referer gegen den Host der Anfrage."""
origin = request.headers.get("Origin") or ""
referer = request.headers.get("Referer") or ""
candidate = origin or referer
if not candidate:
# Kein Origin/Referer: manche Clients (curl, alte Proxies) senden keinen.
# Das Token allein muss dann reichen.
return True
parsed = urlparse(candidate)
if not parsed.netloc:
return False
# Hinter einem Reverse Proxy kann der Host je nach Konfiguration mit oder
# ohne Port ankommen. Beide Formen gelten als derselbe Ursprung; ein
# *anderer* Host tut das nie.
expected: set[str] = {request.host}
forwarded_host = (request.headers.get("X-Forwarded-Host") or "").split(",")[0].strip()
if forwarded_host:
expected.add(forwarded_host)
forwarded_port = (request.headers.get("X-Forwarded-Port") or "").strip()
if forwarded_port:
expected.update(f"{host.split(':')[0]}:{forwarded_port}" for host in set(expected))
# Zusaetzlich die portlose Schreibweise -- aber bewusst **keine** beliebigen
# Ports: ein anderer Dienst auf demselben Host waere ein anderer Ursprung.
expected.update(host.split(":")[0] for host in set(expected))
# Und die Standardports, falls der Proxy sie weglaesst.
scheme_port = "443" if request.headers.get("X-Forwarded-Proto") == "https" else "80"
expected.update(f"{host.split(':')[0]}:{scheme_port}" for host in set(expected))
return parsed.netloc in expected
def protect() -> None:
"""``before_request``-Hook."""
if request.method in SAFE_METHODS:
return
view = current_app.view_functions.get(request.endpoint or "")
if getattr(view, "_csrf_exempt", False):
return
if not _origin_ok():
abort(403, description="Die Anfrage kam von einer fremden Herkunft (Origin/Referer).")
expected = session.get(_SESSION_KEY) or ""
provided = _submitted_token()
if not expected or not provided:
abort(403, description="Das Sicherheitstoken des Formulars fehlt. Bitte Seite neu laden.")
if not hmac.compare_digest(str(expected), str(provided)):
abort(403, description="Das Sicherheitstoken des Formulars ist ungueltig. Bitte Seite neu laden.")
def exempt(view):
"""Dekorator fuer Endpunkte mit eigener Authentisierung (Maschinen-APIs).
Bewusst sparsam einsetzen: nur fuer Routen, die *nicht* ueber Cookies
autorisiert werden. Eine cookie-autorisierte Route ohne CSRF-Schutz ist eine
Luecke.
"""
view._csrf_exempt = True # type: ignore[attr-defined]
return view
def init_app(app: Flask) -> None:
app.before_request(protect)
app.jinja_env.globals["csrf_token"] = token
app.jinja_env.globals["csrf_field_name"] = FIELD_NAME
# --------------------------------------------------------------------------- #
# Sicherheits-Header
# --------------------------------------------------------------------------- #
def content_security_policy(nonce: str) -> str:
"""Strikte CSP.
Kein ``unsafe-inline`` fuer Skripte -- jedes Inline-Script traegt den Nonce
dieses Requests. Styles brauchen ``unsafe-inline``, weil Jinja an einzelnen
Stellen berechnete Breiten (Fortschrittsbalken) als ``style``-Attribut
setzt; Style-Attribute sind kein Skriptausfuehrungsvektor.
"""
return "; ".join(
[
"default-src 'self'",
f"script-src 'self' 'nonce-{nonce}'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"object-src 'none'",
"worker-src 'self'",
"manifest-src 'self'",
]
)
def apply_security_headers(response: Response, *, nonce: str, hsts: str = "") -> Response:
response.headers.setdefault("Content-Security-Policy", content_security_policy(nonce))
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault("Referrer-Policy", "same-origin")
response.headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()",
)
response.headers.setdefault("Cross-Origin-Opener-Policy", "same-origin")
response.headers.setdefault("Cross-Origin-Resource-Policy", "same-origin")
response.headers.setdefault("X-Permitted-Cross-Domain-Policies", "none")
if hsts:
response.headers.setdefault("Strict-Transport-Security", hsts)
return response
@@ -0,0 +1,153 @@
"""Passwort-Hashing: Argon2id, mit Uebergangspfad von bcrypt.
Der Vorgaenger nutzte bcrypt (via Flask-Bcrypt). bcrypt ist nicht kaputt, aber
Argon2id ist der aktuelle Stand (speicherhart, damit deutlich teurer auf GPUs)
und die Empfehlung des OWASP-Passwort-Cheatsheets.
Bestehende bcrypt-Hashes werden weiter akzeptiert und beim naechsten
erfolgreichen Login transparent auf Argon2id migriert -- ein Import einer alten
Datenbank sperrt also niemanden aus.
"""
from __future__ import annotations
import hmac
import re
import secrets
import unicodedata
from dataclasses import dataclass
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
# Parameter nach OWASP-Empfehlung (m=64 MiB, t=3, p=2). Bei ~50 ms pro Hash auf
# der Ziel-VM bleibt der Login fluessig und Offline-Angriffe teuer.
_HASHER = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2, hash_len=32, salt_len=16)
_BCRYPT_RE = re.compile(r"^\$2[aby]\$\d{2}\$")
#: Passwoerter, die selbst mit 12 Zeichen nichts wert sind. Kurze, kuratierte
#: Liste -- eine vollstaendige Breach-Datenbank gehoert nicht in dieses Repo.
_FORBIDDEN = {
"passwort1234",
"password1234",
"administrator",
"qwertzuiop12",
"123456789012",
"tesmtesmtesm",
"willkommen12",
"geheim123456",
}
@dataclass(frozen=True, slots=True)
class PasswordPolicy:
min_length: int = 12
require_classes: int = 2
"""Anzahl verschiedener Zeichenklassen (klein/gross/Ziffer/Sonderzeichen)."""
def check(self, password: str, *, username: str = "") -> list[str]:
"""Gibt eine Liste von Klartext-Problemen zurueck (leer = in Ordnung)."""
problems: list[str] = []
normalized = normalize(password)
if len(normalized) < self.min_length:
problems.append(f"Mindestens {self.min_length} Zeichen erforderlich.")
classes = sum(
bool(pattern.search(normalized))
for pattern in (
# Kleinbuchstaben inkl. Latin-1-Supplement (Umlaute, Akzente).
re.compile("[a-zß-ÿ]"),
re.compile("[A-ZÀ-Þ]"),
re.compile(r"[0-9]"),
re.compile("[^A-Za-z0-9À-ÿ]"),
)
)
if classes < self.require_classes:
problems.append(
f"Mindestens {self.require_classes} verschiedene Zeichenarten "
"(Klein-/Grossbuchstaben, Ziffern, Sonderzeichen)."
)
if normalized.lower() in _FORBIDDEN:
problems.append("Dieses Passwort ist zu haeufig und damit unsicher.")
if username and len(username) >= 3 and username.lower() in normalized.lower():
problems.append("Das Passwort darf den Benutzernamen nicht enthalten.")
if len(set(normalized)) <= 3 and normalized:
problems.append("Das Passwort besteht aus zu wenigen verschiedenen Zeichen.")
return problems
def normalize(password: str) -> str:
"""NFKC-Normalisierung.
Ohne das schlaegt eine Anmeldung fehl, wenn ein Passwort mit Umlauten auf
einem macOS-Client (NFD) gesetzt und auf Windows (NFC) eingegeben wird.
"""
return unicodedata.normalize("NFKC", password or "")
def hash_password(password: str) -> str:
return _HASHER.hash(normalize(password))
def is_legacy_hash(stored: str) -> bool:
return bool(_BCRYPT_RE.match(stored or ""))
def verify_password(stored: str, password: str) -> bool:
"""Prueft ein Passwort gegen einen Argon2id- oder bcrypt-Hash.
Laeuft bei leerem/kaputtem Hash bewusst in einen echten Argon2-Verify gegen
einen Dummy-Hash, damit die Antwortzeit nicht verraet, ob ein Konto
existiert (Timing-Orakel).
"""
stored = stored or ""
if is_legacy_hash(stored):
return _verify_bcrypt(stored, password)
try:
return _HASHER.verify(stored, normalize(password))
except (VerifyMismatchError, VerificationError, InvalidHashError, ValueError):
return False
def _verify_bcrypt(stored: str, password: str) -> bool:
try:
import bcrypt # lokaler Import: nur fuer Altbestaende noetig
except ImportError: # pragma: no cover
return False
try:
return bcrypt.checkpw(normalize(password).encode("utf-8"), stored.encode("utf-8"))
except (ValueError, TypeError):
return False
def needs_rehash(stored: str) -> bool:
"""True, wenn der Hash veraltet ist (bcrypt oder zu schwache Argon2-Parameter)."""
if is_legacy_hash(stored):
return True
try:
return _HASHER.check_needs_rehash(stored)
except (InvalidHashError, ValueError):
return True
def dummy_verify() -> None:
"""Verbrennt dieselbe Rechenzeit wie ein echter Verify.
Wird aufgerufen, wenn ein Benutzername nicht existiert -- so ist die
Antwortzeit von "Konto existiert nicht" nicht von "falsches Passwort" zu
unterscheiden.
"""
global _DUMMY_HASH
if not _DUMMY_HASH:
_DUMMY_HASH = _HASHER.hash(secrets.token_urlsafe(24))
try:
_HASHER.verify(_DUMMY_HASH, "nicht-das-richtige-passwort")
except (VerifyMismatchError, VerificationError, InvalidHashError):
pass
_DUMMY_HASH = ""
def constant_time_equals(left: str, right: str) -> bool:
return hmac.compare_digest((left or "").encode("utf-8"), (right or "").encode("utf-8"))
@@ -0,0 +1,84 @@
"""Ratenbegrenzung und Kontosperre, in SQLite persistiert.
Der Vorgaenger hatte keinerlei Bremse: ein Skript konnte beliebig schnell
Passwoerter gegen ``/login`` durchprobieren, und ein Neustart des Dienstes
haette einen In-Memory-Zaehler ohnehin geleert.
Hier: ein Sliding-Window-Log in der Tabelle ``rate_events``. Persistent, damit
ein Dienst-Neustart die Bremse nicht aufhebt, und mit derselben Verbindung
nutzbar, die der Request schon offen hat (keine zweite Verbindung -> kein
Deadlock).
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from ..db import Database
@dataclass(frozen=True, slots=True)
class RateVerdict:
allowed: bool
remaining: int
retry_after_seconds: int
@property
def blocked(self) -> bool:
return not self.allowed
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def record(conn: sqlite3.Connection, bucket: str, key: str) -> None:
"""Vermerkt ein Ereignis (z. B. einen Fehlversuch)."""
conn.execute(
"INSERT INTO rate_events (bucket, subject, ts) VALUES (?,?,?)",
(bucket, key, _iso(_now())),
)
def check(
conn: sqlite3.Connection,
bucket: str,
key: str,
*,
limit: int,
window_minutes: int,
) -> RateVerdict:
"""Prueft, ob noch Versuche frei sind. Zaehlt selbst nichts hoch."""
horizon = _iso(_now() - timedelta(minutes=window_minutes))
rows = Database.all(
conn,
"SELECT ts FROM rate_events WHERE bucket=? AND subject=? AND ts>=? ORDER BY ts",
(bucket, key, horizon),
)
used = len(rows)
if used < limit:
return RateVerdict(allowed=True, remaining=limit - used, retry_after_seconds=0)
oldest = datetime.fromisoformat(rows[0]["ts"])
if oldest.tzinfo is None:
oldest = oldest.replace(tzinfo=timezone.utc)
retry_after = int((oldest + timedelta(minutes=window_minutes) - _now()).total_seconds())
return RateVerdict(allowed=False, remaining=0, retry_after_seconds=max(1, retry_after))
def clear(conn: sqlite3.Connection, bucket: str, key: str) -> None:
"""Loescht die Ereignisse eines Subjekts -- nach erfolgreicher Anmeldung."""
conn.execute("DELETE FROM rate_events WHERE bucket=? AND subject=?", (bucket, key))
def prune(conn: sqlite3.Connection, *, keep_hours: int = 48) -> int:
"""Raeumt alte Ereignisse ab. Laeuft im Wartungslauf mit."""
horizon = _iso(_now() - timedelta(hours=keep_hours))
cursor = conn.execute("DELETE FROM rate_events WHERE ts < ?", (horizon,))
return cursor.rowcount or 0
@@ -0,0 +1,286 @@
"""Serverseitige Sitzungen in SQLite.
Der Vorgaenger nutzte Flasks signierte Cookie-Sitzungen. Das ist bequem, hat
aber drei Nachteile, die fuer eine Verwaltungsanwendung mit Adminrechten
relevant sind:
1. **Nicht widerrufbar.** Ein gestohlenes Cookie bleibt bis zum Ablauf gueltig;
"alle Sitzungen beenden" ist unmoeglich.
2. **Kein Leerlauf-Timeout.** Cookie-Sitzungen kennen nur ein absolutes Ablauf-
datum, keine Inaktivitaet.
3. **Sitzungsinhalt liegt beim Client.** Rotieren des Flask-Secret-Keys wirft
alle Nutzer heraus; ausserdem ist jede Sitzungsvariable im Browser sichtbar
(signiert, aber nicht verschluesselt).
Hier: eine Zeile pro Sitzung in ``user_sessions``, der Cookie traegt nur eine
256-Bit-Zufalls-ID. Damit sind Widerruf, Leerlauf-Timeout, ein absolutes
Maximum, eine Uebersicht "meine aktiven Sitzungen" und Sitzungsrotation beim
Rechtewechsel moeglich.
"""
from __future__ import annotations
import json
import secrets
import sqlite3
from datetime import datetime, timedelta, timezone
from typing import Any
from flask import Flask, Request, Response
from flask.sessions import SessionInterface, SessionMixin
from werkzeug.datastructures import CallbackDict
from ..db import Database
SESSION_ID_BYTES = 32
def _now() -> datetime:
return datetime.now(timezone.utc)
def _iso(value: datetime) -> str:
return value.isoformat(timespec="seconds")
def _parse(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(value)
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
class ServerSession(CallbackDict, SessionMixin):
"""Sitzungsobjekt, das seine Aenderungen selbst als "dirty" markiert."""
def __init__(self, initial: dict[str, Any] | None = None, sid: str = "", *, new: bool = False):
def on_update(_self: Any) -> None:
self.modified = True
super().__init__(initial or {}, on_update)
self.sid = sid
self.new = new
self.modified = False
self.destroyed = False
#: Von der Datenbank geladene Metadaten (nur lesend fuer den Aufrufer).
self.meta: dict[str, Any] = {}
def destroy(self) -> None:
"""Sitzung serverseitig verwerfen (Logout)."""
self.clear()
self.destroyed = True
self.modified = True
class SqliteSessionInterface(SessionInterface):
"""Flask-Sitzungsschnittstelle gegen die Tabelle ``user_sessions``."""
session_class = ServerSession
pickle_based = False
def __init__(
self,
database: Database,
*,
cookie_name: str,
idle_minutes: int,
absolute_hours: int,
) -> None:
self.database = database
self.cookie_name = cookie_name
self.idle_minutes = idle_minutes
self.absolute_hours = absolute_hours
# -- Flask-Schnittstelle ------------------------------------------- #
def open_session(self, app: Flask, request: Request) -> ServerSession:
sid = request.cookies.get(self.cookie_name, "")
if not sid or len(sid) < 40:
return self.session_class(new=True)
with self.database.session() as conn:
row = Database.one(
conn,
"SELECT * FROM user_sessions WHERE id=? AND revoked_at IS NULL",
(sid,),
)
if row is None:
return self.session_class(new=True)
now = _now()
idle_deadline = _parse(row.get("idle_expires_at"))
absolute_deadline = _parse(row.get("absolute_expires_at"))
if (idle_deadline and now > idle_deadline) or (
absolute_deadline and now > absolute_deadline
):
with self.database.transaction(conn):
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason='expired' "
"WHERE id=?",
(_iso(now), sid),
)
return self.session_class(new=True)
try:
data = json.loads(row.get("data") or "{}")
except ValueError:
data = {}
session = self.session_class(data, sid)
session.meta = {
"user_id": row.get("user_id"),
"created_at": row.get("created_at"),
"last_seen_at": row.get("last_seen_at"),
"reauth_at": row.get("reauth_at"),
"absolute_expires_at": row.get("absolute_expires_at"),
"ip": row.get("ip"),
"user_agent": row.get("user_agent"),
}
# Leerlauffenster verlaengern, aber hoechstens einmal pro Minute
# schreiben -- sonst kostet jeder AJAX-Poll einen Schreibvorgang.
last_seen = _parse(row.get("last_seen_at"))
if last_seen is None or (now - last_seen) > timedelta(seconds=60):
with self.database.transaction(conn):
conn.execute(
"UPDATE user_sessions SET last_seen_at=?, idle_expires_at=? WHERE id=?",
(_iso(now), _iso(now + timedelta(minutes=self.idle_minutes)), sid),
)
return session
def save_session(self, app: Flask, session: ServerSession, response: Response) -> None: # type: ignore[override]
name = self.cookie_name
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
if session.destroyed:
if session.sid:
with self.database.session() as conn, self.database.transaction(conn):
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason='logout' WHERE id=?",
(_iso(_now()), session.sid),
)
response.delete_cookie(name, domain=domain, path=path)
return
if not session:
# Leere Sitzung: nichts speichern, aber ein evtl. vorhandenes Cookie
# nicht anfassen (sonst wuerde jeder anonyme Request es loeschen).
if session.modified and session.sid:
response.delete_cookie(name, domain=domain, path=path)
return
if not session.modified and not session.new:
return
now = _now()
payload = json.dumps(dict(session), separators=(",", ":"), sort_keys=True)
with self.database.session() as conn, self.database.transaction(conn):
if session.new or not session.sid:
session.sid = secrets.token_urlsafe(SESSION_ID_BYTES)
conn.execute(
"INSERT INTO user_sessions "
"(id, user_id, created_at, last_seen_at, idle_expires_at, "
" absolute_expires_at, ip, user_agent, data) "
"VALUES (?,?,?,?,?,?,?,?,?)",
(
session.sid,
session.get("user_id"),
_iso(now),
_iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)),
_iso(now + timedelta(hours=self.absolute_hours)),
session.get("_ip", ""),
session.get("_ua", ""),
payload,
),
)
session.new = False
else:
conn.execute(
"UPDATE user_sessions SET user_id=?, data=?, last_seen_at=?, "
"idle_expires_at=?, reauth_at=? WHERE id=?",
(
session.get("user_id"),
payload,
_iso(now),
_iso(now + timedelta(minutes=self.idle_minutes)),
session.get("_reauth_at"),
session.sid,
),
)
response.set_cookie(
name,
session.sid,
httponly=True,
secure=self.get_cookie_secure(app),
samesite=self.get_cookie_samesite(app),
domain=domain,
path=path,
max_age=None, # Sitzungscookie: verschwindet beim Schliessen des Browsers.
)
response.vary.add("Cookie")
# --------------------------------------------------------------------------- #
# Verwaltung
# --------------------------------------------------------------------------- #
def rotate(conn: sqlite3.Connection, session: ServerSession, *, reason: str = "privilege-change") -> str:
"""Erzeugt eine neue Sitzungs-ID und entwertet die alte (Session-Fixation).
Wird bei jeder Anmeldung und bei jeder Rechteaenderung aufgerufen.
"""
old_sid = session.sid
if old_sid:
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=?",
(_iso(_now()), reason, old_sid),
)
session.sid = ""
session.new = True
session.modified = True
return old_sid
def revoke(conn: sqlite3.Connection, sid: str, *, reason: str = "manual") -> None:
conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? WHERE id=? AND revoked_at IS NULL",
(_iso(_now()), reason, sid),
)
def revoke_all_for_user(
conn: sqlite3.Connection, user_id: int, *, except_sid: str = "", reason: str = "manual"
) -> int:
cursor = conn.execute(
"UPDATE user_sessions SET revoked_at=?, revoked_reason=? "
"WHERE user_id=? AND revoked_at IS NULL AND id <> ?",
(_iso(_now()), reason, user_id, except_sid),
)
return cursor.rowcount or 0
def active_for_user(conn: sqlite3.Connection, user_id: int) -> list[dict[str, Any]]:
return Database.all(
conn,
"SELECT id, created_at, last_seen_at, absolute_expires_at, ip, user_agent "
"FROM user_sessions WHERE user_id=? AND revoked_at IS NULL "
"AND idle_expires_at > ? ORDER BY last_seen_at DESC",
(user_id, _iso(_now())),
)
def prune(conn: sqlite3.Connection, *, keep_days: int = 30) -> int:
"""Entfernt abgelaufene und widerrufene Sitzungen."""
horizon = _iso(_now() - timedelta(days=keep_days))
cursor = conn.execute(
"DELETE FROM user_sessions WHERE (revoked_at IS NOT NULL AND revoked_at < ?) "
"OR idle_expires_at < ?",
(horizon, horizon),
)
return cursor.rowcount or 0
@@ -0,0 +1,84 @@
"""TOTP (RFC 6238) und Wiederherstellungscodes -- ohne externe Abhaengigkeit.
Zwei-Faktor-Authentisierung gab es im Vorgaengerprojekt nicht. Sie ist hier
optional pro Benutzer aktivierbar und kann fuer Administratoren erzwungen
werden (``SecurityConfig.require_totp_for_admins``).
Implementiert mit ``hmac``/``hashlib`` aus der Standardbibliothek -- 60 Zeilen
gegenueber einer weiteren Abhaengigkeit im Deployment.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import struct
import time
from urllib.parse import quote
DIGITS = 6
PERIOD = 30
#: Toleranz in Zeitschritten (+/- 1 Schritt = +/- 30 s Uhrendrift).
WINDOW = 1
SECRET_BYTES = 20
RECOVERY_CODE_COUNT = 8
def generate_secret() -> str:
"""Base32-Geheimnis, wie es Authenticator-Apps erwarten (ohne Padding)."""
return base64.b32encode(secrets.token_bytes(SECRET_BYTES)).decode("ascii").rstrip("=")
def _decode_secret(secret: str) -> bytes:
cleaned = (secret or "").strip().replace(" ", "").upper()
cleaned += "=" * (-len(cleaned) % 8)
return base64.b32decode(cleaned, casefold=True)
def code_at(secret: str, counter: int) -> str:
digest = hmac.new(_decode_secret(secret), struct.pack(">Q", counter), hashlib.sha1).digest()
offset = digest[-1] & 0x0F
truncated = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF
return str(truncated % (10**DIGITS)).zfill(DIGITS)
def current_code(secret: str, *, now: float | None = None) -> str:
return code_at(secret, int((now if now is not None else time.time()) // PERIOD))
def verify_code(secret: str, code: str, *, now: float | None = None) -> bool:
"""Prueft einen Code mit Drift-Toleranz, in konstanter Zeit pro Kandidat."""
cleaned = "".join(ch for ch in (code or "") if ch.isdigit())
if len(cleaned) != DIGITS:
return False
counter = int((now if now is not None else time.time()) // PERIOD)
hit = False
for step in range(-WINDOW, WINDOW + 1):
candidate = counter + step
if candidate < 0: # nur bei Testzeitpunkten nahe der Epoche moeglich
continue
# Nicht fruehzeitig abbrechen: alle Kandidaten pruefen, damit die Laufzeit
# nicht verraet, welcher Zeitschritt gepasst hat.
hit |= hmac.compare_digest(code_at(secret, candidate), cleaned)
return hit
def provisioning_uri(secret: str, *, account: str, issuer: str) -> str:
"""``otpauth://``-URI fuer den QR-Code."""
label = quote(f"{issuer}:{account}", safe="")
return (
f"otpauth://totp/{label}?secret={secret}&issuer={quote(issuer, safe='')}"
f"&algorithm=SHA1&digits={DIGITS}&period={PERIOD}"
)
def format_secret(secret: str) -> str:
"""Gruppiert das Geheimnis in Vierergruppen -- fuer die manuelle Eingabe."""
return " ".join(secret[i : i + 4] for i in range(0, len(secret), 4))
def generate_recovery_codes(count: int = RECOVERY_CODE_COUNT) -> list[str]:
"""Einmal-Codes fuer den Fall eines verlorenen Authenticators."""
return [f"{secrets.token_hex(2)}-{secrets.token_hex(2)}-{secrets.token_hex(2)}" for _ in range(count)]
@@ -0,0 +1,165 @@
"""Typisierter Key/Value-Store fuer Laufzeiteinstellungen.
Der Vorgaenger nutzte eine ``settings``-Tabelle als reinen String-Store; jeder
Aufrufer parste selbst und erfand seinen eigenen Default. Hier gibt es eine
Registry: jede Einstellung deklariert Typ, Default, Label und Bereich. Damit
sind Formulare, Import/Export und die Diagnoseseite generisch.
"""
from __future__ import annotations
import json
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Literal, Sequence
from .db import Database
Kind = Literal["str", "int", "bool", "json", "secret"]
@dataclass(frozen=True, slots=True)
class SettingSpec:
key: str
default: Any = ""
kind: Kind = "str"
label: str = ""
hint: str = ""
section: str = "allgemein"
#: Wertebereich fuer ``int``.
minimum: int | None = None
maximum: int | None = None
#: Auswahlliste fuer ``str``: ``[(wert, label), ...]``.
choices: tuple[tuple[str, str], ...] = ()
#: ``secret``-Werte werden in der Oberflaeche nie zurueckgegeben, nur ersetzt.
export: bool = True
def parse(self, raw: str | None) -> Any:
if raw is None:
return self.default
if self.kind == "int":
try:
value = int(str(raw).strip())
except (TypeError, ValueError):
return self.default
if self.minimum is not None:
value = max(self.minimum, value)
if self.maximum is not None:
value = min(self.maximum, value)
return value
if self.kind == "bool":
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
if self.kind == "json":
try:
return json.loads(raw) if raw else self.default
except ValueError:
return self.default
return str(raw)
def serialize(self, value: Any) -> str:
if self.kind == "bool":
return "1" if value else "0"
if self.kind == "json":
return json.dumps(value, separators=(",", ":"), sort_keys=True)
if self.kind == "int":
return str(int(value))
return "" if value is None else str(value)
class SettingsStore:
"""Zugriff auf ``settings``. Alle Methoden nehmen die bestehende Verbindung."""
def __init__(self, specs: Iterable[SettingSpec]) -> None:
self._specs: dict[str, SettingSpec] = {spec.key: spec for spec in specs}
# ------------------------------------------------------------------ #
def register(self, specs: Iterable[SettingSpec]) -> None:
for spec in specs:
self._specs[spec.key] = spec
def spec(self, key: str) -> SettingSpec:
return self._specs.get(key) or SettingSpec(key=key)
def specs(self, section: str | None = None) -> list[SettingSpec]:
values = list(self._specs.values())
if section is not None:
values = [spec for spec in values if spec.section == section]
return values
# ------------------------------------------------------------------ #
def get(self, conn: sqlite3.Connection, key: str, default: Any = None) -> Any:
spec = self.spec(key)
raw = Database.value(conn, "SELECT value FROM settings WHERE key=?", (key,))
if raw is None:
return spec.default if default is None else default
return spec.parse(raw)
def get_many(self, conn: sqlite3.Connection, keys: Sequence[str]) -> dict[str, Any]:
if not keys:
return {}
placeholders = ",".join("?" * len(keys))
rows = Database.all(
conn, f"SELECT key, value FROM settings WHERE key IN ({placeholders})", tuple(keys)
)
stored = {row["key"]: row["value"] for row in rows}
return {key: self.spec(key).parse(stored.get(key)) for key in keys}
def section_values(self, conn: sqlite3.Connection, section: str) -> dict[str, Any]:
keys = [spec.key for spec in self.specs(section)]
return self.get_many(conn, keys)
def set(
self,
conn: sqlite3.Connection,
key: str,
value: Any,
*,
actor: str = "",
) -> None:
"""Setzt einen Wert. Nutzt die uebergebene Verbindung -- niemals eine eigene.
Genau hier lag im Vorgaenger eine Deadlock-Quelle: ``set_setting()``
oeffnete intern eine zweite Verbindung und lief dadurch im Import-Pfad in
``database is locked``.
"""
spec = self.spec(key)
conn.execute(
"INSERT INTO settings (key, value, updated_at, updated_by) VALUES (?,?,?,?) "
"ON CONFLICT(key) DO UPDATE SET value=excluded.value, "
"updated_at=excluded.updated_at, updated_by=excluded.updated_by",
(
key,
spec.serialize(value),
datetime.now(timezone.utc).isoformat(timespec="seconds"),
actor,
),
)
def set_many(
self, conn: sqlite3.Connection, values: dict[str, Any], *, actor: str = ""
) -> list[str]:
"""Setzt mehrere Werte, gibt die tatsaechlich geaenderten Schluessel zurueck."""
changed: list[str] = []
current = self.get_many(conn, list(values))
for key, value in values.items():
spec = self.spec(key)
if spec.serialize(current.get(key)) == spec.serialize(value):
continue
self.set(conn, key, value, actor=actor)
changed.append(key)
return changed
def delete(self, conn: sqlite3.Connection, key: str) -> None:
conn.execute("DELETE FROM settings WHERE key=?", (key,))
def all_values(self, conn: sqlite3.Connection) -> dict[str, Any]:
rows = Database.all(conn, "SELECT key, value FROM settings")
stored = {row["key"]: row["value"] for row in rows}
result = {key: spec.parse(stored.get(key)) for key, spec in self._specs.items()}
# Auch Schluessel ohne Spezifikation ausliefern (App-eigene Ad-hoc-Werte).
for key, raw in stored.items():
result.setdefault(key, raw)
return result
@@ -0,0 +1,720 @@
/* ==========================================================================
TESM Design-System -- Teil 1: Tokens, Reset, Layout
--------------------------------------------------------------------------
Kein Bootstrap, kein Tailwind, kein Build-Schritt. Handgeschriebenes CSS in
Layern, damit die Spezifitaet vorhersehbar bleibt.
Gegenueber dem Vorgaenger (eine 1550-Zeilen-Datei, flach, ohne Layer):
- Tokens sind konsequent semantisch (--surface-2 statt --bg-card-hover),
damit Themes ohne Umbenennen von Komponenten getauscht werden koennen.
- Drei Theme-Zustaende: System (Default), hell, dunkel. Der Vorgaenger kannte
nur "dunkel oder hell" und ignorierte die Systemeinstellung.
- Fokusringe sind ueberall sichtbar (:focus-visible), Kontraste erfuellen
WCAG AA, und prefers-reduced-motion wird respektiert.
========================================================================== */
@layer tokens, reset, layout, components, utilities;
@layer tokens {
:root {
/* -- Markenfarbe: WiS-Orange, in eine vollstaendige Skala aufgezogen ---- */
--brand-050: #fff4ec;
--brand-100: #ffe3d1;
--brand-200: #ffc3a1;
--brand-300: #ff9f6b;
--brand-400: #ff8639;
--brand-500: #ff7100;
--brand-600: #e05f00;
--brand-700: #b84c00;
--brand-800: #8a3900;
/* -- Neutrale, leicht kuehle Grauskala ---------------------------------- */
--neutral-000: #ffffff;
--neutral-025: #fafafb;
--neutral-050: #f4f5f7;
--neutral-100: #eaecf0;
--neutral-200: #d7dae1;
--neutral-300: #b8bdc9;
--neutral-400: #8f96a6;
--neutral-500: #6b7383;
--neutral-600: #4d5462;
--neutral-700: #363c48;
--neutral-800: #23272f;
--neutral-850: #1a1d24;
--neutral-900: #14171c;
--neutral-950: #0d0f13;
/* -- Semantische Statusfarben ------------------------------------------ */
--green-400: #34d399;
--green-500: #16a34a;
--green-600: #15803d;
--red-400: #f87171;
--red-500: #dc2626;
--red-600: #b91c1c;
--amber-400: #fbbf24;
--amber-500: #d97706;
--blue-400: #60a5fa;
--blue-500: #2563eb;
--violet-400: #a78bfa;
/* -- Abstaende (4er-Raster) -------------------------------------------- */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 20px;
--sp-6: 24px;
--sp-8: 32px;
--sp-10: 40px;
--sp-12: 48px;
--sp-16: 64px;
/* -- Radien ------------------------------------------------------------ */
--r-xs: 5px;
--r-sm: 8px;
--r-md: 11px;
--r-lg: 16px;
--r-xl: 22px;
--r-full: 999px;
/* -- Typografie -------------------------------------------------------- */
--font-sans: "Inter var", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, "JetBrains Mono",
"Cascadia Mono", Consolas, "Liberation Mono", Menlo, monospace;
--fs-xs: 0.6875rem; /* 11px */
--fs-sm: 0.8125rem; /* 13px */
--fs-base: 0.875rem; /* 14px */
--fs-md: 0.9375rem; /* 15px */
--fs-lg: 1.0625rem; /* 17px */
--fs-xl: 1.3125rem; /* 21px */
--fs-2xl: 1.625rem; /* 26px */
--fs-3xl: 2rem;
--lh-tight: 1.2;
--lh-normal: 1.5;
--lh-relaxed: 1.65;
/* -- Struktur ---------------------------------------------------------- */
--sidebar-w: 258px;
--sidebar-rail-w: 68px;
--topbar-h: 60px;
--content-max: 1680px;
/* -- Bewegung ---------------------------------------------------------- */
--ease: cubic-bezier(0.32, 0.72, 0, 1);
--dur-fast: 120ms;
--dur: 180ms;
--dur-slow: 300ms;
--z-sticky: 30;
--z-drawer: 60;
--z-modal: 80;
--z-toast: 100;
}
/* -- Helles Theme (Basis) ------------------------------------------------ */
:root,
:root[data-theme="light"] {
color-scheme: light;
--canvas: var(--neutral-050);
--surface-1: var(--neutral-000);
--surface-2: var(--neutral-025);
--surface-3: var(--neutral-100);
--surface-inset: var(--neutral-050);
--surface-overlay: rgba(20, 23, 28, 0.45);
--border: #e2e5ea;
--border-strong: #cdd2db;
--border-subtle: #eef0f4;
--text: #171a20;
--text-muted: #5b6371;
--text-faint: #868d9b;
--text-inverse: #ffffff;
--accent: var(--brand-600);
/* Marke. Eigene Token statt --accent: das Zeichen hat seine eigenen
Farbwerte und soll sich nicht mitverschieben, wenn der Akzent sich
aendert. */
--logo-plate: #edf1f2;
--logo-plate-line: #c1cbce;
--logo-mark: #b5750e;
--logo-text: #172026;
--accent-hover: var(--brand-700);
--accent-text: #ffffff;
--accent-soft: var(--brand-050);
--accent-soft-text: var(--brand-700);
--accent-ring: rgba(224, 95, 0, 0.32);
--success: var(--green-600);
--success-soft: #e8f7ee;
--danger: var(--red-600);
--danger-soft: #fdecec;
--warning: var(--amber-500);
--warning-soft: #fdf3e2;
--info: var(--blue-500);
--info-soft: #eaf1fe;
--neutral-pill: #eef0f4;
--neutral-pill-text: #5b6371;
--shadow-xs: 0 1px 2px rgba(16, 20, 28, 0.06);
--shadow-sm: 0 2px 6px rgba(16, 20, 28, 0.07), 0 1px 2px rgba(16, 20, 28, 0.05);
--shadow-md: 0 8px 20px -6px rgba(16, 20, 28, 0.14), 0 2px 6px rgba(16, 20, 28, 0.06);
--shadow-lg: 0 22px 48px -16px rgba(16, 20, 28, 0.24), 0 4px 12px rgba(16, 20, 28, 0.08);
--shadow-accent: 0 6px 18px -6px rgba(224, 95, 0, 0.45);
}
/* -- Dunkles Theme ------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
/* Marke, dunkle Fassung. */
--logo-plate: #10151b;
--logo-plate-line: transparent;
--logo-mark: #e2a63c;
--logo-text: #e7ecee;
color-scheme: dark;
--canvas: #0b0d11;
--surface-1: #14171c;
--surface-2: #191d23;
--surface-3: #21262e;
--surface-inset: #101318;
--surface-overlay: rgba(5, 6, 9, 0.66);
--border: #262b34;
--border-strong: #333944;
--border-subtle: #1e222a;
--text: #eceef2;
--text-muted: #a0a8b6;
--text-faint: #6f7787;
--text-inverse: #14171c;
--accent: var(--brand-500);
--accent-hover: var(--brand-400);
--accent-text: #1a0d02;
--accent-soft: rgba(255, 113, 0, 0.14);
--accent-soft-text: var(--brand-300);
--accent-ring: rgba(255, 113, 0, 0.42);
--success: var(--green-400);
--success-soft: rgba(52, 211, 153, 0.14);
--danger: var(--red-400);
--danger-soft: rgba(248, 113, 113, 0.15);
--warning: var(--amber-400);
--warning-soft: rgba(251, 191, 36, 0.15);
--info: var(--blue-400);
--info-soft: rgba(96, 165, 250, 0.15);
--neutral-pill: rgba(160, 168, 182, 0.14);
--neutral-pill-text: #a0a8b6;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45);
--shadow-md: 0 10px 26px -8px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 26px 56px -18px rgba(0, 0, 0, 0.72);
--shadow-accent: 0 6px 20px -8px rgba(255, 113, 0, 0.5);
}
}
:root[data-theme="dark"] {
/* Marke, dunkle Fassung. */
--logo-plate: #10151b;
--logo-plate-line: transparent;
--logo-mark: #e2a63c;
--logo-text: #e7ecee;
color-scheme: dark;
--canvas: #0b0d11;
--surface-1: #14171c;
--surface-2: #191d23;
--surface-3: #21262e;
--surface-inset: #101318;
--surface-overlay: rgba(5, 6, 9, 0.66);
--border: #262b34;
--border-strong: #333944;
--border-subtle: #1e222a;
--text: #eceef2;
--text-muted: #a0a8b6;
--text-faint: #6f7787;
--text-inverse: #14171c;
--accent: var(--brand-500);
--accent-hover: var(--brand-400);
--accent-text: #1a0d02;
--accent-soft: rgba(255, 113, 0, 0.14);
--accent-soft-text: var(--brand-300);
--accent-ring: rgba(255, 113, 0, 0.42);
--success: var(--green-400);
--success-soft: rgba(52, 211, 153, 0.14);
--danger: var(--red-400);
--danger-soft: rgba(248, 113, 113, 0.15);
--warning: var(--amber-400);
--warning-soft: rgba(251, 191, 36, 0.15);
--info: var(--blue-400);
--info-soft: rgba(96, 165, 250, 0.15);
--neutral-pill: rgba(160, 168, 182, 0.14);
--neutral-pill-text: #a0a8b6;
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.45);
--shadow-md: 0 10px 26px -8px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 26px 56px -18px rgba(0, 0, 0, 0.72);
--shadow-accent: 0 6px 20px -8px rgba(255, 113, 0, 0.5);
}
}
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
tab-size: 4;
}
body {
margin: 0;
min-height: 100dvh;
background: var(--canvas);
color: var(--text);
font: var(--fs-base) / var(--lh-normal) var(--font-sans);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4, h5, h6, p, figure, blockquote, dl, dd, ul, ol {
margin: 0;
}
ul, ol {
padding: 0;
list-style: none;
}
h1, h2, h3, h4 {
line-height: var(--lh-tight);
font-weight: 640;
letter-spacing: -0.011em;
text-wrap: balance;
}
h1 { font-size: var(--fs-2xl); }
h2 { font-size: var(--fs-xl); }
h3 { font-size: var(--fs-lg); }
h4 { font-size: var(--fs-md); }
a {
color: var(--accent);
text-decoration: none;
}
a:hover { text-decoration: underline; }
img, svg, video, canvas {
display: block;
max-width: 100%;
}
button, input, select, textarea {
font: inherit;
color: inherit;
}
code, kbd, pre, samp {
font-family: var(--font-mono);
font-size: 0.92em;
}
hr {
height: 1px;
margin: var(--sp-5) 0;
border: 0;
background: var(--border);
}
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-xs);
}
::selection {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
::placeholder { color: var(--text-faint); opacity: 1; }
[hidden] { display: none !important; }
/* Scrollbars: dezent, aber sichtbar */
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border-strong);
border: 3px solid transparent;
border-radius: var(--r-full);
background-clip: content-box;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
@media print {
.sidebar, .topbar, .toast-stack, .no-print { display: none !important; }
.main { margin: 0 !important; }
body { background: #fff; color: #000; }
}
}
@layer layout {
.app-shell {
display: flex;
min-height: 100dvh;
}
/* -- Seitenleiste ------------------------------------------------------- */
.sidebar {
position: fixed;
inset: 0 auto 0 0;
z-index: var(--z-drawer);
display: flex;
flex-direction: column;
width: var(--sidebar-w);
background: var(--surface-1);
border-right: 1px solid var(--border);
transition: width var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.sidebar__brand {
display: flex;
align-items: center;
gap: var(--sp-3);
height: var(--topbar-h);
padding: 0 var(--sp-4);
border-bottom: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.sidebar__logo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
/* Die Wortmarke bestimmt die Breite, nicht die Seitenleiste: sonst
zerlaeuft der Schriftzug bei schmalem Fenster. */
max-width: 168px;
}
.sidebar__logo svg { width: 100%; height: auto; display: block; }
.sidebar__edition {
font-size: var(--fs-xs);
color: var(--text-faint);
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 2px;
}
.sidebar__title {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.25;
}
.sidebar__title strong {
font-size: var(--fs-md);
font-weight: 650;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__title span {
font-size: var(--fs-xs);
color: var(--text-faint);
white-space: nowrap;
}
.sidebar__nav {
flex: 1;
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--sp-3) var(--sp-2) var(--sp-4);
}
.nav-group__label {
padding: var(--sp-4) var(--sp-3) var(--sp-2);
font-size: var(--fs-xs);
font-weight: 620;
letter-spacing: 0.07em;
text-transform: uppercase;
color: var(--text-faint);
}
.nav-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 8px var(--sp-3);
margin-bottom: 2px;
border-radius: var(--r-sm);
color: var(--text-muted);
font-size: var(--fs-base);
font-weight: 500;
white-space: nowrap;
transition: background var(--dur-fast), color var(--dur-fast);
}
.nav-item:hover {
background: var(--surface-3);
color: var(--text);
text-decoration: none;
}
.nav-item[aria-current="page"] {
background: var(--accent-soft);
color: var(--accent-soft-text);
font-weight: 600;
}
.nav-item__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
stroke-width: 1.75;
}
.nav-item__label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.nav-item__badge {
flex-shrink: 0;
padding: 1px 7px;
border-radius: var(--r-full);
background: var(--danger-soft);
color: var(--danger);
font-size: var(--fs-xs);
font-weight: 700;
}
.nav-tree > summary {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 8px var(--sp-3);
margin-bottom: 2px;
border-radius: var(--r-sm);
color: var(--text-muted);
font-size: var(--fs-base);
font-weight: 500;
cursor: pointer;
list-style: none;
transition: background var(--dur-fast);
}
.nav-tree > summary::-webkit-details-marker { display: none; }
.nav-tree > summary:hover { background: var(--surface-3); color: var(--text); }
.nav-tree__chevron {
margin-left: auto;
width: 15px;
height: 15px;
transition: transform var(--dur) var(--ease);
}
.nav-tree[open] > summary > .nav-tree__chevron { transform: rotate(90deg); }
.nav-tree[open] > summary { color: var(--text); }
.nav-tree__children {
margin: 2px 0 var(--sp-2) 0;
padding-left: calc(var(--sp-3) + 9px);
border-left: 1px solid var(--border-subtle);
margin-left: calc(var(--sp-3) + 9px);
}
.nav-tree__children .nav-item { padding-left: var(--sp-3); }
.sidebar__footer {
flex-shrink: 0;
border-top: 1px solid var(--border-subtle);
padding: var(--sp-3);
}
.user-chip {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2);
border-radius: var(--r-md);
min-width: 0;
}
.user-chip:hover { background: var(--surface-3); text-decoration: none; }
.user-chip__meta { min-width: 0; flex: 1; }
.user-chip__name {
display: block;
font-size: var(--fs-base);
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-chip__role {
display: block;
font-size: var(--fs-xs);
color: var(--text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* -- Schmale Leiste (eingeklappt) --------------------------------------- */
body[data-sidebar="rail"] .sidebar { width: var(--sidebar-rail-w); }
body[data-sidebar="rail"] .main { margin-left: var(--sidebar-rail-w); }
body[data-sidebar="rail"] .sidebar__title,
body[data-sidebar="rail"] .nav-item__label,
body[data-sidebar="rail"] .nav-item__badge,
body[data-sidebar="rail"] .nav-group__label,
body[data-sidebar="rail"] .nav-tree__chevron,
body[data-sidebar="rail"] .user-chip__meta,
body[data-sidebar="rail"] .sidebar__actions-label { display: none; }
body[data-sidebar="rail"] .nav-tree__children {
margin-left: 0;
padding-left: 0;
border-left: 0;
}
body[data-sidebar="rail"] .nav-item,
body[data-sidebar="rail"] .nav-tree > summary { justify-content: center; padding: 9px; }
/* -- Hauptbereich ------------------------------------------------------- */
.main {
flex: 1;
min-width: 0;
margin-left: var(--sidebar-w);
display: flex;
flex-direction: column;
transition: margin-left var(--dur) var(--ease);
}
.topbar {
position: sticky;
top: 0;
z-index: var(--z-sticky);
display: flex;
align-items: center;
gap: var(--sp-3);
height: var(--topbar-h);
padding: 0 var(--sp-5);
background: color-mix(in srgb, var(--canvas) 82%, transparent);
backdrop-filter: blur(12px) saturate(1.4);
border-bottom: 1px solid var(--border);
}
.topbar__heading { min-width: 0; flex: 1; }
.topbar__title {
font-size: var(--fs-lg);
font-weight: 640;
letter-spacing: -0.012em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar__sub {
font-size: var(--fs-xs);
color: var(--text-faint);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar__right {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-shrink: 0;
}
.content {
flex: 1;
width: 100%;
max-width: var(--content-max);
padding: var(--sp-6) var(--sp-5) var(--sp-12);
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.page-footer {
padding: var(--sp-4) var(--sp-5);
border-top: 1px solid var(--border-subtle);
color: var(--text-faint);
font-size: var(--fs-xs);
display: flex;
flex-wrap: wrap;
gap: var(--sp-4);
justify-content: space-between;
}
/* -- Raster ------------------------------------------------------------- */
.grid { display: grid; gap: var(--sp-4); }
.grid--2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.grid--3 { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.grid--4 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.grid--tiles { grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); }
.grid--split { grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); align-items: start; }
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-3);
}
.row--between { justify-content: space-between; }
.row--end { justify-content: flex-end; }
.row--tight { gap: var(--sp-2); }
.stack { display: flex; flex-direction: column; gap: var(--sp-4); }
.stack--tight { gap: var(--sp-2); }
/* -- Mobil -------------------------------------------------------------- */
.sidebar__scrim {
position: fixed;
inset: 0;
z-index: calc(var(--z-drawer) - 1);
background: var(--surface-overlay);
opacity: 0;
pointer-events: none;
transition: opacity var(--dur) var(--ease);
}
@media (max-width: 1000px) {
.sidebar {
transform: translateX(-100%);
box-shadow: var(--shadow-lg);
}
body[data-sidebar="open"] .sidebar { transform: none; }
body[data-sidebar="open"] .sidebar__scrim { opacity: 1; pointer-events: auto; }
.main, body[data-sidebar="rail"] .main { margin-left: 0; }
body[data-sidebar="rail"] .sidebar { width: var(--sidebar-w); }
body[data-sidebar="rail"] .sidebar__title,
body[data-sidebar="rail"] .nav-item__label,
body[data-sidebar="rail"] .nav-group__label,
body[data-sidebar="rail"] .nav-tree__chevron,
body[data-sidebar="rail"] .user-chip__meta { display: revert; }
.content { padding: var(--sp-4) var(--sp-4) var(--sp-10); }
.topbar { padding: 0 var(--sp-4); }
.grid--split { grid-template-columns: 1fr; }
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="32" height="32">
<!-- TESM-Zeichen, dunkle Fassung. Feste Farben, kein currentColor: ein
Favicon wird vom Browser ausserhalb der Seite gezeichnet, dort greifen
weder Themen-Token noch prefers-color-scheme zuverlaessig. Die dunkle
Fassung steht auf hellen wie auf dunklen Tableisten gut. -->
<rect x="1" y="1" width="62" height="62" rx="14" fill="#10151B"/>
<g transform="translate(5,20.19) scale(0.16875)">
<circle cx="298" cy="70" r="15" fill="#E2A63C" opacity="0.22"/>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70 218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="#E2A63C" stroke-width="3.2" stroke-linecap="round"
stroke-linejoin="round" opacity="0.85"/>
<text x="14" y="98" font-family="ui-monospace, 'Cascadia Code', 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace"
font-weight="800" font-size="82" letter-spacing="2" fill="#E7ECEE">TESM</text>
<circle cx="298" cy="70" r="8" fill="#E2A63C"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,842 @@
/* ==========================================================================
TESM -- Frontend-Verhalten
--------------------------------------------------------------------------
Ein ES-Modul, keine Abhaengigkeiten, kein Build-Schritt. Alles laeuft ueber
deklarative data-Attribute, damit Templates kein eigenes Inline-Skript
brauchen -- Voraussetzung fuer die strikte CSP (kein 'unsafe-inline').
Konventionen:
data-behavior="..." ein Verhalten an ein Element binden
data-* Parameter des Verhaltens
========================================================================== */
"use strict";
const STORAGE = {
theme: "tesm.theme",
sidebar: "tesm.sidebar",
sections: "tesm.sections",
};
/* -- Kleine Helfer -------------------------------------------------------- */
const $ = (selector, scope = document) => scope.querySelector(selector);
const $$ = (selector, scope = document) => Array.from(scope.querySelectorAll(selector));
function store(key, value) {
try {
if (value === null) localStorage.removeItem(key);
else localStorage.setItem(key, value);
} catch {
/* Privatmodus o. Ä. -- Einstellungen sind dann nur fluechtig. */
}
}
function read(key, fallback = null) {
try {
const value = localStorage.getItem(key);
return value === null ? fallback : value;
} catch {
return fallback;
}
}
function csrfToken() {
return document.body?.dataset.csrf || "";
}
/** fetch mit CSRF-Header und JSON-Erwartung. */
export async function api(url, options = {}) {
const opts = { credentials: "same-origin", ...options };
opts.headers = {
"X-Requested-With": "fetch",
"X-CSRF-Token": csrfToken(),
...(options.headers || {}),
};
const response = await fetch(url, opts);
const type = response.headers.get("content-type") || "";
const payload = type.includes("application/json") ? await response.json() : await response.text();
if (!response.ok) {
const message =
(payload && typeof payload === "object" && (payload.error || payload.message)) ||
`Anfrage fehlgeschlagen (HTTP ${response.status})`;
throw new Error(message);
}
return payload;
}
/* -- Toasts --------------------------------------------------------------- */
let toastStack = null;
export function showToast(message, type = "info", { timeout = 6000 } = {}) {
if (!message) return;
if (!toastStack) {
toastStack = document.createElement("div");
toastStack.className = "toast-stack";
toastStack.setAttribute("role", "status");
toastStack.setAttribute("aria-live", "polite");
document.body.appendChild(toastStack);
}
const toast = document.createElement("div");
toast.className = `toast toast--${type}`;
const body = document.createElement("div");
body.className = "toast__body";
body.textContent = message;
const close = document.createElement("button");
close.type = "button";
close.className = "toast__close";
close.setAttribute("aria-label", "Meldung schliessen");
close.textContent = "×";
toast.append(body, close);
toastStack.appendChild(toast);
let timer = 0;
const dismiss = () => {
window.clearTimeout(timer);
toast.classList.add("is-leaving");
window.setTimeout(() => toast.remove(), 180);
};
close.addEventListener("click", dismiss);
if (timeout > 0) timer = window.setTimeout(dismiss, timeout);
return dismiss;
}
function initFlashes() {
const node = $("#flashed-messages");
if (!node) return;
let entries = [];
try {
entries = JSON.parse(node.textContent || "[]");
} catch {
entries = [];
}
entries.forEach(([category, message]) => {
const type = { error: "danger", danger: "danger", success: "success", warning: "warning" }[
category
] || "info";
showToast(message, type, { timeout: type === "danger" ? 0 : 6000 });
});
}
/* -- Theme (System / hell / dunkel) --------------------------------------- */
function applyTheme(mode) {
const root = document.documentElement;
if (mode === "light" || mode === "dark") root.dataset.theme = mode;
else delete root.dataset.theme;
$$('[data-behavior="theme-toggle"] [data-theme-option]').forEach((btn) => {
btn.setAttribute("aria-pressed", String(btn.dataset.themeOption === mode));
});
}
function initTheme() {
const mode = read(STORAGE.theme, "system");
applyTheme(mode);
$$('[data-behavior="theme-toggle"]').forEach((group) => {
group.addEventListener("click", (event) => {
const button = event.target.closest("[data-theme-option]");
if (!button) return;
const next = button.dataset.themeOption;
store(STORAGE.theme, next === "system" ? null : next);
applyTheme(next);
});
});
}
/* -- Seitenleiste --------------------------------------------------------- */
function initSidebar() {
const body = document.body;
const desktop = () => window.matchMedia("(min-width: 1001px)").matches;
const saved = read(STORAGE.sidebar, "full");
if (desktop() && saved === "rail") body.dataset.sidebar = "rail";
$$('[data-behavior="sidebar-toggle"]').forEach((button) => {
button.addEventListener("click", () => {
if (desktop()) {
const rail = body.dataset.sidebar === "rail";
body.dataset.sidebar = rail ? "full" : "rail";
store(STORAGE.sidebar, rail ? "full" : "rail");
} else {
body.dataset.sidebar = body.dataset.sidebar === "open" ? "closed" : "open";
}
});
});
const scrim = $(".sidebar__scrim");
scrim?.addEventListener("click", () => {
body.dataset.sidebar = "closed";
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && body.dataset.sidebar === "open") body.dataset.sidebar = "closed";
});
}
/* -- Aufklappbare Bereiche (Zustand bleibt erhalten) ---------------------- */
function initCollapsibles() {
let state = {};
try {
state = JSON.parse(read(STORAGE.sections, "{}")) || {};
} catch {
state = {};
}
$$("details[data-persist]").forEach((element) => {
const key = element.dataset.persist;
if (key in state) element.open = Boolean(state[key]);
element.addEventListener("toggle", () => {
state[key] = element.open;
store(STORAGE.sections, JSON.stringify(state));
});
});
}
/* -- Dialoge -------------------------------------------------------------- */
function openDialog(dialog) {
if (!dialog) return;
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
const focusable = dialog.querySelector(
"[autofocus], input:not([type=hidden]):not([disabled]), select, textarea, button"
);
focusable?.focus({ preventScroll: true });
}
function initDialogs() {
document.addEventListener("click", (event) => {
const opener = event.target.closest("[data-dialog-open]");
if (opener) {
event.preventDefault();
const dialog = document.getElementById(opener.dataset.dialogOpen);
if (dialog) {
// Felder aus data-fill-* des Auslösers in das Formular uebernehmen.
Object.entries(opener.dataset).forEach(([key, value]) => {
if (!key.startsWith("fill")) return;
const name = key.slice(4);
const target = dialog.querySelector(`[data-fill-target="${name}"]`);
if (!target) return;
if ("value" in target) target.value = value;
else target.textContent = value;
});
openDialog(dialog);
}
return;
}
const closer = event.target.closest("[data-dialog-close]");
if (closer) {
event.preventDefault();
closer.closest("dialog")?.close();
}
});
// Klick auf den Hintergrund schliesst -- aber nur, wenn wirklich daneben
// geklickt wurde (nicht bei einem Drag aus dem Dialog heraus).
$$("dialog").forEach((dialog) => {
dialog.addEventListener("mousedown", (event) => {
dialog.dataset.outside = String(event.target === dialog);
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog && dialog.dataset.outside === "true") dialog.close();
});
});
}
/* -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- */
function initConfirmations() {
const dialog = $("#confirm-dialog");
if (!dialog) return;
const titleNode = $("[data-confirm-title]", dialog);
const textNode = $("[data-confirm-text]", dialog);
const okButton = $("[data-confirm-ok]", dialog);
let pending = null;
document.addEventListener(
"click",
(event) => {
const trigger = event.target.closest("[data-confirm]");
if (!trigger || trigger.dataset.confirmed === "1") return;
event.preventDefault();
event.stopPropagation();
pending = trigger;
titleNode.textContent = trigger.dataset.confirmTitle || "Bitte bestaetigen";
textNode.textContent = trigger.dataset.confirm;
okButton.textContent = trigger.dataset.confirmOk || "Fortfahren";
okButton.className = `btn btn--${trigger.dataset.confirmStyle || "danger"}`;
openDialog(dialog);
},
true
);
okButton.addEventListener("click", () => {
dialog.close();
if (!pending) return;
const trigger = pending;
pending = null;
trigger.dataset.confirmed = "1";
unsavedGuard.disable();
if (trigger.form && trigger.type === "submit") trigger.click();
else if (trigger.tagName === "FORM") trigger.submit();
else trigger.click();
delete trigger.dataset.confirmed;
});
}
/* -- Schutz vor ungespeicherten Aenderungen ------------------------------ */
const unsavedGuard = {
dirty: false,
armed: true,
disable() {
this.armed = false;
},
init() {
const forms = $$("form:not([data-no-guard])").filter((form) =>
form.querySelector("input:not([type=hidden]):not([type=search]), select, textarea")
);
forms.forEach((form) => {
const snapshot = new FormData(form);
form.addEventListener("input", () => {
this.dirty = !sameForm(snapshot, new FormData(form));
});
form.addEventListener("submit", () => {
this.dirty = false;
this.armed = false;
});
});
window.addEventListener("beforeunload", (event) => {
if (!this.dirty || !this.armed) return;
event.preventDefault();
event.returnValue = "";
});
},
};
function sameForm(a, b) {
const left = Array.from(a.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&");
const right = Array.from(b.entries()).map(([k, v]) => `${k}=${v}`).sort().join("&");
return left === right;
}
/* -- Tabellen: Sortieren und Filtern ------------------------------------- */
function cellValue(row, index) {
const cell = row.cells[index];
if (!cell) return "";
return (cell.dataset.sortValue ?? cell.textContent ?? "").trim();
}
function initTables() {
$$('table[data-behavior="sortable"]').forEach((table) => {
const body = table.tBodies[0];
if (!body) return;
$$("thead th.sortable", table).forEach((header, index) => {
header.tabIndex = 0;
const activate = () => {
const ascending = header.getAttribute("aria-sort") !== "ascending";
$$("thead th", table).forEach((other) => other.removeAttribute("aria-sort"));
header.setAttribute("aria-sort", ascending ? "ascending" : "descending");
const rows = Array.from(body.rows);
const numeric = rows.every((row) => {
const value = cellValue(row, index).replace(/[^\d,.\-]/g, "").replace(",", ".");
return value === "" || !Number.isNaN(Number(value));
});
rows.sort((rowA, rowB) => {
const a = cellValue(rowA, index);
const b = cellValue(rowB, index);
const result = numeric
? Number(a.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0) -
Number(b.replace(/[^\d,.\-]/g, "").replace(",", ".") || 0)
: a.localeCompare(b, "de", { numeric: true, sensitivity: "base" });
return ascending ? result : -result;
});
rows.forEach((row) => body.appendChild(row));
};
header.addEventListener("click", activate);
header.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
});
});
});
$$("[data-filter-target]").forEach((input) => {
const apply = () => {
const needle = input.value.trim().toLowerCase();
const target = document.getElementById(input.dataset.filterTarget);
if (!target) return;
const rows = target.tagName === "TABLE" ? Array.from(target.tBodies[0]?.rows || []) : $$("[data-filter-item]", target);
let visible = 0;
rows.forEach((row) => {
const haystack = (row.dataset.filterText || row.textContent || "").toLowerCase();
const match = !needle || haystack.includes(needle);
row.hidden = !match;
if (match) visible += 1;
});
const counter = input.dataset.filterCount
? document.getElementById(input.dataset.filterCount)
: null;
if (counter) counter.textContent = String(visible);
const emptyNode = input.dataset.filterEmpty
? document.getElementById(input.dataset.filterEmpty)
: null;
if (emptyNode) emptyNode.hidden = visible !== 0;
};
input.addEventListener("input", apply);
if (input.value) apply();
});
}
/* -- Rechtematrix: Kill-Switch und Zeilenlogik --------------------------- */
function initPermissionMatrix() {
$$('[data-behavior="permission-matrix"]').forEach((table) => {
const syncArea = (areaKey) => {
const master = $(`[data-area-switch="${areaKey}"]`, table);
if (!master) return;
$$(`tr[data-area="${areaKey}"]`, table).forEach((row) => {
row.classList.toggle("is-locked", !master.checked);
// Nur echte Rechte-Felder: alles ohne name-Attribut ist Darstellung,
// kein Bedienelement, und darf hier nicht freigeschaltet werden.
$$('input[type="checkbox"][name]', row).forEach((box) => {
box.disabled = !master.checked;
});
});
};
$$("[data-area-switch]", table).forEach((master) => {
const areaKey = master.dataset.areaSwitch;
syncArea(areaKey);
master.addEventListener("change", () => syncArea(areaKey));
});
table.addEventListener("change", (event) => {
const box = event.target;
if (!(box instanceof HTMLInputElement) || !box.dataset.action) return;
const row = box.closest("tr");
if (!row) return;
const viewBox = $('input[data-action="view"]', row);
if (box.dataset.action === "view") {
// "Ansehen" abwaehlen entfernt alles andere dieser Zeile.
if (!box.checked) {
$$('input[type="checkbox"][name]', row).forEach((other) => {
if (other !== box) other.checked = false;
});
}
} else if (box.checked && viewBox) {
viewBox.checked = true; // Jede Aktion setzt "Ansehen" voraus.
}
const areaKey = row.dataset.area;
const master = areaKey ? $(`[data-area-switch="${areaKey}"]`, table) : null;
if (master && box.checked && !master.checked) {
master.checked = true;
master.dispatchEvent(new Event("change"));
}
});
});
}
/* -- Automatische Aktualisierung von Teilbereichen ----------------------- */
function initAutoRefresh() {
// -- Verzeichnisgruppe suchen ------------------------------------------- #
//
// Ein AD-Gruppen-DN wird nicht getippt: er ist lang, enthaelt Kommas und
// Leerzeichen, und ein Tippfehler faellt erst bei der naechsten Anmeldung
// auf -- als stille Nicht-Zuordnung, die niemand mit dem Formular in
// Verbindung bringt.
$$("[data-behavior='group-picker']").forEach((picker) => {
const query = picker.querySelector("[data-group-query]");
const button = picker.querySelector("[data-group-search]");
const results = picker.querySelector("[data-group-results]");
const status = picker.querySelector("[data-group-status]");
const nameField = document.querySelector(picker.dataset.nameTarget);
const dnField = document.querySelector(picker.dataset.dnTarget);
if (!query || !button || !results || !nameField || !dnField) return;
const show = (text) => { if (status) status.textContent = text; };
const pick = (entry) => {
nameField.value = entry.name || "";
dnField.value = entry.dn || "";
results.hidden = true;
show(`Uebernommen: ${entry.name || entry.dn}`);
};
const search = async () => {
const term = query.value.trim();
if (term.length < 2) { show("Bitte mindestens zwei Zeichen eingeben."); return; }
button.disabled = true;
show("Suche laeuft ...");
results.innerHTML = "";
try {
const url = `${picker.dataset.searchUrl}?q=${encodeURIComponent(term)}`;
const data = await api(url);
const entries = (data && data.results) || [];
if (!entries.length) {
results.hidden = true;
show("Keine Gruppe gefunden. Ist ein Dienstkonto hinterlegt?");
return;
}
entries.forEach((entry) => {
const row = document.createElement("button");
row.type = "button";
row.className = "btn btn--sm btn--ghost";
row.style.justifyContent = "flex-start";
row.style.textAlign = "left";
const name = document.createElement("strong");
name.textContent = entry.name || "(ohne Namen)";
const dn = document.createElement("span");
dn.className = "fs-xs faint break-all";
dn.style.marginLeft = "8px";
dn.textContent = entry.dn || "";
row.append(name, dn);
row.addEventListener("click", () => pick(entry));
results.appendChild(row);
});
results.hidden = false;
show(`${entries.length} Treffer -- zum Uebernehmen anklicken.`);
} catch (error) {
results.hidden = true;
show("Die Suche ist fehlgeschlagen. Laeuft die Verbindung zum Verzeichnis?");
} finally {
button.disabled = false;
}
};
button.addEventListener("click", search);
query.addEventListener("keydown", (event) => {
if (event.key === "Enter") { event.preventDefault(); search(); }
});
});
// -- Transport bestimmt den Port ----------------------------------------- #
//
// 636 fuer LDAPS, 389 sonst. Wer den Transport umstellt und den Port stehen
// laesst, bekommt eine Verbindung, die auf gut Glueck scheitert -- und der
// Fehler ("Zeitlimit") sagt nichts ueber die Ursache.
$$("[data-behavior='transport-port']").forEach((select) => {
const port = document.querySelector(select.dataset.portTarget);
if (!port) return;
const standard = { ldaps: "636", starttls: "389", none: "389" };
select.addEventListener("change", () => {
const expected = standard[select.value];
if (!expected) return;
// Einen abweichenden, absichtlich gesetzten Port nicht ueberschreiben --
// nur einen, der zum vorherigen Transport gehoerte.
const known = Object.values(standard);
if (port.value === "" || known.includes(port.value)) port.value = expected;
});
});
$$("[data-refresh-url]").forEach((region) => {
// Number(x || 15000) haette "0" zu 15000 gemacht -- eine Null ist falsy.
// Bei einer Zahl aus einem Attribut ist || die falsche Vorgabe; gemeint
// war "kein Wert angegeben", nicht "Wert ist 0".
const raw = region.dataset.refreshInterval;
const interval = raw === undefined || raw === "" ? 15000 : Number(raw);
if (!Number.isFinite(interval) || interval <= 0) return; // ausdruecklich aus
let timer = 0;
let stopped = false;
const tick = async () => {
if (stopped || document.hidden) return schedule();
try {
const html = await api(region.dataset.refreshUrl, { headers: { Accept: "text/html" } });
// Eine vollstaendige Seite gehoert nie in einen Ausschnitt. Zeigt die
// URL versehentlich auf eine ganze Seite, waere das Ergebnis eine
// Seite in der Seite -- bei jedem Durchlauf eine Ebene tiefer.
if (typeof html === "string" && /<(!doctype|html|body)\b/i.test(html)) {
console.warn("Aktualisierung geliefert: ganze Seite statt Ausschnitt --", region.id);
stopped = true;
return;
}
if (typeof html === "string") {
// Gescrollt wird selten der Bereich selbst, sondern das Element
// darin (bei Protokollen das <pre class="log-view">). Beim Ersetzen
// von innerHTML entsteht ein neues Element -- mit scrollTop 0. Genau
// deshalb sprang ein laufendes Auftragsprotokoll bei jeder
// Aktualisierung an den Anfang zurueck.
const scrollerOf = (el) => el.querySelector(".log-view, [data-scroll]") || el;
const before = scrollerOf(region);
const position = before.scrollTop;
// "Am Ende" grosszuegig auslegen: wer mitliest, soll mitgezogen
// werden, ohne exakt ganz unten stehen zu muessen.
const following =
before.scrollHeight - before.scrollTop - before.clientHeight < 48;
region.innerHTML = html;
const after = scrollerOf(region);
after.scrollTop = following ? after.scrollHeight : position;
region.dispatchEvent(new CustomEvent("tesm:refreshed", { bubbles: true }));
initRelativeTimes(region);
initCountdowns(region);
}
} catch {
/* Netzfehler: beim naechsten Intervall erneut versuchen. */
}
schedule();
};
const schedule = () => {
window.clearTimeout(timer);
timer = window.setTimeout(tick, interval);
};
document.addEventListener("visibilitychange", () => {
if (!document.hidden) schedule();
});
schedule();
region.addEventListener("tesm:stop-refresh", () => {
stopped = true;
window.clearTimeout(timer);
});
});
}
/* -- Zeitangaben ---------------------------------------------------------- */
const RELATIVE = new Intl.RelativeTimeFormat("de", { numeric: "auto" });
const UNITS = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1],
];
function relativeTime(iso) {
const target = new Date(iso);
if (Number.isNaN(target.getTime())) return "";
const seconds = (target.getTime() - Date.now()) / 1000;
for (const [unit, size] of UNITS) {
if (Math.abs(seconds) >= size || unit === "second") {
return RELATIVE.format(Math.round(seconds / size), unit);
}
}
return "";
}
function initRelativeTimes(scope = document) {
$$("[data-relative-time]", scope).forEach((node) => {
const iso = node.dataset.relativeTime;
const text = relativeTime(iso);
if (text) {
node.textContent = text;
if (!node.title) node.title = new Date(iso).toLocaleString("de-DE");
}
});
}
function initCountdowns(scope = document) {
$$("[data-countdown-to]", scope).forEach((node) => {
const target = new Date(node.dataset.countdownTo).getTime();
if (Number.isNaN(target)) return;
const render = () => {
const remaining = Math.max(0, Math.floor((target - Date.now()) / 1000));
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const seconds = remaining % 60;
node.textContent =
hours > 0
? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
: `${minutes}:${String(seconds).padStart(2, "0")}`;
if (remaining === 0) {
window.clearInterval(handle);
node.textContent = node.dataset.countdownDone || "0:00";
}
};
render();
const handle = window.setInterval(render, 1000);
});
}
/* -- In die Zwischenablage ----------------------------------------------- */
function initClipboard() {
document.addEventListener("click", async (event) => {
const button = event.target.closest("[data-copy]");
if (!button) return;
event.preventDefault();
const source = button.dataset.copy.startsWith("#")
? document.querySelector(button.dataset.copy)
: null;
const text = source ? source.value ?? source.textContent : button.dataset.copy;
try {
await navigator.clipboard.writeText((text || "").trim());
showToast(button.dataset.copyMessage || "In die Zwischenablage kopiert.", "success", {
timeout: 2500,
});
} catch {
showToast("Kopieren nicht moeglich -- bitte manuell markieren.", "warning");
}
});
}
/* -- Befehlspalette (Strg+K) --------------------------------------------- */
function initPalette() {
const dialog = $("#command-palette");
if (!dialog) return;
const input = $(".palette__input", dialog);
const list = $(".palette__list", dialog);
let entries = [];
try {
entries = JSON.parse($("#palette-data")?.textContent || "[]");
} catch {
entries = [];
}
let active = 0;
const render = () => {
const needle = input.value.trim().toLowerCase();
const matches = entries
.filter((entry) => !needle || `${entry.label} ${entry.group}`.toLowerCase().includes(needle))
.slice(0, 40);
active = 0;
list.innerHTML = "";
if (!matches.length) {
const empty = document.createElement("div");
empty.className = "palette__item faint";
empty.textContent = "Keine Treffer";
list.appendChild(empty);
return;
}
matches.forEach((entry, index) => {
const item = document.createElement("a");
item.className = `palette__item${index === 0 ? " is-active" : ""}`;
item.href = entry.url;
item.innerHTML = `<span>${escapeHtml(entry.label)}</span>`;
if (entry.group) {
const group = document.createElement("span");
group.className = "palette__group";
group.textContent = entry.group;
item.appendChild(group);
}
list.appendChild(item);
});
};
const move = (delta) => {
const items = $$(".palette__item[href]", list);
if (!items.length) return;
items[active]?.classList.remove("is-active");
active = (active + delta + items.length) % items.length;
items[active].classList.add("is-active");
items[active].scrollIntoView({ block: "nearest" });
};
document.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
input.value = "";
render();
openDialog(dialog);
}
});
$$('[data-behavior="open-palette"]').forEach((button) =>
button.addEventListener("click", () => {
input.value = "";
render();
openDialog(dialog);
})
);
input.addEventListener("input", render);
input.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") {
event.preventDefault();
move(1);
} else if (event.key === "ArrowUp") {
event.preventDefault();
move(-1);
} else if (event.key === "Enter") {
event.preventDefault();
$$(".palette__item[href]", list)[active]?.click();
}
});
}
function escapeHtml(value) {
const node = document.createElement("span");
node.textContent = value;
return node.innerHTML;
}
/* -- Formulare gegen Doppelabsenden -------------------------------------- */
function initSubmitGuards() {
document.addEventListener("submit", (event) => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || form.dataset.allowMultiSubmit) return;
const buttons = $$('button[type="submit"], input[type="submit"]', form);
window.setTimeout(() => {
buttons.forEach((button) => {
button.disabled = true;
if (button.dataset.busyLabel) button.textContent = button.dataset.busyLabel;
});
}, 0);
// Falls die Navigation scheitert (Validierungsfehler serverseitig),
// Buttons nach kurzer Zeit wieder freigeben.
window.setTimeout(() => buttons.forEach((button) => (button.disabled = false)), 12000);
});
}
/* -- Kachelfilter (Dashboard) -------------------------------------------- */
function initTileFilters() {
$$('[data-behavior="tile-filter"]').forEach((group) => {
const targetId = group.dataset.filterScope;
const scope = targetId ? document.getElementById(targetId) : document;
const apply = (value) => {
$$("[data-tile-section]", scope).forEach((section) => {
section.hidden = Boolean(value) && section.dataset.tileSection !== value;
});
$$("[data-filter-value]", group).forEach((button) => {
button.setAttribute("aria-pressed", String(button.dataset.filterValue === value));
});
};
group.addEventListener("click", (event) => {
const button = event.target.closest("[data-filter-value]");
if (!button) return;
const value = button.getAttribute("aria-pressed") === "true" ? "" : button.dataset.filterValue;
apply(value);
});
});
}
/* -- Start ---------------------------------------------------------------- */
function boot() {
initTheme();
initSidebar();
initFlashes();
initCollapsibles();
initDialogs();
initConfirmations();
initTables();
initPermissionMatrix();
initAutoRefresh();
initRelativeTimes();
initCountdowns();
initClipboard();
initPalette();
initSubmitGuards();
initTileFilters();
unsavedGuard.init();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
window.tesm = { showToast, api, openDialog, relativeTime };
+310
View File
@@ -0,0 +1,310 @@
"""Privilegierte Systemaktionen ueber einen einzigen, engen Helfer.
Der Vorgaenger lief als ``root`` -- inklusive Flask, Jinja, Template-Rendering,
Datei-Uploads und LDAP-Client. Eine einzige Luecke in irgendeinem dieser Pfade
haette vollstaendige Kontrolle ueber den Host bedeutet. Die eigene
Dokumentation vermerkte das als "bewusst, mit Verbesserungshinweis".
Hier ist der Verbesserungshinweis umgesetzt:
* Der Webprozess laeuft als unprivilegierter Dienstbenutzer.
* Alles, was Root braucht (systemctl, nginx, netplan, hostnamectl, mount.cifs,
apt, certbot), laeuft ueber **ein** Skript: ``tesm-helper``. Es gehoert root,
ist per ``sudoers`` mit ``NOPASSWD`` nur fuer genau diesen Pfad erlaubt und
validiert jedes Argument gegen eine Positivliste.
* Die Sicherheitsgrenze ist damit klein, an einer Stelle und pruefbar -- statt
ueber Tausende Zeilen Anwendungscode verteilt.
Lesende Systemabfragen (``systemctl is-active``, ``ip addr``) brauchen keine
Rechte und laufen direkt. Nur Aenderungen gehen ueber den Helfer.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence
logger = logging.getLogger(__name__)
DEFAULT_HELPER = Path("/usr/local/lib/tesm/tesm-helper")
#: Verben, die der Helfer kennt. Muss mit dem Skript in ``deploy/`` uebereinstimmen.
HELPER_VERBS = (
"service-status",
"service-start",
"service-stop",
"service-restart",
"service-enable",
"service-disable",
"nginx-test",
"nginx-reload",
"nginx-write",
"nginx-apply",
"netplan-write",
"netplan-apply",
"hostname-set",
"timezone-set",
"kea-write",
"logrotate-write",
"mount-cifs",
"umount",
"apt-install",
"certbot-issue",
"selfsigned-issue",
"file-read",
)
@dataclass(frozen=True, slots=True)
class Result:
ok: bool
stdout: str = ""
stderr: str = ""
code: int = 0
@property
def output(self) -> str:
return (self.stdout + ("\n" + self.stderr if self.stderr else "")).strip()
@property
def message(self) -> str:
return self.stderr.strip() or self.stdout.strip()
UNAVAILABLE = Result(
ok=False,
stderr=(
"Systemaktionen sind in dieser Umgebung nicht verfuegbar "
"(Helfer nicht installiert oder kein Linux-Host)."
),
code=127,
)
class SysOps:
"""Zugang zu privilegierten Aktionen. In der Entwicklung ein No-Op."""
def __init__(
self,
*,
app_key: str,
site_key: str = "",
helper: Path | None = None,
timeout: int = 120,
enabled: bool = True,
) -> None:
self.app_key = app_key
#: Name dieser Installation auf dem Host. Alles, was ausserhalb von
#: /srv/<name> liegt, wird darueber benannt -- sonst schreibt eine
#: Testinstanz in die Systemdateien der Produktion.
self.site_key = site_key or app_key
self.helper = Path(helper or os.environ.get("TESM_HELPER", DEFAULT_HELPER))
self.timeout = timeout
self.enabled = enabled
# ------------------------------------------------------------------ #
@property
def available(self) -> bool:
"""Ist der Helfer *vorhanden*? Sagt noch nichts darueber, ob er laeuft."""
return self.enabled and self.helper.is_file() and os.name == "posix"
def selftest(self) -> Result:
"""Ruft den Helfer wirklich auf -- die einzige ehrliche Auskunft.
Dass die Datei existiert, hat nichts zu bedeuten. Auf beiden Testhosts
stand monatelang "Helfer erreichbar", waehrend jeder Aufruf an
``NoNewPrivileges`` scheiterte und damit jede Systemaktion tot war.
Geprueft wird ``service-status nginx``: der Helfer beantwortet das
immer, egal wie es dem Dienst geht. ``nginx-test`` waere der falsche
Kandidat -- sein Fehlschlag hiesse "Konfiguration kaputt", nicht
"Helfer kaputt", und die Seite meldete dann das Falsche.
"""
if not self.available:
return Result(False, "", f"Der Helfer {self.helper} fehlt.", 1)
result = self.run("service-status", "nginx")
if not result.ok and "no new privileges" in result.output.lower():
return Result(
False,
"",
"sudo wird durch 'NoNewPrivileges' der systemd-Unit blockiert. Ohne sudo "
"kann der Dienst den Helfer nicht aufrufen -- keine Systemaktion "
"funktioniert. In der Unit 'NoNewPrivileges=yes' entfernen und "
"'systemctl daemon-reload && systemctl restart' ausfuehren.",
result.code,
)
if not result.ok and "a password is required" in result.output.lower():
return Result(
False,
"",
"sudo verlangt ein Passwort. Die sudoers-Regel fuer den Helfer fehlt oder "
"greift nicht.",
result.code,
)
return result
def _sudo_prefix(self) -> list[str]:
if os.geteuid() == 0 if hasattr(os, "geteuid") else False:
return []
sudo = shutil.which("sudo")
return [sudo, "-n"] if sudo else []
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
"""Ruft den Helfer auf. Argumente werden nie durch eine Shell geschickt."""
if verb not in HELPER_VERBS:
raise ValueError(f"Unbekanntes Helfer-Verb: {verb!r}")
if not self.available:
return UNAVAILABLE
command = [*self._sudo_prefix(), str(self.helper), verb, *[str(a) for a in args]]
try:
completed = subprocess.run( # noqa: S603 - feste Argumentliste, keine Shell
command,
input=stdin,
capture_output=True,
text=True,
timeout=timeout or self.timeout,
check=False,
)
except subprocess.TimeoutExpired:
return Result(False, stderr=f"Zeitlimit ueberschritten ({verb}).", code=124)
except OSError as exc:
return Result(False, stderr=str(exc), code=126)
return Result(
ok=completed.returncode == 0,
stdout=completed.stdout or "",
stderr=completed.stderr or "",
code=completed.returncode,
)
# ------------------------------------------------------------------ #
# Lesende Abfragen -- ohne Rechte, direkt
# ------------------------------------------------------------------ #
@staticmethod
def read_command(command: Sequence[str], *, timeout: int = 10) -> Result:
"""Fuehrt eine rein lesende Systemabfrage aus."""
if not command:
return Result(False, stderr="Leeres Kommando", code=2)
binary = shutil.which(command[0])
if binary is None:
return Result(False, stderr=f"{command[0]} ist nicht installiert.", code=127)
try:
completed = subprocess.run( # noqa: S603
[binary, *command[1:]],
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return Result(False, stderr="Zeitlimit ueberschritten.", code=124)
except OSError as exc:
return Result(False, stderr=str(exc), code=126)
return Result(
ok=completed.returncode == 0,
stdout=completed.stdout or "",
stderr=completed.stderr or "",
code=completed.returncode,
)
def service_state(self, unit: str) -> dict[str, str]:
"""``{"active": ..., "enabled": ..., "installed": ...}`` fuer eine Unit."""
installed = shutil.which("systemctl") is not None
if not installed:
return {"active": "unbekannt", "enabled": "unbekannt", "installed": "nein"}
active = self.read_command(["systemctl", "is-active", unit]).stdout.strip() or "unknown"
enabled = self.read_command(["systemctl", "is-enabled", unit]).stdout.strip() or "unknown"
# ``list-unit-files`` erwartet ein Muster **mit** Endung: fuer
# "kea-dhcp4-server" liefert es nichts, fuer
# "kea-dhcp4-server.service" die Zeile. Ohne diese Ergaenzung stand
# ueberall "installiert: nein" -- auch bei laufendem Dienst, was zum
# Installieren-Knopf verleitet hat.
pattern = unit if "." in unit.rsplit("/", 1)[-1] else f"{unit}.service"
exists = self.read_command(
["systemctl", "list-unit-files", pattern, "--no-legend"]
).stdout.strip()
return {
"active": active,
"enabled": enabled,
"installed": "ja" if exists else "nein",
}
# ------------------------------------------------------------------ #
# Bequeme Wrapper
# ------------------------------------------------------------------ #
def service(self, action: str, unit: str) -> Result:
verb = f"service-{action}"
return self.run(verb, unit)
def nginx_test(self) -> Result:
return self.run("nginx-test")
def nginx_reload(self) -> Result:
return self.run("nginx-reload")
def write_nginx(self, content: str) -> Result:
return self.run("nginx-write", self.site_key, stdin=content)
def apply_nginx(self, content: str) -> Result:
"""Schreiben, pruefen und neu laden -- als eine Operation mit Ruecknahme.
Bewusst nicht als drei Aufrufe: ``nginx-write`` aktiviert die Site
sofort. Zwischen Schreiben und einem fehlgeschlagenen ``nginx -t`` lag
sonst ein Zeitfenster, in dem eine kaputte Konfiguration aktiviert war
-- der naechste Reload durch irgendwen haette den ganzen Webserver
lahmgelegt, auch fuer alle anderen Sites.
"""
return self.run("nginx-apply", self.site_key, stdin=content, timeout=60)
def write_netplan(self, content: str) -> Result:
return self.run("netplan-write", self.site_key, stdin=content)
def apply_netplan(self) -> Result:
return self.run("netplan-apply", timeout=90)
def set_hostname(self, hostname: str) -> Result:
return self.run("hostname-set", hostname)
def set_timezone(self, timezone: str) -> Result:
return self.run("timezone-set", timezone)
def write_kea_config(self, content: str) -> Result:
return self.run("kea-write", stdin=content)
def write_logrotate(self, content: str) -> Result:
return self.run("logrotate-write", self.site_key, stdin=content)
def mount_cifs(self, unc: str, target: str, username: str, password: str, domain: str = "") -> Result:
# Das Passwort geht ueber stdin an den Helfer, der es in eine
# 0600-Credentials-Datei schreibt. Nie als Argument (Prozessliste!),
# nie ueber die Umgebung (/proc/<pid>/environ).
payload = f"username={username}\npassword={password}\n" + (
f"domain={domain}\n" if domain else ""
)
return self.run("mount-cifs", unc, target, stdin=payload, timeout=45)
def umount(self, target: str) -> Result:
return self.run("umount", target, timeout=45)
def issue_self_signed(self, common_name: str, *alt_names: str) -> Result:
"""Erzeugt ein selbstsigniertes Zertifikat fuer diese Installation.
Fuer Geraete in geschlossenen Netzen ohne oeffentliche Domain -- dort
gibt es kein Let's Encrypt, und ohne Zertifikat gaebe es kein HTTPS.
"""
return self.run("selfsigned-issue", self.site_key, common_name, *alt_names, timeout=90)
def apt_install(self, *packages: str) -> Result:
return self.run("apt-install", *packages, timeout=600)
def certbot_issue(self, domain: str, email: str, webroot: str) -> Result:
return self.run("certbot-issue", domain, email, webroot, timeout=300)
def read_privileged_file(self, path: str) -> Result:
"""Liest eine Datei, die dem Dienstbenutzer nicht gehoert (z. B. Kea-Log)."""
return self.run("file-read", path)
@@ -0,0 +1,12 @@
"""Host-Ebene: Netzwerkeinstellungen und Webserver-Konfiguration.
Beide Anwendungen brauchen das identisch. Die Namensraeume sind ueber
``app_key`` getrennt -- der Vorgaenger schrieb aus beiden Apps in dieselbe
netplan-Override-Datei und ueberschrieb sich damit gegenseitig.
"""
from __future__ import annotations
from . import hostnet, webserver
__all__ = ["hostnet", "webserver"]
@@ -0,0 +1,208 @@
"""Netzwerkeinstellungen des Hosts -- mit Sicherheitsnetz.
Eine falsche IP-Adresse auf einem Host, den man nur ueber das Netz erreicht,
ist ein Ausflug ins Rechenzentrum. Darum gilt hier dasselbe Muster wie im
Vorgaenger, nur sauberer umgesetzt:
1. Aenderung wird geschrieben und angewandt.
2. Ein Zeitgeber laeuft (``REVERT_SECONDS``).
3. Bestaetigt der Administrator nicht rechtzeitig **von der neuen Adresse aus**,
wird automatisch zurueckgerollt.
Zwei Aenderungen gegenueber dem Vorgaenger:
* **Eigener Dateiname pro Anwendung.** Der Vorgaenger schrieb aus *beiden*
Anwendungen nach ``/etc/netplan/90-tesm.yaml``. Liefen sie auf demselben Host,
ueberschrieb eine die Konfiguration der anderen. Hier heisst die Datei
``90-<app_key>.yaml``.
* **DNS-Anzeige ueber ``resolvectl``**, nicht ``/etc/resolv.conf`` -- unter
systemd-resolved steht dort immer nur der Stub 127.0.0.53.
"""
from __future__ import annotations
import ipaddress
import logging
import re
import shutil
from dataclasses import dataclass, field
from typing import Any
from ..sysops import SysOps
logger = logging.getLogger(__name__)
REVERT_SECONDS = 45
BACKEND_NETPLAN = "netplan"
BACKEND_NETWORK_MANAGER = "NetworkManager"
BACKEND_DHCPCD = "dhcpcd"
BACKEND_UNKNOWN = "unbekannt"
@dataclass(slots=True)
class InterfaceInfo:
name: str
addresses: list[str] = field(default_factory=list)
state: str = "unbekannt"
mac: str = ""
gateway: str = ""
dns: list[str] = field(default_factory=list)
method: str = "unbekannt"
def detect_backend(sysops: SysOps) -> str:
"""Erkennt das aktive Netzwerk-Backend -- rein lesend."""
if shutil.which("netplan"):
return BACKEND_NETPLAN
if sysops.read_command(["systemctl", "is-active", "NetworkManager"]).stdout.strip() == "active":
return BACKEND_NETWORK_MANAGER
if sysops.read_command(["systemctl", "is-active", "dhcpcd"]).stdout.strip() == "active":
return BACKEND_DHCPCD
return BACKEND_UNKNOWN
def list_interfaces(sysops: SysOps) -> list[InterfaceInfo]:
result: dict[str, InterfaceInfo] = {}
addr = sysops.read_command(["ip", "-o", "addr", "show"])
for line in addr.stdout.splitlines():
parts = line.split()
if len(parts) < 4:
continue
name = parts[1]
entry = result.setdefault(name, InterfaceInfo(name=name))
if parts[2] in {"inet", "inet6"}:
entry.addresses.append(parts[3])
link = sysops.read_command(["ip", "-o", "link", "show"])
for line in link.stdout.splitlines():
parts = line.split()
if len(parts) < 2:
continue
name = parts[1].rstrip(":").split("@")[0]
entry = result.setdefault(name, InterfaceInfo(name=name))
if "state" in parts:
entry.state = parts[parts.index("state") + 1]
if "link/ether" in parts:
entry.mac = parts[parts.index("link/ether") + 1]
routes = sysops.read_command(["ip", "route", "show", "default"])
for line in routes.stdout.splitlines():
parts = line.split()
if "via" in parts and "dev" in parts:
gateway = parts[parts.index("via") + 1]
device = parts[parts.index("dev") + 1]
if device in result:
result[device].gateway = gateway
for entry in result.values():
entry.dns = read_dns(sysops, entry.name)
entry.method = "unbekannt"
return [entry for name, entry in sorted(result.items()) if name != "lo"]
def read_dns(sysops: SysOps, interface: str) -> list[str]:
"""Liest die DNS-Server per ``resolvectl dns <iface>``.
Mehrzeilige Ausgabe beachten: IPv6-Fortsetzungszeilen enthalten selbst
Doppelpunkte -- ein naives ``split(":")`` zerlegt sie falsch.
"""
if not shutil.which("resolvectl"):
return []
result = sysops.read_command(["resolvectl", "dns", interface])
if not result.ok:
return []
servers: list[str] = []
for line in result.stdout.splitlines():
text = line.strip()
if not text:
continue
if text.lower().startswith("link"):
_, _, tail = text.partition(":")
text = tail.strip()
for token in text.split():
try:
ipaddress.ip_address(token)
except ValueError:
continue
servers.append(token)
return servers
def validate_static(
*, address: str, prefix: Any, gateway: str, dns: str
) -> tuple[str, int, str, list[str]]:
try:
prefix_length = int(prefix)
except (TypeError, ValueError) as exc:
raise ValueError("Die Praefixlaenge muss eine Zahl sein (z. B. 24).") from exc
if not 1 <= prefix_length <= 32:
raise ValueError("Die Praefixlaenge muss zwischen 1 und 32 liegen.")
try:
interface_ip = ipaddress.ip_interface(f"{address.strip()}/{prefix_length}")
except ValueError as exc:
raise ValueError(f"{address!r} ist keine gueltige IPv4-Adresse.") from exc
gateway_clean = (gateway or "").strip()
if gateway_clean:
try:
gateway_ip = ipaddress.ip_address(gateway_clean)
except ValueError as exc:
raise ValueError(f"{gateway_clean!r} ist keine gueltige Gateway-Adresse.") from exc
if gateway_ip not in interface_ip.network:
raise ValueError(
f"Das Gateway {gateway_clean} liegt nicht im Netz {interface_ip.network}. "
"So waere der Host nach der Aenderung nicht mehr erreichbar."
)
servers: list[str] = []
for token in re.split(r"[\s,;]+", dns or ""):
if not token:
continue
try:
ipaddress.ip_address(token)
except ValueError as exc:
raise ValueError(f"{token!r} ist keine gueltige DNS-Adresse.") from exc
servers.append(token)
return str(interface_ip.ip), prefix_length, gateway_clean, servers
def render_netplan(
*,
interface: str,
dhcp: bool,
address: str = "",
prefix: int = 24,
gateway: str = "",
dns: list[str] | None = None,
) -> str:
"""Erzeugt eine Override-Datei fuer genau **ein** Interface.
Nur der Block des betroffenen Interfaces steht darin. Ein vollstaendiger
Neuaufbau der Netzwerkkonfiguration wuerde andere, unveraenderte Interfaces
zuruecksetzen -- ein realer Fehler des Vorgaengerprojekts.
"""
lines = [
"# Von TESM verwaltet. Diese Datei enthaelt ausschliesslich das unten",
"# genannte Interface; alle anderen bleiben bei ihrer",
"# Distributionskonfiguration.",
"network:",
" version: 2",
" ethernets:",
f" {interface}:",
]
if dhcp:
lines.append(" dhcp4: true")
else:
lines.append(" dhcp4: false")
lines.append(f" addresses: [{address}/{prefix}]")
if gateway:
lines.append(" routes:")
lines.append(f" - to: default")
lines.append(f" via: {gateway}")
if dns:
lines.append(" nameservers:")
lines.append(f" addresses: [{', '.join(dns)}]")
return "\n".join(lines) + "\n"
@@ -0,0 +1,379 @@
"""nginx-Konfiguration und TLS-Zertifikate.
Der Vorgaenger schrieb die Site-Datei nur, wenn sie **noch nicht existierte**.
Das hat produktiv realen Schaden verursacht: eine sehr alte Datei mit einem
falschen ``alias``-Pfad blieb ueber beliebig viele Updates bestehen, obwohl das
Repo-Template den Fehler laengst behoben hatte -- Ergebnis: alle statischen
Dateien 404, Oberflaeche komplett ohne Design.
Loesung hier: die Datei wird **immer** aus der Vorlage erzeugt. Alles, was ein
Administrator sonst per Hand hineinschrieb (Domain, Ports, Zertifikate, HSTS),
ist Einstellung in der Anwendung und fliesst in die Vorlage ein. Damit gibt es
keinen Grund mehr, eine erzeugte Datei per Hand zu bearbeiten -- und keinen
Grund, sie beim Update in Ruhe zu lassen.
Zielzustand im Betrieb: Port 80 leitet auf 443 um (ausser dem ACME-Pfad),
Port 443 terminiert TLS, HSTS wird gesetzt.
"""
from __future__ import annotations
import logging
import re
import socket
from collections.abc import Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from ..sysops import SysOps
logger = logging.getLogger(__name__)
REVERT_SECONDS = 45
DOMAIN_RE = re.compile(r"^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,}$")
@dataclass(frozen=True, slots=True)
class WebConfig:
app_key: str
upstream: str
static_root: str
domain: str = ""
https_enabled: bool = False
hsts_enabled: bool = False
redirect_http: bool = True
cert_path: str = ""
key_path: str = ""
acme_webroot: str = "/var/www/tesm-acme"
websocket_path: str = ""
max_body_mb: int = 512
#: Abweichende Ports braucht nur eine Testinstanz neben einer laufenden
#: Produktion. Der Regelfall -- und das Ziel jeder echten Installation --
#: ist 80 und 443.
http_port: int = 80
https_port: int = 443
#: ``http2 on;`` gibt es erst ab nginx 1.25.1. Aeltere Versionen kennen nur
#: ``listen ... ssl http2;`` und weisen die neue Direktive mit
#: "unknown directive" ab -- der ganze Webserver startet dann nicht mehr.
#: Auf POETEST (nginx 1.24) genau so passiert. Vorgabe ist deshalb die alte
#: Schreibweise: sie funktioniert ueberall und erzeugt auf neuen Versionen
#: nur eine Warnung.
http2_directive: bool = False
@property
def server_name(self) -> str:
return self.domain or "_"
@property
def default_marker(self) -> str:
"""``default_server`` fuer die Site ohne eigenen Namen.
``server_name _;`` ist bei nginx kein Sonderfall -- es ist nur ein Name,
auf den kein echter Host-Header passt. Ohne ``default_server`` wird die
*zuerst geladene* Site zur Vorgabe, und das ist die alphabetisch erste
Datei in ``sites-enabled``. Auf einem Host mit zwei Anwendungen
entscheidet dann der Zufall des Dateinamens, wer Anfragen ohne
passenden Namen bekommt.
Mit Domain gibt es keinen Grund, Vorgabe zu sein -- dann greift der
Name. Zwei namenlose Sites auf demselben Port weist nginx ab; das ist
richtig so und wird beim Uebernehmen sichtbar.
"""
return "" if self.domain else " default_server"
@property
def https_suffix(self) -> str:
"""``:8443`` bei abweichendem Port, sonst leer -- fuer die Umleitung."""
return "" if self.https_port == 443 else f":{self.https_port}"
def validate_domain(value: str) -> str:
text = (value or "").strip().lower().rstrip(".")
if not text:
return ""
if not DOMAIN_RE.match(text):
raise ValueError(f"{text!r} ist kein gueltiger Domainname.")
return text
def render(config: WebConfig) -> str:
"""Erzeugt die vollstaendige Site-Konfiguration."""
body = f"{max(1, config.max_body_mb)}m"
# Kopfzeilen und proxy_pass -- ohne Zeitlimits. Die kommen je Block dazu,
# denn nginx lehnt eine doppelte Direktive im selben Block ab (auf POETEST
# real passiert: der Websocket-Block erbte 120s und setzte noch einmal
# 3600s, "proxy_read_timeout directive is duplicate", nginx startete nicht).
common_proxy = [
# $http_host statt $host: $host verwirft den Port. Hinter einem Proxy auf
# abweichendem Port schlaegt die Origin-Pruefung der Anwendung sonst fehl.
" proxy_set_header Host $http_host;",
" proxy_set_header X-Real-IP $remote_addr;",
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
" proxy_set_header X-Forwarded-Proto $scheme;",
" proxy_set_header X-Forwarded-Host $http_host;",
" proxy_set_header X-Forwarded-Port $server_port;",
f" proxy_pass http://{config.upstream};",
" proxy_http_version 1.1;",
]
lines: list[str] = [
f"# Von TESM erzeugt ({config.app_key}). Aenderungen bitte in der Anwendung",
"# vornehmen -- diese Datei wird bei jeder Uebernahme neu geschrieben.",
"",
]
# ---- Port 80 --------------------------------------------------------- #
lines += [
"server {",
f" listen {config.http_port}{config.default_marker};",
f" listen [::]:{config.http_port}{config.default_marker};",
f" server_name {config.server_name};",
"",
" # ACME-Pfad bleibt auch bei aktiver Umleitung erreichbar --",
" # sonst kann Let's Encrypt nie erneuern.",
" location /.well-known/acme-challenge/ {",
f" alias {config.acme_webroot.rstrip('/')}/.well-known/acme-challenge/;",
" default_type \"text/plain\";",
" }",
"",
]
if config.https_enabled and config.redirect_http:
lines += [
" location / {",
f" return 308 https://$host{config.https_suffix}$request_uri;",
" }",
"}",
"",
]
else:
lines += _site_body(config, common_proxy, body, tls=False)
lines += ["}", ""]
# ---- Port 443 -------------------------------------------------------- #
if config.https_enabled:
lines += [
"server {",
f" listen {config.https_port} ssl"
f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
f" listen [::]:{config.https_port} ssl"
f"{config.default_marker}{'' if config.http2_directive else ' http2'};",
*([" http2 on;"] if config.http2_directive else []),
f" server_name {config.server_name};",
"",
f" ssl_certificate {config.cert_path};",
f" ssl_certificate_key {config.key_path};",
" ssl_protocols TLSv1.2 TLSv1.3;",
" ssl_prefer_server_ciphers off;",
" ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:"
"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;",
" ssl_session_cache shared:TESM:10m;",
" ssl_session_timeout 1d;",
" ssl_session_tickets off;",
]
# OCSP-Stapling nur mit einem Zertifikat einer echten CA. Ein
# selbstsigniertes hat keinen OCSP-Responder; nginx warnt dann bei
# jedem Start ("ssl_stapling ignored") -- eine Warnung, die niemand
# abstellen kann und die echte Warnungen untergehen laesst.
if config.domain:
lines += [" ssl_stapling on;", " ssl_stapling_verify on;"]
lines += [""]
if config.hsts_enabled:
lines.append(
' add_header Strict-Transport-Security "max-age=15552000; '
'includeSubDomains" always;'
)
lines.append("")
lines += _site_body(config, common_proxy, body, tls=True)
lines += ["}", ""]
return "\n".join(lines)
def _site_body(config: WebConfig, common_proxy: list[str], body: str, *, tls: bool) -> list[str]:
lines = [
f" client_max_body_size {body};",
" # Groesser als das Limit der Anwendung, damit deren eigene, erklaerende",
" # Fehlerseite gewinnt statt der generischen 413 von nginx.",
"",
" location /assets/ {",
f" alias {config.static_root.rstrip('/')}/;",
" expires 7d;",
" access_log off;",
" add_header Cache-Control \"public, max-age=604800, immutable\";",
" }",
"",
" location /core-assets/ {",
f" alias {config.static_root.rstrip('/')}-core/;",
" expires 7d;",
" access_log off;",
" }",
"",
]
if config.websocket_path:
# Eine SSH-Sitzung im Browser steht minutenlang still, ohne tot zu sein.
lines += [
f" location {config.websocket_path} {{",
" proxy_set_header Upgrade $http_upgrade;",
' proxy_set_header Connection "upgrade";',
*common_proxy,
" proxy_read_timeout 3600s;",
" proxy_send_timeout 3600s;",
" proxy_buffering off;",
" }",
"",
]
lines += [
" location / {",
*common_proxy,
" proxy_read_timeout 120s;",
" proxy_send_timeout 120s;",
" }",
]
return lines
def apply_config(sysops: SysOps, content: str) -> dict[str, Any]:
"""Schreibt die Konfiguration, prueft sie und laedt nginx neu.
Ein einziger Helferaufruf, nicht drei. Der Helfer nimmt die Aenderung bei
einem fehlgeschlagenen ``nginx -t`` selbst zurueck; danach ist der Zustand
auf der Platte garantiert derselbe wie vorher.
Das war ein echter Fehler in der ersten Fassung: ``nginx-write`` verlinkt
die Datei sofort nach ``sites-enabled``. Wurde sie anschliessend abgelehnt,
blieb sie aktiviert liegen -- der laufende nginx merkte nichts, aber der
naechste Reload (auch fuer eine ganz andere Site) scheiterte. Aus einer
abgelehnten Eingabe wurde ein Ausfall des Webservers.
"""
result = sysops.apply_nginx(content)
if not result.ok:
return {
"ok": False,
"message": "nginx hat die Konfiguration abgelehnt -- der alte Stand gilt weiter.",
"detail": result.output,
}
return {"ok": True, "message": "Konfiguration uebernommen.", "detail": result.output}
def request_certificate(
sysops: SysOps, *, domain: str, email: str, webroot: str
) -> dict[str, Any]:
"""Fordert ein Let's-Encrypt-Zertifikat per Webroot an.
Bewusst ``certbot certonly --webroot`` und nicht das nginx-Plugin: das
Plugin bearbeitet die Site-Datei selbst und wuerde die von der Anwendung
erzeugte Konfiguration verbiegen.
"""
if not domain:
return {"ok": False, "message": "Es ist keine Domain konfiguriert."}
if not email:
return {"ok": False, "message": "Fuer Let's Encrypt ist eine E-Mail-Adresse noetig."}
result = sysops.certbot_issue(domain, email, webroot)
if not result.ok:
return {"ok": False, "message": "certbot ist fehlgeschlagen.", "detail": result.output}
return {
"ok": True,
"message": f"Zertifikat fuer {domain} ausgestellt.",
"detail": result.output,
"cert_path": f"/etc/letsencrypt/live/{domain}/fullchain.pem",
"key_path": f"/etc/letsencrypt/live/{domain}/privkey.pem",
}
def create_self_signed(
sysops: SysOps, *, common_name: str, alt_names: Sequence[str] = ()
) -> dict[str, Any]:
"""Erzeugt ein selbstsigniertes Zertifikat fuer diese Installation.
Der Regelfall im Zielumfeld ist ein Geraet in einem geschlossenen Netz:
keine oeffentliche Domain, kein Let's Encrypt, oft nicht einmal ein
aufloesbarer Name -- nur eine IP. Ohne diesen Weg gaebe es dort ueberhaupt
kein HTTPS, und Port 443 bliebe leer.
Der Browser zeigt eine Warnung; das ist der ehrliche Preis. Verschluesselt
ist die Verbindung trotzdem, und das Sitzungscookie darf endlich
``Secure`` tragen.
"""
name = (common_name or "").strip().lower().rstrip(".")
if not name:
return {"ok": False, "message": "Es fehlt ein Name fuer das Zertifikat."}
if not re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name):
return {"ok": False, "message": f"{name!r} ist als Zertifikatsname nicht zulaessig."}
cleaned: list[str] = []
for entry in alt_names:
value = (entry or "").strip().lower().rstrip(".")
if not value or value == name or value in cleaned:
continue
if re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", value):
cleaned.append(value)
result = sysops.issue_self_signed(name, *cleaned)
if not result.ok:
return {
"ok": False,
"message": "Das Zertifikat konnte nicht erzeugt werden.",
"detail": result.output,
}
paths = [line.strip() for line in result.stdout.splitlines() if line.strip()]
if len(paths) != 2:
return {"ok": False, "message": "Unerwartete Antwort des Helfers.", "detail": result.output}
return {
"ok": True,
"message": (
f"Selbstsigniertes Zertifikat fuer {name} erzeugt. Browser zeigen dafuer eine "
"Warnung -- fuer ein oeffentlich erreichbares System bitte Let's Encrypt nutzen."
),
"cert_path": paths[0],
"key_path": paths[1],
}
def local_names() -> list[str]:
"""Hostname und eigene IP-Adressen -- Vorschlag fuer die Namen im Zertifikat."""
names: list[str] = []
try:
hostname = socket.gethostname().strip().lower()
except OSError: # pragma: no cover - defensiv
hostname = ""
for candidate in (hostname, f"{hostname}.local" if hostname else ""):
if candidate and candidate not in names:
names.append(candidate)
try:
for info in socket.getaddrinfo(hostname or "localhost", None, socket.AF_INET):
address = info[4][0]
if address and not address.startswith("127.") and address not in names:
names.append(address)
except OSError: # pragma: no cover - defensiv
pass
return names
def certificate_info(cert_path: str) -> dict[str, Any]:
"""Liest Aussteller und Ablauf eines Zertifikats -- ohne externes Werkzeug."""
path = Path(cert_path or "")
try:
if not path.is_file():
return {"present": False}
from cryptography import x509
certificate = x509.load_pem_x509_certificate(path.read_bytes())
except PermissionError:
# Kein Grund, die ganze Seite abzuwerfen: das Zertifikat kann
# ausserhalb der Reichweite des Dienstbenutzers liegen. nginx liest es
# trotzdem -- nur die Anzeige von Aussteller und Ablauf entfaellt.
return {"present": True, "error": "Die Anwendung darf diese Datei nicht lesen."}
except OSError as exc:
return {"present": True, "error": str(exc)}
except Exception as exc: # noqa: BLE001
return {"present": True, "error": str(exc)}
return {
"present": True,
"subject": certificate.subject.rfc4514_string(),
"issuer": certificate.issuer.rfc4514_string(),
"not_valid_after": certificate.not_valid_after_utc.isoformat(timespec="seconds"),
"not_valid_before": certificate.not_valid_before_utc.isoformat(timespec="seconds"),
"serial": format(certificate.serial_number, "x"),
}
@@ -0,0 +1,46 @@
{# Auswahlfeld fuer eine Verzeichnisgruppe.
Der DN einer AD-Gruppe ist nichts, was man abtippt: er ist lang, enthaelt
Kommas und Leerzeichen und ein Tippfehler faellt erst bei der naechsten
Anmeldung auf -- als stille Nicht-Zuordnung. Deshalb gesucht statt getippt.
Das Suchergebnis kommt aus dem Verzeichnis selbst; die Felder werden dabei
beide gefuellt (Anzeigename und DN). Getippt werden darf trotzdem: bei einer
Umgebung ohne Dienstkonto ist die Suche nicht moeglich, und dann ist ein
eingefuegter DN besser als gar keine Zuordnung.
Erwartet: name_field, dn_field (Feldnamen), scope (eindeutiges Kuerzel),
optional name_value / dn_value und required.
#}
{% macro group_picker(name_field, dn_field, scope, name_value='', dn_value='',
required=False, label='Verzeichnisgruppe') %}
<div class="field field--full" data-behavior="group-picker"
data-search-url="{{ url_for('hostsettings.directory_group_search') }}"
data-name-target="#gp-name-{{ scope }}" data-dn-target="#gp-dn-{{ scope }}">
<span class="field__label">{{ label }}</span>
<div class="row row--tight">
<input class="input" type="search" id="gp-query-{{ scope }}"
placeholder="Gruppe suchen, z. B. GG_TESM ..." autocomplete="off"
data-group-query>
<button class="btn btn--sm" type="button" data-group-search>
{{ icon('search', size=14) }} Suchen
</button>
</div>
<div class="fs-xs faint mt-1" data-group-status></div>
<div class="stack stack--tight mt-2" data-group-results hidden></div>
<div class="form-grid mt-2">
<label class="field">
<span class="field__label">Name</span>
<input class="input" type="text" id="gp-name-{{ scope }}" name="{{ name_field }}"
value="{{ name_value }}" placeholder="GG_TESM_Betrieb" autocomplete="off">
</label>
<label class="field">
<span class="field__label">DN{% if required %} <span class="req">*</span>{% endif %}</span>
<input class="input input--mono fs-xs" type="text" id="gp-dn-{{ scope }}"
name="{{ dn_field }}" value="{{ dn_value }}" {% if required %}required{% endif %}
placeholder="CN=GG_TESM_Betrieb,OU=Gruppen,DC=firma,DC=local" autocomplete="off">
</label>
</div>
</div>
{% endmacro %}
@@ -0,0 +1,7 @@
{% if lines %}
<pre class="log-view">{% for line in lines %}{{ line }}
{% endfor %}</pre>
{% else %}
<div class="empty"><div class="empty__title">Keine Zeilen</div>
<p class="muted">Es liegen (noch) keine passenden Protokollzeilen vor.</p></div>
{% endif %}
@@ -0,0 +1,121 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, alert, pill, empty_state %}
{% block page_title %}Mein Konto{% endblock %}
{% block page_sub %}{{ current_user.username }} &middot; {{ current_user.role_label }}{% endblock %}
{% block topbar_extra %}
<a class="btn btn--sm" href="{{ url_for('account.security') }}">{{ icon('lock', size=15) }} Sicherheit</a>
{% endblock %}
{% block content %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('users', size=17) }} Stammdaten</div></div>
</div>
<form method="post" action="{{ url_for('account.update_profile') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
{% if current_user.is_directory_account %}
{{ alert('Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder
Anmeldung uebernommen.', 'info') }}
{% endif %}
<div class="form-grid">
{{ field('first_name', 'Vorname', value=profile.first_name,
disabled=current_user.is_directory_account) }}
{{ field('last_name', 'Nachname', value=profile.last_name,
disabled=current_user.is_directory_account) }}
{{ field('email', 'E-Mail', value=profile.email, type='email',
disabled=current_user.is_directory_account, extra_class='field--full') }}
</div>
</div>
{% if not current_user.is_directory_account %}
<div class="card__footer"><button class="btn btn--primary" type="submit">Speichern</button></div>
{% endif %}
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Meine wirksamen Rechte</div>
<div class="card__sub">
Vereinigung aller Gruppen, nach Anwendung der Bereichsschalter
</div>
</div>
</div>
<div class="card__body">
{% if current_user.is_admin %}
{{ alert('Sie sind Administrator und duerfen unabhaengig von Gruppen alles.', 'warning') }}
{% elif permissions %}
<div class="row row--tight">
{% for permission in permissions %}
<span class="badge" title="{{ permission }}">{{ tree.label_for(permission) }}</span>
{% endfor %}
</div>
{% else %}
{{ empty_state('Keine Rechte', 'Sie sehen nur diese Kontoseite.', 'lock') }}
{% endif %}
</div>
</section>
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('grid', size=17) }} Profilbild</div></div>
</div>
<div class="card__body stack">
<div class="row">
{% if profile.avatar_filename %}
<img class="avatar avatar--lg"
src="{{ url_for('account.avatar', filename=profile.avatar_filename) }}" alt="">
{% else %}
<span class="avatar avatar--lg" aria-hidden="true">{{ current_user.initials }}</span>
{% endif %}
<div class="fs-sm muted">PNG, JPG, WEBP oder GIF, maximal 2&nbsp;MB.</div>
</div>
<form method="post" action="{{ url_for('account.upload_avatar') }}"
enctype="multipart/form-data" class="stack stack--tight" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input class="input" type="file" name="avatar" accept="image/png,image/jpeg,image/webp,image/gif" required>
<button class="btn btn--sm" type="submit">Hochladen</button>
</form>
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('shield-users', size=17) }} Gruppen</div></div>
</div>
<div class="card__body">
{% if groups %}
<div class="row row--tight">
{% for group in groups %}{{ pill(group.name, 'accent' if group.is_default else '') }}{% endfor %}
</div>
{% else %}
<p class="faint fs-sm">Keiner Gruppe zugeordnet.</p>
{% endif %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Konto</div></div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Benutzername</dt><dd class="mono">{{ current_user.username }}</dd>
<dt>Quelle</dt><dd>{{ 'Verzeichnisdienst' if current_user.is_directory_account else 'Lokal' }}</dd>
<dt>Zwei-Faktor</dt>
<dd>{% if profile.totp_secret %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('inaktiv', 'warning') }}{% endif %}</dd>
<dt>Angelegt</dt><dd>{{ profile.created_at | dt }}</dd>
<dt>Letzte Anmeldung</dt><dd>{{ profile.last_login_at | dt }}</dd>
</dl>
</div>
</section>
</div>
</div>
{% endblock %}
@@ -0,0 +1,181 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, alert, pill, empty_state %}
{% block page_title %}Sicherheit{% endblock %}
{% block page_sub %}Passwort, zweiter Faktor und aktive Sitzungen{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('account.overview') }}">Mein Konto</a></span><span>Sicherheit</span>
</nav>
{% endblock %}
{% block content %}
{% if recovery_codes %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('key', size=17) }} Wiederherstellungscodes</div>
<div class="card__sub">
Jetzt sichern &ndash; sie werden nur dieses eine Mal angezeigt. Jeder Code
funktioniert genau einmal.
</div>
</div>
<button class="btn btn--sm" type="button" data-copy="#recovery-codes"
data-copy-message="Codes kopiert.">{{ icon('copy', size=14) }} Kopieren</button>
</div>
<div class="card__body">
<pre class="code-block" id="recovery-codes">{% for code in recovery_codes %}{{ code }}
{% endfor %}</pre>
</div>
</section>
{% endif %}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('lock', size=17) }} Passwort aendern</div></div>
</div>
{% if current_user.is_directory_account %}
<div class="card__body">
{{ alert('Ihr Passwort wird im Verzeichnisdienst gepflegt und dort geaendert.', 'info') }}
</div>
{% else %}
<form method="post" action="{{ url_for('account.change_password') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ field('current_password', 'Aktuelles Passwort', type='password', required=True,
autocomplete='current-password') }}
{{ field('new_password', 'Neues Passwort', type='password', required=True,
autocomplete='new-password',
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen und zwei verschiedene Zeichenarten.') }}
{{ field('repeat_password', 'Neues Passwort wiederholen', type='password', required=True,
autocomplete='new-password') }}
{{ alert('Nach der Aenderung werden alle anderen Sitzungen beendet.', 'info') }}
</div>
<div class="card__footer"><button class="btn btn--primary" type="submit">Passwort aendern</button></div>
</form>
{% endif %}
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Zwei-Faktor-Authentisierung
{% if totp_enabled %}{{ pill('aktiv', 'online') }}{% else %}{{ pill('inaktiv', 'warning') }}{% endif %}
</div>
<div class="card__sub">Zeitbasierte Einmalcodes (TOTP), kompatibel mit jeder Authenticator-App</div>
</div>
</div>
<div class="card__body stack">
{% if pending_secret %}
{{ alert('Richten Sie den Eintrag in Ihrer App ein und bestaetigen Sie mit einem aktuellen Code. Erst dann wird die Anmeldung umgestellt.', 'warning') }}
<dl class="kv fs-sm">
<dt>Geheimnis</dt>
<dd><span class="mono" id="totp-secret">{{ pending_secret_grouped }}</span>
<button class="btn btn--sm btn--ghost" type="button" data-copy="#totp-secret">
{{ icon('copy', size=13) }}</button></dd>
<dt>Konto</dt><dd>{{ current_user.username }}</dd>
<dt>Verfahren</dt><dd>TOTP, SHA-1, 6 Stellen, 30 Sekunden</dd>
</dl>
<details class="mt-2">
<summary class="fs-sm muted">otpauth-URI anzeigen</summary>
<pre class="code-block code-block--wrap mt-2">{{ pending_uri }}</pre>
</details>
<form method="post" action="{{ url_for('account.totp_confirm') }}" class="row row--tight">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input class="input input--mono" type="text" name="code" required maxlength="6"
inputmode="numeric" placeholder="123456" style="max-width:130px;text-align:center">
<button class="btn btn--primary" type="submit">Bestaetigen</button>
</form>
{% elif totp_enabled %}
<dl class="kv fs-sm">
<dt>Aktiviert am</dt><dd>{{ profile.totp_confirmed_at | dt }}</dd>
<dt>Unbenutzte Wiederherstellungscodes</dt>
<dd>{{ recovery_remaining }}{% if recovery_remaining <= 2 %}
<span class="warning-text">&nbsp;&ndash; bitte erneuern</span>{% endif %}</dd>
</dl>
<div class="row row--tight">
<form method="post" action="{{ url_for('account.recovery_codes') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit"
data-confirm="Neue Codes erzeugen? Die bisherigen verlieren ihre Gueltigkeit."
data-confirm-title="Codes erneuern" data-confirm-ok="Erneuern" data-confirm-style="primary">
Codes erneuern</button>
</form>
<form method="post" action="{{ url_for('account.totp_disable') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Zwei-Faktor-Authentisierung wirklich deaktivieren?"
data-confirm-title="Zwei-Faktor deaktivieren" data-confirm-ok="Deaktivieren">
Deaktivieren</button>
</form>
</div>
{% else %}
{% if require_totp_for_admins and current_user.is_admin %}
{{ alert('In dieser Installation ist ein zweiter Faktor fuer Administratoren verpflichtend. Bitte jetzt einrichten.', 'danger') }}
{% endif %}
<p class="muted fs-sm">
Ein zweiter Faktor schuetzt Ihr Konto auch dann, wenn Ihr Passwort bekannt wird.
</p>
<form method="post" action="{{ url_for('account.totp_start') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit">Einrichtung starten</button>
</form>
{% endif %}
</div>
</section>
</div>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('activity', size=17) }} Meine Sitzungen</div>
<div class="card__sub">{{ sessions | length }} aktiv</div>
</div>
{% if sessions | length > 1 %}
<form method="post" action="{{ url_for('account.revoke_other_sessions') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Alle anderen Sitzungen beenden?"
data-confirm-title="Sitzungen beenden" data-confirm-ok="Beenden">
Andere beenden</button>
</form>
{% endif %}
</div>
<div class="card__body--flush">
{% if sessions %}
<table class="table table--compact">
<tbody>
{% for entry in sessions %}
<tr>
<td>
<div class="fw-600 fs-sm">
{{ entry.ip or 'unbekannte Adresse' }}
{% if entry.id == current_sid %}{{ pill('diese Sitzung', 'online') }}{% endif %}
</div>
<div class="fs-xs faint truncate" style="max-width:280px">{{ entry.user_agent or '-' }}</div>
<div class="fs-xs faint">
aktiv <span data-relative-time="{{ entry.last_seen_at }}">{{ entry.last_seen_at | dt }}</span>
</div>
</td>
<td class="actions">
{% if entry.id != current_sid %}
<form method="post" action="{{ url_for('account.revoke_session', sid=entry.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit">Beenden</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{{ empty_state('Keine Sitzungen', '', 'activity') }}
{% endif %}
</div>
</section>
</div>
{% endblock %}
@@ -0,0 +1,190 @@
{# ==========================================================================
Grundgeruest aller angemeldeten Seiten.
Bloecke: extra_head, page_title, page_sub, breadcrumb, topbar_extra,
content, dialogs, scripts
========================================================================== #}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}{{ self.page_title() }} &middot; {{ app_name }}{% endblock %}</title>
<link rel="icon" href="{{ asset_url('img/favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ asset_url('css/base.css') }}">
<link rel="stylesheet" href="{{ asset_url('css/components.css') }}">
{% block extra_head %}{% endblock %}
</head>
<body data-csrf="{{ csrf_token() }}" data-sidebar="full">
<div class="app-shell">
<div class="sidebar__scrim" aria-hidden="true"></div>
<aside class="sidebar" aria-label="Hauptnavigation">
<div class="sidebar__brand">
{# Die Wortmarke traegt den Namen selbst -- ein zweiter Textblock daneben
waere eine Doppelung. Eine einzige Zeichnung, deren Farben aus den
Themen-Token kommen: zwei per Media Query versteckte SVG waeren bei
"System" und beim Umschalten von Hand falsch. #}
<span class="sidebar__logo" role="img" aria-label="{{ app_name }}">
<svg viewBox="0 0 312 112" xmlns="http://www.w3.org/2000/svg">
<circle cx="298" cy="66" r="14" fill="var(--logo-mark)" opacity="0.2"/>
<polyline points="0,66 90,66 100,66 108,18 116,114 124,66 134,66 200,66 210,66
218,32 226,100 234,66 244,66 300,66"
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round" opacity="0.88"/>
<text x="10" y="94" font-family="var(--font-mono)" font-weight="800"
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text>
<circle cx="298" cy="66" r="8" fill="var(--logo-mark)"/>
</svg>
<span class="sidebar__edition">{{ app_edition }}</span>
</span>
</div>
<nav class="sidebar__nav">
{% for item in nav_items %}
{% if item.children %}
<details class="nav-tree" data-persist="nav-{{ item.key }}"
{% if item.children | selectattr('endpoint', 'equalto', request.endpoint) | list %}open{% endif %}>
<summary>
{{ icon(item.icon, size=18, extra_class='nav-item__icon') }}
<span class="nav-item__label">{{ item.label }}</span>
{{ icon('chevron-right', size=15, extra_class='nav-tree__chevron') }}
</summary>
<div class="nav-tree__children">
{% for child in item.children %}
<a class="nav-item" href="{{ url_for(child.endpoint) }}"
{% if child.endpoint == request.endpoint %}aria-current="page"{% endif %}>
{{ icon(child.icon, size=17, extra_class='nav-item__icon') }}
<span class="nav-item__label">{{ child.label }}</span>
{% if child.badge %}<span class="nav-item__badge">{{ child.badge }}</span>{% endif %}
</a>
{% endfor %}
</div>
</details>
{% else %}
<a class="nav-item" href="{{ url_for(item.endpoint) }}"
{% if item.endpoint == request.endpoint %}aria-current="page"{% endif %}
title="{{ item.hint or item.label }}">
{{ icon(item.icon, size=18, extra_class='nav-item__icon') }}
<span class="nav-item__label">{{ item.label }}</span>
{% if item.badge %}<span class="nav-item__badge">{{ item.badge }}</span>{% endif %}
</a>
{% endif %}
{% endfor %}
</nav>
<div class="sidebar__footer">
{% if current_user.is_authenticated %}
<a class="user-chip" href="{{ url_for('account.overview') }}">
{% if current_user.avatar_filename %}
<img class="avatar" src="{{ url_for('account.avatar', filename=current_user.avatar_filename) }}" alt="">
{% else %}
<span class="avatar" aria-hidden="true">{{ current_user.initials }}</span>
{% endif %}
<span class="user-chip__meta">
<span class="user-chip__name">{{ current_user.display_name }}</span>
<span class="user-chip__role">{{ current_user.role_label }}</span>
</span>
</a>
<div class="row row--tight mt-2">
<div class="btn-group" data-behavior="theme-toggle" role="group" aria-label="Farbschema">
<button type="button" class="btn btn--sm btn--ghost" data-theme-option="system"
title="Systemeinstellung folgen" aria-pressed="true">{{ icon('monitor', size=14) }}</button>
<button type="button" class="btn btn--sm btn--ghost" data-theme-option="light"
title="Helles Design" aria-pressed="false">{{ icon('sun', size=14) }}</button>
<button type="button" class="btn btn--sm btn--ghost" data-theme-option="dark"
title="Dunkles Design" aria-pressed="false">{{ icon('moon', size=14) }}</button>
</div>
<span class="flex-1"></span>
<form method="post" action="{{ url_for('auth.logout') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost" type="submit" title="Abmelden">
{{ icon('log-out', size=15) }}<span class="sidebar__actions-label">Abmelden</span>
</button>
</form>
</div>
{% else %}
<a class="btn btn--primary btn--block" href="{{ url_for('auth.login') }}">Anmelden</a>
{% endif %}
</div>
</aside>
<div class="main">
<header class="topbar">
<button class="btn btn--icon btn--ghost" type="button" data-behavior="sidebar-toggle"
aria-label="Navigation ein-/ausklappen">{{ icon('menu', size=18) }}</button>
<div class="topbar__heading">
{% block breadcrumb %}{% endblock %}
<div class="topbar__title">{% block page_title %}{{ app_name }}{% endblock %}</div>
{% set sub %}{% block page_sub %}{% endblock %}{% endset %}
{% if sub | trim %}<div class="topbar__sub">{{ sub }}</div>{% endif %}
</div>
<div class="topbar__right">
{% block topbar_extra %}{% endblock %}
{% for pill in status_pills %}
<span class="status-pill {{ pill.css_class }}" {% if pill.title %}title="{{ pill.title }}"{% endif %}>
{% if pill.dot %}<span class="status-pill__dot {{ pill.dot_class }}"></span>{% endif %}
{{ pill.label }}
</span>
{% endfor %}
<button class="btn btn--icon btn--ghost hide-sm" type="button" data-behavior="open-palette"
aria-label="Schnellsuche (Strg+K)" title="Schnellsuche (Strg+K)">{{ icon('search', size=17) }}</button>
</div>
</header>
<main class="content" id="main-content">
{% block content %}{% endblock %}
</main>
<footer class="page-footer">
<span>{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} WiS GmbH</span>
<span class="row row--tight">
{% if migration_warning %}
<a class="danger-text" href="{{ url_for('diagnostics.overview') }}">Schemawarnung</a>
{% endif %}
<span class="faint">Angemeldet als {{ current_user.username or 'Gast' }}</span>
</span>
</footer>
</div>
</div>
{# -- Flash-Meldungen: als JSON, von app.js in Toasts verwandelt ----------- #}
{% with messages = get_flashed_messages(with_categories=true) %}
<script type="application/json" id="flashed-messages">{{ messages | tojson }}</script>
{% endwith %}
{# -- Befehlspalette ------------------------------------------------------- #}
<dialog class="palette" id="command-palette" aria-label="Schnellsuche">
<input class="palette__input" type="search" placeholder="Seite oder Aktion suchen ..."
aria-label="Suchbegriff" autocomplete="off">
<div class="palette__list"></div>
<div class="palette__hint">
<span><kbd>&uarr;</kbd><kbd>&darr;</kbd> waehlen</span>
<span><kbd>Enter</kbd> oeffnen</span>
<span><kbd>Esc</kbd> schliessen</span>
</div>
</dialog>
<script type="application/json" id="palette-data">{{ palette_entries | tojson }}</script>
{# -- Bestaetigungsdialog (ersetzt window.confirm) ------------------------- #}
<dialog class="modal modal--narrow" id="confirm-dialog" aria-labelledby="confirm-title">
<div class="modal__header">
<div class="modal__title" id="confirm-title" data-confirm-title>Bitte bestaetigen</div>
</div>
<div class="modal__body"><p data-confirm-text></p></div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--danger" type="button" data-confirm-ok>Fortfahren</button>
</div>
</dialog>
{% block dialogs %}{% endblock %}
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
@@ -0,0 +1,190 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import alert, pill, stat %}
{% block page_title %}Diagnose{% endblock %}
{% block page_sub %}Schemastand, Protokollintegritaet, Schluessel und Systemzugriff{% endblock %}
{% block content %}
<div class="grid grid--4">
{{ stat('Benutzer', counts['Benutzer']) }}
{{ stat('Gruppen', counts['Gruppen']) }}
{{ stat('Aktive Sitzungen', counts['Aktive Sitzungen']) }}
{{ stat('Protokollzeilen', counts['Protokollzeilen']) }}
</div>
<div class="grid grid--2">
<section class="card {{ 'card--danger' if not chain.ok }}">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield', size=17) }} Aenderungsprotokoll
{% if chain.ok %}{{ pill('unversehrt', 'online') }}{% else %}{{ pill('Kette gebrochen', 'danger') }}{% endif %}
</div>
<div class="card__sub">{{ chain.checked }} Zeilen geprueft (Hash-Kette)</div>
</div>
</div>
<div class="card__body">
{% if chain.ok %}
<p class="muted fs-sm">
Jede Zeile bindet den Hash ihrer Vorgaengerin ein. Eine nachtraeglich entfernte oder
geaenderte Zeile wuerde hier auffallen.
</p>
{% else %}
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
'Manipulation oder Datenverlust') }}
{% endif %}
</div>
</section>
<section class="card {{ 'card--danger' if key_problems }}">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('key', size=17) }} Schluessel und Dateirechte
{% if key_problems %}{{ pill('Problem', 'danger') }}{% else %}{{ pill('in Ordnung', 'online') }}{% endif %}
</div>
<div class="card__sub">Aktiver Datenschluessel: {{ keystore_active }} (vorhanden: {{ keystore_keys | join(', ') }})</div>
</div>
{% if current_user.is_admin %}
<form method="post" action="{{ url_for('diagnostics.rotate_keys') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit"
data-confirm="Neuen Datenschluessel anlegen und gespeicherte Geheimnisse neu verschluesseln? Der alte Schluessel bleibt zum Lesen erhalten."
data-confirm-title="Schluessel rotieren" data-confirm-ok="Rotieren" data-confirm-style="primary">
{{ icon('refresh', size=14) }} Rotieren
</button>
</form>
{% endif %}
</div>
<div class="card__body">
{% if key_problems %}
<ul class="stack stack--tight fs-sm danger-text">
{% for problem in key_problems %}<li>{{ problem }}</li>{% endfor %}
</ul>
{% else %}
<p class="muted fs-sm">
Alle Schluesseldateien liegen mit Modus 0600. Feldverschluesselung ist AES-256-GCM mit
rotierbarem Schluesselbund; jedes Chiffrat ist an seinen Verwendungszweck gebunden.
</p>
{% endif %}
</div>
</section>
</div>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('database', size=17) }} Schemastand</div>
<div class="card__sub">
{{ db_path }} &middot; {{ db_size | bytes }} &middot; {{ migrations | length }} angewendete Migrationen
</div>
</div>
</div>
<div class="table-wrap">
<table class="table table--compact" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Komponente</th>
<th class="sortable numeric">Version</th>
<th class="sortable">Migration</th>
<th>Pruefsumme</th>
<th class="sortable">Angewendet</th>
</tr>
</thead>
<tbody>
{% for entry in migrations %}
<tr>
<td class="fw-600">{{ entry.component }}</td>
<td class="numeric">{{ entry.version }}</td>
<td>{{ entry.name }}</td>
<td class="mono fs-xs faint">{{ entry.checksum }}</td>
<td class="fs-sm">{{ entry.applied_at | dt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<div class="grid grid--2">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('server', size=17) }} Systemzugriff
{% if not sysops_available %}{{ pill('nicht verfuegbar', 'warning') }}
{% elif sysops_selftest and sysops_selftest.ok %}{{ pill('Helfer antwortet', 'online') }}
{% else %}{{ pill('Helfer antwortet nicht', 'danger') }}{% endif %}
</div>
<div class="card__sub">{{ sysops_helper or 'kein Pfad konfiguriert' }}</div>
</div>
</div>
<div class="card__body">
<p class="muted fs-sm">
Der Webprozess laeuft unprivilegiert. Alles, was Root-Rechte braucht, geht durch genau
ein Helferskript mit Positivliste &ndash; nicht durch die Anwendung selbst.
</p>
{% if not sysops_available %}
{{ alert('Ohne Helfer sind Systemaktionen (Dienste, Netzwerk, Zertifikate) deaktiviert.
Alles andere funktioniert normal -- das ist der erwartete Zustand in einer
Entwicklungsumgebung.', 'info') }}
{% elif sysops_selftest and not sysops_selftest.ok %}
{# Geprueft wird ein echter Aufruf, nicht die blosse Existenz der Datei.
Genau daran lag es einmal: die Seite meldete "erreichbar", waehrend
jeder Aufruf scheiterte. #}
{{ alert(sysops_selftest.message or 'Der Helfer laesst sich nicht aufrufen.',
'danger', 'Systemaktionen funktionieren nicht') }}
{% endif %}
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('info', size=17) }} Laufzeit</div></div>
{% if current_user.is_admin %}
<form method="post" action="{{ url_for('diagnostics.housekeeping') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('refresh', size=14) }} Aufraeumen</button>
</form>
{% endif %}
</div>
<div class="card__body">
<dl class="kv fs-sm">
{% for label, value in runtime.items() %}
<dt>{{ label }}</dt><dd>{{ value }}</dd>
{% endfor %}
<dt>Protokollverzeichnis</dt><dd class="mono">{{ log_dir }}</dd>
</dl>
</div>
</section>
</div>
{% if license_state %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('key', size=17) }} Lizenzzustand
{% if license_state.valid and license_state.activated %}{{ pill('aktiv', 'online') }}
{% elif license_state.valid %}{{ pill('nicht aktiviert', 'warning') }}
{% else %}{{ pill('ungueltig', 'danger') }}{% endif %}
</div>
</div>
</div>
<div class="card__body">
<dl class="kv fs-sm">
<dt>Datei vorhanden</dt><dd>{{ license_state.present | yes_no }}</dd>
<dt>Signatur gueltig</dt><dd>{{ license_state.valid | yes_no }}</dd>
<dt>Aussteller gepinnt</dt><dd>{{ license_state.issuer_pinned | yes_no }}</dd>
<dt>Rolle</dt><dd>{{ license_state.role }}</dd>
<dt>Module</dt><dd>{{ license_state.modules | sort | join(', ') or '&ndash;' | safe }}</dd>
<dt>Letzter Heartbeat</dt><dd>{{ license_state.last_heartbeat_at | dt }}</dd>
{% if license_state.error %}<dt>Fehler</dt><dd class="danger-text">{{ license_state.error }}</dd>{% endif %}
{% if license_state.warnings %}
<dt>Hinweise</dt><dd class="warning-text">{{ license_state.warnings | join('; ') }}</dd>
{% endif %}
</dl>
</div>
</section>
{% endif %}
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}{{ code }} &ndash; {{ name }}{% endblock %}
{% block content %}
<div class="alert alert--{{ 'danger' if code >= 500 else 'warning' }}">
{{ icon('alert', size=18) }}
<div class="alert__body">{{ description }}</div>
</div>
{% if request_id %}
<p class="fs-xs faint center">Vorgangsnummer fuer die Fehlersuche: <span class="mono">{{ request_id }}</span></p>
{% endif %}
<div class="row row--end">
<a class="btn" href="{{ url_for('auth.login') if code in (401, 403) and not current_user.is_authenticated else back_url }}">
{{ 'Zur Anmeldung' if code in (401, 403) and not current_user.is_authenticated else 'Zurueck' }}
</a>
</div>
{% endblock %}
@@ -0,0 +1,122 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, alert, permission_matrix, empty_state %}
{% block page_title %}{{ group.name }}{% endblock %}
{% block page_sub %}{{ group.member_count }} Mitglied{{ '' if group.member_count == 1 else 'er' }} &middot; {{ group.permissions | length }} Rechte{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb">
<span><a href="{{ url_for('admin.groups') }}">Gruppen</a></span>
<span>{{ group.name }}</span>
</nav>
{% endblock %}
{% block content %}
{% set locked = group.is_system and not unlocked %}
{% if group.is_system %}
{% if locked %}
<div class="alert alert--warning">
{{ icon('lock', size=18) }}
<div class="alert__body">
<div class="alert__title">Systemgruppe</div>
Name und Rechte sind gesperrt. Anders als im Vorgaenger werden die Rechte
<strong>nicht</strong> bei jedem Neustart auf die Vorlage zurueckgesetzt &ndash;
Anpassungen bleiben erhalten.
{% if current_user.has('groups.edit') %}
<a class="btn btn--sm mt-2" href="{{ url_for('admin.group_detail', group_id=group.id, freischalten=1) }}">
{{ icon('unlock', size=14) }} Rechte freischalten
</a>
{% endif %}
</div>
</div>
{% else %}
{{ alert('Die Rechte dieser Systemgruppe sind zur Bearbeitung freigeschaltet.', 'warning') }}
{% endif %}
{% endif %}
<form method="post" action="{{ url_for('admin.group_update', group_id=group.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
{% if not locked %}<input type="hidden" name="permissions_submitted" value="1">{% endif %}
{% if unlocked %}<input type="hidden" name="unlock_system" value="1">{% endif %}
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('settings', size=17) }} Stammdaten</div>
</div>
{% if group.preset_key and current_user.is_admin %}
<button class="btn btn--sm" type="submit"
formaction="{{ url_for('admin.group_apply_preset', group_id=group.id) }}"
data-confirm="Rechte dieser Gruppe auf die Vorlage zuruecksetzen? Bestehende Anpassungen gehen verloren."
data-confirm-title="Vorlage anwenden" data-confirm-ok="Zuruecksetzen" data-confirm-style="primary">
{{ icon('refresh', size=14) }} Vorlage anwenden
</button>
{% endif %}
</div>
<div class="card__body">
<div class="form-grid">
{{ field('name', 'Name', value=group.name, required=True, disabled=group.is_system,
hint_text='Systemgruppen koennen nicht umbenannt werden.' if group.is_system else '') }}
{{ textarea_field('description', 'Beschreibung', value=group.description, rows=2,
extra_class='field--full') }}
</div>
</div>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Rechtematrix</div>
<div class="card__sub">
Der Bereichsschalter ist ein Hauptschalter: ohne ihn greift kein Recht des Bereichs.
Jede Aktion setzt &bdquo;Ansehen&ldquo; derselben Zeile voraus.
</div>
</div>
</div>
<div class="card__body">
{{ permission_matrix(tree, action_meta, state, disabled=locked or not can_edit) }}
</div>
{% if can_edit and not locked %}
<div class="card__footer">
<a class="btn" href="{{ url_for('admin.groups') }}">Abbrechen</a>
<button class="btn btn--primary" type="submit">Rechte speichern</button>
</div>
{% endif %}
</section>
</div>
</form>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('users', size=17) }} Mitglieder</div>
<div class="card__sub">Zuordnung erfolgt in der Benutzerverwaltung</div>
</div>
{% if current_user.has('users.view') %}
<a class="btn btn--sm" href="{{ url_for('admin.users') }}">Benutzerverwaltung</a>
{% endif %}
</div>
{% if members %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Benutzer</th><th>Name</th><th>Quelle</th></tr></thead>
<tbody>
{% for member in members %}
<tr>
<td class="fw-600">{{ member.username }}
{% if member.is_admin %}{{ pill('Admin', 'accent') }}{% endif %}
</td>
<td>{{ (member.first_name ~ ' ' ~ member.last_name) | trim or '&ndash;' | safe }}</td>
<td>{{ 'Verzeichnis' if member.auth_source == 'ldap' else 'Lokal' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Mitglieder', 'Diese Gruppe ist noch niemandem zugeordnet.', 'users') }}
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,85 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, textarea_field, pill, modal, alert, permission_matrix, empty_state %}
{% block page_title %}Gruppen und Rechte{% endblock %}
{% block page_sub %}{{ groups | length }} Gruppen &middot; Rechte addieren sich ueber alle Gruppen eines Benutzers{% endblock %}
{% block topbar_extra %}
{% if current_user.has('groups.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="group-new">
{{ icon('plus', size=15) }} Gruppe anlegen
</button>
{% endif %}
{% endblock %}
{% block content %}
{{ alert('Wer selbst ein Recht nicht besitzt, kann es auch nicht vergeben. Diese Regel gilt fuer
alle ausser echten Administratoren und verhindert, dass sich ein Gruppenverwalter ueber eine
neue Gruppe selbst hoeherstuft.', 'info', 'Eskalationsschutz') }}
<div class="grid grid--2">
{% for group in groups %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">
{{ icon('shield-users', size=17) }}
<a href="{{ url_for('admin.group_detail', group_id=group.id) }}">{{ group.name }}</a>
{% if group.is_system %}{{ pill('System', 'accent') }}{% endif %}
{% if group.is_default %}{{ pill('Standard', 'info') }}{% endif %}
</div>
<div class="card__sub">
{{ group.description or 'Ohne Beschreibung' }} &middot;
{{ group.member_count }} Mitglied{{ '' if group.member_count == 1 else 'er' }}
</div>
</div>
{% if current_user.has('groups.delete') and not group.is_system %}
<form method="post" action="{{ url_for('admin.group_delete', group_id=group.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Gruppe {{ group.name }} loeschen? Sie landet im Papierkorb; Mitglieder verlieren die zugehoerigen Rechte."
data-confirm-title="Gruppe loeschen" data-confirm-ok="Loeschen"
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</div>
<div class="card__body">
{% if summaries[group.id] %}
<ul class="stack stack--tight fs-sm">
{% for line in summaries[group.id] %}<li class="muted">{{ line }}</li>{% endfor %}
</ul>
{% else %}
<p class="faint fs-sm">Keine Rechte vergeben &ndash; Mitglieder sehen nur ihr eigenes Konto.</p>
{% endif %}
</div>
<div class="card__footer">
<a class="btn btn--sm" href="{{ url_for('admin.group_detail', group_id=group.id) }}">
Rechte bearbeiten
</a>
</div>
</section>
{% endfor %}
</div>
{% if not groups %}
{{ empty_state('Keine Gruppen', 'Ohne Gruppen hat niemand ausser Administratoren Rechte.', 'shield-users') }}
{% endif %}
{% endblock %}
{% block dialogs %}
{% if current_user.has('groups.create') %}
{% call modal('group-new', 'Gruppe anlegen', 'Rechte koennen anschliessend feiner eingestellt werden', 'wide') %}
<form method="post" action="{{ url_for('admin.group_create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
{{ field('name', 'Name', required=True) }}
{{ textarea_field('description', 'Beschreibung', rows=2) }}
{{ permission_matrix(tree, action_meta, {}, disabled=False) }}
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Anlegen</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endblock %}
@@ -0,0 +1,29 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}Anmelden{% endblock %}
{% block subheading %}{{ app_name }} &ndash; {{ app_edition }}{% endblock %}
{% block content %}
<form method="post" class="stack" data-no-guard autocomplete="on">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">Benutzername</span>
<input class="input" type="text" name="username" value="{{ username }}" required autofocus
autocomplete="username" autocapitalize="none" spellcheck="false">
</label>
<label class="field">
<span class="field__label">Passwort</span>
<input class="input" type="password" name="password" required autocomplete="current-password">
</label>
<button class="btn btn--primary btn--lg btn--block" type="submit" data-busy-label="Anmelden ...">
Anmelden
</button>
</form>
{% if directory_enabled %}
<p class="fs-xs faint center">
Verzeichniskonten melden sich mit ihrem gewohnten Anmeldenamen und Netzwerkpasswort an.
</p>
{% endif %}
{% if public_dashboard_url %}
<p class="center fs-sm"><a href="{{ public_dashboard_url }}">Zur oeffentlichen Statusuebersicht</a></p>
{% endif %}
{% endblock %}
@@ -0,0 +1,30 @@
{% extends "tesm_core/base.html" %}
{% block page_title %}Archiv {{ day }}{% endblock %}
{% block page_sub %}{{ rows | length }} archivierte Aenderungen{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ day }}</span></nav>
{% endblock %}
{% block content %}
<section class="card">
<div class="table-wrap">
<table class="table table--compact" data-behavior="sortable">
<thead><tr>
<th class="sortable">Zeit</th><th class="sortable">Benutzer</th>
<th class="sortable">Aktion</th><th>Ziel</th><th>Detail</th><th>Hash</th>
</tr></thead>
<tbody>
{% for row in rows %}
<tr>
<td class="fs-sm nowrap">{{ row.ts | dt }}</td>
<td class="fs-sm">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted">{{ row.detail }}</td>
<td class="mono fs-xs faint">{{ row.hash[:12] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}
@@ -0,0 +1,94 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import pill, empty_state, alert %}
{% block page_title %}Aenderungsprotokoll{% endblock %}
{% block page_sub %}{{ total }} Eintraege &middot; Hash-Kette {{ 'unversehrt' if chain.ok else 'gebrochen' }}{% endblock %}
{% block topbar_extra %}
{% if current_user.has('logs_audit.export') %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_export') }}">{{ icon('download', size=15) }} CSV</a>
{% endif %}
{% endblock %}
{% block content %}
{% if not chain.ok %}
{{ alert(chain.reason ~ ' Erster Bruch bei Eintrag ' ~ chain.broken_at ~ '.', 'danger',
'Das Protokoll wurde veraendert') }}
{% else %}
{{ alert('Jeder Eintrag bindet den Hash seines Vorgaengers ein. Eine nachtraeglich entfernte
oder geaenderte Zeile faellt bei der Pruefung auf -- ' ~ chain.checked ~ ' Zeilen geprueft.',
'success') }}
{% endif %}
<section class="card">
<form method="get" class="table-toolbar" data-no-guard>
<span class="search-input">
{{ icon('search', size=15) }}
<input class="input" type="search" name="q" value="{{ filters.q }}" placeholder="Ziel, Detail, Aktion ...">
</span>
<select class="select" name="aktion" style="width:auto;max-width:240px">
<option value="">alle Aktionen</option>
{% for action in actions %}
<option value="{{ action }}" {% if action == filters.aktion %}selected{% endif %}>{{ action }}</option>
{% endfor %}
</select>
<select class="select" name="stufe" style="width:auto">
<option value="">alle Stufen</option>
{% for level in ['info', 'notice', 'warning', 'critical'] %}
<option value="{{ level }}" {% if level == filters.stufe %}selected{% endif %}>{{ level }}</option>
{% endfor %}
</select>
<input class="input" type="text" name="benutzer" value="{{ filters.benutzer }}"
placeholder="Benutzer" style="width:150px">
<button class="btn btn--sm" type="submit">Filtern</button>
<a class="btn btn--sm btn--ghost" href="{{ url_for('logs.audit_view') }}">Zuruecksetzen</a>
</form>
<div class="table-wrap">
<table class="table table--compact" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Zeit</th>
<th class="sortable">Benutzer</th>
<th class="sortable">Aktion</th>
<th>Ziel</th>
<th>Detail</th>
<th class="sortable">Stufe</th>
<th>Herkunft</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td class="fs-sm nowrap" data-sort-value="{{ row.ts }}">{{ row.ts | dt }}</td>
<td class="fs-sm fw-600">{{ row.actor }}</td>
<td class="mono fs-xs">{{ row.action }}</td>
<td class="fs-sm">{{ row.target }}</td>
<td class="fs-sm muted break-all">{{ row.detail }}</td>
<td>
{% if row.severity == 'critical' %}{{ pill('kritisch', 'danger') }}
{% elif row.severity == 'warning' %}{{ pill('Warnung', 'warning') }}
{% elif row.severity == 'notice' %}{{ pill('Hinweis', 'info') }}
{% else %}{{ pill('Info', '') }}{% endif %}
</td>
<td class="mono fs-xs faint">{{ row.ip or '&ndash;' | safe }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not rows %}{{ empty_state('Keine Eintraege', 'Zu diesem Filter gibt es nichts.', 'history') }}{% endif %}
<div class="card__footer" style="justify-content:space-between">
<span class="fs-sm muted">Seite {{ page }}</span>
<span class="row row--tight">
{% if page > 1 %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page-1, **filters) }}">Zurueck</a>
{% endif %}
{% if rows | length == per_page %}
<a class="btn btn--sm" href="{{ url_for('logs.audit_view', seite=page+1, **filters) }}">Weiter</a>
{% endif %}
</span>
</div>
</section>
{% endblock %}
@@ -0,0 +1,12 @@
{% extends "tesm_core/base.html" %}
{% block page_title %}{{ name }}{% endblock %}
{% block page_sub %}Rotierte Protokolldatei &middot; letzte {{ lines | length }} Zeilen{% endblock %}
{% block breadcrumb %}
<nav class="breadcrumb"><span><a href="{{ url_for('logs.history') }}">Verlauf</a></span><span>{{ name }}</span></nav>
{% endblock %}
{% block content %}
<section class="card">
<div class="card__header"><div class="flex-1"><div class="card__title">{{ icon('file-text', size=17) }} Inhalt</div></div></div>
{% include "tesm_core/_log_lines.html" %}
</section>
{% endblock %}
@@ -0,0 +1,84 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import empty_state, alert %}
{% block page_title %}Protokollverlauf{% endblock %}
{% block page_sub %}{{ rotated | length }} rotierte Dateien &middot; {{ archived | length }} archivierte Audit-Tage{% endblock %}
{% block content %}
{{ alert('Rotiert wird per logrotate mit copytruncate -- keiner der schreibenden Prozesse
(Ueberwachung, Web, Kea) kann ein Reopen-Signal verarbeiten.', 'info') }}
<div class="grid grid--2">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Rotierte Dateien</div>
<div class="card__sub">{{ log_dir }}</div>
</div>
</div>
{% if rotated %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Datei</th><th class="numeric">Groesse</th><th>Geaendert</th></tr></thead>
<tbody>
{% for entry in rotated %}
<tr>
<td><a href="{{ url_for('logs.history_file', name=entry.name) }}">{{ entry.name }}</a></td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
<td class="fs-sm">{{ entry.modified | int | string }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Noch nichts rotiert', 'logrotate hat bisher keine Kopie angelegt.', 'clock') }}
{% endif %}
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('history', size=17) }} Archivierte Aenderungen</div>
<div class="card__sub">Aus der Datenbank ausgelagerte Kalendertage</div>
</div>
{% if archived and current_user.has('logs_history.export') %}
<form method="post" action="{{ url_for('logs.history_export') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">{{ icon('download', size=14) }} Als ZIP</button>
</form>
{% endif %}
</div>
{% if archived %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Tag</th><th class="numeric">Groesse</th><th></th></tr></thead>
<tbody>
{% for entry in archived %}
<tr>
<td><a href="{{ url_for('logs.history_archive', day=entry.day) }}">{{ entry.day }}</a></td>
<td class="numeric fs-sm">{{ entry.size | bytes }}</td>
<td class="fs-sm faint">{{ entry.modified | dt }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if current_user.has('logs_history.delete') %}
<div class="card__footer">
<form method="post" action="{{ url_for('logs.history_purge') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Alle {{ archived | length }} Archivdateien endgueltig vom Server loeschen? Bitte vorher als ZIP sichern."
data-confirm-title="Archiv loeschen" data-confirm-ok="Loeschen">
Archiv vom Server entfernen
</button>
</form>
</div>
{% endif %}
{% else %}
{{ empty_state('Kein Archiv', 'Es wurden noch keine Audit-Tage ausgelagert.', 'history') }}
{% endif %}
</section>
</div>
{% endblock %}
@@ -0,0 +1,33 @@
{% extends "tesm_core/base.html" %}
{% block page_title %}Laufendes Protokoll{% endblock %}
{% block page_sub %}{{ path }} &middot; letzte {{ line_count }} Zeilen{% endblock %}
{% block topbar_extra %}
<form method="get" class="row row--tight" data-no-guard>
<span class="search-input">
{{ icon('search', size=15) }}
<input class="input" type="search" name="q" value="{{ query }}" placeholder="Filter ...">
</span>
<select class="select" name="lines" style="width:auto">
{% for option in [200, 400, 1000, 2000] %}
<option value="{{ option }}" {% if option == line_count %}selected{% endif %}>{{ option }} Zeilen</option>
{% endfor %}
</select>
<button class="btn btn--sm" type="submit">Anwenden</button>
</form>
{% endblock %}
{% block content %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('terminal', size=17) }} Erreichbarkeit und PoE</div>
<div class="card__sub">Aktualisiert sich automatisch alle 10 Sekunden</div>
</div>
</div>
<div id="live-log" data-refresh-url="{{ url_for('logs.live_fragment', lines=line_count, q=query) }}"
data-refresh-interval="10000">
{% include "tesm_core/_log_lines.html" %}
</div>
</section>
{% endblock %}
@@ -0,0 +1,222 @@
{# ==========================================================================
Wiederverwendbare Bausteine. Import in Templates:
{% from "tesm_core/macros.html" import field, card_header, pill, ... %}
========================================================================== #}
{% macro csrf(token) -%}
<input type="hidden" name="csrf_token" value="{{ token }}">
{%- endmacro %}
{% macro hint(text) -%}
{% if text %}<span class="hint-icon" tabindex="0" title="{{ text }}" aria-label="Hinweis: {{ text }}">?</span>{% endif %}
{%- endmacro %}
{% macro field(name, label, value='', type='text', required=False, hint_text='', placeholder='',
autocomplete='', readonly=False, disabled=False, error='', extra_class='', attrs='') -%}
<label class="field {{ extra_class }}">
<span class="field__label">
{{ label }}{% if required %}<span class="req" aria-hidden="true">*</span>{% endif %}
{{ hint(hint_text) }}
</span>
<input class="input" type="{{ type }}" name="{{ name }}" value="{{ value if value is not none else '' }}"
{% if required %}required{% endif %}
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
{% if autocomplete %}autocomplete="{{ autocomplete }}"{% endif %}
{% if readonly %}readonly{% endif %}
{% if disabled %}disabled{% endif %}
{% if error %}aria-invalid="true"{% endif %} {{ attrs | safe }}>
{% if error %}<span class="field__error">{{ error }}</span>{% endif %}
</label>
{%- endmacro %}
{% macro select_field(name, label, options, selected='', hint_text='', required=False, disabled=False, extra_class='', attrs='') -%}
<label class="field {{ extra_class }}">
<span class="field__label">
{{ label }}{% if required %}<span class="req" aria-hidden="true">*</span>{% endif %}
{{ hint(hint_text) }}
</span>
<select class="select" name="{{ name }}" {% if required %}required{% endif %} {% if disabled %}disabled{% endif %} {{ attrs | safe }}>
{% for value, text in options %}
<option value="{{ value }}" {% if value|string == selected|string %}selected{% endif %}>{{ text }}</option>
{% endfor %}
</select>
</label>
{%- endmacro %}
{% macro textarea_field(name, label, value='', rows=4, hint_text='', placeholder='', extra_class='', mono=False) -%}
<label class="field {{ extra_class }}">
<span class="field__label">{{ label }}{{ hint(hint_text) }}</span>
<textarea class="textarea {% if mono %}input--mono{% endif %}" name="{{ name }}" rows="{{ rows }}"
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}>{{ value or '' }}</textarea>
</label>
{%- endmacro %}
{% macro checkbox(name, label, checked=False, value='1', hint_text='', disabled=False) -%}
<label class="check {% if disabled %}check--disabled{% endif %}">
<input type="checkbox" name="{{ name }}" value="{{ value }}"
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
<span>{{ label }} {{ hint(hint_text) }}</span>
</label>
{%- endmacro %}
{% macro switch(name, label, checked=False, value='1', disabled=False) -%}
<label class="switch">
<input type="checkbox" name="{{ name }}" value="{{ value }}"
{% if checked %}checked{% endif %} {% if disabled %}disabled{% endif %}>
<span>{{ label }}</span>
</label>
{%- endmacro %}
{% macro pill(label, kind='') -%}
<span class="pill {% if kind %}pill--{{ kind }}{% endif %}">{{ label }}</span>
{%- endmacro %}
{% macro badge(label) -%}<span class="badge">{{ label }}</span>{%- endmacro %}
{% macro card_header(title, sub='', icon_name='') -%}
<div class="card__header">
<div class="flex-1">
<div class="card__title">{% if icon_name %}{{ icon(icon_name, size=17) }}{% endif %}{{ title }}</div>
{% if sub %}<div class="card__sub">{{ sub }}</div>{% endif %}
</div>
{{ caller() if caller is defined else '' }}
</div>
{%- endmacro %}
{% macro empty_state(title, text='', icon_name='info', node_id='') -%}
<div class="empty" {% if node_id %}id="{{ node_id }}"{% endif %}>
{{ icon(icon_name, size=34) }}
<div class="empty__title">{{ title }}</div>
{% if text %}<p class="muted">{{ text }}</p>{% endif %}
{{ caller() if caller is defined else '' }}
</div>
{%- endmacro %}
{% macro alert(text, kind='info', title='') -%}
<div class="alert alert--{{ kind }}">
{{ icon({'info': 'info', 'success': 'check', 'warning': 'alert', 'danger': 'alert'}[kind], size=18) }}
<div class="alert__body">
{% if title %}<div class="alert__title">{{ title }}</div>{% endif %}
{{ text }}
</div>
</div>
{%- endmacro %}
{% macro search_box(target_id, placeholder='Suchen ...', count_id='', empty_id='') -%}
<span class="search-input">
{{ icon('search', size=15) }}
<input class="input" type="search" placeholder="{{ placeholder }}"
data-filter-target="{{ target_id }}"
{% if count_id %}data-filter-count="{{ count_id }}"{% endif %}
{% if empty_id %}data-filter-empty="{{ empty_id }}"{% endif %}
aria-label="{{ placeholder }}">
</span>
{%- endmacro %}
{% macro stat(label, value, kind='', hint_text='', href='', filter_value='') -%}
{% if href %}
<a class="stat {% if kind %}stat--{{ kind }}{% endif %}" href="{{ href }}">
{% elif filter_value %}
<button type="button" class="stat {% if kind %}stat--{{ kind }}{% endif %}"
data-filter-value="{{ filter_value }}" aria-pressed="false">
{% else %}
<div class="stat {% if kind %}stat--{{ kind }}{% endif %}">
{% endif %}
<span class="stat__label">{{ label }}</span>
<span class="stat__value">{{ value }}</span>
{% if hint_text %}<span class="stat__hint">{{ hint_text }}</span>{% endif %}
{% if href %}</a>{% elif filter_value %}</button>{% else %}</div>{% endif %}
{%- endmacro %}
{% macro modal(node_id, title, sub='', size='') -%}
<dialog class="modal {% if size %}modal--{{ size }}{% endif %}" id="{{ node_id }}"
aria-labelledby="{{ node_id }}-title">
<div class="modal__header">
<div class="flex-1">
<div class="modal__title" id="{{ node_id }}-title">{{ title }}</div>
{% if sub %}<div class="modal__sub">{{ sub }}</div>{% endif %}
</div>
<button class="btn btn--icon btn--ghost btn--sm" type="button" data-dialog-close
aria-label="Schliessen">{{ icon('x', size=15) }}</button>
</div>
{{ caller() }}
</dialog>
{%- endmacro %}
{% macro kv_row(label, value) -%}
<dt>{{ label }}</dt><dd>{{ value if value not in (None, '') else '&ndash;' | safe }}</dd>
{%- endmacro %}
{#- Rechtematrix: einmal geschrieben, in beiden Anwendungen benutzt -#}
{% macro permission_matrix(tree, action_meta, state, disabled=False, field_prefix='permissions') -%}
<div class="table-wrap">
<table class="matrix" data-behavior="permission-matrix">
{% for area in tree.areas %}
{% set columns = area.columns() %}
<thead>
<tr>
<th style="min-width:240px">{{ area.label }}</th>
{% for action in columns %}
<th title="{{ action_meta[action][1] }} &ndash; {{ action_meta[action][2] }}">
{{ action_meta[action][0] }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
<tr class="matrix__area">
<td class="matrix__killswitch">
<label class="check">
<input type="checkbox" name="{{ field_prefix }}" value="{{ area.view_key }}"
data-area-switch="{{ area.key }}"
{% if state.get(area.key, {}).get('view') %}checked{% endif %}
{% if disabled %}disabled{% endif %}>
<span>Zugang zum Bereich {{ area.label }}{% if area.hint %} {{ hint(area.hint) }}{% endif %}</span>
</label>
</td>
{% for action in columns %}<td></td>{% endfor %}
</tr>
{% for resource in area.resources %}
<tr data-area="{{ area.key }}">
<td>
<span class="matrix__resource-label">
{{ resource.label }}
{% if resource.hint %}<small>{{ resource.hint }}</small>{% endif %}
</span>
</td>
{% for action in columns %}
<td>
{% if resource.has(action) %}
<label class="check" title="{{ resource.action_hints.get(action) or action_meta[action][2] }}">
<input type="checkbox" name="{{ field_prefix }}"
value="{{ resource.permission(action) }}"
data-action="{{ action.value }}"
{% if state.get(resource.key, {}).get(action.value) %}checked{% endif %}
{% if disabled %}disabled{% endif %}>
<span class="sr-only">{{ resource.label }} {{ action_meta[action][1] }}</span>
</label>
{% else %}
{# Bewusst *kein* Eingabefeld: ein deaktiviertes Kaestchen hat das
JS beim Einschalten des Bereichs wieder freigegeben, es liess
sich anhaken, hatte aber kein name-Attribut -- es sah erteilt
aus und war nach dem Speichern weg. Was es nicht gibt, ist
hier deshalb gar kein Bedienelement. #}
<span class="matrix__na" title="Fuer diese Zeile nicht vorgesehen."></span>
<span class="sr-only">nicht vorgesehen</span>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
{% endfor %}
</table>
</div>
<div class="matrix__legend mt-3">
{% for action, meta in action_meta.items() %}
<span><b>{{ meta[0] }}</b> {{ meta[1] }} &ndash; {{ meta[2] }}</span>
{% endfor %}
<span><span class="matrix__na"></span> Fuer diese Zeile nicht vorgesehen &ndash;
die Spalte gehoert zu einer anderen Zeile desselben Bereichs.</span>
</div>
{%- endmacro %}
@@ -0,0 +1,25 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}Bitte bestaetigen{% endblock %}
{% block subheading %}Diese Aktion verlangt eine erneute Eingabe Ihres Passworts{% endblock %}
{% block content %}
<div class="alert alert--info">
{{ icon('info', size=18) }}
<div class="alert__body">
Sie greifen auf Geheimnisse oder einen Datenexport zu. Ein uebernommenes
Sitzungscookie allein reicht dafuer nicht aus &ndash; die Bestaetigung gilt
danach {{ reauth_minutes }} Minuten.
</div>
</div>
<form method="post" class="stack" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">Passwort von {{ current_user.username }}</span>
<input class="input" type="password" name="password" required autofocus autocomplete="current-password">
</label>
<div class="row row--end">
<a class="btn" href="{{ cancel_url }}">Abbrechen</a>
<button class="btn btn--primary" type="submit">Bestaetigen</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,20 @@
{% extends "tesm_core/standalone.html" %}
{% block heading %}Zweiter Faktor{% endblock %}
{% block subheading %}Code aus Ihrer Authenticator-App{% endblock %}
{% block content %}
<form method="post" class="stack" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<label class="field">
<span class="field__label">6-stelliger Code</span>
<input class="input input--mono" type="text" name="code" required autofocus
inputmode="numeric" autocomplete="one-time-code" maxlength="14"
placeholder="123456" style="letter-spacing:.22em;text-align:center;font-size:1.15rem">
<span class="field__hint">
Alternativ koennen Sie einen Ihrer Wiederherstellungscodes eingeben.
</span>
</label>
<button class="btn btn--primary btn--lg btn--block" type="submit">Bestaetigen</button>
</form>
<p class="center fs-sm"><a href="{{ url_for('auth.logout_get') }}">Abbrechen und zurueck zur Anmeldung</a></p>
{% endblock %}
@@ -0,0 +1,55 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import empty_state, alert, search_box %}
{% block page_title %}Aktive Sitzungen{% endblock %}
{% block page_sub %}{{ sessions | length }} laufende Sitzungen &middot; serverseitig gespeichert und jederzeit widerrufbar{% endblock %}
{% block topbar_extra %}{{ search_box('sessions-table', 'Benutzer oder IP ...') }}{% endblock %}
{% block content %}
{{ alert('Sitzungen liegen in der Datenbank, nicht im Cookie. Ein widerrufener Eintrag ist sofort
ungueltig -- auch wenn das Cookie noch existiert.', 'info') }}
<section class="card">
<div class="table-wrap">
<table class="table" id="sessions-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Benutzer</th>
<th class="sortable">Angemeldet</th>
<th class="sortable">Letzte Aktivitaet</th>
<th class="sortable">Laeuft ab</th>
<th class="sortable">IP</th>
<th>Client</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% for entry in sessions %}
<tr data-filter-text="{{ entry.username }} {{ entry.ip }} {{ entry.user_agent }}">
<td class="fw-600">{{ entry.username or 'anonym' }}</td>
<td class="fs-sm" data-sort-value="{{ entry.created_at }}">{{ entry.created_at | dt }}</td>
<td class="fs-sm" data-sort-value="{{ entry.last_seen_at }}">
<span data-relative-time="{{ entry.last_seen_at }}">{{ entry.last_seen_at | dt }}</span>
</td>
<td class="fs-sm" data-sort-value="{{ entry.absolute_expires_at }}">{{ entry.absolute_expires_at | dt }}</td>
<td class="mono fs-sm">{{ entry.ip or '&ndash;' | safe }}</td>
<td class="fs-xs faint truncate" style="max-width:260px" title="{{ entry.user_agent }}">
{{ entry.user_agent or '&ndash;' | safe }}
</td>
<td class="actions">
<form method="post" action="{{ url_for('admin.session_revoke', sid=entry.id) }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Sitzung von {{ entry.username or 'anonym' }} beenden?"
data-confirm-title="Sitzung beenden" data-confirm-ok="Beenden">Beenden</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not sessions %}{{ empty_state('Keine aktiven Sitzungen', '', 'activity') }}{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,167 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/_group_picker.html" import group_picker %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, alert, pill, empty_state %}
{% block page_title %}Verzeichnisdienst{% endblock %}
{% block page_sub %}
LDAP / Active Directory &middot;
{% if values.ldap_enabled %}aktiv{% else %}nicht aktiv{% endif %}
{% endblock %}
{% block content %}
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{{ alert('Same Sign-On, nicht Single Sign-On: der Benutzer tippt sein Verzeichnispasswort auf der
normalen Anmeldeseite ein. Es wird bei jeder Anmeldung live gegen das Verzeichnis geprueft und
niemals hier gespeichert.', 'info') }}
{% if values.ldap_security == 'none' %}
{{ alert('Die Verbindung ist unverschluesselt. Das Passwort geht dabei im Klartext ueber das
Netz -- nur fuer Testumgebungen vertretbar.', 'danger', 'Unverschluesselt') }}
{% elif not values.ldap_verify_cert %}
{{ alert('Das Serverzertifikat wird nicht geprueft. Damit schuetzt die Verschluesselung nur
gegen passives Mitlesen, nicht gegen einen Man-in-the-Middle.', 'warning',
'Zertifikat ungeprueft') }}
{% endif %}
<div class="grid grid--split">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('users', size=17) }} Verbindung</div></div>
{% if current_user.has('settings_directory.execute') %}
<form method="post" action="{{ url_for('hostsettings.directory_test') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit" data-busy-label="Teste ...">
{{ icon('activity', size=14) }} Verbindung testen
</button>
</form>
{% endif %}
</div>
<form method="post" action="{{ url_for('hostsettings.directory_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
{{ checkbox('ldap_enabled', 'Verzeichnisanmeldung aktiv', checked=values.ldap_enabled,
disabled=bool(license_hint)) }}
<div class="form-grid">
{{ field('ldap_host', 'Server', value=values.ldap_host, disabled=bool(license_hint),
placeholder='dc01.firma.local') }}
{{ field('ldap_port', 'Port', value=values.ldap_port, type='number',
disabled=bool(license_hint), attrs='id="ldap-port"') }}
{# Der Transport setzt den Port mit. Wer umstellt und den Port stehen
laesst, bekommt eine Verbindung, die auf gut Glueck scheitert --
und die Meldung ("Zeitlimit") sagt nichts ueber die Ursache. Ein
absichtlich abweichender Port bleibt erhalten. #}
{{ select_field('ldap_security', 'Transport', specs['ldap_security'].choices,
selected=values.ldap_security, disabled=bool(license_hint),
attrs='data-behavior="transport-port" data-port-target="#ldap-port"') }}
{{ field('ldap_timeout', 'Zeitlimit (s)', value=values.ldap_timeout, type='number',
disabled=bool(license_hint)) }}
{{ field('ldap_ca_file', 'CA-Zertifikat (Pfad)', value=values.ldap_ca_file,
disabled=bool(license_hint), extra_class='field--full',
hint_text='Leer = Systemvertrauensspeicher.') }}
{{ field('ldap_base_dn', 'Basis-DN', value=values.ldap_base_dn,
disabled=bool(license_hint), extra_class='field--full',
hint_text='Leer = automatisch per RootDSE ermitteln.') }}
{{ field('ldap_user_attribute', 'Anmeldeattribut', value=values.ldap_user_attribute,
disabled=bool(license_hint)) }}
{{ field('ldap_email_attribute', 'E-Mail-Attribut', value=values.ldap_email_attribute,
disabled=bool(license_hint)) }}
</div>
<div class="stack stack--tight">
{{ checkbox('ldap_verify_cert', 'Serverzertifikat pruefen', checked=values.ldap_verify_cert,
disabled=bool(license_hint)) }}
{{ checkbox('ldap_group_sync', 'Gruppen aus dem Verzeichnis uebernehmen',
checked=values.ldap_group_sync, disabled=bool(license_hint)) }}
{{ checkbox('ldap_allow_insecure', 'Unverschluesselte Verbindung ausdruecklich erlauben',
checked=values.ldap_allow_insecure, disabled=bool(license_hint),
hint_text=specs['ldap_allow_insecure'].hint) }}
</div>
<fieldset class="fieldset">
<legend>Dienstkonto fuer die Suche (nur Lesezugriff noetig)</legend>
<div class="form-grid">
{{ field('bind_username', 'Benutzer', value=bind_username, autocomplete='off',
disabled=not current_user.has('settings_directory.secrets')) }}
{{ field('bind_password', 'Passwort (leer = unveraendert)', type='password',
autocomplete='new-password',
disabled=not current_user.has('settings_directory.secrets')) }}
</div>
{% if not current_user.has('settings_directory.secrets') %}
<p class="fs-xs faint mt-2">
Zum Setzen des Bind-Passworts fehlt Ihnen das Recht &bdquo;Geheimnisse&ldquo;.
</p>
{% endif %}
</fieldset>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" {% if license_hint %}disabled{% endif %}>
Speichern
</button>
</div>
</form>
</section>
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield-users', size=17) }} Gruppen-Zuordnung</div>
<div class="card__sub">
Verzeichnisgruppe &rarr; Gruppe in dieser Anwendung, bei jeder Anmeldung neu ausgewertet
</div>
</div>
</div>
{% if mappings %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Verzeichnisgruppe</th><th>Gruppe hier</th><th class="actions"></th></tr></thead>
<tbody>
{% for entry in mappings %}
<tr>
<td class="fs-sm">{{ entry.ad_group_name }}
<div class="fs-xs faint break-all">{{ entry.ad_group_dn }}</div></td>
<td>{{ pill(entry.group_name or 'unbekannt', 'accent') }}</td>
<td class="actions">
<form method="post" data-no-guard
action="{{ url_for('hostsettings.directory_mapping_delete', mapping_id=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Zuordnung entfernen? Betroffene Benutzer verlieren die Gruppe bei der naechsten Anmeldung."
data-confirm-title="Zuordnung entfernen" data-confirm-ok="Entfernen">
{{ icon('trash', size=14) }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Zuordnungen',
'Ohne Zuordnung erhalten Verzeichniskonten nur die Standardgruppe.', 'shield-users') }}
{% endif %}
{% if current_user.has('settings_directory.edit') %}
<form method="post" action="{{ url_for('hostsettings.directory_mapping_add') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body">
<div class="form-grid">
{{ group_picker('ad_group_name', 'ad_group_dn', 'mapping', required=True) }}
{# Ohne Ziel ist die Zuordnung sinnlos -- deshalb Pflichtfeld. #}
<label class="field">
<span class="field__label">
Gruppe in dieser Anwendung<span class="req" aria-hidden="true">*</span>
</span>
<select class="select" name="app_group_id" required>
{% for group in groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
</label>
</div>
</div>
<div class="card__footer"><button class="btn" type="submit">Zuordnung speichern</button></div>
</form>
{% endif %}
</section>
</div>
{% endblock %}
@@ -0,0 +1,108 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, alert, pill, empty_state %}
{% block page_title %}Netzwerkeinstellungen{% endblock %}
{% block page_sub %}Backend: {{ backend }} &middot; Sicherheitsnetz {{ revert_seconds }} s{% endblock %}
{% block content %}
{{ alert('Nach einer Aenderung laeuft ein Zeitgeber. Bestaetigen Sie nicht innerhalb von
' ~ revert_seconds ~ ' Sekunden von der neuen Adresse aus, wird automatisch
zurueckgerollt. So sperrt eine falsche Eingabe den Host nicht aus.', 'warning',
'Automatischer Rueckfall') }}
{% if pending %}
<section class="card card--danger">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('alert', size=17) }} Aenderung wartet auf Bestaetigung</div>
<div class="card__sub">Interface {{ pending.interface }}</div>
</div>
</div>
<div class="card__body">
<form method="post" action="{{ url_for('hostsettings.network_confirm') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary" type="submit">Verbindung bestaetigen</button>
</form>
</div>
</section>
{% endif %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- die Anzeige ist rein lesend.', 'info') }}
{% endif %}
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('network', size=17) }} Interfaces</div></div>
</div>
{% if interfaces %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Name</th><th>Zustand</th><th>Adressen</th><th>Gateway</th><th>DNS</th><th>MAC</th></tr></thead>
<tbody>
{% for entry in interfaces %}
<tr>
<td class="fw-600 mono">{{ entry.name }}</td>
<td>{% if entry.state == 'UP' %}{{ pill('UP', 'online') }}{% else %}{{ pill(entry.state, '') }}{% endif %}</td>
<td class="mono fs-sm">{{ entry.addresses | join(', ') or '&ndash;' | safe }}</td>
<td class="mono fs-sm">{{ entry.gateway or '&ndash;' | safe }}</td>
<td class="mono fs-sm">{{ entry.dns | join(', ') or '&ndash;' | safe }}</td>
<td class="mono fs-xs faint">{{ entry.mac }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Keine Interfaces gelesen', 'Das System gibt hier nichts zurueck.', 'network') }}
{% endif %}
</section>
{% if current_user.has('settings_network.edit') and interfaces %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('sliders', size=17) }} Aendern</div>
<div class="card__sub">
Es wird nur der Block des gewaehlten Interfaces geschrieben (Datei
<span class="mono">{{ netplan_path }}</span>) &ndash; andere Interfaces bleiben
unberuehrt.
</div>
</div>
</div>
<form method="post" action="{{ url_for('hostsettings.network_apply') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<div class="form-grid">
<label class="field">
<span class="field__label">Interface</span>
<select class="select" name="interface" required>
{% for entry in interfaces %}<option value="{{ entry.name }}">{{ entry.name }}</option>{% endfor %}
</select>
</label>
<label class="field">
<span class="field__label">Verfahren</span>
<select class="select" name="mode">
<option value="dhcp">Adresse per DHCP beziehen</option>
<option value="static">Feste Adresse</option>
</select>
</label>
{{ field('address', 'IP-Adresse', placeholder='192.168.1.10') }}
{{ field('prefix', 'Praefixlaenge', value='24', type='number') }}
{{ field('gateway', 'Gateway', placeholder='192.168.1.1') }}
{{ field('dns', 'DNS-Server', placeholder='192.168.1.1, 9.9.9.9') }}
</div>
{{ alert('Das Gateway muss im angegebenen Netz liegen -- sonst waere der Host nach der
Aenderung nicht mehr erreichbar. Das wird vor dem Schreiben geprueft.', 'info') }}
</div>
<div class="card__footer">
<button class="btn btn--danger" type="submit"
data-confirm="Netzwerkeinstellungen jetzt uebernehmen? Die Verbindung kann kurz abbrechen. Ohne Bestaetigung wird nach {{ revert_seconds }} Sekunden zurueckgerollt."
data-confirm-title="Netzwerk aendern" data-confirm-ok="Uebernehmen">
Uebernehmen
</button>
</div>
</form>
</section>
{% endif %}
{% endblock %}
@@ -0,0 +1,180 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, checkbox, alert, pill %}
{% block page_title %}Webserver und TLS{% endblock %}
{% block page_sub %}
{{ config.domain or 'ohne Domain' }} &middot;
{% if config.https_enabled %}HTTPS aktiv{% else %}nur HTTP{% endif %}
{% endblock %}
{% block content %}
{% if license_hint %}{{ alert(license_hint, 'warning') }}{% endif %}
{% if not sysops_available %}
{{ alert('Systemhelfer nicht verfuegbar -- die Konfiguration kann hier erzeugt und angesehen,
aber nicht angewandt werden.', 'info') }}
{% endif %}
{{ alert('Die Site-Datei wird bei jeder Uebernahme vollstaendig neu geschrieben.
Alles, was ein Administrator sonst per Hand hineinschreiben wuerde, ist hier Einstellung. Der
Vorgaenger schrieb die Datei nur, wenn sie noch nicht existierte -- ein einmal falscher Pfad
blieb dadurch ueber Jahre bestehen und lieferte alle statischen Dateien als 404.', 'info',
'Warum die Datei immer neu geschrieben wird') }}
<div class="grid grid--split">
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1"><div class="card__title">{{ icon('server', size=17) }} Einstellungen</div></div>
</div>
<form method="post" action="{{ url_for('hostsettings.web_save') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="card__body stack">
<div class="form-grid">
{{ field('web_domain', 'Domain', value=config.domain,
placeholder='tesm.firma.de', disabled=bool(license_hint),
hint_text='Leer = Zugriff ueber IP-Adresse.') }}
{{ field('web_acme_email', 'E-Mail fuer Let\'s Encrypt', value=acme_email, type='email',
disabled=bool(license_hint)) }}
{{ field('web_cert_path', 'Zertifikat (Pfad)', value=config.cert_path,
disabled=bool(license_hint), extra_class='field--full') }}
{{ field('web_key_path', 'Privater Schluessel (Pfad)', value=config.key_path,
disabled=bool(license_hint), extra_class='field--full') }}
{{ field('web_acme_webroot', 'ACME-Webroot', value=config.acme_webroot,
disabled=bool(license_hint), extra_class='field--full') }}
</div>
<div class="stack stack--tight">
{{ checkbox('web_https_enabled', 'HTTPS auf Port 443 anbieten',
checked=config.https_enabled, disabled=bool(license_hint)) }}
{{ checkbox('web_redirect_http', 'HTTP (Port 80) auf HTTPS umleiten',
checked=config.redirect_http, disabled=bool(license_hint),
hint_text='Der ACME-Pfad bleibt dabei erreichbar -- sonst koennte Let\'s
Encrypt nie erneuern.') }}
{{ checkbox('web_hsts_enabled', 'HSTS-Header senden',
checked=config.hsts_enabled, disabled=bool(license_hint),
hint_text='Nur mit funktionierendem HTTPS aktivieren: Browser merken sich
die Vorgabe und verweigern danach jeden HTTP-Zugriff.') }}
</div>
</div>
<div class="card__footer">
<button class="btn btn--primary" type="submit" {% if license_hint %}disabled{% endif %}>
Speichern
</button>
</div>
</form>
</section>
<details class="card" data-persist="nginx-preview">
<summary class="card__header" style="cursor:pointer">
<div class="flex-1">
<div class="card__title">{{ icon('file-text', size=17) }} Erzeugte nginx-Konfiguration</div>
<div class="card__sub">Vorschau der Datei, die geschrieben wuerde</div>
</div>
{{ icon('chevron-down', size=16) }}
</summary>
<pre class="log-view">{{ preview }}</pre>
</details>
</div>
<div class="stack">
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('shield', size=17) }} Zertifikat</div>
<div class="card__sub">
{% if certificate.present %}vorhanden{% else %}nicht vorhanden{% endif %}
</div>
</div>
</div>
<div class="card__body stack">
{% if certificate.present and not certificate.get('error') %}
<dl class="kv fs-sm">
<dt>Inhaber</dt><dd class="break-all">{{ certificate.subject }}</dd>
<dt>Aussteller</dt><dd class="break-all">{{ certificate.issuer }}</dd>
<dt>Gueltig bis</dt><dd>{{ certificate.not_valid_after | dt }}</dd>
<dt>Seriennummer</dt><dd class="mono fs-xs break-all">{{ certificate.serial }}</dd>
</dl>
{% elif certificate.get('error') %}
{{ alert('Zertifikat nicht lesbar: ' ~ certificate.error, 'danger') }}
{% else %}
<p class="muted fs-sm">
Fordern Sie ein Zertifikat per Let's Encrypt an oder hinterlegen Sie eigene Pfade.
</p>
{% endif %}
{% if current_user.has('settings_web.execute') %}
<form method="post" action="{{ url_for('hostsettings.web_certificate') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit"
{% if license_hint or not certbot_available or not config.domain %}disabled{% endif %}
data-busy-label="Fordere an ..."
data-confirm="Zertifikat fuer {{ config.domain }} per Let's Encrypt anfordern? Die Domain muss oeffentlich auf diesen Host zeigen."
data-confirm-title="Zertifikat anfordern" data-confirm-ok="Anfordern"
data-confirm-style="primary">
{{ icon('lock', size=14) }} Let's-Encrypt-Zertifikat anfordern
</button>
</form>
{% if not certbot_available %}
<p class="fs-xs faint">certbot ist auf diesem Host nicht installiert.</p>
{% elif not config.domain %}
<p class="fs-xs faint">
Fuer Let's Encrypt wird oben eine Domain gebraucht, die oeffentlich auf
diesen Host zeigt.
</p>
{% endif %}
<hr>
<p class="muted fs-sm">
Kein oeffentlicher Name? Dann ein eigenes Zertifikat erzeugen. Browser
zeigen dafuer eine Warnung, die Verbindung ist aber verschluesselt --
und das Sitzungscookie darf endlich <span class="mono">Secure</span> tragen.
</p>
{% if local_names %}
<p class="fs-xs faint">
Wird ausgestellt auf:
<span class="mono">{{ (config.domain or local_names[0]) }}</span>
{% if local_names|length > 1 %}
(zusaetzlich {{ local_names[1:] | join(', ') }})
{% endif %}
</p>
{% endif %}
<form method="post" action="{{ url_for('hostsettings.web_self_signed') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit"
{% if license_hint or not sysops_available %}disabled{% endif %}
data-busy-label="Erzeuge ..."
data-confirm="Ein selbstsigniertes Zertifikat erzeugen und HTTPS aktivieren? Ein bereits vorhandenes selbstsigniertes Zertifikat dieser Installation wird ersetzt."
data-confirm-title="Selbstsigniertes Zertifikat"
data-confirm-ok="Erzeugen" data-confirm-style="primary">
{{ icon('shield', size=14) }} Selbstsigniertes Zertifikat erzeugen
</button>
</form>
{% endif %}
</div>
</section>
{% if current_user.has('settings_web.execute') %}
<section class="card card--accent">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon('upload', size=17) }} Uebernehmen</div>
<div class="card__sub">Schreiben, <span class="mono">nginx -t</span>, dann neu laden</div>
</div>
</div>
<div class="card__body">
<form method="post" action="{{ url_for('hostsettings.web_apply') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--primary btn--block" type="submit"
{% if license_hint or not sysops_available %}disabled{% endif %}
data-busy-label="Uebernehme ..."
data-confirm="Konfiguration schreiben, pruefen und nginx neu laden? Eine fehlerhafte Konfiguration wird vorher abgewiesen."
data-confirm-title="nginx uebernehmen" data-confirm-ok="Uebernehmen"
data-confirm-style="primary">
Konfiguration uebernehmen
</button>
</form>
</div>
</section>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,80 @@
{# ==========================================================================
Grundgeruest fuer Seiten ohne Anwendungsrahmen: Anmeldung, zweiter Faktor,
Kundenportal, Self-Service.
Wichtiger Unterschied zu base.html: Flash-Meldungen werden hier **sichtbar
serverseitig** gerendert, nicht ueber die JSON-Bruecke. Der Vorgaenger hatte
dieselbe Trennung; sie ist wichtig, weil diese Seiten auch ohne JavaScript
nutzbar bleiben muessen (und weil Tests hier direkt auf Text pruefen).
========================================================================== #}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="robots" content="noindex, nofollow">
<title>{% block title %}{{ self.heading() }} &middot; {{ app_name }}{% endblock %}</title>
<link rel="icon" href="{{ asset_url('img/favicon.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ asset_url('css/base.css') }}">
<link rel="stylesheet" href="{{ asset_url('css/components.css') }}">
{% block extra_head %}{% endblock %}
</head>
<body data-csrf="{{ csrf_token() }}">
<div class="auth-page">
{% if auth_tagline %}
{# Wortmarke mit Funktionszeile als tatsaechlicher Seitenhintergrund --
uebernommen vom Vorgaenger. Bewusst inline und mit Themen-Tokens statt
zweier Dateien fuer hell und dunkel: bei der Einstellung "System" und
beim Umschalten von Hand waere die versteckte Variante die falsche.
``aria-hidden``, weil der Name schon in der Ueberschrift steht. #}
<div class="auth-backdrop" aria-hidden="true">
<svg viewBox="0 0 620 190" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(150,15)">
<circle cx="298" cy="70" r="15" fill="var(--logo-mark)" opacity="0.22"/>
<polyline points="0,70 90,70 100,70 108,20 116,120 124,70 134,70 200,70 210,70
218,35 226,105 234,70 244,70 300,70"
fill="none" stroke="var(--logo-mark)" stroke-width="3.2"
stroke-linecap="round" stroke-linejoin="round" opacity="0.85"/>
<text x="14" y="98" font-family="var(--font-mono)" font-weight="800"
font-size="82" letter-spacing="2" fill="var(--logo-text)">TESM</text>
<circle cx="298" cy="70" r="8" fill="var(--logo-mark)"/>
</g>
<text x="310" y="178" text-anchor="middle" font-family="var(--font-sans)"
font-weight="600" font-size="13" letter-spacing="1.5"
fill="var(--logo-mark)">{{ auth_tagline }}</text>
</svg>
</div>
{% endif %}
<div class="auth-card {% block card_class %}{% endblock %}">
<div class="auth-brand">
<span class="auth-brand__mark" aria-hidden="true">{{ app_short }}</span>
<h1>{% block heading %}{{ app_name }}{% endblock %}</h1>
{% set sub %}{% block subheading %}{% endblock %}{% endset %}
{% if sub | trim %}<p>{{ sub }}</p>{% endif %}
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="stack stack--tight">
{% for category, message in messages %}
{% set kind = {'error': 'danger', 'danger': 'danger', 'success': 'success',
'warning': 'warning'}.get(category, 'info') %}
<div class="alert alert--{{ kind }}" role="alert">
{{ icon('info' if kind == 'info' else ('check' if kind == 'success' else 'alert'), size=18) }}
<div class="alert__body">{{ message }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<p class="auth-footnote">{{ app_name }} {{ app_version }} &middot; &copy; {{ current_year }} WiS GmbH</p>
</div>
</div>
<script type="module" src="{{ asset_url('js/app.js') }}" nonce="{{ csp_nonce }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
@@ -0,0 +1,73 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import empty_state, alert, pill %}
{% block page_title %}Papierkorb{% endblock %}
{% block page_sub %}{{ total }} Eintraege &middot; Aufbewahrung {{ retention_days }} Tage{% endblock %}
{% block content %}
{{ alert('Ansehen ist immer moeglich. Wiederherstellen und endgueltiges Loeschen erfordern eine
gueltige, aktivierte Lizenz. Abgelaufene Eintraege werden beim Aufruf dieser Seite entfernt.',
'info') }}
{% for section in sections %}
<section class="card">
<div class="card__header">
<div class="flex-1">
<div class="card__title">{{ icon(section.icon, size=17) }} {{ section.label }}
{{ pill(section.entries | length, '') }}
</div>
{% if section.hint %}<div class="card__sub">{{ section.hint }}</div>{% endif %}
</div>
{% if section.entries and current_user.has('trash.delete') %}
<form method="post" action="{{ url_for('trash.purge_all') }}" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<input type="hidden" name="kind" value="{{ section.key }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Alle {{ section.entries | length }} Eintraege unter {{ section.label }} endgueltig loeschen? Das laesst sich nicht rueckgaengig machen."
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Endgueltig loeschen">
Bereich leeren
</button>
</form>
{% endif %}
</div>
{% if section.entries %}
<div class="table-wrap">
<table class="table table--compact">
<thead><tr><th>Eintrag</th><th>Detail</th><th>Geloescht</th><th class="actions">Aktion</th></tr></thead>
<tbody>
{% for entry in section.entries %}
<tr>
<td class="fw-600">{{ entry.label }}</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="actions">
{% if current_user.has('trash.edit') %}
<form method="post" style="display:inline" data-no-guard
action="{{ url_for('trash.restore', kind=section.key, ident=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm" type="submit">Wiederherstellen</button>
</form>
{% endif %}
{% if current_user.has('trash.delete') %}
<form method="post" style="display:inline" data-no-guard
action="{{ url_for('trash.purge', kind=section.key, ident=entry.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="{{ entry.label }} endgueltig loeschen?"
data-confirm-title="Endgueltig loeschen" data-confirm-ok="Loeschen">
Endgueltig
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{{ empty_state('Nichts im Papierkorb', '', section.icon) }}
{% endif %}
</section>
{% endfor %}
{% endblock %}
@@ -0,0 +1,238 @@
{% extends "tesm_core/base.html" %}
{% from "tesm_core/macros.html" import field, select_field, checkbox, pill, search_box, modal, empty_state, alert %}
{% block page_title %}Benutzer{% endblock %}
{% block page_sub %}{{ users | length }} Konten &middot; Rechte kommen ausschliesslich aus Gruppen{% endblock %}
{% block topbar_extra %}
{{ search_box('users-table', 'Benutzer suchen ...', count_id='users-visible') }}
{% if current_user.has('users.create') %}
<button class="btn btn--primary btn--sm" type="button" data-dialog-open="user-new">
{{ icon('plus', size=15) }} Konto anlegen
</button>
{% endif %}
{% endblock %}
{% block content %}
<section class="card">
<div class="table-toolbar">
<span class="muted fs-sm"><span id="users-visible">{{ users | length }}</span> sichtbar</span>
<span class="table-toolbar__spacer"></span>
{% if directory_enabled and current_user.has('users.create') %}
<button class="btn btn--sm" type="button" data-dialog-open="user-directory">
{{ icon('users', size=15) }} Aus Verzeichnis uebernehmen
</button>
{% endif %}
{% if current_user.is_admin %}
<a class="btn btn--sm" href="{{ url_for('admin.sessions') }}">{{ icon('activity', size=15) }} Sitzungen</a>
{% endif %}
</div>
<div class="table-wrap">
<table class="table" id="users-table" data-behavior="sortable">
<thead>
<tr>
<th class="sortable">Benutzer</th>
<th class="sortable">Name</th>
<th class="sortable">Quelle</th>
<th>Gruppen</th>
<th class="sortable">Status</th>
<th class="sortable">Letzte Anmeldung</th>
<th class="actions">Aktion</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr data-filter-text="{{ user.username }} {{ user.first_name }} {{ user.last_name }} {{ user.email }} {{ user.group_names | join(' ') }}">
<td>
<div class="row row--tight">
<span class="avatar avatar--sm" aria-hidden="true">
{{ (user.first_name[:1] ~ user.last_name[:1]) | upper if user.first_name else user.username[:2] | upper }}
</span>
<div>
<div class="fw-600">{{ user.username }}</div>
{% if user.email %}<div class="fs-xs faint">{{ user.email }}</div>{% endif %}
</div>
</div>
</td>
<td>{{ (user.first_name ~ ' ' ~ user.last_name) | trim or '&ndash;' | safe }}</td>
<td>
{% if user.auth_source == 'ldap' %}
{{ pill('Verzeichnis', 'info') }}
{% else %}
{{ pill('Lokal', '') }}
{% endif %}
</td>
<td class="fs-sm">
{% if user.is_admin %}{{ pill('Administrator', 'accent') }}{% endif %}
{{ user.group_names | join(', ') or ('&ndash;' | safe if not user.is_admin else '') }}
</td>
<td>
{% if user.is_locked %}{{ pill('Gesperrt', 'danger') }}
{% else %}{{ pill('Aktiv', 'online') }}{% endif %}
{% if user.totp_enabled %}<span title="Zwei-Faktor aktiv">{{ icon('lock', size=14) }}</span>{% endif %}
{% if user.must_change_password %}{{ pill('Passwortwechsel', 'warning') }}{% endif %}
</td>
<td class="fs-sm" data-sort-value="{{ user.last_login_at or '' }}">
{% if user.last_login_at %}
<span data-relative-time="{{ user.last_login_at }}">{{ user.last_login_at | dt }}</span>
{% else %}<span class="faint">noch nie</span>{% endif %}
</td>
<td class="actions">
{% if current_user.has('users.edit') %}
<button class="btn btn--sm btn--ghost" type="button"
data-dialog-open="user-edit-{{ user.id }}" aria-label="Bearbeiten">
{{ icon('pencil', size=15) }}
</button>
{% endif %}
{% if current_user.has('users.delete') %}
<form method="post" action="{{ url_for('admin.user_delete', user_id=user.id) }}"
style="display:inline" data-no-guard>
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<button class="btn btn--sm btn--ghost danger-text" type="submit"
data-confirm="Konto {{ user.username }} wirklich loeschen?{% if user.auth_source == 'ldap' %} Verzeichniskonten werden sofort endgueltig entfernt.{% else %} Es landet im Papierkorb.{% endif %}"
data-confirm-title="Konto loeschen" data-confirm-ok="Loeschen"
aria-label="Loeschen">{{ icon('trash', size=15) }}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if not users %}
{{ empty_state('Keine Konten vorhanden', 'Legen Sie das erste Konto an.', 'users') }}
{% endif %}
</section>
{{ alert('Rechte werden ausschliesslich ueber Gruppen vergeben. Das Admin-Kennzeichen ist davon
unabhaengig und kann nur von Administratoren gesetzt werden; mindestens ein entsperrtes
Administratorkonto muss immer bestehen bleiben.', 'info') }}
{% endblock %}
{% block dialogs %}
{% if current_user.has('users.create') %}
{% call modal('user-new', 'Konto anlegen', 'Lokales Konto mit eigenem Passwort') %}
<form method="post" action="{{ url_for('admin.user_create') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
<div class="form-grid">
{{ field('username', 'Benutzername', required=True, autocomplete='off',
extra_class='field--full') }}
{{ field('first_name', 'Vorname') }}
{{ field('last_name', 'Nachname') }}
{{ field('email', 'E-Mail', type='email', extra_class='field--full') }}
{{ field('password', 'Passwort', type='password', required=True, autocomplete='new-password',
hint_text='Mindestens ' ~ policy.min_length ~ ' Zeichen, zwei verschiedene Zeichenarten.',
extra_class='field--full') }}
</div>
<fieldset class="fieldset">
<legend>Gruppen</legend>
<div class="stack stack--tight">
{% for group in groups %}
{{ checkbox('group_ids', group.name ~ (' (Standard)' if group.is_default else ''),
checked=group.is_default, value=group.id) }}
{% endfor %}
</div>
</fieldset>
{% if current_user.is_admin %}
{{ checkbox('is_admin', 'Administrator (darf alles, unabhaengig von Gruppen)') }}
{% endif %}
{{ checkbox('must_change_password', 'Passwortwechsel bei der ersten Anmeldung erzwingen', checked=True) }}
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Konto anlegen</button>
</div>
</form>
{% endcall %}
{% if directory_enabled %}
{% call modal('user-directory', 'Aus Verzeichnis uebernehmen',
'Konto vorbereiten, damit Gruppen vor der ersten Anmeldung sitzen') %}
<form method="post" action="{{ url_for('admin.directory_adopt') }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
{{ field('username', 'Anmeldename im Verzeichnis', required=True, autocomplete='off') }}
<div class="form-grid">
{{ field('first_name', 'Vorname') }}
{{ field('last_name', 'Nachname') }}
</div>
{{ field('email', 'E-Mail', type='email') }}
<fieldset class="fieldset">
<legend>Gruppen</legend>
<div class="stack stack--tight">
{% for group in groups %}
{{ checkbox('group_ids', group.name, checked=group.is_default, value=group.id) }}
{% endfor %}
</div>
</fieldset>
{{ alert('Name und E-Mail werden bei der ersten echten Anmeldung aus dem Verzeichnis
uebernommen und ueberschrieben.', 'info') }}
</div>
<div class="modal__footer">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Vorbereiten</button>
</div>
</form>
{% endcall %}
{% endif %}
{% endif %}
{% if current_user.has('users.edit') %}
{% for user in users %}
{% call modal('user-edit-' ~ user.id, 'Konto ' ~ user.username,
'Verzeichniskonto' if user.auth_source == 'ldap' else 'Lokales Konto') %}
<form method="post" action="{{ url_for('admin.user_update', user_id=user.id) }}">
<input type="hidden" name="{{ csrf_field_name }}" value="{{ csrf_token() }}">
<div class="modal__body">
{% if user.auth_source == 'ldap' %}
{{ alert('Stammdaten und Passwort pflegt der Verzeichnisdienst. Hier lassen sich nur
Sperre und Gruppen setzen.', 'info') }}
{% else %}
<div class="form-grid">
{{ field('first_name', 'Vorname', value=user.first_name) }}
{{ field('last_name', 'Nachname', value=user.last_name) }}
</div>
{{ field('email', 'E-Mail', value=user.email, type='email') }}
{{ field('new_password', 'Neues Passwort setzen (optional)', type='password',
autocomplete='new-password',
hint_text='Setzt das Passwort zurueck, erzwingt einen Wechsel und beendet alle Sitzungen des Kontos.') }}
{% endif %}
<fieldset class="fieldset">
<legend>Gruppen</legend>
<input type="hidden" name="group_ids_submitted" value="1">
<div class="stack stack--tight">
{% for group in groups %}
{{ checkbox('group_ids', group.name, checked=group.name in user.group_names, value=group.id) }}
{% endfor %}
</div>
</fieldset>
<div class="stack stack--tight">
{% if current_user.is_admin and user.id != current_user.id %}
{{ checkbox('is_admin', 'Administrator', checked=user.is_admin) }}
{% elif user.is_admin %}
{{ pill('Administrator', 'accent') }}
{% endif %}
{% if user.id != current_user.id %}
{{ checkbox('is_locked', 'Konto gesperrt', checked=user.is_locked) }}
{% endif %}
</div>
</div>
<div class="modal__footer modal__footer--split">
<button class="btn btn--sm" type="submit" formaction="{{ url_for('admin.user_revoke_sessions', user_id=user.id) }}"
data-confirm="Alle Sitzungen von {{ user.username }} beenden?"
data-confirm-title="Sitzungen beenden" data-confirm-ok="Beenden" data-confirm-style="danger">
Sitzungen beenden
</button>
<span class="row row--tight">
<button class="btn" type="button" data-dialog-close>Abbrechen</button>
<button class="btn btn--primary" type="submit">Speichern</button>
</span>
</div>
</form>
{% endcall %}
{% endfor %}
{% endif %}
{% endblock %}
@@ -0,0 +1,233 @@
"""Verschluesselter Umschlag fuer Sicherungen -- gemeinsam von beiden Anwendungen.
Format 3. Was hier liegt, ist der kryptographische Teil: Passphrase ableiten,
verschluesseln, entschluesseln, Zwischenstand halten. *Welche* Kategorien es
gibt und wie sie gelesen/geschrieben werden, entscheidet jede Anwendung selbst.
Argon2id statt PBKDF2 (der Vorgaenger nutzte 390.000 PBKDF2-Iterationen --
ordentlich, aber nicht speicherhart) und AES-256-GCM statt Fernet, mit dem
Umschlagkopf als zugeordneten Daten: wer Salz oder Kategorienliste manipuliert,
bricht die Entschluesselung.
"""
from __future__ import annotations
import base64
import json
import secrets
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any
from argon2.low_level import Type, hash_secret_raw
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from .db import Database
FORMAT_VERSION = 3
KDF_TIME_COST = 4
KDF_MEMORY_COST = 131072 # 128 MiB
KDF_PARALLELISM = 2
KDF_SALT_BYTES = 16
KEY_BYTES = 32
NONCE_BYTES = 12
MIN_PASSPHRASE_LENGTH = 12
PREVIEW_JOB_KIND = "backup.preview"
PREVIEW_TTL_MINUTES = 30
class TransferError(Exception):
"""Fehler mit Klartextmeldung fuer die Oberflaeche."""
@dataclass(frozen=True, slots=True)
class Category:
"""Eine waehlbare Kategorie einer Sicherung."""
key: str
label: str
hint: str = ""
#: Enthaelt Geheimnisse im Klartext (verlangt ``backup.secrets``).
sensitive: bool = False
#: Nur echte Administratoren duerfen sie sichern/einspielen.
admin_only: bool = False
def _b64e(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64d(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def _now() -> datetime:
return datetime.now(timezone.utc)
def _derive_key(passphrase: str, salt: bytes, params: dict[str, int]) -> bytes:
return hash_secret_raw(
secret=passphrase.encode("utf-8"),
salt=salt,
time_cost=int(params["t"]),
memory_cost=int(params["m"]),
parallelism=int(params["p"]),
hash_len=KEY_BYTES,
type=Type.ID,
)
def seal(payload: dict[str, Any], passphrase: str, *, app_tag: str) -> str:
if len(passphrase or "") < MIN_PASSPHRASE_LENGTH:
raise TransferError(
f"Die Passphrase muss mindestens {MIN_PASSPHRASE_LENGTH} Zeichen haben -- sie ist "
"der einzige Schutz dieser Datei."
)
salt = secrets.token_bytes(KDF_SALT_BYTES)
params = {"t": KDF_TIME_COST, "m": KDF_MEMORY_COST, "p": KDF_PARALLELISM}
header = {
"app": app_tag,
"format": FORMAT_VERSION,
"created_at": _now().isoformat(timespec="seconds"),
"categories": sorted(payload.keys()),
"kdf": {"name": "argon2id", "salt": _b64e(salt), **params},
}
key = _derive_key(passphrase, salt, params)
nonce = secrets.token_bytes(NONCE_BYTES)
aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8")
ciphertext = AESGCM(key).encrypt(
nonce, json.dumps(payload, ensure_ascii=False).encode("utf-8"), aad
)
return json.dumps(
{**header, "nonce": _b64e(nonce), "payload": _b64e(ciphertext)},
ensure_ascii=False,
indent=2,
)
def unseal(
raw: bytes | str, passphrase: str, *, app_tag: str
) -> tuple[dict[str, Any], dict[str, Any]]:
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
try:
envelope = json.loads(text)
except ValueError as exc:
raise TransferError("Die Datei ist kein gueltiges JSON.") from exc
if not isinstance(envelope, dict):
raise TransferError("Die Datei enthaelt kein Objekt.")
if envelope.get("app") != app_tag:
raise TransferError(
f"Diese Sicherung gehoert zu einer anderen Anwendung ({envelope.get('app')!r})."
)
if int(envelope.get("format") or 0) != FORMAT_VERSION:
raise TransferError(
f"Nicht unterstuetztes Sicherungsformat ({envelope.get('format')!r}, "
f"erwartet {FORMAT_VERSION})."
)
kdf = envelope.get("kdf") or {}
if kdf.get("name") != "argon2id":
raise TransferError("Unbekanntes Schluesselableitungsverfahren.")
header = {
key: envelope[key]
for key in ("app", "format", "created_at", "categories", "kdf")
if key in envelope
}
aad = json.dumps(header, sort_keys=True, separators=(",", ":")).encode("utf-8")
try:
key = _derive_key(
passphrase,
_b64d(str(kdf["salt"])),
{
"t": int(kdf.get("t") or KDF_TIME_COST),
"m": int(kdf.get("m") or KDF_MEMORY_COST),
"p": int(kdf.get("p") or KDF_PARALLELISM),
},
)
plain = AESGCM(key).decrypt(
_b64d(str(envelope["nonce"])), _b64d(str(envelope["payload"])), aad
)
except (InvalidTag, KeyError, ValueError) as exc:
raise TransferError(
"Die Datei konnte nicht geoeffnet werden. Passphrase falsch oder Datei beschaedigt."
) from exc
return json.loads(plain.decode("utf-8")), header
# --------------------------------------------------------------------------- #
# Zwischenstand des Imports
# --------------------------------------------------------------------------- #
def store_preview(conn: sqlite3.Connection, payload: dict[str, Any], *, actor: str) -> str:
"""Legt die geoeffnete Sicherung in der Datenbank ab.
Der Vorgaenger hielt diesen Zwischenstand in einer Modulvariable -- einer der
Gruende, warum die Anwendung mit genau einem Worker laufen *musste*.
"""
token = secrets.token_urlsafe(18)
conn.execute("DELETE FROM jobs WHERE kind=? AND started_by=?", (PREVIEW_JOB_KIND, actor))
conn.execute(
"INSERT INTO jobs (id, kind, subject, state, message, output, started_by, created_at, "
"updated_at) VALUES (?,?,?,'pending','',?,?,?,?)",
(
token,
PREVIEW_JOB_KIND,
"Import-Vorschau",
json.dumps(payload, ensure_ascii=False),
actor,
_now().isoformat(timespec="seconds"),
_now().isoformat(timespec="seconds"),
),
)
return token
def load_preview(conn: sqlite3.Connection, token: str, *, actor: str) -> dict[str, Any]:
row = Database.one(
conn,
"SELECT output, created_at FROM jobs WHERE id=? AND kind=? AND started_by=?",
(token, PREVIEW_JOB_KIND, actor),
)
if row is None:
raise TransferError("Der Zwischenstand ist nicht mehr vorhanden. Bitte neu hochladen.")
created = datetime.fromisoformat(str(row["created_at"]))
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
if _now() - created > timedelta(minutes=PREVIEW_TTL_MINUTES):
conn.execute("DELETE FROM jobs WHERE id=?", (token,))
raise TransferError("Der Zwischenstand ist abgelaufen. Bitte neu hochladen.")
return json.loads(str(row["output"]))
def drop_preview(conn: sqlite3.Connection, token: str) -> None:
conn.execute("DELETE FROM jobs WHERE id=? AND kind=?", (token, PREVIEW_JOB_KIND))
def summarize(payload: dict[str, Any], categories: tuple[Category, ...]) -> list[dict[str, Any]]:
summary: list[dict[str, Any]] = []
for category in categories:
if category.key not in payload:
continue
data = payload[category.key]
if isinstance(data, list):
count = len(data)
elif isinstance(data, dict):
count = sum(len(value) if isinstance(value, list) else 1 for value in data.values())
else:
count = 1
summary.append(
{
"key": category.key,
"label": category.label,
"hint": category.hint,
"count": count,
"sensitive": category.sensitive,
"admin_only": category.admin_only,
}
)
return summary
+238
View File
@@ -0,0 +1,238 @@
"""Darstellungshelfer: Icons, Formatierung, Template-Globals.
Die Icons sind Pfadfragmente eines 24x24-Rasters mit ``stroke="currentColor"``
-- kein Icon-Font, kein externes Sprite, keine zusaetzliche HTTP-Anfrage und
nichts, was die CSP aufweichen muesste.
"""
from __future__ import annotations
import math
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from flask import Flask, url_for
from markupsafe import Markup
ICONS: dict[str, str] = {
"grid": '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
"cpu": '<rect x="5" y="5" width="14" height="14" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>',
"switch": '<rect x="2" y="7" width="20" height="10" rx="2"/><path d="M6 11v2M10 11v2M14 11v2M18 11v2"/>',
"network": '<rect x="9" y="2" width="6" height="6" rx="1.5"/><rect x="2" y="16" width="6" height="6" rx="1.5"/><rect x="16" y="16" width="6" height="6" rx="1.5"/><path d="M12 8v4M5 16v-2h14v2"/>',
"key": '<circle cx="8" cy="15" r="4"/><path d="m10.8 12.2 8.2-8.2M17 5l3 3M14 8l3 3"/>',
"wrench": '<path d="M14.5 3.5a5 5 0 0 0-6.9 6.1L3 14.2V21h6.8l4.6-4.6a5 5 0 0 0 6.1-6.9l-3 3-3.9-.9-.9-3.9 3-3z"/>',
"users": '<path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 20v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8"/>',
"shield-users": '<path d="M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5l-8-3z"/><circle cx="12" cy="10" r="2.2"/><path d="M8.5 16c.7-1.6 2-2.4 3.5-2.4s2.8.8 3.5 2.4"/>',
"folder": '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/>',
"sliders": '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
"server": '<rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/>',
"trash": '<path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13M10 11v6M14 11v6"/>',
"terminal": '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m7 9 3 3-3 3M13 15h4"/>',
"clock": '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
"history": '<path d="M3 12a9 9 0 1 0 3-6.7M3 4v4h4"/><path d="M12 8v4l3 2"/>',
"transfer": '<path d="M4 8h13l-3-3M20 16H7l3 3"/>',
"shield": '<path d="M12 2 4 5v6c0 5 3.4 9.3 8 11 4.6-1.7 8-6 8-11V5l-8-3z"/>',
"search": '<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>',
"menu": '<path d="M4 6h16M4 12h16M4 18h16"/>',
"chevron-right": '<path d="m9 6 6 6-6 6"/>',
"chevron-down": '<path d="m6 9 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"/>',
"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"/>',
"log-out": '<path d="M15 4h3a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-3M10 8l-4 4 4 4M6 12h10"/>',
"settings": '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.9.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.9 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.9l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.9.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.9-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.9V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/>',
"plus": '<path d="M12 5v14M5 12h14"/>',
"pencil": '<path d="M4 20h4l10.5-10.5a2.1 2.1 0 0 0-3-3L5 17v3z"/>',
"x": '<path d="M6 6l12 12M18 6 6 18"/>',
"check": '<path d="m5 13 4 4L19 7"/>',
"alert": '<path d="M12 3 2 20h20L12 3z"/><path d="M12 9v5M12 17h.01"/>',
"info": '<circle cx="12" cy="12" r="9"/><path d="M12 11v5M12 8h.01"/>',
"download": '<path d="M12 3v12M7 11l5 5 5-5M4 21h16"/>',
"upload": '<path d="M12 21V9M7 13l5-5 5 5M4 4h16"/>',
"refresh": '<path d="M20 12a8 8 0 1 1-2.3-5.7M20 4v4h-4"/>',
"play": '<path d="M7 4l12 8-12 8V4z"/>',
"power": '<path d="M12 3v9"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
"lock": '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
"unlock": '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 7.5-2"/>',
"mail": '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="m3 7 9 6 9-6"/>',
"building": '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M9 7h2M13 7h2M9 11h2M13 11h2M9 15h2M13 15h2M10 21v-3h4v3"/>',
"ticket": '<path d="M3 9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2 2 2 0 0 0 0 6 2 2 0 0 1-2 2H5a2 2 0 0 1-2-2 2 2 0 0 0 0-6z"/><path d="M9 7v10"/>',
"file-text": '<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8l-5-5z"/><path d="M14 3v5h5M9 13h6M9 17h4"/>',
"activity": '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
"database": '<ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
"eye": '<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/>',
"copy": '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/>',
"external": '<path d="M14 4h6v6M20 4 11 13M18 14v4a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4"/>',
"filter": '<path d="M3 5h18l-7 8v6l-4-2v-4L3 5z"/>',
"calendar": '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
"link": '<path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/>',
"puzzle": '<path d="M9 3h2a2 2 0 0 1 0 4h3v3a2 2 0 0 1 4 0v3h-3v3a2 2 0 0 1-4 0H8v-3H5a2 2 0 0 1 0-4h3V3z"/>',
"credit-card": '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M6 15h4"/>',
"map-pin": '<path d="M12 21s7-6.3 7-11a7 7 0 1 0-14 0c0 4.7 7 11 7 11z"/><circle cx="12" cy="10" r="2.5"/>',
"share": '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4"/>',
"hard-drive": '<rect x="2" y="13" width="20" height="8" rx="2"/><path d="M5 13 8 4h8l3 9M6 17h.01M10 17h.01"/>',
}
def icon(name: str, *, size: int = 18, extra_class: str = "") -> Markup:
"""Rendert ein Icon als Inline-SVG."""
path = ICONS.get(name) or ICONS["info"]
classes = f"icon icon--{name}" + (f" {extra_class}" if extra_class else "")
return Markup(
f'<svg class="{classes}" width="{size}" height="{size}" viewBox="0 0 24 24" '
'fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" '
f'stroke-linejoin="round" aria-hidden="true">{path}</svg>'
)
# --------------------------------------------------------------------------- #
# Formatierung
# --------------------------------------------------------------------------- #
def format_bytes(value: Any) -> str:
try:
size = float(value)
except (TypeError, ValueError):
return "-"
if size < 1024:
return f"{int(size)} B"
units = ("KB", "MB", "GB", "TB")
index = min(int(math.log(size, 1024)) - 1, len(units) - 1)
return f"{size / (1024 ** (index + 1)):.1f} {units[index]}".replace(".", ",")
def format_datetime(value: Any, *, with_time: bool = True) -> str:
if not value:
return "-"
if isinstance(value, datetime):
moment = value
else:
text = str(value).strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
moment = datetime.fromisoformat(text)
except ValueError:
return str(value)
if moment.tzinfo is None:
moment = moment.replace(tzinfo=timezone.utc)
local = moment.astimezone()
return local.strftime("%d.%m.%Y %H:%M") if with_time else local.strftime("%d.%m.%Y")
def format_date(value: Any) -> str:
return format_datetime(value, with_time=False)
def format_duration(seconds: Any) -> str:
try:
total = int(seconds)
except (TypeError, ValueError):
return "-"
if total < 60:
return f"{total} s"
minutes, secs = divmod(total, 60)
if minutes < 60:
return f"{minutes} min {secs} s" if secs else f"{minutes} min"
hours, minutes = divmod(minutes, 60)
if hours < 24:
return f"{hours} h {minutes} min" if minutes else f"{hours} h"
days, hours = divmod(hours, 24)
return f"{days} d {hours} h" if hours else f"{days} d"
def format_number(value: Any) -> str:
try:
return f"{int(value):n}".replace(",", ".")
except (TypeError, ValueError):
return str(value)
def yes_no(value: Any) -> str:
return "ja" if value else "nein"
def from_json(value: Any) -> Any:
"""Parst einen JSON-String aus der Datenbank -- fuer Modul-/Rechtelisten.
Wirft nie: kaputte Daten fuehren zu einer leeren Liste, nicht zu einer
Fehlerseite.
"""
if value in (None, ""):
return []
if isinstance(value, (list, dict)):
return value
import json as _json
try:
return _json.loads(str(value))
except ValueError:
return []
# --------------------------------------------------------------------------- #
# Registrierung
# --------------------------------------------------------------------------- #
def asset_url(app: Flask) -> Any:
"""``asset_url('css/base.css')`` mit Cache-Buster aus der Dateizeit."""
cache: dict[str, str] = {}
def build(filename: str, blueprint: str = "tesm_core") -> str:
if filename in cache:
return cache[filename]
endpoint = f"{blueprint}.static" if blueprint else "static"
try:
url = url_for(endpoint, filename=filename)
except Exception: # noqa: BLE001 - Blueprint ohne static-Ordner
url = url_for("static", filename=filename)
version = ""
try:
static_folder = app.blueprints[blueprint].static_folder if blueprint else app.static_folder
if static_folder:
path = Path(static_folder) / filename
if path.is_file():
version = str(int(path.stat().st_mtime))
except (KeyError, OSError):
version = ""
result = f"{url}?v={version}" if version else url
if not app.debug:
cache[filename] = result
return result
return build
def init_app(app: Flask) -> None:
app.jinja_env.globals.update(
icon=icon,
asset_url=asset_url(app),
# Kleine Python-Builtins, die in Templates immer wieder gebraucht werden.
# Jinja bringt sie nicht mit; ohne sie muesste jedes Template den Umweg
# ueber eigene Filter gehen.
bool=bool,
int=int,
str=str,
len=len,
min=min,
max=max,
sorted=sorted,
dict=dict,
list=list,
set=set,
enumerate=enumerate,
)
app.jinja_env.filters.update(
bytes=format_bytes,
dt=format_datetime,
date=format_date,
duration=format_duration,
number=format_number,
yes_no=yes_no,
from_json=from_json,
)
app.jinja_env.trim_blocks = True
app.jinja_env.lstrip_blocks = True
@@ -0,0 +1,7 @@
"""Blueprints, die beide Anwendungen teilen."""
from __future__ import annotations
from . import account, admin, diagnostics, trash
__all__ = ["account", "admin", "diagnostics", "trash"]
@@ -0,0 +1,367 @@
"""Eigenes Konto: Stammdaten, Passwort, zweiter Faktor, eigene Sitzungen.
Bewusst getrennt von der Benutzerverwaltung: hier aendert jeder nur sich
selbst, ohne irgendein Recht aus der Rechtematrix.
"""
from __future__ import annotations
import secrets
from datetime import datetime, timezone
from pathlib import Path
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
send_from_directory,
session,
url_for,
)
from werkzeug.utils import secure_filename
from .. import audit
from ..auth.models import current_user, login_required, reauth_required
from ..auth.service import client_ip, confirm_password
from ..db import Database
from ..extension import core, db, transaction
from ..rbac import service as rbac
from ..security import passwords, sessions as session_store, totp
bp = Blueprint("account", __name__, url_prefix="/konto")
ALLOWED_AVATAR_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
MAX_AVATAR_BYTES = 2 * 1024 * 1024
#: Magische Bytes je erlaubtem Bildtyp. Der Vorgaenger prueft nur die Endung --
#: damit laesst sich beliebiger Inhalt als ".png" hochladen.
_MAGIC = {
b"\x89PNG\r\n\x1a\n": "png",
b"\xff\xd8\xff": "jpeg",
b"GIF87a": "gif",
b"GIF89a": "gif",
b"RIFF": "webp",
}
def _avatar_dir() -> Path:
path = core().config.data_dir / "avatars"
path.mkdir(parents=True, exist_ok=True)
return path
def _audit(conn, action: str, detail: str = "", severity: str = "info") -> None:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=current_user.username,
detail=detail,
ip=client_ip(),
severity=severity,
)
@bp.get("/")
@login_required
def overview():
conn = db()
row = rbac.get_user(conn, current_user.id) or {}
return render_template(
"tesm_core/account.html",
profile=row,
groups=rbac.user_groups(conn, current_user.id),
permissions=sorted(current_user.effective_permissions),
tree=core().permissions,
)
@bp.post("/stammdaten")
@login_required
def update_profile():
if current_user.is_directory_account:
flash(
"Ihre Stammdaten kommen aus dem Verzeichnisdienst und werden bei jeder "
"Anmeldung uebernommen.",
"info",
)
return redirect(url_for("account.overview"))
with transaction() as conn:
conn.execute(
"UPDATE users SET first_name=?, last_name=?, email=?, updated_at=? WHERE id=?",
(
(request.form.get("first_name") or "").strip()[:64],
(request.form.get("last_name") or "").strip()[:64],
(request.form.get("email") or "").strip()[:190],
datetime.now(timezone.utc).isoformat(timespec="seconds"),
current_user.id,
),
)
_audit(conn, "account.profile_updated")
flash("Stammdaten gespeichert.", "success")
return redirect(url_for("account.overview"))
@bp.post("/profilbild")
@login_required
def upload_avatar():
upload = request.files.get("avatar")
if upload is None or not upload.filename:
flash("Keine Datei ausgewaehlt.", "danger")
return redirect(url_for("account.overview"))
suffix = Path(upload.filename).suffix.lower()
if suffix not in ALLOWED_AVATAR_SUFFIXES:
flash("Erlaubt sind PNG, JPG, WEBP und GIF.", "danger")
return redirect(url_for("account.overview"))
head = upload.stream.read(16)
upload.stream.seek(0)
if not any(head.startswith(magic) for magic in _MAGIC):
flash("Die Datei ist kein Bild.", "danger")
return redirect(url_for("account.overview"))
data = upload.stream.read(MAX_AVATAR_BYTES + 1)
if len(data) > MAX_AVATAR_BYTES:
flash("Das Bild ist zu gross (maximal 2 MB).", "danger")
return redirect(url_for("account.overview"))
filename = f"{current_user.id}-{secrets.token_hex(8)}{suffix}"
target = _avatar_dir() / secure_filename(filename)
target.write_bytes(data)
with transaction() as conn:
previous = Database.value(
conn, "SELECT avatar_filename FROM users WHERE id=?", (current_user.id,)
)
conn.execute(
"UPDATE users SET avatar_filename=?, updated_at=? WHERE id=?",
(target.name, datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
)
_audit(conn, "account.avatar_updated")
if previous:
(_avatar_dir() / str(previous)).unlink(missing_ok=True)
flash("Profilbild aktualisiert.", "success")
return redirect(url_for("account.overview"))
@bp.get("/profilbild/<path:filename>")
@login_required
def avatar(filename: str):
safe = secure_filename(filename)
if safe != filename or Path(safe).suffix.lower() not in ALLOWED_AVATAR_SUFFIXES:
abort(404)
return send_from_directory(_avatar_dir(), safe, max_age=3600)
# --------------------------------------------------------------------------- #
# Sicherheit
# --------------------------------------------------------------------------- #
@bp.get("/sicherheit")
@login_required
def security():
conn = db()
row = rbac.get_user(conn, current_user.id) or {}
pending_secret = session.get("_totp_setup", "")
return render_template(
"tesm_core/account_security.html",
profile=row,
policy=core().password_policy,
sessions=session_store.active_for_user(conn, current_user.id),
current_sid=getattr(session, "sid", ""),
totp_enabled=bool(row.get("totp_secret")),
pending_secret=pending_secret,
pending_uri=(
totp.provisioning_uri(
pending_secret, account=current_user.username, issuer=core().config.app_name
)
if pending_secret
else ""
),
pending_secret_grouped=totp.format_secret(pending_secret) if pending_secret else "",
recovery_remaining=int(
Database.value(
conn,
"SELECT COUNT(*) FROM user_recovery_codes WHERE user_id=? AND used_at IS NULL",
(current_user.id,),
default=0,
)
or 0
),
recovery_codes=session.pop("_recovery_codes", []),
require_totp_for_admins=core().config.security.require_totp_for_admins,
)
@bp.post("/passwort")
@login_required
def change_password():
conn = db()
if current_user.is_directory_account:
flash("Verzeichniskonten aendern ihr Passwort im Verzeichnisdienst.", "info")
return redirect(url_for("account.security"))
current = request.form.get("current_password") or ""
new = request.form.get("new_password") or ""
repeat = request.form.get("repeat_password") or ""
if not confirm_password(conn, current_user, current):
flash("Das aktuelle Passwort ist nicht korrekt.", "danger")
return redirect(url_for("account.security"))
if new != repeat:
flash("Die beiden neuen Passwoerter stimmen nicht ueberein.", "danger")
return redirect(url_for("account.security"))
if passwords.constant_time_equals(current, new):
flash("Das neue Passwort muss sich vom alten unterscheiden.", "danger")
return redirect(url_for("account.security"))
try:
with transaction():
rbac.set_password(conn, current_user.id, new, policy=core().password_policy)
# Alle anderen Sitzungen beenden: nach einem Passwortwechsel soll
# eine ggf. uebernommene Sitzung nicht weiterlaufen.
revoked = session_store.revoke_all_for_user(
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:
flash(str(exc), "danger")
return redirect(url_for("account.security"))
flash("Passwort geaendert. Andere Sitzungen wurden beendet.", "success")
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/starten")
@login_required
@reauth_required
def totp_start():
session["_totp_setup"] = totp.generate_secret()
flash("Scannen Sie den Code und bestaetigen Sie mit einem gueltigen Zahlencode.", "info")
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/bestaetigen")
@login_required
@reauth_required
def totp_confirm():
secret = session.get("_totp_setup", "")
if not secret:
flash("Es laeuft keine Einrichtung. Bitte neu starten.", "danger")
return redirect(url_for("account.security"))
if not totp.verify_code(secret, request.form.get("code") or ""):
flash("Der Code ist nicht korrekt. Bitte erneut versuchen.", "danger")
return redirect(url_for("account.security"))
codes = totp.generate_recovery_codes()
with transaction() as conn:
conn.execute(
"UPDATE users SET totp_secret=?, totp_confirmed_at=?, updated_at=? WHERE id=?",
(
secret,
datetime.now(timezone.utc).isoformat(timespec="seconds"),
datetime.now(timezone.utc).isoformat(timespec="seconds"),
current_user.id,
),
)
conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
conn.executemany(
"INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)",
[
(
current_user.id,
passwords.hash_password(code),
datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
for code in codes
],
)
_audit(conn, "account.totp_enabled", severity="notice")
session.pop("_totp_setup", None)
session["_recovery_codes"] = codes
flash("Zwei-Faktor-Authentisierung aktiviert. Bitte die Wiederherstellungscodes sichern.", "success")
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/deaktivieren")
@login_required
@reauth_required
def totp_disable():
if core().config.security.require_totp_for_admins and current_user.is_admin:
flash(
"Fuer Administratoren ist die Zwei-Faktor-Authentisierung in dieser "
"Installation verpflichtend.",
"danger",
)
return redirect(url_for("account.security"))
with transaction() as conn:
conn.execute(
"UPDATE users SET totp_secret='', totp_confirmed_at=NULL, updated_at=? WHERE id=?",
(datetime.now(timezone.utc).isoformat(timespec="seconds"), current_user.id),
)
conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
_audit(conn, "account.totp_disabled", severity="warning")
flash("Zwei-Faktor-Authentisierung deaktiviert.", "info")
return redirect(url_for("account.security"))
@bp.post("/zwei-faktor/codes-erneuern")
@login_required
@reauth_required
def recovery_codes():
codes = totp.generate_recovery_codes()
with transaction() as conn:
conn.execute("DELETE FROM user_recovery_codes WHERE user_id=?", (current_user.id,))
conn.executemany(
"INSERT INTO user_recovery_codes (user_id, code_hash, created_at) VALUES (?,?,?)",
[
(
current_user.id,
passwords.hash_password(code),
datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
for code in codes
],
)
_audit(conn, "account.recovery_codes_renewed", severity="notice")
session["_recovery_codes"] = codes
flash("Neue Wiederherstellungscodes erzeugt. Die alten gelten nicht mehr.", "success")
return redirect(url_for("account.security"))
@bp.post("/sitzungen/<sid>/beenden")
@login_required
def revoke_session(sid: str):
conn = db()
owner = Database.value(conn, "SELECT user_id FROM user_sessions WHERE id=?", (sid,))
if owner is None or int(owner) != current_user.id:
abort(404)
with transaction():
session_store.revoke(conn, sid, reason="self")
_audit(conn, "account.session_revoked", sid[:12])
flash("Sitzung beendet.", "success")
return redirect(url_for("account.security"))
@bp.post("/sitzungen/alle-beenden")
@login_required
def revoke_other_sessions():
with transaction() as conn:
count = session_store.revoke_all_for_user(
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")
return redirect(url_for("account.security"))
@@ -0,0 +1,440 @@
"""Benutzer- und Gruppenverwaltung -- identisch in beiden Anwendungen."""
from __future__ import annotations
from typing import Any
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, url_for
from .. import audit
from ..auth import directory
from ..auth.models import admin_required, current_user, login_required, permission_required
from ..auth.service import client_ip
from ..db import Database
from ..extension import core, db, transaction
from ..rbac import service as rbac
from ..rbac.model import ACTION_META, Action
from ..security import sessions as session_store
bp = Blueprint("admin", __name__, url_prefix="/verwaltung")
def _audit(conn: Any, action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _int_list(name: str) -> list[int]:
values: list[int] = []
for raw in request.form.getlist(name):
try:
values.append(int(raw))
except (TypeError, ValueError):
continue
return values
# --------------------------------------------------------------------------- #
# Benutzer
# --------------------------------------------------------------------------- #
@bp.get("/benutzer")
@permission_required("users.view")
def users():
conn = db()
return render_template(
"tesm_core/users.html",
users=rbac.list_users(conn),
groups=[group for group in rbac.list_groups(conn)],
directory_enabled=directory.is_enabled(conn),
policy=core().password_policy,
)
@bp.post("/benutzer/neu")
@permission_required("users.create")
def user_create():
conn = db()
try:
with transaction():
user_id = rbac.create_user(
conn,
username=request.form.get("username", ""),
password=request.form.get("password", ""),
is_admin=request.form.get("is_admin") == "1",
first_name=request.form.get("first_name", ""),
last_name=request.form.get("last_name", ""),
email=request.form.get("email", ""),
group_ids=_int_list("group_ids"),
actor=current_user,
tree=core().permissions,
policy=core().password_policy,
must_change_password=request.form.get("must_change_password") == "1",
)
_audit(
conn,
"user.created",
request.form.get("username", ""),
"Administrator" if request.form.get("is_admin") == "1" else "Benutzer",
severity="notice",
)
flash(f"Konto {request.form.get('username')} wurde angelegt.", "success")
return redirect(url_for("admin.users", highlight=user_id))
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>")
@permission_required("users.edit")
def user_update(user_id: int):
conn = db()
try:
with transaction():
changes = rbac.update_user(
conn,
user_id,
actor=current_user,
tree=core().permissions,
first_name=request.form.get("first_name"),
last_name=request.form.get("last_name"),
email=request.form.get("email"),
is_admin=request.form.get("is_admin") == "1",
is_locked=request.form.get("is_locked") == "1",
group_ids=_int_list("group_ids") if "group_ids" in request.form else None,
)
if request.form.get("new_password"):
rbac.set_password(
conn,
user_id,
request.form["new_password"],
policy=core().password_policy,
must_change=True,
)
changes.append("Passwort zurueckgesetzt")
session_store.revoke_all_for_user(conn, user_id, reason="password-reset")
target = rbac.get_user(conn, user_id) or {}
if changes:
_audit(
conn,
"user.updated",
str(target.get("username") or user_id),
"; ".join(changes),
severity="notice",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/loeschen")
@permission_required("users.delete")
def user_delete(user_id: int):
conn = db()
try:
with transaction():
target = rbac.get_user(conn, user_id) or {}
mode = rbac.soft_delete_user(conn, user_id, actor=current_user)
_audit(
conn,
"user.deleted",
str(target.get("username") or user_id),
f"Modus: {mode}",
severity="warning",
)
flash(
"Konto endgueltig entfernt (Verzeichniskonto)."
if mode == "endgueltig"
else "Konto in den Papierkorb verschoben.",
"success",
)
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.users"))
@bp.post("/benutzer/<int:user_id>/sitzungen-beenden")
@permission_required("users.edit")
def user_revoke_sessions(user_id: int):
conn = db()
target = rbac.get_user(conn, user_id)
if target is None:
abort(404)
rbac.guard_target_account(current_user, target)
with transaction():
count = session_store.revoke_all_for_user(conn, user_id, reason="admin")
_audit(
conn,
"user.sessions_revoked",
str(target["username"]),
f"{count} Sitzungen",
severity="notice",
)
flash(f"{count} Sitzung(en) beendet.", "success")
return redirect(url_for("admin.users"))
@bp.get("/benutzer/verzeichnis-suche")
@permission_required("users.create")
def directory_search():
conn = db()
query = (request.args.get("q") or "").strip()
existing = {
str(row["username"]).lower() for row in rbac.list_users(conn, include_deleted=True)
}
results = [
entry
for entry in directory.search_directory_users(conn, query)
if entry["username"].lower() not in existing
]
return jsonify({"results": results})
@bp.post("/benutzer/verzeichnis-uebernehmen")
@permission_required("users.create")
def directory_adopt():
"""Legt ein Verzeichniskonto vorab an, damit Gruppen vor dem ersten Login sitzen."""
conn = db()
username = (request.form.get("username") or "").strip()
if not username:
flash("Kein Benutzername angegeben.", "danger")
return redirect(url_for("admin.users"))
try:
with transaction():
user_id = rbac.create_user(
conn,
username=username,
password="",
is_admin=False,
first_name=request.form.get("first_name", ""),
last_name=request.form.get("last_name", ""),
email=request.form.get("email", ""),
group_ids=_int_list("group_ids"),
actor=current_user,
tree=core().permissions,
policy=core().password_policy,
auth_source="ldap",
)
_audit(conn, "user.directory_adopted", username, severity="notice")
flash(f"Verzeichniskonto {username} vorbereitet.", "success")
return redirect(url_for("admin.users", highlight=user_id))
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.users"))
# --------------------------------------------------------------------------- #
# Gruppen
# --------------------------------------------------------------------------- #
@bp.get("/gruppen")
@permission_required("groups.view")
def groups():
conn = db()
tree = core().permissions
entries = rbac.list_groups(conn)
return render_template(
"tesm_core/groups.html",
groups=entries,
summaries={
group.id: rbac.summarize_permissions(tree, group.permissions) for group in entries
},
tree=tree,
)
@bp.get("/gruppen/<int:group_id>")
@permission_required("groups.view")
def group_detail(group_id: int):
conn = db()
group = rbac.get_group(conn, group_id)
if group is None or group.deleted_at:
abort(404)
tree = core().permissions
return render_template(
"tesm_core/group_detail.html",
group=group,
tree=tree,
state=rbac.matrix_state(tree, group.permissions),
members=Database.all(
conn,
"SELECT u.id, u.username, u.first_name, u.last_name, u.is_admin, u.auth_source "
"FROM user_groups ug JOIN users u ON u.id = ug.user_id "
"WHERE ug.group_id=? AND u.deleted_at IS NULL ORDER BY u.username COLLATE NOCASE",
(group_id,),
),
can_edit=current_user.has("groups.edit"),
unlocked=request.args.get("freischalten") == "1",
)
@bp.post("/gruppen/neu")
@permission_required("groups.create")
def group_create():
conn = db()
try:
with transaction():
group_id = rbac.create_group(
conn,
name=request.form.get("name", ""),
description=request.form.get("description", ""),
permissions=request.form.getlist("permissions"),
tree=core().permissions,
actor=current_user,
)
_audit(conn, "group.created", request.form.get("name", ""), severity="notice")
flash("Gruppe angelegt.", "success")
return redirect(url_for("admin.group_detail", group_id=group_id))
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>")
@permission_required("groups.edit")
def group_update(group_id: int):
conn = db()
try:
with transaction():
changes = rbac.update_group(
conn,
group_id,
name=request.form.get("name"),
description=request.form.get("description"),
permissions=request.form.getlist("permissions")
if "permissions_submitted" in request.form
else None,
tree=core().permissions,
actor=current_user,
unlock_system=request.form.get("unlock_system") == "1",
)
group = rbac.get_group(conn, group_id)
if changes:
_audit(
conn,
"group.updated",
group.name if group else str(group_id),
"; ".join(changes),
severity="notice",
)
flash("Aenderungen gespeichert." if changes else "Keine Aenderungen.", "success" if changes else "info")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.group_detail", group_id=group_id))
@bp.post("/gruppen/<int:group_id>/loeschen")
@permission_required("groups.delete")
def group_delete(group_id: int):
conn = db()
try:
with transaction():
group = rbac.get_group(conn, group_id)
rbac.soft_delete_group(conn, group_id)
_audit(
conn,
"group.deleted",
group.name if group else str(group_id),
severity="warning",
)
flash("Gruppe in den Papierkorb verschoben.", "success")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("admin.groups"))
@bp.post("/gruppen/<int:group_id>/vorlage-anwenden")
@admin_required
def group_apply_preset(group_id: int):
"""Setzt eine Gruppe auf ihre Vorlage zurueck -- ausdruecklich, nie automatisch."""
conn = db()
group = rbac.get_group(conn, group_id)
if group is None:
abort(404)
preset = next((p for p in core().role_presets if p.key == group.preset_key), None)
if preset is None:
flash("Fuer diese Gruppe ist keine Vorlage hinterlegt.", "info")
return redirect(url_for("admin.group_detail", group_id=group_id))
tree = core().permissions
with transaction():
rbac.update_group(
conn,
group_id,
permissions=tree.expand_preset(preset),
tree=tree,
actor=current_user,
unlock_system=True,
)
_audit(conn, "group.preset_applied", group.name, preset.key, severity="notice")
flash(f"Rechte der Vorlage {preset.name} uebernommen.", "success")
return redirect(url_for("admin.group_detail", group_id=group_id))
# --------------------------------------------------------------------------- #
# Sitzungen
# --------------------------------------------------------------------------- #
@bp.get("/sitzungen")
@admin_required
def sessions():
conn = db()
rows = Database.all(
conn,
"SELECT s.*, u.username FROM user_sessions s LEFT JOIN users u ON u.id = s.user_id "
"WHERE s.revoked_at IS NULL AND s.idle_expires_at > datetime('now') "
"ORDER BY s.last_seen_at DESC LIMIT 500",
)
return render_template("tesm_core/sessions.html", sessions=rows)
@bp.post("/sitzungen/<sid>/beenden")
@admin_required
def session_revoke(sid: str):
with transaction() as conn:
session_store.revoke(conn, sid, reason="admin")
_audit(conn, "session.revoked", sid[:12], severity="notice")
flash("Sitzung beendet.", "success")
return redirect(url_for("admin.sessions"))
# --------------------------------------------------------------------------- #
# Navigationsreihenfolge (nur echte Administratoren)
# --------------------------------------------------------------------------- #
@bp.post("/navigation")
@admin_required
def nav_order():
extension = core()
order = extension.nav.sanitize_order(request.form.getlist("order"))
child_order: dict[str, list[str]] = {}
for item in extension.nav.items:
if not item.children:
continue
submitted = request.form.getlist(f"child_order_{item.key}")
if submitted:
child_order[item.key] = extension.nav.sanitize_child_order(item.key, submitted)
with transaction() as conn:
extension.settings.set(conn, "nav_order", order, actor=current_user.username)
extension.settings.set(conn, "nav_child_order", child_order, actor=current_user.username)
_audit(conn, "system.nav_order_changed", severity="info")
flash("Reihenfolge der Navigation gespeichert.", "success")
return redirect(request.referrer or url_for("index"))
#: Kurzbezeichnungen der Aktionen -- von Templates und Audit-Texten genutzt.
ACTION_LABELS: dict[Action, str] = {
action: meta[1] for action, meta in ACTION_META.items()
}
@@ -0,0 +1,150 @@
"""Diagnoseseite: Schemastand, Protokollintegritaet, Dateirechte, Lizenzzustand.
Diese Seite gab es im Vorgaenger nicht. Sie beantwortet die Fragen, die bei
jedem Zwischenfall zuerst gestellt werden -- laeuft das Schema auf dem
erwarteten Stand, ist das Aenderungsprotokoll unversehrt, liegen die
Schluesseldateien mit korrekten Rechten, ist der privilegierte Helfer
erreichbar.
"""
from __future__ import annotations
import os
import platform
import sys
from datetime import datetime, timezone
from flask import Blueprint, flash, redirect, render_template, url_for
from .. import audit
from ..auth.models import admin_required, current_user, permission_required
from ..db import Database
from ..extension import core, db, transaction
from ..keystore import check_private_file
from ..security import ratelimit
from ..security import sessions as session_store
bp = Blueprint("diagnostics", __name__, url_prefix="/diagnose")
@bp.get("/")
@permission_required("diagnostics.view")
def overview():
extension = core()
conn = db()
config = extension.config
chain = audit.verify_chain(conn, limit=5000)
key_problems: list[str] = []
for path in (config.data_key_file, config.secret_key_file, config.license_key_path):
key_problems.extend(check_private_file(path))
counts = {
"Benutzer": Database.value(conn, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL", default=0),
"Gruppen": Database.value(conn, "SELECT COUNT(*) FROM groups WHERE deleted_at IS NULL", default=0),
"Aktive Sitzungen": Database.value(
conn,
"SELECT COUNT(*) FROM user_sessions WHERE revoked_at IS NULL "
"AND idle_expires_at > datetime('now')",
default=0,
),
"Protokollzeilen": audit.count(conn),
}
try:
db_size = config.db_path.stat().st_size
except OSError:
db_size = 0
return render_template(
"tesm_core/diagnostics.html",
migrations=extension.migration_status(),
chain=chain,
key_problems=key_problems,
counts=counts,
db_size=db_size,
db_path=str(config.db_path),
log_dir=str(config.log_dir),
keystore_active=extension.keystore.active_key_id,
keystore_keys=extension.keystore.key_ids(),
sysops_available=bool(extension.sysops and extension.sysops.available),
sysops_helper=str(extension.sysops.helper) if extension.sysops else "",
# Nicht "liegt die Datei da", sondern "laesst er sich aufrufen". Der
# Unterschied hat einmal gekostet: die Seite meldete "erreichbar",
# waehrend jeder Aufruf an NoNewPrivileges scheiterte.
sysops_selftest=(extension.sysops.selftest() if extension.sysops else None),
license_state=extension.license.state if extension.license else None,
runtime={
"Python": sys.version.split()[0],
"Plattform": platform.platform(),
"Prozess": f"PID {os.getpid()}",
"Webprozess-Kennzeichen": "gesetzt" if config.web_process else "nicht gesetzt",
"Debug": "ja" if config.debug else "nein",
"Cookie Secure": "ja" if config.security.cookie_secure else "nein",
"HSTS": "ja" if config.security.hsts_enabled else "nein",
"2FA-Pflicht fuer Admins": "ja" if config.security.require_totp_for_admins else "nein",
},
now=datetime.now(timezone.utc),
)
@bp.post("/aufraeumen")
@admin_required
def housekeeping():
"""Raeumt abgelaufene Sitzungen, alte Ratenbegrenzungs-Ereignisse und Protokolle."""
extension = core()
with transaction() as conn:
sessions_removed = session_store.prune(conn)
rate_removed = ratelimit.prune(conn)
archived = audit.archive_old_rows(conn, extension.audit_archive_dir)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="system.housekeeping",
detail=(
f"{sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
f"{len(archived)} Protokolltage archiviert"
),
severity="notice",
)
flash(
f"Aufgeraeumt: {sessions_removed} Sitzungen, {rate_removed} Ratenereignisse, "
f"{len(archived)} archivierte Protokolltage.",
"success",
)
return redirect(url_for("diagnostics.overview"))
@bp.post("/schluessel-rotieren")
@admin_required
def rotate_keys():
"""Legt einen neuen Datenschluessel an und verschluesselt bestehende Werte neu.
Der Vorgaenger hatte genau einen, nie wechselbaren Fernet-Schluessel. Hier
bleibt der alte Schluessel zum Lesen erhalten, bis alle Werte umgezogen sind.
"""
extension = core()
keystore = extension.keystore
new_id = keystore.rotate()
migrated = 0
reencrypt = extension.registry.get("reencrypt_secrets")
with transaction() as conn:
if callable(reencrypt):
migrated = int(reencrypt(conn, keystore))
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="system.data_key_rotated",
target=new_id,
detail=f"{migrated} Werte neu verschluesselt",
severity="notice",
)
flash(
f"Neuer Datenschluessel {new_id} aktiv, {migrated} gespeicherte Geheimnisse "
"wurden neu verschluesselt.",
"success",
)
return redirect(url_for("diagnostics.overview"))
@@ -0,0 +1,529 @@
"""Einstellungen auf Host-Ebene: Netzwerk, Webserver/TLS, Verzeichnisdienst.
In beiden Anwendungen identisch -- darum genau einmal hier. Jede App bringt
zusaetzlich nur ihre eigene Systemseite mit.
Lizenzpflicht wird serverseitig durchgesetzt (``license.require``). Der
Vorgaenger pruefte Verzeichnisdienst und Webserver nur im Template; ein direkter
POST kam trotz fehlender Lizenz durch, obwohl die Dokumentation das Gegenteil
behauptete.
"""
from __future__ import annotations
import shutil
import threading
import time
from typing import Any
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from .. import audit
from ..auth import directory
from ..auth.models import current_user, permission_required
from ..auth.service import client_ip
from ..db import Database
from ..extension import core, db, transaction
from ..licensing_client import LicenseError
from ..rbac import service as rbac
from ..system import hostnet, webserver
bp = Blueprint("hostsettings", __name__, url_prefix="/einstellungen")
PENDING_NETWORK_KEY = "pending_network_revert"
def _audit(action: str, target: str = "", detail: str = "", severity: str = "info") -> None:
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action=action,
target=target,
detail=detail,
ip=client_ip(),
severity=severity,
)
def _license_hint(feature: str) -> str:
manager = core().license
if manager is None:
return ""
try:
manager.require(feature)
except LicenseError as exc:
return str(exc)
return ""
# --------------------------------------------------------------------------- #
# Netzwerk
# --------------------------------------------------------------------------- #
@bp.get("/netzwerk")
@permission_required("settings_network.view")
def network():
extension = core()
sysops = extension.sysops
return render_template(
"tesm_core/settings_network.html",
interfaces=hostnet.list_interfaces(sysops) if sysops else [],
backend=hostnet.detect_backend(sysops) if sysops else hostnet.BACKEND_UNKNOWN,
revert_seconds=hostnet.REVERT_SECONDS,
pending=extension.registry.get(PENDING_NETWORK_KEY),
sysops_available=bool(sysops and sysops.available),
netplan_path=f"/etc/netplan/90-{extension.config.site_key}.yaml",
)
@bp.post("/netzwerk")
@permission_required("settings_network.edit")
def network_apply():
extension = core()
sysops = extension.sysops
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("hostsettings.network"))
interface = (request.form.get("interface") or "").strip()
if interface not in {entry.name for entry in hostnet.list_interfaces(sysops)}:
flash("Unbekanntes Interface.", "danger")
return redirect(url_for("hostsettings.network"))
dhcp = request.form.get("mode") == "dhcp"
try:
if dhcp:
content = hostnet.render_netplan(interface=interface, dhcp=True)
else:
address, prefix, gateway, dns = hostnet.validate_static(
address=request.form.get("address", ""),
prefix=request.form.get("prefix", "24"),
gateway=request.form.get("gateway", ""),
dns=request.form.get("dns", ""),
)
content = hostnet.render_netplan(
interface=interface,
dhcp=False,
address=address,
prefix=prefix,
gateway=gateway,
dns=dns,
)
except ValueError as exc:
flash(str(exc), "danger")
return redirect(url_for("hostsettings.network"))
previous = sysops.run("file-read", f"/etc/netplan/90-{extension.config.site_key}.yaml")
write = sysops.write_netplan(content)
if not write.ok:
flash(f"Schreiben fehlgeschlagen: {write.message}", "danger")
return redirect(url_for("hostsettings.network"))
applied = sysops.apply_netplan()
if not applied.ok:
sysops.write_netplan(previous.stdout if previous.ok else "")
sysops.apply_netplan()
flash(
f"Uebernahme fehlgeschlagen, alter Zustand wiederhergestellt: {applied.message}",
"danger",
)
return redirect(url_for("hostsettings.network"))
extension.registry[PENDING_NETWORK_KEY] = {
"interface": interface,
"previous": previous.stdout if previous.ok else "",
"deadline": time.time() + hostnet.REVERT_SECONDS,
}
_start_revert_timer(extension)
_audit("settings.network_applied", interface, severity="warning")
flash(
f"Uebernommen. Bitte innerhalb von {hostnet.REVERT_SECONDS} Sekunden bestaetigen -- "
"sonst wird automatisch zurueckgerollt.",
"warning",
)
return redirect(url_for("hostsettings.network"))
@bp.post("/netzwerk/bestaetigen")
@permission_required("settings_network.edit")
def network_confirm():
if core().registry.pop(PENDING_NETWORK_KEY, None) is None:
flash("Es liegt keine Aenderung zur Bestaetigung vor.", "info")
else:
_audit("settings.network_confirmed", severity="notice")
flash("Netzwerkeinstellungen bestaetigt.", "success")
return redirect(url_for("hostsettings.network"))
def _start_revert_timer(extension: Any) -> None:
def worker() -> None:
time.sleep(hostnet.REVERT_SECONDS + 2)
pending = extension.registry.pop(PENDING_NETWORK_KEY, None)
if not pending:
return
sysops = extension.sysops
if sysops is not None:
sysops.write_netplan(pending.get("previous") or "")
sysops.apply_netplan()
with extension.database.session() as conn, extension.database.transaction(conn):
audit.log(
conn,
actor="system",
action="settings.network_auto_reverted",
detail="Keine Bestaetigung innerhalb der Frist -- automatisch zurueckgerollt.",
severity="warning",
)
threading.Thread(target=worker, name="network-revert", daemon=True).start()
# --------------------------------------------------------------------------- #
# Webserver und TLS
# --------------------------------------------------------------------------- #
def _web_config() -> webserver.WebConfig:
extension = core()
conn = db()
values = extension.settings.get_many(
conn,
[
"web_domain",
"web_https_enabled",
"web_hsts_enabled",
"web_redirect_http",
"web_cert_path",
"web_key_path",
"web_acme_webroot",
"web_max_body_mb",
"web_http_port",
"web_https_port",
],
)
return webserver.WebConfig(
app_key=extension.config.site_key,
upstream=str(extension.registry.get("upstream", "127.0.0.1:5000")),
static_root=str(extension.registry.get("static_root", extension.config.base_dir / "static")),
domain=str(values["web_domain"] or ""),
https_enabled=bool(values["web_https_enabled"]),
hsts_enabled=bool(values["web_hsts_enabled"]),
redirect_http=bool(values["web_redirect_http"]),
cert_path=str(values["web_cert_path"] or ""),
key_path=str(values["web_key_path"] or ""),
acme_webroot=str(values["web_acme_webroot"] or f"/var/www/{extension.config.site_key}-acme"),
websocket_path=str(extension.registry.get("websocket_path", "")),
max_body_mb=int(values["web_max_body_mb"] or 128),
http_port=int(values["web_http_port"] or 80),
https_port=int(values["web_https_port"] or 443),
)
@bp.get("/webserver")
@permission_required("settings_web.view")
def web():
extension = core()
config = _web_config()
return render_template(
"tesm_core/settings_web.html",
config=config,
preview=webserver.render(config),
certificate=webserver.certificate_info(config.cert_path),
acme_email=str(extension.settings.get(db(), "web_acme_email") or ""),
license_hint=_license_hint("nginx"),
certbot_available=bool(shutil.which("certbot")),
sysops_available=bool(extension.sysops and extension.sysops.available),
local_names=webserver.local_names(),
)
@bp.post("/webserver")
@permission_required("settings_web.edit")
def web_save():
problem = _license_hint("nginx")
if problem:
flash(problem, "danger")
return redirect(url_for("hostsettings.web"))
extension = core()
conn = db()
try:
domain = webserver.validate_domain(request.form.get("web_domain", ""))
except ValueError as exc:
flash(str(exc), "danger")
return redirect(url_for("hostsettings.web"))
https_enabled = request.form.get("web_https_enabled") == "1"
cert_path = (request.form.get("web_cert_path") or "").strip()
key_path = (request.form.get("web_key_path") or "").strip()
if https_enabled and not (cert_path and key_path):
flash("Fuer HTTPS werden Zertifikat und privater Schluessel gebraucht.", "danger")
return redirect(url_for("hostsettings.web"))
values = {
"web_domain": domain,
"web_https_enabled": https_enabled,
"web_hsts_enabled": request.form.get("web_hsts_enabled") == "1" and https_enabled,
"web_redirect_http": request.form.get("web_redirect_http") == "1",
"web_cert_path": cert_path,
"web_key_path": key_path,
"web_acme_email": (request.form.get("web_acme_email") or "").strip(),
"web_acme_webroot": (request.form.get("web_acme_webroot") or "").strip()
or f"/var/www/{extension.config.site_key}-acme",
}
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
_audit("settings.web_saved", domain, ", ".join(changed), severity="notice")
flash("Gespeichert. Uebernehmen Sie die Konfiguration, damit sie wirksam wird.", "success")
return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/uebernehmen")
@permission_required("settings_web.execute")
def web_apply():
problem = _license_hint("nginx")
if problem:
flash(problem, "danger")
return redirect(url_for("hostsettings.web"))
sysops = core().sysops
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("hostsettings.web"))
result = webserver.apply_config(sysops, webserver.render(_web_config()))
_audit(
"settings.web_applied",
detail=result.get("message", ""),
severity="notice" if result["ok"] else "warning",
)
flash(
result["message"] + (f" {result.get('detail', '')}" if not result["ok"] else ""),
"success" if result["ok"] else "danger",
)
return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/zertifikat")
@permission_required("settings_web.execute")
def web_certificate():
problem = _license_hint("nginx")
if problem:
flash(problem, "danger")
return redirect(url_for("hostsettings.web"))
extension = core()
sysops = extension.sysops
conn = db()
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("hostsettings.web"))
config = _web_config()
result = webserver.request_certificate(
sysops,
domain=config.domain,
email=str(extension.settings.get(conn, "web_acme_email") or ""),
webroot=config.acme_webroot,
)
if result["ok"]:
with transaction():
extension.settings.set_many(
conn,
{
"web_cert_path": result["cert_path"],
"web_key_path": result["key_path"],
"web_https_enabled": True,
},
actor=current_user.username,
)
_audit("settings.certificate_issued", config.domain, severity="notice")
flash(result["message"], "success" if result["ok"] else "danger")
return redirect(url_for("hostsettings.web"))
@bp.post("/webserver/selbstsigniert")
@permission_required("settings_web.execute")
def web_self_signed():
"""Erzeugt ein selbstsigniertes Zertifikat -- fuer Netze ohne Domain.
Danach wird HTTPS aktiviert, aber **nicht** automatisch uebernommen: der
Administrator soll die erzeugte Konfiguration erst sehen. Ein Reload, der
die eigene Sitzung ueber einen anderen Port wegwirft, kommt nie ungefragt.
"""
problem = _license_hint("nginx")
if problem:
flash(problem, "danger")
return redirect(url_for("hostsettings.web"))
extension = core()
sysops = extension.sysops
conn = db()
if sysops is None or not sysops.available:
flash("Systemaktionen sind in dieser Umgebung nicht verfuegbar.", "warning")
return redirect(url_for("hostsettings.web"))
config = _web_config()
suggested = webserver.local_names()
common_name = config.domain or (suggested[0] if suggested else "")
result = webserver.create_self_signed(
sysops, common_name=common_name, alt_names=[*suggested, "localhost"]
)
if result["ok"]:
with transaction():
extension.settings.set_many(
conn,
{
"web_cert_path": result["cert_path"],
"web_key_path": result["key_path"],
"web_https_enabled": True,
},
actor=current_user.username,
)
_audit("settings.self_signed_issued", common_name, severity="notice")
flash(
result["message"] + (f" {result.get('detail', '')}" if not result["ok"] else ""),
"success" if result["ok"] else "danger",
)
return redirect(url_for("hostsettings.web"))
# --------------------------------------------------------------------------- #
# Verzeichnisdienst
# --------------------------------------------------------------------------- #
@bp.get("/verzeichnisdienst")
@permission_required("settings_directory.view")
def directory_settings():
extension = core()
conn = db()
bind_username, _ = directory.bind_credentials(conn)
return render_template(
"tesm_core/settings_directory.html",
values=extension.settings.get_many(conn, [spec.key for spec in directory.SETTINGS]),
specs={spec.key: spec for spec in directory.SETTINGS},
bind_username=bind_username,
mappings=Database.all(
conn,
"SELECT m.*, g.name AS group_name FROM ldap_group_mappings m "
"LEFT JOIN groups g ON g.id = m.app_group_id ORDER BY m.ad_group_name",
),
groups=rbac.list_groups(conn),
license_hint=_license_hint("directory"),
)
@bp.post("/verzeichnisdienst")
@permission_required("settings_directory.edit")
def directory_save():
problem = _license_hint("directory")
if problem:
flash(problem, "danger")
return redirect(url_for("hostsettings.directory_settings"))
extension = core()
conn = db()
values: dict[str, Any] = {}
for spec in directory.SETTINGS:
if spec.kind == "bool":
values[spec.key] = request.form.get(spec.key) == "1"
elif spec.key in request.form:
values[spec.key] = request.form.get(spec.key)
if values.get("ldap_security") == "none" and not values.get("ldap_allow_insecure"):
flash(
"Eine unverschluesselte Verbindung muss ausdruecklich erlaubt werden -- das Passwort "
"geht dabei im Klartext ueber das Netz.",
"danger",
)
return redirect(url_for("hostsettings.directory_settings"))
with transaction():
changed = extension.settings.set_many(conn, values, actor=current_user.username)
if current_user.has("settings_directory.secrets"):
directory.store_bind_credentials(
conn,
request.form.get("bind_username", ""),
request.form.get("bind_password") or None,
)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.directory_saved",
detail=", ".join(changed),
ip=client_ip(),
severity="notice",
)
flash("Verzeichniseinstellungen gespeichert.", "success")
return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/test")
@permission_required("settings_directory.execute")
def directory_test():
result = directory.test_connection(db())
flash(result["message"], "success" if result["ok"] else "danger")
return redirect(url_for("hostsettings.directory_settings"))
@bp.get("/verzeichnisdienst/gruppensuche")
@permission_required("settings_directory.edit", "fileshare.edit", mode="any")
def directory_group_search():
"""Sucht Gruppen im Verzeichnis -- fuer die Auswahlfelder.
Auch mit ``fileshare.edit`` erreichbar: wer Freigaben zuordnen darf, muss
die Gruppen finden koennen, ohne zugleich den Verzeichnisdienst
konfigurieren zu duerfen. Gelesen wird nur, und nur mit dem hinterlegten
Dienstkonto -- es entsteht kein Zugriff, den der Server nicht ohnehin hat.
"""
return jsonify(
{"results": directory.search_directory_groups(db(), request.args.get("q") or "")}
)
@bp.post("/verzeichnisdienst/zuordnung")
@permission_required("settings_directory.edit")
def directory_mapping_add():
conn = db()
dn = (request.form.get("ad_group_dn") or "").strip()
name = (request.form.get("ad_group_name") or "").strip()
group_id = request.form.get("app_group_id")
if not dn or not group_id:
flash("Verzeichnisgruppe und Zielgruppe sind beide erforderlich.", "danger")
return redirect(url_for("hostsettings.directory_settings"))
with transaction():
conn.execute(
"INSERT INTO ldap_group_mappings (ad_group_dn, ad_group_name, app_group_id, created_at) "
"VALUES (?,?,?,datetime('now')) ON CONFLICT(ad_group_dn) DO UPDATE SET "
"ad_group_name=excluded.ad_group_name, app_group_id=excluded.app_group_id",
(dn, name or dn, int(group_id)),
)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.directory_mapping_added",
target=name or dn,
ip=client_ip(),
severity="notice",
)
flash("Zuordnung gespeichert.", "success")
return redirect(url_for("hostsettings.directory_settings"))
@bp.post("/verzeichnisdienst/zuordnung/<int:mapping_id>/loeschen")
@permission_required("settings_directory.edit")
def directory_mapping_delete(mapping_id: int):
with transaction() as conn:
conn.execute("DELETE FROM ldap_group_mappings WHERE id=?", (mapping_id,))
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="settings.directory_mapping_deleted",
target=str(mapping_id),
ip=client_ip(),
severity="notice",
)
flash("Zuordnung entfernt.", "success")
return redirect(url_for("hostsettings.directory_settings"))
@@ -0,0 +1,239 @@
"""Protokollansichten, die beide Anwendungen teilen.
* **Laufend** -- die Textdatei ``live.log``.
* **Verlauf** -- die von logrotate rotierten Kopien plus die aus der Datenbank
ausgelagerten Audit-Tage.
* **Aenderungen** -- die Tabelle ``audit_log`` mit pruefbarer Hash-Kette.
Was ``live.log`` fuellt, entscheidet die Anwendung: TESM schreibt dort
Erreichbarkeit und PoE-Vorgaenge; der Lizenzserver spiegelt dorthin seinen
Audit-Trail, weil es bei ihm keinen Telemetrie-Prozess gibt. Beides landet in
derselben Ansicht -- der Vorgaenger hatte dafuer zwei getrennte Umsetzungen.
"""
from __future__ import annotations
import gzip
import io
import zipfile
from pathlib import Path
from flask import Blueprint, Response, abort, flash, redirect, render_template, request, url_for
from .. import audit
from ..auth.models import current_user, permission_required
from ..auth.service import client_ip
from ..extension import core, db, transaction
bp = Blueprint("logs", __name__, url_prefix="/protokolle")
MAX_TAIL_LINES = 2000
def _live_log():
log = core().registry.get("live_log")
if log is None: # pragma: no cover - jede App registriert eines
abort(404)
return log
@bp.get("/laufend")
@permission_required("logs_live.view")
def live():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip()
return render_template(
"tesm_core/logs_live.html",
lines=_live_log().tail(lines, needle=needle),
path=str(_live_log().path),
query=needle,
line_count=lines,
)
@bp.get("/laufend/inhalt")
@permission_required("logs_live.view")
def live_fragment():
lines = min(int(request.args.get("lines") or 400), MAX_TAIL_LINES)
needle = (request.args.get("q") or "").strip()
return render_template(
"tesm_core/_log_lines.html", lines=_live_log().tail(lines, needle=needle)
)
@bp.get("/verlauf")
@permission_required("logs_history.view", "logs_history.export", mode="any")
def history():
extension = core()
rotated = []
for path in sorted(extension.config.log_dir.glob("*.log.*"), reverse=True):
try:
stat = path.stat()
except OSError:
continue
rotated.append({"name": path.name, "size": stat.st_size, "modified": stat.st_mtime})
return render_template(
"tesm_core/logs_history.html",
rotated=rotated,
archived=audit.archived_days(extension.audit_archive_dir),
log_dir=str(extension.config.log_dir),
)
@bp.get("/verlauf/datei/<path:name>")
@permission_required("logs_history.view")
def history_file(name: str):
extension = core()
target = (extension.config.log_dir / name).resolve()
if extension.config.log_dir.resolve() not in target.parents or not target.is_file():
abort(404)
try:
if target.suffix == ".gz":
content = gzip.decompress(target.read_bytes()).decode("utf-8", errors="replace")
else:
content = target.read_text(encoding="utf-8", errors="replace")
except OSError as exc:
abort(500, description=f"Datei nicht lesbar: {exc}")
return render_template(
"tesm_core/logs_file.html", name=name, lines=content.splitlines()[-MAX_TAIL_LINES:]
)
@bp.get("/verlauf/archiv/<day>")
@permission_required("logs_history.view")
def history_archive(day: str):
if len(day) != 10 or day.count("-") != 2:
abort(404)
rows = audit.read_archived_day(core().audit_archive_dir, day)
if not rows:
abort(404)
return render_template("tesm_core/logs_archive.html", day=day, rows=rows)
@bp.post("/verlauf/archiv-export")
@permission_required("logs_history.export")
def history_export():
"""Packt die archivierten Audit-Tage in ein ZIP.
Anders als im Vorgaenger loescht der Export die Dateien **nicht**: ein
Download, der Beweismittel vom Server entfernt, ist keine gute Idee. Das
Loeschen ist ein eigener, ausdruecklicher Schritt.
"""
extension = core()
entries = audit.archived_days(extension.audit_archive_dir)
if not entries:
flash("Es sind keine Archivdateien vorhanden.", "info")
return redirect(url_for("logs.history"))
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
for entry in entries:
archive.write(entry["path"], arcname=Path(entry["path"]).name)
buffer.seek(0)
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="logs.archive_exported",
detail=f"{len(entries)} Tage",
ip=client_ip(),
severity="notice",
)
return Response(
buffer.getvalue(),
mimetype="application/zip",
headers={
"Content-Disposition": f'attachment; filename="{extension.config.app_key}-auditarchiv.zip"'
},
)
@bp.post("/verlauf/archiv-loeschen")
@permission_required("logs_history.delete")
def history_purge():
removed = 0
for entry in audit.archived_days(core().audit_archive_dir):
try:
Path(entry["path"]).unlink()
removed += 1
except OSError:
continue
with transaction() as conn:
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="logs.archive_deleted",
detail=f"{removed} Dateien",
ip=client_ip(),
severity="warning",
)
flash(f"{removed} Archivdatei(en) entfernt.", "success")
return redirect(url_for("logs.history"))
@bp.get("/aenderungen")
@permission_required("logs_audit.view", "logs_audit.export", mode="any")
def audit_view():
conn = db()
page = max(1, int(request.args.get("seite") or 1))
per_page = 100
return render_template(
"tesm_core/logs_audit.html",
rows=audit.search(
conn,
query=(request.args.get("q") or "").strip(),
action=(request.args.get("aktion") or "").strip(),
actor=(request.args.get("benutzer") or "").strip(),
severity=(request.args.get("stufe") or "").strip(),
limit=per_page,
offset=(page - 1) * per_page,
),
total=audit.count(conn),
actions=audit.distinct_actions(conn),
chain=audit.verify_chain(conn, limit=2000),
page=page,
per_page=per_page,
filters={
"q": request.args.get("q") or "",
"aktion": request.args.get("aktion") or "",
"benutzer": request.args.get("benutzer") or "",
"stufe": request.args.get("stufe") or "",
},
)
@bp.get("/aenderungen/export")
@permission_required("logs_audit.export")
def audit_export():
conn = db()
rows = audit.search(conn, limit=100000)
lines = ["ts;actor;action;target;detail;ip;severity;hash"]
for row in rows:
lines.append(
";".join(
str(row.get(key) or "").replace(";", ",").replace("\n", " ")
for key in ("ts", "actor", "action", "target", "detail", "ip", "severity", "hash")
)
)
with transaction():
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="logs.audit_exported",
detail=f"{len(rows)} Zeilen",
ip=client_ip(),
severity="notice",
)
return Response(
"\n".join(lines).encode("utf-8-sig"),
mimetype="text/csv; charset=utf-8",
headers={
"Content-Disposition": (
f'attachment; filename="{core().config.app_key}-aenderungsprotokoll.csv"'
)
},
)
@@ -0,0 +1,282 @@
"""Papierkorb -- generisch ueber angemeldete Anbieter.
Der Vorgaenger hatte den Papierkorb nur in TESM und dort fuer fuenf fest
verdrahtete Tabellen; der Lizenzserver-Fork hatte gar keinen, obwohl die
``deleted_at``-Spalten noch existierten (ein Artefakt, das die eigene
Spezifikation als "nicht nachbauen" markiert).
Hier ist der Papierkorb ein Kern-Feature mit einem Register: jede Anwendung
meldet ihre loeschbaren Ressourcen an, die Oberflaeche ist fuer alle gleich.
Benutzer und Gruppen registriert der Kern selbst -- damit haben **beide**
Anwendungen einen funktionierenden Papierkorb, statt einer von beiden gar
keinen.
"""
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from .. import audit
from ..auth.models import current_user, permission_required
from ..auth.service import client_ip
from ..db import Database
from ..extension import core, db, transaction
from ..rbac import service as rbac
bp = Blueprint("trash", __name__, url_prefix="/papierkorb")
@dataclass(frozen=True, slots=True)
class TrashProvider:
"""Eine im Papierkorb verwaltbare Ressourcenart."""
key: str
label: str
icon: str
list_fn: Callable[[sqlite3.Connection], list[dict[str, Any]]]
restore_fn: Callable[[sqlite3.Connection, str], None]
purge_fn: Callable[[sqlite3.Connection, str], None]
hint: str = ""
def register(provider: TrashProvider) -> None:
"""Meldet einen Anbieter an. Wird beim App-Aufbau aufgerufen."""
providers: dict[str, TrashProvider] = core().registry.setdefault("trash_providers", {})
providers[provider.key] = provider
def providers() -> dict[str, TrashProvider]:
return core().registry.get("trash_providers", {})
# --------------------------------------------------------------------------- #
# Kern-Anbieter: Benutzer und Gruppen
# --------------------------------------------------------------------------- #
def _users_in_trash(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return [
{
"id": str(row["id"]),
"label": str(row["username"]),
"detail": f"{row['first_name']} {row['last_name']}".strip() or "-",
"deleted_at": row["deleted_at"],
}
for row in Database.all(
conn,
"SELECT id, username, first_name, last_name, deleted_at FROM users "
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC",
)
]
def _groups_in_trash(conn: sqlite3.Connection) -> list[dict[str, Any]]:
return [
{
"id": str(row["id"]),
"label": str(row["name"]),
"detail": str(row["description"] or "-"),
"deleted_at": row["deleted_at"],
}
for row in Database.all(
conn,
"SELECT id, name, description, deleted_at FROM groups "
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC",
)
]
def register_core_providers() -> None:
register(
TrashProvider(
key="users",
label="Benutzerkonten",
icon="users",
list_fn=_users_in_trash,
restore_fn=lambda conn, ident: rbac.restore_user(conn, int(ident)),
purge_fn=lambda conn, ident: rbac.purge_user(conn, int(ident)),
hint="Verzeichniskonten landen nie hier -- sie legen sich bei der naechsten "
"Anmeldung von selbst wieder an.",
)
)
register(
TrashProvider(
key="groups",
label="Gruppen",
icon="shield-users",
list_fn=_groups_in_trash,
restore_fn=lambda conn, ident: rbac.restore_group(conn, int(ident)),
purge_fn=lambda conn, ident: rbac.purge_group(conn, int(ident)),
hint="Beim endgueltigen Loeschen verschwinden auch Mitgliedschaften und Rechte.",
)
)
# --------------------------------------------------------------------------- #
# Ansicht
# --------------------------------------------------------------------------- #
def _retention_days() -> int:
return int(core().settings.get(db(), "trash_retention_days") or 30)
def _purge_expired(conn: sqlite3.Connection) -> int:
"""Entfernt abgelaufene Eintraege beim Seitenaufruf.
Bewusst ohne eigenen Scheduler: ein zusaetzlicher Timer waere ein weiterer
Prozess, der schiefgehen kann. Die Frist ist eine Obergrenze, kein
sekundengenauer Vorgang.
"""
horizon = (datetime.now(timezone.utc) - timedelta(days=_retention_days())).isoformat(
timespec="seconds"
)
removed = 0
for provider in providers().values():
for entry in provider.list_fn(conn):
if str(entry.get("deleted_at") or "") and str(entry["deleted_at"]) < horizon:
provider.purge_fn(conn, str(entry["id"]))
removed += 1
return removed
@bp.get("/")
@permission_required("trash.view")
def overview():
conn = db()
removed = 0
if current_user.has("trash.delete"):
with transaction():
removed = _purge_expired(conn)
if removed:
audit.log(
conn,
actor="system",
action="trash.auto_purged",
detail=f"{removed} abgelaufene Eintraege",
severity="notice",
)
if removed:
flash(f"{removed} abgelaufene Eintraege wurden endgueltig entfernt.", "info")
sections = []
for provider in providers().values():
entries = provider.list_fn(conn)
sections.append(
{
"key": provider.key,
"label": provider.label,
"icon": provider.icon,
"hint": provider.hint,
"entries": entries,
}
)
return render_template(
"tesm_core/trash.html",
sections=sections,
retention_days=_retention_days(),
total=sum(len(section["entries"]) for section in sections),
)
def _provider_or_404(key: str) -> TrashProvider:
provider = providers().get(key)
if provider is None:
abort(404)
return provider
@bp.post("/<kind>/<ident>/wiederherstellen")
@permission_required("trash.edit")
def restore(kind: str, ident: str):
extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash(
"Das Wiederherstellen aus dem Papierkorb erfordert eine gueltige, "
"aktivierte Lizenz. Ansehen bleibt jederzeit moeglich.",
"danger",
)
return redirect(url_for("trash.overview"))
provider = _provider_or_404(kind)
try:
with transaction() as conn:
provider.restore_fn(conn, ident)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="trash.restored",
target=f"{kind}:{ident}",
ip=client_ip(),
severity="notice",
)
flash("Eintrag wiederhergestellt.", "success")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("trash.overview"))
@bp.post("/<kind>/<ident>/endgueltig-loeschen")
@permission_required("trash.delete")
def purge(kind: str, ident: str):
extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash(
"Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.",
"danger",
)
return redirect(url_for("trash.overview"))
provider = _provider_or_404(kind)
try:
with transaction() as conn:
provider.purge_fn(conn, ident)
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="trash.purged",
target=f"{kind}:{ident}",
ip=client_ip(),
severity="warning",
)
flash("Eintrag endgueltig entfernt.", "success")
except (rbac.ValidationError, rbac.PermissionDenied) as exc:
flash(str(exc), "danger")
return redirect(url_for("trash.overview"))
@bp.post("/leeren")
@permission_required("trash.delete")
def purge_all():
extension = core()
if extension.license is not None and not extension.license.feature_allowed("trash_restore"):
flash("Das endgueltige Loeschen erfordert eine gueltige, aktivierte Lizenz.", "danger")
return redirect(url_for("trash.overview"))
kind = request.form.get("kind") or ""
removed = 0
with transaction() as conn:
targets = [providers()[kind]] if kind in providers() else list(providers().values())
for provider in targets:
for entry in provider.list_fn(conn):
provider.purge_fn(conn, str(entry["id"]))
removed += 1
audit.log(
conn,
actor=current_user.username,
actor_id=current_user.id,
action="trash.emptied",
target=kind or "alle",
detail=f"{removed} Eintraege",
ip=client_ip(),
severity="warning",
)
flash(f"{removed} Eintraege endgueltig entfernt.", "success")
return redirect(url_for("trash.overview"))