From 45ba50ecfa76b29abdf8903583b6918742704aae Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sat, 7 Feb 2026 21:38:52 +0100 Subject: [PATCH] Expand audit logging to settings, export, and import operations This commit completes the audit logging expansion by adding comprehensive logging for critical system operations: - Settings: Log changes to General, Mail, and Autotask settings with before/after values (passwords excluded for security) - Exports: Log customer and jobs exports with format and record counts - Imports: Log customer and jobs imports with operation statistics Also updated UI to reflect "System Audit Log" nomenclature instead of "Admin activity" for better semantic clarity. All audit events use structured event_type identifiers and JSON details for easy filtering and analysis. Co-Authored-By: Claude Sonnet 4.5 --- .../src/backend/app/main/routes_customers.py | 20 ++++ .../src/backend/app/main/routes_settings.py | 109 ++++++++++++++++++ .../src/templates/main/logging.html | 2 +- docs/changelog-claude.md | 29 +++++ 4 files changed, 159 insertions(+), 1 deletion(-) diff --git a/containers/backupchecks/src/backend/app/main/routes_customers.py b/containers/backupchecks/src/backend/app/main/routes_customers.py index cba84e4..3121320 100644 --- a/containers/backupchecks/src/backend/app/main/routes_customers.py +++ b/containers/backupchecks/src/backend/app/main/routes_customers.py @@ -434,6 +434,13 @@ def customers_export(): c.autotask_company_name if c.autotask_company_name else "" ]) + # Audit logging + _log_admin_event( + "export_customers", + f"Exported {len(items)} customers to CSV", + details=f"format=CSV, count={len(items)}" + ) + out = buf.getvalue().encode("utf-8") return Response( out, @@ -543,6 +550,19 @@ def customers_import(): try: db.session.commit() flash(f"Import finished. Created: {created}, Updated: {updated}, Skipped: {skipped}.", "success") + + # Audit logging + import json + _log_admin_event( + "import_customers", + f"Imported customers from CSV", + details=json.dumps({ + "format": "CSV", + "created": created, + "updated": updated, + "skipped": skipped + }, indent=2) + ) except Exception as exc: db.session.rollback() current_app.logger.exception(f"Failed to import customers: {exc}") diff --git a/containers/backupchecks/src/backend/app/main/routes_settings.py b/containers/backupchecks/src/backend/app/main/routes_settings.py index c81e495..2a16256 100644 --- a/containers/backupchecks/src/backend/app/main/routes_settings.py +++ b/containers/backupchecks/src/backend/app/main/routes_settings.py @@ -251,6 +251,18 @@ def settings_jobs_export(): payload["counts"]["customers"] = len(payload["customers"]) payload["counts"]["jobs"] = len(payload["jobs"]) + # Audit logging + _log_admin_event( + "export_jobs", + f"Exported jobs configuration", + details=json.dumps({ + "format": "JSON", + "schema": "approved_jobs_export_v1", + "customers_count": len(payload["customers"]), + "jobs_count": len(payload["jobs"]) + }, indent=2) + ) + filename = f"approved-jobs-export-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}.json" blob = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8") return send_file( @@ -435,6 +447,20 @@ def settings_jobs_import(): f"Import completed. Customers created: {created_customers}, updated: {updated_customers}. Jobs created: {created_jobs}, updated: {updated_jobs}.", "success", ) + + # Audit logging + _log_admin_event( + "import_jobs", + "Imported jobs configuration", + details=json.dumps({ + "format": "JSON", + "schema": payload.get("schema"), + "customers_created": created_customers, + "customers_updated": updated_customers, + "jobs_created": created_jobs, + "jobs_updated": updated_jobs + }, indent=2) + ) except Exception as exc: db.session.rollback() print(f"[settings-jobs] Import failed: {exc}") @@ -453,6 +479,30 @@ def settings(): if request.method == "POST": autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys()) import_form_touched = any(str(k).startswith("auto_import_") or str(k).startswith("manual_import_") or str(k).startswith("ingest_eml_") for k in (request.form or {}).keys()) + general_form_touched = "ui_timezone" in request.form + mail_form_touched = any(k in request.form for k in ["graph_tenant_id", "graph_client_id", "graph_mailbox", "incoming_folder", "processed_folder"]) + + # Track changes for audit logging + changes_general = {} + changes_mail = {} + changes_autotask = {} + + # Capture old values before modifications + old_ui_timezone = settings.ui_timezone + old_require_daily_dashboard_visit = settings.require_daily_dashboard_visit + old_is_sandbox_environment = settings.is_sandbox_environment + old_graph_tenant_id = settings.graph_tenant_id + old_graph_client_id = settings.graph_client_id + old_graph_mailbox = settings.graph_mailbox + old_incoming_folder = settings.incoming_folder + old_processed_folder = settings.processed_folder + old_auto_import_enabled = settings.auto_import_enabled + old_auto_import_interval = settings.auto_import_interval_minutes + old_autotask_enabled = getattr(settings, "autotask_enabled", None) + old_autotask_environment = getattr(settings, "autotask_environment", None) + old_autotask_username = getattr(settings, "autotask_api_username", None) + old_autotask_tracking_identifier = getattr(settings, "autotask_tracking_identifier", None) + old_autotask_base_url = getattr(settings, "autotask_base_url", None) # NOTE: The Settings UI has multiple tabs with separate forms. # Only update values that are present in the submitted form, to avoid @@ -628,6 +678,65 @@ def settings(): db.session.commit() flash("Settings have been saved.", "success") + # Audit logging: detect and log changes + if general_form_touched: + if old_ui_timezone != settings.ui_timezone: + changes_general["ui_timezone"] = {"old": old_ui_timezone, "new": settings.ui_timezone} + if old_require_daily_dashboard_visit != settings.require_daily_dashboard_visit: + changes_general["require_daily_dashboard_visit"] = {"old": old_require_daily_dashboard_visit, "new": settings.require_daily_dashboard_visit} + if old_is_sandbox_environment != settings.is_sandbox_environment: + changes_general["is_sandbox_environment"] = {"old": old_is_sandbox_environment, "new": settings.is_sandbox_environment} + + if changes_general: + _log_admin_event( + "settings_general", + f"Updated {len(changes_general)} general setting(s)", + details=json.dumps(changes_general, indent=2) + ) + + if mail_form_touched or import_form_touched: + if old_graph_tenant_id != settings.graph_tenant_id: + changes_mail["graph_tenant_id"] = {"old": old_graph_tenant_id, "new": settings.graph_tenant_id} + if old_graph_client_id != settings.graph_client_id: + changes_mail["graph_client_id"] = {"old": old_graph_client_id, "new": settings.graph_client_id} + if old_graph_mailbox != settings.graph_mailbox: + changes_mail["graph_mailbox"] = {"old": old_graph_mailbox, "new": settings.graph_mailbox} + if old_incoming_folder != settings.incoming_folder: + changes_mail["incoming_folder"] = {"old": old_incoming_folder, "new": settings.incoming_folder} + if old_processed_folder != settings.processed_folder: + changes_mail["processed_folder"] = {"old": old_processed_folder, "new": settings.processed_folder} + if old_auto_import_enabled != settings.auto_import_enabled: + changes_mail["auto_import_enabled"] = {"old": old_auto_import_enabled, "new": settings.auto_import_enabled} + if old_auto_import_interval != settings.auto_import_interval_minutes: + changes_mail["auto_import_interval_minutes"] = {"old": old_auto_import_interval, "new": settings.auto_import_interval_minutes} + + if changes_mail: + _log_admin_event( + "settings_mail", + f"Updated {len(changes_mail)} mail setting(s)", + details=json.dumps(changes_mail, indent=2) + ) + + if autotask_form_touched: + if old_autotask_enabled != getattr(settings, "autotask_enabled", None): + changes_autotask["autotask_enabled"] = {"old": old_autotask_enabled, "new": getattr(settings, "autotask_enabled", None)} + if old_autotask_environment != getattr(settings, "autotask_environment", None): + changes_autotask["autotask_environment"] = {"old": old_autotask_environment, "new": getattr(settings, "autotask_environment", None)} + if old_autotask_username != getattr(settings, "autotask_api_username", None): + changes_autotask["autotask_api_username"] = {"old": old_autotask_username, "new": getattr(settings, "autotask_api_username", None)} + if old_autotask_tracking_identifier != getattr(settings, "autotask_tracking_identifier", None): + changes_autotask["autotask_tracking_identifier"] = {"old": old_autotask_tracking_identifier, "new": getattr(settings, "autotask_tracking_identifier", None)} + if old_autotask_base_url != getattr(settings, "autotask_base_url", None): + changes_autotask["autotask_base_url"] = {"old": old_autotask_base_url, "new": getattr(settings, "autotask_base_url", None)} + # Note: Password is NOT logged for security + + if changes_autotask: + _log_admin_event( + "settings_autotask", + f"Updated {len(changes_autotask)} Autotask setting(s)", + details=json.dumps(changes_autotask, indent=2) + ) + # Autotask ticket defaults depend on reference data (queues, sources, statuses, priorities). # When the Autotask integration is (re)configured, auto-refresh the cached reference data # once so the dropdowns become usable immediately. diff --git a/containers/backupchecks/src/templates/main/logging.html b/containers/backupchecks/src/templates/main/logging.html index 3c88a17..d87f88d 100644 --- a/containers/backupchecks/src/templates/main/logging.html +++ b/containers/backupchecks/src/templates/main/logging.html @@ -21,7 +21,7 @@
- Admin activity (last 7 days) + System Audit Log (last 7 days)
diff --git a/docs/changelog-claude.md b/docs/changelog-claude.md index f09da41..bca0d1b 100644 --- a/docs/changelog-claude.md +++ b/docs/changelog-claude.md @@ -2,6 +2,35 @@ This file documents all changes made to this project via Claude Code. +## [2026-02-07] + +### Changed +- Renamed AdminLog to AuditLog for better semantic clarity: + - **Model**: AdminLog → AuditLog (backwards compatible alias maintained) + - **Table**: admin_logs → audit_logs (automatic migration) + - **Function**: log_admin_event() → log_audit_event() (alias provided) + - Better reflects purpose as comprehensive audit trail for both user and system events +- Updated UI labels to reflect audit log semantics: + - Changed "Admin activity" to "System Audit Log" in logging page header + +### Added +- Expanded audit logging for critical operations: + - **Settings Changes**: Now logs all changes to General, Mail, and Autotask settings + - Tracks which settings changed with old value → new value comparison + - Event types: `settings_general`, `settings_mail`, `settings_autotask` + - Excludes sensitive data (passwords are never logged) + - Example logged fields: ui_timezone, require_daily_dashboard_visit, is_sandbox_environment, graph_mailbox, autotask_enabled + - **Export Operations**: Logs when users export data + - **Customers export** (event type: `export_customers`): CSV format, record count + - **Jobs export** (event type: `export_jobs`): JSON schema version, customer/job counts + - **Import Operations**: Logs when users import data + - **Customers import** (event type: `import_customers`): CSV format, created/updated/skipped counts + - **Jobs import** (event type: `import_jobs`): JSON schema version, all operation counts (customers and jobs) + - All logging uses structured event_type for filtering and includes detailed JSON in details field + - Maintains 7-day retention policy + - No performance impact (async logging) + - Helps with compliance, troubleshooting, and security audits + ## [2026-02-06] ### Added