Tuesday, September 2, 2025

Building a Comprehensive Audit Trail for Letting Agency Compliance

Paul (Founder)
Compliance & Audit

On 2 September 2025, we implemented a comprehensive audit trail system that automatically tracks every significant action in the platform: property updates, photo changes, status modifications, and user activities. This wasn't an afterthought—for letting agencies subject to increasingly stringent regulations, detailed audit logs are legally required and operationally critical.

Regulatory frameworks like right to rent checks, deposit protection schemes, and safety certificate compliance all require documented evidence of when actions occurred, who performed them, and what changed. Insurance claims, tenancy disputes, and regulatory inspections demand this paper trail. The audit system transforms these compliance requirements from burdensome manual recordkeeping into automatic background logging.

What Your Team Will Notice

Staff see audit history throughout the application. On property detail pages, the activity feed shows recent changes: "Sarah updated rental price from £1,200 to £1,250 on 2 Sep at 14:35." On the global activities page (/activities), a chronological timeline shows all agency activity with time-based grouping (today, yesterday, this week, earlier).

When compliance officers need reports—say, for a council inspection or insurance claim—the audit logs provide evidence. Every certificate upload, every property status change, every tenant document is timestamped and attributed to specific users. The system tracks not just what changed, but the before/after values, enabling precise reconstruction of events.

The interface is deliberately unobtrusive. Staff don't need to remember to "log" actions—the system handles it automatically through Rails callbacks. The only visible element is the orange notification dot in the header when new activity occurs, providing awareness without interrupting workflow.

For agencies with multiple offices or franchise networks, audit trails provide accountability and visibility. Head office can review what actions regional negotiators performed. Franchise owners can monitor their teams. Quality assurance can spot patterns (e.g., one user consistently skipping required fields).

Under the Bonnet: The AuditLog Model

The audit trail uses a dedicated AuditLog model with polymorphic associations, enabling it to track changes across any model (properties, users, tenancies, certificates):

# app/models/audit_log.rb
class AuditLog < ApplicationRecord
  acts_as_tenant :agency

  belongs_to :agency
  belongs_to :user, optional: true  # System actions may not have users
  belongs_to :auditable, polymorphic: true, optional: true

  validates :action, presence: true
  validates :message, presence: true

  scope :recent, -> { order(created_at: :desc).limit(100) }
  scope :for_auditable, ->(auditable) { where(auditable: auditable) }
  scope :by_action, ->(action) { where(action: action) }

  # Format details as pretty JSON for display
  def formatted_details
    return nil unless details.present?

    JSON.pretty_generate(details)
  end

  # Human-readable timestamp
  def display_time
    if created_at.today?
      created_at.strftime("Today at %H:%M")
    elsif created_at.yesterday?
      created_at.strftime("Yesterday at %H:%M")
    else
      created_at.strftime("%d %b %Y at %H:%M")
    end
  end
end

The polymorphic auditable association allows one AuditLog record to reference any model. When auditing a Property update, auditable_type = "Property" and auditable_id = 42. When auditing a User change, auditable_type = "User" and auditable_id = 17. This flexibility means adding audit trails to new models requires no schema changes.

The acts_as_tenant :agency scoping ensures audit logs are tenant-isolated—Agency A cannot see Agency B's audit logs, maintaining multi-tenancy security.

The database migration creates the necessary tables and indexes:

# db/migrate/20250902122106_create_audit_logs.rb
class CreateAuditLogs < ActiveRecord::Migration[8.0]
  def change
    create_table :audit_logs do |t|
      t.references :agency, null: false, foreign_key: true
      t.references :user, null: true, foreign_key: true
      t.string :action, null: false
      t.references :auditable, polymorphic: true, null: true
      t.jsonb :details, default: {}
      t.text :message, null: false
      t.timestamps
    end

    add_index :audit_logs, [:agency_id, :created_at]
    add_index :audit_logs, [:auditable_type, :auditable_id]
    add_index :audit_logs, [:user_id, :created_at]
  end
end

The jsonb column type (PostgreSQL-specific) stores structured details (field changes, before/after values) efficiently while allowing queries against JSON content. The indexes optimize common queries:

  • "Show recent activity for this agency"
  • "Show activity for this property"
  • "Show activity by this user"

The Auditable Concern: Automatic Change Tracking

Rather than manually inserting audit log code into every controller action, we use the Auditable concern that models include:

