Skip to main content

Auditing (Custom-Action Audit Metadata)

Overview​

cm-admin extends PaperTrail to record two first-class audit dimensions on every version record:

  • action_type — the category of operation (default, custom, bulk_action, or tab)
  • action_name — the specific action (create, update, destroy, or the name of a custom action like approve)

These are stored as queryable columns on the PaperTrail versions table, so you can filter and report on audit history by what kind of action triggered each change — not just who did it (whodunnit) and what changed (object / object_changes).

Best of all, developers keep writing bare has_paper_trail — the engine auto-injects the metadata configuration. No per-model setup is required.


Quick Start​

1. Add the columns to your versions table​

Run the generator and migrate:

rails g cm_admin:add_audit_metadata_to_versions
rails db:migrate

The generator creates a migration that adds action_type and action_name (both string) to versions, plus indexes on each for fast filtering:

class AddAuditMetadataToVersions < ActiveRecord::Migration[8.1]
def change
add_column :versions, :action_type, :string
add_index :versions, :action_type
add_column :versions, :action_name, :string
add_index :versions, :action_name
end
end

If only one of the columns is missing, the generator only adds the missing one.

2. That's it​

Any model that already declares has_paper_trail will now automatically record action_type and action_name on each version — no changes to the model are needed.


How It Works​

  1. CmCurrent (cm-admin's ActiveSupport::CurrentAttributes store) gains two attributes: action_type and action_name.
  2. CmAdmin::ResourceController sets them at the start of each mutating request:
    • cm_create → action_type: 'default', action_name: 'create'
    • cm_update → action_type: 'default', action_name: 'update'
    • cm_destroy → action_type: 'default', action_name: 'destroy'
    • cm_custom_method → action_type: 'custom', action_name: &lt;the custom action's name&gt;
    • cm_bulk_action → action_type: 'bulk_action', action_name: &lt;the bulk action's name&gt; They are reset to nil at the end of the request.
  3. has_paper_trail is auto-wrapped by the engine. When called on a model, it injects:
    meta: {
    action_type: ->(_record) { CmCurrent.action_type },
    action_name: ->(_record) { CmCurrent.action_name }
    }
    but only if the versions table actually has those columns (so apps that haven't run the generator are not broken).

Querying Audit History​

Because action_type and action_name are real columns, you can filter and aggregate efficiently:

# All versions created by custom actions
record.versions.where(action_type: 'custom')

# All versions of a specific custom action across all records
PaperTrail::Version.where(action_type: 'custom', action_name: 'approve')

# Every update made through the standard edit form
record.versions.where(action_type: 'default', action_name: 'update')

# All bulk action mutations
record.versions.where(action_type: 'bulk_action')

# Audit history for a single custom action
record.versions.where(action_name: 'approve').order(created_at: :desc)

History Page Display​

The cm-admin history tab automatically renders the audit metadata. For standard CRUD operations it shows the usual text (e.g. "Jane updated the Demo Form 2 minutes ago"). For custom and bulk actions it appends via the {display name} {action type}:

Jane updated the Demo Form via the Approve Custom Action 2 minutes ago John updated the Demo Form via the Approve Bulk Action 5 minutes ago

The display name is resolved from the cm-admin action's display_name (falling back to the action name humanized), so it matches what users see on buttons and modals — no extra configuration needed. The action type label distinguishes whether the change was made through a custom action or a bulk action.


Values Reference​

action_typeaction_nameWhen
defaultcreateRecord created via cm-admin new/create
defaultupdateRecord updated via cm-admin edit/update
defaultdestroyRecord destroyed via cm-admin destroy
custom<action name>A cm-admin custom_action mutated the record
bulk_action<action name>A cm-admin bulk_action mutated the record
nilnilChange made outside a cm-admin request (e.g. background job, console, seeds)

Overriding / Extending​

If a model supplies its own meta: for action_type or action_name, cm-admin respects it and does not clobber it:

class Order < ApplicationRecord
has_paper_trail meta: { action_type: ->(_) { 'order_specific' } }
end
# Order's `action_type` will be 'order_specific', not 'default'/'custom'/'bulk_action'.
# `action_name` is still auto-injected from CmCurrent.

To opt out entirely for a model, pass both keys:

has_paper_trail meta: {
action_type: ->(_) { nil },
action_name: ->(_) { nil }
}

Requirements​

  • The paper_trail gem must be installed and has_paper_trail declared on the model.
  • The versions table must have action_type and action_name columns (added by the generator above).
  • Current.user must be set in the request lifecycle (already required by cm-admin for whodunnit and Trackable).

Notes​

  • Historical versions: Existing versions rows will have NULL for action_type/action_name. Only versions created after the migration is run get populated.
  • Background jobs: CmCurrent is request-scoped, so changes made in background jobs record NULL for both fields — which is correct, since no HTTP action triggered them.
  • The has_paper_trail wrapper is global: it affects every ActiveRecord model in the host app, not just cm-admin DSL models. This is intentional and harmless — models without the versions columns simply skip meta injection, and developer-supplied meta: is always respected.