From 7f9d2b431dc5045d896d6106552ddb0edbf4adaa Mon Sep 17 00:00:00 2001 From: Moe Date: Wed, 15 Jul 2026 05:02:02 +0200 Subject: [PATCH] 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. --- .gitignore | 3 + Cargo.lock | 2565 ++++++++++++++++++++++++++++++++ Cargo.toml | 36 + Dockerfile | 23 + README.md | 61 +- app.py | 1563 ------------------- db.py | 547 ------- deploy/backup.sh | 0 deploy/bootstrap.sh | 22 - deploy/docker-compose.yml | 33 +- forge_client.py | 91 -- requirements.txt | 3 - schema.sql | 73 + src/admin.rs | 408 +++++ src/auth.rs | 292 ++++ src/cmdb.rs | 319 ++++ src/config.rs | 66 + src/dashboard.rs | 101 ++ src/db.rs | 910 +++++++++++ src/forge.rs | 123 ++ src/itil.rs | 136 ++ src/kb.rs | 283 ++++ src/main.rs | 152 ++ src/security.rs | 186 +++ src/services.rs | 139 ++ src/tickets.rs | 667 +++++++++ src/web.rs | 215 +++ static/app.js | 270 ++++ static/style.css | 106 ++ templates/admin_audit.html | 14 + templates/admin_settings.html | 34 + templates/admin_user_edit.html | 25 + templates/admin_users.html | 74 + templates/base.html | 70 + templates/cmdb_detail.html | 66 + templates/cmdb_form.html | 30 + templates/cmdb_list.html | 22 + templates/dashboard.html | 32 + templates/kb_detail.html | 27 + templates/kb_form.html | 25 + templates/kb_list.html | 30 + templates/login.html | 26 + templates/services.html | 28 + templates/setup.html | 44 + templates/tickets.html | 76 + 45 files changed, 7782 insertions(+), 2234 deletions(-) create mode 100755 .gitignore create mode 100755 Cargo.lock create mode 100755 Cargo.toml create mode 100755 Dockerfile mode change 100644 => 100755 README.md delete mode 100644 app.py delete mode 100644 db.py mode change 100644 => 100755 deploy/backup.sh delete mode 100644 deploy/bootstrap.sh mode change 100644 => 100755 deploy/docker-compose.yml delete mode 100644 forge_client.py delete mode 100644 requirements.txt mode change 100644 => 100755 schema.sql create mode 100755 src/admin.rs create mode 100755 src/auth.rs create mode 100755 src/cmdb.rs create mode 100755 src/config.rs create mode 100755 src/dashboard.rs create mode 100755 src/db.rs create mode 100755 src/forge.rs create mode 100755 src/itil.rs create mode 100755 src/kb.rs create mode 100755 src/main.rs create mode 100755 src/security.rs create mode 100755 src/services.rs create mode 100755 src/tickets.rs create mode 100755 src/web.rs create mode 100755 static/app.js create mode 100755 static/style.css create mode 100755 templates/admin_audit.html create mode 100755 templates/admin_settings.html create mode 100755 templates/admin_user_edit.html create mode 100755 templates/admin_users.html create mode 100755 templates/base.html create mode 100755 templates/cmdb_detail.html create mode 100755 templates/cmdb_form.html create mode 100755 templates/cmdb_list.html create mode 100755 templates/dashboard.html create mode 100755 templates/kb_detail.html create mode 100755 templates/kb_form.html create mode 100755 templates/kb_list.html create mode 100755 templates/login.html create mode 100755 templates/services.html create mode 100755 templates/setup.html create mode 100755 templates/tickets.html diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..d604665 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +Cargo.lock.orig +*.swp diff --git a/Cargo.lock b/Cargo.lock new file mode 100755 index 0000000..4148e22 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2565 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_axum" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163" +dependencies = [ + "askama", + "axum-core", + "http", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "fastrand", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-postgres" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d697d376cbfa018c23eb4caab1fd1883dd9c906a8c034e8d9a3cb06a7e0bef9" +dependencies = [ + "async-trait", + "deadpool", + "getrandom 0.2.17", + "tokio", + "tokio-postgres", + "tracing", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "itsm" +version = "1.0.0" +dependencies = [ + "anyhow", + "argon2", + "askama", + "askama_axum", + "axum", + "axum-extra", + "base64 0.21.7", + "chrono", + "deadpool-postgres", + "form_urlencoded", + "futures", + "hex", + "pbkdf2", + "rand 0.8.7", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "subtle", + "time", + "tokio", + "tokio-postgres", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64 0.22.1", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100755 index 0000000..4bb1e9e --- /dev/null +++ b/Cargo.toml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..cec880e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md old mode 100644 new mode 100755 index c85bb28..64a62e9 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ # ITSM -ITSM-Plattform (Service-Katalog, Tickets, Wissensdatenbank, Assets) -- AES ist ein buchbarer Service darin. \ No newline at end of file +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 diff --git a/app.py b/app.py deleted file mode 100644 index e0cf92a..0000000 --- a/app.py +++ /dev/null @@ -1,1563 +0,0 @@ -""" -ITSM-Plattform -- eigenstaendiges Produkt (nicht Teil von AES), mandantenfaehig. - -Betrieb gemaess ISO 27001 / DSGVO / NIS 2 (Nutzer-Vorgabe, 2026-07-13): - - Persistenz in PostgreSQL statt Dateien (Zugriffskontrolle, Backup/Recovery, - Verschluesselung im Ruhezustand ueber die DB moeglich). - - Audit-Log fuer alle sicherheitsrelevanten/aendernden Aktionen (ISO 27001 - A.12.4, DSGVO Art. 30 Rechenschaftspflicht). - - Ersteinrichtungs-Assistent (/setup/new): legt pro Kunde einen eigenen - Mandanten (Tenant) mit eigenem Admin-Konto, SLA-Standardwerten und - DSGVO-Angaben (Datenschutzbeauftragter, Aufbewahrungsfristen) an. - Mandantenfaehig: mehrere Organisationen auf einer Instanz, Daten strikt - per tenant_id getrennt (kein Datenzugriff ueber Mandantengrenzen). - - Automatische Aufbewahrungsfrist-Bereinigung (Hintergrund-Thread, siehe - db.run_retention_cleanup) -- loescht abgeschlossene Tickets/Audit-Log- - Eintraege, die aelter als die hinterlegte Frist sind (DSGVO Speicher- - begrenzung). - -AES bleibt ein buchbarer Service im Katalog, integriert per HTTP (kein Code- -Import) -- siehe _aes_live_status(). - -ENV: - DATABASE_URL postgresql://user:pass@host:5432/dbname - ITSM_SECRET_KEY Flask-Session-Secret - AES_DASHBOARD_URL Basis-URL des AES-Dashboards fuer die Service-Integration - RETENTION_INTERVAL_SECONDS Intervall fuer die Retention-Bereinigung (Default 24h) - FORGE_BASE_URL Basis-URL des Forge-Git-Servers (phase-008-itsm-repo-audit, - siehe forge_client.py) -- ohne diese Variable ist die - Repo-Bearbeitung aus dem Ticket heraus deaktiviert. - FORGE_SERVICE_TOKEN API-Token eines Forge-admin-Nutzers fuer den Contents- - Schreibzugriff (siehe forge_client.py) - -Repo-Bearbeitung aus Tickets heraus (phase-008-itsm-repo-audit, Nutzer-Vorgabe -2026-07-14): Repos auf Forge sind aus einem Ticket heraus editierbar, aber JEDE -Aenderung wird zwingend als Worklog-Eintrag in der ticket_timeline dieses -Tickets dokumentiert (siehe db.log_repo_edit) -- kein stiller Repo-Zugriff -ohne Ticket-Bezug. Schlaegt das Schreiben des Worklog-Eintrags fehl, wird dem -Nutzer ein Fehler gemeldet, selbst wenn der Forge-Commit bereits durch ist -(siehe repo_edit_route) -- Konsistenz zwischen Repo-Zustand und Audit-Trail -hat Vorrang vor einer optimistischen Erfolgsmeldung. -""" -import os -import html -import time -import secrets -import threading -import datetime as _dt -import urllib.request -import urllib.error - -from flask import Flask, request, redirect, url_for, session, jsonify, abort -from werkzeug.security import generate_password_hash, check_password_hash - -import db -import forge_client - -AES_DASHBOARD_URL = os.getenv("AES_DASHBOARD_URL", "").rstrip("/") -RETENTION_INTERVAL_SECONDS = int(os.getenv("RETENTION_INTERVAL_SECONDS", str(24 * 60 * 60))) - -app = Flask(__name__) -app.secret_key = os.getenv("ITSM_SECRET_KEY") or secrets.token_hex(32) - -CATEGORIES = ["Incident", "Problem", "Task", "Change", "Release"] -PRIORITIES = ["Niedrig", "Mittel", "Hoch", "Kritisch"] - - -def _client_ip(): - return request.headers.get("X-Forwarded-For", request.remote_addr or "") - - -def _now(): - return _dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z" - - -def _jsonable(v): - if isinstance(v, (_dt.datetime, _dt.date)): - return v.isoformat() - if isinstance(v, dict): - return {k: _jsonable(x) for k, x in v.items()} - if isinstance(v, list): - return [_jsonable(x) for x in v] - return v - - -# ── Auth / Session-Helfer ────────────────────────────────────────────────────── -def _current_user(): - if not session.get("user_id"): - return None - return { - "id": session["user_id"], - "email": session.get("email"), - "tenant_id": session.get("tenant_id"), - "tenant_name": session.get("tenant_name"), - "role": session.get("role"), - } - - -def _require_login(): - if not session.get("user_id"): - return redirect(url_for("login", next=request.path)) - return None - - -def _require_admin(): - r = _require_login() - if r: - return r - if session.get("role") != "admin": - return ("

Zugriff verweigert -- nur fuer Administratoren.

", 403) - return None - - -# ── CSS (an AES-Farbschema angelehnt) ────────────────────────────────────────── -_CSS = """ -:root{--bg:#161d2b;--panel:#1e2738;--panel2:#232e42;--border:#324259;--text:#e6edf5; ---sub:#93a3b8;--accent:#4c9eba;--accent2:#5db3d0;--ok:#3ecf8e;--warn:#e0a83e; ---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} -.btn:hover{background:var(--accent2)} -.btn.ghost{background:transparent;border:1px solid var(--border);color:var(--text)} -.sz{color:var(--sub);font-size:12px} -.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)} -.topbar .search{flex:1;max-width:420px;background:var(--bg);border:1px solid var(--border); - border-radius:6px;padding:7px 12px;color:var(--sub);font-size:13px} -.content{padding:24px} -h1.page-title{font-size:22px;margin:0 0 18px} -h2.section-title{font-size:15px;margin:22px 0 10px;color:var(--sub);text-transform:uppercase;letter-spacing:.04em} -.kpi-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:18px} -.kpi{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:14px 16px} -.kpi .lbl{color:var(--sub);font-size:12px;margin-bottom:6px} -.kpi .val{font-size:26px;font-weight:700} -.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} -.bar-track{background:#2a3446;border-radius:99px;height:6px;width:90px;display:inline-block;vertical-align:middle} -.bar-fill{background:var(--accent);height:6px;border-radius:99px} -.panel{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:18px;margin-bottom:16px} -.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} -.svc-card .badge{padding:2px 9px;border-radius:99px;font-size:11px;background:#1a4a35;color:var(--ok)} -.svc-card .badge.err{background:#4a1e22;color:var(--bad)} -.svc-card .badge.unknown{background:#2a2a2a;color:#888} -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} -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:420px;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} -.dt-tabs{display:flex;gap:14px;border-bottom:1px solid var(--border);margin:14px 0} -.dt-tabs span{padding-bottom:8px;color:var(--sub);font-size:13px;cursor:pointer} -.dt-tabs span.active{color:var(--accent2);border-bottom:2px solid var(--accent2)} -.tl-item{border-left:2px solid var(--border);padding-left:12px;margin-bottom:12px;position:relative} -.tl-item::before{content:'';position:absolute;left:-5px;top:2px;width:8px;height:8px;border-radius:50%;background:var(--accent)} -.err{color:#E5534B;font-size:13px;margin:0 0 10px} -.hint{color:var(--sub);font-size:11px;margin:-6px 0 10px} -""" -_CSS_TAG = "" - -_NAV = [ - ("Service Management", [ - ("/tickets", "Tickets"), - ("/probleme", "Probleme"), - ("/aenderungen", "Aenderungen"), - ("/releases", "Releases"), - ]), - ("Services", [("/services", "Service-Katalog")]), - ("Wissen", [("/wissen", "Wissensdatenbank")]), - ("Assets", [("/assets", "Asset-Liste")]), - ("Administration", [ - ("/admin", "Einstellungen"), - ("/admin/users", "Benutzer"), - ("/admin/audit", "Audit-Log"), - ]), -] - -CI_TYPES = ["Server", "Software", "Lizenz", "Vertrag", "Netzwerkgeraet", "Sonstiges"] -CI_STATUS = ["Aktiv", "Inaktiv", "Wartung", "Ausgemustert"] -REL_TYPES = ["haengt ab von", "beinhaltet", "verbunden mit", "ersetzt"] -KB_KATEGORIEN = ["Allgemein", "Anleitung", "Stoerung", "Konfiguration", "FAQ"] - - -def _attrs_to_text(attrs): - """dict -> 'Schluessel: Wert' je Zeile, fuer die Textarea im Formular.""" - if not attrs: - return "" - return "\n".join("%s: %s" % (k, v) for k, v in attrs.items()) - - -def _text_to_attrs(text): - """'Schluessel: Wert' je Zeile -> dict. Leere/fehlerhafte Zeilen werden ignoriert.""" - out = {} - for line in (text or "").splitlines(): - line = line.strip() - if not line or ":" not in line: - continue - k, v = line.split(":", 1) - k = k.strip() - if k: - out[k] = v.strip() - return out - - -def _shell(active_path, title, body_html, extra_head=""): - u = _current_user() - groups_html = [] - for group_name, items in _NAV: - links = "".join( - '%s' % (href, "active" if href == active_path else "", html.escape(label)) - for href, label in items - ) - groups_html.append('' % (html.escape(group_name), links)) - nav_html = "".join(groups_html) - who = "%s · %s" % (html.escape(u["email"] or ""), html.escape(u["tenant_name"] or "")) if u else "" - return """ - -%s -- ITSM%s%s -
- -
-
- -
%s · Abmelden
-
-
%s
-
-
-
-
-""" % (html.escape(title), _CSS_TAG, extra_head, nav_html, who, body_html) - - -# ── Ersteinrichtung (neuer Mandant) ──────────────────────────────────────────── -@app.route("/setup/new", methods=["GET", "POST"]) -def setup_new(): - # Die Ersteinrichtung ist ein einmaliger Vorgang: sobald irgendein - # Mandant existiert, ist die Registrierung neuer Organisationen ueber - # diese Route dauerhaft gesperrt (Nutzer-Vorgabe 2026-07-13: "/setup/new - # darf nur einmal funktionieren"). Weitere Mandanten koennen danach nur - # noch durch einen Administrator ueber die Datenbank/ein internes - # Verfahren angelegt werden, nicht mehr per offenem Self-Service-Formular. - if db.any_tenant_exists(): - return redirect(url_for("login", already_setup="1")) - - error = None - # Vorbelegung: bei GET die Standardwerte, bei einem fehlgeschlagenen POST - # die vom Nutzer eingegebenen Werte (ausser Passwoerter -- die bleiben aus - # Sicherheitsgruenden leer und muessen erneut eingegeben werden). - firma = "" - email = "" - dsb_name = "" - dsb_email = "" - retention_tickets = "1095" - retention_audit = "1825" - sla_antwort = "480" - sla_loesung = "2880" - - if request.method == "POST": - firma = request.form.get("firma", "").strip() - email = request.form.get("email", "").strip().lower() - pw = request.form.get("password", "") - pw2 = request.form.get("password2", "") - dsb_name = request.form.get("dsb_name", "").strip() - dsb_email = request.form.get("dsb_email", "").strip() - retention_tickets = request.form.get("retention_tickets_days", "1095").strip() - retention_audit = request.form.get("retention_audit_days", "1825").strip() - sla_antwort = request.form.get("sla_antwort_minuten", "480").strip() - sla_loesung = request.form.get("sla_loesung_minuten", "2880").strip() - - if not firma: - error = "Firmenname ist erforderlich." - elif not email or "@" not in email: - error = "Bitte eine gueltige E-Mail-Adresse angeben." - elif len(pw) < 10: - error = "Das Passwort muss mindestens 10 Zeichen lang sein." - elif pw != pw2: - error = "Die Passwoerter stimmen nicht ueberein." - elif db.email_exists(email): - error = "Diese E-Mail-Adresse ist bereits registriert." - else: - try: - tenant_id = db.create_tenant( - firma, dsb_name or None, dsb_email or None, - int(retention_tickets or 1095), int(retention_audit or 1825), - int(sla_antwort or 480), int(sla_loesung or 2880), - ) - user_id = db.create_user(tenant_id, email, generate_password_hash(pw), role="admin") - db.create_service( - tenant_id, "AES -- Autonomous Engineering System", - "Automatisierte Software-Entwicklung: Projekte, Phasen, " - "LLM-gestuetzte Agenten, Auto-Fix und Release-Pipeline.", - "Entwicklung", True, AES_DASHBOARD_URL, - ) - db.log_audit(tenant_id, user_id, "tenant_created", "tenant", str(tenant_id), - {"firma": firma}, _client_ip()) - db.log_audit(tenant_id, user_id, "user_created", "user", str(user_id), - {"email": email, "role": "admin"}, _client_ip()) - return redirect(url_for("login", registered="1")) - except Exception as e: - error = "Fehler bei der Einrichtung: %s" % html.escape(str(e)) - - err_html = "

%s

" % error if error else "" - return """ - -Neue Organisation einrichten -- ITSM%s -
""" % ( - _CSS_TAG, err_html, - html.escape(firma), html.escape(email), - html.escape(sla_antwort), html.escape(sla_loesung), - html.escape(dsb_name), html.escape(dsb_email), - html.escape(retention_tickets), html.escape(retention_audit), - ) - - -# ── Login/Logout ──────────────────────────────────────────────────────────────── -@app.route("/login", methods=["GET", "POST"]) -def login(): - error = None - notice = None - if request.args.get("registered"): - notice = "Organisation angelegt. Bitte melde dich mit deinem Admin-Konto an." - elif request.args.get("already_setup"): - notice = "Die Ersteinrichtung wurde bereits abgeschlossen. Bitte melde dich mit deinem bestehenden Konto an." - if request.method == "POST": - email = request.form.get("email", "").strip().lower() - pw = request.form.get("password", "") - user = db.get_user_by_email(email) - if user and check_password_hash(user["password_hash"], pw): - tenant = db.get_tenant(user["tenant_id"]) - session["user_id"] = user["id"] - session["email"] = user["email"] - session["tenant_id"] = user["tenant_id"] - session["tenant_name"] = tenant["name"] if tenant else "" - session["role"] = user["role"] - db.touch_last_login(user["id"]) - db.log_audit(user["tenant_id"], user["id"], "login_success", "user", str(user["id"]), None, _client_ip()) - return redirect(request.args.get("next") or url_for("tickets_route")) - db.log_audit(user["tenant_id"] if user else None, user["id"] if user else None, - "login_failed", "user", email, None, _client_ip()) - error = "E-Mail oder Passwort falsch." - err_html = "

%s

" % html.escape(error) if error else "" - notice_html = "

%s

" % html.escape(notice) if notice else "" - # Der Registrierungs-Link wird nur angezeigt, solange die Ersteinrichtung - # noch nicht abgeschlossen ist -- danach ist /setup/new gesperrt, ein - # Link dorthin waere ein toter Verweis (Nutzer-Vorgabe 2026-07-13). - setup_link_html = ('
Neue Organisation? ' - 'Jetzt einrichten
') if not db.any_tenant_exists() else "" - return """ - -Anmelden -- ITSM%s -
""" % (_CSS_TAG, err_html, notice_html, setup_link_html) - - -@app.route("/logout") -def logout(): - u = _current_user() - if u: - db.log_audit(u["tenant_id"], u["id"], "logout", "user", str(u["id"]), None, _client_ip()) - session.clear() - return redirect(url_for("login")) - - -# ── Tickets: Berechnung + Rendering ──────────────────────────────────────────── -def _is_overdue(t): - if t["status"] in ("Geloest", "Geschlossen"): - return False - created = t["created_at"] - if created.tzinfo is None: - created = created.replace(tzinfo=_dt.timezone.utc) - age_min = (_dt.datetime.now(_dt.timezone.utc) - created).total_seconds() / 60 - return age_min > (t.get("sla_loesung_minuten") or 999999) - - -def _kpis(tickets): - total = len(tickets) - offen = sum(1 for t in tickets if t["status"] == "Offen") - bearbeitung = sum(1 for t in tickets if t["status"] == "In Bearbeitung") - warten = sum(1 for t in tickets if t["status"] == "Warten") - ueberfaellig = sum(1 for t in tickets if _is_overdue(t)) - now = _dt.datetime.now(_dt.timezone.utc) - geloest_7d = 0 - for t in tickets: - if t["status"] in ("Geloest", "Geschlossen"): - upd = t["updated_at"] - if upd.tzinfo is None: - upd = upd.replace(tzinfo=_dt.timezone.utc) - if (now - upd).days <= 7: - geloest_7d += 1 - return {"Gesamt": total, "Offen": offen, "In Bearbeitung": bearbeitung, - "Warten auf Input": warten, "Ueberfaellig": ueberfaellig, "Geloest (7 Tage)": geloest_7d} - - -def _status_class(s): - return {"Offen": "offen", "In Bearbeitung": "inbearbeitung", "Warten": "warten", - "Geloest": "geloest", "Geschlossen": "geschlossen"}.get(s, "offen") - - -def _prio_class(p): - return {"Kritisch": "krit", "Hoch": "hoch", "Mittel": "mittel", "Niedrig": "niedrig"}.get(p, "mittel") - - -def _ticket_row(t): - status = "ueberfaellig" if _is_overdue(t) else _status_class(t["status"]) - status_label = "Ueberfaellig" if _is_overdue(t) else t["status"] - return """ - %s - %s
%s
- %s - %s - %s - %s - %s -
%s%% -""" % ( - t["id"], t["id"], html.escape(t["ticket_nr"]), html.escape(t["titel"]), - html.escape(t.get("service_name") or ""), status, status_label, - _prio_class(t["prioritaet"]), t["prioritaet"], t["kategorie"], - html.escape(t["zugewiesen_an"] or ""), t["updated_at"].strftime("%Y-%m-%d %H:%M"), - t["fortschritt"], t["fortschritt"]) - - -def _kpi_tile(label, value): - return '
%s
%s
' % (html.escape(label), value) - - -def _tickets_body(tenant_id, filter_status=None, filter_kategorie=None): - tickets = db.list_tickets(tenant_id) - kpis = _kpis(tickets) - kpi_html = "".join(_kpi_tile(k, v) for k, v in kpis.items()) - shown = tickets - if filter_kategorie: - shown = [t for t in shown if t["kategorie"] == filter_kategorie] - tabs = [("", "Alle"), ("Offen", "Offen"), ("In Bearbeitung", "In Bearbeitung"), - ("Warten", "Warten"), ("Geloest", "Geloest"), ("Ueberfaellig", "Ueberfaellig")] - base = "/probleme" if filter_kategorie == "Problem" else ( - "/aenderungen" if filter_kategorie == "Change" else ( - "/releases" if filter_kategorie == "Release" else "/tickets")) - tabs_html = "".join( - '%s' % ( - base, ("?status=" + s) if s else "", "active" if (s or "") == (filter_status or "") else "", label) - for s, label in tabs) - if filter_status == "Ueberfaellig": - shown = [t for t in shown if _is_overdue(t)] - elif filter_status: - shown = [t for t in shown if t["status"] == filter_status] - rows = "".join(_ticket_row(t) for t in shown) or 'Keine Tickets.' - services = db.list_services(tenant_id) - svc_opts = "".join('' % (s["id"], html.escape(s["name"])) for s in services) - new_form = """

Neues Ticket

-
-
-
-
-
-
-
-
- -
""" % ( - "".join('' % (c, c) for c in CATEGORIES), - "".join('' % (p, p) for p in PRIORITIES), svc_opts) - return """

Tickets

-
%s
-%s -
%s
- - - -%s
Ticket-NrTitel (Service)StatusPrioritaetKategorieZugewiesen anAktualisiertFortschritt
-""" % (kpi_html, new_form, tabs_html, rows) - - -@app.route("/tickets") -def tickets_route(): - r = _require_login() - if r: - return r - return _shell("/tickets", "Tickets", _tickets_body(session["tenant_id"], request.args.get("status"))) - - -@app.route("/probleme") -def probleme_route(): - r = _require_login() - if r: - return r - return _shell("/probleme", "Probleme", _tickets_body(session["tenant_id"], request.args.get("status"), "Problem")) - - -@app.route("/aenderungen") -def aenderungen_route(): - r = _require_login() - if r: - return r - return _shell("/aenderungen", "Aenderungen", _tickets_body(session["tenant_id"], request.args.get("status"), "Change")) - - -@app.route("/releases") -def releases_route(): - r = _require_login() - if r: - return r - return _shell("/releases", "Releases", _tickets_body(session["tenant_id"], request.args.get("status"), "Release")) - - -@app.route("/tickets/new", methods=["POST"]) -def tickets_new_route(): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - service_id = request.form.get("service_id") or None - tid, ticket_nr = db.create_ticket( - tenant_id, - request.form.get("titel", "").strip() or "(ohne Titel)", - request.form.get("beschreibung", "").strip(), - int(service_id) if service_id else None, - request.form.get("prioritaet", "Mittel"), - request.form.get("kategorie", "Task"), - session.get("email"), - session["user_id"], - 480, 2880, - session.get("email"), - ) - db.log_audit(tenant_id, session["user_id"], "ticket_created", "ticket", ticket_nr, None, _client_ip()) - return redirect(url_for("tickets_route")) - - -@app.route("/tickets//status", methods=["POST"]) -def ticket_status_route(ticket_id): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - status = request.form.get("status", "Offen") - db.update_ticket_status(tenant_id, ticket_id, status, session.get("email")) - db.log_audit(tenant_id, session["user_id"], "ticket_status_changed", "ticket", str(ticket_id), - {"status": status}, _client_ip()) - return jsonify({"ok": True}) - - -@app.route("/api/tickets/") -def api_ticket(ticket_id): - r = _require_login() - if r: - return r - ticket = db.get_ticket(session["tenant_id"], ticket_id) - if not ticket: - abort(404) - return jsonify(_jsonable(ticket)) - - -# ── Repo-Bearbeitung aus Tickets heraus (phase-008-itsm-repo-audit) ───────────── -def _repo_edit_actor_label(): - """Einheitliches akteur-Label fuer ticket_timeline-Eintraege dieser - Funktion -- gleiche Konvention wie an anderen Stellen (session.get("email")).""" - return session.get("email") or "unbekannt" - - -@app.route("/api/tickets//repo-file") -def api_ticket_repo_file(ticket_id): - """Laedt eine Datei aus einem Forge-Repo, um sie im Ticket editierbar - anzuzeigen. Verlangt ein zum Ticket gehoeriges (also existierendes, - mandanten-eigenes) Ticket, damit die Aktion ueberhaupt einen Ticket-Bezug - hat -- das ist Voraussetzung fuer den Worklog-Eintrag beim Speichern, - nicht nur beim Lesen.""" - r = _require_login() - if r: - return r - ticket = db.get_ticket(session["tenant_id"], ticket_id) - if not ticket: - abort(404) - repo = request.args.get("repo", "").strip() - path = request.args.get("path", "").strip() - branch = request.args.get("branch", "").strip() or "main" - if not repo or not path: - return jsonify({"ok": False, "error": "repo und path sind erforderlich"}), 400 - try: - text, sha = forge_client.get_contents(repo, path, ref=branch) - except forge_client.ForgeClientError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - return jsonify({"ok": True, "content": text, "sha": sha, "repo": repo, "path": path, "branch": branch}) - - -@app.route("/tickets//repo-edit", methods=["POST"]) -def ticket_repo_edit_route(ticket_id): - """Schreibt eine Datei-Aenderung ueber die Forge-Contents-API und - dokumentiert sie zwingend im Worklog (ticket_timeline) dieses Tickets. - Harte Anforderung (siehe Modul-Docstring/project.yaml phase-008): - schlaegt der Worklog-Schreibvorgang fehl, wird KEIN Erfolg gemeldet, auch - wenn der Forge-Commit zu diesem Zeitpunkt bereits geschrieben wurde -- - ein bereits erfolgter Git-Commit kann nicht atomar mit dem DB-Insert - zusammengefasst werden (zwei verschiedene Systeme), daher hier stattdessen - die zweitbeste Garantie: der Nutzer wird nie faelschlich im Glauben - gelassen, dass alles dokumentiert wurde. Der (dann undokumentierte) - Forge-Commit bleibt fuer eine manuelle Nachbearbeitung im Server-Log - sichtbar (siehe except-Zweig unten).""" - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - ticket = db.get_ticket(tenant_id, ticket_id) - if not ticket: - abort(404) - repo = request.form.get("repo", "").strip() - path = request.form.get("path", "").strip() - branch = request.form.get("branch", "").strip() or "main" - sha = request.form.get("sha", "").strip() - content = request.form.get("content", "") - message = request.form.get("message", "").strip() - if not repo or not path or not sha or not message: - return jsonify({"ok": False, "error": "repo, path, sha und message sind erforderlich"}), 400 - - message_with_ticket = "%s (ITSM-Ticket %s)" % (message, ticket["ticket_nr"]) - actor = _repo_edit_actor_label() - try: - commit_sha = forge_client.update_contents( - repo, path, content, sha, branch, message_with_ticket, - author_name=actor, author_email=(session.get("email") or "itsm@forge.local"), - ) - except forge_client.ForgeClientError as e: - return jsonify({"ok": False, "error": str(e)}), 502 - - try: - db.log_repo_edit(tenant_id, ticket_id, actor, repo, path, branch, commit_sha) - except Exception as e: - # Forge-Commit ist bereits geschrieben (commit_sha oben), aber der - # Worklog-Eintrag ist NICHT geglueckt -- gemaess phase-008-Vorgabe - # darf dies nicht als Erfolg gemeldet werden. Der Commit-Sha wird - # dennoch geloggt, damit eine manuelle Nachdokumentation moeglich - # bleibt (Konsistenz-Luecke ist sichtbar, nicht verdeckt). - app.logger.error( - "phase-008: Forge-Commit %s (Repo %s, Ticket %s) erfolgreich, " - "aber Worklog-Eintrag fehlgeschlagen: %s", commit_sha, repo, ticket_id, e, - ) - return jsonify({ - "ok": False, - "error": "Datei wurde in Forge committet (%s...), aber der Worklog-Eintrag " - "konnte nicht gespeichert werden. Bitte Admin informieren." % commit_sha[:10], - }), 500 - - db.log_audit(tenant_id, session["user_id"], "repo_file_edited", "ticket", str(ticket_id), - {"repo": repo, "path": path, "branch": branch, "commit": commit_sha}, _client_ip()) - return jsonify({"ok": True, "commit": commit_sha}) - - -# ── Service-Katalog ───────────────────────────────────────────────────────────── -def _service_live_status(endpoint): - """Prueft die Live-Erreichbarkeit eines gebuchten Service-Endpunkts (z.B. AES-Dashboard). - Generisch fuer alle Services mit Endpoint, nicht nur AES. Timeout kurz gehalten, - damit ein haengender/ausgefallener Service den Katalog nicht blockiert. - Rueckgabe: True (erreichbar) / False (nicht erreichbar) / None (kein Endpoint hinterlegt).""" - if not endpoint: - return None - try: - req = urllib.request.Request(endpoint, method="GET") - with urllib.request.urlopen(req, timeout=4) as resp: - return 200 <= resp.status < 400 - except Exception: - return False - - -def _services_body(tenant_id): - services = db.list_services(tenant_id) - cards = [] - for s in services: - if not s["gebucht"]: - badge = 'nicht gebucht' - elif s.get("endpoint"): - live = _service_live_status(s["endpoint"]) - if live is True: - badge = 'gebucht · erreichbar' - elif live is False: - badge = 'gebucht · nicht erreichbar' - else: - badge = 'gebucht' - else: - badge = 'gebucht' - link = ('' - % html.escape(s["endpoint"])) if s.get("endpoint") else "" - cards.append("""
-
%s
%s
%s
%s
""" % ( - html.escape(s["name"]), html.escape(s["beschreibung"] or ""), badge, link)) - add_form = """

Service hinzufuegen

-
-
-
-
- -
""" - return '

Service-Katalog

' + "".join(cards) + add_form - - -@app.route("/services") -def services_route(): - r = _require_login() - if r: - return r - return _shell("/services", "Service-Katalog", _services_body(session["tenant_id"])) - - -@app.route("/services/new", methods=["POST"]) -def services_new_route(): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - sid = db.create_service( - tenant_id, request.form.get("name", "").strip(), request.form.get("beschreibung", "").strip(), - "Sonstiges", True, request.form.get("endpoint", "").strip(), - ) - db.log_audit(tenant_id, session["user_id"], "service_created", "service", str(sid), None, _client_ip()) - return redirect(url_for("services_route")) - - -# ── Administration: Einstellungen + Audit-Log ────────────────────────────────── -@app.route("/admin", methods=["GET", "POST"]) -def admin_route(): - r = _require_admin() - if r: - return r - tenant_id = session["tenant_id"] - notice = "" - if request.method == "POST": - db.update_tenant_settings( - tenant_id, - request.form.get("dsb_name", "").strip() or None, - request.form.get("dsb_email", "").strip() or None, - int(request.form.get("retention_tickets_days") or 1095), - int(request.form.get("retention_audit_days") or 1825), - int(request.form.get("sla_antwort_minuten") or 480), - int(request.form.get("sla_loesung_minuten") or 2880), - ) - db.log_audit(tenant_id, session["user_id"], "tenant_settings_updated", "tenant", str(tenant_id), None, _client_ip()) - notice = "

Gespeichert.

" - if request.args.get("cleanup"): - notice += "

Bereinigung durchgefuehrt -- Details im Audit-Log.

" - t = db.get_tenant(tenant_id) - body = """

Einstellungen

-

%s

%s -
-

SLA-Standardwerte

-
-
-
-
-

Datenschutz (DSGVO)

-
-
-
-
-
-
-
-
- -
-
-

Aufbewahrungsfrist-Bereinigung

-

Laeuft automatisch alle 24 Stunden im Hintergrund: geloeste/geschlossene Tickets und Audit-Log-Eintraege, die aelter als die oben hinterlegten Fristen sind, werden entfernt. Offene/laufende Tickets werden nie geloescht.

-
- -
-
""" % ( - html.escape(t["name"]), notice, t["sla_antwort_minuten"], t["sla_loesung_minuten"], - html.escape(t["dsb_name"] or ""), html.escape(t["dsb_email"] or ""), - t["retention_tickets_days"], t["retention_audit_days"]) - return _shell("/admin", "Einstellungen", body) - - -@app.route("/admin/retention/run", methods=["POST"]) -def admin_retention_run(): - r = _require_admin() - if r: - return r - db.run_retention_cleanup() - return redirect(url_for("admin_route", cleanup="1")) - - -@app.route("/admin/audit") -def admin_audit_route(): - r = _require_admin() - if r: - return r - entries = db.list_audit(session["tenant_id"], limit=300) - rows = "".join( - "%s%s%s%s %s" % ( - e["zeit"].strftime("%Y-%m-%d %H:%M:%S"), html.escape(e["user_email"] or "(unbekannt)"), - html.escape(e["aktion"]), html.escape(e["entity_typ"] or ""), html.escape(e["entity_id"] or "")) - for e in entries - ) or "Keine Eintraege." - body = """

Audit-Log

-
Alle sicherheitsrelevanten Aktionen dieses Mandanten (Anmeldungen, Aenderungen). Aufbewahrung gemaess Einstellungen.
- -%s
Zeitpunkt (UTC)BenutzerAktionObjekt
""" % rows - return _shell("/admin/audit", "Audit-Log", body) - - -# ── Administration: Benutzerverwaltung ───────────────────────────────────────── -def _role_pill(role): - return '%s' % (html.escape(role), html.escape(role)) - - -def _users_body(tenant_id, notice="", form_vals=None): - # form_vals: bei einem fehlgeschlagenen POST die eingegebenen Werte (ausser - # Passwoerter), damit das Formular nicht komplett geleert wird (Nutzer- - # Vorgabe 2026-07-13, analog zum gleichen Fix bei /setup/new). - fv = form_vals or {} - users = db.list_users(tenant_id) - rows = [] - for u in users: - status_pill = ('aktiv' - if u["active"] else - 'gesperrt') - toggle_label = "Sperren" if u["active"] else "Entsperren" - role_opts = "".join( - '' % (r, "selected" if r == u["role"] else "", r) - for r in ("admin", "agent") - ) - name = " ".join(x for x in (u["vorname"], u["nachname"]) if x) or "--" - rows.append(""" -%s -%s -%s -%s -%s -%s -%s - -Details -
- -
-
- -
- -""" % ( - html.escape(u["email"]), html.escape(name), _role_pill(u["role"]), status_pill, - html.escape(u["telefon"] or ""), html.escape(u["auth_source"]), - u["last_login_at"].strftime("%Y-%m-%d %H:%M") if u["last_login_at"] else "nie", - u["id"], u["id"], role_opts, u["id"], toggle_label, - )) - table = """ - -%s
E-MailNameRolleStatusTelefonQuelleLetzte AnmeldungAktionen
""" % ("".join(rows) or "Keine Benutzer.") - - open_attr = " open" if (notice or fv) else "" - add_form = """ -+ Neuen Benutzer -

Neuen Benutzer anlegen

-
Manuelle Verwaltung. Eine AD/LDAP-Anbindung ist als naechste Ausbaustufe vorgesehen (Spalte "Quelle" zeigt dann "ldap" statt "local").
-
-
-
-
-
-
-
-
-
-
Mindestens 10 Zeichen. Der Benutzer sollte das Passwort nach der ersten Anmeldung selbst aendern.
-

Details (optional)

-
-
-
-
-
-
-
-
-
- -
""" % ( - open_attr, - html.escape(fv.get("email", "")), - "selected" if fv.get("role") == "agent" or not fv.get("role") else "", - "selected" if fv.get("role") == "admin" else "", - html.escape(fv.get("vorname", "")), html.escape(fv.get("nachname", "")), - html.escape(fv.get("telefon", "")), html.escape(fv.get("abteilung", "")), - html.escape(fv.get("adresse", "")), - ) - - return '

Benutzerverwaltung

' + notice + table + add_form - - -@app.route("/admin/users", methods=["GET", "POST"]) -def admin_users_route(): - r = _require_admin() - if r: - return r - tenant_id = session["tenant_id"] - notice = "" - form_vals = {} - if request.method == "POST": - email = request.form.get("email", "").strip().lower() - role = request.form.get("role", "agent").strip() - if role not in ("admin", "agent"): - role = "agent" - pw = request.form.get("password", "") - pw2 = request.form.get("password2", "") - vorname = request.form.get("vorname", "").strip() - nachname = request.form.get("nachname", "").strip() - telefon = request.form.get("telefon", "").strip() - abteilung = request.form.get("abteilung", "").strip() - adresse = request.form.get("adresse", "").strip() - form_vals = {"email": email, "role": role, "vorname": vorname, "nachname": nachname, - "telefon": telefon, "abteilung": abteilung, "adresse": adresse} - if not email or "@" not in email: - notice = "

Bitte eine gueltige E-Mail-Adresse angeben.

" - elif len(pw) < 10: - notice = "

Das Passwort muss mindestens 10 Zeichen lang sein.

" - elif pw != pw2: - notice = "

Die Passwoerter stimmen nicht ueberein.

" - elif db.email_exists(email): - notice = "

Diese E-Mail-Adresse ist bereits registriert.

" - else: - uid = db.create_user(tenant_id, email, generate_password_hash(pw), role=role, - vorname=vorname or None, nachname=nachname or None, - telefon=telefon or None, abteilung=abteilung or None, - adresse=adresse or None) - db.log_audit(tenant_id, session["user_id"], "user_created", "user", str(uid), - {"email": email, "role": role, "auth_source": "local"}, _client_ip()) - notice = "

Benutzer angelegt.

" - form_vals = {} - return _shell("/admin/users", "Benutzerverwaltung", _users_body(tenant_id, notice, form_vals)) - - -@app.route("/admin/users//edit", methods=["GET", "POST"]) -def admin_users_edit_route(user_id): - r = _require_admin() - if r: - return r - tenant_id = session["tenant_id"] - u = db.get_user(tenant_id, user_id) - if not u: - abort(404) - error = None - ok_msg = None - if request.method == "POST": - email = request.form.get("email", "").strip().lower() - vorname = request.form.get("vorname", "").strip() - nachname = request.form.get("nachname", "").strip() - telefon = request.form.get("telefon", "").strip() - abteilung = request.form.get("abteilung", "").strip() - adresse = request.form.get("adresse", "").strip() - if not email or "@" not in email: - error = "Bitte eine gueltige E-Mail-Adresse angeben." - elif db.email_exists_excluding(email, user_id): - error = "Diese E-Mail-Adresse wird bereits von einem anderen Benutzer verwendet." - else: - old_email = u["email"] - if email != old_email: - db.update_user_email(tenant_id, user_id, email) - db.log_audit(tenant_id, session["user_id"], "user_email_changed", "user", str(user_id), - {"alt": old_email, "neu": email}, _client_ip()) - if user_id == session["user_id"]: - session["email"] = email - db.update_user_profile(tenant_id, user_id, vorname or None, nachname or None, - telefon or None, abteilung or None, adresse or None) - db.log_audit(tenant_id, session["user_id"], "user_profile_updated", "user", str(user_id), - None, _client_ip()) - u = dict(u) - u.update({"email": email, "vorname": vorname, "nachname": nachname, "telefon": telefon, - "abteilung": abteilung, "adresse": adresse}) - ok_msg = "Gespeichert." - err_html = "

%s

" % html.escape(error) if error else "" - ok_html = ("

%s

" % html.escape(ok_msg)) if ok_msg else "" - body = """

Benutzerdetails

-
%s%s
-
-
Wird sofort als Login-Adresse wirksam. Aenderungen werden im Audit-Log erfasst.
-

Details (optional)

-
-
-
-
-
-
-
-
-
- -
-Zurueck zur Benutzerverwaltung""" % ( - err_html, ok_html, html.escape(u["email"]), - html.escape(u["vorname"] or ""), html.escape(u["nachname"] or ""), - html.escape(u["telefon"] or ""), html.escape(u["abteilung"] or ""), - html.escape(u["adresse"] or ""), - ) - return _shell("/admin/users", "Benutzerdetails", body) - - -@app.route("/admin/users//role", methods=["POST"]) -def admin_users_role_route(user_id): - r = _require_admin() - if r: - return r - tenant_id = session["tenant_id"] - role = request.form.get("role", "agent").strip() - if role not in ("admin", "agent"): - return redirect(url_for("admin_users_route")) - db.set_user_role(tenant_id, user_id, role) - db.log_audit(tenant_id, session["user_id"], "user_role_changed", "user", str(user_id), - {"role": role}, _client_ip()) - return redirect(url_for("admin_users_route")) - - -@app.route("/admin/users//active", methods=["POST"]) -def admin_users_active_route(user_id): - r = _require_admin() - if r: - return r - tenant_id = session["tenant_id"] - current = db.get_user(tenant_id, user_id) - if not current: - return redirect(url_for("admin_users_route")) - new_active = not current["active"] - if user_id == session["user_id"] and not new_active: - return redirect(url_for("admin_users_route")) # eigenes Konto nicht selbst sperren - db.set_user_active(tenant_id, user_id, new_active) - db.log_audit(tenant_id, session["user_id"], - "user_activated" if new_active else "user_deactivated", - "user", str(user_id), None, _client_ip()) - return redirect(url_for("admin_users_route")) - - -# ── Wissensdatenbank ──────────────────────────────────────────────────────────── -def _kb_list_body(tenant_id): - kategorie = request.args.get("kategorie") or None - q = request.args.get("q") or None - articles = db.list_kb_articles(tenant_id, kategorie=kategorie, q=q) - tabs = "".join( - '%s' % ( - html.escape(k), "active" if kategorie == k else "", html.escape(k)) - for k in KB_KATEGORIEN - ) - tabs = 'Alle' % ("active" if not kategorie else "") + tabs - rows = [] - for a in articles: - tag_pills = "".join('%s' % html.escape(t.strip()) - for t in (a["tags"] or "").split(",") if t.strip()) - rows.append(""" -%s%s%s -%s%s""" % ( - a["id"], html.escape(a["titel"]), html.escape(a["kategorie"]), tag_pills, - html.escape(a["autor_email"] or ""), a["updated_at"].strftime("%Y-%m-%d %H:%M"))) - table = """ - -%s
TitelKategorieTagsAutorAktualisiert
""" % ("".join(rows) or "Keine Artikel.") - search_form = """
- - -
""" % html.escape(q or "") - return ('

Wissensdatenbank

' - '
%s
%s%s' - '' % (tabs, search_form, table)) - - -@app.route("/wissen") -def wissen_route(): - r = _require_login() - if r: - return r - return _shell("/wissen", "Wissensdatenbank", _kb_list_body(session["tenant_id"])) - - -@app.route("/wissen/new", methods=["GET", "POST"]) -def wissen_new_route(): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - error = None - titel, kategorie, inhalt, tags = "", "Allgemein", "", "" - if request.method == "POST": - titel = request.form.get("titel", "").strip() - kategorie = request.form.get("kategorie", "Allgemein").strip() - inhalt = request.form.get("inhalt", "").strip() - tags = request.form.get("tags", "").strip() - if not titel: - error = "Titel ist erforderlich." - elif not inhalt: - error = "Inhalt darf nicht leer sein." - else: - aid = db.create_kb_article(tenant_id, titel, kategorie, inhalt, tags, session["user_id"]) - db.log_audit(tenant_id, session["user_id"], "kb_article_created", "kb_article", str(aid), - {"titel": titel}, _client_ip()) - return redirect(url_for("wissen_detail_route", article_id=aid)) - kat_opts = "".join('' % (k, "selected" if k == kategorie else "", k) - for k in KB_KATEGORIEN) - err_html = "

%s

" % html.escape(error) if error else "" - body = """

Neuer Wissensartikel

-
%s
-
-
-
-
- -
""" % (err_html, html.escape(titel), kat_opts, html.escape(inhalt), html.escape(tags)) - return _shell("/wissen", "Neuer Wissensartikel", body) - - -@app.route("/wissen/") -def wissen_detail_route(article_id): - r = _require_login() - if r: - return r - a = db.get_kb_article(session["tenant_id"], article_id) - if not a: - abort(404) - tag_pills = "".join('%s' % html.escape(t.strip()) - for t in (a["tags"] or "").split(",") if t.strip()) - body = """

%s

-
%s · %s · aktualisiert %s
-
%s
-
%s
-Bearbeiten -Zurueck zur Uebersicht""" % ( - html.escape(a["titel"]), html.escape(a["kategorie"]), html.escape(a["autor_email"] or ""), - a["updated_at"].strftime("%Y-%m-%d %H:%M"), html.escape(a["inhalt"]), tag_pills, a["id"]) - return _shell("/wissen", a["titel"], body) - - -@app.route("/wissen//edit", methods=["GET", "POST"]) -def wissen_edit_route(article_id): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - a = db.get_kb_article(tenant_id, article_id) - if not a: - abort(404) - error = None - if request.method == "POST": - titel = request.form.get("titel", "").strip() - kategorie = request.form.get("kategorie", "Allgemein").strip() - inhalt = request.form.get("inhalt", "").strip() - tags = request.form.get("tags", "").strip() - if not titel or not inhalt: - error = "Titel und Inhalt duerfen nicht leer sein." - else: - db.update_kb_article(tenant_id, article_id, titel, kategorie, inhalt, tags) - db.log_audit(tenant_id, session["user_id"], "kb_article_updated", "kb_article", str(article_id), - {"titel": titel}, _client_ip()) - return redirect(url_for("wissen_detail_route", article_id=article_id)) - a = dict(a) - a.update({"titel": titel, "kategorie": kategorie, "inhalt": inhalt, "tags": tags}) - kat_opts = "".join('' % (k, "selected" if k == a["kategorie"] else "", k) - for k in KB_KATEGORIEN) - err_html = "

%s

" % html.escape(error) if error else "" - body = """

Artikel bearbeiten

-
%s
-
-
-
-
- - - -
-
""" % (err_html, html.escape(a["titel"]), kat_opts, html.escape(a["inhalt"]), html.escape(a["tags"] or ""), article_id) - return _shell("/wissen", "Artikel bearbeiten", body) - - -@app.route("/wissen//delete", methods=["POST"]) -def wissen_delete_route(article_id): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - db.delete_kb_article(tenant_id, article_id) - db.log_audit(tenant_id, session["user_id"], "kb_article_deleted", "kb_article", str(article_id), None, _client_ip()) - return redirect(url_for("wissen_route")) - - -# ── CMDB (Configuration Management Database) ─────────────────────────────────── -def _ci_status_pill(status): - cls = {"Aktiv": "geloest", "Inaktiv": "geschlossen", "Wartung": "bearbeitung", "Ausgemustert": "ueberfaellig"}.get(status, "offen") - return '%s' % (cls, html.escape(status)) - - -def _cmdb_list_body(tenant_id): - ci_typ = request.args.get("typ") or None - cis = db.list_cis(tenant_id, ci_typ=ci_typ) - tabs = 'Alle' % ("active" if not ci_typ else "") - tabs += "".join('%s' % (t, "active" if ci_typ == t else "", t) for t in CI_TYPES) - rows = [] - for c in cis: - rows.append(""" -%s%s%s%s""" % ( - c["id"], html.escape(c["name"]), html.escape(c["ci_typ"]), _ci_status_pill(c["status"]), - html.escape(c["beschreibung"] or ""))) - table = """ - -%s
NameTypStatusBeschreibung
""" % ("".join(rows) or "Keine Configuration Items.") - return ('

CMDB -- Configuration Items

' - '
%s
%s' - '' % (tabs, table)) - - -@app.route("/assets") -def assets_route(): - r = _require_login() - if r: - return r - return _shell("/assets", "CMDB", _cmdb_list_body(session["tenant_id"])) - - -@app.route("/assets/new", methods=["GET", "POST"]) -def assets_new_route(): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - error = None - name, ci_typ, status, beschreibung, attr_text = "", "Server", "Aktiv", "", "" - if request.method == "POST": - name = request.form.get("name", "").strip() - ci_typ = request.form.get("ci_typ", "Sonstiges").strip() - status = request.form.get("status", "Aktiv").strip() - beschreibung = request.form.get("beschreibung", "").strip() - attr_text = request.form.get("attribute", "") - if not name: - error = "Name ist erforderlich." - else: - cid = db.create_ci(tenant_id, name, ci_typ, status, beschreibung, _text_to_attrs(attr_text)) - db.log_audit(tenant_id, session["user_id"], "ci_created", "ci", str(cid), - {"name": name, "ci_typ": ci_typ}, _client_ip()) - return redirect(url_for("assets_detail_route", ci_id=cid)) - typ_opts = "".join('' % (t, "selected" if t == ci_typ else "", t) for t in CI_TYPES) - status_opts = "".join('' % (s, "selected" if s == status else "", s) for s in CI_STATUS) - err_html = "

%s

" % html.escape(error) if error else "" - body = """

Neues Configuration Item

-
%s
-
-
-
-
-
-
-
-
-
-
-
- -
""" % (err_html, html.escape(name), typ_opts, status_opts, html.escape(beschreibung), html.escape(attr_text)) - return _shell("/assets", "Neues Configuration Item", body) - - -@app.route("/assets/", methods=["GET", "POST"]) -def assets_detail_route(ci_id): - r = _require_login() - if r: - return r - tenant_id = session["tenant_id"] - error = None - if request.method == "POST": - name = request.form.get("name", "").strip() - ci_typ = request.form.get("ci_typ", "Sonstiges").strip() - status = request.form.get("status", "Aktiv").strip() - beschreibung = request.form.get("beschreibung", "").strip() - attr_text = request.form.get("attribute", "") - if not name: - error = "Name ist erforderlich." - else: - db.update_ci(tenant_id, ci_id, name, ci_typ, status, beschreibung, _text_to_attrs(attr_text)) - db.log_audit(tenant_id, session["user_id"], "ci_updated", "ci", str(ci_id), {"name": name}, _client_ip()) - c = db.get_ci(tenant_id, ci_id) - if not c: - abort(404) - typ_opts = "".join('' % (t, "selected" if t == c["ci_typ"] else "", t) for t in CI_TYPES) - status_opts = "".join('' % (s, "selected" if s == c["status"] else "", s) for s in CI_STATUS) - err_html = "

%s

" % html.escape(error) if error else "" - - other_cis = [x for x in db.list_cis(tenant_id) if x["id"] != ci_id] - ci_opts = "".join('' % (x["id"], html.escape(x["name"]), x["ci_typ"]) for x in other_cis) - rel_typ_opts = "".join('' % (t, t) for t in REL_TYPES) - rel_out_rows = "".join( - "
%s %s
" - "
" % ( - html.escape(r["beziehungs_typ"]), html.escape(r["to_name"]), ci_id, r["id"]) - for r in c["rel_out"] - ) or "

Keine ausgehenden Beziehungen.

" - rel_in_rows = "".join( - "
%s %s (eingehend)
" % ( - html.escape(r["beziehungs_typ"]), html.escape(r["from_name"])) - for r in c["rel_in"] - ) or "

Keine eingehenden Beziehungen.

" - - body = """

%s

-
%s
-
-
-
-
-
-
-
-
-
-
-
- -
-

Beziehungen

-
-

Ausgehend

%s -

Eingehend

%s -

Neue Beziehung

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

Interner Fehler -- Details im Server-Log.

".to_string())) + .into_response() + } +} + +pub type WebResult = Result; + +// ── RBAC-Hilfen ──────────────────────────────────────────────────────────────── +pub fn need_auth(ctx: &ReqCtx, next_path: &str) -> Result { + 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!("

Zugriff verweigert -- {}

", 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 { + // 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, + ConnectInfo(peer): ConnectInfo, + 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("

CSRF-Pruefung fehlgeschlagen -- bitte Seite neu laden.

".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 +} diff --git a/static/app.js b/static/app.js new file mode 100755 index 0000000..736b597 --- /dev/null +++ b/static/app.js @@ -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, "'"); + } + + 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 '' + esc(text) + " "; + } + + function renderDetail(t) { + var h = ""; + h += "

" + esc(t.ticket_nr) + "

"; + h += '
' + esc(t.titel) + "
"; + h += "
" + 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 += "
"; + + h += '
Beschreibung
' + esc(t.beschreibung || "") + "
"; + h += '
Service
' + esc(t.service_name || "(kein Service)") + "
"; + + if (t.sla) { + h += '
SLA
'; + h += "Antwort bis: " + esc(t.sla.response_due || "--"); + h += " · Loesung bis: " + esc(t.sla.resolve_due || "--"); + if (t.sla.overdue) h += ' · Ueberfaellig'; + h += "
"; + } + if (t.problem_nr) { + h += '
Problem
Verknuepft mit ' + esc(t.problem_nr) + "
"; + } + + if (t.operative && t.allowed_next && t.allowed_next.length) { + h += '
Status aendern (erlaubte ITIL-Uebergaenge)
'; + t.allowed_next.forEach(function (s) { + h += '"; + }); + h += "
"; + } + + if (t.can_approve) { + h += '
Change-Freigabe (CAB)
'; + h += ''; + h += '
'; + } + + if (t.operative && t.kategorie === "Problem") { + h += '
Problem Management
'; + h += '
"; + } + + if (t.operative && t.kategorie !== "Problem" && t.problems && t.problems.length) { + h += '
Mit Problem verknuepfen
'; + h += '
'; + } + + if (t.operative) { + h += '
Configuration Items (CMDB)
'; + (t.cis || []).forEach(function (c) { + h += '' + esc(c.name) + + ' '; + }); + if (t.cis_available && t.cis_available.length) { + h += '
'; + } + h += "
"; + } + + if (t.can_repo_edit) { + h += '
Repo-Datei bearbeiten (Forge, dokumentationspflichtig)
'; + h += '
'; + h += '
'; + h += '
'; + h += '
'; + h += ''; + h += '
'; + h += ''; + h += ''; + h += ''; + h += ''; + h += "
"; + } else if (t.operative && t.kategorie === "Change") { + h += '
Repo-Bearbeitung: erfordert Rolle Change Manager/Administrator und einen freigegebenen Change.
'; + } + + h += '
Kommentar (Worklog)
'; + h += ''; + h += '
'; + + h += '
Zeitleiste
'; + var tl = (t.timeline || []).map(function (e) { + return '
' + esc(e.akteur || "") + '
' + + esc(e.zeit) + "
" + esc(e.text || "") + "
"; + }).join("") || '
Keine Ereignisse.
'; + h += tl; + h += '
'; + 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."; }); + }); + } + } +})(); diff --git a/static/style.css b/static/style.css new file mode 100755 index 0000000..2a1e043 --- /dev/null +++ b/static/style.css @@ -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} diff --git a/templates/admin_audit.html b/templates/admin_audit.html new file mode 100755 index 0000000..616f0a4 --- /dev/null +++ b/templates/admin_audit.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block content %} +

Audit-Log

+
Alle sicherheitsrelevanten Aktionen dieses Mandanten (Anmeldungen, Aenderungen). Aufbewahrung gemaess Einstellungen.
+ + + + {% for e in rows %} + + {% endfor %} + {% if rows.is_empty() %}{% endif %} + +
Zeitpunkt (UTC)BenutzerAktionObjekt
{{ e.zeit }}{{ e.user }}{{ e.aktion }}{{ e.objekt }}
Keine Eintraege.
+{% endblock %} diff --git a/templates/admin_settings.html b/templates/admin_settings.html new file mode 100755 index 0000000..eea644b --- /dev/null +++ b/templates/admin_settings.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block content %} +

Einstellungen

+
+

{{ tenant_name }}

+ {% if !notice.is_empty() %}

{{ notice }}

{% endif %} +
+ +

SLA-Standardwerte

+
+
+
+
+

Datenschutz (DSGVO)

+
+
+
+
+
+
+
+
+ +
+
+
+

Aufbewahrungsfrist-Bereinigung

+

Laeuft automatisch alle 24 Stunden im Hintergrund: geloeste/geschlossene Tickets und Audit-Log-Eintraege, die aelter als die oben hinterlegten Fristen sind, werden entfernt. Offene/laufende Tickets werden nie geloescht.

+
+ + +
+
+{% endblock %} diff --git a/templates/admin_user_edit.html b/templates/admin_user_edit.html new file mode 100755 index 0000000..0495d1d --- /dev/null +++ b/templates/admin_user_edit.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +

Benutzerdetails

+
+ {% if !error.is_empty() %}

{{ error }}

{% endif %} + {% if !notice.is_empty() %}

{{ notice }}

{% endif %} +
+ +
+
Wird sofort als Login-Adresse wirksam. Aenderungen werden im Audit-Log erfasst.
+

Details (optional)

+
+
+
+
+
+
+
+
+
+ +
+
+Zurueck zur Benutzerverwaltung +{% endblock %} diff --git a/templates/admin_users.html b/templates/admin_users.html new file mode 100755 index 0000000..23aeba8 --- /dev/null +++ b/templates/admin_users.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block content %} +

Benutzerverwaltung

+{% if !notice.is_empty() %}

{{ notice }}

{% endif %} +{% if !error.is_empty() %}

{{ error }}

{% endif %} + + + + + + {% for u in rows %} + + + + + + + + + + + {% endfor %} + +
E-MailNameRolleStatusTelefonQuelleLetzte AnmeldungAktionen
{{ u.email }}{{ u.name }}{{ u.role_label }}{% if u.active %}aktiv{% else %}gesperrt{% endif %}{{ u.telefon }}{{ u.auth_source }}{{ u.last_login }} + Details +
+ + +
+ {% if !u.is_self %} +
+ + +
+ {% endif %} +
+ +
+ + Neuen Benutzer +
+

Neuen Benutzer anlegen

+
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.
+
+ +
+
+
+ +
+
+
+
+
+
+
Mindestens {{ password_min_length }} Zeichen, nicht nur Ziffern. Der Benutzer sollte das Passwort nach der ersten Anmeldung selbst aendern.
+

Details (optional)

+
+
+
+
+
+
+
+
+
+ +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100755 index 0000000..19bd4da --- /dev/null +++ b/templates/base.html @@ -0,0 +1,70 @@ + + + + + + +{{ title }} — ITSM + + + +
+ +
+
+
{{ ctx.tenant_name }}
+
+ {{ ctx.email }} · {{ ctx.role_label }} · +
+ + +
+
+
+
+ {% block content %}{% endblock %} +
+
+
+
+
+ + + diff --git a/templates/cmdb_detail.html b/templates/cmdb_detail.html new file mode 100755 index 0000000..515b36c --- /dev/null +++ b/templates/cmdb_detail.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block content %} +

{{ name }}

+
+ {% if !error.is_empty() %}

{{ error }}

{% endif %} +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+ +

Beziehungen

+
+

Ausgehend

+ {% for r in rel_out %} +
{{ r.typ }} {{ r.name }} +
+ + +
+
+ {% endfor %} + {% if rel_out.is_empty() %}

Keine ausgehenden Beziehungen.

{% endif %} +

Eingehend

+ {% for r in rel_in %} +
{{ r.typ }} {{ r.name }} (eingehend)
+ {% endfor %} + {% if rel_in.is_empty() %}

Keine eingehenden Beziehungen.

{% endif %} +

Neue Beziehung

+
+ +
+
+ +
+
+ +
+
+ +
+
+{% endblock %} diff --git a/templates/cmdb_form.html b/templates/cmdb_form.html new file mode 100755 index 0000000..2016eb9 --- /dev/null +++ b/templates/cmdb_form.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block content %} +

{{ title }}

+
+ {% if !error.is_empty() %}

{{ error }}

{% endif %} +
+ +
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+{% endblock %} diff --git a/templates/cmdb_list.html b/templates/cmdb_list.html new file mode 100755 index 0000000..544ee21 --- /dev/null +++ b/templates/cmdb_list.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block content %} +

CMDB — Configuration Items

+
+ {% for t in tabs %}{{ t.label }}{% endfor %} +
+ + + + {% for c in rows %} + + + + + + + {% endfor %} + {% if rows.is_empty() %}{% endif %} + +
NameTypStatusBeschreibung
{{ c.name }}{{ c.typ }}{{ c.status }}{{ c.beschreibung }}
Keine Configuration Items.
+ +{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100755 index 0000000..9b9c505 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block content %} +

Dashboard

+
+ {% for kpi in kpis %} +
{{ kpi.0 }}
{{ kpi.1 }}
+ {% endfor %} +
+
+
+

Tickets nach Kategorie

+ {% for r in by_category %} +
+ {{ r.label }} +
+ {{ r.count }} +
+ {% endfor %} +
+
+

Tickets nach Prioritaet

+ {% for r in by_priority %} +
+ {{ r.label }} +
+ {{ r.count }} +
+ {% endfor %} +
+
+
Kennzahlen gemaess ITIL Continual Improvement (v4) / CSI (v3): SLA-Erfuellung und MTTR beziehen sich auf die letzten 30 Tage.
+{% endblock %} diff --git a/templates/kb_detail.html b/templates/kb_detail.html new file mode 100755 index 0000000..500be3b --- /dev/null +++ b/templates/kb_detail.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block content %} +

