provenance 1.0.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 +28 -0
- data/LICENSE +21 -0
- data/README.md +333 -0
- data/lib/provenance/configuration.rb +34 -0
- data/lib/provenance/context.rb +58 -0
- data/lib/provenance/journal.rb +123 -0
- data/lib/provenance/trackers/auditable.rb +125 -0
- data/lib/provenance/trackers/bulk_operations.rb +72 -0
- data/lib/provenance/trackers/error_reporting.rb +48 -0
- data/lib/provenance/trackers/providers.rb +163 -0
- data/lib/provenance/trackers/trackable.rb +341 -0
- data/lib/provenance/transaction_key.rb +19 -0
- data/lib/provenance/version.rb +5 -0
- data/lib/provenance.rb +56 -0
- metadata +122 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 272ac0a7de2193660ff6e31bc2e5e1ef79f0809837057c3cce8df4b56c00e5f1
|
|
4
|
+
data.tar.gz: e34d95e42e38eb2c1e40d238cc981b2ee0d7c06829d24b9c134d9eae5b7cc41d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f789e8800b2bd3082424a4f6567ba981f3b0d7f732ab8ec1bfde3f1f0d66dbed31290b4ad30a067491d27e6d2c6ed58490ffad662321a1083f34ee2b4fa30bd8
|
|
7
|
+
data.tar.gz: '03285aa742eb534df8d103659467b004dc5a040991351072d37e7120a16e144697172987cf54742ec11a259cd3482d21dd214d89df8f271e64806cfbb52518c2'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [1.0.0] - 2026-06-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Initial public release.
|
|
15
|
+
- Model change tracking (create / update / destroy) via ActiveRecord callbacks,
|
|
16
|
+
grouped per request and per transaction.
|
|
17
|
+
- Controller-level auditing with automatic event-type generation.
|
|
18
|
+
- Error reporting through `audit_error(error, status)`.
|
|
19
|
+
- Bulk operation tracking for `update_all` / `delete_all` (opt-in).
|
|
20
|
+
- `has_and_belongs_to_many` join-table change tracking.
|
|
21
|
+
- Recursive sensitive-data filtering with global and per-model attribute lists.
|
|
22
|
+
- Pluggable value providers (username, roles, remote IP, origin IP, session id).
|
|
23
|
+
- Configurable delivery hooks for shipping audit events to any sink.
|
|
24
|
+
- Transaction-aware delivery: events are flushed only after every transaction
|
|
25
|
+
has committed, and discarded on rollback.
|
|
26
|
+
|
|
27
|
+
[Unreleased]: https://github.com/inikalaev/provenance/compare/v1.0.0...HEAD
|
|
28
|
+
[1.0.0]: https://github.com/inikalaev/provenance/releases/tag/v1.0.0
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ivan Nikolaev
|
|
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,333 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# ๐ Provenance
|
|
4
|
+
|
|
5
|
+
### A drop-in audit trail for Rails โ every user action and model change, captured.
|
|
6
|
+
|
|
7
|
+
[](https://github.com/inikalaev/provenance/actions/workflows/ci.yml)
|
|
8
|
+
[](https://rubygems.org/gems/provenance)
|
|
9
|
+
[](LICENSE)
|
|
10
|
+
[](.ruby-version)
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
Provenance watches your Rails app and records **who did what, to which record, when** โ then ships
|
|
17
|
+
a structured event anywhere you want: a log pipeline, a data warehouse, an external SIEM, or just
|
|
18
|
+
`Rails.logger`. You wire it in once; it stays out of your controllers and models.
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
# An audit event Provenance produces for a successful request
|
|
22
|
+
{
|
|
23
|
+
"event_type": "create_users",
|
|
24
|
+
"status": 201,
|
|
25
|
+
"username": "admin@example.com",
|
|
26
|
+
"remote_ip": "203.0.113.1",
|
|
27
|
+
"message": {
|
|
28
|
+
"count": 1,
|
|
29
|
+
"changes": [
|
|
30
|
+
{ "model": "User", "model_id": 42, "action": "create",
|
|
31
|
+
"changes": { "attributes": { "email": "user@example.com", "password": "[FILTERED]" } } }
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"source": "myapp_production"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## โจ Features
|
|
39
|
+
|
|
40
|
+
- ๐งพ **Model change tracking** โ `create` / `update` / `destroy` captured straight from ActiveRecord callbacks.
|
|
41
|
+
- ๐ **Controller auditing** โ one `around_action` records every change made during a request and emits a single event.
|
|
42
|
+
- ๐งฌ **Transaction-aware** โ changes are grouped per transaction, flushed only after every transaction commits, and **discarded on rollback**.
|
|
43
|
+
- ๐ฅ **Error reporting** โ emit a dedicated audit event for failed requests with `audit_error`.
|
|
44
|
+
- ๐๏ธ **Bulk operations** โ opt-in tracking for `update_all` / `delete_all`, which normally bypass callbacks.
|
|
45
|
+
- ๐ **`has_and_belongs_to_many`** โ join-table writes are tracked through SQL notifications.
|
|
46
|
+
- ๐ก๏ธ **Sensitive-data filtering** โ recursive `[FILTERED]` redaction with global and per-model attribute lists.
|
|
47
|
+
- ๐ **Pluggable providers & hooks** โ decide how to resolve the actor and where events are delivered.
|
|
48
|
+
- ๐ชถ **Fail-safe** โ auditing never breaks the underlying request or operation.
|
|
49
|
+
|
|
50
|
+
## ๐ฆ Installation
|
|
51
|
+
|
|
52
|
+
Add it to your `Gemfile`:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
gem "provenance"
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then install:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bundle install
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## ๐ Quick start
|
|
65
|
+
|
|
66
|
+
### 1. Configure the initializer
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# config/initializers/provenance.rb
|
|
70
|
+
require "provenance"
|
|
71
|
+
|
|
72
|
+
Provenance.configure do |config|
|
|
73
|
+
config.source_name = "myapp_#{Rails.env}"
|
|
74
|
+
config.sensitive_attributes = %w[password password_confirmation token secret_key api_key]
|
|
75
|
+
|
|
76
|
+
# Auditing is disabled in the test environment by default. Override if needed:
|
|
77
|
+
# config.enabled = true
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# How to resolve the actor and request metadata (each receives the controller):
|
|
81
|
+
Provenance.setup_username_provider(->(controller) { controller.current_user&.email })
|
|
82
|
+
Provenance.setup_roles_provider(->(controller) { controller.current_user&.roles || [] })
|
|
83
|
+
Provenance.setup_remote_ip_provider(->(controller) { controller.request.remote_ip })
|
|
84
|
+
Provenance.setup_origin_ip_provider(->(controller) { ENV["SERVER_IP"] || "127.0.0.1" })
|
|
85
|
+
Provenance.setup_session_id_provider(->(controller) { controller.request.headers["Authorization"]&.split(" ")&.last })
|
|
86
|
+
|
|
87
|
+
# Where audit events go (you can register more than one hook):
|
|
88
|
+
Provenance.config.add_audit_hook do |audit_data|
|
|
89
|
+
Rails.logger.info("AUDIT: #{audit_data.to_json}")
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### 2. Mix the concerns in
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# app/controllers/application_controller.rb
|
|
97
|
+
class ApplicationController < ActionController::API
|
|
98
|
+
include Provenance::Auditable # records changes per request
|
|
99
|
+
include Provenance::ErrorReporting # adds audit_error(error, status)
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# app/models/application_record.rb
|
|
105
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
106
|
+
self.abstract_class = true
|
|
107
|
+
include Provenance::Trackable
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
That's it. Every write that happens inside a request now produces an audit event.
|
|
112
|
+
|
|
113
|
+
## ๐งญ How it works
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
Request โโถ Auditable (around_action)
|
|
117
|
+
โ opens a Journal for this request
|
|
118
|
+
โผ
|
|
119
|
+
Trackable callbacks โโโถ Journal โโโ BulkOperations / HABTM SQL
|
|
120
|
+
(create/update/destroy) (grouped by transaction)
|
|
121
|
+
โ
|
|
122
|
+
โผ
|
|
123
|
+
all transactions committed?
|
|
124
|
+
โ yes โ rollback
|
|
125
|
+
โผ โผ
|
|
126
|
+
audit hooks receive changes discarded
|
|
127
|
+
one assembled event
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The `Journal` lives in fiber-local storage for the duration of the request, so concurrent requests
|
|
131
|
+
never see each other's changes.
|
|
132
|
+
|
|
133
|
+
## ๐ฅ Error logging
|
|
134
|
+
|
|
135
|
+
Call `audit_error` from your rescue handlers to record failures:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
def render_errors(errors, status: :unprocessable_entity)
|
|
139
|
+
audit_error(errors, status)
|
|
140
|
+
render json: { errors: Array(errors) }, status: status
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
rescue_from ActiveRecord::RecordNotFound do
|
|
144
|
+
audit_error("Not found", :not_found)
|
|
145
|
+
head :not_found
|
|
146
|
+
end
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Symbolic statuses (`:not_found`, `:unauthorized`, `:forbidden`, `:unprocessable_entity`, `:conflict`)
|
|
150
|
+
are mapped to their numeric codes automatically; anything else defaults to `500`.
|
|
151
|
+
|
|
152
|
+
## ๐ก๏ธ Sensitive data filtering
|
|
153
|
+
|
|
154
|
+
Filtered values are replaced with `[FILTERED]` โ in model attributes, request params, and nested
|
|
155
|
+
hashes/arrays alike.
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
# Global (applies everywhere)
|
|
159
|
+
Provenance.configure do |config|
|
|
160
|
+
config.sensitive_attributes = %w[password token api_key]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Per-model (takes priority over the global list)
|
|
164
|
+
class Payment < ApplicationRecord
|
|
165
|
+
sensitive_attributes :card_number, :cvv, :token
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
# in # out
|
|
171
|
+
{ user: { { user: {
|
|
172
|
+
email: "user@example.com", email: "user@example.com",
|
|
173
|
+
password: "secret", password: "[FILTERED]",
|
|
174
|
+
profile: { api_key: "abc123" } profile: { api_key: "[FILTERED]" }
|
|
175
|
+
} } } }
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## ๐๏ธ Bulk operations
|
|
179
|
+
|
|
180
|
+
`update_all` and `delete_all` skip ActiveRecord callbacks, so they are tracked separately and must be
|
|
181
|
+
enabled explicitly:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Provenance.configure do |config|
|
|
185
|
+
config.track_bulk_operations = true # default: false
|
|
186
|
+
config.bulk_operations_max_ids = 1000 # cap ids per record; over the cap sets truncated: true
|
|
187
|
+
end
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The affected ids are collected with a `pluck` **before** the statement runs, so factor in one extra
|
|
191
|
+
query on large result sets. Operations outside an HTTP request (migrations, rake tasks, background
|
|
192
|
+
jobs) are not tracked. A bulk change looks like:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
{
|
|
196
|
+
model: "Comment",
|
|
197
|
+
model_ids: ["101", "102"],
|
|
198
|
+
action: "bulk_update", # or "bulk_delete"
|
|
199
|
+
count: 2,
|
|
200
|
+
changes: { status: "deleted", deleted_at: "2026-06-05T12:00:00Z" }
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## ๐ has_and_belongs_to_many
|
|
205
|
+
|
|
206
|
+
Join-table inserts and deletes never trigger model callbacks, so Provenance observes them through
|
|
207
|
+
`sql.active_record` notifications and folds them into the owner's change as an `*_ids` update. No extra
|
|
208
|
+
setup is required beyond including `Provenance::Trackable` in the participating models.
|
|
209
|
+
|
|
210
|
+
> **Note:** the SQL reconstruction for HABTM is tuned for PostgreSQL bind placeholders (`$1`, `$2`).
|
|
211
|
+
> Insert tracking is portable; delete tracking depends on that placeholder style.
|
|
212
|
+
|
|
213
|
+
## โ๏ธ Fine-tuning
|
|
214
|
+
|
|
215
|
+
### Skip auditing per action
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
class UsersController < ApplicationController
|
|
219
|
+
skip_audit_logging :index, :show # no event at all
|
|
220
|
+
skip_model_change_tracking :index # event, but without model diffs
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### Custom event types
|
|
225
|
+
|
|
226
|
+
```ruby
|
|
227
|
+
class SessionsController < ApplicationController
|
|
228
|
+
custom_audit_event_type :create, "user_login"
|
|
229
|
+
custom_audit_event_type :destroy, "user_logout"
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Automatic event-type generation
|
|
234
|
+
|
|
235
|
+
When you don't override it, Provenance derives the event type from the controller and action:
|
|
236
|
+
|
|
237
|
+
| Action | Event type | Example (`UsersController`) |
|
|
238
|
+
| ----------------- | ------------------------- | ---------------------------------- |
|
|
239
|
+
| `index` | `read_{controller}` | `read_users` |
|
|
240
|
+
| `show` | `show_{singular}` | `show_user` |
|
|
241
|
+
| `create` | `create_{controller}` | `create_users` |
|
|
242
|
+
| `update` | `update_{singular}` | `update_user` |
|
|
243
|
+
| `destroy` | `destroy_{singular}` | `destroy_user` |
|
|
244
|
+
| _custom action_ | `{action}_{controller}` | `archive_users` |
|
|
245
|
+
|
|
246
|
+
Namespaced controllers are flattened with `_`, e.g. `Admin::UsersController#index` โ `read_admin_users`.
|
|
247
|
+
|
|
248
|
+
### Delivery hooks
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
Provenance.config.add_audit_hook { |event| ExternalAuditService.deliver(event) }
|
|
252
|
+
Provenance.config.add_audit_hook { |event| Rails.logger.info("AUDIT: #{event.to_json}") }
|
|
253
|
+
Provenance.config.clear_audit_hooks # remove all hooks
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
## ๐ง Configuration reference
|
|
257
|
+
|
|
258
|
+
| Option | Default | Description |
|
|
259
|
+
| --------------------------- | -------------------- | ------------------------------------------------------- |
|
|
260
|
+
| `source_name` | `"app_#{Rails.env}"` | Identifies the emitting application in every event. |
|
|
261
|
+
| `sensitive_attributes` | `[]` | Global attribute names to redact. |
|
|
262
|
+
| `enabled` | `!Rails.env.test?` | Master switch for the whole pipeline. |
|
|
263
|
+
| `track_bulk_operations` | `false` | Track `update_all` / `delete_all`. |
|
|
264
|
+
| `bulk_operations_max_ids` | `1000` | Max ids recorded per bulk change. |
|
|
265
|
+
| `audit_hooks` | `[]` | Delivery callbacks (use `add_audit_hook`). |
|
|
266
|
+
|
|
267
|
+
Providers: `username`, `roles`, `remote_ip`, `origin_ip`, `session_id` โ each set via
|
|
268
|
+
`Provenance.setup_<name>_provider(callable)`.
|
|
269
|
+
|
|
270
|
+
## ๐ Event structure
|
|
271
|
+
|
|
272
|
+
**Successful request**
|
|
273
|
+
|
|
274
|
+
```json
|
|
275
|
+
{
|
|
276
|
+
"timestamp": "2026-06-05T12:00:00.000Z",
|
|
277
|
+
"event_type": "create_users",
|
|
278
|
+
"status": 201,
|
|
279
|
+
"message": {
|
|
280
|
+
"count": 1,
|
|
281
|
+
"changes": [
|
|
282
|
+
{
|
|
283
|
+
"model": "User",
|
|
284
|
+
"model_id": 123,
|
|
285
|
+
"action": "create",
|
|
286
|
+
"changes": { "attributes": { "email": "user@example.com", "name": "John Doe" } },
|
|
287
|
+
"timestamp": "2026-06-05T12:00:00.000Z"
|
|
288
|
+
}
|
|
289
|
+
],
|
|
290
|
+
"params": { "user": { "email": "user@example.com" } }
|
|
291
|
+
},
|
|
292
|
+
"username": "admin@example.com",
|
|
293
|
+
"remote_ip": "203.0.113.1",
|
|
294
|
+
"origin_ip": "192.168.1.100",
|
|
295
|
+
"session_id": "token123",
|
|
296
|
+
"roles": ["admin"],
|
|
297
|
+
"request_id": "req-123",
|
|
298
|
+
"source": "myapp_production"
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
**Failed request** (via `audit_error`)
|
|
303
|
+
|
|
304
|
+
```json
|
|
305
|
+
{
|
|
306
|
+
"timestamp": "2026-06-05T12:00:00.000Z",
|
|
307
|
+
"event_type": "create_users",
|
|
308
|
+
"status": "422",
|
|
309
|
+
"message": {
|
|
310
|
+
"error_type": "ActiveRecord::RecordInvalid",
|
|
311
|
+
"error_message": "Validation failed: Email has already been taken",
|
|
312
|
+
"params": { "user": { "email": "invalid" } }
|
|
313
|
+
},
|
|
314
|
+
"username": "admin@example.com",
|
|
315
|
+
"source": "myapp_production"
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## ๐งช Development
|
|
320
|
+
|
|
321
|
+
```bash
|
|
322
|
+
bundle install
|
|
323
|
+
bundle exec rspec # run the test suite
|
|
324
|
+
bundle exec rubocop # lint
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## ๐ค Contributing
|
|
328
|
+
|
|
329
|
+
Bug reports and pull requests are welcome โ see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
330
|
+
|
|
331
|
+
## ๐ License
|
|
332
|
+
|
|
333
|
+
Released under the [MIT License](LICENSE).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Holds the global configuration: the data source name, the list of sensitive
|
|
5
|
+
# attributes, the delivery hooks and the value providers used to enrich every
|
|
6
|
+
# audit event.
|
|
7
|
+
class Configuration
|
|
8
|
+
attr_accessor :source_name, :sensitive_attributes, :audit_hooks, :enabled,
|
|
9
|
+
:username_provider, :roles_provider, :remote_ip_provider, :origin_ip_provider, :session_id_provider,
|
|
10
|
+
:track_bulk_operations, :bulk_operations_max_ids
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@source_name = "app_#{Rails.env}"
|
|
14
|
+
@sensitive_attributes = []
|
|
15
|
+
@audit_hooks = []
|
|
16
|
+
@enabled = !Rails.env.test?
|
|
17
|
+
@track_bulk_operations = false
|
|
18
|
+
@bulk_operations_max_ids = 1000
|
|
19
|
+
@username_provider = nil
|
|
20
|
+
@roles_provider = nil
|
|
21
|
+
@remote_ip_provider = nil
|
|
22
|
+
@origin_ip_provider = nil
|
|
23
|
+
@session_id_provider = nil
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_audit_hook(&block)
|
|
27
|
+
@audit_hooks << block
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def clear_audit_hooks
|
|
31
|
+
@audit_hooks.clear
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Provenance
|
|
6
|
+
# Per-request scratch space backed by fiber-local storage. Holds the active
|
|
7
|
+
# journal, the deferred delivery callback and the request/response metadata
|
|
8
|
+
# for the duration of a single request.
|
|
9
|
+
class Context
|
|
10
|
+
class << self
|
|
11
|
+
def journal
|
|
12
|
+
Thread.current[:provenance_journal]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def journal=(value)
|
|
16
|
+
Thread.current[:provenance_journal] = value
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def pending_audit_log
|
|
20
|
+
Thread.current[:provenance_pending_log]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pending_audit_log=(callback)
|
|
24
|
+
Thread.current[:provenance_pending_log] = callback
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def request_id
|
|
28
|
+
Thread.current[:provenance_request_id]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def request_id=(value)
|
|
32
|
+
Thread.current[:provenance_request_id] = value
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def response_status
|
|
36
|
+
Thread.current[:provenance_response_status]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def response_status=(value)
|
|
40
|
+
Thread.current[:provenance_response_status] = value
|
|
41
|
+
Thread.current[:provenance_request_completed] = true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def request_completed?
|
|
45
|
+
Thread.current[:provenance_request_completed] || false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def cleanup
|
|
49
|
+
Thread.current[:provenance_journal] = nil
|
|
50
|
+
Thread.current[:provenance_pending_log] = nil
|
|
51
|
+
Thread.current[:provenance_request_id] = nil
|
|
52
|
+
Thread.current[:provenance_send_scheduled] = nil
|
|
53
|
+
Thread.current[:provenance_response_status] = nil
|
|
54
|
+
Thread.current[:provenance_request_completed] = nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Provenance
|
|
4
|
+
# Accumulates model and bulk changes for the current request, grouped by
|
|
5
|
+
# transaction key so they can be discarded on rollback and flushed together
|
|
6
|
+
# once every transaction has completed.
|
|
7
|
+
class Journal
|
|
8
|
+
attr_reader :changes
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@changes = []
|
|
12
|
+
@active_transactions = Set.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def register_transaction(transaction_id)
|
|
16
|
+
return if transaction_id.nil?
|
|
17
|
+
|
|
18
|
+
@active_transactions.add(transaction_id)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def complete_transaction(transaction_id)
|
|
22
|
+
return if transaction_id.nil?
|
|
23
|
+
|
|
24
|
+
@active_transactions.delete(transaction_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_change(model, action, data)
|
|
28
|
+
change_data = {
|
|
29
|
+
model: model.class.name,
|
|
30
|
+
model_id: model.id,
|
|
31
|
+
action: action.to_s,
|
|
32
|
+
changes: filter_sensitive_data(data, model.class),
|
|
33
|
+
timestamp: Time.current.utc.iso8601(3)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
change_data[:transaction_id] = data[:transaction_id] if data.is_a?(Hash) && data[:transaction_id]
|
|
37
|
+
|
|
38
|
+
@changes << change_data
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def add_bulk_change(model_class, action, ids, updates, truncated: false, transaction_id: nil)
|
|
42
|
+
change_data = {
|
|
43
|
+
model: model_class.name,
|
|
44
|
+
model_ids: ids,
|
|
45
|
+
action: action.to_s,
|
|
46
|
+
count: ids.size,
|
|
47
|
+
changes: updates.nil? ? {} : filter_sensitive_data(updates, model_class),
|
|
48
|
+
timestamp: Time.current.utc.iso8601(3)
|
|
49
|
+
}
|
|
50
|
+
change_data[:truncated] = true if truncated
|
|
51
|
+
change_data[:transaction_id] = transaction_id if transaction_id
|
|
52
|
+
|
|
53
|
+
@changes << change_data
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def remove_changes_for_transaction(transaction_id)
|
|
57
|
+
return if transaction_id.nil?
|
|
58
|
+
|
|
59
|
+
@changes.reject! { |change| change[:transaction_id] == transaction_id }
|
|
60
|
+
@active_transactions.delete(transaction_id)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def all_transactions_completed?
|
|
64
|
+
@active_transactions.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def to_h
|
|
68
|
+
{
|
|
69
|
+
count: @changes.size,
|
|
70
|
+
changes: @changes
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clear!
|
|
75
|
+
@changes.clear
|
|
76
|
+
@active_transactions.clear
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def empty?
|
|
80
|
+
@changes.empty?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def present?
|
|
84
|
+
@changes.any?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def filter_sensitive_data(data, model_class = nil)
|
|
90
|
+
sensitive_attrs = model_sensitive_attributes(model_class)
|
|
91
|
+
return data if sensitive_attrs.empty?
|
|
92
|
+
|
|
93
|
+
case data
|
|
94
|
+
when Hash
|
|
95
|
+
filtered_data = {}
|
|
96
|
+
data.each do |key, value|
|
|
97
|
+
filtered_data[key] = if key == "transaction_id"
|
|
98
|
+
value
|
|
99
|
+
elsif sensitive_attrs.include?(key.to_s)
|
|
100
|
+
"[FILTERED]"
|
|
101
|
+
else
|
|
102
|
+
filter_sensitive_data(value, model_class)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
filtered_data
|
|
106
|
+
when Array
|
|
107
|
+
data.map { |item| filter_sensitive_data(item, model_class) }
|
|
108
|
+
else
|
|
109
|
+
data
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def model_sensitive_attributes(model_class)
|
|
114
|
+
attributes = model_class&.attribute_names
|
|
115
|
+
if model_class.respond_to?(:sensitive_attributes_list)
|
|
116
|
+
attrs = model_class.sensitive_attributes_list
|
|
117
|
+
attributes = attrs.map(&:to_s)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
(Provenance.config.sensitive_attributes + attributes).map(&:to_s)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|