Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Ruby on Rails  Module 6 – Authentication and authorization

Admin areas and audit trails

Build a namespaced admin area with its own base controller, write an after_commit audit row for every state change, and compare the gems.

  • Intermediate
  • 20 minutes
  • Examples run with Ruby 4.0.7 and rails 8.1.4
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Build a namespaced admin area with its own base controller and policy
  • Compare admin gems with a hand-built admin
  • Record who changed what and when
  • Protect admin flags from mass assignment

Before you start

What you’ll learn · Before you start

What you will learn

  • Build a namespaced admin area with its own base controller and policy
  • Compare admin gems with a hand-built admin
  • Record who changed what and when
  • Protect admin flags from mass assignment

Before you start

On this page

An owner at the Kaksha coaching centre opens /admin/fee_waivers/42 and approves the student’s waiver. A regulator later asks who approved it, when, and from which request. The question has one honest answer only if the app wrote a record at the moment the state changed, with the actor, the diff and the request id. This lesson builds that record, inside an admin space of its own with a role check that non-admins never reach, and keeps the admin flag itself off the list of things a form may change.

The prerequisite on roles and tenancy fixed insecure direct object references by scoping queries; here the role check moves one level up, to a whole namespace only an admin enters, and the admin flag becomes a column no request can set. Both examples run on the pinned Rails 8.1.4: one sends four requests at a single waiver and keeps three audit rows, the other aims every kind of write at the admin flag and shows the one that gets through.

Why the admin area is its own namespace

A Rails app that treats admin screens as “another resource under a before_action” grows bugs with every new controller: one forgotten filter and a form a non-admin should never see is open to any logged-in user. The fix is structure, not vigilance: group every admin URL under /admin, give them an admin namespace: A Rails routing block, namespace :admin do … end, that groups a set of URLs under /admin and maps them to controllers under the Admin:: module; the admin base controller sits over every action. of their own, and inherit from a base controller that answers one question before any action runs: is the current session an admin’s?

Step through one request so you can see where the role check sits, and what still happens when it refuses:

Interactive · step by step One admin request, from signed-in to audit row Follow one owner approving a fee waiver. The branch marks what happens when the role check refuses.
One admin request, from signed-in to audit row, step 8 of 8. Showing Signed-in owner, namespace admin, Base controller (require_admin first), Role check (owner only), FeeWaivers update (state is approved), after_commit (actor, request, diff), audit_events row (one row per change), 403 forbidden. In focus: audit_events row.Signed-in ownernamespace adminBase controllerrequire_admin firstRole checkowner onlyFeeWaivers updatestate is approvedafter_commitactor, request, diffaudit_events rowone row per change403 forbiddenINSERT

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Request arrives on an admin URL. An owner opens PATCH /admin/fee_waivers/42 with a signed session cookie and a form that marks the waiver approved. The URL belongs to the admin space, which has its own routes.
  2. The admin namespace catches it. namespace :admin in config/routes.rb groups every admin URL under /admin and maps them to controllers in the Admin module, so this request heads for Admin::FeeWaiversController.
  3. The base controller runs first. Every admin controller inherits from Admin::BaseController, whose before_action :require_admin runs before any action. A non-admin login never reaches one.
  4. The role check decides. require_admin reads Current.membership.role. An owner continues; any other role gets head forbidden and no action runs. The dashed branch marks the refused path.
  5. A non-admin would be refused. A teacher or a guardian hitting the same URL ends here with HTTP 403 and nothing written. The next step is only reached by an admin role.
  6. The update runs. FeeWaivers#update writes the new state and amount_paise through strong parameters that never list admin or role. The transaction commits, which is the moment the next step hears about.
  7. The audit row is written. The FeeWaiver model has after_commit :record_audit. Rails calls it once for each record a committed transaction changed, with saved_changes as the diff. The callback hands the diff, the actor and the request id to AuditEvent.
  8. One row per state change. AuditEvent.create! writes a row with the time and request id, the entry point, the actor and the diff. The audit example further down runs it: create, update and destroy each leave one row, and a rolled-back change leaves none.

The scene names three Rails pieces that work together. config/routes.rb carries namespace :admin do resources :fee_waivers end, which maps /admin/fee_waivers onto Admin::FeeWaiversController. The controller inherits from Admin::BaseController, which has before_action :require_admin, so the role check runs before any admin action, not inside each one. require_admin reads the signed-in user’s membership from Current attributes: A class built on ActiveSupport::CurrentAttributes that holds per-request context, such as the signed-in user, isolated to the request's thread and emptied by Rails before and after each request and job. More in “Sessions, cookies, flash and Current” and calls head :forbidden for every role but owner. The same files also put a job dashboard behind the check, which the end of this section explains:

config/routes.rb, the admin base controller and the job dashboard Ruby · snippets/admin_namespace.rb
# config/routes.rb, app/controllers/admin/base_controller.rb and config/application.rb (shown, not run on their own)
# One namespace for the URLs, one base controller every admin action inherits from, and a job dashboard put behind it.

# config/routes.rb
Rails.application.routes.draw do
  namespace :admin do
    resources :fee_waivers
    resources :memberships, only: [:index, :update]
  end

  # An engine keeps its own controllers: the line in config/application.rb below gives them the admin check.
  mount MissionControl::Jobs::Engine, at: "/admin/jobs"
end

# app/controllers/admin/base_controller.rb
class Admin::BaseController < ApplicationController
  before_action :require_admin

  private

  def require_admin
    head :forbidden unless Current.membership&.role == "owner"
  end
end

# config/application.rb, inside class Application
config.mission_control.jobs.base_controller_class = "Admin::BaseController"
config.mission_control.jobs.http_basic_auth_enabled = false # the admin check above now guards the dashboard

One place to answer the role question

Every admin controller inherits from Admin::BaseController, so the role check lives in one file. A new admin controller gets the check for nothing; a request test for the base controller alone covers the whole area.

A request test proves the structure holds for every role the app knows:

A request test: a teacher gets 403 Ruby · snippets/admin_access_test.rb
# test/controllers/admin/fee_waivers_controller_test.rb (shown, not run on its own)
# The test that proves a non-owner cannot reach an admin page. sign_in_as comes from the session test helper that
# bin/rails generate authentication writes (test/test_helpers/session_test_helper.rb).
require "test_helper"

class Admin::FeeWaiversControllerTest < ActionDispatch::IntegrationTest
  test "a teacher is refused on admin pages" do
    sign_in_as users(:teacher_a)
    get admin_fee_waivers_path
    assert_response :forbidden
  end
end

Job dashboards and other mounted engines need the same gate, and the routes cannot give it to them: an engine draws its pages with controllers of its own. Mission Control for Jobs ships closed behind HTTP basic authentication, and its controllers inherit from your ApplicationController unless config.mission_control.jobs.base_controller_class names another class. Name Admin::BaseController there, so require_admin runs before every job page, and only then turn its own password prompt off with http_basic_auth_enabled = false. Mounting the engine inside namespace :admin would only move its URL under /admin.

Key idea

An admin area is a boundary, not a label: one namespace :admin and one base controller put the role check in front of every admin action, a request test on the base covers them all, and a mounted dashboard joins the boundary only through its own base-controller setting.

Checkpoint

Check: Admin namespace and base controller

  1. Question 1 of 2 A fresh Rails 8.1 app adds admin screens. Which routing block maps /admin/fee_waivers to Admin::FeeWaiversController?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: namespace :admin do resources :fee_waivers end

    namespace :admin wraps the URLs and the controller module in one go, so the admin base controller sits over every action. The Rails routing guide shows the same block with articles.

  2. Question 2 of 2 Admin::BaseController runs before_action :require_admin. What is the benefit over putting the same check in each controller?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A new admin controller inherits the check for nothing, and a request test on the base covers them all

    One place to answer "is this an admin?" means a dozen admin controllers inherit the same policy, and the request test on the base controller catches a non-admin before any action-specific test runs.

Never trust params for admin flags

Scoped queries kept a teacher from reading another centre’s batches. A different hole appears on the write side: a form that posts { user: { admin: true } } and reaches a model that happily sets the attribute. Mass assignment is how one line of a request body promotes someone to admin. Two defences stop it: one at the edge, where the request is read, and one in the model, where every write ends up.

The first is strong parameters, Rails’ allowlist: A rule that accepts only values on a known-good list and refuses everything else, rather than trying to list the values to reject, which always misses one. More in “Validations, errors and normalization” for request keys. A controller or form object calls params.permit(:name, :email_address) and whatever else arrives is dropped. The second is attr_readonly: A declaration on an Active Record model (attr_readonly :admin) that lets a column be set when a row is created and keeps it out of every later model write: save leaves it as it was, update_columns refuses it, and a new Rails 8.1 app raises on assignment. Only relation-level writes such as update_all still reach it., an Active Record declaration that lets a column be set when a row is created and keeps it out of every later write the model makes:

app/models/user.rb: admin is read-only Ruby · snippets/user_attr_readonly.rb
# app/models/user.rb (shown, not run on its own)
# attr_readonly lets admin be set when the row is created and keeps it out of every later model write. In an app on
# config.load_defaults 8.1, assigning admin on a saved user raises ActiveRecord::ReadonlyAttributeError.
class User < ApplicationRecord
  attr_readonly :admin
end

Strong parameters close the usual door; attr_readonly closes the door you forgot. The example below aims both at the flag, then tries the other ways a model can write a column, and one write that never builds a model at all:

Strong parameters, attr_readonly and the one write that gets past them Ruby · admin_namespace.rb
# Two defences for the flag that makes a user an admin, each tried on a plain Ruby run:
# 1) strong parameters: a small UserForm drops :admin from the request (ActionController::Parameters#permit);
# 2) attr_readonly: a saved row keeps its admin value through every model write; one SQL-level write still gets past.
require "active_record"
require "action_controller"

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

# Keep the output the same on every run: no migration timings.
ActiveRecord::Schema.verbose = false

ActiveRecord::Schema.define do
  create_table :users do |t|
    t.string :name, null: false
    t.string :email_address, null: false
    t.boolean :admin, null: false, default: false
  end
end

# This one-file script runs on Active Record's own defaults, where raise_on_assign_to_attr_readonly is false:
# assigning admin on a saved row changes the object in memory, and save leaves the column out of its UPDATE.
class User < ActiveRecord::Base
  attr_readonly :admin
end

# The form object between the controller and the model.
class UserForm
  # Strong parameters: this list is the whole of what the form takes from the request.
  ALLOWED = [:name, :email_address].freeze

  def initialize(params)
    @params = ActionController::Parameters.new(params)
  end

  def permitted
    @params.permit(*ALLOWED).to_h.symbolize_keys
  end
end

# Runs one write and says whether Active Record took it or refused it.
def attempt(label)
  result = yield
  puts "#{label}: done, returned #{result.inspect}"
rescue ActiveRecord::ActiveRecordError => e
  puts "#{label}: refused, #{e.class}: #{e.message}"
end

def admin_in_database(id) = User.find(id).admin?

puts "--- 1. Strong parameters drop :admin ---"
params = { name: "Mallory", email_address: "m@kaksha.example", admin: true }
form = UserForm.new(params)
puts "params gave:   #{params.inspect}"
puts "form returned: #{form.permitted.inspect}"
raise "the admin key reached the model" if form.permitted.key?(:admin)

puts "\n--- 2. The row created from the form is not an admin ---"
user = User.create!(form.permitted)
puts "admin in the database: #{admin_in_database(user.id)}"

puts "\n--- 3. A later bug assigns admin and saves (this script's defaults) ---"
user.admin = true
puts "admin in memory: #{user.admin?}"
attempt("user.save") { user.save }
puts "admin in the database: #{admin_in_database(user.id)}"

puts "\n--- 4. The single-column writers check attr_readonly too ---"
fresh = User.find(user.id)
attempt("update_attribute(:admin, true)") { fresh.update_attribute(:admin, true) }
attempt("update_columns(admin: true)") { fresh.update_columns(admin: true) }
puts "admin in the database: #{admin_in_database(user.id)}"

puts "\n--- 5. A relation-level write goes straight to SQL ---"
attempt("User.where(id: user.id).update_all(admin: true)") { User.where(id: user.id).update_all(admin: true) }
puts "admin in the database: #{admin_in_database(user.id)} (attr_readonly never saw this write)"
raise "update_all should have reached the column" unless admin_in_database(user.id)

puts "\n--- 6. What a new Rails 8.1 app does: load_defaults turns raise_on_assign_to_attr_readonly on ---"
ActiveRecord.raise_on_assign_to_attr_readonly = true
class StrictUser < ActiveRecord::Base
  self.table_name = "users"
  attr_readonly :admin # declared after the switch, so the assignment itself raises
end
ravi = StrictUser.create!(name: "Ravi", email_address: "r@kaksha.example")
attempt("ravi.admin = true") { ravi.admin = true }
puts "admin in the database: #{StrictUser.find(ravi.id).admin?}"

Output

--- 1. Strong parameters drop :admin ---
params gave:   {name: "Mallory", email_address: "m@kaksha.example", admin: true}
form returned: {name: "Mallory", email_address: "m@kaksha.example"}

--- 2. The row created from the form is not an admin ---
admin in the database: false

