The first post in this series was about an error message. I wrote @challenges = ... in an action, the way I have written it in Rails controllers for twenty years, and Hanami answered:

FrozenError: can't modify frozen CtfTracker::Actions::Challenges::Index

The action is instantiated once and shared across requests, so there is no per-request object to hang state on. That post ended by calling this an architecture rather than a preference, and moved on to a screen where the state was a list of rows and the answer was easy: hand it to the response.

Authentication is where that answer stops being easy. current_user is not a list for one screen. In Rails it is a memoized method on the controller, reachable from any action, any view, any helper, and I have never once thought about where it lives. Here there is nowhere to put it.

Three places it cannot go

It cannot go on the action, which is the whole point of the first post. It cannot go in a constant, for reasons nobody needs explained.

And it cannot go in the container, which is the answer my Rails reflex reached for after the first two failed. The container holds the objects the application is made of: repos, operations, the things I write include Deps["repos.user_repo"] to get. They are built once and shared, which is exactly the property a per-request identity must not have. Reaching for it would have been the frozen action mistake again, one layer down.

What is left is the request, which is per-request by construction because it is the request.

def require_signed_in(request, response)
  return if self.class.public_action?

  user = signed_in_user(request)

  return response.redirect_to("/sign_in") if user.nil?

  request.env["ctf_tracker.current_user"] = user
end

A before callback on the base action, the session for the identifier, the Rack environment for the object. Nothing crosses a request boundary, and nothing is stored on anything long-lived.

The view side reads it back through the context, which every template already has:

module CtfTracker
  module Views
    class Context < Hanami::View::Context
      def current_user
        request.env["ctf_tracker.current_user"]
      end
    end
  end
end

That file had been in the repository since the generator wrote it, empty, with a comment inviting me to define a view context. It took an authentication feature to find out what it was for. It now holds both halves of what a template needs to know about the request in front of it: who is asking, and the CSRF token that proves the request came from a page we served.

The callback I could not remove

Protecting every action by default is one line on the base class:

before :require_signed_in

Then the sign-in page needs an exemption, because a signed-out visitor has to reach it, and I typed the Hanami spelling of skip_before_action without checking whether it existed.

It does not. Hanami::Utils::Callbacks::Chain has append, prepend, run, dup and freeze. The chain only grows. An inherited callback cannot be taken back by a subclass, and no amount of looking for the method makes one appear.

So the exemption became something the action says about itself:

class New < CtfTracker::Action
  public_action

  def handle(_request, response)

with the class method living on the base action, next to the guard that reads it:

def self.public_action
  @public_action = true
end

def require_signed_in(request, response)
  return if self.class.public_action?
  # ...
end

I went looking for delete and came away preferring what I found instead. skip_before_action :authenticate_user! describes the machinery: there is a chain, and this class removes a link from it. public_action describes the action: this one is open to anybody. The second is a fact about the thing, and the reader of a controller two years from now needs the fact, not the mechanism.

The property that matters survives either way, and it is the one to insist on: a new action is protected unless it says otherwise. Forgetting to protect something is the mistake that costs you; forgetting to open something is a mistake your first click finds.

Five posts in, what Deps actually is

The guard needs to load a user, which means it needs a repo, which finally forces the question this series has been dodging. Every action and operation I have shown you starts with a line like this:

include Deps["repos.user_repo"]

I have never explained it, because until now nothing made me. It reads like a global lookup with unusual syntax, and it is not.

The other direction makes the difference visible. Creating an account happens from a terminal, in a Rake task, where no action exists to inject anything:

Hanami.app["repos.user_repo"].create(...)

Same object, obtained by asking the container for it by key. That is the global lookup, and it is fine for five lines at the edge of the system. What Deps adds is not the lookup but the declaration: a class that says Deps["repos.user_repo"] states its dependency in its first line, gets a reader written for it, and can have that dependency replaced in a test without touching the class or the container.

The Rake task gets the object and states nothing, which is exactly why it is the wrong shape for application code and an acceptable one for a task whose whole job is to parse three arguments.

Protecting everything broke twenty-seven tests

Which is what protecting everything should do, and the failures came in two families.

The request specs were the expected family. They drive the app over HTTP, so they now get a redirect, and they need to sign in first. One helper, one line per spec file.

The action specs were the interesting family. They call the action directly, building a Rack environment from a hash rather than going through HTTP, so no session exists unless the spec puts one there. And the key inside must be a String:

subject.call({"rack.session" => {"user_id" => user.id}})   # 200
subject.call({"rack.session" => {user_id: user.id}})       # 302, the guard rejects

Nothing normalises that plain hash on the way in. Over HTTP the same call writes response.session[:user_id] = user.id with a symbol and reads it back with a symbol quite happily, because what answers there is not a Hash at all:

last_request.session.class
# => Rack::Session::Abstract::PersistedSecure::SecureSessionHash

Two objects wearing the same name, and the specs that skip the middleware are the ones that find out.

I could have papered over this with a helper that accepts either. I wrote the helper to build the string form, and left the difference visible, because it is telling me something true: an action spec is not a small request spec. It tests the action, and the session is not part of the action.

The question the first post left open

Authentication in a framework that ships none of it comes to a users table, bcrypt in one object, an operation that answers the same way to a wrong password and an unknown address, sessions, a guard, and a form. None of it is hard, and I would not write it again for an application anyone depends on. Rodauth is the serious Ruby answer outside Rails, it is built on Sequel so it sits naturally on a Hanami and ROM stack, and it arrives with the password rules, the session handling and the account verification already argued over by people who do this full time.

What the day bought is the answer to the question the first post left open. Rails gives you current_user and never makes you ask where it lives, which is a genuine kindness right up until the moment you need to know. Hanami has no opinion about current_user at all, and by refusing to let me store it anywhere convenient, it made me put it in the only place it was ever true: the request that is asking.

The companion app is public, and this work is the eleven commits ending at 7bdfb3c.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.