standard_audit 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +59 -2
- data/MIT-LICENSE +1 -1
- data/README.md +3 -4
- data/app/jobs/standard_audit/cleanup_job.rb +14 -0
- data/app/models/standard_audit/audit_log.rb +130 -3
- data/lib/generators/standard_audit/add_checksums/add_checksums_generator.rb +16 -0
- data/lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb +6 -0
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +11 -7
- data/lib/generators/standard_audit/install/templates/initializer.rb.erb +12 -5
- data/lib/standard_audit/configuration.rb +28 -3
- data/lib/standard_audit/presets/standard_id.rb +22 -0
- data/lib/standard_audit/version.rb +1 -1
- data/lib/standard_audit.rb +70 -9
- data/lib/tasks/standard_audit_tasks.rake +24 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ae2015728333aa6f6549bf138de8c27017c78c23f0d930ab3df9cf73c99b14cf
|
|
4
|
+
data.tar.gz: 6e479ec25dcb88c2538a23efbec0796762b4274fd0baa2c5ad6707f57e90b65d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d3ec930cf81109adf17c563d15dba60a0e82ab33bcb0cb6ce422009f5b157b8d1335c46da73269053ed3e09e8169bbada9c548409080871d6dd9e3c928b65ce8
|
|
7
|
+
data.tar.gz: 64906fb08b3d97c7e25626790c32fcf6eb08885cd4c0d5f7cf22a6acf9bd2516101f768fe90d93bfd3db4a3120f726b1e63eaa49036beffcf513d139f68d58f8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
-
|
|
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
|
+
## [0.3.0] - 2026-03-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Tamper detection via chained SHA-256 checksums — each record's `checksum` column hashes its content plus the previous record's checksum
|
|
15
|
+
- `AuditLog.verify_chain` to walk the chain and detect modified records
|
|
16
|
+
- `AuditLog.backfill_checksums!` to retroactively checksum pre-existing records
|
|
17
|
+
- Rake tasks: `standard_audit:verify` (exits non-zero on failure) and `standard_audit:backfill_checksums`
|
|
18
|
+
- Upgrade generator: `rails g standard_audit:add_checksums` adds the checksum column and created_at index
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Primary keys now use UUIDv7 (time-ordered) instead of UUIDv4 for deterministic chain ordering
|
|
23
|
+
- Batch inserts (`StandardAudit.batch`) now compute chained checksums
|
|
24
|
+
|
|
25
|
+
### Upgrade
|
|
26
|
+
|
|
27
|
+
Run the upgrade generator to add the checksum column:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
rails generate standard_audit:add_checksums
|
|
31
|
+
rails db:migrate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Optionally backfill checksums for existing records:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
rake standard_audit:backfill_checksums
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## [0.2.0] - 2026-03-25
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Batch insert mode via `StandardAudit.batch { }` for high-volume audit logging
|
|
45
|
+
- `StandardAudit::CleanupJob` for automated retention enforcement
|
|
46
|
+
- `config.use_preset(:standard_id)` to subscribe to StandardId auth events in one call
|
|
47
|
+
- GIN index on metadata JSONB column in install generator (PostgreSQL)
|
|
48
|
+
- CI-driven gem publishing via GitHub Actions trusted publisher
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- Migration template uses `jsonb` instead of `json` for metadata column
|
|
53
|
+
- Expanded default `sensitive_keys` to include `api_key`, `access_token`, `refresh_token`, `private_key`, `certificate_chain`, `ssn`, `credit_card`, `authorization`
|
|
54
|
+
|
|
55
|
+
### Breaking Changes
|
|
56
|
+
|
|
57
|
+
- AuditLog records are now immutable — `update`/`destroy` raises `ActiveRecord::ReadOnlyRecord`. Use `update_columns` for privileged operations like GDPR anonymization. `delete`/`delete_all` still work for bulk cleanup.
|
|
58
|
+
- Removed `auto_cleanup` config attribute. Schedule `StandardAudit::CleanupJob` directly instead.
|
|
59
|
+
|
|
60
|
+
## [0.1.0] - 2026-03-03
|
|
61
|
+
|
|
62
|
+
### Added
|
|
6
63
|
|
|
7
64
|
- Core audit log model with UUID primary keys and GlobalID-based polymorphic references
|
|
8
65
|
- Convenience API: `StandardAudit.record` with sync, async, and block forms
|
data/MIT-LICENSE
CHANGED
data/README.md
CHANGED
|
@@ -161,7 +161,7 @@ StandardAudit.configure do |config|
|
|
|
161
161
|
|
|
162
162
|
# -- Sensitive Data --
|
|
163
163
|
# Keys automatically stripped from metadata.
|
|
164
|
-
config.sensitive_keys
|
|
164
|
+
config.sensitive_keys += %i[my_custom_secret] # added to built-in defaults
|
|
165
165
|
|
|
166
166
|
# -- Metadata Builder --
|
|
167
167
|
# Optional proc to transform metadata before storage.
|
|
@@ -179,9 +179,8 @@ StandardAudit.configure do |config|
|
|
|
179
179
|
# Metadata keys to strip during anonymization.
|
|
180
180
|
config.anonymizable_metadata_keys = %i[email name ip_address]
|
|
181
181
|
|
|
182
|
-
# -- Retention --
|
|
182
|
+
# -- Retention (schedule StandardAudit::CleanupJob to enforce) --
|
|
183
183
|
config.retention_days = 90
|
|
184
|
-
config.auto_cleanup = false
|
|
185
184
|
end
|
|
186
185
|
```
|
|
187
186
|
|
|
@@ -353,7 +352,7 @@ t.jsonb :metadata, default: {}
|
|
|
353
352
|
**Sensitive data**: Configure `sensitive_keys` to automatically strip passwords, tokens, and secrets from metadata. Add domain-specific keys as needed:
|
|
354
353
|
|
|
355
354
|
```ruby
|
|
356
|
-
config.sensitive_keys
|
|
355
|
+
config.sensitive_keys += %i[medical_record_number] # extend the built-in defaults
|
|
357
356
|
```
|
|
358
357
|
|
|
359
358
|
**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.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
class CleanupJob < ActiveJob::Base
|
|
3
|
+
queue_as { StandardAudit.config.queue_name }
|
|
4
|
+
|
|
5
|
+
def perform
|
|
6
|
+
days = StandardAudit.config.retention_days
|
|
7
|
+
return unless days
|
|
8
|
+
|
|
9
|
+
cutoff = days.days.ago
|
|
10
|
+
deleted = StandardAudit::AuditLog.before(cutoff).in_batches(of: 10_000).delete_all
|
|
11
|
+
Rails.logger.info("[StandardAudit] CleanupJob deleted #{deleted} audit logs older than #{days} days")
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -1,8 +1,25 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
1
3
|
module StandardAudit
|
|
2
4
|
class AuditLog < ApplicationRecord
|
|
3
5
|
self.table_name = "audit_logs"
|
|
4
6
|
|
|
7
|
+
CHECKSUM_FIELDS = %w[
|
|
8
|
+
id event_type actor_gid actor_type target_gid target_type
|
|
9
|
+
scope_gid scope_type metadata request_id ip_address
|
|
10
|
+
user_agent session_id occurred_at
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
5
13
|
before_create :assign_uuid, if: -> { id.blank? }
|
|
14
|
+
before_create :compute_checksum, if: -> { checksum.blank? }
|
|
15
|
+
after_create_commit :emit_created_event
|
|
16
|
+
|
|
17
|
+
# Audit logs are append-only. Use update_columns for privileged
|
|
18
|
+
# operations like GDPR anonymization that must bypass this guard.
|
|
19
|
+
# Note: delete/delete_all bypass callbacks and are permitted for
|
|
20
|
+
# bulk cleanup operations (see CleanupJob, rake standard_audit:cleanup).
|
|
21
|
+
before_update { raise ActiveRecord::ReadOnlyRecord }
|
|
22
|
+
before_destroy { raise ActiveRecord::ReadOnlyRecord }
|
|
6
23
|
|
|
7
24
|
validates :event_type, presence: true
|
|
8
25
|
validates :occurred_at, presence: true
|
|
@@ -85,8 +102,8 @@ module StandardAudit
|
|
|
85
102
|
scope :for_request, ->(request_id) { where(request_id: request_id) }
|
|
86
103
|
scope :from_ip, ->(ip_address) { where(ip_address: ip_address) }
|
|
87
104
|
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) }
|
|
105
|
+
scope :chronological, -> { order(occurred_at: :asc, created_at: :asc) }
|
|
106
|
+
scope :reverse_chronological, -> { order(occurred_at: :desc, created_at: :desc) }
|
|
90
107
|
scope :recent, ->(n = 10) { reverse_chronological.limit(n) }
|
|
91
108
|
|
|
92
109
|
# -- GDPR methods --
|
|
@@ -148,10 +165,120 @@ module StandardAudit
|
|
|
148
165
|
}
|
|
149
166
|
end
|
|
150
167
|
|
|
168
|
+
# Recomputes the checksum from the record's current field values and the
|
|
169
|
+
# given previous checksum. Useful for verification without saving.
|
|
170
|
+
def compute_checksum_value(previous_checksum: nil)
|
|
171
|
+
self.class.compute_checksum_value(
|
|
172
|
+
attributes.slice(*CHECKSUM_FIELDS),
|
|
173
|
+
previous_checksum: previous_checksum
|
|
174
|
+
)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.compute_checksum_value(attrs, previous_checksum: nil)
|
|
178
|
+
canonical = CHECKSUM_FIELDS.map { |f|
|
|
179
|
+
value = attrs[f]
|
|
180
|
+
value = value.to_json if value.is_a?(Hash)
|
|
181
|
+
value = value.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ") if value.respond_to?(:strftime) && value.respond_to?(:utc)
|
|
182
|
+
"#{f}=#{value}"
|
|
183
|
+
}.join("|")
|
|
184
|
+
|
|
185
|
+
canonical = "#{previous_checksum}|#{canonical}" if previous_checksum.present?
|
|
186
|
+
|
|
187
|
+
OpenSSL::Digest::SHA256.hexdigest(canonical)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Verifies the integrity of the audit log chain. Returns a result hash with
|
|
191
|
+
# :valid (boolean), :verified (count), and :failures (array of hashes).
|
|
192
|
+
#
|
|
193
|
+
# Records are processed in (created_at, id) order. Records without a
|
|
194
|
+
# checksum (pre-feature data) reset the chain — the next checksummed
|
|
195
|
+
# record starts a new independent chain segment.
|
|
196
|
+
def self.verify_chain(scope: nil, batch_size: 1000)
|
|
197
|
+
relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
|
|
198
|
+
|
|
199
|
+
previous_checksum = nil
|
|
200
|
+
verified = 0
|
|
201
|
+
failures = []
|
|
202
|
+
|
|
203
|
+
relation.in_batches(of: batch_size) do |batch|
|
|
204
|
+
batch.order(created_at: :asc, id: :asc).each do |record|
|
|
205
|
+
if record.checksum.blank?
|
|
206
|
+
previous_checksum = nil
|
|
207
|
+
next
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
expected = record.compute_checksum_value(previous_checksum: previous_checksum)
|
|
211
|
+
|
|
212
|
+
if record.checksum != expected
|
|
213
|
+
failures << {
|
|
214
|
+
id: record.id,
|
|
215
|
+
event_type: record.event_type,
|
|
216
|
+
created_at: record.created_at,
|
|
217
|
+
expected: expected,
|
|
218
|
+
actual: record.checksum
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
verified += 1
|
|
223
|
+
previous_checksum = record.checksum
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
{ valid: failures.empty?, verified: verified, failures: failures }
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Backfills checksums for records that don't have them (e.g. pre-existing
|
|
231
|
+
# records before the checksum feature was added).
|
|
232
|
+
def self.backfill_checksums!(batch_size: 1000)
|
|
233
|
+
previous_checksum = nil
|
|
234
|
+
count = 0
|
|
235
|
+
|
|
236
|
+
in_batches(of: batch_size) do |batch|
|
|
237
|
+
batch.order(created_at: :asc, id: :asc).each do |record|
|
|
238
|
+
if record.checksum.present?
|
|
239
|
+
previous_checksum = record.checksum
|
|
240
|
+
next
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
new_checksum = compute_checksum_value(
|
|
244
|
+
record.attributes.slice(*CHECKSUM_FIELDS),
|
|
245
|
+
previous_checksum: previous_checksum
|
|
246
|
+
)
|
|
247
|
+
record.update_columns(checksum: new_checksum)
|
|
248
|
+
|
|
249
|
+
previous_checksum = new_checksum
|
|
250
|
+
count += 1
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
count
|
|
255
|
+
end
|
|
256
|
+
|
|
151
257
|
private
|
|
152
258
|
|
|
259
|
+
def emit_created_event
|
|
260
|
+
ActiveSupport::Notifications.instrument("standard_audit.audit_log.created", {
|
|
261
|
+
id: id,
|
|
262
|
+
event_type: event_type,
|
|
263
|
+
actor_type: actor_type,
|
|
264
|
+
target_type: target_type,
|
|
265
|
+
scope_type: scope_type
|
|
266
|
+
})
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
Rails.logger.warn("[StandardAudit] Failed to emit event: #{e.class}: #{e.message}")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Fetches the most recent record's checksum and chains the new record to it.
|
|
272
|
+
# Note: concurrent inserts can read the same "previous" record, forking
|
|
273
|
+
# the chain. Use database-level advisory locks if you need serializable
|
|
274
|
+
# chain integrity under concurrent writes.
|
|
275
|
+
def compute_checksum
|
|
276
|
+
previous = self.class.order(created_at: :desc, id: :desc).limit(1).pick(:checksum)
|
|
277
|
+
self.checksum = compute_checksum_value(previous_checksum: previous)
|
|
278
|
+
end
|
|
279
|
+
|
|
153
280
|
def assign_uuid
|
|
154
|
-
self.id = SecureRandom.
|
|
281
|
+
self.id = SecureRandom.uuid_v7
|
|
155
282
|
end
|
|
156
283
|
end
|
|
157
284
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
module Generators
|
|
3
|
+
class AddChecksumsGenerator < 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 "add_checksum_to_audit_logs.rb.erb", "db/migrate/add_checksum_to_audit_logs.rb"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -5,26 +5,30 @@ class CreateAuditLogs < ActiveRecord::Migration[<%= ActiveRecord::Migration.curr
|
|
|
5
5
|
t.string :actor_type
|
|
6
6
|
t.string :target_gid
|
|
7
7
|
t.string :target_type
|
|
8
|
+
# Multi-tenancy: include StandardAudit::AuditScope in your tenant model
|
|
9
|
+
# (e.g., Organisation) to scope audit logs. Leave nil for single-tenant apps.
|
|
8
10
|
t.string :scope_gid
|
|
9
11
|
t.string :scope_type
|
|
10
12
|
t.string :event_type, null: false
|
|
11
13
|
t.string :request_id
|
|
12
14
|
t.string :ip_address
|
|
13
|
-
t.
|
|
15
|
+
t.text :user_agent
|
|
14
16
|
t.string :session_id
|
|
15
|
-
t.
|
|
17
|
+
t.jsonb :metadata, default: {}
|
|
16
18
|
t.datetime :occurred_at, null: false
|
|
19
|
+
t.string :checksum, limit: 64
|
|
17
20
|
t.timestamps
|
|
18
21
|
end
|
|
19
22
|
|
|
20
23
|
add_index :audit_logs, :event_type
|
|
21
|
-
add_index :audit_logs, :
|
|
22
|
-
add_index :audit_logs, :
|
|
24
|
+
add_index :audit_logs, [:actor_gid, :occurred_at]
|
|
25
|
+
add_index :audit_logs, [:target_gid, :occurred_at]
|
|
23
26
|
add_index :audit_logs, [:scope_type, :scope_gid]
|
|
24
|
-
add_index :audit_logs, :scope_type
|
|
25
27
|
add_index :audit_logs, :request_id
|
|
26
|
-
add_index :audit_logs, :occurred_at
|
|
27
|
-
add_index :audit_logs, :
|
|
28
|
+
add_index :audit_logs, [:occurred_at, :created_at]
|
|
29
|
+
add_index :audit_logs, :created_at
|
|
28
30
|
add_index :audit_logs, :session_id
|
|
31
|
+
# GIN index requires PostgreSQL; remove if using another database
|
|
32
|
+
add_index :audit_logs, :metadata, using: :gin
|
|
29
33
|
end
|
|
30
34
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
StandardAudit.configure do |config|
|
|
2
|
-
#
|
|
2
|
+
# Use a preset to subscribe to common event patterns
|
|
3
|
+
# config.use_preset(:standard_id)
|
|
4
|
+
|
|
5
|
+
# Or subscribe to ActiveSupport::Notifications patterns manually
|
|
3
6
|
# config.subscribe_to "audit.**"
|
|
4
7
|
|
|
5
8
|
# Actor extractor from notification payload
|
|
@@ -11,6 +14,9 @@ StandardAudit.configure do |config|
|
|
|
11
14
|
# Scope extractor from notification payload
|
|
12
15
|
# config.scope_extractor = ->(payload) { payload[:scope] }
|
|
13
16
|
|
|
17
|
+
# Multi-tenancy: include StandardAudit::AuditScope in your tenant model
|
|
18
|
+
# to add a scoped_audit_logs association.
|
|
19
|
+
|
|
14
20
|
# Fallback resolvers (used when payload values are nil)
|
|
15
21
|
# config.current_actor_resolver = -> { Current.user }
|
|
16
22
|
# config.current_request_id_resolver = -> { Current.request_id }
|
|
@@ -18,8 +24,10 @@ StandardAudit.configure do |config|
|
|
|
18
24
|
# config.current_user_agent_resolver = -> { Current.user_agent }
|
|
19
25
|
# config.current_session_id_resolver = -> { Current.session_id }
|
|
20
26
|
|
|
21
|
-
# Keys to strip from metadata
|
|
22
|
-
#
|
|
27
|
+
# Keys to strip from metadata (defaults include: password, password_confirmation,
|
|
28
|
+
# token, secret, api_key, access_token, refresh_token, private_key,
|
|
29
|
+
# certificate_chain, ssn, credit_card, authorization)
|
|
30
|
+
# config.sensitive_keys += %i[my_custom_secret]
|
|
23
31
|
|
|
24
32
|
# Run audit log creation in background job
|
|
25
33
|
# config.async = false
|
|
@@ -31,7 +39,6 @@ StandardAudit.configure do |config|
|
|
|
31
39
|
# GDPR: metadata keys to strip on anonymization
|
|
32
40
|
# config.anonymizable_metadata_keys = %i[email name ip_address]
|
|
33
41
|
|
|
34
|
-
# Data retention
|
|
42
|
+
# Data retention (schedule StandardAudit::CleanupJob to enforce)
|
|
35
43
|
# config.retention_days = nil
|
|
36
|
-
# config.auto_cleanup = false
|
|
37
44
|
end
|
|
@@ -6,10 +6,11 @@ module StandardAudit
|
|
|
6
6
|
:current_ip_address_resolver, :current_user_agent_resolver,
|
|
7
7
|
:current_session_id_resolver,
|
|
8
8
|
:sensitive_keys, :metadata_builder,
|
|
9
|
-
:anonymizable_metadata_keys, :retention_days
|
|
9
|
+
:anonymizable_metadata_keys, :retention_days
|
|
10
10
|
|
|
11
11
|
def initialize
|
|
12
12
|
@subscriptions = []
|
|
13
|
+
@applied_presets = []
|
|
13
14
|
@async = false
|
|
14
15
|
@queue_name = :default
|
|
15
16
|
@enabled = true
|
|
@@ -34,11 +35,18 @@ module StandardAudit
|
|
|
34
35
|
defined?(Current) && Current.respond_to?(:session_id) ? Current.session_id : nil
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
# Note: :authorization filters the HTTP Authorization header value.
|
|
39
|
+
# If you use "authorization" as a metadata key for policy decisions,
|
|
40
|
+
# rename it (e.g. :authorization_policy) to avoid accidental filtering.
|
|
41
|
+
@sensitive_keys = %i[
|
|
42
|
+
password password_confirmation token secret
|
|
43
|
+
api_key access_token refresh_token
|
|
44
|
+
private_key certificate_chain
|
|
45
|
+
ssn credit_card authorization
|
|
46
|
+
]
|
|
38
47
|
@metadata_builder = nil
|
|
39
48
|
@anonymizable_metadata_keys = %i[email name ip_address]
|
|
40
49
|
@retention_days = nil
|
|
41
|
-
@auto_cleanup = false
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
def subscribe_to(pattern)
|
|
@@ -48,5 +56,22 @@ module StandardAudit
|
|
|
48
56
|
def subscriptions
|
|
49
57
|
@subscriptions.dup.freeze
|
|
50
58
|
end
|
|
59
|
+
|
|
60
|
+
def use_preset(name)
|
|
61
|
+
key = name.to_sym
|
|
62
|
+
return self if @applied_presets.include?(key)
|
|
63
|
+
|
|
64
|
+
preset = case key
|
|
65
|
+
when :standard_id
|
|
66
|
+
require "standard_audit/presets/standard_id"
|
|
67
|
+
StandardAudit::Presets::StandardId
|
|
68
|
+
else
|
|
69
|
+
raise ArgumentError, "Unknown preset: #{name}. Available presets: :standard_id"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
preset.apply(self)
|
|
73
|
+
@applied_presets << key
|
|
74
|
+
self
|
|
75
|
+
end
|
|
51
76
|
end
|
|
52
77
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module StandardAudit
|
|
2
|
+
module Presets
|
|
3
|
+
module StandardId
|
|
4
|
+
# Regex wildcards capture all events in a namespace. Session uses
|
|
5
|
+
# explicit strings to exclude noisy events like session.validated
|
|
6
|
+
# that fire on every authenticated request.
|
|
7
|
+
SUBSCRIPTIONS = [
|
|
8
|
+
/\Astandard_id\.authentication\./,
|
|
9
|
+
"standard_id.session.created",
|
|
10
|
+
"standard_id.session.revoked",
|
|
11
|
+
"standard_id.session.expired",
|
|
12
|
+
/\Astandard_id\.account\./,
|
|
13
|
+
/\Astandard_id\.social\./,
|
|
14
|
+
/\Astandard_id\.passwordless\./
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
def self.apply(config)
|
|
18
|
+
SUBSCRIPTIONS.each { |pattern| config.subscribe_to(pattern) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/standard_audit.rb
CHANGED
|
@@ -44,15 +44,20 @@ module StandardAudit
|
|
|
44
44
|
return
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
gid_attrs = {
|
|
48
|
+
actor_gid: actor&.to_global_id&.to_s,
|
|
49
|
+
actor_type: actor&.class&.name,
|
|
50
|
+
target_gid: target&.to_global_id&.to_s,
|
|
51
|
+
target_type: target&.class&.name,
|
|
52
|
+
scope_gid: scope&.to_global_id&.to_s,
|
|
53
|
+
scope_type: scope&.class&.name
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if batching?
|
|
57
|
+
Thread.current[:standard_audit_batch] << attrs.merge(gid_attrs)
|
|
58
|
+
nil
|
|
59
|
+
elsif config.async
|
|
60
|
+
StandardAudit::CreateAuditLogJob.perform_later(attrs.merge(gid_attrs).stringify_keys)
|
|
56
61
|
else
|
|
57
62
|
log = StandardAudit::AuditLog.new(attrs)
|
|
58
63
|
log.actor = actor
|
|
@@ -63,6 +68,24 @@ module StandardAudit
|
|
|
63
68
|
end
|
|
64
69
|
end
|
|
65
70
|
|
|
71
|
+
# Buffers record calls and flushes them via insert_all! on block exit.
|
|
72
|
+
# If the block raises, buffered records are dropped — only successful
|
|
73
|
+
# batches are persisted. Nested batches flush independently.
|
|
74
|
+
# Block-form record calls (with AS::Notifications) bypass the buffer
|
|
75
|
+
# and are processed normally since they don't persist records directly.
|
|
76
|
+
# Note: uses Thread.current for storage, which is not fiber-safe.
|
|
77
|
+
# Apps using async adapters (Falcon) should avoid concurrent batches.
|
|
78
|
+
def batch
|
|
79
|
+
previous = Thread.current[:standard_audit_batch]
|
|
80
|
+
buffer = Thread.current[:standard_audit_batch] = []
|
|
81
|
+
|
|
82
|
+
yield
|
|
83
|
+
|
|
84
|
+
flush_batch(buffer) if buffer.any?
|
|
85
|
+
ensure
|
|
86
|
+
Thread.current[:standard_audit_batch] = previous
|
|
87
|
+
end
|
|
88
|
+
|
|
66
89
|
def subscriber
|
|
67
90
|
@subscriber ||= Subscriber.new
|
|
68
91
|
end
|
|
@@ -70,5 +93,43 @@ module StandardAudit
|
|
|
70
93
|
def reset_configuration!
|
|
71
94
|
@configuration = nil
|
|
72
95
|
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def batching?
|
|
100
|
+
Thread.current[:standard_audit_batch].is_a?(Array)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def flush_batch(buffer)
|
|
104
|
+
now = Time.current
|
|
105
|
+
previous_checksum = StandardAudit::AuditLog
|
|
106
|
+
.order(created_at: :desc, id: :desc)
|
|
107
|
+
.limit(1)
|
|
108
|
+
.pick(:checksum)
|
|
109
|
+
|
|
110
|
+
# Generate sorted UUIDs to ensure batch ordering matches id ordering.
|
|
111
|
+
# UUIDv7 within the same millisecond can have non-monotonic lower bits;
|
|
112
|
+
# sorting guarantees the chain order matches the id order used by
|
|
113
|
+
# verify_chain. Under very high throughput this is a best-effort
|
|
114
|
+
# guarantee — see compute_checksum's concurrency note.
|
|
115
|
+
ids = buffer.size.times.map { SecureRandom.uuid_v7 }.sort
|
|
116
|
+
|
|
117
|
+
rows = buffer.each_with_index.map do |attrs, i|
|
|
118
|
+
row = attrs.merge(
|
|
119
|
+
id: ids[i],
|
|
120
|
+
created_at: now,
|
|
121
|
+
updated_at: now
|
|
122
|
+
)
|
|
123
|
+
checksum = StandardAudit::AuditLog.compute_checksum_value(
|
|
124
|
+
row.stringify_keys,
|
|
125
|
+
previous_checksum: previous_checksum
|
|
126
|
+
)
|
|
127
|
+
row[:checksum] = checksum
|
|
128
|
+
previous_checksum = checksum
|
|
129
|
+
row
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
StandardAudit::AuditLog.insert_all!(rows)
|
|
133
|
+
end
|
|
73
134
|
end
|
|
74
135
|
end
|
|
@@ -55,6 +55,30 @@ namespace :standard_audit do
|
|
|
55
55
|
puts "Anonymized #{count} audit logs for #{args[:actor_gid]}"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
desc "Verify audit log chain integrity (tamper detection)"
|
|
59
|
+
task verify: :environment do
|
|
60
|
+
result = StandardAudit::AuditLog.verify_chain
|
|
61
|
+
|
|
62
|
+
puts "Audit Log Chain Verification"
|
|
63
|
+
puts "============================="
|
|
64
|
+
puts "Records verified: #{result[:verified]}"
|
|
65
|
+
puts "Chain valid: #{result[:valid]}"
|
|
66
|
+
|
|
67
|
+
if result[:failures].any?
|
|
68
|
+
puts "\nTampered records detected: #{result[:failures].size}"
|
|
69
|
+
result[:failures].each do |failure|
|
|
70
|
+
puts " #{failure[:id]} (#{failure[:event_type]}) at #{failure[:created_at]}"
|
|
71
|
+
end
|
|
72
|
+
abort "Chain verification failed"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
desc "Backfill checksums for records that don't have them"
|
|
77
|
+
task backfill_checksums: :environment do
|
|
78
|
+
count = StandardAudit::AuditLog.backfill_checksums!
|
|
79
|
+
puts "Backfilled checksums for #{count} audit log records"
|
|
80
|
+
end
|
|
81
|
+
|
|
58
82
|
desc "Export audit logs for a specific actor (GDPR right to access)"
|
|
59
83
|
task :export_actor, [:actor_gid, :output] => :environment do |_t, args|
|
|
60
84
|
raise "actor_gid is required" unless args[:actor_gid].present?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: standard_audit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -77,10 +77,13 @@ files:
|
|
|
77
77
|
- MIT-LICENSE
|
|
78
78
|
- README.md
|
|
79
79
|
- Rakefile
|
|
80
|
+
- app/jobs/standard_audit/cleanup_job.rb
|
|
80
81
|
- app/jobs/standard_audit/create_audit_log_job.rb
|
|
81
82
|
- app/models/standard_audit/application_record.rb
|
|
82
83
|
- app/models/standard_audit/audit_log.rb
|
|
83
84
|
- config/routes.rb
|
|
85
|
+
- lib/generators/standard_audit/add_checksums/add_checksums_generator.rb
|
|
86
|
+
- lib/generators/standard_audit/add_checksums/templates/add_checksum_to_audit_logs.rb.erb
|
|
84
87
|
- lib/generators/standard_audit/install/install_generator.rb
|
|
85
88
|
- lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb
|
|
86
89
|
- lib/generators/standard_audit/install/templates/initializer.rb.erb
|
|
@@ -89,6 +92,7 @@ files:
|
|
|
89
92
|
- lib/standard_audit/auditable.rb
|
|
90
93
|
- lib/standard_audit/configuration.rb
|
|
91
94
|
- lib/standard_audit/engine.rb
|
|
95
|
+
- lib/standard_audit/presets/standard_id.rb
|
|
92
96
|
- lib/standard_audit/subscriber.rb
|
|
93
97
|
- lib/standard_audit/version.rb
|
|
94
98
|
- lib/tasks/standard_audit_tasks.rake
|
|
@@ -99,6 +103,7 @@ metadata:
|
|
|
99
103
|
homepage_uri: https://github.com/rarebit-one/standard_audit
|
|
100
104
|
source_code_uri: https://github.com/rarebit-one/standard_audit
|
|
101
105
|
changelog_uri: https://github.com/rarebit-one/standard_audit/blob/main/CHANGELOG.md
|
|
106
|
+
bug_tracker_uri: https://github.com/rarebit-one/standard_audit/issues
|
|
102
107
|
rdoc_options: []
|
|
103
108
|
require_paths:
|
|
104
109
|
- lib
|