--- 3. A later bug assigns admin and saves (this script's defaults) ---
admin in memory: true
user.save: done, returned true
admin in the database: false

--- 4. The single-column writers check attr_readonly too ---
update_attribute(:admin, true): refused, ActiveRecord::ActiveRecordError: admin is marked as readonly
update_columns(admin: true): refused, ActiveRecord::ActiveRecordError: admin is marked as readonly
admin in the database: false

--- 5. A relation-level write goes straight to SQL ---
User.where(id: user.id).update_all(admin: true): done, returned 1
admin in the database: true (attr_readonly never saw this write)

--- 6. What a new Rails 8.1 app does: load_defaults turns raise_on_assign_to_attr_readonly on ---
ravi.admin = true: refused, ActiveRecord::ReadonlyAttributeError: admin
admin in the database: false

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby admin_namespace.rb

Steps 1 and 2 show permit returning only the name and the email address, so the user created from that Hash is not an admin. In step 3 a bug assigns admin = true and saves: save returns true, yet the database still holds false, because this one-file script runs on Active Record’s own defaults, where the readonly column is simply left out of the UPDATE. Step 4 shows update_attribute and update_columns refusing with admin is marked as readonly. Step 5 is the gap: update_all builds its SQL from a relation, never loads a User, and the flag reads true afterwards.

A new Rails 8.1 app raises at the assignment

config.load_defaults 7.1 and every later version, so a new 8.1 app too, set config.active_record.raise_on_assign_to_attr_readonly = true. The assignment itself then raises ActiveRecord::ReadonlyAttributeError, as step 6 shows, so the bug from step 3 fails loudly in its test instead of saving quietly.

Whichever default the app runs on, the flag survives every write that goes through a model object. What is left is the write that never builds one:

update_all goes around attr_readonly

User.where(id: user.id).update_all(admin: true), like raw SQL, writes the table directly: no readonly check, no validation, no callback. Keep relation-level writes to the admin flag out of controllers and jobs, keep the one script that promotes a user in version control, and write its audit row by hand, because the model’s callback never runs for it.

That last point carries into the next section: its audit row is written by the model, so it records every change that goes through the model and none that goes around it.

Checkpoint

Check: Admin flags off the request

  1. Question 1 of 2 A form posts name, email_address and admin: true. UserForm.permitted returns only name and email_address. What dropped the admin key?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The permit(:name, :email_address) call in UserForm

    Strong parameters keep only the keys you list. In the recorded run the admin key was in the params and missing from form.permitted, so the user created from it was not an admin.

  2. Question 2 of 2 attr_readonly :admin is set on User. Which write still changes the admin column of a saved row?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: User.where(id: user.id).update_all(admin: true)

    attr_readonly guards every write that goes through a model object: save drops the column, update_attribute and update_columns refuse it. A relation-level update_all, like raw SQL, goes straight to the table, which is why it stays out of request code.

Writing audit rows that record who did what

OWASP’s Logging Cheat Sheet asks that logs hold “when, where, who and what” for each event, and its list under each heading is the useful part. The time sits under when, beside an interaction identifier that ties together every event of one request; the entry point, for a web app its URL and HTTP method, sits under where; the user’s identity under who; the kind of event and what it did under what. An audit trail: A separate table that records who changed what, when, and from which request, for every state change of a model; often written in an after_commit callback with the actor and the diff from saved_changes. is a small table with a column for each answer, written for every state change. The Rails log is not enough: it says a request came in, not the before and after of the row it wrote.

A model-level audit builds the row from pieces Rails already has. The ActionDispatch::RequestId middleware gives every request an id, reusing an X-Request-Id header set in front of the app or making a new UUID, and request.request_id reads it; a client can send that header too, so treat the id as a key into your logs rather than as proof. A before_action copies it and the entry point into Current, which the model reads without a controller in sight. after_commit: A callback that runs only once the database transaction around a save has committed, when the change is permanent and visible to other connections; after_rollback runs instead when it rolls back. More in “Callbacks, enums, JSON and custom types” then runs once for each record a committed transaction created, updated or destroyed, and saved_changes holds the diff of a save as a Hash of column => [before, after]:

Current, the request id and app/models/fee_waiver.rb Ruby · snippets/fee_waiver_audit.rb
# app/models/current.rb, app/controllers/application_controller.rb and app/models/fee_waiver.rb (shown, not run on
# their own): the request's context reaches the model through Current attributes, and after_commit writes the row.

# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :session, :account, :membership, :request_id, :entry_point
  delegate :user, to: :session, allow_nil: true
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Authentication

  before_action do
    Current.request_id = request.request_id # the id the ActionDispatch::RequestId middleware gave this request
    Current.entry_point = "#{request.request_method} #{request.path}"
  end
end

# app/models/fee_waiver.rb
class FeeWaiver < ApplicationRecord
  NOT_AUDITED = %w[student_name created_at updated_at].freeze

  after_commit :record_audit

  private

  def record_audit
    if destroyed?
      action = "destroy"
      diff = attributes.except("id", *NOT_AUDITED).transform_values { |value| [value, nil] }
    else
      action = previously_new_record? ? "create" : "update"
      diff = saved_changes.except(*NOT_AUDITED)
    end
    AuditEvent.create!(
      occurred_at: Time.current, request_id: Current.request_id, entry_point: Current.entry_point,
      actor_email: Current.user&.email_address, action: action, record_type: self.class.name,
      record_id: id, changes_json: diff.to_json
    )
  end
end

The example runs four requests against one waiver on an in-memory database, each loading the waiver afresh as a controller would: a create, an approval that moves state and the amount, a second change whose transaction rolls back, and a destroy. It prints every row the trail holds:

Four requests, three audit rows: a rolled-back change leaves none Ruby · audit_event.rb
# A FeeWaiver model that writes one audit row for each committed change, holding OWASP's "when, where, who and what".
# No gem beyond Active Record: Current attributes carry the request's context, and an after_commit callback copies
# Rails' saved_changes diff into a JSON column. Each request below loads the waiver afresh, as a controller does.
require "active_record"
require "active_support/testing/time_helpers"
require "json"

include ActiveSupport::Testing::TimeHelpers

ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")

# Keep the output the same on every run: no migration timings.
ActiveRecord::Schema.verbose = false

ActiveRecord::Schema.define do
  create_table :fee_waivers do |t|
    t.string :student_name, null: false
    t.integer :amount_paise, null: false
    t.string :state, null: false, default: "pending"
    t.timestamps
  end

  create_table :audit_events do |t|
    t.datetime :occurred_at, null: false # when
    t.string :request_id, null: false    # when: OWASP's interaction identifier, shared by one request's events
    t.string :entry_point, null: false   # where: the HTTP method and path the change came through
    t.string :actor_email, null: false   # who
    t.string :action, null: false        # what: create, update or destroy
    t.string :record_type, null: false   # what: the record it happened to
    t.integer :record_id, null: false
    t.text :changes_json, null: false    # what: [before, after] for each audited column
  end
end

# In an app, a before_action fills these from the request; here each simulated request sets them.
class Current < ActiveSupport::CurrentAttributes
  attribute :actor_email, :request_id, :entry_point
end

class AuditEvent < ActiveRecord::Base
end

class FeeWaiver < ActiveRecord::Base
  # The student's name stays out of the trail: the record id is enough to find the waiver again.
  NOT_AUDITED = %w[student_name created_at updated_at].freeze

  after_commit :record_audit

  private

  def record_audit
    if destroyed?
      action = "destroy"
      # saved_changes describes this object's last save, never the destroy, so the destroy row is built from the
      # values the row held when it was removed.
      diff = attributes.except("id", *NOT_AUDITED).transform_values { |value| [value, nil] }
    else
      action = previously_new_record? ? "create" : "update"
      diff = saved_changes.except(*NOT_AUDITED)
    end
    AuditEvent.create!(
      occurred_at: Time.current, request_id: Current.request_id, entry_point: Current.entry_point,
      actor_email: Current.actor_email, action: action, record_type: self.class.name, record_id: id,
      changes_json: diff.to_json
    )
  end
end

# One simulated request: a pinned clock, and the context a controller would copy into Current.
def in_request(at, entry_point, request_id)
  travel_to(Time.utc(2031, 5, 12, *at)) do
    Current.set(actor_email: "owner@kaksha.example", request_id: request_id, entry_point: entry_point) do
      puts "#{entry_point} (#{request_id}): #{yield}"
    end
  end
end

id = nil
in_request([9, 15], "POST /admin/fee_waivers", "req-1a2b") do
  id = FeeWaiver.create!(student_name: "Asha", amount_paise: 150_00).id
  "created waiver #{id}"
end

in_request([9, 40], "PATCH /admin/fee_waivers/#{id}", "req-3c4d") do
  FeeWaiver.find(id).update!(state: "approved", amount_paise: 180_00)
  "approved, 18000 paise"
end

in_request([9, 41], "PATCH /admin/fee_waivers/#{id}", "req-7e8f") do
  FeeWaiver.transaction do
    FeeWaiver.find(id).update!(amount_paise: 200_00)
    raise ActiveRecord::Rollback # something later in the request failed, so nothing commits
  end
  "rolled back, the row still holds #{FeeWaiver.find(id).amount_paise} paise"
end

in_request([10, 5], "DELETE /admin/fee_waivers/#{id}", "req-5a6b") do
  FeeWaiver.find(id).destroy!
  "destroyed waiver #{id}"
end

rows = AuditEvent.order(:id).to_a
puts "\naudit_events holds #{rows.length} rows"
rows.each do |e|
  puts "\n#{e.id}. #{e.action} #{e.record_type}##{e.record_id}"
  puts "   when:  #{e.occurred_at.strftime("%H:%M")} UTC, request #{e.request_id}"
  puts "   where: #{e.entry_point}"
  puts "   who:   #{e.actor_email}"
  puts "   what:  #{e.changes_json}"
end

raise "expected three rows" unless rows.map(&:action) == %w[create update destroy]
raise "the rolled-back request left a row" if rows.any? { |e| e.request_id == "req-7e8f" }
puts "\nok: one row for each committed change, none for the rolled-back one"

Output

POST /admin/fee_waivers (req-1a2b): created waiver 1
PATCH /admin/fee_waivers/1 (req-3c4d): approved, 18000 paise
PATCH /admin/fee_waivers/1 (req-7e8f): rolled back, the row still holds 18000 paise
DELETE /admin/fee_waivers/1 (req-5a6b): destroyed waiver 1

audit_events holds 3 rows

1. create FeeWaiver#1
   when:  09:15 UTC, request req-1a2b
   where: POST /admin/fee_waivers
   who:   owner@kaksha.example
   what:  {"id":[null,1],"amount_paise":[null,15000]}

2. update FeeWaiver#1
   when:  09:40 UTC, request req-3c4d
   where: PATCH /admin/fee_waivers/1
   who:   owner@kaksha.example
   what:  {"amount_paise":[15000,18000],"state":["pending","approved"]}

3. destroy FeeWaiver#1
   when:  10:05 UTC, request req-5a6b
   where: DELETE /admin/fee_waivers/1
   who:   owner@kaksha.example
   what:  {"amount_paise":[18000,null],"state":["approved",null]}

ok: one row for each committed change, none for the rolled-back one

Recorded with ruby 4.0.7 (revision 229531a6cf) +PRISM [arm64-darwin23] on macOS 26 arm64. To run it yourself: ruby audit_event.rb

Four requests left three rows, each with its time and request id, its entry point, its actor and its diff. The create row carries id and amount_paise, because on a create saved_changes lists only the columns that moved from their defaults, and state kept its default pending. The update row holds the [pending, approved] and [15000, 18000] diffs a dispute turns on. The rolled-back request left no row, because after_rollback runs in place of after_commit, and the database still holds 18,000 paise. The destroy row is built from the values the row held, with null on the right, since saved_changes describes an object’s last save and never a destroy.

Filter personal data at the source

Logs travel: to a database replica, an export, a developer’s laptop. NOT_AUDITED keeps student_name out of every diff, and the record id finds the waiver when a question needs it. OWASP’s sheet, under “Data to exclude”, names personal names and email addresses among the data to treat with care before an event is recorded. The actor’s email stays in this table because who acted is the point of the row; a user id would serve as well.

Two gems do this for you. paper_trail writes a versions row holding the record as it stood before each change, as YAML unless you choose its JSON serializer, plus the diff when the table has an object_changes column; audited writes an audits row whose audited_changes holds the diff. Both bring a table and a model of their own to learn, which pays off once many models need a trail. For an admin with four or five audited models, the callback above fits in a page and records exactly the columns the business asks about.

after_commit runs once the change is permanent

A failure inside record_audit cannot undo the approval: the waiver stays approved and the trail misses it, the case the Rails callbacks guide describes for any record written in after_commit. Where a change without its row is worse than a refused change, write the row from after_save and after_destroy instead, inside the same transaction, so the two commit or roll back together.

Checkpoint

Check: Audit rows for every state change

  1. Question 1 of 2 The lesson's run creates a waiver, updates it, tries a second update whose transaction rolls back, then destroys it. How many audit rows does after_commit :record_audit write?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 3 rows

    after_commit runs once per record for each committed transaction: here the create, the update and the destroy, each its own transaction. The rolled-back one runs after_rollback instead, so the recorded run printed "audit_events holds 3 rows".

  2. Question 2 of 2 OWASP's Logging Cheat Sheet sorts what a log entry records under when, where, who and what. Under which heading does the audit row's request_id belong?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: When: it is the interaction identifier that links one request's events

    The sheet's list under when holds the event time and the interaction identifier, the id that ties together everything one interaction did. In a Rails app that is the id the ActionDispatch::RequestId middleware gives each request, and it joins the row to every log line of the same request.

Choosing an admin: hand-built or a gem

A hand-built admin is one bin/rails generate scaffold_controller Admin::FeeWaiver --model-name=FeeWaiver per resource: the controller with its seven actions under app/controllers/admin/, its views under app/views/admin/fee_waivers/, a controller test and the route inside namespace :admin, all for the existing FeeWaiver model, plus the base controller above. Plain scaffold Admin::FeeWaiver would also generate a second model, Admin::FeeWaiver, with an admin_fee_waivers table of its own. The gems take a different trade: many screens from one line each, with a DSL between you and the page.

Answer three questions about your admin and the table rules out what will not fit:

Interactive · answer and compare Choosing an admin for a Rails 8.1 app Answer three questions about your app, and the table says which approach fits and which is ruled out.
How many resources will the admin cover, and how often will the model shape move?
Do you need every admin screen to look and behave exactly as you draw it?
Can you ship an app that depends on an LGPL or commercial licence?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five approaches to an admin area against four criteria that matter in production.
Approach Time to first screen Custom views and actions Licence Works with Rails 8.1
Hand-built admin What you get: namespace admin + scaffold_controller Mixed one hour per resource: generator, policy, request test Good any Rails view, any form, any action; nothing hidden Good no gem, no third-party licence; your own code Good the Rails 8.1 defaults, generated by bin/rails
Administrate What you get: a dashboard class per model Good administrate:install writes a dashboard and a controller per model Mixed override dashboards and views; new columns go in by hand Good MIT licence Good Administrate 1.0 accepts Rails 6.0 up to 8.x
Avo What you get: DSL-driven resource pages Good avo:install, then one avo:resource per model Good DSL fields, resource tools, custom pages, and ERB when you need it Mixed LGPLv3 open-source gem; its paid tiers are commercial Good Avo 4 asks for Rails 6.1 or later
ActiveAdmin What you get: ActiveAdmin.register per model Good register one model and the index, show and form are drawn Mixed Arbre DSL for views; custom actions take practice Good MIT licence Mixed stable 3.x covers Rails 8.1 since 3.4.0; 4.0 is still a beta
Madmin What you get: scaffold-like resource files Good madmin:install writes a resource file per model Good madmin:views copies the views into your app, so edits stay yours Good MIT licence Good Madmin 3 asks for Rails 7.0 or later
How the answers decide
  • “How many resources will the admin cover, and how often will the model shape move?” Many, often changing: rules out Hand-built admin. A dozen generated controllers that change every month cost more time than a gem that generates the screens.
  • “Do you need every admin screen to look and behave exactly as you draw it?” Yes, pixel by pixel: adds a caveat to Administrate and ActiveAdmin. Administrate and ActiveAdmin give you fights when the design strays from their defaults; still usable.
  • “Can you ship an app that depends on an LGPL or commercial licence?” MIT or similar only: rules out Avo. The open-source Avo gem is LGPLv3 and its paid tiers are commercial; the three other gems are MIT, and a hand-built admin has no licence of its own.
  • “How many resources will the admin cover, and how often will the model shape move?” A handful, stable and “Do you need every admin screen to look and behave exactly as you draw it?” Yes, pixel by pixel and “Can you ship an app that depends on an LGPL or commercial licence?” MIT or similar only: A hand-built admin or Madmin keeps every screen under your own code and under MIT.

Read the licence column first. The open-source Avo gem is LGPLv3 and its paid tiers carry a commercial licence, as the repository’s LICENSE file says; the three other gems are MIT, and a hand-built admin adds no licence at all. ActiveAdmin’s stable line is 3.x, which gained Rails 8.1 support in 3.4.0, while 4.0 is still published as a beta. Administrate 1.0 accepts Rails 6.0 up to 8.x, and its installer writes a dashboard and a controller per model, though a column added later goes into its dashboard by hand. Madmin’s installer writes a resource file per model into your app and copies its views there when you ask, so a change you make stays in your code.

For Kaksha, with a half-dozen resources that change rarely, a hand-built admin wins: the role check is one file, the generated controllers are standard Rails, and the audit row lives in the model it belongs to. For a SaaS with thirty resources that move with every sprint, Administrate or Avo earns its DSL on index and form pages nobody has to write; Madmin’s generated files are a middle path when you want a head start without a DSL you cannot leave.

Licences and names, as stated by the projects

Mission Control for Jobs, Administrate, Avo, ActiveAdmin, Madmin, paper_trail and audited are their maintainers’ projects. The licences, versions and Rails ranges above come from the projects’ repositories and RubyGems.org pages linked in references. MySmartCoPilot is not affiliated with any of them.

Checkpoint

Check: Hand-built or a gem

  1. Question 1 of 2 A project rules out any dependency that is not MIT or similar. Which admin gem on this lesson's table is ruled out?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Avo (LGPLv3, or commercial for its paid tiers)

    Avo is the only gem in the table without a permissive licence: LGPLv3 for the open-source gem and a commercial licence for its paid tiers. A project that must stay on permissive licences is left with the three MIT gems or a hand-built admin.

  2. Question 2 of 2 An app has five resources whose shape rarely changes, and the design team wants every admin screen to look exactly as drawn. Which option fits best?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A hand-built admin with generated controllers

    Few resources, strict design: the time saved by a gem is small, the time lost fighting a DSL is large. A hand-built admin keeps every screen in your code.

Practise: build the fee-waiver admin step by step

Work in a copy of the two example files. Each is a single Ruby file with its own in-memory SQLite database, so one ruby <file> after every step tells you whether the rule held.

  1. In admin_namespace.rb, add a role string column to the users table and leave it out of UserForm::ALLOWED. Prove with a role: "owner" key in the params that permitted drops it.
  2. In the same file, add attr_readonly :role to User, change the role on a saved user and call save, and prove the reloaded row keeps the original role; then try update_columns(role: "owner") and read the error it raises.
  3. In audit_event.rb, add an ip_address attribute to Current and a column to audit_events, set it in in_request, and prove that two requests from different addresses leave two rows with the right address.
  4. In the same file, add amount_paise to NOT_AUDITED and confirm the rows still carry state, so the dispute question is still answerable.
  5. Add a for_record scope to AuditEvent that returns one record’s events in time order, and print the waiver’s whole history with it.

Together the five tasks are the shape of a fee-waiver admin: a flag no request can set, and a row for every change the model commits. Run the file after each task, so every check closes on the lesson’s own runs.

Practice that adapts to you

Practise this lesson

8 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 Which URL does namespace :admin do resources :memberships, only: [:index, :update] end create for listing memberships?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: /admin/memberships

    namespace :admin wraps the URL and the controller module. only: [:index, :update] limits which routes are drawn; index is the GET /admin/memberships route.

  2. Question 2 of 8 Mission Control for Jobs is mounted at /admin/jobs, and only owners may see job pages. Which setting makes Admin::BaseController's role check run on those pages?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: config.mission_control.jobs.base_controller_class = "Admin::BaseController"

    An engine draws its pages with controllers of its own. Mission Control's controllers inherit from the host app's ApplicationController unless base_controller_class names another class, so pointing it at Admin::BaseController puts the admin check in front of every job page; only then is its HTTP basic authentication safe to switch off.

  3. Question 3 of 8 A controller writes params.permit(:name, :email_address, :admin). Why is this a mass-assignment vulnerability?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: The allowlist includes :admin, so a form can set it in one request

    Strong parameters are an allowlist; whatever you list is allowed. :admin belongs on no public form's allowlist. For a change made by an admin, write a separate admin-only action under the admin namespace.

  4. Question 4 of 8 With attr_readonly :admin on User, user.admin = true; user.save returned true in the recorded run. What happened in the database?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: The row saved every other change; the admin column stayed as it was

    attr_readonly is per column, not per record: on Active Record's own defaults save succeeds, writes any other changed column and leaves the readonly one as it was. The reload in step 3 still read false; step 6 shows the new-app setting raising at the assignment instead.

  5. Question 5 of 8 In the lesson's audit_event.rb, the destroy row carries {"amount_paise":[18000,null],"state":["approved",null]}. Why is it built from the attributes instead of saved_changes?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: saved_changes describes the object's last save, and a destroy is not a save

    The example builds the destroy diff from the values the row held, with null on the right, so the trail answers "what was in the row when it was removed", not just "it is gone".

  6. Question 6 of 8 An AuditEvent diff carries student_name. A support engineer later needs to answer "did the owner change this student's amount?" without seeing names. What change makes the trail safe to share?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: Filter student_name out of the diff at write time, keep record_type and record_id

    OWASP's "Data to exclude" section names personal names and email addresses among the data to treat with care before an event is recorded, and suggests de-identifying data where the person's identity is not needed. The record id is enough to join back to the student when a question is asked.

  7. Question 7 of 8 A team added paper_trail to a Rails app and found the versions table had grown to 40 GB after three months. Which change below is the first one worth making?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: Narrow has_paper_trail to the models that actually need an audit, and set skip to drop fields with large text

    The gem makes it easy to record everything, which is often more than needed. has_paper_trail only: [...] saves a version only when one of those attributes changes, and skip: [...] keeps a field out of every version row; together they trim the table to what the audit question needs.

  8. Question 8 of 8 An app has thirty resources whose shape moves with every sprint, and the team is on an MIT-only policy. Which approach fits best?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: Administrate: one install writes a dashboard and a controller per model, under MIT

    Administrate fits a long, moving resource list under a permissive licence. The hand-built option fits better when the list is short and stable, as the lesson's Kaksha case showed.

Interview questions

Warm-up: why does attr_readonly :admin protect the admin flag better than relying only on strong parameters?

Strong parameters stop a form from reaching the model with an admin key, but only on the paths that pass through the controller holding the allowlist. A background job that builds a user from a Hash, a Rake task that imports users from a CSV, a webhook that calls User.update(id, attributes): each takes its attributes from somewhere else, and each is a chance to forget the list. attr_readonly :admin moves the defence into the model: once the row exists, no save, update, update_attribute or update_columns changes the column, whichever code called it, and a new Rails 8.1 app raises at the assignment. The gap is a write that never builds a model, such as update_all or raw SQL. Together they are belt and braces: strong parameters on the paths you know, attr_readonly on the ones you do not.

The module bank keeps two harder questions on this ground: one asks how to split an admin whose plain screens and drawn multi-step flows pull in different directions, the other weighs paper_trail against an AuditEvent model you grow yourself. Work out your own answer first, then compare.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A coaching SaaS has eight admin resources. Six are plain lists and forms; fee waivers and refunds need multi-step screens drawn by the design team, with an audit row for every step. The licence policy is MIT-only. Would you build the whole admin by hand, use Administrate for all of it, or mix the two, and why?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A compliance review asks the team to log every change to fee waivers with the actor, the before and after, and the request id. Would you add paper_trail, or grow an AuditEvent model as the lesson did, and how would you handle personal data in the diff?

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. Why give the admin area its own namespace and base controller?

    The role check then sits in one place: each new admin controller inherits it, and one request test on the base covers the whole area. A mounted dashboard such as Mission Control joins that boundary only through its own base-controller setting.

  2. How does attr_readonly :admin protect a saved user's admin flag?

    save leaves the column as it was, update_attribute and update_columns refuse it, and a new Rails 8.1 app raises at the assignment itself. Strong parameters drop the key too, but only for requests through that one controller.

  3. Which kind of write goes around attr_readonly and every callback?

    A relation-level write such as update_all. Keep it out of request code, and write its audit row by hand.

  4. How does an after_commit callback with saved_changes make a sound audit trail?

    It writes one row for each record a committed transaction changed and none for a rolled-back one, holding the time, the request id, the entry point, the actor and the diff. A row that must never go missing is written inside the transaction instead.

  5. What should an audit row's diff leave out?

    Personal data such as a student's name, removed where the row is written; logs travel, and the record id is enough to find the record again.

  6. When does a hand-built admin fit, and what do the admin and audit gems trade?

    A hand-built admin suits a small area with stable resources. Administrate, Avo, ActiveAdmin and Madmin trade a DSL for many generated screens; paper_trail and audited trade a table of their own for a ready-made trail; and Avo's licence can settle the choice by itself.

Key takeaways

  • An admin namespace with its own base controller puts the role check in one place, so a new admin controller inherits it and a request test on the base controller covers the whole area; a mounted dashboard such as Mission Control joins it only through its own base-controller setting.
  • Strong parameters drop the admin key at the edge, and attr_readonly keeps a saved row’s flag through every model write: save leaves the column out, update_attribute and update_columns refuse it, and a new Rails 8.1 app raises at the assignment.
  • A relation-level write such as update_all goes around attr_readonly and every callback, so it stays out of request code and its audit row is written by hand.
  • after_commit with saved_changes writes one audit row for each record a committed transaction changed and none for a rolled-back one, holding the time and request id, the entry point, the actor and the diff that answer OWASP’s “when, where, who and what”; a row that must never go missing is written inside the transaction instead.
  • Filter personal data such as a student’s name out of the diff at the source: logs travel, and the record id is enough to find the record again.
  • For a small admin with stable resources a hand-built admin fits; Administrate, Avo, ActiveAdmin and Madmin trade a DSL for many generated screens, paper_trail and audited trade a table of their own for a ready-made trail, and Avo’s LGPLv3 or commercial licence can decide the choice by itself.

Terms in this lesson

admin namespace
A Rails routing block, namespace :admin do … end, that groups a set of URLs under /admin and maps them to controllers under the Admin:: module; the admin base controller sits over every action.
attr_readonly
A declaration on an Active Record model (attr_readonly :admin) that lets a column be set when a row is created and keeps it out of every later model write: save leaves it as it was, update_columns refuses it, and a new Rails 8.1 app raises on assignment. Only relation-level writes such as update_all still reach it.
audit trail
A separate table that records who changed what, when, and from which request, for every state change of a model; often written in an after_commit callback with the actor and the diff from saved_changes.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.