diff --git a/create_pod_finance.sh b/create_pod_finance.sh index 9ef0a15..096669e 100755 --- a/create_pod_finance.sh +++ b/create_pod_finance.sh @@ -212,6 +212,12 @@ podman exec "$DB_CTR_NAME" psql -U finance -d finance -c \ echo "Role 'finance_read' is ready." # API container (runs alembic upgrade head on start via entrypoint.sh) +# The extra "-v $ENV_FILE:/data/.env:Z" below (Ausbaustufe 4 Task 2, Admin +# password change) is a SINGLE-FILE bind mount. Unlike a directory mount, +# this follows the host file's INODE: the app must read/write it in place +# (open r+/truncate under flock - see app/services/admin.py), NEVER via +# temp-file+rename, because a rename would swap in a new inode that the +# already-running mount no longer points at. podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \ -e FB_DATABASE_URL="postgresql+psycopg://finance:$POSTGRES_PASSWORD@localhost:5432/finance" \ -e FB_API_KEY \ @@ -221,6 +227,7 @@ podman run -d --name "$API_CTR_NAME" --pod "$POD_NAME" \ -e FB_INBOX_DIR=/data/inbox \ -e FB_UPLOADS_DIR=/data/uploads \ -v "$DATA_DIR:/data:Z" \ + -v "$ENV_FILE:/data/.env:Z" \ "$API_IMAGE" echo "Container '$API_CTR_NAME' started (rc=$?)" diff --git a/finance/app/auth.py b/finance/app/auth.py index 098c4b3..243f90a 100644 --- a/finance/app/auth.py +++ b/finance/app/auth.py @@ -1,6 +1,8 @@ import hashlib import hmac +import re import secrets +from pathlib import Path from fastapi import HTTPException, Request from itsdangerous import BadSignature, TimestampSigner @@ -10,6 +12,48 @@ from app.config import get_settings COOKIE = "fb_session" MAX_AGE = 60 * 60 * 12 # 12 h +# mtime-Cache fuer den aus der (ggf. bind-gemounteten) .env gelesenen +# GUI-Passwort-Hash: {Pfad: (mtime, hash)}. Vermeidet, die Datei bei jedem +# Login/Request neu zu parsen, invalidiert sich aber automatisch, sobald +# services.admin.change_password() die Datei in-place neu schreibt (neue +# mtime). +_hash_cache: dict[str, tuple[float, str]] = {} + +_ENV_LINE_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)='([^']*)'\s*$") + + +def _parse_env_file(env_file: Path) -> dict[str, str]: + """Parst single-quoted KEY='value'-Zeilen wie sie create_pod_finance.sh + schreibt. Zeilen in anderer Form (Kommentare, unquoted, leer) werden + ignoriert statt einen Fehler zu werfen.""" + values: dict[str, str] = {} + for line in env_file.read_text().splitlines(): + m = _ENV_LINE_RE.match(line) + if m: + values[m.group(1)] = m.group(2) + return values + + +def current_password_hash() -> str: + """Liest FB_GUI_PASSWORD_HASH zur Laufzeit aus der .env (Bind-Mount, + siehe KRITISCH-Hinweis Global Constraints Ausbaustufe 4: Einzeldatei-Mount + folgt dem Inode, die Datei wird von services.admin.change_password() + in-place ueberschrieben). Existiert die Datei nicht (z.B. lokale + Entwicklung ohne Pod), wird auf die Umgebungsvariable zurueckgefallen.""" + settings = get_settings() + env_file = settings.env_file + if env_file.exists(): + mtime = env_file.stat().st_mtime + key = str(env_file) + cached = _hash_cache.get(key) + if cached is not None and cached[0] == mtime: + return cached[1] + value = _parse_env_file(env_file).get("FB_GUI_PASSWORD_HASH") + if value is not None: + _hash_cache[key] = (mtime, value) + return value + return settings.gui_password_hash + def hash_password(pw: str, salt: str | None = None) -> str: salt = salt or secrets.token_hex(16) diff --git a/finance/app/config.py b/finance/app/config.py index 12fdc74..7b36a76 100644 --- a/finance/app/config.py +++ b/finance/app/config.py @@ -16,6 +16,8 @@ class Settings: uploads_dir: Path warn_threshold: Decimal horizon_days: int + env_file: Path + grafana_url: str @lru_cache @@ -31,4 +33,9 @@ def get_settings() -> Settings: uploads_dir=Path(e("FB_UPLOADS_DIR", "./uploads")), warn_threshold=Decimal(e("FB_WARN_THRESHOLD", "0")), horizon_days=int(e("FB_HORIZON_DAYS", "548")), + # Bind-gemountete .env (siehe create_pod_finance.sh): Laufzeit-Lesen + # der GUI-Passwort-Hashes fuer die Admin-Passwortaenderung (Ausbaustufe + # 4 Task 2). Default passt zum Container-Mountpunkt "/data/.env". + env_file=Path(e("FB_ENV_FILE", "/data/.env")), + grafana_url=e("FB_GRAFANA_URL", "http://localhost:3000"), ) diff --git a/finance/app/main.py b/finance/app/main.py index d28b058..be9be9d 100644 --- a/finance/app/main.py +++ b/finance/app/main.py @@ -10,7 +10,7 @@ from app.auth import require_auth from app.config import get_settings from app.db import get_engine from app.models.views import create_views -from app.routers import (accounts, categories, gui, imports, planning, +from app.routers import (accounts, admin, categories, gui, imports, planning, scenarios, transactions) from app.routers.gui import templates from app.version import get_version @@ -33,6 +33,7 @@ app.include_router(categories.router) app.include_router(imports.router) app.include_router(planning.router) app.include_router(scenarios.router) +app.include_router(admin.router) @app.get("/login") @@ -44,7 +45,7 @@ def login_form(request: Request): def login(username: str = Form(...), password: str = Form(...)): s = get_settings() if not (hmac.compare_digest(username, s.gui_user) - and auth.verify_password(password, s.gui_password_hash)): + and auth.verify_password(password, auth.current_password_hash())): return HTMLResponse("Login fehlgeschlagen", status_code=401) resp = RedirectResponse("/", status_code=303) resp.set_cookie(auth.COOKIE, auth.make_session_token(), httponly=True, diff --git a/finance/app/routers/admin.py b/finance/app/routers/admin.py new file mode 100644 index 0000000..17dc2c5 --- /dev/null +++ b/finance/app/routers/admin.py @@ -0,0 +1,52 @@ +from fastapi import APIRouter, Depends, Form, Request +from sqlalchemy.orm import Session + +from app.auth import require_auth +from app.db import get_session +from app.routers.gui import gui_session, templates +from app.services.admin import apply_rules_retroactively, change_password + +router = APIRouter() + + +@router.get("/admin", dependencies=[Depends(gui_session)]) +def admin_page(request: Request): + return templates.TemplateResponse(request, "admin.html", {"error": None, "success": None}) + + +@router.post("/admin/passwort", dependencies=[Depends(gui_session)]) +def admin_change_password( + request: Request, + alt: str = Form(...), + neu: str = Form(...), + neu2: str = Form(...), +): + # UX-Regel: das Formular selbst bleibt immer sichtbar/bedienbar; Fehler + # werden inline auf derselben Seite gemeldet statt still zu verwerfen. + if len(neu) < 8: + return templates.TemplateResponse(request, "admin.html", { + "error": "Das neue Passwort muss mindestens 8 Zeichen lang sein.", + "success": None, + }, status_code=400) + if neu != neu2: + return templates.TemplateResponse(request, "admin.html", { + "error": "Die Wiederholung stimmt nicht mit dem neuen Passwort überein.", + "success": None, + }, status_code=400) + try: + change_password(alt, neu) + except (ValueError, RuntimeError) as exc: + return templates.TemplateResponse(request, "admin.html", { + "error": str(exc), + "success": None, + }, status_code=400) + return templates.TemplateResponse(request, "admin.html", { + "error": None, + "success": "Passwort erfolgreich geändert (gilt für GUI und Grafana). " + "Bestehende Sitzungen bleiben angemeldet.", + }) + + +@router.post("/api/category-rules/apply", dependencies=[Depends(require_auth)]) +def apply_category_rules(session: Session = Depends(get_session)): + return {"categorized": apply_rules_retroactively(session)} diff --git a/finance/app/services/admin.py b/finance/app/services/admin.py new file mode 100644 index 0000000..e44e8c2 --- /dev/null +++ b/finance/app/services/admin.py @@ -0,0 +1,116 @@ +"""Admin-Funktionen: Passwortaenderung (GUI+Grafana gemeinsam) und +Regel-Neuanwendung (Ausbaustufe 4 Task 2). + +KRITISCH (siehe Global Constraints des Plans): die .env wird im API-Container +als Einzeldatei-Bind-Mount eingehaengt. Ein solcher Mount folgt dem Inode - +die Datei MUSS in-place ueberschrieben werden (open r+/truncate unter flock), +NIEMALS ueber Temp-Datei+rename (das erzeugt einen neuen Inode und würde vom +Container nicht mehr gesehen). +""" +import fcntl +import json +import urllib.error +import urllib.request +from base64 import b64encode +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.auth import current_password_hash, hash_password, verify_password +from app.config import get_settings +from app.models.tables import Transaction +from app.services.categorize import apply_rules + + +def _sync_grafana_password(old: str, new: str, base_url: str) -> None: + """PUT /api/admin/users/1/password gegen Grafana, Basic-Auth mit dem + ALTEN Passwort (admin:old) - Grafana authentifiziert den Request noch mit + dem bisherigen Passwort, aendert es aber auf `new`. Wird IMMER vor dem + Schreiben der .env aufgerufen: schlaegt Grafana fehl, bleibt die Datei + unangetastet (kein inkonsistenter Zwischenzustand GUI-Hash != Grafana).""" + url = f"{base_url.rstrip('/')}/api/admin/users/1/password" + auth_header = "Basic " + b64encode(f"admin:{old}".encode()).decode() + body = json.dumps({"password": new}).encode() + req = urllib.request.Request( + url, data=body, method="PUT", + headers={"Authorization": auth_header, "Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + if resp.status >= 400: + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen (HTTP {resp.status})." + ) + except urllib.error.HTTPError as exc: + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen (HTTP {exc.code})." + ) from exc + except urllib.error.URLError as exc: + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen: {exc.reason}" + ) from exc + except (TimeoutError, OSError) as exc: + # Ein Hang in der Lesephase (nach erfolgreichem Verbindungsaufbau) + # kann ein rohes TimeoutError/socket.timeout werfen, das urlopen NICHT + # in ein URLError verpackt (URLError selbst ist zwar ein OSError, aber + # dieser Pfad faengt Faelle ab, die es nicht bis dorthin schaffen). + # Ohne diesen Fang wuerde die Exception bis in den Router durchschlagen + # und dort als 500 statt als deutsche 400-Fehlermeldung enden. + raise RuntimeError( + f"Grafana-Passwortänderung fehlgeschlagen: {exc}" + ) from exc + + +def _rewrite_env_file(env_file: Path, new_password: str, new_hash: str) -> None: + """Ersetzt FB_PASSWORD und FB_GUI_PASSWORD_HASH IN-PLACE (open r+ unter + flock, truncate) - der Inode der Datei bleibt unveraendert, siehe + Modul-Docstring.""" + with open(env_file, "r+", encoding="utf-8") as f: + fcntl.flock(f, fcntl.LOCK_EX) + try: + lines = f.read().splitlines() + out = [] + for line in lines: + if line.startswith("FB_PASSWORD="): + out.append(f"FB_PASSWORD='{new_password}'") + elif line.startswith("FB_GUI_PASSWORD_HASH="): + out.append(f"FB_GUI_PASSWORD_HASH='{new_hash}'") + else: + out.append(line) + f.seek(0) + f.write("\n".join(out) + "\n") + f.truncate() + finally: + fcntl.flock(f, fcntl.LOCK_UN) + + +def change_password(old: str, new: str) -> None: + """Aendert das gemeinsame GUI-/Grafana-Passwort. Reihenfolge ist bindend: + 1) altes Passwort gegen den aktuellen Hash verifizieren, + 2) Grafana AKTUALISIEREN (bricht bei Fehler ab, ohne die .env + anzufassen), + 3) erst danach die .env in-place neu schreiben. + """ + if not verify_password(old, current_password_hash()): + raise ValueError("Das alte Passwort ist falsch.") + + settings = get_settings() + _sync_grafana_password(old, new, settings.grafana_url) + + new_hash = hash_password(new) + _rewrite_env_file(settings.env_file, new, new_hash) + + +def apply_rules_retroactively(session: Session) -> int: + """Wendet die aktuellen Kategorie-Regeln rueckwirkend auf alle + bestaetigten, noch unkategorisierten Buchungen an und committet.""" + txs = session.execute( + select(Transaction).where( + Transaction.status == "confirmed", + Transaction.category_id.is_(None), + ) + ).scalars().all() + hits = apply_rules(session, txs) + session.commit() + return hits diff --git a/finance/app/static/style.css b/finance/app/static/style.css index 9d8c603..2d4201b 100644 --- a/finance/app/static/style.css +++ b/finance/app/static/style.css @@ -72,6 +72,14 @@ td.account { border-radius: 4px; } +.success { + background: #d4edda; + border: 1px solid #7bc088; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: 4px; +} + .total-balance { font-size: 1.5rem; font-weight: bold; diff --git a/finance/app/templates/admin.html b/finance/app/templates/admin.html new file mode 100644 index 0000000..c1f9027 --- /dev/null +++ b/finance/app/templates/admin.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}Admin – Finanzberatung{% endblock %} +{% block content %} +
Gilt gemeinsam für GUI- und Grafana-Login. Bestehende Sitzungen bleiben nach der Änderung gültig.
+ {% if error %}Wendet die aktuellen Kategorie-Regeln rückwirkend auf noch nicht kategorisierte, bestätigte Buchungen an.
+ + +