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.