feat!: Rust-Rewrite (axum/PostgreSQL) mit ITIL-v3/v4-Prozessen und Sicherheits-Haertung

Komplette Neuentwicklung der Plattform in Rust (axum + tokio-postgres +
askama), ersetzt die Python/Flask-Version. Bestandsdaten bleiben nutzbar:
Schema-Migrationen laufen idempotent beim Start, alte Werkzeug-PBKDF2-
Passwoerter werden beim ersten Login transparent auf Argon2id migriert.

ITIL v3/v4:
- Rollenmodell: admin / change_manager / agent / user (Self-Service)
- Statusmodell mit erzwungenen Uebergaengen, Reopen, finalem Geschlossen
- Prioritaet automatisch aus Impact-x-Urgency-Matrix (v3 SO 4.2.5.4)
- Change Enablement: Standard/Normal/Emergency, CAB-Freigabe durch
  change_manager/admin; Umsetzung und Repo-Edits erst nach Freigabe
- Problem Management: Incident-Problem-Verknuepfung, Known Error
- Service Request als eigene Kategorie; SLA-Zeitstempel (Reaktion/Loesung)
- Knowledge Management: Freigabe-Workflow (Entwurf -> Freigegeben)
- SACM: Ticket-CI-Verknuepfung; CSI-Dashboard (SLA-Erfuellung, MTTR)

Sicherheit (behebt Review-Befunde 2026-07-15):
- CSRF-Schutz fuer alle zustandsaendernden Requests (vorher: keiner)
- Login-Rate-Limit pro E-Mail+IP, DB-gestuetzt (vorher: keins)
- Serverseitige, widerrufbare Sessions (SHA-256-Token-Hash in DB) statt
  Client-Side-Sessions mit optionalem Secret
- Argon2id statt PBKDF2; Passwort-Policy min. 12 Zeichen
- Repo-Edit aus Tickets: RBAC (change_manager/admin) + freigegebener
  Change noetig; vorher jeder eingeloggte User mit Admin-Token
- SSRF-Guard fuer Service-Endpoints (Link-Local/Metadaten blockiert,
  Anlegen admin-only), Erreichbarkeitscheck parallel + gecacht
- Security-Header (CSP ohne Inline-JS, X-Frame-Options, nosniff, HSTS)
- X-Forwarded-For nur bei konfigurierten Trusted Proxies (Audit-Log-IP)
- Open-Redirect im Login-next-Parameter geschlossen

Deployment: Multi-Stage-Dockerfile statt git-clone+pip beim Container-
Start (reproduzierbare Images, kein ungetesteter main-Stand in Prod).

Tests: 7 Unit-Tests (Statusmodell, Matrix, Change-Gate, Hash-Verifikation,
SSRF-Guard) + 30 End-to-End-Smoke-Tests gegen lokalen PostgreSQL gruen.
This commit is contained in:
Moe 2026-07-15 05:02:02 +02:00
parent 6d7c511ea7
commit 7f9d2b431d
45 changed files with 7782 additions and 2234 deletions

3
.gitignore vendored Executable file
View File

@ -0,0 +1,3 @@
/target
Cargo.lock.orig
*.swp

2565
Cargo.lock generated Executable file

File diff suppressed because it is too large Load Diff

36
Cargo.toml Executable file
View File

@ -0,0 +1,36 @@
[package]
name = "itsm"
version = "1.0.0"
edition = "2021"
description = "ITSM-Plattform (Service-Katalog, Tickets, Wissensdatenbank, CMDB) -- Rust/axum, ITIL-v3/v4-orientiert"
[dependencies]
axum = "0.7"
axum-extra = { version = "0.9", features = ["cookie"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "time"] }
tower-http = { version = "0.5", features = ["fs"] }
askama = { version = "0.12", features = ["with-axum"] }
askama_axum = "0.4"
form_urlencoded = "1"
futures = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio-postgres = { version = "0.7", features = ["with-chrono-0_4", "with-serde_json-1"] }
deadpool-postgres = "0.14"
chrono = { version = "0.4", features = ["serde"] }
argon2 = "0.5"
pbkdf2 = "0.12"
sha2 = "0.10"
subtle = "2"
rand = "0.8"
hex = "0.4"
base64 = "0.21"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
anyhow = "1"
time = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[profile.release]
lto = "thin"
strip = true

23
Dockerfile Executable file
View File

@ -0,0 +1,23 @@
# ITSM -- Multi-Stage-Build: reproduzierbares Release-Binary statt des
# frueheren "git clone + pip install beim Container-Start" (das zog bei jedem
# Neustart ungetesteten main-Stand und ist fuer Rust ohnehin ungeeignet).
FROM rust:1-slim-bookworm AS builder
WORKDIR /build
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
COPY templates ./templates
COPY schema.sql ./
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update -qq \
&& apt-get install -y -qq --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /build/target/release/itsm /app/itsm
COPY static /app/static
EXPOSE 8090
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD curl -fsS http://127.0.0.1:8090/health || exit 1
USER nobody
CMD ["/app/itsm"]

61
README.md Normal file → Executable file
View File

@ -1,3 +1,62 @@
# ITSM # ITSM
ITSM-Plattform (Service-Katalog, Tickets, Wissensdatenbank, Assets) -- AES ist ein buchbarer Service darin. ITSM-Plattform (Service-Katalog, Tickets, Wissensdatenbank, CMDB) -- AES ist ein
buchbarer Service darin. Eigenstaendiges Produkt, mandantenfaehig.
**Stack:** Rust (axum + tokio-postgres + askama), PostgreSQL 16.
Rust-Rewrite 2026-07-15; zuvor Python/Flask -- Bestandsdaten (inkl. Passwort-
Hashes) werden ohne Migration weiterverwendet, alte Werkzeug-PBKDF2-Hashes
werden beim ersten Login transparent auf Argon2id umgestellt.
## ITIL-Ausrichtung (v3-Prozesse / v4-Practices)
- **Rollen:** admin (Service Owner/IT-Leitung), change_manager (Change
Enablement/CAB), agent (Service Desk), user (Requester/Self-Service:
eigene Tickets, nur freigegebene Wissensartikel).
- **Incident/Service Request:** getrennte Kategorien; Statusmodell mit
erzwungenen Uebergaengen (Offen -> In Bearbeitung -> Warten/Geloest ->
Geschlossen; Reopen von Geloest).
- **Prioritaet:** automatisch aus der Impact-x-Urgency-Matrix (v3 SO 4.2.5.4).
- **Change Enablement:** Standard (vorautorisiert) / Normal (CAB-Freigabe
durch change_manager/admin) / Emergency (sofort, nachtraegliche
ECAB-Freigabe). Umsetzung + Repo-Aenderungen erst nach Freigabe.
- **Problem Management:** Incident-Problem-Verknuepfung, Known-Error-Status.
- **SLA (Service Level Management):** Antwort-/Loesungsfristen mit
Zeitstempeln (erste Reaktion, Loesung, Schliessung), Ueberfaelligkeit.
- **Knowledge Management:** Freigabe-Workflow (Entwurf -> Freigegeben).
- **SACM/CMDB:** CIs, Beziehungen, Ticket-CI-Verknuepfung.
- **Continual Improvement:** Dashboard mit SLA-Erfuellung, MTTR, Verteilungen.
## Sicherheit
- Serverseitige Sessions in PostgreSQL (Cookie enthaelt nur ein Zufallstoken,
DB speichert dessen SHA-256; Logout/Sperrung widerruft sofort).
- CSRF-Schutz fuer alle zustandsaendernden Requests (Formular-Feld/Header).
- Login-Rate-Limit (pro E-Mail und IP, DB-gestuetzt), Audit-Log aller
sicherheitsrelevanten Aktionen (ISO 27001 A.12.4 / DSGVO Art. 30).
- Argon2id-Passwoerter, Policy: min. 12 Zeichen.
- Security-Header (CSP ohne Inline-JS, X-Frame-Options, nosniff, HSTS bei
ITSM_HTTPS=1); X-Forwarded-For nur bei ITSM_TRUSTED_PROXY_COUNT > 0.
- SSRF-Guard fuer Service-Endpoint-URLs (Link-Local/Metadaten blockiert,
Anlegen admin-only); Erreichbarkeitspruefung parallel + gecacht.
- Repo-Bearbeitung aus Tickets (Forge-Contents-API, phase-008): nur
admin/change_manager, nur aus freigegebenen Change-Tickets, jede Aenderung
zwingend im Worklog dokumentiert.
- DSGVO-Retention: automatische Bereinigung von Tickets/Audit-Log gemaess
Mandanten-Fristen; Login-Versuche nach 7 Tagen, abgelaufene Sessions sofort.
## Betrieb
# Entwicklung
DATABASE_URL=postgresql://itsm:pw@localhost:5432/itsm cargo run
# Produktion: siehe deploy/docker-compose.yml (Multi-Stage-Build,
# kein Code-Pull beim Container-Start mehr)
ENV-Variablen: siehe src/config.rs. Schema-Migrationen laufen idempotent beim
Start (schema.sql). Backup: deploy/backup.sh (taeglicher pg_dump, 14 Tage).
## Tests
cargo test # ITIL-Statusmodell, Prioritaetsmatrix, Change-Gate,
# Argon2/Werkzeug-Hash-Verifikation, SSRF-Guard, Policy

1563
app.py

File diff suppressed because it is too large Load Diff

547
db.py
View File

@ -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))

0
deploy/backup.sh Normal file → Executable file
View File

View File

@ -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

33
deploy/docker-compose.yml Normal file → Executable file
View File

@ -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: services:
postgres: 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 image: postgres:16-alpine
container_name: itsm_postgres container_name: itsm_postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: itsm POSTGRES_DB: itsm
POSTGRES_USER: itsm POSTGRES_USER: itsm
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein}
volumes: volumes:
- ./pgdata:/var/lib/postgresql/data - ./pgdata:/var/lib/postgresql/data
healthcheck: healthcheck:
@ -16,9 +29,10 @@ services:
retries: 10 retries: 10
itsm: itsm:
image: python:3.12-slim build:
context: ../
dockerfile: Dockerfile
container_name: itsm_app container_name: itsm_app
command: ["sh", "/bootstrap.sh"]
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
postgres: postgres:
@ -26,10 +40,15 @@ services:
ports: ports:
- "8090:8090" - "8090:8090"
environment: environment:
DATABASE_URL: postgresql://itsm:${POSTGRES_PASSWORD:-}@postgres:5432/itsm DATABASE_URL: postgresql://itsm:${POSTGRES_PASSWORD}@postgres:5432/itsm
ITSM_SECRET_KEY: ${ITSM_SECRET_KEY:-}
AES_DASHBOARD_URL: ${AES_DASHBOARD_URL:-http://host.docker.internal:8080} AES_DASHBOARD_URL: ${AES_DASHBOARD_URL:-http://host.docker.internal:8080}
volumes: FORGE_BASE_URL: ${FORGE_BASE_URL:-}
- ./bootstrap.sh:/bootstrap.sh:ro 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: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View File

@ -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"]

View File

@ -1,3 +0,0 @@
flask
gunicorn
psycopg2-binary

73
schema.sql Normal file → Executable file
View File

@ -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_from ON ci_relationships (from_ci_id);
CREATE INDEX IF NOT EXISTS idx_cirel_to ON ci_relationships (to_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);

408
src/admin.rs Executable file
View File

@ -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<SettingsTemplate> {
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<AppState>, Extension(ctx): Extension<ReqCtx>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<SettingsForm>) -> 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::<i32>().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<AppState>, Extension(ctx): Extension<ReqCtx>) -> 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<AuditRow>,
}
pub async fn audit_page(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>) -> 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<UserRow>,
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<UsersTemplate> {
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<AppState>, Extension(ctx): Extension<ReqCtx>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<NewUserForm>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(user_id): Path<i32>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(user_id): Path<i32>, Form(f): Form<UserEditForm>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(user_id): Path<i32>, Form(f): Form<RoleForm>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(user_id): Path<i32>) -> 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())
}

292
src/auth.rs Executable file
View File

@ -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<String>,
pub already_setup: Option<String>,
pub next: Option<String>,
}
#[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<AppState>, Query(q): Query<LoginQuery>) -> 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<AppState>,
Extension(ctx): Extension<ReqCtx>,
jar: CookieJar,
Form(form): Form<LoginForm>) -> 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<AppState>,
Extension(ctx): Extension<ReqCtx>,
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<AppState>) -> 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<AppState>,
Extension(ctx): Extension<ReqCtx>,
Form(f): Form<SetupForm>) -> 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::<i32>().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<AppState>, Extension(ctx): Extension<ReqCtx>) -> 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())
}

319
src/cmdb.rs Executable file
View File

@ -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::<Vec<_>>().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<CiTab>,
pub rows: Vec<CiRow>,
}
#[derive(Deserialize)]
pub struct CmdbQuery {
pub typ: Option<String>,
}
pub async fn cmdb_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<CmdbQuery>) -> 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<SelectOpt> {
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<SelectOpt>,
pub status_opts: Vec<SelectOpt>,
}
pub async fn ci_new_get(Extension(ctx): Extension<ReqCtx>) -> 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<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<CiForm>) -> 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<SelectOpt>,
pub status_opts: Vec<SelectOpt>,
pub rel_out: Vec<RelRow>,
pub rel_in: Vec<RelRow>,
pub rel_typ_opts: Vec<SelectOpt>,
pub other_cis: Vec<SelectOpt>,
}
async fn detail_template(app: &AppState, auth: &crate::db::AuthUser, ci_id: i32,
error: String) -> anyhow::Result<Option<CmdbDetailTemplate>> {
let Some((ci, rel_out, rel_in)) = app.db.get_ci(auth.tenant_id, ci_id).await? else {
return Ok(None);
};
let other_cis = app.db.list_cis(auth.tenant_id, None).await?
.into_iter().filter(|c| c.id != ci_id)
.map(|c| SelectOpt {
value: c.id.to_string(),
label: format!("{} ({})", c.name, c.ci_typ),
selected: false,
}).collect();
let map_rel = |r: &crate::db::CiRel| RelRow {
id: r.id,
typ: r.beziehungs_typ.clone(),
name: r.other_name.clone(),
};
Ok(Some(CmdbDetailTemplate {
title: ci.name.clone(),
ctx: page_ctx(auth, "/assets"),
error,
id: ci.id,
name: ci.name.clone(),
beschreibung: ci.beschreibung.clone().unwrap_or_default(),
attribute: attrs_to_text(&ci.attribute),
typ_opts: opts(&CI_TYPES, &ci.ci_typ),
status_opts: opts(&CI_STATUS, &ci.status),
rel_out: rel_out.iter().map(map_rel).collect(),
rel_in: rel_in.iter().map(map_rel).collect(),
rel_typ_opts: opts(&REL_TYPES, REL_TYPES[0]),
other_cis,
}))
}
pub async fn ci_detail_get(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ci_id): Path<i32>) -> 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); }
match detail_template(&app, &auth, ci_id, String::new()).await? {
Some(tpl) => Ok(tpl.into_response()),
None => Ok(StatusCode::NOT_FOUND.into_response()),
}
}
pub async fn ci_detail_post(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ci_id): Path<i32>, Form(f): Form<CiForm>) -> 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 name = f.name.trim();
if name.is_empty() {
match detail_template(&app, &auth, ci_id, "Name ist erforderlich.".into()).await? {
Some(tpl) => return Ok(tpl.into_response()),
None => return Ok(StatusCode::NOT_FOUND.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" };
app.db.update_ci(auth.tenant_id, ci_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_updated", Some("ci"),
Some(&ci_id.to_string()), Some(serde_json::json!({"name": name})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/assets/{}", ci_id)).into_response())
}
#[derive(Deserialize)]
pub struct RelForm {
pub to_ci_id: String,
#[serde(default)]
pub beziehungs_typ: String,
}
pub async fn ci_rel_add(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ci_id): Path<i32>, Form(f): Form<RelForm>) -> 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 = if REL_TYPES.contains(&f.beziehungs_typ.as_str()) { f.beziehungs_typ.as_str() } else { REL_TYPES[0] };
if let Ok(to_ci) = f.to_ci_id.parse::<i32>() {
if to_ci != ci_id && app.db.get_ci(auth.tenant_id, to_ci).await?.is_some() {
let rid = app.db.create_ci_relationship(auth.tenant_id, ci_id, to_ci, typ).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "ci_relationship_created",
Some("ci_relationship"), Some(&rid.to_string()),
Some(serde_json::json!({"from": ci_id, "to": to_ci, "typ": typ})),
ctx.ip.as_deref()).await?;
}
}
Ok(Redirect::to(&format!("/assets/{}", ci_id)).into_response())
}
pub async fn ci_rel_delete(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path((ci_id, rel_id)): Path<(i32, i32)>) -> 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); }
app.db.delete_ci_relationship(auth.tenant_id, rel_id).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "ci_relationship_deleted",
Some("ci_relationship"), Some(&rel_id.to_string()), None,
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/assets/{}", ci_id)).into_response())
}

66
src/config.rs Executable file
View File

@ -0,0 +1,66 @@
//! Zentrale Konfiguration (ENV) -- harte Startpruefung: lieber sofort
//! scheitern als unsicher laufen.
//!
//! ENV:
//! DATABASE_URL postgresql://user:pass@host:5432/dbname (Pflicht)
//! ITSM_HTTPS "1" = Secure-Flag fuer Session-Cookies (hinter TLS)
//! ITSM_TRUSTED_PROXY_COUNT Anzahl vertrauenswuerdiger Reverse-Proxies. Nur
//! dann wird X-Forwarded-For ausgewertet -- sonst
//! zaehlt die TCP-Peer-Adresse (kein IP-Spoofing
//! im Audit-Log durch selbstgesetzte Header).
//! ITSM_SESSION_HOURS Session-Lebensdauer (Default 8)
//! ITSM_BIND Bind-Adresse (Default 0.0.0.0:8090)
//! AES_DASHBOARD_URL Basis-URL des AES-Dashboards (Service-Katalog)
//! RETENTION_INTERVAL_SECONDS Intervall Retention-Bereinigung (Default 24h)
//! FORGE_BASE_URL Basis-URL des Forge-Git-Servers (optional)
//! FORGE_SERVICE_TOKEN API-Token fuer Forge-Contents-Zugriffe (optional)
//!
//! Hinweis: anders als die fruehere Flask-Version braucht der Rust-Server
//! kein ITSM_SECRET_KEY mehr -- Sessions liegen serverseitig in PostgreSQL
//! (Tabelle sessions), im Cookie steckt nur ein Zufallstoken.
pub struct Config {
pub database_url: String,
pub https: bool,
pub trusted_proxy_count: usize,
pub session_hours: i64,
pub bind: String,
pub aes_dashboard_url: String,
pub retention_interval_seconds: u64,
pub forge_base_url: String,
pub forge_service_token: String,
pub login_max_attempts: i64,
pub login_window_minutes: i64,
pub password_min_length: usize,
}
fn env(name: &str) -> String {
std::env::var(name).unwrap_or_default()
}
fn env_num<T: std::str::FromStr>(name: &str, default: T) -> T {
std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default)
}
impl Config {
pub fn from_env() -> anyhow::Result<Config> {
let cfg = Config {
database_url: env("DATABASE_URL"),
https: env("ITSM_HTTPS") == "1",
trusted_proxy_count: env_num("ITSM_TRUSTED_PROXY_COUNT", 0usize),
session_hours: env_num("ITSM_SESSION_HOURS", 8i64),
bind: std::env::var("ITSM_BIND").unwrap_or_else(|_| "0.0.0.0:8090".into()),
aes_dashboard_url: env("AES_DASHBOARD_URL").trim_end_matches('/').to_string(),
retention_interval_seconds: env_num("RETENTION_INTERVAL_SECONDS", 24 * 60 * 60u64),
forge_base_url: env("FORGE_BASE_URL").trim_end_matches('/').to_string(),
forge_service_token: env("FORGE_SERVICE_TOKEN"),
login_max_attempts: env_num("ITSM_LOGIN_MAX_ATTEMPTS", 5i64),
login_window_minutes: env_num("ITSM_LOGIN_WINDOW_MINUTES", 15i64),
password_min_length: env_num("ITSM_PASSWORD_MIN_LENGTH", 12usize),
};
if cfg.database_url.is_empty() {
anyhow::bail!("DATABASE_URL ist nicht gesetzt.");
}
Ok(cfg)
}
}

101
src/dashboard.rs Executable file
View File

@ -0,0 +1,101 @@
//! KPI-Dashboard (ITIL v4: Continual Improvement / v3: CSI).
//! Kennzahlen: offene Tickets, SLA-Erfuellung, MTTR, Verteilung nach
//! Kategorie und Prioritaet -- Basis fuer Service-Reviews.
use askama::Template;
use axum::extract::{Extension, State};
use axum::response::IntoResponse;
use chrono::{Duration, Utc};
use crate::tickets::{is_overdue, resolve_due};
use crate::web::{need_auth, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
use crate::itil;
pub struct CountRow {
pub label: String,
pub count: usize,
pub pct: i32,
}
#[derive(Template)]
#[template(path = "dashboard.html")]
pub struct DashboardTemplate {
pub title: String,
pub ctx: PageCtx,
pub kpis: Vec<(String, String)>,
pub by_category: Vec<CountRow>,
pub by_priority: Vec<CountRow>,
}
pub async fn dashboard(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>) -> WebResult {
let auth = match need_auth(&ctx, "/dashboard") { Ok(a) => a, Err(r) => return Ok(r) };
let ersteller = if itil::is_operative(&auth.role) { None } else { Some(auth.user_id) };
let tickets = app.db.list_tickets(auth.tenant_id, ersteller).await?;
let now = Utc::now();
let open = tickets.iter().filter(|t| t.status != "Geloest" && t.status != "Geschlossen").count();
let overdue = tickets.iter().filter(|t| is_overdue(t, now)).count();
// SLA-Erfuellung: Anteil geloester Tickets, die innerhalb der Loesungsfrist
// geloest wurden (letzte 30 Tage).
let recent_resolved: Vec<_> = tickets.iter()
.filter(|t| t.resolved_at.map(|r| now - r <= Duration::days(30)).unwrap_or(false))
.collect();
let sla_met = recent_resolved.iter()
.filter(|t| match (t.resolved_at, resolve_due(t)) {
(Some(r), Some(due)) => r <= due,
_ => true,
}).count();
let sla_pct = if recent_resolved.is_empty() {
"--".to_string()
} else {
format!("{}%", (sla_met * 100) / recent_resolved.len())
};
// MTTR (Mean Time To Resolve) ueber die letzten 30 Tage.
let mttr = if recent_resolved.is_empty() {
"--".to_string()
} else {
let total_min: i64 = recent_resolved.iter()
.filter_map(|t| t.resolved_at.map(|r| (r - t.created_at).num_minutes()))
.sum();
let avg = total_min / recent_resolved.len() as i64;
if avg >= 60 * 24 {
format!("{:.1} Tage", avg as f64 / (60.0 * 24.0))
} else if avg >= 60 {
format!("{:.1} Std", avg as f64 / 60.0)
} else {
format!("{} Min", avg)
}
};
let kpis = vec![
("Tickets gesamt".to_string(), tickets.len().to_string()),
("Offen / laufend".to_string(), open.to_string()),
("Ueberfaellig (SLA)".to_string(), overdue.to_string()),
("SLA-Erfuellung (30 T)".to_string(), sla_pct),
("MTTR (30 T)".to_string(), mttr),
("Geloest (30 T)".to_string(), recent_resolved.len().to_string()),
];
let dist = |values: Vec<&str>, get: &dyn Fn(&crate::db::Ticket) -> String| -> Vec<CountRow> {
let total = tickets.len().max(1);
values.iter().map(|v| {
let count = tickets.iter().filter(|t| get(t) == *v).count();
CountRow {
label: v.to_string(),
count,
pct: ((count * 100) / total) as i32,
}
}).collect()
};
let tpl = DashboardTemplate {
title: "Dashboard".into(),
ctx: page_ctx(&auth, "/dashboard"),
kpis,
by_category: dist(itil::CATEGORIES.to_vec(), &|t| t.kategorie.clone()),
by_priority: dist(itil::PRIORITIES.to_vec(), &|t| t.prioritaet.clone()),
};
Ok(tpl.into_response())
}

910
src/db.rs Executable file
View File

@ -0,0 +1,910 @@
//! Datenzugriffsschicht (PostgreSQL via deadpool/tokio-postgres, mandantenfaehig).
//!
//! Persistenz in PostgreSQL gemaess ISO 27001 / DSGVO / NIS 2 (Zugriffskontrolle,
//! Audit-Log, Backup/Recovery). Alle Queries parametrisiert; Mandantentrennung
//! strikt ueber tenant_id in jeder Abfrage.
use chrono::{DateTime, Utc};
use deadpool_postgres::{Manager, ManagerConfig, Pool, RecyclingMethod};
use tokio_postgres::types::ToSql;
use tokio_postgres::NoTls;
pub type DbResult<T> = anyhow::Result<T>;
#[derive(Clone)]
pub struct Db {
pool: Pool,
}
// ── Datenstrukturen ────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
#[allow(dead_code)] // vollstaendiges Abbild der DB-Zeile
pub struct Tenant {
pub id: i32,
pub name: String,
pub dsb_name: Option<String>,
pub dsb_email: Option<String>,
pub retention_tickets_days: i32,
pub retention_audit_days: i32,
pub sla_antwort_minuten: i32,
pub sla_loesung_minuten: i32,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct User {
pub id: i32,
pub tenant_id: i32,
pub email: String,
pub password_hash: String,
pub role: String,
pub active: bool,
}
#[derive(Debug, Clone)]
pub struct UserDetails {
pub id: i32,
pub email: String,
pub role: String,
pub active: bool,
pub auth_source: String,
pub last_login_at: Option<DateTime<Utc>>,
pub vorname: Option<String>,
pub nachname: Option<String>,
pub telefon: Option<String>,
pub abteilung: Option<String>,
pub adresse: Option<String>,
}
/// Angemeldeter Nutzer inkl. Session-Kontext (aus Session-Middleware).
#[derive(Debug, Clone)]
pub struct AuthUser {
pub user_id: i32,
pub tenant_id: i32,
pub email: String,
pub role: String,
pub tenant_name: String,
pub csrf_token: String,
}
#[derive(Debug, Clone)]
pub struct Service {
pub id: i32,
pub name: String,
pub beschreibung: Option<String>,
pub gebucht: bool,
pub endpoint: Option<String>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct Ticket {
pub id: i32,
pub ticket_nr: String,
pub titel: String,
pub beschreibung: Option<String>,
pub service_id: Option<i32>,
pub service_name: Option<String>,
pub status: String,
pub prioritaet: String,
pub kategorie: String,
pub impact: String,
pub urgency: String,
pub change_typ: Option<String>,
pub approval_status: Option<String>,
pub problem_id: Option<i32>,
pub problem_nr: Option<String>,
pub known_error: bool,
pub zugewiesen_an: Option<String>,
pub ersteller_id: Option<i32>,
pub fortschritt: i32,
pub sla_antwort_minuten: Option<i32>,
pub sla_loesung_minuten: Option<i32>,
pub first_response_at: Option<DateTime<Utc>>,
pub resolved_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct TimelineItem {
pub zeit: DateTime<Utc>,
pub akteur: Option<String>,
pub text: Option<String>,
}
#[derive(Debug, Clone)]
pub struct TicketCi {
pub id: i32,
pub name: String,
pub ci_typ: String,
}
#[derive(Debug, Clone)]
pub struct ProblemRef {
pub id: i32,
pub ticket_nr: String,
pub titel: String,
pub known_error: bool,
}
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub zeit: DateTime<Utc>,
pub user_email: Option<String>,
pub aktion: String,
pub entity_typ: Option<String>,
pub entity_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct KbArticle {
pub id: i32,
pub titel: String,
pub kategorie: String,
pub inhalt: String,
pub tags: Option<String>,
pub status: String,
pub autor_email: Option<String>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct Ci {
pub id: i32,
pub name: String,
pub ci_typ: String,
pub status: String,
pub beschreibung: Option<String>,
pub attribute: serde_json::Value,
}
#[derive(Debug, Clone)]
pub struct CiRel {
pub id: i32,
pub beziehungs_typ: String,
pub other_name: String,
}
fn ticket_from_row(r: &tokio_postgres::Row) -> Ticket {
Ticket {
id: r.get("id"),
ticket_nr: r.get("ticket_nr"),
titel: r.get("titel"),
beschreibung: r.get("beschreibung"),
service_id: r.get("service_id"),
service_name: r.get("service_name"),
status: r.get("status"),
prioritaet: r.get("prioritaet"),
kategorie: r.get("kategorie"),
impact: r.get("impact"),
urgency: r.get("urgency"),
change_typ: r.get("change_typ"),
approval_status: r.get("approval_status"),
problem_id: r.get("problem_id"),
problem_nr: r.get("problem_nr"),
known_error: r.get("known_error"),
zugewiesen_an: r.get("zugewiesen_an"),
ersteller_id: r.get("ersteller_id"),
fortschritt: r.get("fortschritt"),
sla_antwort_minuten: r.get("sla_antwort_minuten"),
sla_loesung_minuten: r.get("sla_loesung_minuten"),
first_response_at: r.get("first_response_at"),
resolved_at: r.get("resolved_at"),
created_at: r.get("created_at"),
updated_at: r.get("updated_at"),
}
}
const TICKET_SELECT: &str = "SELECT t.*, s.name AS service_name, p.ticket_nr AS problem_nr
FROM tickets t
LEFT JOIN services s ON s.id = t.service_id
LEFT JOIN tickets p ON p.id = t.problem_id";
impl Db {
pub fn connect(database_url: &str) -> DbResult<Db> {
let pg_config: tokio_postgres::Config = database_url.parse()?;
let mgr = Manager::from_config(
pg_config,
NoTls,
ManagerConfig { recycling_method: RecyclingMethod::Fast },
);
let pool = Pool::builder(mgr).max_size(10).build()?;
Ok(Db { pool })
}
async fn conn(&self) -> DbResult<deadpool_postgres::Object> {
Ok(self.pool.get().await?)
}
/// Legt das Schema an (idempotent, siehe schema.sql). Beim App-Start.
pub async fn init_schema(&self) -> DbResult<()> {
let c = self.conn().await?;
c.batch_execute(include_str!("../schema.sql")).await?;
Ok(())
}
pub async fn health(&self) -> DbResult<()> {
let c = self.conn().await?;
c.query_one("SELECT 1", &[]).await?;
Ok(())
}
// ── Tenants / Ersteinrichtung ──────────────────────────────────────────────
pub async fn any_tenant_exists(&self) -> DbResult<bool> {
let c = self.conn().await?;
Ok(c.query_opt("SELECT 1 FROM tenants LIMIT 1", &[]).await?.is_some())
}
#[allow(clippy::too_many_arguments)]
pub async fn create_tenant(&self, name: &str, dsb_name: Option<&str>, dsb_email: Option<&str>,
retention_tickets_days: i32, retention_audit_days: i32,
sla_antwort: i32, sla_loesung: i32) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO tenants (name, dsb_name, dsb_email, retention_tickets_days,
retention_audit_days, sla_antwort_minuten, sla_loesung_minuten)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id",
&[&name, &dsb_name, &dsb_email, &retention_tickets_days, &retention_audit_days,
&sla_antwort, &sla_loesung]).await?;
Ok(row.get(0))
}
pub async fn get_tenant(&self, tenant_id: i32) -> DbResult<Option<Tenant>> {
let c = self.conn().await?;
Ok(c.query_opt("SELECT * FROM tenants WHERE id=$1", &[&tenant_id]).await?.map(|r| Tenant {
id: r.get("id"),
name: r.get("name"),
dsb_name: r.get("dsb_name"),
dsb_email: r.get("dsb_email"),
retention_tickets_days: r.get("retention_tickets_days"),
retention_audit_days: r.get("retention_audit_days"),
sla_antwort_minuten: r.get("sla_antwort_minuten"),
sla_loesung_minuten: r.get("sla_loesung_minuten"),
}))
}
#[allow(clippy::too_many_arguments)]
pub async fn update_tenant_settings(&self, tenant_id: i32, dsb_name: Option<&str>, dsb_email: Option<&str>,
retention_tickets_days: i32, retention_audit_days: i32,
sla_antwort: i32, sla_loesung: i32) -> DbResult<()> {
let c = self.conn().await?;
c.execute(
"UPDATE tenants SET dsb_name=$1, dsb_email=$2, retention_tickets_days=$3,
retention_audit_days=$4, sla_antwort_minuten=$5, sla_loesung_minuten=$6 WHERE id=$7",
&[&dsb_name, &dsb_email, &retention_tickets_days, &retention_audit_days,
&sla_antwort, &sla_loesung, &tenant_id]).await?;
Ok(())
}
// ── Users ──────────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
pub async fn create_user(&self, tenant_id: i32, email: &str, password_hash: &str, role: &str,
vorname: Option<&str>, nachname: Option<&str>, telefon: Option<&str>,
abteilung: Option<&str>, adresse: Option<&str>) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO users (tenant_id, email, password_hash, role, vorname, nachname, telefon, abteilung, adresse)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id",
&[&tenant_id, &email, &password_hash, &role, &vorname, &nachname, &telefon, &abteilung, &adresse]).await?;
Ok(row.get(0))
}
pub async fn get_user_by_email(&self, email: &str) -> DbResult<Option<User>> {
let c = self.conn().await?;
Ok(c.query_opt("SELECT id, tenant_id, email, password_hash, role, active FROM users WHERE email=$1 AND active=TRUE",
&[&email]).await?.map(|r| User {
id: r.get("id"),
tenant_id: r.get("tenant_id"),
email: r.get("email"),
password_hash: r.get("password_hash"),
role: r.get("role"),
active: r.get("active"),
}))
}
pub async fn touch_last_login(&self, user_id: i32) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE users SET last_login_at=now() WHERE id=$1", &[&user_id]).await?;
Ok(())
}
pub async fn update_password_hash(&self, user_id: i32, hash: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE users SET password_hash=$1 WHERE id=$2", &[&hash, &user_id]).await?;
Ok(())
}
pub async fn email_exists(&self, email: &str, exclude_user_id: Option<i32>) -> DbResult<bool> {
let c = self.conn().await?;
let found = match exclude_user_id {
Some(uid) => c.query_opt("SELECT 1 FROM users WHERE email=$1 AND id<>$2", &[&email, &uid]).await?,
None => c.query_opt("SELECT 1 FROM users WHERE email=$1", &[&email]).await?,
};
Ok(found.is_some())
}
pub async fn list_users(&self, tenant_id: i32) -> DbResult<Vec<UserDetails>> {
let c = self.conn().await?;
let rows = c.query(
"SELECT id, email, role, active, auth_source, last_login_at, vorname, nachname,
telefon, abteilung, adresse FROM users WHERE tenant_id=$1 ORDER BY email",
&[&tenant_id]).await?;
Ok(rows.iter().map(user_details_from_row).collect())
}
pub async fn get_user_details(&self, tenant_id: i32, user_id: i32) -> DbResult<Option<UserDetails>> {
let c = self.conn().await?;
Ok(c.query_opt(
"SELECT id, email, role, active, auth_source, last_login_at, vorname, nachname,
telefon, abteilung, adresse FROM users WHERE tenant_id=$1 AND id=$2",
&[&tenant_id, &user_id]).await?.map(|r| user_details_from_row(&r)))
}
#[allow(clippy::too_many_arguments)]
pub async fn update_user_profile(&self, tenant_id: i32, user_id: i32, vorname: Option<&str>,
nachname: Option<&str>, telefon: Option<&str>,
abteilung: Option<&str>, adresse: Option<&str>) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE users SET vorname=$1, nachname=$2, telefon=$3, abteilung=$4, adresse=$5
WHERE tenant_id=$6 AND id=$7",
&[&vorname, &nachname, &telefon, &abteilung, &adresse, &tenant_id, &user_id]).await?;
Ok(())
}
pub async fn update_user_email(&self, tenant_id: i32, user_id: i32, email: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE users SET email=$1 WHERE tenant_id=$2 AND id=$3", &[&email, &tenant_id, &user_id]).await?;
Ok(())
}
pub async fn set_user_role(&self, tenant_id: i32, user_id: i32, role: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE users SET role=$1 WHERE tenant_id=$2 AND id=$3", &[&role, &tenant_id, &user_id]).await?;
Ok(())
}
pub async fn set_user_active(&self, tenant_id: i32, user_id: i32, active: bool) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE users SET active=$1 WHERE tenant_id=$2 AND id=$3", &[&active, &tenant_id, &user_id]).await?;
if !active {
// Kontosperrung widerruft alle laufenden Sessions sofort.
c.execute("DELETE FROM sessions WHERE user_id=$1", &[&user_id]).await?;
}
Ok(())
}
// ── Sessions (serverseitig, widerrufbar) ───────────────────────────────────
pub async fn create_session(&self, token_hash: &str, user_id: i32, csrf_token: &str, hours: i64) -> DbResult<()> {
let c = self.conn().await?;
c.execute(
"INSERT INTO sessions (token_hash, user_id, csrf_token, expires_at)
VALUES ($1,$2,$3, now() + ($4 || ' hours')::interval)",
&[&token_hash, &user_id, &csrf_token, &hours.to_string()]).await?;
Ok(())
}
pub async fn get_session_user(&self, token_hash: &str) -> DbResult<Option<AuthUser>> {
let c = self.conn().await?;
Ok(c.query_opt(
"SELECT u.id AS user_id, u.tenant_id, u.email, u.role, t.name AS tenant_name, s.csrf_token
FROM sessions s
JOIN users u ON u.id = s.user_id AND u.active=TRUE
JOIN tenants t ON t.id = u.tenant_id
WHERE s.token_hash=$1 AND s.expires_at > now()",
&[&token_hash]).await?.map(|r| AuthUser {
user_id: r.get("user_id"),
tenant_id: r.get("tenant_id"),
email: r.get("email"),
role: r.get("role"),
tenant_name: r.get("tenant_name"),
csrf_token: r.get("csrf_token"),
}))
}
pub async fn delete_session(&self, token_hash: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("DELETE FROM sessions WHERE token_hash=$1", &[&token_hash]).await?;
Ok(())
}
// ── Login-Rate-Limiting ────────────────────────────────────────────────────
pub async fn record_login_attempt(&self, email: &str, ip: Option<&str>, success: bool) -> DbResult<()> {
let c = self.conn().await?;
c.execute("INSERT INTO login_attempts (email, ip, success) VALUES ($1,$2,$3)",
&[&email, &ip, &success]).await?;
Ok(())
}
/// Fehlversuche im Zeitfenster; der hoehere Wert von E-Mail- und IP-Zaehlung
/// zaehlt (gezieltes Bruteforcing UND breites Passwort-Spraying abdecken).
pub async fn count_recent_failed_logins(&self, email: &str, ip: Option<&str>, window_minutes: i64) -> DbResult<i64> {
let c = self.conn().await?;
let row = c.query_one(
"SELECT
(SELECT count(*) FROM login_attempts WHERE email=$1 AND success=FALSE
AND zeit > now() - ($2 || ' minutes')::interval),
(SELECT count(*) FROM login_attempts WHERE ip=$3 AND $3 IS NOT NULL AND success=FALSE
AND zeit > now() - ($2 || ' minutes')::interval)",
&[&email, &window_minutes.to_string(), &ip]).await?;
let by_email: i64 = row.get(0);
let by_ip: i64 = row.get(1);
Ok(by_email.max(by_ip))
}
// ── Audit-Log ──────────────────────────────────────────────────────────────
pub async fn log_audit(&self, tenant_id: Option<i32>, user_id: Option<i32>, aktion: &str,
entity_typ: Option<&str>, entity_id: Option<&str>,
details: Option<serde_json::Value>, ip: Option<&str>) -> DbResult<()> {
let c = self.conn().await?;
c.execute(
"INSERT INTO audit_log (tenant_id, user_id, aktion, entity_typ, entity_id, details, ip)
VALUES ($1,$2,$3,$4,$5,$6,$7)",
&[&tenant_id, &user_id, &aktion, &entity_typ, &entity_id, &details, &ip]).await?;
Ok(())
}
pub async fn list_audit(&self, tenant_id: i32, limit: i64) -> DbResult<Vec<AuditEntry>> {
let c = self.conn().await?;
let rows = c.query(
"SELECT a.zeit, a.aktion, a.entity_typ, a.entity_id, u.email AS user_email
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
WHERE a.tenant_id=$1 ORDER BY a.zeit DESC LIMIT $2",
&[&tenant_id, &limit]).await?;
Ok(rows.iter().map(|r| AuditEntry {
zeit: r.get("zeit"),
user_email: r.get("user_email"),
aktion: r.get("aktion"),
entity_typ: r.get("entity_typ"),
entity_id: r.get("entity_id"),
}).collect())
}
// ── Services ───────────────────────────────────────────────────────────────
pub async fn create_service(&self, tenant_id: i32, name: &str, beschreibung: &str,
kategorie: &str, gebucht: bool, endpoint: Option<&str>) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO services (tenant_id, name, beschreibung, kategorie, gebucht, endpoint)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
&[&tenant_id, &name, &beschreibung, &kategorie, &gebucht, &endpoint]).await?;
Ok(row.get(0))
}
pub async fn list_services(&self, tenant_id: i32) -> DbResult<Vec<Service>> {
let c = self.conn().await?;
let rows = c.query("SELECT id, name, beschreibung, gebucht, endpoint FROM services
WHERE tenant_id=$1 ORDER BY id", &[&tenant_id]).await?;
Ok(rows.iter().map(|r| Service {
id: r.get("id"),
name: r.get("name"),
beschreibung: r.get("beschreibung"),
gebucht: r.get("gebucht"),
endpoint: r.get("endpoint"),
}).collect())
}
// ── Tickets ────────────────────────────────────────────────────────────────
async fn next_ticket_nr(&self, tenant_id: i32) -> DbResult<String> {
let year = Utc::now().format("%Y").to_string();
let prefix = format!("TKT-{}-", year);
let like = format!("{}%", prefix);
let c = self.conn().await?;
let row = c.query_opt(
"SELECT ticket_nr FROM tickets WHERE tenant_id=$1 AND ticket_nr LIKE $2
ORDER BY ticket_nr DESC LIMIT 1",
&[&tenant_id, &like]).await?;
let seq = row
.and_then(|r| r.get::<_, String>(0).rsplit('-').next().and_then(|s| s.parse::<u32>().ok()))
.map(|n| n + 1)
.unwrap_or(1);
Ok(format!("{}{:06}", prefix, seq))
}
#[allow(clippy::too_many_arguments)]
pub async fn create_ticket(&self, tenant_id: i32, titel: &str, beschreibung: &str,
service_id: Option<i32>, prioritaet: &str, kategorie: &str,
zugewiesen_an: Option<&str>, ersteller_id: i32,
sla_antwort: i32, sla_loesung: i32, actor: &str,
impact: &str, urgency: &str,
change_typ: Option<&str>, approval_status: Option<&str>) -> DbResult<(i32, String)> {
let ticket_nr = self.next_ticket_nr(tenant_id).await?;
let mut c = self.conn().await?;
let tx = c.transaction().await?;
let row = tx.query_one(
"INSERT INTO tickets (tenant_id, ticket_nr, titel, beschreibung, service_id, prioritaet,
kategorie, zugewiesen_an, ersteller_id, sla_antwort_minuten, sla_loesung_minuten,
impact, urgency, change_typ, approval_status)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id",
&[&tenant_id, &ticket_nr, &titel, &beschreibung, &service_id, &prioritaet, &kategorie,
&zugewiesen_an, &ersteller_id, &sla_antwort, &sla_loesung, &impact, &urgency,
&change_typ, &approval_status]).await?;
let tid: i32 = row.get(0);
tx.execute("INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text) VALUES ($1,$2,$3,$4)",
&[&tid, &tenant_id, &actor, &"Ticket angelegt."]).await?;
tx.commit().await?;
Ok((tid, ticket_nr))
}
pub async fn list_tickets(&self, tenant_id: i32, ersteller_id: Option<i32>) -> DbResult<Vec<Ticket>> {
let c = self.conn().await?;
let mut sql = format!("{} WHERE t.tenant_id=$1", TICKET_SELECT);
let mut params: Vec<&(dyn ToSql + Sync)> = vec![&tenant_id];
if let Some(ref eid) = ersteller_id {
sql.push_str(" AND t.ersteller_id=$2");
params.push(eid);
}
sql.push_str(" ORDER BY t.updated_at DESC");
let rows = c.query(&sql, &params).await?;
Ok(rows.iter().map(ticket_from_row).collect())
}
pub async fn get_ticket(&self, tenant_id: i32, ticket_id: i32) -> DbResult<Option<Ticket>> {
let c = self.conn().await?;
let sql = format!("{} WHERE t.tenant_id=$1 AND t.id=$2", TICKET_SELECT);
Ok(c.query_opt(&sql, &[&tenant_id, &ticket_id]).await?.map(|r| ticket_from_row(&r)))
}
pub async fn get_ticket_timeline(&self, tenant_id: i32, ticket_id: i32) -> DbResult<Vec<TimelineItem>> {
let c = self.conn().await?;
let rows = c.query(
"SELECT zeit, akteur, text FROM ticket_timeline WHERE tenant_id=$1 AND ticket_id=$2 ORDER BY zeit DESC",
&[&tenant_id, &ticket_id]).await?;
Ok(rows.iter().map(|r| TimelineItem {
zeit: r.get("zeit"),
akteur: r.get("akteur"),
text: r.get("text"),
}).collect())
}
pub async fn get_ticket_cis(&self, tenant_id: i32, ticket_id: i32) -> DbResult<Vec<TicketCi>> {
let c = self.conn().await?;
let rows = c.query(
"SELECT c.id, c.name, c.ci_typ FROM ticket_ci_links l
JOIN configuration_items c ON c.id = l.ci_id
WHERE l.tenant_id=$1 AND l.ticket_id=$2 ORDER BY c.name",
&[&tenant_id, &ticket_id]).await?;
Ok(rows.iter().map(|r| TicketCi { id: r.get("id"), name: r.get("name"), ci_typ: r.get("ci_typ") }).collect())
}
/// Setzt den Status und pflegt SLA-Zeitstempel (erste Reaktion, Loesung,
/// Schliessung). Reopen setzt resolved_at zurueck. Die Gueltigkeit des
/// Uebergangs prueft der Aufrufer via itil::is_valid_transition.
pub async fn update_ticket_status(&self, tenant_id: i32, ticket_id: i32, status: &str, actor: &str) -> DbResult<()> {
let mut c = self.conn().await?;
let tx = c.transaction().await?;
tx.execute(
"UPDATE tickets SET status=$1, updated_at=now(),
fortschritt = CASE WHEN $1 IN ('Geloest','Geschlossen') THEN 100 ELSE fortschritt END,
first_response_at = CASE WHEN $1 = 'In Bearbeitung' AND first_response_at IS NULL
THEN now() ELSE first_response_at END,
resolved_at = CASE WHEN $1 = 'Geloest' THEN now()
WHEN $1 = 'Offen' THEN NULL ELSE resolved_at END,
closed_at = CASE WHEN $1 = 'Geschlossen' THEN now() ELSE closed_at END
WHERE tenant_id=$2 AND id=$3",
&[&status, &tenant_id, &ticket_id]).await?;
let text = format!("Status geaendert auf '{}'.", status);
tx.execute("INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text) VALUES ($1,$2,$3,$4)",
&[&ticket_id, &tenant_id, &actor, &text]).await?;
tx.commit().await?;
Ok(())
}
/// Change Enablement: Freigabe/Ablehnung eines Change-Tickets.
pub async fn set_ticket_approval(&self, tenant_id: i32, ticket_id: i32, approval_status: &str,
approver_user_id: i32, actor: &str) -> DbResult<()> {
let mut c = self.conn().await?;
let tx = c.transaction().await?;
tx.execute(
"UPDATE tickets SET approval_status=$1, approved_by=$2, approved_at=now(), updated_at=now()
WHERE tenant_id=$3 AND id=$4 AND kategorie='Change'",
&[&approval_status, &approver_user_id, &tenant_id, &ticket_id]).await?;
let text = format!("Change-Freigabe: {}.", approval_status);
tx.execute("INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text) VALUES ($1,$2,$3,$4)",
&[&ticket_id, &tenant_id, &actor, &text]).await?;
tx.commit().await?;
Ok(())
}
/// Problem Management: Incident <-> Problem verknuepfen/loesen.
pub async fn set_ticket_problem_link(&self, tenant_id: i32, ticket_id: i32,
problem_id: Option<i32>, actor: &str) -> DbResult<()> {
let mut c = self.conn().await?;
let tx = c.transaction().await?;
tx.execute("UPDATE tickets SET problem_id=$1, updated_at=now() WHERE tenant_id=$2 AND id=$3",
&[&problem_id, &tenant_id, &ticket_id]).await?;
let text = match problem_id {
Some(pid) => format!("Mit Problem #{} verknuepft.", pid),
None => "Problem-Verknuepfung entfernt.".to_string(),
};
tx.execute("INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text) VALUES ($1,$2,$3,$4)",
&[&ticket_id, &tenant_id, &actor, &text]).await?;
tx.commit().await?;
Ok(())
}
pub async fn set_known_error(&self, tenant_id: i32, ticket_id: i32, known_error: bool, actor: &str) -> DbResult<()> {
let mut c = self.conn().await?;
let tx = c.transaction().await?;
tx.execute(
"UPDATE tickets SET known_error=$1, updated_at=now() WHERE tenant_id=$2 AND id=$3 AND kategorie='Problem'",
&[&known_error, &tenant_id, &ticket_id]).await?;
let text = if known_error { "Als Known Error markiert." } else { "Known-Error-Markierung entfernt." };
tx.execute("INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text) VALUES ($1,$2,$3,$4)",
&[&ticket_id, &tenant_id, &actor, &text]).await?;
tx.commit().await?;
Ok(())
}
pub async fn add_ticket_comment(&self, tenant_id: i32, ticket_id: i32, actor: &str, text: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("INSERT INTO ticket_timeline (ticket_id, tenant_id, akteur, text) VALUES ($1,$2,$3,$4)",
&[&ticket_id, &tenant_id, &actor, &text]).await?;
Ok(())
}
/// Worklog-Eintrag fuer eine ueber ITSM vorgenommene Forge-Repo-Aenderung
/// (phase-008: JEDE Repo-Aenderung MUSS im Worklog dokumentiert werden;
/// Fehler hier => Aufrufer darf keinen Erfolg melden).
pub async fn log_repo_edit(&self, tenant_id: i32, ticket_id: i32, actor: &str, repo: &str,
path: &str, branch: &str, commit_sha: &str) -> DbResult<()> {
let short = &commit_sha[..commit_sha.len().min(10)];
let text = format!("Repo-Datei bearbeitet: {}@{} ({}) -- Commit {}", repo, branch, path, short);
self.add_ticket_comment(tenant_id, ticket_id, actor, &text).await
}
pub async fn list_problems(&self, tenant_id: i32) -> DbResult<Vec<ProblemRef>> {
let c = self.conn().await?;
let rows = c.query(
"SELECT id, ticket_nr, titel, known_error FROM tickets
WHERE tenant_id=$1 AND kategorie='Problem' AND status <> 'Geschlossen' ORDER BY id DESC",
&[&tenant_id]).await?;
Ok(rows.iter().map(|r| ProblemRef {
id: r.get("id"),
ticket_nr: r.get("ticket_nr"),
titel: r.get("titel"),
known_error: r.get("known_error"),
}).collect())
}
pub async fn link_ticket_ci(&self, tenant_id: i32, ticket_id: i32, ci_id: i32) -> DbResult<()> {
let c = self.conn().await?;
c.execute("INSERT INTO ticket_ci_links (tenant_id, ticket_id, ci_id) VALUES ($1,$2,$3)
ON CONFLICT (ticket_id, ci_id) DO NOTHING",
&[&tenant_id, &ticket_id, &ci_id]).await?;
Ok(())
}
pub async fn unlink_ticket_ci(&self, tenant_id: i32, ticket_id: i32, ci_id: i32) -> DbResult<()> {
let c = self.conn().await?;
c.execute("DELETE FROM ticket_ci_links WHERE tenant_id=$1 AND ticket_id=$2 AND ci_id=$3",
&[&tenant_id, &ticket_id, &ci_id]).await?;
Ok(())
}
// ── Wissensdatenbank ───────────────────────────────────────────────────────
pub async fn create_kb_article(&self, tenant_id: i32, titel: &str, kategorie: &str, inhalt: &str,
tags: &str, autor_user_id: i32) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO kb_articles (tenant_id, titel, kategorie, inhalt, tags, autor_user_id, status)
VALUES ($1,$2,$3,$4,$5,$6,'Entwurf') RETURNING id",
&[&tenant_id, &titel, &kategorie, &inhalt, &tags, &autor_user_id]).await?;
Ok(row.get(0))
}
pub async fn list_kb_articles(&self, tenant_id: i32, kategorie: Option<&str>, q: Option<&str>,
only_released: bool) -> DbResult<Vec<KbArticle>> {
let c = self.conn().await?;
let mut sql = String::from(
"SELECT k.id, k.titel, k.kategorie, k.inhalt, k.tags, k.status, k.updated_at,
u.email AS autor_email FROM kb_articles k
LEFT JOIN users u ON u.id = k.autor_user_id WHERE k.tenant_id=$1");
let like;
let mut params: Vec<&(dyn ToSql + Sync)> = vec![&tenant_id];
if only_released {
sql.push_str(" AND k.status='Freigegeben'");
}
if let Some(ref kat) = kategorie {
params.push(kat);
sql.push_str(&format!(" AND k.kategorie=${}", params.len()));
}
if let Some(qs) = q {
like = format!("%{}%", qs);
params.push(&like);
let n = params.len();
sql.push_str(&format!(" AND (k.titel ILIKE ${n} OR k.inhalt ILIKE ${n} OR k.tags ILIKE ${n})"));
}
sql.push_str(" ORDER BY k.updated_at DESC");
let rows = c.query(&sql, &params).await?;
Ok(rows.iter().map(kb_from_row).collect())
}
pub async fn get_kb_article(&self, tenant_id: i32, article_id: i32) -> DbResult<Option<KbArticle>> {
let c = self.conn().await?;
Ok(c.query_opt(
"SELECT k.id, k.titel, k.kategorie, k.inhalt, k.tags, k.status, k.updated_at,
u.email AS autor_email FROM kb_articles k
LEFT JOIN users u ON u.id = k.autor_user_id WHERE k.tenant_id=$1 AND k.id=$2",
&[&tenant_id, &article_id]).await?.map(|r| kb_from_row(&r)))
}
pub async fn update_kb_article(&self, tenant_id: i32, article_id: i32, titel: &str,
kategorie: &str, inhalt: &str, tags: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE kb_articles SET titel=$1, kategorie=$2, inhalt=$3, tags=$4, updated_at=now()
WHERE tenant_id=$5 AND id=$6",
&[&titel, &kategorie, &inhalt, &tags, &tenant_id, &article_id]).await?;
Ok(())
}
pub async fn set_kb_status(&self, tenant_id: i32, article_id: i32, status: &str) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE kb_articles SET status=$1, updated_at=now() WHERE tenant_id=$2 AND id=$3",
&[&status, &tenant_id, &article_id]).await?;
Ok(())
}
pub async fn delete_kb_article(&self, tenant_id: i32, article_id: i32) -> DbResult<()> {
let c = self.conn().await?;
c.execute("DELETE FROM kb_articles WHERE tenant_id=$1 AND id=$2", &[&tenant_id, &article_id]).await?;
Ok(())
}
// ── CMDB ───────────────────────────────────────────────────────────────────
pub async fn create_ci(&self, tenant_id: i32, name: &str, ci_typ: &str, status: &str,
beschreibung: &str, attribute: serde_json::Value) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO configuration_items (tenant_id, name, ci_typ, status, beschreibung, attribute)
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
&[&tenant_id, &name, &ci_typ, &status, &beschreibung, &attribute]).await?;
Ok(row.get(0))
}
pub async fn list_cis(&self, tenant_id: i32, ci_typ: Option<&str>) -> DbResult<Vec<Ci>> {
let c = self.conn().await?;
let rows = match ci_typ {
Some(t) => c.query("SELECT id, name, ci_typ, status, beschreibung, attribute FROM configuration_items
WHERE tenant_id=$1 AND ci_typ=$2 ORDER BY name", &[&tenant_id, &t]).await?,
None => c.query("SELECT id, name, ci_typ, status, beschreibung, attribute FROM configuration_items
WHERE tenant_id=$1 ORDER BY ci_typ, name", &[&tenant_id]).await?,
};
Ok(rows.iter().map(ci_from_row).collect())
}
pub async fn get_ci(&self, tenant_id: i32, ci_id: i32) -> DbResult<Option<(Ci, Vec<CiRel>, Vec<CiRel>)>> {
let c = self.conn().await?;
let Some(row) = c.query_opt(
"SELECT id, name, ci_typ, status, beschreibung, attribute FROM configuration_items
WHERE tenant_id=$1 AND id=$2", &[&tenant_id, &ci_id]).await? else {
return Ok(None);
};
let ci = ci_from_row(&row);
let rel_out = c.query(
"SELECT r.id, r.beziehungs_typ, c.name AS other_name FROM ci_relationships r
JOIN configuration_items c ON c.id = r.to_ci_id
WHERE r.tenant_id=$1 AND r.from_ci_id=$2 ORDER BY r.id", &[&tenant_id, &ci_id]).await?;
let rel_in = c.query(
"SELECT r.id, r.beziehungs_typ, c.name AS other_name FROM ci_relationships r
JOIN configuration_items c ON c.id = r.from_ci_id
WHERE r.tenant_id=$1 AND r.to_ci_id=$2 ORDER BY r.id", &[&tenant_id, &ci_id]).await?;
let map = |r: &tokio_postgres::Row| CiRel {
id: r.get("id"),
beziehungs_typ: r.get("beziehungs_typ"),
other_name: r.get("other_name"),
};
Ok(Some((ci, rel_out.iter().map(map).collect(), rel_in.iter().map(map).collect())))
}
#[allow(clippy::too_many_arguments)]
pub async fn update_ci(&self, tenant_id: i32, ci_id: i32, name: &str, ci_typ: &str, status: &str,
beschreibung: &str, attribute: serde_json::Value) -> DbResult<()> {
let c = self.conn().await?;
c.execute("UPDATE configuration_items SET name=$1, ci_typ=$2, status=$3, beschreibung=$4,
attribute=$5, updated_at=now() WHERE tenant_id=$6 AND id=$7",
&[&name, &ci_typ, &status, &beschreibung, &attribute, &tenant_id, &ci_id]).await?;
Ok(())
}
pub async fn create_ci_relationship(&self, tenant_id: i32, from_ci: i32, to_ci: i32, typ: &str) -> DbResult<i32> {
let c = self.conn().await?;
let row = c.query_one(
"INSERT INTO ci_relationships (tenant_id, from_ci_id, to_ci_id, beziehungs_typ)
VALUES ($1,$2,$3,$4) RETURNING id",
&[&tenant_id, &from_ci, &to_ci, &typ]).await?;
Ok(row.get(0))
}
pub async fn delete_ci_relationship(&self, tenant_id: i32, rel_id: i32) -> DbResult<()> {
let c = self.conn().await?;
c.execute("DELETE FROM ci_relationships WHERE tenant_id=$1 AND id=$2", &[&tenant_id, &rel_id]).await?;
Ok(())
}
// ── Aufbewahrungsfrist-Bereinigung (DSGVO Speicherbegrenzung) ──────────────
/// Loescht abgeschlossene Tickets / alte Audit-Eintraege gemaess den pro
/// Mandant hinterlegten Fristen. Postgres-Advisory-Lock verhindert
/// parallele Laeufe. Abgelaufene Sessions und alte Login-Versuche werden
/// mit bereinigt (Datenminimierung).
pub async fn run_retention_cleanup(&self) -> DbResult<()> {
const LOCK_KEY: i64 = 727271;
let c = self.conn().await?;
let got: bool = c.query_one("SELECT pg_try_advisory_lock($1)", &[&LOCK_KEY]).await?.get(0);
if !got {
return Ok(());
}
let result = async {
let tenants = c.query(
"SELECT id, retention_tickets_days, retention_audit_days FROM tenants", &[]).await?;
for t in &tenants {
let tid: i32 = t.get("id");
let rt: i32 = t.get("retention_tickets_days");
let ra: i32 = t.get("retention_audit_days");
let tickets_deleted = c.execute(
"DELETE FROM tickets WHERE tenant_id=$1 AND status IN ('Geloest','Geschlossen')
AND updated_at < now() - ($2 || ' days')::interval",
&[&tid, &rt.to_string()]).await?;
let audit_deleted = c.execute(
"DELETE FROM audit_log WHERE tenant_id=$1 AND aktion <> 'retention_cleanup'
AND zeit < now() - ($2 || ' days')::interval",
&[&tid, &ra.to_string()]).await?;
if tickets_deleted > 0 || audit_deleted > 0 {
let details = serde_json::json!({
"tickets_deleted": tickets_deleted,
"audit_log_deleted": audit_deleted,
});
c.execute(
"INSERT INTO audit_log (tenant_id, aktion, entity_typ, entity_id, details)
VALUES ($1,'retention_cleanup','tenant',$2,$3)",
&[&tid, &tid.to_string(), &details]).await?;
}
}
c.execute("DELETE FROM login_attempts WHERE zeit < now() - interval '7 days'", &[]).await?;
c.execute("DELETE FROM sessions WHERE expires_at < now()", &[]).await?;
Ok::<(), anyhow::Error>(())
}.await;
c.execute("SELECT pg_advisory_unlock($1)", &[&LOCK_KEY]).await.ok();
result
}
}
fn user_details_from_row(r: &tokio_postgres::Row) -> UserDetails {
UserDetails {
id: r.get("id"),
email: r.get("email"),
role: r.get("role"),
active: r.get("active"),
auth_source: r.get("auth_source"),
last_login_at: r.get("last_login_at"),
vorname: r.get("vorname"),
nachname: r.get("nachname"),
telefon: r.get("telefon"),
abteilung: r.get("abteilung"),
adresse: r.get("adresse"),
}
}
fn kb_from_row(r: &tokio_postgres::Row) -> KbArticle {
KbArticle {
id: r.get("id"),
titel: r.get("titel"),
kategorie: r.get("kategorie"),
inhalt: r.get("inhalt"),
tags: r.get("tags"),
status: r.get("status"),
autor_email: r.get("autor_email"),
updated_at: r.get("updated_at"),
}
}
fn ci_from_row(r: &tokio_postgres::Row) -> Ci {
Ci {
id: r.get("id"),
name: r.get("name"),
ci_typ: r.get("ci_typ"),
status: r.get("status"),
beschreibung: r.get("beschreibung"),
attribute: r.get("attribute"),
}
}

123
src/forge.rs Executable file
View File

@ -0,0 +1,123 @@
//! HTTP-Client fuer die Forge-Contents-API (phase-008-itsm-repo-audit).
//!
//! Spricht ausschliesslich die Forge-REST-API (siehe mscadm/forge,
//! forge-web/src/api.rs), die bewusst Gitea-API-kompatibel gehalten ist.
//! Bewusster Scope-Schnitt: nur get_contents/update_contents.
use base64::Engine;
use serde::Deserialize;
pub struct ForgeClient {
base_url: String,
token: String,
http: reqwest::Client,
}
#[derive(Debug)]
pub struct ForgeError(pub String);
impl std::fmt::Display for ForgeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
impl std::error::Error for ForgeError {}
#[derive(Deserialize)]
struct ContentsResponse {
#[serde(rename = "type")]
typ: Option<String>,
content: Option<String>,
sha: Option<String>,
}
#[derive(Deserialize)]
struct CommitInfo {
sha: String,
}
#[derive(Deserialize)]
struct UpdateResponse {
commit: CommitInfo,
}
impl ForgeClient {
/// None, wenn FORGE_BASE_URL nicht konfiguriert ist -- die Repo-Bearbeitung
/// aus Tickets heraus ist dann deaktiviert.
pub fn from_config(base_url: &str, token: &str) -> Option<ForgeClient> {
if base_url.is_empty() {
return None;
}
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.ok()?;
Some(ForgeClient {
base_url: base_url.trim_end_matches('/').to_string(),
token: token.to_string(),
http,
})
}
/// Liest eine UTF-8-Textdatei aus einem Forge-Repo. Rueckgabe: (text, sha).
pub async fn get_contents(&self, repo: &str, path: &str, r#ref: &str) -> Result<(String, String), ForgeError> {
let url = format!("{}/api/v1/repos/x/{}/contents/{}?ref={}",
self.base_url, repo, path, urlencode(r#ref));
let resp = self.http.get(&url)
.header("Authorization", format!("token {}", self.token))
.send().await
.map_err(|e| ForgeError(format!("Forge nicht erreichbar: {e}")))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(ForgeError(format!("Forge-API-Fehler ({}): {}", status.as_u16(), body)));
}
let parsed: ContentsResponse = serde_json::from_str(&body)
.map_err(|e| ForgeError(format!("Forge-Antwort nicht parsebar: {e}")))?;
if parsed.typ.as_deref() != Some("file") {
return Err(ForgeError(format!("Pfad ist keine Datei: {path}")));
}
let raw = base64::engine::general_purpose::STANDARD
.decode(parsed.content.unwrap_or_default().replace('\n', ""))
.map_err(|e| ForgeError(format!("Base64-Fehler: {e}")))?;
let text = String::from_utf8(raw)
.map_err(|_| ForgeError("Datei ist keine UTF-8-Textdatei -- ueber ITSM nicht editierbar".into()))?;
Ok((text, parsed.sha.unwrap_or_default()))
}
/// Schreibt eine Datei (optimistisches Sha-Locking der Contents-API).
/// Rueckgabe: commit_sha.
#[allow(clippy::too_many_arguments)]
pub async fn update_contents(&self, repo: &str, path: &str, content_text: &str, sha: &str,
branch: &str, message: &str, author_name: &str,
author_email: &str) -> Result<String, ForgeError> {
let url = format!("{}/api/v1/repos/x/{}/contents/{}", self.base_url, repo, path);
let body = serde_json::json!({
"content": base64::engine::general_purpose::STANDARD.encode(content_text.as_bytes()),
"sha": sha,
"branch": branch,
"message": message,
"author": {"name": author_name, "email": author_email},
});
let resp = self.http.put(&url)
.header("Authorization", format!("token {}", self.token))
.json(&body)
.send().await
.map_err(|e| ForgeError(format!("Forge nicht erreichbar: {e}")))?;
let status = resp.status();
let text = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(ForgeError(format!("Forge-API-Fehler ({}): {}", status.as_u16(), text)));
}
let parsed: UpdateResponse = serde_json::from_str(&text)
.map_err(|e| ForgeError(format!("Forge-Antwort nicht parsebar: {e}")))?;
Ok(parsed.commit.sha)
}
}
fn urlencode(s: &str) -> String {
s.bytes().map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => (b as char).to_string(),
_ => format!("%{:02X}", b),
}).collect()
}

136
src/itil.rs Executable file
View File

@ -0,0 +1,136 @@
//! ITIL-Prozesslogik (v3-Prozesse / v4-Practices), zentral gebuendelt.
//!
//! Abgedeckt:
//! - Rollenmodell (RBAC) angelehnt an ITIL-Rollen
//! - Incident-/Ticket-Statusmodell mit erlaubten Uebergaengen (v3 SO 4.2)
//! - Prioritaetsmatrix Impact x Urgency (v3 SO 4.2.5.4)
//! - Change Enablement (v4) / Change Management (v3): Typen + Freigabe
//!
//! Bewusst reine Funktionen/Konstanten ohne Web- oder DB-Abhaengigkeit,
//! damit die Logik isoliert testbar bleibt (siehe #[cfg(test)] unten).
/// Rollen (ITIL-orientiert):
/// - admin IT-Leitung / Service Owner: Vollzugriff
/// - change_manager genehmigt Changes (CAB), darf Repo-Aenderungen aus Changes
/// - agent Service Desk: Tickets, KB-Entwuerfe, CMDB
/// - user Requester/Self-Service: eigene Tickets, freigegebene KB
pub const ROLES: [&str; 4] = ["admin", "change_manager", "agent", "user"];
pub fn role_label(role: &str) -> &'static str {
match role {
"admin" => "Administrator",
"change_manager" => "Change Manager",
"agent" => "Service-Desk-Agent",
"user" => "Anwender",
_ => "Unbekannt",
}
}
/// Operative Rollen sehen alle Tickets, CMDB und KB-Entwuerfe.
pub fn is_operative(role: &str) -> bool {
matches!(role, "admin" | "change_manager" | "agent")
}
/// Change-Freigabe + Repo-Aenderungen aus Changes heraus.
pub fn is_change_approver(role: &str) -> bool {
matches!(role, "admin" | "change_manager")
}
/// Kategorien (v4-Practices: Incident, Service Request, Problem, Change, ...).
pub const CATEGORIES: [&str; 6] = ["Incident", "Service Request", "Problem", "Task", "Change", "Release"];
/// Kategorien, die die Rolle 'user' im Self-Service anlegen darf.
pub const USER_CATEGORIES: [&str; 2] = ["Incident", "Service Request"];
pub const PRIORITIES: [&str; 4] = ["Niedrig", "Mittel", "Hoch", "Kritisch"];
pub const IMPACT_URGENCY_LEVELS: [&str; 3] = ["Niedrig", "Mittel", "Hoch"];
// ── Statusmodell (v3 Incident Lifecycle) ──────────────────────────────────────
#[allow(dead_code)] // dokumentiert das Statusmodell, Logik nutzt allowed_next_statuses
pub const STATUSES: [&str; 5] = ["Offen", "In Bearbeitung", "Warten", "Geloest", "Geschlossen"];
/// Erlaubte Uebergaenge; "Geloest" -> "Offen" ist das Reopen, "Geschlossen" final.
pub fn allowed_next_statuses(current: &str) -> &'static [&'static str] {
match current {
"Offen" => &["In Bearbeitung", "Geloest"],
"In Bearbeitung" => &["Warten", "Geloest", "Offen"],
"Warten" => &["In Bearbeitung", "Geloest"],
"Geloest" => &["Geschlossen", "Offen"],
_ => &[],
}
}
pub fn is_valid_transition(current: &str, new: &str) -> bool {
allowed_next_statuses(current).contains(&new)
}
/// Prioritaetsmatrix Impact x Urgency (ITIL v3 SO 4.2.5.4, 3x3).
pub fn priority_from_matrix(impact: &str, urgency: &str) -> &'static str {
match (impact, urgency) {
("Hoch", "Hoch") => "Kritisch",
("Hoch", "Mittel") | ("Mittel", "Hoch") => "Hoch",
("Hoch", "Niedrig") | ("Niedrig", "Hoch") | ("Mittel", "Mittel") => "Mittel",
_ => "Niedrig",
}
}
// ── Change Enablement (v4) / Change Management (v3) ───────────────────────────
pub const CHANGE_TYPES: [&str; 3] = ["Standard", "Normal", "Emergency"];
pub const APPROVAL_NOT_REQUIRED: &str = "Nicht erforderlich";
pub const APPROVAL_PENDING: &str = "Ausstehend";
pub const APPROVAL_APPROVED: &str = "Genehmigt";
pub const APPROVAL_REJECTED: &str = "Abgelehnt";
/// Standard-Changes sind vorautorisiert (v4: "pre-authorized"); Normal- und
/// Emergency-Changes brauchen eine (bei Emergency nachtraegliche ECAB-)Freigabe.
pub fn initial_approval_status(change_typ: &str) -> &'static str {
if change_typ == "Standard" { APPROVAL_NOT_REQUIRED } else { APPROVAL_PENDING }
}
/// Darf an diesem Change implementiert werden (Repo-Aenderung, Umsetzung)?
/// Standard: vorautorisiert. Emergency: sofort, Freigabe nachtraeglich (ECAB),
/// solange nicht abgelehnt. Normal: erst nach Genehmigung.
pub fn change_may_be_implemented(kategorie: &str, change_typ: Option<&str>, approval_status: Option<&str>) -> bool {
if kategorie != "Change" {
return false;
}
let typ = change_typ.unwrap_or("Normal");
let status = approval_status.unwrap_or(APPROVAL_PENDING);
if status == APPROVAL_REJECTED {
return false;
}
match typ {
"Standard" | "Emergency" => true,
_ => status == APPROVAL_APPROVED,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matrix() {
assert_eq!(priority_from_matrix("Hoch", "Hoch"), "Kritisch");
assert_eq!(priority_from_matrix("Hoch", "Mittel"), "Hoch");
assert_eq!(priority_from_matrix("Mittel", "Mittel"), "Mittel");
assert_eq!(priority_from_matrix("Niedrig", "Niedrig"), "Niedrig");
}
#[test]
fn transitions() {
assert!(is_valid_transition("Offen", "In Bearbeitung"));
assert!(is_valid_transition("Geloest", "Offen")); // Reopen
assert!(!is_valid_transition("Geschlossen", "Offen")); // final
assert!(!is_valid_transition("Offen", "Warten"));
}
#[test]
fn change_gate() {
assert!(change_may_be_implemented("Change", Some("Standard"), Some(APPROVAL_NOT_REQUIRED)));
assert!(change_may_be_implemented("Change", Some("Emergency"), Some(APPROVAL_PENDING)));
assert!(!change_may_be_implemented("Change", Some("Normal"), Some(APPROVAL_PENDING)));
assert!(change_may_be_implemented("Change", Some("Normal"), Some(APPROVAL_APPROVED)));
assert!(!change_may_be_implemented("Change", Some("Emergency"), Some(APPROVAL_REJECTED)));
assert!(!change_may_be_implemented("Incident", None, None));
}
}

283
src/kb.rs Executable file
View File

@ -0,0 +1,283 @@
//! Wissensdatenbank (ITIL v4: Knowledge Management) mit Freigabe-Workflow:
//! Artikel starten als 'Entwurf' (sichtbar fuers Service-Team), werden durch
//! admin/change_manager 'Freigegeben' und sind erst dann fuer die Rolle
//! 'user' (Self-Service) sichtbar.
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_change_approver, need_operative, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
use crate::itil;
pub const KB_KATEGORIEN: [&str; 5] = ["Allgemein", "Anleitung", "Stoerung", "Konfiguration", "FAQ"];
pub struct KbRow {
pub id: i32,
pub titel: String,
pub kategorie: String,
pub tags: Vec<String>,
pub status: String,
pub status_class: String,
pub autor: String,
pub updated: String,
}
pub struct KbTab {
pub href: String,
pub label: String,
pub active: bool,
}
#[derive(Template)]
#[template(path = "kb_list.html")]
pub struct KbListTemplate {
pub title: String,
pub ctx: PageCtx,
pub tabs: Vec<KbTab>,
pub rows: Vec<KbRow>,
pub q: String,
}
#[derive(Deserialize)]
pub struct KbQuery {
pub kategorie: Option<String>,
pub q: Option<String>,
}
fn split_tags(tags: &Option<String>) -> Vec<String> {
tags.as_deref().unwrap_or("").split(',')
.map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect()
}
fn status_class(status: &str) -> &'static str {
if status == "Freigegeben" { "geloest" } else { "warten" }
}
pub async fn kb_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<KbQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen") { Ok(a) => a, Err(r) => return Ok(r) };
let only_released = !itil::is_operative(&auth.role);
let kategorie = q.kategorie.as_deref().filter(|k| KB_KATEGORIEN.contains(k));
let articles = app.db.list_kb_articles(auth.tenant_id, kategorie,
q.q.as_deref().filter(|s| !s.is_empty()),
only_released).await?;
let mut tabs = vec![KbTab {
href: "/wissen".into(),
label: "Alle".into(),
active: kategorie.is_none(),
}];
tabs.extend(KB_KATEGORIEN.iter().map(|k| KbTab {
href: format!("/wissen?kategorie={}", k),
label: k.to_string(),
active: kategorie == Some(k),
}));
let rows = articles.iter().map(|a| KbRow {
id: a.id,
titel: a.titel.clone(),
kategorie: a.kategorie.clone(),
tags: split_tags(&a.tags),
status: a.status.clone(),
status_class: status_class(&a.status).into(),
autor: a.autor_email.clone().unwrap_or_default(),
updated: a.updated_at.format("%Y-%m-%d %H:%M").to_string(),
}).collect();
let tpl = KbListTemplate {
title: "Wissensdatenbank".into(),
ctx: page_ctx(&auth, "/wissen"),
tabs,
rows,
q: q.q.unwrap_or_default(),
};
Ok(tpl.into_response())
}
#[derive(Template)]
#[template(path = "kb_form.html")]
pub struct KbFormTemplate {
pub title: String,
pub ctx: PageCtx,
pub error: String,
pub action: String,
pub titel: String,
pub kategorie: String,
pub inhalt: String,
pub tags: String,
pub kategorien: Vec<String>,
pub delete_action: String,
}
#[derive(Deserialize)]
pub struct KbForm {
pub titel: String,
#[serde(default)]
pub kategorie: String,
#[serde(default)]
pub inhalt: String,
#[serde(default)]
pub tags: String,
}
fn form_template(auth: &crate::db::AuthUser, title: &str, action: &str, delete_action: &str) -> KbFormTemplate {
KbFormTemplate {
title: title.to_string(),
ctx: page_ctx(auth, "/wissen"),
error: String::new(),
action: action.to_string(),
titel: String::new(),
kategorie: "Allgemein".into(),
inhalt: String::new(),
tags: String::new(),
kategorien: KB_KATEGORIEN.iter().map(|s| s.to_string()).collect(),
delete_action: delete_action.to_string(),
}
}
pub async fn kb_new_get(State(_app): State<AppState>, Extension(ctx): Extension<ReqCtx>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen/new") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_operative(&auth) { return Ok(r); }
Ok(form_template(&auth, "Neuer Wissensartikel", "/wissen/new", "").into_response())
}
pub async fn kb_new_post(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<KbForm>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen/new") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_operative(&auth) { return Ok(r); }
let titel = f.titel.trim();
let inhalt = f.inhalt.trim();
let kategorie = if KB_KATEGORIEN.contains(&f.kategorie.as_str()) { f.kategorie.as_str() } else { "Allgemein" };
if titel.is_empty() || inhalt.is_empty() {
let mut tpl = form_template(&auth, "Neuer Wissensartikel", "/wissen/new", "");
tpl.error = "Titel und Inhalt duerfen nicht leer sein.".into();
tpl.titel = titel.to_string();
tpl.kategorie = kategorie.to_string();
tpl.inhalt = inhalt.to_string();
tpl.tags = f.tags.trim().to_string();
return Ok(tpl.into_response());
}
let aid = app.db.create_kb_article(auth.tenant_id, titel, kategorie, inhalt,
f.tags.trim(), auth.user_id).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "kb_article_created", Some("kb_article"),
Some(&aid.to_string()), Some(serde_json::json!({"titel": titel})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/wissen/{}", aid)).into_response())
}
#[derive(Template)]
#[template(path = "kb_detail.html")]
pub struct KbDetailTemplate {
pub title: String,
pub ctx: PageCtx,
pub id: i32,
pub titel: String,
pub kategorie: String,
pub inhalt: String,
pub tags: Vec<String>,
pub status: String,
pub status_class: String,
pub autor: String,
pub updated: String,
pub can_release: bool,
}
pub async fn kb_detail(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(article_id): Path<i32>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen") { Ok(a) => a, Err(r) => return Ok(r) };
let Some(a) = app.db.get_kb_article(auth.tenant_id, article_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
// Self-Service sieht nur freigegebene Artikel.
if !itil::is_operative(&auth.role) && a.status != "Freigegeben" {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let tpl = KbDetailTemplate {
title: a.titel.clone(),
ctx: page_ctx(&auth, "/wissen"),
id: a.id,
titel: a.titel.clone(),
kategorie: a.kategorie.clone(),
inhalt: a.inhalt.clone(),
tags: split_tags(&a.tags),
status: a.status.clone(),
status_class: status_class(&a.status).into(),
autor: a.autor_email.clone().unwrap_or_default(),
updated: a.updated_at.format("%Y-%m-%d %H:%M").to_string(),
can_release: itil::is_change_approver(&auth.role),
};
Ok(tpl.into_response())
}
pub async fn kb_edit_get(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(article_id): Path<i32>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_operative(&auth) { return Ok(r); }
let Some(a) = app.db.get_kb_article(auth.tenant_id, article_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let mut tpl = form_template(&auth, "Artikel bearbeiten",
&format!("/wissen/{}/edit", article_id),
&format!("/wissen/{}/delete", article_id));
tpl.titel = a.titel;
tpl.kategorie = a.kategorie;
tpl.inhalt = a.inhalt;
tpl.tags = a.tags.unwrap_or_default();
Ok(tpl.into_response())
}
pub async fn kb_edit_post(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(article_id): Path<i32>, Form(f): Form<KbForm>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_operative(&auth) { return Ok(r); }
let Some(_) = app.db.get_kb_article(auth.tenant_id, article_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let titel = f.titel.trim();
let inhalt = f.inhalt.trim();
let kategorie = if KB_KATEGORIEN.contains(&f.kategorie.as_str()) { f.kategorie.as_str() } else { "Allgemein" };
if titel.is_empty() || inhalt.is_empty() {
let mut tpl = form_template(&auth, "Artikel bearbeiten",
&format!("/wissen/{}/edit", article_id),
&format!("/wissen/{}/delete", article_id));
tpl.error = "Titel und Inhalt duerfen nicht leer sein.".into();
return Ok(tpl.into_response());
}
app.db.update_kb_article(auth.tenant_id, article_id, titel, kategorie, inhalt, f.tags.trim()).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "kb_article_updated", Some("kb_article"),
Some(&article_id.to_string()), Some(serde_json::json!({"titel": titel})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/wissen/{}", article_id)).into_response())
}
#[derive(Deserialize)]
pub struct KbStatusForm {
pub status: String,
}
/// Freigabe-Workflow: nur admin/change_manager (Knowledge-Governance).
pub async fn kb_status_post(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(article_id): Path<i32>, Form(f): Form<KbStatusForm>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_change_approver(&auth) { return Ok(r); }
let status = match f.status.as_str() {
"Freigegeben" => "Freigegeben",
_ => "Entwurf",
};
app.db.set_kb_status(auth.tenant_id, article_id, status).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "kb_article_status_changed",
Some("kb_article"), Some(&article_id.to_string()),
Some(serde_json::json!({"status": status})), ctx.ip.as_deref()).await?;
Ok(Redirect::to(&format!("/wissen/{}", article_id)).into_response())
}
pub async fn kb_delete(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(article_id): Path<i32>) -> WebResult {
let auth = match need_auth(&ctx, "/wissen") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = need_operative(&auth) { return Ok(r); }
app.db.delete_kb_article(auth.tenant_id, article_id).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "kb_article_deleted", Some("kb_article"),
Some(&article_id.to_string()), None, ctx.ip.as_deref()).await?;
Ok(Redirect::to("/wissen").into_response())
}

152
src/main.rs Executable file
View File

@ -0,0 +1,152 @@
//! ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES), mandantenfaehig.
//!
//! Rust-Rewrite 2026-07-15 (zuvor Python/Flask). Betrieb gemaess
//! ISO 27001 / DSGVO / NIS 2:
//! - PostgreSQL-Persistenz, Audit-Log fuer alle aendernden Aktionen
//! - serverseitige Sessions (widerrufbar), CSRF-Schutz, Login-Rate-Limit,
//! Argon2id-Passwoerter (Werkzeug-Altbestand wird beim Login migriert)
//! - automatische Aufbewahrungsfrist-Bereinigung (DSGVO Speicherbegrenzung)
//! - ITIL-v3/v4-Prozesse: Statusmodell, Impact/Urgency-Matrix, Change
//! Enablement mit Freigabe, Problem Management, SLA, Knowledge-Freigabe
//!
//! AES bleibt ein buchbarer Service im Katalog, integriert per HTTP.
//! Repo-Bearbeitung aus Tickets (phase-008): nur admin/change_manager, nur aus
//! freigegebenen Change-Tickets, jede Aenderung zwingend im Worklog.
mod admin;
mod auth;
mod cmdb;
mod config;
mod dashboard;
mod db;
mod forge;
mod itil;
mod kb;
mod security;
mod services;
mod tickets;
mod web;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::extract::State;
use axum::response::IntoResponse;
use axum::routing::{get, post};
use axum::{Json, Router};
use tokio::sync::Mutex;
use tower_http::services::ServeDir;
use web::AppState;
async fn health(State(app): State<AppState>) -> impl IntoResponse {
match app.db.health().await {
Ok(()) => Json(serde_json::json!({"status": "ok", "db": "ok"})).into_response(),
Err(e) => (axum::http::StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({"status": "error", "db": e.to_string()}))).into_response(),
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into())).init();
let cfg = Arc::new(config::Config::from_env()?);
let db = db::Db::connect(&cfg.database_url)?;
db.init_schema().await?;
tracing::info!("Schema initialisiert.");
let forge = Arc::new(forge::ForgeClient::from_config(&cfg.forge_base_url, &cfg.forge_service_token));
if forge.is_none() {
tracing::info!("FORGE_BASE_URL nicht gesetzt -- Repo-Bearbeitung aus Tickets deaktiviert.");
}
let state = AppState {
cfg: cfg.clone(),
db: db.clone(),
forge,
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()?,
svc_status_cache: Arc::new(Mutex::new(HashMap::new())),
};
// Retention-Bereinigung im Hintergrund (Advisory-Lock in der DB verhindert
// parallele Laeufe mehrerer Instanzen).
{
let db = db.clone();
let interval = cfg.retention_interval_seconds;
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
loop {
if let Err(e) = db.run_retention_cleanup().await {
tracing::error!("Retention-Bereinigung fehlgeschlagen: {:#}", e);
}
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;
}
});
}
let app = Router::new()
.route("/", get(auth::root))
.route("/health", get(health))
.route("/login", get(auth::login_get).post(auth::login_post))
.route("/logout", post(auth::logout))
.route("/setup/new", get(auth::setup_get).post(auth::setup_post))
.route("/dashboard", get(dashboard::dashboard))
// Tickets + ITIL-Prozesse
.route("/tickets", get(tickets::tickets_list))
.route("/probleme", get(tickets::probleme_list))
.route("/aenderungen", get(tickets::aenderungen_list))
.route("/releases", get(tickets::releases_list))
.route("/tickets/new", post(tickets::ticket_new))
.route("/tickets/:id/status", post(tickets::ticket_status))
.route("/tickets/:id/comment", post(tickets::ticket_comment))
.route("/tickets/:id/approval", post(tickets::ticket_approval))
.route("/tickets/:id/problem-link", post(tickets::ticket_problem_link))
.route("/tickets/:id/known-error", post(tickets::ticket_known_error))
.route("/tickets/:id/ci-link", post(tickets::ticket_ci_link))
.route("/tickets/:id/ci-unlink", post(tickets::ticket_ci_unlink))
.route("/tickets/:id/repo-edit", post(tickets::ticket_repo_edit))
.route("/api/tickets/:id", get(tickets::api_ticket))
.route("/api/tickets/:id/repo-file", get(tickets::api_repo_file))
// Service-Katalog
.route("/services", get(services::services_page))
.route("/services/new", post(services::service_new))
// Wissensdatenbank
.route("/wissen", get(kb::kb_list))
.route("/wissen/new", get(kb::kb_new_get).post(kb::kb_new_post))
.route("/wissen/:id", get(kb::kb_detail))
.route("/wissen/:id/edit", get(kb::kb_edit_get).post(kb::kb_edit_post))
.route("/wissen/:id/status", post(kb::kb_status_post))
.route("/wissen/:id/delete", post(kb::kb_delete))
// CMDB
.route("/assets", get(cmdb::cmdb_list))
.route("/assets/new", get(cmdb::ci_new_get).post(cmdb::ci_new_post))
.route("/assets/:id", get(cmdb::ci_detail_get).post(cmdb::ci_detail_post))
.route("/assets/:id/relationships", post(cmdb::ci_rel_add))
.route("/assets/:id/relationships/:rel_id/delete", post(cmdb::ci_rel_delete))
// Administration
.route("/admin", get(admin::settings_get).post(admin::settings_post))
.route("/admin/retention/run", post(admin::retention_run))
.route("/admin/audit", get(admin::audit_page))
.route("/admin/users", get(admin::users_get).post(admin::users_post))
.route("/admin/users/:id/edit", get(admin::user_edit_get).post(admin::user_edit_post))
.route("/admin/users/:id/role", post(admin::user_role_post))
.route("/admin/users/:id/active", post(admin::user_active_post))
.nest_service("/static", ServeDir::new("static"))
.layer(axum::middleware::from_fn_with_state(state.clone(), web::ctx_middleware))
.with_state(state);
let listener = tokio::net::TcpListener::bind(&cfg.bind).await?;
tracing::info!("ITSM laeuft auf http://{}", cfg.bind);
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(async {
tokio::signal::ctrl_c().await.ok();
})
.await?;
Ok(())
}

186
src/security.rs Executable file
View File

@ -0,0 +1,186 @@
//! Sicherheits-Layer: Passwort-Hashing (Argon2id, mit transparenter Migration
//! alter Werkzeug-PBKDF2-Hashes), Session-/CSRF-Token, Passwort-Policy und
//! SSRF-Schutz fuer Service-Endpoint-URLs.
//!
//! Hintergrund (Sicherheitsreview 2026-07-15): die fruehere Flask-Version
//! hatte keinen CSRF-Schutz, kein Login-Rate-Limit, Client-Side-Sessions mit
//! optionalem Secret und ungeprueften Endpoint-URLs.
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::Argon2;
use rand::RngCore;
use sha2::{Digest, Sha256};
use std::net::{IpAddr, ToSocketAddrs};
// ── Passwort-Hashing ───────────────────────────────────────────────────────────
pub fn hash_password(password: &str) -> anyhow::Result<String> {
let salt = SaltString::generate(&mut argon2::password_hash::rand_core::OsRng);
Ok(Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| anyhow::anyhow!("argon2: {e}"))?
.to_string())
}
/// Prueft ein Passwort gegen einen gespeicherten Hash. Unterstuetzt:
/// - Argon2 (neue Hashes dieses Servers)
/// - Werkzeug-PBKDF2 ("pbkdf2:sha256:<iter>$<salt>$<hex>") aus der frueheren
/// Python-Version -- Bestandskonten bleiben so ohne Reset nutzbar.
/// Rueckgabe: (gueltig, braucht_rehash) -- bei einem gueltigen Alt-Hash soll
/// der Aufrufer auf Argon2 rehashen (schleichende Migration beim Login).
pub fn verify_password(stored: &str, password: &str) -> (bool, bool) {
if let Some(rest) = stored.strip_prefix("pbkdf2:sha256") {
return (verify_werkzeug_pbkdf2(rest, password), true);
}
match PasswordHash::new(stored) {
Ok(parsed) => (
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok(),
false,
),
Err(_) => (false, false),
}
}
/// rest = ":<iterationen>$<salt>$<hex-digest>" oder "$<salt>$<hex>" (alte Defaults).
fn verify_werkzeug_pbkdf2(rest: &str, password: &str) -> bool {
let (iterations, rest) = match rest.strip_prefix(':') {
Some(r) => {
let mut it = r.splitn(2, '$');
let n = it.next().and_then(|s| s.parse::<u32>().ok());
match (n, it.next()) {
(Some(n), Some(tail)) => (n, tail),
_ => return false,
}
}
// Sehr alte Werkzeug-Versionen: Default-Iterationen, Format "$salt$hex"
None => match rest.strip_prefix('$') {
Some(tail) => (260000, tail),
None => return false,
},
};
let mut parts = rest.splitn(2, '$');
let (Some(salt), Some(expected_hex)) = (parts.next(), parts.next()) else {
return false;
};
let Ok(expected) = hex::decode(expected_hex) else {
return false;
};
let mut out = vec![0u8; expected.len()];
pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt.as_bytes(), iterations, &mut out);
use subtle::ConstantTimeEq;
out.ct_eq(&expected).into()
}
// ── Passwort-Policy ────────────────────────────────────────────────────────────
/// None wenn ok, sonst deutsche Fehlermeldung. Laenge vor Komplexitaet
/// (BSI ORP.4 / NIST SP 800-63B), nur reine Ziffernfolgen werden abgelehnt.
pub fn password_problem(pw: &str, min_length: usize) -> Option<String> {
if pw.chars().count() < min_length {
return Some(format!("Das Passwort muss mindestens {} Zeichen lang sein.", min_length));
}
if pw.chars().all(|c| c.is_ascii_digit()) {
return Some("Das Passwort darf nicht nur aus Ziffern bestehen.".to_string());
}
None
}
// ── Tokens ─────────────────────────────────────────────────────────────────────
pub fn random_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}
/// In der DB liegt nur der Hash des Session-Tokens (DB-Leak != Session-Leak).
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
pub fn csrf_ok(expected: &str, supplied: &str) -> bool {
use subtle::ConstantTimeEq;
!expected.is_empty() && expected.as_bytes().ct_eq(supplied.as_bytes()).into()
}
// ── SSRF-Schutz fuer Service-Endpoints ────────────────────────────────────────
/// Der Service-Katalog prueft Endpoint-URLs serverseitig auf Erreichbarkeit.
/// Damit darueber keine Link-Local-/Cloud-Metadaten-Adressen (169.254.0.0/16,
/// insb. 169.254.169.254, fe80::/10) abgefragt werden koennen, werden diese
/// blockiert. Private Adressen (10/8, 192.168/16, localhost) bleiben erlaubt,
/// weil interne Dienste (AES-Dashboard, Forge) der Hauptzweck des Katalogs
/// sind; das Anlegen von Services ist zusaetzlich admin-only.
/// None = zulaessig, sonst Fehlertext.
pub fn endpoint_url_problem(url: &str) -> Option<String> {
if url.is_empty() {
return None;
}
let rest = if let Some(r) = url.strip_prefix("https://") {
r
} else if let Some(r) = url.strip_prefix("http://") {
r
} else {
return Some("Endpoint-URL muss mit http:// oder https:// beginnen.".into());
};
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let hostport = authority.rsplit('@').next().unwrap_or(""); // userinfo abtrennen
if hostport.is_empty() {
return Some("Endpoint-URL enthaelt keinen Hostnamen.".into());
}
let host = if hostport.starts_with('[') {
hostport.trim_start_matches('[').split(']').next().unwrap_or("")
} else {
hostport.rsplit_once(':').map(|(h, p)| if p.parse::<u16>().is_ok() { h } else { hostport }).unwrap_or(hostport)
};
// Aufloesen; nicht aufloesbar ist kein Sicherheitsproblem (spaeter schlicht
// "nicht erreichbar"), aber aufgeloeste Link-Local-Adressen sind tabu.
if let Ok(addrs) = (host, 80u16).to_socket_addrs() {
for a in addrs {
match a.ip() {
IpAddr::V4(v4) if v4.is_link_local() => {
return Some("Endpoint-URL zeigt auf eine blockierte Link-Local-/Metadaten-Adresse.".into());
}
IpAddr::V6(v6) if (v6.segments()[0] & 0xffc0) == 0xfe80 => {
return Some("Endpoint-URL zeigt auf eine blockierte Link-Local-Adresse.".into());
}
_ => {}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn argon2_roundtrip() {
let h = hash_password("korrekt-pferd-batterie").unwrap();
assert_eq!(verify_password(&h, "korrekt-pferd-batterie"), (true, false));
assert_eq!(verify_password(&h, "falsch").0, false);
}
#[test]
fn werkzeug_pbkdf2() {
// werkzeug.security.generate_password_hash("test-passwort-123", method="pbkdf2:sha256:1000", salt_length=8)
// reproduziert mit hashlib.pbkdf2_hmac('sha256', b'test-passwort-123', b'abcdefgh', 1000)
let h = "pbkdf2:sha256:1000$abcdefgh$161d2822224e216f7c39618c7ec8afd0d316d356d9252ead84472423b125c245";
let (ok, rehash) = verify_password(h, "test-passwort-123");
assert!(rehash);
assert!(ok, "werkzeug-pbkdf2-hash muss verifizierbar sein");
assert!(!verify_password(h, "falsch").0);
}
#[test]
fn ssrf_guard() {
assert!(endpoint_url_problem("").is_none());
assert!(endpoint_url_problem("http://10.0.0.5:8080/x").is_none());
assert!(endpoint_url_problem("ftp://x").is_some());
assert!(endpoint_url_problem("http://169.254.169.254/latest/meta-data").is_some());
}
#[test]
fn policy() {
assert!(password_problem("kurz", 12).is_some());
assert!(password_problem("123456789012345", 12).is_some());
assert!(password_problem("langes-gutes-passwort", 12).is_none());
}
}

139
src/services.rs Executable file
View File

@ -0,0 +1,139 @@
//! Service-Katalog (ITIL v4: Service Catalogue Management).
//!
//! Live-Erreichbarkeitspruefung gebuchter Services mit kurzem Timeout,
//! parallelisiert und gecacht (30 s), damit der Katalog nicht blockiert.
//! Anlegen von Services ist admin-only und Endpoint-URLs laufen durch den
//! SSRF-Guard (security::endpoint_url_problem).
use std::time::{Duration, Instant};
use askama::Template;
use axum::extract::{Extension, State};
use axum::response::{IntoResponse, Redirect};
use axum::Form;
use serde::Deserialize;
use crate::web::{need_admin, need_auth, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
pub struct ServiceCard {
pub name: String,
pub beschreibung: String,
pub endpoint: String,
pub badge_class: String,
pub badge_label: String,
}
#[derive(Template)]
#[template(path = "services.html")]
pub struct ServicesTemplate {
pub title: String,
pub ctx: PageCtx,
pub cards: Vec<ServiceCard>,
pub error: String,
}
const STATUS_CACHE_TTL: Duration = Duration::from_secs(30);
async fn live_status(app: &AppState, service_id: i32, endpoint: &str) -> Option<bool> {
{
let cache = app.svc_status_cache.lock().await;
if let Some((ts, result)) = cache.get(&service_id) {
if ts.elapsed() < STATUS_CACHE_TTL {
return *result;
}
}
}
let result = match app.http.get(endpoint).timeout(Duration::from_secs(3)).send().await {
Ok(resp) => Some(resp.status().is_success() || resp.status().is_redirection()),
Err(_) => Some(false),
};
app.svc_status_cache.lock().await.insert(service_id, (Instant::now(), result));
result
}
pub async fn services_page(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>) -> WebResult {
let auth = match need_auth(&ctx, "/services") { Ok(a) => a, Err(r) => return Ok(r) };
let services = app.db.list_services(auth.tenant_id).await?;
// Erreichbarkeit parallel pruefen (frueher: sequenziell mit 4s-Timeout je
// Service -- das blockierte die Seite bei mehreren toten Endpoints).
let checks = services.iter().map(|s| {
let app = app.clone();
let endpoint = s.endpoint.clone().unwrap_or_default();
let id = s.id;
let gebucht = s.gebucht;
async move {
if !gebucht || endpoint.is_empty() {
None
} else {
live_status(&app, id, &endpoint).await
}
}
});
let states: Vec<Option<bool>> = futures::future::join_all(checks).await;
let cards = services.iter().zip(states).map(|(s, live)| {
let (badge_class, badge_label) = if !s.gebucht {
("unknown", "nicht gebucht")
} else {
match live {
Some(true) => ("ok", "gebucht · erreichbar"),
Some(false) => ("err", "gebucht · nicht erreichbar"),
None => ("unknown", "gebucht"),
}
};
ServiceCard {
name: s.name.clone(),
beschreibung: s.beschreibung.clone().unwrap_or_default(),
endpoint: s.endpoint.clone().unwrap_or_default(),
badge_class: badge_class.into(),
badge_label: badge_label.into(),
}
}).collect();
let tpl = ServicesTemplate {
title: "Service-Katalog".into(),
ctx: page_ctx(&auth, "/services"),
cards,
error: String::new(),
};
Ok(tpl.into_response())
}
#[derive(Deserialize)]
pub struct NewServiceForm {
pub name: String,
#[serde(default)]
pub beschreibung: String,
#[serde(default)]
pub endpoint: String,
}
pub async fn service_new(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<NewServiceForm>) -> WebResult {
let auth = match need_auth(&ctx, "/services") { Ok(a) => a, Err(r) => return Ok(r) };
// Anlegen ist admin-only: Endpoint-URLs werden serverseitig abgefragt,
// das soll kein normaler Agent steuern koennen (SSRF-Flaeche minimieren).
if let Err(r) = need_admin(&auth) { return Ok(r); }
let name = f.name.trim();
let endpoint = f.endpoint.trim();
if name.is_empty() {
return Ok(Redirect::to("/services").into_response());
}
if let Some(problem) = crate::security::endpoint_url_problem(endpoint) {
let tpl = ServicesTemplate {
title: "Service-Katalog".into(),
ctx: page_ctx(&auth, "/services"),
cards: Vec::new(),
error: problem,
};
return Ok(tpl.into_response());
}
let sid = app.db.create_service(
auth.tenant_id, name, f.beschreibung.trim(), "Sonstiges", true,
if endpoint.is_empty() { None } else { Some(endpoint) }).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "service_created", Some("service"),
Some(&sid.to_string()), None, ctx.ip.as_deref()).await?;
Ok(Redirect::to("/services").into_response())
}

667
src/tickets.rs Executable file
View File

@ -0,0 +1,667 @@
//! Ticket-Management: Incident/Service Request/Problem/Task/Change/Release.
//!
//! ITIL-Prozesse:
//! - Statusmodell mit erzwungenen Uebergaengen (itil::is_valid_transition)
//! - Prioritaet aus Impact x Urgency (itil::priority_from_matrix)
//! - Change Enablement: Standard/Normal/Emergency + Freigabe (CAB) durch
//! change_manager/admin; Umsetzung erst nach Freigabe (Normal)
//! - Problem Management: Incident->Problem-Verknuepfung, Known Error
//! - SLA-Zeitstempel + Ueberfaelligkeits-Bewertung (itil::sla via db-Felder)
//! - Repo-Bearbeitung aus Tickets (phase-008): nur admin/change_manager und
//! nur aus einem umsetzbaren Change-Ticket heraus; jede Aenderung wird
//! zwingend im Worklog dokumentiert (Fehler dabei => kein Erfolg gemeldet)
use askama::Template;
use axum::extract::{Extension, Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Redirect};
use axum::{Form, Json};
use chrono::{DateTime, Duration, Utc};
use serde::Deserialize;
use crate::db::{AuthUser, Ticket};
use crate::web::{forbidden, need_auth, page_ctx, AppState, PageCtx, ReqCtx, WebResult};
use crate::itil;
// ── SLA-Bewertung ──────────────────────────────────────────────────────────────
pub fn resolve_due(t: &Ticket) -> Option<DateTime<Utc>> {
t.sla_loesung_minuten.map(|m| t.created_at + Duration::minutes(m as i64))
}
pub fn response_due(t: &Ticket) -> Option<DateTime<Utc>> {
t.sla_antwort_minuten.map(|m| t.created_at + Duration::minutes(m as i64))
}
pub fn is_overdue(t: &Ticket, now: DateTime<Utc>) -> bool {
if t.status == "Geloest" || t.status == "Geschlossen" {
return false;
}
resolve_due(t).map(|due| now > due).unwrap_or(false)
}
fn status_class(s: &str) -> &'static str {
match s {
"In Bearbeitung" => "inbearbeitung",
"Warten" => "warten",
"Geloest" => "geloest",
"Geschlossen" => "geschlossen",
_ => "offen",
}
}
fn prio_class(p: &str) -> &'static str {
match p {
"Kritisch" => "krit",
"Hoch" => "hoch",
"Niedrig" => "niedrig",
_ => "mittel",
}
}
// ── Listenansicht ──────────────────────────────────────────────────────────────
pub struct TicketRow {
pub id: i32,
pub nr: String,
pub titel: String,
pub service: String,
pub status_class: String,
pub status_label: String,
pub prio_class: String,
pub prio: String,
pub kategorie: String,
pub badge: String,
pub zugewiesen: String,
pub updated: String,
pub fortschritt: i32,
}
pub struct Tab {
pub href: String,
pub label: String,
pub active: bool,
}
#[derive(Template)]
#[template(path = "tickets.html")]
pub struct TicketsTemplate {
pub title: String,
pub ctx: PageCtx,
pub kpis: Vec<(String, String)>,
pub tabs: Vec<Tab>,
pub rows: Vec<TicketRow>,
pub services: Vec<(i32, String)>,
pub categories: Vec<String>,
pub impact_levels: Vec<String>,
pub change_types: Vec<String>,
}
#[derive(Deserialize)]
pub struct ListQuery {
pub status: Option<String>,
}
fn kpis(tickets: &[Ticket], now: DateTime<Utc>) -> Vec<(String, String)> {
let count = |f: &dyn Fn(&&Ticket) -> bool| tickets.iter().filter(f).count();
let geloest_7d = tickets.iter().filter(|t| {
(t.status == "Geloest" || t.status == "Geschlossen") && (now - t.updated_at).num_days() <= 7
}).count();
vec![
("Gesamt".into(), tickets.len().to_string()),
("Offen".into(), count(&|t| t.status == "Offen").to_string()),
("In Bearbeitung".into(), count(&|t| t.status == "In Bearbeitung").to_string()),
("Warten auf Input".into(), count(&|t| t.status == "Warten").to_string()),
("Ueberfaellig".into(), count(&|t| is_overdue(t, now)).to_string()),
("Geloest (7 Tage)".into(), geloest_7d.to_string()),
]
}
fn ticket_badge(t: &Ticket) -> String {
let mut parts = Vec::new();
if t.kategorie == "Change" {
if let Some(typ) = &t.change_typ {
parts.push(typ.clone());
}
if let Some(a) = &t.approval_status {
parts.push(a.clone());
}
}
if t.known_error {
parts.push("Known Error".into());
}
if let Some(nr) = &t.problem_nr {
parts.push(format!("=> {}", nr));
}
parts.join(" · ")
}
async fn list_page(app: &AppState, auth: &AuthUser, base: &str, title: &str,
filter_kategorie: Option<&str>, filter_status: Option<String>) -> WebResult {
let now = Utc::now();
// Rolle 'user' (Requester): nur eigene Tickets (Self-Service-Sicht).
let ersteller = if itil::is_operative(&auth.role) { None } else { Some(auth.user_id) };
let all = app.db.list_tickets(auth.tenant_id, ersteller).await?;
let kpi_list = kpis(&all, now);
let mut shown: Vec<&Ticket> = all.iter()
.filter(|t| filter_kategorie.map(|k| t.kategorie == k).unwrap_or(true))
.collect();
match filter_status.as_deref() {
Some("Ueberfaellig") => shown.retain(|t| is_overdue(t, now)),
Some(s) if !s.is_empty() => shown.retain(|t| t.status == s),
_ => {}
}
let tabs = [("", "Alle"), ("Offen", "Offen"), ("In Bearbeitung", "In Bearbeitung"),
("Warten", "Warten"), ("Geloest", "Geloest"), ("Ueberfaellig", "Ueberfaellig")]
.iter().map(|(s, label)| Tab {
href: if s.is_empty() { base.to_string() } else { format!("{}?status={}", base, s.replace(' ', "+")) },
label: label.to_string(),
active: filter_status.as_deref().unwrap_or("") == *s,
}).collect();
let rows = shown.iter().map(|t| {
let overdue = is_overdue(t, now);
TicketRow {
id: t.id,
nr: t.ticket_nr.clone(),
titel: t.titel.clone(),
service: t.service_name.clone().unwrap_or_default(),
status_class: if overdue { "ueberfaellig".into() } else { status_class(&t.status).into() },
status_label: if overdue { "Ueberfaellig".into() } else { t.status.clone() },
prio_class: prio_class(&t.prioritaet).into(),
prio: t.prioritaet.clone(),
kategorie: t.kategorie.clone(),
badge: ticket_badge(t),
zugewiesen: t.zugewiesen_an.clone().unwrap_or_default(),
updated: t.updated_at.format("%Y-%m-%d %H:%M").to_string(),
fortschritt: t.fortschritt,
}
}).collect();
let services = app.db.list_services(auth.tenant_id).await?
.into_iter().map(|s| (s.id, s.name)).collect();
let categories = if itil::is_operative(&auth.role) {
itil::CATEGORIES.iter().map(|s| s.to_string()).collect()
} else {
itil::USER_CATEGORIES.iter().map(|s| s.to_string()).collect()
};
let tpl = TicketsTemplate {
title: title.to_string(),
ctx: page_ctx(auth, base),
kpis: kpi_list,
tabs,
rows,
services,
categories,
impact_levels: itil::IMPACT_URGENCY_LEVELS.iter().map(|s| s.to_string()).collect(),
change_types: itil::CHANGE_TYPES.iter().map(|s| s.to_string()).collect(),
};
Ok(tpl.into_response())
}
pub async fn tickets_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
list_page(&app, &auth, "/tickets", "Tickets", None, q.status).await
}
pub async fn probleme_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/probleme") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); }
list_page(&app, &auth, "/probleme", "Probleme", Some("Problem"), q.status).await
}
pub async fn aenderungen_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/aenderungen") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); }
list_page(&app, &auth, "/aenderungen", "Aenderungen", Some("Change"), q.status).await
}
pub async fn releases_list(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Query(q): Query<ListQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/releases") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); }
list_page(&app, &auth, "/releases", "Releases", Some("Release"), q.status).await
}
// ── Ticket anlegen ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct NewTicketForm {
pub titel: String,
#[serde(default)]
pub beschreibung: String,
#[serde(default)]
pub kategorie: String,
#[serde(default)]
pub impact: String,
#[serde(default)]
pub urgency: String,
#[serde(default)]
pub service_id: String,
#[serde(default)]
pub change_typ: String,
}
fn pick<'a>(value: &'a str, allowed: &[&'static str], default: &'static str) -> &'a str {
if allowed.contains(&value) { value } else { default }
}
pub async fn ticket_new(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Form(f): Form<NewTicketForm>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
let kategorie = if itil::is_operative(&auth.role) {
pick(&f.kategorie, &itil::CATEGORIES, "Task").to_string()
} else {
// Self-Service: nur Incident / Service Request.
pick(&f.kategorie, &itil::USER_CATEGORIES, "Incident").to_string()
};
let impact = pick(&f.impact, &itil::IMPACT_URGENCY_LEVELS, "Mittel").to_string();
let urgency = pick(&f.urgency, &itil::IMPACT_URGENCY_LEVELS, "Mittel").to_string();
// Prioritaet ergibt sich aus der Impact-x-Urgency-Matrix (ITIL v3).
let prioritaet = itil::priority_from_matrix(&impact, &urgency);
let (change_typ, approval_status) = if kategorie == "Change" {
let typ = pick(&f.change_typ, &itil::CHANGE_TYPES, "Normal");
(Some(typ), Some(itil::initial_approval_status(typ)))
} else {
(None, None)
};
let service_id = f.service_id.parse::<i32>().ok();
let tenant = app.db.get_tenant(auth.tenant_id).await?
.ok_or_else(|| anyhow::anyhow!("Mandant nicht gefunden"))?;
let titel = f.titel.trim();
let titel = if titel.is_empty() { "(ohne Titel)" } else { titel };
let (tid, ticket_nr) = app.db.create_ticket(
auth.tenant_id, titel, f.beschreibung.trim(), service_id, prioritaet, &kategorie,
Some(&auth.email), auth.user_id,
tenant.sla_antwort_minuten, tenant.sla_loesung_minuten,
&auth.email, &impact, &urgency, change_typ, approval_status).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "ticket_created", Some("ticket"),
Some(&ticket_nr), Some(serde_json::json!({"id": tid, "kategorie": kategorie})),
ctx.ip.as_deref()).await?;
Ok(Redirect::to("/tickets").into_response())
}
// ── Ticket-Zugriff (Mandant + Self-Service-Beschraenkung) ─────────────────────
async fn load_ticket(app: &AppState, auth: &AuthUser, ticket_id: i32) -> Result<Option<Ticket>, anyhow::Error> {
let t = app.db.get_ticket(auth.tenant_id, ticket_id).await?;
Ok(match t {
Some(t) if itil::is_operative(&auth.role) || t.ersteller_id == Some(auth.user_id) => Some(t),
_ => None,
})
}
// ── Status aendern ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct StatusForm {
pub status: String,
}
pub async fn ticket_status(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<StatusForm>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
if !itil::is_operative(&auth.role) {
return Ok(forbidden("Statusaenderungen sind dem Service-Team vorbehalten."));
}
let Some(t) = load_ticket(&app, &auth, ticket_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
if !itil::is_valid_transition(&t.status, &f.status) {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({
"ok": false,
"error": format!("Uebergang '{}' -> '{}' ist im ITIL-Statusmodell nicht erlaubt.", t.status, f.status),
}))).into_response());
}
// Change Enablement: Umsetzung erst nach Freigabe (Normal-Changes).
if t.kategorie == "Change" && f.status == "In Bearbeitung"
&& !itil::change_may_be_implemented(&t.kategorie, t.change_typ.as_deref(), t.approval_status.as_deref()) {
return Ok((StatusCode::CONFLICT, Json(serde_json::json!({
"ok": false,
"error": "Change ist nicht freigegeben (CAB-Genehmigung erforderlich).",
}))).into_response());
}
app.db.update_ticket_status(auth.tenant_id, ticket_id, &f.status, &auth.email).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "ticket_status_changed", Some("ticket"),
Some(&ticket_id.to_string()), Some(serde_json::json!({"status": f.status})),
ctx.ip.as_deref()).await?;
Ok(Json(serde_json::json!({"ok": true})).into_response())
}
// ── Kommentar (Worklog) ────────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CommentForm {
pub text: String,
}
pub async fn ticket_comment(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<CommentForm>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
let Some(_) = load_ticket(&app, &auth, ticket_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let text = f.text.trim();
if text.is_empty() {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": "Leerer Kommentar."}))).into_response());
}
app.db.add_ticket_comment(auth.tenant_id, ticket_id, &auth.email, text).await?;
Ok(Json(serde_json::json!({"ok": true})).into_response())
}
// ── Change-Freigabe (CAB) ─────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct ApprovalForm {
pub decision: String,
}
pub async fn ticket_approval(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<ApprovalForm>) -> WebResult {
let auth = match need_auth(&ctx, "/aenderungen") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = crate::web::need_change_approver(&auth) { return Ok(r); }
let Some(t) = load_ticket(&app, &auth, ticket_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
if t.kategorie != "Change" {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": "Kein Change-Ticket."}))).into_response());
}
let decision = match f.decision.as_str() {
"Genehmigt" => itil::APPROVAL_APPROVED,
"Abgelehnt" => itil::APPROVAL_REJECTED,
_ => return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": "Ungueltige Entscheidung."}))).into_response()),
};
app.db.set_ticket_approval(auth.tenant_id, ticket_id, decision, auth.user_id, &auth.email).await?;
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "change_approval", Some("ticket"),
Some(&ticket_id.to_string()), Some(serde_json::json!({"decision": decision})),
ctx.ip.as_deref()).await?;
Ok(Json(serde_json::json!({"ok": true})).into_response())
}
// ── Problem-Verknuepfung + Known Error ────────────────────────────────────────
#[derive(Deserialize)]
pub struct ProblemLinkForm {
#[serde(default)]
pub problem_id: String,
}
pub async fn ticket_problem_link(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<ProblemLinkForm>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); }
let Some(_) = load_ticket(&app, &auth, ticket_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let problem_id = f.problem_id.parse::<i32>().ok();
if let Some(pid) = problem_id {
match app.db.get_ticket(auth.tenant_id, pid).await? {
Some(p) if p.kategorie == "Problem" && pid != ticket_id => {}
_ => return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": "Ungueltiges Problem-Ticket."}))).into_response()),
}
}
app.db.set_ticket_problem_link(auth.tenant_id, ticket_id, problem_id, &auth.email).await?;
Ok(Json(serde_json::json!({"ok": true})).into_response())
}
#[derive(Deserialize)]
pub struct KnownErrorForm {
#[serde(default)]
pub known_error: String,
}
pub async fn ticket_known_error(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<KnownErrorForm>) -> WebResult {
let auth = match need_auth(&ctx, "/probleme") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); }
let Some(t) = load_ticket(&app, &auth, ticket_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
if t.kategorie != "Problem" {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": "Known Error gilt nur fuer Problem-Tickets."}))).into_response());
}
let value = f.known_error == "1" || f.known_error == "true";
app.db.set_known_error(auth.tenant_id, ticket_id, value, &auth.email).await?;
Ok(Json(serde_json::json!({"ok": true})).into_response())
}
// ── CI-Verknuepfung (SACM) ────────────────────────────────────────────────────
#[derive(Deserialize)]
pub struct CiLinkForm {
pub ci_id: String,
}
pub async fn ticket_ci_link(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<CiLinkForm>) -> WebResult {
ci_link_common(app, ctx, ticket_id, f, true).await
}
pub async fn ticket_ci_unlink(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<CiLinkForm>) -> WebResult {
ci_link_common(app, ctx, ticket_id, f, false).await
}
async fn ci_link_common(app: AppState, ctx: ReqCtx, ticket_id: i32, f: CiLinkForm, link: bool) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
if let Err(r) = crate::web::need_operative(&auth) { return Ok(r); }
let Some(_) = load_ticket(&app, &auth, ticket_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let Ok(ci_id) = f.ci_id.parse::<i32>() else {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({"ok": false, "error": "Ungueltige CI-Id."}))).into_response());
};
if link {
app.db.link_ticket_ci(auth.tenant_id, ticket_id, ci_id).await?;
} else {
app.db.unlink_ticket_ci(auth.tenant_id, ticket_id, ci_id).await?;
}
Ok(Json(serde_json::json!({"ok": true})).into_response())
}
// ── JSON-API fuer das Detail-Panel ────────────────────────────────────────────
pub async fn api_ticket(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
let Some(t) = load_ticket(&app, &auth, ticket_id).await? else {
return Ok(StatusCode::NOT_FOUND.into_response());
};
let timeline = app.db.get_ticket_timeline(auth.tenant_id, ticket_id).await?;
let cis = app.db.get_ticket_cis(auth.tenant_id, ticket_id).await?;
let operative = itil::is_operative(&auth.role);
let allowed_next: Vec<&str> = itil::allowed_next_statuses(&t.status).iter()
.filter(|s| !(t.kategorie == "Change" && **s == "In Bearbeitung"
&& !itil::change_may_be_implemented(&t.kategorie, t.change_typ.as_deref(), t.approval_status.as_deref())))
.copied().collect();
let can_approve = operative && itil::is_change_approver(&auth.role) && t.kategorie == "Change"
&& t.approval_status.as_deref() == Some(itil::APPROVAL_PENDING);
let can_repo_edit = app.forge.is_some() && itil::is_change_approver(&auth.role)
&& itil::change_may_be_implemented(&t.kategorie, t.change_typ.as_deref(), t.approval_status.as_deref());
let problems = if operative {
app.db.list_problems(auth.tenant_id).await?
} else {
Vec::new()
};
let cis_available = if operative {
app.db.list_cis(auth.tenant_id, None).await?
} else {
Vec::new()
};
let now = Utc::now();
let fmt = |d: Option<DateTime<Utc>>| d.map(|d| d.format("%Y-%m-%d %H:%M").to_string());
let body = serde_json::json!({
"ok": true,
"id": t.id,
"ticket_nr": t.ticket_nr,
"titel": t.titel,
"beschreibung": t.beschreibung,
"status": t.status,
"prioritaet": t.prioritaet,
"impact": t.impact,
"urgency": t.urgency,
"kategorie": t.kategorie,
"change_typ": t.change_typ,
"approval_status": t.approval_status,
"problem_id": t.problem_id,
"problem_nr": t.problem_nr,
"known_error": t.known_error,
"service_name": t.service_name,
"zugewiesen_an": t.zugewiesen_an,
"fortschritt": t.fortschritt,
"sla": {
"response_due": fmt(response_due(&t)),
"resolve_due": fmt(resolve_due(&t)),
"first_response_at": fmt(t.first_response_at),
"resolved_at": fmt(t.resolved_at),
"overdue": is_overdue(&t, now),
},
"allowed_next": allowed_next,
"operative": operative,
"can_approve": can_approve,
"can_repo_edit": can_repo_edit,
"timeline": timeline.iter().map(|e| serde_json::json!({
"zeit": e.zeit.format("%Y-%m-%d %H:%M").to_string(),
"akteur": e.akteur,
"text": e.text,
})).collect::<Vec<_>>(),
"cis": cis.iter().map(|c| serde_json::json!({"id": c.id, "name": c.name, "typ": c.ci_typ}))
.collect::<Vec<_>>(),
"problems": problems.iter().map(|p| serde_json::json!({
"id": p.id, "nr": p.ticket_nr, "titel": p.titel, "known_error": p.known_error,
})).collect::<Vec<_>>(),
"cis_available": cis_available.iter().map(|c| serde_json::json!({
"id": c.id, "name": c.name, "typ": c.ci_typ,
})).collect::<Vec<_>>(),
});
Ok(Json(body).into_response())
}
// ── Repo-Bearbeitung aus Tickets (phase-008-itsm-repo-audit) ──────────────────
/// Gemeinsames RBAC-Gate: nur admin/change_manager, nur aus einem umsetzbaren
/// Change-Ticket heraus (ITIL Change Enablement: Repo-Aenderungen sind
/// Implementierungen und verlangen ein freigegebenes Change-Ticket).
async fn repo_edit_gate(app: &AppState, auth: &AuthUser, ticket_id: i32)
-> Result<Result<Ticket, axum::response::Response>, anyhow::Error> {
if !itil::is_change_approver(&auth.role) {
return Ok(Err((StatusCode::FORBIDDEN, Json(serde_json::json!({
"ok": false,
"error": "Repo-Aenderungen erfordern die Rolle Change Manager oder Administrator.",
}))).into_response()));
}
let Some(t) = app.db.get_ticket(auth.tenant_id, ticket_id).await? else {
return Ok(Err(StatusCode::NOT_FOUND.into_response()));
};
if !itil::change_may_be_implemented(&t.kategorie, t.change_typ.as_deref(), t.approval_status.as_deref()) {
return Ok(Err((StatusCode::CONFLICT, Json(serde_json::json!({
"ok": false,
"error": "Repo-Aenderungen erfordern ein freigegebenes Change-Ticket \
(Standard-Change oder genehmigter Normal-/Emergency-Change).",
}))).into_response()));
}
Ok(Ok(t))
}
#[derive(Deserialize)]
pub struct RepoFileQuery {
pub repo: String,
pub path: String,
#[serde(default)]
pub branch: String,
}
pub async fn api_repo_file(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Query(q): Query<RepoFileQuery>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
match repo_edit_gate(&app, &auth, ticket_id).await? {
Err(r) => return Ok(r),
Ok(_) => {}
}
let Some(forge) = app.forge.as_ref() else {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({
"ok": false, "error": "FORGE_BASE_URL ist nicht konfiguriert.",
}))).into_response());
};
let branch = if q.branch.is_empty() { "main" } else { &q.branch };
match forge.get_contents(&q.repo, &q.path, branch).await {
Ok((content, sha)) => Ok(Json(serde_json::json!({
"ok": true, "content": content, "sha": sha,
"repo": q.repo, "path": q.path, "branch": branch,
})).into_response()),
Err(e) => Ok((StatusCode::BAD_GATEWAY, Json(serde_json::json!({
"ok": false, "error": e.to_string(),
}))).into_response()),
}
}
#[derive(Deserialize)]
pub struct RepoEditForm {
pub repo: String,
pub path: String,
#[serde(default)]
pub branch: String,
pub sha: String,
#[serde(default)]
pub content: String,
pub message: String,
}
pub async fn ticket_repo_edit(State(app): State<AppState>, Extension(ctx): Extension<ReqCtx>,
Path(ticket_id): Path<i32>, Form(f): Form<RepoEditForm>) -> WebResult {
let auth = match need_auth(&ctx, "/tickets") { Ok(a) => a, Err(r) => return Ok(r) };
let t = match repo_edit_gate(&app, &auth, ticket_id).await? {
Err(r) => return Ok(r),
Ok(t) => t,
};
let Some(forge) = app.forge.as_ref() else {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({
"ok": false, "error": "FORGE_BASE_URL ist nicht konfiguriert.",
}))).into_response());
};
if f.repo.trim().is_empty() || f.path.trim().is_empty() || f.sha.trim().is_empty() || f.message.trim().is_empty() {
return Ok((StatusCode::BAD_REQUEST, Json(serde_json::json!({
"ok": false, "error": "repo, path, sha und message sind erforderlich.",
}))).into_response());
}
let branch = if f.branch.trim().is_empty() { "main" } else { f.branch.trim() };
let message = format!("{} (ITSM-Ticket {})", f.message.trim(), t.ticket_nr);
let commit_sha = match forge.update_contents(
f.repo.trim(), f.path.trim(), &f.content, f.sha.trim(), branch, &message,
&auth.email, &auth.email).await {
Ok(sha) => sha,
Err(e) => return Ok((StatusCode::BAD_GATEWAY, Json(serde_json::json!({
"ok": false, "error": e.to_string(),
}))).into_response()),
};
// Harte phase-008-Anforderung: schlaegt der Worklog-Eintrag fehl, wird KEIN
// Erfolg gemeldet, obwohl der Forge-Commit bereits geschrieben ist -- der
// Nutzer darf nie faelschlich glauben, dass alles dokumentiert wurde.
if let Err(e) = app.db.log_repo_edit(auth.tenant_id, ticket_id, &auth.email,
f.repo.trim(), f.path.trim(), branch, &commit_sha).await {
tracing::error!(
"phase-008: Forge-Commit {} (Repo {}, Ticket {}) erfolgreich, aber Worklog fehlgeschlagen: {:#}",
commit_sha, f.repo, ticket_id, e);
return Ok((StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({
"ok": false,
"error": format!("Datei wurde in Forge committet ({}...), aber der Worklog-Eintrag \
konnte nicht gespeichert werden. Bitte Admin informieren.",
&commit_sha[..commit_sha.len().min(10)]),
}))).into_response());
}
app.db.log_audit(Some(auth.tenant_id), Some(auth.user_id), "repo_file_edited", Some("ticket"),
Some(&ticket_id.to_string()),
Some(serde_json::json!({
"repo": f.repo.trim(), "path": f.path.trim(),
"branch": branch, "commit": commit_sha,
})),
ctx.ip.as_deref()).await?;
Ok(Json(serde_json::json!({"ok": true, "commit": commit_sha})).into_response())
}

215
src/web.rs Executable file
View File

@ -0,0 +1,215 @@
//! Gemeinsame Web-Infrastruktur: AppState, Request-Kontext (Session + Client-IP),
//! Middleware (Session-Laden, CSRF-Pruefung, Security-Header), Fehlertyp,
//! RBAC-Hilfen und Seiten-Kontext fuer Templates.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Instant;
use axum::body::Body;
use axum::extract::{ConnectInfo, Request, State};
use axum::http::{header, Method, StatusCode};
use axum::middleware::Next;
use axum::response::{Html, IntoResponse, Redirect, Response};
use axum_extra::extract::cookie::CookieJar;
use tokio::sync::Mutex;
use crate::config::Config;
use crate::db::{AuthUser, Db};
use crate::forge::ForgeClient;
use crate::{itil, security};
pub const SESSION_COOKIE: &str = "itsm_session";
#[derive(Clone)]
pub struct AppState {
pub cfg: Arc<Config>,
pub db: Db,
pub forge: Arc<Option<ForgeClient>>,
pub http: reqwest::Client,
/// Cache fuer Service-Erreichbarkeit (service_id -> (Zeitpunkt, Ergebnis)).
pub svc_status_cache: Arc<Mutex<HashMap<i32, (Instant, Option<bool>)>>>,
}
/// Pro Request ermittelter Kontext (von der Middleware in die Extensions gelegt).
#[derive(Clone)]
pub struct ReqCtx {
pub auth: Option<AuthUser>,
pub ip: Option<String>,
}
// ── Fehlertyp ──────────────────────────────────────────────────────────────────
pub struct AppError(pub anyhow::Error);
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(e: E) -> Self {
AppError(e.into())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
tracing::error!("interner Fehler: {:#}", self.0);
(StatusCode::INTERNAL_SERVER_ERROR,
Html("<div class='panel'><p class='err'>Interner Fehler -- Details im Server-Log.</p></div>".to_string()))
.into_response()
}
}
pub type WebResult = Result<Response, AppError>;
// ── RBAC-Hilfen ────────────────────────────────────────────────────────────────
pub fn need_auth(ctx: &ReqCtx, next_path: &str) -> Result<AuthUser, Response> {
match &ctx.auth {
Some(a) => Ok(a.clone()),
None => {
let enc: String = form_urlencoded::Serializer::new(String::new())
.append_pair("next", next_path)
.finish();
Err(Redirect::to(&format!("/login?{}", enc)).into_response())
}
}
}
pub fn forbidden(msg: &str) -> Response {
(StatusCode::FORBIDDEN,
Html(format!("<div class='panel'><p class='err'>Zugriff verweigert -- {}</p></div>", msg)))
.into_response()
}
pub fn need_operative(auth: &AuthUser) -> Result<(), Response> {
if itil::is_operative(&auth.role) {
Ok(())
} else {
Err(forbidden("diese Ansicht ist dem Service-Team vorbehalten."))
}
}
pub fn need_admin(auth: &AuthUser) -> Result<(), Response> {
if auth.role == "admin" {
Ok(())
} else {
Err(forbidden("nur fuer Administratoren."))
}
}
pub fn need_change_approver(auth: &AuthUser) -> Result<(), Response> {
if itil::is_change_approver(&auth.role) {
Ok(())
} else {
Err(forbidden("erforderliche Rolle: Change Manager oder Administrator."))
}
}
// ── Seiten-Kontext fuer Templates ─────────────────────────────────────────────
pub struct PageCtx {
pub email: String,
pub tenant_name: String,
pub role_label: String,
pub csrf: String,
pub operative: bool,
pub admin: bool,
pub active: String,
}
pub fn page_ctx(auth: &AuthUser, active: &str) -> PageCtx {
PageCtx {
email: auth.email.clone(),
tenant_name: auth.tenant_name.clone(),
role_label: itil::role_label(&auth.role).to_string(),
csrf: auth.csrf_token.clone(),
operative: itil::is_operative(&auth.role),
admin: auth.role == "admin",
active: active.to_string(),
}
}
// ── Middleware ─────────────────────────────────────────────────────────────────
fn client_ip(cfg: &Config, req: &Request, peer: SocketAddr) -> Option<String> {
// X-Forwarded-For nur auswerten, wenn explizit Proxies konfiguriert sind --
// sonst waere die Audit-Log-IP durch selbstgesetzte Header faelschbar.
if cfg.trusted_proxy_count > 0 {
if let Some(xff) = req.headers().get("x-forwarded-for").and_then(|v| v.to_str().ok()) {
let hops: Vec<&str> = xff.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
if hops.len() >= cfg.trusted_proxy_count {
return hops.get(hops.len() - cfg.trusted_proxy_count).map(|s| s.to_string());
}
}
}
Some(peer.ip().to_string())
}
/// Session laden, CSRF pruefen (fuer POST), Kontext in Extensions ablegen,
/// Security-Header auf die Antwort setzen.
pub async fn ctx_middleware(State(app): State<AppState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
jar: CookieJar,
mut req: Request,
next: Next) -> Response {
let ip = client_ip(&app.cfg, &req, peer);
let auth = match jar.get(SESSION_COOKIE) {
Some(c) => app.db.get_session_user(&security::hash_token(c.value())).await.unwrap_or(None),
None => None,
};
if matches!(*req.method(), Method::POST | Method::PUT | Method::PATCH | Method::DELETE) {
let path = req.uri().path().to_string();
// /login und /setup/new laufen vor einer Session (kein Token vorhanden);
// beide sind durch Rate-Limit bzw. Einmaligkeit geschuetzt.
let exempt = path == "/login" || path == "/setup/new";
if !exempt {
let expected = auth.as_ref().map(|a| a.csrf_token.as_str()).unwrap_or("");
let mut supplied = req.headers().get("x-csrf-token")
.and_then(|v| v.to_str().ok()).map(str::to_string);
if supplied.is_none() {
let is_form = req.headers().get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(|v| v.starts_with("application/x-www-form-urlencoded"))
.unwrap_or(false);
if is_form {
let (parts, body) = req.into_parts();
let bytes = axum::body::to_bytes(body, 2 * 1024 * 1024).await.unwrap_or_default();
supplied = form_urlencoded::parse(&bytes)
.find(|(k, _)| k == "_csrf")
.map(|(_, v)| v.into_owned());
req = Request::from_parts(parts, Body::from(bytes));
}
}
if !security::csrf_ok(expected, supplied.as_deref().unwrap_or("")) {
let resp = if path.starts_with("/api/") {
(StatusCode::FORBIDDEN,
axum::Json(serde_json::json!({"ok": false, "error": "CSRF-Token fehlt oder ist ungueltig."})))
.into_response()
} else {
(StatusCode::FORBIDDEN,
Html("<div class='panel'><p class='err'>CSRF-Pruefung fehlgeschlagen -- bitte Seite neu laden.</p></div>".to_string()))
.into_response()
};
return apply_security_headers(resp, &app.cfg);
}
}
}
req.extensions_mut().insert(ReqCtx { auth, ip });
let resp = next.run(req).await;
apply_security_headers(resp, &app.cfg)
}
fn apply_security_headers(mut resp: Response, cfg: &Config) -> Response {
let h = resp.headers_mut();
// CSP: Skripte/Styles nur aus /static (kein Inline-JS -- die fruehere
// Version hatte onclick-Handler im HTML, alles nach static/app.js verlegt).
let csp = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
h.entry("content-security-policy").or_insert(csp.parse().unwrap());
h.entry("x-content-type-options").or_insert("nosniff".parse().unwrap());
h.entry("x-frame-options").or_insert("DENY".parse().unwrap());
h.entry("referrer-policy").or_insert("same-origin".parse().unwrap());
h.entry("permissions-policy").or_insert("camera=(), microphone=(), geolocation=()".parse().unwrap());
if cfg.https {
h.entry("strict-transport-security").or_insert("max-age=31536000; includeSubDomains".parse().unwrap());
}
resp
}

