diff --git a/README.md b/README.md index 0fa942e..69cf8c3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.35 +# AssetManager 0.5.5.40 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. The project is released under the **Apache License 2.0** and may be used, modified, and redistributed for private and commercial purposes. diff --git a/VERSION b/VERSION index a609c26..6c18eb9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.35 +0.5.5.40 diff --git a/app/config.py b/app/config.py index a5d4d50..9861a8c 100644 --- a/app/config.py +++ b/app/config.py @@ -44,6 +44,7 @@ DEFAULT_CONFIG: dict[str, Any] = { "automatic_retry_max_retries": 3, "inventory_exclusion_rules": [] }, + "privacy": {}, "meshcentral": { "enabled": False, "url": "", @@ -111,5 +112,6 @@ def public_config() -> dict[str, Any]: "general": dict(config.get("general", {})), "authentication": dict(config.get("authentication", {})), "software": dict(config.get("software", {})), + "privacy": dict(config.get("privacy", {})), "meshcentral": mesh, } diff --git a/app/i18n.py b/app/i18n.py index 57937e3..4c2be87 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -1216,3 +1216,97 @@ BASE_TRANSLATIONS.update({ "software.compare.status": ("Comparison status", "Vergleichsstatus"), "software.compare.empty": ("No software inventory is available for these assets.", "Für diese Assets sind keine Softwareinventuren vorhanden."), }) + +BASE_TRANSLATIONS.update({ + "common.add": ("Add", "Hinzufügen"), + "privacy.menu": ("Security & privacy", "Sicherheit & Datenschutz"), + "privacy.title": ("Security & privacy", "Sicherheit & Datenschutz"), + "privacy.intro": ("Document processing activities, personal-data categories, retention criteria, manual deletion procedures, recipients, transfers and technical safeguards.", "Dokumentiere Verarbeitungstätigkeiten, Kategorien personenbezogener Daten, Löschkriterien, manuelle Löschverfahren, Empfänger, Übermittlungen und technische Schutzmaßnahmen."), + "privacy.export_text": ("Export text report", "Textbericht exportieren"), + "privacy.export_excel": ("Export Excel report", "Excel-Bericht exportieren"), + "privacy.export_html": ("Export HTML report", "HTML-Bericht exportieren"), + "privacy.phase1_title": ("Phase 1: documentation only.", "Stufe 1: nur Dokumentation."), + "privacy.phase1_help": ("Automatic deletion is not executed in this version. Settings describe desired retention and prepare later dry-run and deletion functions.", "In dieser Version werden keine automatischen Löschungen ausgeführt. Die Einstellungen dokumentieren die gewünschten Fristen und bereiten spätere Prüfläufe und Löschfunktionen vor."), + "privacy.processing_activity": ("Processing activity", "Verarbeitungstätigkeit"), + "privacy.activity_title": ("Title of processing activity", "Bezeichnung der Verarbeitungstätigkeit"), + "privacy.document_version": ("Document version", "Dokumentversion"), + "privacy.controller": ("Controller / organisation", "Verantwortlicher / Organisation"), + "privacy.responsible_department": ("Responsible department", "Verantwortlicher Fachbereich"), + "privacy.responsible_people": ("Responsible people", "Verantwortliche Personen"), + "privacy.data_protection_contact": ("Data protection contact", "Datenschutzkontakt"), + "privacy.legal_basis": ("Legal basis", "Rechtsgrundlage"), + "privacy.data_subject_groups": ("Data-subject groups", "Betroffene Personengruppen"), + "privacy.purposes": ("Purposes", "Zwecke"), + "privacy.purposes_help": ("List the purposes of processing separately.", "Führe die Verarbeitungszwecke einzeln auf."), + "privacy.recipients_transfers": ("Recipients and transfers", "Empfänger und Übermittlungen"), + "privacy.internal_recipients": ("Internal recipients", "Interne Empfänger"), + "privacy.external_recipients": ("External recipients", "Externe Empfänger"), + "privacy.third_country_transfer": ("Transfer to a third country or international organisation", "Übermittlung in ein Drittland oder an eine internationale Organisation"), + "privacy.third_country_details": ("Transfer details and safeguards", "Angaben zur Übermittlung und zu Garantien"), + "privacy.systems_backups": ("Systems and backups", "Systeme und Sicherungen"), + "privacy.systems": ("Systems and data locations", "Systeme und Speicherorte"), + "privacy.backup_system": ("Backup system", "Backup-System"), + "privacy.backup_retention_days": ("Backup retention in days", "Backup-Aufbewahrung in Tagen"), + "privacy.restore_deletion_procedure": ("Deletion procedure after restore", "Löschverfahren nach einer Wiederherstellung"), + "privacy.data_categories": ("Data categories and retention", "Datenkategorien und Löschfristen"), + "privacy.data_categories_help": ("Define storage, overwrite and deletion criteria per category. Automatic deletion remains disabled in phase 1.", "Definiere Speicherung, Überschreibung und Löschkriterien je Kategorie. Die automatische Löschung bleibt in Stufe 1 deaktiviert."), + "privacy.data_categories_tabs_help": ("Each data category is displayed as a separate worksheet-style tab. Tabs can be added, edited and removed.", "Jede Datenkategorie wird als eigener Karteireiter dargestellt. Karteireiter können hinzugefügt, bearbeitet und gelöscht werden."), + "privacy.add_category": ("Add data category", "Datenkategorie hinzufügen"), + "privacy.remove_category": ("Remove category", "Kategorie entfernen"), + "privacy.new_category": ("New data category", "Neue Datenkategorie"), + "privacy.category_name": ("Category name", "Bezeichnung der Kategorie"), + "privacy.system": ("System", "System"), + "privacy.data_subjects": ("Data subjects", "Betroffene Personen"), + "privacy.personal_data": ("Personal data", "Personenbezogene Daten"), + "privacy.purpose": ("Purpose", "Zweck"), + "privacy.retention_type": ("Retention/deletion type", "Aufbewahrungs-/Löschart"), + "privacy.retention_days": ("Retention in days", "Aufbewahrung in Tagen"), + "privacy.retention_trigger": ("Start/trigger of the period", "Beginn/Auslöser der Frist"), + "privacy.automatic_possible": ("Automatic deletion technically possible", "Automatische Löschung technisch möglich"), + "privacy.manual_procedure": ("Manual deletion procedure", "Manuelles Löschverfahren"), + "privacy.backup_handling": ("Backup handling", "Behandlung in Sicherungen"), + "privacy.responsible": ("Responsible role", "Verantwortliche Rolle"), + "privacy.notes": ("Notes", "Hinweise"), + "privacy.retention_days_type": ("Delete/anonymise after a number of days", "Nach einer Anzahl von Tagen löschen/anonymisieren"), + "privacy.retention_manual_parent": ("Manual deletion with parent record", "Manuelle Löschung mit übergeordnetem Datensatz"), + "privacy.retention_overwrite_parent": ("Overwrite current value; delete with parent record", "Aktuellen Wert überschreiben; mit übergeordnetem Datensatz löschen"), + "privacy.retention_external_days": ("External system: retention in days", "Externes System: Aufbewahrung in Tagen"), + "privacy.retention_external_manual": ("External system: manual deletion", "Externes System: manuelle Löschung"), + "privacy.retention_external_overwrite": ("External system: overwrite and parent deletion", "Externes System: Überschreiben und Löschung mit Datensatz"), + "privacy.retention_not_stored": ("Not stored permanently", "Nicht dauerhaft gespeichert"), + "privacy.retention_manual": ("Manual procedure", "Manuelles Verfahren"), + "privacy.toms": ("Technical and organisational measures", "Technische und organisatorische Maßnahmen"), + "privacy.toms_help": ("Document access control, encryption, backups, logging, patching and operational safeguards.", "Dokumentiere Zugriffskontrolle, Verschlüsselung, Sicherungen, Protokollierung, Patchmanagement und organisatorische Maßnahmen."), + "privacy.review": ("Review and approval", "Prüfung und Freigabe"), + "privacy.review_interval_months": ("Review interval in months", "Prüfintervall in Monaten"), + "privacy.last_reviewed_at": ("Last reviewed", "Zuletzt geprüft"), + "privacy.approved_by": ("Approved by", "Freigegeben durch"), + "privacy.policy_audit": ("Policy audit log", "Änderungsprotokoll des Datenschutzkonzepts"), + "privacy.policy_audit_help": ("Records policy changes and exports without storing the deleted personal data itself.", "Protokolliert Änderungen und Exporte, ohne die gelöschten personenbezogenen Inhalte selbst zu speichern."), + "privacy.action": ("Action", "Aktion"), + "privacy.changed_by": ("Changed by", "Bearbeiter"), + "privacy.details": ("Details", "Details"), + "privacy.no_audit_entries": ("No policy changes have been recorded yet.", "Noch keine Änderungen am Datenschutzkonzept protokolliert."), + "privacy.saved": ("Privacy settings saved.", "Datenschutz-Einstellungen gespeichert."), + "privacy.phase2_title": ("Phase 2: checks and manual deletion.", "Stufe 2: Prüfläufe und manuelle Löschung."), + "privacy.phase2_help": ("Automatic deletion remains disabled. Administrators can run checks and manually confirmed deletion runs for selected protected system categories.", "Die automatische Löschung bleibt deaktiviert. Administratoren können für ausgewählte geschützte Systemkategorien Prüfläufe und manuell bestätigte Löschläufe ausführen."), + "privacy.read_only_title": ("Read-only access.", "Nur-Lese-Zugriff."), + "privacy.read_only_help": ("Only administrators may change privacy settings or execute retention actions.", "Nur Administratoren dürfen Datenschutz-Einstellungen ändern oder Löschaktionen ausführen."), + "privacy.system_category_help": ("Protected system category. It cannot be removed because application retention functions depend on its stable identifier.", "Geschützte Systemkategorie. Sie kann nicht entfernt werden, da Löschfunktionen der Anwendung von ihrer festen Kennung abhängen."), + "privacy.run_check": ("Run check", "Prüflauf starten"), + "privacy.run_manual_deletion": ("Run manual deletion", "Manuelle Löschung ausführen"), + "privacy.handler_not_implemented": ("No executable retention handler is implemented yet.", "Für diese Kategorie ist noch keine ausführbare Löschfunktion implementiert."), + "privacy.check_result": ("Last check result", "Letztes Prüfergebnis"), + "privacy.matched_records": ("Matched records/files", "Betroffene Datensätze/Dateien"), + "privacy.approx_size": ("Approximate size", "Ungefähre Größe"), + "privacy.cutoff": ("Cutoff UTC", "Stichtag UTC"), + "privacy.check_completed": ("Retention check completed.", "Prüflauf abgeschlossen."), + "privacy.check_not_supported": ("No executable retention handler is available for this category.", "Für diese Kategorie ist keine ausführbare Löschfunktion verfügbar."), + "privacy.delete_confirmation_prompt": ("Enter DELETE to permanently execute the retention action for {category}.", "Gib DELETE ein, um die Löschaktion für {category} dauerhaft auszuführen."), + "privacy.delete_confirmation_invalid": ("Enter DELETE to confirm the deletion run.", "Gib DELETE ein, um den Löschlauf zu bestätigen."), + "privacy.delete_completed": ("Manual deletion run completed.", "Manueller Löschlauf abgeschlossen."), + "privacy.deletion_audit": ("Deletion run log", "Löschprotokoll"), + "privacy.deletion_audit_help": ("Records checks and deletion runs without storing the deleted personal content itself.", "Protokolliert Prüfläufe und Löschläufe, ohne die gelöschten personenbezogenen Inhalte selbst zu speichern."), + "privacy.run_id": ("Run ID", "Lauf-ID"), + "privacy.no_deletion_entries": ("No checks or deletion runs have been recorded yet.", "Noch keine Prüfläufe oder Löschläufe protokolliert."), +}) diff --git a/app/main.py b/app/main.py index 7bc77a5..3b13f6f 100644 --- a/app/main.py +++ b/app/main.py @@ -51,6 +51,8 @@ from .software_control import token_hash, detect_platform, execute_job, build_re from .job_state import backfill_asset_job_states, filter_state_key, sync_asset_job_state from .presence import mesh_presence_loop from .version import APP_VERSION +from .privacy import merge_privacy_settings, localized_privacy_settings, normalize_category, append_privacy_audit, privacy_audit_tail, PROTECTED_PRIVACY_CATEGORY_KEYS +from .privacy_retention import check_retention_category, delete_retention_category, append_deletion_audit, deletion_audit_tail, IMPLEMENTED_RETENTION_KEYS from .backup import (BACKUP_DIR, BACKUP_INTERVAL_HOURS, BACKUP_RETENTION_DAYS, backup_path, create_backup, delete_backup, list_backups, restore_backup, store_uploaded_backup, automatic_backup_loop, system_storage_information) from openpyxl import Workbook, load_workbook from openpyxl.styles import Font, PatternFill, Alignment @@ -2220,6 +2222,14 @@ def _seed_default_charts(db: Session) -> None: @app.get("/") def dashboard(request: Request, db: Session = Depends(get_db)): categories = db.query(Category).options(joinedload(Category.visible_fields)).all() + category_asset_counts = dict( + _apply_asset_access( + db.query(Asset.category_id, func.count(Asset.id)), + request, + ) + .group_by(Asset.category_id) + .all() + ) assets = ( _apply_asset_access(db.query(Asset), request) .options(joinedload(Asset.category).joinedload(Category.visible_fields)) @@ -2239,7 +2249,7 @@ def dashboard(request: Request, db: Session = Depends(get_db)): dashboard_charts = [_chart_payload(chart, chart_assets) for chart in db.query(ChartDefinition).filter(ChartDefinition.dashboard_visible.is_(True)).order_by(ChartDefinition.id).all()] return templates.TemplateResponse( "dashboard.html", - {"request": request, "categories": categories, "assets": assets, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None), "dashboard_charts": dashboard_charts}, + {"request": request, "categories": categories, "category_asset_counts": category_asset_counts, "assets": assets, "fields": fields, "is_admin": _is_admin(request), "issue_status_name": (_action_status(db, "issue").name if _action_status(db, "issue") else None), "dashboard_charts": dashboard_charts}, ) @@ -7147,6 +7157,453 @@ def settings_import_export_page(request: Request): ) + +@app.get("/settings/privacy") +def settings_privacy_page(request: Request): + config = load_config() + privacy_settings = localized_privacy_settings(config.get("privacy"), _request_language(request)) + preview_results = request.session.pop("privacy_retention_preview", {}) + return templates.TemplateResponse( + "settings_privacy.html", + { + "request": request, + "privacy_settings": privacy_settings, + "privacy_audit": privacy_audit_tail(50), + "deletion_audit": deletion_audit_tail(50), + "can_edit": _is_admin(request), + "implemented_retention_keys": IMPLEMENTED_RETENTION_KEYS, + "preview_results": preview_results if isinstance(preview_results, dict) else {}, + }, + ) + + +@app.post("/settings/privacy") +async def settings_privacy_save(request: Request): + _require_admin(request) + form = await request.form() + current = merge_privacy_settings(load_config().get("privacy")) + activity = dict(current.get("processing_activity", {})) + text_fields = ( + "title", "responsible_department", "responsible_people", "controller", + "data_protection_contact", "legal_basis", "data_subject_groups", + "internal_recipients", "external_recipients", "third_country_details", + "systems", "backup_system", "restore_deletion_procedure", + "last_reviewed_at", "approved_by", "document_version", + ) + for field in text_fields: + activity[field] = str(form.get(field, "") or "").strip()[:8000] + activity["third_country_transfer"] = form.get("third_country_transfer") == "on" + activity["backup_retention_days"] = _optional_form_int( + form.get("backup_retention_days"), field_name="backup_retention_days", minimum=0, maximum=36500 + ) + activity["review_interval_months"] = _optional_form_int( + form.get("review_interval_months"), field_name="review_interval_months", minimum=1, maximum=120 + ) or 12 + activity["purposes"] = [str(value).strip()[:2000] for value in form.getlist("purposes") if str(value).strip()] + activity["toms"] = [str(value).strip()[:3000] for value in form.getlist("toms") if str(value).strip()] + + category_keys = form.getlist("category_key") + categories = [] + for index, key in enumerate(category_keys): + def value(name: str, default: str = "") -> str: + values = form.getlist(name) + return str(values[index] if index < len(values) else default) + category = normalize_category({ + "key": value("category_key", f"custom_{index + 1}"), + "name": value("category_name"), + "system": value("category_system"), + "data_subjects": value("category_data_subjects"), + "personal_data": value("category_personal_data"), + "purpose": value("category_purpose"), + "retention_type": value("category_retention_type", "manual"), + "retention_days": value("category_retention_days"), + "retention_trigger": value("category_retention_trigger"), + "automatic_possible": value("category_automatic_possible") == "1", + "manual_procedure": value("category_manual_procedure"), + "backup_handling": value("category_backup_handling"), + "responsible": value("category_responsible"), + "notes": value("category_notes"), + }, index) + if category["name"]: + categories.append(category) + + # System categories are part of executable retention handlers and cannot be removed. + submitted_keys = {str(item.get("key") or "") for item in categories} + current_by_key = {str(item.get("key") or ""): item for item in current.get("categories", [])} + default_by_key = {str(item.get("key") or ""): item for item in merge_privacy_settings(None).get("categories", [])} + for protected_key in PROTECTED_PRIVACY_CATEGORY_KEYS: + if protected_key not in submitted_keys: + restored = current_by_key.get(protected_key) or default_by_key.get(protected_key) + if restored: + categories.append(normalize_category(restored, len(categories))) + + updated = { + "processing_activity": activity, + "categories": categories, + "phase": 2, + "automatic_deletion_available": False, + } + save_config({"privacy": updated}) + append_privacy_audit( + "privacy_policy_updated", + _changed_by(request) or "admin", + {"categories": len(categories), "purposes": len(activity["purposes"]), "toms": len(activity["toms"])}, + ) + return RedirectResponse( + "/settings/privacy?toast_success=" + quote(_translate_request(request, "privacy.saved", "Privacy settings saved.")), + status_code=303, + ) + + + +def _privacy_category_by_key(category_key: str) -> dict[str, Any]: + settings = merge_privacy_settings(load_config().get("privacy")) + for category in settings.get("categories", []): + if str(category.get("key") or "") == category_key: + return category + raise HTTPException(404, "Privacy category not found.") + + +@app.post("/settings/privacy/retention/{category_key}/check") +def settings_privacy_retention_check(request: Request, category_key: str): + _require_admin(request) + category = _privacy_category_by_key(category_key) + result = check_retention_category(category_key, category.get("retention_days")) + result["category_name"] = category.get("name") or category_key + request.session["privacy_retention_preview"] = {category_key: result} + append_deletion_audit( + "retention_check", + _changed_by(request) or "admin", + category_key=category_key, + retention_days=category.get("retention_days"), + result=result, + ) + message_key = "privacy.check_completed" if result.get("supported") else "privacy.check_not_supported" + fallback = "Retention check completed." if result.get("supported") else "No executable retention handler is available for this category." + return RedirectResponse( + "/settings/privacy?toast_success=" + quote(_translate_request(request, message_key, fallback)) + "#privacy-category-list", + status_code=303, + ) + + +@app.post("/settings/privacy/retention/{category_key}/delete") +async def settings_privacy_retention_delete(request: Request, category_key: str): + _require_admin(request) + form = await request.form() + confirmation = str(form.get("confirmation") or "").strip().upper() + if confirmation != "DELETE": + return RedirectResponse( + "/settings/privacy?toast_error=" + quote(_translate_request(request, "privacy.delete_confirmation_invalid", "Enter DELETE to confirm the deletion run.")), + status_code=303, + ) + category = _privacy_category_by_key(category_key) + try: + result = delete_retention_category(category_key, category.get("retention_days")) + except ValueError as exc: + return RedirectResponse( + "/settings/privacy?toast_error=" + quote(str(exc)), + status_code=303, + ) + result["category_name"] = category.get("name") or category_key + append_deletion_audit( + "manual_deletion", + _changed_by(request) or "admin", + category_key=category_key, + retention_days=category.get("retention_days"), + result=result, + ) + append_privacy_audit( + "privacy_manual_deletion_executed", + _changed_by(request) or "admin", + { + "category_key": category_key, + "retention_days": category.get("retention_days"), + "records": result.get("deleted_or_anonymised_records", 0), + "status": result.get("status"), + }, + ) + return RedirectResponse( + "/settings/privacy?toast_success=" + quote(_translate_request(request, "privacy.delete_completed", "Manual deletion run completed.")) + "#privacy-category-list", + status_code=303, + ) + +def _privacy_report_text(settings: dict[str, Any], language_code: str = "en") -> str: + activity = settings["processing_activity"] + de = str(language_code or "").lower().startswith("de") + labels = { + "title": "VERZEICHNIS VON VERARBEITUNGSTÄTIGKEITEN / LÖSCHKONZEPT" if de else "RECORD OF PROCESSING ACTIVITIES / RETENTION CONCEPT", + "document_version": "Dokumentversion" if de else "Document version", + "activity": "Verarbeitungstätigkeit" if de else "Processing activity", + "controller": "Verantwortlicher" if de else "Controller", + "department": "Verantwortlicher Fachbereich" if de else "Responsible department", + "people": "Verantwortliche Personen" if de else "Responsible people", + "dpo": "Datenschutzkontakt" if de else "Data protection contact", + "purposes": "1. ZWECKE" if de else "1. PURPOSES", + "legal": "2. RECHTSGRUNDLAGE" if de else "2. LEGAL BASIS", + "subjects": "3. BETROFFENE PERSONENGRUPPEN" if de else "3. DATA SUBJECT GROUPS", + "recipients": "4. EMPFÄNGER" if de else "4. RECIPIENTS", + "internal": "Intern" if de else "Internal", + "external": "Extern" if de else "External", + "third": "5. DRITTLANDÜBERMITTLUNGEN" if de else "5. THIRD-COUNTRY TRANSFERS", + "intended": "Übermittlung vorgesehen" if de else "Transfer intended", + "yes": "Ja" if de else "Yes", "no": "Nein" if de else "No", + "systems": "6. SYSTEME UND SICHERUNGEN" if de else "6. SYSTEMS AND BACKUPS", + "system_label": "Systeme" if de else "Systems", + "backup_system": "Backup-System" if de else "Backup system", + "backup_retention": "Backup-Aufbewahrung" if de else "Backup retention", + "restore": "Verfahren nach Wiederherstellung" if de else "Restore procedure", + "categories": "7. DATENKATEGORIEN UND LÖSCHFRISTEN" if de else "7. DATA CATEGORIES AND RETENTION", + "system": "System", "data_subjects": "Betroffene Personen" if de else "Data subjects", + "personal_data": "Personenbezogene Daten" if de else "Personal data", + "purpose": "Zweck" if de else "Purpose", "retention_type": "Löschart" if de else "Retention type", + "retention_period": "Aufbewahrungsfrist" if de else "Retention period", + "trigger": "Fristbeginn/Auslöser" if de else "Trigger", + "automatic_possible": "Automatische Löschung technisch möglich" if de else "Automatic deletion technically possible", + "automatic_active": "Automatische Löschung aktiv: Nein; Prüfläufe und manuelle Löschung für ausgewählte Kategorien verfügbar" if de else "Automatic deletion active: No; checks and manual deletion are available for selected categories", + "manual": "Manuelles Verfahren" if de else "Manual procedure", + "backup": "Behandlung in Sicherungen" if de else "Backup handling", + "responsible": "Verantwortlich" if de else "Responsible", "notes": "Hinweise" if de else "Notes", + "toms": "8. TECHNISCHE UND ORGANISATORISCHE MASSNAHMEN" if de else "8. TECHNICAL AND ORGANISATIONAL MEASURES", + "review": "9. DOKUMENTENPRÜFUNG" if de else "9. DOCUMENT REVIEW", + "review_interval": "Prüfintervall" if de else "Review interval", + "last_reviewed": "Zuletzt geprüft" if de else "Last reviewed", + "approved": "Freigegeben durch" if de else "Approved by", + "notice": "HINWEIS" if de else "NOTICE", + "notice_text": f"Dieser Export dokumentiert konfigurierte Löschregeln. Version {APP_VERSION} führt keine automatische Löschung aus; Prüfläufe und manuell bestätigte Löschläufe sind für ausgewählte Kategorien verfügbar." if de else f"This export documents configured retention rules. Version {APP_VERSION} does not execute automatic deletion; checks and manually confirmed deletion runs are available for selected categories.", + } + lines = [ + labels["title"], + "=" * 64, + f"{labels['document_version']}: {activity.get('document_version') or '-'}", + f"{labels['activity']}: {activity.get('title') or '-'}", + f"{labels['controller']}: {activity.get('controller') or '-'}", + f"{labels['department']}: {activity.get('responsible_department') or '-'}", + f"{labels['people']}: {activity.get('responsible_people') or '-'}", + f"{labels['dpo']}: {activity.get('data_protection_contact') or '-'}", + "", + labels["purposes"], + ] + lines.extend(f"- {item}" for item in activity.get("purposes", [])) + lines.extend([ + "", labels["legal"], activity.get("legal_basis") or "-", + "", labels["subjects"], activity.get("data_subject_groups") or "-", + "", labels["recipients"], + f"{labels['internal']}: {activity.get('internal_recipients') or '-'}", + f"{labels['external']}: {activity.get('external_recipients') or '-'}", + "", labels["third"], + f"{labels['intended']}: {labels['yes'] if activity.get('third_country_transfer') else labels['no']}", + activity.get("third_country_details") or "-", + "", labels["systems"], + f"{labels['system_label']}: {activity.get('systems') or '-'}", + f"{labels['backup_system']}: {activity.get('backup_system') or '-'}", + f"{labels['backup_retention']}: {activity.get('backup_retention_days') if activity.get('backup_retention_days') is not None else '-'} Tage" if de else f"{labels['backup_retention']}: {activity.get('backup_retention_days') if activity.get('backup_retention_days') is not None else '-'} days", + f"{labels['restore']}: {activity.get('restore_deletion_procedure') or '-'}", + "", labels["categories"], + ]) + for number, category in enumerate(settings.get("categories", []), 1): + lines.extend([ + "", f"7.{number} {category.get('name')}", + f"{labels['system']}: {category.get('system') or '-'}", + f"{labels['data_subjects']}: {category.get('data_subjects') or '-'}", + f"{labels['personal_data']}: {category.get('personal_data') or '-'}", + f"{labels['purpose']}: {category.get('purpose') or '-'}", + f"{labels['retention_type']}: {category.get('retention_type') or '-'}", + f"{labels['retention_period']}: {category.get('retention_days') if category.get('retention_days') is not None else '-'} Tage" if de else f"{labels['retention_period']}: {category.get('retention_days') if category.get('retention_days') is not None else '-'} days", + f"{labels['trigger']}: {category.get('retention_trigger') or '-'}", + f"{labels['automatic_possible']}: {labels['yes'] if category.get('automatic_possible') else labels['no']}", + labels["automatic_active"], + f"{labels['manual']}: {category.get('manual_procedure') or '-'}", + f"{labels['backup']}: {category.get('backup_handling') or '-'}", + f"{labels['responsible']}: {category.get('responsible') or '-'}", + f"{labels['notes']}: {category.get('notes') or '-'}", + ]) + lines.extend(["", labels["toms"]]) + lines.extend(f"- {item}" for item in activity.get("toms", [])) + lines.extend([ + "", labels["review"], + f"{labels['review_interval']}: {activity.get('review_interval_months') or 12} Monate" if de else f"{labels['review_interval']}: {activity.get('review_interval_months') or 12} months", + f"{labels['last_reviewed']}: {activity.get('last_reviewed_at') or '-'}", + f"{labels['approved']}: {activity.get('approved_by') or '-'}", + "", labels["notice"], + labels["notice_text"], + ]) + return "\n".join(lines) + "\n" + + +@app.get("/settings/privacy/export.txt") +def settings_privacy_export_text(request: Request): + settings = localized_privacy_settings(load_config().get("privacy"), _request_language(request)) + append_privacy_audit("privacy_report_exported", _changed_by(request) or "authenticated-user", {"format": "text"}) + return Response( + _privacy_report_text(settings, _request_language(request)), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": "attachment; filename=assetmanager-privacy-processing-record.txt"}, + ) + + +@app.get("/settings/privacy/export.html") +def settings_privacy_export_html(request: Request): + language_code = _request_language(request) + settings = localized_privacy_settings(load_config().get("privacy"), language_code) + rendered = templates.env.get_template("privacy_report_export.html").render( + settings=settings, + activity=settings["processing_activity"], + privacy_audit=list(reversed(privacy_audit_tail(1000))), + language_code=language_code, + app_version=APP_VERSION, + generated_at=datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"), + ) + payload = rendered.encode("utf-8") + append_privacy_audit("privacy_report_exported", _changed_by(request) or "authenticated-user", {"format": "html"}) + return Response( + content=payload, + media_type="text/html; charset=utf-8", + headers={ + "Content-Disposition": "attachment; filename=assetmanager-privacy-processing-record.html", + "Content-Length": str(len(payload)), + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }, + ) + + +def _privacy_excel_value(value: Any) -> Any: + """Return a safe Excel cell value without control characters or oversized text.""" + if value is None: + return "" + if isinstance(value, (bool, int, float, date, datetime)): + return value + if isinstance(value, (dict, list)): + value = json.dumps(value, ensure_ascii=False, sort_keys=True) + text_value = str(value) + text_value = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", text_value) + return text_value[:32767] + + +def _style_privacy_workbook(workbook: Workbook) -> None: + header_fill = PatternFill("solid", fgColor="D9EAF7") + for sheet in workbook.worksheets: + sheet.freeze_panes = "A2" + sheet.auto_filter.ref = sheet.dimensions + for cell in sheet[1]: + cell.font = Font(bold=True) + cell.fill = header_fill + cell.alignment = Alignment(vertical="top", wrap_text=True) + for column in sheet.columns: + width = min(max((len(str(cell.value or "")) for cell in column), default=10) + 2, 60) + sheet.column_dimensions[column[0].column_letter].width = max(width, 12) + for cell in column: + cell.alignment = Alignment(vertical="top", wrap_text=True) + + +@app.get("/settings/privacy/export.xlsx") +def settings_privacy_export_excel(request: Request): + language_code = _request_language(request) + de = str(language_code or "").lower().startswith("de") + settings = localized_privacy_settings(load_config().get("privacy"), language_code) + activity = settings["processing_activity"] + workbook = Workbook() + + overview = workbook.active + overview.title = "Übersicht" if de else "Overview" + overview.append(["Feld" if de else "Field", "Wert" if de else "Value"]) + overview_fields = ( + (("Verarbeitungstätigkeit" if de else "Processing activity"), "title"), + (("Verantwortlicher" if de else "Controller"), "controller"), + (("Verantwortlicher Fachbereich" if de else "Responsible department"), "responsible_department"), + (("Verantwortliche Personen" if de else "Responsible people"), "responsible_people"), + (("Datenschutzkontakt" if de else "Data protection contact"), "data_protection_contact"), + (("Rechtsgrundlage" if de else "Legal basis"), "legal_basis"), + (("Betroffene Personengruppen" if de else "Data subject groups"), "data_subject_groups"), + (("Interne Empfänger" if de else "Internal recipients"), "internal_recipients"), + (("Externe Empfänger" if de else "External recipients"), "external_recipients"), + (("Drittlandübermittlung" if de else "Third-country transfer"), "third_country_transfer"), + (("Details zur Drittlandübermittlung" if de else "Third-country details"), "third_country_details"), + (("Systeme" if de else "Systems"), "systems"), + (("Backup-System" if de else "Backup system"), "backup_system"), + (("Backup-Aufbewahrungstage" if de else "Backup retention days"), "backup_retention_days"), + (("Löschverfahren nach Wiederherstellung" if de else "Restore deletion procedure"), "restore_deletion_procedure"), + (("Prüfintervall in Monaten" if de else "Review interval months"), "review_interval_months"), + (("Zuletzt geprüft" if de else "Last reviewed"), "last_reviewed_at"), + (("Freigegeben durch" if de else "Approved by"), "approved_by"), + (("Dokumentversion" if de else "Document version"), "document_version"), + (("AssetManager-Version" if de else "AssetManager version"), None), + ) + for label, key in overview_fields: + value = APP_VERSION if key is None else activity.get(key, "") + if isinstance(value, bool): + value = ("Ja" if value else "Nein") if de else ("Yes" if value else "No") + overview.append([_privacy_excel_value(label), _privacy_excel_value(value)]) + + purposes = workbook.create_sheet("Zwecke" if de else "Purposes") + purposes.append(["Nr." if de else "No.", "Zweck" if de else "Purpose"]) + for index, value in enumerate(activity.get("purposes", []), 1): + purposes.append([index, _privacy_excel_value(value)]) + + categories = workbook.create_sheet("Datenkategorien" if de else "Data categories") + category_headers = [ + "Name", "System", "Betroffene Personen" if de else "Data subjects", + "Personenbezogene Daten" if de else "Personal data", "Zweck" if de else "Purpose", + "Löschart" if de else "Retention type", "Löschfrist (Tage)" if de else "Retention days", + "Fristbeginn/Auslöser" if de else "Retention trigger", + "Automatisch technisch möglich" if de else "Automatic possible", + "Automatische Löschung aktiv" if de else "Automatic active", + "Manuelles Verfahren" if de else "Manual procedure", + "Behandlung in Sicherungen" if de else "Backup handling", + "Verantwortlich" if de else "Responsible", "Hinweise" if de else "Notes", + ] + categories.append(category_headers) + for item in settings.get("categories", []): + categories.append([_privacy_excel_value(value) for value in [ + item.get("name"), item.get("system"), item.get("data_subjects"), item.get("personal_data"), + item.get("purpose"), item.get("retention_type"), item.get("retention_days"), item.get("retention_trigger"), + ("Ja" if item.get("automatic_possible") else "Nein") if de else ("Yes" if item.get("automatic_possible") else "No"), + "Nein - manuell für ausgewählte Kategorien" if de else "No - manual for selected categories", item.get("manual_procedure"), + item.get("backup_handling"), item.get("responsible"), item.get("notes"), + ]]) + + toms = workbook.create_sheet("TOMs") + toms.append(["Nr." if de else "No.", "Technische und organisatorische Maßnahme" if de else "Technical and organisational measure"]) + for index, value in enumerate(activity.get("toms", []), 1): + toms.append([index, _privacy_excel_value(value)]) + + audit = workbook.create_sheet("Änderungsprotokoll" if de else "Policy audit") + audit.append([ + "Zeitstempel UTC" if de else "Timestamp UTC", "Aktion" if de else "Action", + "Geändert von" if de else "Changed by", "Details", + ]) + for row in reversed(privacy_audit_tail(1000)): + audit.append([_privacy_excel_value(value) for value in [ + row.get("timestamp_utc"), row.get("action"), row.get("changed_by"), row.get("details", {}), + ]]) + + _style_privacy_workbook(workbook) + output = io.BytesIO() + workbook.save(output) + payload = output.getvalue() + + # Verify the generated archive before sending it to the browser. + try: + load_workbook(io.BytesIO(payload), read_only=True, data_only=True).close() + except Exception as exc: + logging.exception("Privacy XLSX validation failed") + raise HTTPException(500, f"Privacy Excel export could not be validated: {exc}") from exc + + append_privacy_audit("privacy_report_exported", _changed_by(request) or "authenticated-user", {"format": "xlsx"}) + filename = "assetmanager-datenschutz-verarbeitungstaetigkeit.xlsx" if de else "assetmanager-privacy-processing-record.xlsx" + disposition = f"attachment; filename={filename}; filename*=UTF-8''{quote(filename)}" + return Response( + content=payload, + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={ + "Content-Disposition": disposition, + "Content-Length": str(len(payload)), + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + }, + ) + + @app.get("/settings/software") def settings_software_page(request: Request): _require_admin(request) diff --git a/app/privacy.py b/app/privacy.py new file mode 100644 index 0000000..4b5a85a --- /dev/null +++ b/app/privacy.py @@ -0,0 +1,546 @@ +from __future__ import annotations + +from copy import deepcopy +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +import json +import os + +PRIVACY_AUDIT_LOG = Path(os.getenv("PRIVACY_AUDIT_LOG", "/app/data/logs/privacy-policy-audit.log")) + +DEFAULT_PURPOSES = [ + "Inventory of company computers and servers", + "Technical support and troubleshooting", + "Remote access to desktop, terminal and file system", + "Installation and maintenance of software", + "Execution of administrative commands and scripts", + "Security and patch management", + "Traceability of administrative interventions", + "Device management through an asset management system", +] + +DEFAULT_TOMS = [ + "Self-hosted deployment as Docker containers", + "Application is restricted to the internal network", + "Encrypted connections between clients and servers", + "Access restricted to a small group of administrators and IT support staff", + "Role-based access control and LDAP/local authentication", + "Protected local emergency administrator for LDAP outages", + "Database and host access restricted to authorized administrators", + "Regular backups with controlled retention", + "Logging of administrative actions and deletion-policy changes", + "Regular patching and update procedures", +] + +DEFAULT_CATEGORIES: list[dict[str, Any]] = [ + { + "key": "am_user_accounts", + "name": "AssetManager user accounts", + "system": "AssetManager / LDAP-AD", + "data_subjects": "Administrators and IT support staff", + "personal_data": "Name, username, email address, optional AD image, permissions, timestamps, authentication source and local password hash", + "purpose": "Authentication, authorization and traceability of administrative activity", + "retention_type": "manual_parent_delete", + "retention_days": None, + "retention_trigger": "Account deletion after authorization and operational retention checks", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "Disable the account first; delete it manually when it is no longer needed. Protected emergency administrators cannot be deleted.", + "backup_handling": "Removed through the regular backup rotation; repeat deletion after a restore.", + "responsible": "IT", + "notes": "LDAP passwords are not stored. Local passwords are stored only as password hashes.", + }, + { + "key": "am_asset_identity", + "name": "Asset and device master data", + "system": "AssetManager", + "data_subjects": "Employees whose devices are managed", + "personal_data": "Computer name, IP address, MAC address, assigned user, department, location, currently logged-on user and device identifiers", + "purpose": "Inventory, support, security and device management", + "retention_type": "overwrite_parent_delete", + "retention_days": None, + "retention_trigger": "Values are overwritten by later inventories; final deletion with the asset", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "Delete or anonymize the asset after it is decommissioned and no longer needed for operational or evidentiary purposes.", + "backup_handling": "Removed through backup rotation; repeat deletion after a restore.", + "responsible": "IT", + "notes": "Current values may be updated or overwritten during synchronization.", + }, + { + "key": "am_software_inventory", + "name": "Software inventory", + "system": "AssetManager", + "data_subjects": "Employees whose devices are managed", + "personal_data": "Software names, versions, publishers, installation information and linked asset/device identity", + "purpose": "Software inventory, maintenance, security and future licence management", + "retention_type": "overwrite_parent_delete", + "retention_days": None, + "retention_trigger": "Replaced by the next successful inventory; final deletion with the asset", + "automatic_possible": True, + "automatic_enabled": False, + "manual_procedure": "Delete the asset or run a new inventory. Excluded software is not stored.", + "backup_handling": "Removed through backup rotation.", + "responsible": "IT", + "notes": "The current implementation replaces the inventory of an asset during a successful import.", + }, + { + "key": "am_job_metadata", + "name": "Job metadata and administrative activity", + "system": "AssetManager", + "data_subjects": "Administrators, IT support staff and employees whose devices are managed", + "personal_data": "Job ID, asset, job type, status, timestamps, initiating user and execution history", + "purpose": "Execution control, troubleshooting and traceability of administrative interventions", + "retention_type": "days", + "retention_days": 365, + "retention_trigger": "Job completion", + "automatic_possible": True, + "automatic_enabled": False, + "manual_procedure": "Delete old job history through an approved deletion run after checking audit requirements.", + "backup_handling": "Removed through backup rotation.", + "responsible": "IT", + "notes": "Suggested value; must be approved by the data protection officer.", + }, + { + "key": "am_job_payloads", + "name": "Job scripts, output, callback payloads and diagnostics", + "system": "AssetManager", + "data_subjects": "Administrators, IT support staff and employees whose devices are managed", + "personal_data": "Scripts, commands, standard output, error output, callback data and technical diagnostics which may contain usernames or device information", + "purpose": "Troubleshooting and verification of job execution", + "retention_type": "days", + "retention_days": 90, + "retention_trigger": "Job completion or final failure", + "automatic_possible": True, + "automatic_enabled": False, + "manual_procedure": "Remove contents after the troubleshooting period while retaining only necessary job metadata.", + "backup_handling": "Removed through backup rotation.", + "responsible": "IT", + "notes": "Callback debug logging should remain disabled unless required for troubleshooting.", + }, + { + "key": "am_diagnostic_logs", + "name": "Application, LDAP and diagnostic logs", + "system": "AssetManager", + "data_subjects": "Administrators, IT support staff and employees whose devices are managed", + "personal_data": "Usernames, timestamps, device names, IP addresses, authentication results and error details", + "purpose": "Security monitoring and troubleshooting", + "retention_type": "days", + "retention_days": 30, + "retention_trigger": "Log entry creation", + "automatic_possible": True, + "automatic_enabled": False, + "manual_procedure": "Delete or rotate logs after the retention period; use detailed LDAP debug only temporarily.", + "backup_handling": "Logs in backups expire through backup rotation.", + "responsible": "IT", + "notes": "Detailed LDAP debug logs should have a shorter operational lifetime, for example 7–14 days.", + }, + { + "key": "am_deletion_logs", + "name": "Deletion run and policy audit log", + "system": "AssetManager", + "data_subjects": "Administrators and IT support staff", + "personal_data": "Executing administrator, timestamps, rule identifier, counts and result; no deleted content", + "purpose": "Proof and traceability of deletion processes", + "retention_type": "days", + "retention_days": 730, + "retention_trigger": "Policy change or deletion run", + "automatic_possible": True, + "automatic_enabled": False, + "manual_procedure": "Remove old audit entries only after the evidence period and DPO approval.", + "backup_handling": "Removed through backup rotation.", + "responsible": "IT / Data protection", + "notes": "The log must not reproduce the deleted personal data.", + }, + { + "key": "mc_accounts", + "name": "LDAP/AD user accounts and profile information", + "system": "MeshCentral", + "data_subjects": "Administrators and IT support staff", + "personal_data": "Username, display name and optional email address", + "purpose": "Authentication and administration of remote support", + "retention_type": "external_manual", + "retention_days": None, + "retention_trigger": "Manual deletion of the MeshCentral account", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "Delete the account manually in MeshCentral. Profile information is deleted with the account.", + "backup_handling": "Subject to MeshCentral and backup retention.", + "responsible": "IT", + "notes": "No automatic deletion in the standard configuration.", + }, + { + "key": "mc_device_current", + "name": "Current MeshCentral device, network and logged-on-user data", + "system": "MeshCentral", + "data_subjects": "Employees whose devices are managed", + "personal_data": "Computer name, current IP and MAC address, current logged-on user and device identity", + "purpose": "Remote support, inventory and device management", + "retention_type": "external_overwrite_parent_delete", + "retention_days": None, + "retention_trigger": "Values are updated/overwritten; final deletion with the device", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "Delete the device manually in MeshCentral when it is no longer managed.", + "backup_handling": "Subject to MeshCentral and backup retention.", + "responsible": "IT", + "notes": "No fixed retention period for the current value.", + }, + { + "key": "mc_events_10_20", + "name": "Historical IP, online and device events", + "system": "MeshCentral", + "data_subjects": "Employees whose devices are managed and support staff", + "personal_data": "Historical online status, IP addresses, device events and timestamps", + "purpose": "Remote support, troubleshooting and traceability", + "retention_type": "external_days", + "retention_days": 20, + "retention_trigger": "Event creation", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "Configure the relevant MeshCentral event retention. Individual event types may use 10 or 20 days.", + "backup_handling": "Subject to MeshCentral database backup rotation.", + "responsible": "IT", + "notes": "The exact period depends on the MeshCentral event type.", + }, + { + "key": "mc_support_sessions", + "name": "Support connection timestamps and duration", + "system": "MeshCentral", + "data_subjects": "Employees whose devices are supported and support staff", + "personal_data": "Connection start, end, duration, user/device reference and support activity", + "purpose": "Remote support and traceability", + "retention_type": "external_days", + "retention_days": 20, + "retention_trigger": "Connection event creation", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "Configure MeshCentral event retention and verify it regularly.", + "backup_handling": "Subject to MeshCentral database backup rotation.", + "responsible": "IT", + "notes": "Treated as general events in the current concept.", + }, + { + "key": "mc_live_content", + "name": "Screen, clipboard and transferred session content", + "system": "MeshCentral / endpoints", + "data_subjects": "Employees whose devices are supported", + "personal_data": "Screen content, clipboard content and transferred files", + "purpose": "Live remote support and file transfer", + "retention_type": "not_stored", + "retention_days": None, + "retention_trigger": "Not applicable", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "No server-side deletion is required when content is only transmitted live. Files remain on source or target devices and must be handled there.", + "backup_handling": "Not applicable to MeshCentral if no copy is stored.", + "responsible": "IT / endpoint owner", + "notes": "Standard configuration does not permanently store these contents on the server.", + }, + { + "key": "mc_recordings", + "name": "Screenshots and session recordings", + "system": "MeshCentral / support workstation", + "data_subjects": "Employees whose devices are supported and support staff", + "personal_data": "Screenshots, screen recordings, usernames, device identity and session timestamps", + "purpose": "Support documentation, if separately authorized", + "retention_type": "external_manual", + "retention_days": None, + "retention_trigger": "Manual creation or configured recording retention", + "automatic_possible": False, + "automatic_enabled": False, + "manual_procedure": "Local screenshots and recordings must be deleted by the support employee. Server-side recordings are disabled by default; if enabled, configure maxRecordingDays.", + "backup_handling": "Depends on local workstation and MeshCentral backup configuration.", + "responsible": "IT / support employee", + "notes": "Without maxRecordingDays, server-side recordings have no automatic retention limit.", + }, +] + +PROTECTED_PRIVACY_CATEGORY_KEYS = frozenset(str(item.get("key")) for item in DEFAULT_CATEGORIES) + + +DEFAULT_PRIVACY_SETTINGS: dict[str, Any] = { + "processing_activity": { + "title": "Remote support, software inventory and asset management", + "responsible_department": "IT", + "responsible_people": "", + "controller": "", + "data_protection_contact": "", + "legal_basis": "To be reviewed and approved by the data protection officer", + "data_subject_groups": "Employees whose computers are managed; administrators and IT support staff", + "internal_recipients": "Authorized IT administrators and IT support staff", + "external_recipients": "None", + "third_country_transfer": False, + "third_country_details": "No transfer intended", + "systems": "AssetManager, MeshCentral, Active Directory/LDAP, managed endpoints and backup system", + "backup_system": "", + "backup_retention_days": None, + "restore_deletion_procedure": "After a restore, deletion runs must be repeated for data deleted since the backup was created.", + "review_interval_months": 12, + "last_reviewed_at": "", + "approved_by": "", + "document_version": "1.0", + "purposes": deepcopy(DEFAULT_PURPOSES), + "toms": deepcopy(DEFAULT_TOMS), + }, + "categories": deepcopy(DEFAULT_CATEGORIES), + "phase": 2, + "automatic_deletion_available": False, +} + + +def merge_privacy_settings(raw: Any) -> dict[str, Any]: + result = deepcopy(DEFAULT_PRIVACY_SETTINGS) + if not isinstance(raw, dict): + return result + activity = raw.get("processing_activity") + if isinstance(activity, dict): + result["processing_activity"].update(activity) + categories = raw.get("categories") + if isinstance(categories, list) and categories: + result["categories"] = [normalize_category(item, index) for index, item in enumerate(categories) if isinstance(item, dict)] + present_keys = {str(item.get("key") or "") for item in result["categories"]} + for default_category in DEFAULT_CATEGORIES: + if str(default_category.get("key") or "") not in present_keys: + result["categories"].append(normalize_category(default_category, len(result["categories"]))) + result["phase"] = 2 + result["automatic_deletion_available"] = False + return result + + +def normalize_category(item: dict[str, Any], index: int = 0) -> dict[str, Any]: + days = item.get("retention_days") + try: + days = int(days) if days not in (None, "") else None + except (TypeError, ValueError): + days = None + return { + "key": str(item.get("key") or f"custom_{index + 1}")[:100], + "name": str(item.get("name") or "")[:250], + "system": str(item.get("system") or "")[:200], + "data_subjects": str(item.get("data_subjects") or "")[:1000], + "personal_data": str(item.get("personal_data") or "")[:4000], + "purpose": str(item.get("purpose") or "")[:4000], + "retention_type": str(item.get("retention_type") or "manual")[:80], + "retention_days": max(0, days) if days is not None else None, + "retention_trigger": str(item.get("retention_trigger") or "")[:1000], + "automatic_possible": bool(item.get("automatic_possible")), + "automatic_enabled": False, + "manual_procedure": str(item.get("manual_procedure") or "")[:4000], + "backup_handling": str(item.get("backup_handling") or "")[:2000], + "responsible": str(item.get("responsible") or "")[:500], + "notes": str(item.get("notes") or "")[:4000], + } + + +def append_privacy_audit(action: str, changed_by: str, details: dict[str, Any]) -> None: + PRIVACY_AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True) + record = { + "timestamp_utc": datetime.now(timezone.utc).isoformat(), + "action": action, + "changed_by": changed_by or "unknown", + "details": details, + } + with PRIVACY_AUDIT_LOG.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n") + + +def privacy_audit_tail(limit: int = 100) -> list[dict[str, Any]]: + if not PRIVACY_AUDIT_LOG.exists(): + return [] + try: + lines = PRIVACY_AUDIT_LOG.read_text(encoding="utf-8", errors="replace").splitlines()[-max(1, limit):] + except OSError: + return [] + result: list[dict[str, Any]] = [] + for line in reversed(lines): + try: + result.append(json.loads(line)) + except json.JSONDecodeError: + continue + return result + +_GERMAN_ACTIVITY = { + "title": "Fernwartung, Softwareinventarisierung und Assetmanagement", + "responsible_department": "IT", + "legal_basis": "Vom Datenschutzbeauftragten zu prüfen und freizugeben", + "data_subject_groups": "Beschäftigte, deren Computer verwaltet werden; Administratoren und IT-Support-Mitarbeitende", + "internal_recipients": "Berechtigte IT-Administratoren und IT-Support-Mitarbeitende", + "external_recipients": "Keine", + "third_country_details": "Keine Übermittlung vorgesehen", + "systems": "AssetManager, MeshCentral, Active Directory/LDAP, verwaltete Endgeräte und Backup-System", + "restore_deletion_procedure": "Nach einer Wiederherstellung werden Löschläufe für Daten wiederholt, die seit Erstellung der Sicherung gelöscht wurden.", + "purposes": [ + "Inventarisierung betrieblicher Computer und Server", + "Technischer Support und Fehlerbehebung", + "Fernzugriff auf Desktop, Terminal und Dateisystem", + "Installation und Wartung von Software", + "Ausführung administrativer Befehle und Skripte", + "Sicherheits- und Patchmanagement", + "Nachvollziehbarkeit administrativer Eingriffe", + "Geräteverwaltung über ein Assetmanagement-System", + ], + "toms": [ + "Selbst gehosteter Betrieb als Docker-Container", + "Anwendung ist auf das interne Netzwerk beschränkt", + "Verschlüsselte Verbindungen zwischen Clients und Servern", + "Zugriff nur für einen kleinen Kreis von Administratoren und IT-Support-Mitarbeitenden", + "Rollenbasierte Zugriffskontrolle und LDAP-/lokale Authentifizierung", + "Geschützter lokaler Notfall-Administrator bei LDAP-Ausfällen", + "Datenbank- und Hostzugriff nur für berechtigte Administratoren", + "Regelmäßige Sicherungen mit kontrollierter Aufbewahrung", + "Protokollierung administrativer Aktionen und Änderungen des Löschkonzepts", + "Regelmäßige Patch- und Updateverfahren", + ], +} + +_GERMAN_CATEGORIES = { + "am_user_accounts": { + "name": "AssetManager-Benutzerkonten", + "data_subjects": "Administratoren und IT-Support-Mitarbeitende", + "personal_data": "Name, Benutzername, E-Mail-Adresse, optionales AD-Bild, Berechtigungen, Zeitstempel, Authentifizierungsquelle und lokaler Passwort-Hash", + "purpose": "Authentifizierung, Autorisierung und Nachvollziehbarkeit administrativer Tätigkeiten", + "retention_trigger": "Kontolöschung nach Freigabe und Prüfung betrieblicher Nachweispflichten", + "manual_procedure": "Konto zunächst deaktivieren und manuell löschen, sobald es nicht mehr benötigt wird. Geschützte Notfall-Administratoren können nicht gelöscht werden.", + "backup_handling": "Entfernung durch reguläre Backuprotation; nach Wiederherstellung Löschung erneut ausführen.", + "notes": "LDAP-Passwörter werden nicht gespeichert. Lokale Passwörter werden nur als Passwort-Hash gespeichert.", + }, + "am_asset_identity": { + "name": "Asset- und Geräte-Stammdaten", + "data_subjects": "Beschäftigte, deren Geräte verwaltet werden", + "personal_data": "Computername, IP-Adresse, MAC-Adresse, zugeordneter Benutzer, Abteilung, Standort, aktuell angemeldeter Benutzer und Gerätekennungen", + "purpose": "Inventarisierung, Support, Sicherheit und Geräteverwaltung", + "retention_trigger": "Werte werden durch spätere Inventuren überschrieben; endgültige Löschung mit dem Asset", + "manual_procedure": "Asset nach Außerbetriebnahme und Ablauf betrieblicher oder nachweisbezogener Erfordernisse löschen oder anonymisieren.", + "backup_handling": "Entfernung durch Backuprotation; nach Wiederherstellung Löschung erneut ausführen.", + "notes": "Aktuelle Werte können bei Synchronisierungen aktualisiert oder überschrieben werden.", + }, + "am_software_inventory": { + "name": "Softwareinventur", + "data_subjects": "Beschäftigte, deren Geräte verwaltet werden", + "personal_data": "Softwarename, Version, Hersteller, Installationsinformationen und Verknüpfung zur Asset-/Geräteidentität", + "purpose": "Softwareinventarisierung, Wartung, Sicherheit und spätere Lizenzverwaltung", + "retention_trigger": "Ersetzung durch die nächste erfolgreiche Inventur; endgültige Löschung mit dem Asset", + "manual_procedure": "Asset löschen oder neue Inventur durchführen. Ausgeschlossene Software wird nicht gespeichert.", + "backup_handling": "Entfernung durch Backuprotation.", + "notes": "Die aktuelle Implementierung ersetzt die Inventur eines Assets beim erfolgreichen Import.", + }, + "am_job_metadata": { + "name": "Job-Metadaten und administrative Aktivitäten", + "data_subjects": "Administratoren, IT-Support-Mitarbeitende und Beschäftigte, deren Geräte verwaltet werden", + "personal_data": "Job-ID, Asset, Jobart, Status, Zeitstempel, auslösender Benutzer und Ausführungshistorie", + "purpose": "Ausführungssteuerung, Fehleranalyse und Nachvollziehbarkeit administrativer Eingriffe", + "retention_trigger": "Abschluss des Jobs", + "manual_procedure": "Alte Jobhistorie nach Prüfung der Nachweisanforderungen über einen freigegebenen Löschlauf löschen.", + "backup_handling": "Entfernung durch Backuprotation.", + "notes": "Vorschlagswert; durch den Datenschutzbeauftragten freizugeben.", + }, + "am_job_payloads": { + "name": "Jobskripte, Ausgaben, Callback-Daten und Diagnosen", + "data_subjects": "Administratoren, IT-Support-Mitarbeitende und Beschäftigte, deren Geräte verwaltet werden", + "personal_data": "Skripte, Befehle, Standardausgabe, Fehlerausgabe, Callback-Daten und technische Diagnosen mit möglichen Benutzernamen oder Geräteinformationen", + "purpose": "Fehleranalyse und Nachweis der Jobausführung", + "retention_trigger": "Abschluss oder endgültiger Fehler des Jobs", + "manual_procedure": "Inhalte nach Ablauf der Fehleranalysefrist entfernen und nur erforderliche Job-Metadaten behalten.", + "backup_handling": "Entfernung durch Backuprotation.", + "notes": "Callback-Debugging soll nur bei konkretem Analysebedarf aktiviert werden.", + }, + "am_diagnostic_logs": { + "name": "Anwendungs-, LDAP- und Diagnoseprotokolle", + "data_subjects": "Administratoren, IT-Support-Mitarbeitende und Beschäftigte, deren Geräte verwaltet werden", + "personal_data": "Benutzernamen, Zeitstempel, Gerätenamen, IP-Adressen, Anmeldeergebnisse und Fehlerdetails", + "purpose": "Sicherheitsüberwachung und Fehleranalyse", + "retention_trigger": "Erstellung des Protokolleintrags", + "manual_procedure": "Protokolle nach Ablauf der Frist löschen oder rotieren; ausführliches LDAP-Debugging nur vorübergehend verwenden.", + "backup_handling": "Protokolle in Sicherungen verfallen durch Backuprotation.", + "notes": "Ausführliche LDAP-Debugprotokolle sollten nur 7–14 Tage aufbewahrt werden.", + }, + "am_deletion_logs": { + "name": "Löschlauf- und Richtlinienprotokoll", + "data_subjects": "Administratoren und IT-Support-Mitarbeitende", + "personal_data": "Ausführender Administrator, Zeitstempel, Regelkennung, Anzahlen und Ergebnis; keine gelöschten Inhalte", + "purpose": "Nachweis und Nachvollziehbarkeit von Löschprozessen", + "retention_trigger": "Änderung einer Regel oder Durchführung eines Löschlaufs", + "manual_procedure": "Alte Nachweise erst nach Ablauf der Nachweisfrist und Freigabe durch den Datenschutzbeauftragten entfernen.", + "backup_handling": "Entfernung durch Backuprotation.", + "notes": "Das Protokoll darf die gelöschten personenbezogenen Inhalte nicht erneut speichern.", + }, + "mc_accounts": { + "name": "LDAP-/AD-Benutzerkonten und Profilinformationen", + "data_subjects": "Administratoren und IT-Support-Mitarbeitende", + "personal_data": "Benutzername, Anzeigename und optionale E-Mail-Adresse", + "purpose": "Authentifizierung und Verwaltung der Fernunterstützung", + "retention_trigger": "Manuelle Löschung des MeshCentral-Kontos", + "manual_procedure": "Konto manuell in MeshCentral löschen. Profilinformationen werden mit dem Konto gelöscht.", + "backup_handling": "Unterliegt der MeshCentral- und Backup-Aufbewahrung.", + "notes": "Keine automatische Löschung in der Standardkonfiguration.", + }, + "mc_device_current": { + "name": "Aktuelle MeshCentral-Geräte-, Netzwerk- und Benutzerinformationen", + "data_subjects": "Beschäftigte, deren Geräte verwaltet werden", + "personal_data": "Computername, aktuelle IP- und MAC-Adresse, aktuell angemeldeter Benutzer und Geräteidentität", + "purpose": "Fernsupport, Inventarisierung und Geräteverwaltung", + "retention_trigger": "Werte werden aktualisiert/überschrieben; endgültige Löschung mit dem Gerät", + "manual_procedure": "Gerät manuell in MeshCentral löschen, wenn es nicht mehr verwaltet wird.", + "backup_handling": "Unterliegt der MeshCentral- und Backup-Aufbewahrung.", + "notes": "Keine feste Löschfrist für den aktuellen Wert.", + }, + "mc_events_10_20": { + "name": "Historische IP-, Online- und Geräteereignisse", + "data_subjects": "Beschäftigte, deren Geräte verwaltet werden, und Support-Mitarbeitende", + "personal_data": "Historischer Onlinestatus, IP-Adressen, Geräteereignisse und Zeitstempel", + "purpose": "Fernsupport, Fehleranalyse und Nachvollziehbarkeit", + "retention_trigger": "Erstellung des Ereignisses", + "manual_procedure": "Passende MeshCentral-Ereignisaufbewahrung konfigurieren. Einzelne Ereignistypen verwenden 10 oder 20 Tage.", + "backup_handling": "Unterliegt der Rotation der MeshCentral-Datenbanksicherungen.", + "notes": "Die genaue Frist hängt vom MeshCentral-Ereignistyp ab.", + }, + "mc_support_sessions": { + "name": "Zeitpunkt und Dauer von Supportverbindungen", + "data_subjects": "Beschäftigte, deren Geräte unterstützt werden, und Support-Mitarbeitende", + "personal_data": "Verbindungsbeginn, Ende, Dauer, Benutzer-/Gerätebezug und Supportaktivität", + "purpose": "Fernsupport und Nachvollziehbarkeit", + "retention_trigger": "Erstellung des Verbindungsereignisses", + "manual_procedure": "MeshCentral-Ereignisaufbewahrung konfigurieren und regelmäßig prüfen.", + "backup_handling": "Unterliegt der Rotation der MeshCentral-Datenbanksicherungen.", + "notes": "Im aktuellen Konzept als allgemeine Ereignisse behandelt.", + }, + "mc_live_content": { + "name": "Bildschirm-, Zwischenablage- und Dateiübertragungsinhalte", + "data_subjects": "Beschäftigte, deren Geräte unterstützt werden", + "personal_data": "Bildschirminhalte, Zwischenablageinhalte und übertragene Dateien", + "purpose": "Live-Fernsupport und Dateiübertragung", + "retention_trigger": "Nicht anwendbar", + "manual_procedure": "Bei reiner Live-Übertragung ist keine serverseitige Löschung erforderlich. Dateien verbleiben auf Quell- oder Zielgeräten und müssen dort behandelt werden.", + "backup_handling": "Nicht auf MeshCentral anwendbar, wenn keine Kopie gespeichert wird.", + "responsible": "IT / Eigentümer des Endgeräts", + "notes": "Die Standardkonfiguration speichert diese Inhalte nicht dauerhaft auf dem Server.", + }, + "mc_recordings": { + "name": "Screenshots und Sitzungsaufzeichnungen", + "data_subjects": "Beschäftigte, deren Geräte unterstützt werden, und Support-Mitarbeitende", + "personal_data": "Screenshots, Bildschirmaufzeichnungen, Benutzernamen, Geräteidentität und Sitzungszeitstempel", + "purpose": "Supportdokumentation, sofern gesondert autorisiert", + "retention_trigger": "Manuelle Erstellung oder konfigurierte Aufbewahrung für Aufzeichnungen", + "manual_procedure": "Lokale Screenshots und Aufzeichnungen durch Support-Mitarbeitende löschen. Serverseitige Aufzeichnungen sind standardmäßig deaktiviert; bei Aktivierung maxRecordingDays konfigurieren.", + "backup_handling": "Abhängig von lokaler Arbeitsstation und MeshCentral-Backupkonfiguration.", + "responsible": "IT / Support-Mitarbeitende", + "notes": "Ohne maxRecordingDays besteht für serverseitige Aufzeichnungen keine automatische Löschfrist.", + }, +} + + +def localized_privacy_settings(raw: Any, language_code: str = "en") -> dict[str, Any]: + settings = merge_privacy_settings(raw) + for category in settings["categories"]: + category["is_system"] = category.get("key") in PROTECTED_PRIVACY_CATEGORY_KEYS + if not str(language_code or "").lower().startswith("de") or (isinstance(raw, dict) and raw): + return settings + settings["processing_activity"].update(deepcopy(_GERMAN_ACTIVITY)) + for category in settings["categories"]: + override = _GERMAN_CATEGORIES.get(category.get("key")) + if override: + category.update(deepcopy(override)) + category["is_system"] = category.get("key") in PROTECTED_PRIVACY_CATEGORY_KEYS + return settings diff --git a/app/privacy_retention.py b/app/privacy_retention.py new file mode 100644 index 0000000..7d03577 --- /dev/null +++ b/app/privacy_retention.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any +import json +import os +import uuid + +from sqlalchemy import cast, func, or_, Text + +from .database import SessionLocal +from .models import SoftwareJob + +PRIVACY_DELETION_LOG = Path(os.getenv("PRIVACY_DELETION_LOG", "/app/data/logs/privacy-deletion-audit.log")) +APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs")) + +IMPLEMENTED_RETENTION_KEYS = {"am_job_payloads", "am_diagnostic_logs"} +PROTECTED_LOG_FILES = { + "privacy-policy-audit.log", + "privacy-deletion-audit.log", +} +ACTIVE_LOG_FILES = {"errors.log", "ldap.log", "software-callback-debug.log"} +TERMINAL_JOB_STATUSES = {"success", "failed", "partial", "timeout", "cancelled"} + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def append_deletion_audit( + action: str, + changed_by: str, + *, + category_key: str, + retention_days: int | None, + result: dict[str, Any], + run_id: str | None = None, +) -> str: + run_id = run_id or uuid.uuid4().hex + PRIVACY_DELETION_LOG.parent.mkdir(parents=True, exist_ok=True) + record = { + "timestamp_utc": _utcnow().isoformat(), + "run_id": run_id, + "action": action, + "changed_by": changed_by or "unknown", + "category_key": category_key, + "retention_days": retention_days, + "result": result, + } + with PRIVACY_DELETION_LOG.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(record, ensure_ascii=False, sort_keys=True) + "\n") + return run_id + + +def deletion_audit_tail(limit: int = 100) -> list[dict[str, Any]]: + if not PRIVACY_DELETION_LOG.exists(): + return [] + try: + lines = PRIVACY_DELETION_LOG.read_text(encoding="utf-8", errors="replace").splitlines()[-max(1, limit):] + except OSError: + return [] + rows: list[dict[str, Any]] = [] + for line in reversed(lines): + try: + rows.append(json.loads(line)) + except json.JSONDecodeError: + continue + return rows + + +def _job_cutoff_expression(): + return func.coalesce(SoftwareJob.finished_at, SoftwareJob.callback_completed_at, SoftwareJob.created_at) + + +def _job_payload_query(db, cutoff: datetime): + content_present = or_( + SoftwareJob.command_preview.isnot(None), + SoftwareJob.resolved_script.isnot(None), + SoftwareJob.meshctrl_stdout.isnot(None), + SoftwareJob.meshctrl_stderr.isnot(None), + SoftwareJob.message.isnot(None), + SoftwareJob.log_text != "", + cast(SoftwareJob.result_data, Text) != "{}", + ) + return db.query(SoftwareJob).filter( + SoftwareJob.status.in_(TERMINAL_JOB_STATUSES), + _job_cutoff_expression() < cutoff, + content_present, + ) + + +def _check_job_payloads(retention_days: int) -> dict[str, Any]: + cutoff = datetime.utcnow() - timedelta(days=retention_days) + db = SessionLocal() + try: + query = _job_payload_query(db, cutoff) + count = query.count() + oldest = query.with_entities(func.min(_job_cutoff_expression())).scalar() + # Approximate payload size without transferring the payloads to Python. + byte_expression = ( + func.length(func.coalesce(SoftwareJob.command_preview, "")) + + func.length(func.coalesce(SoftwareJob.resolved_script, "")) + + func.length(func.coalesce(SoftwareJob.meshctrl_stdout, "")) + + func.length(func.coalesce(SoftwareJob.meshctrl_stderr, "")) + + func.length(func.coalesce(SoftwareJob.message, "")) + + func.length(func.coalesce(SoftwareJob.log_text, "")) + + func.length(func.coalesce(cast(SoftwareJob.result_data, Text), "")) + ) + approx_bytes = query.with_entities(func.coalesce(func.sum(byte_expression), 0)).scalar() or 0 + return { + "supported": True, + "category_key": "am_job_payloads", + "cutoff_utc": cutoff.isoformat(), + "matched_records": int(count), + "approx_bytes": int(approx_bytes), + "oldest_record_utc": oldest.isoformat() if oldest else None, + "operation": "clear_job_payloads", + } + finally: + db.close() + + +def _delete_job_payloads(retention_days: int) -> dict[str, Any]: + preview = _check_job_payloads(retention_days) + cutoff = datetime.utcnow() - timedelta(days=retention_days) + db = SessionLocal() + try: + updated = _job_payload_query(db, cutoff).update( + { + SoftwareJob.command_preview: None, + SoftwareJob.resolved_script: None, + SoftwareJob.meshctrl_stdout: None, + SoftwareJob.meshctrl_stderr: None, + SoftwareJob.message: None, + SoftwareJob.log_text: "", + SoftwareJob.result_data: {}, + }, + synchronize_session=False, + ) + db.commit() + preview["deleted_or_anonymised_records"] = int(updated) + preview["status"] = "success" + return preview + except Exception: + db.rollback() + raise + finally: + db.close() + + +def _eligible_log_files(cutoff: datetime) -> list[Path]: + if not APP_LOG_DIR.exists(): + return [] + result: list[Path] = [] + cutoff_timestamp = cutoff.timestamp() + for path in APP_LOG_DIR.rglob("*"): + try: + if not path.is_file() or path.name in PROTECTED_LOG_FILES: + continue + if path.stat().st_mtime < cutoff_timestamp: + result.append(path) + except OSError: + continue + return result + + +def _check_diagnostic_logs(retention_days: int) -> dict[str, Any]: + cutoff = _utcnow() - timedelta(days=retention_days) + files = _eligible_log_files(cutoff) + total_bytes = 0 + oldest: datetime | None = None + for path in files: + try: + stat = path.stat() + except OSError: + continue + total_bytes += stat.st_size + modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + oldest = modified if oldest is None or modified < oldest else oldest + return { + "supported": True, + "category_key": "am_diagnostic_logs", + "cutoff_utc": cutoff.isoformat(), + "matched_records": len(files), + "approx_bytes": int(total_bytes), + "oldest_record_utc": oldest.isoformat() if oldest else None, + "operation": "delete_log_files", + } + + +def _delete_diagnostic_logs(retention_days: int) -> dict[str, Any]: + cutoff = _utcnow() - timedelta(days=retention_days) + files = _eligible_log_files(cutoff) + deleted = 0 + failed = 0 + freed_bytes = 0 + for path in files: + try: + size = path.stat().st_size + if path.name in ACTIVE_LOG_FILES: + # Keep the inode used by the active FileHandler and clear only its content. + path.write_text("", encoding="utf-8") + else: + path.unlink() + deleted += 1 + freed_bytes += size + except OSError: + failed += 1 + return { + "supported": True, + "category_key": "am_diagnostic_logs", + "cutoff_utc": cutoff.isoformat(), + "matched_records": len(files), + "deleted_or_anonymised_records": deleted, + "failed_records": failed, + "approx_bytes": int(freed_bytes), + "operation": "delete_log_files", + "status": "success" if failed == 0 else "partial", + } + + +def check_retention_category(category_key: str, retention_days: int | None) -> dict[str, Any]: + if category_key not in IMPLEMENTED_RETENTION_KEYS: + return { + "supported": False, + "category_key": category_key, + "matched_records": 0, + "reason": "No executable retention handler is implemented for this category.", + } + if retention_days is None or retention_days < 1: + return { + "supported": False, + "category_key": category_key, + "matched_records": 0, + "reason": "A retention period of at least one day is required.", + } + if category_key == "am_job_payloads": + return _check_job_payloads(retention_days) + return _check_diagnostic_logs(retention_days) + + +def delete_retention_category(category_key: str, retention_days: int | None) -> dict[str, Any]: + if category_key not in IMPLEMENTED_RETENTION_KEYS: + raise ValueError("No executable retention handler is implemented for this category.") + if retention_days is None or retention_days < 1: + raise ValueError("A retention period of at least one day is required.") + if category_key == "am_job_payloads": + return _delete_job_payloads(retention_days) + return _delete_diagnostic_logs(retention_days) diff --git a/app/static/css/app.css b/app/static/css/app.css index 32fce9a..bc4f8c1 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -2826,3 +2826,28 @@ html[data-theme="dark"] .chart-total{ /* Keep server-filter controls visible while only the table body is refreshed. */ table.server-filter-loading tbody{opacity:.55;transition:opacity .12s ease} + +/* Privacy and retention settings */ +.privacy-toolbar { align-items: flex-start; gap: 1rem; } +.privacy-phase-notice { margin-bottom: 1rem; } +.repeatable-list { display: grid; gap: .65rem; } +.repeatable-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .65rem; align-items: start; } +.repeatable-row textarea { min-height: 4rem; } +.privacy-category-workbook { border: 1px solid var(--border-color); border-radius: .7rem; background: var(--surface-color); overflow: hidden; } +.privacy-category-tabs { display: flex; align-items: flex-end; gap: .2rem; overflow-x: auto; padding: .55rem .55rem 0; background: color-mix(in srgb, var(--surface-color) 88%, var(--primary-color)); scrollbar-width: thin; } +.privacy-category-tab { flex: 0 0 auto; max-width: 260px; min-width: 120px; margin: 0; padding: .65rem .9rem; border: 1px solid transparent; border-bottom: 0; border-radius: .55rem .55rem 0 0; background: transparent; color: var(--text-color); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } +.privacy-category-tab:hover { background: color-mix(in srgb, var(--surface-color) 78%, var(--primary-color)); } +.privacy-category-tab.active { background: var(--surface-color); border-color: var(--border-color); color: var(--primary-color); } +.privacy-category-tab:focus-visible { outline: 2px solid var(--primary-color); outline-offset: -2px; } +.privacy-category-panels { min-height: 18rem; } +.privacy-category-panel { display: grid; gap: .8rem; padding: 1rem; } +.privacy-category-panel[hidden] { display: none !important; } +@media (max-width: 850px) { .privacy-toolbar { display: block; } .privacy-toolbar .toolbar-actions { margin-top: .75rem; } .privacy-category-tab { min-width: 105px; max-width: 200px; } } + +.privacy-system-badge { margin-left: .35rem; font-size: .8rem; } +.privacy-retention-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .65rem; margin-top: 1rem; } +.privacy-system-note { margin-right: auto; } +.privacy-preview-result { display: grid; gap: .25rem; margin-top: 1rem; } +.privacy-settings-form[data-privacy-readonly="true"] input:disabled, +.privacy-settings-form[data-privacy-readonly="true"] textarea:disabled, +.privacy-settings-form[data-privacy-readonly="true"] select:disabled { opacity: 1; color: inherit; -webkit-text-fill-color: currentColor; cursor: default; } diff --git a/app/templates/base.html b/app/templates/base.html index a04ac46..1486e72 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -82,12 +82,20 @@ {{ t('settings.meshcentral', 'MeshCentral') }} {{ t('software.settings.menu') }} {{ t('settings.backup', 'Sicherung') }} + {{ t('privacy.menu') }} {{ t('app.status_values', 'Statuswerte') }} {{ t('settings.system_information', 'Systeminformationen') }} {{ t('settings.translations', 'Übersetzungen') }} {{ t('users.management', 'Benutzerverwaltung') }} + {% else %} +
+ {{ t('app.settings') }} + +
{% endif %}
{% block content %}{% endblock %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 5919921..e3bb50d 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block content %}

