diff --git a/app.py b/app.py
index 3e6dbf3..57b3857 100644
--- a/app.py
+++ b/app.py
@@ -1,189 +1,94 @@
"""
-ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES).
+ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES), mandantenfaehig.
-Architektur-Entscheidung (2026-07-13, Nutzer-Vorgabe): "Es soll ein ITSM werden,
-mit AES als 'buchbares' Modul." D.h. dieses Repo/Deployment ist NICHT eine
-Erweiterung des AES-Dashboards, sondern ein eigenes System mit Service-Katalog.
-AES ist darin nur der erste eingetragene, buchbare Service -- integriert ueber
-die bestehende AES-Dashboard-HTTP-API (Dispatch, Projekt-Status), nicht per
-Code-Import. Weitere Services (Netzwerk, Cloud, Security, ...) koennen spaeter
-denselben Mechanismus nutzen.
+Betrieb gemaess ISO 27001 / DSGVO / NIS 2 (Nutzer-Vorgabe, 2026-07-13):
+ - Persistenz in PostgreSQL statt Dateien (Zugriffskontrolle, Backup/Recovery,
+ Verschluesselung im Ruhezustand ueber die DB moeglich).
+ - Audit-Log fuer alle sicherheitsrelevanten/aendernden Aktionen (ISO 27001
+ A.12.4, DSGVO Art. 30 Rechenschaftspflicht).
+ - Ersteinrichtungs-Assistent (/setup/new): legt pro Kunde einen eigenen
+ Mandanten (Tenant) mit eigenem Admin-Konto, SLA-Standardwerten und
+ DSGVO-Angaben (Datenschutzbeauftragter, Aufbewahrungsfristen) an.
+ Mandantenfaehig: mehrere Organisationen auf einer Instanz, Daten strikt
+ per tenant_id getrennt (kein Datenzugriff ueber Mandantengrenzen).
-Persistenz: einfache JSON-Dateien unter DATA_DIR (kein Datenbankserver noetig,
-gleiches Prinzip wie im AES-Dashboard). Ein Prozess, ein Worker -- Threading-Lock
-schuetzt gegen Nebenlaeufigkeit bei mehreren Gunicorn-Threads.
+AES bleibt ein buchbarer Service im Katalog, integriert per HTTP (kein Code-
+Import) -- siehe _aes_live_status().
ENV:
- ITSM_DATA_DIR Verzeichnis fuer tickets.json/services.json/users.json
- ITSM_USER / ITSM_PASSWORD / ITSM_SECRET_KEY Login + Session-Secret
- AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration
+ DATABASE_URL postgresql://user:pass@host:5432/dbname
+ ITSM_SECRET_KEY Flask-Session-Secret
+ AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration
"""
import os
-import json
import html
-import time
-import uuid
+import json
import secrets
-import hashlib
-import threading
+import datetime as _dt
import urllib.request
import urllib.error
-import datetime as _dt
from flask import Flask, request, redirect, url_for, session, jsonify, abort
+from werkzeug.security import generate_password_hash, check_password_hash
+
+import db
-DATA_DIR = os.getenv("ITSM_DATA_DIR", "/data")
-ITSM_USER = os.getenv("ITSM_USER", "admin")
-ITSM_PASSWORD = os.getenv("ITSM_PASSWORD", "")
AES_DASHBOARD_URL = os.getenv("AES_DASHBOARD_URL", "").rstrip("/")
app = Flask(__name__)
app.secret_key = os.getenv("ITSM_SECRET_KEY") or secrets.token_hex(32)
-_LOCK = threading.Lock()
-
CATEGORIES = ["Incident", "Problem", "Task", "Change", "Release"]
PRIORITIES = ["Niedrig", "Mittel", "Hoch", "Kritisch"]
-STATUSES = ["Offen", "In Bearbeitung", "Warten", "Geloest", "Geschlossen"]
-# ── Persistenz ────────────────────────────────────────────────────────────────
-def _path(name):
- return os.path.join(DATA_DIR, name)
-
-
-def _load(name, default):
- p = _path(name)
- if not os.path.isfile(p):
- return default
- try:
- with open(p, "r", encoding="utf-8") as f:
- return json.load(f)
- except Exception:
- return default
-
-
-def _save(name, data):
- os.makedirs(DATA_DIR, exist_ok=True)
- p = _path(name)
- tmp = p + ".tmp"
- with open(tmp, "w", encoding="utf-8") as f:
- json.dump(data, f, ensure_ascii=False, indent=2)
- os.replace(tmp, p)
+def _client_ip():
+ return request.headers.get("X-Forwarded-For", request.remote_addr or "")
def _now():
return _dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
-def _next_ticket_id(tickets):
- year = _dt.datetime.utcnow().year
- seq = 1
- prefix = "TKT-%d-" % year
- existing = [t["id"] for t in tickets if t["id"].startswith(prefix)]
- if existing:
- nums = [int(i.split("-")[-1]) for i in existing]
- seq = max(nums) + 1
- return "%s%06d" % (prefix, seq)
+def _jsonable(v):
+ if isinstance(v, (_dt.datetime, _dt.date)):
+ return v.isoformat()
+ if isinstance(v, dict):
+ return {k: _jsonable(x) for k, x in v.items()}
+ if isinstance(v, list):
+ return [_jsonable(x) for x in v]
+ return v
-def _seed_if_empty():
- with _LOCK:
- tickets = _load("tickets.json", [])
- if tickets:
- return
- services = _load("services.json", [])
- service_names = [s["name"] for s in services] or ["Allgemein"]
- seed = [
- {
- "id": "TKT-%d-000001" % _dt.datetime.utcnow().year,
- "titel": "Willkommen im ITSM",
- "beschreibung": (
- "Dies ist ein Beispiel-Ticket. Ueber 'Neues Ticket' oben rechts "
- "kannst du echte Vorgaenge anlegen. Tickets koennen optional "
- "einem Service aus dem Katalog zugeordnet werden (z. B. AES)."
- ),
- "service": service_names[0],
- "status": "Offen",
- "prioritaet": "Niedrig",
- "kategorie": "Task",
- "zugewiesen_an": ITSM_USER,
- "erstellt": _now(),
- "aktualisiert": _now(),
- "fortschritt": 0,
- "betroffene_services": [],
- "ci_elemente": [],
- "sla_antwort_minuten": 480,
- "sla_loesung_minuten": 2880,
- "timeline": [{"zeit": _now(), "akteur": "System", "text": "Ticket angelegt."}],
- }
- ]
- _save("tickets.json", seed)
-
-
-def _seed_services_if_empty():
- with _LOCK:
- services = _load("services.json", [])
- if services:
- return
- services = [
- {
- "id": "aes",
- "name": "AES -- Autonomous Engineering System",
- "beschreibung": (
- "Automatisierte Software-Entwicklung: Projekte, Phasen, "
- "LLM-gestuetzte Agenten, Auto-Fix und Release-Pipeline."
- ),
- "kategorie": "Entwicklung",
- "gebucht": True,
- "endpoint": AES_DASHBOARD_URL,
- "hinzugefuegt": _now(),
- }
- ]
- _save("services.json", services)
-
-
-# ── Auth ──────────────────────────────────────────────────────────────────────
-def _check_login(user, pw):
- return user == ITSM_USER and ITSM_PASSWORD and pw == ITSM_PASSWORD
+# ── Auth / Session-Helfer ──────────────────────────────────────────────────────
+def _current_user():
+ if not session.get("user_id"):
+ return None
+ return {
+ "id": session["user_id"],
+ "email": session.get("email"),
+ "tenant_id": session.get("tenant_id"),
+ "tenant_name": session.get("tenant_name"),
+ "role": session.get("role"),
+ }
def _require_login():
- if not session.get("user"):
+ if not session.get("user_id"):
return redirect(url_for("login", next=request.path))
return None
-@app.route("/login", methods=["GET", "POST"])
-def login():
- error = None
- if request.method == "POST":
- u = request.form.get("username", "")
- p = request.form.get("password", "")
- if _check_login(u, p):
- session["user"] = u
- return redirect(request.args.get("next") or url_for("tickets_route"))
- error = "Benutzername oder Passwort falsch."
- err_html = "
%s
" % html.escape(error) if error else ""
- return """
-
-Anmelden -- ITSM%s
-""" % (_CSS_TAG, err_html)
+def _require_admin():
+ r = _require_login()
+ if r:
+ return r
+ if session.get("role") != "admin":
+ return ("Zugriff verweigert -- nur fuer Administratoren.
", 403)
+ return None
-@app.route("/logout")
-def logout():
- session.clear()
- return redirect(url_for("login"))
-
-
-# ── CSS (eigenstaendig, an AES-Farbschema angelehnt aber neu) ─────────────────
+# ── CSS (an AES-Farbschema angelehnt) ──────────────────────────────────────────
_CSS = """
:root{--bg:#161d2b;--panel:#1e2738;--panel2:#232e42;--border:#324259;--text:#e6edf5;
--sub:#93a3b8;--accent:#4c9eba;--accent2:#5db3d0;--ok:#3ecf8e;--warn:#e0a83e;
@@ -193,9 +98,10 @@ body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,S
a{color:var(--accent2);text-decoration:none}
a:hover{text-decoration:underline}
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center}
-.login-box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:28px;width:300px}
+.login-box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:28px;width:340px}
+.login-box.wide{width:560px}
.login-box h1{margin:0 0 4px;font-size:22px}
-.login-box input{width:100%;padding:9px 10px;margin-bottom:10px;background:var(--bg);
+.login-box input,.login-box select,.login-box textarea{width:100%;padding:9px 10px;margin-bottom:10px;background:var(--bg);
border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px}
.btn{background:var(--accent);border:none;color:#0c1420;font-weight:600;padding:9px 14px;
border-radius:6px;cursor:pointer;font-size:13px}
@@ -219,30 +125,30 @@ a:hover{text-decoration:underline}
border-radius:6px;padding:7px 12px;color:var(--sub);font-size:13px}
.content{padding:24px}
h1.page-title{font-size:22px;margin:0 0 18px}
+h2.section-title{font-size:15px;margin:22px 0 10px;color:var(--sub);text-transform:uppercase;letter-spacing:.04em}
.kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px}
.kpi{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px}
.kpi .lbl{color:var(--sub);font-size:12px;margin-bottom:6px}
.kpi .val{font-size:26px;font-weight:700}
-.kpi .trend{font-size:11px;margin-top:4px}
-.kpi .trend.up{color:var(--ok)} .kpi .trend.down{color:var(--bad)}
.tabs{display:flex;gap:4px;margin-bottom:14px;flex-wrap:wrap}
.tabs a{padding:6px 12px;border-radius:6px;font-size:13px;color:var(--sub);border:1px solid transparent}
.tabs a.active{background:var(--panel2);color:var(--text);border-color:var(--border)}
-table.tickets{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden}
-table.tickets th{text-align:left;font-size:11px;text-transform:uppercase;color:var(--sub);
+table.tickets,table.audit{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--border);border-radius:8px;overflow:hidden}
+table.tickets th,table.audit th{text-align:left;font-size:11px;text-transform:uppercase;color:var(--sub);
padding:10px 12px;border-bottom:1px solid var(--border);background:var(--panel2)}
-table.tickets td{padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:top}
-table.tickets tr:last-child td{border-bottom:none}
+table.tickets td,table.audit td{padding:10px 12px;border-bottom:1px solid var(--border);vertical-align:top;font-size:13px}
+table.tickets tr:last-child td,table.audit tr:last-child td{border-bottom:none}
table.tickets tr.row{cursor:pointer}
table.tickets tr.row:hover{background:var(--panel2)}
.pill{display:inline-block;padding:2px 9px;border-radius:99px;font-size:11px;font-weight:600}
.pill.offen{background:#2e3a52;color:#9fb3ce}
-.pill.bearbeitung{background:#4a3a1a;color:var(--warn)}
+.pill.bearbeitung,.pill.inbearbeitung{background:#4a3a1a;color:var(--warn)}
.pill.warten{background:#3a2f52;color:#b79ee0}
.pill.geloest{background:#1a4a35;color:var(--ok)}
.pill.geschlossen{background:#2a2a2a;color:#888}
.pill.ueberfaellig{background:#4a1e22;color:var(--bad)}
.pill.krit{color:var(--crit)} .pill.hoch{color:var(--bad)} .pill.mittel{color:var(--warn)} .pill.niedrig{color:var(--sub)}
+.pill.admin{background:#1a4a35;color:var(--ok)} .pill.agent{background:#2e3a52;color:#9fb3ce}
.bar-track{background:#2a3446;border-radius:99px;height:6px;width:90px;display:inline-block;vertical-align:middle}
.bar-fill{background:var(--accent);height:6px;border-radius:99px}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:16px}
@@ -250,10 +156,12 @@ table.tickets tr.row:hover{background:var(--panel2)}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.svc-card{display:flex;justify-content:space-between;align-items:flex-start}
.svc-card .badge{padding:2px 9px;border-radius:99px;font-size:11px;background:#1a4a35;color:var(--ok)}
-input[type=text],textarea,select{background:var(--bg);border:1px solid var(--border);border-radius:6px;
+input[type=text],input[type=email],input[type=password],input[type=number],textarea,select{
+ background:var(--bg);border:1px solid var(--border);border-radius:6px;
color:var(--text);padding:8px 10px;font-size:13px;width:100%}
textarea{min-height:70px;font-family:inherit}
.formrow{margin-bottom:10px}
+.formrow label{display:block;font-size:12px;color:var(--sub);margin-bottom:4px}
#detail-overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;z-index:40}
#detail-panel{position:fixed;right:0;top:0;bottom:0;width:420px;background:var(--panel);
border-left:1px solid var(--border);z-index:41;transform:translateX(100%);
@@ -265,6 +173,8 @@ textarea{min-height:70px;font-family:inherit}
.dt-tabs span.active{color:var(--accent2);border-bottom:2px solid var(--accent2)}
.tl-item{border-left:2px solid var(--border);padding-left:12px;margin-bottom:12px;position:relative}
.tl-item::before{content:'';position:absolute;left:-5px;top:2px;width:8px;height:8px;border-radius:50%;background:var(--accent)}
+.err{color:#E5534B;font-size:13px;margin:0 0 10px}
+.hint{color:var(--sub);font-size:11px;margin:-6px 0 10px}
"""
_CSS_TAG = ""
@@ -275,22 +185,18 @@ _NAV = [
("/aenderungen", "Aenderungen"),
("/releases", "Releases"),
]),
- ("Services", [
- ("/services", "Service-Katalog"),
- ]),
- ("Wissen", [
- ("/wissen", "Wissensdatenbank"),
- ]),
- ("Assets", [
- ("/assets", "Asset-Liste"),
- ]),
+ ("Services", [("/services", "Service-Katalog")]),
+ ("Wissen", [("/wissen", "Wissensdatenbank")]),
+ ("Assets", [("/assets", "Asset-Liste")]),
("Administration", [
("/admin", "Einstellungen"),
+ ("/admin/audit", "Audit-Log"),
]),
]
def _shell(active_path, title, body_html, extra_head=""):
+ u = _current_user()
groups_html = []
for group_name, items in _NAV:
links = "".join(
@@ -299,6 +205,7 @@ def _shell(active_path, title, body_html, extra_head=""):
)
groups_html.append('%s
%s' % (html.escape(group_name), links))
nav_html = "".join(groups_html)
+ who = "%s · %s" % (html.escape(u["email"] or ""), html.escape(u["tenant_name"] or "")) if u else ""
return """
%s -- ITSM%s%s
@@ -314,19 +221,151 @@ def _shell(active_path, title, body_html, extra_head=""):
-""" % (html.escape(title), _CSS_TAG, extra_head, nav_html, html.escape(session.get("user", "")), body_html)
+