Auditing

Part 8 of building a Rails 7 application

This application will be making a lot of small edits to data that customers will see, things like fixing spelling mistakes, replacing incorrect words with the correct ones and so on. Many of these changes will be happening automatically so it will be important to know what has changed and when.

There are a number of alternative gems available that can do the job of auditing these changes. Having a look at libhunt shows the top 3 according to their rankings are PaperTrail, audited and paranoia. On The Ruby Toolbox in the versioning category it shows PaperTrail, audited and logidze as potential options.

PaperTrail has a feature for creating versions of changed records, thus allowing them to be reverted if necessary. I don't think my project will need this level of functionality, and while I've used PaperTrail in the past I think for this project I'll look at using audited.

I'll start by creating a new git branch to hold these changes.

% git checkout main
% git pull origin main
% git checkout -b part8_auditing
Switched to a new branch 'part8_auditing'

And now start following the installation instructions for the audited gem:

Gemfile

# Produce audit logs for record changes
gem 'audited', '~> 5.0'

Create the database schema changes and execute them. I've chosen to make use of the Postgres jsonb column rather than the default yaml format. This should require less code to read later.

% rails generate audited:install --audited-changes-column-type jsonb
% rake db:migrate

Lastly I need to tell ActiveRecord to include the audited gem.

class ItemSellPack < ApplicationRecord
  has_many :item_sell_pack_aliases, dependent: :destroy

  audited
end

Now I'll do a quick test to check it is working as expected:

> pack = ItemSellPack.last 
  ItemSellPack Load (0.2ms)  SELECT "item_sell_packs".* FROM "item_sell_packs" ORDER BY "item_sell_packs"."id" DESC LIMIT $1  [["LIMIT", 1]]
 => #<ItemSellPack:0x00007ff573888a40 id: 1, name: "each", canonical: true, created_at: Mon, 13 Jun 2022 10:02:24.001526000 UTC +00:00, updated_at: Mon, 13 Jun 2022 10:02:24.001526000 UTC +00:00> 

> pack.update(name: 'carton')
  TRANSACTION (0.1ms)  BEGIN
  Audited::Audit Maximum (3.4ms)  SELECT MAX("audits"."version") FROM "audits" WHERE "audits"."auditable_id" = $1 AND "audits"."auditable_type" = $2  [["auditable_id", 1], ["auditable_type", "ItemSellPack"]]
  Audited::Audit Create (2.6ms)  INSERT INTO "audits" ("auditable_id", "auditable_type", "associated_id", "associated_type", "user_id", "user_type", "username", "action", "audited_changes", "version", "comment", "remote_address", "request_uuid", "created_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING "id"  [["auditable_id", 1], ["auditable_type", "ItemSellPack"], ["associated_id", nil], ["associated_type", nil], ["user_id", nil], ["user_type", nil], ["username", nil], ["action", "update"], ["audited_changes", "{\"name\":[\"each\",\"carton\"]}"], ["version", 1], ["comment", nil], ["remote_address", nil], ["request_uuid", "9a31dae0-34ff-4505-b8b6-14a3e4c4e382"], ["created_at", "2022-06-13 10:04:20.699665"]]                  
  ItemSellPack Update (1.1ms)  UPDATE "item_sell_packs" SET "name" = $1, "updated_at" = $2 WHERE "item_sell_packs"."id" = $3  [["name", "carton"], ["updated_at", "2022-06-13 10:04:20.654292"], ["id", 1]]
  TRANSACTION (1.1ms)  COMMIT                                            
 => true      

 > pack.audits 
  Audited::Audit Load (2.3ms)  SELECT "audits".* FROM "audits" WHERE "audits"."auditable_id" = $1 AND "audits"."auditable_type" = $2 ORDER BY "audits"."version" ASC  [["auditable_id", 1], ["auditable_type", "ItemSellPack"]]
 =>                                                                      
[#<Audited::Audit:0x00007ff5744f0828                                     
  id: 1,                                                                 
  auditable_id: 1,                                                       
  auditable_type: "ItemSellPack",                                        
  associated_id: nil,                                                    
  associated_type: nil,                                                  
  user_id: nil,                                                          
  user_type: nil,                                                        
  username: nil,                                                         
  action: "update",
  audited_changes: {"name"=>["each", "carton"]},
  version: 1,
  comment: nil,
  remote_address: nil,
  request_uuid: "9a31dae0-34ff-4505-b8b6-14a3e4c4e382",
  created_at: Mon, 13 Jun 2022 10:04:20.699665000 UTC +00:00>]

That all appears to be working, I can see an audit entry has been added recording that I changed the name attribute from each to carton.

Lastly I want to take advantage of a feature called "Associated Audits". Since I have a number of records that have a has_many association it would be nice if the audit logs would also be recorded against the "parent" record whenever a "child" record is changed. To do that I can do the following:

class ItemSellPack < ApplicationRecord
  has_many :item_sell_pack_aliases, dependent: :destroy
  has_associated_audits

  audited
end
class ItemSellPackAlias < ApplicationRecord
  belongs_to :item_sell_pack

  audited associated_with: :item_sell_pack
end

Now I'll try this to see that it works as I want:

 > a = ItemSellPackAlias.last 
  ItemSellPackAlias Load (0.1ms)  SELECT "item_sell_pack_aliases".* FROM "item_sell_pack_aliases" ORDER BY "item_sell_pack_aliases"."id" DESC LIMIT $1  [["LIMIT", 1]]
 => #<ItemSellPackAlias:0x00007fc4e9178a80 id: 1, item_sell_pack_id: 1, alias: "ctn", confirmed: false, created_at: Mon, 13 Jun 2022 10:15:52.726828000 UTC +00:00, updated_at: Mon, 13 Jun 2022 10:21:09.438164000 UTC +00:00> 

 > a.update(alias: 'cart')
  TRANSACTION (0.2ms)  BEGIN
  Audited::Audit Maximum (0.4ms)  SELECT MAX("audits"."version") FROM "audits" WHERE "audits"."auditable_id" = $1 AND "audits"."auditable_type" = $2  [["auditable_id", 1], ["auditable_type", "ItemSellPackAlias"]]
  Audited::Audit Create (0.3ms)  INSERT INTO "audits" ("auditable_id", "auditable_type", "associated_id", "associated_type", "user_id", "user_type", "username", "action", "audited_changes", "version", "comment", "remote_address", "request_uuid", "created_at") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING "id"  [["auditable_id", 1], ["auditable_type", "ItemSellPackAlias"], ["associated_id", 1], ["associated_type", "ItemSellPack"], ["user_id", nil], ["user_type", nil], ["username", nil], ["action", "update"], ["audited_changes", "{\"alias\":[\"ctn\",\"cart\"]}"], ["version", 4], ["comment", nil], ["remote_address", nil], ["request_uuid", "a15b7721-3cf1-4e83-a5e3-9248384eac57"], ["created_at", "2022-06-13 10:21:39.889941"]]
  ItemSellPackAlias Update (0.2ms)  UPDATE "item_sell_pack_aliases" SET "alias" = $1, "updated_at" = $2 WHERE "item_sell_pack_aliases"."id" = $3  [["alias", "cart"], ["updated_at", "2022-06-13 10:21:39.888321"], ["id", 1]]
  TRANSACTION (1.6ms)  COMMIT
 => true 

> pack = ItemSellPack.last 
  ItemSellPack Load (0.2ms)  SELECT "item_sell_packs".* FROM "item_sell_packs" ORDER BY "item_sell_packs"."id" DESC LIMIT $1  [["LIMIT", 1]]
 => #<ItemSellPack:0x00007fc4dcb25a40 id: 1, name: "carton", canonical: true, created_at: Mon, 13 Jun 2022 10:02:24.001526000 UTC +00:00, updated_at: Mon, 13 Jun 2022 10:04:20.654292000 UTC +00:00> 

 > pack.associated_audits 
  Audited::Audit Load (0.3ms)  SELECT "audits".* FROM "audits" WHERE "audits"."associated_id" = $1 AND "audits"."associated_type" = $2  [["associated_id", 1], ["associated_type", "ItemSellPack"]]
 =>                                                                      
[#<Audited::Audit:0x00007fc4df419aa0                                     
  id: 5,                                                                 
  auditable_id: 1,                                                       
  auditable_type: "ItemSellPackAlias",                                   
  associated_id: 1,                                                      
  associated_type: "ItemSellPack",                                       
  user_id: nil,
  user_type: nil,
  username: nil,
  action: "update",
  audited_changes: {"alias"=>["ctn", "cart"]},
  version: 4,
  comment: nil,
  remote_address: nil,
  request_uuid: "a15b7721-3cf1-4e83-a5e3-9248384eac57",
  created_at: Mon, 13 Jun 2022 10:21:39.889941000 UTC +00:00>]

That appears to work successfully. I can ask the ItemSellPack for its associated audits and see where I had changed the alias from ctn to cart.

I do not need to add any minitest test cases for this as I have not written any new code myself. I should rely on the audited gem having its own suite of tests.