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 <noreply@anthropic.com>
This commit is contained in:
Ivo Oskamp 2026-02-07 21:38:52 +01:00
parent 4c0b5ada37
commit 45ba50ecfa
4 changed files with 159 additions and 1 deletions

View File

@ -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}")

View File

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

View File

@ -21,7 +21,7 @@
<div class="card">
<div class="card-header">
Admin activity (last 7 days)
System Audit Log (last 7 days)
</div>
<div class="card-body">
<div class="table-responsive">

View File

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