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:
parent
4c0b5ada37
commit
45ba50ecfa
@ -434,6 +434,13 @@ def customers_export():
|
|||||||
c.autotask_company_name if c.autotask_company_name else ""
|
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")
|
out = buf.getvalue().encode("utf-8")
|
||||||
return Response(
|
return Response(
|
||||||
out,
|
out,
|
||||||
@ -543,6 +550,19 @@ def customers_import():
|
|||||||
try:
|
try:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Import finished. Created: {created}, Updated: {updated}, Skipped: {skipped}.", "success")
|
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:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
current_app.logger.exception(f"Failed to import customers: {exc}")
|
current_app.logger.exception(f"Failed to import customers: {exc}")
|
||||||
|
|||||||
@ -251,6 +251,18 @@ def settings_jobs_export():
|
|||||||
payload["counts"]["customers"] = len(payload["customers"])
|
payload["counts"]["customers"] = len(payload["customers"])
|
||||||
payload["counts"]["jobs"] = len(payload["jobs"])
|
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"
|
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")
|
blob = json.dumps(payload, indent=2, ensure_ascii=False).encode("utf-8")
|
||||||
return send_file(
|
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}.",
|
f"Import completed. Customers created: {created_customers}, updated: {updated_customers}. Jobs created: {created_jobs}, updated: {updated_jobs}.",
|
||||||
"success",
|
"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:
|
except Exception as exc:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
print(f"[settings-jobs] Import failed: {exc}")
|
print(f"[settings-jobs] Import failed: {exc}")
|
||||||
@ -453,6 +479,30 @@ def settings():
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
autotask_form_touched = any(str(k).startswith("autotask_") for k in (request.form or {}).keys())
|
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())
|
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.
|
# NOTE: The Settings UI has multiple tabs with separate forms.
|
||||||
# Only update values that are present in the submitted form, to avoid
|
# Only update values that are present in the submitted form, to avoid
|
||||||
@ -628,6 +678,65 @@ def settings():
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Settings have been saved.", "success")
|
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).
|
# Autotask ticket defaults depend on reference data (queues, sources, statuses, priorities).
|
||||||
# When the Autotask integration is (re)configured, auto-refresh the cached reference data
|
# When the Autotask integration is (re)configured, auto-refresh the cached reference data
|
||||||
# once so the dropdowns become usable immediately.
|
# once so the dropdowns become usable immediately.
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
Admin activity (last 7 days)
|
System Audit Log (last 7 days)
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
|
|||||||
@ -2,6 +2,35 @@
|
|||||||
|
|
||||||
This file documents all changes made to this project via Claude Code.
|
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]
|
## [2026-02-06]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user