Using ActiveSupport::Notifications and ActiveSupport::Concern To Create An Audit Trail


In my previous post, I outlined a scenario that needs to be audited for HIPAA compliance – a patient with a list of medications. Every time a medication is added, updated or deleted, an audit record has to be created to tell us who made the change, what the change was, and what patient it was for. I also outlined my desire to use an event aggregator pattern or other pub/sub pattern on the backend of my system to facilitate this and other functionality. After some discussion via that blog post and twitter, I came across ActiveSupport::Notifications. I also remembered seeing a tweet from DHH talking about the use of ActiveSupport::Concerns. The combination of these two things makes for a very nice audit system… though I do still have a few issues to work out regarding specific data I need in the audit record.

Auditing With ActiveSupport::Notifications

There’s a lot of great information on this subject around the interwebertubes, so it was easy for me to learn about it and how to use it. Here’s a few of the places I read / watched:

As it turns out, the implementation of this part of ActiveSupport is basically an event aggregator. This made it an easy choice for me to use for my auditing needs, and possibly useful for other needs, too.

The first thing I want to do for my auditing is have a database record created every time a patient medication is saved. To do this, I can use an after_save callback, and instrument the audit from the Medication class directly.

Here’s the medication class with the call to the instrumentation.

# app/models/medication.rb
class Medication
  include Mongoid::Document
  after_save :audit

  embedded_in :patient

  private

  def audit
    ActiveSupport::Notifications.instrument(:audit,
      :event_name => "modified patient medication",
      :audited_object => self,
      :patient => patient,
      :current_user => Thread.current[:user]
    )
  end
end

Then I need to set up a subscriber for the :audited event and have it create the audit record. Here’s the code for the Audit class and the subscriber that does the work.

# app/models/audit.rb
class Audit
  include Mongoid::Document
  include Mongoid::Timestamps
end

# lib/initializers/audit_notification_subscriber.rb
ActiveSupport.Notifications.subscribe(/audit) do |*args|
  data = args.last
  event_name = data[:event_name]
  audited_object = data[:audited_object]
  current_user = data[:current_user]
  patient = data[:patient]

  audit_data = {
    :event => event_name,
    :modified_by => {
      :name => current_user.full_name,
      :email => current_user.email
    },
    :data => audited_object.audit_data,
    :patient => {
      patient.full_name,
      patient.patient_id
    }
  }

  Audit.create! audit_data
end

This is a fairly simple setup. Whenever my medication class is saved, the audit subscriber will create an audit record with all of the data that I need. From here, I can add auditing for updated, deletes, and other callbacks on the model.

Simplifying Audits With ActiveSupport::Concern

DHH talked about this in a tweet and pointed to a gist that outlines one way he is using concerns to keep his models clean. There’s a good write up on concerns over at OpenSoul.org, to get more familiar with them.

I liked this idea and thought it would be fun to see if it helped me with my auditing. After all, my model really isn’t concerned with auditing detail, other than knowing that it needs to be audited. With that in mind, I wrote a simple concern that I can include in my models and have them audited without me having to worry about the detail within the model.

# lib/audited.rb
module Audited
  extend ActiveSupport::Concern

  included do
    after_save :audit
  end

  def audit_data
    if respond_to? :attributes
      self.attributes
    else
      fail "No audit data available for #{self.class.name}. Please add an #audit_data method and return a hash of data from it."
    end
  end

  def audit
    event_name = "Save #{self.class.name.split("::").last}"
    ActiveSupport::Notifications.instrument :audit, :event_name => event_name, :current_user => Thread.current[:user], :audited_object => self
  end
end

This module sets up the after_save callback for me, allowing me to remove that from my Medication model which keeps the model cleaner. It then sets up the audit method which does the call into the ActiveSupport::Notifications, as well.

My medications model is now much more simple than it was, previously:

# app/models/medication.rb
class Medication
  include Mongoid::Document
  include Audited

  embedded_in :patient
end

A Few Remaining Problems

Unfortunately this solution isn’t quite as perfect as I had hoped. There are a few remaining problems and ugly things that I haven’t figured out how to clean up, for my specific scenario. I do think that the idea is good, in general, but I obviously need to do a little more work.

Access To The Current User via Thread.current[:user]

I know this is ugly, but I don’t know of any other way for my audit code to get access to the current_user … the user that is currently making the request to update the patient’s medication list. I’ve read through several blog posts and stack overflow questions, and this seems to be the “best” (meaning, least ugly and horrible) way that I could find. If anyone could point me in a better direction, I would really appreciate it.

Access To The Current Patient

You’ll notice in the Concern version of the auditing, that there is a distinct lack of code to provide the patient to the audit trail. Since I moved this code into the concern, I don’t have knowledge of how to get the patient from the class that the concern is included in. I have no guarantee that there is a .patient method on the class, to get the current patient that the user is working with. The best solution I could come up with for this, is to use a session variable to set the patient when we load the patient the first time, and read that session variable from within the concern. Again, I think this is ugly, but I don’t know what else to do. Any suggestions you might have for this is also appreciated.

Are Notifications Overkill, In This Scenario?

I think they might be, honestly. I could easily move the code that does the audit creation into the ‘audit’ method of the Audited concern, and remove the notifications entirely. I’m not sure it adds any value to have a notification for this purpose. I’m probably going to remove the notification. I’m leaving it in this blog post, though, to show you how I progressed through my audit needs to end up where I am right now.

How do you handle simple pub-sub, evented architecture in rails apps?