collectiveidea / audited

Audited (formerly acts_as_audited) is an ORM extension that logs all changes to your Rails models.
MIT License
3.39k stars 686 forks source link

How to associate additional metadata with an audit? #194

Open mgraham opened 10 years ago

mgraham commented 10 years ago

In our system, an admin user can impersonate another user. In our audit trail, we'd like to store both the effective user and the real user (admin).

What would be the best way to approach this?

werdlerk commented 6 years ago

Hello @mgraham, I'm facing the same issue. Have you found a solution since making this issue with storing extra admin user information?

Thanks!

tbrisker commented 6 years ago

Perhaps add an after_create callback on audit that adds that information in the audit comment, or add another field in your audit table and fill that in similarly

werdlerk commented 6 years ago

Hi @tbrisker, I think that is a good place to start but this requires me to store the current AdminUser into the Thread.current hash for saving into the Audit in the after_create callback. I rather modify the Audited::Sweeper class to get the current AdminUser directly from the controller.

However, there doesn't seem to be any other possibility at the moment (apart from coding everything myself). Thanks for your comment and pointer, much appreciated :-)

I'll see if I can get this working. Once it's working, I'll post my results here.

werdlerk commented 6 years ago

Unfortunately it seems the 3.0 version doesn't allow subclassing the Audited::Audit for a custom Audit model as it's a Module.

werdlerk commented 6 years ago

So far, I have managed to get it working using the after_audit callback. I put it in a concern Module for ease of use.

I put all the code in this gist: https://gist.github.com/werdlerk/3cf429b7e2495c125749ce5260dffdf5

It's not the best solution but it works for now.

mustmodify commented 2 years ago

Using v5

I'm trying to implement this idiomatically. IE not hackishly. Notes:

I added an impersonator_id column to the audits table.

I noticed that Sweeper has a list of the attributes that will be taken from the environment in Audited::Sweeper::STORED_DATA. It isn't so much designed to be extended since it's a const, but it's Ruby so... 😂 I gave it a shot using the pattern for current_user

Audited::Sweeper::STORED_DATA[:impersonator] = :impersonator

module Audited
  class Sweeper
    def impersonator
      lambda { 
        Rails.logger.info("[ GETTING IMPERSONATOR ] =========================== \n#{{session_id: controller.try(:user_session).try(:id), impersonator: controller.try(:impersonator)}.inspect}\n\n")
        controller.try(:impersonator) 
      }
    end
  end
end

The impersonator method was called but the lambda was not.

But then:

jw@logopolis:/projects/client/project$ bin/rails c
Running via Spring preloader in process 28340
Loading development environment (Rails 6.1.4.6)

>> Audited::Sweeper::STORED_DATA
=>
{:current_remote_address=>:remote_ip,
 :current_request_uuid=>:request_uuid,
 :current_user=>:current_user}
>> exit

jw@logopolis:/projects/client/project$ spring stop
Spring stopped.

jw@logopolis:/projects/client/project$ bin/rails c
Running via Spring preloader in process 28454
Loading development environment (Rails 6.1.4.6)
>> Audited::Sweeper::STORED_DATA
=>
{:current_remote_address=>:remote_ip,
 :current_request_uuid=>:request_uuid,
 :current_user=>:current_user,
 :impersonator=>:impersonator}

😠

Anyway... the impersonator lambda still wasn't called. I copied this method and named it current_user and then it stopped tracking the current user, also, so at least I felt like I was headed vaguely in the correct direction.

Next I tried to (a) see if auditor just returned a user, would it be included? (b) make my version of current_user work.


    def current_user
      lambda { 
        Rails.logger.info("[ audit / current_user ] =========================== \n#{{session_id: controller.send(:user_session).try(:id), current_user: controller.send(Audited.current_user_method)}.inspect}\n\n")
        controller.send(Audited.current_user_method) if controller.respond_to?(Audited.current_user_method, true)
      }
    end

    def impersonator
      lambda { 
        User.last
      }
    end

Interesting... 🤔 I learned that controller.try(:whatever) results in nil, but controller.send(:whatever) results in a value... 🤷 Seems wrong to me. My current_user method was now being used, and current_user was being audited again but impersonator was not. Time to dig into this hash to see where it's used. Disappointingly, it is only used to gather data.

Sweeper is used in an around_action. The data is then used here... making progress!

module Audited
  class Audit < ::ActiveRecord::Base
    before_create :set_impersonator

    belongs_to :impersonator, required: false, class_name: 'User'

    def set_impersonator
      self.impersonator ||= ::Audited.store[:impersonator].try!(:call) # from Sweeper
      nil # prevent stopping callback chains
    end
end

And then...

  Audited::Audit Create (1.2ms)  INSERT INTO "audits" ("auditable_id", "auditable_type", "user_id", "user_type", "action", "audited_changes", "version", "remote_address", "request_uuid", "created_at", "impersonator_id") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING "id"  [["auditable_id", 4], ["auditable_type", "SomeThing"], ["user_id", 3], ["user_type", "User"], ["action", "update"], ["audited_changes", "{\"status_id\":[3,9]}"], ["version", 32], ["remote_address", "192.168.1.22"], ["request_uuid", "7c4d1bb7-8d36-4851-add6-a41f5a66249f"], ["created_at", "2022-04-20 17:18:39.712769"], ["impersonator_id", 7]]

and that did it! Impersonator is now being stored in the audits table.

So the final code. We're defining it in config/initializers/audited.rb. Note that means you need to restart Rails for it to take effect... not necessarily what I would have chosen but there was already some audited-gem-related stuff here so I'm going with our convention.

Audited::Sweeper::STORED_DATA[:impersonator] = :impersonator

module Audited
  class Sweeper
    def impersonator
      lambda { 
        controller.send(:impersonator)
      }
    end
  end
end

module Audited
  class Audit < ::ActiveRecord::Base
    before_create :set_impersonator
    belongs_to :impersonator, required: false, class_name: 'User'

    def set_impersonator
      self.impersonator ||= ::Audited.store[:impersonator].try!(:call) # from Sweeper
      nil # prevent stopping callback chains
    end
  end
end