Here is a question worth asking about any application you have shipped: what, precisely, stops a row with an empty name from reaching your database?
I could not answer it for my own until I measured, and the answer was thinner than I expected.
The code I trusted
Validation in Hanami lives on the action, in a block that is both the whitelist and the contract:
params do
required(:challenge).hash do
required(:name).filled(:string)
required(:category).filled(:string)
optional(:difficulty).filled(:integer)
optional(:points).filled(:integer)
optional(:event_id).maybe(:integer)
end
end
The column, meanwhile, was declared in the migration the obvious way:
column :name, String, null: false
Two guards, one at the edge and one at the bottom. A challenge without a name had nowhere to get through.
Each guard, measured
So I asked, in a console, rather than reasoning about it. Creating a challenge
straight through the repo with a nil name raised
ROM::SQL::NotNullConstraintError, which is what I expected. Creating one
with an empty string was accepted, and the row landed with name set
to "".
null: false refuses NULL. That is all it has ever promised, and it kept its
promise exactly. An empty string is a perfectly good string as far as SQLite
is concerned, and it went straight in.
Which leaves filled(:string) as the only thing standing between my table and
a nameless row. It works, and I have a passing test that proves it works. But
it lives on one action, and it only runs when a request goes through that
action.
I checked the layer in between too, because by then I had stopped trusting my assumptions. The operation that creates a challenge takes the empty name without complaint. Of course it does. Nobody told it not to.
So the guard is one layer thick, at the HTTP edge. A seed file, a background job, a console session, a second action written next month by somebody in a hurry: none of them pass through that contract, and none of them will be stopped.
The rule that had no home
The thinness became a bug the same afternoon, in a way I did not plan.
Challenges can be attached to an event, so the form has a select. I posted one
with an event_id that no longer existed. Not an attack, just a stale form.
ROM::SQL::ForeignKeyConstraintError: SQLite3::ConstraintException: FOREIGN KEY constraint failed
A 500. A crash page, for a situation the application could have described in a sentence.
Look at where that fell. filled(:integer) checked the shape of the parameter
and was satisfied: 999999 is an integer. The foreign key checked existence and
was not satisfied, but a database constraint answers with an exception, not
with a message a person can read. The question “does this event still exist?”
belongs to neither. It is not a fact about the shape of an HTTP parameter, and
it is not a fact about a column. It is a rule about what it means to create a
challenge, and there was nowhere in my code that such a rule lived.
Giving it somewhere to live
Hanami’s answer is an operation, and once the rule has an address the code stops being clever:
def call(attributes)
step ensure_event_exists(attributes[:event_id])
challenge_repo.create(attributes)
end
private
def ensure_event_exists(event_id)
return Success(nil) if event_id.nil?
event_repo.find(event_id) ? Success(event_id) : Failure(:unknown_event)
end
The action stops hoping and starts mapping outcomes:
case create.call(request.params[:challenge])
in Success(_challenge)
response.redirect_to "/challenges"
in Failure(:unknown_event)
reject(response, {challenge: {event_id: ["does not exist"]}})
end
Same request as before, driven against the running app: 422 now, with “does not exist” rendered in the form where the user can see it. The nominal paths still redirect, and a blank name is still refused at the edge.
The same hole, in Rails
None of this is really about Hanami.
In Rails I would have written validates :name, presence: true on the model
and stopped thinking about it, genuinely believing the question was settled.
It would not have been: update_column, insert_all and any console session
go straight around it. The hole I found here exists in most Rails applications
I have worked on, including ones I wrote, and I never went looking for it
because the model made me feel like somebody had.
That is the difference, and it does not flatter Hanami much. The Rails model never closed that gap, it just stopped me asking about it. Take the model away and the question comes back, at the least convenient moment, which in my case was a 500 in a form I had built an hour earlier.
The question I ask now
I ask where a rule lives, and I ask it as a separate question from where the rule is written down.
A shape belongs in the contract. A column’s floor belongs in the migration. Anything that depends on the state of the world, like whether the thing you are pointing at is still there, belongs in something that can look. Those are three different places, and the moment I stopped assuming they overlapped, I found a crash I had shipped and a column I had never really protected.
The state described here is tagged
04-rules-need-a-home.
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.