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 @@