{{ titel }}

+
+ {{ kategorie }} · {{ status }} · {{ autor }} · aktualisiert {{ updated }} +
+
{{ inhalt }}
+
+ {% for t in tags %}{{ t }}{% endfor %} +
+{% if ctx.operative %} +Bearbeiten +{% endif %} +{% if can_release %} +
+ + {% if status == "Freigegeben" %} + + + {% else %} + + + {% endif %} +
+{% endif %} +Zurueck zur Uebersicht +{% endblock %} diff --git a/templates/kb_form.html b/templates/kb_form.html new file mode 100755 index 0000000..0c75020 --- /dev/null +++ b/templates/kb_form.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} +

{{ title }}

+
+ {% if !error.is_empty() %}

{{ error }}

{% endif %} +
+ +
+
+ +
+
+
+ +
+ {% if !delete_action.is_empty() %} +
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/kb_list.html b/templates/kb_list.html new file mode 100755 index 0000000..e5b040d --- /dev/null +++ b/templates/kb_list.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block content %} +

Wissensdatenbank

+
+ {% for t in tabs %}{{ t.label }}{% endfor %} +
+
+ + +
+ + + + {% for a in rows %} + + + + + + + + + {% endfor %} + {% if rows.is_empty() %}{% endif %} + +
TitelKategorieStatusTagsAutorAktualisiert
{{ a.titel }}{{ a.kategorie }}{{ a.status }}{% for t in a.tags %}{{ t }}{% endfor %}{{ a.autor }}{{ a.updated }}
Keine Artikel.
+{% if ctx.operative %} + +{% endif %} +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100755 index 0000000..3fcb7fa --- /dev/null +++ b/templates/login.html @@ -0,0 +1,26 @@ + + + + + +Anmelden — ITSM + + + + + + diff --git a/templates/services.html b/templates/services.html new file mode 100755 index 0000000..0231463 --- /dev/null +++ b/templates/services.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +

Service-Katalog

+{% if !error.is_empty() %}

{{ error }}

{% endif %} +{% for s in cards %} +
+
+
{{ s.name }}
{{ s.beschreibung }}
+ {{ s.badge_label }} +
+ {% if !s.endpoint.is_empty() %} + + {% endif %} +
+{% endfor %} +{% if ctx.admin %} +
+

Service hinzufuegen

+
+ +
+
+
+ +
+
+{% endif %} +{% endblock %} diff --git a/templates/setup.html b/templates/setup.html new file mode 100755 index 0000000..d4676e3 --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,44 @@ + + + + + +Neue Organisation einrichten — ITSM + + + + + + diff --git a/templates/tickets.html b/templates/tickets.html new file mode 100755 index 0000000..23ea445 --- /dev/null +++ b/templates/tickets.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block content %} +

{{ title }}

+
+ {% for kpi in kpis %} +
{{ kpi.0 }}
{{ kpi.1 }}
+ {% endfor %} +
+ +
+

Neues Ticket

+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+ +
+
+
Die Prioritaet ergibt sich automatisch aus der ITIL-Matrix Impact x Urgency.
+
+ +
+ +
+
+ +
+ {% for t in tabs %}{{ t.label }}{% endfor %} +
+ + + + + + + + {% for r in rows %} + + + + + + + + + + + {% endfor %} + {% if rows.is_empty() %}{% endif %} + +
Ticket-NrTitel (Service)StatusPrioritaetKategorieZugewiesen anAktualisiertFortschritt
{{ r.nr }}{{ r.titel }}
{{ r.service }}
{{ r.status_label }}{{ r.prio }}{{ r.kategorie }}{% if !r.badge.is_empty() %}
{{ r.badge }}
{% endif %}
{{ r.zugewiesen }}{{ r.updated }}
{{ r.fortschritt }}%
Keine Tickets.
+{% endblock %}