The Ban Arc: Thin Controller, Orderly Model, Ambient Guard

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.

verb-as-noun: banning is creating a Ban the fat-model transaction as an ordered checklist transaction ordering as the load-bearing correctness detail snapshot durable state before you delete its source security as an ambient self-registering before_action the block outlives the session and the account altitude: defer the slow fan-out (content removal) to a job authorization-by-association at the request gate campfire by nityeshagarwal

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.

A clean technical teaching poster titled "THE NAIVE BAN — four ways it leaks" i…
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 rowssessions.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.

A precise technical teaching poster titled "THE BAN TRANSACTION — order is the …

"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.

A clean technical teaching poster titled "ONE INCLUDE = THE GATE EVERYWHERE" in…
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 end onto the controller it seems to belong to, because that grows a fat UsersController and an untestable action. Find the noun whose lifecycle the verb describes: banning is just creating a Ban, unbanning is destroying one. Campfire writes resource :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 do and treat the order of the lines as the spec, not as cosmetics. The naive update!(status: "banned") is one unguarded statement that leaves you with a half-applied ban if anything downstream fails. Campfire's ban runs create_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 still active.
  • 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 a Ban row indexed on ip_address (schema.rb:68) that survives both the logout and the account, and Ban.banned? is a single exists?(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, while remove_banned_content_later hands the destruction to a tiny RemoveBannedContentJob (bannable.rb:19-21, remove_banned_content_job.rb) that returns instantly — and because perform_later enqueues on commit, a rolled-back ban never spawns the job.
  • Install request-time enforcement once, ambiently, instead of sprinkling a redirect_if_banned into every controller you remember. The scattered version only bites on the next login and checks a flag on Current.user, which is useless against an already-authenticated banned session that keeps posting. Campfire's BlockBannedRequests registers itself via included do before_action :reject_banned_ip (block_banned_requests.rb:4-5), and the single word BlockBannedRequests in the ApplicationController include 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 Forbidden tells an attacker they've been specifically targeted so they can probe around it. Campfire's unless: :safe_request? lets GET and HEAD through and blocks only POST/PUT/PATCH/DELETE (block_banned_requests.rb:5,13-14), and replies head :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 unban branch that flips flags back and tries to remember what cleanup to skip. Campfire's unban is just bans.delete_all; active! in its own transaction (bannable.rb:12-17) — destroying the noun's rows and restoring the status — the exact mirror of ban, with no bespoke logic.