yes-core 1.3.0 → 2.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a7213daeb56ae07a8921c83b912e83b7f9cd81f6d93d90a4394cbe07b7c9299
4
- data.tar.gz: 58379a04830d2ba08c0ca4431d4e372ee8cc67f8445621d7143c61f1dc27ab9a
3
+ metadata.gz: 4845d0466658aee907edb5bab0679603e25a9b5c088a8df067d0fbfcc060bff4
4
+ data.tar.gz: cca505e973fa99d5b8e4b07c44752b78b330fdc0035b45528c83139ee36a0e4b
5
5
  SHA512:
6
- metadata.gz: f5ce8a14fb78e7c9bc1b11264e16a17f0cbdc0ef3dcbbd7175114a1c772097b67cb1226f359429a6be83900f26d0e911dd18df838aa9c9ad5a76cab5d999b889
7
- data.tar.gz: 01de285d8c90f81625bdd1ff721877b87d23edad1949b1eb860ebd4fc0274c459fbbeb3080e6ecb678fa1cd0174b010b4eb4f146ff00d6e4bea4be935685bb08
6
+ metadata.gz: '08121024f7bb36c1bb17281d772c907f4c1e9c119437caf6642c0461fcc9ce2d434c15fa6dc548ec8c6362b520b5144f8869d7054c8e9a110e33a984d757fd63'
7
+ data.tar.gz: b2b2bcbf061918d7876e27c6014d41adfe1ed5133163a078d09a31490219105a57f47699c5bd2d6c9d3400c12df527c13e2d0f76d64b32f1d4a2b1aaec1b4b8f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.2.0] - 2026-09-01
4
+
5
+ - See root CHANGELOG.md for details.
6
+
7
+ ## [1.4.0] - 2026-06-24
8
+
9
+ - See root CHANGELOG.md for details.
10
+
11
+ ## [1.3.1] - 2026-06-16
12
+
13
+ - See root CHANGELOG.md for details.
14
+
3
15
  ## [1.3.0] - 2026-05-18
4
16
 
5
17
  - See root CHANGELOG.md for details.
