orfeas_lyra 0.6.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 +222 -0
- data/LICENSE +21 -0
- data/README.md +1165 -0
- data/Rakefile +728 -0
- data/app/controllers/lyra/application_controller.rb +23 -0
- data/app/controllers/lyra/dashboard_controller.rb +624 -0
- data/app/controllers/lyra/flow_controller.rb +224 -0
- data/app/controllers/lyra/privacy_controller.rb +182 -0
- data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
- data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
- data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
- data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
- data/app/views/lyra/dashboard/index.html.erb +119 -0
- data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
- data/app/views/lyra/dashboard/projections.html.erb +302 -0
- data/app/views/lyra/dashboard/schema.html.erb +283 -0
- data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
- data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
- data/app/views/lyra/dashboard/verification.html.erb +370 -0
- data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
- data/app/views/lyra/flow/timeline.html.erb +260 -0
- data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
- data/app/views/lyra/privacy/policy.html.erb +188 -0
- data/app/workflows/es_async_mode_workflow.rb +80 -0
- data/app/workflows/es_sync_mode_workflow.rb +64 -0
- data/app/workflows/hijack_mode_workflow.rb +54 -0
- data/app/workflows/lifecycle_workflow.rb +43 -0
- data/app/workflows/monitor_mode_workflow.rb +39 -0
- data/config/privacy_policies.rb +273 -0
- data/config/routes.rb +48 -0
- data/lib/lyra/aggregate.rb +131 -0
- data/lib/lyra/associations/event_aware.rb +225 -0
- data/lib/lyra/command.rb +81 -0
- data/lib/lyra/command_handler.rb +155 -0
- data/lib/lyra/configuration.rb +124 -0
- data/lib/lyra/consistency/read_your_writes.rb +91 -0
- data/lib/lyra/correlation.rb +144 -0
- data/lib/lyra/dual_view.rb +231 -0
- data/lib/lyra/engine.rb +67 -0
- data/lib/lyra/event.rb +71 -0
- data/lib/lyra/event_analyzer.rb +135 -0
- data/lib/lyra/event_flow.rb +449 -0
- data/lib/lyra/event_mapper.rb +106 -0
- data/lib/lyra/event_store_adapter.rb +72 -0
- data/lib/lyra/id_generator.rb +137 -0
- data/lib/lyra/interceptors/association_interceptor.rb +169 -0
- data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
- data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
- data/lib/lyra/privacy/pii_detector.rb +85 -0
- data/lib/lyra/privacy/pii_masker.rb +66 -0
- data/lib/lyra/privacy/policy_integration.rb +253 -0
- data/lib/lyra/projection.rb +94 -0
- data/lib/lyra/projections/async_projection_job.rb +63 -0
- data/lib/lyra/projections/cached_projection.rb +322 -0
- data/lib/lyra/projections/cached_relation.rb +757 -0
- data/lib/lyra/projections/event_store_reader.rb +127 -0
- data/lib/lyra/projections/model_projection.rb +143 -0
- data/lib/lyra/schema/diff.rb +331 -0
- data/lib/lyra/schema/event_class_registrar.rb +63 -0
- data/lib/lyra/schema/generator.rb +190 -0
- data/lib/lyra/schema/reporter.rb +188 -0
- data/lib/lyra/schema/store.rb +156 -0
- data/lib/lyra/schema/validator.rb +100 -0
- data/lib/lyra/strict_data_access.rb +363 -0
- data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
- data/lib/lyra/verification/workflow_generator.rb +540 -0
- data/lib/lyra/version.rb +3 -0
- data/lib/lyra/visualization/activity_heatmap.rb +215 -0
- data/lib/lyra/visualization/event_graph.rb +310 -0
- data/lib/lyra/visualization/timeline.rb +398 -0
- data/lib/lyra.rb +150 -0
- data/lib/tasks/dist.rake +391 -0
- data/lib/tasks/gems.rake +185 -0
- data/lib/tasks/lyra_schema.rake +231 -0
- data/lib/tasks/lyra_workflows.rake +452 -0
- data/lib/tasks/public_release.rake +351 -0
- data/lib/tasks/stats.rake +175 -0
- data/lib/tasks/testbed.rake +479 -0
- data/lib/tasks/version.rake +159 -0
- metadata +221 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
# Error raised when strict data access mode is enabled and a callback-bypassing
|
|
5
|
+
# operation is attempted on a Lyra-monitored model.
|
|
6
|
+
class StrictDataAccessViolation < StandardError
|
|
7
|
+
attr_reader :method_name, :model_class, :alternative
|
|
8
|
+
|
|
9
|
+
ALTERNATIVES = {
|
|
10
|
+
update_columns: "update!",
|
|
11
|
+
update_column: "update!",
|
|
12
|
+
delete: "destroy",
|
|
13
|
+
update_all: "find_each { |r| r.update!(...) }",
|
|
14
|
+
delete_all: "find_each(&:destroy)",
|
|
15
|
+
insert_all: "records.each { |attrs| create!(attrs) }",
|
|
16
|
+
insert_all!: "records.each { |attrs| create!(attrs) }",
|
|
17
|
+
upsert_all: "records.each { |attrs| find_or_create_by!(...).update!(attrs) }"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(method_name, model_class)
|
|
21
|
+
@method_name = method_name
|
|
22
|
+
@model_class = model_class
|
|
23
|
+
@alternative = ALTERNATIVES[method_name.to_sym]
|
|
24
|
+
|
|
25
|
+
super(build_message)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def build_message
|
|
31
|
+
msg = "#{method_name} bypasses ActiveRecord callbacks and won't be captured by Lyra."
|
|
32
|
+
msg += " Use #{alternative} instead." if alternative
|
|
33
|
+
msg += " Disable strict_data_access mode if this is intentional."
|
|
34
|
+
msg
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Module that overrides callback-bypassing instance methods to raise errors
|
|
39
|
+
# when strict_data_access mode is enabled.
|
|
40
|
+
# Uses prepend to properly intercept method calls.
|
|
41
|
+
# When operations are allowed (strict mode off or bypassed), events are created
|
|
42
|
+
# to ensure the event stream captures all state changes.
|
|
43
|
+
module StrictDataAccess
|
|
44
|
+
def update_columns(attributes)
|
|
45
|
+
raise_strict_violation!(:update_columns)
|
|
46
|
+
publish_bypass_update_event(attributes)
|
|
47
|
+
super
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def update_column(name, value)
|
|
51
|
+
raise_strict_violation!(:update_column)
|
|
52
|
+
publish_bypass_update_event({ name => value })
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete
|
|
57
|
+
raise_strict_violation!(:delete)
|
|
58
|
+
publish_bypass_destroy_event
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def raise_strict_violation!(method_name)
|
|
65
|
+
return unless Lyra.config.strict_data_access
|
|
66
|
+
return if Thread.current[:lyra_bypass_strict_access]
|
|
67
|
+
|
|
68
|
+
raise StrictDataAccessViolation.new(method_name, self.class)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Check if this model should have bypass events published
|
|
72
|
+
def should_publish_bypass_event?
|
|
73
|
+
return false unless self.class.respond_to?(:lyra_monitored) && self.class.lyra_monitored
|
|
74
|
+
return false if Lyra.config.mode == :disabled
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Publish an "updated" event for bypass operations like update_columns
|
|
79
|
+
def publish_bypass_update_event(attributes)
|
|
80
|
+
return unless should_publish_bypass_event?
|
|
81
|
+
|
|
82
|
+
# Build changes hash with old and new values
|
|
83
|
+
changes = {}
|
|
84
|
+
attributes.each do |key, new_value|
|
|
85
|
+
key_str = key.to_s
|
|
86
|
+
old_value = read_attribute(key_str)
|
|
87
|
+
changes[key_str] = [old_value, new_value] if old_value != new_value
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
return if changes.empty?
|
|
91
|
+
|
|
92
|
+
# Get event class
|
|
93
|
+
config = self.class.lyra_config || Lyra.config.model_config(self.class)
|
|
94
|
+
event_name = config.event_name_for(:updated)
|
|
95
|
+
sanitized_name = event_name.to_s.gsub("::", "")
|
|
96
|
+
|
|
97
|
+
event_class = if Lyra::Events.const_defined?(sanitized_name, false)
|
|
98
|
+
Lyra::Events.const_get(sanitized_name, false)
|
|
99
|
+
else
|
|
100
|
+
Lyra::Events.const_set(sanitized_name, Class.new(Lyra::Event))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Build metadata
|
|
104
|
+
metadata = {
|
|
105
|
+
correlation_id: Lyra::Correlation.current_id,
|
|
106
|
+
causation_id: Lyra::Causation.current_id,
|
|
107
|
+
bypass_source: "update_columns"
|
|
108
|
+
}.compact
|
|
109
|
+
|
|
110
|
+
# Build event data
|
|
111
|
+
event_data = {
|
|
112
|
+
model_class: self.class.name,
|
|
113
|
+
model_id: id,
|
|
114
|
+
operation: :updated,
|
|
115
|
+
attributes: attributes.transform_keys(&:to_s),
|
|
116
|
+
changes: changes,
|
|
117
|
+
timestamp: Time.current
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
event = event_class.new(data: event_data, metadata: metadata)
|
|
121
|
+
stream_name = "#{self.class.name}$#{id}"
|
|
122
|
+
|
|
123
|
+
Lyra.config.event_store.publish(event, stream_name: stream_name)
|
|
124
|
+
rescue => e
|
|
125
|
+
Rails.logger.error("Lyra: Failed to publish bypass update event - #{e.message}")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Publish a "destroyed" event for bypass operations like delete
|
|
129
|
+
def publish_bypass_destroy_event
|
|
130
|
+
return unless should_publish_bypass_event?
|
|
131
|
+
|
|
132
|
+
# Get event class
|
|
133
|
+
config = self.class.lyra_config || Lyra.config.model_config(self.class)
|
|
134
|
+
event_name = config.event_name_for(:destroyed)
|
|
135
|
+
sanitized_name = event_name.to_s.gsub("::", "")
|
|
136
|
+
|
|
137
|
+
event_class = if Lyra::Events.const_defined?(sanitized_name, false)
|
|
138
|
+
Lyra::Events.const_get(sanitized_name, false)
|
|
139
|
+
else
|
|
140
|
+
Lyra::Events.const_set(sanitized_name, Class.new(Lyra::Event))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Build metadata
|
|
144
|
+
metadata = {
|
|
145
|
+
correlation_id: Lyra::Correlation.current_id,
|
|
146
|
+
causation_id: Lyra::Causation.current_id,
|
|
147
|
+
bypass_source: "delete"
|
|
148
|
+
}.compact
|
|
149
|
+
|
|
150
|
+
# Build event data
|
|
151
|
+
event_data = {
|
|
152
|
+
model_class: self.class.name,
|
|
153
|
+
model_id: id,
|
|
154
|
+
operation: :destroyed,
|
|
155
|
+
attributes: attributes.except("created_at", "updated_at"),
|
|
156
|
+
changes: {},
|
|
157
|
+
timestamp: Time.current
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
event = event_class.new(data: event_data, metadata: metadata)
|
|
161
|
+
stream_name = "#{self.class.name}$#{id}"
|
|
162
|
+
|
|
163
|
+
Lyra.config.event_store.publish(event, stream_name: stream_name)
|
|
164
|
+
rescue => e
|
|
165
|
+
Rails.logger.error("Lyra: Failed to publish bypass destroy event - #{e.message}")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Class methods for bulk operations that bypass callbacks.
|
|
170
|
+
# These are called directly on the model class (not on relations).
|
|
171
|
+
module StrictDataAccessClassMethods
|
|
172
|
+
def insert_all(attributes, **options)
|
|
173
|
+
raise_strict_class_violation!(:insert_all)
|
|
174
|
+
super
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def insert_all!(attributes, **options)
|
|
178
|
+
raise_strict_class_violation!(:insert_all!)
|
|
179
|
+
super
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def upsert_all(attributes, **options)
|
|
183
|
+
raise_strict_class_violation!(:upsert_all)
|
|
184
|
+
super
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Note: update_all and delete_all are handled via relation extension
|
|
188
|
+
# because they're typically called on scopes (Model.where(...).update_all)
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def raise_strict_class_violation!(method_name)
|
|
193
|
+
return unless Lyra.config.strict_data_access
|
|
194
|
+
return if Thread.current[:lyra_bypass_strict_access]
|
|
195
|
+
|
|
196
|
+
raise StrictDataAccessViolation.new(method_name, self)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Extension for ActiveRecord::Relation to intercept update_all/delete_all
|
|
201
|
+
# on scoped queries. This is needed because Model.where(...).update_all
|
|
202
|
+
# is called on a Relation, not the model class.
|
|
203
|
+
module StrictDataAccessRelation
|
|
204
|
+
def update_all(updates)
|
|
205
|
+
check_strict_mode!(:update_all, updates)
|
|
206
|
+
super
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def delete_all
|
|
210
|
+
check_strict_mode!(:delete_all)
|
|
211
|
+
super
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def check_strict_mode!(method_name, updates = nil)
|
|
217
|
+
return unless Lyra.config.strict_data_access
|
|
218
|
+
return unless klass.respond_to?(:lyra_monitored) && klass.lyra_monitored
|
|
219
|
+
|
|
220
|
+
# Allow update_all when called from Rails association handling (dependent: :nullify)
|
|
221
|
+
# These updates only set a foreign key to NULL and are internal Rails operations
|
|
222
|
+
if method_name == :update_all && association_nullify_update?(updates)
|
|
223
|
+
# Create events for affected records before the bulk update
|
|
224
|
+
# This ensures the event stream captures the state change
|
|
225
|
+
publish_nullify_events(updates) if Lyra.config.mode != :disabled
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Allow operations when bypassed via Lyra.without_strict_access block
|
|
230
|
+
return if Thread.current[:lyra_bypass_strict_access]
|
|
231
|
+
|
|
232
|
+
raise StrictDataAccessViolation.new(method_name, klass)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Detect if this is an association nullify operation (dependent: :nullify)
|
|
236
|
+
# Rails sets a single foreign key column to NULL when destroying parent records
|
|
237
|
+
def association_nullify_update?(updates)
|
|
238
|
+
return false unless updates.is_a?(Hash)
|
|
239
|
+
return false unless updates.size == 1
|
|
240
|
+
|
|
241
|
+
# Check if it's a foreign key being set to nil
|
|
242
|
+
key, value = updates.first
|
|
243
|
+
value.nil? && key.to_s.end_with?("_id")
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Publish "updated" events for each record affected by dependent: :nullify
|
|
247
|
+
# This captures the foreign key nullification in the event stream
|
|
248
|
+
def publish_nullify_events(updates)
|
|
249
|
+
column = updates.keys.first.to_s
|
|
250
|
+
|
|
251
|
+
# Get the records being updated with their current values
|
|
252
|
+
# In ES Disabled mode, records don't exist in DB, so we need to query
|
|
253
|
+
# the event store via the model's read path (which uses CachedRelation)
|
|
254
|
+
records_data = fetch_affected_records_for_nullify(column)
|
|
255
|
+
return if records_data.empty?
|
|
256
|
+
|
|
257
|
+
# Get the event class for updates
|
|
258
|
+
config = klass.lyra_config || Lyra.config.model_config(klass)
|
|
259
|
+
event_name = config.event_name_for(:updated)
|
|
260
|
+
sanitized_name = event_name.to_s.gsub("::", "")
|
|
261
|
+
|
|
262
|
+
event_class = if Lyra::Events.const_defined?(sanitized_name, false)
|
|
263
|
+
Lyra::Events.const_get(sanitized_name, false)
|
|
264
|
+
else
|
|
265
|
+
Lyra::Events.const_set(sanitized_name, Class.new(Lyra::Event))
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Build metadata (only include non-nil values, use strings for RES compatibility)
|
|
269
|
+
metadata = {
|
|
270
|
+
correlation_id: Lyra::Correlation.current_id,
|
|
271
|
+
causation_id: Lyra::Causation.current_id,
|
|
272
|
+
nullify_source: "dependent_association"
|
|
273
|
+
}.compact
|
|
274
|
+
|
|
275
|
+
# Publish an event for each affected record
|
|
276
|
+
records_data.each do |id, old_value|
|
|
277
|
+
event_data = {
|
|
278
|
+
model_class: klass.name,
|
|
279
|
+
model_id: id,
|
|
280
|
+
operation: :updated,
|
|
281
|
+
attributes: { column => nil },
|
|
282
|
+
changes: { column => [old_value, nil] },
|
|
283
|
+
timestamp: Time.current
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
event = event_class.new(data: event_data, metadata: metadata)
|
|
287
|
+
stream_name = "#{klass.name}$#{id}"
|
|
288
|
+
|
|
289
|
+
Lyra.config.event_store.publish(event, stream_name: stream_name)
|
|
290
|
+
end
|
|
291
|
+
rescue => e
|
|
292
|
+
# Don't fail the nullify operation if event publishing fails
|
|
293
|
+
Rails.logger.error("Lyra: Failed to publish nullify events - #{e.message}")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Fetch affected records for nullify operation
|
|
297
|
+
# Handles both DB-backed mode and ES Disabled mode (event store only)
|
|
298
|
+
def fetch_affected_records_for_nullify(column)
|
|
299
|
+
# First, try to get records from DB (works in non-ES-Disabled modes)
|
|
300
|
+
records_data = self.pluck(:id, column)
|
|
301
|
+
return records_data unless records_data.empty?
|
|
302
|
+
|
|
303
|
+
# In ES Disabled mode, records don't exist in DB
|
|
304
|
+
# Extract the foreign key value from the where clause and query event store
|
|
305
|
+
foreign_key_value = extract_foreign_key_from_where_clause(column)
|
|
306
|
+
return [] unless foreign_key_value
|
|
307
|
+
|
|
308
|
+
# Query the model using its normal read path (which uses EventStoreReader in ES mode)
|
|
309
|
+
# This will return records from the event store cache
|
|
310
|
+
if Lyra.event_sourcing_mode? && Lyra.config.projection_mode == :disabled
|
|
311
|
+
cached_records = klass.where(column.to_sym => foreign_key_value)
|
|
312
|
+
cached_records.map { |r| [r.id, r.send(column)] }
|
|
313
|
+
else
|
|
314
|
+
[]
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Extract the foreign key value from the relation's where clause
|
|
319
|
+
# The relation is something like: Registration.where(group_id: 123)
|
|
320
|
+
def extract_foreign_key_from_where_clause(column)
|
|
321
|
+
return nil unless respond_to?(:where_clause)
|
|
322
|
+
|
|
323
|
+
where_clause.send(:predicates).each do |predicate|
|
|
324
|
+
next unless predicate.is_a?(Arel::Nodes::Equality)
|
|
325
|
+
next unless predicate.left.respond_to?(:name)
|
|
326
|
+
next unless predicate.left.name.to_s == column
|
|
327
|
+
|
|
328
|
+
# Extract the value - Rails 7/8 uses QueryAttribute
|
|
329
|
+
right = predicate.right
|
|
330
|
+
case right
|
|
331
|
+
when Arel::Nodes::Casted
|
|
332
|
+
return right.value
|
|
333
|
+
when Arel::Nodes::BindParam
|
|
334
|
+
return right.value.value_before_type_cast
|
|
335
|
+
when Integer, String
|
|
336
|
+
return right
|
|
337
|
+
else
|
|
338
|
+
# Rails 7/8: ActiveRecord::Relation::QueryAttribute
|
|
339
|
+
if right.respond_to?(:value)
|
|
340
|
+
return right.value
|
|
341
|
+
elsif right.respond_to?(:value_before_type_cast)
|
|
342
|
+
return right.value_before_type_cast
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
nil
|
|
348
|
+
rescue => e
|
|
349
|
+
Rails.logger.debug("Lyra: Could not extract foreign key from where clause - #{e.message}")
|
|
350
|
+
nil
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Temporarily bypass strict data access checks
|
|
355
|
+
# Use this for legitimate bulk operations in migrations, seeds, etc.
|
|
356
|
+
def self.without_strict_access
|
|
357
|
+
previous = Thread.current[:lyra_bypass_strict_access]
|
|
358
|
+
Thread.current[:lyra_bypass_strict_access] = true
|
|
359
|
+
yield
|
|
360
|
+
ensure
|
|
361
|
+
Thread.current[:lyra_bypass_strict_access] = previous
|
|
362
|
+
end
|
|
363
|
+
end
|