" % html.escape(notice) if notice else ""
- # Der Registrierungs-Link wird nur angezeigt, solange die Ersteinrichtung
- # noch nicht abgeschlossen ist -- danach ist /setup/new gesperrt, ein
- # Link dorthin waere ein toter Verweis (Nutzer-Vorgabe 2026-07-13).
- setup_link_html = ('
' % (html.escape(label), value)
-
-
-def _tickets_body(tenant_id, filter_status=None, filter_kategorie=None):
- tickets = db.list_tickets(tenant_id)
- kpis = _kpis(tickets)
- kpi_html = "".join(_kpi_tile(k, v) for k, v in kpis.items())
- shown = tickets
- if filter_kategorie:
- shown = [t for t in shown if t["kategorie"] == filter_kategorie]
- tabs = [("", "Alle"), ("Offen", "Offen"), ("In Bearbeitung", "In Bearbeitung"),
- ("Warten", "Warten"), ("Geloest", "Geloest"), ("Ueberfaellig", "Ueberfaellig")]
- base = "/probleme" if filter_kategorie == "Problem" else (
- "/aenderungen" if filter_kategorie == "Change" else (
- "/releases" if filter_kategorie == "Release" else "/tickets"))
- tabs_html = "".join(
- '%s' % (
- base, ("?status=" + s) if s else "", "active" if (s or "") == (filter_status or "") else "", label)
- for s, label in tabs)
- if filter_status == "Ueberfaellig":
- shown = [t for t in shown if _is_overdue(t)]
- elif filter_status:
- shown = [t for t in shown if t["status"] == filter_status]
- rows = "".join(_ticket_row(t) for t in shown) or '
Keine Tickets.
'
- services = db.list_services(tenant_id)
- svc_opts = "".join('' % (s["id"], html.escape(s["name"])) for s in services)
- new_form = """
Neues Ticket
-
""" % (
- "".join('' % (c, c) for c in CATEGORIES),
- "".join('' % (p, p) for p in PRIORITIES), svc_opts)
- return """
Tickets
-
%s
-%s
-
%s
-
-
Ticket-Nr
Titel (Service)
Status
Prioritaet
-
Kategorie
Zugewiesen an
Aktualisiert
Fortschritt
-
%s
-""" % (kpi_html, new_form, tabs_html, rows)
-
-
-@app.route("/tickets")
-def tickets_route():
- r = _require_login()
- if r:
- return r
- return _shell("/tickets", "Tickets", _tickets_body(session["tenant_id"], request.args.get("status")))
-
-
-@app.route("/probleme")
-def probleme_route():
- r = _require_login()
- if r:
- return r
- return _shell("/probleme", "Probleme", _tickets_body(session["tenant_id"], request.args.get("status"), "Problem"))
-
-
-@app.route("/aenderungen")
-def aenderungen_route():
- r = _require_login()
- if r:
- return r
- return _shell("/aenderungen", "Aenderungen", _tickets_body(session["tenant_id"], request.args.get("status"), "Change"))
-
-
-@app.route("/releases")
-def releases_route():
- r = _require_login()
- if r:
- return r
- return _shell("/releases", "Releases", _tickets_body(session["tenant_id"], request.args.get("status"), "Release"))
-
-
-@app.route("/tickets/new", methods=["POST"])
-def tickets_new_route():
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- service_id = request.form.get("service_id") or None
- tid, ticket_nr = db.create_ticket(
- tenant_id,
- request.form.get("titel", "").strip() or "(ohne Titel)",
- request.form.get("beschreibung", "").strip(),
- int(service_id) if service_id else None,
- request.form.get("prioritaet", "Mittel"),
- request.form.get("kategorie", "Task"),
- session.get("email"),
- session["user_id"],
- 480, 2880,
- session.get("email"),
- )
- db.log_audit(tenant_id, session["user_id"], "ticket_created", "ticket", ticket_nr, None, _client_ip())
- return redirect(url_for("tickets_route"))
-
-
-@app.route("/tickets//status", methods=["POST"])
-def ticket_status_route(ticket_id):
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- status = request.form.get("status", "Offen")
- db.update_ticket_status(tenant_id, ticket_id, status, session.get("email"))
- db.log_audit(tenant_id, session["user_id"], "ticket_status_changed", "ticket", str(ticket_id),
- {"status": status}, _client_ip())
- return jsonify({"ok": True})
-
-
-@app.route("/api/tickets/")
-def api_ticket(ticket_id):
- r = _require_login()
- if r:
- return r
- ticket = db.get_ticket(session["tenant_id"], ticket_id)
- if not ticket:
- abort(404)
- return jsonify(_jsonable(ticket))
-
-
-# ── Repo-Bearbeitung aus Tickets heraus (phase-008-itsm-repo-audit) ─────────────
-def _repo_edit_actor_label():
- """Einheitliches akteur-Label fuer ticket_timeline-Eintraege dieser
- Funktion -- gleiche Konvention wie an anderen Stellen (session.get("email"))."""
- return session.get("email") or "unbekannt"
-
-
-@app.route("/api/tickets//repo-file")
-def api_ticket_repo_file(ticket_id):
- """Laedt eine Datei aus einem Forge-Repo, um sie im Ticket editierbar
- anzuzeigen. Verlangt ein zum Ticket gehoeriges (also existierendes,
- mandanten-eigenes) Ticket, damit die Aktion ueberhaupt einen Ticket-Bezug
- hat -- das ist Voraussetzung fuer den Worklog-Eintrag beim Speichern,
- nicht nur beim Lesen."""
- r = _require_login()
- if r:
- return r
- ticket = db.get_ticket(session["tenant_id"], ticket_id)
- if not ticket:
- abort(404)
- repo = request.args.get("repo", "").strip()
- path = request.args.get("path", "").strip()
- branch = request.args.get("branch", "").strip() or "main"
- if not repo or not path:
- return jsonify({"ok": False, "error": "repo und path sind erforderlich"}), 400
- try:
- text, sha = forge_client.get_contents(repo, path, ref=branch)
- except forge_client.ForgeClientError as e:
- return jsonify({"ok": False, "error": str(e)}), 502
- return jsonify({"ok": True, "content": text, "sha": sha, "repo": repo, "path": path, "branch": branch})
-
-
-@app.route("/tickets//repo-edit", methods=["POST"])
-def ticket_repo_edit_route(ticket_id):
- """Schreibt eine Datei-Aenderung ueber die Forge-Contents-API und
- dokumentiert sie zwingend im Worklog (ticket_timeline) dieses Tickets.
- Harte Anforderung (siehe Modul-Docstring/project.yaml phase-008):
- schlaegt der Worklog-Schreibvorgang fehl, wird KEIN Erfolg gemeldet, auch
- wenn der Forge-Commit zu diesem Zeitpunkt bereits geschrieben wurde --
- ein bereits erfolgter Git-Commit kann nicht atomar mit dem DB-Insert
- zusammengefasst werden (zwei verschiedene Systeme), daher hier stattdessen
- die zweitbeste Garantie: der Nutzer wird nie faelschlich im Glauben
- gelassen, dass alles dokumentiert wurde. Der (dann undokumentierte)
- Forge-Commit bleibt fuer eine manuelle Nachbearbeitung im Server-Log
- sichtbar (siehe except-Zweig unten)."""
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- ticket = db.get_ticket(tenant_id, ticket_id)
- if not ticket:
- abort(404)
- repo = request.form.get("repo", "").strip()
- path = request.form.get("path", "").strip()
- branch = request.form.get("branch", "").strip() or "main"
- sha = request.form.get("sha", "").strip()
- content = request.form.get("content", "")
- message = request.form.get("message", "").strip()
- if not repo or not path or not sha or not message:
- return jsonify({"ok": False, "error": "repo, path, sha und message sind erforderlich"}), 400
-
- message_with_ticket = "%s (ITSM-Ticket %s)" % (message, ticket["ticket_nr"])
- actor = _repo_edit_actor_label()
- try:
- commit_sha = forge_client.update_contents(
- repo, path, content, sha, branch, message_with_ticket,
- author_name=actor, author_email=(session.get("email") or "itsm@forge.local"),
- )
- except forge_client.ForgeClientError as e:
- return jsonify({"ok": False, "error": str(e)}), 502
-
- try:
- db.log_repo_edit(tenant_id, ticket_id, actor, repo, path, branch, commit_sha)
- except Exception as e:
- # Forge-Commit ist bereits geschrieben (commit_sha oben), aber der
- # Worklog-Eintrag ist NICHT geglueckt -- gemaess phase-008-Vorgabe
- # darf dies nicht als Erfolg gemeldet werden. Der Commit-Sha wird
- # dennoch geloggt, damit eine manuelle Nachdokumentation moeglich
- # bleibt (Konsistenz-Luecke ist sichtbar, nicht verdeckt).
- app.logger.error(
- "phase-008: Forge-Commit %s (Repo %s, Ticket %s) erfolgreich, "
- "aber Worklog-Eintrag fehlgeschlagen: %s", commit_sha, repo, ticket_id, e,
- )
- return jsonify({
- "ok": False,
- "error": "Datei wurde in Forge committet (%s...), aber der Worklog-Eintrag "
- "konnte nicht gespeichert werden. Bitte Admin informieren." % commit_sha[:10],
- }), 500
-
- db.log_audit(tenant_id, session["user_id"], "repo_file_edited", "ticket", str(ticket_id),
- {"repo": repo, "path": path, "branch": branch, "commit": commit_sha}, _client_ip())
- return jsonify({"ok": True, "commit": commit_sha})
-
-
-# ── Service-Katalog ─────────────────────────────────────────────────────────────
-def _service_live_status(endpoint):
- """Prueft die Live-Erreichbarkeit eines gebuchten Service-Endpunkts (z.B. AES-Dashboard).
- Generisch fuer alle Services mit Endpoint, nicht nur AES. Timeout kurz gehalten,
- damit ein haengender/ausgefallener Service den Katalog nicht blockiert.
- Rueckgabe: True (erreichbar) / False (nicht erreichbar) / None (kein Endpoint hinterlegt)."""
- if not endpoint:
- return None
- try:
- req = urllib.request.Request(endpoint, method="GET")
- with urllib.request.urlopen(req, timeout=4) as resp:
- return 200 <= resp.status < 400
- except Exception:
- return False
-
-
-def _services_body(tenant_id):
- services = db.list_services(tenant_id)
- cards = []
- for s in services:
- if not s["gebucht"]:
- badge = 'nicht gebucht'
- elif s.get("endpoint"):
- live = _service_live_status(s["endpoint"])
- if live is True:
- badge = 'gebucht · erreichbar'
- elif live is False:
- badge = 'gebucht · nicht erreichbar'
- else:
- badge = 'gebucht'
- else:
- badge = 'gebucht'
- link = ('
' + "".join(cards) + add_form
-
-
-@app.route("/services")
-def services_route():
- r = _require_login()
- if r:
- return r
- return _shell("/services", "Service-Katalog", _services_body(session["tenant_id"]))
-
-
-@app.route("/services/new", methods=["POST"])
-def services_new_route():
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- sid = db.create_service(
- tenant_id, request.form.get("name", "").strip(), request.form.get("beschreibung", "").strip(),
- "Sonstiges", True, request.form.get("endpoint", "").strip(),
- )
- db.log_audit(tenant_id, session["user_id"], "service_created", "service", str(sid), None, _client_ip())
- return redirect(url_for("services_route"))
-
-
-# ── Administration: Einstellungen + Audit-Log ──────────────────────────────────
-@app.route("/admin", methods=["GET", "POST"])
-def admin_route():
- r = _require_admin()
- if r:
- return r
- tenant_id = session["tenant_id"]
- notice = ""
- if request.method == "POST":
- db.update_tenant_settings(
- tenant_id,
- request.form.get("dsb_name", "").strip() or None,
- request.form.get("dsb_email", "").strip() or None,
- int(request.form.get("retention_tickets_days") or 1095),
- int(request.form.get("retention_audit_days") or 1825),
- int(request.form.get("sla_antwort_minuten") or 480),
- int(request.form.get("sla_loesung_minuten") or 2880),
- )
- db.log_audit(tenant_id, session["user_id"], "tenant_settings_updated", "tenant", str(tenant_id), None, _client_ip())
- notice = "
Gespeichert.
"
- if request.args.get("cleanup"):
- notice += "
Bereinigung durchgefuehrt -- Details im Audit-Log.
"
- t = db.get_tenant(tenant_id)
- body = """
Einstellungen
-
%s
%s
-
-
-
Aufbewahrungsfrist-Bereinigung
-
Laeuft automatisch alle 24 Stunden im Hintergrund: geloeste/geschlossene Tickets und Audit-Log-Eintraege, die aelter als die oben hinterlegten Fristen sind, werden entfernt. Offene/laufende Tickets werden nie geloescht.
-
-
""" % (
- html.escape(t["name"]), notice, t["sla_antwort_minuten"], t["sla_loesung_minuten"],
- html.escape(t["dsb_name"] or ""), html.escape(t["dsb_email"] or ""),
- t["retention_tickets_days"], t["retention_audit_days"])
- return _shell("/admin", "Einstellungen", body)
-
-
-@app.route("/admin/retention/run", methods=["POST"])
-def admin_retention_run():
- r = _require_admin()
- if r:
- return r
- db.run_retention_cleanup()
- return redirect(url_for("admin_route", cleanup="1"))
-
-
-@app.route("/admin/audit")
-def admin_audit_route():
- r = _require_admin()
- if r:
- return r
- entries = db.list_audit(session["tenant_id"], limit=300)
- rows = "".join(
- "
%s
%s
%s
%s %s
" % (
- e["zeit"].strftime("%Y-%m-%d %H:%M:%S"), html.escape(e["user_email"] or "(unbekannt)"),
- html.escape(e["aktion"]), html.escape(e["entity_typ"] or ""), html.escape(e["entity_id"] or ""))
- for e in entries
- ) or "
Keine Eintraege.
"
- body = """
Audit-Log
-
Alle sicherheitsrelevanten Aktionen dieses Mandanten (Anmeldungen, Aenderungen). Aufbewahrung gemaess Einstellungen.
-
Zeitpunkt (UTC)
Benutzer
Aktion
Objekt
-%s
""" % rows
- return _shell("/admin/audit", "Audit-Log", body)
-
-
-# ── Administration: Benutzerverwaltung ─────────────────────────────────────────
-def _role_pill(role):
- return '%s' % (html.escape(role), html.escape(role))
-
-
-def _users_body(tenant_id, notice="", form_vals=None):
- # form_vals: bei einem fehlgeschlagenen POST die eingegebenen Werte (ausser
- # Passwoerter), damit das Formular nicht komplett geleert wird (Nutzer-
- # Vorgabe 2026-07-13, analog zum gleichen Fix bei /setup/new).
- fv = form_vals or {}
- users = db.list_users(tenant_id)
- rows = []
- for u in users:
- status_pill = ('aktiv'
- if u["active"] else
- 'gesperrt')
- toggle_label = "Sperren" if u["active"] else "Entsperren"
- role_opts = "".join(
- '' % (r, "selected" if r == u["role"] else "", r)
- for r in ("admin", "agent")
- )
- name = " ".join(x for x in (u["vorname"], u["nachname"]) if x) or "--"
- rows.append("""
' % (tabs, search_form, table))
-
-
-@app.route("/wissen")
-def wissen_route():
- r = _require_login()
- if r:
- return r
- return _shell("/wissen", "Wissensdatenbank", _kb_list_body(session["tenant_id"]))
-
-
-@app.route("/wissen/new", methods=["GET", "POST"])
-def wissen_new_route():
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- error = None
- titel, kategorie, inhalt, tags = "", "Allgemein", "", ""
- if request.method == "POST":
- titel = request.form.get("titel", "").strip()
- kategorie = request.form.get("kategorie", "Allgemein").strip()
- inhalt = request.form.get("inhalt", "").strip()
- tags = request.form.get("tags", "").strip()
- if not titel:
- error = "Titel ist erforderlich."
- elif not inhalt:
- error = "Inhalt darf nicht leer sein."
- else:
- aid = db.create_kb_article(tenant_id, titel, kategorie, inhalt, tags, session["user_id"])
- db.log_audit(tenant_id, session["user_id"], "kb_article_created", "kb_article", str(aid),
- {"titel": titel}, _client_ip())
- return redirect(url_for("wissen_detail_route", article_id=aid))
- kat_opts = "".join('' % (k, "selected" if k == kategorie else "", k)
- for k in KB_KATEGORIEN)
- err_html = "
%s
" % html.escape(error) if error else ""
- body = """
Neuer Wissensartikel
-
%s
""" % (err_html, html.escape(titel), kat_opts, html.escape(inhalt), html.escape(tags))
- return _shell("/wissen", "Neuer Wissensartikel", body)
-
-
-@app.route("/wissen/")
-def wissen_detail_route(article_id):
- r = _require_login()
- if r:
- return r
- a = db.get_kb_article(session["tenant_id"], article_id)
- if not a:
- abort(404)
- tag_pills = "".join('%s' % html.escape(t.strip())
- for t in (a["tags"] or "").split(",") if t.strip())
- body = """
%s
-
%s · %s · aktualisiert %s
-
%s
-
%s
-Bearbeiten
-Zurueck zur Uebersicht""" % (
- html.escape(a["titel"]), html.escape(a["kategorie"]), html.escape(a["autor_email"] or ""),
- a["updated_at"].strftime("%Y-%m-%d %H:%M"), html.escape(a["inhalt"]), tag_pills, a["id"])
- return _shell("/wissen", a["titel"], body)
-
-
-@app.route("/wissen//edit", methods=["GET", "POST"])
-def wissen_edit_route(article_id):
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- a = db.get_kb_article(tenant_id, article_id)
- if not a:
- abort(404)
- error = None
- if request.method == "POST":
- titel = request.form.get("titel", "").strip()
- kategorie = request.form.get("kategorie", "Allgemein").strip()
- inhalt = request.form.get("inhalt", "").strip()
- tags = request.form.get("tags", "").strip()
- if not titel or not inhalt:
- error = "Titel und Inhalt duerfen nicht leer sein."
- else:
- db.update_kb_article(tenant_id, article_id, titel, kategorie, inhalt, tags)
- db.log_audit(tenant_id, session["user_id"], "kb_article_updated", "kb_article", str(article_id),
- {"titel": titel}, _client_ip())
- return redirect(url_for("wissen_detail_route", article_id=article_id))
- a = dict(a)
- a.update({"titel": titel, "kategorie": kategorie, "inhalt": inhalt, "tags": tags})
- kat_opts = "".join('' % (k, "selected" if k == a["kategorie"] else "", k)
- for k in KB_KATEGORIEN)
- err_html = "
%s
" % html.escape(error) if error else ""
- body = """
Artikel bearbeiten
-
%s
-
""" % (err_html, html.escape(a["titel"]), kat_opts, html.escape(a["inhalt"]), html.escape(a["tags"] or ""), article_id)
- return _shell("/wissen", "Artikel bearbeiten", body)
-
-
-@app.route("/wissen//delete", methods=["POST"])
-def wissen_delete_route(article_id):
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- db.delete_kb_article(tenant_id, article_id)
- db.log_audit(tenant_id, session["user_id"], "kb_article_deleted", "kb_article", str(article_id), None, _client_ip())
- return redirect(url_for("wissen_route"))
-
-
-# ── CMDB (Configuration Management Database) ───────────────────────────────────
-def _ci_status_pill(status):
- cls = {"Aktiv": "geloest", "Inaktiv": "geschlossen", "Wartung": "bearbeitung", "Ausgemustert": "ueberfaellig"}.get(status, "offen")
- return '%s' % (cls, html.escape(status))
-
-
-def _cmdb_list_body(tenant_id):
- ci_typ = request.args.get("typ") or None
- cis = db.list_cis(tenant_id, ci_typ=ci_typ)
- tabs = 'Alle' % ("active" if not ci_typ else "")
- tabs += "".join('%s' % (t, "active" if ci_typ == t else "", t) for t in CI_TYPES)
- rows = []
- for c in cis:
- rows.append("""
' % (tabs, table))
-
-
-@app.route("/assets")
-def assets_route():
- r = _require_login()
- if r:
- return r
- return _shell("/assets", "CMDB", _cmdb_list_body(session["tenant_id"]))
-
-
-@app.route("/assets/new", methods=["GET", "POST"])
-def assets_new_route():
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- error = None
- name, ci_typ, status, beschreibung, attr_text = "", "Server", "Aktiv", "", ""
- if request.method == "POST":
- name = request.form.get("name", "").strip()
- ci_typ = request.form.get("ci_typ", "Sonstiges").strip()
- status = request.form.get("status", "Aktiv").strip()
- beschreibung = request.form.get("beschreibung", "").strip()
- attr_text = request.form.get("attribute", "")
- if not name:
- error = "Name ist erforderlich."
- else:
- cid = db.create_ci(tenant_id, name, ci_typ, status, beschreibung, _text_to_attrs(attr_text))
- db.log_audit(tenant_id, session["user_id"], "ci_created", "ci", str(cid),
- {"name": name, "ci_typ": ci_typ}, _client_ip())
- return redirect(url_for("assets_detail_route", ci_id=cid))
- typ_opts = "".join('' % (t, "selected" if t == ci_typ else "", t) for t in CI_TYPES)
- status_opts = "".join('' % (s, "selected" if s == status else "", s) for s in CI_STATUS)
- err_html = "
%s
" % html.escape(error) if error else ""
- body = """
Neues Configuration Item
-
%s
""" % (err_html, html.escape(name), typ_opts, status_opts, html.escape(beschreibung), html.escape(attr_text))
- return _shell("/assets", "Neues Configuration Item", body)
-
-
-@app.route("/assets/", methods=["GET", "POST"])
-def assets_detail_route(ci_id):
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- error = None
- if request.method == "POST":
- name = request.form.get("name", "").strip()
- ci_typ = request.form.get("ci_typ", "Sonstiges").strip()
- status = request.form.get("status", "Aktiv").strip()
- beschreibung = request.form.get("beschreibung", "").strip()
- attr_text = request.form.get("attribute", "")
- if not name:
- error = "Name ist erforderlich."
- else:
- db.update_ci(tenant_id, ci_id, name, ci_typ, status, beschreibung, _text_to_attrs(attr_text))
- db.log_audit(tenant_id, session["user_id"], "ci_updated", "ci", str(ci_id), {"name": name}, _client_ip())
- c = db.get_ci(tenant_id, ci_id)
- if not c:
- abort(404)
- typ_opts = "".join('' % (t, "selected" if t == c["ci_typ"] else "", t) for t in CI_TYPES)
- status_opts = "".join('' % (s, "selected" if s == c["status"] else "", s) for s in CI_STATUS)
- err_html = "
%s
" % html.escape(error) if error else ""
-
- other_cis = [x for x in db.list_cis(tenant_id) if x["id"] != ci_id]
- ci_opts = "".join('' % (x["id"], html.escape(x["name"]), x["ci_typ"]) for x in other_cis)
- rel_typ_opts = "".join('' % (t, t) for t in REL_TYPES)
- rel_out_rows = "".join(
- "
%s %s
" % (
- html.escape(r["beziehungs_typ"]), html.escape(r["to_name"]), ci_id, r["id"])
- for r in c["rel_out"]
- ) or "
Keine ausgehenden Beziehungen.
"
- rel_in_rows = "".join(
- "
%s %s(eingehend)
" % (
- html.escape(r["beziehungs_typ"]), html.escape(r["from_name"]))
- for r in c["rel_in"]
- ) or "
Keine eingehenden Beziehungen.
"
-
- body = """
%s
-
%s
-
Beziehungen
-
-
Ausgehend
%s
-
Eingehend
%s
-
Neue Beziehung
-
-
""" % (
- html.escape(c["name"]), err_html, html.escape(c["name"]), typ_opts, status_opts,
- html.escape(c["beschreibung"] or ""), _attrs_to_text(c["attribute"]),
- rel_out_rows, rel_in_rows, ci_id, rel_typ_opts, ci_opts,
- )
- return _shell("/assets", c["name"], body)
-
-
-@app.route("/assets//relationships", methods=["POST"])
-def assets_relationship_add_route(ci_id):
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- to_ci_id = request.form.get("to_ci_id", type=int)
- beziehungs_typ = request.form.get("beziehungs_typ", "haengt ab von").strip()
- if to_ci_id and to_ci_id != ci_id:
- rid = db.create_ci_relationship(tenant_id, ci_id, to_ci_id, beziehungs_typ)
- db.log_audit(tenant_id, session["user_id"], "ci_relationship_created", "ci_relationship", str(rid),
- {"from": ci_id, "to": to_ci_id, "typ": beziehungs_typ}, _client_ip())
- return redirect(url_for("assets_detail_route", ci_id=ci_id))
-
-
-@app.route("/assets//relationships//delete", methods=["POST"])
-def assets_relationship_delete_route(ci_id, rel_id):
- r = _require_login()
- if r:
- return r
- tenant_id = session["tenant_id"]
- db.delete_ci_relationship(tenant_id, rel_id)
- db.log_audit(tenant_id, session["user_id"], "ci_relationship_deleted", "ci_relationship", str(rel_id), None, _client_ip())
- return redirect(url_for("assets_detail_route", ci_id=ci_id))
-
-
-@app.route("/")
-def root_route():
- if not db.any_tenant_exists():
- return redirect(url_for("setup_new"))
- if session.get("user_id"):
- return redirect(url_for("tickets_route"))
- return redirect(url_for("login"))
-
-
-@app.route("/health")
-def health_route():
- try:
- with db.get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("SELECT 1")
- return jsonify({"status": "ok", "db": "ok"})
- except Exception as e:
- return jsonify({"status": "error", "db": str(e)}), 500
-
-
-db.init_db()
-
-
-def _retention_loop():
- """Hintergrund-Thread: bereinigt abgelaufene Daten periodisch (siehe
- db.run_retention_cleanup). Laeuft einmal kurz nach dem Start und danach im
- festen Intervall. Der Postgres-Advisory-Lock in run_retention_cleanup()
- schuetzt davor, dass mehrere Worker/Prozesse gleichzeitig loeschen."""
- time.sleep(30)
- while True:
- try:
- db.run_retention_cleanup()
- except Exception as e:
- print("[retention] Fehler bei der Bereinigung:", e, flush=True)
- time.sleep(RETENTION_INTERVAL_SECONDS)
-
-
-threading.Thread(target=_retention_loop, daemon=True).start()
-
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=8090)
diff --git a/db.py b/db.py
deleted file mode 100644
index 857bbf8..0000000
--- a/db.py
+++ /dev/null
@@ -1,547 +0,0 @@
-"""
-Datenzugriffsschicht fuer die ITSM-Plattform (PostgreSQL, mandantenfaehig).
-
-Ersetzt die fruehere JSON-Datei-Ablage. Grund: Betrieb unter ISO 27001, DSGVO
-und NIS 2 verlangt belastbare Zugriffskontrolle, Nachvollziehbarkeit (Audit-Log)
-und geordnete Backup-/Wiederherstellungsfaehigkeit -- das leistet eine Datei-
-Ablage nicht.
-
-ENV:
- DATABASE_URL postgresql://user:pass@host:5432/dbname
-"""
-import os
-import json
-import datetime as _dt
-from contextlib import contextmanager
-
-import psycopg2
-import psycopg2.extras
-import psycopg2.pool
-
-DATABASE_URL = os.getenv("DATABASE_URL", "")
-
-_pool = None
-
-
-def _get_pool():
- global _pool
- if _pool is None:
- _pool = psycopg2.pool.SimpleConnectionPool(1, 10, dsn=DATABASE_URL)
- return _pool
-
-
-@contextmanager
-def get_conn():
- pool = _get_pool()
- conn = pool.getconn()
- try:
- yield conn
- conn.commit()
- except Exception:
- conn.rollback()
- raise
- finally:
- pool.putconn(conn)
-
-
-def _dict_cursor(conn):
- return conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
-
-
-def init_db():
- """Legt das Schema an (idempotent). Wird beim App-Start aufgerufen."""
- here = os.path.dirname(os.path.abspath(__file__))
- schema_path = os.path.join(here, "schema.sql")
- with open(schema_path, "r", encoding="utf-8") as f:
- ddl = f.read()
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(ddl)
-
-
-def any_tenant_exists():
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("SELECT 1 FROM tenants LIMIT 1")
- return cur.fetchone() is not None
-
-
-# ── Tenants / Ersteinrichtung ──────────────────────────────────────────────────
-def create_tenant(name, dsb_name, dsb_email, retention_tickets_days,
- retention_audit_days, sla_antwort_minuten, sla_loesung_minuten):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """INSERT INTO tenants
- (name, dsb_name, dsb_email, retention_tickets_days,
- retention_audit_days, sla_antwort_minuten, sla_loesung_minuten)
- VALUES (%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
- (name, dsb_name, dsb_email, retention_tickets_days,
- retention_audit_days, sla_antwort_minuten, sla_loesung_minuten),
- )
- return cur.fetchone()["id"]
-
-
-def get_tenant(tenant_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute("SELECT * FROM tenants WHERE id=%s", (tenant_id,))
- return cur.fetchone()
-
-
-def update_tenant_settings(tenant_id, dsb_name, dsb_email, retention_tickets_days,
- retention_audit_days, sla_antwort_minuten, sla_loesung_minuten):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """UPDATE tenants SET dsb_name=%s, dsb_email=%s,
- retention_tickets_days=%s, retention_audit_days=%s,
- sla_antwort_minuten=%s, sla_loesung_minuten=%s
- WHERE id=%s""",
- (dsb_name, dsb_email, retention_tickets_days, retention_audit_days,
- sla_antwort_minuten, sla_loesung_minuten, tenant_id),
- )
-
-
-# ── Users ───────────────────────────────────────────────────────────────────────
-def create_user(tenant_id, email, password_hash, role="admin",
- vorname=None, nachname=None, telefon=None, abteilung=None, adresse=None):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """INSERT INTO users (tenant_id, email, password_hash, role,
- vorname, nachname, telefon, abteilung, adresse)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
- (tenant_id, email, password_hash, role, vorname, nachname, telefon, abteilung, adresse),
- )
- return cur.fetchone()["id"]
-
-
-def get_user_by_email(email):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute("SELECT * FROM users WHERE email=%s AND active=TRUE", (email,))
- return cur.fetchone()
-
-
-def touch_last_login(user_id):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("UPDATE users SET last_login_at=now() WHERE id=%s", (user_id,))
-
-
-def email_exists(email):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("SELECT 1 FROM users WHERE email=%s", (email,))
- return cur.fetchone() is not None
-
-
-def email_exists_excluding(email, exclude_user_id):
- """Wie email_exists, aber ignoriert den eigenen Datensatz -- fuer die
- Eindeutigkeitspruefung beim Aendern der E-Mail eines bestehenden Nutzers."""
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("SELECT 1 FROM users WHERE email=%s AND id<>%s", (email, exclude_user_id))
- return cur.fetchone() is not None
-
-
-_USER_COLUMNS = ("id, email, role, active, auth_source, created_at, last_login_at, "
- "vorname, nachname, telefon, abteilung, adresse")
-
-
-def list_users(tenant_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- "SELECT %s FROM users WHERE tenant_id=%%s ORDER BY email" % _USER_COLUMNS, (tenant_id,))
- return cur.fetchall()
-
-
-def get_user(tenant_id, user_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- "SELECT %s FROM users WHERE tenant_id=%%s AND id=%%s" % _USER_COLUMNS, (tenant_id, user_id))
- return cur.fetchone()
-
-
-def update_user_profile(tenant_id, user_id, vorname, nachname, telefon, abteilung, adresse):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """UPDATE users SET vorname=%s, nachname=%s, telefon=%s, abteilung=%s, adresse=%s
- WHERE tenant_id=%s AND id=%s""",
- (vorname, nachname, telefon, abteilung, adresse, tenant_id, user_id),
- )
-
-
-def update_user_email(tenant_id, user_id, email):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("UPDATE users SET email=%s WHERE tenant_id=%s AND id=%s", (email, tenant_id, user_id))
-
-
-def set_user_role(tenant_id, user_id, role):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("UPDATE users SET role=%s WHERE tenant_id=%s AND id=%s", (role, tenant_id, user_id))
-
-
-def set_user_active(tenant_id, user_id, active):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("UPDATE users SET active=%s WHERE tenant_id=%s AND id=%s", (active, tenant_id, user_id))
-
-
-# ── Audit-Log ───────────────────────────────────────────────────────────────────
-def log_audit(tenant_id, user_id, aktion, entity_typ=None, entity_id=None, details=None, ip=None):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """INSERT INTO audit_log (tenant_id, user_id, aktion, entity_typ, entity_id, details, ip)
- VALUES (%s,%s,%s,%s,%s,%s,%s)""",
- (tenant_id, user_id, aktion, entity_typ, entity_id,
- json.dumps(details) if details is not None else None, ip),
- )
-
-
-def list_audit(tenant_id, limit=200):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """SELECT a.*, u.email AS user_email FROM audit_log a
- LEFT JOIN users u ON u.id = a.user_id
- WHERE a.tenant_id=%s ORDER BY a.zeit DESC LIMIT %s""",
- (tenant_id, limit),
- )
- return cur.fetchall()
-
-
-# ── Services ────────────────────────────────────────────────────────────────────
-def create_service(tenant_id, name, beschreibung, kategorie, gebucht, endpoint):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """INSERT INTO services (tenant_id, name, beschreibung, kategorie, gebucht, endpoint)
- VALUES (%s,%s,%s,%s,%s,%s) RETURNING id""",
- (tenant_id, name, beschreibung, kategorie, gebucht, endpoint),
- )
- return cur.fetchone()["id"]
-
-
-def list_services(tenant_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute("SELECT * FROM services WHERE tenant_id=%s ORDER BY id", (tenant_id,))
- return cur.fetchall()
-
-
-# ── Tickets ─────────────────────────────────────────────────────────────────────
-def _next_ticket_nr(tenant_id):
- year = _dt.datetime.utcnow().year
- prefix = "TKT-%d-" % year
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """SELECT ticket_nr FROM tickets
- WHERE tenant_id=%s AND ticket_nr LIKE %s
- ORDER BY ticket_nr DESC LIMIT 1""",
- (tenant_id, prefix + "%"),
- )
- row = cur.fetchone()
- seq = 1
- if row:
- try:
- seq = int(row[0].split("-")[-1]) + 1
- except Exception:
- seq = 1
- return "%s%06d" % (prefix, seq)
-
-
-def create_ticket(tenant_id, titel, beschreibung, service_id, prioritaet, kategorie,
- zugewiesen_an, ersteller_id, sla_antwort_minuten, sla_loesung_minuten,
- actor_label):
- ticket_nr = _next_ticket_nr(tenant_id)
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """INSERT INTO tickets
- (tenant_id, ticket_nr, titel, beschreibung, service_id, prioritaet,
- kategorie, zugewiesen_an, ersteller_id, sla_antwort_minuten, sla_loesung_minuten)
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
- (tenant_id, ticket_nr, titel, beschreibung, service_id, prioritaet,
- kategorie, zugewiesen_an, ersteller_id, sla_antwort_minuten, sla_loesung_minuten),
- )
- tid = cur.fetchone()["id"]
- cur.execute(
- """INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text)
- VALUES (%s,%s,%s,%s)""",
- (tid, tenant_id, actor_label, "Ticket angelegt."),
- )
- return tid, ticket_nr
-
-
-def list_tickets(tenant_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """SELECT t.*, s.name AS service_name FROM tickets t
- LEFT JOIN services s ON s.id = t.service_id
- WHERE t.tenant_id=%s ORDER BY t.updated_at DESC""",
- (tenant_id,),
- )
- return cur.fetchall()
-
-
-def get_ticket(tenant_id, ticket_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """SELECT t.*, s.name AS service_name FROM tickets t
- LEFT JOIN services s ON s.id = t.service_id
- WHERE t.tenant_id=%s AND t.id=%s""",
- (tenant_id, ticket_id),
- )
- ticket = cur.fetchone()
- if not ticket:
- return None
- cur.execute(
- "SELECT * FROM ticket_timeline WHERE ticket_id=%s ORDER BY zeit DESC",
- (ticket_id,),
- )
- ticket = dict(ticket)
- ticket["timeline"] = cur.fetchall()
- return ticket
-
-
-def log_repo_edit(tenant_id, ticket_id, actor_label, repo, path, branch, commit_sha):
- """Schreibt einen Worklog-Eintrag fuer eine ueber ITSM vorgenommene
- Forge-Repo-Aenderung (phase-008-itsm-repo-audit, Nutzer-Vorgabe
- 2026-07-14: JEDE Repo-Aenderung ueber ITSM muss im Worklog des
- zugehoerigen Projekttickets dokumentiert werden). Nutzt die bestehende
- ticket_timeline-Tabelle -- kein neues Datenmodell noetig, gleiches
- Insert-Muster wie update_ticket_status/create_ticket. Wirft bei einem
- DB-Fehler regulaer weiter (Aufrufer in app.py MUSS das als Fehlschlag
- behandeln und darf den Repo-Commit dann nicht als erfolgreich melden --
- siehe project.yaml phase-008)."""
- text = "Repo-Datei bearbeitet: %s@%s (%s) -- Commit %s" % (repo, branch, path, commit_sha[:10])
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text)
- VALUES (%s,%s,%s,%s)""",
- (ticket_id, tenant_id, actor_label, text),
- )
-
-
-def update_ticket_status(tenant_id, ticket_id, status, actor_label):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """UPDATE tickets SET status=%s, updated_at=now(),
- fortschritt = CASE WHEN %s IN ('Geloest','Geschlossen') THEN 100 ELSE fortschritt END
- WHERE tenant_id=%s AND id=%s""",
- (status, status, tenant_id, ticket_id),
- )
- cur.execute(
- """INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text)
- VALUES (%s,%s,%s,%s)""",
- (ticket_id, tenant_id, actor_label, "Status geaendert auf '%s'." % status),
- )
-
-
-# ── Aufbewahrungsfrist-Bereinigung (DSGVO Speicherbegrenzung) ─────────────────
-_RETENTION_LOCK_KEY = 727271 # feste Postgres-Advisory-Lock-ID fuer diesen Job
-
-
-def run_retention_cleanup():
- """Loescht abgeschlossene Tickets und Audit-Log-Eintraege, die aelter sind
- als die pro Mandant hinterlegte Aufbewahrungsfrist (retention_tickets_days /
- retention_audit_days). Offene/laufende Tickets werden NIE geloescht, nur
- solche im Status 'Geloest'/'Geschlossen'.
-
- Nutzt einen Postgres-Advisory-Lock: laeuft der Job bereits (z. B. durch
- einen anderen Worker-Prozess), wird dieser Aufruf uebersprungen statt
- parallel zu loeschen.
- """
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("SELECT pg_try_advisory_lock(%s)", (_RETENTION_LOCK_KEY,))
- got_lock = cur.fetchone()[0]
- if not got_lock:
- return {"skipped": "lock_not_acquired"}
- try:
- with _dict_cursor(conn) as cur:
- cur.execute("SELECT id, name, retention_tickets_days, retention_audit_days FROM tenants")
- tenants = cur.fetchall()
- summary = []
- for t in tenants:
- tid = t["id"]
- with conn.cursor() as cur:
- cur.execute(
- """DELETE FROM tickets WHERE tenant_id=%s AND status IN ('Geloest','Geschlossen')
- AND updated_at < now() - (%s || ' days')::interval""",
- (tid, t["retention_tickets_days"]),
- )
- tickets_deleted = cur.rowcount
- cur.execute(
- """DELETE FROM audit_log WHERE tenant_id=%s AND aktion <> 'retention_cleanup'
- AND zeit < now() - (%s || ' days')::interval""",
- (tid, t["retention_audit_days"]),
- )
- audit_deleted = cur.rowcount
- if tickets_deleted or audit_deleted:
- log_audit(tid, None, "retention_cleanup", "tenant", str(tid),
- {"tickets_deleted": tickets_deleted, "audit_log_deleted": audit_deleted})
- summary.append({"tenant_id": tid, "tenant": t["name"],
- "tickets_deleted": tickets_deleted, "audit_log_deleted": audit_deleted})
- return {"summary": summary}
- finally:
- with conn.cursor() as cur:
- cur.execute("SELECT pg_advisory_unlock(%s)", (_RETENTION_LOCK_KEY,))
-
-
-
-# ── CMDB (Configuration Management Database) ─────────────────────────────────
-def create_ci(tenant_id, name, ci_typ, status, beschreibung, attribute):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """INSERT INTO configuration_items
- (tenant_id, name, ci_typ, status, beschreibung, attribute)
- VALUES (%s,%s,%s,%s,%s,%s) RETURNING id""",
- (tenant_id, name, ci_typ, status, beschreibung, json.dumps(attribute or {})),
- )
- return cur.fetchone()["id"]
-
-
-def list_cis(tenant_id, ci_typ=None):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- if ci_typ:
- cur.execute(
- "SELECT * FROM configuration_items WHERE tenant_id=%s AND ci_typ=%s ORDER BY name",
- (tenant_id, ci_typ))
- else:
- cur.execute(
- "SELECT * FROM configuration_items WHERE tenant_id=%s ORDER BY ci_typ, name",
- (tenant_id,))
- return cur.fetchall()
-
-
-def get_ci(tenant_id, ci_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- "SELECT * FROM configuration_items WHERE tenant_id=%s AND id=%s",
- (tenant_id, ci_id))
- ci = cur.fetchone()
- if not ci:
- return None
- ci = dict(ci)
- cur.execute(
- """SELECT r.*, c.name AS to_name FROM ci_relationships r
- JOIN configuration_items c ON c.id = r.to_ci_id
- WHERE r.tenant_id=%s AND r.from_ci_id=%s ORDER BY r.id""",
- (tenant_id, ci_id))
- ci["rel_out"] = cur.fetchall()
- cur.execute(
- """SELECT r.*, c.name AS from_name FROM ci_relationships r
- JOIN configuration_items c ON c.id = r.from_ci_id
- WHERE r.tenant_id=%s AND r.to_ci_id=%s ORDER BY r.id""",
- (tenant_id, ci_id))
- ci["rel_in"] = cur.fetchall()
- return ci
-
-
-def update_ci(tenant_id, ci_id, name, ci_typ, status, beschreibung, attribute):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """UPDATE configuration_items SET name=%s, ci_typ=%s, status=%s,
- beschreibung=%s, attribute=%s, updated_at=now()
- WHERE tenant_id=%s AND id=%s""",
- (name, ci_typ, status, beschreibung, json.dumps(attribute or {}), tenant_id, ci_id),
- )
-
-
-def delete_ci(tenant_id, ci_id):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("DELETE FROM configuration_items WHERE tenant_id=%s AND id=%s", (tenant_id, ci_id))
-
-
-def create_ci_relationship(tenant_id, from_ci_id, to_ci_id, beziehungs_typ):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """INSERT INTO ci_relationships (tenant_id, from_ci_id, to_ci_id, beziehungs_typ)
- VALUES (%s,%s,%s,%s) RETURNING id""",
- (tenant_id, from_ci_id, to_ci_id, beziehungs_typ),
- )
- return cur.fetchone()["id"]
-
-
-def delete_ci_relationship(tenant_id, rel_id):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("DELETE FROM ci_relationships WHERE tenant_id=%s AND id=%s", (tenant_id, rel_id))
-
-
-# ── Wissensdatenbank ──────────────────────────────────────────────────────────
-def create_kb_article(tenant_id, titel, kategorie, inhalt, tags, autor_user_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """INSERT INTO kb_articles (tenant_id, titel, kategorie, inhalt, tags, autor_user_id)
- VALUES (%s,%s,%s,%s,%s,%s) RETURNING id""",
- (tenant_id, titel, kategorie, inhalt, tags, autor_user_id),
- )
- return cur.fetchone()["id"]
-
-
-def list_kb_articles(tenant_id, kategorie=None, q=None):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- sql = """SELECT k.*, u.email AS autor_email FROM kb_articles k
- LEFT JOIN users u ON u.id = k.autor_user_id
- WHERE k.tenant_id=%s"""
- params = [tenant_id]
- if kategorie:
- sql += " AND k.kategorie=%s"
- params.append(kategorie)
- if q:
- sql += " AND (k.titel ILIKE %s OR k.inhalt ILIKE %s OR k.tags ILIKE %s)"
- like = "%%%s%%" % q
- params += [like, like, like]
- sql += " ORDER BY k.updated_at DESC"
- cur.execute(sql, params)
- return cur.fetchall()
-
-
-def get_kb_article(tenant_id, article_id):
- with get_conn() as conn:
- with _dict_cursor(conn) as cur:
- cur.execute(
- """SELECT k.*, u.email AS autor_email FROM kb_articles k
- LEFT JOIN users u ON u.id = k.autor_user_id
- WHERE k.tenant_id=%s AND k.id=%s""",
- (tenant_id, article_id))
- return cur.fetchone()
-
-
-def update_kb_article(tenant_id, article_id, titel, kategorie, inhalt, tags):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """UPDATE kb_articles SET titel=%s, kategorie=%s, inhalt=%s, tags=%s, updated_at=now()
- WHERE tenant_id=%s AND id=%s""",
- (titel, kategorie, inhalt, tags, tenant_id, article_id),
- )
-
-
-def delete_kb_article(tenant_id, article_id):
- with get_conn() as conn:
- with conn.cursor() as cur:
- cur.execute("DELETE FROM kb_articles WHERE tenant_id=%s AND id=%s", (tenant_id, article_id))
diff --git a/deploy/backup.sh b/deploy/backup.sh
old mode 100644
new mode 100755
diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh
deleted file mode 100644
index 28d776b..0000000
--- a/deploy/bootstrap.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/bin/sh
-set -e
-export DEBIAN_FRONTEND=noninteractive
-echo "[bootstrap-itsm] Installiere OS-Abhaengigkeiten..."
-apt-get update -qq && apt-get install -y -qq --no-install-recommends git ca-certificates >/dev/null
-
-echo "[bootstrap-itsm] Hole ITSM-Quellcode von Gitea (git clone)..."
-rm -rf /opt/itsm-src
-git clone --depth 1 https://git1.mrmoe.de/mscadm/ITSM.git /opt/itsm-src >/dev/null 2>&1
-
-echo "[bootstrap-itsm] Installiere Python-Abhaengigkeiten..."
-pip install --quiet --root-user-action=ignore -r /opt/itsm-src/requirements.txt
-
-echo "[bootstrap-itsm] Bereite App vor..."
-mkdir -p /app
-cp /opt/itsm-src/app.py /app/app.py
-cp /opt/itsm-src/db.py /app/db.py
-cp /opt/itsm-src/schema.sql /app/schema.sql
-
-echo "[bootstrap-itsm] Sync abgeschlossen, starte gunicorn."
-cd /app
-exec gunicorn -b 0.0.0.0:8090 --worker-class gthread -w 1 --threads 4 --timeout 120 app:app
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
old mode 100644
new mode 100755
index e565bbf..13eef35
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -1,12 +1,25 @@
+# ITSM-Deployment (Rust-Version).
+#
+# Aufsetzen:
+# 1. Repo nach /docker/itsm/src klonen (oder Build-Kontext anpassen)
+# 2. .env mit POSTGRES_PASSWORD anlegen
+# 3. docker compose up -d --build
+#
+# Update: git pull im Quell-Checkout, dann docker compose up -d --build.
+# Anders als frueher wird beim Container-NEUSTART kein Code mehr aus dem
+# Git gezogen -- ein Neustart startet exakt das gebaute Image (reproduzierbar,
+# kein ungetesteter main-Stand in Prod).
services:
postgres:
+ # Bewusst bei postgres:16 bleiben: das bestehende pgdata-Verzeichnis ist
+ # mit 16 initialisiert; ein Major-Upgrade braucht pg_upgrade/dump+restore.
image: postgres:16-alpine
container_name: itsm_postgres
restart: unless-stopped
environment:
POSTGRES_DB: itsm
POSTGRES_USER: itsm
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein}
volumes:
- ./pgdata:/var/lib/postgresql/data
healthcheck:
@@ -16,9 +29,10 @@ services:
retries: 10
itsm:
- image: python:3.12-slim
+ build:
+ context: ../
+ dockerfile: Dockerfile
container_name: itsm_app
- command: ["sh", "/bootstrap.sh"]
restart: unless-stopped
depends_on:
postgres:
@@ -26,10 +40,15 @@ services:
ports:
- "8090:8090"
environment:
- DATABASE_URL: postgresql://itsm:${POSTGRES_PASSWORD:-}@postgres:5432/itsm
- ITSM_SECRET_KEY: ${ITSM_SECRET_KEY:-}
+ DATABASE_URL: postgresql://itsm:${POSTGRES_PASSWORD}@postgres:5432/itsm
AES_DASHBOARD_URL: ${AES_DASHBOARD_URL:-http://host.docker.internal:8080}
- volumes:
- - ./bootstrap.sh:/bootstrap.sh:ro
+ FORGE_BASE_URL: ${FORGE_BASE_URL:-}
+ FORGE_SERVICE_TOKEN: ${FORGE_SERVICE_TOKEN:-}
+ # Hinter TLS-Terminierung (Reverse Proxy) setzen:
+ # ITSM_HTTPS: "1"
+ # ITSM_TRUSTED_PROXY_COUNT: "1"
+ ITSM_HTTPS: ${ITSM_HTTPS:-0}
+ ITSM_TRUSTED_PROXY_COUNT: ${ITSM_TRUSTED_PROXY_COUNT:-0}
+ RUST_LOG: ${RUST_LOG:-info}
extra_hosts:
- "host.docker.internal:host-gateway"
diff --git a/forge_client.py b/forge_client.py
deleted file mode 100644
index 53a261a..0000000
--- a/forge_client.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-Kleiner HTTP-Client fuer die Forge-Contents-API (phase-008-itsm-repo-audit).
-
-Analog zum bestehenden Muster in app.py (urllib.request statt einer externen
-HTTP-Bibliothek -- keine zusaetzliche Abhaengigkeit fuer nur zwei Aufrufe).
-Spricht ausschliesslich die Forge-REST-API (siehe mscadm/forge
-forge-web/src/api.rs), die bewusst Gitea-API-kompatibel gehalten ist.
-
-ENV (siehe auch app.py-Docstring):
- FORGE_BASE_URL Basis-URL des Forge-Servers, z.B. http://127.0.0.1:8095
- FORGE_SERVICE_TOKEN API-Token eines Forge-Nutzers mit role=admin (siehe
- Forge /api/v1/admin/users) -- dient hier NICHT der
- Admin-API, sondern normalen Contents-Schreibzugriffen;
- admin ist ausreichend, da Forge (noch) kein feineres
- Repo-Schreibrecht kennt.
-
-Bewusster Scope-Schnitt: nur get_contents/update_contents (was diese Phase
-tatsaechlich braucht), keine generische Forge-API-Client-Klasse mit allen
-Endpunkten -- analog zur Begruendung in forge-web/src/api.rs ("nicht 100%
-Paritaet ab Tag 1").
-"""
-import base64
-import json
-import os
-import urllib.error
-import urllib.parse
-import urllib.request
-
-FORGE_BASE_URL = os.getenv("FORGE_BASE_URL", "").rstrip("/")
-FORGE_SERVICE_TOKEN = os.getenv("FORGE_SERVICE_TOKEN", "")
-
-
-class ForgeClientError(Exception):
- """Fehler beim Sprechen mit der Forge-API -- Aufrufer soll dies dem
- Nutzer als Fehlermeldung zeigen, nicht stillschweigend schlucken."""
-
-
-def _request(method, path, body=None):
- if not FORGE_BASE_URL:
- raise ForgeClientError("FORGE_BASE_URL ist nicht konfiguriert")
- url = FORGE_BASE_URL + path
- headers = {"Authorization": "token " + FORGE_SERVICE_TOKEN}
- data = None
- if body is not None:
- data = json.dumps(body).encode("utf-8")
- headers["Content-Type"] = "application/json"
- req = urllib.request.Request(url, data=data, headers=headers, method=method)
- try:
- with urllib.request.urlopen(req, timeout=10) as resp:
- return json.loads(resp.read().decode("utf-8"))
- except urllib.error.HTTPError as e:
- detail = ""
- try:
- detail = e.read().decode("utf-8")
- except Exception:
- pass
- raise ForgeClientError("Forge-API-Fehler (%s): %s" % (e.code, detail or e.reason))
- except urllib.error.URLError as e:
- raise ForgeClientError("Forge nicht erreichbar: %s" % e.reason)
-
-
-def get_contents(repo, path, ref=None):
- """Liest eine Datei aus einem Forge-Repo. Rueckgabe: (text, sha).
- `text` ist bereits UTF-8-dekodiert (Repo-Dateien fuer diese Funktion sind
- Text-Dateien wie Doku/Config -- Binaerdateien werden bewusst nicht
- unterstuetzt, siehe project.yaml phase-008 Scope)."""
- q = "?ref=" + urllib.parse.quote(ref, safe="") if ref else ""
- result = _request("GET", "/api/v1/repos/x/%s/contents/%s%s" % (repo, path, q))
- if result.get("type") != "file":
- raise ForgeClientError("Pfad ist keine Datei: %s" % path)
- raw = base64.b64decode(result["content"])
- try:
- text = raw.decode("utf-8")
- except UnicodeDecodeError:
- raise ForgeClientError("Datei ist keine UTF-8-Textdatei -- ueber ITSM nicht editierbar")
- return text, result["sha"]
-
-
-def update_contents(repo, path, content_text, sha, branch, message, author_name, author_email):
- """Schreibt eine Datei in ein Forge-Repo (optimistisches Sha-Locking wie
- von Forges Contents-API verlangt). Rueckgabe: commit_sha (str)."""
- encoded = base64.b64encode(content_text.encode("utf-8")).decode("ascii")
- body = {
- "content": encoded,
- "sha": sha,
- "branch": branch,
- "message": message,
- "author": {"name": author_name, "email": author_email},
- }
- result = _request("PUT", "/api/v1/repos/x/%s/contents/%s" % (repo, path), body)
- return result["commit"]["sha"]
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index cb04ebd..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-flask
-gunicorn
-psycopg2-binary
diff --git a/schema.sql b/schema.sql
old mode 100644
new mode 100755
index a8a4db8..f7cdb53
--- a/schema.sql
+++ b/schema.sql
@@ -139,3 +139,76 @@ CREATE INDEX IF NOT EXISTS idx_cirel_tenant ON ci_relationships (tenant_id);
CREATE INDEX IF NOT EXISTS idx_cirel_from ON ci_relationships (from_ci_id);
CREATE INDEX IF NOT EXISTS idx_cirel_to ON ci_relationships (to_ci_id);
+
+-- ═══════════════════════════════════════════════════════════════════════════════
+-- Migration 2026-07-15: ITIL-v3/v4-Ausrichtung + Sicherheits-Haertung (Rust-Rewrite)
+-- Alle Aenderungen idempotent, damit init beim App-Start gefahrlos wiederholt laeuft.
+-- ═══════════════════════════════════════════════════════════════════════════════
+
+-- ── Rollen (ITIL-orientiert) ──────────────────────────────────────────────────
+-- 'admin' (Service Owner), 'change_manager' (Change Enablement/CAB),
+-- 'agent' (Service Desk), 'user' (Requester/Self-Service).
+-- users.role existiert bereits als TEXT; erlaubte Werte prueft die App.
+
+-- ── Tickets: Impact/Urgency-Prioritaetsmatrix (ITIL v3, SO 4.2.5.4) ──────────
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS impact TEXT NOT NULL DEFAULT 'Mittel';
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS urgency TEXT NOT NULL DEFAULT 'Mittel';
+
+-- ── Tickets: Change Enablement (v4) / Change Management (v3) ──────────────────
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS change_typ TEXT;
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS approval_status TEXT;
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS approved_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
+
+-- ── Tickets: Problem Management (v3 SO 4.4 / v4 Practice) ─────────────────────
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS problem_id INTEGER REFERENCES tickets(id) ON DELETE SET NULL;
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS known_error BOOLEAN NOT NULL DEFAULT FALSE;
+CREATE INDEX IF NOT EXISTS idx_tickets_problem ON tickets (problem_id);
+
+-- ── Tickets: SLA-Zeitstempel (Service Level Management) ───────────────────────
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS first_response_at TIMESTAMPTZ;
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ;
+ALTER TABLE tickets ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
+
+-- ── Ticket-CI-Verknuepfung (SACM v3 / Service Configuration Management v4) ────
+CREATE TABLE IF NOT EXISTS ticket_ci_links (
+ id SERIAL PRIMARY KEY,
+ tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
+ ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
+ ci_id INTEGER NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE (ticket_id, ci_id)
+);
+CREATE INDEX IF NOT EXISTS idx_tcl_ticket ON ticket_ci_links (ticket_id);
+CREATE INDEX IF NOT EXISTS idx_tcl_ci ON ticket_ci_links (ci_id);
+
+-- ── Wissensdatenbank: Freigabe-Workflow (Knowledge Management) ────────────────
+-- Bestehende Artikel werden einmalig 'Freigegeben'; neue starten als 'Entwurf'.
+ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS status TEXT;
+UPDATE kb_articles SET status='Freigegeben' WHERE status IS NULL;
+ALTER TABLE kb_articles ALTER COLUMN status SET DEFAULT 'Entwurf';
+ALTER TABLE kb_articles ALTER COLUMN status SET NOT NULL;
+
+-- ── Login-Rate-Limiting (Brute-Force-Schutz, DB-basiert => multi-worker-fest) ──
+CREATE TABLE IF NOT EXISTS login_attempts (
+ id SERIAL PRIMARY KEY,
+ email TEXT NOT NULL,
+ ip TEXT,
+ zeit TIMESTAMPTZ NOT NULL DEFAULT now(),
+ success BOOLEAN NOT NULL DEFAULT FALSE
+);
+CREATE INDEX IF NOT EXISTS idx_login_attempts_email_zeit ON login_attempts (email, zeit DESC);
+CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_zeit ON login_attempts (ip, zeit DESC);
+
+-- ── Server-seitige Sessions (Rust-Rewrite 2026-07-15) ─────────────────────────
+-- In der DB liegt nur der SHA-256-Hash des Cookie-Tokens: ein DB-Leak
+-- kompromittiert keine laufenden Sessions; Sessions sind widerrufbar.
+CREATE TABLE IF NOT EXISTS sessions (
+ token_hash TEXT PRIMARY KEY,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ csrf_token TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ expires_at TIMESTAMPTZ NOT NULL
+);
+CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);
+CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at);
diff --git a/src/admin.rs b/src/admin.rs
new file mode 100755
index 0000000..f831bf1
--- /dev/null
+++ b/src/admin.rs
@@ -0,0 +1,408 @@
+//! Administration: Mandanten-Einstellungen (SLA, DSGVO-Retention), Audit-Log,
+//! Benutzerverwaltung mit ITIL-Rollenmodell. Alles admin-only.
+
+use askama::Template;
+use axum::extract::{Extension, Path, State};
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Redirect};
+use axum::Form;
+use serde::Deserialize;
+
+use crate::web::{need_admin, need_auth, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
+use crate::{itil, security};
+
+// ── Einstellungen ──────────────────────────────────────────────────────────────
+#[derive(Template)]
+#[template(path = "admin_settings.html")]
+pub struct SettingsTemplate {
+ pub title: String,
+ pub ctx: PageCtx,
+ pub notice: String,
+ pub tenant_name: String,
+ pub sla_antwort: i32,
+ pub sla_loesung: i32,
+ pub dsb_name: String,
+ pub dsb_email: String,
+ pub retention_tickets: i32,
+ pub retention_audit: i32,
+}
+
+async fn settings_template(app: &AppState, auth: &crate::db::AuthUser, notice: String)
+ -> anyhow::Result {
+ let t = app.db.get_tenant(auth.tenant_id).await?
+ .ok_or_else(|| anyhow::anyhow!("Mandant nicht gefunden"))?;
+ Ok(SettingsTemplate {
+ title: "Einstellungen".into(),
+ ctx: page_ctx(auth, "/admin"),
+ notice,
+ tenant_name: t.name,
+ sla_antwort: t.sla_antwort_minuten,
+ sla_loesung: t.sla_loesung_minuten,
+ dsb_name: t.dsb_name.unwrap_or_default(),
+ dsb_email: t.dsb_email.unwrap_or_default(),
+ retention_tickets: t.retention_tickets_days,
+ retention_audit: t.retention_audit_days,
+ })
+}
+
+pub async fn settings_get(State(app): State, Extension(ctx): Extension) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ Ok(settings_template(&app, &auth, String::new()).await?.into_response())
+}
+
+#[derive(Deserialize)]
+pub struct SettingsForm {
+ #[serde(default)]
+ pub dsb_name: String,
+ #[serde(default)]
+ pub dsb_email: String,
+ #[serde(default)]
+ pub retention_tickets_days: String,
+ #[serde(default)]
+ pub retention_audit_days: String,
+ #[serde(default)]
+ pub sla_antwort_minuten: String,
+ #[serde(default)]
+ pub sla_loesung_minuten: String,
+}
+
+pub async fn settings_post(State(app): State, Extension(ctx): Extension,
+ Form(f): Form) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ let parse = |s: &str, d: i32| s.trim().parse::().unwrap_or(d).max(1);
+ let dsb_name = f.dsb_name.trim();
+ let dsb_email = f.dsb_email.trim();
+ app.db.update_tenant_settings(
+ auth.tenant_id,
+ if dsb_name.is_empty() { None } else { Some(dsb_name) },
+ if dsb_email.is_empty() { None } else { Some(dsb_email) },
+ parse(&f.retention_tickets_days, 1095),
+ parse(&f.retention_audit_days, 1825),
+ parse(&f.sla_antwort_minuten, 480),
+ parse(&f.sla_loesung_minuten, 2880)).await?;
+ app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "tenant_settings_updated",
+ Some("tenant"), Some(&auth.tenant_id.to_string()), None,
+ ctx.ip.as_deref()).await?;
+ Ok(settings_template(&app, &auth, "Gespeichert.".into()).await?.into_response())
+}
+
+pub async fn retention_run(State(app): State, Extension(ctx): Extension) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ app.db.run_retention_cleanup().await?;
+ Ok(settings_template(&app, &auth, "Bereinigung durchgefuehrt -- Details im Audit-Log.".into())
+ .await?.into_response())
+}
+
+// ── Audit-Log ──────────────────────────────────────────────────────────────────
+pub struct AuditRow {
+ pub zeit: String,
+ pub user: String,
+ pub aktion: String,
+ pub objekt: String,
+}
+
+#[derive(Template)]
+#[template(path = "admin_audit.html")]
+pub struct AuditTemplate {
+ pub title: String,
+ pub ctx: PageCtx,
+ pub rows: Vec,
+}
+
+pub async fn audit_page(State(app): State, Extension(ctx): Extension) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin/audit") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ let entries = app.db.list_audit(auth.tenant_id, 300).await?;
+ let rows = entries.iter().map(|e| AuditRow {
+ zeit: e.zeit.format("%Y-%m-%d %H:%M:%S").to_string(),
+ user: e.user_email.clone().unwrap_or_else(|| "(unbekannt)".into()),
+ aktion: e.aktion.clone(),
+ objekt: format!("{} {}",
+ e.entity_typ.clone().unwrap_or_default(),
+ e.entity_id.clone().unwrap_or_default()).trim().to_string(),
+ }).collect();
+ let tpl = AuditTemplate {
+ title: "Audit-Log".into(),
+ ctx: page_ctx(&auth, "/admin/audit"),
+ rows,
+ };
+ Ok(tpl.into_response())
+}
+
+// ── Benutzerverwaltung ─────────────────────────────────────────────────────────
+pub struct UserRow {
+ pub id: i32,
+ pub email: String,
+ pub name: String,
+ pub role: String,
+ pub role_label: String,
+ pub active: bool,
+ pub telefon: String,
+ pub auth_source: String,
+ pub last_login: String,
+ pub is_self: bool,
+ pub role_opts: Vec<(String, String, bool)>,
+}
+
+#[derive(Template)]
+#[template(path = "admin_users.html")]
+pub struct UsersTemplate {
+ pub title: String,
+ pub ctx: PageCtx,
+ pub notice: String,
+ pub error: String,
+ pub rows: Vec,
+ pub form_open: bool,
+ pub fv_email: String,
+ pub fv_role: String,
+ pub fv_vorname: String,
+ pub fv_nachname: String,
+ pub fv_telefon: String,
+ pub fv_abteilung: String,
+ pub fv_adresse: String,
+ pub role_opts: Vec<(String, String)>,
+ pub password_min_length: usize,
+}
+
+async fn users_template(app: &AppState, auth: &crate::db::AuthUser, notice: String, error: String,
+ fv: Option<&NewUserForm>) -> anyhow::Result {
+ let users = app.db.list_users(auth.tenant_id).await?;
+ let rows = users.iter().map(|u| UserRow {
+ id: u.id,
+ email: u.email.clone(),
+ name: {
+ let n = format!("{} {}",
+ u.vorname.clone().unwrap_or_default(),
+ u.nachname.clone().unwrap_or_default());
+ let n = n.trim().to_string();
+ if n.is_empty() { "--".into() } else { n }
+ },
+ role: u.role.clone(),
+ role_label: itil::role_label(&u.role).to_string(),
+ active: u.active,
+ telefon: u.telefon.clone().unwrap_or_default(),
+ auth_source: u.auth_source.clone(),
+ last_login: u.last_login_at.map(|d| d.format("%Y-%m-%d %H:%M").to_string())
+ .unwrap_or_else(|| "nie".into()),
+ is_self: u.id == auth.user_id,
+ role_opts: itil::ROLES.iter().map(|r| {
+ (r.to_string(), itil::role_label(r).to_string(), *r == u.role)
+ }).collect(),
+ }).collect();
+ Ok(UsersTemplate {
+ title: "Benutzerverwaltung".into(),
+ ctx: page_ctx(auth, "/admin/users"),
+ notice,
+ error,
+ rows,
+ form_open: fv.is_some(),
+ fv_email: fv.map(|f| f.email.clone()).unwrap_or_default(),
+ fv_role: fv.map(|f| f.role.clone()).unwrap_or_else(|| "agent".into()),
+ fv_vorname: fv.map(|f| f.vorname.clone()).unwrap_or_default(),
+ fv_nachname: fv.map(|f| f.nachname.clone()).unwrap_or_default(),
+ fv_telefon: fv.map(|f| f.telefon.clone()).unwrap_or_default(),
+ fv_abteilung: fv.map(|f| f.abteilung.clone()).unwrap_or_default(),
+ fv_adresse: fv.map(|f| f.adresse.clone()).unwrap_or_default(),
+ role_opts: itil::ROLES.iter().map(|r| (r.to_string(), itil::role_label(r).to_string())).collect(),
+ password_min_length: app.cfg.password_min_length,
+ })
+}
+
+pub async fn users_get(State(app): State, Extension(ctx): Extension) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin/users") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ Ok(users_template(&app, &auth, String::new(), String::new(), None).await?.into_response())
+}
+
+#[derive(Deserialize)]
+pub struct NewUserForm {
+ pub email: String,
+ #[serde(default)]
+ pub role: String,
+ pub password: String,
+ pub password2: String,
+ #[serde(default)]
+ pub vorname: String,
+ #[serde(default)]
+ pub nachname: String,
+ #[serde(default)]
+ pub telefon: String,
+ #[serde(default)]
+ pub abteilung: String,
+ #[serde(default)]
+ pub adresse: String,
+}
+
+pub async fn users_post(State(app): State, Extension(ctx): Extension,
+ Form(f): Form) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin/users") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ let email = f.email.trim().to_lowercase();
+ let role = if itil::ROLES.contains(&f.role.as_str()) { f.role.clone() } else { "agent".into() };
+
+ let error = if email.is_empty() || !email.contains('@') {
+ Some("Bitte eine gueltige E-Mail-Adresse angeben.".to_string())
+ } else if let Some(p) = security::password_problem(&f.password, app.cfg.password_min_length) {
+ Some(p)
+ } else if f.password != f.password2 {
+ Some("Die Passwoerter stimmen nicht ueberein.".to_string())
+ } else if app.db.email_exists(&email, None).await? {
+ Some("Diese E-Mail-Adresse ist bereits registriert.".to_string())
+ } else {
+ None
+ };
+
+ if let Some(e) = error {
+ return Ok(users_template(&app, &auth, String::new(), e, Some(&f)).await?.into_response());
+ }
+
+ let hash = security::hash_password(&f.password).map_err(anyhow::Error::from)?;
+ let opt = |s: &str| { let s = s.trim(); if s.is_empty() { None } else { Some(s.to_string()) } };
+ let uid = app.db.create_user(auth.tenant_id, &email, &hash, &role,
+ opt(&f.vorname).as_deref(), opt(&f.nachname).as_deref(),
+ opt(&f.telefon).as_deref(), opt(&f.abteilung).as_deref(),
+ opt(&f.adresse).as_deref()).await?;
+ app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "user_created", Some("user"),
+ Some(&uid.to_string()),
+ Some(serde_json::json!({"email": email, "role": role, "auth_source": "local"})),
+ ctx.ip.as_deref()).await?;
+ Ok(users_template(&app, &auth, "Benutzer angelegt.".into(), String::new(), None).await?.into_response())
+}
+
+// ── Benutzerdetails ────────────────────────────────────────────────────────────
+#[derive(Template)]
+#[template(path = "admin_user_edit.html")]
+pub struct UserEditTemplate {
+ pub title: String,
+ pub ctx: PageCtx,
+ pub error: String,
+ pub notice: String,
+ pub user_id: i32,
+ pub email: String,
+ pub vorname: String,
+ pub nachname: String,
+ pub telefon: String,
+ pub abteilung: String,
+ pub adresse: String,
+}
+
+fn user_edit_template(auth: &crate::db::AuthUser, u: &crate::db::UserDetails,
+ error: String, notice: String) -> UserEditTemplate {
+ UserEditTemplate {
+ title: "Benutzerdetails".into(),
+ ctx: page_ctx(auth, "/admin/users"),
+ error,
+ notice,
+ user_id: u.id,
+ email: u.email.clone(),
+ vorname: u.vorname.clone().unwrap_or_default(),
+ nachname: u.nachname.clone().unwrap_or_default(),
+ telefon: u.telefon.clone().unwrap_or_default(),
+ abteilung: u.abteilung.clone().unwrap_or_default(),
+ adresse: u.adresse.clone().unwrap_or_default(),
+ }
+}
+
+pub async fn user_edit_get(State(app): State, Extension(ctx): Extension,
+ Path(user_id): Path) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin/users") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ let Some(u) = app.db.get_user_details(auth.tenant_id, user_id).await? else {
+ return Ok(StatusCode::NOT_FOUND.into_response());
+ };
+ Ok(user_edit_template(&auth, &u, String::new(), String::new()).into_response())
+}
+
+#[derive(Deserialize)]
+pub struct UserEditForm {
+ pub email: String,
+ #[serde(default)]
+ pub vorname: String,
+ #[serde(default)]
+ pub nachname: String,
+ #[serde(default)]
+ pub telefon: String,
+ #[serde(default)]
+ pub abteilung: String,
+ #[serde(default)]
+ pub adresse: String,
+}
+
+pub async fn user_edit_post(State(app): State, Extension(ctx): Extension,
+ Path(user_id): Path, Form(f): Form) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin/users") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ let Some(u) = app.db.get_user_details(auth.tenant_id, user_id).await? else {
+ return Ok(StatusCode::NOT_FOUND.into_response());
+ };
+ let email = f.email.trim().to_lowercase();
+ if email.is_empty() || !email.contains('@') {
+ return Ok(user_edit_template(&auth, &u, "Bitte eine gueltige E-Mail-Adresse angeben.".into(),
+ String::new()).into_response());
+ }
+ if app.db.email_exists(&email, Some(user_id)).await? {
+ return Ok(user_edit_template(&auth, &u,
+ "Diese E-Mail-Adresse wird bereits von einem anderen Benutzer verwendet.".into(),
+ String::new()).into_response());
+ }
+ if email != u.email {
+ app.db.update_user_email(auth.tenant_id, user_id, &email).await?;
+ app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "user_email_changed", Some("user"),
+ Some(&user_id.to_string()),
+ Some(serde_json::json!({"alt": u.email, "neu": email})),
+ ctx.ip.as_deref()).await?;
+ }
+ let opt = |s: &str| { let s = s.trim(); if s.is_empty() { None } else { Some(s.to_string()) } };
+ app.db.update_user_profile(auth.tenant_id, user_id,
+ opt(&f.vorname).as_deref(), opt(&f.nachname).as_deref(),
+ opt(&f.telefon).as_deref(), opt(&f.abteilung).as_deref(),
+ opt(&f.adresse).as_deref()).await?;
+ app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "user_profile_updated", Some("user"),
+ Some(&user_id.to_string()), None, ctx.ip.as_deref()).await?;
+ let updated = app.db.get_user_details(auth.tenant_id, user_id).await?.unwrap_or(u);
+ Ok(user_edit_template(&auth, &updated, String::new(), "Gespeichert.".into()).into_response())
+}
+
+#[derive(Deserialize)]
+pub struct RoleForm {
+ pub role: String,
+}
+
+pub async fn user_role_post(State(app): State, Extension(ctx): Extension,
+ Path(user_id): Path, Form(f): Form) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin/users") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ if !itil::ROLES.contains(&f.role.as_str()) {
+ return Ok(Redirect::to("/admin/users").into_response());
+ }
+ // Sich selbst nicht die Admin-Rolle entziehen (Aussperr-Schutz).
+ if user_id == auth.user_id && f.role != "admin" {
+ return Ok(Redirect::to("/admin/users").into_response());
+ }
+ app.db.set_user_role(auth.tenant_id, user_id, &f.role).await?;
+ app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "user_role_changed", Some("user"),
+ Some(&user_id.to_string()), Some(serde_json::json!({"role": f.role})),
+ ctx.ip.as_deref()).await?;
+ Ok(Redirect::to("/admin/users").into_response())
+}
+
+pub async fn user_active_post(State(app): State, Extension(ctx): Extension,
+ Path(user_id): Path) -> WebResult {
+ let auth = match need_auth(&ctx, "/admin/users") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_admin(&auth) { return Ok(r); }
+ let Some(u) = app.db.get_user_details(auth.tenant_id, user_id).await? else {
+ return Ok(Redirect::to("/admin/users").into_response());
+ };
+ let new_active = !u.active;
+ if user_id == auth.user_id && !new_active {
+ return Ok(Redirect::to("/admin/users").into_response()); // eigenes Konto nicht sperren
+ }
+ app.db.set_user_active(auth.tenant_id, user_id, new_active).await?;
+ app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id),
+ if new_active { "user_activated" } else { "user_deactivated" },
+ Some("user"), Some(&user_id.to_string()), None, ctx.ip.as_deref()).await?;
+ Ok(Redirect::to("/admin/users").into_response())
+}
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100755
index 0000000..4d9cb5d
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,292 @@
+//! Anmeldung, Abmeldung, Ersteinrichtung.
+//!
+//! Sicherheitsmerkmale gegenueber der frueheren Version:
+//! - Login-Rate-Limit (DB-gestuetzt, pro E-Mail und pro IP)
+//! - Argon2id-Hashes; alte Werkzeug-PBKDF2-Hashes werden beim ersten
+//! erfolgreichen Login transparent auf Argon2 migriert
+//! - serverseitige Sessions (widerrufbar), HttpOnly/SameSite=Lax-Cookie
+//! - "next"-Redirect nur auf lokale Pfade (kein Open Redirect)
+
+use askama::Template;
+use axum::extract::{Extension, Query, State};
+use axum::response::{IntoResponse, Redirect};
+use axum::Form;
+use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
+use serde::Deserialize;
+
+use crate::web::{AppState, ReqCtx, WebResult, SESSION_COOKIE};
+use crate::security;
+
+#[derive(Template)]
+#[template(path = "login.html")]
+pub struct LoginTemplate {
+ pub error: String,
+ pub notice: String,
+ pub show_setup_link: bool,
+ pub next: String,
+}
+
+#[derive(Deserialize)]
+pub struct LoginQuery {
+ pub registered: Option,
+ pub already_setup: Option,
+ pub next: Option,
+}
+
+#[derive(Deserialize)]
+pub struct LoginForm {
+ pub email: String,
+ pub password: String,
+ #[serde(default)]
+ pub next: String,
+}
+
+fn notice_from_query(q: &LoginQuery) -> String {
+ if q.registered.is_some() {
+ "Organisation angelegt. Bitte melde dich mit deinem Admin-Konto an.".into()
+ } else if q.already_setup.is_some() {
+ "Die Ersteinrichtung wurde bereits abgeschlossen. Bitte melde dich mit deinem bestehenden Konto an.".into()
+ } else {
+ String::new()
+ }
+}
+
+/// Nur lokale Pfade als Redirect-Ziel zulassen (kein Open Redirect).
+fn safe_next(next: &str) -> &str {
+ if next.starts_with('/') && !next.starts_with("//") {
+ next
+ } else {
+ "/"
+ }
+}
+
+pub async fn login_get(State(app): State, Query(q): Query) -> WebResult {
+ let tpl = LoginTemplate {
+ error: String::new(),
+ notice: notice_from_query(&q),
+ show_setup_link: !app.db.any_tenant_exists().await?,
+ next: q.next.unwrap_or_default(),
+ };
+ Ok(tpl.into_response())
+}
+
+pub async fn login_post(State(app): State,
+ Extension(ctx): Extension,
+ jar: CookieJar,
+ Form(form): Form) -> WebResult {
+ let email = form.email.trim().to_lowercase();
+ let ip = ctx.ip.as_deref();
+
+ // Rate-Limit VOR der Passwortpruefung (Brute-Force-Schutz).
+ let failed = app.db.count_recent_failed_logins(&email, ip, app.cfg.login_window_minutes).await?;
+ if failed >= app.cfg.login_max_attempts {
+ app.db.log_audit(None, None, "login_rate_limited", Some("user"), Some(&email), None, ip).await?;
+ let tpl = LoginTemplate {
+ error: format!("Zu viele Fehlversuche. Bitte in {} Minuten erneut versuchen.",
+ app.cfg.login_window_minutes),
+ notice: String::new(),
+ show_setup_link: false,
+ next: form.next.clone(),
+ };
+ return Ok((axum::http::StatusCode::TOO_MANY_REQUESTS, tpl).into_response());
+ }
+
+ let user = app.db.get_user_by_email(&email).await?;
+ let ok = match &user {
+ Some(u) => {
+ let (valid, needs_rehash) = security::verify_password(&u.password_hash, &form.password);
+ if valid && needs_rehash {
+ // Schleichende Migration Werkzeug-PBKDF2 -> Argon2id.
+ if let Ok(new_hash) = security::hash_password(&form.password) {
+ app.db.update_password_hash(u.id, &new_hash).await.ok();
+ }
+ }
+ valid
+ }
+ None => {
+ // Dummy-Verifikation gegen Timing-basiertes User-Enumeration.
+ let _ = security::verify_password(
+ "$argon2id$v=19$m=19456,t=2,p=1$YWJjZGVmZ2hpamts$m9Xtvd5RXQ3PXSyRt5S+dCLouLZzeSGf16y1SnGJgLs",
+ &form.password);
+ false
+ }
+ };
+
+ app.db.record_login_attempt(&email, ip, ok).await?;
+
+ if let (true, Some(u)) = (ok, user) {
+ let token = security::random_token();
+ let csrf = security::random_token();
+ app.db.create_session(&security::hash_token(&token), u.id, &csrf, app.cfg.session_hours).await?;
+ app.db.touch_last_login(u.id).await?;
+ app.db.log_audit(Some(u.tenant_id), Some(u.id), "login_success", Some("user"),
+ Some(&u.id.to_string()), None, ip).await?;
+ let cookie = Cookie::build((SESSION_COOKIE, token))
+ .path("/")
+ .http_only(true)
+ .same_site(SameSite::Lax)
+ .secure(app.cfg.https)
+ .max_age(time::Duration::hours(app.cfg.session_hours))
+ .build();
+ return Ok((jar.add(cookie), Redirect::to(safe_next(&form.next))).into_response());
+ }
+
+ app.db.log_audit(None, None, "login_failed", Some("user"), Some(&email), None, ip).await?;
+ let tpl = LoginTemplate {
+ error: "E-Mail oder Passwort falsch.".into(),
+ notice: String::new(),
+ show_setup_link: !app.db.any_tenant_exists().await?,
+ next: form.next,
+ };
+ Ok(tpl.into_response())
+}
+
+pub async fn logout(State(app): State,
+ Extension(ctx): Extension,
+ jar: CookieJar) -> WebResult {
+ if let Some(a) = &ctx.auth {
+ app.db.log_audit(Some(a.tenant_id), Some(a.user_id), "logout", Some("user"),
+ Some(&a.user_id.to_string()), None, ctx.ip.as_deref()).await?;
+ }
+ if let Some(c) = jar.get(SESSION_COOKIE) {
+ app.db.delete_session(&security::hash_token(c.value())).await?;
+ }
+ let jar = jar.remove(Cookie::from(SESSION_COOKIE));
+ Ok((jar, Redirect::to("/login")).into_response())
+}
+
+// ── Ersteinrichtung ────────────────────────────────────────────────────────────
+#[derive(Template)]
+#[template(path = "setup.html")]
+pub struct SetupTemplate {
+ pub error: String,
+ pub firma: String,
+ pub email: String,
+ pub dsb_name: String,
+ pub dsb_email: String,
+ pub retention_tickets: String,
+ pub retention_audit: String,
+ pub sla_antwort: String,
+ pub sla_loesung: String,
+ pub password_min_length: usize,
+}
+
+impl SetupTemplate {
+ fn empty(min_len: usize) -> SetupTemplate {
+ SetupTemplate {
+ error: String::new(),
+ firma: String::new(),
+ email: String::new(),
+ dsb_name: String::new(),
+ dsb_email: String::new(),
+ retention_tickets: "1095".into(),
+ retention_audit: "1825".into(),
+ sla_antwort: "480".into(),
+ sla_loesung: "2880".into(),
+ password_min_length: min_len,
+ }
+ }
+}
+
+#[derive(Deserialize)]
+pub struct SetupForm {
+ pub firma: String,
+ pub email: String,
+ pub password: String,
+ pub password2: String,
+ #[serde(default)]
+ pub dsb_name: String,
+ #[serde(default)]
+ pub dsb_email: String,
+ #[serde(default)]
+ pub retention_tickets_days: String,
+ #[serde(default)]
+ pub retention_audit_days: String,
+ #[serde(default)]
+ pub sla_antwort_minuten: String,
+ #[serde(default)]
+ pub sla_loesung_minuten: String,
+}
+
+pub async fn setup_get(State(app): State) -> WebResult {
+ // Einmaliger Vorgang: sobald ein Mandant existiert, ist die Route gesperrt.
+ if app.db.any_tenant_exists().await? {
+ return Ok(Redirect::to("/login?already_setup=1").into_response());
+ }
+ Ok(SetupTemplate::empty(app.cfg.password_min_length).into_response())
+}
+
+pub async fn setup_post(State(app): State,
+ Extension(ctx): Extension,
+ Form(f): Form) -> WebResult {
+ if app.db.any_tenant_exists().await? {
+ return Ok(Redirect::to("/login?already_setup=1").into_response());
+ }
+ let email = f.email.trim().to_lowercase();
+ let firma = f.firma.trim().to_string();
+
+ let mut tpl = SetupTemplate {
+ error: String::new(),
+ firma: firma.clone(),
+ email: email.clone(),
+ dsb_name: f.dsb_name.trim().to_string(),
+ dsb_email: f.dsb_email.trim().to_string(),
+ retention_tickets: f.retention_tickets_days.trim().to_string(),
+ retention_audit: f.retention_audit_days.trim().to_string(),
+ sla_antwort: f.sla_antwort_minuten.trim().to_string(),
+ sla_loesung: f.sla_loesung_minuten.trim().to_string(),
+ password_min_length: app.cfg.password_min_length,
+ };
+
+ if firma.is_empty() {
+ tpl.error = "Firmenname ist erforderlich.".into();
+ } else if email.is_empty() || !email.contains('@') {
+ tpl.error = "Bitte eine gueltige E-Mail-Adresse angeben.".into();
+ } else if let Some(p) = security::password_problem(&f.password, app.cfg.password_min_length) {
+ tpl.error = p;
+ } else if f.password != f.password2 {
+ tpl.error = "Die Passwoerter stimmen nicht ueberein.".into();
+ } else if app.db.email_exists(&email, None).await? {
+ tpl.error = "Diese E-Mail-Adresse ist bereits registriert.".into();
+ } else {
+ let parse = |s: &str, d: i32| s.parse::().unwrap_or(d).max(1);
+ let tenant_id = app.db.create_tenant(
+ &firma,
+ if tpl.dsb_name.is_empty() { None } else { Some(&tpl.dsb_name) },
+ if tpl.dsb_email.is_empty() { None } else { Some(&tpl.dsb_email) },
+ parse(&tpl.retention_tickets, 1095),
+ parse(&tpl.retention_audit, 1825),
+ parse(&tpl.sla_antwort, 480),
+ parse(&tpl.sla_loesung, 2880)).await?;
+ let hash = security::hash_password(&f.password).map_err(anyhow::Error::from)?;
+ let user_id = app.db.create_user(tenant_id, &email, &hash, "admin",
+ None, None, None, None, None).await?;
+ if !app.cfg.aes_dashboard_url.is_empty() {
+ app.db.create_service(
+ tenant_id, "AES -- Autonomous Engineering System",
+ "Automatisierte Software-Entwicklung: Projekte, Phasen, LLM-gestuetzte Agenten, \
+ Auto-Fix und Release-Pipeline.",
+ "Entwicklung", true, Some(&app.cfg.aes_dashboard_url)).await?;
+ }
+ let ip = ctx.ip.as_deref();
+ app.db.log_audit(Some(tenant_id), Some(user_id), "tenant_created", Some("tenant"),
+ Some(&tenant_id.to_string()),
+ Some(serde_json::json!({"firma": firma})), ip).await?;
+ app.db.log_audit(Some(tenant_id), Some(user_id), "user_created", Some("user"),
+ Some(&user_id.to_string()),
+ Some(serde_json::json!({"email": email, "role": "admin"})), ip).await?;
+ return Ok(Redirect::to("/login?registered=1").into_response());
+ }
+ Ok(tpl.into_response())
+}
+
+/// Root: Setup -> Login -> Dashboard.
+pub async fn root(State(app): State, Extension(ctx): Extension) -> WebResult {
+ if !app.db.any_tenant_exists().await? {
+ return Ok(Redirect::to("/setup/new").into_response());
+ }
+ if ctx.auth.is_some() {
+ return Ok(Redirect::to("/dashboard").into_response());
+ }
+ Ok(Redirect::to("/login").into_response())
+}
diff --git a/src/cmdb.rs b/src/cmdb.rs
new file mode 100755
index 0000000..130a51f
--- /dev/null
+++ b/src/cmdb.rs
@@ -0,0 +1,319 @@
+//! CMDB (ITIL v3: SACM / v4: Service Configuration Management).
+//! Zugriff nur fuer operative Rollen (admin/change_manager/agent).
+
+use askama::Template;
+use axum::extract::{Extension, Path, Query, State};
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Redirect};
+use axum::Form;
+use serde::Deserialize;
+
+use crate::web::{need_auth, need_operative, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
+
+pub const CI_TYPES: [&str; 6] = ["Server", "Software", "Lizenz", "Vertrag", "Netzwerkgeraet", "Sonstiges"];
+pub const CI_STATUS: [&str; 4] = ["Aktiv", "Inaktiv", "Wartung", "Ausgemustert"];
+pub const REL_TYPES: [&str; 4] = ["haengt ab von", "beinhaltet", "verbunden mit", "ersetzt"];
+
+fn ci_status_class(status: &str) -> &'static str {
+ match status {
+ "Aktiv" => "geloest",
+ "Inaktiv" => "geschlossen",
+ "Wartung" => "inbearbeitung",
+ "Ausgemustert" => "ueberfaellig",
+ _ => "offen",
+ }
+}
+
+/// dict -> "Schluessel: Wert" je Zeile (Formular-Textarea).
+fn attrs_to_text(attrs: &serde_json::Value) -> String {
+ attrs.as_object().map(|m| {
+ m.iter().map(|(k, v)| {
+ let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string());
+ format!("{}: {}", k, val)
+ }).collect::>().join("\n")
+ }).unwrap_or_default()
+}
+
+/// "Schluessel: Wert" je Zeile -> JSON-Objekt (fehlerhafte Zeilen ignoriert).
+fn text_to_attrs(text: &str) -> serde_json::Value {
+ let mut map = serde_json::Map::new();
+ for line in text.lines() {
+ let line = line.trim();
+ if let Some((k, v)) = line.split_once(':') {
+ let k = k.trim();
+ if !k.is_empty() {
+ map.insert(k.to_string(), serde_json::Value::String(v.trim().to_string()));
+ }
+ }
+ }
+ serde_json::Value::Object(map)
+}
+
+// ── Liste ──────────────────────────────────────────────────────────────────────
+pub struct CiRow {
+ pub id: i32,
+ pub name: String,
+ pub typ: String,
+ pub status: String,
+ pub status_class: String,
+ pub beschreibung: String,
+}
+
+pub struct CiTab {
+ pub href: String,
+ pub label: String,
+ pub active: bool,
+}
+
+#[derive(Template)]
+#[template(path = "cmdb_list.html")]
+pub struct CmdbListTemplate {
+ pub title: String,
+ pub ctx: PageCtx,
+ pub tabs: Vec,
+ pub rows: Vec,
+}
+
+#[derive(Deserialize)]
+pub struct CmdbQuery {
+ pub typ: Option,
+}
+
+pub async fn cmdb_list(State(app): State, Extension(ctx): Extension,
+ Query(q): Query) -> WebResult {
+ let auth = match need_auth(&ctx, "/assets") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_operative(&auth) { return Ok(r); }
+ let typ = q.typ.as_deref().filter(|t| CI_TYPES.contains(t));
+ let cis = app.db.list_cis(auth.tenant_id, typ).await?;
+ let mut tabs = vec![CiTab { href: "/assets".into(), label: "Alle".into(), active: typ.is_none() }];
+ tabs.extend(CI_TYPES.iter().map(|t| CiTab {
+ href: format!("/assets?typ={}", t),
+ label: t.to_string(),
+ active: typ == Some(t),
+ }));
+ let rows = cis.iter().map(|c| CiRow {
+ id: c.id,
+ name: c.name.clone(),
+ typ: c.ci_typ.clone(),
+ status: c.status.clone(),
+ status_class: ci_status_class(&c.status).into(),
+ beschreibung: c.beschreibung.clone().unwrap_or_default(),
+ }).collect();
+ let tpl = CmdbListTemplate {
+ title: "CMDB".into(),
+ ctx: page_ctx(&auth, "/assets"),
+ tabs,
+ rows,
+ };
+ Ok(tpl.into_response())
+}
+
+// ── Neu / Detail ───────────────────────────────────────────────────────────────
+#[derive(Deserialize)]
+pub struct CiForm {
+ pub name: String,
+ #[serde(default)]
+ pub ci_typ: String,
+ #[serde(default)]
+ pub status: String,
+ #[serde(default)]
+ pub beschreibung: String,
+ #[serde(default)]
+ pub attribute: String,
+}
+
+pub struct SelectOpt {
+ pub value: String,
+ pub label: String,
+ pub selected: bool,
+}
+
+fn opts(values: &[&str], selected: &str) -> Vec {
+ values.iter().map(|v| SelectOpt {
+ value: v.to_string(),
+ label: v.to_string(),
+ selected: *v == selected,
+ }).collect()
+}
+
+#[derive(Template)]
+#[template(path = "cmdb_form.html")]
+pub struct CmdbFormTemplate {
+ pub title: String,
+ pub ctx: PageCtx,
+ pub error: String,
+ pub name: String,
+ pub beschreibung: String,
+ pub attribute: String,
+ pub typ_opts: Vec,
+ pub status_opts: Vec,
+}
+
+pub async fn ci_new_get(Extension(ctx): Extension) -> WebResult {
+ let auth = match need_auth(&ctx, "/assets/new") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_operative(&auth) { return Ok(r); }
+ let tpl = CmdbFormTemplate {
+ title: "Neues Configuration Item".into(),
+ ctx: page_ctx(&auth, "/assets"),
+ error: String::new(),
+ name: String::new(),
+ beschreibung: String::new(),
+ attribute: String::new(),
+ typ_opts: opts(&CI_TYPES, "Server"),
+ status_opts: opts(&CI_STATUS, "Aktiv"),
+ };
+ Ok(tpl.into_response())
+}
+
+pub async fn ci_new_post(State(app): State, Extension(ctx): Extension,
+ Form(f): Form) -> WebResult {
+ let auth = match need_auth(&ctx, "/assets/new") { Ok(a) => a, Err(r) => return Ok(r) };
+ if let Err(r) = need_operative(&auth) { return Ok(r); }
+ let name = f.name.trim();
+ if name.is_empty() {
+ let tpl = CmdbFormTemplate {
+ title: "Neues Configuration Item".into(),
+ ctx: page_ctx(&auth, "/assets"),
+ error: "Name ist erforderlich.".into(),
+ name: String::new(),
+ beschreibung: f.beschreibung.trim().to_string(),
+ attribute: f.attribute.clone(),
+ typ_opts: opts(&CI_TYPES, &f.ci_typ),
+ status_opts: opts(&CI_STATUS, &f.status),
+ };
+ return Ok(tpl.into_response());
+ }
+ let ci_typ = if CI_TYPES.contains(&f.ci_typ.as_str()) { f.ci_typ.as_str() } else { "Sonstiges" };
+ let status = if CI_STATUS.contains(&f.status.as_str()) { f.status.as_str() } else { "Aktiv" };
+ let cid = app.db.create_ci(auth.tenant_id, name, ci_typ, status,
+ f.beschreibung.trim(), text_to_attrs(&f.attribute)).await?;
+ app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "ci_created", Some("ci"),
+ Some(&cid.to_string()),
+ Some(serde_json::json!({"name": name, "ci_typ": ci_typ})),
+ ctx.ip.as_deref()).await?;
+ Ok(Redirect::to(&format!("/assets/{}", cid)).into_response())
+}
+
+pub struct RelRow {
+ pub id: i32,
+ pub typ: String,
+ pub name: String,
+}
+
+#[derive(Template)]
+#[template(path = "cmdb_detail.html")]
+pub struct CmdbDetailTemplate {
+ pub title: String,
+ pub ctx: PageCtx,
+ pub error: String,
+ pub id: i32,
+ pub name: String,
+ pub beschreibung: String,
+ pub attribute: String,
+ pub typ_opts: Vec,
+ pub status_opts: Vec,
+ pub rel_out: Vec,
+ pub rel_in: Vec,
+ pub rel_typ_opts: Vec,
+ pub other_cis: Vec,
+}
+
+async fn detail_template(app: &AppState, auth: &crate::db::AuthUser, ci_id: i32,
+ error: String) -> anyhow::Result