{{ t('dashboard.overview') }}

{% if is_admin %}➕ {{ t('assets.create') }}{% endif %}
-
{% for c in categories %}{% if c.image_path %}{% else %}
{{c.name[:1]}}
{% endif %}
{{c.name}}{{c.assets|length}} {{ t('common.assets') }}
{% endfor %}
+
{% for c in categories %}{% if c.image_path %}{% else %}
{{c.name[:1]}}
{% endif %}
{{c.name}}{{ category_asset_counts.get(c.id, 0) }} {{ t('common.assets') }}
{% endfor %}
{% if dashboard_charts %}

{{ t('app.charts') }}

{{ t('charts.show_all') }}
{% for item in dashboard_charts %}{% include '_chart.html' %}{% endfor %}
{% endif %}

{{ t('dashboard.recently_changed') }}

{{ t('dashboard.list_fields_hint') }}

diff --git a/app/templates/privacy_report_export.html b/app/templates/privacy_report_export.html new file mode 100644 index 0000000..f4b905a --- /dev/null +++ b/app/templates/privacy_report_export.html @@ -0,0 +1,100 @@ + + + + + +{{ 'Verzeichnis von Verarbeitungstätigkeiten / Löschkonzept' if language_code.startswith('de') else 'Record of Processing Activities / Retention Concept' }} + + + +{% set de = language_code.startswith('de') %} +

