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.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +222 -0
  3. data/LICENSE +21 -0
  4. data/README.md +1165 -0
  5. data/Rakefile +728 -0
  6. data/app/controllers/lyra/application_controller.rb +23 -0
  7. data/app/controllers/lyra/dashboard_controller.rb +624 -0
  8. data/app/controllers/lyra/flow_controller.rb +224 -0
  9. data/app/controllers/lyra/privacy_controller.rb +182 -0
  10. data/app/views/lyra/dashboard/audit_trail.html.erb +324 -0
  11. data/app/views/lyra/dashboard/discrepancies.html.erb +125 -0
  12. data/app/views/lyra/dashboard/event_graph_view.html.erb +525 -0
  13. data/app/views/lyra/dashboard/heatmap_view.html.erb +155 -0
  14. data/app/views/lyra/dashboard/index.html.erb +119 -0
  15. data/app/views/lyra/dashboard/model_overview.html.erb +115 -0
  16. data/app/views/lyra/dashboard/projections.html.erb +302 -0
  17. data/app/views/lyra/dashboard/schema.html.erb +283 -0
  18. data/app/views/lyra/dashboard/schema_history.html.erb +78 -0
  19. data/app/views/lyra/dashboard/schema_version.html.erb +340 -0
  20. data/app/views/lyra/dashboard/verification.html.erb +370 -0
  21. data/app/views/lyra/flow/crud_mapping.html.erb +125 -0
  22. data/app/views/lyra/flow/timeline.html.erb +260 -0
  23. data/app/views/lyra/privacy/pii_detection.html.erb +148 -0
  24. data/app/views/lyra/privacy/policy.html.erb +188 -0
  25. data/app/workflows/es_async_mode_workflow.rb +80 -0
  26. data/app/workflows/es_sync_mode_workflow.rb +64 -0
  27. data/app/workflows/hijack_mode_workflow.rb +54 -0
  28. data/app/workflows/lifecycle_workflow.rb +43 -0
  29. data/app/workflows/monitor_mode_workflow.rb +39 -0
  30. data/config/privacy_policies.rb +273 -0
  31. data/config/routes.rb +48 -0
  32. data/lib/lyra/aggregate.rb +131 -0
  33. data/lib/lyra/associations/event_aware.rb +225 -0
  34. data/lib/lyra/command.rb +81 -0
  35. data/lib/lyra/command_handler.rb +155 -0
  36. data/lib/lyra/configuration.rb +124 -0
  37. data/lib/lyra/consistency/read_your_writes.rb +91 -0
  38. data/lib/lyra/correlation.rb +144 -0
  39. data/lib/lyra/dual_view.rb +231 -0
  40. data/lib/lyra/engine.rb +67 -0
  41. data/lib/lyra/event.rb +71 -0
  42. data/lib/lyra/event_analyzer.rb +135 -0
  43. data/lib/lyra/event_flow.rb +449 -0
  44. data/lib/lyra/event_mapper.rb +106 -0
  45. data/lib/lyra/event_store_adapter.rb +72 -0
  46. data/lib/lyra/id_generator.rb +137 -0
  47. data/lib/lyra/interceptors/association_interceptor.rb +169 -0
  48. data/lib/lyra/interceptors/crud_interceptor.rb +543 -0
  49. data/lib/lyra/privacy/gdpr_compliance.rb +161 -0
  50. data/lib/lyra/privacy/pii_detector.rb +85 -0
  51. data/lib/lyra/privacy/pii_masker.rb +66 -0
  52. data/lib/lyra/privacy/policy_integration.rb +253 -0
  53. data/lib/lyra/projection.rb +94 -0
  54. data/lib/lyra/projections/async_projection_job.rb +63 -0
  55. data/lib/lyra/projections/cached_projection.rb +322 -0
  56. data/lib/lyra/projections/cached_relation.rb +757 -0
  57. data/lib/lyra/projections/event_store_reader.rb +127 -0
  58. data/lib/lyra/projections/model_projection.rb +143 -0
  59. data/lib/lyra/schema/diff.rb +331 -0
  60. data/lib/lyra/schema/event_class_registrar.rb +63 -0
  61. data/lib/lyra/schema/generator.rb +190 -0
  62. data/lib/lyra/schema/reporter.rb +188 -0
  63. data/lib/lyra/schema/store.rb +156 -0
  64. data/lib/lyra/schema/validator.rb +100 -0
  65. data/lib/lyra/strict_data_access.rb +363 -0
  66. data/lib/lyra/verification/crud_lifecycle_workflow.rb +456 -0
  67. data/lib/lyra/verification/workflow_generator.rb +540 -0
  68. data/lib/lyra/version.rb +3 -0
  69. data/lib/lyra/visualization/activity_heatmap.rb +215 -0
  70. data/lib/lyra/visualization/event_graph.rb +310 -0
  71. data/lib/lyra/visualization/timeline.rb +398 -0
  72. data/lib/lyra.rb +150 -0
  73. data/lib/tasks/dist.rake +391 -0
  74. data/lib/tasks/gems.rake +185 -0
  75. data/lib/tasks/lyra_schema.rake +231 -0
  76. data/lib/tasks/lyra_workflows.rake +452 -0
  77. data/lib/tasks/public_release.rake +351 -0
  78. data/lib/tasks/stats.rake +175 -0
  79. data/lib/tasks/testbed.rake +479 -0
  80. data/lib/tasks/version.rake +159 -0
  81. 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