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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +13 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +7 -4
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +20 -5
- data/lib/yes/core/authorization/lookup_cache.rb +72 -0
- data/lib/yes/core/command_handling/command_group_executor.rb +2 -1
- data/lib/yes/core/command_handling/event_publisher.rb +8 -3
- data/lib/yes/core/commands/command_group.rb +8 -3
- data/lib/yes/core/commands/processor.rb +28 -7
- data/lib/yes/core/commands/stateless/handler.rb +9 -3
- data/lib/yes/core/commands/stateless/handler_helpers.rb +1 -1
- data/lib/yes/core/configuration.rb +17 -0
- data/lib/yes/core/data_decryptor.rb +2 -2
- data/lib/yes/core/data_encryptor.rb +6 -2
- data/lib/yes/core/failed_subscription_notifier.rb +29 -0
- data/lib/yes/core/middlewares/encryptor.rb +9 -1
- data/lib/yes/core/middlewares/write_encryptor.rb +35 -0
- data/lib/yes/core/middlewares.rb +45 -0
- data/lib/yes/core/railtie.rb +20 -0
- data/lib/yes/core/test_support/event_helpers.rb +1 -1
- data/lib/yes/core/types.rb +8 -1
- data/lib/yes/core/utils/hash_utils.rb +7 -2
- data/lib/yes/core/version.rb +1 -1
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4845d0466658aee907edb5bab0679603e25a9b5c088a8df067d0fbfcc060bff4
|
|
4
|
+
data.tar.gz: cca505e973fa99d5b8e4b07c44752b78b330fdc0035b45528c83139ee36a0e4b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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?
|
|
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]
|
|
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]
|
|
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,
|
|
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)
|
|
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
|
-
|
|
152
|
-
|
|
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.
|
|
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
|
|
113
|
-
#
|
|
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 =
|
|
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
|
-
#
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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[
|
|
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(
|
|
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] =
|
|
55
|
-
data[
|
|
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
|
-
#
|
|
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
|
data/lib/yes/core/middlewares.rb
CHANGED
|
@@ -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)
|
data/lib/yes/core/railtie.rb
CHANGED
|
@@ -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
|
data/lib/yes/core/types.rb
CHANGED
|
@@ -20,7 +20,14 @@ module Yes
|
|
|
20
20
|
|
|
21
21
|
EMPTY_STRING = /\A\s*\z/
|
|
22
22
|
|
|
23
|
-
|
|
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
|
data/lib/yes/core/version.rb
CHANGED
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:
|
|
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: '
|
|
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: '
|
|
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
|