Dev.to Security ๐Ÿ” Cybersecurity ๐Ÿ‘ 0 ๐Ÿ“– 13 min read

Your Rails audit log has 4 blind spots. Auditors know all of them.

By the Steadybyte team Search for "Rails audit log" and you'll find good answers: paper_trail, audited, logidze, and many tutorials that add an after_save callback and an audits table. They all answer one question well:

By the Steadybyte team

Search for "Rails audit log" and you'll find good answers: paper_trail, audited, logidze, and many tutorials that add an after_save callback and an audits table. They all answer one question well: what changed, and who changed it?

That's often enough until your first HIPAA risk assessment, SOC 2 audit or enterprise security questionnaire. Then the questions get wider. This post walks through those questions, shows where change tracking stops, and explains how to close each gap with plain Rails and PostgreSQL. At the end we show the gem we're building for this, but everything before that section works without it.

Not legal advice. This post is about engineering, not law. Which rules apply to you, and what counts as enough evidence, is for your compliance team and your auditor to decide.

A quick glossary

  • PHI (protected health information): under HIPAA, health information that can be tied to a person, such as a diagnosis on a patient record.
  • HIPAA audit controls: 45 CFR ยง164.312(b) requires "hardware, software, and/or procedural mechanisms that record and examine activity in information systems that contain or use electronic protected health information." It doesn't list which fields to log. A related rule, ยง164.308(a)(1)(ii)(D), asks you to "regularly review records of information system activity, such as audit logs, access reports, and security incident tracking reports."
  • SOC 2: an audit report based on the AICPA's Trust Services Criteria. Audit logs usually come up under the common criteria for logical and physical access controls (CC6) and system operations, which include monitoring (CC7). Auditors ask for evidence, not just a description.
  • Evidence: something you can hand an auditor, such as an export, a report or a screenshot, that shows a control actually operated.

The five questions

Here are the questions an auditor or a customer's security team commonly asks about an audit trail:

  1. Who changed this record, and what did it look like before?
  2. Who viewed or exported this sensitive data?
  3. How do you know nobody edited or deleted the log?
  4. How long do you keep it, is that enforced, and how does it fit with deletion requests?
  5. Can you show me? For example, who accessed patient X last quarter, and what did admins do?

Change-tracking gems are built for question 1. Teams often end up answering the other four by hand, shortly before the audit.