data/README.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  Core event sourcing framework providing the aggregate DSL, commands, events, read models, and supporting infrastructure for the [Yes](https://github.com/yousty/yes) framework.
4
4
 
5
+ ## Compatibility with pg_eventstore
6
+
7
+ ⚠️ **pg_eventstore 3.x requires yes-core >= 2.1.1.** pg_eventstore 3.0.0 generates event
8
+ ids with `SecureRandom.uuid_v7` (it moved the default off the database's
9
+ `gen_random_uuid()`, which always produced v4). Those ids arrive as `causation_id` /
10
+ `correlation_id`, and `Types::UUID` accepted only v4 before 2.1.1 — so
11
+ `TransactionDetails.new` raised `Dry::Struct::Error`, the event handler failed, and the
12
+ subscription died once its restarts ran out.
13
+
14
+ `Types::UUID` now accepts any RFC 9562 version (1-8) while still requiring a valid
15
+ version and variant nibble.
16
+
17
+
5
18
  ## Installation
6
19
 
7
20
  Add this line to your application's Gemfile:
@@ -3,22 +3,25 @@
3
3
  module Yes
4
4
  module Core
5
5
  module ActiveJobSerializers
6
- # ActiveJob serializer for CommandGroup objects.
6
+ # ActiveJob serializer for {Yes::Core::Commands::Group} (legacy stateless
7
+ # cross-aggregate groups) and {Yes::Core::Commands::CommandGroup}
8
+ # (aggregate-DSL groups). Both round-trip through `to_h` / `Class.new`.
7
9
  class CommandGroupSerializer < ActiveJob::Serializers::ObjectSerializer
8
10
  # @param argument [Object] the argument to check
9
11
  # @return [Boolean] true if the argument can be serialized
10
12
  def serialize?(argument)
11
- argument.is_a? Yes::Core::Commands::Group
13
+ argument.is_a?(Yes::Core::Commands::Group) ||
14
+ argument.is_a?(Yes::Core::Commands::CommandGroup)
12
15
  end
13
16
 
14
- # @param command_group [Yes::Core::Commands::Group] the command group to serialize
17
+ # @param command_group [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
15
18
  # @return [Hash] the serialized representation
16
19
  def serialize(command_group)
17
20
  super(command_group.to_h.merge(_type: command_group.class.name))
18
21
  end
19
22
 
20
23
  # @param hash [Hash] the serialized representation
21
- # @return [Yes::Core::Commands::Group] the deserialized command group
24
+ # @return [Yes::Core::Commands::Group, Yes::Core::Commands::CommandGroup]
22
25
  def deserialize(hash)
23
26
  symbolized_hash = hash.deep_symbolize_keys
24
27
  Object.const_get(symbolized_hash[:_type]).new(symbolized_hash.except(:_aj_serialized, :_type))
@@ -36,7 +36,8 @@ module Yes
36
36
 
37
37
  raise_command_unauthorized_error!(decision)
38
38
  end
39
- otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command')
39
+ otl_trackable :call,
40
+ OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command', track_sql: true)
40
41
 
41
42
  private
42
43
 
@@ -60,6 +61,10 @@ module Yes
60
61
  raise self::CommandNotAuthorized, msg
61
62
  end
62
63
 
64
+ # Loads the resource the command acts on. Commands batched into one request
65
+ # commonly target the same resource, so the lookup is cached for the duration
66
+ # of the authorization pass (see {LookupCache}).
67
+ #
63
68
  # @param command [Yes::Core::Command] command to authorize
64
69
  # @return [ActiveRecord::Base] resource to authorize
65
70
  # @raise [StandardError] if RESOURCE[:name] or RESOURCE[:read_model] is not defined
@@ -70,7 +75,10 @@ module Yes
70
75
  raise StandardError, message
71
76
  end
72
77
 
73
- read_model(command).find_by(id: command.send("#{self::RESOURCE[:name]}_id"))
78
+ model = read_model(command)
79
+ id = command.send("#{self::RESOURCE[:name]}_id")
80
+
81
+ LookupCache.fetch([:resource, model, id]) { model.find_by(id:) }
74
82
  end
75
83
 
76
84
  # Returns the appropriate read model class for the command.
@@ -145,12 +153,19 @@ module Yes
145
153
  resource&.try(:auth_attributes)&.as_json || {}
146
154
  end
147
155
 
156
+ # Builds the principal data for the request. It is derived purely from the
157
+ # auth data, which is the same for every command of a batch, so it is built
158
+ # once per authorization pass (see {LookupCache}). The result is shared
159
+ # between commands and must not be mutated.
160
+ #
148
161
  # @param auth_data [Hash] authorization data
149
162
  # @return [Hash] principal data for Cerbos check_resource
150
163
  def principal_data(auth_data)
151
- Yes::Core.configuration.cerbos_principal_data_builder.call(
152
- auth_data.with_indifferent_access
153
- )
164
+ data = auth_data.with_indifferent_access
165
+
166
+ LookupCache.fetch([:principal_data, data]) do
167
+ Yes::Core.configuration.cerbos_principal_data_builder.call(data)
168
+ end
154
169
  end
155
170
 
156
171
  # @param auth_data [Hash]
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Authorization
6
+ # Scoped memoization for the read-only lookups an authorization pass repeats.
7
+ #
8
+ # Authorizing a batch of commands resolves the same two things once per command:
9
+ # the principal data (which depends only on the request's auth data) and the
10
+ # authorized resource (which the commands of a batch commonly share). Both are
11
+ # pure reads, and a batch is authorized in full before any of its commands is
12
+ # executed, so no write can invalidate them while the pass is running.
13
+ #
14
+ # Caching is only active inside {.with_scope}. Outside one, {.fetch} just yields,
15
+ # so callers keep their uncached behaviour unless they opt in. The store lives in
16
+ # ActiveSupport::IsolatedExecutionState, which keeps it per thread/fiber, and
17
+ # {.with_scope} always clears it on the way out so nothing leaks into the next
18
+ # request.
19
+ #
20
+ # Cached values are shared by every {.fetch} for the same key, so callers must
21
+ # treat them as read-only.
22
+ #
23
+ # @example Caching the lookups of one authorization pass
24
+ # LookupCache.with_scope do
25
+ # commands.each { |command| authorizer_for(command).call(command, auth_data) }
26
+ # end
27
+ class LookupCache
28
+ STORE_KEY = :yes_core_authorization_lookup_cache
29
+
30
+ class << self
31
+ # Runs the block with caching enabled, clearing the cache afterwards.
32
+ # A nested scope reuses the cache of the outermost one and leaves clearing
33
+ # to it.
34
+ #
35
+ # @yield the block to run with caching enabled
36
+ # @return [Object] the block's return value
37
+ def with_scope
38
+ return yield if active?
39
+
40
+ ActiveSupport::IsolatedExecutionState[STORE_KEY] = {}
41
+
42
+ begin
43
+ yield
44
+ ensure
45
+ ActiveSupport::IsolatedExecutionState.delete(STORE_KEY)
46
+ end
47
+ end
48
+
49
+ # Returns the value cached under key, computing it via the block on a miss.
50
+ # Without an open scope the block's value is returned uncached.
51
+ #
52
+ # @param key [Object] cache key
53
+ # @yield computes the value when it is not cached yet
54
+ # @return [Object] the cached or freshly computed value
55
+ def fetch(key)
56
+ return yield unless active?
57
+
58
+ store = ActiveSupport::IsolatedExecutionState[STORE_KEY]
59
+ return store[key] if store.key?(key)
60
+
61
+ store[key] = yield
62
+ end
63
+
64
+ # @return [Boolean] whether a scope is currently open
65
+ def active?
66
+ ActiveSupport::IsolatedExecutionState.key?(STORE_KEY)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -169,7 +169,8 @@ module Yes
169
169
  PgEventstore.client.append_to_stream(
170
170
  utils.build_stream(metadata: sub_cmd.metadata || {}),
171
171
  event,
172
- options: { expected_revision: :any }
172
+ options: { expected_revision: :any },
173
+ middlewares: Middlewares.for_write
173
174
  )
174
175
  end
175
176
 
@@ -90,7 +90,8 @@ module Yes
90
90
  PgEventstore.client.append_to_stream(
91
91
  command_utilities.build_stream(metadata:),
92
92
  event,
93
- options: { expected_revision: }
93
+ options: { expected_revision: },
94
+ middlewares: Middlewares.for_write
94
95
  ).tap { otl_record_response(_1) }
95
96
  end
96
97
 
@@ -111,10 +112,14 @@ module Yes
111
112
 
112
113
  next if normalized_revision == expected_revision
113
114
 
115
+ # pg_eventstore 3.0 requires `verdict:`, which selects the error's
116
+ # message. This branch is only reached when the revision we hold
117
+ # differs from the store's, which is exactly :unmatched_stream_revision.
114
118
  raise PgEventstore::WrongExpectedRevisionError.new(
115
119
  revision: aggregate_revision,
116
120
  expected_revision:,
117
- stream:
121
+ stream:,
122
+ verdict: :unmatched_stream_revision
118
123
  )
119
124
  end
120
125
  end
@@ -175,7 +180,7 @@ module Yes
175
180
  timestamp: result.created_at,
176
181
  attributes: {
177
182
  'event.type' => result.type,
178
- 'event.link_id' => result.link_id || '',
183
+ 'event.link_global_position' => result.link_global_position || '',
179
184
  'global_position' => result.global_position,
180
185
  'stream' => result.stream.to_json,
181
186
  'stream.revision' => result.stream_revision,
@@ -109,10 +109,15 @@ module Yes
109
109
  @commands = build_commands
110
110
  end
111
111
 
112
- # @return [Hash] hash form for serialization, merging normalized payload
113
- # and reserved keys (matches legacy {Yes::Core::Commands::Group#to_h})
112
+ # @return [Hash] hash form for serialization the FLAT input payload
113
+ # merged with reserved keys (transaction/origin/batch_id/metadata/
114
+ # command_id). This shape round-trips cleanly: calling
115
+ # `self.class.new(cmd.to_h)` produces an equivalent group with the
116
+ # same `payload`. Used by
117
+ # {Yes::Core::ActiveJobSerializers::CommandGroupSerializer} and by
118
+ # the Command API controller's `add_metadata`.
114
119
  def to_h
115
- merged = normalized_payload.merge(group_attributes.to_h)
120
+ merged = payload.merge(group_attributes.to_h)
116
121
  transaction ? merged.merge(transaction:) : merged
117
122
  end
118
123
 
@@ -79,14 +79,13 @@ module Yes
79
79
  end
80
80
 
81
81
  # Executes a single command on its aggregate
82
- # @param cmd [Command] the command to execute
83
- # @return [Response] response from executing the command
82
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] the command to execute
83
+ # @return [Response, Yes::Core::Commands::CommandGroupResponse] response from executing the command
84
84
  def run_command(cmd)
85
85
  command_helper = Yes::Core::Commands::Helper.new(cmd)
86
86
  draft = draft?(cmd)
87
87
  aggregate = aggregate_class(cmd).new(cmd.aggregate_id, draft:)
88
88
  I18n.with_locale(command_helper.command_locale) do
89
- # Pass payload as first argument, guards as option
90
89
  aggregate.public_send(command_helper.command_name, cmd.to_h, guards: !draft)
91
90
  end
92
91
  end
@@ -103,21 +102,43 @@ module Yes
103
102
  end
104
103
 
105
104
  # Checks if a guard evaluator exists for the given command
106
- # @param cmd [Command] The command to check
105
+ #
106
+ # Aggregate-DSL command groups register their guard evaluator under a
107
+ # dedicated registry type, so they are resolved through a different
108
+ # configuration lookup than single commands.
109
+ #
110
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] The command to check
107
111
  # @return [Boolean] true if a guard evaluator exists
108
112
  # @raise [UnregisteredCommand] if no guard evaluator is found for the command
109
113
  def guard_evaluator_exists?(cmd)
110
114
  command_helper = Yes::Core::Commands::Helper.new(cmd)
111
115
 
112
- klass = Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
113
- command_helper.subject,
114
- command_helper.command_name)
116
+ klass = guard_evaluator_class_for(cmd, command_helper)
115
117
 
116
118
  raise UnregisteredCommand, "Unregistered command: #{cmd.class}" unless klass
117
119
 
118
120
  true
119
121
  end
120
122
 
123
+ # Resolves the guard evaluator class for a command, dispatching to the
124
+ # command-group registry for {Yes::Core::Commands::CommandGroup} and to
125
+ # the single-command registry otherwise.
126
+ #
127
+ # @param cmd [Command, Yes::Core::Commands::CommandGroup] The command
128
+ # @param command_helper [Yes::Core::Commands::Helper] helper for the command
129
+ # @return [Class, nil] the registered guard evaluator class or nil
130
+ def guard_evaluator_class_for(cmd, command_helper)
131
+ if cmd.is_a?(Yes::Core::Commands::CommandGroup)
132
+ Yes::Core.configuration.command_group_guard_evaluator_class(command_helper.command_context,
133
+ command_helper.subject,
134
+ command_helper.command_name)
135
+ else
136
+ Yes::Core.configuration.guard_evaluator_class(command_helper.command_context,
137
+ command_helper.subject,
138
+ command_helper.command_name)
139
+ end
140
+ end
141
+
121
142
  # Ensures handlers exist for all commands