{{ 'Verzeichnis von Verarbeitungstätigkeiten / Löschkonzept' if de else 'Record of Processing Activities / Retention Concept' }}

+
{{ 'Erzeugt' if de else 'Generated' }}: {{ generated_at }} · AssetManager {{ app_version }} · {{ 'Dokumentversion' if de else 'Document version' }}: {{ activity.document_version or '-' }}
+
{{ 'Dieser Bericht dokumentiert die konfigurierten Regeln. Automatische Löschungen sind noch nicht aktiv. Prüfläufe und manuell bestätigte Löschläufe sind für ausgewählte AssetManager-Kategorien verfügbar.' if de else 'This report documents the configured rules. Automatic deletion is not active. Dry runs and manually confirmed deletion runs are available for selected AssetManager categories.' }}
+ +

{{ '1. Verarbeitungstätigkeit und Verantwortlichkeit' if de else '1. Processing activity and responsibility' }}

+
+
{{ 'Bezeichnung' if de else 'Title' }}
{{ activity.title or '-' }}
+
{{ 'Verantwortlicher' if de else 'Controller' }}
{{ activity.controller or '-' }}
+
{{ 'Fachbereich' if de else 'Responsible department' }}
{{ activity.responsible_department or '-' }}
+
{{ 'Verantwortliche Personen' if de else 'Responsible people' }}
{{ activity.responsible_people or '-' }}
+
{{ 'Datenschutzkontakt' if de else 'Data protection contact' }}
{{ activity.data_protection_contact or '-' }}
+
+ +