# app/models/concerns/auditable.rb (excerpt)
module Auditable
  extend ActiveSupport::Concern

  included do
    after_create :audit_creation
    after_update :audit_update
    after_destroy :audit_destruction
  end

  module ClassMethods
    def audit_activity(action:, auditable: nil, details: {}, message:, user: nil, async: false)
      current_agency = ActsAsTenant.current_tenant
      current_user = user || Current.user

      return unless current_agency

      if async
        AuditLogJob.perform_later(
          agency_id: current_agency.id,
          user_id: current_user&.id,
          action: action.to_s,
          auditable: auditable,
          details: details,
          message: message
        )
      else
        AuditLog.create!(
          agency: current_agency,
          user_id: current_user&.id,
          action: action.to_s,
          auditable: auditable,
          details: details,
          message: message
        )
      end
    end
  end

  private

  def audit_creation
    self.class.audit_activity(
      action: :create,
      auditable: self,
      message: "#{self.class.name} created",
      async: true
    )
  end

  def audit_update
    return unless saved_changes.any?

    fields_changed = saved_changes.keys - %w[updated_at]
    return unless fields_changed.any?

    details = {}
    fields_changed.each do |field|
      old_value, new_value = saved_changes[field]
      details[field] = { from: old_value, to: new_value }
    end

    self.class.audit_activity(
      action: :update,
      auditable: self,
      details: details,
      message: generate_update_message(fields_changed),
      async: true
    )
  end

  def audit_destruction
    self.class.audit_activity(
      action: :destroy,
      auditable: self,
      message: "#{self.class.name} deleted",
      async: false  # Immediate, before record disappears
    )
  end

  def generate_update_message(fields_changed)
    key_fields = fields_changed & %w[headline price status reference]

    if key_fields.any?
      changes = key_fields.map { |f| "#{f.humanize}: #{send(f)}" }.join(", ")
      "#{self.class.name} updated: #{changes}"
    else
      "#{self.class.name} updated (#{fields_changed.count} fields changed)"
    end
  end
end

Models include this concern:

# app/models/property.rb
class Property < ApplicationRecord
  include Auditable

  # ... rest of model definition
end

Now, whenever a Property is created, updated, or destroyed, the callbacks fire automatically, creating audit log entries. The saved_changes method (provided by Rails) contains the before/after values of changed fields, which we store in the details JSON column.

The async: true flag delegates audit log creation to background jobs, preventing audit overhead from slowing down user-facing requests. Destruction audits use async: false because we need the log created before the record disappears.

Background Job Processing with AuditLogJob

The background job handles audit log creation asynchronously:

# app/jobs/audit_log_job.rb
class AuditLogJob < ApplicationJob
  queue_as :default

  def perform(agency_id:, user_id:, action:, auditable:, details:, message:)
    # Set tenant context for multi-tenancy
    ActsAsTenant.current_tenant = Agency.find(agency_id)

    AuditLog.create!(
      agency_id: agency_id,
      user_id: user_id,
      action: action,
      auditable: auditable,
      details: details,
      message: message
    )
  rescue ActiveRecord::RecordNotFound => e
    # Agency was deleted between queuing and processing
    Rails.logger.error("Audit log job failed: #{e.message}")
  end
end

Using SolidQueue (configured in Week 35), these jobs process in the background. If audit log creation takes 50ms (database writes, JSON serialization), that's 50ms saved from the user's request/response cycle. For high-traffic periods, this improves perceived performance significantly.

User Context Tracking with Current Model

To attribute actions to specific users, we need the current user accessible throughout the request cycle. The Current model provides thread-safe storage:

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :user
end

The ApplicationController sets this at the start of each request:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_current_user

  private

  def set_current_user
    Current.user = current_user if user_signed_in?
  end
end

Now, anywhere in the codebase—models, concerns, jobs—Current.user returns the user making the request. This enables automatic user attribution without passing user objects through method parameters.

ActiveSupport::CurrentAttributes uses thread-local storage, ensuring concurrent requests don't interfere. Request A's Current.user doesn't leak into Request B, even in multi-threaded application servers.

The Activities Controller: Displaying Audit Logs

The ActivitiesController presents audit logs to users:

# app/controllers/activities_controller.rb (excerpt)
class ActivitiesController < ApplicationController
  before_action :authenticate_user!

  def index
    @audit_logs = AuditLog.includes(:user, :auditable)
                          .order(created_at: :desc)
                          .page(params[:page])
                          .per(50)

    # Group by time periods for better UX
    @grouped_logs = group_logs_by_time(@audit_logs)
  end

  private

  def group_logs_by_time(logs)
    {
      today: logs.select { |log| log.created_at.today? },
      yesterday: logs.select { |log| log.created_at.yesterday? },
      this_week: logs.select { |log| log.created_at > 7.days.ago && !log.created_at.today? && !log.created_at.yesterday? },
      earlier: logs.select { |log| log.created_at <= 7.days.ago }
    }
  end
end

The .includes(:user, :auditable) eager loading prevents N+1 queries when displaying user names and auditable objects. Time-based grouping (today, yesterday, this week, earlier) makes the feed easier to scan—a UX detail inspired by messaging apps.

The view presents this data cleanly:

<!-- app/views/activities/index.html.erb -->
<div class="activity-feed">
  <% @grouped_logs.each do |period, logs| %>
    <% next if logs.empty? %>

    <h3 class="text-lg font-semibold text-black dark:text-white mb-4">
      <%= period.to_s.titleize %>
    </h3>

    <% logs.each do |log| %>
      <div class="activity-item flex items-start gap-4 p-4 bg-white dark:bg-boxdark rounded-lg shadow mb-4">
        <div class="activity-icon">
          <%= activity_icon(log.action) %>
        </div>

        <div class="activity-content flex-1">
          <p class="text-sm text-black dark:text-white font-medium">
            <%= log.message %>
          </p>

          <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
            <%= log.user&.display_name || "System" %> • <%= log.display_time %>
          </p>

          <% if log.auditable %>
            <%= link_to "View", polymorphic_path(log.auditable), class: "text-primary hover:underline text-xs" %>
          <% end %>
        </div>
      </div>
    <% end %>
  <% end %>
</div>

The polymorphic_path helper generates correct URLs regardless of auditable type—property_path(property) for properties, user_path(user) for users, etc.

Performance Considerations

Audit logging introduces performance concerns—every write operation now creates an additional database record. Our mitigations:

Background processing: Audit log creation happens asynchronously (except deletions), removing overhead from the request cycle.

Selective auditing: Not every field change needs auditing. We exclude trivial fields like updated_at that change on every save.

Batch operations: For bulk imports or batch updates, we can disable auditing temporarily:

Property.without_auditing do
  Property.import(csv_data)
end

Index optimization: Database indexes on [agency_id, created_at] make recent activity queries fast, even with millions of audit log records.

Retention policies: We can implement automatic pruning of old audit logs (e.g., archive logs older than 7 years) to control database size, though regulatory requirements often mandate longer retention.

Testing Audit Functionality

The comprehensive test suite (98.8% coverage achieved this week) includes audit trail tests:

# spec/models/concerns/auditable_spec.rb
RSpec.describe Auditable do
  let(:agency) { create(:agency) }
  let(:user) { create(:user, agency: agency) }
  let(:property) { create(:property, agency: agency) }

  before do
    ActsAsTenant.current_tenant = agency
    Current.user = user
  end

  describe "audit_creation" do
    it "creates audit log when property is created" do
      expect {
        create(:property, agency: agency)
      }.to have_enqueued_job(AuditLogJob).with(
        hash_including(action: "create")
      )
    end
  end

  describe "audit_update" do
    it "captures field changes" do
      property.update(price: 1500, headline: "Updated Property")

      expect(AuditLogJob).to have_been_enqueued.with(
        hash_including(
          action: "update",
          details: hash_including("price", "headline")
        )
      )
    end

    it "skips audit if only updated_at changed" do
      property.touch

      expect(AuditLogJob).not_to have_been_enqueued
    end
  end
end

These tests verify auditing works correctly, captures appropriate data, and skips trivial changes.

What's Next

The audit trail foundation enables future enhancements:

  • Compliance reports: Generate regulatory reports from audit logs
  • Change notifications: Email alerts when critical fields change
  • Audit log exports: CSV/PDF exports for insurance claims or legal proceedings
  • Advanced filtering: Search audit logs by user, date range, or action type
  • Restoration: Use audit logs to restore deleted records or revert changes

But the core system—automatic, comprehensive, multi-tenant audit logging with user attribution—was established on 2 September, providing the compliance foundation letting agencies require.


Related articles: