Audit log
bin/rails g trek:audit_log
bin/rails db:migrateRecords who changed what in the admin, one row per request, and lists the rows to admins. The generator installs:
- the
AuditLogmodel, includingTrek::AuditLog, and itsaudit_logstable config/initializers/audit_log.rb, which includesTrek::Auditablein the admin controllers, the sign-in and the password reset- the admin list at
/admin/audit_logs(controller, views, policy and route), open to theadminrole only - its specs,
spec/models/audit_log_spec.rbandspec/requests/admin/audit_logs_spec.rb
To reach the list from the admin menu, add AuditLog to MODELS_IN_MENU in app/models/admin/dashboard.rb.
What is recorded
Each mutation (any request but GET and HEAD) writes a row:
| Column | Value |
|---|---|
actor | Current.user, nil when nobody is signed in (a failed sign-in, a request redirected to the sign-in page) |
auditable | the record the action worked on: @object once saved, or once destroyed. For a sign-in, the user signed in to or tried, so an account's failed sign-ins can be listed |
action | controller#action, e.g. admin/pages#update |
http_method, path, http_status | the request and its response status: a failed sign-in answers 422, a throttled one 429, so both count as failures |
params | the request's parameter names; their values are replaced with [FILTERED], except for id and role (see audit_param_values below), so the log doesn't copy what the forms send. config.filter_parameters (passwords, tokens, emails…) still applies |
ip_address, user_agent, request_id | where the request came from |
error_class | the class of the error the request raised, if any (http_status is then nil) |
Rows are append-only: a saved row raises ActiveRecord::ReadOnlyRecord when updated. Writing one never breaks the request: a failure is reported through Rails.error.
The admin list filters on action, actor_type/actor_id and auditable_type/auditable_id (e.g. /admin/audit_logs?filters[auditable_type]=Page&filters[auditable_id]=12), and lists failures only (an error status or a raised error) with ?scopes[failures]=1. Trek::AuditLogsHelper#audit_log_path_for(record) links to a record's rows, for a button on its admin page.
Customizing
Include Trek::Auditable in any other controller to audit it too, e.g. the front office's ApplicationController. Override its hooks there:
class Api::BaseController < ActionController::API
include Trek::Auditable
# Record reads as well as mutations
self.audit_reads = true
# Actions that only read although they're not a GET, skipped as reads are (default: none),
# e.g. the chips hotwire_combobox requests with a POST
self.audit_read_actions = %i[chips]
# The parameters whose values are recorded, the others by name only (default: id and role)
self.audit_param_values = %w[id role status]
private
# Who made the request (default: Current.user)
def audit_actor = Current.api_client
# The record the action worked on (default: @object, once saved or destroyed)
def audit_target = @order
# The parameters to record (default: their names, with the values of audit_param_values)
def audit_params = super.except("signature")
# Values for the app's own columns, added by a migration of its own
def audit_attributes = { shop_id: Current.shop&.id }
endA read showing sensitive data can be recorded explicitly, whatever the HTTP method, with audit!(name, target); the row's action is then controller#name:
def show
super
audit!(:show_medical_record, @object)
endSwitching it off
FEATURE_AUDIT_LOG=false stops recording, e.g. if writing the rows slows down a busy app. It is read on each request.
Purging old rows
Trek::AuditLog::PurgeJob deletes the rows older than 6 months, the low end of the CNIL's recommendation for logs (6 months to 1 year, up to 3 years for a particularly sensitive processing). The generator schedules it every night in GoodJob's cron, in the config/initializers/good_job.rb the installer writes. With another Active Job backend, it says so, and the job is to schedule daily with that backend. An app that installed the audit log before the generator did this adds the entry to its config.good_job.cron by hand:
purge_audit_logs: {
cron: "0 4 * * *",
class: "Trek::AuditLog::PurgeJob",
description: "Delete the audit log rows past the retention window"
},To keep the rows longer or shorter, set the retention in an initializer: Trek::AuditLog::PurgeJob.retention = 1.year.
Personal data
The rows hold personal data: the author, the IP address, the user agent, and whatever the request's parameters contain. Recording them to secure the app is allowed under the GDPR (its article 32, and recital 49 for people who aren't signed in), on the conditions the CNIL's recommendation on logs sets out:
- Inform the people recorded: the admin users (e.g. in their onboarding or the admin's legal notice), and the site's visitors if the front office is audited (its privacy policy): what is recorded, why, and for how long. List the processing in the app's record of processing activities.
- Use the rows for security only: investigating an incident or a misuse, not, for instance, evaluating the people recorded.
- Keep them for a limited time: 6 months to 1 year (see Purging old rows).
- Limit who reads them: the list is open to the
adminrole only; keep that role to the few people who need it. - Record no more than needed: the log keeps the parameters' names, not their values; add a parameter to
audit_param_valuesonly when its value matters to security (a role, a status), and never one carrying sensitive data such as health data.
Moving from an app's own audit log
The audit_logs table uses the column names of the audit logs some apps wrote themselves (actor, auditable, action, http_method, path, http_status, params, ip_address, user_agent, request_id, error_class, created_at), so existing rows carry over. In such an app, skip the migration and replace the app's own files:
- Run
bin/rails g trek:audit_log, delete the generateddb/migrate/*_trek_audit_logs.rb, and let the generator overwrite the model, controller, views and policy. - Delete the app's
Auditableconcern, its initializer and its purge job, and update the job's cron entry toTrek::AuditLog::PurgeJob. - Move app-specific columns (e.g. a request duration, or a denormalized foreign key) to
audit_attributes, and their scopes and associations to theAuditLogmodel.