standard_audit 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 +18 -0
- data/MIT-LICENSE +20 -0
- data/README.md +365 -0
- data/Rakefile +8 -0
- data/app/jobs/standard_audit/create_audit_log_job.rb +47 -0
- data/app/models/standard_audit/application_record.rb +5 -0
- data/app/models/standard_audit/audit_log.rb +157 -0
- data/config/routes.rb +2 -0
- data/lib/generators/standard_audit/install/install_generator.rb +20 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +30 -0
- data/lib/generators/standard_audit/install/templates/initializer.rb.erb +37 -0
- data/lib/standard_audit/audit_scope.rb +9 -0
- data/lib/standard_audit/auditable.rb +29 -0
- data/lib/standard_audit/configuration.rb +52 -0
- data/lib/standard_audit/engine.rb +11 -0
- data/lib/standard_audit/subscriber.rb +87 -0
- data/lib/standard_audit/version.rb +3 -0
- data/lib/standard_audit.rb +74 -0
- data/lib/tasks/standard_audit_tasks.rake +71 -0
- metadata +119 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 44b12d653a138dc099f16e659e14cd139b612369f2279859b9fcae7eff57827c
|
|
4
|
+
data.tar.gz: f36ceca0425ba35b8ad73c1ac91cf63e82eb6da21fdaf7f7117c73f9c09ecc4e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e10c0a45d0941ab4284f3f1fd0a8bd25b5b9c83b0052a1e354d3acb43be984eb1997e934ff8ea45beb9df772e12420300f41403bd018d3a75e2080b32df05cd0
|
|
7
|
+
data.tar.gz: c0fcae8a3eee766f6a2caf7f01323788e4eeadf3de73ff3b37a7258e3a037b2771e557a7865adf559ebd98afc3d975b2e92bbdfc0b2735b1819fffaef80511e8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-03-03)
|
|
4
|
+
|
|
5
|
+
Initial release.
|
|
6
|
+
|
|
7
|
+
- Core audit log model with UUID primary keys and GlobalID-based polymorphic references
|
|
8
|
+
- Convenience API: `StandardAudit.record` with sync, async, and block forms
|
|
9
|
+
- ActiveSupport::Notifications subscriber for automatic event capture
|
|
10
|
+
- Configurable Current attribute resolvers for request context
|
|
11
|
+
- Multi-tenancy support via scope column
|
|
12
|
+
- 20+ composable query scopes (by actor, target, scope, event type, time, request context)
|
|
13
|
+
- Async processing via ActiveJob with configurable queue
|
|
14
|
+
- Sensitive key filtering for metadata
|
|
15
|
+
- GDPR compliance: `anonymize_actor!` (right to erasure) and `export_for_actor` (right to access)
|
|
16
|
+
- Model concerns: `Auditable` for actors/targets, `AuditScope` for tenant models
|
|
17
|
+
- Install generator with migration and initializer templates
|
|
18
|
+
- Rake tasks for cleanup, archival, statistics, and GDPR operations
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright TODO: Write your name
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# StandardAudit
|
|
2
|
+
|
|
3
|
+
Database-backed audit logging for Rails via ActiveSupport::Notifications.
|
|
4
|
+
|
|
5
|
+
StandardAudit is a standalone Rails engine that captures audit events into a dedicated `audit_logs` table. It uses [GlobalID](https://github.com/rails/globalid) for polymorphic references, making it work with any ActiveRecord model without foreign keys or tight coupling.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "standard_audit"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Run the install generator:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
rails generate standard_audit:install
|
|
19
|
+
rails db:migrate
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
This creates:
|
|
23
|
+
- A migration for the `audit_logs` table (UUID primary keys, JSON metadata)
|
|
24
|
+
- An initializer at `config/initializers/standard_audit.rb`
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
### 1. Subscribe to events
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# config/initializers/standard_audit.rb
|
|
32
|
+
StandardAudit.configure do |config|
|
|
33
|
+
config.subscribe_to "myapp.*"
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 2. Instrument events in your code
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
ActiveSupport::Notifications.instrument("myapp.orders.created", {
|
|
41
|
+
actor: current_user,
|
|
42
|
+
target: @order,
|
|
43
|
+
scope: current_organisation
|
|
44
|
+
})
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### 3. Query the logs
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
StandardAudit::AuditLog.for_actor(current_user).this_week
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Recording Events
|
|
54
|
+
|
|
55
|
+
StandardAudit provides three ways to record audit events.
|
|
56
|
+
|
|
57
|
+
### Convenience API
|
|
58
|
+
|
|
59
|
+
The simplest approach — call `StandardAudit.record` directly:
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
StandardAudit.record("orders.created",
|
|
63
|
+
actor: current_user,
|
|
64
|
+
target: @order,
|
|
65
|
+
scope: current_organisation,
|
|
66
|
+
metadata: { total: @order.total }
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
When `actor` is omitted, it falls back to the configured `current_actor_resolver` (which reads from `Current.user` by default).
|
|
71
|
+
|
|
72
|
+
### ActiveSupport::Notifications
|
|
73
|
+
|
|
74
|
+
Instrument events and let the subscriber handle persistence:
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
ActiveSupport::Notifications.instrument("myapp.orders.created", {
|
|
78
|
+
actor: current_user,
|
|
79
|
+
target: @order,
|
|
80
|
+
scope: current_organisation,
|
|
81
|
+
total: 99.99
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Any payload keys not in the reserved set (`actor`, `target`, `scope`, `request_id`, `ip_address`, `user_agent`, `session_id`) are stored as metadata.
|
|
86
|
+
|
|
87
|
+
### Block form
|
|
88
|
+
|
|
89
|
+
Wrap an operation so the event is only recorded if the block succeeds:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
StandardAudit.record("orders.created", actor: current_user, target: @order) do
|
|
93
|
+
@order.process!
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
This uses `ActiveSupport::Notifications.instrument` under the hood.
|
|
98
|
+
|
|
99
|
+
## Model Concerns
|
|
100
|
+
|
|
101
|
+
### Auditable
|
|
102
|
+
|
|
103
|
+
Include `StandardAudit::Auditable` in models that act as actors or targets:
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
class User < ApplicationRecord
|
|
107
|
+
include StandardAudit::Auditable
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
This provides:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
user.audit_logs_as_actor # logs where this user is the actor
|
|
115
|
+
user.audit_logs_as_target # logs where this user is the target
|
|
116
|
+
user.audit_logs # logs where this user is either
|
|
117
|
+
user.record_audit("users.updated", target: @profile)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### AuditScope
|
|
121
|
+
|
|
122
|
+
Include `StandardAudit::AuditScope` in tenant/organisation models:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
class Organisation < ApplicationRecord
|
|
126
|
+
include StandardAudit::AuditScope
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This provides:
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
organisation.scoped_audit_logs # all logs scoped to this organisation
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Configuration Reference
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
StandardAudit.configure do |config|
|
|
140
|
+
# -- Subscriptions --
|
|
141
|
+
# Subscribe to ActiveSupport::Notifications patterns.
|
|
142
|
+
# Supports wildcards.
|
|
143
|
+
config.subscribe_to "myapp.*"
|
|
144
|
+
config.subscribe_to "auth.*"
|
|
145
|
+
|
|
146
|
+
# -- Extractors --
|
|
147
|
+
# How to pull actor/target/scope from notification payloads.
|
|
148
|
+
# Defaults shown below.
|
|
149
|
+
config.actor_extractor = ->(payload) { payload[:actor] }
|
|
150
|
+
config.target_extractor = ->(payload) { payload[:target] }
|
|
151
|
+
config.scope_extractor = ->(payload) { payload[:scope] }
|
|
152
|
+
|
|
153
|
+
# -- Current Attribute Resolvers --
|
|
154
|
+
# Fallbacks used when payload values are nil.
|
|
155
|
+
# Designed to work with Rails Current attributes.
|
|
156
|
+
config.current_actor_resolver = -> { Current.user }
|
|
157
|
+
config.current_request_id_resolver = -> { Current.request_id }
|
|
158
|
+
config.current_ip_address_resolver = -> { Current.ip_address }
|
|
159
|
+
config.current_user_agent_resolver = -> { Current.user_agent }
|
|
160
|
+
config.current_session_id_resolver = -> { Current.session_id }
|
|
161
|
+
|
|
162
|
+
# -- Sensitive Data --
|
|
163
|
+
# Keys automatically stripped from metadata.
|
|
164
|
+
config.sensitive_keys = %i[password password_confirmation token secret]
|
|
165
|
+
|
|
166
|
+
# -- Metadata Builder --
|
|
167
|
+
# Optional proc to transform metadata before storage.
|
|
168
|
+
config.metadata_builder = ->(metadata) { metadata.slice(:relevant_key) }
|
|
169
|
+
|
|
170
|
+
# -- Async Processing --
|
|
171
|
+
# Offload audit log creation to ActiveJob.
|
|
172
|
+
config.async = false
|
|
173
|
+
config.queue_name = :default
|
|
174
|
+
|
|
175
|
+
# -- Feature Toggle --
|
|
176
|
+
config.enabled = true
|
|
177
|
+
|
|
178
|
+
# -- GDPR --
|
|
179
|
+
# Metadata keys to strip during anonymization.
|
|
180
|
+
config.anonymizable_metadata_keys = %i[email name ip_address]
|
|
181
|
+
|
|
182
|
+
# -- Retention --
|
|
183
|
+
config.retention_days = 90
|
|
184
|
+
config.auto_cleanup = false
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### Default Current Attribute Resolvers
|
|
189
|
+
|
|
190
|
+
Out of the box, StandardAudit reads from `Current` if it responds to the relevant method. This means if your app (or an auth library like StandardId) populates `Current.user`, `Current.request_id`, etc., audit logs automatically capture request context with zero configuration.
|
|
191
|
+
|
|
192
|
+
## Query Interface
|
|
193
|
+
|
|
194
|
+
`StandardAudit::AuditLog` ships with composable scopes:
|
|
195
|
+
|
|
196
|
+
### By association
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
AuditLog.for_actor(user) # logs for a specific actor
|
|
200
|
+
AuditLog.for_target(order) # logs for a specific target
|
|
201
|
+
AuditLog.for_scope(organisation) # logs within a scope/tenant
|
|
202
|
+
AuditLog.by_actor_type("User") # logs by actor class name
|
|
203
|
+
AuditLog.by_target_type("Order") # logs by target class name
|
|
204
|
+
AuditLog.by_scope_type("Organisation")
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### By event
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
AuditLog.by_event_type("orders.created") # exact match
|
|
211
|
+
AuditLog.matching_event("orders.%") # SQL LIKE pattern
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### By time
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
AuditLog.today
|
|
218
|
+
AuditLog.yesterday
|
|
219
|
+
AuditLog.this_week
|
|
220
|
+
AuditLog.this_month
|
|
221
|
+
AuditLog.last_n_days(30)
|
|
222
|
+
AuditLog.since(1.hour.ago)
|
|
223
|
+
AuditLog.before(1.day.ago)
|
|
224
|
+
AuditLog.between(start_time, end_time)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### By request context
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
AuditLog.for_request("req-abc-123")
|
|
231
|
+
AuditLog.from_ip("192.168.1.1")
|
|
232
|
+
AuditLog.for_session("session-xyz")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Ordering
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
AuditLog.chronological # oldest first
|
|
239
|
+
AuditLog.reverse_chronological # newest first
|
|
240
|
+
AuditLog.recent(20) # newest 20 records
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Composing queries
|
|
244
|
+
|
|
245
|
+
All scopes are chainable:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
AuditLog
|
|
249
|
+
.for_scope(current_organisation)
|
|
250
|
+
.by_event_type("orders.created")
|
|
251
|
+
.this_month
|
|
252
|
+
.reverse_chronological
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
## Multi-Tenancy
|
|
256
|
+
|
|
257
|
+
StandardAudit supports multi-tenancy through the `scope` column. Pass any ActiveRecord model as the scope — typically an Organisation or Account:
|
|
258
|
+
|
|
259
|
+
```ruby
|
|
260
|
+
StandardAudit.record("orders.created",
|
|
261
|
+
actor: current_user,
|
|
262
|
+
target: @order,
|
|
263
|
+
scope: current_organisation
|
|
264
|
+
)
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Then query all audit activity within that tenant:
|
|
268
|
+
|
|
269
|
+
```ruby
|
|
270
|
+
StandardAudit::AuditLog.for_scope(current_organisation)
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
The scope is stored as a GlobalID string, so it works with any model class.
|
|
274
|
+
|
|
275
|
+
## Async Processing
|
|
276
|
+
|
|
277
|
+
For high-throughput applications, offload audit log creation to a background job:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
StandardAudit.configure do |config|
|
|
281
|
+
config.async = true
|
|
282
|
+
config.queue_name = :audit # default: :default
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
When async is enabled, `StandardAudit::CreateAuditLogJob` serialises actor, target, and scope as GlobalID strings and resolves them back when the job runs. If a referenced record has been deleted between event capture and job execution, the GID string and type are preserved on the audit log (the record just won't be resolvable).
|
|
287
|
+
|
|
288
|
+
## GDPR Compliance
|
|
289
|
+
|
|
290
|
+
### Right to Erasure (Anonymization)
|
|
291
|
+
|
|
292
|
+
Strip personally identifiable information from audit logs while preserving the event timeline:
|
|
293
|
+
|
|
294
|
+
```ruby
|
|
295
|
+
StandardAudit::AuditLog.anonymize_actor!(user)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
This:
|
|
299
|
+
- Replaces `actor_gid` / `target_gid` with `[anonymized]` where the user appears
|
|
300
|
+
- Clears `ip_address`, `user_agent`, and `session_id`
|
|
301
|
+
- Removes metadata keys listed in `anonymizable_metadata_keys`
|
|
302
|
+
|
|
303
|
+
### Right to Access (Export)
|
|
304
|
+
|
|
305
|
+
Export all audit data for a specific user:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
data = StandardAudit::AuditLog.export_for_actor(user)
|
|
309
|
+
File.write("export.json", JSON.pretty_generate(data))
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Returns a hash with `subject`, `exported_at`, `total_records`, and a `records` array.
|
|
313
|
+
|
|
314
|
+
## Rake Tasks
|
|
315
|
+
|
|
316
|
+
```bash
|
|
317
|
+
# Delete logs older than N days (default: retention_days config or 90)
|
|
318
|
+
rake standard_audit:cleanup[180]
|
|
319
|
+
|
|
320
|
+
# Archive old logs to a JSON file before deleting
|
|
321
|
+
rake standard_audit:archive[90,audit_backup.json]
|
|
322
|
+
|
|
323
|
+
# Show statistics
|
|
324
|
+
rake standard_audit:stats
|
|
325
|
+
|
|
326
|
+
# GDPR: anonymize all logs for an actor
|
|
327
|
+
rake "standard_audit:anonymize_actor[gid://myapp/User/123]"
|
|
328
|
+
|
|
329
|
+
# GDPR: export all logs for an actor
|
|
330
|
+
rake "standard_audit:export_actor[gid://myapp/User/123,export.json]"
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Database Support
|
|
334
|
+
|
|
335
|
+
The migration uses `json` column type by default, which works across:
|
|
336
|
+
|
|
337
|
+
| Database | Column Type | Notes |
|
|
338
|
+
|------------|-------------|-------|
|
|
339
|
+
| PostgreSQL | `jsonb` | Consider changing `json` to `jsonb` in the migration for better query performance |
|
|
340
|
+
| MySQL | `json` | Native JSON support |
|
|
341
|
+
| SQLite | `json` | Stored as text; suitable for development and testing |
|
|
342
|
+
|
|
343
|
+
For PostgreSQL, edit the generated migration to use `jsonb` instead of `json`:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
t.jsonb :metadata, default: {}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
## Best Practices
|
|
350
|
+
|
|
351
|
+
**What to audit**: Authentication events, data mutations, permission changes, financial transactions, admin actions, data exports, and API access from external services.
|
|
352
|
+
|
|
353
|
+
**Sensitive data**: Configure `sensitive_keys` to automatically strip passwords, tokens, and secrets from metadata. Add domain-specific keys as needed:
|
|
354
|
+
|
|
355
|
+
```ruby
|
|
356
|
+
config.sensitive_keys = %i[password token secret ssn credit_card_number]
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**Performance**: For high-volume applications, enable async processing and ensure your `audit_logs` table has appropriate indexes (the install generator adds them by default). Consider partitioning by `occurred_at` for very large tables.
|
|
360
|
+
|
|
361
|
+
**Retention**: Set `retention_days` in your configuration and run `rake standard_audit:cleanup` via a scheduled job (e.g., cron or SolidQueue recurring). Archive before deleting if you need long-term storage.
|
|
362
|
+
|
|
363
|
+
## License
|
|
364
|
+
|
|
365
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
class CreateAuditLogJob < ActiveJob::Base
|
|
3
|
+
queue_as { StandardAudit.config.queue_name }
|
|
4
|
+
|
|
5
|
+
def perform(attrs)
|
|
6
|
+
attrs = attrs.symbolize_keys
|
|
7
|
+
|
|
8
|
+
actor_gid = attrs.delete(:actor_gid)
|
|
9
|
+
target_gid = attrs.delete(:target_gid)
|
|
10
|
+
scope_gid = attrs.delete(:scope_gid)
|
|
11
|
+
actor_type = attrs.delete(:actor_type)
|
|
12
|
+
target_type = attrs.delete(:target_type)
|
|
13
|
+
scope_type = attrs.delete(:scope_type)
|
|
14
|
+
|
|
15
|
+
log = StandardAudit::AuditLog.new(attrs)
|
|
16
|
+
|
|
17
|
+
if actor_gid.present?
|
|
18
|
+
begin
|
|
19
|
+
log.actor = GlobalID::Locator.locate(actor_gid)
|
|
20
|
+
rescue ActiveRecord::RecordNotFound
|
|
21
|
+
log.actor_gid = actor_gid
|
|
22
|
+
log.actor_type = actor_type
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
if target_gid.present?
|
|
27
|
+
begin
|
|
28
|
+
log.target = GlobalID::Locator.locate(target_gid)
|
|
29
|
+
rescue ActiveRecord::RecordNotFound
|
|
30
|
+
log.target_gid = target_gid
|
|
31
|
+
log.target_type = target_type
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
if scope_gid.present?
|
|
36
|
+
begin
|
|
37
|
+
log.scope = GlobalID::Locator.locate(scope_gid)
|
|
38
|
+
rescue ActiveRecord::RecordNotFound
|
|
39
|
+
log.scope_gid = scope_gid
|
|
40
|
+
log.scope_type = scope_type
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
log.save!
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
class AuditLog < ApplicationRecord
|
|
3
|
+
self.table_name = "audit_logs"
|
|
4
|
+
|
|
5
|
+
before_create :assign_uuid, if: -> { id.blank? }
|
|
6
|
+
|
|
7
|
+
validates :event_type, presence: true
|
|
8
|
+
validates :occurred_at, presence: true
|
|
9
|
+
|
|
10
|
+
# -- Actor assignment via GlobalID --
|
|
11
|
+
|
|
12
|
+
def actor=(record)
|
|
13
|
+
if record.nil?
|
|
14
|
+
self.actor_gid = nil
|
|
15
|
+
self.actor_type = nil
|
|
16
|
+
else
|
|
17
|
+
self.actor_gid = record.to_global_id.to_s
|
|
18
|
+
self.actor_type = record.class.name
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def actor
|
|
23
|
+
return nil if actor_gid.blank?
|
|
24
|
+
GlobalID::Locator.locate(actor_gid)
|
|
25
|
+
rescue ActiveRecord::RecordNotFound
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# -- Target assignment via GlobalID --
|
|
30
|
+
|
|
31
|
+
def target=(record)
|
|
32
|
+
if record.nil?
|
|
33
|
+
self.target_gid = nil
|
|
34
|
+
self.target_type = nil
|
|
35
|
+
else
|
|
36
|
+
self.target_gid = record.to_global_id.to_s
|
|
37
|
+
self.target_type = record.class.name
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def target
|
|
42
|
+
return nil if target_gid.blank?
|
|
43
|
+
GlobalID::Locator.locate(target_gid)
|
|
44
|
+
rescue ActiveRecord::RecordNotFound
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -- Scope assignment via GlobalID --
|
|
49
|
+
|
|
50
|
+
def scope=(record)
|
|
51
|
+
if record.nil?
|
|
52
|
+
self.scope_gid = nil
|
|
53
|
+
self.scope_type = nil
|
|
54
|
+
else
|
|
55
|
+
self.scope_gid = record.to_global_id.to_s
|
|
56
|
+
self.scope_type = record.class.name
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def scope
|
|
61
|
+
return nil if scope_gid.blank?
|
|
62
|
+
GlobalID::Locator.locate(scope_gid)
|
|
63
|
+
rescue ActiveRecord::RecordNotFound
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# -- Query scopes --
|
|
68
|
+
|
|
69
|
+
scope :for_actor, ->(record) { where(actor_gid: record.to_global_id.to_s) }
|
|
70
|
+
scope :by_actor_type, ->(type) { where(actor_type: type.is_a?(Class) ? type.name : type.to_s) }
|
|
71
|
+
scope :for_target, ->(record) { where(target_gid: record.to_global_id.to_s) }
|
|
72
|
+
scope :by_target_type, ->(type) { where(target_type: type.is_a?(Class) ? type.name : type.to_s) }
|
|
73
|
+
scope :for_scope, ->(record) { where(scope_gid: record.to_global_id.to_s) }
|
|
74
|
+
scope :by_scope_type, ->(type) { where(scope_type: type.is_a?(Class) ? type.name : type.to_s) }
|
|
75
|
+
scope :by_event_type, ->(event_type) { where(event_type: event_type) }
|
|
76
|
+
scope :matching_event, ->(pattern) { where("event_type LIKE ?", pattern) }
|
|
77
|
+
scope :between, ->(start_time, end_time) { where(occurred_at: start_time..end_time) }
|
|
78
|
+
scope :since, ->(time) { where("occurred_at >= ?", time) }
|
|
79
|
+
scope :before, ->(time) { where("occurred_at < ?", time) }
|
|
80
|
+
scope :today, -> { where(occurred_at: Time.current.beginning_of_day..Time.current.end_of_day) }
|
|
81
|
+
scope :yesterday, -> { where(occurred_at: 1.day.ago.beginning_of_day..1.day.ago.end_of_day) }
|
|
82
|
+
scope :this_week, -> { where(occurred_at: Time.current.beginning_of_week..Time.current.end_of_week) }
|
|
83
|
+
scope :this_month, -> { where(occurred_at: Time.current.beginning_of_month..Time.current.end_of_month) }
|
|
84
|
+
scope :last_n_days, ->(n) { where("occurred_at >= ?", n.days.ago.beginning_of_day) }
|
|
85
|
+
scope :for_request, ->(request_id) { where(request_id: request_id) }
|
|
86
|
+
scope :from_ip, ->(ip_address) { where(ip_address: ip_address) }
|
|
87
|
+
scope :for_session, ->(session_id) { where(session_id: session_id) }
|
|
88
|
+
scope :chronological, -> { order(occurred_at: :asc) }
|
|
89
|
+
scope :reverse_chronological, -> { order(occurred_at: :desc) }
|
|
90
|
+
scope :recent, ->(n = 10) { reverse_chronological.limit(n) }
|
|
91
|
+
|
|
92
|
+
# -- GDPR methods --
|
|
93
|
+
|
|
94
|
+
def self.anonymize_actor!(record)
|
|
95
|
+
gid = record.to_global_id.to_s
|
|
96
|
+
logs = where("actor_gid = ? OR target_gid = ?", gid, gid)
|
|
97
|
+
count = logs.count
|
|
98
|
+
|
|
99
|
+
anonymizable_keys = StandardAudit.config.anonymizable_metadata_keys.map(&:to_s)
|
|
100
|
+
|
|
101
|
+
logs.find_each do |log|
|
|
102
|
+
attrs = {
|
|
103
|
+
ip_address: nil,
|
|
104
|
+
user_agent: nil,
|
|
105
|
+
session_id: nil
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
attrs[:actor_gid] = "[anonymized]" if log.actor_gid == gid
|
|
109
|
+
attrs[:actor_type] = "[anonymized]" if log.actor_gid == gid
|
|
110
|
+
attrs[:target_gid] = "[anonymized]" if log.target_gid == gid
|
|
111
|
+
attrs[:target_type] = "[anonymized]" if log.target_gid == gid
|
|
112
|
+
|
|
113
|
+
if log.metadata.present? && anonymizable_keys.any?
|
|
114
|
+
cleaned_metadata = log.metadata.reject { |k, _| anonymizable_keys.include?(k.to_s) }
|
|
115
|
+
attrs[:metadata] = cleaned_metadata
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
log.update_columns(attrs)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
count
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def self.export_for_actor(record)
|
|
125
|
+
gid = record.to_global_id.to_s
|
|
126
|
+
logs = where("actor_gid = ? OR target_gid = ?", gid, gid).chronological
|
|
127
|
+
|
|
128
|
+
records = logs.map do |log|
|
|
129
|
+
{
|
|
130
|
+
id: log.id,
|
|
131
|
+
event_type: log.event_type,
|
|
132
|
+
actor_gid: log.actor_gid,
|
|
133
|
+
target_gid: log.target_gid,
|
|
134
|
+
scope_gid: log.scope_gid,
|
|
135
|
+
metadata: log.metadata,
|
|
136
|
+
occurred_at: log.occurred_at.iso8601,
|
|
137
|
+
ip_address: log.ip_address,
|
|
138
|
+
user_agent: log.user_agent,
|
|
139
|
+
request_id: log.request_id
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
{
|
|
144
|
+
subject: gid,
|
|
145
|
+
exported_at: Time.current.iso8601,
|
|
146
|
+
total_records: records.size,
|
|
147
|
+
records: records
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def assign_uuid
|
|
154
|
+
self.id = SecureRandom.uuid
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
module Generators
|
|
3
|
+
class InstallGenerator < Rails::Generators::Base
|
|
4
|
+
include Rails::Generators::Migration
|
|
5
|
+
source_root File.expand_path("templates", __dir__)
|
|
6
|
+
|
|
7
|
+
def self.next_migration_number(dirname)
|
|
8
|
+
Time.now.utc.strftime("%Y%m%d%H%M%S")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def copy_migration
|
|
12
|
+
migration_template "create_audit_logs.rb.erb", "db/migrate/create_audit_logs.rb"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def copy_initializer
|
|
16
|
+
template "initializer.rb.erb", "config/initializers/standard_audit.rb"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
def change
|
|
3
|
+
create_table :audit_logs, id: :uuid do |t|
|
|
4
|
+
t.string :actor_gid
|
|
5
|
+
t.string :actor_type
|
|
6
|
+
t.string :target_gid
|
|
7
|
+
t.string :target_type
|
|
8
|
+
t.string :scope_gid
|
|
9
|
+
t.string :scope_type
|
|
10
|
+
t.string :event_type, null: false
|
|
11
|
+
t.string :request_id
|
|
12
|
+
t.string :ip_address
|
|
13
|
+
t.string :user_agent
|
|
14
|
+
t.string :session_id
|
|
15
|
+
t.json :metadata, default: {}
|
|
16
|
+
t.datetime :occurred_at, null: false
|
|
17
|
+
t.timestamps
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
add_index :audit_logs, :event_type
|
|
21
|
+
add_index :audit_logs, :actor_type
|
|
22
|
+
add_index :audit_logs, :target_type
|
|
23
|
+
add_index :audit_logs, [:scope_type, :scope_gid]
|
|
24
|
+
add_index :audit_logs, :scope_type
|
|
25
|
+
add_index :audit_logs, :request_id
|
|
26
|
+
add_index :audit_logs, :occurred_at
|
|
27
|
+
add_index :audit_logs, :ip_address
|
|
28
|
+
add_index :audit_logs, :session_id
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
StandardAudit.configure do |config|
|
|
2
|
+
# Subscribe to ActiveSupport::Notifications patterns
|
|
3
|
+
# config.subscribe_to "audit.**"
|
|
4
|
+
|
|
5
|
+
# Actor extractor from notification payload
|
|
6
|
+
# config.actor_extractor = ->(payload) { payload[:actor] }
|
|
7
|
+
|
|
8
|
+
# Target extractor from notification payload
|
|
9
|
+
# config.target_extractor = ->(payload) { payload[:target] }
|
|
10
|
+
|
|
11
|
+
# Scope extractor from notification payload
|
|
12
|
+
# config.scope_extractor = ->(payload) { payload[:scope] }
|
|
13
|
+
|
|
14
|
+
# Fallback resolvers (used when payload values are nil)
|
|
15
|
+
# config.current_actor_resolver = -> { Current.user }
|
|
16
|
+
# config.current_request_id_resolver = -> { Current.request_id }
|
|
17
|
+
# config.current_ip_address_resolver = -> { Current.ip_address }
|
|
18
|
+
# config.current_user_agent_resolver = -> { Current.user_agent }
|
|
19
|
+
# config.current_session_id_resolver = -> { Current.session_id }
|
|
20
|
+
|
|
21
|
+
# Keys to strip from metadata
|
|
22
|
+
# config.sensitive_keys = %i[password password_confirmation token secret]
|
|
23
|
+
|
|
24
|
+
# Run audit log creation in background job
|
|
25
|
+
# config.async = false
|
|
26
|
+
# config.queue_name = :default
|
|
27
|
+
|
|
28
|
+
# Enable/disable audit logging
|
|
29
|
+
# config.enabled = true
|
|
30
|
+
|
|
31
|
+
# GDPR: metadata keys to strip on anonymization
|
|
32
|
+
# config.anonymizable_metadata_keys = %i[email name ip_address]
|
|
33
|
+
|
|
34
|
+
# Data retention
|
|
35
|
+
# config.retention_days = nil
|
|
36
|
+
# config.auto_cleanup = false
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
module Auditable
|
|
3
|
+
extend ActiveSupport::Concern
|
|
4
|
+
|
|
5
|
+
def audit_logs_as_actor
|
|
6
|
+
StandardAudit::AuditLog.for_actor(self)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def audit_logs_as_target
|
|
10
|
+
StandardAudit::AuditLog.for_target(self)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def audit_logs
|
|
14
|
+
gid = to_global_id.to_s
|
|
15
|
+
StandardAudit::AuditLog.where("actor_gid = ? OR target_gid = ?", gid, gid)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record_audit(event_type, target: nil, scope: nil, metadata: {}, **options)
|
|
19
|
+
StandardAudit.record(
|
|
20
|
+
event_type,
|
|
21
|
+
actor: self,
|
|
22
|
+
target: target,
|
|
23
|
+
scope: scope,
|
|
24
|
+
metadata: metadata,
|
|
25
|
+
**options
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
class Configuration
|
|
3
|
+
attr_accessor :async, :queue_name, :enabled,
|
|
4
|
+
:actor_extractor, :target_extractor, :scope_extractor,
|
|
5
|
+
:current_actor_resolver, :current_request_id_resolver,
|
|
6
|
+
:current_ip_address_resolver, :current_user_agent_resolver,
|
|
7
|
+
:current_session_id_resolver,
|
|
8
|
+
:sensitive_keys, :metadata_builder,
|
|
9
|
+
:anonymizable_metadata_keys, :retention_days, :auto_cleanup
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@subscriptions = []
|
|
13
|
+
@async = false
|
|
14
|
+
@queue_name = :default
|
|
15
|
+
@enabled = true
|
|
16
|
+
|
|
17
|
+
@actor_extractor = ->(payload) { payload[:actor] }
|
|
18
|
+
@target_extractor = ->(payload) { payload[:target] }
|
|
19
|
+
@scope_extractor = ->(payload) { payload[:scope] }
|
|
20
|
+
|
|
21
|
+
@current_actor_resolver = -> {
|
|
22
|
+
defined?(Current) && Current.respond_to?(:user) ? Current.user : nil
|
|
23
|
+
}
|
|
24
|
+
@current_request_id_resolver = -> {
|
|
25
|
+
defined?(Current) && Current.respond_to?(:request_id) ? Current.request_id : nil
|
|
26
|
+
}
|
|
27
|
+
@current_ip_address_resolver = -> {
|
|
28
|
+
defined?(Current) && Current.respond_to?(:ip_address) ? Current.ip_address : nil
|
|
29
|
+
}
|
|
30
|
+
@current_user_agent_resolver = -> {
|
|
31
|
+
defined?(Current) && Current.respond_to?(:user_agent) ? Current.user_agent : nil
|
|
32
|
+
}
|
|
33
|
+
@current_session_id_resolver = -> {
|
|
34
|
+
defined?(Current) && Current.respond_to?(:session_id) ? Current.session_id : nil
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@sensitive_keys = %i[password password_confirmation token secret]
|
|
38
|
+
@metadata_builder = nil
|
|
39
|
+
@anonymizable_metadata_keys = %i[email name ip_address]
|
|
40
|
+
@retention_days = nil
|
|
41
|
+
@auto_cleanup = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def subscribe_to(pattern)
|
|
45
|
+
@subscriptions << pattern
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def subscriptions
|
|
49
|
+
@subscriptions.dup.freeze
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
class Subscriber
|
|
3
|
+
attr_reader :subscriptions
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@subscriptions = []
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def setup!
|
|
10
|
+
config = StandardAudit.config
|
|
11
|
+
config.subscriptions.each do |pattern|
|
|
12
|
+
subscriber = ActiveSupport::Notifications.subscribe(pattern) do |event|
|
|
13
|
+
handle_event(event)
|
|
14
|
+
end
|
|
15
|
+
@subscriptions << subscriber
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def teardown!
|
|
20
|
+
@subscriptions.each do |subscriber|
|
|
21
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
22
|
+
end
|
|
23
|
+
@subscriptions.clear
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def handle_event(event)
|
|
29
|
+
return unless StandardAudit.config.enabled
|
|
30
|
+
|
|
31
|
+
config = StandardAudit.config
|
|
32
|
+
payload = event.payload
|
|
33
|
+
|
|
34
|
+
actor = config.actor_extractor.call(payload)
|
|
35
|
+
target = config.target_extractor.call(payload)
|
|
36
|
+
scope = config.scope_extractor.call(payload)
|
|
37
|
+
|
|
38
|
+
# Fall back to Current attributes when payload values are nil
|
|
39
|
+
actor ||= config.current_actor_resolver.call
|
|
40
|
+
|
|
41
|
+
metadata = extract_metadata(payload, config)
|
|
42
|
+
|
|
43
|
+
attrs = {
|
|
44
|
+
event_type: event.name,
|
|
45
|
+
occurred_at: Time.current,
|
|
46
|
+
request_id: payload[:request_id] || config.current_request_id_resolver.call,
|
|
47
|
+
ip_address: payload[:ip_address] || config.current_ip_address_resolver.call,
|
|
48
|
+
user_agent: payload[:user_agent] || config.current_user_agent_resolver.call,
|
|
49
|
+
session_id: payload[:session_id] || config.current_session_id_resolver.call,
|
|
50
|
+
metadata: metadata
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if config.async
|
|
54
|
+
job_attrs = attrs.dup
|
|
55
|
+
job_attrs[:actor_gid] = actor&.to_global_id&.to_s
|
|
56
|
+
job_attrs[:target_gid] = target&.to_global_id&.to_s
|
|
57
|
+
job_attrs[:scope_gid] = scope&.to_global_id&.to_s
|
|
58
|
+
job_attrs[:actor_type] = actor&.class&.name
|
|
59
|
+
job_attrs[:target_type] = target&.class&.name
|
|
60
|
+
job_attrs[:scope_type] = scope&.class&.name
|
|
61
|
+
StandardAudit::CreateAuditLogJob.perform_later(job_attrs.stringify_keys)
|
|
62
|
+
else
|
|
63
|
+
log = StandardAudit::AuditLog.new(attrs)
|
|
64
|
+
log.actor = actor
|
|
65
|
+
log.target = target
|
|
66
|
+
log.scope = scope
|
|
67
|
+
log.save!
|
|
68
|
+
end
|
|
69
|
+
rescue => e
|
|
70
|
+
Rails.logger.error("[StandardAudit] Error creating audit log: #{e.message}")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def extract_metadata(payload, config)
|
|
74
|
+
# Remove known non-metadata keys
|
|
75
|
+
excluded_keys = %i[actor target scope request_id ip_address user_agent session_id]
|
|
76
|
+
raw_metadata = payload.except(*excluded_keys)
|
|
77
|
+
|
|
78
|
+
if config.metadata_builder
|
|
79
|
+
raw_metadata = config.metadata_builder.call(raw_metadata)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Filter sensitive keys
|
|
83
|
+
sensitive = config.sensitive_keys.map(&:to_s)
|
|
84
|
+
raw_metadata.reject { |k, _| sensitive.include?(k.to_s) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require "standard_audit/version"
|
|
2
|
+
require "standard_audit/engine"
|
|
3
|
+
require "standard_audit/configuration"
|
|
4
|
+
require "standard_audit/subscriber"
|
|
5
|
+
require "standard_audit/auditable"
|
|
6
|
+
require "standard_audit/audit_scope"
|
|
7
|
+
|
|
8
|
+
module StandardAudit
|
|
9
|
+
class << self
|
|
10
|
+
def configure
|
|
11
|
+
yield(config) if block_given?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def config
|
|
15
|
+
@configuration ||= Configuration.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record(event_type, actor: nil, target: nil, scope: nil, metadata: {}, **options)
|
|
19
|
+
return unless config.enabled
|
|
20
|
+
|
|
21
|
+
actor ||= config.current_actor_resolver.call
|
|
22
|
+
|
|
23
|
+
# Filter sensitive keys
|
|
24
|
+
sensitive = config.sensitive_keys.map(&:to_s)
|
|
25
|
+
filtered_metadata = metadata.reject { |k, _| sensitive.include?(k.to_s) }
|
|
26
|
+
|
|
27
|
+
attrs = {
|
|
28
|
+
event_type: event_type,
|
|
29
|
+
occurred_at: Time.current,
|
|
30
|
+
request_id: options[:request_id] || config.current_request_id_resolver.call,
|
|
31
|
+
ip_address: options[:ip_address] || config.current_ip_address_resolver.call,
|
|
32
|
+
user_agent: options[:user_agent] || config.current_user_agent_resolver.call,
|
|
33
|
+
session_id: options[:session_id] || config.current_session_id_resolver.call,
|
|
34
|
+
metadata: filtered_metadata
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if block_given?
|
|
38
|
+
# Block form: instrument via ActiveSupport::Notifications
|
|
39
|
+
ActiveSupport::Notifications.instrument(event_type, metadata.merge(
|
|
40
|
+
actor: actor, target: target, scope: scope
|
|
41
|
+
)) do
|
|
42
|
+
yield
|
|
43
|
+
end
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if config.async
|
|
48
|
+
job_attrs = attrs.dup
|
|
49
|
+
job_attrs[:actor_gid] = actor&.to_global_id&.to_s
|
|
50
|
+
job_attrs[:target_gid] = target&.to_global_id&.to_s
|
|
51
|
+
job_attrs[:scope_gid] = scope&.to_global_id&.to_s
|
|
52
|
+
job_attrs[:actor_type] = actor&.class&.name
|
|
53
|
+
job_attrs[:target_type] = target&.class&.name
|
|
54
|
+
job_attrs[:scope_type] = scope&.class&.name
|
|
55
|
+
StandardAudit::CreateAuditLogJob.perform_later(job_attrs.stringify_keys)
|
|
56
|
+
else
|
|
57
|
+
log = StandardAudit::AuditLog.new(attrs)
|
|
58
|
+
log.actor = actor
|
|
59
|
+
log.target = target
|
|
60
|
+
log.scope = scope
|
|
61
|
+
log.save!
|
|
62
|
+
log
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def subscriber
|
|
67
|
+
@subscriber ||= Subscriber.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def reset_configuration!
|
|
71
|
+
@configuration = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
namespace :standard_audit do
|
|
2
|
+
desc "Delete audit logs older than specified days (default: 90)"
|
|
3
|
+
task :cleanup, [:days] => :environment do |_t, args|
|
|
4
|
+
days = (args[:days] || StandardAudit.config.retention_days || 90).to_i
|
|
5
|
+
cutoff = days.days.ago
|
|
6
|
+
|
|
7
|
+
deleted = StandardAudit::AuditLog.where("occurred_at < ?", cutoff).delete_all
|
|
8
|
+
puts "Deleted #{deleted} audit logs older than #{days} days"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
desc "Archive audit logs to JSON file"
|
|
12
|
+
task :archive, [:days, :output] => :environment do |_t, args|
|
|
13
|
+
days = (args[:days] || 90).to_i
|
|
14
|
+
output = args[:output] || "audit_logs_archive_#{Date.current}.json"
|
|
15
|
+
cutoff = days.days.ago
|
|
16
|
+
|
|
17
|
+
logs = StandardAudit::AuditLog.where("occurred_at < ?", cutoff)
|
|
18
|
+
|
|
19
|
+
File.open(output, "w") do |f|
|
|
20
|
+
logs.find_each do |log|
|
|
21
|
+
f.puts log.attributes.to_json
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
puts "Archived #{logs.count} logs to #{output}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
desc "Show audit log statistics"
|
|
29
|
+
task stats: :environment do
|
|
30
|
+
total = StandardAudit::AuditLog.count
|
|
31
|
+
today = StandardAudit::AuditLog.today.count
|
|
32
|
+
this_week = StandardAudit::AuditLog.this_week.count
|
|
33
|
+
|
|
34
|
+
by_type = StandardAudit::AuditLog
|
|
35
|
+
.group(:event_type)
|
|
36
|
+
.order(count_all: :desc)
|
|
37
|
+
.limit(10)
|
|
38
|
+
.count
|
|
39
|
+
|
|
40
|
+
puts "Audit Log Statistics"
|
|
41
|
+
puts "===================="
|
|
42
|
+
puts "Total: #{total}"
|
|
43
|
+
puts "Today: #{today}"
|
|
44
|
+
puts "This week: #{this_week}"
|
|
45
|
+
puts ""
|
|
46
|
+
puts "Top 10 Event Types:"
|
|
47
|
+
by_type.each { |type, count| puts " #{type}: #{count}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
desc "Anonymize audit logs for a specific actor (GDPR right to erasure)"
|
|
51
|
+
task :anonymize_actor, [:actor_gid] => :environment do |_t, args|
|
|
52
|
+
raise "actor_gid is required" unless args[:actor_gid].present?
|
|
53
|
+
|
|
54
|
+
count = StandardAudit::AuditLog.anonymize_actor!(args[:actor_gid])
|
|
55
|
+
puts "Anonymized #{count} audit logs for #{args[:actor_gid]}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "Export audit logs for a specific actor (GDPR right to access)"
|
|
59
|
+
task :export_actor, [:actor_gid, :output] => :environment do |_t, args|
|
|
60
|
+
raise "actor_gid is required" unless args[:actor_gid].present?
|
|
61
|
+
output = args[:output] || "audit_export_#{Date.current}.json"
|
|
62
|
+
|
|
63
|
+
data = StandardAudit::AuditLog.export_for_actor(args[:actor_gid])
|
|
64
|
+
|
|
65
|
+
File.open(output, "w") do |f|
|
|
66
|
+
f.puts JSON.pretty_generate(data)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
puts "Exported #{data[:total_records]} audit logs to #{output}"
|
|
70
|
+
end
|
|
71
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: standard_audit
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Jaryl Sim
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: activejob
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '7.1'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '7.1'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: activesupport
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7.1'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '7.1'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: globalid
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.0'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.0'
|
|
68
|
+
description: StandardAudit is a standalone Rails gem for database-backed audit logging
|
|
69
|
+
via ActiveSupport::Notifications. Generic, flexible, and works with any Rails application.
|
|
70
|
+
email:
|
|
71
|
+
- code@jaryl.dev
|
|
72
|
+
executables: []
|
|
73
|
+
extensions: []
|
|
74
|
+
extra_rdoc_files: []
|
|
75
|
+
files:
|
|
76
|
+
- CHANGELOG.md
|
|
77
|
+
- MIT-LICENSE
|
|
78
|
+
- README.md
|
|
79
|
+
- Rakefile
|
|
80
|
+
- app/jobs/standard_audit/create_audit_log_job.rb
|
|
81
|
+
- app/models/standard_audit/application_record.rb
|
|
82
|
+
- app/models/standard_audit/audit_log.rb
|
|
83
|
+
- config/routes.rb
|
|
84
|
+
- lib/generators/standard_audit/install/install_generator.rb
|
|
85
|
+
- lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
|
|
86
|
+
- lib/generators/standard_audit/install/templates/initializer.rb.erb
|
|
87
|
+
- lib/standard_audit.rb
|
|
88
|
+
- lib/standard_audit/audit_scope.rb
|
|
89
|
+
- lib/standard_audit/auditable.rb
|
|
90
|
+
- lib/standard_audit/configuration.rb
|
|
91
|
+
- lib/standard_audit/engine.rb
|
|
92
|
+
- lib/standard_audit/subscriber.rb
|
|
93
|
+
- lib/standard_audit/version.rb
|
|
94
|
+
- lib/tasks/standard_audit_tasks.rake
|
|
95
|
+
homepage: https://github.com/rarebit-one/standard_audit
|
|
96
|
+
licenses:
|
|
97
|
+
- MIT
|
|
98
|
+
metadata:
|
|
99
|
+
homepage_uri: https://github.com/rarebit-one/standard_audit
|
|
100
|
+
source_code_uri: https://github.com/rarebit-one/standard_audit
|
|
101
|
+
changelog_uri: https://github.com/rarebit-one/standard_audit/blob/main/CHANGELOG.md
|
|
102
|
+
rdoc_options: []
|
|
103
|
+
require_paths:
|
|
104
|
+
- lib
|
|
105
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '3.2'
|
|
110
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
111
|
+
requirements:
|
|
112
|
+
- - ">="
|
|
113
|
+
- !ruby/object:Gem::Version
|
|
114
|
+
version: '0'
|
|
115
|
+
requirements: []
|
|
116
|
+
rubygems_version: 4.0.3
|
|
117
|
+
specification_version: 4
|
|
118
|
+
summary: Database-backed audit logging for Rails via ActiveSupport::Notifications.
|
|
119
|
+
test_files: []
|