Skip to content

Audit log ​

sh
bin/rails g trek:audit_log
bin/rails db:migrate

Records who changed what in the admin, one row per request, and lists the rows to admins. The generator installs:

  • the AuditLog model, including Trek::AuditLog, and its audit_logs table
  • config/initializers/audit_log.rb, which includes Trek::Auditable in the admin controllers, the sign-in and the password reset
  • the admin list at /admin/audit_logs (controller, views, policy and route), open to the admin role only
  • its specs, spec/models/audit_log_spec.rb and spec/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:

ColumnValue
actorCurrent.user, nil when nobody is signed in (a failed sign-in, a request redirected to the sign-in page)
auditablethe 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
actioncontroller#action, e.g. admin/pages#update
http_method, path, http_statusthe request and its response status: a failed sign-in answers 422, a throttled one 429, so both count as failures
paramsthe 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_idwhere the request came from
error_classthe 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:

ruby
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 }
end

A read showing sensitive data can be recorded explicitly, whatever the HTTP method, with audit!(name, target); the row's action is then controller#name:

ruby
def show
  super
  audit!(:show_medical_record, @object)
end

Switching 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:

ruby
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 admin role 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_values only 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:

  1. Run bin/rails g trek:audit_log, delete the generated db/migrate/*_trek_audit_logs.rb, and let the generator overwrite the model, controller, views and policy.
  2. Delete the app's Auditable concern, its initializer and its purge job, and update the job's cron entry to Trek::AuditLog::PurgeJob.
  3. Move app-specific columns (e.g. a request duration, or a denormalized foreign key) to audit_attributes, and their scopes and associations to the AuditLog model.

Released under the MIT License.