{{ '2. Zwecke' if de else '2. Purposes' }}

+ + +

{{ '3. Rechtsgrundlage und betroffene Personen' if de else '3. Legal basis and data subjects' }}

+
{{ 'Rechtsgrundlage' if de else 'Legal basis' }}
{{ activity.legal_basis or '-' }}
{{ 'Betroffene Personengruppen' if de else 'Data subject groups' }}
{{ activity.data_subject_groups or '-' }}
+ +

{{ '4. Empfänger und Drittlandübermittlung' if de else '4. Recipients and third-country transfers' }}

+
+
{{ 'Interne Empfänger' if de else 'Internal recipients' }}
{{ activity.internal_recipients or '-' }}
+
{{ 'Externe Empfänger' if de else 'External recipients' }}
{{ activity.external_recipients or '-' }}
+
{{ 'Drittlandübermittlung' if de else 'Third-country transfer' }}
{{ ('Ja' if activity.third_country_transfer else 'Nein') if de else ('Yes' if activity.third_country_transfer else 'No') }}
+
{{ 'Details' if de else 'Details' }}
{{ activity.third_country_details or '-' }}
+
+ +

{{ '5. Systeme und Sicherungen' if de else '5. Systems and backups' }}

+
+
{{ 'Systeme' if de else 'Systems' }}
{{ activity.systems or '-' }}
+
{{ 'Backup-System' if de else 'Backup system' }}
{{ activity.backup_system or '-' }}
+
{{ 'Backup-Aufbewahrung' if de else 'Backup retention' }}
{{ activity.backup_retention_days if activity.backup_retention_days is not none else '-' }} {{ 'Tage' if de else 'days' }}
+
{{ 'Verfahren nach Wiederherstellung' if de else 'Restore procedure' }}
{{ activity.restore_deletion_procedure or '-' }}
+
+ +

{{ '6. Datenkategorien und Löschfristen' if de else '6. Data categories and retention' }}

+{% for category in settings.categories %} +
+

6.{{ loop.index }} {{ category.name }}

+ + + + + + + + + + + + + +
{{ 'System' if de else 'System' }}{{ category.system or '-' }}
{{ 'Betroffene Personen' if de else 'Data subjects' }}{{ category.data_subjects or '-' }}
{{ 'Personenbezogene Daten' if de else 'Personal data' }}{{ category.personal_data or '-' }}
{{ 'Zweck' if de else 'Purpose' }}{{ category.purpose or '-' }}
{{ 'Löschart' if de else 'Retention type' }}{{ category.retention_type or '-' }}
{{ 'Löschfrist' if de else 'Retention period' }}{{ category.retention_days if category.retention_days is not none else '-' }} {{ 'Tage' if de else 'days' }}
{{ 'Fristbeginn/Auslöser' if de else 'Trigger' }}{{ category.retention_trigger or '-' }}
{{ 'Automatisch technisch möglich' if de else 'Automatic deletion technically possible' }}{{ ('Ja' if category.automatic_possible else 'Nein') if de else ('Yes' if category.automatic_possible else 'No') }}
{{ 'Manuelles Verfahren' if de else 'Manual procedure' }}{{ category.manual_procedure or '-' }}
{{ 'Behandlung in Sicherungen' if de else 'Backup handling' }}{{ category.backup_handling or '-' }}
{{ 'Verantwortlich' if de else 'Responsible' }}{{ category.responsible or '-' }}
{{ 'Hinweise' if de else 'Notes' }}{{ category.notes or '-' }}
+
+{% endfor %} + +

{{ '7. Technische und organisatorische Maßnahmen' if de else '7. Technical and organisational measures' }}

+ + +

{{ '8. Dokumentenprüfung' if de else '8. Document review' }}

+
+
{{ 'Prüfintervall' if de else 'Review interval' }}
{{ activity.review_interval_months or 12 }} {{ 'Monate' if de else 'months' }}
+
{{ 'Zuletzt geprüft' if de else 'Last reviewed' }}
{{ activity.last_reviewed_at or '-' }}
+
{{ 'Freigegeben durch' if de else 'Approved by' }}
{{ activity.approved_by or '-' }}
+
+ +

{{ '9. Änderungsprotokoll' if de else '9. Policy audit' }}

+ +{% for row in privacy_audit %}{% else %}{% endfor %} +
{{ 'Zeitstempel UTC' if de else 'Timestamp UTC' }}{{ 'Aktion' if de else 'Action' }}{{ 'Geändert von' if de else 'Changed by' }}Details
{{ row.timestamp_utc }}{{ row.action }}{{ row.changed_by }}{{ row.details }}
-
+ + diff --git a/app/templates/settings_privacy.html b/app/templates/settings_privacy.html new file mode 100644 index 0000000..1abda59 --- /dev/null +++ b/app/templates/settings_privacy.html @@ -0,0 +1,374 @@ +{% extends "base.html" %} +{% block content %} +
+
+

{{ t('privacy.title') }}

+

{{ t('privacy.intro') }}

+
+
+ {{ t('privacy.export_text') }} + {{ t('privacy.export_html') }} + {{ t('privacy.export_excel') }} +
+
+ +
+ {{ t('privacy.phase2_title') }} + {{ t('privacy.phase2_help') }} +
+{% if not can_edit %}
{{ t('privacy.read_only_title') }} {{ t('privacy.read_only_help') }}
{% endif %} + +
+ {% set activity = privacy_settings.processing_activity %} +
+

{{ t('privacy.processing_activity') }}

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

{{ t('privacy.purposes') }}

{{ t('privacy.purposes_help') }}

+ +
+
+ {% for value in activity.purposes %} +
+ {% endfor %} +
+
+ +
+

{{ t('privacy.recipients_transfers') }}

+
+ + +
+ + +
+ +
+

{{ t('privacy.systems_backups') }}

+ +
+ + +
+ +
+ +
+
+

{{ t('privacy.data_categories') }}

{{ t('privacy.data_categories_tabs_help') }}

+ {% if can_edit %}{% endif %} +
+
+
+ {% for category in privacy_settings.categories %} + + {% endfor %} +
+
+ {% for category in privacy_settings.categories %} +
+ +
+ + +
+ + + +
+ + + +
+ + + +
+ + +
+
+ {% if category.is_system %}🔒 {{ t('privacy.system_category_help') }}{% endif %} + {% if can_edit and category.key in implemented_retention_keys %} + + + {% elif category.automatic_possible %} + {{ t('privacy.handler_not_implemented') }} + {% endif %} + {% if can_edit and not category.is_system %}{% endif %} +
+ {% set preview = preview_results.get(category.key) %} + {% if preview %} +
+ {{ t('privacy.check_result') }} + {{ t('privacy.matched_records') }}: {{ preview.matched_records or 0 }} + {{ t('privacy.approx_size') }}: {{ ((preview.approx_bytes or 0) / 1024 / 1024)|round(2) }} MB + {{ t('privacy.cutoff') }}: {{ preview.cutoff_utc or '-' }} + {% if not preview.supported %}{{ preview.reason }}{% endif %} +
+ {% endif %} +
+ {% endfor %} +
+
+
+ +
+
+

