Skip to content

Let strong parameters authorize a model with no allowlist - #1717

Merged
scarroll32 merged 4 commits into
mainfrom
feat-1403-strong-parameters
Oct 1, 2026
Merged

scarroll32 merged 4 commits into
mainfrom
feat-1403-strong-parameters

Conversation

@scarroll32

@scarroll32 scarroll32 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Will be merged to main at the Start of October if there is no comment, and v6.0.0 will be cut at the end of October

Closes #1403. Part of #1640.

Why

Ransack discarded the permitted? flag. The first thing Search#initialize did was to_unsafe_h, so strong parameters filtered keys but never authorized anything, and every model needed its own allowlists even when the controller had already said exactly which keys may be searched.

What

No configuration. The model decides which layer applies:

  • A model that defines ransackable_attributes, ransackable_associations or ransortable_attributes keeps them. They apply to every search, permitted or not, as in 4.x and 5.x. A key must be permitted by the controller and in the model's list.
  • A model that defines none of them hands the decision to the controller. A search built from permitted ActionController::Parameters may use every permitted key that names a real column, ransacker, alias or association. searchable_attributes / sortable_attributes (the form helpers) list the same.
  • A plain Hash has no permitted flag, and unpermitted params are not trusted, so both still raise asking for a list on a model that has none, exactly as today.
  • ransackable_scopes is always consulted, whatever the model defines and whatever was permitted. The sort_by_<name>_<dir> sort-scope convention is separate: reached by name, takes nothing from the request, neither opened nor closed here.
  • A key naming nothing is dropped, or raises under ransack!, as before.
  • Each model on the path is asked for itself: author_name_cont on a listless Article still consults Author's lists if Author has them.
class Article < ApplicationRecord
  belongs_to :author
  # no ransackable_* lists: the controller's permit is the boundary
end

def search_params
  params.fetch(:q, {}).permit(:title_cont, :author_name_cont, :created_at_gteq, :s, s: [])
end

@q = Article.ransack(search_params)

Every existing app already defines the lists, because 4.0 raises otherwise, so the upgrade is a no-op. The only new behaviour is that a model without lists now works through a permitting controller instead of raising. Giving a model a list is the override; there is no mode and no per-call option.

Permitting s permits the key, not the columns named in its value, so on a listless model a permitted s sorts by anything that exists. The docs say so, show a controller validating the value, and point at ransortable_attributes where sorting must stay restricted.

permit(q: {}) permits everything under q and Ransack cannot tell it apart from an explicit list, so on a listless model that action is a full bypass. The docs say so in a warning box, a spec pins it, and the same spec shows a model list still applying to it.

The proposal, as decided

The options are on the issue and the decided design is at the top of the issue body. @dukz's point that these are two separate layers led to dropping the strong_parameters config and per-call option from the earlier revision of this PR: with "model list wins when defined" there is nothing left for a switch to choose.

How

  • Search#initialize reads permitted? before the unwrap and stores it on Context#permitted.
  • Ransack::ActiveRecord::Base#ransackable_list_defined? is the existing private explicitly_defined? made public: whether the model, or a superclass, defines the given list rather than inheriting Ransack's default.
  • Context routes the three ransackable_*? checks and the three form-helper lists through one private allowlist lookup: the model's list when it defines one, the authorizable_ransackable_* existence list when it defines none and the params were permitted, and otherwise the model's default, which raises. ransortable_attributes counts as defined if either it or ransackable_attributes is.

Docs

  • Authorization gets a subsection, "Strong parameters instead of model allowlists": what to permit (condition keys, not attributes; s in both shapes; fetch not require), how a model list stacks on top, the three invariants, and the permit(q: {}) warning.
  • Upgrading to 6.0 gets a short note: the three list methods are optional for a model only searched through permitting controllers.

Verification

  • 716 examples, 0 failures on SQLite (Active Record 7.2.3.2, Ruby 3.4.9)
  • strong_parameters_spec.rb (16 examples) uses Unlisted, a model inheriting from ActiveRecord::Base directly so it has no lists: any column, sort and association through permitted params; the traversed model's list still applies; existence under ransack!; scopes never bypassed (expect(Unlisted).not_to receive(:active)); plain Hash and unpermitted params raise; permit(q: {}). And for Person and a Listed model: the list applies to permitted params, to sorting, to permit(q: {}), to an STI subclass, and to the form helpers.
  • RuboCop clean on the changed files

🤖 Generated with Claude Code

Ransack discarded the `permitted?` flag: the first thing `Search#initialize`
did was `to_unsafe_h`, so strong parameters filtered keys but never
authorized them, and every model needed its own allowlists even when the
controller had already said exactly what may be searched (#1403).

`config.strong_parameters = true` (off by default) makes a search built
from permitted `ActionController::Parameters` skip
`ransackable_attributes`, `ransackable_associations` and
`ransortable_attributes`; every permitted key that names a real column,
ransacker, alias or association is searchable, and the form helpers list
the same. `ransack(params, strong_parameters: true / false)` overrides it
for one search in either direction.

What does not change: `ransackable_scopes` is consulted whatever the
params, a plain Hash or unpermitted params keep the model allowlists, and
a key naming nothing is dropped or raises under `ransack!` as before.

- Search#initialize reads `permitted?` before the unwrap and resolves it
  with the option and the config into `Context#strong_parameters`
- Context routes the three attribute/association checks and the three
  form-helper lists through one pair of private lookups that pick the
  model list or the `authorizable_*` existence list
- spec/ransack/strong_parameters_spec.rb covers the default, the config,
  the per-call override both ways, unpermitted params, a plain Hash,
  existence, scopes, sorting, associations, the form-helper lists and
  the `permit(q: {})` wholesale case
- docs: a subsection under Authorization with what to permit and the
  `permit(q: {})` warning, a Configuration entry, and an Upgrading note

Closes #1403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Permitted sorting can invoke unlisted sort scopes, contradicting the documented authorization invariant.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Low severity

Open (2)
What changed in this PR

Adds opt-in trust of permitted Rails strong parameters as Ransack’s authorization boundary.

Changes:

  • Adds global and per-search configuration.
  • Routes attribute, association, and sorting authorization through permitted parameters.
  • Adds tests and migration/security documentation.
File Description
lib/​ransack/​search.rb Detects permitted parameters and configures the context.
lib/​ransack/​context.rb Selects existence lists for trusted searches.
lib/​ransack/​configuration.rb Adds the global option.
spec/​ransack/​strong_parameters_spec.rb Tests the new authorization behavior.
docs/​going-further/​other-notes.md Documents usage and risks.
docs/​getting-started/​upgrading.md Adds migration guidance.
docs/​getting-started/​configuration.md Documents configuration.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread spec/ransack/strong_parameters_spec.rb Outdated
Comment on lines +72 to +74
it 'never bypasses ransackable_scopes' do
expect(Person).not_to receive(:active)
expect(Person.ransack(permitted(active: true)).result.to_sql).not_to include 'active = 1'

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reply from Claude (Claude Code), acting on behalf of @scarroll32.

Right that the sort_by_<name>_<dir> convention is reachable here, but it is reachable in exactly the same way without this setting: Search#add_sort accepts it on respond_to? in every mode, and ransackable_scopes has never gated it. It is reached by name and takes nothing from the request, so it is a different thing from a condition scope with a user-supplied argument. This PR neither opens nor closes it.

Fixed by narrowing the invariant rather than the code: the docs now say "a permitted condition key can never call a class method the model has not listed" and name the sort convention as separate, and a new spec pins that a trusted s: 'reverse_name asc' produces byte-for-byte the same SQL as the plain-Hash search. Gating the convention behind ransackable_scopes would be a behaviour change for every existing user and is out of scope here.

Comment on lines +300 to +303
What is permitted is the condition key, so `title_cont` allows exactly that
and not `title_eq`. `s` is a string for a single sort and an array for
several, hence both forms. Use `fetch` rather than `require`, because a request
with no search is a normal request.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Reply from Claude (Claude Code), acting on behalf of @scarroll32.

Agreed, and documented. permit(:s) permits the key and cannot express a list of columns, so with the setting on a permitted s sorts by anything that exists. The Authorization docs now have a paragraph saying exactly that, a controller example that validates the sort value against its own list before passing it on, and the alternative of strong_parameters: false for that search so ransortable_attributes applies. A spec covers the override.

@scarroll32 scarroll32 changed the title Trust strong parameters as the authorization boundary Use strong parameters as the authorization boundary Sep 22, 2026
scarroll32 and others added 2 commits September 22, 2026 10:47
Two Copilot findings on the PR, both about sorting:

- `permit(:s)` permits the key, not the column named in its value, so a
  trusted `s` can sort by anything that exists. The docs now say so and
  show a controller validating the value against its own list, or passing
  `strong_parameters: false` so `ransortable_attributes` applies. Spec for
  the override.
- The `sort_by_<name>_<dir>` convention is reached by name in every mode
  and is not gated by `ransackable_scopes`; it takes nothing from the
  request. The "scopes never bypass" invariant now says "condition key"
  and names the convention as separate, and a spec pins that a trusted
  search produces the same SQL as a plain one for it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@scarroll32

scarroll32 commented Sep 22, 2026 •

Copy link
Copy Markdown
Member Author

@deivid-rodriguez, @gregbell thoughts on moving to this into the 6.0.0 release of Ransack?

A model that defines ransackable_attributes, ransackable_associations or
ransortable_attributes keeps them for every search, permitted or not. A
model that defines none lets permitted ActionController::Parameters be
the boundary, and still raises for a plain Hash or unpermitted params.
ransackable_scopes applies regardless.

Drops the strong_parameters configuration and per-call option from the
earlier revision: every existing app defines the lists, so "model list
wins when defined" makes the upgrade a no-op and leaves nothing for a
switch to choose. Makes explicitly_defined? public as
ransackable_list_defined? so the context can ask.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@scarroll32 scarroll32 changed the title Use strong parameters as the authorization boundary Let strong parameters authorize a model with no allowlist Sep 27, 2026
@scarroll32
scarroll32 merged commit 650044a into main Oct 1, 2026
29 checks passed
@scarroll32
scarroll32 deleted the feat-1403-strong-parameters branch October 1, 2026 05:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Extend 4.0.0 allow/deny listing with Strong Parameters

2 participants