Security Is the Shape of Your Data Access

Security Is the Shape of Your Data Access

A principle tutorial: authorization is strongest when it isn't a guard you remember to add but the very query you write. Load every record through the current user and the leaking version becomes one you literally cannot type. Derives security-as-shape from first principles, shows it compounding with the model-owns-truth, CRUD-on-a-noun, concerns, and polymorphism principles, then grounds it in Campfire's reachable_messages, authentication macros, can_administer?, the ambient ban gate, and a fail-closed SSRF guard.

authorization-by-association the IDOR you cannot type load every record through the current user find_by! (hard 404) vs find_by + redirect (human nav) reachable_messages as the single source of truth for visibility secure-by-default authentication concern opt-out-by-name with allow_unauthenticated_access / allow_bot_access OR'd auth strategies in priority order conditional CSRF on .bot_key? one can_administer? predicate guarding every write subclass overrides the guard (Direct rooms) the ambient self-registering before_action (block_banned_requests) capability by subtraction (deny_bots by default) fail-closed SSRF guard (invalid means dangerous) defense-in-depth with no per-call vigilance campfire by nityeshagarwal
Builds on:

The principle: Authorization is strongest when it isn't a guard you remember to add but the very query you write — load every record THROUGH the current user, so the leaking version is one you literally cannot type.

The bug you ship by forgetting one line

Here's the feature: a user may read only the rooms and messages they belong to. Simple. You sit down to write the controller, and here's the version you'd vibe-code first — the honest one, before any convention clicked:

class MessagesController < ApplicationController
  def index
    @room = Room.find(params[:room_id])                          # 1. load the room
    head :forbidden unless @room.users.include?(Current.user)    # 2. ...then check access
    @messages = @room.messages.last(100)
  end
end

Two statements. The first one loads the room. The second one is the entire security model of your application, and it is a line you have to remember to type. It works. You test it, it forbids the right people, you move on.

Then the app grows. Someone adds boosts (reactions). They need a boosts controller, and they start it the way every controller starts — by finding the thing:

def create
  @message = Message.find(params[:message_id])   # ...and that's it. No second line.
  @message.boosts.create!(boost_params)
end

They copied the first line out of habit and never wrote the second, because the second line isn't load-bearing to making boosts work — it only matters when an attacker shows up. Now any logged-in user can boost any message in any private room by guessing an id. That's an IDOR — an Insecure Direct Object Reference — and you didn't introduce it with bad code. You introduced it by writing correct code that happened to forget an invisible second step.

GET /messages/8231/boosts   # a message in a room you were never invited to

This is the trap of security-as-a-checkpoint. The guard is a separate act from the work, so the safe version and the unsafe version look almost identical — the unsafe one is just shorter, and shorter code is the code that gets copied. Every new controller is a fresh chance to forget. Defense by vigilance scales like vigilance: badly.

"Can't I just be disciplined and always remember the check?" You can. For a while. But "the codebase is secure as long as no one ever forgets a line across forty controllers and three years" is not a security model — it's a hope. The whole move of this principle is to make the unsafe version unwriteable, so discipline isn't required, because the leak isn't a line you could forget — it's a line you cannot form.

① Derive it: make the leak impossible to type

Step back and ask the Feynman question — what is authorization, underneath? In the naive version it's a predicate: "given this record and this user, return true or false." But notice where the danger lives. It lives in the gap between loading the record and checking the predicate. The record is already in your hands, fully loaded, before you've asked whether the user was allowed to touch it. The check is a thing bolted on after access.

So invert it. What if there were no gap — because the only way to find the record were through the user in the first place?

@message = Current.user.reachable_messages.find(params[:message_id])

Read that as a sentence: of the messages reachable by the current user, find this one. If the message belongs to a room the user isn't in, it is not in reachable_messages, so find raises RecordNotFound — a 404. Not a 403 ("you're forbidden," which leaks that the row exists), just "no such thing, from where you're standing."

The record a user can't see does not exist from their vantage point. Authorization stopped being an act you perform and became the shape of the association you traverse. And here is the property that makes it beautiful — there is no Message.find in this code to copy into the next controller. The global, unscoped lookup that was the bug isn't merely discouraged; it's absent. The IDOR you cannot type: you can't accidentally leak a message because the only verb available to you already starts at the user.

