active_record-undo 0.1.4 β 0.2.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/DOCUMENTATION.md +226 -92
- data/README.md +402 -131
- data/app/controllers/active_record/undo/application_controller.rb +91 -0
- data/app/controllers/active_record/undo/logs_controller.rb +87 -0
- data/app/controllers/active_record/undo/restores_controller.rb +11 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb +16 -0
- data/lib/active_record/undo/configuration.rb +10 -1
- data/lib/active_record/undo/engine.rb +14 -1
- data/lib/active_record/undo/model_extension/attribution_helper.rb +54 -0
- data/lib/active_record/undo/model_extension/tenant_verification.rb +39 -0
- data/lib/active_record/undo/model_extension.rb +61 -21
- data/lib/active_record/undo/purger/reflection_helper.rb +38 -0
- data/lib/active_record/undo/purger.rb +50 -30
- data/lib/active_record/undo/safe_redirect.rb +60 -0
- data/lib/active_record/undo/undo_log.rb +81 -2
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo/view_helpers.rb +101 -0
- data/lib/active_record/undo.rb +53 -0
- metadata +11 -1
data/README.md
CHANGED
|
@@ -3,61 +3,129 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/active_record-undo)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
|
|
6
|
-
**ActiveRecord::Undo** brings transactional, cascade-aware soft deletes
|
|
6
|
+
**ActiveRecord::Undo** brings transactional, cascade-aware soft deletes, instant single-line restores, and a mountable HTTP route engine to Ruby on Rails applications.
|
|
7
7
|
|
|
8
|
-
Unlike
|
|
8
|
+
Unlike traditional soft-deletion gems that merely flip a timestamp on a single record, `active_record-undo` creates a polymorphic audit log capturing every affected child record across `dependent: :destroy` and `dependent: :delete_all` associations. Restoring a deleted record cleanly recovers the entire deleted subtree in a single atomic database transactionβwith out-of-the-box support for multi-tenancy, user attribution, background purging, and zero-boilerplate HTTP endpoints.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Table of Contents
|
|
13
|
+
|
|
14
|
+
- [Features](#features)
|
|
15
|
+
- [Installation \& Migrations](#installation--migrations)
|
|
16
|
+
- [Database Migrations](#database-migrations)
|
|
17
|
+
- [Model Setup](#model-setup)
|
|
18
|
+
- [1. Add Timestamp Column to Tables](#1-add-timestamp-column-to-tables)
|
|
19
|
+
- [2. Declare `acts_as_undoable`](#2-declare-acts_as_undoable)
|
|
20
|
+
- [Basic Usage](#basic-usage)
|
|
21
|
+
- [Cascading Soft Deletes](#cascading-soft-deletes)
|
|
22
|
+
- [Restoring Records](#restoring-records)
|
|
23
|
+
- [Option A: Restore from the model (Recommended)](#option-a-restore-from-the-model-recommended)
|
|
24
|
+
- [Option B: Restore from the `UndoLog`](#option-b-restore-from-the-undolog)
|
|
25
|
+
- [Status \& Eligibility Helpers](#status--eligibility-helpers)
|
|
26
|
+
- [Query Scopes](#query-scopes)
|
|
27
|
+
- [Inspecting Deletion Logs](#inspecting-deletion-logs)
|
|
28
|
+
- [Mountable Route \& Controller Engine](#mountable-route--controller-engine)
|
|
29
|
+
- [Mounting the Engine](#mounting-the-engine)
|
|
30
|
+
- [Available Endpoints](#available-endpoints)
|
|
31
|
+
- [View Helpers (`undo_button_to` \& `undo_link_to`)](#view-helpers-undo_button_to--undo_link_to)
|
|
32
|
+
- [Cryptographic Signed Restore Tokens](#cryptographic-signed-restore-tokens)
|
|
33
|
+
- [Generating \& Using Signed Tokens](#generating--using-signed-tokens)
|
|
34
|
+
- [Key Security Properties](#key-security-properties)
|
|
35
|
+
- [Multi-Format Responses](#multi-format-responses)
|
|
36
|
+
- [Security \& Open-Redirect Protection](#security--open-redirect-protection)
|
|
37
|
+
- [Multi-Tenant Isolation \& User Attribution](#multi-tenant-isolation--user-attribution)
|
|
38
|
+
- [User Attribution (`whodunnit`)](#user-attribution-whodunnit)
|
|
39
|
+
- [Multi-Tenant Scoping](#multi-tenant-scoping)
|
|
40
|
+
- [Tenant Matching Security](#tenant-matching-security)
|
|
41
|
+
- [Configured Context Enforcement](#configured-context-enforcement)
|
|
42
|
+
- [Retention, Expiration \& Purging](#retention-expiration--purging)
|
|
43
|
+
- [Expiration Checks](#expiration-checks)
|
|
44
|
+
- [Purger Service](#purger-service)
|
|
45
|
+
- [ActiveJob Background Worker](#activejob-background-worker)
|
|
46
|
+
- [Rake Task](#rake-task)
|
|
47
|
+
- [Configuration Reference](#configuration-reference)
|
|
48
|
+
- [How It Works](#how-it-works)
|
|
49
|
+
- [Error Reference](#error-reference)
|
|
50
|
+
- [Development](#development)
|
|
51
|
+
- [Contributing](#contributing)
|
|
52
|
+
- [License](#license)
|
|
9
53
|
|
|
10
54
|
---
|
|
11
55
|
|
|
12
56
|
## Features
|
|
13
57
|
|
|
14
|
-
- π **Cascading Soft Deletes:** Soft deletes parent
|
|
15
|
-
- βͺ **Atomic Restores:** Reverses soft deletion for an entire object tree (`
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
58
|
+
- π **Cascading Soft Deletes:** Soft deletes parent records along with dependent associations (`dependent: :destroy` / `:delete_all`).
|
|
59
|
+
- βͺ **Atomic Restores:** Reverses soft deletion for an entire object tree (`record.restore!` or `undo_log.restore!`) within a single database transaction.
|
|
60
|
+
- π **Mountable Engine & Endpoints:** Out-of-the-box controller actions for instant undo links with HTML flash redirects, Turbo Streams, and JSON responses.
|
|
61
|
+
- π **Cryptographic Signed Links:** Direct undo restoration tokens (`undo_log.signed_token`) preventing ID enumeration on public or email links.
|
|
62
|
+
- π¨ **Clean View Helpers:** Drop-in `undo_button_to` and `undo_link_to` view helpers for seamless UI integration.
|
|
63
|
+
- π **Restoration Verification (`#undoable?`):** Instantly check if a soft-deleted record is eligible for restore before rendering UI buttons.
|
|
64
|
+
- π€ **User Attribution (`whodunnit`):** Tracks who initiated soft deletes and restores automatically via ambient context or explicit parameters.
|
|
65
|
+
- π’ **Multi-Tenant Isolation:** Scopes deletion logs to specific tenants with strict tenant-matching security.
|
|
66
|
+
- βοΈ **Custom Soft-Delete Columns:** Supports custom columns (e.g., `:archived_at`, `:discarded_at`) per model while defaulting to `:deleted_at`.
|
|
67
|
+
- π§Ή **Retention & Auto-Purging:** Built-in expiration scopes, batch SQL purger, ActiveJob worker, and Rake task with foreign-key constraint protection.
|
|
68
|
+
- π **Zero Generator Setup:** Built as a `Rails::Engine`. Migrations automatically hook into `rails db:migrate`.
|
|
22
69
|
|
|
23
70
|
---
|
|
24
71
|
|
|
25
|
-
## Installation
|
|
72
|
+
## Installation & Migrations
|
|
26
73
|
|
|
27
|
-
Add
|
|
74
|
+
Add the gem to your application's `Gemfile`:
|
|
28
75
|
|
|
29
76
|
```ruby
|
|
30
77
|
gem "active_record-undo"
|
|
31
78
|
```
|
|
32
79
|
|
|
33
|
-
Then
|
|
80
|
+
Then install dependencies:
|
|
34
81
|
|
|
35
82
|
```bash
|
|
36
|
-
|
|
83
|
+
bundle install
|
|
37
84
|
```
|
|
38
85
|
|
|
39
|
-
|
|
86
|
+
### Database Migrations
|
|
87
|
+
|
|
88
|
+
`ActiveRecord::Undo` automatically appends its migrations (`undo_logs` and `undo_log_items`) to your application's migration path:
|
|
40
89
|
|
|
41
90
|
```bash
|
|
42
|
-
|
|
91
|
+
rails db:migrate
|
|
43
92
|
```
|
|
44
93
|
|
|
45
|
-
*(Optional)* If you
|
|
94
|
+
*(Optional)* If you plan to use **Multi-Tenant Isolation** or **User Attribution**, add the polymorphic columns to `undo_logs`:
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
# db/migrate/XXXXXX_add_tenant_and_whodunnit_to_undo_logs.rb
|
|
98
|
+
class AddTenantAndWhodunnitToUndoLogs < ActiveRecord::Migration[7.0]
|
|
99
|
+
def change
|
|
100
|
+
change_table :undo_logs, bulk: true do |t|
|
|
101
|
+
t.string :whodunnit_type, null: true
|
|
102
|
+
t.bigint :whodunnit_id, null: true
|
|
103
|
+
t.string :tenant_type, null: true
|
|
104
|
+
t.bigint :tenant_id, null: true
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
add_index :undo_logs, [:whodunnit_type, :whodunnit_id], name: "index_undo_logs_on_whodunnit"
|
|
108
|
+
add_index :undo_logs, [:tenant_type, :tenant_id], name: "index_undo_logs_on_tenant"
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
*(Optional)* If you wish to customize the gem's core migration directly:
|
|
46
114
|
|
|
47
115
|
```bash
|
|
48
|
-
|
|
116
|
+
rails active_record_undo:install:migrations
|
|
49
117
|
```
|
|
50
118
|
|
|
51
119
|
---
|
|
52
120
|
|
|
53
|
-
##
|
|
121
|
+
## Model Setup
|
|
54
122
|
|
|
55
|
-
### 1.
|
|
123
|
+
### 1. Add Timestamp Column to Tables
|
|
56
124
|
|
|
57
|
-
Ensure
|
|
125
|
+
Ensure every model using soft deletion has a timestamp column:
|
|
58
126
|
|
|
59
127
|
```ruby
|
|
60
|
-
class
|
|
128
|
+
class AddDeletedAtToModels < ActiveRecord::Migration[7.0]
|
|
61
129
|
def change
|
|
62
130
|
add_column :posts, :deleted_at, :datetime
|
|
63
131
|
add_column :comments, :deleted_at, :datetime
|
|
@@ -70,244 +138,447 @@ class AddSoftDeleteColumnsToModels < ActiveRecord::Migration[7.0]
|
|
|
70
138
|
end
|
|
71
139
|
```
|
|
72
140
|
|
|
73
|
-
### 2.
|
|
141
|
+
### 2. Declare `acts_as_undoable`
|
|
74
142
|
|
|
75
|
-
Add `acts_as_undoable` to models
|
|
143
|
+
Add `acts_as_undoable` to your models. By default, it uses the `:deleted_at` column:
|
|
76
144
|
|
|
77
145
|
```ruby
|
|
78
146
|
class Post < ApplicationRecord
|
|
79
|
-
# Uses default :deleted_at column
|
|
80
147
|
acts_as_undoable
|
|
81
148
|
|
|
82
149
|
has_many :comments, dependent: :destroy
|
|
83
150
|
end
|
|
84
151
|
|
|
85
152
|
class Comment < ApplicationRecord
|
|
86
|
-
# Uses default :deleted_at column
|
|
87
153
|
acts_as_undoable
|
|
88
154
|
|
|
89
155
|
belongs_to :post
|
|
90
156
|
end
|
|
91
157
|
|
|
92
158
|
class ArchiveItem < ApplicationRecord
|
|
93
|
-
# Configured with a custom column
|
|
159
|
+
# Configured with a custom timestamp column
|
|
94
160
|
acts_as_undoable column: :archived_at
|
|
95
161
|
end
|
|
96
162
|
```
|
|
97
163
|
|
|
98
164
|
---
|
|
99
165
|
|
|
100
|
-
##
|
|
166
|
+
## Basic Usage
|
|
101
167
|
|
|
102
|
-
### Soft
|
|
168
|
+
### Cascading Soft Deletes
|
|
103
169
|
|
|
104
|
-
Call `soft_delete!` on
|
|
170
|
+
Call `soft_delete!` on any undoable record. It updates the timestamp column across the record and all dependent associations inside a single database transaction, returning an `ActiveRecord::Undo::UndoLog` instance:
|
|
105
171
|
|
|
106
172
|
```ruby
|
|
107
173
|
post = Post.find(1)
|
|
108
174
|
|
|
109
|
-
# Soft deletes post and all associated comments
|
|
175
|
+
# Soft deletes post and cascades to all associated comments
|
|
110
176
|
undo_log = post.soft_delete!
|
|
111
177
|
|
|
112
|
-
post.soft_deleted?
|
|
178
|
+
post.soft_deleted? # => true
|
|
113
179
|
post.comments.kept.count # => 0
|
|
114
180
|
```
|
|
115
181
|
|
|
116
|
-
###
|
|
182
|
+
### Restoring Records
|
|
183
|
+
|
|
184
|
+
You can restore an entire deleted tree either from the model instance or from the `UndoLog`:
|
|
185
|
+
|
|
186
|
+
#### Option A: Restore from the model (Recommended)
|
|
187
|
+
|
|
188
|
+
Calling `restore!` directly on the model automatically looks up its latest deletion log, executes the atomic restoration, destroys the log records, and reloads the model instance:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
post.restore!
|
|
192
|
+
|
|
193
|
+
post.soft_deleted? # => false
|
|
194
|
+
post.comments.count # => 2
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
#### Option B: Restore from the `UndoLog`
|
|
117
198
|
|
|
118
199
|
```ruby
|
|
119
|
-
|
|
120
|
-
item.soft_delete!
|
|
200
|
+
undo_log.restore!
|
|
121
201
|
|
|
122
|
-
|
|
123
|
-
item.archived_at # => 2026-08-08 22:20:16 UTC
|
|
202
|
+
post.reload.soft_deleted? # => false
|
|
124
203
|
```
|
|
125
204
|
|
|
126
|
-
###
|
|
205
|
+
### Status & Eligibility Helpers
|
|
127
206
|
|
|
128
|
-
|
|
207
|
+
`ActiveRecord::Undo` provides fast predicate methods and audit helpers on models:
|
|
129
208
|
|
|
130
209
|
```ruby
|
|
131
|
-
|
|
210
|
+
# Checks if the soft-delete column is set
|
|
211
|
+
post.soft_deleted? # => true / false
|
|
132
212
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
213
|
+
# Verifies record is soft-deleted AND has a valid undo log available in the database
|
|
214
|
+
post.undoable? # => true / false
|
|
215
|
+
|
|
216
|
+
# Checks if the soft-deleted record has exceeded the configured retention period
|
|
217
|
+
post.expired? # => true / false
|
|
218
|
+
|
|
219
|
+
# Retrieves the latest UndoLog audit record associated with this model
|
|
220
|
+
post.undo_log # => #<ActiveRecord::Undo::UndoLog id: 14, ...>
|
|
221
|
+
post.latest_undo_log # alias
|
|
222
|
+
|
|
223
|
+
# Generates a cryptographic signed restoration token directly from the model
|
|
224
|
+
post.signed_token # => "eyJfcmFpbHMiOnsiZGF0YSI6..."
|
|
137
225
|
```
|
|
138
226
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
227
|
+
> [!TIP]
|
|
228
|
+
> Use `#undoable?` to conditionally display restore buttons in your user interface, passing the model instance directly:
|
|
229
|
+
>
|
|
230
|
+
> ```erb
|
|
231
|
+
> <% if post.undoable? %>
|
|
232
|
+
> <%= undo_button_to(post, text: "Restore") %>
|
|
233
|
+
> <% end %>
|
|
234
|
+
> ```
|
|
143
235
|
|
|
144
|
-
###
|
|
236
|
+
### Query Scopes
|
|
145
237
|
|
|
146
|
-
|
|
238
|
+
Filter records easily without writing manual SQL:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
# Only active (non-deleted) records
|
|
242
|
+
Post.kept
|
|
243
|
+
|
|
244
|
+
# Only soft-deleted records
|
|
245
|
+
Post.soft_deleted
|
|
246
|
+
|
|
247
|
+
# Soft-deleted records past the retention limit
|
|
248
|
+
Post.expired
|
|
249
|
+
|
|
250
|
+
# Retrieve all records including soft-deleted ones
|
|
251
|
+
Post.unscoped.all
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Inspecting Deletion Logs
|
|
255
|
+
|
|
256
|
+
Inspect affected records through standard Rails associations on `UndoLog`:
|
|
147
257
|
|
|
148
258
|
```ruby
|
|
149
|
-
# List all items affected by this deletion event
|
|
150
259
|
undo_log.undo_log_items.map(&:item)
|
|
151
|
-
# => [#<Comment 101
|
|
260
|
+
# => [#<Comment id: 101>, #<Comment id: 102>, #<Post id: 1>]
|
|
152
261
|
```
|
|
153
262
|
|
|
154
|
-
|
|
263
|
+
---
|
|
155
264
|
|
|
156
|
-
|
|
265
|
+
## Mountable Route & Controller Engine
|
|
157
266
|
|
|
158
|
-
|
|
159
|
-
Calling `restore!` directly on the soft-deleted model automatically resolves its latest deletion event log, performs the cascading restore, and cleans up the log database rows:
|
|
267
|
+
`active_record-undo` provides a mountable Rails engine route and controller endpoints so host applications can handle undo/restore actions via HTTP requests without writing boilerplate controller logic.
|
|
160
268
|
|
|
161
|
-
|
|
162
|
-
# Restores the post and all comments deleted in the same batch (automatically reloads in-memory)
|
|
163
|
-
post.restore!
|
|
269
|
+
### Mounting the Engine
|
|
164
270
|
|
|
165
|
-
|
|
166
|
-
post.comments.count # => 2
|
|
167
|
-
```
|
|
271
|
+
Mount the engine in your application routes:
|
|
168
272
|
|
|
169
|
-
#### Option B: Restore from the `UndoLog`
|
|
170
273
|
```ruby
|
|
171
|
-
#
|
|
172
|
-
|
|
274
|
+
# config/routes.rb
|
|
275
|
+
Rails.application.routes.draw do
|
|
276
|
+
mount ActiveRecord::Undo::Engine => "/undo"
|
|
277
|
+
end
|
|
278
|
+
```
|
|
173
279
|
|
|
174
|
-
|
|
175
|
-
|
|
280
|
+
### Available Endpoints
|
|
281
|
+
|
|
282
|
+
| Method | Route | Controller#Action | Description |
|
|
283
|
+
| :----- | :----------------------- | :----------------------------------- | :----------------------------------------- |
|
|
284
|
+
| `POST` | `/undo/logs/:id/restore` | `active_record/undo/logs#restore` | Restores an `UndoLog` by ID |
|
|
285
|
+
| `POST` | `/undo/restore/:token` | `active_record/undo/restores#create` | Restores an `UndoLog` using a signed token |
|
|
286
|
+
|
|
287
|
+
### View Helpers (`undo_button_to` & `undo_link_to`)
|
|
288
|
+
|
|
289
|
+
Clean view helpers are automatically available in all Rails views and forms:
|
|
290
|
+
|
|
291
|
+
```erb
|
|
292
|
+
<%# Standard button_to targeting /undo/logs/:id/restore %>
|
|
293
|
+
<%= undo_button_to(@undo_log) %>
|
|
294
|
+
|
|
295
|
+
<%# Pass model instances directly %>
|
|
296
|
+
<%= undo_button_to(post, text: "Undo Delete", class: "btn btn-primary") %>
|
|
297
|
+
|
|
298
|
+
<%# Secure direct link with signed token (prevents ID enumeration in emails or public links) %>
|
|
299
|
+
<%= undo_button_to(@undo_log, signed: true, class: "btn btn-success") %>
|
|
300
|
+
|
|
301
|
+
<%# Turbo Stream / Hotwire compatible link %>
|
|
302
|
+
<%= undo_link_to(@undo_log, text: "Undo", class: "text-decoration-underline") %>
|
|
303
|
+
<%= undo_link_to(@undo_log, signed: true) %>
|
|
176
304
|
```
|
|
177
305
|
|
|
178
|
-
|
|
306
|
+
### Cryptographic Signed Restore Tokens
|
|
307
|
+
|
|
308
|
+
When exposing restore actions in flash notifications, transactional emails, webhook alerts, or public interfaces, relying on sequential database IDs (e.g., `POST /undo/logs/42/restore`) can expose your application to ID enumeration attacks.
|
|
309
|
+
|
|
310
|
+
`active_record-undo` provides signed tokens for restoration via tamper-proof, opaque URLs: `POST /undo/restore/:token`.
|
|
311
|
+
|
|
312
|
+
#### Generating & Using Signed Tokens
|
|
179
313
|
|
|
180
|
-
|
|
314
|
+
**In Views (using helpers):**
|
|
181
315
|
|
|
182
|
-
|
|
316
|
+
```erb
|
|
317
|
+
<%# Renders a form POST to /undo/restore/:token %>
|
|
318
|
+
<%= undo_button_to(post, signed: true, text: "Undo Delete", class: "btn btn-outline-primary") %>
|
|
319
|
+
|
|
320
|
+
<%# Renders an anchor tag for Turbo / Hotwire %>
|
|
321
|
+
<%= undo_link_to(post, signed: true, text: "Undo") %>
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
**In Controllers, Background Jobs & Mailers:**
|
|
183
325
|
|
|
184
326
|
```ruby
|
|
185
|
-
#
|
|
186
|
-
|
|
327
|
+
# Generate token directly from the model instance
|
|
328
|
+
token = post.signed_token
|
|
187
329
|
|
|
188
|
-
#
|
|
189
|
-
|
|
330
|
+
# Or with a custom expiration window
|
|
331
|
+
token = post.signed_token(expires_in: 2.hours)
|
|
332
|
+
|
|
333
|
+
# Or directly from an UndoLog instance
|
|
334
|
+
token = undo_log.signed_token
|
|
190
335
|
|
|
191
|
-
#
|
|
192
|
-
|
|
336
|
+
# Construct full URL for transactional emails or Slack webhooks
|
|
337
|
+
restore_url = active_record_undo.signed_restore_url(token: token)
|
|
338
|
+
```
|
|
193
339
|
|
|
194
|
-
|
|
195
|
-
post.undoable?
|
|
340
|
+
**Manual Verification & Retrieval:**
|
|
196
341
|
|
|
197
|
-
|
|
198
|
-
|
|
342
|
+
```ruby
|
|
343
|
+
# Manually verify and retrieve the associated UndoLog
|
|
344
|
+
undo_log = ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
|
|
199
345
|
```
|
|
200
346
|
|
|
347
|
+
#### Key Security Properties
|
|
348
|
+
|
|
349
|
+
1. **HMAC-SHA256 Cryptographic Tamper Resistance:** Tokens are signed using `Rails.application.message_verifier(:active_record_undo)` derived securely from your application's `secret_key_base`. Any payload alteration invalidates the cryptographic signature.
|
|
350
|
+
2. **Purpose Isolation (`purpose: :restore`):** Tokens are strictly scoped to restoration. Tokens generated for other purposes (or by other verifiers) are rejected.
|
|
351
|
+
3. **Time-Limited Lifespan:** Tokens include an embedded expiration timestamp (configurable via `config.token_expires_in`, defaults to `24.hours`). Expired tokens are rejected automatically.
|
|
352
|
+
4. **Single-Use Replay Protection:** When `undo_log.restore!` succeeds, it automatically destroys the `UndoLog` and its associated items from the database (`destroy!`). Even if an attacker or user resubmits the signed token before its cryptographic expiration, the database lookup fails and returns `nil`, rendering a `404 Not Found`.
|
|
353
|
+
5. **Multi-Tenant Authorization:** Cryptographic validity only proves the token was authentic. During execution, `undo_log.restore!` still enforces multi-tenant boundary matching against `ActiveRecord::Undo.current_tenant`. An authenticated user from Tenant B cannot use a token from Tenant A to restore data.
|
|
354
|
+
|
|
355
|
+
### Multi-Format Responses
|
|
356
|
+
|
|
357
|
+
The engine controller seamlessly handles multiple response formats:
|
|
358
|
+
|
|
359
|
+
- **HTML:** Redirects to `params[:redirect_to]` (if a validated safe URL), `request.referer`, or `config.default_redirect_path` with a flash notice (`flash[:notice] = "Record successfully restored."`).
|
|
360
|
+
- **Turbo Stream (`text/vnd.turbo-stream.html`):** Renders inline `<turbo-stream>` notification elements with HTTP status `200 OK`.
|
|
361
|
+
- **JSON:** Returns `{ "success": true, "restored_items_count": count }` with HTTP status `200 OK`.
|
|
362
|
+
|
|
363
|
+
In error scenarios (missing record, expired action, or security mismatch):
|
|
364
|
+
|
|
365
|
+
- **`403 Forbidden`:** Rendered on `ActiveRecord::Undo::SecurityError` (or HTML redirected with `flash[:alert]`).
|
|
366
|
+
- **`404 Not Found`:** Rendered when the undo log does not exist or has already been restored.
|
|
367
|
+
- **`422 Unprocessable Content`:** Rendered when the undo action has expired beyond the retention period.
|
|
368
|
+
|
|
369
|
+
### Security & Open-Redirect Protection
|
|
370
|
+
|
|
371
|
+
- **CSRF Protection:** Inherits from `ActionController::Base` (or your configured `base_controller`) with CSRF verification enabled.
|
|
372
|
+
- **Open-Redirect Mitigation:** `params[:redirect_to]` and `request.referer` undergo strict URL validation. Only relative paths or URLs matching the request's exact host and port are accepted; foreign or protocol-relative URLs (e.g., `//evil.com`) are rejected in favor of the safe fallback.
|
|
373
|
+
- **Signed Tokens:** `undo_log.signed_token` uses Rails' `message_verifier(:active_record_undo)` to sign tokens cryptographically, preventing tampering, replay, and ID enumeration.
|
|
374
|
+
|
|
201
375
|
---
|
|
202
376
|
|
|
203
|
-
##
|
|
377
|
+
## Multi-Tenant Isolation & User Attribution
|
|
204
378
|
|
|
205
|
-
To
|
|
379
|
+
To support enterprise-grade Rails applications, `active_record-undo` natively integrates user auditing and tenant scoping.
|
|
206
380
|
|
|
207
|
-
###
|
|
381
|
+
### User Attribution (`whodunnit`)
|
|
208
382
|
|
|
209
|
-
|
|
383
|
+
Track which user initiated a soft deletion or restoration:
|
|
210
384
|
|
|
211
385
|
```ruby
|
|
212
|
-
#
|
|
386
|
+
# 1. Via explicit parameter
|
|
387
|
+
post.soft_delete!(whodunnit: current_user)
|
|
388
|
+
post.restore!(whodunnit: current_user)
|
|
389
|
+
|
|
390
|
+
# 2. Via Thread / Fiber ambient context
|
|
391
|
+
ActiveRecord::Undo.whodunnit = current_user
|
|
392
|
+
post.soft_delete!
|
|
393
|
+
|
|
394
|
+
# 3. Via global callable proc
|
|
213
395
|
ActiveRecord::Undo.configure do |config|
|
|
214
|
-
|
|
215
|
-
config.retention_period = 30.days
|
|
396
|
+
config.current_user_method = -> { Current.user }
|
|
216
397
|
end
|
|
217
398
|
```
|
|
218
399
|
|
|
219
|
-
|
|
400
|
+
### Multi-Tenant Scoping
|
|
220
401
|
|
|
221
|
-
|
|
402
|
+
Scope soft deletes and logs to accounts or organizations:
|
|
222
403
|
|
|
223
|
-
|
|
404
|
+
```ruby
|
|
405
|
+
# 1. Via explicit parameter
|
|
406
|
+
post.soft_delete!(tenant: current_account)
|
|
407
|
+
|
|
408
|
+
# 2. Via Thread / Fiber ambient context
|
|
409
|
+
ActiveRecord::Undo.current_tenant = current_account
|
|
410
|
+
post.soft_delete!
|
|
224
411
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
```ruby
|
|
231
|
-
post.expired? # => true/false
|
|
232
|
-
```
|
|
233
|
-
- **`UndoLog.expired` Scope**: Returns undo log entries older than the retention period.
|
|
234
|
-
```ruby
|
|
235
|
-
ActiveRecord::Undo::UndoLog.expired # => logs created > 30 days ago
|
|
236
|
-
```
|
|
412
|
+
# 3. Via global callable proc
|
|
413
|
+
ActiveRecord::Undo.configure do |config|
|
|
414
|
+
config.current_tenant_method = -> { Current.account }
|
|
415
|
+
end
|
|
416
|
+
```
|
|
237
417
|
|
|
238
|
-
###
|
|
418
|
+
### Tenant Matching Security
|
|
239
419
|
|
|
240
|
-
|
|
241
|
-
The `ActiveRecord::Undo::Purger` class performs hard SQL deletes on expired records and logs using `delete_all` (bypassing callbacks and validations for efficiency):
|
|
420
|
+
When restoring a log belonging to a tenant, the gem verifies that the executing context's tenant matches the log's tenant. If there is a mismatch or missing tenant context, an `ActiveRecord::Undo::SecurityError` is raised:
|
|
242
421
|
|
|
243
422
|
```ruby
|
|
244
|
-
|
|
423
|
+
ActiveRecord::Undo.current_tenant = wrong_account
|
|
424
|
+
post.restore! # => raises ActiveRecord::Undo::SecurityError (Tenant mismatch)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### Configured Context Enforcement
|
|
428
|
+
|
|
429
|
+
When `current_user_method` or `current_tenant_method` is configured via `ActiveRecord::Undo.configure`, operations ensure the context does not evaluate to `nil`:
|
|
430
|
+
|
|
431
|
+
```ruby
|
|
432
|
+
# If current_user_method evaluates to nil (e.g., unauthenticated request):
|
|
433
|
+
post.soft_delete! # => raises ActiveRecord::Undo::SecurityError: Configured current_user_method returned nil.
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
*(If neither method is configured, operations proceed normally without requiring user or tenant context).*
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Retention, Expiration & Purging
|
|
441
|
+
|
|
442
|
+
To keep your database lean, `active_record-undo` provides retention management and automated background purging.
|
|
443
|
+
|
|
444
|
+
### Expiration Checks
|
|
445
|
+
|
|
446
|
+
Query and check expired records using the configured retention window (default: `30.days`):
|
|
447
|
+
|
|
448
|
+
```ruby
|
|
449
|
+
# Scopes
|
|
450
|
+
Post.expired # Soft-deleted posts older than retention period
|
|
451
|
+
ActiveRecord::Undo::UndoLog.expired # Undo logs older than retention period
|
|
452
|
+
|
|
453
|
+
# Predicates
|
|
454
|
+
post.expired? # => true / false
|
|
455
|
+
undo_log.expired? # => true / false
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Purger Service
|
|
459
|
+
|
|
460
|
+
`ActiveRecord::Undo::Purger` performs batch SQL deletes bypassing model callbacks for peak efficiency:
|
|
461
|
+
|
|
462
|
+
```ruby
|
|
463
|
+
# Purge all expired records and logs across registered models
|
|
245
464
|
ActiveRecord::Undo::Purger.purge_expired!(batch_size: 1000)
|
|
465
|
+
|
|
466
|
+
# Scoped purge for a specific tenant
|
|
467
|
+
ActiveRecord::Undo.current_tenant = account_1
|
|
468
|
+
ActiveRecord::Undo::Purger.purge_expired!
|
|
246
469
|
```
|
|
247
470
|
|
|
248
471
|
> [!NOTE]
|
|
249
|
-
> **Relational Integrity
|
|
472
|
+
> **Relational Integrity Protection:** To prevent foreign-key constraint violations (`FOREIGN KEY constraint failed`), `Purger` dynamically resolves dependent associations (`:destroy`, `:delete_all`, `:soft_delete`) and recursively cleans up child records bottom-up before purging parent records. For `:nullify` associations, foreign keys are nullified (or cascaded if restricted by a `NOT NULL` constraint).
|
|
473
|
+
|
|
474
|
+
### ActiveJob Background Worker
|
|
250
475
|
|
|
251
|
-
|
|
252
|
-
The gem provides an ActiveJob class that calls the Purger service:
|
|
476
|
+
Enqueue purges easily via ActiveJob:
|
|
253
477
|
|
|
254
478
|
```ruby
|
|
255
|
-
# Enqueue the purge job to run in the background
|
|
256
479
|
ActiveRecord::Undo::PurgeJob.perform_later(batch_size: 1000)
|
|
257
480
|
```
|
|
258
481
|
|
|
259
|
-
|
|
260
|
-
|
|
482
|
+
### Rake Task
|
|
483
|
+
|
|
484
|
+
Run purging from cron, cron-like schedulers, or CI:
|
|
261
485
|
|
|
262
486
|
```bash
|
|
263
|
-
#
|
|
264
|
-
|
|
487
|
+
# Default batch size (1000)
|
|
488
|
+
rails active_record_undo:purge_expired
|
|
265
489
|
|
|
266
|
-
#
|
|
267
|
-
|
|
490
|
+
# Custom batch size
|
|
491
|
+
BATCH_SIZE=500 rails active_record_undo:purge_expired
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
496
|
+
## Configuration Reference
|
|
497
|
+
|
|
498
|
+
Configure all gem options in a single initializer:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
# config/initializers/active_record_undo.rb
|
|
502
|
+
ActiveRecord::Undo.configure do |config|
|
|
503
|
+
# Default retention period for soft-deleted records and undo logs (defaults to 30.days)
|
|
504
|
+
# Set to nil to disable expiration
|
|
505
|
+
config.retention_period = 30.days
|
|
506
|
+
|
|
507
|
+
# Callable procs to resolve ambient user and tenant context (defaults to nil)
|
|
508
|
+
config.current_user_method = -> { Current.user }
|
|
509
|
+
config.current_tenant_method = -> { Current.account }
|
|
510
|
+
|
|
511
|
+
# Base controller for engine authentication and authorization hooks
|
|
512
|
+
# Defaults to "::ApplicationController" if defined, falling back to ActionController::Base
|
|
513
|
+
config.base_controller = "::ApplicationController"
|
|
514
|
+
|
|
515
|
+
# Fallback redirect path after successful restore when no redirect_to or referer exists
|
|
516
|
+
config.default_redirect_path = ->(main_app) { main_app.root_path }
|
|
517
|
+
|
|
518
|
+
# Expiration duration for signed restore tokens (defaults to 24.hours)
|
|
519
|
+
config.token_expires_in = 24.hours
|
|
520
|
+
|
|
521
|
+
# Custom secret key for signed tokens (defaults to nil, utilizing Rails application verifier)
|
|
522
|
+
config.token_secret_key = nil
|
|
523
|
+
|
|
524
|
+
# Error handling strategy for HTML requests (:auto, :redirect, or :render)
|
|
525
|
+
# Defaults to :auto (redirects with flash alert if referer/redirect_to present, else renders status)
|
|
526
|
+
config.error_handling = :auto
|
|
527
|
+
end
|
|
268
528
|
```
|
|
269
529
|
|
|
270
530
|
---
|
|
271
531
|
|
|
272
532
|
## How It Works
|
|
273
533
|
|
|
274
|
-
1. **Cascade Inspection:**
|
|
275
|
-
2. **
|
|
276
|
-
3. **
|
|
277
|
-
4. **
|
|
278
|
-
5. **
|
|
534
|
+
1. **Cascade Inspection:** On calling `soft_delete!`, `ActiveRecord::Undo::CascadeHandler` reflects on `has_many`, `has_one`, and `belongs_to` associations configured with `dependent: :destroy` or `:delete_all`.
|
|
535
|
+
2. **Column Resolution:** Identifies `record.class.undoable_column` to apply the correct timestamp column (`:deleted_at`, `:archived_at`, etc.) across all affected models.
|
|
536
|
+
3. **Audit Logging:** Creates an `UndoLog` and individual `UndoLogItem` entries storing polymorphic references (`item_type`, `item_id`) for every soft-deleted record.
|
|
537
|
+
4. **Atomic Operation:** Deletions and log creations execute within a single `ActiveRecord::Base.transaction`.
|
|
538
|
+
5. **Reverse Restoration:** Calling `restore!` executes within a transaction, iterating over items in reverse order (`reverse_each`) so parent and dependent records are re-activated in the proper sequence before purging the log.
|
|
279
539
|
|
|
280
540
|
---
|
|
281
541
|
|
|
282
|
-
## Error
|
|
542
|
+
## Error Reference
|
|
283
543
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
544
|
+
| Error Class | Trigger Scenario |
|
|
545
|
+
| :---------------------------------- | :---------------------------------------------------------------------------- |
|
|
546
|
+
| `ActiveRecord::Undo::Error` | The configured soft-delete column does not exist on the table. |
|
|
547
|
+
| `ActiveRecord::Undo::Error` | A model class referenced by an `UndoLogItem` was renamed or deleted. |
|
|
548
|
+
| `ActiveRecord::Undo::SecurityError` | Attempted restore where the context's tenant does not match the log's tenant. |
|
|
549
|
+
| `ActiveRecord::Undo::SecurityError` | Attempted restore of a tenant-scoped log when no tenant is set in context. |
|
|
550
|
+
| `ActiveRecord::Undo::SecurityError` | Configured `current_user_method` or `current_tenant_method` returned `nil`. |
|
|
288
551
|
|
|
289
552
|
---
|
|
290
553
|
|
|
291
554
|
## Development
|
|
292
555
|
|
|
293
|
-
|
|
556
|
+
Clone the repository and install dependencies:
|
|
294
557
|
|
|
295
558
|
```bash
|
|
296
|
-
|
|
559
|
+
git clone https://github.com/saurabh-activecode/active_record-undo.git
|
|
560
|
+
cd active_record-undo
|
|
561
|
+
bundle install
|
|
297
562
|
```
|
|
298
563
|
|
|
299
564
|
Run test suite via RSpec:
|
|
300
565
|
|
|
301
566
|
```bash
|
|
302
|
-
|
|
567
|
+
bundle exec rspec
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
Run RuboCop linting:
|
|
571
|
+
|
|
572
|
+
```bash
|
|
573
|
+
bundle exec rubocop
|
|
303
574
|
```
|
|
304
575
|
|
|
305
576
|
---
|
|
306
577
|
|
|
307
578
|
## Contributing
|
|
308
579
|
|
|
309
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/saurabh-activecode/active_record-undo.
|
|
580
|
+
Bug reports and pull requests are welcome on GitHub at [https://github.com/saurabh-activecode/active_record-undo](https://github.com/saurabh-activecode/active_record-undo).
|
|
310
581
|
|
|
311
582
|
## License
|
|
312
583
|
|
|
313
|
-
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
584
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|