concerns_on_rails 1.22.0 → 1.24.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +91 -7
- data/lib/concerns_on_rails/configuration.rb +27 -0
- data/lib/concerns_on_rails/controllers/idempotentable.rb +7 -4
- data/lib/concerns_on_rails/controllers/throttleable.rb +7 -3
- data/lib/concerns_on_rails/legacy_aliases.rb +25 -27
- data/lib/concerns_on_rails/models/activatable.rb +1 -1
- data/lib/concerns_on_rails/models/addressable.rb +1 -1
- data/lib/concerns_on_rails/models/anonymizable.rb +218 -0
- data/lib/concerns_on_rails/models/auditable.rb +1 -1
- data/lib/concerns_on_rails/models/counter_cacheable.rb +1 -1
- data/lib/concerns_on_rails/models/duplicable.rb +207 -0
- data/lib/concerns_on_rails/models/encryptable.rb +2 -2
- data/lib/concerns_on_rails/models/expirable.rb +1 -1
- data/lib/concerns_on_rails/models/hashable.rb +2 -1
- data/lib/concerns_on_rails/models/lockable.rb +2 -1
- data/lib/concerns_on_rails/models/monetizable.rb +1 -1
- data/lib/concerns_on_rails/models/publishable.rb +1 -1
- data/lib/concerns_on_rails/models/schedulable.rb +1 -1
- data/lib/concerns_on_rails/models/sequenceable.rb +3 -3
- data/lib/concerns_on_rails/models/sluggable.rb +12 -2
- data/lib/concerns_on_rails/models/soft_deletable.rb +1 -1
- data/lib/concerns_on_rails/models/sortable.rb +11 -2
- data/lib/concerns_on_rails/models/stateable.rb +1 -1
- data/lib/concerns_on_rails/models/storable.rb +1 -1
- data/lib/concerns_on_rails/models/taggable.rb +1 -1
- data/lib/concerns_on_rails/models/tokenizable.rb +1 -1
- data/lib/concerns_on_rails/support/column_guard.rb +23 -4
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +95 -65
- metadata +54 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5361fdba6417a0a46213e0e8e41f8ced13d00f76cb17f102404d1d794a98a39
|
|
4
|
+
data.tar.gz: 0fab62907022084c9b54d1cca52f8b4eeefbecced0a4bc905e4b3a8a55f8f582
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5654215000d983a63260507d6741090add3b7861e200e81ebd180ad7d7de61891c0646c49b666d4b01b179b51ae88d1da73386ffb35bc1cbb24328d8f3238c99
|
|
7
|
+
data.tar.gz: 436b80fa129a4a3922a0a6b7ba4945f90205f1071a4d190875fa26e87458bb39ba483b0f682bff0c64cd3fb09fda79ec15cec776f22b3d085af03d14fe5a2275
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
<!-- CHANGELOG.md -->
|
|
2
2
|
|
|
3
|
+
## 1.24.0 (2026-08-15)
|
|
4
|
+
|
|
5
|
+
A developer-experience wave from the 2026-08-15 usability review: leaner install, lazy loading, teaching errors, and one-initializer store configuration. No behavior changes for configured concerns. 1084 examples, 0 failures.
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- **Dependencies**: the gem now depends on `actionpack` / `activerecord` / `activesupport` instead of the full `rails` meta-gem (API-only hosts stop pulling Action Cable / Mailbox / Text), and the `acts_as_list` constraint is `>= 0.7.5, < 2` — the old `~> 0.7.5` pin was an unresolvable Bundler conflict for any app already on acts_as_list 1.x. Suite verified against acts_as_list 1.2.6.
|
|
9
|
+
- **Lazy loading**: `require "concerns_on_rails"` no longer eagerly loads all 40 concern/support files — everything is autoloaded on first constant reference, and `friendly_id` / `acts_as_list` load only when Sluggable / Sortable is actually used (a missing gem raises `ConcernsOnRails::MissingDependency`, a `LoadError` subclass whose message names the Gemfile line to add). The pre-1.6 top-level aliases (`ConcernsOnRails::Sluggable`, …) keep working, now resolved lazily via `const_missing`.
|
|
10
|
+
- **Support::ColumnGuard** (all model concerns): the missing-column `ArgumentError` now teaches the fix — it appends a ready-to-paste `bin/rails generate migration ...` command carrying the concern's expected column type (e.g. `AddDeletedAtToArticles deleted_at:datetime`; token/slug columns suggest `string:uniq`, Encryptable blind-index columns `string:index`).
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`ConcernsOnRails.setup`**: gem-wide configuration entry point (`lib/concerns_on_rails/configuration.rb`). First setting: `config.cache_store` — a store or a Proc (e.g. `-> { Rails.cache }`) used as the fallback store by `Controllers::Throttleable` and `Controllers::Idempotentable`, so one initializer line replaces per-controller `self.throttleable_store = ...` / `self.idempotency_store = ...` wiring. A class-level store still wins; with neither configured the concerns raise exactly as before.
|
|
14
|
+
|
|
15
|
+
## 1.23.0 (2026-07-26)
|
|
16
|
+
|
|
17
|
+
Two new model concerns — the roadmap picks after 1.22's fixes round: the right-to-erasure capability that completes the sensitive-data suite, and the concern-aware deep copy that makes "duplicate this invoice" safe next to unique slugs, tokens, and sequence numbers. 1075 examples, 0 failures.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Models::Anonymizable**: declarative right-to-erasure ("GDPR-lite") for personal data — the fourth member of the sensitive-data suite (Maskable masks display, Sanitizable strips HTML, Encryptable protects at rest; Anonymizable DESTROYS). `anonymizable *fields, with:` declares an erasure strategy per field group (repeatable; rules merge): presets `:nullify`, `:redact`, `:hash` (SHA-256 — deterministic pseudonymization, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive erasure), `:random_hex`, or a callable (`->(value)` / `->(value, record)`); presets pass nil through untouched. `anonymize!` runs `before_anonymize` + ONE `update_columns` UPDATE + `after_anonymize` in a single transaction — deliberately skipping validations (erasure must not be blocked by a presence check) and callbacks (nothing may copy the old values elsewhere; Auditable's capture hook is the canonical example) — then reloads. Values serialize through the model's attribute types, so a field that is also `encryptable` stores a fresh ciphertext envelope of the anonymized value, never plaintext (spec-verified). When an erased field is also `auditable_by`, the audit column (which holds its plaintext history) is cleared in the same UPDATE (`clear_audit_trail: false` opts out). `stamp:` column (default `:anonymized_at`; `false` opts out) powers `anonymized?`, the `anonymized`/`not_anonymized` scopes (affixable via `prefix:`/`suffix:`), and the idempotent batch `anonymize_all!` (Integer count, skips stamped records — the 1.22 batch contract). Macro-time `ArgumentError` validation for fields, presets, callables, and columns. Zero new dependencies.
|
|
21
|
+
- **Models::Duplicable**: concern-aware deep copy ("clone this invoice/template"). A bare AR `dup` copies identity-bearing columns — slug, API token, invoice number, audit trail, even `created_at` (which AR preserves on save when present) — so naive copies collide with unique indexes or lie about their history. `duplicate` (unsaved) / `duplicate!(overrides)` (saved, one transaction, autosaved children) blank exactly those columns automatically: timestamps, Sluggable's slug, Tokenizable/Hashable columns, Sequenceable sequence + `into:` columns, Auditable's trail (the copy inherits no history; its own creation is then audited normally), SoftDeletable's timestamp (a copy of trash is live), and Lockable's attempts (0) / locked_at (nil) — each concern regenerates fresh values on the copy's save. Business state (Publishable/Stateable/…) is deliberately NOT auto-reset — list it in `reset:`. `duplicable_by associations:, reset:, suffix:` (optional — bare include works): the association allow-list is validated at macro time (declared-before, the CounterCacheable convention); `has_many`/`has_one` children deep-copy — a child whose class also includes Duplicable copies via ITS OWN rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the same records; `belongs_to` and `has_many :through` are rejected with explanations. `suffix: { title: " (copy)" }` appends to present values; `on_duplicate(copy)` is the override hook. Zero new dependencies.
|
|
22
|
+
|
|
23
|
+
### Internal
|
|
24
|
+
- Top-level aliases `ConcernsOnRails::Anonymizable` / `ConcernsOnRails::Duplicable` registered alongside the existing set; docs site pages + registry entries added for both concerns.
|
|
25
|
+
|
|
3
26
|
## 1.22.0 (2026-07-26)
|
|
4
27
|
|
|
5
28
|
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.
|
data/README.md
CHANGED
|
@@ -12,7 +12,7 @@ One `include`, one declarative macro — done.
|
|
|
12
12
|
[](https://rubyonrails.org)
|
|
13
13
|
[](#-license)
|
|
14
14
|
|
|
15
|
-
🧩 **
|
|
15
|
+
🧩 **26 model concerns** · 🎮 **16 controller concerns** · 🪶 **lean deps** · ✅ **schema-validated**
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
@@ -66,6 +66,8 @@ Article.published.without_deleted.find("hello-world")
|
|
|
66
66
|
| [⚙️ Storable](#-storable) | Typed accessors over one JSON column ("store_attribute-lite") |
|
|
67
67
|
| [🧮 CounterCacheable](#-countercacheable) | Conditional denormalized counters ("counter_culture-lite") |
|
|
68
68
|
| [🔏 Encryptable](#-encryptable) | Transparent field encryption (AES-256-GCM) + blind-index lookups |
|
|
69
|
+
| [🕵️ Anonymizable](#-anonymizable) | GDPR right-to-erasure with per-field strategies |
|
|
70
|
+
| [🧬 Duplicable](#-duplicable) | Concern-aware deep copy ("clone this invoice") |
|
|
69
71
|
|
|
70
72
|
### 🎮 Controller concerns
|
|
71
73
|
|
|
@@ -92,9 +94,9 @@ Article.published.without_deleted.find("hello-world")
|
|
|
92
94
|
|
|
93
95
|
## ✨ Why this gem?
|
|
94
96
|
|
|
95
|
-
- **Twenty-
|
|
97
|
+
- **Twenty-six model concerns + sixteen controller concerns**, all production-ready
|
|
96
98
|
- **One include, one macro** — no boilerplate, no glue code
|
|
97
|
-
- **Lean dependencies** — only `acts_as_list` (Sortable) and `friendly_id` (Sluggable); controller concerns have zero extra deps
|
|
99
|
+
- **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
|
|
98
100
|
- **Schema-validated configuration** — every macro checks that the configured column exists and raises `ArgumentError` early
|
|
99
101
|
- **Composable** — concerns are independent; mix and match per model
|
|
100
102
|
|
|
@@ -105,7 +107,7 @@ Article.published.without_deleted.find("hello-world")
|
|
|
105
107
|
Add to your application's `Gemfile`:
|
|
106
108
|
|
|
107
109
|
```ruby
|
|
108
|
-
gem "concerns_on_rails", "~> 1.
|
|
110
|
+
gem "concerns_on_rails", "~> 1.24"
|
|
109
111
|
```
|
|
110
112
|
|
|
111
113
|
Or pull the latest from GitHub:
|
|
@@ -120,6 +122,27 @@ Then run:
|
|
|
120
122
|
bundle install
|
|
121
123
|
```
|
|
122
124
|
|
|
125
|
+
### Optional: one-initializer configuration
|
|
126
|
+
|
|
127
|
+
Everything works with zero configuration. The store-backed controller concerns
|
|
128
|
+
(`Throttleable`, `Idempotentable`) need a cache store — set it once, gem-wide,
|
|
129
|
+
instead of per controller class:
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
# config/initializers/concerns_on_rails.rb
|
|
133
|
+
ConcernsOnRails.setup do |config|
|
|
134
|
+
config.cache_store = -> { Rails.cache } # fallback for Throttleable / Idempotentable
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Encryptable's key lives in its own config (see the Encryptable section):
|
|
138
|
+
ConcernsOnRails.configure_encryption do |c|
|
|
139
|
+
c.key = -> { Rails.application.credentials.dig(:encryption, :key) }
|
|
140
|
+
end
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A per-class `self.throttleable_store = ...` / `self.idempotency_store = ...`
|
|
144
|
+
still wins over the gem-wide fallback.
|
|
145
|
+
|
|
123
146
|
---
|
|
124
147
|
|
|
125
148
|
## 🧪 Compatibility
|
|
@@ -1167,6 +1190,66 @@ Patient.where_email("a@b.com") # chainable Relation (accepts arrays too)
|
|
|
1167
1190
|
|
|
1168
1191
|
---
|
|
1169
1192
|
|
|
1193
|
+
## 🕵️ Anonymizable
|
|
1194
|
+
|
|
1195
|
+
Declarative right-to-erasure ("GDPR-lite"): each personal-data field gets an erasure strategy, and `anonymize!` rewrites them all in **one UPDATE** while stamping `anonymized_at`. Completes the sensitive-data suite — Maskable masks *display*, Sanitizable strips *HTML*, Encryptable protects *at rest*; Anonymizable **destroys**.
|
|
1196
|
+
|
|
1197
|
+
```ruby
|
|
1198
|
+
class User < ApplicationRecord
|
|
1199
|
+
include ConcernsOnRails::Models::Anonymizable
|
|
1200
|
+
|
|
1201
|
+
anonymizable :email, with: :email # unique fake address
|
|
1202
|
+
anonymizable :first_name, :last_name, with: :redact
|
|
1203
|
+
anonymizable :ssn, with: :nullify
|
|
1204
|
+
anonymizable :bio, with: ->(value) { value && "removed by user request" }
|
|
1205
|
+
end
|
|
1206
|
+
|
|
1207
|
+
user.anonymize! # hooks + single UPDATE + stamp, in one transaction
|
|
1208
|
+
user.anonymized? # => true
|
|
1209
|
+
User.not_anonymized # scope (and .anonymized)
|
|
1210
|
+
User.where(...).anonymize_all! # batch; returns the count, skips stamped records
|
|
1211
|
+
```
|
|
1212
|
+
|
|
1213
|
+
**Strategies** (`with:`): `:nullify`, `:redact` (`"[REDACTED]"`), `:hash` (SHA-256 — deterministic *pseudonymization*, joins keep working), `:email` (random unique `anon-…@anonymized.invalid`, so NOT NULL + unique email columns survive), `:random_hex`, or a callable (`->(value)` / `->(value, record)`). Presets pass `nil` through untouched.
|
|
1214
|
+
|
|
1215
|
+
**Options** (repeatable; field rules merge): `stamp:` (default `:anonymized_at`; `false` disables stamping + scopes), `clear_audit_trail:` (default `true` — when an erased field is also `auditable_by`, the trail holding its plaintext history is cleared in the same UPDATE), `prefix:`/`suffix:` (scope names).
|
|
1216
|
+
|
|
1217
|
+
**Notes**
|
|
1218
|
+
- Deliberately `update_columns`: erasure is never blocked by validations and never runs callbacks that could copy old values elsewhere. Values still serialize through the attribute types, so an `encryptable` field stores a fresh ciphertext envelope — never plaintext.
|
|
1219
|
+
- `before_anonymize`/`after_anonymize` hooks run inside the transaction; the record reloads afterwards (erasure is terminal for the instance).
|
|
1220
|
+
- `:hash` is pseudonymization — use `:nullify`/`:random_hex` for true erasure. Backups/replicas/logs are out of scope.
|
|
1221
|
+
|
|
1222
|
+
---
|
|
1223
|
+
|
|
1224
|
+
## 🧬 Duplicable
|
|
1225
|
+
|
|
1226
|
+
Concern-aware deep copy — the "clone this invoice / duplicate this template" feature. A bare `dup` copies identity-bearing columns (slug, token, invoice number, audit trail, even `created_at`, which AR preserves on save), so naive copies collide with unique indexes. Duplicable blanks exactly those columns and lets each sibling concern regenerate fresh values on save.
|
|
1227
|
+
|
|
1228
|
+
```ruby
|
|
1229
|
+
class Invoice < ApplicationRecord
|
|
1230
|
+
include ConcernsOnRails::Models::Duplicable
|
|
1231
|
+
|
|
1232
|
+
has_many :line_items
|
|
1233
|
+
duplicable_by associations: %i[line_items],
|
|
1234
|
+
reset: %i[issued_at],
|
|
1235
|
+
suffix: { title: " (copy)" }
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
copy = invoice.duplicate # unsaved deep copy
|
|
1239
|
+
copy = invoice.duplicate!(title: "Q3") # saved (one transaction, autosaved children)
|
|
1240
|
+
```
|
|
1241
|
+
|
|
1242
|
+
**Auto-reset identity columns** (no configuration): `created_at`/`updated_at`, Sluggable slug, Tokenizable/Hashable tokens, Sequenceable sequence + `into:` columns, Auditable trail, SoftDeletable timestamp, Lockable attempts/locked_at. Business state (Publishable, Stateable, …) is a judgment call — list it in `reset:`.
|
|
1243
|
+
|
|
1244
|
+
**Associations** (`associations:` allow-list, declared before the macro, validated at macro time): `has_many`/`has_one` children are deep-copied — a child that also includes Duplicable copies via **its own** rules, so nested graphs stay declarative; `has_and_belongs_to_many` re-links the *same* records; `belongs_to` and `has_many :through` are rejected with an explanation.
|
|
1245
|
+
|
|
1246
|
+
**Notes**
|
|
1247
|
+
- The macro is optional — bare `include` gives `duplicate`/`duplicate!` with the auto resets.
|
|
1248
|
+
- Override `on_duplicate(copy)` for custom tweaks; it receives the unsaved copy last.
|
|
1249
|
+
- Reach for [`amoeba`](https://github.com/amoeba-rb/amoeba) when you need per-attribute regex/prepend rules or belongs_to graph copying.
|
|
1250
|
+
|
|
1251
|
+
---
|
|
1252
|
+
|
|
1170
1253
|
# 🎮 Controller Concerns
|
|
1171
1254
|
|
|
1172
1255
|
Pure ActionController + ActiveRecord — **zero extra runtime dependencies** (no Kaminari, Pundit, or Ransack).
|
|
@@ -1537,7 +1620,7 @@ Fixed-window counter: the key embeds a floored time bucket (`epoch / period`) so
|
|
|
1537
1620
|
|
|
1538
1621
|
**Notes**
|
|
1539
1622
|
- The store MUST support **atomic increment-with-expiry** (`Rails.cache` with `#increment`, or Redis) — a non-atomic store under-counts under concurrency.
|
|
1540
|
-
- There is **no in-process default store** on purpose: the first throttled request raises `ArgumentError` until you set `throttleable_store
|
|
1623
|
+
- There is **no in-process default store** on purpose: the first throttled request raises `ArgumentError` until you set `throttleable_store` (or the gem-wide fallback `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`), so you never silently rate-limit per-process.
|
|
1541
1624
|
- When `Respondable` is included, the 429 body delegates to `render_error` (`code: "rate_limited"`).
|
|
1542
1625
|
- Backports the essentials of Rails 7.2's `rate_limit` (with standardized headers) to Rails 5.0+. For richer rules (fail2ban, allow/deny lists, exponential backoff) reach for [`rack-attack`](https://github.com/rack/rack-attack).
|
|
1543
1626
|
|
|
@@ -1586,7 +1669,7 @@ Per-key lifecycle: claim atomically (`write unless_exist`, TTL `lock_ttl:`) →
|
|
|
1586
1669
|
|
|
1587
1670
|
**Notes**
|
|
1588
1671
|
- Cache keys are scoped per `controller#action` and the client key is SHA256-hashed, so the same key on different endpoints never collides.
|
|
1589
|
-
- There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store
|
|
1672
|
+
- There is **no in-process default store** on purpose: the first keyed request raises `ArgumentError` until you set `idempotency_store` (or the gem-wide fallback `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`).
|
|
1590
1673
|
- When `Respondable` is included, the 400/409/422 bodies delegate to `render_error`.
|
|
1591
1674
|
- Declare halting filters (authentication, `Throttleable`) **before** including this concern — a 401/403 rendered by an inner filter would be cached and replayed for the full TTL. Responses rendered by `rescue_from` handlers are never cached.
|
|
1592
1675
|
- Keys must be ≤255 chars with no control characters (the raw key is echoed in `X-Idempotency-Key`); set `lock_ttl:` above the slowest declared action's worst case.
|
|
@@ -1739,6 +1822,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1739
1822
|
| Full-text search with ranking / stemming | [`pg_search`](https://github.com/Casecommons/pg_search) / Elasticsearch |
|
|
1740
1823
|
| 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
1824
|
| Field encryption with managed key rotation / Rails-native key infrastructure | [`lockbox`](https://github.com/ankane/lockbox) / Rails 7+ native `encrypts` |
|
|
1825
|
+
| Deep clone with per-attribute regex/prepend rules or belongs_to graph copying | [`amoeba`](https://github.com/amoeba-rb/amoeba) |
|
|
1742
1826
|
|
|
1743
1827
|
`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.
|
|
1744
1828
|
|
|
@@ -1750,7 +1834,7 @@ Both forms reference the same module, so you can freely mix them.
|
|
|
1750
1834
|
bundle install # install dev dependencies
|
|
1751
1835
|
bundle exec rspec # run the test suite
|
|
1752
1836
|
gem build concerns_on_rails.gemspec # build the gem
|
|
1753
|
-
gem install ./concerns_on_rails-1.
|
|
1837
|
+
gem install ./concerns_on_rails-1.24.0.gem # install locally
|
|
1754
1838
|
```
|
|
1755
1839
|
|
|
1756
1840
|
The test suite uses an in-memory SQLite database and a lightweight `FakeController` harness for controller-concern specs — no Rails routes or boot required.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module ConcernsOnRails
|
|
2
|
+
# Gem-wide configuration, set once from an initializer:
|
|
3
|
+
#
|
|
4
|
+
# ConcernsOnRails.setup do |config|
|
|
5
|
+
# config.cache_store = -> { Rails.cache }
|
|
6
|
+
# end
|
|
7
|
+
#
|
|
8
|
+
# `cache_store` is the fallback store consulted by Controllers::Throttleable
|
|
9
|
+
# and Controllers::Idempotentable when the controller class hasn't set its
|
|
10
|
+
# own (`self.throttleable_store = ...` / `self.idempotency_store = ...`
|
|
11
|
+
# still win). A store object or a zero-arg callable — prefer a Proc so
|
|
12
|
+
# `Rails.cache` is read lazily, after the framework has booted. The store
|
|
13
|
+
# contract is unchanged: atomic #increment for throttling, #read /
|
|
14
|
+
# #write(expires_in:, unless_exist:) / #delete for idempotency. There is
|
|
15
|
+
# still no in-process default on purpose — a non-atomic store silently
|
|
16
|
+
# under-counts, so the host must opt in explicitly (just once, here).
|
|
17
|
+
class Configuration
|
|
18
|
+
attr_accessor :cache_store
|
|
19
|
+
|
|
20
|
+
# The fallback store with any callable resolved (per lookup, so a Proc
|
|
21
|
+
# reading Rails.cache follows a swapped-out cache in tests). nil when the
|
|
22
|
+
# host never configured one.
|
|
23
|
+
def resolved_cache_store
|
|
24
|
+
cache_store.respond_to?(:call) ? cache_store.call : cache_store
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -33,8 +33,10 @@ module ConcernsOnRails
|
|
|
33
33
|
# The claim is taken atomically via `write(..., unless_exist: true)`
|
|
34
34
|
# (memcached `add` / Redis `SET NX` through Rails.cache); a store without
|
|
35
35
|
# that atomicity is best-effort under concurrency. There is no in-process
|
|
36
|
-
# default store on purpose — configure one explicitly
|
|
37
|
-
#
|
|
36
|
+
# default store on purpose — configure one explicitly (per controller as
|
|
37
|
+
# above, or once for the whole app via `ConcernsOnRails.setup { |c|
|
|
38
|
+
# c.cache_store = -> { Rails.cache } }`) or the first keyed request raises
|
|
39
|
+
# ArgumentError. Note that responses rendered by
|
|
38
40
|
# `rescue_from` handlers bypass the around filter's success path and are
|
|
39
41
|
# never cached.
|
|
40
42
|
#
|
|
@@ -246,12 +248,13 @@ module ConcernsOnRails
|
|
|
246
248
|
end
|
|
247
249
|
|
|
248
250
|
def idempotency_store!
|
|
249
|
-
store = self.class.idempotency_store
|
|
251
|
+
store = self.class.idempotency_store || ConcernsOnRails.config.resolved_cache_store
|
|
250
252
|
return store if store
|
|
251
253
|
|
|
252
254
|
raise ArgumentError,
|
|
253
255
|
"ConcernsOnRails::Controllers::Idempotentable: no store configured. " \
|
|
254
|
-
"Set `self.idempotency_store = Rails.cache` " \
|
|
256
|
+
"Set `self.idempotency_store = Rails.cache` on the controller, or the gem-wide " \
|
|
257
|
+
"fallback: ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } } " \
|
|
255
258
|
"(must support #read, #write(expires_in:, unless_exist:) and #delete)."
|
|
256
259
|
end
|
|
257
260
|
|
|
@@ -23,7 +23,9 @@ module ConcernsOnRails
|
|
|
23
23
|
# exact. The store MUST support atomic increment-with-expiry (`Rails.cache`
|
|
24
24
|
# with `#increment`, or Redis); a non-atomic store under-counts under
|
|
25
25
|
# concurrency. There is no in-process default store on purpose — configure
|
|
26
|
-
# one explicitly or
|
|
26
|
+
# one explicitly (per controller as above, or once for the whole app via
|
|
27
|
+
# `ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } }`) or
|
|
28
|
+
# the first throttled request raises ArgumentError.
|
|
27
29
|
module Throttleable
|
|
28
30
|
extend ActiveSupport::Concern
|
|
29
31
|
|
|
@@ -161,12 +163,14 @@ module ConcernsOnRails
|
|
|
161
163
|
end
|
|
162
164
|
|
|
163
165
|
def throttle_store!
|
|
164
|
-
store = self.class.throttleable_store
|
|
166
|
+
store = self.class.throttleable_store || ConcernsOnRails.config.resolved_cache_store
|
|
165
167
|
return store if store
|
|
166
168
|
|
|
167
169
|
raise ArgumentError,
|
|
168
170
|
"ConcernsOnRails::Controllers::Throttleable: no store configured. " \
|
|
169
|
-
"Set `self.throttleable_store = Rails.cache`
|
|
171
|
+
"Set `self.throttleable_store = Rails.cache` on the controller, or the gem-wide " \
|
|
172
|
+
"fallback: ConcernsOnRails.setup { |c| c.cache_store = -> { Rails.cache } } " \
|
|
173
|
+
"(must support atomic #increment)."
|
|
170
174
|
end
|
|
171
175
|
|
|
172
176
|
def throttle_action_name
|
|
@@ -1,29 +1,27 @@
|
|
|
1
1
|
module ConcernsOnRails
|
|
2
|
-
# Backwards-compatibility aliases for pre-1.6 module paths
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
CounterCacheable = Models::CounterCacheable
|
|
28
|
-
Encryptable = Models::Encryptable
|
|
2
|
+
# Backwards-compatibility aliases for pre-1.6 module paths, resolved lazily:
|
|
3
|
+
# eager `Sluggable = Models::Sluggable` assignments would force-load every
|
|
4
|
+
# concern file at boot and defeat the loader's autoload laziness. The first
|
|
5
|
+
# reference to `ConcernsOnRails::<Name>` loads the real module and pins the
|
|
6
|
+
# alias constant, so `include ConcernsOnRails::Sluggable` works unchanged.
|
|
7
|
+
# New code is encouraged to use the namespaced form:
|
|
8
|
+
# `ConcernsOnRails::Models::Sluggable`.
|
|
9
|
+
#
|
|
10
|
+
# Note `Sortable` resolves to Models::Sortable (as it always has) — the
|
|
11
|
+
# controller concern is only reachable as Controllers::Sortable.
|
|
12
|
+
LEGACY_MODEL_ALIASES = %i[
|
|
13
|
+
Sluggable Sortable Publishable SoftDeletable Hashable Schedulable
|
|
14
|
+
Expirable Normalizable Searchable Activatable Tokenizable Stateable
|
|
15
|
+
Addressable Sequenceable Taggable Sanitizable Maskable Monetizable
|
|
16
|
+
Auditable Lockable Aliasable Storable CounterCacheable Encryptable
|
|
17
|
+
Anonymizable Duplicable
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def self.const_missing(name)
|
|
21
|
+
return super unless LEGACY_MODEL_ALIASES.include?(name)
|
|
22
|
+
|
|
23
|
+
# Idempotent under a concurrent first reference: both threads pin the
|
|
24
|
+
# same module object, and once pinned const_missing never fires again.
|
|
25
|
+
const_set(name, Models.const_get(name))
|
|
26
|
+
end
|
|
29
27
|
end
|
|
@@ -33,7 +33,7 @@ module ConcernsOnRails
|
|
|
33
33
|
|
|
34
34
|
def activatable_by(field = DEFAULT_FIELD, prefix: nil, suffix: nil)
|
|
35
35
|
self.activatable_field = field.to_sym
|
|
36
|
-
ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field)
|
|
36
|
+
ensure_columns!("ConcernsOnRails::Models::Activatable", activatable_field, types: :boolean)
|
|
37
37
|
|
|
38
38
|
# Affix the scope names so two concerns that each define `.active`
|
|
39
39
|
# (e.g. SoftDeletable / Expirable) can coexist on one model.
|
|
@@ -86,7 +86,7 @@ module ConcernsOnRails
|
|
|
86
86
|
raise ArgumentError, "#{LABEL}: unknown address part(s): #{unknown.join(', ')}" if unknown.any?
|
|
87
87
|
|
|
88
88
|
overrides = mapping.to_h { |part, column| [part.to_sym, column.to_sym] }
|
|
89
|
-
ensure_columns!(LABEL, overrides.values)
|
|
89
|
+
ensure_columns!(LABEL, overrides.values, types: :string)
|
|
90
90
|
DEFAULT_FIELDS.merge(overrides).select { |_part, column| column_names.include?(column.to_s) }
|
|
91
91
|
end
|
|
92
92
|
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
require "active_support/concern"
|
|
2
|
+
require "concerns_on_rails/support/column_guard"
|
|
3
|
+
require "digest"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module ConcernsOnRails
|
|
7
|
+
module Models
|
|
8
|
+
# Declarative right-to-erasure ("GDPR-lite") for personal data. The fourth
|
|
9
|
+
# member of the sensitive-data suite: Maskable masks *display*, Sanitizable
|
|
10
|
+
# strips *HTML*, Encryptable protects *at rest* — Anonymizable DESTROYS.
|
|
11
|
+
#
|
|
12
|
+
# class User < ApplicationRecord
|
|
13
|
+
# include ConcernsOnRails::Models::Anonymizable
|
|
14
|
+
#
|
|
15
|
+
# anonymizable :email, with: :email # unique fake address
|
|
16
|
+
# anonymizable :first_name, :last_name, with: :redact
|
|
17
|
+
# anonymizable :ssn, with: :nullify
|
|
18
|
+
# anonymizable :bio, with: ->(value) { value && "removed by user request" }
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# user.anonymize! # one UPDATE: strategies + anonymized_at stamp
|
|
22
|
+
# user.anonymized? # => true
|
|
23
|
+
# User.not_anonymized # scope (and .anonymized)
|
|
24
|
+
# User.where(...).anonymize_all! # batch; returns the count
|
|
25
|
+
#
|
|
26
|
+
# HOW IT WRITES — deliberately update_columns (single UPDATE, no
|
|
27
|
+
# validations, no callbacks): erasure must not be blocked by a presence/
|
|
28
|
+
# format validation, and must not run callbacks that would copy the OLD
|
|
29
|
+
# values somewhere new (Auditable's capture hook is the canonical example).
|
|
30
|
+
# update_columns serializes each value through the model's attribute types
|
|
31
|
+
# — Encryptable's custom type included — so a field that is also
|
|
32
|
+
# `encryptable` stores a fresh ciphertext envelope of the anonymized
|
|
33
|
+
# value, never plaintext. The record is reloaded afterwards so in-memory
|
|
34
|
+
# readers see the anonymized values through the types.
|
|
35
|
+
#
|
|
36
|
+
# Strategy presets (`with:`):
|
|
37
|
+
# :nullify — nil
|
|
38
|
+
# :redact — "[REDACTED]"
|
|
39
|
+
# :hash — SHA-256 hex of the value (deterministic pseudonymization:
|
|
40
|
+
# the same input digests the same, so datasets keyed on the
|
|
41
|
+
# value still join — NOT full anonymization)
|
|
42
|
+
# :email — "anon-<random-hex>@anonymized.invalid" (random + unique,
|
|
43
|
+
# so NOT NULL / unique-index email columns survive erasure;
|
|
44
|
+
# .invalid is an RFC 2606 reserved TLD — it can never send)
|
|
45
|
+
# :random_hex — 32 random hex chars (unique tokens/usernames)
|
|
46
|
+
# a callable — ->(value) { ... } or ->(value, record) { ... }; nil-in
|
|
47
|
+
# nil-out is the preset convention, custom callables choose
|
|
48
|
+
#
|
|
49
|
+
# Notes:
|
|
50
|
+
# * The stamp column (default :anonymized_at, `stamp: false` to opt out)
|
|
51
|
+
# is what makes `anonymized?`, the scopes, and anonymize_all!'s
|
|
52
|
+
# idempotency work — add it (a datetime) unless you truly can't.
|
|
53
|
+
# * Auditable interaction: if any anonymized field is also audited, the
|
|
54
|
+
# trail already holds historical plaintext, so anonymize! clears the
|
|
55
|
+
# audit column in the SAME update (opt out per-macro with
|
|
56
|
+
# `clear_audit_trail: false`). The trail is one column — clearing is
|
|
57
|
+
# all-or-nothing.
|
|
58
|
+
# * Encryptable interaction: works transparently (see HOW IT WRITES).
|
|
59
|
+
# * Erasure is terminal: unsaved changes on the instance are discarded by
|
|
60
|
+
# the post-write reload.
|
|
61
|
+
module Anonymizable
|
|
62
|
+
extend ActiveSupport::Concern
|
|
63
|
+
|
|
64
|
+
LABEL = "ConcernsOnRails::Models::Anonymizable".freeze
|
|
65
|
+
DEFAULT_STAMP = :anonymized_at
|
|
66
|
+
# Distinguishes "option not passed" from an explicit value, so repeat
|
|
67
|
+
# macro calls merge fields without silently resetting earlier options.
|
|
68
|
+
UNSET = Object.new
|
|
69
|
+
|
|
70
|
+
PRESETS = {
|
|
71
|
+
nullify: ->(_value) {},
|
|
72
|
+
redact: ->(value) { value.nil? ? nil : "[REDACTED]" },
|
|
73
|
+
hash: ->(value) { value.nil? ? nil : Digest::SHA256.hexdigest(value.to_s) },
|
|
74
|
+
email: ->(value) { value.nil? ? nil : "anon-#{SecureRandom.hex(10)}@anonymized.invalid" },
|
|
75
|
+
random_hex: ->(value) { value.nil? ? nil : SecureRandom.hex(16) }
|
|
76
|
+
}.freeze
|
|
77
|
+
|
|
78
|
+
included do
|
|
79
|
+
class_attribute :anonymizable_rules, instance_accessor: false, default: {}
|
|
80
|
+
class_attribute :anonymizable_stamp, instance_accessor: false, default: DEFAULT_STAMP
|
|
81
|
+
class_attribute :anonymizable_clear_audit, instance_accessor: false, default: true
|
|
82
|
+
class_attribute :anonymizable_scopes_defined, instance_accessor: false, default: false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
module ClassMethods
|
|
86
|
+
include ConcernsOnRails::Support::ColumnGuard
|
|
87
|
+
|
|
88
|
+
# Declare fields and their erasure strategy. Repeatable — field rules
|
|
89
|
+
# merge across calls; stamp:/clear_audit_trail:/prefix:/suffix: apply
|
|
90
|
+
# only when explicitly passed (last explicit value wins).
|
|
91
|
+
def anonymizable(*fields, with:, stamp: UNSET, clear_audit_trail: UNSET, prefix: nil, suffix: nil)
|
|
92
|
+
raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
|
|
93
|
+
|
|
94
|
+
strategy = anonymizable_resolve_strategy(with)
|
|
95
|
+
anonymizable_apply_options(stamp, clear_audit_trail)
|
|
96
|
+
|
|
97
|
+
ensure_columns!(LABEL, fields)
|
|
98
|
+
ensure_columns!(LABEL, anonymizable_stamp) if anonymizable_stamp
|
|
99
|
+
self.anonymizable_rules = anonymizable_rules.merge(fields.to_h { |f| [f.to_sym, strategy] })
|
|
100
|
+
|
|
101
|
+
anonymizable_define_scopes(prefix, suffix)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Anonymize every matching record that isn't already stamped, in one
|
|
105
|
+
# transaction. Returns the Integer count of records anonymized (the
|
|
106
|
+
# 1.22 batch contract). Without a stamp column every record matches.
|
|
107
|
+
def anonymize_all!
|
|
108
|
+
transaction do
|
|
109
|
+
all.to_a.count do |record|
|
|
110
|
+
next false if record.anonymized?
|
|
111
|
+
|
|
112
|
+
record.anonymize!
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def anonymizable_apply_options(stamp, clear_audit_trail)
|
|
121
|
+
# `.presence` (not `&.`): `stamp: false` must resolve to nil, and
|
|
122
|
+
# false&.to_sym would raise.
|
|
123
|
+
self.anonymizable_stamp = stamp.presence && stamp.to_sym unless stamp.equal?(UNSET)
|
|
124
|
+
return if clear_audit_trail.equal?(UNSET)
|
|
125
|
+
|
|
126
|
+
self.anonymizable_clear_audit = clear_audit_trail ? true : false
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def anonymizable_resolve_strategy(with)
|
|
130
|
+
case with
|
|
131
|
+
when Symbol
|
|
132
|
+
PRESETS.fetch(with) do
|
|
133
|
+
raise ArgumentError, "#{LABEL}: unknown preset '#{with}'. Valid presets: #{PRESETS.keys.join(', ')}"
|
|
134
|
+
end
|
|
135
|
+
else
|
|
136
|
+
raise ArgumentError, "#{LABEL}: :with must be a preset symbol or a callable, got #{with.class}" unless with.respond_to?(:call)
|
|
137
|
+
|
|
138
|
+
with
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Scopes read the class attribute lazily, so later stamp changes take
|
|
143
|
+
# effect; defined once (affixes come from the first defining call).
|
|
144
|
+
def anonymizable_define_scopes(prefix, suffix)
|
|
145
|
+
return if anonymizable_scopes_defined || anonymizable_stamp.nil?
|
|
146
|
+
|
|
147
|
+
self.anonymizable_scopes_defined = true
|
|
148
|
+
affixed = ->(base) { [prefix, base, suffix].compact.join("_") }
|
|
149
|
+
scope affixed.call("anonymized"), -> { where.not(anonymizable_stamp => nil) }
|
|
150
|
+
scope affixed.call("not_anonymized"), -> { where(anonymizable_stamp => nil) }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Lifecycle hooks — override in the model. Run inside the anonymize!
|
|
155
|
+
# transaction, so a raising hook rolls the erasure back.
|
|
156
|
+
def before_anonymize; end
|
|
157
|
+
def after_anonymize; end
|
|
158
|
+
|
|
159
|
+
# Erase the configured fields in a single UPDATE (see the module docs for
|
|
160
|
+
# why validations and callbacks are deliberately skipped). Returns true.
|
|
161
|
+
def anonymize!
|
|
162
|
+
raise ArgumentError, "#{LABEL}: anonymize! cannot be called on a new record" if new_record?
|
|
163
|
+
|
|
164
|
+
payload = anonymizable_payload
|
|
165
|
+
transaction do
|
|
166
|
+
before_anonymize
|
|
167
|
+
update_columns(payload)
|
|
168
|
+
after_anonymize
|
|
169
|
+
end
|
|
170
|
+
# update_columns leaves DB-serialized values (e.g. ciphertext) in the
|
|
171
|
+
# in-memory attributes; reload so readers decode through the types.
|
|
172
|
+
reload
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# True when the stamp column is set; always false with `stamp: false`
|
|
177
|
+
# (there is nothing to observe).
|
|
178
|
+
def anonymized?
|
|
179
|
+
stamp = self.class.anonymizable_stamp
|
|
180
|
+
stamp ? self[stamp].present? : false
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
# { column => value }: strategy output cast through the attribute's type,
|
|
186
|
+
# plus the stamp and — when an anonymized field is also audited — the
|
|
187
|
+
# cleared audit column. update_columns serializes each value through the
|
|
188
|
+
# model's attribute types (verified: Encryptable's custom type included),
|
|
189
|
+
# so an encrypted field stores a fresh ciphertext envelope — passing a
|
|
190
|
+
# pre-serialized value here would double-encrypt.
|
|
191
|
+
def anonymizable_payload
|
|
192
|
+
payload = {}
|
|
193
|
+
self.class.anonymizable_rules.each do |field, strategy|
|
|
194
|
+
value = anonymizable_apply_strategy(strategy, public_send(field))
|
|
195
|
+
payload[field] = self.class.type_for_attribute(field.to_s).cast(value)
|
|
196
|
+
end
|
|
197
|
+
stamp = self.class.anonymizable_stamp
|
|
198
|
+
payload[stamp] = Time.zone.now if stamp
|
|
199
|
+
payload[self.class.auditable_into] = nil if anonymizable_clear_audit_column?
|
|
200
|
+
payload
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def anonymizable_apply_strategy(strategy, value)
|
|
204
|
+
strategy.arity == 1 ? strategy.call(value) : strategy.call(value, self)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# The audit trail holds historical plaintext of tracked fields; when any
|
|
208
|
+
# of them is being erased, the trail must go too (see module docs).
|
|
209
|
+
def anonymizable_clear_audit_column?
|
|
210
|
+
return false unless self.class.anonymizable_clear_audit
|
|
211
|
+
return false unless self.class.respond_to?(:auditable_fields)
|
|
212
|
+
|
|
213
|
+
tracked = Array(self.class.auditable_fields).map(&:to_sym)
|
|
214
|
+
self.class.anonymizable_rules.keys.intersect?(tracked)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -74,7 +74,7 @@ module ConcernsOnRails
|
|
|
74
74
|
self.auditable_actor = actor
|
|
75
75
|
self.auditable_max_entries = max_entries
|
|
76
76
|
self.auditable_max_value_length = max_value_length
|
|
77
|
-
ensure_columns!(LABEL, into, *fields)
|
|
77
|
+
ensure_columns!(LABEL, into, *fields, types: { into => :text })
|
|
78
78
|
auditable_guard_encryptable!(fields)
|
|
79
79
|
|
|
80
80
|
before_save :auditable_capture_changes
|