270
static/app.js Executable file
View File

@ -0,0 +1,270 @@
/* ITSM Frontend-Logik -- CSP-konform: kein Inline-JS, keine onclick-Attribute.
Alle zustandsaendernden Requests senden das CSRF-Token als Header. */
(function () {
"use strict";
var CSRF = (document.querySelector('meta[name="csrf"]') || {}).content || "";
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
function post(url, params) {
var body = new URLSearchParams(params || {}).toString();
return fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-CSRF-Token": CSRF
},
body: body
}).then(function (r) { return r.json().catch(function () { return { ok: r.ok }; }); });
}
// ── Zeilen-Navigation + Ticket-Detail ────────────────────────────────────────
document.addEventListener("click", function (ev) {
var row = ev.target.closest("tr.row");
if (!row) return;
if (ev.target.closest("a, button, form, select, input")) return;
if (row.dataset.ticketId) openDetail(parseInt(row.dataset.ticketId, 10));
else if (row.dataset.href) location.href = row.dataset.href;
});
// Formulare mit Bestaetigung (z. B. Loeschen) und Auto-Submit-Selects.
document.addEventListener("submit", function (ev) {
var f = ev.target;
if (f.dataset && f.dataset.confirm && !window.confirm(f.dataset.confirm)) {
ev.preventDefault();
}
});
document.addEventListener("change", function (ev) {
if (ev.target.matches("select[data-autosubmit]")) ev.target.form.submit();
if (ev.target.id === "new-kategorie") {
var row = document.getElementById("change-typ-row");
if (row) row.hidden = ev.target.value !== "Change";
}
});
var overlay = document.getElementById("detail-overlay");
var panel = document.getElementById("detail-panel");
if (overlay) overlay.addEventListener("click", closeDetail);
function openDetail(id) {
fetch("/api/tickets/" + id, { headers: { "Accept": "application/json" } })
.then(function (r) { return r.json(); })
.then(function (t) {
if (!t || t.ok === false) return;
panel.innerHTML = renderDetail(t);
bindDetail(t);
panel.classList.add("open");
overlay.classList.add("open");
});
}
function closeDetail() {
panel.classList.remove("open");
overlay.classList.remove("open");
}
function pill(text) {
return '<span class="pill">' + esc(text) + "</span> ";
}
function renderDetail(t) {
var h = "";
h += "<h2>" + esc(t.ticket_nr) + "</h2>";
h += '<div class="sz" style="margin-bottom:10px">' + esc(t.titel) + "</div>";
h += "<div>" + pill(t.status) + pill(t.prioritaet) + pill(t.kategorie);
h += pill("Impact: " + t.impact) + pill("Urgency: " + t.urgency);
if (t.kategorie === "Change" && t.change_typ) h += pill(t.change_typ) + pill("Freigabe: " + (t.approval_status || "-"));
if (t.known_error) h += pill("Known Error");
h += "</div>";
h += '<div class="sect">Beschreibung</div><div>' + esc(t.beschreibung || "") + "</div>";
h += '<div class="sect">Service</div><div>' + esc(t.service_name || "(kein Service)") + "</div>";
if (t.sla) {
h += '<div class="sect">SLA</div><div class="sz">';
h += "Antwort bis: " + esc(t.sla.response_due || "--");
h += " · Loesung bis: " + esc(t.sla.resolve_due || "--");
if (t.sla.overdue) h += ' · <span class="pill ueberfaellig">Ueberfaellig</span>';
h += "</div>";
}
if (t.problem_nr) {
h += '<div class="sect">Problem</div><div class="sz">Verknuepft mit ' + esc(t.problem_nr) + "</div>";
}
if (t.operative && t.allowed_next && t.allowed_next.length) {
h += '<div class="sect">Status aendern (erlaubte ITIL-Uebergaenge)</div><div class="btnrow">';
t.allowed_next.forEach(function (s) {
h += '<button class="btn ghost" data-status="' + esc(s) + '">' + esc(s) + "</button>";
});
h += "</div>";
}
if (t.can_approve) {
h += '<div class="sect">Change-Freigabe (CAB)</div><div class="btnrow">';
h += '<button class="btn" data-approve="Genehmigt">Genehmigen</button>';
h += '<button class="btn ghost" data-approve="Abgelehnt">Ablehnen</button></div>';
}
if (t.operative && t.kategorie === "Problem") {
h += '<div class="sect">Problem Management</div><div class="btnrow">';
h += '<button class="btn ghost" data-known-error="' + (t.known_error ? "0" : "1") + '">' +
(t.known_error ? "Known-Error-Markierung entfernen" : "Als Known Error markieren") + "</button></div>";
}
if (t.operative && t.kategorie !== "Problem" && t.problems && t.problems.length) {
h += '<div class="sect">Mit Problem verknuepfen</div><div class="btnrow">';
h += '<select id="dt-problem"><option value="">(keine Verknuepfung)</option>';
t.problems.forEach(function (p) {
var sel = t.problem_id === p.id ? " selected" : "";
h += '<option value="' + p.id + '"' + sel + ">" + esc(p.nr + " " + p.titel) + "</option>";
});
h += '</select> <button class="btn ghost" id="dt-problem-save">Speichern</button></div>';
}
if (t.operative) {
h += '<div class="sect">Configuration Items (CMDB)</div><div>';
(t.cis || []).forEach(function (c) {
h += '<span class="pill agent tagpill">' + esc(c.name) +
' <button class="linklike" data-ci-unlink="' + c.id + '">x</button></span>';
});
if (t.cis_available && t.cis_available.length) {
h += '<div style="margin-top:6px"><select id="dt-ci">';
t.cis_available.forEach(function (c) {
h += '<option value="' + c.id + '">' + esc(c.name + " (" + c.typ + ")") + "</option>";
});
h += '</select> <button class="btn ghost" id="dt-ci-add">Verknuepfen</button></div>';
}
h += "</div>";
}
if (t.can_repo_edit) {
h += '<div class="sect">Repo-Datei bearbeiten (Forge, dokumentationspflichtig)</div>';
h += '<div class="panel">';
h += '<div class="grid-2"><input type="text" id="repo-name" placeholder="Repo (z.B. demo)">';
h += '<input type="text" id="repo-branch" placeholder="Branch" value="main"></div>';
h += '<div class="formrow"><input type="text" id="repo-path" placeholder="Dateipfad (z.B. README.md)"></div>';
h += '<button class="btn ghost" id="repo-load">Datei laden</button>';
h += '<div id="repo-status" class="sz" style="margin:6px 0"></div>';
h += '<textarea id="repo-content" rows="8" class="mono" hidden></textarea>';
h += '<input type="hidden" id="repo-sha">';
h += '<div class="formrow" id="repo-msg-row" hidden><input type="text" id="repo-msg" placeholder="Commit-Nachricht"></div>';
h += '<button class="btn" id="repo-save" hidden>Speichern (im Repo committen)</button>';
h += "</div>";
} else if (t.operative && t.kategorie === "Change") {
h += '<div class="sz" style="margin-top:10px">Repo-Bearbeitung: erfordert Rolle Change Manager/Administrator und einen freigegebenen Change.</div>';
}
h += '<div class="sect">Kommentar (Worklog)</div>';
h += '<textarea id="dt-comment" rows="2" placeholder="Kommentar hinzufuegen ..."></textarea>';
h += '<div style="margin-top:6px"><button class="btn ghost" id="dt-comment-save">Hinzufuegen</button></div>';
h += '<div class="sect">Zeitleiste</div>';
var tl = (t.timeline || []).map(function (e) {
return '<div class="tl-item"><b>' + esc(e.akteur || "") + '</b><div class="sz">' +
esc(e.zeit) + "</div><div>" + esc(e.text || "") + "</div></div>";
}).join("") || '<div class="sz">Keine Ereignisse.</div>';
h += tl;
h += '<div style="margin-top:16px"><button class="btn ghost" id="dt-close">Schliessen</button></div>';
return h;
}
function bindDetail(t) {
var id = t.id;
panel.querySelectorAll("[data-status]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/status", { status: b.dataset.status }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
});
});
});
panel.querySelectorAll("[data-approve]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/approval", { decision: b.dataset.approve }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
});
});
});
panel.querySelectorAll("[data-known-error]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/known-error", { known_error: b.dataset.knownError }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
});
});
});
panel.querySelectorAll("[data-ci-unlink]").forEach(function (b) {
b.addEventListener("click", function () {
post("/tickets/" + id + "/ci-unlink", { ci_id: b.dataset.ciUnlink }).then(function () { openDetail(id); });
});
});
var ciAdd = panel.querySelector("#dt-ci-add");
if (ciAdd) ciAdd.addEventListener("click", function () {
post("/tickets/" + id + "/ci-link", { ci_id: panel.querySelector("#dt-ci").value })
.then(function () { openDetail(id); });
});
var probSave = panel.querySelector("#dt-problem-save");
if (probSave) probSave.addEventListener("click", function () {
post("/tickets/" + id + "/problem-link", { problem_id: panel.querySelector("#dt-problem").value })
.then(function (d) { if (d.ok) openDetail(id); else alert(d.error || "Fehler"); });
});
var commentSave = panel.querySelector("#dt-comment-save");
if (commentSave) commentSave.addEventListener("click", function () {
var text = panel.querySelector("#dt-comment").value.trim();
if (!text) return;
post("/tickets/" + id + "/comment", { text: text }).then(function (d) {
if (d.ok) openDetail(id); else alert(d.error || "Fehler");
});
});
var close = panel.querySelector("#dt-close");
if (close) close.addEventListener("click", closeDetail);
// Repo-Bearbeitung (nur wenn gerendert)
var repoLoad = panel.querySelector("#repo-load");
if (repoLoad) {
repoLoad.addEventListener("click", function () {
var repo = panel.querySelector("#repo-name").value.trim();
var path = panel.querySelector("#repo-path").value.trim();
var branch = panel.querySelector("#repo-branch").value.trim() || "main";
var status = panel.querySelector("#repo-status");
if (!repo || !path) { status.textContent = "Repo und Dateipfad angeben."; return; }
status.textContent = "Laedt...";
fetch("/api/tickets/" + id + "/repo-file?repo=" + encodeURIComponent(repo) +
"&path=" + encodeURIComponent(path) + "&branch=" + encodeURIComponent(branch))
.then(function (r) { return r.json(); })
.then(function (d) {
if (!d.ok) { status.textContent = "Fehler: " + d.error; return; }
status.textContent = "Geladen (sha " + d.sha.slice(0, 10) + ").";
var ta = panel.querySelector("#repo-content");
ta.value = d.content;
ta.hidden = false;
panel.querySelector("#repo-sha").value = d.sha;
panel.querySelector("#repo-msg-row").hidden = false;
panel.querySelector("#repo-save").hidden = false;
})
.catch(function () { status.textContent = "Fehler beim Laden."; });
});
panel.querySelector("#repo-save").addEventListener("click", function () {
var status = panel.querySelector("#repo-status");
var message = panel.querySelector("#repo-msg").value.trim();
if (!message) { status.textContent = "Bitte eine Commit-Nachricht angeben."; return; }
status.textContent = "Speichert...";
post("/tickets/" + id + "/repo-edit", {
repo: panel.querySelector("#repo-name").value.trim(),
path: panel.querySelector("#repo-path").value.trim(),
branch: panel.querySelector("#repo-branch").value.trim() || "main",
sha: panel.querySelector("#repo-sha").value,
content: panel.querySelector("#repo-content").value,
message: message
}).then(function (d) {
if (!d.ok) { status.textContent = "Fehler: " + d.error; return; }
status.textContent = "Gespeichert -- Commit " + d.commit.slice(0, 10) + ", im Worklog dokumentiert.";
openDetail(id);
}).catch(function () { status.textContent = "Fehler beim Speichern."; });
});
}
}
})();

106
static/style.css Executable file
View File

@ -0,0 +1,106 @@
/* ITSM -- Farbschema an AES angelehnt (uebernommen aus der frueheren Version,
ergaenzt um Dashboard-, Formular- und Utility-Klassen fuer den CSP-konformen
Betrieb ohne Inline-Styles in JS-generiertem Markup). */
:root{--bg:#161d2b;--panel:#1e2738;--panel2:#232e42;--border:#324259;--text:#e6edf5;
--sub:#93a3b8;--accent:#4c9eba;--accent2:#5db3d0;--ok:#3ecf8e;--warn:#e0a83e;
--bad:#e5534b;--crit:#c9364a;}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:14px}
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:340px}
.login-box.wide{width:560px}
.login-box h1{margin:0 0 4px;font-size:22px}
.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;display:inline-block;text-decoration:none}
.btn:hover{background:var(--accent2);text-decoration:none}
.btn.ghost{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn.mini{padding:4px 10px;font-size:12px}
.linklike{background:none;border:none;color:var(--accent2);cursor:pointer;font-size:12px;padding:0}
.linklike:hover{text-decoration:underline}
.inline-form{display:inline}
.summary-btn{width:auto;display:inline-block;list-style:none;cursor:pointer}
.sz{color:var(--sub);font-size:12px}
.ok-text{color:var(--ok) !important;margin:0 0 10px}
.prewrap{white-space:pre-wrap}
.tagpill{margin-right:4px}
.shell{display:flex;min-height:100vh}
.sidebar{width:220px;flex:0 0 220px;background:var(--panel);border-right:1px solid var(--border);padding:16px 0;overflow-y:auto}
.brand{padding:0 16px 16px;font-weight:700;font-size:16px}
.brand .sz{font-weight:400}
.navgroup{margin-top:14px}
.navgroup h4{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--sub);
padding:0 16px;margin:0 0 4px}
.navgroup a{display:block;padding:6px 16px;color:var(--text);font-size:13px}
.navgroup a:hover{background:var(--panel2);text-decoration:none}
.navgroup a.active{background:var(--panel2);border-left:2px solid var(--accent);color:var(--accent2)}
.main{flex:1;min-width:0}
.topbar{display:flex;align-items:center;justify-content:space-between;padding:12px 24px;
border-bottom:1px solid var(--border);background:var(--panel)}
.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}
.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,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,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,.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}
.pill.change_manager{background:#4a3a1a;color:var(--warn)} .pill.user{background:#2a2a2a;color:#aaa}
.bar-track{background:#2a3446;border-radius:99px;height:6px;width:90px;display:inline-block;vertical-align:middle}
.bar-track.wide{width:60%}
.bar-fill{background:var(--accent);height:6px;border-radius:99px}
.dist-row{display:flex;align-items:center;gap:10px;margin-bottom:8px}
.dist-label{width:110px;font-size:13px}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:16px}
.card{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:12px}
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.svc-card{display:flex;justify-content:space-between;align-items:flex-start}
.badge{padding:2px 9px;border-radius:99px;font-size:11px;background:#1a4a35;color:var(--ok)}
.badge.err{background:#4a1e22;color:var(--bad)}
.badge.unknown{background:#2a2a2a;color:#888}
.badge.ok{background:#1a4a35;color:var(--ok)}
input,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}
.search-input{max-width:420px;display:inline-block;width:auto;min-width:280px}
details > summary{list-style:none;cursor:pointer}
details > summary::-webkit-details-marker{display:none}
.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:460px;background:var(--panel);
border-left:1px solid var(--border);z-index:41;transform:translateX(100%);
transition:transform .18s ease;overflow-y:auto;padding:20px}
#detail-panel.open{transform:translateX(0)}
#detail-overlay.open{display:block}
#detail-panel .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
#detail-panel h2{margin-top:0}
#detail-panel .sect{color:var(--sub);font-size:12px;margin:14px 0 6px}
#detail-panel .btnrow button{margin:2px}
.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}
[hidden]{display:none !important}

14
templates/admin_audit.html Executable file
View File

@ -0,0 +1,14 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Audit-Log</h1>
<div class="sz" style="margin-bottom:12px">Alle sicherheitsrelevanten Aktionen dieses Mandanten (Anmeldungen, Aenderungen). Aufbewahrung gemaess Einstellungen.</div>
<table class="audit">
<thead><tr><th>Zeitpunkt (UTC)</th><th>Benutzer</th><th>Aktion</th><th>Objekt</th></tr></thead>
<tbody>
{% for e in rows %}
<tr><td class="sz">{{ e.zeit }}</td><td>{{ e.user }}</td><td>{{ e.aktion }}</td><td class="sz">{{ e.objekt }}</td></tr>
{% endfor %}
{% if rows.is_empty() %}<tr><td colspan="4" class="sz">Keine Eintraege.</td></tr>{% endif %}
</tbody>
</table>
{% endblock %}

34
templates/admin_settings.html Executable file
View File

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Einstellungen</h1>
<div class="panel">
<h3 style="margin-top:0">{{ tenant_name }}</h3>
{% if !notice.is_empty() %}<p class="sz ok-text">{{ notice }}</p>{% endif %}
<form method="post" action="/admin">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<h2 class="section-title" style="margin-top:0">SLA-Standardwerte</h2>
<div class="grid-2">
<div class="formrow"><label>Antwortzeit (Minuten)</label><input type="number" name="sla_antwort_minuten" value="{{ sla_antwort }}"></div>
<div class="formrow"><label>Loesungszeit (Minuten)</label><input type="number" name="sla_loesung_minuten" value="{{ sla_loesung }}"></div>
</div>
<h2 class="section-title">Datenschutz (DSGVO)</h2>
<div class="grid-2">
<div class="formrow"><label>Datenschutzbeauftragter/Kontakt</label><input name="dsb_name" value="{{ dsb_name }}"></div>
<div class="formrow"><label>Kontakt-E-Mail</label><input name="dsb_email" value="{{ dsb_email }}"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Aufbewahrung Tickets (Tage)</label><input type="number" name="retention_tickets_days" value="{{ retention_tickets }}"></div>
<div class="formrow"><label>Aufbewahrung Audit-Log (Tage)</label><input type="number" name="retention_audit_days" value="{{ retention_audit }}"></div>
</div>
<button class="btn" type="submit">Speichern</button>
</form>
</div>
<div class="panel">
<h3 style="margin-top:0">Aufbewahrungsfrist-Bereinigung</h3>
<p class="sz">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.</p>
<form method="post" action="/admin/retention/run">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost" type="submit">Jetzt manuell bereinigen</button>
</form>
</div>
{% endblock %}

25
templates/admin_user_edit.html Executable file
View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Benutzerdetails</h1>
<div class="panel">
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
{% if !notice.is_empty() %}<p class="sz ok-text">{{ notice }}</p>{% endif %}
<form method="post" action="/admin/users/{{ user_id }}/edit">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><label>E-Mail (Login)</label><input type="email" name="email" value="{{ email }}" required></div>
<div class="hint">Wird sofort als Login-Adresse wirksam. Aenderungen werden im Audit-Log erfasst.</div>
<h2 class="section-title" style="margin-top:14px">Details (optional)</h2>
<div class="grid-2">
<div class="formrow"><label>Vorname</label><input type="text" name="vorname" value="{{ vorname }}"></div>
<div class="formrow"><label>Nachname</label><input type="text" name="nachname" value="{{ nachname }}"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Telefon</label><input type="text" name="telefon" value="{{ telefon }}"></div>
<div class="formrow"><label>Abteilung</label><input type="text" name="abteilung" value="{{ abteilung }}"></div>
</div>
<div class="formrow"><label>Adresse</label><input type="text" name="adresse" value="{{ adresse }}"></div>
<button class="btn" type="submit">Speichern</button>
</form>
</div>
<a class="btn ghost" href="/admin/users">Zurueck zur Benutzerverwaltung</a>
{% endblock %}

74
templates/admin_users.html Executable file
View File

@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Benutzerverwaltung</h1>
{% if !notice.is_empty() %}<p class="sz ok-text">{{ notice }}</p>{% endif %}
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
<table class="tickets">
<thead><tr>
<th>E-Mail</th><th>Name</th><th>Rolle</th><th>Status</th><th>Telefon</th><th>Quelle</th><th>Letzte Anmeldung</th><th>Aktionen</th>
</tr></thead>
<tbody>
{% for u in rows %}
<tr>
<td>{{ u.email }}</td>
<td class="sz">{{ u.name }}</td>
<td><span class="pill {{ u.role }}">{{ u.role_label }}</span></td>
<td>{% if u.active %}<span class="pill geloest">aktiv</span>{% else %}<span class="pill ueberfaellig">gesperrt</span>{% endif %}</td>
<td class="sz">{{ u.telefon }}</td>
<td class="sz">{{ u.auth_source }}</td>
<td class="sz">{{ u.last_login }}</td>
<td>
<a class="btn ghost mini" href="/admin/users/{{ u.id }}/edit">Details</a>
<form method="post" action="/admin/users/{{ u.id }}/role" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<select name="role" data-autosubmit {% if u.is_self %}disabled title="Eigene Rolle kann nicht geaendert werden"{% endif %}>
{% for o in u.role_opts %}<option value="{{ o.0 }}" {% if o.2 %}selected{% endif %}>{{ o.1 }}</option>{% endfor %}
</select>
</form>
{% if !u.is_self %}
<form method="post" action="/admin/users/{{ u.id }}/active" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost mini" type="submit">{% if u.active %}Sperren{% else %}Entsperren{% endif %}</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
<details {% if form_open %}open{% endif %} style="margin-top:16px">
<summary class="btn summary-btn">+ Neuen Benutzer</summary>
<div class="panel" style="margin-top:10px">
<h3 style="margin-top:0">Neuen Benutzer anlegen</h3>
<div class="sz" style="margin-bottom:10px">Rollen gemaess ITIL: Administrator (Vollzugriff), Change Manager (Change-Freigaben, Repo-Aenderungen), Service-Desk-Agent (Ticketbearbeitung), Anwender (Self-Service: eigene Tickets, freigegebene Wissensartikel). Eine AD/LDAP-Anbindung ist als naechste Ausbaustufe vorgesehen.</div>
<form method="post" action="/admin/users">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><label>E-Mail</label><input type="email" name="email" value="{{ fv_email }}" required></div>
<div class="formrow"><label>Rolle</label>
<select name="role">
{% for o in role_opts %}<option value="{{ o.0 }}" {% if o.0 == fv_role %}selected{% endif %}>{{ o.1 }}</option>{% endfor %}
</select>
</div>
</div>
<div class="grid-2">
<div class="formrow"><label>Passwort</label><input type="password" name="password" required></div>
<div class="formrow"><label>Passwort wiederholen</label><input type="password" name="password2" required></div>
</div>
<div class="hint">Mindestens {{ password_min_length }} Zeichen, nicht nur Ziffern. Der Benutzer sollte das Passwort nach der ersten Anmeldung selbst aendern.</div>
<h2 class="section-title">Details (optional)</h2>
<div class="grid-2">
<div class="formrow"><label>Vorname</label><input type="text" name="vorname" value="{{ fv_vorname }}"></div>
<div class="formrow"><label>Nachname</label><input type="text" name="nachname" value="{{ fv_nachname }}"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Telefon</label><input type="text" name="telefon" value="{{ fv_telefon }}"></div>
<div class="formrow"><label>Abteilung</label><input type="text" name="abteilung" value="{{ fv_abteilung }}"></div>
</div>
<div class="formrow"><label>Adresse</label><input type="text" name="adresse" value="{{ fv_adresse }}"></div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
</details>
{% endblock %}

70
templates/base.html Executable file
View File

@ -0,0 +1,70 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf" content="{{ ctx.csrf }}">
<title>{{ title }} — ITSM</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="shell">
<div class="sidebar">
<div class="brand">ITSM<div class="sz">IT Service Management</div></div>
<div class="navgroup">
<h4>Uebersicht</h4>
<a href="/dashboard" class="{% if ctx.active == "/dashboard" %}active{% endif %}">Dashboard</a>
</div>
<div class="navgroup">
<h4>Service Management</h4>
<a href="/tickets" class="{% if ctx.active == "/tickets" %}active{% endif %}">Tickets</a>
{% if ctx.operative %}
<a href="/probleme" class="{% if ctx.active == "/probleme" %}active{% endif %}">Probleme</a>
<a href="/aenderungen" class="{% if ctx.active == "/aenderungen" %}active{% endif %}">Aenderungen</a>
<a href="/releases" class="{% if ctx.active == "/releases" %}active{% endif %}">Releases</a>
{% endif %}
</div>
<div class="navgroup">
<h4>Services</h4>
<a href="/services" class="{% if ctx.active == "/services" %}active{% endif %}">Service-Katalog</a>
</div>
<div class="navgroup">
<h4>Wissen</h4>
<a href="/wissen" class="{% if ctx.active == "/wissen" %}active{% endif %}">Wissensdatenbank</a>
</div>
{% if ctx.operative %}
<div class="navgroup">
<h4>Assets</h4>
<a href="/assets" class="{% if ctx.active == "/assets" %}active{% endif %}">CMDB</a>
</div>
{% endif %}
{% if ctx.admin %}
<div class="navgroup">
<h4>Administration</h4>
<a href="/admin" class="{% if ctx.active == "/admin" %}active{% endif %}">Einstellungen</a>
<a href="/admin/users" class="{% if ctx.active == "/admin/users" %}active{% endif %}">Benutzer</a>
<a href="/admin/audit" class="{% if ctx.active == "/admin/audit" %}active{% endif %}">Audit-Log</a>
</div>
{% endif %}
</div>
<div class="main">
<div class="topbar">
<div class="sz">{{ ctx.tenant_name }}</div>
<div class="sz">
{{ ctx.email }} · {{ ctx.role_label }} ·
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="linklike" type="submit">Abmelden</button>
</form>
</div>
</div>
<div class="content">
{% block content %}{% endblock %}
</div>
</div>
</div>
<div id="detail-overlay"></div>
<div id="detail-panel"></div>
<script src="/static/app.js"></script>
</body>
</html>

66
templates/cmdb_detail.html Executable file
View File

@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">{{ name }}</h1>
<div class="panel">
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
<form method="post" action="/assets/{{ id }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><label>Name</label><input name="name" value="{{ name }}" required></div>
<div class="formrow"><label>Typ</label>
<select name="ci_typ">
{% for o in typ_opts %}<option value="{{ o.value }}" {% if o.selected %}selected{% endif %}>{{ o.label }}</option>{% endfor %}
</select>
</div>
</div>
<div class="grid-2">
<div class="formrow"><label>Status</label>
<select name="status">
{% for o in status_opts %}<option value="{{ o.value }}" {% if o.selected %}selected{% endif %}>{{ o.label }}</option>{% endfor %}
</select>
</div>
<div class="formrow"></div>
</div>
<div class="formrow"><label>Beschreibung</label><textarea name="beschreibung">{{ beschreibung }}</textarea></div>
<div class="formrow"><label>Attribute (ein "Schluessel: Wert" je Zeile)</label>
<textarea name="attribute" style="min-height:100px">{{ attribute }}</textarea></div>
<button class="btn" type="submit">Speichern</button>
</form>
</div>
<h2 class="section-title">Beziehungen</h2>
<div class="panel">
<h3 style="margin-top:0">Ausgehend</h3>
{% for r in rel_out %}
<div class="tl-item">{{ r.typ }} <b>{{ r.name }}</b>
<form method="post" action="/assets/{{ id }}/relationships/{{ r.id }}/delete" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost mini" type="submit">x</button>
</form>
</div>
{% endfor %}
{% if rel_out.is_empty() %}<p class="sz">Keine ausgehenden Beziehungen.</p>{% endif %}
<h3>Eingehend</h3>
{% for r in rel_in %}
<div class="tl-item">{{ r.typ }} <b>{{ r.name }}</b> <span class="sz">(eingehend)</span></div>
{% endfor %}
{% if rel_in.is_empty() %}<p class="sz">Keine eingehenden Beziehungen.</p>{% endif %}
<h3>Neue Beziehung</h3>
<form method="post" action="/assets/{{ id }}/relationships">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><label>Beziehungstyp</label>
<select name="beziehungs_typ">
{% for o in rel_typ_opts %}<option value="{{ o.value }}">{{ o.label }}</option>{% endfor %}
</select>
</div>
<div class="formrow"><label>Ziel-CI</label>
<select name="to_ci_id">
{% for o in other_cis %}<option value="{{ o.value }}">{{ o.label }}</option>{% endfor %}
</select>
</div>
</div>
<button class="btn ghost" type="submit">Hinzufuegen</button>
</form>
</div>
{% endblock %}

30
templates/cmdb_form.html Executable file
View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">{{ title }}</h1>
<div class="panel">
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
<form method="post" action="/assets/new">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="grid-2">
<div class="formrow"><label>Name</label><input name="name" value="{{ name }}" required></div>
<div class="formrow"><label>Typ</label>
<select name="ci_typ">
{% for o in typ_opts %}<option value="{{ o.value }}" {% if o.selected %}selected{% endif %}>{{ o.label }}</option>{% endfor %}
</select>
</div>
</div>
<div class="grid-2">
<div class="formrow"><label>Status</label>
<select name="status">
{% for o in status_opts %}<option value="{{ o.value }}" {% if o.selected %}selected{% endif %}>{{ o.label }}</option>{% endfor %}
</select>
</div>
<div class="formrow"></div>
</div>
<div class="formrow"><label>Beschreibung</label><textarea name="beschreibung">{{ beschreibung }}</textarea></div>
<div class="formrow"><label>Attribute (ein "Schluessel: Wert" je Zeile, z. B. IP: 10.0.0.5)</label>
<textarea name="attribute" style="min-height:100px">{{ attribute }}</textarea></div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
{% endblock %}

22
templates/cmdb_list.html Executable file
View File

@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">CMDB — Configuration Items</h1>
<div class="tabs">
{% for t in tabs %}<a href="{{ t.href }}" class="{% if t.active %}active{% endif %}">{{ t.label }}</a>{% endfor %}
</div>
<table class="tickets">
<thead><tr><th>Name</th><th>Typ</th><th>Status</th><th>Beschreibung</th></tr></thead>
<tbody>
{% for c in rows %}
<tr class="row" data-href="/assets/{{ c.id }}">
<td><b>{{ c.name }}</b></td>
<td>{{ c.typ }}</td>
<td><span class="pill {{ c.status_class }}">{{ c.status }}</span></td>
<td class="sz">{{ c.beschreibung }}</td>
</tr>
{% endfor %}
{% if rows.is_empty() %}<tr><td colspan="4" class="sz">Keine Configuration Items.</td></tr>{% endif %}
</tbody>
</table>
<div style="margin-top:14px"><a class="btn" href="/assets/new">+ Neues CI</a></div>
{% endblock %}

32
templates/dashboard.html Executable file
View File

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Dashboard</h1>
<div class="kpi-row">
{% for kpi in kpis %}
<div class="kpi"><div class="lbl">{{ kpi.0 }}</div><div class="val">{{ kpi.1 }}</div></div>
{% endfor %}
</div>
<div class="grid-2">
<div class="panel">
<h3 style="margin-top:0">Tickets nach Kategorie</h3>
{% for r in by_category %}
<div class="dist-row">
<span class="dist-label">{{ r.label }}</span>
<div class="bar-track wide"><div class="bar-fill" style="width:{{ r.pct }}%"></div></div>
<span class="sz">{{ r.count }}</span>
</div>
{% endfor %}
</div>
<div class="panel">
<h3 style="margin-top:0">Tickets nach Prioritaet</h3>
{% for r in by_priority %}
<div class="dist-row">
<span class="dist-label">{{ r.label }}</span>
<div class="bar-track wide"><div class="bar-fill" style="width:{{ r.pct }}%"></div></div>
<span class="sz">{{ r.count }}</span>
</div>
{% endfor %}
</div>
</div>
<div class="sz" style="margin-top:8px">Kennzahlen gemaess ITIL Continual Improvement (v4) / CSI (v3): SLA-Erfuellung und MTTR beziehen sich auf die letzten 30 Tage.</div>
{% endblock %}

27
templates/kb_detail.html Executable file
View File

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">{{ titel }}</h1>
<div class="sz" style="margin-bottom:14px">
{{ kategorie }} · <span class="pill {{ status_class }}">{{ status }}</span> · {{ autor }} · aktualisiert {{ updated }}
</div>
<div class="panel prewrap">{{ inhalt }}</div>
<div style="margin-bottom:14px">
{% for t in tags %}<span class="pill agent tagpill">{{ t }}</span>{% endfor %}
</div>
{% if ctx.operative %}
<a class="btn ghost" href="/wissen/{{ id }}/edit">Bearbeiten</a>
{% endif %}
{% if can_release %}
<form method="post" action="/wissen/{{ id }}/status" class="inline-form">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
{% if status == "Freigegeben" %}
<input type="hidden" name="status" value="Entwurf">
<button class="btn ghost" type="submit">Freigabe zurueckziehen</button>
{% else %}
<input type="hidden" name="status" value="Freigegeben">
<button class="btn" type="submit">Freigeben</button>
{% endif %}
</form>
{% endif %}
<a class="btn ghost" href="/wissen">Zurueck zur Uebersicht</a>
{% endblock %}

25
templates/kb_form.html Executable file
View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">{{ title }}</h1>
<div class="panel">
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
<form method="post" action="{{ action }}">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><label>Titel</label><input name="titel" value="{{ titel }}" required></div>
<div class="formrow"><label>Kategorie</label>
<select name="kategorie">
{% for k in kategorien %}<option value="{{ k }}" {% if k.as_str() == kategorie.as_str() %}selected{% endif %}>{{ k }}</option>{% endfor %}
</select>
</div>
<div class="formrow"><label>Inhalt</label><textarea name="inhalt" style="min-height:220px" required>{{ inhalt }}</textarea></div>
<div class="formrow"><label>Tags (kommagetrennt)</label><input name="tags" value="{{ tags }}"></div>
<button class="btn" type="submit">Speichern</button>
</form>
{% if !delete_action.is_empty() %}
<form method="post" action="{{ delete_action }}" style="margin-top:10px" data-confirm="Artikel wirklich loeschen?">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<button class="btn ghost" type="submit">Loeschen</button>
</form>
{% endif %}
</div>
{% endblock %}

30
templates/kb_list.html Executable file
View File

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Wissensdatenbank</h1>
<div class="tabs">
{% for t in tabs %}<a href="{{ t.href }}" class="{% if t.active %}active{% endif %}">{{ t.label }}</a>{% endfor %}
</div>
<form method="get" action="/wissen" style="margin-bottom:14px">
<input type="text" name="q" placeholder="Suche in Titel, Inhalt, Tags ..." value="{{ q }}" class="search-input">
<button class="btn ghost" type="submit">Suchen</button>
</form>
<table class="tickets">
<thead><tr><th>Titel</th><th>Kategorie</th><th>Status</th><th>Tags</th><th>Autor</th><th>Aktualisiert</th></tr></thead>
<tbody>
{% for a in rows %}
<tr class="row" data-href="/wissen/{{ a.id }}">
<td><b>{{ a.titel }}</b></td>
<td><span class="pill">{{ a.kategorie }}</span></td>
<td><span class="pill {{ a.status_class }}">{{ a.status }}</span></td>
<td>{% for t in a.tags %}<span class="pill agent tagpill">{{ t }}</span>{% endfor %}</td>
<td class="sz">{{ a.autor }}</td>
<td class="sz">{{ a.updated }}</td>
</tr>
{% endfor %}
{% if rows.is_empty() %}<tr><td colspan="6" class="sz">Keine Artikel.</td></tr>{% endif %}
</tbody>
</table>
{% if ctx.operative %}
<div style="margin-top:14px"><a class="btn" href="/wissen/new">+ Neuer Artikel</a></div>
{% endif %}
{% endblock %}

26
templates/login.html Executable file
View File

@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Anmelden — ITSM</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-wrap">
<form class="login-box" method="post" action="/login">
<h1>ITSM</h1>
<div class="sz" style="margin-bottom:14px">IT Service Management</div>
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
{% if !notice.is_empty() %}<p class="sz ok-text">{{ notice }}</p>{% endif %}
<input type="hidden" name="next" value="{{ next }}">
<input name="email" type="email" placeholder="E-Mail" autofocus required>
<input name="password" type="password" placeholder="Passwort" required>
<button class="btn" type="submit">Anmelden</button>
{% if show_setup_link %}
<div class="sz" style="margin-top:12px">Neue Organisation? <a href="/setup/new">Jetzt einrichten</a></div>
{% endif %}
</form>
</div>
</body>
</html>

28
templates/services.html Executable file
View File

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">Service-Katalog</h1>
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
{% for s in cards %}
<div class="card">
<div class="svc-card">
<div><b>{{ s.name }}</b><div class="sz" style="margin-top:4px">{{ s.beschreibung }}</div></div>
<span class="badge {{ s.badge_class }}">{{ s.badge_label }}</span>
</div>
{% if !s.endpoint.is_empty() %}
<div class="sz" style="margin-top:8px"><a href="{{ s.endpoint }}" target="_blank" rel="noopener noreferrer">Zum Service &rarr;</a></div>
{% endif %}
</div>
{% endfor %}
{% if ctx.admin %}
<div class="panel">
<h3 style="margin-top:0">Service hinzufuegen</h3>
<form method="post" action="/services/new">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="name" placeholder="Name" required></div>
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
<div class="formrow"><input type="text" name="endpoint" placeholder="Endpoint-URL (optional, http/https)"></div>
<button class="btn" type="submit">Hinzufuegen</button>
</form>
</div>
{% endif %}
{% endblock %}

44
templates/setup.html Executable file
View File

@ -0,0 +1,44 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Neue Organisation einrichten — ITSM</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="login-wrap">
<form class="login-box wide" method="post" action="/setup/new">
<h1>Neue Organisation einrichten</h1>
<div class="sz" style="margin-bottom:14px">Jede Organisation erhaelt einen eigenen, getrennten Mandanten. Diese Angaben lassen sich unter Einstellungen jederzeit anpassen.</div>
{% if !error.is_empty() %}<p class="err">{{ error }}</p>{% endif %}
<h2 class="section-title" style="margin-top:0">Firmenprofil</h2>
<div class="formrow"><label>Firmenname</label><input name="firma" value="{{ firma }}" required></div>
<h2 class="section-title">Administrator-Konto</h2>
<div class="formrow"><label>E-Mail (Login)</label><input name="email" type="email" value="{{ email }}" required></div>
<div class="grid-2">
<div class="formrow"><label>Passwort</label><input name="password" type="password" required></div>
<div class="formrow"><label>Passwort wiederholen</label><input name="password2" type="password" required></div>
</div>
<div class="hint">Mindestens {{ password_min_length }} Zeichen, nicht nur Ziffern.</div>
<h2 class="section-title">SLA-Standardwerte</h2>
<div class="grid-2">
<div class="formrow"><label>Antwortzeit (Minuten)</label><input name="sla_antwort_minuten" type="number" value="{{ sla_antwort }}"></div>
<div class="formrow"><label>Loesungszeit (Minuten)</label><input name="sla_loesung_minuten" type="number" value="{{ sla_loesung }}"></div>
</div>
<h2 class="section-title">Datenschutz (DSGVO)</h2>
<div class="grid-2">
<div class="formrow"><label>Datenschutzbeauftragter/Kontakt</label><input name="dsb_name" value="{{ dsb_name }}"></div>
<div class="formrow"><label>Kontakt-E-Mail</label><input name="dsb_email" type="email" value="{{ dsb_email }}"></div>
</div>
<div class="grid-2">
<div class="formrow"><label>Aufbewahrung Tickets (Tage)</label><input name="retention_tickets_days" type="number" value="{{ retention_tickets }}"></div>
<div class="formrow"><label>Aufbewahrung Audit-Log (Tage)</label><input name="retention_audit_days" type="number" value="{{ retention_audit }}"></div>
</div>
<div class="hint">Diese Werte legen fest, wie lange Tickets bzw. Audit-Eintraege vorgehalten werden (DSGVO Speicherbegrenzung). Ein Hintergrund-Job bereinigt automatisch alle 24 Stunden.</div>
<button class="btn" type="submit" style="margin-top:8px">Organisation anlegen</button>
<div class="sz" style="margin-top:12px">Bereits registriert? <a href="/login">Anmelden</a></div>
</form>
</div>
</body>
</html>

76
templates/tickets.html Executable file
View File

@ -0,0 +1,76 @@
{% extends "base.html" %}
{% block content %}
<h1 class="page-title">{{ title }}</h1>
<div class="kpi-row">
{% for kpi in kpis %}
<div class="kpi"><div class="lbl">{{ kpi.0 }}</div><div class="val">{{ kpi.1 }}</div></div>
{% endfor %}
</div>
<div class="panel">
<h3 style="margin-top:0">Neues Ticket</h3>
<form method="post" action="/tickets/new">
<input type="hidden" name="_csrf" value="{{ ctx.csrf }}">
<div class="formrow"><input type="text" name="titel" placeholder="Titel" required></div>
<div class="formrow"><textarea name="beschreibung" placeholder="Beschreibung"></textarea></div>
<div class="grid-2">
<div class="formrow"><label>Kategorie</label>
<select name="kategorie" id="new-kategorie">
{% for c in categories %}<option value="{{ c }}">{{ c }}</option>{% endfor %}
</select>
</div>
<div class="formrow" id="change-typ-row" hidden><label>Change-Typ (Freigabe: Standard = vorautorisiert, Normal = CAB, Emergency = nachtraeglich)</label>
<select name="change_typ">
{% for t in change_types %}<option value="{{ t }}">{{ t }}</option>{% endfor %}
</select>
</div>
</div>
<div class="grid-2">
<div class="formrow"><label>Impact (Auswirkung)</label>
<select name="impact">
{% for l in impact_levels %}<option value="{{ l }}" {% if l == "Mittel" %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
</div>
<div class="formrow"><label>Urgency (Dringlichkeit)</label>
<select name="urgency">
{% for l in impact_levels %}<option value="{{ l }}" {% if l == "Mittel" %}selected{% endif %}>{{ l }}</option>{% endfor %}
</select>
</div>
</div>
<div class="hint">Die Prioritaet ergibt sich automatisch aus der ITIL-Matrix Impact x Urgency.</div>
<div class="formrow">
<select name="service_id">
<option value="">(kein Service)</option>
{% for s in services %}<option value="{{ s.0 }}">{{ s.1 }}</option>{% endfor %}
</select>
</div>
<button class="btn" type="submit">Anlegen</button>
</form>
</div>
<div class="tabs">
{% for t in tabs %}<a href="{{ t.href }}" class="{% if t.active %}active{% endif %}">{{ t.label }}</a>{% endfor %}
</div>
<table class="tickets">
<thead><tr>
<th>Ticket-Nr</th><th>Titel (Service)</th><th>Status</th><th>Prioritaet</th>
<th>Kategorie</th><th>Zugewiesen an</th><th>Aktualisiert</th><th>Fortschritt</th>
</tr></thead>
<tbody>
{% for r in rows %}
<tr class="row" data-ticket-id="{{ r.id }}">
<td>{{ r.nr }}</td>
<td><b>{{ r.titel }}</b><div class="sz">{{ r.service }}</div></td>
<td><span class="pill {{ r.status_class }}">{{ r.status_label }}</span></td>
<td><span class="pill {{ r.prio_class }}">{{ r.prio }}</span></td>
<td>{{ r.kategorie }}{% if !r.badge.is_empty() %}<div class="sz">{{ r.badge }}</div>{% endif %}</td>
<td class="sz">{{ r.zugewiesen }}</td>
<td class="sz">{{ r.updated }}</td>
<td><div class="bar-track"><div class="bar-fill" style="width:{{ r.fortschritt }}%"></div></div> {{ r.fortschritt }}%</td>
</tr>
{% endfor %}
{% if rows.is_empty() %}<tr><td colspan="8" class="sz">Keine Tickets.</td></tr>{% endif %}
</tbody>
</table>
{% endblock %}