concerns_on_rails 1.21.0 → 1.22.0

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +48 -9
  4. data/lib/concerns_on_rails/controllers/authorizable.rb +14 -6
  5. data/lib/concerns_on_rails/controllers/cacheable.rb +25 -7
  6. data/lib/concerns_on_rails/controllers/cursor_paginatable.rb +23 -8
  7. data/lib/concerns_on_rails/controllers/deprecatable.rb +18 -10
  8. data/lib/concerns_on_rails/controllers/error_handleable.rb +4 -5
  9. data/lib/concerns_on_rails/controllers/filterable.rb +6 -6
  10. data/lib/concerns_on_rails/controllers/idempotentable.rb +15 -6
  11. data/lib/concerns_on_rails/controllers/includable.rb +5 -1
  12. data/lib/concerns_on_rails/controllers/localizable.rb +17 -8
  13. data/lib/concerns_on_rails/controllers/paginatable.rb +37 -16
  14. data/lib/concerns_on_rails/controllers/secure_headable.rb +9 -2
  15. data/lib/concerns_on_rails/controllers/throttleable.rb +24 -6
  16. data/lib/concerns_on_rails/controllers/timezoneable.rb +20 -10
  17. data/lib/concerns_on_rails/controllers/webhook_verifiable.rb +2 -3
  18. data/lib/concerns_on_rails/encryption.rb +22 -0
  19. data/lib/concerns_on_rails/models/activatable.rb +1 -0
  20. data/lib/concerns_on_rails/models/addressable.rb +23 -5
  21. data/lib/concerns_on_rails/models/aliasable.rb +7 -7
  22. data/lib/concerns_on_rails/models/auditable.rb +37 -1
  23. data/lib/concerns_on_rails/models/counter_cacheable.rb +35 -18
  24. data/lib/concerns_on_rails/models/encryptable.rb +124 -51
  25. data/lib/concerns_on_rails/models/expirable.rb +5 -0
  26. data/lib/concerns_on_rails/models/hashable.rb +22 -2
  27. data/lib/concerns_on_rails/models/lockable.rb +1 -0
  28. data/lib/concerns_on_rails/models/maskable.rb +1 -0
  29. data/lib/concerns_on_rails/models/monetizable.rb +14 -1
  30. data/lib/concerns_on_rails/models/normalizable.rb +8 -1
  31. data/lib/concerns_on_rails/models/publishable.rb +32 -10
  32. data/lib/concerns_on_rails/models/sanitizable.rb +1 -0
  33. data/lib/concerns_on_rails/models/schedulable.rb +17 -6
  34. data/lib/concerns_on_rails/models/searchable.rb +1 -0
  35. data/lib/concerns_on_rails/models/sequenceable.rb +15 -0
  36. data/lib/concerns_on_rails/models/sluggable.rb +6 -1
  37. data/lib/concerns_on_rails/models/soft_deletable.rb +67 -11
  38. data/lib/concerns_on_rails/models/sortable.rb +32 -14
  39. data/lib/concerns_on_rails/models/stateable.rb +16 -6
  40. data/lib/concerns_on_rails/models/storable.rb +66 -10
  41. data/lib/concerns_on_rails/models/taggable.rb +1 -0
  42. data/lib/concerns_on_rails/models/tokenizable.rb +19 -7
  43. data/lib/concerns_on_rails/railtie.rb +18 -0
  44. data/lib/concerns_on_rails/support/column_guard.rb +30 -4
  45. data/lib/concerns_on_rails/support/encryptor.rb +55 -11
  46. data/lib/concerns_on_rails/support/error_envelope.rb +29 -0
  47. data/lib/concerns_on_rails/support/filter_parameter_registry.rb +67 -0
  48. data/lib/concerns_on_rails/support/money.rb +3 -1
  49. data/lib/concerns_on_rails/support/random_value.rb +18 -1
  50. data/lib/concerns_on_rails/support/scalar_param.rb +35 -0
  51. data/lib/concerns_on_rails/support/unique_retry.rb +38 -0
  52. data/lib/concerns_on_rails/version.rb +1 -1
  53. data/lib/concerns_on_rails.rb +29 -2
  54. metadata +7 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c419970fee56d2f4d1d02f534c07abd646219db975823b089f1801192eb15689
4
- data.tar.gz: 266601e25a1dd81854dacc62f77c85d913e7dc633614a32001b43359abe33069
3
+ metadata.gz: 7414e40bd04fc80a1b87b4c27b908ff169c5a9c2f9c50e66d8ab8d75198b9f40
4
+ data.tar.gz: 6f458ab30c720e633cd50f34515b7941ec9b6b8a8ef208f3beed909860ac1d9f
5
5
  SHA512:
