Skip to content

Commit

Permalink
feat(logger): introduce audit logging
Browse files Browse the repository at this point in the history
  • Loading branch information
vkrizan committed Feb 2, 2021
1 parent e399f45 commit 04d8aa0
Show file tree
Hide file tree
Showing 5 changed files with 215 additions and 1 deletion.
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,36 @@ Or install it yourself as:

## Usage

#### Insights::Api::Common::Filter
### Insights::Api::Common::AuditLog

Middleware and utility to add entries to an audit log. By default it logs them to STDOUT,
but eventually it can be tied into CloudWatch.
Log entries are formatted into JSON, split by a new-line.

Rails setup:
```
class Application < Rails::Application
...
require 'insights/api/common'
config.middleware.use Insights::API::Common::AuditLog::Middleware
end
```

Optionally the logger can be set up with:
```
Insights::API::Common::AuditLog.setup(Logger.new)
```

Setting context of an account (e.g. an authenticated account) can be done with these options
```
Insights::API::Common::AuditLog.with_account('12345')
Insights::API::Common::AuditLog.with_account('12345') do
# limited context
end
```

### Insights::Api::Common::Filter

| Supported Comparators | Comparator |
| --------------------- | ---------- |
Expand Down
1 change: 1 addition & 0 deletions lib/insights/api/common.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
require "insights/api/common/audit_log"
require "insights/api/common/custom_exceptions"
require "insights/api/common/engine"
require "insights/api/common/entitlement"
Expand Down
41 changes: 41 additions & 0 deletions lib/insights/api/common/audit_log.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# frozen_string_literal: true

require_relative 'audit_log/formatter'
require_relative 'audit_log/middleware'

module Insights::API::Common
# Audit Logger into selected logger, but primarily to CloudWatch
class AuditLog
class << self
def logger
@logger ||= init_logger
end

def logger=(logger)
@logger = init_logger(logger)
end

def setup(logger = nil)
self.logger = logger
end

def with_account(account_number)
original = Thread.current[:audit_account_number]
Thread.current[:audit_account_number] = account_number
return unless block_given?

yield
Thread.current[:audit_account_number] = original
end

private

def init_logger(logger = nil)
logger ||= Logger.new($stdout)
logger.level = Logger::INFO
logger.formatter = Formatter.new
logger
end
end
end
end
51 changes: 51 additions & 0 deletions lib/insights/api/common/audit_log/formatter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# frozen_string_literal: true

require "manageiq/loggers"

module Insights::API::Common
class AuditLog
class Formatter < ManageIQ::Loggers::Container::Formatter
ALLOWED_PAYLOAD_KEYS = %i[message account_number controller remote_ip
transaction_id].freeze

def call(severity, time, progname, msg)
payload = {
:'@timestamp' => format_datetime(time),
:hostname => hostname,
:pid => $PROCESS_ID,
:thread_id => thread_id,
:service => progname,
:level => translate_error(severity),
:account_number => account_number
}
JSON.generate(merge_message(payload, msg).compact) << "\n"
end

def merge_message(payload, msg)
if msg.kind_of?(Hash)
payload.merge!(msg.slice(*ALLOWED_PAYLOAD_KEYS))
else
payload[:message] = msg2str(msg)
end
payload[:transaction_id] = transaction_id if payload[:transaction_id].blank?
payload
end

private

def format_datetime(time)
time.utc.strftime('%Y-%m-%dT%H:%M:%S.%6NZ')
end

def account_number
Thread.current[:audit_account_number]
end

def transaction_id
ActiveSupport::Notifications.instrumenter.id
# TODO: Sidekiq job id
# TODO: Racecar id
end
end
end
end
92 changes: 92 additions & 0 deletions lib/insights/api/common/audit_log/middleware.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
module Insights::API::Common
class AuditLog
class Middleware
attr_reader :logger, :evidence, :request, :status

def initialize(app)
@app = app
@logger = AuditLog.logger
@subscribers = []
@evidence = {}
end

def call(env)
subscribe
@request = ActionDispatch::Request.new(env)
@app.call(env).tap do |status, _headers, _body|
@status = status
response_finished
end
ensure
unsubscribe
end

private

def response_finished
payload = {
:controller => evidence[:controller],
:remote_ip => request.remote_ip,
:message => generate_message
}
log(payload)
end

def generate_message
status_label = Rack::Utils::HTTP_STATUS_CODES[status]
msg = "#{request.method} #{request.original_fullpath} -> #{status} #{status_label}"
if evidence[:unpermitted_parameters]
msg += "; unpermitted params #{fmt_params(evidence[:unpermitted_parameters])}"
end
if evidence[:halted_callback].present?
msg += "; filter chain halted by :#{evidence[:halted_callback]}"
end
msg
end

def log(payload)
if status < 400
logger.info(payload)
elsif status < 500
logger.warn(payload)
else
logger.error(payload)
end
end

def subscribe
@subscribers << subscribe_conroller
end

def subscribe_conroller
ActiveSupport::Notifications.subscribe(/\.action_controller$/) do |name, _started, _finished, _unique_id, payload|
# https://guides.rubyonrails.org/active_support_instrumentation.html#action-controller
case name.split('.')[0]
when 'process_action'
@evidence[:controller] = fmt_controller(payload)
when 'halted_callback'
@evidence[:halted_callback] = payload[:filter]
when 'unpermitted_parameters'
@evidence[:unpermitted_parameters] = payload[:keys]
end
end
end

def unsubscribe
@subscribers.each do |sub|
ActiveSupport::Notifications.unsubscribe(sub)
end
end

def fmt_controller(payload)
return if payload[:controller].blank?

[payload[:controller], payload[:action]].compact.join('#')
end

def fmt_params(params)
params.map { |e| ":#{e}" }.join(", ")
end
end
end
end

0 comments on commit 04d8aa0

Please sign in to comment.