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 +4 -4
- data/CHANGELOG.md +108 -0
- data/README.md +4 -3
- data/lib/concerns_on_rails/controllers/deprecatable.rb +15 -3
- data/lib/concerns_on_rails/controllers/filterable.rb +22 -4
- data/lib/concerns_on_rails/controllers/paginatable.rb +46 -4
- data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +53 -4
- data/lib/concerns_on_rails/models/aliasable.rb +9 -0
- data/lib/concerns_on_rails/models/encryptable.rb +28 -5
- data/lib/concerns_on_rails/models/lockable.rb +9 -1
- data/lib/concerns_on_rails/models/stateable.rb +24 -3
- data/lib/concerns_on_rails/models/taggable.rb +9 -0
- data/lib/concerns_on_rails/support/encryptor.rb +23 -0
- data/lib/concerns_on_rails/support/error_envelope.rb +11 -2
- data/lib/concerns_on_rails/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 615b4ddcff3ebdffc7e32f618731510bf5e386fa015cb0a1ae958a4e6fb04d1c
|
|
4
|
+
data.tar.gz: 723b3ae2077f43fc5771947d2e610f011ca83ee8215b78fb0894a04400362220
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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.
|
|
46
|
-
# are skipped so
|
|
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
|
|
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
|
-
|
|
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
|
|
63
|
-
self.paginatable_max_per_page = max_per_page
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
191
|
-
return nil
|
|
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
|
-
|
|
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
|
-
#
|
|
275
|
-
# deserializes it
|
|
276
|
-
# plaintext is at rest.
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
28
|
+
controller.send(:render_error, **kwargs)
|
|
20
29
|
else
|
|
21
30
|
error = { message: message }
|
|
22
31
|
error[:code] = code if code
|