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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +851 -0
  3. data/README.md +196 -987
  4. data/app/controllers/lyra/application_controller.rb +48 -0
  5. data/app/controllers/lyra/dashboard_controller.rb +0 -1
  6. data/app/views/lyra/dashboard/projections.html.erb +0 -4
  7. data/app/views/lyra/dashboard/verification.html.erb +8 -2
  8. data/app/workflows/es_async_mode_workflow.rb +24 -60
  9. data/app/workflows/es_sync_mode_workflow.rb +25 -50
  10. data/app/workflows/hijack_mode_workflow.rb +18 -33
  11. data/app/workflows/lifecycle_workflow.rb +1 -1
  12. data/app/workflows/monitor_mode_workflow.rb +12 -22
  13. data/config/privacy_policies.rb +2 -1
  14. data/config/routes.rb +4 -2
  15. data/lib/lyra/access_log.rb +121 -0
  16. data/lib/lyra/advisory_lock.rb +45 -0
  17. data/lib/lyra/aggregate.rb +25 -13
  18. data/lib/lyra/associations/event_aware.rb +3 -4
  19. data/lib/lyra/associations/habtm_links.rb +255 -0
  20. data/lib/lyra/bypass_events.rb +208 -0
  21. data/lib/lyra/command.rb +3 -0
  22. data/lib/lyra/command_handler.rb +164 -42
  23. data/lib/lyra/configuration.rb +217 -8
  24. data/lib/lyra/consistency/read_your_writes.rb +74 -23
  25. data/lib/lyra/domain_events.rb +157 -0
  26. data/lib/lyra/dual_view.rb +27 -6
  27. data/lib/lyra/dual_view_sampler.rb +33 -0
  28. data/lib/lyra/encrypted_attributes.rb +101 -0
  29. data/lib/lyra/engine.rb +67 -4
  30. data/lib/lyra/erasure.rb +316 -0
  31. data/lib/lyra/event.rb +120 -2
  32. data/lib/lyra/event_flow.rb +16 -6
  33. data/lib/lyra/event_serializer.rb +45 -0
  34. data/lib/lyra/event_store_adapter.rb +58 -62
  35. data/lib/lyra/genesis.rb +189 -0
  36. data/lib/lyra/id_generator.rb +42 -10
  37. data/lib/lyra/interceptors/association_interceptor.rb +123 -6
  38. data/lib/lyra/interceptors/crud_interceptor.rb +400 -74
  39. data/lib/lyra/interceptors/lazy_reads.rb +46 -0
  40. data/lib/lyra/mode_sync.rb +125 -0
  41. data/lib/lyra/mode_transition.rb +386 -0
  42. data/lib/lyra/model_selection.rb +160 -0
  43. data/lib/lyra/optimistic_locking.rb +88 -0
  44. data/lib/lyra/optional_dependency.rb +24 -0
  45. data/lib/lyra/own_connection.rb +54 -0
  46. data/lib/lyra/privacy/adapters/pam.rb +151 -0
  47. data/lib/lyra/privacy/gdpr_compliance.rb +51 -1
  48. data/lib/lyra/privacy/interface.rb +262 -0
  49. data/lib/lyra/privacy/pii_detector.rb +8 -10
  50. data/lib/lyra/privacy/pii_masker.rb +30 -13
  51. data/lib/lyra/privacy/policy_integration.rb +39 -148
  52. data/lib/lyra/projection.rb +30 -7
  53. data/lib/lyra/projections/association_conditions.rb +81 -0
  54. data/lib/lyra/projections/async_checkpoint.rb +57 -0
  55. data/lib/lyra/projections/async_projection_job.rb +68 -15
  56. data/lib/lyra/projections/cached_joins.rb +192 -0
  57. data/lib/lyra/projections/cached_projection.rb +279 -163
  58. data/lib/lyra/projections/cached_relation.rb +666 -85
  59. data/lib/lyra/projections/deferred_relation.rb +120 -0
  60. data/lib/lyra/projections/event_store_reader.rb +27 -4
  61. data/lib/lyra/projections/lazy_projection.rb +259 -0
  62. data/lib/lyra/projections/model_projection.rb +32 -34
  63. data/lib/lyra/projections/rebuild.rb +170 -0
  64. data/lib/lyra/projections/reference_links.rb +309 -0
  65. data/lib/lyra/purpose_bound_reads.rb +197 -0
  66. data/lib/lyra/repair.rb +114 -0
  67. data/lib/lyra/retention.rb +180 -0
  68. data/lib/lyra/schema/event_class_registrar.rb +2 -1
  69. data/lib/lyra/schema/validator.rb +14 -5
  70. data/lib/lyra/sti_stream_migration.rb +70 -0
  71. data/lib/lyra/strict_data_access.rb +225 -175
  72. data/lib/lyra/temporal.rb +128 -0
  73. data/lib/lyra/verification/bypass_workflow.rb +170 -0
  74. data/lib/lyra/verification/crud_lifecycle_workflow.rb +32 -6
  75. data/lib/lyra/verification/workflow_generator.rb +73 -179
  76. data/lib/lyra/version.rb +1 -1
  77. data/lib/lyra.rb +114 -17
  78. data/lib/tasks/lyra_mode.rake +89 -0
  79. data/lib/tasks/lyra_projections.rake +78 -0
  80. data/lib/tasks/lyra_schema.rake +4 -0
  81. data/lib/tasks/lyra_workflows.rake +28 -27
  82. metadata +39 -11
  83. data/lib/lyra/event_mapper.rb +0 -106
  84. data/lib/tasks/dist.rake +0 -391
  85. data/lib/tasks/gems.rake +0 -185
  86. data/lib/tasks/public_release.rake +0 -351
  87. data/lib/tasks/stats.rake +0 -175
  88. 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
