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.
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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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, 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:
# 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
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 (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:
# 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
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]:
# 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:
# 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
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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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.
- In
admin_namespace.rb, add arolestring column to the users table and leave it out ofUserForm::ALLOWED. Prove with arole: "owner"key in the params thatpermitteddrops it. - In the same file, add
attr_readonly :roletoUser, change the role on a saved user and callsave, and prove the reloaded row keeps the original role; then tryupdate_columns(role: "owner")and read the error it raises. - In
audit_event.rb, add anip_addressattribute toCurrentand a column toaudit_events, set it inin_request, and prove that two requests from different addresses leave two rows with the right address. - In the same file, add
amount_paisetoNOT_AUDITEDand confirm the rows still carrystate, so the dispute question is still answerable. - Add a
for_recordscope toAuditEventthat 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.
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.
-
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?
-
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?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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_readonlykeeps a saved row’s flag through every model write:saveleaves the column out,update_attributeandupdate_columnsrefuse it, and a new Rails 8.1 app raises at the assignment. - A relation-level write such as
update_allgoes aroundattr_readonlyand every callback, so it stays out of request code and its audit row is written by hand. after_commitwithsaved_changeswrites 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
- Logging Cheat Sheet (OWASP)
- Rails Routing from the Outside In (namespace) (Ruby on Rails)
- ActiveRecord::ReadonlyAttributes (attr_readonly) (Ruby on Rails)
- ActiveRecord::Persistence (update_attribute, update_columns) (Ruby on Rails)
- Configuring Rails Applications (raise_on_assign_to_attr_readonly) (Ruby on Rails)
- ActiveRecord::Transactions (after_commit) (Ruby on Rails)
- Active Record Callbacks (transaction callbacks) (Ruby on Rails)
- ActiveSupport::CurrentAttributes (Ruby on Rails)
- ActionDispatch::RequestId (Ruby on Rails)
- Rails generators, resource_helpers.rb (--model-name) (Ruby on Rails)
- Mission Control — Jobs (README, authentication) (Ruby on Rails)
- Administrate, Getting Started (thoughtbot)
- administrate on RubyGems.org (RubyGems.org)
- Avo licence (LICENSE.md) (Avo HQ)
- Active Admin releases (ActiveAdmin)
- activeadmin versions on RubyGems.org (RubyGems.org)
- Madmin (Chris Oliver)
- madmin on RubyGems.org (RubyGems.org)
- paper_trail (paper_trail)
- Audited (Collective Idea)
Report a problem with this lesson
Kept only in this browser. Your Learn progress