Question 1: what changed (the part that's already solved)

The existing tools deserve proper credit here, because they do this well:

  • paper_trail writes a row to a versions table on create, update, destroy and touch. The row stores the record's previous state, and optionally the diff in an object_changes column. The whodunnit column holds the user, set by a set_paper_trail_whodunnit controller callback. You can reify any version to see or restore the record as it was.
  • audited writes to an audits table with the changed values, the current user, a version number and an optional comment. In controller requests it also stores the remote IP and a request UUID. It can link audits to a parent record (associated_with) and rebuild old versions (revision, revision_at).
  • logidze (PostgreSQL only) uses database triggers to keep a record's history in a log_data JSONB column, on the same row by default. Because it works in the database, bulk updates such as update_all are logged too. Its README is clear that it is "designed to only track changes": when the row is deleted, its history goes with it.

If what you need is undo, a history view or debugging, these are excellent choices. None of the gaps below are bugs in these gems. They're simply outside what the gems were built to do.

Question 2: who viewed or exported it?

Why change tracking can't answer it: these gems record writes. When a support agent opens a patient's page, downloads a CSV of 4,000 customers, or an API client fetches /patients/42, nothing is written, so nothing is recorded.

With PHI and other personal data, reads are often what matters most. The worrying case is usually not someone changing a diagnosis. It's someone looking at records they had no reason to see.

Closing the gap yourself. Use a separate, narrow table for access events. Don't mix reads into your versions table: there are far more reads than writes, and they would bury the change history.

# db/migrate/xxxx_create_access_events.rb
create_table :access_events do |t|
  t.string   :actor_type
  t.string   :actor_id
  t.string   :action, null: false        # "show", "index", "export"
  t.string   :resource_type, null: false
  t.json     :resource_ids, null: false, default: []
  t.integer  :record_count, null: false
  t.string   :request_id
  t.string   :remote_ip
  t.datetime :created_at, null: false
end
add_index :access_events, [:resource_type, :created_at]

Then add a controller concern that each action calls explicitly:

# app/controllers/concerns/logs_access.rb
module LogsAccess
  extend ActiveSupport::Concern

  private

  # Call this after authorization has passed, just before the data goes out.
  def log_access(action, records, resource_type: nil)
    records = Array(records)
    AccessEvent.create!(
      actor_type: current_user&.class&.name,
      actor_id: current_user&.id&.to_s,
      action: action,
      resource_type: resource_type || records.first&.class&.base_class&.name || controller_name.classify,
      resource_ids: records.first(1000).map(&:id), # cap it; record_count has the total
      record_count: records.size,
      request_id: request.request_id,
      remote_ip: request.remote_ip,
      created_at: Time.current
    )
  end
end

class PatientsController < ApplicationController
  include LogsAccess

  def show
    @patient = Patient.find(params[:id]) # after your authorization check
    log_access("show", @patient)
  end

  def export
    patients = Patient.where(clinic_id: current_user.clinic_id).to_a
    log_access("export", patients, resource_type: "Patient")
    send_data PatientCsv.new(patients).to_csv, filename: "patients.csv"
  end
end

Things to watch for:

  • Log at the point of disclosure: after authorization, just before the data leaves. A before_action would also log requests that later fail.
  • Pay most attention to exports. One CSV can expose thousands of records, so record how many rows left and which ones.
  • Cover the other ways data leaves: JSON APIs, GraphQL resolvers, Active Storage downloads, admin panels, mailers that include PHI, and the Rails console.
  • Decide what happens if the log write fails. create! raises, so the response fails and no data goes out without a log entry. For the most sensitive data that's usually the easier choice to defend, but it should be a deliberate decision.

Question 3: can you prove the log wasn't edited?

Why an ordinary table falls short: an audit table is just a table. Anyone who can write to the database, whether a DBA, a leaked app credential or a buggy migration, can UPDATE or DELETE rows without leaving a trace. Marking the model read-only in Active Record stops your application code, but not SQL.

Two layers help, and it's worth having both.

Layer 1: append-only permissions. Give ownership of the table to a separate role, then let the app's database user insert and read but not change rows:

-- PostgreSQL. access_events is owned by a separate role (e.g. the one that
-- runs migrations), and the app connects as app_user.
REVOKE ALL ON access_events FROM app_user;
GRANT SELECT, INSERT ON access_events TO app_user;
GRANT USAGE ON SEQUENCE access_events_id_seq TO app_user;

The ownership part is important: a table's owner can always grant itself rights back. Run deletions for retention (below) under a separate, more privileged role used only by that job.

Layer 2: make tampering detectable with a hash chain. Each entry stores a hash of its own contents plus the previous entry's hash. If someone edits an old row, its hash no longer matches. If they delete a row, the next row's prev_digest points to a hash that isn't there. Either way, a verify pass catches it.

require "openssl"
require "json"

module AuditChain
  GENESIS = "GENESIS"

  module_function

  # Same content => same bytes: sort keys at every level.
  def canonical(value)
    case value
    when Hash  then value.to_h { |k, v| [k.to_s, canonical(v)] }.sort.to_h
    when Array then value.map { |v| canonical(v) }
    else value
    end
  end

  def digest_for(payload, prev_digest)
    OpenSSL::Digest::SHA256.hexdigest("#{prev_digest}|#{JSON.generate(canonical(payload))}")
  end

  # entries: oldest first, each responding to #payload, #prev_digest and #digest.
  # Returns the first entry that breaks the chain, or nil if it is intact.
  def verify(entries, start: GENESIS)
    prev = start
    entries.each do |entry|
      return entry unless entry.prev_digest == prev
      return entry unless entry.digest == digest_for(entry.payload, prev)

      prev = entry.digest
    end
    nil
  end
end

Appending has to be serialized. Otherwise two requests can read the same "previous" hash at once and fork the chain:

class ChainedEvent < ApplicationRecord # columns: payload (json), prev_digest, digest
  CHAIN_LOCK_KEY = 4242

  def self.append!(data)
    transaction do
      # PostgreSQL: one writer at a time; the lock is released at COMMIT/ROLLBACK.
      connection.execute("SELECT pg_advisory_xact_lock(#{CHAIN_LOCK_KEY})")
      prev = order(:id).last&.digest || AuditChain::GENESIS
      payload = AuditChain.canonical(data.merge(at: Time.current.utc.iso8601(6)))
      create!(payload: payload, prev_digest: prev, digest: AuditChain.digest_for(payload, prev))
    end
  end
end

AuditChain.verify(ChainedEvent.order(:id).find_each) # => nil, or the first bad row

What tutorials usually leave out:

  • Contention. A single global lock caps your write rate. Separate chains per tenant or per day spread the load.
  • Canonical serialization. Store values in the exact form you hashed: timestamps as fixed-precision strings, sorted keys. That way a JSON round trip, or a Rails upgrade, doesn't change the bytes.
  • Anchoring. Someone with full database access could rewrite the whole chain. Regularly copy the latest digest somewhere they can't reach, such as WORM storage (for example S3 Object Lock), a separate account, or a signed message, and verify against those copies.
  • Run verify on a schedule and alert on failure. A check nobody runs isn't a control.

Question 4: retention, and deletion requests

Why it gets awkward: change and access logs grow without limit unless you prune them. Two pressures pull in opposite directions:

  • Keep it long enough. HIPAA requires documentation required by the Security Rule to be kept for six years (ยง164.316(b)(2)(i)). Many teams apply that period to audit logs too. How it applies to your logs is a question for your compliance advisor. India's DPDP Rules, 2025 (Rule 6, which is being phased in) call for logs giving "visibility on the accessing of such personal data", kept for one year unless another law requires otherwise. Customer contracts often add their own periods.
  • Don't keep personal data forever. GDPR's storage limitation principle and right to erasure (Articles 5(1)(e) and 17), and India's DPDP Act, push you to delete personal data you no longer need.

The gems offer limits based on count rather than time: paper_trail's version_limit, audited's max_audits, and logidze's log size limit. paper_trail's README also shows how to delete old versions by date yourself. For a policy based on time, a nightly job works:

# Run with the privileged "retention" database role.
class AuditRetentionJob < ApplicationJob
  POLICIES = { AccessEvent => 6.years }.freeze

  def perform
    POLICIES.each do |model, period|
      expired = model.where(created_at: ...period.ago)
      archive!(expired)
      expired.in_batches(of: 10_000).delete_all
    end
  end

  private

  def archive!(relation)
    # Your code: e.g. write relation.find_each rows as compressed JSON lines
    # to an S3 bucket with Object Lock, before they are deleted.
  end
end

If the table is hash-chained, deleting the oldest rows makes verify fail where the chain now begins. Save a checkpoint instead: the digest of the last deleted row. Verify from there with AuditChain.verify(remaining, start: checkpoint_digest).

Erasure is harder. Deleting a person's entries breaks the chain and destroys evidence you may need to keep. Common ways around this:

  • Don't put raw personal data in the log at all. Store a keyed fingerprint (HMAC) or a masked value instead. You can still search by fingerprint, but the log never holds the value.
  • Hash each field, then chain the field hashes. To erase, replace a value with a marker but keep its field hash. The chain still verifies, and the personal data is gone.
  • Crypto-shredding. Encrypt each person's data with their own key, and delete the key when they ask to be erased.

Question 5: can you show me?

Auditors want artifacts. In practice that means things like:

  • Per-subject access history: everyone who viewed or changed patient 42 between January and March.
  • Access review: who accessed sensitive data, how often, and whether that fits their role.
  • Privileged activity: what admins and support staff did, including bulk operations and console work.
  • Integrity evidence: the result of the latest chain verification, with dates.

Once the data above exists, these are ordinary queries. The hard part is collecting that data reliably, in a consistent shape, before anyone asks for it.

Putting it together

paper_trail audited logidze DIY tutorial SealedTrail (free) SealedTrail Pro (early access)
Change history โœ… create, update, destroy, touch โœ… create, update, destroy, touch Inserts and updates; history is lost when the row is deleted โœ… usually โœ… create, update, destroy (touch opt-in) โœ…
View or restore old versions โœ… reify โœ… revision, revision_at โœ… at, undo!, redo! Rarely โŒ โŒ not planned
Who made the change โœ… whodunnit via controller callback โœ… current_user Via with_responsible Varies โœ… current_user, with fallback labels โœ…
Request ID and IP Via info_for_paper_trail metadata and your own columns โœ… in controller requests Via with_meta Varies โœ… in requests โœ…
Background jobs inherit the user Not built in Not built in Not built in Rarely โœ… Active Job โœ…
update_all / delete_all โŒ not recorded โŒ not recorded update_all logged by the trigger โŒ usually โœ… one summary entry each โœ…
Sensitive values skip: per attribute redacted: option; encrypts attributes filtered only / except columns Manual Redacted by default (common names and encrypts), plus mask and fingerprint options โœ…
Reads and exports logged โŒ โŒ โŒ If you build it โŒ Planned
Tamper-evident chain and verify task โŒ โŒ โŒ If you build it โŒ Planned
Retention Count limit per record Count limit per record Log size limit per record If you build it โŒ Planned: time-based, with erasure
Auditor reports โŒ โŒ โŒ If you build it โŒ Planned
Data stays in your own database โœ… โœ… โœ… โœ… โœ… โœ…

Based on each project's README and source as of October 2026 (paper_trail 17.0.0 and audited 5.8.0 for the update_all/delete_all row, which we checked by running them). Check their docs for the latest. If you need version restore, use paper_trail, audited or logidze. SealedTrail runs alongside paper_trail and audited.

Where SealedTrail fits

We're building SealedTrail for the questions above. It's open core.

The free core (MIT, available now) handles question 1, plus details that tend to come up in audits. Every entry has an actor, with the request ID and IP for changes made in a request. Active Job jobs inherit the user who enqueued them. Bulk operations are recorded, and sensitive values are redacted by default.

# Gemfile
gem "sealed_trail"
bundle install
bin/rails generate sealed_trail:install
bin/rails db:migrate
class Patient < ApplicationRecord
  has_sealed_trail(
    ignore: [:last_seen_at],
    redact: { ssn: :mask, insurance_id: :fingerprint, diagnosis: :redact },
    meta: { clinic_id: :clinic_id }
  )
end

# In controllers the actor is current_user. Elsewhere, set it explicitly:
SealedTrail.with_actor("nightly_import") { Importer.run }

SealedTrail.with_metadata(reason: "Support ticket #4521") do
  patient.update!(status: "discharged")
end

# Bulk operations get one summary entry, with the affected ids (capped):
Patient.where(clinic_id: 7).update_all(status: "archived")
SealedTrail::Entry.bulk.last.bulk_summary
# => {"operation"=>"update_all", "row_count"=>42, "ids"=>[...], "ids_truncated"=>false, "where_sql"=>"..."}

# Querying
SealedTrail::Entry.for_item_type(Patient).events(:update).for_actor(user).between(1.month.ago, nil)
SealedTrail::Entry.with_fingerprint("INS-12345") # entries whose insurance_id fingerprint matches

The free core has limits too. Entries are read-only through Active Record, but that isn't tamper evidence. Hand-written SQL (connection.execute) isn't captured, and reads aren't logged. The README lists all of this.

SealedTrail Pro (early access, not released yet) is aimed at questions 2โ€“5. A beta for founding customers is planned for December 2026, and v1.0 by 31 January 2027. The API below is planned and may change:

# PLANNED Pro API: illustrative, not available yet
class Patient < ApplicationRecord
  has_sealed_trail track_reads: true, retain_for: 6.years
end

SealedTrail.access_report(record: patient, since: 7.days.ago) # who viewed or changed it
# PLANNED
bin/rails sealed_trail:verify   # walks the hash chain, reports edited or missing entries

Planned Pro features:

  • read and export logging
  • a SHA-256 hash chain with append-only permissions and a verify task
  • retention per model, with erasure that keeps the chain verifiable
  • auditor reports: access reviews, privileged activity and per-subject history
  • import from paper_trail and audited

Everything stays in your own database, so there's no SaaS and no per-event pricing.

SealedTrail helps you produce evidence. It doesn't make an app HIPAA- or SOC 2-compliant by itself. That depends on your whole system, your policies and your auditor.

Try it, or tell us what we're missing

If you've been through a HIPAA or SOC 2 audit on Rails, we'd like to hear which of these questions hurt most, or which one we missed. Leave a comment, open an issue, or email [email protected].

The Steadybyte team

๐Ÿ“ฐ Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.