@@ -84,7 +84,6 @@ module Lyra
84
84
  @model_configs[model_class.name] = {
85
85
  event_prefix: config.event_prefix,
86
86
  aggregate_class: config.aggregate_class,
87
- command_handler: config.command_handler,
88
87
  privacy_policy: config.privacy_policy
89
88
  }
90
89
  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][:deadlock_free] ? "Yes" : "No" %></div>
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-01-04 17:33:03 UTC
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
- # Full event sourcing with asynchronous (non-blocking) projection
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, :command_received, :aggregate_loading, :aggregate_loaded,
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
- # Command processing flow (sequential)
25
- transition :receive_command,
14
+ transition :intercept_write,
26
15
  from: :idle,
27
- to: :command_received,
28
- trigger: "CommandHandler receives command"
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 :load_aggregate,
31
- from: :command_received,
32
- to: :aggregate_loading,
33
- trigger: "Load aggregate from event stream"
19
+ transition :build_event,
20
+ from: :command_built,
21
+ to: :event_built,
22
+ trigger: "Lyra::CommandHandler.handle: create_events"
34
23
 
35
- transition :aggregate_ready,
36
- from: :aggregate_loading,
37
- to: :aggregate_loaded,
38
- trigger: "Aggregate hydrated from events"
24
+ transition :apply_event,
25
+ from: :event_built,
26
+ to: :event_applied,
27
+ trigger: "aggregate.apply(event)"
39
28
 
40
- transition :apply_command,
41
- from: :aggregate_loaded,
42
- to: :command_applying,
43
- trigger: "Aggregate.apply(command)"
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: :events_stored,
66
- to: [:response_returned, :job_processing],
67
- trigger: "Enqueue job & return response (parallel)"
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 :async_complete,
76
- from: :projecting_async,
39
+ transition :project_row,
40
+ from: :job_enqueued,
77
41
  to: :projection_complete,
78
- trigger: "Read model eventually consistent"
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-01-04 17:33:03 UTC
3
+ # Generated at: 2026-10-05 05:52:34 UTC
4
4
 
5
5
  # Event Sourcing Sync Mode Workflow
6
- # Full event sourcing with synchronous (blocking) projection
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, :command_received, :aggregate_loading, :aggregate_loaded, :command_applying, :events_generated, :events_storing, :events_stored, :projecting_sync, :projection_complete, :completed
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 :receive_command,
14
+ transition :intercept_write,
15
15
  from: :idle,
16
- to: :command_received,
17
- trigger: "CommandHandler receives command"
18
-
19
- transition :load_aggregate,
20
- from: :command_received,
21
- to: :aggregate_loading,
22
- trigger: "Load aggregate from event stream"
23
-
24
- transition :aggregate_ready,
25
- from: :aggregate_loading,
26
- to: :aggregate_loaded,
27
- trigger: "Aggregate hydrated from events"
28
-
29
- transition :apply_command,
30
- from: :aggregate_loaded,
31
- to: :command_applying,
32
- trigger: "Aggregate.apply(command)"
33
-
34
- transition :generate_events,
35
- from: :command_applying,
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: "Response returned to caller"
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-01-04 17:33:03 UTC
3
+ # Generated at: 2026-10-05 05:52:34 UTC
4
4
 
5
5
  # Hijack Mode Workflow
6
- # Intercepts CRUD operations and converts to event sourcing
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, :crud_intercepted, :command_created, :command_validating, :command_valid, :event_created, :event_stored, :projecting, :completed
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 :intercept_crud,
14
+ transition :intercept_write,
15
15
  from: :idle,
16
- to: :crud_intercepted,
17
- trigger: "before_* callback intercepts operation"
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 :create_command,
20
- from: :crud_intercepted,
21
- to: :command_created,
22
- trigger: "Convert CRUD to Lyra::Command"
19
+ transition :build_event,
20
+ from: :command_built,
21
+ to: :event_built,
22
+ trigger: "Lyra::CommandHandler.handle: create_events"
23
23
 
24
- transition :validate_command,
25
- from: :command_created,
26
- to: :command_validating,
27
- trigger: "CommandHandler.validate"
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: :event_created,
30
+ from: :event_applied,
41
31
  to: :event_stored,
42
- trigger: "RailsEventStore.publish"
32
+ trigger: "aggregate.store: Lyra.append_events"
43
33
 
44
- transition :project_state,
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: "Model state updated from event"
37
+ trigger: "ActiveRecord writes the row with the event's attributes"
53
38
 
54
39
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  # Auto-generated by Lyra::Verification::WorkflowGenerator
3
- # Generated at: 2026-01-04 17:33:03 UTC
3
+ # Generated at: 2026-10-05 05:52:34 UTC
4
4
 
5
5
  # CRUD Entity Lifecycle (Generated)
6
6
  class LifecycleWorkflow < PetriFlow::Workflow
@@ -1,39 +1,29 @@
1
1
  # frozen_string_literal: true
2
2
  # Auto-generated by Lyra::Verification::WorkflowGenerator
3
- # Generated at: 2026-01-04 17:33:03 UTC
3
+ # Generated at: 2026-10-05 05:52:34 UTC
4
4
 
5
5
  # Monitor Mode Workflow
6
- # Passively observes CRUD operations without modification
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, :crud_executing, :crud_completed, :event_building, :event_publishing, :completed
10
+ places :idle, :row_written, :event_built, :completed
11
11
  initial_place :idle
12
12
  terminal_places :completed
13
13
 
14
- transition :receive_crud,
14
+ transition :write_row,
15
15
  from: :idle,
16
- to: :crud_executing,
17
- trigger: "ActiveRecord callback triggered"
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: :crud_completed,
26
- to: :event_building,
27
- trigger: "Extract changes from model"
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 :store_event,
35
- from: :event_publishing,
24
+ transition :append_event,
25
+ from: :event_built,
36
26
  to: :completed,
37
- trigger: "RailsEventStore.publish"
27
+ trigger: "Lyra.append_events, in a savepoint of the same transaction"
38
28
 
39
29
  end
@@ -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
- root to: redirect('/lyra/dashboard')
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