concerns_on_rails 1.28.3 → 1.28.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d3f06aad189272886038354ad98dfd58a84642d64e1313c4aac5d88e7d1ec90a
4
- data.tar.gz: b38acca4cb5cd6a48496d0d4909da90782c14b6b71748af88b72df482a02ef62
3
+ metadata.gz: 615b4ddcff3ebdffc7e32f618731510bf5e386fa015cb0a1ae958a4e6fb04d1c
4
+ data.tar.gz: 723b3ae2077f43fc5771947d2e610f011ca83ee8215b78fb0894a04400362220
5
5
  SHA512:
6
- metadata.gz: 98d77a84da148195bdd1c013054b12bd3b8c4f6ce2bf5b7ae8ee7e91c0a24b4d5d45e6540c139456e1d51b91afbfaed5cf68d9faf9859399cb4ec2b76ccdd90b
7
- data.tar.gz: 6852cb66a64150139c9625d765cd3f724881a6b35a9e5920140713c21b69f148c956353d587765f3ad1eca6018b38de70b98aa8aa9c40006ad0a6919242607c8
6
+ metadata.gz: 352ff281ce000213b296a1b8ccae97478d6a6726644fb6bcf69c5f4e05d94c57e674f5cff59f3ed0ff8d90891c90b6ebc53affdec5f47ea64102ca88a3c40a0e
7
+ data.tar.gz: 595236a2464439aa26d10ed118fc5d307ca47022cac8e5c9cae6077419da39dc7a3fc2d085534bfb2d36bdf3625c99442054dc7fd5868be71b9034923aa66e11
data/CHANGELOG.md CHANGED
@@ -1,5 +1,113 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.28.4 (2026-09-16)
4
+
5
+ Eleven bug-fix PRs (#91–#101) from the audit of the shipped gem, released as a
6
+ patch: no new concerns, no new options, no migrations, no dependency changes.
7
+ Two close fail-open holes (WebhookVerifiable, Encryptable); the rest are
8
+ correctness fixes for behaviour the docs already promised. Every fix ships with
9
+ a regression spec that fails on 1.28.3. 1460 examples, 0 failures.
10
+
11
+ ### Security
12
+ - **Controllers::WebhookVerifiable**: verification could be skipped entirely,
13
+ leaving the action to run on an unverified — possibly forged — payload. Two
14
+ paths: `webhook_verification_failed` returned `nil` when there was no response
15
+ object to render into, which left the `before_action` chain unhalted; and
16
+ `webhook_rule_for_action` returned `nil` (read as "no rule applies, carry on")
17
+ when `action_name` was unresolvable or `""`. Both fail closed now — the first
18
+ raises, the second falls back to the catch-all rule, or to a lone declared
19
+ rule, and verifies. With several action-specific rules and no catch-all it
20
+ raises rather than verifying against an arbitrary provider's secret, which
21
+ would reject a valid delivery as "signature invalid". The render guard also
22
+ honours a `render_error` override on its own, so a controller supplying one
23
+ but no response object renders its rejection instead of raising. Mirrors the
24
+ fix Authorizable got in 1.22. A resolvable action simply not covered by any
25
+ rule still passes through untouched. (#92)
26
+ - **Models::Encryptable**: `<field>_ciphertext` — documented for "asserting no
27
+ plaintext is at rest" — returned the caller's **plaintext** whenever the value
28
+ had not round-tripped through the database (a new record, or any pending
29
+ assignment: exactly the state inside a `before_save`, a validator, or an
30
+ error-reporting path), so `log.info(user.ssn_ciphertext)` wrote the SSN
31
+ straight to the log. It returns `nil` in that state now. `<field>_encrypted?`
32
+ used a bare `.present?`, true for plaintext too; it now checks that what is
33
+ stored really is an encryption envelope, via the new
34
+ `Support::Encryptor.envelope?`. (#97)
35
+
36
+ ### Fixed
37
+ - **Models::Aliasable**: an aliased `belongs_to` carrying `counter_cache:`
38
+ double-counted. The alias copy kept the `:counter_cache` option, and because
39
+ the `#association` override maps the alias back to the same association
40
+ object, ActiveRecord's counter-cache pass fired once per name — the parent's
41
+ count came out doubled on create and doubled on destroy, drifting permanently
42
+ negative once rows predating the alias were removed. The copy no longer
43
+ carries the option; the source reflection still owns the counter. (#93)
44
+ - **Controllers::Paginatable**: `?page=99999999999999999999` was an
45
+ unauthenticated 500 — `(page - 1) * per_page` produced an offset no backend
46
+ accepts (`StatementInvalid` on a relation, `RangeError` on an Array). `page`
47
+ is now clamped to `MAX_PAGE` (1,000,000) and comes back as an empty page past
48
+ the end; `per_page` is held under the matching `MAX_PER_PAGE`, since with
49
+ `max_per_page: 0` ("no cap") the identical value overflowed `LIMIT` instead.
50
+ `paginate_by` also validates `per_page` now: 0 and negatives raise
51
+ `ArgumentError` at class-load time instead of misbehaving on every request
52
+ (`per_page: -1` means `LIMIT -1`, i.e. NO LIMIT on SQLite and MySQL —
53
+ serialising the whole table; `per_page: 0` made every page permanently empty).
54
+ A negative `max_per_page` still means "no cap", as documented. (#94)
55
+ - **Models::Taggable**: `all_tags` raised on PostgreSQL for any model that also
56
+ includes `Models::Sortable` — `SELECT DISTINCT` cannot be ordered by a column
57
+ outside the select list, and Sortable installs exactly such a `default_scope`.
58
+ The inherited `ORDER BY` is dropped with `reorder(nil)`; the result is sorted
59
+ in Ruby anyway. Passed on SQLite, which permits it. (#95)
60
+ - **Models::Lockable, Models::Stateable**: `ActiveRecord::Rollback` raised from
61
+ an `after_lock` / `after_transition` hook did nothing when the call was nested
62
+ inside a caller's own transaction — a bare `transaction` joins the enclosing
63
+ one and Rails swallows `Rollback` without rolling anything back. Both open a
64
+ savepoint now (`requires_new: true`), so the documented abort works: Lockable
65
+ no longer leaves a row locked in the database while reporting `false` in
66
+ memory (with `lock_access!`'s idempotency guard then making every retry a
67
+ no-op), and Stateable no longer commits a state change its hook asked to
68
+ abort. Stateable's `<event>!` also took its return value from `update!`, which
69
+ runs *before* the hook, so an aborted transition reported success —
70
+ `raise unless ticket.archive!` never fired and `transition_all` counted a row
71
+ it had rolled back. It reports `false` now, which `transition_all` treats as
72
+ the documented failed-record signal. Note `transition_all` opens one savepoint
73
+ per record. (#96)
74
+ - **Support::ErrorEnvelope**: the `render_error` lookup was public-only, but
75
+ `render_error` is very often declared under `private` — the idiomatic way to
76
+ keep a controller helper from becoming a routable action. Those overrides were
77
+ silently ignored and the gem's inline envelope rendered instead, so an app
78
+ rendering RFC 9457 problem+json got the wrong shape for every Authorizable
79
+ 403, WebhookVerifiable 401, Throttleable 429 and CursorPaginatable 400, with
80
+ no error or warning. Now `respond_to?(:render_error, true)`, the spelling
81
+ Authorizable already used for `current_user`. Controllers::Deprecatable keeps
82
+ its own copy of that check before rendering a sunset 410, and it had the same
83
+ blind spot — a private `render_error` with no response object skipped the 410
84
+ and served the sunset action. (#98)
85
+ - **Controllers::Deprecatable**: `deprecate_actions` mutated the caller's own
86
+ `Time`. `Time#utc` is an alias of `#gmtime` and converts the receiver IN
87
+ PLACE, so a host passing a frozen constant (`SUNSET = Time.new(...).freeze`)
88
+ got a `FrozenError` while the controller class body was still loading — the
89
+ app would not boot — and an unfrozen `Time` was silently rewritten to UTC
90
+ behind the caller's back. Now `getutc`. (#99)
91
+ - **Controllers::Filterable**: a boolean `false` read as "filter not supplied",
92
+ so `filter_by :active` could never select the inactive rows — `false.blank?`
93
+ is true, the rule was skipped and the UNFILTERED relation came back. Only JSON
94
+ request bodies were affected; a query string carries the String `"false"`,
95
+ which is not blank. Everything genuinely empty — `nil`, `""`, `" "`, `[]`,
96
+ `{}` — is still skipped, and in `scope:` mode (which discards the value) an
97
+ explicit `false` still means "do not apply this scope". (#100)
98
+ - **Models::Stateable**: `transition_all` silently skipped rows whose state is
99
+ NULL. `where.not(state: to)` compiles to `NOT (state = 'x')`, which SQL
100
+ three-valued logic evaluates to NULL — never TRUE — for a NULL state, so those
101
+ rows were dropped from the batch and from the returned count even though they
102
+ ARE eligible (`may_<event>?` returns true for them and the per-record
103
+ `<event>!` succeeds). The predicate is NULL-safe now. (#101)
104
+
105
+ ### Internal
106
+ - **Specs**: the Aliasable join-alias SQL assertion accepts both the Rails 8.1
107
+ `AS`-qualified table alias and the older unqualified form, so the suite passes
108
+ on Rails 8.1 — unblocking the pending Rails 8.1 dependency bumps. No library
109
+ change. (#91)
110
+
3
111
  ## 1.28.3 (2026-09-16)
4
112
 
5
113
  Three merged PRs from the September loop (#41, #43, #52), shipped as a patch at
data/README.md CHANGED
@@ -148,7 +148,7 @@ across all 43 concerns — press <kbd>/</kbd> and type.
148
148
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable), and both load **lazily**: an app that never includes those concerns never loads them. Depends on `activerecord`/`actionpack`/`activesupport`, not the full `rails` meta-gem; controller concerns have zero extra deps
149
149
  - **Schema-validated configuration** — every macro checks that the configured columns exist and raises `ArgumentError` early — listing *every* missing column at once, with one ready-to-paste `rails generate migration` command that adds them all
150
150
  - **Composable** — concerns are independent; mix and match per model
151
- - **Tested like an app, not a snippet** — **1,396 RSpec examples** run against a real database on every CI build
151
+ - **Tested like an app, not a snippet** — **1,460 RSpec examples** run against a real database on every CI build
152
152
  - **Documented twice** — everything in this README also lives as a per-concern page on the [docs site](https://vsn2015.github.io/concerns_on_rails), searchable and deep-linkable
153
153
 
154
154
  ---
@@ -1417,6 +1417,7 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1417
1417
  **Notes**
1418
1418
  - The declared column must be `text`/binary (it stores an opaque envelope, not the logical type); a blind-index column holds a 64-char hex digest — add an index on it.
1419
1419
  - Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
1420
+ - `ssn_ciphertext` is `nil` while the field has an unsaved change (so it can never return the plaintext you just assigned), and `ssn_encrypted?` asks whether what is stored really is an envelope.
1420
1421
  - Never `update_column(s)` an encrypted field — that bypasses the type and writes raw plaintext. Declaring a field with both `encryptable` and `auditable_by` raises (either order).
1421
1422
  - Wrong key / tampered ciphertext / malformed envelope raise `Encryption::DecryptionError`. Encrypted field names are auto-registered with Rails' `filter_parameters` (via the gem's railtie), so they're redacted from request logs.
1422
1423
  - Reach for [`lockbox`](https://github.com/ankane/lockbox) or Rails 7+ native `encrypts` when you need key rotation today or Rails-managed key infrastructure (rotation is planned — the envelope already reserves the `key_id` byte).
@@ -2249,9 +2250,9 @@ Point your agent at `llms.txt` for an overview, or paste a single concern's `.md
2249
2250
 
2250
2251
  ```sh
2251
2252
  bundle install # install dev dependencies
2252
- bundle exec rspec # run the test suite (1,396 examples)
2253
+ bundle exec rspec # run the test suite (1,460 examples)
2253
2254
  gem build concerns_on_rails.gemspec # build the gem
2254
- gem install ./concerns_on_rails-1.28.3.gem # install locally
2255
+ gem install ./concerns_on_rails-1.28.4.gem # install locally
2255
2256
 
2256
2257
  # Preview the docs site locally (GitHub Pages serves docs/ as-is):
2257
2258
  cd docs && python3 -m http.server 8000 # → http://localhost:8000
@@ -141,8 +141,15 @@ module ConcernsOnRails
141
141
  # Module#=== checks the real ancestry, so `when Time` alone would
142
142
  # miss it — and Time.current / 1.month.from_now are exactly the
143
143
  # values Rails hosts pass.
144
- when ActiveSupport::TimeWithZone, Time then value.utc
145
- when DateTime then value.to_time.utc
144
+ # getutc, NOT utc: Time#utc is an alias of #gmtime, which converts the
145
+ # receiver IN PLACE and returns self. A host passing a frozen
146
+ # constant (SUNSET = Time.new(...).freeze) got a FrozenError while
147
+ # the controller class body was still loading — the app would not
148
+ # boot — and an unfrozen Time was silently rewritten to UTC behind
149
+ # the caller's back. TimeWithZone#utc is a harmless reader, but
150
+ # getutc is correct for both, so the branch stays single.
151
+ when ActiveSupport::TimeWithZone, Time then value.getutc
152
+ when DateTime then value.to_time.getutc
146
153
  when Date then Time.utc(value.year, value.month, value.day)
147
154
  when String then parse_deprecation_string(value)
148
155
  end
@@ -247,7 +254,12 @@ module ConcernsOnRails
247
254
  return unless deprecation_sunset_reached?(rule)
248
255
 
249
256
  message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
250
- return unless respond_to?(:render_error) || (respond_to?(:response) && response)
257
+ # respond_to?(..., true) for the same reason Support::ErrorEnvelope
258
+ # uses it: render_error is very often declared under `private`. The
259
+ # public-only check skipped the 410 for exactly those controllers and
260
+ # let the sunset action run — a fail-open on the branch whose job is to
261
+ # stop serving the endpoint.
262
+ return unless respond_to?(:render_error, true) || (respond_to?(:response) && response)
251
263
 
252
264
  ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: :gone, code: "endpoint_sunset")
253
265
  end
@@ -42,12 +42,12 @@ module ConcernsOnRails
42
42
  end
43
43
  end
44
44
 
45
- # Apply all declared filters to a relation based on params. Blank values
46
- # are skipped so unset filters don't narrow the relation.
45
+ # Apply all declared filters to a relation based on params. Unset values
46
+ # are skipped so absent filters don't narrow the relation.
47
47
  def filtered(relation)
48
48
  self.class.filterable_rules.each do |field, options|
49
49
  value = params[field]
50
- next if value.blank?
50
+ next if filterable_unset?(value)
51
51
 
52
52
  relation = apply_filter(relation, field, value, options)
53
53
  end
@@ -56,11 +56,29 @@ module ConcernsOnRails
56
56
 
57
57
  private
58
58
 
59
+ # NOT `value.blank?`: `false.blank?` is true, so a genuine boolean false
60
+ # read as "filter not supplied" and the relation came back UNFILTERED —
61
+ # `filter_by :active` could never select the inactive rows. Query strings
62
+ # were unaffected (they carry the String "false", which is not blank), so
63
+ # this only bit JSON request bodies, where the value really is `false`.
64
+ # Everything actually empty — nil, "", " ", [], {} — is still skipped.
65
+ def filterable_unset?(value)
66
+ return false if value == false
67
+ return true if value.nil?
68
+
69
+ value.respond_to?(:blank?) ? value.blank? : false
70
+ end
71
+
59
72
  def apply_filter(relation, field, value, options)
60
73
  if options[:with]
61
74
  options[:with].call(relation, value)
62
75
  elsif options[:scope]
63
- relation.public_send(options[:scope])
76
+ # Scope mode discards the value, so an explicit `false` can only mean
77
+ # "do not apply this scope" — applying it would hand the client the
78
+ # exact opposite of what it asked for. (A query string still carries
79
+ # the String "false", which has always triggered the scope; only a
80
+ # real boolean is read as a negation.)
81
+ value == false ? relation : relation.public_send(options[:scope])
64
82
  elsif filterable_scalar?(value)
65
83
  relation.where(field => value)
66
84
  else
@@ -36,6 +36,21 @@ module ConcernsOnRails
36
36
  LABEL = "ConcernsOnRails::Controllers::Paginatable".freeze
37
37
  DEFAULT_PER_PAGE = 25
38
38
  DEFAULT_MAX_PER_PAGE = 200
39
+ # Upper bound on the requested page. `page` is untrusted input and its
40
+ # only job is to become `(page - 1) * per_page`, so an unbounded value
41
+ # produced an offset no backend can take: the relation branch raised
42
+ # StatementInvalid and Array#[] raised RangeError ("bignum too big to
43
+ # convert into `long'") — a 500 from `?page=99999999999999999999`.
44
+ # Clamping keeps the request in range; the page is far past any real
45
+ # dataset, so it simply comes back empty. Deep pagination at this depth
46
+ # wants Controllers::CursorPaginatable instead.
47
+ MAX_PAGE = 1_000_000
48
+ # The same guard for per_page. `max_per_page: 0` is documented as "no
49
+ # cap", and with no cap the identical untrusted value overflowed LIMIT
50
+ # instead of OFFSET — the same unauthenticated 500, one option away. "No
51
+ # cap" means no CONFIGURED cap, not an unbounded LIMIT; a page of a
52
+ # million records is already far past what any client can render.
53
+ MAX_PER_PAGE = 1_000_000
39
54
 
40
55
  included do
41
56
  class_attribute :paginatable_per_page, default: DEFAULT_PER_PAGE
@@ -59,8 +74,8 @@ module ConcernsOnRails
59
74
  # paginate_by per_page: 50, max_per_page: 500, link_header: false
60
75
  def paginate_by(per_page: DEFAULT_PER_PAGE, max_per_page: DEFAULT_MAX_PER_PAGE, link_header: true,
61
76
  page_param: nil, per_page_param: nil, style: :flat, window: nil)
62
- self.paginatable_per_page = per_page.to_i
63
- self.paginatable_max_per_page = max_per_page.to_i
77
+ self.paginatable_per_page = paginatable_per_page!(per_page)
78
+ self.paginatable_max_per_page = paginatable_max_per_page!(max_per_page)
64
79
  self.paginatable_link_header = link_header ? true : false
65
80
  self.paginatable_window = paginatable_window!(window)
66
81
  defaults = paginatable_style_params!(style)
@@ -79,6 +94,29 @@ module ConcernsOnRails
79
94
  end
80
95
  end
81
96
 
97
+ # per_page must be positive. A bare `.to_i` let a negative through, and
98
+ # `LIMIT -1` means NO LIMIT on SQLite and MySQL — so `per_page: -1`
99
+ # silently serialized the entire table on every request, while
100
+ # `per_page: 0` made every page permanently empty. Both are broken
101
+ # configuration with no sane reading, so they raise at class-load time
102
+ # rather than misbehaving on every request.
103
+ def paginatable_per_page!(value)
104
+ size = value.to_i
105
+ return size if size.positive?
106
+
107
+ raise ArgumentError, "#{LABEL}: per_page: must be a positive integer (got #{value.inspect})"
108
+ end
109
+
110
+ # max_per_page does NOT raise: "0 or a negative integer disables the
111
+ # cap" is this option's documented contract, so rejecting a negative
112
+ # would fail the boot of an app that is configured exactly as written —
113
+ # and on a patch upgrade at that. Normalize to 0 instead; the reader's
114
+ # guard only asks whether the cap is positive.
115
+ def paginatable_max_per_page!(value)
116
+ size = value.to_i
117
+ size.negative? ? 0 : size
118
+ end
119
+
82
120
  # nil / false disable the window (no `pages:` key). `0` is meaningful:
83
121
  # first, current and last only.
84
122
  def paginatable_window!(value)
@@ -206,14 +244,18 @@ module ConcernsOnRails
206
244
  # Both readers route through ScalarParam: `?page[]=1` / `?page[x]=1`
207
245
  # arrive as Array/Parameters, and calling .to_i on those was a 500.
208
246
  def pagination_page
209
- [ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0), 1].max
247
+ requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_page_param), default: 0)
248
+ requested.clamp(1, MAX_PAGE)
210
249
  end
211
250
 
212
251
  def pagination_per_page
213
252
  requested = ConcernsOnRails::Support::ScalarParam.to_i(pagination_param(self.class.paginatable_per_page_param), default: 0)
214
253
  requested = self.class.paginatable_per_page if requested < 1
215
254
  cap = self.class.paginatable_max_per_page
216
- cap.positive? ? [requested, cap].min : requested
255
+ requested = [requested, cap].min if cap.positive?
256
+ # Applied even when a cap IS configured: `max_per_page: 10**30` is its
257
+ # own way of asking for the overflow back.
258
+ [requested, MAX_PER_PAGE].min
217
259
  end
218
260
 
219
261
  # Dig the configured path out of params: `["page"]` → params[:page];
@@ -178,19 +178,68 @@ module ConcernsOnRails
178
178
  # Single funnel for all failure outcomes (override point). Uses
179
179
  # Respondable's render_error when available, otherwise the same inline
180
180
  # envelope as Throttleable / Idempotentable.
181
+ #
182
+ # Fails CLOSED, matching Authorizable#authorization_denied: when there is
183
+ # nothing to render the rejection into, raise. Returning nil here (the
184
+ # pre-1.29 behavior) left the before_action chain unhalted, so the action
185
+ # ran on an unverified — possibly forged — payload.
181
186
  def webhook_verification_failed(message:, status:, code:)
182
- return unless respond_to?(:response) && response
187
+ unless webhook_can_render?
188
+ raise "ConcernsOnRails::Controllers::WebhookVerifiable: rejection for " \
189
+ "'#{webhook_action_name || '(unknown action)'}' could not be rendered " \
190
+ "(no response object) — refusing to fail open"
191
+ end
183
192
 
184
193
  ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: code)
185
194
  end
186
195
 
187
196
  private
188
197
 
198
+ # Mirrors the render path in Support::ErrorEnvelope: a render_error
199
+ # override is enough on its own, so a controller that supplies one but no
200
+ # response object still rejects properly instead of raising.
201
+ def webhook_can_render?
202
+ respond_to?(:render_error, true) || (respond_to?(:response) && response)
203
+ end
204
+
205
+ # nil when the action cannot be determined. `action_name` can also be ""
206
+ # (truthy), which a bare `unless action` guard would let through.
207
+ def webhook_action_name
208
+ return nil unless respond_to?(:action_name)
209
+
210
+ name = action_name.to_s
211
+ name.empty? ? nil : name
212
+ end
213
+
189
214
  def webhook_rule_for_action
190
- action = respond_to?(:action_name) ? action_name.to_s : nil
191
- return nil unless action
215
+ rules = self.class.webhook_rules
216
+ return nil if rules.empty?
217
+
218
+ action = webhook_action_name
219
+ # Fail closed: rules ARE declared but we cannot tell which action this
220
+ # is, so "no rule applies" is not a conclusion we may draw. Previously
221
+ # an unresolvable action_name skipped verification entirely and every
222
+ # webhook was accepted without a signature check.
223
+ return webhook_unresolvable_action_rule(rules) if action.nil?
224
+
225
+ rules.find { |rule| rule[:actions].empty? || rule[:actions].include?(action) }
226
+ end
192
227
 
193
- self.class.webhook_rules.find { |rule| rule[:actions].empty? || rule[:actions].include?(action) }
228
+ # Which rule to verify against when the action cannot be resolved. A
229
+ # catch-all is well defined, and so is a lone rule. Several
230
+ # action-specific rules are NOT: each carries its own provider secret and
231
+ # scheme, so picking the first would reject a perfectly valid delivery
232
+ # with "signature invalid" — sending that provider chasing a signing bug
233
+ # that does not exist. Raise instead. Still fails closed either way; this
234
+ # one just says what actually went wrong.
235
+ def webhook_unresolvable_action_rule(rules)
236
+ catch_all = rules.find { |rule| rule[:actions].empty? }
237
+ return catch_all if catch_all
238
+ return rules.first if rules.one?
239
+
240
+ raise "ConcernsOnRails::Controllers::WebhookVerifiable: cannot tell which action this request is " \
241
+ "(no action_name) and #{rules.size} action-specific rules are declared with no catch-all — " \
242
+ "refusing to verify against an arbitrary rule's secret"
194
243
  end
195
244
 
196
245
  def webhook_render_outcome(rule, outcome)
@@ -274,6 +274,15 @@ module ConcernsOnRails
274
274
  # polymorphic).
275
275
  def aliasable_copy_options(src)
276
276
  opts = src.options.dup
277
+ # The SOURCE reflection already owns the counter. Carrying the option
278
+ # onto the copy makes ActiveRecord::CounterCache count it twice: it
279
+ # iterates _reflections and calls association(name) for each
280
+ # counter-cached one, and the #association override below maps the
281
+ # alias back to the SAME association object, so increment_counters
282
+ # fires once per name with no dedup guard. The parent's count came
283
+ # out doubled on create and doubled on destroy — drifting
284
+ # permanently negative once rows predating the alias were removed.
285
+ opts.delete(:counter_cache)
277
286
  if opts[:through]
278
287
  opts[:source] ||= aliasable_through_source_name(src)
279
288
  else
@@ -271,11 +271,34 @@ module ConcernsOnRails
271
271
  end
272
272
 
273
273
  def encryptable_define_helpers(field)
274
- # Raw stored value: the DB ciphertext once persisted (before the type
275
- # deserializes it). Useful for migrations, debugging, and asserting no
276
- # plaintext is at rest.
277
- define_method("#{field}_ciphertext") { read_attribute_before_type_cast(field) }
278
- define_method("#{field}_encrypted?") { read_attribute_before_type_cast(field).present? }
274
+ # The value AT REST — the column's stored content, before the type
275
+ # deserializes it. Useful for migrations, debugging, and asserting no
276
+ # plaintext is at rest. nil while the field carries an unsaved change.
277
+ #
278
+ # That last clause is the fix: this used to return
279
+ # read_attribute_before_type_cast unconditionally, and for a column
280
+ # overridden with `attribute` that is the caller's PLAINTEXT whenever
281
+ # the value has not round-tripped through the database — a new record,
282
+ # or any record with a pending assignment (i.e. exactly the state
283
+ # inside a before_save, a validator, or an error-reporting path). A
284
+ # reader named `_ciphertext`, documented for "asserting no plaintext
285
+ # is at rest", handed back the SSN, so `log.info(user.ssn_ciphertext)`
286
+ # wrote it straight to the log.
287
+ define_method("#{field}_ciphertext") do
288
+ next nil if new_record? || public_send("#{field}_changed?")
289
+
290
+ read_attribute_before_type_cast(field)
291
+ end
292
+
293
+ # True only when what is stored really is an encryption envelope. The
294
+ # old `.present?` was true for plaintext too, so the natural guard
295
+ # `raise unless user.ssn_encrypted?` passed on a record whose column
296
+ # held the raw value. Note this is honestly false under
297
+ # `on_missing_key: :passthrough`, where plaintext at rest is the
298
+ # opted-into behavior.
299
+ define_method("#{field}_encrypted?") do
300
+ ConcernsOnRails::Support::Encryptor.envelope?(public_send("#{field}_ciphertext"))
301
+ end
279
302
  end
280
303
 
281
304
  # find_by_<field> / where_<field> / <field>_fingerprint for equality
@@ -313,7 +313,15 @@ module ConcernsOnRails
313
313
  def lockable_write_with_hooks(previous_values)
314
314
  completed = false
315
315
  begin
316
- transaction do
316
+ # requires_new: a bare `transaction` JOINS an enclosing one rather
317
+ # than opening a savepoint, and Rails then swallows
318
+ # ActiveRecord::Rollback without rolling anything back. Inside a
319
+ # caller's `ApplicationRecord.transaction { user.lock_access! }`,
320
+ # a hook raising Rollback left the row locked in the database while
321
+ # the ensure below restored locked_at = nil in memory and this
322
+ # method returned false — and the idempotency guard in
323
+ # lock_access! then made every retry a no-op.
324
+ transaction(requires_new: true) do
317
325
  yield
318
326
  completed = true
319
327
  end
@@ -113,7 +113,15 @@ module ConcernsOnRails
113
113
  method_base = stateable_method_name(name)
114
114
 
115
115
  eligible = from.empty? ? all : all.where(field => from)
116
- eligible = eligible.where.not(field => to)
116
+ # NULL-safe: `where.not(field => to)` compiles to `NOT (state = 'x')`,
117
+ # which SQL three-valued logic evaluates to NULL — never TRUE — for a
118
+ # NULL state, so those rows were silently dropped from the batch and
119
+ # from the returned count. They ARE eligible: a transition with no
120
+ # `from:` is documented as allowed from any state, `may_<event>?`
121
+ # returns true for them, and `record.<event>!` on the same row
122
+ # succeeds. A NULL state is reachable through an imported row,
123
+ # insert_all, or the documented `create!(status: nil)`.
124
+ eligible = eligible.where(arel_table[field].not_eq(to).or(arel_table[field].eq(nil)))
117
125
 
118
126
  ConcernsOnRails::Support::BatchOps.run(
119
127
  eligible,
@@ -225,10 +233,23 @@ module ConcernsOnRails
225
233
  raise InvalidTransition, "#{self.class.name}: cannot #{event} from '#{self[field]}'" unless from.empty? || from.include?(current)
226
234
 
227
235
  result = false
228
- transaction do
236
+ # requires_new: a bare `transaction` JOINS an enclosing one instead of
237
+ # opening a savepoint, so under a caller's transaction Rails swallowed
238
+ # an ActiveRecord::Rollback from after_transition and rolled nothing
239
+ # back — the state change committed and this returned true, exactly
240
+ # opposite to the documented contract above.
241
+ # Set AFTER after_transition, never from update! — the same reason
242
+ # Lockable's lockable_write_with_hooks flips `completed` only once the
243
+ # block has run to the end. Rails swallows ActiveRecord::Rollback at
244
+ # the savepoint boundary, so taking the return value from update!
245
+ # reported a fake success for a transition the hook had just aborted:
246
+ # `raise unless ticket.archive!` never fired, and transition_all
247
+ # counted a row it had rolled back.
248
+ transaction(requires_new: true) do
229
249
  before_transition(event, current, to)
230
- result = update!(field => to)
250
+ update!(field => to)
231
251
  after_transition(event, current, to)
252
+ result = true
232
253
  end
233
254
  result
234
255
  end
@@ -75,8 +75,17 @@ module ConcernsOnRails
75
75
  # All distinct tags currently stored across the table, sorted.
76
76
  # distinct + NULL filter dedupe DB-side, so identical tag strings ship
77
77
  # over the wire once instead of once per row.
78
+ #
79
+ # reorder(nil) drops any inherited ORDER BY: PostgreSQL rejects
80
+ # SELECT DISTINCT ordered by a column outside the select list ("for
81
+ # SELECT DISTINCT, ORDER BY expressions must appear in select list"),
82
+ # and Models::Sortable installs exactly such a default_scope — so
83
+ # Taggable + Sortable raised on Postgres while passing on SQLite,
84
+ # which permits it. The ordering is meaningless here anyway: the
85
+ # result is sorted in Ruby below.
78
86
  def all_tags
79
87
  where.not(taggable_field => nil)
88
+ .reorder(nil)
80
89
  .distinct
81
90
  .pluck(taggable_field)
82
91
  .flat_map { |raw| taggable_split(raw) }
@@ -85,6 +85,29 @@ module ConcernsOnRails
85
85
  "could not decrypt value (wrong key or tampered ciphertext)"
86
86
  end
87
87
 
88
+ # True when `value` really is an envelope produced by #encrypt: strict
89
+ # Base64 decoding to at least header + IV + tag, carrying a version byte
90
+ # we recognize. Deliberately does NOT check the algorithm byte, so a
91
+ # future alg (0x11, deterministic) still reads as an envelope.
92
+ #
93
+ # Backs Encryptable#<field>_encrypted?, which used a bare `.present?` —
94
+ # true for plaintext too. Cheap: no key material, no crypto, no KDF.
95
+ def envelope?(value)
96
+ return false unless value.is_a?(String)
97
+
98
+ # "" for non-Base64 input, which then fails the length check below.
99
+ raw =
100
+ begin
101
+ value.unpack1("m0").to_s
102
+ rescue ArgumentError
103
+ ""
104
+ end
105
+ return false if raw.bytesize < MIN_ENVELOPE_BYTES
106
+
107
+ version, = raw.byteslice(0, HEADER_LEN).unpack(HEADER_FORMAT)
108
+ version == VERSION_BYTE
109
+ end
110
+
88
111
  # Deterministic keyed fingerprint (lowercase hex) for equality lookups — a
89
112
  # "blind index". The HMAC key is domain-separated from the AES key via
90
113
  # BLIND_INDEX_INFO, so the two are cryptographically independent. The same
@@ -10,13 +10,22 @@ module ConcernsOnRails
10
10
  module_function
11
11
 
12
12
  def render(controller, message:, status:, code: nil, details: nil)
13
- if controller.respond_to?(:render_error)
13
+ # respond_to?(..., true) because render_error is very often declared
14
+ # under `private` — the idiomatic way to keep a controller helper from
15
+ # becoming a routable action — or exposed as a helper_method. The
16
+ # public-only check silently missed those and fell through to the
17
+ # inline body below, so an app rendering RFC 9457 problem+json got the
18
+ # gem's non-conforming shape for every Authorizable 403,
19
+ # WebhookVerifiable 401, Throttleable 429 and CursorPaginatable 400,
20
+ # with no error or warning. Authorizable already uses this spelling for
21
+ # current_user (`respond_to?(via, true)`) for exactly the same reason.
22
+ if controller.respond_to?(:render_error, true)
14
23
  # errors: only when there are details — several concerns document the
15
24
  # override contract as `render_error(message:, status:, code:)`, and
16
25
  # an unconditional errors: kwarg would break those implementations.
17
26
  kwargs = { message: message, code: code, status: status }
18
27
  kwargs[:errors] = details if details
19
- controller.render_error(**kwargs)
28
+ controller.send(:render_error, **kwargs)
20
29
  else
21
30
  error = { message: message }
22
31
  error[:code] = code if code
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.28.3".freeze
2
+ VERSION = "1.28.4".freeze
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.28.3
4
+ version: 1.28.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen