The Ban Arc: Thin Controller, Orderly Model, Ambient Guard
A capstone tracing Campfire's ban feature end-to-end. Banning a user looks like a one-liner, but its correctness lives entirely in the ORDER of operations inside one model transaction — and four principles (CRUD-on-a-noun, the fat-model transaction, security-as-shape, and altitude) collaborate to make the block outlive the logout and the account itself. Grounded in user/bannable.rb, bans_controller.rb, ban.rb, and block_banned_requests.rb.
Banning a user is one feature that exercises CRUD-on-a-noun, the fat-model transaction, security-as-shape, and altitude all at once — and its correctness lives entirely in the order of operations inside one model method.
Cold open: the version you'd naturally vibe-code first
Someone's being abusive in a room. You need a "Ban" button. Let's be honest about the first version you'd actually write — the one you'd vibe-code before you'd thought about what "banned" really has to mean.
A ban is a status, so you reach for a status column and flip it. The button POSTs somewhere, so you bolt a custom verb onto the controller you already have. And to actually keep the banned user out, you sprinkle a check wherever you remember to:
class UsersController < ApplicationController
# routes.rb: resources :users do; member { post :ban }; end
def ban
user = User.find(params[:id])
user.update!(status: "banned") # flip a flag and call it a day
user.messages.each(&:destroy) # ...delete their stuff, in-request
redirect_to user
end
end
class ApplicationController < ActionController::Base
before_action :kick_banned_user
private
def kick_banned_user
redirect_to login_path if Current.user&.banned? # ...if Current.user exists
end # and if you remembered
end # this line in THIS controller
Walk through what actually happens the day you ship this. The banned user is logged in on their phone — Current.user is them — so kick_banned_user does nothing useful; the check only bites on the next fresh login, and only on controllers where you remembered the before_action. So they keep posting from the still-live session. Worse: ban is account-level, tied to the user row. The attacker logs out, re-registers a brand-new account from the same laptop in the same coffee shop, and they're back in thirty seconds — your "ban" never touched the machine. And messages.each(&:destroy) runs inside the request: in a real account that's thousands of rows, each firing callbacks and a broadcast, so the admin's click spins for forty seconds and times out half-done — some messages gone, some not, status maybe flipped, maybe not, because nothing wrapped it in a transaction.
THE NAIVE "BAN A USER" (the version you'd vibe-code first)
[Ban button]
│
▼
UsersController#ban (custom member verb, fat controller)
│
├─► user.update(status: "banned") ← a flag on the account row only
├─► user.messages.each(&:destroy) ← N destroys IN-REQUEST → timeout
│ (no transaction → partial failure)
└─► redirect
live session still open ──► keeps posting
enforcement: redirect_if_banned scattered ──► only on remembered controllers,
into some controllers only bites the NEXT login
re-register same machine ──► back in 30s
A flag that only describes the account, not the machine. Enforcement you have to remember to wire into every controller. A slow fan-out blocking the request. And no transaction, so a failure halfway leaves the user in a contradictory state.
Campfire asks that question, and the answer reshapes the whole feature. A ban is not a flag on a person — it is a durable fact about a network address, created the moment you ban, enforced by an ambient guard that no controller has to opt into. The feature is four collaborating pieces, and the magic is entirely in the order they run.
Let's trace one ban from the button to the locked door.
Step 1 — The Controller: banning is creating a Ban
The route is not a custom verb. It's a resource (routes.rb:40):
resource :ban, only: %i[ create destroy ]
POST /users/:user_id/ban is create. DELETE is destroy. Banning is the creation of a Ban; unbanning is its destruction. That single reframing — the verb is a noun's lifecycle — is why the controller stays a stub (bans_controller.rb:1-19):
class Users::BansController < ApplicationController
before_action :ensure_can_administer
before_action :set_user
def create
@user.ban
redirect_to @user
end
def destroy
@user.unban
redirect_to @user
end
private
def set_user
@user = User.find(params[:user_id])
end
end
Two real lines per action. The controller does exactly what a controller is for: authorize, translate HTTP to a method call, redirect. It does not know what banning entails — not the IP harvest, not the session purge, not the deferred content removal. We don't re-derive why every state change wants to be CRUD on a noun here; that's see P6: Model Every State Change as CRUD on a Noun's job. We only need the payoff: because ban found its noun, there's no member do post :ban thicket in routes.rb, no fifteenth action piling onto UsersController, and the scope module: "users" namespace files this controller at app/controllers/users/bans_controller.rb so the folder tree mirrors the URL tree.
Notice ensure_can_administer runs first, before set_user. It's the same one-predicate guard that gates every privileged write in the app — head :forbidden unless Current.user.can_administer? (authorization.rb:3-4), backed by can_administer? (user/role.rb:8-10). Authorization isn't bespoke ban logic; it's the ambient shape of every write. That worldview lives in see P3: Security Is the Shape of Your Data Access.
"If the controller is this thin, where did the feature go?" Into the model, on purpose. The controller translates a request into a single method call against the aggregate root; the consequences of being banned belong to the user record, not to whoever clicked the button. That's see P1: The Model Owns Its Consequences. The thinness of the controller is the receipt that the model took the weight.
The contrast: The naive version bolts a custom ban verb onto a fat UsersController and inlines the work right there in the action — so the routes file grows a member do block, the controller grows a tenth action, and the actual banning logic is now untestable except through an HTTP request. Campfire's controller is a two-line CRUD stub because ban is a method on the model and the route is a plain resource.
Step 2 — The Transaction: the order is the entire feature
Open the trait. This is where the feature actually lives (bannable.rb:4-10):
def ban
transaction do
create_bans_from_sessions
apply_ban
banned!
end
end
Three lines, wrapped in one transaction. Read them as a checklist: record the evidence, enforce the ban, then mark the account banned. Every line of this method is private below it, and the order is not cosmetic — it is the correctness. Here is the hinge of the entire tutorial (bannable.rb:31-41):
private
def create_bans_from_sessions
sessions.pluck(:ip_address).compact_blank.uniq.each do |ip|
bans.create!(ip_address: ip) # ① snapshot the IPs into durable Ban rows
end
end
def apply_ban
close_remote_connections # ② kick the live websocket connections
sessions.delete_all # ③ delete the sessions ← IPs are GONE after this
remove_banned_content_later # ④ defer the slow fan-out to a job
end
Watch the dependency between step ① and step ③. The user's IP addresses live on their session rows — sessions.ip_address. Step ③ deletes every session. So if you reordered these — purged sessions first, then tried to harvest IPs — you'd harvest nothing, because the rows that held them are gone. The ban would be a status flag with no teeth.
create_bans_from_sessions runs first, copying each distinct session IP into a permanent Ban row before apply_ban deletes the sessions out from under it. The Ban row is the durable shadow of a fact that's about to be destroyed.
"Why copy the IPs into their own table — why not just keep the sessions around and read IPs from them?" Because a ban must outlive the very things that prove it. The session is ephemeral — it gets deleted the instant you log the user out (step ③). The account itself might later be deactivated or deleted. But the ban has to keep working after all of that: a banned user shouldn't be able to re-register a fresh account from the same machine and walk back in. So the durable fact — "this IP is banned" — has to be lifted out of the disposable session and frozen into a row that survives the logout and the account. The transaction guarantees the snapshot and the purge are one atomic act: you never end up with sessions deleted but IPs unrecorded, or IPs recorded but the account still
active.
THE BAN TRANSACTION — why the order is load-bearing
┌──────────────────────── transaction do ────────────────────────┐
│ │
│ ① create_bans_from_sessions │
│ sessions.pluck(:ip_address) ──► Ban rows (DURABLE) │
│ │ │
│ │ reads the IPs ───────────┐ │
│ ▼ │ must happen BEFORE │
│ ② close_remote_connections │ the source is deleted │
│ kick live websockets │ │
│ ▼ ▼ │
│ ③ sessions.delete_all ◄──── the IPs lived here. now GONE. │
│ ▼ │
│ ④ remove_banned_content_later ──► enqueue job (out-of-band) │
│ ▼ │
│ ⑤ banned! (status enum flips to :banned) │
│ │
└─────────── all-or-nothing: no contradictory half-state ─────────┘
banned! on line 8 is the bang-setter generated for free by enum :status, %i[ active deactivated banned ], default: :active (user.rb:18). One enum declaration gives you the banned! writer, the banned? predicate, and a banned scope — we don't re-derive how an enum generates that whole family here; that's see P7: Polymorphism Over Conditionals. The point is that flipping the status is the last line, not the first: the account is only marked banned once the evidence is frozen and the doors are shut.
And notice the symmetry of unban (bannable.rb:12-17): bans.delete_all then active!, also in a transaction. Undoing a ban is destroying its rows and restoring the status — the inverse CRUD, no special case.
The contrast: The naive version's update!(status: "banned") is a single statement that records nothing about the machine and isn't wrapped against partial failure. Campfire's ban is an ordered, atomic checklist where the first step deliberately snapshots a fact the third step is about to erase — and the whole thing commits or rolls back as one. The correctness isn't in any single line; it's in the sequence.
Step 3 — Altitude: the slow part leaves the request
Look again at line ④, remove_banned_content_later. The naive version deleted messages inline and timed out. Campfire splits the work by altitude (bannable.rb:19-28):
def remove_banned_content_later
RemoveBannedContentJob.perform_later(self) # the _later wrapper: enqueue and return
end
def remove_banned_content
messages.each do |message|
message.destroy
message.broadcast_remove # the actual slow fan-out
end
end
This is the _later/plain-method pairing the codebase uses everywhere — the thinnest thread boundary: the _later method is the thread boundary, the plain method is the work. The job itself is a three-line thunk that delegates straight back (remove_banned_content_job.rb:1-5):
class RemoveBannedContentJob < ApplicationJob
def perform(user)
user.remove_banned_content
end
end
The expensive part — destroying every message and broadcasting a remove to every live screen — is deferred so the admin's click returns instantly. We don't re-unpack the discipline of keeping a job a paper-thin delegate to a model method here; that's see P9: Put Work at Its Right Altitude and F5: Jobs & Background Work. What matters for the ban arc: the correctness-critical work (snapshot IPs, kill sessions, flip status) is in-band and durable inside the transaction; the slow, fan-out work (content removal) is out-of-band. Drawing that line is what keeps the ban both correct and fast.
One subtlety worth holding: content removal runs after the transaction commits, because perform_later enqueues on commit. A rolled-back ban never spawns a destruction job — the same _commit-flavored discipline that prevents the ghost row elsewhere in the app. The model owns what to remove; the job boundary owns where it runs.
The contrast: The naive messages.each(&:destroy) ran in-request and blew the timeout, leaving content half-deleted with no transaction to protect it. Campfire keeps the in-band part to three fast operations and ships the unbounded fan-out to a background job — one method call, perform_later, against machinery Rails already ships.
Step 4 — The Ambient Guard: enforcement nobody opts into
We've banned the IPs and locked the account. Now: how does a future request from a banned machine get rejected — without sprinkling redirect_if_banned into every controller you remember? The whole enforcement layer is one self-registering concern (block_banned_requests.rb):
module BlockBannedRequests
extend ActiveSupport::Concern
included do
before_action :reject_banned_ip, unless: :safe_request? # installs itself on the host
end
private
def reject_banned_ip
head :too_many_requests if Ban.banned?(request.remote_ip)
end
def safe_request?
request.get? || request.head?
end
end
The included do block is the wiring harness: the instant a controller includes this concern, the before_action is installed on it. And it's included exactly once, at the top of the tree (application_controller.rb:2):
include AllowBrowser, Authentication, Authorization, BlockBannedRequests, SetCurrentRequest, SetPlatform, TrackedRoomVisit, VersionHeaders
That one word in the include list — BlockBannedRequests — installs the ban gate on every controller in the entire app. No per-controller before_action, no list of "protected" endpoints to keep in sync, no controller that can forget to guard itself. Listing the concern is the enforcement.
The check itself, Ban.banned?(request.remote_ip), is a one-line class method on the noun we created in Step 2 (ban.rb:6-8):
def self.banned?(ip_address)
exists?(ip_address: ip_address)
end
A single indexed EXISTS against the bans table — and the table is indexed on ip_address (schema.rb:68). This is why Step 2's ordering mattered: enforcement reads from the durable rows the transaction froze. The session is long gone; the Ban row answers the question.
Two craftsman's details reward a second look. First, unless: :safe_request?: GET and HEAD requests pass through, only mutating verbs (POST/PUT/PATCH/DELETE) are blocked. A banned machine can still read a public page, but it can't act. Second, the response is head :too_many_requests — 429, not 403. A deliberate choice: it leaks nothing about why you're blocked and reads to an attacker like ordinary rate-limiting, not a targeted ban they could probe around.
"A banned user just switches to mobile data or a VPN and the IP changes — so what did the IP ban actually buy?" It's not a perfect wall, and it isn't trying to be — IP bans are a speed bump that raises the cost, not a cryptographic lock. The honest framing: the naive flag stopped nothing (the live session kept posting and a re-register beat it instantly); the IP ban makes the trivial re-entry — same machine, same network, new account — stop working, which kills the overwhelming majority of casual abuse. Determined evasion is a different threat model. (There's even a small honest caveat in the codebase:
Ban's validation rejects private/loopback/link-local IPs, and also rejects an unparseable address by adding a validation error —ban.rb:11-19— so banning is scoped to real, valid public addresses on purpose.)
The contrast: The naive redirect_if_banned had to be remembered in every controller, only bit on the next login, and checked a flag on Current.user — useless against an already-authenticated banned session. Campfire's gate installs itself everywhere from one include line, runs on every mutating request regardless of who's logged in, and answers from a durable IP row that outlives the session entirely.
The whole journey, on one screen
[Admin clicks Ban]
│
▼
POST /users/:id/ban ──► Users::BansController#create (Step 1: 2-line CRUD stub)
│ before_action :ensure_can_administer (one predicate, every write)
│ @user.ban
▼
┌──────────────────── @user.ban — transaction (Step 2) ───────────────────┐
│ ① create_bans_from_sessions → snapshot session IPs into Ban rows │
│ (must run BEFORE ③ — sessions hold the IPs) │
│ ② close_remote_connections → kick the live websockets (shared w/ │
│ deactivate, user.rb:61-63) │
│ ③ sessions.delete_all → log them out; the IPs are now gone │
│ ④ remove_banned_content_later→ enqueue RemoveBannedContentJob (Step 3, │
│ out-of-band; runs on commit) │
│ ⑤ banned! → status enum flips last │
└──────────── all-or-nothing; the durable Ban rows survive ────────────────┘
│ commit
▼
RemoveBannedContentJob#perform ──► messages.each { destroy; broadcast_remove }
(slow fan-out, off the request thread)
── LATER: any request from a banned machine ──
request ──► ApplicationController (includes BlockBannedRequests) (Step 4)
│ before_action :reject_banned_ip, unless GET/HEAD
▼
Ban.banned?(request.remote_ip) → indexed EXISTS on bans.ip_address
│
└─► true ──► head :too_many_requests (429) ← ambient, no controller opted in
Count the files Campfire touched: a controller stub, one concern with a five-line transaction, a three-line job, a Ban model, and one word in an include list. Now count the naive version's: a fat controller with a custom verb, an in-request loop that times out, a status flag that describes the account but not the machine, and a redirect_if_banned you copy-paste into every controller and still miss one.
That's the aha, stated plainly: the naive version is a flag and a scattered guard that the attacker beats by re-registering; Campfire's is a durable fact, frozen in the right order, enforced by a gate nobody had to remember to install. Count the edge cases this line absorbs for free — the snapshot-before-purge ordering absorbs "the ban with no teeth," the transaction absorbs "the contradictory half-state," perform_later absorbs "the timeout," and the one-word include absorbs "the controller that forgot to guard itself." None of it is clever. Each piece just lives at exactly the right altitude, in exactly the right order.
Key Takeaways — Patterns to Steal
- When a feature arrives as a verb — ban, unban, suspend — resist bolting
member do post :ban endonto the controller it seems to belong to, because that grows a fatUsersControllerand an untestable action. Find the noun whose lifecycle the verb describes: banning is just creating a Ban, unbanning is destroying one. Campfire writesresource :ban, only: %i[ create destroy ](routes.rb:40) and the whole controller collapses to two real lines —@user.ban; redirect_to @user(bans_controller.rb:5-13). - When one model method does several things that must all happen or none, wrap them in
transaction doand treat the order of the lines as the spec, not as cosmetics. The naiveupdate!(status: "banned")is one unguarded statement that leaves you with a half-applied ban if anything downstream fails. Campfire'sbanrunscreate_bans_from_sessions; apply_ban; banned!as one atomic unit (bannable.rb:4-10), so you never see sessions deleted but IPs unrecorded, or content gone but the account stillactive. - Persist a ban as a fact about the machine, not a flag on the account, so it outlives everything that could prove it. A
status: "banned"column dies with the user row — log out, re-register a fresh account from the same laptop, and you're back in thirty seconds. Campfire lifts the IP off the disposable session into aBanrow indexed onip_address(schema.rb:68) that survives both the logout and the account, andBan.banned?is a singleexists?(ip_address:)lookup (ban.rb:6-8) that re-registration still slams into. - Split correctness-critical work from slow fan-out by altitude: keep the fast, must-be-atomic steps in-band inside the transaction and push the unbounded loop to a job. Don't run
messages.each(&:destroy)in-request — in a real account that's thousands of rows each firing a broadcast, and the admin's click spins for forty seconds and times out half-done. Campfire keeps the IP snapshot, session purge, and status flip in-band, whileremove_banned_content_laterhands the destruction to a tinyRemoveBannedContentJob(bannable.rb:19-21, remove_banned_content_job.rb) that returns instantly — and becauseperform_laterenqueues on commit, a rolled-back ban never spawns the job. - Install request-time enforcement once, ambiently, instead of sprinkling a
redirect_if_bannedinto every controller you remember. The scattered version only bites on the next login and checks a flag onCurrent.user, which is useless against an already-authenticated banned session that keeps posting. Campfire'sBlockBannedRequestsregisters itself viaincluded do before_action :reject_banned_ip(block_banned_requests.rb:4-5), and the single wordBlockBannedRequestsin theApplicationControllerinclude list (application_controller.rb:2) gates every controller in the app — there's no opt-in to forget. - Guard mutating requests, not reads, and answer a blocked request in a way that reveals nothing. Blanket-blocking every verb breaks harmless GETs, and a candid
403 Forbiddentells an attacker they've been specifically targeted so they can probe around it. Campfire'sunless: :safe_request?lets GET and HEAD through and blocks only POST/PUT/PATCH/DELETE (block_banned_requests.rb:5,13-14), and replieshead :too_many_requests— a 429 that reads like ordinary rate-limiting (block_banned_requests.rb:10). - Write the undo as the literal inverse CRUD, not as a special-cased reversal path. Don't add an
unbanbranch that flips flags back and tries to remember what cleanup to skip. Campfire'sunbanis justbans.delete_all; active!in its own transaction (bannable.rb:12-17) — destroying the noun's rows and restoring the status — the exact mirror ofban, with no bespoke logic.