6
- metadata.gz: 5069eb540a84ecda03c062eeae3dea59ec47ecff05d6107d1c198b936a0c55c2a8364a558c4376d7ee38ad9c082e5ddf0aafd72042a2370415f5056714a71098
7
- data.tar.gz: 65f7b71e32329fbb8575342f44349757f37a835b913334ebf6f362b015962c70c5f3e8fd45fac0afdace8da2914beb2c49a1d74b564c36e3795de4a2b7f1b9b6
6
+ metadata.gz: dbbcf38ca1645b801c05e8949f869e587752607bb0d679c279eeac2bc8904c446d7da7ee865498b8578ca5d1d8212477b60cb840d0e3c53e1a1d9593065805af
7
+ data.tar.gz: 524c14d7bd3e5824c0d266a3ab84e13cd21d622d7637dc4bde38225ff40e88d24559eb36055c16eaa60edb53b99c025c96effa4a458e1701b36a0826bd8a12b5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,59 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.22.0 (2026-07-26)
4
+
5
+ A fixes-and-optimizations release driven by a full-library review of all 40 concerns: one data-loss bug, three request-crashing 500s verified through real ActionController dispatch, security headers restored on rescued error responses, a fail-open authorization path closed, and the gem's largest performance defect (unmemoized PBKDF2 per encrypted-value access) eliminated. 1031 examples, 0 failures.
6
+
7
+ ### Fixed
8
+ - **Models::SoftDeletable**: `really_destroy_all` now hard-deletes only the records matching the calling relation (soft-deleted included) — previously it ran `unscoped.delete_all` and deleted the ENTIRE table regardless of scope (`User.where(account_id: 1).really_destroy_all` wiped every account). Note `unscope` also drops a caller's own predicate on the soft-delete column, so `only_deleted.really_destroy_all` widens to the whole relation — use `soft_deleted.delete_all` to purge trash.
9
+ - **Controllers::Includable**: sparse fieldsets (`?fields[articles]=id,title`) no longer 500 — in a real controller `params[:fields]` is an `ActionController::Parameters`, which has no `each_with_object`; every documented fieldsets request raised `NoMethodError`. Verified through a new real-ActionController integration harness.
10
+ - **Controllers::Paginatable / CursorPaginatable**: `?page[]=1`, `?per_page[x]=5` and friends no longer 500 — untrusted param reads route through the new `Support::ScalarParam` (Filterable's guard, now shared; Filterable also rejects arrays *containing* nested hashes, the `?status[][x]=1` shape).
11
+ - **Controllers::SecureHeadable**: security headers are now also applied via `prepend_before_action` — Rails skips `after_action` callbacks entirely when `rescue_from` handles an exception, so every handled 404/422/500 previously shipped with NO security headers. (Opting out now requires `skip_before_action :apply_secure_headers` alongside the existing `skip_after_action`.)
12
+ - **Controllers::Cacheable**: `no_store` policies are likewise applied early, so rescued error responses on `no_store` endpoints carry `Cache-Control: no-store`; positive freshness deliberately stays post-action (a rescued 500 must never become CDN-cacheable). `stale_resource?` no longer writes ETag/Last-Modified validators for unsafe (non-GET/HEAD) requests, and the 304 path is a single write.
13
+ - **Controllers::Authorizable**: fails CLOSED — a denial that cannot be rendered (no response object) now raises instead of silently letting the action run; `require_role` supports actors whose role method returns an Array (`roles: ["admin"]` previously stringified and was always denied).
14
+ - **Controllers::Localizable**: regional locales match — `Accept-Language: fr-CA` now tries the full tag before falling back to the primary subtag (previously the region was discarded, so `available: [:"fr-CA"]` could never match).
15
+ - **Controllers::Throttleable**: the first-hit seed uses `unless_exist:` so two concurrent first requests can't under-count the window; a nil/blank discriminator (e.g. `-> { current_user&.id }` on an anonymous request) raises instead of collapsing every client into one shared bucket.
16
+ - **Controllers::Idempotentable**: the 409 conflict's `Retry-After` reports the remaining lock time (from the claim's `claimed_at`) instead of the full `lock_ttl`.
17
+ - **Controllers::Deprecatable**: `link:`/`successor:` values are validated against control characters and `<`/`>` at macro time (Link-header injection).
18
+ - **Support::ColumnGuard** (all 24 model concerns): macros no longer raise when the schema is unreachable — models using these concerns previously could not even load without a migrated database, breaking `db:create`, fresh `db:migrate`, `assets:precompile`, and CI bootstrap. Validation is skipped (never silently for a *reachable* schema with a missing column) and the guard is shared with CounterCacheable/Aliasable/Storable, which had each hand-rolled their own.
19
+ - **Models::Sortable**: a bare `sortable_by` no longer crashes with `NoMethodError` (uses the documented `:position` default); the `default_scope` no longer re-validates the schema on every relation construction; new `default_scope: false` opt-out with a `sorted` scope for explicit ordering.
20
+ - **Models::Monetizable**: the money setter casts garbage input (`"abc"`, `""`) to nil — ActiveModel convention — instead of raising `ArgumentError` out of a form assignment.
21
+ - **Models::Encryptable**: `where_<field>(nil)` / `find_by_<field>(nil)` return `none`/nil instead of matching every row without a fingerprint; encrypted field names now register with Rails' parameter filtering through a live registry + railtie (`before: "active_record.set_filter_attributes"`), so redaction works with boot-time snapshots (ActiveRecord `filter_attributes`, lograge-style initializers) and lazily-loaded model classes.
22
+ - **Models::Sequenceable**: with `reset:` enabled, `created_at` is pinned to the period-anchor instant during `before_create`, so a row can no longer be numbered for one year/month/day but timestamped into the next.
23
+ - **Models::Sluggable**: the slug column itself is validated at macro time (a missing `slug` column previously failed at first save with an opaque friendly_id error).
24
+ - **Models::Schedulable**: `reschedule!` accepts either/both keywords (single-column windows no longer fabricate the missing kwarg) and raises when a value targets an unconfigured column instead of silently dropping it.
25
+ - **Models::CounterCacheable**: `touch: true` raises at macro time on Rails < 6.0 (`update_counters` lacks the option there and would read it as a column named `touch`).
26
+
27
+ ### Changed
28
+ - **Models::SoftDeletable**: `soft_delete_all` / `restore_all` (and the `destroy_all` alias) now return the Integer count of records transitioned and raise `ActiveRecord::RecordNotSaved` (rolling the batch back) when a record fails — previously the rollback happened silently and the methods returned nil/records. With `touch: false` and no overridden hooks both collapse to a single `UPDATE`.
29
+ - **Models::Publishable / Stateable**: lifecycle hooks now share one transaction with the state write (SoftDeletable's pattern) — a raising `after_publish`/`after_transition` rolls the change back instead of leaving it committed with the side effect half-done. `publish_at!` now fires the publish hooks.
30
+ - **Models::Encryptable + Auditable**: the encrypted×audited overlap is rejected at macro time from BOTH declaration orders (Auditable gained the mirror guard); the per-save backstop callback is gone.
31
+ - **Models::Hashable / Tokenizable**: `regenerate_<field>!` goes through the same uniqueness path as create-time assignment (precheck + bounded retry on `ActiveRecord::RecordNotUnique` via the new `Support::UniqueRetry`) — previously one blind `update!` with no collision handling. Hashable's `type: :integer` now generates fixed-width codes (`length: 6` is always 6 digits).
32
+ - **Effective Rails support documented**: the gemspec keeps `rails >= 5.0, < 9`, but Ruby 3.2 (required) means Rails 7.0.4+ in practice; README updated accordingly.
33
+
34
+ ### Performance
35
+ - **Support::Encryptor**: PBKDF2-derived keys are memoized (bounded, mutex-guarded, keyed by digest+salt+iterations) — previously EVERY encrypt/decrypt/blind-index call re-ran the 65,536-iteration KDF, turning a 200-row load of one encrypted column into seconds of key stretching. `configure_encryption` purges the cache.
36
+ - **Models::Storable**: per-record decode memoization — readers/writers/dirty-checks/validation shared one `JSON.parse` per raw column value instead of re-parsing per key access (a ten-key form save cost dozens of parses). Read-back semantics are byte-identical (pinned by spec); the native-hash column detection is now mutex-guarded and never caches a negative result taken without a reachable schema.
37
+ - **Models::Auditable**: saves that touch no tracked field skip building the changes hash (`will_save_change_to_attribute?` pre-check); `audit_trail` is memoized per raw column value (returns a fresh Array each call; entry hashes shared, treat as read-only).
38
+ - **Controllers::Paginatable**: `paginated` + `pagination_meta` (no argument) now share one memoized COUNT — the documented records+meta composition ran the identical COUNT twice. The COUNT also strips `:select` and uses `count(:all)`, so custom SELECT lists and DISTINCT relations no longer produce invalid SQL or skewed totals.
39
+ - **Models::Stateable**: the state default uses `attribute ... default:` instead of an `after_initialize` that ran for every row materialized from the database. (Edge: `Model.new(state_field => nil)` now keeps the explicit nil.)
40
+ - **Models::CounterCacheable**: `recount_counter_caches!` runs in a transaction (a crash mid-repair could previously leave every counter zeroed) and groups parents by tally value — O(distinct counts) UPDATEs instead of one per parent row.
41
+ - **Models::Normalizable / Addressable**: normalization skips fields that are not part of the current save on persisted records; `resolved_country` is memoized per raw value (was computed three times per validation cycle).
42
+ - **Controllers::Localizable / Timezoneable / Deprecatable / CursorPaginatable**: per-request memoization of the resolved locale/zone/deprecation rule; the timezone wrapper is skipped when it would be a no-op; the row-predicate adapter check is cached per model class.
43
+ - **Support::RandomValue / Money**: batched CSPRNG draws with rejection sampling (one `SecureRandom` call per token instead of per character); single-pass thousands delimiter.
44
+
45
+ ### Internal
46
+ - New support modules: `ScalarParam` (shared untrusted-param coercion), `UniqueRetry` (bounded `RecordNotUnique` retry — also usable directly: `UniqueRetry.with_retries { Invoice.create!(attrs) }`), `ErrorEnvelope` (the `render_error`-or-inline dance, previously hand-copied across seven controller concerns — custom `render_error(message:, status:, code:)` overrides keep working), `FilterParameterRegistry` + `ConcernsOnRails::Railtie`.
47
+ - `ConcernsOnRails.deprecator` / `.encryption` / `.filter_parameter_registry` memoization is now thread-safe; every concern file explicitly requires the support modules it uses (files are self-contained under direct `require`).
48
+ - New real-ActionController integration harness (`spec/support/integration_harness.rb`) — the dependency-free FakeController cannot reproduce `ActionController::Parameters` or `rescue_from` semantics, which is how the fixed 500s previously stayed green; regression specs now assert mechanisms (statement counts, parse counts, KDF invocations) through real dispatch.
49
+
50
+ ## 1.21.1 (2026-07-18)
51
+
52
+ Blind-index support for Models::Encryptable, making encrypted fields queryable by exact match — the follow-up promised in 1.21.0. 991 examples, 0 failures.
53
+
54
+ ### Added
55
+ - **Models::Encryptable**: `blind_index:` option for exact-match lookups on otherwise-unqueryable encrypted columns. Encryption stays non-deterministic by design, so opting in with `encryptable :email, blind_index: true` maintains a deterministic keyed HMAC of the value in a companion `<field>_bidx` column and generates `find_by_<field>` / `where_<field>` / `<field>_fingerprint` class methods. `where_<field>` accepts one value, several, or an array (multiple become an IN query) and returns a plain Relation, so it chains with scopes, `.or`, and `.merge` for joins (`Order.joins(:user).merge(User.where_email(...))`). The fingerprint HMAC key is domain-separated from the AES key (a labeled-HMAC subkey via `Support::Encryptor.blind_index`), so the two are cryptographically independent while both driven from the configured key. `blind_index: { column:, expression: }` overrides the companion column and supplies a normalization callable (e.g. `->(v) { v.to_s.downcase }`) applied symmetrically on write and query for case-insensitive lookups. The index is recomputed in `before_save` only when the plaintext changes, stores `nil` for a nil value, and never contains plaintext. Macro-time `ArgumentError` validation: the blind-index column must exist, a custom `column:` cannot combine with multiple fields, and `expression:` must be callable. Key resolution was extracted to `Encryption::Config#resolve_material` so encryption and blind indexing share one path. Deterministic equality leaks (identical values share a digest) — documented; use it only for lookup keys. Zero new runtime dependencies.
56
+
3
57
  ## 1.21.0 (2026-07-01)
4
58
 
5
59
  A new model concern for transparent field-level encryption — the "encrypt SSN/DOB at rest" capability the sensitive-data toolkit (Maskable, Sanitizable, Tokenizable) was missing. Hand-rolled on stdlib OpenSSL so it behaves identically on Rails 5.0–8, rather than delegating to Rails 7.1+ native `encrypts`. 978 examples, 0 failures.
data/README.md CHANGED
@@ -65,6 +65,7 @@ Article.published.without_deleted.find("hello-world")
65
65
  | [🪞 Aliasable](#-aliasable) | Full read / write / query association aliases |
66
66
  | [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
67
67
  | [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
68
+ | [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups |
68
69
 
69
70
  ### 🎮 Controller concerns
70
71
 
@@ -91,7 +92,7 @@ Article.published.without_deleted.find("hello-world")
91
92
 
92
93
  ## ✨ Why this gem?
93
94
 
94
- - **Twenty-three model concerns + sixteen controller concerns**, all production-ready
95
+ - **Twenty-four model concerns + sixteen controller concerns**, all production-ready
95
96
  - **One include, one macro** — no boilerplate, no glue code
96
97
  - **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
97
98
  - **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
@@ -104,7 +105,7 @@ Article.published.without_deleted.find("hello-world")
104
105
  Add to your application's `Gemfile`:
105
106
 
106
107
  ```ruby
107
- gem "concerns_on_rails", "~> 1.11"
108
+ gem "concerns_on_rails", "~> 1.22"
108
109
  ```
109
110
 
110
111
  Or pull the latest from GitHub:
@@ -124,7 +125,7 @@ bundle install
124
125
  ## 🧪 Compatibility
125
126
 
126
127
  - **Ruby**: 3.2+
127
- - **Rails**: 5.0 through 8.x
128
+ - **Rails**: declared 5.0 through 8.x — in practice the Ruby 3.2 requirement means Rails 7.0.4+ is what can actually install the gem; the test suite runs against Rails 7.1
128
129
 
129
130
  ---
130
131
 
@@ -325,13 +326,17 @@ User.unscoped # everything (deleted + non-deleted)
325
326
  **Bulk operations**
326
327
 
327
328
  ```ruby
328
- User.soft_delete_all # soft-deletes all matching records (explicit — preferred)
329
- User.destroy_all # alias of soft_delete_all (kept for backwards compatibility)
330
- User.really_destroy_all # hard-deletes all matching records
331
- User.restore_all # restores all soft-deleted records
329
+ User.soft_delete_all # soft-deletes all matching records; returns the count (explicit — preferred)
330
+ User.destroy_all # alias of soft_delete_all (kept for backwards compatibility; returns a count, not records)
331
+ User.really_destroy_all # hard-deletes the records matching the CURRENT relation (soft-deleted included)
332
+ User.restore_all # restores the matching soft-deleted records; returns the count
332
333
  ```
333
334
 
334
- All of these run in a transaction, so a raising hook rolls the whole batch back.
335
+ A record that fails to transition raises `ActiveRecord::RecordNotSaved` and rolls the whole
336
+ batch back. With `touch: false` and no overridden hooks, `soft_delete_all` / `restore_all`
337
+ collapse to a single `UPDATE`. Note that `really_destroy_all` peels the soft-delete
338
+ predicate off the relation, so `only_deleted.really_destroy_all` widens to the whole
339
+ relation — purge trash with `User.soft_deleted.delete_all` instead.
335
340
 
336
341
  **Lifecycle hooks** — override these methods on the model:
337
342
 
@@ -1129,6 +1134,39 @@ Counters are adjusted with `update_counters` (a single atomic SQL `COALESCE(col,
1129
1134
 
1130
1135
  ---
1131
1136
 
1137
+ ## 🔏 Encryptable
1138
+
1139
+ Transparent per-field encryption for sensitive columns (SSN, DOB, cards) — AES-256-GCM on stdlib OpenSSL, zero new dependencies. Reads and writes stay plaintext in memory; the DB column holds a versioned, authenticated Base64 envelope.
1140
+
1141
+ ```ruby
1142
+ ConcernsOnRails.configure_encryption { |c| c.key = ENV["ENCRYPTION_KEY"] }
1143
+
1144
+ class Patient < ApplicationRecord
1145
+ include ConcernsOnRails::Models::Encryptable
1146
+
1147
+ encryptable :ssn, :notes # transparent string encryption
1148
+ encryptable :dob, type: :date # decrypts back to a Date
1149
+ encryptable :email, blind_index: true # + a queryable fingerprint column
1150
+ end
1151
+
1152
+ patient.ssn # => "123-45-6789" (plaintext in memory)
1153
+ patient.ssn_ciphertext # => "AQEA..." (what's actually at rest)
1154
+ patient.ssn_encrypted? # => true
1155
+ Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
1156
+ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
1157
+ ```
1158
+
1159
+ **Options** (`encryptable *fields, …`, repeatable): `type:` (cast the decrypted value — `:string` default, `:integer`, `:float`, `:decimal`, `:boolean`, `:date`, `:datetime`), `key:` (per-field override; a String or lazy Proc), `blind_index:` (`true`, or `{ column:, expression: }` — maintains a deterministic keyed-HMAC companion column, default `<field>_bidx`, for equality lookups; `expression:` normalizes symmetrically on write and query).
1160
+
1161
+ **Notes**
1162
+ - 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.
1163
+ - Ciphertext is non-deterministic (random IV), so `where(ssn: ...)` matches nothing — query through a blind index. `nil` stays `nil`; presence checks work normally.
1164
+ - 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).
1165
+ - 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.
1166
+ - 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).
1167
+
1168
+ ---
1169
+
1132
1170
  # 🎮 Controller Concerns
1133
1171
 
1134
1172
  Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
@@ -1700,6 +1738,7 @@ Both forms reference the same module, so you can freely mix them.
1700
1738
  | Tagging with contexts, ownership, or tag clouds | [`acts-as-taggable-on`](https://github.com/mbleigh/acts-as-taggable-on) |
1701
1739
  | Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
1702
1740
  | Versioned audit trails with undo/reify, who-dunnit queries, or association tracking | [`paper_trail`](https://github.com/paper-trail-gem/paper_trail) / [`audited`](https://github.com/collectiveidea/audited) |
1741
+ | Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
1703
1742
 
1704
1743
  `Sluggable` wraps [`friendly_id`](https://github.com/norman/friendly_id) and `Sortable` wraps [`acts_as_list`](https://github.com/brendon/acts_as_list), so you get those leaders' engines behind the declarative macro.
1705
1744
 
@@ -1711,7 +1750,7 @@ Both forms reference the same module, so you can freely mix them.
1711
1750
  bundle install # install dev dependencies
1712
1751
  bundle exec rspec # run the test suite
1713
1752
  gem build concerns_on_rails.gemspec # build the gem
1714
- gem install ./concerns_on_rails-1.11.2.gem # install locally
1753
+ gem install ./concerns_on_rails-1.22.0.gem # install locally
1715
1754
  ```
1716
1755
 
1717
1756
  The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
 
3
4
  module ConcernsOnRails
4
5
  module Controllers
@@ -54,7 +55,11 @@ module ConcernsOnRails
54
55
  # a helper_method (which keeps it private on the instance), so the
55
56
  # default public-only check would resolve nil and deny everyone.
56
57
  actor = respond_to?(via, true) ? send(via) : nil
57
- actor.respond_to?(role_method, true) && wanted.include?(actor.send(role_method).to_s)
58
+ # Array() handles both a scalar role and an array-valued `roles`
59
+ # method — pre-1.22 an actor with roles = ["admin"] stringified to
60
+ # '["admin"]' and was always denied.
61
+ actor.respond_to?(role_method, true) &&
62
+ Array(actor.send(role_method)).any? { |role| wanted.include?(role.to_s) }
58
63
  end
59
64
  add_authorization_rule(check: check, only: only, except: except, status: status, message: message)
60
65
  end
@@ -87,13 +92,16 @@ module ConcernsOnRails
87
92
  nil
88
93
  end
89
94
 
90
- # Public override point for how a denial is rendered.
95
+ # Public override point for how a denial is rendered. Fails CLOSED: when
96
+ # there is no response object to render into, raise — returning nil here
97
+ # (the pre-1.22 behavior) let the action run unauthorized.
91
98
  def authorization_denied(status:, message:)
92
- return unless respond_to?(:response) && response
93
-
94
- return render_error(message: message, status: status, code: "forbidden") if respond_to?(:render_error)
99
+ unless respond_to?(:response) && response
100
+ raise "ConcernsOnRails::Controllers::Authorizable: denial for '#{authorization_action_name}' " \
101
+ "could not be rendered (no response object) refusing to fail open"
102
+ end
95
103
 
96
- render json: { success: false, error: { message: message, code: "forbidden" } }, status: status
104
+ ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: "forbidden")
97
105
  end
98
106
 
99
107
  private
@@ -61,6 +61,13 @@ module ConcernsOnRails
61
61
 
62
62
  included do
63
63
  class_attribute :cacheable_rules, instance_accessor: false, default: []
64
+ # no_store is applied EARLY as well: rescue_from-rendered errors skip
65
+ # after_action entirely, and for a no_store endpoint (tokens, PII) even
66
+ # the error response must not be stored. Positive freshness policies
67
+ # deliberately do NOT move early — rescued errors already get Rails'
68
+ # safe `max-age=0, private` default, and emitting `public, max-age` on
69
+ # a rescued 500 would let shared caches serve the error.
70
+ prepend_before_action :apply_http_no_store if respond_to?(:prepend_before_action)
64
71
  after_action :apply_http_cache_headers
65
72
  end
66
73
 
@@ -143,12 +150,26 @@ module ConcernsOnRails
143
150
  nil
144
151
  end
145
152
 
153
+ # Early half of the policy (see the included block): only no_store, and
154
+ # idempotent with apply_http_cache_headers re-applying it after render.
155
+ def apply_http_no_store
156
+ rule = http_cache_rule_for_action
157
+ return unless rule && rule[:no_store]
158
+ return unless respond_to?(:response) && response
159
+
160
+ response.set_header("Cache-Control", "no-store")
161
+ end
162
+
146
163
  # Set ETag/Last-Modified validators for the resource, then — for a safe
147
164
  # request whose precondition matches — send 304 and return false. Returns
148
165
  # true when the client must be sent a fresh body. Mirrors Rails `stale?`.
149
166
  def stale_resource?(resource = nil, etag: nil, last_modified: nil)
150
- validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
167
+ # Unsafe methods get neither a 304 nor validators: a POST response must
168
+ # not advertise an ETag a client could replay against GET (pre-1.22 the
169
+ # validators were written before the safe-method check).
151
170
  return true unless http_cache_safe_request?
171
+
172
+ validators = set_cache_validators(resource, etag: etag, last_modified: last_modified)
152
173
  return true unless request_matches_cache?(etag: validators[:etag], last_modified: validators[:last_modified])
153
174
 
154
175
  http_cache_send_not_modified
@@ -225,13 +246,10 @@ module ConcernsOnRails
225
246
  end
226
247
 
227
248
  def http_cache_send_not_modified
228
- response.status = 304 if respond_to?(:response) && response
249
+ return head(:not_modified) if respond_to?(:head)
229
250
 
230
- if respond_to?(:head)
231
- head :not_modified
232
- else
233
- render(status: :not_modified)
234
- end
251
+ response.status = 304 if respond_to?(:response) && response
252
+ render(status: :not_modified)
235
253
  end
236
254
 
237
255
  def http_cache_safe_request?
@@ -1,4 +1,6 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
3
+ require "concerns_on_rails/support/scalar_param"
2
4
  require "json"
3
5
  require "time" # Time#iso8601(fraction_digits) lives in the stdlib time library
4
6
 
@@ -56,6 +58,9 @@ module ConcernsOnRails
56
58
  CURSOR_DIRECTIONS = %w[next prev].freeze
57
59
  # Adapters whose SQL supports row-value (tuple) comparison: (a, b) > (x, y).
58
60
  ROW_PREDICATE_ADAPTERS = /postgres|mysql|trilogy|sqlite/i
61
+ # Deliberately mutable: memoizes adapter row-predicate support per model
62
+ # class — stable for the life of the process, benign write race.
63
+ ROW_PREDICATE_SUPPORT_CACHE = {} # rubocop:disable Style/MutableConstant
59
64
 
60
65
  # Raised when params[:cursor] is malformed, tampered with, or was minted
61
66
  # under a different table/order configuration. Auto-rescued to a 400 when
@@ -195,16 +200,16 @@ module ConcernsOnRails
195
200
  # Public override point (mirrors ErrorHandleable's public handlers):
196
201
  # delegates to Respondable#render_error when available.
197
202
  def render_invalid_cursor(error)
198
- return render_error(message: error.message, status: :bad_request, code: "invalid_cursor") if respond_to?(:render_error)
199
-
200
- render json: { success: false, error: { message: error.message, code: "invalid_cursor" } }, status: :bad_request
203
+ ConcernsOnRails::Support::ErrorEnvelope.render(
204
+ self, message: error.message, status: :bad_request, code: "invalid_cursor"
205
+ )
201
206
  end
202
207
 
203
208
  # Same override contract for unknown ?order= preset names.
204
209
  def render_invalid_order_preset(error)
205
- return render_error(message: error.message, status: :bad_request, code: "invalid_order_preset") if respond_to?(:render_error)
206
-
207
- render json: { success: false, error: { message: error.message, code: "invalid_order_preset" } }, status: :bad_request
210
+ ConcernsOnRails::Support::ErrorEnvelope.render(
211
+ self, message: error.message, status: :bad_request, code: "invalid_order_preset"
212
+ )
208
213
  end
209
214
 
210
215
  private
@@ -311,7 +316,9 @@ module ConcernsOnRails
311
316
  end
312
317
 
313
318
  def cursor_per_page(override)
314
- requested = (override || params[:per_page]).to_i
319
+ # ScalarParam: `?per_page[]=50` arrives as an Array and .to_i on it was
320
+ # a 500 (mirrors Paginatable).
321
+ requested = ConcernsOnRails::Support::ScalarParam.to_i(override || params[:per_page], default: 0)
315
322
  requested = self.class.cursor_paginatable_per_page if requested < 1
316
323
  cap = self.class.cursor_paginatable_max_per_page
317
324
  cap.positive? ? [requested, cap].min : requested
@@ -462,7 +469,15 @@ module ConcernsOnRails
462
469
 
463
470
  return true
464
471
  end
465
- uniform && model.connection.adapter_name.match?(ROW_PREDICATE_ADAPTERS)
472
+ uniform && cursor_adapter_row_predicate?(model)
473
+ end
474
+
475
+ def cursor_adapter_row_predicate?(model)
476
+ key = model.name || model.table_name
477
+ cached = ROW_PREDICATE_SUPPORT_CACHE[key]
478
+ return cached unless cached.nil?
479
+
480
+ ROW_PREDICATE_SUPPORT_CACHE[key] = model.connection.adapter_name.match?(ROW_PREDICATE_ADAPTERS)
466
481
  end
467
482
 
468
483
  def cursor_row_predicate(model, pairs, values)
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
  require "active_support/notifications"
3
4
  require "date"
4
5
  require "time"
@@ -122,9 +123,14 @@ module ConcernsOnRails
122
123
 
123
124
  def validate_deprecation_link!(name, value)
124
125
  return if value.nil?
125
- return if value.is_a?(String) && !value.strip.empty?
126
126
 
127
- raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String"
127
+ raise ArgumentError, "#{LABEL}: :#{name} must be a non-blank String" unless value.is_a?(String) && !value.strip.empty?
128
+ return unless value.match?(/[[:cntrl:]<>]/)
129
+
130
+ # These values are interpolated into the Link header: a control
131
+ # character would split the header (response splitting), and <> would
132
+ # terminate or forge the URI-Reference delimiters.
133
+ raise ArgumentError, "#{LABEL}: :#{name} must not contain control characters or '<'/'>'"
128
134
  end
129
135
 
130
136
  # Eager parse to a UTC Time. A bare Date (or date-only String) becomes
@@ -194,14 +200,17 @@ module ConcernsOnRails
194
200
  private
195
201
 
196
202
  def deprecation_rule_for_action
197
- action = deprecation_action_name
198
- return nil unless action
203
+ # Memoized (nil included) — the before_action plus the two public
204
+ # predicates each re-scanned the rule list every request.
205
+ return @deprecation_rule_for_action if defined?(@deprecation_rule_for_action)
199
206
 
207
+ action = deprecation_action_name
200
208
  # Last match wins — see the module comment. reverse_each.find returns the
201
209
  # most recently declared rule covering this action (catch-all or not).
202
- self.class.deprecatable_rules.reverse_each.find do |rule|
203
- rule[:actions].empty? || rule[:actions].include?(action)
204
- end
210
+ @deprecation_rule_for_action =
211
+ action && self.class.deprecatable_rules.reverse_each.find do |rule|
212
+ rule[:actions].empty? || rule[:actions].include?(action)
213
+ end
205
214
  end
206
215
 
207
216
  def emit_deprecation_headers(rule)
@@ -238,10 +247,9 @@ module ConcernsOnRails
238
247
  return unless deprecation_sunset_reached?(rule)
239
248
 
240
249
  message = "This endpoint was sunset on #{rule[:sunset_at].httpdate}."
241
- return render_error(message: message, status: :gone, code: "endpoint_sunset") if respond_to?(:render_error)
242
- return unless respond_to?(:response) && response
250
+ return unless respond_to?(:render_error) || (respond_to?(:response) && response)
243
251
 
244
- render json: { success: false, error: { message: message, code: "endpoint_sunset" } }, status: :gone
252
+ ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: :gone, code: "endpoint_sunset")
245
253
  end
246
254
 
247
255
  # Inclusive: the boundary instant itself counts as sunset.
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
 
3
4
  module ConcernsOnRails
4
5
  module Controllers
@@ -64,11 +65,9 @@ module ConcernsOnRails
64
65
  private
65
66
 
66
67
  def render_error_envelope(message:, code:, status:, errors: nil)
67
- return render_error(message: message, code: code, status: status, errors: errors) if respond_to?(:render_error)
68
-
69
- error = { message: message, code: code }
70
- error[:details] = errors if errors
71
- render json: { success: false, error: error }, status: status
68
+ ConcernsOnRails::Support::ErrorEnvelope.render(
69
+ self, message: message, code: code, status: status, details: errors
70
+ )
72
71
  end
73
72
  end
74
73
  end
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/scalar_param"
2
3
 
3
4
  module ConcernsOnRails
4
5
  module Controllers
@@ -70,13 +71,12 @@ module ConcernsOnRails
70
71
  end
71
72
  end
72
73
 
73
- # Scalars (and arrays, which AR turns into `IN (...)`) are safe to pass to
74
- # .where; a Hash / ActionController::Parameters is not.
74
+ # Scalars (and arrays of scalars, which AR turns into `IN (...)`) are safe
75
+ # to pass to .where; a Hash / ActionController::Parameters is not — and
76
+ # since 1.22 neither is an array CONTAINING one (`?status[][x]=1` used to
77
+ # slip through as [Parameters] and 500 with a TypeError).
75
78
  def filterable_scalar?(value)
76
- return false if value.is_a?(Hash)
77
- return false if defined?(ActionController::Parameters) && value.is_a?(ActionController::Parameters)
78
-
79
- true
79
+ ConcernsOnRails::Support::ScalarParam.where_safe?(value)
80
80
  end
81
81
  end
82
82
  end
@@ -1,4 +1,5 @@
1
1
  require "active_support/concern"
2
+ require "concerns_on_rails/support/error_envelope"
2
3
  require "digest"
3
4
  require "json"
4
5
 
@@ -136,9 +137,7 @@ module ConcernsOnRails
136
137
  def idempotency_error_response(message:, status:, code:)
137
138
  return unless respond_to?(:response) && response
138
139
 
139
- return render_error(message: message, status: status, code: code) if respond_to?(:render_error)
140
-
141
- render json: { success: false, error: { message: message, code: code } }, status: status
140
+ ConcernsOnRails::Support::ErrorEnvelope.render(self, message: message, status: status, code: code)
142
141
  end
143
142
 
144
143
  private
@@ -209,15 +208,25 @@ module ConcernsOnRails
209
208
 
210
209
  # In flight — or the claim expired between our failed write and this
211
210
  # read (rare); answering 409 is the conservative, retry-safe choice.
212
- idempotency_conflict_response(rule)
211
+ idempotency_conflict_response(rule, record)
213
212
  end
214
213
 
215
- def idempotency_conflict_response(rule)
216
- response.set_header("Retry-After", rule[:lock_ttl].to_s) if respond_to?(:response) && response
214
+ def idempotency_conflict_response(rule, record = nil)
215
+ response.set_header("Retry-After", idempotency_retry_after(rule, record).to_s) if respond_to?(:response) && response
217
216
  idempotency_error_response(message: "A request with this #{rule[:header]} is already in progress.",
218
217
  status: :conflict, code: "idempotency_conflict")
219
218
  end
220
219
 
220
+ # Remaining lock time (floored at 1s), not the full lock TTL — the claim
221
+ # record carries when it was taken, so clients don't back off for a lock
222
+ # that is about to expire.
223
+ def idempotency_retry_after(rule, record)
224
+ claimed_at = record.is_a?(Hash) ? record["claimed_at"] : nil
225
+ return rule[:lock_ttl] unless claimed_at
226
+
227
+ [rule[:lock_ttl] - (Time.now.to_i - claimed_at.to_i), 1].max
228
+ end
229
+
221
230
  def read_idempotency_header(rule)
222
231
  return nil unless respond_to?(:request) && request.respond_to?(:headers) && request.headers
223
232
 
@@ -65,7 +65,11 @@ module ConcernsOnRails
65
65
  return {} unless raw.respond_to?(:each_pair)
66
66
 
67
67
  allowed = self.class.includable_fields
68
- raw.each_with_object({}) do |(table, cols), memo|
68
+ # Iterate via each_pair: in a real controller `raw` is an
69
+ # ActionController::Parameters, which has each_pair but no Enumerable —
70
+ # calling each_with_object directly on it was a guaranteed
71
+ # NoMethodError 500 for every ?fields[...]= request.
72
+ raw.each_pair.with_object({}) do |(table, cols), memo|
69
73
  key = table.to_sym
70
74
  next unless allowed.key?(key)
71
75
 
@@ -46,12 +46,16 @@ module ConcernsOnRails
46
46
  end
47
47
 
48
48
  # The locale chosen for this request — always one I18n can switch to.
49
+ # Memoized per request (the wrapper plus any helpers may read it several
50
+ # times; parsing the Accept-Language header repeatedly is waste).
49
51
  def resolved_locale
50
- opts = self.class.localizable_options
51
- allowed = opts[:available].presence || I18n.available_locales
52
- candidate = locale_from_param(opts, allowed) || locale_from_header(opts, allowed) || opts[:default]
52
+ @resolved_locale ||= begin
53
+ opts = self.class.localizable_options
54
+ allowed = opts[:available].presence || I18n.available_locales
55
+ candidate = locale_from_param(opts, allowed) || locale_from_header(opts, allowed) || opts[:default]
53
56
 
54
- candidate && I18n.available_locales.include?(candidate.to_sym) ? candidate.to_sym : I18n.default_locale
57
+ candidate && I18n.available_locales.include?(candidate.to_sym) ? candidate.to_sym : I18n.default_locale
58
+ end
55
59
  end
56
60
 
57
61
  private
@@ -78,21 +82,26 @@ module ConcernsOnRails
78
82
 
79
83
  def parse_accept_language(header, allowed)
80
84
  ranked_accept_languages(header).each do |lang|
81
- match = match_locale(lang, allowed)
85
+ # Full tag first (fr-CA matches an available :"fr-CA"), then the
86
+ # primary subtag (fr). Pre-1.22 the region was discarded outright,
87
+ # so a regional locale in available: could never match its own
88
+ # Accept-Language tag.
89
+ match = match_locale(lang, allowed) || match_locale(lang.split("-").first, allowed)
82
90
  return match if match
83
91
  end
84
92
  nil
85
93
  end
86
94
 
87
- # Languages from an Accept-Language header, q=0 dropped, highest-q first
88
- # (RFC 7231 preference order).
95
+ # Language tags from an Accept-Language header (kept whole see
96
+ # parse_accept_language for region handling), q=0 dropped, highest-q
97
+ # first (RFC 7231 preference order).
89
98
  def ranked_accept_languages(header)
90
99
  pairs = header.split(",").filter_map do |part|
91
100
  token, *params = part.split(";").map(&:strip)
92
101
  quality = accept_language_quality(params)
93
102
  next if quality <= 0.0
94
103
 
95
- lang = token.to_s.split("-").first
104
+ lang = token.to_s.strip
96
105
  [lang, quality] if lang.present?
97
106
  end
98
107
  pairs.sort_by { |(_lang, quality)| -quality }.map(&:first)