This is authorization-by-association, and it's the model-owns-truth principle (see P1: The Model Owns Its Consequences) applied to the question "what can you see?" The answer is a fact about a relationship — and facts about relationships live on associations, not in scattered if statements. It's also derive-don't-store (see P2: Derive, Don't Store) applied to permission: you don't store an access_control_list; you derive visibility from the membership rows that already exist.

The same inversion has a request-layer twin. The naive default for "is this action protected?" is open — a new controller action is public until you remember to guard it, same forgettable-line problem one level up. So flip the default there too: make every action require a session and deny bots unless a controller explicitly says otherwise. The unsafe state (an unprotected endpoint) becomes one you have to ask for by name, which means you can read off exactly which doors are open by grepping for the opt-out. We'll see both halves in the evidence.

A square 1:1 technical teaching poster on an off-white (#FAF7F0) ground, in the…

② The beauty in combination

Security-as-shape isn't a clever trick in isolation — it gets its real power from the fact that it's the same reachable_messages everywhere, composing with four other principles at once.

With CRUD-on-a-noun (see P6: Model Every State Change as CRUD on a Noun). When every state change is a tiny resourceful controller, each of those controllers loads its subject through the user, so authorization rides in for free on the same line that does the loading. There's no separate "auth layer" to wire into each new noun — banning, boosting, searching all start from Current.user.something. The thin controller and the secure controller are the same controller.

With one renderer over the wire (see P5: One Renderer: HTML Over the Wire) and search (see C2: Search Is Almost Entirely Convention). Because reachable_messages is a chainable Relation, the auth boundary drops onto anything downstream. Current.user.reachable_messages.search(query) means full-text search is authorized by construction — the search scope composes onto the access scope, and a user physically cannot search a room they're not in. You didn't write "filter search results by permission." The permission is the left half of the chain.

With concerns (see P8: Give Behavior a Home). The most striking version: a security gate that installs itself by being listed. A concern can register a before_action on its host the moment it's included — so a single line in ApplicationController's include list mounts a guard on every endpoint in the app, with zero per-controller code. Security becomes ambient. There is no per-action vigilance because there is no per-action anything — the gate is wired once, structurally, by the include line that is also the table of contents (see F7: Concerns as a Mechanism).

With polymorphism (see P7: Polymorphism Over Conditionals). A bot is just a User with role: :bot, denied everywhere by default; allow_bot_access opens exactly one door. That's capability by subtraction — you don't grant the bot fifteen permissions, you deny it everything and un-deny one thing. And when a rule has an exception (every member of a direct message can administer it), the exception is a subclass overriding one method, not an if room.direct? smeared through the controller.

The reader's payoff: this is defense-in-depth that requires no vigilance, because at every layer the safe path is also the only ergonomic path. You don't choose security over convenience at each call site. The convenient thing and the secure thing are the same thing — which is the only kind of security that survives contact with a growing codebase and a tired developer.

③ How 37signals did it

The load IS the authorization

Open RoomScoped, the concern that every room-scoped controller includes (room_scoped.rb:8-12):

module RoomScoped
  extend ActiveSupport::Concern

  included do
    before_action :set_room
  end

  private
    def set_room
      @membership = Current.user.memberships.find_by!(room_id: params[:room_id])
      @room = @membership.room
    end
end

There is no Room.find followed by a permission check. The room is reached through Current.user.memberships — and the find_by! (bang) raises RecordNotFound for a room the user has no membership in. A non-member's room isn't forbidden; it's a 404.

This isn't a Campfire idiom — it's the 37signals way. Fizzy, an unrelated Kanban product, reaches the byte-for-byte same shape: CardScoped#set_card loads Current.user.accessible_cards.find_by!(number: params[:card_id]) (card_scoped.rb:9-11), the same load-through-the-user find_by! that 404s an invisible card instead of forbidding it. When two unrelated products converge on the identical line, it stops being an idiom and becomes doctrine. As a bonus, @membership is captured for free, because the views need it anyway — the auth lookup and the data load are one query.

The single source of truth for "what messages can you see?" is one association line on the User (user.rb:7):

Fizzy mints the identical thing: has_many :accessible_cards, through: :boards, source: :cards (user/accessor.rb:8), one association that is the entire visibility rule for cards, the exact mirror of reachable_messages.

has_many :reachable_messages, through: :rooms, source: :messages

Messages, through the rooms you're a member of. Watch it compose. The boosts controller loads its subject through it (messages/boosts_controller.rb:25-27):

def set_message
  @message = Current.user.reachable_messages.find(params[:message_id])
end

And search does the identical thing, with the search scope chained onto the same access boundary (searches_controller.rb:21-27):

@messages = Current.user.reachable_messages.search(query).last(100)

One association, written once, is the visibility rule for boosting, for searching, for every sub-resource that hangs off a message.

Fizzy's search does the same — the by-number teleport runs through the scope (Current.user.accessible_cards.find_by_id(@query), searches_controller.rb:7), and the full-text path composes right onto it too: Current.user.accessible_cards.mentioning(@query, user: Current.user) (searches_controller.rb:21) — search authorized by construction, the same access-shape composing onto the query in a product that never heard of Campfire. You cannot leak a message through any of these paths because none of them know how to start anywhere but at the user.

"What about top-level navigation — should clicking a stale room link really 404 the user?" No, and Campfire draws the line deliberately. Look at RoomsController#set_room (rooms_controller.rb:22-28) — it uses find_by (no bang) and redirects with a friendly alert instead of raising. The split is intentional: find_by! (hard 404) for API-ish sub-resources where a missing row is a real error, find_by + redirect for human navigation where a stale bookmark deserves a gentle bounce, not a crash. Same shape — load through the user — two failure manners chosen by context. The contrast: the naive Room.find gives you exactly one behavior (a 500 on a missing/forbidden row) and no choice about it.

Secure-by-default, opt-out-by-name

The request layer inverts its default in the Authentication concern (authentication.rb:5-26):

included do
  before_action :require_authentication
  before_action :deny_bots
  # ...
  protect_from_forgery with: :exception, unless: -> { authenticated_by.bot_key? }
end

class_methods do
  def allow_unauthenticated_access(**options)
    skip_before_action :require_authentication, **options
  end

  def allow_bot_access(**options)
    skip_before_action :deny_bots, **options
  end
  # ...
end

Every controller, by inheriting this, requires a session and denies bots — the unsafe state is the one you can't fall into by accident. To open a door you call a macro that reads as intent: allow_unauthenticated_access on the login page, allow_bot_access on the bot message endpoint.

Fizzy declares its posture with the same vocabulary — class_methods do exposing allow_unauthenticated_access and friends over a closed-by-default before_action :require_authentication (authentication.rb:15-25) — confirming that "declare your security posture in readable macros" is a cross-product 37signals convention, not a Campfire flourish. You can list every public action in the app by grepping for those two verbs — the attack surface is enumerable, because each opening is a named declaration, not the absence of a forgotten line.

The auth itself is an OR-chain that is the priority order (authentication.rb:33-35):

def require_authentication
  restore_authentication || bot_authentication || request_authentication
end

Try the session cookie; else try a bot key; else bounce to login (stashing the return URL). And notice the conditional CSRF on line 10: protect_from_forgery unless: -> { authenticated_by.bot_key? }. A key-authenticated client submits no forgeable form, so it has no CSRF surface to protect — the guard is scoped to the threat that actually exists rather than blanket-applied.

"Isn't skip_before_action just the same forgettable-line problem in reverse?" No — and this is the subtle win. Forgetting to add a guard fails open (a leak). Forgetting to call allow_unauthenticated_access fails closed (your new public page demands a login until you notice). The direction of the failure is the whole game: secure-by-default means every mistake errs toward locked, not leaking.

One predicate guards every write — and a subclass bends it

Authorization for mutations collapses to a single sentence on the Role concern (user/role.rb:8-10):

module User::Role
  extend ActiveSupport::Concern

  included do
    enum :role, %i[ member administrator bot ]   # generates administrator?, bot?, member?
  end

  def can_administer?(record = nil)
    administrator? || self == record&.creator || record&.new_record?
  end
end

You can administer a record if you're an admin, or you made it, or it's brand new. That one predicate, reused at ~16 call sites, is wired into the message controller as a single guard over every destructive action (messages_controller.rb:6):

before_action :ensure_can_administer, only: %i[ edit update destroy ]

And here's the polymorphism payoff. Direct messages have a different rule — everyone in a DM can administer it. The naive version would sprinkle if room.direct? into the guard. Campfire makes the rule a subclass override (rooms/directs_controller.rb:33-35):

# All users in a direct room can administer it
def ensure_can_administer
  true
end

The exception lives in exactly one place — the subclass that is the exception — and the base controllers never learn that direct rooms exist (see P7: Polymorphism Over Conditionals).

The gate that installs itself

The ambient version is the cleanest expression of the whole principle. BlockBannedRequests is a concern (block_banned_requests.rb:4-15):

included do
  before_action :reject_banned_ip, unless: :safe_request?
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

The moment this concern is listed in ApplicationController's include line, every non-idempotent request in the entire app is checked against the ban list — no controller asked to be guarded. The security is wired by the included do block (see F7: the included do wiring harness), so it's structural, not per-action. No one can forget to protect a new endpoint, because no one protects endpoints individually at all. (The full arc — how a Ban snapshots IPs into durable rows before the sessions holding them are deleted — is see C3: The Ban Arc.)

Fail-closed at the network edge

The same instinct reaches all the way down into lib/, where Campfire fetches link previews and must not be tricked into hitting its own internal network (an SSRF). The guard (private_network_guard.rb:17-23):

def private_ip?(ip)
  IPAddr.new(ip).then do |ipaddr|
    ipaddr.private? || ipaddr.loopback? || ipaddr.link_local? || ipaddr.ipv4_mapped? || ipaddr.ipv4_compat? || LOCAL_IP.include?(ipaddr)
  end
rescue IPAddr::InvalidAddressError
  true
end

Read the rescue. If the address is so malformed that IPAddr can't even parse it, the method returns truetreat it as private, i.e. dangerous. The naive instinct is rescue => return false ("couldn't tell, let it through"), which is precisely the SSRF hole. Here, invalid means dangerous: when the code can't prove an address is safe, it refuses. Fail-closed is the network-layer echo of secure-by-default — every uncertainty resolves toward locked.

Anti-enumeration by structural identity

There's one threat the room-and-message authorization above never has to face, because Campfire is invite-only: account enumeration. A login form that says "check your email for a magic link" when the address is known, but "no account found" (or simply does nothing different) when it isn't, has just told an attacker which emails are registered. Spray ten thousand addresses, watch which responses differ, and you've harvested your user list. This is frontier ground — passwordless, public-signup auth that Campfire's model doesn't reach — so Fizzy is the worked example here, with no Campfire analog to corroborate.

The naive Rails fix is a conditional that tries to look uniform: if Identity.exists?(...) then redirect else flash.now[:notice] = "..."; render :new end. But the two branches are different code paths, and they leak through the cracks — a different flash key, a redirect vs. a render, a timing difference, a missing cookie. The fix-by-branch is exactly the security-as-a-checkpoint failure this whole tutorial is about, wearing a privacy hat: the safe response and the unsafe response are two shapes, and any divergence between them is the leak.

Fizzy refuses the branch. Look at the third arm of SessionsController#create (sessions_controller.rb:14-22):

def create
  if identity = Identity.find_by(email_address: email_address)
    sign_in identity
  elsif Account.accepting_signups?
    sign_up
  else
    redirect_to_fake_session_magic_link email_address
  end
end

When the email is unknown and signups are closed, it doesn't render a different page or set a "no such account" message. It calls redirect_to_fake_session_magic_link — and the word fake is the whole idea (authentication/via_magic_link.rb:15-23):

def redirect_to_fake_session_magic_link(email_address, **options)
  fake_magic_link = MagicLink.new(
    identity: Identity.new(email_address: email_address),
    code: SecureRandom.base32(6),
    expires_at: MagicLink::EXPIRATION_TIME.from_now
  )

  redirect_to_session_magic_link fake_magic_link, **options
end

Read what it builds: a MagicLink.new wrapping an Identity.new, with a real random code and a real expires_at — and crucially, never saved. No create, no save, no row in the database. It then hands that unsaved object to the exact same redirect_to_session_magic_link (via_magic_link.rb:25-34) that a genuine sign-in uses. The pending-authentication cookie gets set, the redirect to the magic-link page fires, the JSON branch renders a pending_authentication_token — all from an object shape that is byte-for-byte indistinguishable from the real thing. The attacker submits an unknown email and gets the same response a known email would have produced. The enumeration oracle has nothing to read.

This is the tutorial's principle inverted into the privacy dimension. Authorization made the leak unwriteable by removing the global finder; anti-enumeration makes the leak unobservable by removing the conditional that would distinguish the two cases. You don't defend enumeration with a guard that tries to make two branches equal — you defend it by building the same object shape and just not persisting it, so there is only one branch, one response, one shape. Security as the shape of the response, not a branch in it. (The same instinct guards the other direction in this concern: ensure_development_magic_link_not_leaked on via_magic_link.rb:9-13 raises outside development if a magic-link code ever lands in the flash — fail-closed again, the response refusing to exist rather than leak.)

A square 1:1 technical teaching poster, same locked visual system as the prior …

The throughline, honestly

The yardstick of this whole series — count the edge cases this line absorbs for free — lands cleanly here. Current.user.reachable_messages.find(...) absorbs the entire class of "someone forgot the permission check in a new controller," because there's no global finder to forget. before_action :require_authentication in a shared included do absorbs "someone shipped an unprotected endpoint," because the unprotected state requires a named opt-out. rescue → true absorbs "the SSRF filter let a weird address through." Each is a convention at a boundary doing the work that, in the naive version, was a line a human had to remember. Security isn't bolted on at the seam. Security is the seam.

Key Takeaways — Patterns to Steal

  • When a controller needs a record the user is only sometimes allowed to touch, start the query at the user — Current.user.reachable_messages.find(params[:message_id]) — instead of the reflex Message.find(params[:message_id]) followed by an if @message.room.users.include?(Current.user) guard. The guard version works until the day someone copies the find line into a new controller and forgets the check; the through-the-user version has no global finder to copy, so the leak is a line you cannot form. Campfire's boosts controller does exactly this in messages/boosts_controller.rb:25-27, and the whole class of "forgot the permission check in the new controller" simply can't occur.
  • Because that association is a chainable Relation and not a precomputed array, hang the rest of the query off it and the auth boundary rides along automatically — Current.user.reachable_messages.search(query).last(100) (searches_controller.rb:23) is full-text search that is authorized by construction. Don't run an open Message.search(query) and then try to "filter the results by permission" afterward, which is the exact place a search-results leak hides. The permission is the left half of the chain; the user physically cannot search a room they're not in.
  • Invert the request-layer default so every action requires a session and denies bots out of the box, and open each door by name — instead of leaving new actions public and remembering to add a guard. Put before_action :require_authentication and before_action :deny_bots in a shared included do (authentication.rb:5-7) and expose allow_unauthenticated_access / allow_bot_access macros for the exceptions. Now you can read off the entire attack surface by grepping for those two verbs, because each opening is a named declaration rather than the absence of a forgotten line.
  • The reason secure-by-default beats discipline is the direction the mistakes fail: forgetting to add a guard fails open and leaks, but forgetting to call allow_unauthenticated_access fails closed — your new public page just demands a login until you notice. Don't talk yourself into "I'll just always remember to protect each endpoint"; that's a hope, not a security model. With the default flipped in authentication.rb, every slip errs toward locked instead of leaking.
  • Collapse write authorization into one small predicate you reuse everywhere instead of copy-pasting a creator check into each mutating action where it can quietly drift. can_administer? = administrator? || self == record&.creator || record&.new_record? (user/role.rb:8-10) is wired as a single before_action :ensure_can_administer, only: %i[ edit update destroy ] in messages_controller.rb:6 and reused at ~16 sites. One sentence is the rule for every destructive action.
  • Let a concern mount its own gate the instant it's listed, so security becomes ambient and no one has to remember to guard a new endpoint. BlockBannedRequests registers before_action :reject_banned_ip, unless: :safe_request? in its included do (block_banned_requests.rb:4-15), and the single line that includes it in ApplicationController mounts that check on every non-idempotent request in the app. There is no per-action vigilance because there's no per-action anything — the gate is wired once, structurally.
  • Grant a restricted actor capability by subtraction — deny it everything by default and un-deny the single thing it needs — rather than building a parallel permission set that drifts from the human one. A bot is just a User with role: :bot, denied everywhere by deny_bots, and allow_bot_access (authentication.rb:18-20) reopens exactly one action. You're not granting fifteen permissions; you're removing one denial.
  • At the network edge, resolve every uncertainty toward "dangerous" — if you can't prove an address is safe, refuse it. The tempting rescue => false ("couldn't tell, let it through") is precisely the SSRF hole. Campfire's private_ip? rescues IPAddr::InvalidAddressError by returning true (lib/restricted_http/private_network_guard.rb:21-22), so an unparseable address is treated as private and blocked — invalid means dangerous, which is just secure-by-default echoed one layer down.