{{ t('privacy.toms') }}

{{ t('privacy.toms_help') }}

+ +
+
+ {% for value in activity.toms %} +
+ {% endfor %} +
+
+ +
+

{{ t('privacy.review') }}

+
+ + + +
+
+ + {% if can_edit %}
{% endif %} +
+ +
+

{{ t('privacy.policy_audit') }}

+

{{ t('privacy.policy_audit_help') }}

+
+ {% for row in privacy_audit %}{% else %}{% endfor %} +
{{ t('common.date') }}{{ t('privacy.action') }}{{ t('privacy.changed_by') }}{{ t('privacy.details') }}
{{ row.timestamp_utc }}{{ row.action }}{{ row.changed_by }}{{ row.details }}
{{ t('privacy.no_audit_entries') }}
+
+ +
+

{{ t('privacy.deletion_audit') }}

+

{{ t('privacy.deletion_audit_help') }}

+
+ {% for row in deletion_audit %}{% else %}{% endfor %} +
{{ t('common.date') }}{{ t('privacy.run_id') }}{{ t('privacy.action') }}{{ t('privacy.category_name') }}{{ t('privacy.changed_by') }}{{ t('privacy.details') }}
{{ row.timestamp_utc }}{{ row.run_id }}{{ row.action }}{{ row.category_key }}{{ row.changed_by }}{{ row.result }}
{{ t('privacy.no_deletion_entries') }}
+
+ + + + +{% endblock %} diff --git a/app/version.py b/app/version.py index 4173f18..7a1b3a7 100644 --- a/app/version.py +++ b/app/version.py @@ -1,2 +1,2 @@ -APP_VERSION = "0.5.5.35" +APP_VERSION = "0.5.5.40" __version__ = APP_VERSION diff --git a/docs/version-history/README.md b/docs/version-history/README.md index 75ced7e..b730142 100644 --- a/docs/version-history/README.md +++ b/docs/version-history/README.md @@ -159,3 +159,6 @@ Older notes are concise English summaries migrated from the original release doc ## Update instructions See [UPDATE-NOTE.md](UPDATE-NOTE.md). + +- [0.5.5.39](UPDATE-0.5.5.39.md) – Privacy read-only tabs and permission-aware dashboard counts. +- [0.5.5.40](UPDATE-0.5.5.40.md) – Privacy exports available to all authenticated users. diff --git a/docs/version-history/UPDATE-0.5.5.36.md b/docs/version-history/UPDATE-0.5.5.36.md new file mode 100644 index 0000000..a36b1c1 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.36.md @@ -0,0 +1,11 @@ +# AssetManager 0.5.5.36 + +## Security and privacy documentation – phase 1 + +- Added **Settings → Security & privacy**. +- Added configurable processing-activity metadata, purposes, recipients, third-country transfer information, systems, backup retention and review information. +- Added editable personal-data categories with retention type, period, trigger, manual deletion procedure, backup treatment and responsible role. +- Added initial AssetManager and MeshCentral categories based on the documented remote-support and inventory processing activity. +- Added a policy audit log that records configuration changes and report exports without copying deleted personal data. +- Added structured text and Excel exports for review by a data protection officer. +- Automatic deletion is deliberately not executed in this first phase. The settings prepare a later dry-run and controlled deletion implementation. diff --git a/docs/version-history/UPDATE-0.5.5.37.md b/docs/version-history/UPDATE-0.5.5.37.md new file mode 100644 index 0000000..e21e881 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.37.md @@ -0,0 +1,11 @@ +# AssetManager 0.5.5.37 + +## Privacy report and category user interface + +- Fixed the privacy Excel export by returning validated complete XLSX bytes. +- Added protection against invalid control characters and oversized Excel cell content. +- Added localized worksheet names and column headings. +- Added a standalone HTML privacy report export. +- Replaced the expanded category cards with worksheet-style tabs. +- Categories can still be added, renamed and removed without changing the stored data model. +- Automatic deletion remains disabled in privacy phase 1. diff --git a/docs/version-history/UPDATE-0.5.5.38.md b/docs/version-history/UPDATE-0.5.5.38.md new file mode 100644 index 0000000..a288570 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.38.md @@ -0,0 +1,16 @@ +# AssetManager 0.5.5.38 + +## Security and privacy phase 2 + +- makes the security and privacy documentation readable for all authenticated users +- restricts changes, retention checks and deletion runs to administrators +- protects built-in privacy categories from removal while custom categories remain removable +- adds retention dry runs and manually confirmed deletion actions for job payloads and diagnostic logs +- records checks and deletion runs in a separate privacy deletion audit log without copying deleted personal content +- keeps automatic deletion disabled +- fixes the HTML report browser title to "Record of Processing Activities / Retention Concept" + +### Implemented manual retention handlers + +- **Job scripts, output, callback payloads and diagnostics:** clears payload and diagnostic fields of completed jobs older than the configured retention period while retaining job metadata. +- **Application, LDAP and diagnostic logs:** deletes old log files and truncates active log files older than the configured retention period. Privacy policy and deletion audit logs are excluded. diff --git a/docs/version-history/UPDATE-0.5.5.39.md b/docs/version-history/UPDATE-0.5.5.39.md new file mode 100644 index 0000000..c1629b0 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.39.md @@ -0,0 +1,8 @@ +# Version 0.5.5.39 + +## Privacy read-only navigation and dashboard permission counts + +- Fixed privacy category tabs for non-administrator users. Read-only users can now switch between all category tabs while fields and editing actions remain protected. +- Dashboard category cards now count only assets visible to the signed-in user according to the configured asset access rules. +- Dashboard charts and recently changed assets continue to use the same permission-aware asset scope. +- Removed the implicit loading of all category assets solely to calculate card counts. diff --git a/docs/version-history/UPDATE-0.5.5.40.md b/docs/version-history/UPDATE-0.5.5.40.md new file mode 100644 index 0000000..51bc8e6 --- /dev/null +++ b/docs/version-history/UPDATE-0.5.5.40.md @@ -0,0 +1,7 @@ +# Version 0.5.5.40 + +## Changes + +- Privacy text, HTML, and Excel exports are now available to every authenticated user. +- Editing privacy settings, running retention previews, and executing deletions remain restricted to administrators. +- Privacy export audit entries record the authenticated user who initiated the export.