standard_audit 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +29 -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 +22 -2
- data/lib/generators/standard_audit/install/templates/create_audit_logs.rb.erb +9 -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 +51 -9
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c400c65330d8896079475becca2eedc20481027bd23a9e6c35d1aa6011e46762
|
|
4
|
+
data.tar.gz: e5757a3734ccecaa266b0e17f537f53c51d26cebd6b6fdcc0022db758d31935e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f03510f5f6f9c6ba1e47e189da9d29e9c1a6d720886fb149c4938f4612581bd4049b87e61d1a67a36c7a27cb4ca1a147c3f9984c17ee090980d236fa632531c3
|
|
7
|
+
data.tar.gz: faf25861c62875fb9e89b97c938a5ed1730982f3f8a48b40c694a3ee26f9941c9832085a1dc75420d4a7085c0cb03aff0b1474ea59e3a29a4386297d9fb6c879
|
data/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
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.2.0] - 2026-03-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Batch insert mode via `StandardAudit.batch { }` for high-volume audit logging
|
|
15
|
+
- `StandardAudit::CleanupJob` for automated retention enforcement
|
|
16
|
+
- `config.use_preset(:standard_id)` to subscribe to StandardId auth events in one call
|
|
17
|
+
- GIN index on metadata JSONB column in install generator (PostgreSQL)
|
|
18
|
+
- CI-driven gem publishing via GitHub Actions trusted publisher
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Migration template uses `jsonb` instead of `json` for metadata column
|
|
23
|
+
- Expanded default `sensitive_keys` to include `api_key`, `access_token`, `refresh_token`, `private_key`, `certificate_chain`, `ssn`, `credit_card`, `authorization`
|
|
24
|
+
|
|
25
|
+
### Breaking Changes
|
|
26
|
+
|
|
27
|
+
- 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.
|
|
28
|
+
- Removed `auto_cleanup` config attribute. Schedule `StandardAudit::CleanupJob` directly instead.
|
|
29
|
+
|
|
30
|
+
## [0.1.0] - 2026-03-03
|
|
31
|
+
|
|
32
|
+
### Added
|
|
6
33
|
|
|
7
34
|
- Core audit log model with UUID primary keys and GlobalID-based polymorphic references
|
|
8
35
|
- 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
|
|
@@ -3,6 +3,14 @@ module StandardAudit
|
|
|
3
3
|
self.table_name = "audit_logs"
|
|
4
4
|
|
|
5
5
|
before_create :assign_uuid, if: -> { id.blank? }
|
|
6
|
+
after_create_commit :emit_created_event
|
|
7
|
+
|
|
8
|
+
# Audit logs are append-only. Use update_columns for privileged
|
|
9
|
+
# operations like GDPR anonymization that must bypass this guard.
|
|
10
|
+
# Note: delete/delete_all bypass callbacks and are permitted for
|
|
11
|
+
# bulk cleanup operations (see CleanupJob, rake standard_audit:cleanup).
|
|
12
|
+
before_update { raise ActiveRecord::ReadOnlyRecord }
|
|
13
|
+
before_destroy { raise ActiveRecord::ReadOnlyRecord }
|
|
6
14
|
|
|
7
15
|
validates :event_type, presence: true
|
|
8
16
|
validates :occurred_at, presence: true
|
|
@@ -85,8 +93,8 @@ module StandardAudit
|
|
|
85
93
|
scope :for_request, ->(request_id) { where(request_id: request_id) }
|
|
86
94
|
scope :from_ip, ->(ip_address) { where(ip_address: ip_address) }
|
|
87
95
|
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) }
|
|
96
|
+
scope :chronological, -> { order(occurred_at: :asc, created_at: :asc) }
|
|
97
|
+
scope :reverse_chronological, -> { order(occurred_at: :desc, created_at: :desc) }
|
|
90
98
|
scope :recent, ->(n = 10) { reverse_chronological.limit(n) }
|
|
91
99
|
|
|
92
100
|
# -- GDPR methods --
|
|
@@ -150,6 +158,18 @@ module StandardAudit
|
|
|
150
158
|
|
|
151
159
|
private
|
|
152
160
|
|
|
161
|
+
def emit_created_event
|
|
162
|
+
ActiveSupport::Notifications.instrument("standard_audit.audit_log.created", {
|
|
163
|
+
id: id,
|
|
164
|
+
event_type: event_type,
|
|
165
|
+
actor_type: actor_type,
|
|
166
|
+
target_type: target_type,
|
|
167
|
+
scope_type: scope_type
|
|
168
|
+
})
|
|
169
|
+
rescue StandardError => e
|
|
170
|
+
Rails.logger.warn("[StandardAudit] Failed to emit event: #{e.class}: #{e.message}")
|
|
171
|
+
end
|
|
172
|
+
|
|
153
173
|
def assign_uuid
|
|
154
174
|
self.id = SecureRandom.uuid
|
|
155
175
|
end
|
|
@@ -5,26 +5,28 @@ 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
|
|
17
19
|
t.timestamps
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
add_index :audit_logs, :event_type
|
|
21
|
-
add_index :audit_logs, :
|
|
22
|
-
add_index :audit_logs, :
|
|
23
|
+
add_index :audit_logs, [:actor_gid, :occurred_at]
|
|
24
|
+
add_index :audit_logs, [:target_gid, :occurred_at]
|
|
23
25
|
add_index :audit_logs, [:scope_type, :scope_gid]
|
|
24
|
-
add_index :audit_logs, :scope_type
|
|
25
26
|
add_index :audit_logs, :request_id
|
|
26
|
-
add_index :audit_logs, :occurred_at
|
|
27
|
-
add_index :audit_logs, :ip_address
|
|
27
|
+
add_index :audit_logs, [:occurred_at, :created_at]
|
|
28
28
|
add_index :audit_logs, :session_id
|
|
29
|
+
# GIN index requires PostgreSQL; remove if using another database
|
|
30
|
+
add_index :audit_logs, :metadata, using: :gin
|
|
29
31
|
end
|
|
30
32
|
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,24 @@ 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
|
+
rows = buffer.map do |attrs|
|
|
106
|
+
attrs.merge(
|
|
107
|
+
id: SecureRandom.uuid,
|
|
108
|
+
created_at: now,
|
|
109
|
+
updated_at: now
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
StandardAudit::AuditLog.insert_all!(rows)
|
|
114
|
+
end
|
|
73
115
|
end
|
|
74
116
|
end
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -77,6 +77,7 @@ 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
|
|
@@ -89,6 +90,7 @@ files:
|
|
|
89
90
|
- lib/standard_audit/auditable.rb
|
|
90
91
|
- lib/standard_audit/configuration.rb
|
|
91
92
|
- lib/standard_audit/engine.rb
|
|
93
|
+
- lib/standard_audit/presets/standard_id.rb
|
|
92
94
|
- lib/standard_audit/subscriber.rb
|
|
93
95
|
- lib/standard_audit/version.rb
|
|
94
96
|
- lib/tasks/standard_audit_tasks.rake
|
|
@@ -99,6 +101,7 @@ metadata:
|
|
|
99
101
|
homepage_uri: https://github.com/rarebit-one/standard_audit
|
|
100
102
|
source_code_uri: https://github.com/rarebit-one/standard_audit
|
|
101
103
|
changelog_uri: https://github.com/rarebit-one/standard_audit/blob/main/CHANGELOG.md
|
|
104
|
+
bug_tracker_uri: https://github.com/rarebit-one/standard_audit/issues
|
|
102
105
|
rdoc_options: []
|
|
103
106
|
require_paths:
|
|
104
107
|
- lib
|