orfeas_lyra 0.6.0 → 0.9.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 +851 -0
- data/README.md +196 -987
- data/app/controllers/lyra/application_controller.rb +48 -0
- data/app/controllers/lyra/dashboard_controller.rb +0 -1
- data/app/views/lyra/dashboard/projections.html.erb +0 -4
- data/app/views/lyra/dashboard/verification.html.erb +8 -2
- data/app/workflows/es_async_mode_workflow.rb +24 -60
- data/app/workflows/es_sync_mode_workflow.rb +25 -50
- data/app/workflows/hijack_mode_workflow.rb +18 -33
- data/app/workflows/lifecycle_workflow.rb +1 -1
- data/app/workflows/monitor_mode_workflow.rb +12 -22
- data/config/privacy_policies.rb +2 -1
- data/config/routes.rb +4 -2
- data/lib/lyra/access_log.rb +121 -0
- data/lib/lyra/advisory_lock.rb +45 -0
- data/lib/lyra/aggregate.rb +25 -13
- data/lib/lyra/associations/event_aware.rb +3 -4
- data/lib/lyra/associations/habtm_links.rb +255 -0
- data/lib/lyra/bypass_events.rb +208 -0
- data/lib/lyra/command.rb +3 -0
- data/lib/lyra/command_handler.rb +164 -42
- data/lib/lyra/configuration.rb +217 -8
- data/lib/lyra/consistency/read_your_writes.rb +74 -23
- data/lib/lyra/domain_events.rb +157 -0
- data/lib/lyra/dual_view.rb +27 -6
- data/lib/lyra/dual_view_sampler.rb +33 -0
- data/lib/lyra/encrypted_attributes.rb +101 -0
- data/lib/lyra/engine.rb +67 -4
- data/lib/lyra/erasure.rb +316 -0
- data/lib/lyra/event.rb +120 -2
- data/lib/lyra/event_flow.rb +16 -6
- data/lib/lyra/event_serializer.rb +45 -0
- data/lib/lyra/event_store_adapter.rb +58 -62
- data/lib/lyra/genesis.rb +189 -0
- data/lib/lyra/id_generator.rb +42 -10
- data/lib/lyra/interceptors/association_interceptor.rb +123 -6
- data/lib/lyra/interceptors/crud_interceptor.rb +400 -74
- data/lib/lyra/interceptors/lazy_reads.rb +46 -0
- data/lib/lyra/mode_sync.rb +125 -0
- data/lib/lyra/mode_transition.rb +386 -0
- data/lib/lyra/model_selection.rb +160 -0
- data/lib/lyra/optimistic_locking.rb +88 -0
- data/lib/lyra/optional_dependency.rb +24 -0
- data/lib/lyra/own_connection.rb +54 -0
- data/lib/lyra/privacy/adapters/pam.rb +151 -0
- data/lib/lyra/privacy/gdpr_compliance.rb +51 -1
- data/lib/lyra/privacy/interface.rb +262 -0
- data/lib/lyra/privacy/pii_detector.rb +8 -10
- data/lib/lyra/privacy/pii_masker.rb +30 -13
- data/lib/lyra/privacy/policy_integration.rb +39 -148
- data/lib/lyra/projection.rb +30 -7
- data/lib/lyra/projections/association_conditions.rb +81 -0
- data/lib/lyra/projections/async_checkpoint.rb +57 -0
- data/lib/lyra/projections/async_projection_job.rb +68 -15
- data/lib/lyra/projections/cached_joins.rb +192 -0
- data/lib/lyra/projections/cached_projection.rb +279 -163
- data/lib/lyra/projections/cached_relation.rb +666 -85
- data/lib/lyra/projections/deferred_relation.rb +120 -0
- data/lib/lyra/projections/event_store_reader.rb +27 -4
- data/lib/lyra/projections/lazy_projection.rb +259 -0
- data/lib/lyra/projections/model_projection.rb +32 -34
- data/lib/lyra/projections/rebuild.rb +170 -0
- data/lib/lyra/projections/reference_links.rb +309 -0
- data/lib/lyra/purpose_bound_reads.rb +197 -0
- data/lib/lyra/repair.rb +114 -0
- data/lib/lyra/retention.rb +180 -0
- data/lib/lyra/schema/event_class_registrar.rb +2 -1
- data/lib/lyra/schema/validator.rb +14 -5
- data/lib/lyra/sti_stream_migration.rb +70 -0
- data/lib/lyra/strict_data_access.rb +225 -175
- data/lib/lyra/temporal.rb +128 -0
- data/lib/lyra/verification/bypass_workflow.rb +170 -0
- data/lib/lyra/verification/crud_lifecycle_workflow.rb +32 -6
- data/lib/lyra/verification/workflow_generator.rb +73 -179
- data/lib/lyra/version.rb +1 -1
- data/lib/lyra.rb +114 -17
- data/lib/tasks/lyra_mode.rake +89 -0
- data/lib/tasks/lyra_projections.rake +78 -0
- data/lib/tasks/lyra_schema.rake +4 -0
- data/lib/tasks/lyra_workflows.rake +28 -27
- metadata +39 -11
- data/lib/lyra/event_mapper.rb +0 -106
- data/lib/tasks/dist.rake +0 -391
- data/lib/tasks/gems.rake +0 -185
- data/lib/tasks/public_release.rake +0 -351
- data/lib/tasks/stats.rake +0 -175
- data/lib/tasks/testbed.rake +0 -479
|
@@ -3,6 +3,12 @@ module Lyra
|
|
|
3
3
|
# Base controller for Lyra engine
|
|
4
4
|
protect_from_forgery with: :exception, unless: -> { request.format.json? }
|
|
5
5
|
|
|
6
|
+
# Every engine action is authorized first: the routes expose a data
|
|
7
|
+
# subject's personal data (privacy/subject, portable_export, gdpr_report,
|
|
8
|
+
# pii_inventory), so the dashboard fails closed. See
|
|
9
|
+
# Lyra::Configuration#dashboard_authorization.
|
|
10
|
+
before_action :authorize_lyra_dashboard!
|
|
11
|
+
|
|
6
12
|
helper_method :pam_dsl_available?, :user_tracking_configured?, :petri_flow_available?
|
|
7
13
|
|
|
8
14
|
# Check if PAM DSL is available for privacy features
|
|
@@ -19,5 +25,47 @@ module Lyra
|
|
|
19
25
|
def user_tracking_configured?
|
|
20
26
|
defined?(::Current) && ::Current.respond_to?(:user)
|
|
21
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# config.dashboard_authorization is evaluated with instance_exec on this
|
|
32
|
+
# controller, so its body may call the controller's methods directly,
|
|
33
|
+
# private ones included (request, session, cookies, params, and any
|
|
34
|
+
# helper on ActionController::Base, such as Devise's current_user). A
|
|
35
|
+
# proc taking an argument is also given the controller:
|
|
36
|
+
#
|
|
37
|
+
# config.dashboard_authorization = ->(controller) { controller.current_user&.admin? }
|
|
38
|
+
# config.dashboard_authorization = -> { session[:admin] == true }
|
|
39
|
+
#
|
|
40
|
+
# This controller inherits ActionController::Base, not the host's
|
|
41
|
+
# ApplicationController, so methods defined only there are not reachable.
|
|
42
|
+
#
|
|
43
|
+
# Truthy allows the request; falsy answers 403. Unset (nil), the dashboard
|
|
44
|
+
# is open only in the development and test environments.
|
|
45
|
+
def authorize_lyra_dashboard!
|
|
46
|
+
check = Lyra.config.dashboard_authorization
|
|
47
|
+
|
|
48
|
+
allowed =
|
|
49
|
+
if check.nil?
|
|
50
|
+
lyra_dashboard_open_by_default?
|
|
51
|
+
elsif check.arity.zero?
|
|
52
|
+
instance_exec(&check)
|
|
53
|
+
else
|
|
54
|
+
instance_exec(self, &check)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
head :forbidden unless allowed
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def lyra_dashboard_open_by_default?
|
|
61
|
+
return true if Rails.env.development? || Rails.env.test?
|
|
62
|
+
|
|
63
|
+
Rails.logger&.warn(
|
|
64
|
+
"[Lyra] Dashboard request refused (403): set config.dashboard_authorization " \
|
|
65
|
+
"in the Lyra initializer to allow access outside development and test, " \
|
|
66
|
+
"e.g. ->(controller) { controller.current_user&.admin? }"
|
|
67
|
+
)
|
|
68
|
+
false
|
|
69
|
+
end
|
|
22
70
|
end
|
|
23
71
|
end
|
|
@@ -286,10 +286,6 @@ end</code></pre>
|
|
|
286
286
|
<dt>Aggregate Class</dt>
|
|
287
287
|
<dd><code><%= config[:aggregate_class] %></code></dd>
|
|
288
288
|
<% end %>
|
|
289
|
-
<% if config[:command_handler] %>
|
|
290
|
-
<dt>Command Handler</dt>
|
|
291
|
-
<dd><code><%= config[:command_handler] %></code></dd>
|
|
292
|
-
<% end %>
|
|
293
289
|
<% if config[:privacy_policy] %>
|
|
294
290
|
<dt>Privacy Policy</dt>
|
|
295
291
|
<dd><code><%= config[:privacy_policy] %></code></dd>
|
|
@@ -105,6 +105,12 @@
|
|
|
105
105
|
</div>
|
|
106
106
|
<div class="label">Deadlock Free</div>
|
|
107
107
|
</div>
|
|
108
|
+
<div class="stat-card">
|
|
109
|
+
<div class="value <%= @summary[:bypass_covered] ? 'pass' : 'fail' %>">
|
|
110
|
+
<%= @summary[:bypass_covered] ? "✓" : "✗" %>
|
|
111
|
+
</div>
|
|
112
|
+
<div class="label">Bypass Writes Recorded</div>
|
|
113
|
+
</div>
|
|
108
114
|
</div>
|
|
109
115
|
|
|
110
116
|
<div class="recommendations" style="margin-top: 20px; margin-bottom: 20px;">
|
|
@@ -161,8 +167,8 @@
|
|
|
161
167
|
<div class="value"><%= @lifecycle[:verification][:boundedness][:is_safe] ? "Yes" : "No" %></div>
|
|
162
168
|
</div>
|
|
163
169
|
<div class="property">
|
|
164
|
-
<div class="name">Deadlock Free</div>
|
|
165
|
-
<div class="value"><%= @lifecycle[:verification][:liveness][:
|
|
170
|
+
<div class="name">Deadlock Free (except at terminal)</div>
|
|
171
|
+
<div class="value"><%= @lifecycle[:verification][:liveness][:terminates_properly] ? "Yes" : "No" %></div>
|
|
166
172
|
</div>
|
|
167
173
|
</div>
|
|
168
174
|
|
|
@@ -1,80 +1,44 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# Auto-generated by Lyra::Verification::WorkflowGenerator
|
|
3
|
-
# Generated at: 2026-
|
|
4
|
-
# Updated: Fork pattern for true async parallelism
|
|
3
|
+
# Generated at: 2026-10-05 05:52:34 UTC
|
|
5
4
|
|
|
6
5
|
# Event Sourcing Async Mode Workflow
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# Uses Petri net FORK pattern to model true parallelism:
|
|
10
|
-
# After events are stored, a single fork transition places tokens in
|
|
11
|
-
# BOTH response_returned AND job_processing simultaneously.
|
|
12
|
-
# This correctly models async behavior where:
|
|
13
|
-
# 1. Response returns immediately to caller (non-blocking)
|
|
14
|
-
# 2. Background worker processes projection independently
|
|
6
|
+
# The event is stored; a job projects it into the table after commit
|
|
15
7
|
class EsAsyncModeWorkflow < PetriFlow::Workflow
|
|
16
8
|
workflow_name "Event Sourcing Async Mode Workflow"
|
|
17
9
|
|
|
18
|
-
places :idle, :
|
|
19
|
-
:command_applying, :events_generated, :events_storing, :events_stored,
|
|
20
|
-
:response_returned, :job_processing, :projecting_async, :projection_complete
|
|
10
|
+
places :idle, :command_built, :event_built, :event_applied, :event_stored, :response_returned, :job_enqueued, :projection_complete
|
|
21
11
|
initial_place :idle
|
|
22
12
|
terminal_places :response_returned, :projection_complete
|
|
23
13
|
|
|
24
|
-
|
|
25
|
-
transition :receive_command,
|
|
14
|
+
transition :intercept_write,
|
|
26
15
|
from: :idle,
|
|
27
|
-
to: :
|
|
28
|
-
trigger: "
|
|
16
|
+
to: :command_built,
|
|
17
|
+
trigger: "WriteHooks: a Lyra::Command (a create first reserves its id); the row write is withheld"
|
|
29
18
|
|
|
30
|
-
transition :
|
|
31
|
-
from: :
|
|
32
|
-
to: :
|
|
33
|
-
trigger: "
|
|
19
|
+
transition :build_event,
|
|
20
|
+
from: :command_built,
|
|
21
|
+
to: :event_built,
|
|
22
|
+
trigger: "Lyra::CommandHandler.handle: create_events"
|
|
34
23
|
|
|
35
|
-
transition :
|
|
36
|
-
from: :
|
|
37
|
-
to: :
|
|
38
|
-
trigger: "
|
|
24
|
+
transition :apply_event,
|
|
25
|
+
from: :event_built,
|
|
26
|
+
to: :event_applied,
|
|
27
|
+
trigger: "aggregate.apply(event)"
|
|
39
28
|
|
|
40
|
-
transition :
|
|
41
|
-
from: :
|
|
42
|
-
to: :
|
|
43
|
-
trigger: "
|
|
29
|
+
transition :store_event,
|
|
30
|
+
from: :event_applied,
|
|
31
|
+
to: :event_stored,
|
|
32
|
+
trigger: "after_* callback lyra_finalize_event_source: Lyra.append_events"
|
|
44
33
|
|
|
45
|
-
transition :generate_events,
|
|
46
|
-
from: :command_applying,
|
|
47
|
-
to: :events_generated,
|
|
48
|
-
trigger: "Domain events created"
|
|
49
|
-
|
|
50
|
-
transition :store_events,
|
|
51
|
-
from: :events_generated,
|
|
52
|
-
to: :events_storing,
|
|
53
|
-
trigger: "EventStore.append_to_stream"
|
|
54
|
-
|
|
55
|
-
transition :events_persisted,
|
|
56
|
-
from: :events_storing,
|
|
57
|
-
to: :events_stored,
|
|
58
|
-
trigger: "Events committed to store"
|
|
59
|
-
|
|
60
|
-
# FORK: Parallel split - one transition produces tokens in TWO places
|
|
61
|
-
# This models the async behavior where both happen simultaneously:
|
|
62
|
-
# - Response returns immediately to caller
|
|
63
|
-
# - Background job is enqueued for processing
|
|
64
34
|
transition :async_fork,
|
|
65
|
-
from: :
|
|
66
|
-
to: [:response_returned, :
|
|
67
|
-
trigger: "
|
|
68
|
-
|
|
69
|
-
# Background projection flow (runs independently after fork)
|
|
70
|
-
transition :project_async,
|
|
71
|
-
from: :job_processing,
|
|
72
|
-
to: :projecting_async,
|
|
73
|
-
trigger: "ModelProjection.apply (async)"
|
|
35
|
+
from: :event_stored,
|
|
36
|
+
to: [:response_returned, :job_enqueued],
|
|
37
|
+
trigger: "AsyncProjectionJob.perform_later, after commit"
|
|
74
38
|
|
|
75
|
-
transition :
|
|
76
|
-
from: :
|
|
39
|
+
transition :project_row,
|
|
40
|
+
from: :job_enqueued,
|
|
77
41
|
to: :projection_complete,
|
|
78
|
-
trigger: "
|
|
42
|
+
trigger: "AsyncProjectionJob writes the row from the event, under a per-stream lock"
|
|
79
43
|
|
|
80
44
|
end
|
|
@@ -1,64 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# Auto-generated by Lyra::Verification::WorkflowGenerator
|
|
3
|
-
# Generated at: 2026-
|
|
3
|
+
# Generated at: 2026-10-05 05:52:34 UTC
|
|
4
4
|
|
|
5
5
|
# Event Sourcing Sync Mode Workflow
|
|
6
|
-
#
|
|
6
|
+
# The event is stored, then projected into the table in the same transaction
|
|
7
7
|
class EsSyncModeWorkflow < PetriFlow::Workflow
|
|
8
8
|
workflow_name "Event Sourcing Sync Mode Workflow"
|
|
9
9
|
|
|
10
|
-
places :idle, :
|
|
10
|
+
places :idle, :command_built, :event_built, :event_applied, :event_stored, :completed
|
|
11
11
|
initial_place :idle
|
|
12
12
|
terminal_places :completed
|
|
13
13
|
|
|
14
|
-
transition :
|
|
14
|
+
transition :intercept_write,
|
|
15
15
|
from: :idle,
|
|
16
|
-
to: :
|
|
17
|
-
trigger: "
|
|
18
|
-
|
|
19
|
-
transition :
|
|
20
|
-
from: :
|
|
21
|
-
to: :
|
|
22
|
-
trigger: "
|
|
23
|
-
|
|
24
|
-
transition :
|
|
25
|
-
from: :
|
|
26
|
-
to: :
|
|
27
|
-
trigger: "
|
|
28
|
-
|
|
29
|
-
transition :
|
|
30
|
-
from: :
|
|
31
|
-
to: :
|
|
32
|
-
trigger: "
|
|
33
|
-
|
|
34
|
-
transition :
|
|
35
|
-
from: :
|
|
36
|
-
to: :events_generated,
|
|
37
|
-
trigger: "Domain events created"
|
|
38
|
-
|
|
39
|
-
transition :store_events,
|
|
40
|
-
from: :events_generated,
|
|
41
|
-
to: :events_storing,
|
|
42
|
-
trigger: "EventStore.append_to_stream"
|
|
43
|
-
|
|
44
|
-
transition :events_persisted,
|
|
45
|
-
from: :events_storing,
|
|
46
|
-
to: :events_stored,
|
|
47
|
-
trigger: "Events committed to store"
|
|
48
|
-
|
|
49
|
-
transition :project_sync,
|
|
50
|
-
from: :events_stored,
|
|
51
|
-
to: :projecting_sync,
|
|
52
|
-
trigger: "ModelProjection.apply (synchronous)"
|
|
53
|
-
|
|
54
|
-
transition :sync_complete,
|
|
55
|
-
from: :projecting_sync,
|
|
56
|
-
to: :projection_complete,
|
|
57
|
-
trigger: "Read model updated"
|
|
58
|
-
|
|
59
|
-
transition :finalize,
|
|
60
|
-
from: :projection_complete,
|
|
16
|
+
to: :command_built,
|
|
17
|
+
trigger: "WriteHooks: a Lyra::Command (a create first reserves its id); the row write is withheld"
|
|
18
|
+
|
|
19
|
+
transition :build_event,
|
|
20
|
+
from: :command_built,
|
|
21
|
+
to: :event_built,
|
|
22
|
+
trigger: "Lyra::CommandHandler.handle: create_events"
|
|
23
|
+
|
|
24
|
+
transition :apply_event,
|
|
25
|
+
from: :event_built,
|
|
26
|
+
to: :event_applied,
|
|
27
|
+
trigger: "aggregate.apply(event)"
|
|
28
|
+
|
|
29
|
+
transition :store_event,
|
|
30
|
+
from: :event_applied,
|
|
31
|
+
to: :event_stored,
|
|
32
|
+
trigger: "after_* callback lyra_finalize_event_source: Lyra.append_events"
|
|
33
|
+
|
|
34
|
+
transition :project_row,
|
|
35
|
+
from: :event_stored,
|
|
61
36
|
to: :completed,
|
|
62
|
-
trigger: "
|
|
37
|
+
trigger: "synchronous projection writes the row from the event"
|
|
63
38
|
|
|
64
39
|
end
|
|
@@ -1,54 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# Auto-generated by Lyra::Verification::WorkflowGenerator
|
|
3
|
-
# Generated at: 2026-
|
|
3
|
+
# Generated at: 2026-10-05 05:52:34 UTC
|
|
4
4
|
|
|
5
5
|
# Hijack Mode Workflow
|
|
6
|
-
#
|
|
6
|
+
# The event is built and stored before the row, which is written from it
|
|
7
7
|
class HijackModeWorkflow < PetriFlow::Workflow
|
|
8
8
|
workflow_name "Hijack Mode Workflow"
|
|
9
9
|
|
|
10
|
-
places :idle, :
|
|
10
|
+
places :idle, :command_built, :event_built, :event_applied, :event_stored, :completed
|
|
11
11
|
initial_place :idle
|
|
12
12
|
terminal_places :completed
|
|
13
13
|
|
|
14
|
-
transition :
|
|
14
|
+
transition :intercept_write,
|
|
15
15
|
from: :idle,
|
|
16
|
-
to: :
|
|
17
|
-
trigger: "before_*
|
|
16
|
+
to: :command_built,
|
|
17
|
+
trigger: "WriteHooks, after the model's before_* callbacks: a Lyra::Command (a create first reserves its id)"
|
|
18
18
|
|
|
19
|
-
transition :
|
|
20
|
-
from: :
|
|
21
|
-
to: :
|
|
22
|
-
trigger: "
|
|
19
|
+
transition :build_event,
|
|
20
|
+
from: :command_built,
|
|
21
|
+
to: :event_built,
|
|
22
|
+
trigger: "Lyra::CommandHandler.handle: create_events"
|
|
23
23
|
|
|
24
|
-
transition :
|
|
25
|
-
from: :
|
|
26
|
-
to: :
|
|
27
|
-
trigger: "
|
|
28
|
-
|
|
29
|
-
transition :command_passes,
|
|
30
|
-
from: :command_validating,
|
|
31
|
-
to: :command_valid,
|
|
32
|
-
trigger: "Validation passes"
|
|
33
|
-
|
|
34
|
-
transition :emit_event,
|
|
35
|
-
from: :command_valid,
|
|
36
|
-
to: :event_created,
|
|
37
|
-
trigger: "CommandHandler.execute creates event"
|
|
24
|
+
transition :apply_event,
|
|
25
|
+
from: :event_built,
|
|
26
|
+
to: :event_applied,
|
|
27
|
+
trigger: "aggregate.apply(event): the event joins the aggregate's pending events"
|
|
38
28
|
|
|
39
29
|
transition :store_event,
|
|
40
|
-
from: :
|
|
30
|
+
from: :event_applied,
|
|
41
31
|
to: :event_stored,
|
|
42
|
-
trigger: "
|
|
32
|
+
trigger: "aggregate.store: Lyra.append_events"
|
|
43
33
|
|
|
44
|
-
transition :
|
|
34
|
+
transition :write_row,
|
|
45
35
|
from: :event_stored,
|
|
46
|
-
to: :projecting,
|
|
47
|
-
trigger: "Projection.apply(event)"
|
|
48
|
-
|
|
49
|
-
transition :projection_complete,
|
|
50
|
-
from: :projecting,
|
|
51
36
|
to: :completed,
|
|
52
|
-
trigger: "
|
|
37
|
+
trigger: "ActiveRecord writes the row with the event's attributes"
|
|
53
38
|
|
|
54
39
|
end
|
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
# Auto-generated by Lyra::Verification::WorkflowGenerator
|
|
3
|
-
# Generated at: 2026-
|
|
3
|
+
# Generated at: 2026-10-05 05:52:34 UTC
|
|
4
4
|
|
|
5
5
|
# Monitor Mode Workflow
|
|
6
|
-
#
|
|
6
|
+
# The row is written as usual; the event follows in the same transaction
|
|
7
7
|
class MonitorModeWorkflow < PetriFlow::Workflow
|
|
8
8
|
workflow_name "Monitor Mode Workflow"
|
|
9
9
|
|
|
10
|
-
places :idle, :
|
|
10
|
+
places :idle, :row_written, :event_built, :completed
|
|
11
11
|
initial_place :idle
|
|
12
12
|
terminal_places :completed
|
|
13
13
|
|
|
14
|
-
transition :
|
|
14
|
+
transition :write_row,
|
|
15
15
|
from: :idle,
|
|
16
|
-
to: :
|
|
17
|
-
trigger: "ActiveRecord
|
|
18
|
-
|
|
19
|
-
transition :crud_success,
|
|
20
|
-
from: :crud_executing,
|
|
21
|
-
to: :crud_completed,
|
|
22
|
-
trigger: "CRUD operation completes successfully"
|
|
16
|
+
to: :row_written,
|
|
17
|
+
trigger: "ActiveRecord writes the row"
|
|
23
18
|
|
|
24
19
|
transition :build_event,
|
|
25
|
-
from: :
|
|
26
|
-
to: :
|
|
27
|
-
trigger: "
|
|
28
|
-
|
|
29
|
-
transition :publish_event,
|
|
30
|
-
from: :event_building,
|
|
31
|
-
to: :event_publishing,
|
|
32
|
-
trigger: "Lyra::Event.publish"
|
|
20
|
+
from: :row_written,
|
|
21
|
+
to: :event_built,
|
|
22
|
+
trigger: "after_* callback: CrudInterceptor#publish_event builds it (Lyra::DomainEvents.build)"
|
|
33
23
|
|
|
34
|
-
transition :
|
|
35
|
-
from: :
|
|
24
|
+
transition :append_event,
|
|
25
|
+
from: :event_built,
|
|
36
26
|
to: :completed,
|
|
37
|
-
trigger: "
|
|
27
|
+
trigger: "Lyra.append_events, in a savepoint of the same transaction"
|
|
38
28
|
|
|
39
29
|
end
|
data/config/privacy_policies.rb
CHANGED
|
@@ -110,9 +110,9 @@ PamDsl.define_policy :university_system do
|
|
|
110
110
|
purpose :communication do
|
|
111
111
|
describe "Important academic and administrative communications"
|
|
112
112
|
basis :legitimate_interests
|
|
113
|
+
lia_documented!
|
|
113
114
|
requires :email
|
|
114
115
|
optionally :phone, :name
|
|
115
|
-
meta :balancing_test_performed, true
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
purpose :academic_records do
|
|
@@ -140,6 +140,7 @@ PamDsl.define_policy :university_system do
|
|
|
140
140
|
purpose :analytics do
|
|
141
141
|
describe "Improving educational services and user experience"
|
|
142
142
|
basis :legitimate_interests
|
|
143
|
+
lia_documented!
|
|
143
144
|
requires :student_id
|
|
144
145
|
meta :anonymization_applied, true
|
|
145
146
|
end
|
data/config/routes.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
Lyra::Engine.routes.draw do
|
|
2
|
-
# Root redirect to dashboard
|
|
3
|
-
|
|
2
|
+
# Root redirect to dashboard, relative to wherever the engine is mounted:
|
|
3
|
+
# Rails prefixes a relative redirect path with the request's script name
|
|
4
|
+
# (the mount point), so /admin/lyra goes to /admin/lyra/dashboard.
|
|
5
|
+
root to: redirect('dashboard')
|
|
4
6
|
|
|
5
7
|
# Dashboard routes
|
|
6
8
|
get 'dashboard', to: 'dashboard#index'
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
# Records every access the privacy policy validates (PAM's
|
|
5
|
+
# validate_access!) as an event, when config.record_access_events is on:
|
|
6
|
+
# who accessed which declared attributes of which subject, for which
|
|
7
|
+
# purpose under which legal basis, and whether it was granted.
|
|
8
|
+
#
|
|
9
|
+
# - DataAccessed: the access went ahead, outcome "granted", or "audited"
|
|
10
|
+
# when audit mode let it through despite violations (listed).
|
|
11
|
+
# - DataAccessDenied: strict mode refused it (violations listed).
|
|
12
|
+
#
|
|
13
|
+
# Each goes to the subject's access stream, Lyra::DataAccess$<subject>
|
|
14
|
+
# (Lyra::DataAccess$Registration$5 for a record), never to the record's
|
|
15
|
+
# own stream, so replay, DualView and mode transitions do not see it.
|
|
16
|
+
# Field names only, never values.
|
|
17
|
+
#
|
|
18
|
+
# Who accessed goes in the metadata: user_id (Current.user) and
|
|
19
|
+
# ip_address (Current.ip_address) when the application keeps them in
|
|
20
|
+
# Current, request and correlation ids, and whatever
|
|
21
|
+
# config.access_metadata_proc returns for the access (a console, a job,
|
|
22
|
+
# an API token: anything Current does not know). Without either, an
|
|
23
|
+
# access carries no user: set one.
|
|
24
|
+
#
|
|
25
|
+
# Every access is recorded, with no sampling. An access that cannot be
|
|
26
|
+
# recorded does not go ahead: the store's error propagates from
|
|
27
|
+
# validate_access!. Off by default, and nothing is recorded in disabled
|
|
28
|
+
# mode: reads can outnumber writes by orders of magnitude, so its cost is
|
|
29
|
+
# measured separately (LYRA_RECORD_ACCESS=1 in the Aegean testbed).
|
|
30
|
+
#
|
|
31
|
+
# The Article 30 record of processing does not depend on it: PAM's
|
|
32
|
+
# reporter generates that from the declared policy. The access log is
|
|
33
|
+
# the evidence of what was actually accessed.
|
|
34
|
+
module AccessLog
|
|
35
|
+
STREAM = "Lyra::DataAccess"
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# PamDsl.access_recorder protocol.
|
|
39
|
+
def record?(_policy)
|
|
40
|
+
Lyra.config.record_access_events && !Lyra.config.disabled_mode?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(access)
|
|
44
|
+
event_class = access.outcome == :denied ? Lyra::Events::DataAccessDenied : Lyra::Events::DataAccessed
|
|
45
|
+
subject = subject_key(access.subject)
|
|
46
|
+
event = event_class.new(data: data_for(access, subject), metadata: metadata(access))
|
|
47
|
+
Lyra.append_events(event, stream_name: stream_for(subject))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def stream_for(subject)
|
|
51
|
+
subject ? "#{STREAM}$#{subject}" : STREAM
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# "Registration$5" for a record, the value itself for an id.
|
|
55
|
+
def subject_key(subject)
|
|
56
|
+
case subject
|
|
57
|
+
when nil then nil
|
|
58
|
+
when ActiveRecord::Base then Lyra.stream_name(subject.class, subject.id)
|
|
59
|
+
else subject.to_s
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The accesses recorded for +subject+ (a record or an id), oldest first.
|
|
64
|
+
def for(subject)
|
|
65
|
+
Lyra.config.event_store.read.stream(stream_for(subject_key(subject))).to_a
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def data_for(access, subject)
|
|
71
|
+
data = {
|
|
72
|
+
policy: access.policy.to_s,
|
|
73
|
+
purpose: access.purpose.to_s,
|
|
74
|
+
legal_basis: access.legal_basis&.to_s,
|
|
75
|
+
fields: access.fields.map(&:to_s),
|
|
76
|
+
subject: subject,
|
|
77
|
+
outcome: access.outcome.to_s,
|
|
78
|
+
accessed_at: access.at
|
|
79
|
+
}
|
|
80
|
+
if access.violations.any?
|
|
81
|
+
data[:violations] = access.violations.map { |error| { error: error.class.name, message: error.message } }
|
|
82
|
+
end
|
|
83
|
+
data
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def metadata(access)
|
|
87
|
+
{
|
|
88
|
+
user_id: current(:user)&.id,
|
|
89
|
+
ip_address: current(:ip_address),
|
|
90
|
+
request_id: current(:request_id),
|
|
91
|
+
correlation_id: Lyra::Correlation.current_id,
|
|
92
|
+
causation_id: Lyra::Causation.current_id
|
|
93
|
+
}.merge(custom_metadata(access)).merge(source: "lyra_access_log").compact
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def current(attribute)
|
|
97
|
+
defined?(::Current) && ::Current.respond_to?(attribute) ? ::Current.public_send(attribute) : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# config.access_metadata_proc's hash, with symbol keys. A failing proc
|
|
101
|
+
# is logged and skipped: the access is still recorded, without it.
|
|
102
|
+
def custom_metadata(access)
|
|
103
|
+
proc = Lyra.config.access_metadata_proc
|
|
104
|
+
return {} unless proc
|
|
105
|
+
|
|
106
|
+
result = proc.call(access)
|
|
107
|
+
result.is_a?(Hash) ? result.transform_keys(&:to_sym) : {}
|
|
108
|
+
rescue => e
|
|
109
|
+
Rails.logger.warn("Lyra: access_metadata_proc failed - #{e.message}")
|
|
110
|
+
{}
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
module Events
|
|
116
|
+
class DataAccessed < Lyra::Event; end
|
|
117
|
+
class DataAccessDenied < Lyra::Event; end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
PamDsl.access_recorder = Lyra::AccessLog if defined?(PamDsl) && PamDsl.respond_to?(:access_recorder=)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lyra
|
|
4
|
+
# A PostgreSQL transaction-level advisory lock, released at commit. Genesis,
|
|
5
|
+
# ES-Lazy's catch-up and ES-Async's per-stream projection take one so that
|
|
6
|
+
# several processes running them at once never do the same work twice.
|
|
7
|
+
#
|
|
8
|
+
# SQLite needs none: it serialises writers. Other adapters have no such lock
|
|
9
|
+
# here, so the call does nothing there and warns once per purpose, since
|
|
10
|
+
# those paths are then safe in a single process only.
|
|
11
|
+
module AdvisoryLock
|
|
12
|
+
@warned = {}
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Returns true when a lock was taken, false otherwise.
|
|
16
|
+
def xact_lock(connection, key, purpose:)
|
|
17
|
+
adapter = connection.adapter_name
|
|
18
|
+
if adapter.match?(/postgres/i)
|
|
19
|
+
connection.execute("SELECT pg_advisory_xact_lock(hashtext(#{connection.quote(key)}))")
|
|
20
|
+
return true
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
warn_once(adapter, purpose) unless adapter.match?(/sqlite/i)
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Forget which purposes have warned (tests).
|
|
28
|
+
def reset_warnings!
|
|
29
|
+
@warned = {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def warn_once(adapter, purpose)
|
|
35
|
+
return if @warned[purpose]
|
|
36
|
+
|
|
37
|
+
@warned[purpose] = true
|
|
38
|
+
logger = defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
39
|
+
message = "Lyra: #{purpose} runs without a lock on #{adapter} (only PostgreSQL has the " \
|
|
40
|
+
"advisory lock Lyra uses); it is safe in a single process only"
|
|
41
|
+
logger ? logger.warn(message) : Kernel.warn(message)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|