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:
parent
6d7c511ea7
commit
7f9d2b431d
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
Cargo.lock.orig
|
||||
*.swp
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,3 +1,62 @@
|
|||
# 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
|
||||
|
|
|
|||
547
db.py
547
db.py
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -1,12 +1,25 @@
|
|||
# ITSM-Deployment (Rust-Version).
|
||||
#
|
||||
# Aufsetzen:
|
||||
# 1. Repo nach /docker/itsm/src klonen (oder Build-Kontext anpassen)
|
||||
# 2. .env mit POSTGRES_PASSWORD anlegen
|
||||
# 3. docker compose up -d --build
|
||||
#
|
||||
# Update: git pull im Quell-Checkout, dann docker compose up -d --build.
|
||||
# Anders als frueher wird beim Container-NEUSTART kein Code mehr aus dem
|
||||
# Git gezogen -- ein Neustart startet exakt das gebaute Image (reproduzierbar,
|
||||
# kein ungetesteter main-Stand in Prod).
|
||||
services:
|
||||
postgres:
|
||||
# Bewusst bei postgres:16 bleiben: das bestehende pgdata-Verzeichnis ist
|
||||
# mit 16 initialisiert; ein Major-Upgrade braucht pg_upgrade/dump+restore.
|
||||
image: postgres:16-alpine
|
||||
container_name: itsm_postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: itsm
|
||||
POSTGRES_USER: itsm
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss gesetzt sein}
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
|
@ -16,9 +29,10 @@ services:
|
|||
retries: 10
|
||||
|
||||
itsm:
|
||||
image: python:3.12-slim
|
||||
build:
|
||||
context: ../
|
||||
dockerfile: Dockerfile
|
||||
container_name: itsm_app
|
||||
command: ["sh", "/bootstrap.sh"]
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
|
|
@ -26,10 +40,15 @@ services:
|
|||
ports:
|
||||
- "8090:8090"
|
||||
environment:
|
||||
DATABASE_URL: postgresql://itsm:${POSTGRES_PASSWORD:-}@postgres:5432/itsm
|
||||
ITSM_SECRET_KEY: ${ITSM_SECRET_KEY:-}
|
||||
DATABASE_URL: postgresql://itsm:${POSTGRES_PASSWORD}@postgres:5432/itsm
|
||||
AES_DASHBOARD_URL: ${AES_DASHBOARD_URL:-http://host.docker.internal:8080}
|
||||
volumes:
|
||||
- ./bootstrap.sh:/bootstrap.sh:ro
|
||||
FORGE_BASE_URL: ${FORGE_BASE_URL:-}
|
||||
FORGE_SERVICE_TOKEN: ${FORGE_SERVICE_TOKEN:-}
|
||||
# Hinter TLS-Terminierung (Reverse Proxy) setzen:
|
||||
# ITSM_HTTPS: "1"
|
||||
# ITSM_TRUSTED_PROXY_COUNT: "1"
|
||||
ITSM_HTTPS: ${ITSM_HTTPS:-0}
|
||||
ITSM_TRUSTED_PROXY_COUNT: ${ITSM_TRUSTED_PROXY_COUNT:-0}
|
||||
RUST_LOG: ${RUST_LOG:-info}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
flask
|
||||
gunicorn
|
||||
psycopg2-binary
|
||||
|
|
@ -139,3 +139,76 @@ CREATE INDEX IF NOT EXISTS idx_cirel_tenant ON ci_relationships (tenant_id);
|
|||
CREATE INDEX IF NOT EXISTS idx_cirel_from ON ci_relationships (from_ci_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cirel_to ON ci_relationships (to_ci_id);
|
||||
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||
-- Migration 2026-07-15: ITIL-v3/v4-Ausrichtung + Sicherheits-Haertung (Rust-Rewrite)
|
||||
-- Alle Aenderungen idempotent, damit init beim App-Start gefahrlos wiederholt laeuft.
|
||||
-- ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
-- ── Rollen (ITIL-orientiert) ──────────────────────────────────────────────────
|
||||
-- 'admin' (Service Owner), 'change_manager' (Change Enablement/CAB),
|
||||
-- 'agent' (Service Desk), 'user' (Requester/Self-Service).
|
||||
-- users.role existiert bereits als TEXT; erlaubte Werte prueft die App.
|
||||
|
||||
-- ── Tickets: Impact/Urgency-Prioritaetsmatrix (ITIL v3, SO 4.2.5.4) ──────────
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS impact TEXT NOT NULL DEFAULT 'Mittel';
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS urgency TEXT NOT NULL DEFAULT 'Mittel';
|
||||
|
||||
-- ── Tickets: Change Enablement (v4) / Change Management (v3) ──────────────────
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS change_typ TEXT;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS approval_status TEXT;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS approved_by INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
|
||||
|
||||
-- ── Tickets: Problem Management (v3 SO 4.4 / v4 Practice) ─────────────────────
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS problem_id INTEGER REFERENCES tickets(id) ON DELETE SET NULL;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS known_error BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
CREATE INDEX IF NOT EXISTS idx_tickets_problem ON tickets (problem_id);
|
||||
|
||||
-- ── Tickets: SLA-Zeitstempel (Service Level Management) ───────────────────────
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS first_response_at TIMESTAMPTZ;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS resolved_at TIMESTAMPTZ;
|
||||
ALTER TABLE tickets ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
|
||||
|
||||
-- ── Ticket-CI-Verknuepfung (SACM v3 / Service Configuration Management v4) ────
|
||||
CREATE TABLE IF NOT EXISTS ticket_ci_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE,
|
||||
ci_id INTEGER NOT NULL REFERENCES configuration_items(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (ticket_id, ci_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tcl_ticket ON ticket_ci_links (ticket_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tcl_ci ON ticket_ci_links (ci_id);
|
||||
|
||||
-- ── Wissensdatenbank: Freigabe-Workflow (Knowledge Management) ────────────────
|
||||
-- Bestehende Artikel werden einmalig 'Freigegeben'; neue starten als 'Entwurf'.
|
||||
ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS status TEXT;
|
||||
UPDATE kb_articles SET status='Freigegeben' WHERE status IS NULL;
|
||||
ALTER TABLE kb_articles ALTER COLUMN status SET DEFAULT 'Entwurf';
|
||||
ALTER TABLE kb_articles ALTER COLUMN status SET NOT NULL;
|
||||
|
||||
-- ── Login-Rate-Limiting (Brute-Force-Schutz, DB-basiert => multi-worker-fest) ──
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id SERIAL PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
zeit TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
success BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_email_zeit ON login_attempts (email, zeit DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_attempts_ip_zeit ON login_attempts (ip, zeit DESC);
|
||||
|
||||
-- ── Server-seitige Sessions (Rust-Rewrite 2026-07-15) ─────────────────────────
|
||||
-- In der DB liegt nur der SHA-256-Hash des Cookie-Tokens: ein DB-Leak
|
||||
-- kompromittiert keine laufenden Sessions; Sessions sind widerrufbar.
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions (expires_at);
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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, ¶ms).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, ¶ms).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"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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."; });
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
@ -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}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 →</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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
Loading…
Reference in New Issue