122
143
  # @param commands [Array<Command>] The commands to check
123
144
  # @return [Boolean] true if handlers exist for all commands
@@ -118,7 +118,8 @@ module Yes
118
118
  PgEventstore.client.append_to_stream(
119
119
  stream,
120
120
  event,
121
- options: { expected_revision: subject_stream_revision }
121
+ options: { expected_revision: subject_stream_revision },
122
+ middlewares: Middlewares.for_write
122
123
  ).tap { otl_record_response(_1) }
123
124
  end
124
125
  otl_trackable :publish_event, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Publish Event', span_kind: :producer)
@@ -198,7 +199,12 @@ module Yes
198
199
  # @param expected_revision [Integer]
199
200
  # @param stream [PgEventstore::Stream]
200
201
  def revision_error!(revision, expected_revision, stream)
201
- PgEventstore::WrongExpectedRevisionError.new(revision:, expected_revision:, stream:).tap do |error|
202
+ # pg_eventstore 3.0 requires `verdict:`, which selects the error's
203
+ # message. verify_revisions! only calls this when the two revisions
204
+ # differ, which is exactly :unmatched_stream_revision.
205
+ PgEventstore::WrongExpectedRevisionError.new(
206
+ revision:, expected_revision:, stream:, verdict: :unmatched_stream_revision
207
+ ).tap do |error|
202
208
  self.class.current_span&.status = ::OpenTelemetry::Trace::Status.error('Wrong expected revision')
203
209
  self.class.current_span&.add_attributes(
204
210
  {
@@ -277,7 +283,7 @@ module Yes
277
283
  timestamp: result.created_at,
278
284
  attributes: {
279
285
  'event.type' => result.type,
280
- 'event.link_id' => result.link_id || '',
286
+ 'event.link_global_position' => result.link_global_position || '',
281
287
  'global_position' => result.global_position,
282
288
  'stream' => result.stream.to_json,
283
289
  'stream.revision' => result.stream_revision,
@@ -287,7 +287,7 @@ module Yes
287
287
  # @return [Enumerator]
288
288
  def load_events(stream, options: {}, skip_decryption: true)
289
289
  options = { direction: 'Backwards' }.merge(options)
290
- middlewares = Middlewares.without(:encryptor) if skip_decryption
290
+ middlewares = Middlewares.without(Middlewares::ENCRYPTOR) if skip_decryption
291
291
  PgEventstore.client.read_paginated(stream, options:, middlewares:)
292
292
  end
293
293
 
@@ -331,6 +331,23 @@ module Yes
331
331
  aggregate_class(context_name, aggregate_name, command_name.to_s.underscore.to_sym, :guard_evaluator)
332
332
  end
333
333
 
334
+ # Retrieve a guard evaluator class for a specific command group.
335
+ #
336
+ # Aggregate-DSL command groups register their guard evaluator under the
337
+ # dedicated `:command_group_guard_evaluator` type (see
338
+ # {#register_command_group_guard_evaluator_class}), not `:guard_evaluator`,
339
+ # so a group cannot be resolved through {#guard_evaluator_class}.
340
+ #
341
+ # @param context_name [Symbol, String] The context for the aggregate
342
+ # @param aggregate_name [Symbol, String] The name of the aggregate
343
+ # @param group_name [Symbol, String] The name of the command group
344
+ # @return [Class, nil] The registered guard evaluator class or nil if not found
345
+ # @example
346
+ # evaluator = command_group_guard_evaluator_class(:companies, :apprenticeship, :create_apprenticeship)
347
+ def command_group_guard_evaluator_class(context_name, aggregate_name, group_name)
348
+ aggregate_class(context_name, aggregate_name, group_name.to_s.underscore.to_sym, :command_group_guard_evaluator)
349
+ end
350
+
334
351
  # List all registered classes across all aggregates and contexts
335
352
  # @return [Hash] A complete hash of all registered classes
336
353
  # @example
@@ -40,13 +40,13 @@ module Yes
40
40
  def decrypt_attributes(key:, data:, attributes: {}) # rubocop:disable Lint/UnusedMethodArgument
41
41
  return data unless key
42
42
 
43
- res = key_repository.decrypt(key:, message: data['es_encrypted'])
43
+ res = key_repository.decrypt(key:, message: data[DataEncryptor::CIPHERTEXT_KEY])
44
44
  return data if res.failure?
45
45
 
46
46
  decrypted_text = res.value!
47
47
  decrypted = JSON.parse(decrypted_text.attributes[:message]).transform_keys(&:to_s)
48
48
  decrypted.each { |k, value| data[k] = value if data.key?(k) }
49
- data.delete('es_encrypted')
49
+ data.delete(DataEncryptor::CIPHERTEXT_KEY)
50
50
  data
51
51
  end
52
52
 
@@ -10,6 +10,10 @@ module Yes
10
10
  # encryptor.encrypted_data
11
11
  # encryptor.encryption_metadata
12
12
  class DataEncryptor
13
+ # Data key holding the ciphertext of all encrypted attributes. Its presence means the data is
14
+ # currently encrypted: it is written here and removed by {DataDecryptor}.
15
+ CIPHERTEXT_KEY = 'es_encrypted'
16
+
13
17
  # @return [Hash] the encrypted data
14
18
  attr_reader :encrypted_data
15
19
 
@@ -51,8 +55,8 @@ module Yes
51
55
  def encrypt_attributes(key:, data:, attributes:)
52
56
  text = JSON.generate(data.select { |hash_key, _value| attributes.include?(hash_key.to_s) })
53
57
  encrypted = key_repository.encrypt(key:, message: text).value!
54
- attributes.each { |att| data[att.to_s] = 'es_encrypted' if data.key?(att.to_s) }
55
- data['es_encrypted'] = encrypted.attributes[:message]
58
+ attributes.each { |att| data[att.to_s] = CIPHERTEXT_KEY if data.key?(att.to_s) }
59
+ data[CIPHERTEXT_KEY] = encrypted.attributes[:message]
56
60
  data
57
61
  end
58
62
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Reports a permanently dead pg_eventstore subscription to Sentry.
6
+ #
7
+ # pg_eventstore calls its +failed_subscription_notifier+ exactly once, when a
8
+ # subscription exhausts its restarts and stays dead — it is the gem's only
9
+ # death signal, and without it that death is silent. Per-failure
10
+ # errors are only recorded on the subscription row, never raised into Sentry.
11
+ class FailedSubscriptionNotifier
12
+ # @param subscription [PgEventstore::Subscription]
13
+ # @param error [StandardError]
14
+ # @return [void]
15
+ def call(subscription, error)
16
+ Sentry.with_scope do |scope|
17
+ scope.set_tags(failed_subscription_notifier: true) # used in Sentry Alerts
18
+
19
+ Sentry.capture_exception(
20
+ error,
21
+ # a death report must never be swallowed by Sentry's excluded_exceptions
22
+ hint: { ignore_exclusions: true },
23
+ extra: { id: subscription.id, set: subscription.set, name: subscription.name }
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -5,9 +5,12 @@ module Yes
5
5
  module Middlewares
6
6
  # PgEventstore middleware for encrypting/decrypting event data.
7
7
  #
8
+ # Register it through {Middlewares.register_encryptor} rather than by hand, so its write-only
9
+ # counterpart ({WriteEncryptor}) is always registered alongside it.
10
+ #
8
11
  # @example
9
12
  # PgEventstore.configure do |config|
10
- # config.middlewares[:encryptor] = Yes::Core::Middlewares::Encryptor.new(key_repository)
13
+ # Yes::Core::Middlewares.register_encryptor(key_repository, config:)
11
14
  # end
12
15
  class Encryptor
13
16
  include PgEventstore::Middleware
@@ -24,6 +27,11 @@ module Yes
24
27
  # @return [PgEventstore::Event]
25
28
  def serialize(event)
26
29
  return event unless event.class.respond_to?(:encryption_schema)
30
+ # Idempotence guard. With {WriteEncryptor} registered, the DEFAULT middleware list holds two
31
+ # serialize-capable encryptors, so an append that omits `middlewares:` would encrypt twice. The
32
+ # second pass would encrypt the sentinels written by the first and overwrite the real ciphertext,
33
+ # which cannot be recovered. It also guards re-appending an event that was read at rest.
34
+ return event if event.data[DataEncryptor::CIPHERTEXT_KEY].present?
27
35
 
28
36
  encryptor = DataEncryptor.new(
29
37
  data: event.data, schema: event.class.encryption_schema, repository: key_repository
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ module Middlewares
6
+ # Encrypts on the way in exactly like {Encryptor}, but does not decrypt on the way out.
7
+ #
8
+ # pg_eventstore 3.0 runs every registered middleware's #deserialize on the events returned by
9
+ # #append_to_stream, not only on the ones returned by reads. Nothing on the write path reads `data` off
10
+ # that returned event, so decrypting it costs two uncached HTTP calls to the encryptor service (a key
11
+ # lookup and a decrypt) per encrypted event, for a payload that is immediately discarded.
12
+ #
13
+ # #serialize is inherited rather than reimplemented on purpose: encryption at rest must never differ
14
+ # between the read and the write variant.
15
+ #
16
+ # Registered alongside {Encryptor} by {Middlewares.register_encryptor} and selected by
17
+ # {Middlewares.for_write}. The read path keeps using {Encryptor}, unchanged.
18
+ #
19
+ # @example
20
+ # PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
21
+ class WriteEncryptor < Encryptor
22
+ # Deliberately does nothing: the caller does not read the returned event's data.
23
+ #
24
+ # Returns the event rather than nil. pg_eventstore itself ignores the return value, but middlewares
25
+ # are also invoked directly in places that use it.
26
+ #
27
+ # @param event [PgEventstore::Event]
28
+ # @return [PgEventstore::Event]
29
+ def deserialize(event)
30
+ event
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,9 +3,54 @@
3
3
  module Yes
4
4
  module Core
5
5
  module Middlewares
6
+ # Config key of the encryptor used on read paths: encrypts on #serialize, decrypts on #deserialize.
7
+ ENCRYPTOR = :encryptor
8
+ # Config key of the encryptor used on write paths: encrypts on #serialize, no-op on #deserialize.
9
+ WRITE_ENCRYPTOR = :write_encryptor
10
+
6
11
  class << self
12
+ # Registers both encryptor middlewares against the same key repository.
13
+ #
14
+ # Always use this instead of assigning config.middlewares[:encryptor] by hand: registering the
15
+ # decrypting encryptor without its write-only twin silently doubles the encryptor round trips every
16
+ # encrypted append performs (see {WriteEncryptor}).
17
+ #
18
+ # Mutates the given config in place rather than opening its own PgEventstore.configure block, because
19
+ # PgEventstore.configure takes a non-reentrant mutex - nesting one inside another deadlocks.
20
+ #
21
+ # @param key_repository [#find, #create, #encrypt, #decrypt]
22
+ # @param config [PgEventstore::Config] the config yielded by PgEventstore.configure
23
+ # @return [void]
24
+ def register_encryptor(key_repository, config: PgEventstore.config)
25
+ config.middlewares[ENCRYPTOR] = Encryptor.new(key_repository)
26
+ config.middlewares[WRITE_ENCRYPTOR] = WriteEncryptor.new(key_repository)
27
+ end
28
+
29
+ # Middleware keys to pass to #append_to_stream: every configured middleware, with the decrypting
30
+ # encryptor swapped for the write-only one.
31
+ #
32
+ # Derived from the live config, never hard-coded. PgEventstore::Client resolves a passed list with
33
+ # `config.middlewares.slice(*list)`, which silently drops names that are not registered - so a literal
34
+ # list would resolve to one with NO encryptor at all against a config that registered it under a
35
+ # different key, and would write plaintext at rest undetectably. Deriving the list makes that
36
+ # impossible, and picks up any middleware added later for free.
37
+ #
38
+ # Falls back to the full list when {WRITE_ENCRYPTOR} is not registered. That is correct, just as slow
39
+ # as before - unlike a hard-coded list, which would drop encryption altogether.
40
+ #
41
+ # @return [Array<Symbol>]
42
+ def for_write
43
+ keys = PgEventstore.config.middlewares.keys
44
+ return keys unless keys.include?(WRITE_ENCRYPTOR)
45
+
46
+ keys - [ENCRYPTOR]
47
+ end
48
+
7
49
  # Returns middleware keys excluding the specified one.
8
50
  #
51
+ # Note that excluding {ENCRYPTOR} still yields a list containing {WRITE_ENCRYPTOR}, whose #deserialize
52
+ # is a no-op - so `without(:encryptor)` keeps meaning "read the data as it is stored".
53
+ #
9
54
  # @param middleware_name [Symbol] the middleware key to exclude
10
55
  # @return [Array<Symbol>] remaining middleware keys
11
56
  def without(middleware_name)
@@ -16,6 +16,11 @@ module Yes
16
16
  with_indifferent_access: Yes::Core::Middlewares::WithIndifferentAccess.new,
17
17
  timestamp: Yes::Core::Middlewares::Timestamp.new
18
18
  }
19
+
20
+ # pg_eventstore's only death signal — without it a subscription that
21
+ # exhausts its restarts dies silently: per-failure errors are only recorded
22
+ # on the subscription row, never raised.
23
+ config.failed_subscription_notifier = FailedSubscriptionNotifier.new if defined?(Sentry)
19
24
  end
20
25
  end
21
26
 
@@ -49,6 +54,21 @@ module Yes
49
54
  PgEventstore.logger ||= Rails.logger if ENV['PG_ES_LOGGING'] == 'true'
50
55
  end
51
56
 
57
+ # The one misconfiguration Middlewares.for_write cannot fix by itself: a decrypting encryptor registered
58
+ # without its write-only twin. for_write then falls back to the full list, so writes stay CORRECT - they
59
+ # just keep paying an encryptor key lookup and decrypt per encrypted event, for data nobody reads.
60
+ # Warned about once at boot rather than on every append.
61
+ config.after_initialize do
62
+ middlewares = PgEventstore.config.middlewares
63
+ next unless middlewares.key?(Yes::Core::Middlewares::ENCRYPTOR)
64
+ next if middlewares.key?(Yes::Core::Middlewares::WRITE_ENCRYPTOR)
65
+
66
+ Rails.logger.warn(
67
+ 'PgEventstore middleware :encryptor is registered without :write_encryptor, so append_to_stream ' \
68
+ 'decrypts every event it returns. Register both with Yes::Core::Middlewares.register_encryptor.'
69
+ )
70
+ end
71
+
52
72
  # Load aggregate shortcuts when Rails console starts
53
73
  console do
54
74
  Yes::Core::Utils::AggregateShortcuts.load!
@@ -16,7 +16,7 @@ module Yes
16
16
  # @param event [Yes::Core::Event]
17
17
  # @return [void]
18
18
  def append_event(stream, event)
19
- PgEventstore.client.append_to_stream(stream, event)
19
+ PgEventstore.client.append_to_stream(stream, event, middlewares: Yes::Core::Middlewares.for_write)
20
20
  end
21
21
 
22
22
  # Appends an event to a stream and reloads it
@@ -20,7 +20,14 @@ module Yes
20
20
 
21
21
  EMPTY_STRING = /\A\s*\z/
22
22
 
23
- UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
23
+ # Version nibble is [1-8], not a hard-coded 4. pg_eventstore 3.0.0 moved event-id
24
+ # generation off the database's gen_random_uuid() (always v4) to
25
+ # SecureRandom.uuid_v7, and those ids reach us as causation_id / correlation_id.
26
+ # A v4-only pattern makes TransactionDetails raise Dry::Struct::Error, which fails
27
+ # the event handler and kills the subscription once its restarts run out.
28
+ #
29
+ # Still a real constraint, per RFC 9562: version 1-8 and variant nibble 8/9/a/b.
30
+ UUID_REGEXP_BASE = /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}/
24
31
  UUID_REGEXP = /\A#{UUID_REGEXP_BASE}\z/i
25
32
 
26
33
  DATE_TIME_REGEXP = /\A\d{4}-\d{1,2}-\d{1,2} ([0-1][0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?\z/i
@@ -22,7 +22,8 @@ module Yes
22
22
  dupl
23
23
  end
24
24
 
25
- # Returns a hash with the keys flattened
25
+ # Returns a hash with the keys flattened. Scalar, nested-hash and
26
+ # array values all honour the `prefix` consistently.
26
27
  #
27
28
  # @param obj [Hash, Array] the object to flatten
28
29
  # @param prefix [String] the key to use as a prefix for the keys in the hash
@@ -32,6 +33,10 @@ module Yes
32
33
  # @example
33
34
  # HashUtils.deep_flatten_hash({ name: 'A', otl_contexts: { root: { attr: 10, available: true } } })
34
35
  # => {"name"=>"A", "otl_contexts.root.attr"=>10, "otl_contexts.root.available"=>true}
36
+ #
37
+ # @example with a prefix (arrays included)
38
+ # HashUtils.deep_flatten_hash({ id: 1, tags: [{ k: 'v' }] }, 'span')
39
+ # => {"span.id"=>1, "span.tags"=>[{"k"=>"v"}]}
35
40
  def deep_flatten_hash(obj, prefix = nil, memo = {})
36
41
  case obj
37
42
  when Hash
@@ -42,7 +47,7 @@ module Yes
42
47
  in String | Symbol, Hash
43
48
  deep_flatten_hash(value, prefix ? "#{prefix}.#{key}" : key.to_s, memo)
44
49
  in String | Symbol, Array
45
- memo[key.to_s] = deep_flatten_hash(value)
50
+ memo[prefix ? "#{prefix}.#{key}" : key.to_s] = deep_flatten_hash(value)
46
51
  in Array, _
47
52
  memo[deep_flatten_hash(key)] = deep_flatten_hash(value)
48
53
  else
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Yes
4
4
  module Core
5
- VERSION = '1.3.0'
5
+ VERSION = '2.2.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: yes-core
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nico Ritsche
@@ -127,14 +127,14 @@ dependencies:
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: '1.0'
130
+ version: '3.0'
131
131
  type: :runtime
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
- version: '1.0'
137
+ version: '3.0'
138
138
  - !ruby/object:Gem::Dependency
139
139
  name: rails
140
140
  requirement: !ruby/object:Gem::Requirement
@@ -224,6 +224,7 @@ files:
224
224
  - lib/yes/core/authorization/cerbos_client_provider.rb
225
225
  - lib/yes/core/authorization/command_authorizer.rb
226
226
  - lib/yes/core/authorization/command_cerbos_authorizer.rb
227
+ - lib/yes/core/authorization/lookup_cache.rb
227
228
  - lib/yes/core/authorization/read_model_authorizer.rb
228
229
  - lib/yes/core/authorization/read_models_authorizer.rb
229
230
  - lib/yes/core/authorization/read_request_authorizer.rb
@@ -267,6 +268,7 @@ files:
267
268
  - lib/yes/core/error_messages.rb
268
269
  - lib/yes/core/event.rb
269
270
  - lib/yes/core/event_class_resolver.rb
271
+ - lib/yes/core/failed_subscription_notifier.rb
270
272
  - lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb
271
273
  - lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb
272
274
  - lib/yes/core/generators/read_models/templates/migration.rb.erb
@@ -276,6 +278,7 @@ files:
276
278
  - lib/yes/core/middlewares/encryptor.rb
277
279
  - lib/yes/core/middlewares/timestamp.rb
278
280
  - lib/yes/core/middlewares/with_indifferent_access.rb
281
+ - lib/yes/core/middlewares/write_encryptor.rb
279
282
  - lib/yes/core/models/application_record.rb
280
283
  - lib/yes/core/open_telemetry/otl_span.rb
281
284
  - lib/yes/core/open_telemetry/trackable.rb