diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 7674241..2be39bf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(docker exec:*)", "Bash(python3:*)", "Bash(docker-compose restart:*)", - "Bash(docker-compose build:*)" + "Bash(docker-compose build:*)", + "Bash(docker restart:*)", + "Bash(docker network inspect:*)" ], "deny": [] } diff --git a/JOURNAL.md b/JOURNAL.md index 59a5731..17a4141 100644 --- a/JOURNAL.md +++ b/JOURNAL.md @@ -329,4 +329,30 @@ Die Session-Daten werden erst gefüllt, wenn der License Server API implementier - OpenPyXL für Excel-Export - CSV mit Semikolon-Trennung für deutsche Excel-Kompatibilität - Automatische Spaltenbreite in Excel -- BOM für UTF-8 CSV (Excel-Kompatibilität) \ No newline at end of file +- BOM für UTF-8 CSV (Excel-Kompatibilität) + +### 2025-01-06 - Audit-Log implementiert +- Vollständiges Änderungsprotokoll für alle Aktionen +- Filterbare Übersicht mit Pagination +- Detaillierte Anzeige von Änderungen + +**Neue Features:** +- **Audit-Log-Tabelle**: Speichert alle Änderungen mit Zeitstempel, Benutzer, IP +- **Protokollierte Aktionen**: CREATE, UPDATE, DELETE, LOGIN, LOGOUT, EXPORT +- **JSON-Speicherung**: Alte und neue Werte als JSONB für flexible Abfragen +- **Filter-Optionen**: Nach Benutzer, Aktion und Entität +- **Detail-Anzeige**: Aufklappbare Änderungsdetails +- **Navigation**: Audit-Link in allen Templates + +**Geänderte/Neue Dateien:** +- v2_adminpanel/init.sql (audit_log Tabelle mit Indizes) +- v2_adminpanel/app.py (log_audit() Funktion und audit_log() Route) +- v2_adminpanel/templates/audit_log.html (neu erstellt) +- Alle Templates (Audit-Navigation hinzugefügt) + +**Technische Details:** +- JSONB für strukturierte Datenspeicherung +- Performance-Indizes auf timestamp, username und entity +- Farbcodierung für verschiedene Aktionen +- 50 Einträge pro Seite mit Pagination +- IP-Adresse und User-Agent Tracking \ No newline at end of file diff --git a/v2/cookies.txt b/v2/cookies.txt new file mode 100644 index 0000000..0dbc954 --- /dev/null +++ b/v2/cookies.txt @@ -0,0 +1,5 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + +#HttpOnly_localhost FALSE / FALSE 1751984506 session NYjsxmcb81rBYjc17nkaARMbfQAgYxkTegLkLrxWfmM diff --git a/v2_adminpanel/app.py b/v2_adminpanel/app.py index 58d8016..97cbeed 100644 --- a/v2_adminpanel/app.py +++ b/v2_adminpanel/app.py @@ -1,5 +1,6 @@ import os import psycopg2 +from psycopg2.extras import Json from flask import Flask, render_template, request, redirect, session, url_for, send_file from flask_session import Session from functools import wraps @@ -7,6 +8,7 @@ from dotenv import load_dotenv import pandas as pd from datetime import datetime import io +import json load_dotenv() @@ -39,6 +41,37 @@ def get_connection(): conn.set_client_encoding('UTF8') return conn +# Audit-Log-Funktion +def log_audit(action, entity_type, entity_id=None, old_values=None, new_values=None, additional_info=None): + """Protokolliert Änderungen im Audit-Log""" + conn = get_connection() + cur = conn.cursor() + + try: + username = session.get('username', 'system') + ip_address = request.remote_addr if request else None + user_agent = request.headers.get('User-Agent') if request else None + + # Konvertiere Dictionaries zu JSONB + old_json = Json(old_values) if old_values else None + new_json = Json(new_values) if new_values else None + + cur.execute(""" + INSERT INTO audit_log + (username, action, entity_type, entity_id, old_values, new_values, + ip_address, user_agent, additional_info) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (username, action, entity_type, entity_id, old_json, new_json, + ip_address, user_agent, additional_info)) + + conn.commit() + except Exception as e: + print(f"Audit log error: {e}") + conn.rollback() + finally: + cur.close() + conn.close() + @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": @@ -55,6 +88,7 @@ def login(): (username == admin2_user and password == admin2_pass)): session['logged_in'] = True session['username'] = username + log_audit('LOGIN', 'user', additional_info=f"Erfolgreiche Anmeldung") return redirect(url_for('dashboard')) else: return render_template("login.html", error="Ungültige Anmeldedaten") @@ -63,6 +97,8 @@ def login(): @app.route("/logout") def logout(): + username = session.get('username', 'unknown') + log_audit('LOGOUT', 'user', additional_info=f"Abmeldung") session.pop('logged_in', None) session.pop('username', None) return redirect(url_for('login')) @@ -190,9 +226,22 @@ def create_license(): cur.execute(""" INSERT INTO licenses (license_key, customer_id, license_type, valid_from, valid_until, is_active) VALUES (%s, %s, %s, %s, %s, TRUE) + RETURNING id """, (license_key, customer_id, license_type, valid_from, valid_until)) + license_id = cur.fetchone()[0] conn.commit() + + # Audit-Log + log_audit('CREATE', 'license', license_id, + new_values={ + 'license_key': license_key, + 'customer_name': name, + 'customer_email': email, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until + }) cur.close() conn.close() @@ -290,6 +339,13 @@ def edit_license(license_id): cur = conn.cursor() if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute(""" + SELECT license_key, license_type, valid_from, valid_until, is_active + FROM licenses WHERE id = %s + """, (license_id,)) + old_license = cur.fetchone() + # Update license license_key = request.form["license_key"] license_type = request.form["license_type"] @@ -305,6 +361,24 @@ def edit_license(license_id): """, (license_key, license_type, valid_from, valid_until, is_active, license_id)) conn.commit() + + # Audit-Log + log_audit('UPDATE', 'license', license_id, + old_values={ + 'license_key': old_license[0], + 'license_type': old_license[1], + 'valid_from': str(old_license[2]), + 'valid_until': str(old_license[3]), + 'is_active': old_license[4] + }, + new_values={ + 'license_key': license_key, + 'license_type': license_type, + 'valid_from': valid_from, + 'valid_until': valid_until, + 'is_active': is_active + }) + cur.close() conn.close() @@ -334,9 +408,28 @@ def delete_license(license_id): conn = get_connection() cur = conn.cursor() + # Lizenzdetails für Audit-Log abrufen + cur.execute(""" + SELECT l.license_key, c.name, l.license_type + FROM licenses l + JOIN customers c ON l.customer_id = c.id + WHERE l.id = %s + """, (license_id,)) + license_info = cur.fetchone() + cur.execute("DELETE FROM licenses WHERE id = %s", (license_id,)) conn.commit() + + # Audit-Log + if license_info: + log_audit('DELETE', 'license', license_id, + old_values={ + 'license_key': license_info[0], + 'customer_name': license_info[1], + 'license_type': license_info[2] + }) + cur.close() conn.close() @@ -418,6 +511,10 @@ def edit_customer(customer_id): cur = conn.cursor() if request.method == "POST": + # Alte Werte für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + old_customer = cur.fetchone() + # Update customer name = request.form["name"] email = request.form["email"] @@ -429,6 +526,18 @@ def edit_customer(customer_id): """, (name, email, customer_id)) conn.commit() + + # Audit-Log + log_audit('UPDATE', 'customer', customer_id, + old_values={ + 'name': old_customer[0], + 'email': old_customer[1] + }, + new_values={ + 'name': name, + 'email': email + }) + cur.close() conn.close() @@ -477,10 +586,23 @@ def delete_customer(customer_id): conn.close() return redirect("/customers") + # Kundendetails für Audit-Log abrufen + cur.execute("SELECT name, email FROM customers WHERE id = %s", (customer_id,)) + customer_info = cur.fetchone() + # Kunde löschen wenn keine Lizenzen vorhanden cur.execute("DELETE FROM customers WHERE id = %s", (customer_id,)) conn.commit() + + # Audit-Log + if customer_info: + log_audit('DELETE', 'customer', customer_id, + old_values={ + 'name': customer_info[0], + 'email': customer_info[1] + }) + cur.close() conn.close() @@ -588,6 +710,10 @@ def export_licenses(): # Export Format export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'license', + additional_info=f"Export aller Lizenzen als {export_format.upper()}") filename = f'lizenzen_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}' if export_format == 'csv': @@ -665,6 +791,10 @@ def export_customers(): # Export Format export_format = request.args.get('format', 'excel') + + # Audit-Log + log_audit('EXPORT', 'customer', + additional_info=f"Export aller Kunden als {export_format.upper()}") filename = f'kunden_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}' if export_format == 'csv': @@ -708,5 +838,78 @@ def export_customers(): download_name=f'{filename}.xlsx' ) +@app.route("/audit") +@login_required +def audit_log(): + conn = get_connection() + cur = conn.cursor() + + # Parameter + filter_user = request.args.get('user', '').strip() + filter_action = request.args.get('action', '').strip() + filter_entity = request.args.get('entity', '').strip() + page = request.args.get('page', 1, type=int) + per_page = 50 + + # SQL Query mit optionalen Filtern + query = """ + SELECT id, timestamp, username, action, entity_type, entity_id, + old_values, new_values, ip_address, user_agent, additional_info + FROM audit_log + WHERE 1=1 + """ + + params = [] + + # Filter + if filter_user: + query += " AND LOWER(username) LIKE LOWER(%s)" + params.append(f'%{filter_user}%') + + if filter_action: + query += " AND action = %s" + params.append(filter_action) + + if filter_entity: + query += " AND entity_type = %s" + params.append(filter_entity) + + # Gesamtanzahl für Pagination + count_query = "SELECT COUNT(*) FROM (" + query + ") as count_table" + cur.execute(count_query, params) + total = cur.fetchone()[0] + + # Pagination + offset = (page - 1) * per_page + query += " ORDER BY timestamp DESC LIMIT %s OFFSET %s" + params.extend([per_page, offset]) + + cur.execute(query, params) + logs = cur.fetchall() + + # JSON-Werte parsen + parsed_logs = [] + for log in logs: + parsed_log = list(log) + # old_values und new_values sind bereits Dictionaries (JSONB) + # Keine Konvertierung nötig + parsed_logs.append(parsed_log) + + # Pagination Info + total_pages = (total + per_page - 1) // per_page + + cur.close() + conn.close() + + return render_template("audit_log.html", + logs=parsed_logs, + filter_user=filter_user, + filter_action=filter_action, + filter_entity=filter_entity, + page=page, + total_pages=total_pages, + total=total, + username=session.get('username')) + if __name__ == "__main__": app.run(host="0.0.0.0", port=443, ssl_context='adhoc') diff --git a/v2_adminpanel/init.sql b/v2_adminpanel/init.sql index d27099f..a23ae53 100644 --- a/v2_adminpanel/init.sql +++ b/v2_adminpanel/init.sql @@ -29,3 +29,23 @@ CREATE TABLE IF NOT EXISTS sessions ( ended_at TIMESTAMP, is_active BOOLEAN DEFAULT TRUE ); + +-- Audit-Log-Tabelle für Änderungsprotokolle +CREATE TABLE IF NOT EXISTS audit_log ( + id SERIAL PRIMARY KEY, + timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + username TEXT NOT NULL, + action TEXT NOT NULL, + entity_type TEXT NOT NULL, + entity_id INTEGER, + old_values JSONB, + new_values JSONB, + ip_address TEXT, + user_agent TEXT, + additional_info TEXT +); + +-- Index für bessere Performance bei Abfragen +CREATE INDEX idx_audit_log_timestamp ON audit_log(timestamp DESC); +CREATE INDEX idx_audit_log_username ON audit_log(username); +CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id); diff --git a/v2_adminpanel/templates/audit_log.html b/v2_adminpanel/templates/audit_log.html new file mode 100644 index 0000000..545f838 --- /dev/null +++ b/v2_adminpanel/templates/audit_log.html @@ -0,0 +1,235 @@ + + +
+ +| Zeitstempel | +Benutzer | +Aktion | +Entität | +Details | +IP-Adresse | +
|---|---|---|---|---|---|
| {{ log[1].strftime('%d.%m.%Y %H:%M:%S') }} | +{{ log[2] }} | ++ + {% if log[3] == 'CREATE' %}➕ Erstellt + {% elif log[3] == 'UPDATE' %}✏️ Bearbeitet + {% elif log[3] == 'DELETE' %}🗑️ Gelöscht + {% elif log[3] == 'LOGIN' %}🔑 Anmeldung + {% elif log[3] == 'LOGOUT' %}🚪 Abmeldung + {% elif log[3] == 'EXPORT' %}📥 Export + {% else %}{{ log[3] }} + {% endif %} + + | ++ {{ log[4] }} + {% if log[5] %} + #{{ log[5] }} + {% endif %} + | +
+ {% if log[10] %}
+ {{ log[10] }}
+ {% endif %}
+
+ {% if log[6] and log[3] == 'DELETE' %}
+
+
+ {% elif log[6] and log[7] and log[3] == 'UPDATE' %}
+ Gelöschte Werte+
+ {% for key, value in log[6].items() %}
+ {{ key }}: {{ value }}
+ + {% endfor %} +
+
+ {% elif log[7] and log[3] == 'CREATE' %}
+ Änderungen anzeigen+
+ Vorher:
+ + {% for key, value in log[6].items() %} + {% if log[7][key] != value %} + {{ key }}: {{ value }} + {% endif %} + {% endfor %} + + Nachher: + {% for key, value in log[7].items() %} + {% if log[6][key] != value %} + {{ key }}: {{ value }} + {% endif %} + {% endfor %} +
+
+ {% endif %}
+ Erstellte Werte+
+ {% for key, value in log[7].items() %}
+ {{ key }}: {{ value }}
+ + {% endfor %} + |
+ + {{ log[8] or '-' }} + | +
Keine Audit-Log-Einträge gefunden.
+