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,85 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
module Privacy
|
|
3
|
+
# Detects personally identifiable information (PII) in data
|
|
4
|
+
#
|
|
5
|
+
# This class delegates to PamDsl::PIIDetector for the core PII detection logic,
|
|
6
|
+
# providing a unified implementation across both Lyra and PAM DSL.
|
|
7
|
+
#
|
|
8
|
+
# @example Detect PII in attributes
|
|
9
|
+
# PIIDetector.detect({ email: "test@example.com", name: "John" })
|
|
10
|
+
# # => { email: { type: :email, value: "test@example.com", sensitive: false }, ... }
|
|
11
|
+
#
|
|
12
|
+
# @example Check if a field contains PII
|
|
13
|
+
# PIIDetector.contains_pii?(:email) # => true
|
|
14
|
+
# PIIDetector.contains_pii?(:count) # => false
|
|
15
|
+
#
|
|
16
|
+
class PIIDetector
|
|
17
|
+
class << self
|
|
18
|
+
# Detect PII in attributes
|
|
19
|
+
#
|
|
20
|
+
# @param attributes [Hash] Key-value pairs to check for PII
|
|
21
|
+
# @return [Hash] Keys that contain PII, with their type, value, and sensitivity
|
|
22
|
+
#
|
|
23
|
+
def detect(attributes)
|
|
24
|
+
PamDsl::PIIDetector.detect(attributes)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check if a field contains PII
|
|
28
|
+
#
|
|
29
|
+
# @param field_name [String, Symbol] Field name to check
|
|
30
|
+
# @return [Boolean] true if field contains PII
|
|
31
|
+
#
|
|
32
|
+
def contains_pii?(field_name)
|
|
33
|
+
PamDsl::PIIDetector.contains_pii?(field_name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Mask PII for display
|
|
37
|
+
#
|
|
38
|
+
# @param value [Object] The value to mask
|
|
39
|
+
# @param pii_type [Symbol] The type of PII
|
|
40
|
+
# @return [String] Masked value
|
|
41
|
+
#
|
|
42
|
+
def mask(value, pii_type)
|
|
43
|
+
PamDsl::PIIDetector.mask(value, pii_type)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Extract all PII from event stream
|
|
47
|
+
#
|
|
48
|
+
# This is a convenience wrapper around PamDsl::PIIDetector.extract_pii_from_records
|
|
49
|
+
# that provides Lyra-specific extractors for Lyra::Event objects.
|
|
50
|
+
#
|
|
51
|
+
# @param events [Array<Lyra::Event>] Events to scan for PII
|
|
52
|
+
# @return [Hash] PII inventory grouped by type
|
|
53
|
+
#
|
|
54
|
+
# @example
|
|
55
|
+
# events = Lyra.config.event_store.read.stream("User$123").to_a
|
|
56
|
+
# pii = PIIDetector.extract_from_event_stream(events)
|
|
57
|
+
# # => { email: [{ field: :email, value: "...", event_id: "...", ... }], ... }
|
|
58
|
+
#
|
|
59
|
+
def extract_from_event_stream(events)
|
|
60
|
+
PamDsl::PIIDetector.extract_pii_from_records(
|
|
61
|
+
events,
|
|
62
|
+
attribute_extractor: ->(e) { e.attributes },
|
|
63
|
+
metadata_extractor: ->(e) {
|
|
64
|
+
{
|
|
65
|
+
event_id: e.event_id,
|
|
66
|
+
timestamp: e.timestamp,
|
|
67
|
+
model_class: e.model_class,
|
|
68
|
+
model_id: e.model_id
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Check if a PII type is considered sensitive
|
|
75
|
+
#
|
|
76
|
+
# @param pii_type [Symbol] The PII type
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
#
|
|
79
|
+
def sensitive?(pii_type)
|
|
80
|
+
PamDsl::PIIDetector.sensitive?(pii_type)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
module Privacy
|
|
3
|
+
# Masks PII in attribute hashes
|
|
4
|
+
#
|
|
5
|
+
# This class delegates to PamDsl::PIIMasker for the core masking logic,
|
|
6
|
+
# providing a unified implementation across both Lyra and PAM DSL.
|
|
7
|
+
#
|
|
8
|
+
# @example Mask all PII in attributes
|
|
9
|
+
# PIIMasker.mask({ email: "test@example.com", name: "John" })
|
|
10
|
+
# # => { email: "t***@example.com", name: "John ***" }
|
|
11
|
+
#
|
|
12
|
+
# @example Full redaction
|
|
13
|
+
# PIIMasker.mask({ email: "test@example.com" }, strategy: :full)
|
|
14
|
+
# # => { email: "[REDACTED]" }
|
|
15
|
+
#
|
|
16
|
+
class PIIMasker
|
|
17
|
+
class << self
|
|
18
|
+
# Mask all PII fields in an attributes hash
|
|
19
|
+
#
|
|
20
|
+
# @param attributes [Hash] Key-value pairs to mask
|
|
21
|
+
# @param strategy [Symbol] Masking strategy (:partial, :full, :redact_sensitive)
|
|
22
|
+
# @return [Hash] New hash with PII fields masked
|
|
23
|
+
#
|
|
24
|
+
def mask(attributes, strategy: :partial)
|
|
25
|
+
PamDsl::PIIMasker.mask(attributes, strategy: strategy)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Mask specific field
|
|
29
|
+
#
|
|
30
|
+
# @param value [Object] The value to mask
|
|
31
|
+
# @param field_name [String, Symbol] Field name to determine PII type
|
|
32
|
+
# @return [Object] Masked value, or original if not PII
|
|
33
|
+
#
|
|
34
|
+
def mask_field(value, field_name)
|
|
35
|
+
PamDsl::PIIMasker.mask_field(value, field_name)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Mask all PII in a collection of Lyra events
|
|
39
|
+
#
|
|
40
|
+
# @param events [Array<Lyra::Event>] Events to mask
|
|
41
|
+
# @param strategy [Symbol] Masking strategy
|
|
42
|
+
# @return [Array] Events with masked attributes
|
|
43
|
+
#
|
|
44
|
+
def mask_events(events, strategy: :partial)
|
|
45
|
+
PamDsl::PIIMasker.mask_records(
|
|
46
|
+
events,
|
|
47
|
+
attribute_extractor: ->(e) { e.attributes },
|
|
48
|
+
attribute_setter: ->(e, masked) {
|
|
49
|
+
Lyra::Event.new(
|
|
50
|
+
event_id: e.event_id,
|
|
51
|
+
operation: e.operation,
|
|
52
|
+
model_class: e.model_class,
|
|
53
|
+
model_id: e.model_id,
|
|
54
|
+
attributes: masked,
|
|
55
|
+
changes: e.changes,
|
|
56
|
+
timestamp: e.timestamp,
|
|
57
|
+
metadata: e.metadata
|
|
58
|
+
)
|
|
59
|
+
},
|
|
60
|
+
strategy: strategy
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
module Privacy
|
|
3
|
+
# Integration layer between Lyra and PAM DSL
|
|
4
|
+
#
|
|
5
|
+
# When PAM DSL is available, combines policy-defined fields with
|
|
6
|
+
# pattern-based PIIDetector for comprehensive PII detection.
|
|
7
|
+
#
|
|
8
|
+
# @example With policy and detector fallback (default)
|
|
9
|
+
# integration = PolicyIntegration.new(:my_policy)
|
|
10
|
+
# integration.detect_pii(attrs) # Uses policy + detector
|
|
11
|
+
#
|
|
12
|
+
# @example Policy-only mode
|
|
13
|
+
# integration = PolicyIntegration.new(:my_policy, use_detector: false)
|
|
14
|
+
# integration.detect_pii(attrs) # Only fields defined in policy
|
|
15
|
+
#
|
|
16
|
+
class PolicyIntegration
|
|
17
|
+
attr_reader :policy, :use_detector
|
|
18
|
+
|
|
19
|
+
def initialize(policy_name, use_detector: true)
|
|
20
|
+
@use_detector = use_detector
|
|
21
|
+
@policy = PamDsl.policy(policy_name)
|
|
22
|
+
rescue PamDsl::PolicyNotFoundError
|
|
23
|
+
@policy = nil
|
|
24
|
+
rescue NameError
|
|
25
|
+
# PAM DSL not available
|
|
26
|
+
@policy = nil
|
|
27
|
+
@use_detector = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Check if PAM DSL is available
|
|
31
|
+
def pam_dsl_available?
|
|
32
|
+
defined?(PamDsl)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Check if policy is loaded
|
|
36
|
+
def policy_loaded?
|
|
37
|
+
!@policy.nil?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Validate data access for a purpose
|
|
41
|
+
def validate_access!(field_names, purpose, consent_status = {})
|
|
42
|
+
return true unless policy_loaded?
|
|
43
|
+
|
|
44
|
+
@policy.validate_access!(
|
|
45
|
+
field_names,
|
|
46
|
+
purpose,
|
|
47
|
+
consent_granted: consent_status[:granted] || false,
|
|
48
|
+
consent_granted_at: consent_status[:granted_at]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Get PII fields from attributes using policy and/or detector
|
|
53
|
+
#
|
|
54
|
+
# Priority:
|
|
55
|
+
# 1. Fields defined in policy (explicit configuration)
|
|
56
|
+
# 2. Fields detected by PIIDetector (pattern-based, if use_detector: true)
|
|
57
|
+
#
|
|
58
|
+
# @param attributes [Hash] Key-value pairs to check for PII
|
|
59
|
+
# @return [Hash] PII fields with type, value, sensitivity
|
|
60
|
+
#
|
|
61
|
+
def detect_pii(attributes)
|
|
62
|
+
return {} unless pam_dsl_available?
|
|
63
|
+
|
|
64
|
+
pii_fields = {}
|
|
65
|
+
|
|
66
|
+
attributes.each do |key, value|
|
|
67
|
+
field_name = key.to_sym
|
|
68
|
+
|
|
69
|
+
# Try policy first
|
|
70
|
+
if policy_loaded?
|
|
71
|
+
begin
|
|
72
|
+
field = @policy.get_field(field_name)
|
|
73
|
+
pii_fields[key] = {
|
|
74
|
+
type: field.type,
|
|
75
|
+
value: value,
|
|
76
|
+
sensitive: field.sensitive?,
|
|
77
|
+
sensitivity: field.sensitivity,
|
|
78
|
+
source: :policy
|
|
79
|
+
}
|
|
80
|
+
next
|
|
81
|
+
rescue PamDsl::InvalidFieldError
|
|
82
|
+
# Field not in policy, try detector below
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Fall back to PIIDetector if enabled
|
|
87
|
+
if @use_detector
|
|
88
|
+
detected = PamDsl::PIIDetector.detect({ key => value })
|
|
89
|
+
if detected[key]
|
|
90
|
+
pii_fields[key] = detected[key].merge(source: :detector)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
pii_fields
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Mask PII using policy transformations or PIIDetector
|
|
99
|
+
#
|
|
100
|
+
# @param field_name [Symbol, String] Field name
|
|
101
|
+
# @param value [Object] Value to mask
|
|
102
|
+
# @param context [Symbol] Transformation context (default: :display)
|
|
103
|
+
# @return [Object] Masked value, or original if no masking available
|
|
104
|
+
#
|
|
105
|
+
def mask_pii(field_name, value, context = :display)
|
|
106
|
+
return value unless pam_dsl_available?
|
|
107
|
+
|
|
108
|
+
# Try policy transformation first
|
|
109
|
+
if policy_loaded?
|
|
110
|
+
begin
|
|
111
|
+
field = @policy.get_field(field_name)
|
|
112
|
+
return field.apply_transformation(context, value)
|
|
113
|
+
rescue PamDsl::InvalidFieldError
|
|
114
|
+
# Field not in policy, try detector below
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Fall back to PIIDetector masking if enabled
|
|
119
|
+
if @use_detector
|
|
120
|
+
pii_type = PamDsl::PIIDetector.pii_type(field_name)
|
|
121
|
+
return PamDsl::PIIDetector.mask(value, pii_type) if pii_type
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get retention duration for model and field
|
|
128
|
+
# Returns nil if no policy loaded (infinite/manual retention)
|
|
129
|
+
def retention_duration(model_class, field_name: nil)
|
|
130
|
+
return nil unless policy_loaded?
|
|
131
|
+
@policy.retention_for(model_class, field_name: field_name)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check if consent is required for purpose
|
|
135
|
+
def consent_required?(purpose)
|
|
136
|
+
return false unless policy_loaded?
|
|
137
|
+
|
|
138
|
+
begin
|
|
139
|
+
purpose_obj = @policy.get_purpose(purpose)
|
|
140
|
+
purpose_obj.requires_consent?
|
|
141
|
+
rescue PamDsl::Error
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get allowed purposes for a field
|
|
147
|
+
def allowed_purposes(field_name)
|
|
148
|
+
return [] unless policy_loaded?
|
|
149
|
+
|
|
150
|
+
begin
|
|
151
|
+
field = @policy.get_field(field_name)
|
|
152
|
+
field.purposes
|
|
153
|
+
rescue PamDsl::InvalidFieldError
|
|
154
|
+
[]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check if field is allowed for purpose
|
|
159
|
+
def allowed?(field_name, purpose)
|
|
160
|
+
return true unless policy_loaded?
|
|
161
|
+
@policy.allowed?(field_name, purpose)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Get all sensitive fields
|
|
165
|
+
def sensitive_fields
|
|
166
|
+
return [] unless policy_loaded?
|
|
167
|
+
@policy.sensitive_fields.map(&:name)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Get all restricted fields
|
|
171
|
+
def restricted_fields
|
|
172
|
+
return [] unless policy_loaded?
|
|
173
|
+
@policy.restricted_fields.map(&:name)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Get policy metadata
|
|
177
|
+
def metadata
|
|
178
|
+
return {} unless policy_loaded?
|
|
179
|
+
@policy.metadata
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Export policy information
|
|
183
|
+
def to_h
|
|
184
|
+
{
|
|
185
|
+
pam_dsl_available: pam_dsl_available?,
|
|
186
|
+
policy_loaded: policy_loaded?,
|
|
187
|
+
use_detector: @use_detector
|
|
188
|
+
}.tap do |info|
|
|
189
|
+
if policy_loaded?
|
|
190
|
+
info.merge!(
|
|
191
|
+
policy_name: @policy.name,
|
|
192
|
+
fields_count: @policy.fields.count,
|
|
193
|
+
purposes_count: @policy.purposes.count,
|
|
194
|
+
sensitive_fields: sensitive_fields,
|
|
195
|
+
restricted_fields: restricted_fields,
|
|
196
|
+
metadata: @policy.metadata
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Extend GDPRCompliance with policy integration
|
|
204
|
+
class GDPRCompliance
|
|
205
|
+
# Use privacy policy for PII detection if available
|
|
206
|
+
#
|
|
207
|
+
# @param attributes [Hash] Attributes to check
|
|
208
|
+
# @param policy_name [Symbol] Policy name
|
|
209
|
+
# @param use_detector [Boolean] Fall back to PIIDetector (default: true)
|
|
210
|
+
#
|
|
211
|
+
def detect_pii_with_policy(attributes, policy_name, use_detector: true)
|
|
212
|
+
integration = PolicyIntegration.new(policy_name, use_detector: use_detector)
|
|
213
|
+
integration.detect_pii(attributes)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Check retention compliance with policy
|
|
217
|
+
# Returns nil for models without defined retention (infinite/manual)
|
|
218
|
+
def retention_compliance_with_policy(policy_name)
|
|
219
|
+
integration = PolicyIntegration.new(policy_name)
|
|
220
|
+
events = collect_all_events
|
|
221
|
+
|
|
222
|
+
events.group_by { |e| e.model_class }.map do |model_class, model_events|
|
|
223
|
+
retention_duration = integration.retention_duration(model_class)
|
|
224
|
+
|
|
225
|
+
# nil means infinite/manual retention - always compliant
|
|
226
|
+
if retention_duration.nil?
|
|
227
|
+
{
|
|
228
|
+
model_class: model_class,
|
|
229
|
+
total_events: model_events.count,
|
|
230
|
+
expired_events: 0,
|
|
231
|
+
retention_period: nil,
|
|
232
|
+
compliance_status: :manual,
|
|
233
|
+
expired_event_ids: []
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
expired = model_events.select do |event|
|
|
237
|
+
event.timestamp && event.timestamp < (Time.current - retention_duration)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
{
|
|
241
|
+
model_class: model_class,
|
|
242
|
+
total_events: model_events.count,
|
|
243
|
+
expired_events: expired.count,
|
|
244
|
+
retention_period: retention_duration,
|
|
245
|
+
compliance_status: expired.empty? ? :compliant : :requires_action,
|
|
246
|
+
expired_event_ids: expired.map(&:event_id)
|
|
247
|
+
}
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
module Lyra
|
|
2
|
+
# Base class for projections (read models)
|
|
3
|
+
class Projection
|
|
4
|
+
class << self
|
|
5
|
+
def handle(event)
|
|
6
|
+
new.handle(event)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def subscribe_to(*event_types)
|
|
10
|
+
event_types.each do |event_type|
|
|
11
|
+
Lyra.config.event_store.subscribe(self, to: [event_type])
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def handle(event)
|
|
17
|
+
method_name = "apply_#{event.class.name.demodulize.underscore}"
|
|
18
|
+
send(method_name, event) if respond_to?(method_name, true)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# State projection that rebuilds current state from events
|
|
23
|
+
class StateProjection < Projection
|
|
24
|
+
def self.rebuild_state(model_class, model_id)
|
|
25
|
+
stream_name = "#{model_class.name}$#{model_id}"
|
|
26
|
+
events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
27
|
+
|
|
28
|
+
new.rebuild_from_events(events)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def rebuild_from_events(events)
|
|
32
|
+
state = {}
|
|
33
|
+
|
|
34
|
+
events.each do |event|
|
|
35
|
+
case event_operation(event)
|
|
36
|
+
when :created
|
|
37
|
+
state = event_attributes(event)
|
|
38
|
+
when :updated
|
|
39
|
+
state.merge!(event_changes(event).transform_values { |v| v.last })
|
|
40
|
+
when :destroyed
|
|
41
|
+
state[:deleted] = true
|
|
42
|
+
state[:deleted_at] = event_timestamp(event)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
state
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def event_operation(event)
|
|
52
|
+
return event.operation if event.respond_to?(:operation)
|
|
53
|
+
op = event.data[:operation] || event.data["operation"]
|
|
54
|
+
op.is_a?(String) ? op.to_sym : op
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def event_attributes(event)
|
|
58
|
+
return event.attributes if event.respond_to?(:attributes) && !event.attributes.is_a?(Hash)
|
|
59
|
+
event.data[:attributes] || event.data["attributes"] || {}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def event_changes(event)
|
|
63
|
+
return event.changes if event.respond_to?(:changes) && event.method(:changes).owner != ActiveRecord::AttributeMethods::Dirty rescue event.changes
|
|
64
|
+
event.data[:changes] || event.data["changes"] || {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def event_timestamp(event)
|
|
68
|
+
return event.timestamp if event.respond_to?(:timestamp) && event.timestamp
|
|
69
|
+
event.data[:timestamp] || event.data["timestamp"] || event.metadata[:timestamp]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Audit trail projection
|
|
74
|
+
class AuditProjection < Projection
|
|
75
|
+
def self.audit_trail(model_class, model_id)
|
|
76
|
+
stream_name = "#{model_class.name}$#{model_id}"
|
|
77
|
+
events = Lyra.config.event_store.read.stream(stream_name).to_a
|
|
78
|
+
|
|
79
|
+
events.map do |event|
|
|
80
|
+
# Access data with both symbol and string keys (JSON serializer uses strings)
|
|
81
|
+
data = event.data
|
|
82
|
+
nested_metadata = data[:metadata] || data["metadata"] || {}
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
operation: data[:operation] || data["operation"],
|
|
86
|
+
timestamp: data[:timestamp] || data["timestamp"] || event.metadata[:timestamp],
|
|
87
|
+
user_id: nested_metadata[:user_id] || nested_metadata["user_id"],
|
|
88
|
+
changes: data[:changes] || data["changes"] || {},
|
|
89
|
+
attributes: data[:attributes] || data["attributes"] || {}
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
module Projections
|
|
5
|
+
# ActiveJob for asynchronous projection processing.
|
|
6
|
+
#
|
|
7
|
+
# When projection_mode is :async, events are projected to model tables
|
|
8
|
+
# in background jobs rather than synchronously during the request.
|
|
9
|
+
#
|
|
10
|
+
# Benefits:
|
|
11
|
+
# - Faster response times (don't wait for projection)
|
|
12
|
+
# - Better fault isolation (projection failures don't fail requests)
|
|
13
|
+
# - Retry capability for transient failures
|
|
14
|
+
#
|
|
15
|
+
# Trade-offs:
|
|
16
|
+
# - Eventual consistency (reads may not see latest writes immediately)
|
|
17
|
+
# - Requires background job infrastructure (Sidekiq, etc.)
|
|
18
|
+
#
|
|
19
|
+
# Usage:
|
|
20
|
+
# AsyncProjectionJob.perform_later(event_id, 'Registration', :create)
|
|
21
|
+
#
|
|
22
|
+
class AsyncProjectionJob < ActiveJob::Base
|
|
23
|
+
queue_as :lyra_projections
|
|
24
|
+
|
|
25
|
+
# Retry with exponential backoff for transient failures
|
|
26
|
+
retry_on StandardError, wait: :polynomially_longer, attempts: 5
|
|
27
|
+
|
|
28
|
+
# Don't retry on permanent failures
|
|
29
|
+
discard_on ActiveRecord::RecordNotFound
|
|
30
|
+
|
|
31
|
+
def perform(event_id, model_class_name, operation)
|
|
32
|
+
event = load_event(event_id)
|
|
33
|
+
return unless event
|
|
34
|
+
|
|
35
|
+
model_class = model_class_name.constantize
|
|
36
|
+
|
|
37
|
+
# Build a CommandResult-like structure from the event
|
|
38
|
+
result = build_result_from_event(event, operation)
|
|
39
|
+
|
|
40
|
+
# Project to model table
|
|
41
|
+
ModelProjection.project(model_class, operation.to_sym, result)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def load_event(event_id)
|
|
47
|
+
Lyra.config.event_store.read.event(event_id)
|
|
48
|
+
rescue RubyEventStore::EventNotFound
|
|
49
|
+
Rails.logger.warn("Lyra::AsyncProjectionJob: Event #{event_id} not found")
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def build_result_from_event(event, operation)
|
|
54
|
+
# Create a simple struct that looks like CommandResult
|
|
55
|
+
Struct.new(:events, :attributes, :success?, keyword_init: true).new(
|
|
56
|
+
events: [event],
|
|
57
|
+
attributes: event.data[:attributes] || event.data["attributes"] || {},
|
|
58
|
+
success?: true
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|