sealed_trail 0.1.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 +7 -0
- data/CHANGELOG.md +57 -0
- data/LICENSE +21 -0
- data/README.md +457 -0
- data/lib/generators/sealed_trail/install/install_generator.rb +48 -0
- data/lib/generators/sealed_trail/install/templates/create_sealed_trail_entries.rb.tt +21 -0
- data/lib/generators/sealed_trail/install/templates/sealed_trail.rb.tt +71 -0
- data/lib/sealed_trail/bulk.rb +177 -0
- data/lib/sealed_trail/configuration.rb +139 -0
- data/lib/sealed_trail/controller.rb +49 -0
- data/lib/sealed_trail/current.rb +27 -0
- data/lib/sealed_trail/entry.rb +127 -0
- data/lib/sealed_trail/job_context.rb +76 -0
- data/lib/sealed_trail/model.rb +124 -0
- data/lib/sealed_trail/railtie.rb +38 -0
- data/lib/sealed_trail/rake_context.rb +25 -0
- data/lib/sealed_trail/recorder.rb +95 -0
- data/lib/sealed_trail/redactor.rb +92 -0
- data/lib/sealed_trail/version.rb +5 -0
- data/lib/sealed_trail/writer.rb +79 -0
- data/lib/sealed_trail.rb +112 -0
- metadata +112 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ced6357d5aef1fce05614a3c33a0dca712309fd190ccf9f63fbeb6375f1b8058
|
|
4
|
+
data.tar.gz: 7a5589704e24f4aba4359ba805871da0e51c16276850a371497d2e15e5a06223
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 123162a76b42bfd9fffc46e01b4f8ed3a25871dadeb207d2dbbb022d9fb28e31fd9642dc2a8159e1f9247a9b8002c7e4c157a44462a9e6fb0274018973d45c02
|
|
7
|
+
data.tar.gz: 525d6bd6f3ac4a9ea5cda4897569a3b8b588f9007ce8235b36e03b407c81a70c7cdb6a1b7a27cc5053d5ab4020febb007b0cbc64cffc54d3b54d6690773f25fa
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-10-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `has_sealed_trail` model macro recording create, update and destroy events
|
|
14
|
+
with before/after values (`object_changes`), written in the same transaction
|
|
15
|
+
as the change.
|
|
16
|
+
- Options `only:`, `ignore:`, `redact:`, `on:` (including opt-in `:touch`),
|
|
17
|
+
`meta:`, `bulk:`, `if:` and `unless:`.
|
|
18
|
+
- Failure handling: `config.on_failure` = `:log` (default; the entry is written
|
|
19
|
+
in a savepoint, failures are logged, instrumented as
|
|
20
|
+
`write_failed.sealed_trail` and passed to `config.error_handler`, and the
|
|
21
|
+
user's save succeeds), `:raise` (strict) or a callable.
|
|
22
|
+
- Redaction by default of password, token, secret, API/private key, OTP, salt,
|
|
23
|
+
`encrypted_*`, email, `*_email`, phone, `*_phone` and mobile attributes, plus
|
|
24
|
+
attributes declared with `encrypts`.
|
|
25
|
+
- Redaction strategies `:redact` (`"[REDACTED]"`), `:fingerprint` (keyed
|
|
26
|
+
HMAC-SHA256, `"hmac:<hex>"`), `:mask` (`"j***@example.com"`,
|
|
27
|
+
`"******1234"`), callables, and `false` to opt out of a global rule; set per
|
|
28
|
+
attribute (`redact: { email: :fingerprint }`) or globally
|
|
29
|
+
(`config.redaction_strategy`). `SealedTrail.fingerprint` and
|
|
30
|
+
`Entry.with_fingerprint` look values up.
|
|
31
|
+
- Actor tracking (Active Record object or label) through a controller concern
|
|
32
|
+
resolved lazily per request, `SealedTrail.with_actor` and
|
|
33
|
+
`SealedTrail::Current.actor`, with fallbacks: `"anonymous"` in requests,
|
|
34
|
+
`"job:<JobClass>"`, `"rake:<task>"`, `"console"`, then
|
|
35
|
+
`config.default_actor` (`"system"`).
|
|
36
|
+
- Active Job integration: jobs inherit the enqueuing actor (GlobalID or
|
|
37
|
+
label), request id, remote IP and metadata.
|
|
38
|
+
- Request id and remote IP captured automatically in controllers.
|
|
39
|
+
- Recording of callback-skipping methods: `update_columns`/`update_column` and
|
|
40
|
+
`delete` per record; `update_all`, `delete_all`, `insert_all`, `insert_all!`
|
|
41
|
+
and `upsert_all` as one bulk summary entry each, with affected ids (capped by
|
|
42
|
+
`config.bulk_id_limit`), row count and masked WHERE/SET SQL.
|
|
43
|
+
- `SealedTrail.without_tracking`, `SealedTrail.with_tracking` and
|
|
44
|
+
`SealedTrail.with_metadata` blocks, isolated per thread/fiber via
|
|
45
|
+
`ActiveSupport::CurrentAttributes`.
|
|
46
|
+
- `SealedTrail::Entry` query scopes: `for_actor`, `for_item`, `affecting`,
|
|
47
|
+
`for_item_type`, `between`, `events`, `bulk`, `for_request`,
|
|
48
|
+
`with_fingerprint`, `chronological`, `reverse_chronological`;
|
|
49
|
+
`record.audit_entries`.
|
|
50
|
+
- `rails generate sealed_trail:install` creating the `sealed_trail_entries`
|
|
51
|
+
migration (jsonb on PostgreSQL, json on MySQL and SQLite) and a documented
|
|
52
|
+
initializer.
|
|
53
|
+
- Support for Rails 7.1, 7.2, 8.0 and 8.1 on Ruby 3.2+, with PostgreSQL,
|
|
54
|
+
MySQL 8+ (`mysql2` and `trilogy`) and SQLite.
|
|
55
|
+
|
|
56
|
+
[Unreleased]: https://github.com/steadybytehq/sealed_trail/compare/v0.1.0...HEAD
|
|
57
|
+
[0.1.0]: https://github.com/steadybytehq/sealed_trail/releases/tag/v0.1.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steadybyte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
# SealedTrail
|
|
2
|
+
|
|
3
|
+
[](https://github.com/steadybytehq/sealed_trail/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**An audit trail for Rails: who changed what, when, and from where.**
|
|
6
|
+
|
|
7
|
+
SealedTrail records every create, update and destroy on the Active Record models
|
|
8
|
+
you choose: the before and after values, the user (or job, or script) that made
|
|
9
|
+
the change, the time, the request id and the IP address. Bulk operations such
|
|
10
|
+
as `update_all` and `delete_all` are recorded too. Passwords, tokens, secrets,
|
|
11
|
+
emails, phone numbers and encrypted attributes are redacted by default.
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class Patient < ApplicationRecord
|
|
15
|
+
has_sealed_trail
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
patient.update!(status: "discharged", phone: "+1 555 0100")
|
|
19
|
+
|
|
20
|
+
patient.audit_entries.last
|
|
21
|
+
# => #<SealedTrail::Entry event: "update",
|
|
22
|
+
# object_changes: {"status" => ["admitted", "discharged"],
|
|
23
|
+
# "phone" => ["[REDACTED]", "[REDACTED]"], "updated_at" => [...]},
|
|
24
|
+
# actor_type: "User", actor_id: "42", request_id: "9f1c...", remote_ip: "203.0.113.7",
|
|
25
|
+
# created_at: ...>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
> **About compliance.** SealedTrail helps you produce audit evidence (a record of
|
|
29
|
+
> who changed which data and when) for security reviews and audits such as
|
|
30
|
+
> SOC 2, HIPAA or GDPR. It does not, by itself, make an application compliant
|
|
31
|
+
> with any standard or law. Compliance depends on your whole system, processes
|
|
32
|
+
> and policies.
|
|
33
|
+
|
|
34
|
+
## Requirements
|
|
35
|
+
|
|
36
|
+
- Ruby 3.2 or newer
|
|
37
|
+
- Rails 7.1, 7.2, 8.0 or 8.1
|
|
38
|
+
- PostgreSQL, MySQL 8+ (`mysql2` or `trilogy` adapter) or SQLite
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
Add the gem to your Gemfile (or run `bundle add sealed_trail`):
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
gem "sealed_trail"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Then install it and run the installer:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
bundle install
|
|
52
|
+
bin/rails generate sealed_trail:install
|
|
53
|
+
bin/rails db:migrate
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The installer creates:
|
|
57
|
+
|
|
58
|
+
- a migration for the single `sealed_trail_entries` table (`jsonb` columns on
|
|
59
|
+
PostgreSQL, `json` on MySQL and SQLite; force one with `--json-type=json|jsonb`)
|
|
60
|
+
- `config/initializers/sealed_trail.rb` with every setting commented out
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
### Track a model
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
class Invoice < ApplicationRecord
|
|
68
|
+
has_sealed_trail
|
|
69
|
+
end
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Each event creates one `SealedTrail::Entry`:
|
|
73
|
+
|
|
74
|
+
| event | `object_changes` |
|
|
75
|
+
|-----------|--------------------------------------------------------------|
|
|
76
|
+
| `create` | every non-nil attribute as `[nil, value]` |
|
|
77
|
+
| `update` | only the attributes that changed, as `[before, after]` |
|
|
78
|
+
| `destroy` | every non-nil attribute as `[value, nil]` (the final state) |
|
|
79
|
+
|
|
80
|
+
Saves that change nothing are not recorded. Entries are written in the same
|
|
81
|
+
database transaction as the change, so if the change rolls back, so does the
|
|
82
|
+
entry.
|
|
83
|
+
|
|
84
|
+
Methods that skip callbacks are recorded as well:
|
|
85
|
+
|
|
86
|
+
| method | recorded as |
|
|
87
|
+
|----------------------------------------|------------------------------------------------------|
|
|
88
|
+
| `update_columns`, `update_column` | an `update` entry, `metadata["via"] = "update_columns"` |
|
|
89
|
+
| `delete` | a `destroy` entry, `metadata["via"] = "delete"` |
|
|
90
|
+
| `touch` | a `touch` entry, only with `on: [..., :touch]` |
|
|
91
|
+
| `update_all`, `delete_all`, `insert_all`, `insert_all!`, `upsert_all` (and `insert`, `upsert`, `Model.delete(id)`, counter caches) | one bulk summary entry, see [Bulk operations](#bulk-operations) |
|
|
92
|
+
|
|
93
|
+
### When an entry cannot be written
|
|
94
|
+
|
|
95
|
+
By default a failed audit write never blocks the user's save. The entry is
|
|
96
|
+
written inside a savepoint, so a failed INSERT does not abort the surrounding
|
|
97
|
+
transaction (important on PostgreSQL); the error is logged, the
|
|
98
|
+
`write_failed.sealed_trail` notification is instrumented, and your
|
|
99
|
+
`error_handler` is called:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
SealedTrail.configure do |config|
|
|
103
|
+
config.on_failure = :log # default
|
|
104
|
+
config.error_handler = ->(error, payload) { Rails.error.report(error, context: payload.except(:error)) }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
ActiveSupport::Notifications.subscribe("write_failed.sealed_trail") do |event|
|
|
108
|
+
event.payload[:error] # the exception
|
|
109
|
+
event.payload[:entry] # the attributes of the entry that was not written
|
|
110
|
+
end
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Choose `config.on_failure = :raise` for strict mode: the exception propagates
|
|
114
|
+
and the change is rolled back rather than left unaudited. A callable
|
|
115
|
+
`->(error, payload) { ... }` replaces the default handling (raise from it to
|
|
116
|
+
block the save).
|
|
117
|
+
|
|
118
|
+
### Options
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
class Patient < ApplicationRecord
|
|
122
|
+
has_sealed_trail(
|
|
123
|
+
ignore: [:last_seen_at], # never record these attributes
|
|
124
|
+
redact: { ssn: :mask, insurance_id: :fingerprint, diagnosis: :redact },
|
|
125
|
+
meta: { # extra data stored with each entry
|
|
126
|
+
clinic_id: :clinic_id, # Symbol: method on the record
|
|
127
|
+
source: "patient-portal", # static value
|
|
128
|
+
ward: ->(patient) { patient.ward&.name } # Proc: called with the record
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class Article < ApplicationRecord
|
|
134
|
+
has_sealed_trail only: [:title, :body] # record just these attributes
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
class Invoice < ApplicationRecord
|
|
138
|
+
has_sealed_trail on: [:create, :destroy], unless: :draft?
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
| option | meaning |
|
|
143
|
+
|-------------------|-------------------------------------------------------------------------|
|
|
144
|
+
| `only:` | record only these attributes |
|
|
145
|
+
| `ignore:` | leave these attributes out; updates touching only them record nothing |
|
|
146
|
+
| `redact:` | names/Regexps (Array), or a Hash of name/Regexp => strategy (see below) |
|
|
147
|
+
| `on:` | any of `:create`, `:update`, `:destroy` (default: all three), `:touch` |
|
|
148
|
+
| `meta:` | Hash of metadata: static values, Symbols (record methods) or Procs |
|
|
149
|
+
| `bulk:` | record bulk operations on this model (default: `true`) |
|
|
150
|
+
| `if:` / `unless:` | standard Active Record callback conditions (callbacks only) |
|
|
151
|
+
|
|
152
|
+
### Redaction
|
|
153
|
+
|
|
154
|
+
Redacted attributes still show *that* they changed, but not their value. `nil`
|
|
155
|
+
stays `nil`, so you can still see when a value was first set or removed.
|
|
156
|
+
|
|
157
|
+
By default SealedTrail redacts attributes whose names match:
|
|
158
|
+
|
|
159
|
+
- `password` / `passwd` (including `password_digest`)
|
|
160
|
+
- `token`, `secret`, `api_key`, `private_key`
|
|
161
|
+
- `otp_*`, `*salt`, `encrypted_*`
|
|
162
|
+
- `email`, `*_email`, `phone`, `*_phone`, `mobile` (and `*_address` / `*_number` forms)
|
|
163
|
+
- any attribute declared with Rails' `encrypts`
|
|
164
|
+
|
|
165
|
+
Strategies:
|
|
166
|
+
|
|
167
|
+
| strategy | stored as | use it for |
|
|
168
|
+
|----------------|-----------------------------------|-----------------------------------------------|
|
|
169
|
+
| `:redact` | `"[REDACTED]"` (default) | secrets |
|
|
170
|
+
| `:fingerprint` | `"hmac:3f2a…"` (HMAC-SHA256) | emails and identifiers you need to look up |
|
|
171
|
+
| `:mask` | `"j***@example.com"`, `"******1234"` | phone and card numbers a human must recognise |
|
|
172
|
+
| callable | whatever `->(value, attribute) { }` returns | anything else |
|
|
173
|
+
| `false` | the plain value | opting a model out of a global rule |
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
class Customer < ApplicationRecord
|
|
177
|
+
has_sealed_trail redact: { email: :fingerprint, phone: :mask, newsletter_email: false }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Global default strategy, and global rules with their own strategy:
|
|
181
|
+
SealedTrail.configure do |config|
|
|
182
|
+
config.redaction_strategy = :redact
|
|
183
|
+
config.redact_attributes += [{ national_id: :fingerprint }]
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Fingerprints are keyed, so equal values produce equal fingerprints without
|
|
188
|
+
revealing the value. Find entries for a value with:
|
|
189
|
+
|
|
190
|
+
```ruby
|
|
191
|
+
SealedTrail::Entry.with_fingerprint("jane@example.com")
|
|
192
|
+
SealedTrail.fingerprint("jane@example.com") # => "hmac:…"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The key defaults to one derived from `secret_key_base`; set
|
|
196
|
+
`config.fingerprint_secret` to manage it separately. Changing the key changes
|
|
197
|
+
all future fingerprints. Values are hashed exactly as stored, so normalise them
|
|
198
|
+
(e.g. downcase emails) in your model if you want case-insensitive matches.
|
|
199
|
+
Masking keeps the first character of an email's local part and the domain, or
|
|
200
|
+
the last four characters of values of 8+ characters; shorter values are fully
|
|
201
|
+
masked.
|
|
202
|
+
|
|
203
|
+
### Who made the change (the actor)
|
|
204
|
+
|
|
205
|
+
In controllers, SealedTrail records `current_user` automatically. The actor is
|
|
206
|
+
looked up lazily, the first time something is recorded in the request, so it
|
|
207
|
+
works even if your authentication runs in a later `before_action`. The request
|
|
208
|
+
id and remote IP are captured too.
|
|
209
|
+
|
|
210
|
+
Every entry gets an actor. When no user is known, SealedTrail records a label
|
|
211
|
+
instead, in this order:
|
|
212
|
+
|
|
213
|
+
1. the explicit actor (`SealedTrail.with_actor`, `SealedTrail::Current.actor`)
|
|
214
|
+
2. the request's user (`current_user`)
|
|
215
|
+
3. `"anonymous"` in a request with nobody signed in (`config.anonymous_actor`)
|
|
216
|
+
4. `"job:<JobClass>"` in an Active Job job that did not inherit an actor
|
|
217
|
+
5. `"rake:<task name>"` while a rake task runs, `"console"` in `rails console`
|
|
218
|
+
6. `"system"` otherwise (`config.default_actor`; a label, a record or a callable)
|
|
219
|
+
|
|
220
|
+
Use a different method, or customise it per controller:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
# config/initializers/sealed_trail.rb
|
|
224
|
+
SealedTrail.configure { |config| config.actor_method = :current_admin }
|
|
225
|
+
|
|
226
|
+
# or in a controller
|
|
227
|
+
class Admin::BaseController < ApplicationController
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def sealed_trail_actor
|
|
231
|
+
true_user || current_user # e.g. record the real admin while impersonating
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def sealed_trail_metadata
|
|
235
|
+
{ user_agent: request.user_agent }
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Outside requests you can also set the actor yourself. It can be a record or a
|
|
241
|
+
label:
|
|
242
|
+
|
|
243
|
+
```ruby
|
|
244
|
+
SealedTrail.with_actor(user) { invoice.update!(paid: true) }
|
|
245
|
+
|
|
246
|
+
class NightlyImportJob < ApplicationJob
|
|
247
|
+
def perform
|
|
248
|
+
SealedTrail.with_actor("nightly_import") { Importer.run }
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# or for the rest of the current request/job
|
|
253
|
+
SealedTrail::Current.actor = user
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Context is stored in `ActiveSupport::CurrentAttributes`, so it is isolated per
|
|
257
|
+
thread (or per fiber, if you set Rails' isolation level to `:fiber`) and reset
|
|
258
|
+
by Rails after every request and job.
|
|
259
|
+
|
|
260
|
+
**Background jobs inherit the actor.** When an Active Job job is enqueued, the
|
|
261
|
+
current actor (as a GlobalID for records, or the label), request id, remote IP
|
|
262
|
+
and metadata are stored with the job and restored while it runs, so changes
|
|
263
|
+
made by a job enqueued from a request are attributed to the user who made that
|
|
264
|
+
request. If the actor record was deleted in the meantime, its GlobalID string
|
|
265
|
+
is recorded as the label. Turn this off with
|
|
266
|
+
`config.active_job_integration = false`. Other job systems (e.g. plain Sidekiq
|
|
267
|
+
workers) are not integrated; use `with_actor` in them.
|
|
268
|
+
|
|
269
|
+
### Metadata
|
|
270
|
+
|
|
271
|
+
Add metadata to everything recorded in a block, for example a reason or ticket
|
|
272
|
+
number:
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
SealedTrail.with_metadata(reason: "Support ticket #4521") do
|
|
276
|
+
patient.update!(email: new_email)
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Pausing tracking
|
|
281
|
+
|
|
282
|
+
```ruby
|
|
283
|
+
SealedTrail.without_tracking do
|
|
284
|
+
Patient.find_each { |p| p.update!(search_index: p.compute_index) }
|
|
285
|
+
end
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`SealedTrail.with_tracking { }` turns it back on inside such a block, and
|
|
289
|
+
`config.enabled = false` switches tracking off everywhere.
|
|
290
|
+
|
|
291
|
+
## Bulk operations
|
|
292
|
+
|
|
293
|
+
`update_all`, `delete_all`, `insert_all`, `insert_all!` and `upsert_all` on a
|
|
294
|
+
tracked model (including `insert`, `upsert`, `Model.delete(id)` and counter
|
|
295
|
+
cache updates, which use them) each record **one summary entry**:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
Patient.where(clinic_id: 7).update_all(status: "archived")
|
|
299
|
+
|
|
300
|
+
entry = SealedTrail::Entry.bulk.last
|
|
301
|
+
entry.event # "bulk_update" (also "bulk_delete", "bulk_insert", "bulk_upsert")
|
|
302
|
+
entry.item_type # "Patient"
|
|
303
|
+
entry.item_id # "*"
|
|
304
|
+
entry.object_changes # {"status" => [nil, "archived"]}, redacted like any change
|
|
305
|
+
entry.bulk_summary
|
|
306
|
+
# => {"operation" => "update_all", "row_count" => 42,
|
|
307
|
+
# "ids" => ["3", "8", ...], "ids_truncated" => false,
|
|
308
|
+
# "where_sql" => "`patients`.`clinic_id` = 7"}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
- **Affected ids** are read just before an update or delete, capped by
|
|
312
|
+
`config.bulk_id_limit` (default 1000, `ids_truncated` tells you when more
|
|
313
|
+
matched; `row_count` is the database's total). For inserts, ids are recorded
|
|
314
|
+
when the database returns them (PostgreSQL, SQLite); on MySQL they are not,
|
|
315
|
+
and `row_count` is unknown, so `attempted_count` gives the number of rows sent.
|
|
316
|
+
- **SQL** of the WHERE clause, and of the SET clause for `update_all` with a SQL
|
|
317
|
+
string, is stored with quoted string literals replaced by `'?'`
|
|
318
|
+
(`config.bulk_sql = :masked`). Use `:full` to keep values or `:none` to
|
|
319
|
+
store no SQL. Numeric literals are not masked.
|
|
320
|
+
- `update_all` with a Hash that only touches ignored attributes records nothing,
|
|
321
|
+
so add counter cache columns to `ignore:` if you don't want them logged.
|
|
322
|
+
- Bulk entries honour `without_tracking`, the actor rules and `on_failure`.
|
|
323
|
+
Turn them off with `config.track_bulk_operations = false` or per model with
|
|
324
|
+
`has_sealed_trail bulk: false`.
|
|
325
|
+
- `SealedTrail::Entry.affecting(record)` returns a record's own entries plus the
|
|
326
|
+
bulk entries whose stored ids include it.
|
|
327
|
+
|
|
328
|
+
## Querying
|
|
329
|
+
|
|
330
|
+
```ruby
|
|
331
|
+
patient.audit_entries # this record's entries, oldest first
|
|
332
|
+
|
|
333
|
+
SealedTrail::Entry.for_item(patient) # same, as a relation
|
|
334
|
+
SealedTrail::Entry.for_item(Patient, 42) # works after the record is destroyed
|
|
335
|
+
SealedTrail::Entry.for_item_type(Patient) # all patients
|
|
336
|
+
SealedTrail::Entry.for_actor(user) # everything a user did
|
|
337
|
+
SealedTrail::Entry.for_actor("nightly_import")
|
|
338
|
+
SealedTrail::Entry.between(1.month.ago, Time.current) # either end may be nil
|
|
339
|
+
SealedTrail::Entry.events(:update) # or events(:create, :destroy)
|
|
340
|
+
SealedTrail::Entry.for_request(request_id) # everything in one request
|
|
341
|
+
SealedTrail::Entry.bulk # bulk operation summaries
|
|
342
|
+
SealedTrail::Entry.affecting(patient) # for_item + bulk entries listing its id
|
|
343
|
+
SealedTrail::Entry.with_fingerprint("jane@example.com") # entries storing that fingerprint
|
|
344
|
+
SealedTrail::Entry.chronological / .reverse_chronological
|
|
345
|
+
|
|
346
|
+
# Scopes chain:
|
|
347
|
+
SealedTrail::Entry.for_item_type(Patient).events(:update).for_actor(user).between(from, to)
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Each entry has:
|
|
351
|
+
|
|
352
|
+
```ruby
|
|
353
|
+
entry.event # "create", "update", "destroy", "touch" or "bulk_*"
|
|
354
|
+
entry.item # the record (nil once destroyed)
|
|
355
|
+
entry.item_type, entry.item_id # stored as strings, so UUID keys work too
|
|
356
|
+
entry.object_changes # {"attribute" => [before, after]}
|
|
357
|
+
entry.change_for(:email) # ["old@example.com", "new@example.com"]
|
|
358
|
+
entry.changed_attribute_names # ["email", "updated_at"]
|
|
359
|
+
entry.actor # the actor record, or nil for a label
|
|
360
|
+
entry.actor_type, entry.actor_id # e.g. "User", "42" or nil, "rake:db:seed"
|
|
361
|
+
entry.bulk?, entry.bulk_summary, entry.bulk_ids
|
|
362
|
+
entry.metadata, entry.request_id, entry.remote_ip, entry.created_at
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
Entries are read-only through Active Record once saved: `update` and `destroy`
|
|
366
|
+
raise `ActiveRecord::ReadOnlyRecord`. That prevents accidental edits from
|
|
367
|
+
application code, but it is not tamper evidence; anyone with database access
|
|
368
|
+
can still change rows.
|
|
369
|
+
|
|
370
|
+
## Configuration
|
|
371
|
+
|
|
372
|
+
All settings with their defaults:
|
|
373
|
+
|
|
374
|
+
```ruby
|
|
375
|
+
# config/initializers/sealed_trail.rb
|
|
376
|
+
SealedTrail.configure do |config|
|
|
377
|
+
config.enabled = true # master switch
|
|
378
|
+
|
|
379
|
+
# Redaction
|
|
380
|
+
config.redact_attributes = SealedTrail::Configuration::DEFAULT_REDACT_ATTRIBUTES.dup
|
|
381
|
+
config.redaction_strategy = :redact # :redact, :fingerprint, :mask or a callable
|
|
382
|
+
config.fingerprint_secret = nil # nil: derived from secret_key_base
|
|
383
|
+
config.redact_encrypted_attributes = true # redact `encrypts` attributes
|
|
384
|
+
config.ignore_attributes = [] # left out of every model's entries
|
|
385
|
+
|
|
386
|
+
# Failures
|
|
387
|
+
config.on_failure = :log # :log, :raise or ->(error, payload) { }
|
|
388
|
+
config.error_handler = nil # ->(error, payload) { } in :log mode
|
|
389
|
+
config.logger = nil # nil: Rails.logger
|
|
390
|
+
|
|
391
|
+
# Actors
|
|
392
|
+
config.actor_method = :current_user # controller method for the actor
|
|
393
|
+
config.anonymous_actor = "anonymous" # request with nobody signed in (nil: use default_actor)
|
|
394
|
+
config.default_actor = "system" # label, record or callable
|
|
395
|
+
config.detect_process_context = true # "console" and "rake:<task>" labels
|
|
396
|
+
config.active_job_integration = true # jobs inherit the enqueuing actor
|
|
397
|
+
config.auto_include_controller = true # include SealedTrail::Controller everywhere
|
|
398
|
+
|
|
399
|
+
# Bulk operations
|
|
400
|
+
config.track_bulk_operations = true
|
|
401
|
+
config.bulk_id_limit = 1000 # nil: no limit
|
|
402
|
+
config.bulk_sql = :masked # :masked, :full or :none
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
With `auto_include_controller = false`, include `SealedTrail::Controller` in the
|
|
407
|
+
controllers you want.
|
|
408
|
+
|
|
409
|
+
## SealedTrail Pro
|
|
410
|
+
|
|
411
|
+
This gem is the free, MIT-licensed core. **SealedTrail Pro** is a commercial
|
|
412
|
+
add-on for teams preparing for audits and customer security reviews. It is
|
|
413
|
+
planned to add:
|
|
414
|
+
|
|
415
|
+
- logging of who **viewed or exported** sensitive records, not only who changed them
|
|
416
|
+
- a **tamper-evident hash chain**, so edits or deletions in the trail can be detected
|
|
417
|
+
- **retention policies** that keep entries for a set period and then delete them
|
|
418
|
+
- **auditor-ready reports** to support evidence for SOC 2, HIPAA, GDPR and India's DPDP rules
|
|
419
|
+
|
|
420
|
+
Pro is sold as an annual licence, with a founding price for early customers.
|
|
421
|
+
Details, pricing and early access: <https://steadybytehq.github.io/sealedtrail/>
|
|
422
|
+
|
|
423
|
+
## Limitations
|
|
424
|
+
|
|
425
|
+
- **Hand-written SQL is not captured.** Anything run with
|
|
426
|
+
`connection.execute`, `exec_update` or similar, outside Active Record's
|
|
427
|
+
model and relation methods, bypasses SealedTrail; capturing it would need
|
|
428
|
+
database triggers. The same applies to changes made directly in the database.
|
|
429
|
+
- Bulk entries store a capped list of affected ids read just before the
|
|
430
|
+
operation (not atomically with it), not per-row before/after values. Inserts
|
|
431
|
+
record the columns written, not the values. `Entry.affecting` finds bulk
|
|
432
|
+
entries by searching the stored ids, so it misses ids beyond
|
|
433
|
+
`bulk_id_limit` and inserts on MySQL, and in rare cases can match a bulk
|
|
434
|
+
entry whose metadata happens to contain the same string.
|
|
435
|
+
- `if:` / `unless:` apply to callbacks only, not to `update_columns`, `delete`,
|
|
436
|
+
`touch` or bulk operations.
|
|
437
|
+
- Models with composite primary keys are not supported yet.
|
|
438
|
+
- For STI models, `item_type` is the base class; the subclass appears in the
|
|
439
|
+
`type` attribute of create entries.
|
|
440
|
+
- Supported databases are PostgreSQL, MySQL 8+ and SQLite.
|
|
441
|
+
|
|
442
|
+
## Development
|
|
443
|
+
|
|
444
|
+
```sh
|
|
445
|
+
bundle install
|
|
446
|
+
bundle exec rake test # SQLite
|
|
447
|
+
DB=postgresql bundle exec rake test # PostgreSQL (uses PGHOST/PGUSER/PGPASSWORD)
|
|
448
|
+
DB=mysql2 bundle exec rake test # MySQL via mysql2 (MYSQL_HOST/MYSQL_USER/MYSQL_PASSWORD)
|
|
449
|
+
DB=trilogy bundle exec rake test # MySQL via trilogy
|
|
450
|
+
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle install && \
|
|
451
|
+
BUNDLE_GEMFILE=gemfiles/rails_7_1.gemfile bundle exec rake test
|
|
452
|
+
bundle exec rubocop
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
## License
|
|
456
|
+
|
|
457
|
+
MIT. See [LICENSE](LICENSE). Copyright (c) 2026 Steadybyte.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module SealedTrail
|
|
7
|
+
module Generators
|
|
8
|
+
# rails generate sealed_trail:install
|
|
9
|
+
#
|
|
10
|
+
# Creates the sealed_trail_entries migration and an initializer.
|
|
11
|
+
class InstallGenerator < Rails::Generators::Base
|
|
12
|
+
include ActiveRecord::Generators::Migration
|
|
13
|
+
|
|
14
|
+
source_root File.expand_path("templates", __dir__)
|
|
15
|
+
|
|
16
|
+
class_option :json_type,
|
|
17
|
+
type: :string,
|
|
18
|
+
enum: %w[json jsonb],
|
|
19
|
+
desc: "Column type for object_changes and metadata (default: jsonb on PostgreSQL, json otherwise)"
|
|
20
|
+
|
|
21
|
+
desc "Creates the SealedTrail migration and initializer."
|
|
22
|
+
|
|
23
|
+
def create_migration_file
|
|
24
|
+
migration_template "create_sealed_trail_entries.rb.tt", "db/migrate/create_sealed_trail_entries.rb"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_initializer
|
|
28
|
+
template "sealed_trail.rb.tt", "config/initializers/sealed_trail.rb"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def json_type
|
|
34
|
+
options[:json_type] || (postgresql? ? "jsonb" : "json")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def postgresql?
|
|
38
|
+
ActiveRecord::Base.connection_db_config&.adapter.to_s.match?(/postg/i)
|
|
39
|
+
rescue StandardError
|
|
40
|
+
false
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def migration_version
|
|
44
|
+
"[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class CreateSealedTrailEntries < ActiveRecord::Migration<%= migration_version %>
|
|
2
|
+
def change
|
|
3
|
+
create_table :sealed_trail_entries do |t|
|
|
4
|
+
t.string :item_type, null: false
|
|
5
|
+
t.string :item_id, null: false
|
|
6
|
+
t.string :event, null: false
|
|
7
|
+
t.<%= json_type %> :object_changes
|
|
8
|
+
t.<%= json_type %> :metadata
|
|
9
|
+
t.string :actor_type
|
|
10
|
+
t.string :actor_id
|
|
11
|
+
t.string :request_id
|
|
12
|
+
t.string :remote_ip
|
|
13
|
+
t.datetime :created_at, null: false
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
add_index :sealed_trail_entries, [:item_type, :item_id, :created_at], name: "index_sealed_trail_entries_on_item"
|
|
17
|
+
add_index :sealed_trail_entries, [:actor_type, :actor_id, :created_at], name: "index_sealed_trail_entries_on_actor"
|
|
18
|
+
add_index :sealed_trail_entries, :created_at
|
|
19
|
+
add_index :sealed_trail_entries, :request_id
|
|
20
|
+
end
|
|
21
|
+
end
|