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,135 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Analyzes collections of events for patterns and metrics
|
|
3
|
+
class EventAnalyzer
|
|
4
|
+
attr_reader :events
|
|
5
|
+
|
|
6
|
+
def initialize(events)
|
|
7
|
+
@events = events
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Analyze events and return comprehensive metrics
|
|
11
|
+
def analyze
|
|
12
|
+
{
|
|
13
|
+
timeline: calculate_timeline,
|
|
14
|
+
operations: calculate_operations,
|
|
15
|
+
metrics: calculate_metrics,
|
|
16
|
+
privacy: analyze_privacy
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def calculate_timeline
|
|
23
|
+
return {} if @events.empty?
|
|
24
|
+
|
|
25
|
+
first_time = extract_timestamp(@events.first)
|
|
26
|
+
last_time = extract_timestamp(@events.last)
|
|
27
|
+
|
|
28
|
+
return {} if first_time.nil? || last_time.nil?
|
|
29
|
+
|
|
30
|
+
duration_seconds = last_time - first_time
|
|
31
|
+
|
|
32
|
+
{
|
|
33
|
+
first_event: first_time,
|
|
34
|
+
last_event: last_time,
|
|
35
|
+
duration_seconds: duration_seconds,
|
|
36
|
+
duration_minutes: duration_seconds / 60.0,
|
|
37
|
+
duration_hours: duration_seconds / 3600.0,
|
|
38
|
+
duration_days: duration_seconds / 86400.0
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def extract_timestamp(event)
|
|
43
|
+
if event.respond_to?(:timestamp) && event.timestamp
|
|
44
|
+
event.timestamp
|
|
45
|
+
elsif event.respond_to?(:data) && event.data.is_a?(Hash) && event.data[:timestamp]
|
|
46
|
+
event.data[:timestamp]
|
|
47
|
+
elsif event.respond_to?(:metadata) && event.metadata.is_a?(Hash) && event.metadata[:timestamp]
|
|
48
|
+
event.metadata[:timestamp]
|
|
49
|
+
else
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def calculate_operations
|
|
55
|
+
operations = Hash.new(0)
|
|
56
|
+
|
|
57
|
+
@events.each do |event|
|
|
58
|
+
operation = if event.respond_to?(:operation)
|
|
59
|
+
event.operation
|
|
60
|
+
elsif event.respond_to?(:data)
|
|
61
|
+
event.data[:operation]
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
operations[operation] += 1 if operation
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
operations
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def calculate_metrics
|
|
71
|
+
timeline = calculate_timeline
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
total_events: @events.length,
|
|
75
|
+
operations_per_day: if timeline[:duration_days] && timeline[:duration_days] > 0
|
|
76
|
+
@events.length.to_f / timeline[:duration_days]
|
|
77
|
+
else
|
|
78
|
+
0
|
|
79
|
+
end,
|
|
80
|
+
average_time_between_events: if @events.length > 1 && timeline[:duration_seconds]
|
|
81
|
+
timeline[:duration_seconds] / (@events.length - 1)
|
|
82
|
+
else
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def analyze_privacy
|
|
89
|
+
pii_fields = {}
|
|
90
|
+
events_with_pii = 0
|
|
91
|
+
|
|
92
|
+
@events.each do |event|
|
|
93
|
+
event_pii = detect_event_pii(event)
|
|
94
|
+
|
|
95
|
+
if event_pii.any?
|
|
96
|
+
events_with_pii += 1
|
|
97
|
+
event_pii.each do |field, info|
|
|
98
|
+
pii_fields[field] ||= info
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
percentage = @events.empty? ? 0.0 : (events_with_pii.to_f / @events.length * 100.0)
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
pii_fields: pii_fields,
|
|
107
|
+
events_with_pii: events_with_pii,
|
|
108
|
+
percentage: percentage
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def detect_event_pii(event)
|
|
113
|
+
attributes = if event.respond_to?(:attributes)
|
|
114
|
+
event.attributes
|
|
115
|
+
elsif event.respond_to?(:data)
|
|
116
|
+
event.data[:attributes] || {}
|
|
117
|
+
else
|
|
118
|
+
{}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
changes = if event.respond_to?(:changes)
|
|
122
|
+
event.changes
|
|
123
|
+
elsif event.respond_to?(:data)
|
|
124
|
+
event.data[:changes] || {}
|
|
125
|
+
else
|
|
126
|
+
{}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
return {} unless Lyra.privacy_features_available?
|
|
130
|
+
|
|
131
|
+
all_data = attributes.merge(changes)
|
|
132
|
+
Lyra::Privacy::PIIDetector.detect(all_data)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Visualizes event flows and chains
|
|
3
|
+
class EventFlow
|
|
4
|
+
attr_reader :subject_id, :subject_type, :time_range
|
|
5
|
+
|
|
6
|
+
def initialize(subject_id: nil, subject_type: nil, time_range: nil)
|
|
7
|
+
@subject_id = subject_id
|
|
8
|
+
@subject_type = subject_type
|
|
9
|
+
@time_range = time_range || (30.days.ago..Time.current)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Get complete event flow for visualization
|
|
13
|
+
def flow_data
|
|
14
|
+
events = load_events
|
|
15
|
+
grouped = group_by_correlation(events)
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
timeline: build_timeline(events),
|
|
19
|
+
flows: build_flows(grouped),
|
|
20
|
+
statistics: calculate_statistics(events),
|
|
21
|
+
privacy_impact: analyze_privacy_impact(events)
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Build timeline view showing CRUD operations and their events
|
|
26
|
+
def build_timeline(events)
|
|
27
|
+
events.sort_by { |e| event_timestamp(e) }.map do |event|
|
|
28
|
+
{
|
|
29
|
+
event_id: event.event_id,
|
|
30
|
+
timestamp: event_timestamp(event),
|
|
31
|
+
operation: event_operation(event),
|
|
32
|
+
model_class: event_model_class(event),
|
|
33
|
+
model_id: event_model_id(event),
|
|
34
|
+
correlation_id: event.metadata[:correlation_id],
|
|
35
|
+
action_id: event.metadata[:action_id],
|
|
36
|
+
user_action: event.metadata[:user_action],
|
|
37
|
+
changes: event_changes(event),
|
|
38
|
+
pii_fields: detect_pii(event),
|
|
39
|
+
grouped_with: find_grouped_events(event, events)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Build event flows showing cause and effect
|
|
45
|
+
def build_flows(grouped_events)
|
|
46
|
+
grouped_events.map do |correlation_id, events|
|
|
47
|
+
{
|
|
48
|
+
correlation_id: correlation_id,
|
|
49
|
+
started_at: event_timestamp(events.min_by { |e| event_timestamp(e) }),
|
|
50
|
+
completed_at: event_timestamp(events.max_by { |e| event_timestamp(e) }),
|
|
51
|
+
duration: calculate_duration(events),
|
|
52
|
+
user_action: events.first.metadata[:user_action],
|
|
53
|
+
user_id: events.first.metadata[:user_id],
|
|
54
|
+
events_chain: build_event_chain(events),
|
|
55
|
+
crud_operations: extract_crud_operations(events),
|
|
56
|
+
privacy_impact: calculate_flow_privacy_impact(events)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Show how a single CRUD operation maps to events
|
|
62
|
+
def crud_to_event_mapping(model_class, operation, model_id = nil)
|
|
63
|
+
events = load_events.select do |event|
|
|
64
|
+
matches = event_model_class(event) == model_class && event_operation(event) == operation
|
|
65
|
+
matches &&= event_model_id(event) == model_id if model_id
|
|
66
|
+
matches
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
{
|
|
70
|
+
crud_operation: {
|
|
71
|
+
model: model_class,
|
|
72
|
+
operation: operation,
|
|
73
|
+
record_id: model_id
|
|
74
|
+
},
|
|
75
|
+
generated_events: events.map { |e| event_summary(e) },
|
|
76
|
+
count: events.count,
|
|
77
|
+
timeline: events.sort_by { |e| event_timestamp(e) }.map do |e|
|
|
78
|
+
{
|
|
79
|
+
timestamp: event_timestamp(e),
|
|
80
|
+
event_type: e.class.name,
|
|
81
|
+
event_id: e.event_id,
|
|
82
|
+
data: e.data
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Reconstruct state from event chain
|
|
89
|
+
def reconstruct_state_chain(model_class, model_id)
|
|
90
|
+
stream_name = "#{model_class}$#{model_id}"
|
|
91
|
+
events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
92
|
+
|
|
93
|
+
chain = []
|
|
94
|
+
state = {}
|
|
95
|
+
|
|
96
|
+
events.sort_by { |e| event_timestamp(e) }.each do |event|
|
|
97
|
+
previous_state = state.dup
|
|
98
|
+
|
|
99
|
+
case event_operation(event)
|
|
100
|
+
when :created
|
|
101
|
+
state = event_attributes(event).dup
|
|
102
|
+
when :updated
|
|
103
|
+
event_changes(event).each { |k, (old, new)| state[k] = new }
|
|
104
|
+
when :destroyed
|
|
105
|
+
state[:_deleted] = true
|
|
106
|
+
state[:_deleted_at] = event_timestamp(event)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
chain << {
|
|
110
|
+
event_id: event.event_id,
|
|
111
|
+
timestamp: event_timestamp(event),
|
|
112
|
+
operation: event_operation(event),
|
|
113
|
+
previous_state: previous_state,
|
|
114
|
+
changes: event_changes(event),
|
|
115
|
+
new_state: state.dup,
|
|
116
|
+
pii_changed: identify_pii_changes(previous_state, state),
|
|
117
|
+
user_action: event.metadata[:user_action]
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
model: { class: model_class, id: model_id },
|
|
123
|
+
initial_state: {},
|
|
124
|
+
final_state: state,
|
|
125
|
+
events_count: events.count,
|
|
126
|
+
state_evolution: chain
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Visualize data lineage for GDPR compliance
|
|
131
|
+
def data_lineage(field_name, model_class = nil)
|
|
132
|
+
events = load_events
|
|
133
|
+
|
|
134
|
+
if model_class
|
|
135
|
+
events = events.select { |e| event_model_class(e) == model_class }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
lineage = []
|
|
139
|
+
|
|
140
|
+
events.each do |event|
|
|
141
|
+
attrs = event_attributes(event)
|
|
142
|
+
changes = event_changes(event)
|
|
143
|
+
if attrs.key?(field_name) || attrs.key?(field_name.to_s) || changes.key?(field_name) || changes.key?(field_name.to_s)
|
|
144
|
+
lineage << {
|
|
145
|
+
timestamp: event_timestamp(event),
|
|
146
|
+
event_id: event.event_id,
|
|
147
|
+
model: event_model_class(event),
|
|
148
|
+
record_id: event_model_id(event),
|
|
149
|
+
operation: event_operation(event),
|
|
150
|
+
old_value: changes.dig(field_name, 0) || changes.dig(field_name.to_s, 0),
|
|
151
|
+
new_value: changes.dig(field_name, 1) || changes.dig(field_name.to_s, 1) || attrs[field_name] || attrs[field_name.to_s],
|
|
152
|
+
source: event.metadata[:source],
|
|
153
|
+
user_id: event.metadata[:user_id],
|
|
154
|
+
action: event.metadata[:user_action]
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
field: field_name,
|
|
161
|
+
model_class: model_class,
|
|
162
|
+
total_modifications: lineage.count,
|
|
163
|
+
first_seen: lineage.min_by { |l| l[:timestamp] }&.dig(:timestamp),
|
|
164
|
+
last_modified: lineage.max_by { |l| l[:timestamp] }&.dig(:timestamp),
|
|
165
|
+
lineage: lineage.sort_by { |l| l[:timestamp] }
|
|
166
|
+
}
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Analyze privacy impact of event chains
|
|
170
|
+
def privacy_impact_analysis
|
|
171
|
+
events = load_events
|
|
172
|
+
pii_inventory = extract_pii_from_events(events)
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
total_events: events.count,
|
|
176
|
+
events_with_pii: events.count { |e| has_pii?(e) },
|
|
177
|
+
pii_categories: pii_inventory.keys,
|
|
178
|
+
pii_fields_count: pii_inventory.values.sum(&:count),
|
|
179
|
+
sensitive_operations: identify_sensitive_operations(events),
|
|
180
|
+
data_flows: trace_data_flows(events),
|
|
181
|
+
risk_assessment: assess_privacy_risk(events, pii_inventory)
|
|
182
|
+
}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
# Helper methods to extract data from events (works with both Lyra::Event and RubyEventStore::Event)
|
|
188
|
+
def event_operation(event)
|
|
189
|
+
return event.operation if event.respond_to?(:operation)
|
|
190
|
+
op = event.data[:operation] || event.data["operation"]
|
|
191
|
+
op.is_a?(String) ? op.to_sym : op
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def event_model_class(event)
|
|
195
|
+
return event.model_class if event.respond_to?(:model_class)
|
|
196
|
+
event.data[:model_class] || event.data["model_class"]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def event_model_id(event)
|
|
200
|
+
return event.model_id if event.respond_to?(:model_id)
|
|
201
|
+
event.data[:model_id] || event.data["model_id"]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def event_timestamp(event)
|
|
205
|
+
return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
|
|
206
|
+
event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def event_attributes(event)
|
|
210
|
+
return event.attributes if event.respond_to?(:attributes) && !event.attributes.is_a?(Hash)
|
|
211
|
+
event.data[:attributes] || event.data["attributes"] || {}
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def event_changes(event)
|
|
215
|
+
return event.changes if event.respond_to?(:changes) && event.method(:changes).owner != ActiveRecord::AttributeMethods::Dirty
|
|
216
|
+
event.data[:changes] || event.data["changes"] || {}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def load_events
|
|
220
|
+
events = Lyra.config.event_store.read.to_a
|
|
221
|
+
|
|
222
|
+
# Filter by subject if specified
|
|
223
|
+
if subject_id && subject_type
|
|
224
|
+
events = events.select { |e| event_relates_to_subject?(e) }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Filter by time range
|
|
228
|
+
events = events.select { |e| time_range.cover?(parse_timestamp(event_timestamp(e))) }
|
|
229
|
+
|
|
230
|
+
events
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def event_relates_to_subject?(event)
|
|
234
|
+
event.metadata[:user_id] == subject_id ||
|
|
235
|
+
event.data[:user_id] == subject_id ||
|
|
236
|
+
(event_model_class(event) == subject_type && event_model_id(event) == subject_id)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def group_by_correlation(events)
|
|
240
|
+
events.group_by { |e| e.metadata[:correlation_id] || e.event_id }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def build_event_chain(events)
|
|
244
|
+
events.sort_by { |e| event_timestamp(e) }.map do |event|
|
|
245
|
+
{
|
|
246
|
+
event_id: event.event_id,
|
|
247
|
+
timestamp: event_timestamp(event),
|
|
248
|
+
event_type: event.class.name,
|
|
249
|
+
operation: event_operation(event),
|
|
250
|
+
model: "#{event_model_class(event)}##{event_model_id(event)}",
|
|
251
|
+
changes: event_changes(event),
|
|
252
|
+
pii_affected: detect_pii(event).any?
|
|
253
|
+
}
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def extract_crud_operations(events)
|
|
258
|
+
events.map do |event|
|
|
259
|
+
{
|
|
260
|
+
operation: event_operation(event),
|
|
261
|
+
model: event_model_class(event),
|
|
262
|
+
record_id: event_model_id(event),
|
|
263
|
+
timestamp: event_timestamp(event)
|
|
264
|
+
}
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def calculate_duration(events)
|
|
269
|
+
return 0 if events.empty?
|
|
270
|
+
max_time = parse_timestamp(event_timestamp(events.max_by { |e| event_timestamp(e) }))
|
|
271
|
+
min_time = parse_timestamp(event_timestamp(events.min_by { |e| event_timestamp(e) }))
|
|
272
|
+
(max_time - min_time).to_f
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def parse_timestamp(timestamp)
|
|
276
|
+
return timestamp if timestamp.is_a?(Time) || timestamp.is_a?(DateTime)
|
|
277
|
+
return timestamp.to_time if timestamp.respond_to?(:to_time)
|
|
278
|
+
Time.parse(timestamp.to_s)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def calculate_statistics(events)
|
|
282
|
+
{
|
|
283
|
+
total_events: events.count,
|
|
284
|
+
operations: events.group_by { |e| event_operation(e) }.transform_values(&:count),
|
|
285
|
+
models: events.group_by { |e| event_model_class(e) }.transform_values(&:count),
|
|
286
|
+
hourly_distribution: hourly_distribution(events),
|
|
287
|
+
users: events.map { |e| e.metadata[:user_id] }.compact.uniq.count
|
|
288
|
+
}
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def hourly_distribution(events)
|
|
292
|
+
events.group_by { |e| parse_timestamp(event_timestamp(e)).hour }.transform_values(&:count)
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def detect_pii(event)
|
|
296
|
+
return {} unless Lyra.privacy_features_available?
|
|
297
|
+
|
|
298
|
+
Lyra::Privacy::PIIDetector.detect(event_attributes(event))
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def has_pii?(event)
|
|
302
|
+
detect_pii(event).any?
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def find_grouped_events(event, all_events)
|
|
306
|
+
correlation_id = event.metadata[:correlation_id]
|
|
307
|
+
return [] unless correlation_id
|
|
308
|
+
|
|
309
|
+
all_events.select do |e|
|
|
310
|
+
e.metadata[:correlation_id] == correlation_id && e.event_id != event.event_id
|
|
311
|
+
end.map(&:event_id)
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def calculate_flow_privacy_impact(events)
|
|
315
|
+
pii_count = events.count { |e| has_pii?(e) }
|
|
316
|
+
{
|
|
317
|
+
events_with_pii: pii_count,
|
|
318
|
+
percentage: events.empty? ? 0 : (pii_count.to_f / events.count * 100).round(2),
|
|
319
|
+
pii_types: events.flat_map { |e| detect_pii(e).values.map { |v| v[:type] } }.uniq
|
|
320
|
+
}
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def identify_pii_changes(old_state, new_state)
|
|
324
|
+
return {} unless Lyra.privacy_features_available?
|
|
325
|
+
|
|
326
|
+
changes = {}
|
|
327
|
+
|
|
328
|
+
new_state.each do |key, new_value|
|
|
329
|
+
old_value = old_state[key]
|
|
330
|
+
if old_value != new_value && Lyra::Privacy::PIIDetector.contains_pii?(key)
|
|
331
|
+
changes[key] = { from: old_value, to: new_value }
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
changes
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def event_summary(event)
|
|
339
|
+
{
|
|
340
|
+
event_id: event.event_id,
|
|
341
|
+
timestamp: event_timestamp(event),
|
|
342
|
+
operation: event_operation(event),
|
|
343
|
+
has_pii: has_pii?(event)
|
|
344
|
+
}
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def analyze_privacy_impact(events)
|
|
348
|
+
pii_inventory = extract_pii_from_events(events)
|
|
349
|
+
|
|
350
|
+
{
|
|
351
|
+
pii_categories: pii_inventory.keys,
|
|
352
|
+
total_pii_fields: pii_inventory.values.sum(&:count),
|
|
353
|
+
sensitive_data_present: pii_inventory.keys.any? { |k|
|
|
354
|
+
[:ssn, :credit_card, :health, :biometric].include?(k)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def extract_pii_from_events(events)
|
|
360
|
+
# Custom implementation that uses event_attributes helper
|
|
361
|
+
inventory = Hash.new { |h, k| h[k] = [] }
|
|
362
|
+
events.each do |event|
|
|
363
|
+
pii = detect_pii(event)
|
|
364
|
+
pii.each do |field, info|
|
|
365
|
+
inventory[info[:type]] << { field: field, model: event_model_class(event) }
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
inventory
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def identify_sensitive_operations(events)
|
|
372
|
+
events.select { |e| has_pii?(e) && [:destroyed, :updated].include?(event_operation(e)) }
|
|
373
|
+
.map { |e| event_summary(e) }
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def trace_data_flows(events)
|
|
377
|
+
# Identify how data flows between models
|
|
378
|
+
flows = Hash.new { |h, k| h[k] = [] }
|
|
379
|
+
|
|
380
|
+
events.each do |event|
|
|
381
|
+
pii = detect_pii(event)
|
|
382
|
+
next if pii.empty?
|
|
383
|
+
|
|
384
|
+
source = event_model_class(event)
|
|
385
|
+
pii.each do |field, info|
|
|
386
|
+
flows[info[:type]] << {
|
|
387
|
+
source_model: source,
|
|
388
|
+
field: field,
|
|
389
|
+
timestamp: event_timestamp(event),
|
|
390
|
+
operation: event_operation(event)
|
|
391
|
+
}
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
flows
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def assess_privacy_risk(events, pii_inventory)
|
|
399
|
+
risk_factors = []
|
|
400
|
+
|
|
401
|
+
# Check for sensitive PII
|
|
402
|
+
if pii_inventory.keys.any? { |k| [:ssn, :credit_card, :health].include?(k) }
|
|
403
|
+
risk_factors << { level: :high, reason: "Contains highly sensitive PII" }
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# Check for many PII fields
|
|
407
|
+
if pii_inventory.values.sum(&:count) > 10
|
|
408
|
+
risk_factors << { level: :medium, reason: "Large number of PII fields" }
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Check for frequent modifications
|
|
412
|
+
update_events = events.count { |e| event_operation(e) == :updated && has_pii?(e) }
|
|
413
|
+
if update_events > 50
|
|
414
|
+
risk_factors << { level: :medium, reason: "Frequent PII modifications" }
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
{
|
|
418
|
+
overall_risk: calculate_overall_risk(risk_factors),
|
|
419
|
+
factors: risk_factors,
|
|
420
|
+
recommendations: generate_recommendations(risk_factors)
|
|
421
|
+
}
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def calculate_overall_risk(risk_factors)
|
|
425
|
+
return :low if risk_factors.empty?
|
|
426
|
+
return :high if risk_factors.any? { |f| f[:level] == :high }
|
|
427
|
+
return :medium if risk_factors.any? { |f| f[:level] == :medium }
|
|
428
|
+
:low
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def generate_recommendations(risk_factors)
|
|
432
|
+
recommendations = []
|
|
433
|
+
|
|
434
|
+
if risk_factors.any? { |f| f[:reason].include?("sensitive") }
|
|
435
|
+
recommendations << "Implement field-level encryption for sensitive PII"
|
|
436
|
+
recommendations << "Enable audit logging for all sensitive data access"
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
if risk_factors.any? { |f| f[:reason].include?("modifications") }
|
|
440
|
+
recommendations << "Review data retention policies"
|
|
441
|
+
recommendations << "Implement change approval workflows"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
recommendations << "Regular GDPR compliance audits recommended" if risk_factors.any?
|
|
445
|
+
|
|
446
|
+
recommendations
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Maps CRUD operations to domain events
|
|
3
|
+
class EventMapper
|
|
4
|
+
class << self
|
|
5
|
+
# Map a CRUD operation to an event
|
|
6
|
+
def map_operation(model_class, operation, data)
|
|
7
|
+
mapper_class = find_mapper(model_class) || DefaultMapper
|
|
8
|
+
|
|
9
|
+
mapper_class.new(model_class, operation, data).to_event
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Register a custom mapper for a model
|
|
13
|
+
def register_mapper(model_class, mapper_class)
|
|
14
|
+
mappers[model_class.name] = mapper_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def mappers
|
|
20
|
+
@mappers ||= {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def find_mapper(model_class)
|
|
24
|
+
mappers[model_class.name]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :model_class, :operation, :data
|
|
29
|
+
|
|
30
|
+
def initialize(model_class, operation, data)
|
|
31
|
+
@model_class = model_class
|
|
32
|
+
@operation = operation
|
|
33
|
+
@data = data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def to_event
|
|
37
|
+
event_class = resolve_event_class
|
|
38
|
+
event_class.new(data: event_data, metadata: event_metadata)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def event_data
|
|
42
|
+
{
|
|
43
|
+
model_class: model_class.name,
|
|
44
|
+
model_id: data[:id],
|
|
45
|
+
operation: operation,
|
|
46
|
+
attributes: data[:attributes] || {},
|
|
47
|
+
changes: data[:changes] || {},
|
|
48
|
+
timestamp: Time.current
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def event_metadata
|
|
53
|
+
{
|
|
54
|
+
user_id: data[:user_id],
|
|
55
|
+
request_id: data[:request_id],
|
|
56
|
+
correlation_id: Lyra::Correlation.current_id,
|
|
57
|
+
causation_id: Lyra::Causation.current_id,
|
|
58
|
+
source: 'lyra_interceptor'
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolve_event_class
|
|
63
|
+
config = Lyra.config.model_config(model_class)
|
|
64
|
+
event_name = config.event_name_for(operation)
|
|
65
|
+
# Sanitize namespaced event names for constant lookup
|
|
66
|
+
sanitized_name = event_name.to_s.gsub("::", "")
|
|
67
|
+
|
|
68
|
+
# First check if it exists in Lyra::Events namespace
|
|
69
|
+
if Lyra::Events.const_defined?(sanitized_name, false)
|
|
70
|
+
return Lyra::Events.const_get(sanitized_name, false)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Try to constantize (for user-defined event classes)
|
|
74
|
+
begin
|
|
75
|
+
event_name.constantize
|
|
76
|
+
rescue NameError
|
|
77
|
+
create_dynamic_event_class(event_name)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def create_dynamic_event_class(event_name)
|
|
82
|
+
# Sanitize namespaced event names (e.g., "Spree::OrderCreated" -> "SpreeOrderCreated")
|
|
83
|
+
sanitized_name = event_name.to_s.gsub("::", "")
|
|
84
|
+
|
|
85
|
+
return Lyra::Events.const_get(sanitized_name, false) if Lyra::Events.const_defined?(sanitized_name, false)
|
|
86
|
+
|
|
87
|
+
Lyra::Events.const_set(sanitized_name, Class.new(Lyra::Event))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class DefaultMapper < EventMapper
|
|
92
|
+
# Uses the base EventMapper behavior
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Example of a custom mapper
|
|
96
|
+
class AuditMapper < EventMapper
|
|
97
|
+
def event_data
|
|
98
|
+
super.merge(
|
|
99
|
+
audit_info: {
|
|
100
|
+
ip_address: data[:ip_address],
|
|
101
|
+
user_agent: data[:user_agent]
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|