yes-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
- data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
- data/lib/yes/core/aggregate/draftable.rb +205 -0
- data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
- data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
- data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
- data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
- data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
- data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
- data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
- data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
- data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
- data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
- data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
- data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
- data/lib/yes/core/aggregate/has_read_model.rb +169 -0
- data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
- data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
- data/lib/yes/core/aggregate.rb +404 -0
- data/lib/yes/core/authentication_error.rb +8 -0
- data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
- data/lib/yes/core/authorization/command_authorizer.rb +40 -0
- data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
- data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
- data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
- data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
- data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
- data/lib/yes/core/command.rb +35 -0
- data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
- data/lib/yes/core/command_handling/command_executor.rb +171 -0
- data/lib/yes/core/command_handling/command_handler.rb +124 -0
- data/lib/yes/core/command_handling/event_publisher.rb +189 -0
- data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
- data/lib/yes/core/command_handling/guard_runner.rb +76 -0
- data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
- data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
- data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
- data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
- data/lib/yes/core/command_handling/state_updater.rb +113 -0
- data/lib/yes/core/commands/bus.rb +46 -0
- data/lib/yes/core/commands/group.rb +135 -0
- data/lib/yes/core/commands/group_response.rb +13 -0
- data/lib/yes/core/commands/helper.rb +126 -0
- data/lib/yes/core/commands/notifier.rb +65 -0
- data/lib/yes/core/commands/processor.rb +137 -0
- data/lib/yes/core/commands/response.rb +63 -0
- data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
- data/lib/yes/core/commands/stateless/group_response.rb +15 -0
- data/lib/yes/core/commands/stateless/handler.rb +292 -0
- data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
- data/lib/yes/core/commands/stateless/response.rb +14 -0
- data/lib/yes/core/commands/stateless/subject.rb +41 -0
- data/lib/yes/core/commands/validator.rb +28 -0
- data/lib/yes/core/configuration.rb +432 -0
- data/lib/yes/core/data_decryptor.rb +59 -0
- data/lib/yes/core/data_encryptor.rb +60 -0
- data/lib/yes/core/encryption_metadata.rb +33 -0
- data/lib/yes/core/error.rb +14 -0
- data/lib/yes/core/error_messages.rb +37 -0
- data/lib/yes/core/event.rb +222 -0
- data/lib/yes/core/event_class_resolver.rb +40 -0
- data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
- data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
- data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
- data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
- data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
- data/lib/yes/core/middlewares/encryptor.rb +48 -0
- data/lib/yes/core/middlewares/timestamp.rb +29 -0
- data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
- data/lib/yes/core/models/application_record.rb +9 -0
- data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
- data/lib/yes/core/open_telemetry/trackable.rb +101 -0
- data/lib/yes/core/payload_store/base.rb +33 -0
- data/lib/yes/core/payload_store/errors.rb +13 -0
- data/lib/yes/core/payload_store/lookup.rb +44 -0
- data/lib/yes/core/process_managers/access_token_client.rb +107 -0
- data/lib/yes/core/process_managers/base.rb +40 -0
- data/lib/yes/core/process_managers/command_runner.rb +109 -0
- data/lib/yes/core/process_managers/service_client.rb +57 -0
- data/lib/yes/core/process_managers/state.rb +118 -0
- data/lib/yes/core/railtie.rb +58 -0
- data/lib/yes/core/read_model/builder.rb +267 -0
- data/lib/yes/core/read_model/event_handler.rb +64 -0
- data/lib/yes/core/read_model/filter.rb +118 -0
- data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
- data/lib/yes/core/serializer.rb +21 -0
- data/lib/yes/core/subscriptions.rb +94 -0
- data/lib/yes/core/test_support/event_helpers.rb +27 -0
- data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
- data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
- data/lib/yes/core/test_support/test_helper.rb +27 -0
- data/lib/yes/core/test_support.rb +5 -0
- data/lib/yes/core/transaction_details.rb +90 -0
- data/lib/yes/core/type_lookup.rb +88 -0
- data/lib/yes/core/types.rb +110 -0
- data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
- data/lib/yes/core/utils/caller_utils.rb +37 -0
- data/lib/yes/core/utils/command_utils.rb +226 -0
- data/lib/yes/core/utils/error_notifier.rb +101 -0
- data/lib/yes/core/utils/event_name_resolver.rb +67 -0
- data/lib/yes/core/utils/exponential_retrier.rb +180 -0
- data/lib/yes/core/utils/hash_utils.rb +63 -0
- data/lib/yes/core/version.rb +7 -0
- data/lib/yes/core.rb +85 -0
- data/lib/yes.rb +0 -0
- metadata +324 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
# The Aggregate class represents a core entity in the eventsourcing system.
|
|
6
|
+
# It provides functionality for managing event sourcing patterns including:
|
|
7
|
+
# - Attribute management with automatic command and event generation
|
|
8
|
+
# - Parent-child aggregate relationships
|
|
9
|
+
# - Read model associations
|
|
10
|
+
# - Context management
|
|
11
|
+
#
|
|
12
|
+
# @example Define an aggregate with attributes
|
|
13
|
+
# class UserAggregate < Yes::Core::Aggregate
|
|
14
|
+
# primary_context 'Users'
|
|
15
|
+
# attribute :email, :email
|
|
16
|
+
# attribute :name, :string
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# @example Define an aggregate with a parent
|
|
20
|
+
# class ProfileAggregate < Yes::Core::Aggregate
|
|
21
|
+
# parent :user do
|
|
22
|
+
# guard(:user_exists) { payload.user.present? }
|
|
23
|
+
# guard(:not_removed) { trashed_at.blank? }
|
|
24
|
+
# end
|
|
25
|
+
# attribute :bio, :string
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Define an aggregate with a command
|
|
29
|
+
# class CompanyAggregate < Yes::Core::Aggregate
|
|
30
|
+
# primary_context 'Companies'
|
|
31
|
+
#
|
|
32
|
+
# command :assign_user do
|
|
33
|
+
# payload user_id: :uuid
|
|
34
|
+
#
|
|
35
|
+
# guard :user_already_assigned do
|
|
36
|
+
# user_id.present?
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
# end
|
|
40
|
+
#
|
|
41
|
+
# @since 0.1.0
|
|
42
|
+
# @author Nico Ritsche
|
|
43
|
+
class Aggregate
|
|
44
|
+
attr_reader :id, :command_utilities, :draft
|
|
45
|
+
|
|
46
|
+
private :command_utilities, :draft
|
|
47
|
+
|
|
48
|
+
include HasReadModel
|
|
49
|
+
include Draftable
|
|
50
|
+
include HasAuthorizer
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# @return [String, nil] The primary context name for this aggregate
|
|
54
|
+
attr_reader :_primary_context
|
|
55
|
+
|
|
56
|
+
# Hook that runs when a class inherits from Aggregate
|
|
57
|
+
# @param subclass [Class] The class inheriting from Aggregate
|
|
58
|
+
# @return [void]
|
|
59
|
+
def inherited(subclass)
|
|
60
|
+
super
|
|
61
|
+
|
|
62
|
+
# Add an "end of definition" hook using at_exit
|
|
63
|
+
# Setting up read model classes is done here, because it needs to be done after
|
|
64
|
+
# the class definition is complete.
|
|
65
|
+
TracePoint.new(:end) do |tp|
|
|
66
|
+
if tp.self == subclass
|
|
67
|
+
subclass.setup_read_model_classes if subclass.read_model_enabled?
|
|
68
|
+
subclass.setup_authorizer_classes
|
|
69
|
+
tp.disable
|
|
70
|
+
end
|
|
71
|
+
end.enable
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Defines a parent aggregate and automatically registers a corresponding Assign command
|
|
75
|
+
# together with a corresponding attribute.
|
|
76
|
+
#
|
|
77
|
+
# @param name [Symbol] The name of the parent.
|
|
78
|
+
# @param options [Hash] Options for configuring the parent.
|
|
79
|
+
# @yield Block for defining guards and other attribute configurations.
|
|
80
|
+
# @return [void]
|
|
81
|
+
def parent(name, **options, &)
|
|
82
|
+
parent_aggregates[name] = options
|
|
83
|
+
|
|
84
|
+
attribute name, :aggregate
|
|
85
|
+
|
|
86
|
+
return unless options.fetch(:command, true)
|
|
87
|
+
|
|
88
|
+
command :"assign_#{name}" do
|
|
89
|
+
payload "#{name}_id": :uuid
|
|
90
|
+
|
|
91
|
+
guard(:no_change) { public_send(:"#{name}_id") != payload.public_send(:"#{name}_id") }
|
|
92
|
+
|
|
93
|
+
instance_eval(&) if block_given?
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Retrieves or initializes the parent_aggregates hash.
|
|
98
|
+
#
|
|
99
|
+
# @return [Hash<Symbol, Hash>] A hash containing parent aggregates and their configuration options
|
|
100
|
+
def parent_aggregates
|
|
101
|
+
@parent_aggregates ||= {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Defines a default removal behavior for the aggregate.
|
|
105
|
+
#
|
|
106
|
+
# @param attr_name [Symbol] the attribute name to use for marking removal
|
|
107
|
+
# @yield Block for defining additional guards and other removal configurations
|
|
108
|
+
# @return [void]
|
|
109
|
+
#
|
|
110
|
+
# @example Define a default removal behavior
|
|
111
|
+
# class UserAggregate < Yes::Core::Aggregate
|
|
112
|
+
# removable
|
|
113
|
+
# end
|
|
114
|
+
#
|
|
115
|
+
# @example Define a removal behavior with additional custom guards
|
|
116
|
+
# class UserAggregate < Yes::Core::Aggregate
|
|
117
|
+
# removable do
|
|
118
|
+
# guard(:exists) { read_model.name.present? }
|
|
119
|
+
# end
|
|
120
|
+
# end
|
|
121
|
+
#
|
|
122
|
+
# @example Define a removal behavior with a custom attribute name
|
|
123
|
+
# class UserAggregate < Yes::Core::Aggregate
|
|
124
|
+
# removable(attr_name: :deleted_at)
|
|
125
|
+
# end
|
|
126
|
+
#
|
|
127
|
+
def removable(attr_name: :removed_at, &)
|
|
128
|
+
attribute attr_name, :datetime unless attributes.key?(attr_name)
|
|
129
|
+
|
|
130
|
+
command :remove do
|
|
131
|
+
guard(:no_change) { !public_send(attr_name) }
|
|
132
|
+
update_state { method(attr_name).call { Time.current } }
|
|
133
|
+
instance_eval(&) if block_given?
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Sets the primary context for the aggregate.
|
|
138
|
+
#
|
|
139
|
+
# @param context [String] The primary context to set.
|
|
140
|
+
# @return [void]
|
|
141
|
+
def primary_context(context)
|
|
142
|
+
@_primary_context = context
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Defines an attribute on the aggregate which creates corresponding command, event and handler
|
|
146
|
+
#
|
|
147
|
+
# @param name [Symbol] name of the attribute
|
|
148
|
+
# @param type [Symbol] type of the attribute (e.g., :string, :email, :uuid)
|
|
149
|
+
# @param options [Hash] additional options for the attribute
|
|
150
|
+
# @yield Block for defining guards and other attribute configurations
|
|
151
|
+
# @yieldreturn [void]
|
|
152
|
+
#
|
|
153
|
+
# @example Define a string attribute (without command)
|
|
154
|
+
# attribute :name, :string
|
|
155
|
+
#
|
|
156
|
+
# @example Define an aggregate attribute (without command)
|
|
157
|
+
# attribute :location, :aggregate
|
|
158
|
+
#
|
|
159
|
+
# @example Define an email attribute with command
|
|
160
|
+
# attribute :email, :email, command: true
|
|
161
|
+
#
|
|
162
|
+
# @example Define an attribute with command and guards
|
|
163
|
+
# attribute :first_name, :string, command: true do
|
|
164
|
+
# guard :something do
|
|
165
|
+
# first_name == 'John'
|
|
166
|
+
# end
|
|
167
|
+
# end
|
|
168
|
+
#
|
|
169
|
+
# @example Define a localized attribute
|
|
170
|
+
# attribute :description, :string, command: true, localized: true
|
|
171
|
+
def attribute(name, type, **options, &)
|
|
172
|
+
raise 'Aggregate attribute definition with command: true is not allowed' if type == :aggregate && options[:command]
|
|
173
|
+
|
|
174
|
+
@attributes ||= {}
|
|
175
|
+
@attributes[name] = type
|
|
176
|
+
|
|
177
|
+
@attribute_options ||= {}
|
|
178
|
+
@attribute_options[name] = options.slice(:localized)
|
|
179
|
+
|
|
180
|
+
options = options.merge(context:, aggregate:)
|
|
181
|
+
Dsl::AttributeDefiner.new(
|
|
182
|
+
Dsl::AttributeData.new(name, type, self, options)
|
|
183
|
+
).call
|
|
184
|
+
|
|
185
|
+
command(:change, name, type, &) if options[:command]
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Defines a command on the aggregate which creates corresponding command and event classes
|
|
189
|
+
#
|
|
190
|
+
# @overload command(name, &)
|
|
191
|
+
# @param name [Symbol] name of the command
|
|
192
|
+
# @yield Block for defining payload, guards, and other command configurations
|
|
193
|
+
# @yieldreturn [void]
|
|
194
|
+
#
|
|
195
|
+
# @overload command(publish)
|
|
196
|
+
# @param publish [Symbol] passing :publish as a name will generate published attribute and publish command
|
|
197
|
+
# @return [void]
|
|
198
|
+
#
|
|
199
|
+
# @overload command(change, attribute, **options)
|
|
200
|
+
# @param change [Symbol] passing :change as a name will generate a change command and an attribute
|
|
201
|
+
# @param attribute [Symbol] attribute name
|
|
202
|
+
# @param options [Hash] additional options for the attribute
|
|
203
|
+
# @return [void]
|
|
204
|
+
#
|
|
205
|
+
# @overload command(enable, attribute, **options)
|
|
206
|
+
# @param enable [Symbol] passing :enable or :activate as a name will generate a flag set to true command and an attribute
|
|
207
|
+
# @param attribute [Symbol] attribute name
|
|
208
|
+
# @param options [Hash] additional options for the attribute
|
|
209
|
+
# @return [void]
|
|
210
|
+
#
|
|
211
|
+
# @overload command(toggle_names, attribute)
|
|
212
|
+
# @param toggle_names [Array<Symbol>] toggle command names to be generated
|
|
213
|
+
# @param attribute [Symbol] attribute name
|
|
214
|
+
# @return [void]
|
|
215
|
+
#
|
|
216
|
+
# @example Define a basic command
|
|
217
|
+
# command :assign_user
|
|
218
|
+
#
|
|
219
|
+
# @example Define a command with custom payload and guards
|
|
220
|
+
# command :assign_user do
|
|
221
|
+
# payload user_id: :uuid
|
|
222
|
+
#
|
|
223
|
+
# guard :user_already_assigned do
|
|
224
|
+
# user_id.present?
|
|
225
|
+
# end
|
|
226
|
+
#
|
|
227
|
+
# event :user_assigned
|
|
228
|
+
# end
|
|
229
|
+
#
|
|
230
|
+
# @example Define change command and an attribute
|
|
231
|
+
# command :change, :age, :integer, localized: true
|
|
232
|
+
#
|
|
233
|
+
# @example Define set flag to true command an an attribute
|
|
234
|
+
# command :activate, :dropout, attribute: :dropout_enabled
|
|
235
|
+
#
|
|
236
|
+
# @example Define set of toggle commands an an attribute
|
|
237
|
+
# command [:enable, :disable], :dropout
|
|
238
|
+
#
|
|
239
|
+
# @example Define publish command an published attribute
|
|
240
|
+
# command :publish
|
|
241
|
+
#
|
|
242
|
+
def command(*args, **, &)
|
|
243
|
+
return handle_command_shortcut(*args, **, &) unless Dsl::CommandShortcutExpander.base_case?(*args, **, &)
|
|
244
|
+
|
|
245
|
+
name = args.first
|
|
246
|
+
@commands ||= {}
|
|
247
|
+
command_data = Dsl::CommandData.new(name, self, { context:, aggregate: })
|
|
248
|
+
@commands[name] = command_data
|
|
249
|
+
|
|
250
|
+
Dsl::CommandDefiner.new(command_data).call(&)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Returns the context namespace for the aggregate
|
|
254
|
+
#
|
|
255
|
+
# @return [String] The context namespace
|
|
256
|
+
# @example
|
|
257
|
+
# Users::User::Aggregate.context #=> "Users"
|
|
258
|
+
def context
|
|
259
|
+
name.to_s.split('::').first
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Returns the aggregate name without namespace and "Aggregate" suffix
|
|
263
|
+
#
|
|
264
|
+
# @return [String] The aggregate name
|
|
265
|
+
# @example
|
|
266
|
+
# Users::User::Aggregate.aggregate #=> "User"
|
|
267
|
+
def aggregate
|
|
268
|
+
name.to_s.split('::')[-2]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# @return [Hash] The attributes defined on this aggregate
|
|
272
|
+
def attributes
|
|
273
|
+
@attributes ||= {}
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# @return [Hash] The attribute options (localized, encrypted, etc.)
|
|
277
|
+
def attribute_options
|
|
278
|
+
@attribute_options ||= {}
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# @return [Hash] The commands defined on this aggregate
|
|
282
|
+
def commands
|
|
283
|
+
@commands ||= {}
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
#
|
|
289
|
+
# Takes all parameters passed to command invocation, forwards them to the command shortcut expander
|
|
290
|
+
# and then defines commands and attributes
|
|
291
|
+
#
|
|
292
|
+
# @param [Array<Object>] *args
|
|
293
|
+
# @param [Hash<Object, Object>] **kwargs
|
|
294
|
+
# @param [Proc] &block
|
|
295
|
+
#
|
|
296
|
+
# @return [void]
|
|
297
|
+
#
|
|
298
|
+
def handle_command_shortcut(...)
|
|
299
|
+
expanded = Dsl::CommandShortcutExpander.new(...).call
|
|
300
|
+
|
|
301
|
+
expanded.attributes.each do |specification|
|
|
302
|
+
next if attributes.key?(specification.name)
|
|
303
|
+
|
|
304
|
+
attribute(specification.name, specification.type, **specification.options)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
expanded.commands.each do |specification|
|
|
308
|
+
command(specification.name, &specification.block)
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Initializes a new aggregate instance
|
|
314
|
+
# @param id [String] The aggregate ID (optional, defaults to SecureRandom.uuid)
|
|
315
|
+
# @param draft [Boolean] Whether this instance is being edited as a draft (default: false)
|
|
316
|
+
# @return [Yes::Core::Aggregate] A new aggregate instance
|
|
317
|
+
#
|
|
318
|
+
# @example Backwards compatibility - single ID parameter
|
|
319
|
+
# Aggregate.new(some_id)
|
|
320
|
+
#
|
|
321
|
+
# @example With draft as keyword argument
|
|
322
|
+
# Aggregate.new(draft: true)
|
|
323
|
+
#
|
|
324
|
+
# @example With positional id and draft keyword
|
|
325
|
+
# Aggregate.new(some_id, draft: true)
|
|
326
|
+
#
|
|
327
|
+
def initialize(id = SecureRandom.uuid, draft: false)
|
|
328
|
+
validate_draft_initialization(draft)
|
|
329
|
+
|
|
330
|
+
@id = id
|
|
331
|
+
@draft = draft
|
|
332
|
+
|
|
333
|
+
@command_utilities = Utils::CommandUtils.new(
|
|
334
|
+
context: self.class.context,
|
|
335
|
+
aggregate: self.class.aggregate,
|
|
336
|
+
aggregate_id: @id
|
|
337
|
+
)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Reloads the aggregate and its read model
|
|
341
|
+
# @return [Yes::Core::Aggregate] The reloaded aggregate
|
|
342
|
+
def reload
|
|
343
|
+
read_model&.reload
|
|
344
|
+
|
|
345
|
+
self
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Returns the events for the aggregate
|
|
349
|
+
# @return [Enumerator<PgEventstore::Event>] The events for the aggregate
|
|
350
|
+
def events
|
|
351
|
+
PgEventstore.client.read_paginated(
|
|
352
|
+
command_utilities.build_stream(metadata: { draft: draft? }), options: { direction: 'Forwards' }
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Retrieves the most recent event from the aggregate's event stream
|
|
357
|
+
# @return [PgEventstore::Event, nil] The latest event or nil if no events exist
|
|
358
|
+
def latest_event
|
|
359
|
+
PgEventstore.client.read(
|
|
360
|
+
command_utilities.build_stream(metadata: { draft: draft? }), options: { max_count: 1, direction: :desc }
|
|
361
|
+
).first
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Returns the stream revision number of the latest event
|
|
365
|
+
# @return [Integer] The revision number of the latest event in the stream
|
|
366
|
+
# @raise [NoMethodError] If no events exist for this aggregate
|
|
367
|
+
def event_revision
|
|
368
|
+
latest_event.stream_revision
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Returns a list of commands that can be executed on this aggregate with their associated events
|
|
372
|
+
# @return [Hash<Symbol, Array<Symbol>>] A hash of command names to their event names, sorted alphabetically
|
|
373
|
+
# @example
|
|
374
|
+
# user_aggregate.commands
|
|
375
|
+
# # => {
|
|
376
|
+
# # approve_documents: [:documents_approved],
|
|
377
|
+
# # change_age: [:age_changed],
|
|
378
|
+
# # change_email: [:email_changed],
|
|
379
|
+
# # change_name: [:name_changed]
|
|
380
|
+
# # }
|
|
381
|
+
def commands
|
|
382
|
+
mappings = Yes::Core.configuration.command_event_mappings(
|
|
383
|
+
self.class.context,
|
|
384
|
+
self.class.aggregate
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
mappings.sort.to_h
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
private
|
|
391
|
+
|
|
392
|
+
# Validates that draft initialization is only allowed for draftable aggregates
|
|
393
|
+
#
|
|
394
|
+
# @param draft [Boolean] Whether the aggregate is being initialized as a draft
|
|
395
|
+
# @raise [ArgumentError] If draft is true but aggregate is not draftable
|
|
396
|
+
# @return [void]
|
|
397
|
+
def validate_draft_initialization(draft)
|
|
398
|
+
return unless draft && !self.class.draftable?
|
|
399
|
+
|
|
400
|
+
raise ArgumentError, "#{self.class.name} is not draftable. Add 'draftable' to the class definition."
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# Provides a shared Cerbos client instance for authorizer classes.
|
|
7
|
+
#
|
|
8
|
+
# @example Including in a class with class-level methods
|
|
9
|
+
# class MyAuthorizer
|
|
10
|
+
# class << self
|
|
11
|
+
# include Yes::Core::Authorization::CerbosClientProvider
|
|
12
|
+
# end
|
|
13
|
+
# end
|
|
14
|
+
module CerbosClientProvider
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
# @return [Cerbos::Client] Cerbos client configured from Yes::Core configuration
|
|
18
|
+
def cerbos_client
|
|
19
|
+
Cerbos::Client.new(
|
|
20
|
+
Yes::Core.configuration.cerbos_url,
|
|
21
|
+
tls: Yes::Core.configuration.cerbos_tls
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# @abstract command authorizer base class. Subclass and override call method to implement
|
|
7
|
+
# a custom authorizer.
|
|
8
|
+
class CommandAuthorizer
|
|
9
|
+
include OpenTelemetry::Trackable
|
|
10
|
+
|
|
11
|
+
CommandNotAuthorized = Class.new(Yes::Core::Error)
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
include OpenTelemetry::Trackable
|
|
15
|
+
|
|
16
|
+
# Implement this method to authorize a command. Needs to return true if command is authorized,
|
|
17
|
+
# otherwise raise CommandNotAuthorized.
|
|
18
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
19
|
+
# @param auth_data [Hash] authorization data
|
|
20
|
+
# @return [Boolean] true if command is authorized
|
|
21
|
+
def call(_command, auth_data)
|
|
22
|
+
return true if super_admin?(auth_data)
|
|
23
|
+
|
|
24
|
+
current_span&.status = ::OpenTelemetry::Trace::Status.error('Command not authorized')
|
|
25
|
+
raise CommandNotAuthorized
|
|
26
|
+
end
|
|
27
|
+
otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Authorize Command')
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# @param auth_data [Hash] authorization data
|
|
32
|
+
# @return [Boolean] true if user is a super admin
|
|
33
|
+
def super_admin?(auth_data)
|
|
34
|
+
Yes::Core.configuration.super_admin_check.call(auth_data)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# Cerbos-based command authorizer base class.
|
|
7
|
+
#
|
|
8
|
+
# Subclasses must define a RESOURCE constant:
|
|
9
|
+
# RESOURCE = { name: 'apprenticeship', read_model: Apprenticeship, draft_read_model: ApprenticeshipDraft }
|
|
10
|
+
#
|
|
11
|
+
# @abstract
|
|
12
|
+
class CommandCerbosAuthorizer < Yes::Core::Authorization::CommandAuthorizer
|
|
13
|
+
NEW_RESOURCE_ID = 'new'
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
include OpenTelemetry::Trackable
|
|
17
|
+
include CerbosClientProvider
|
|
18
|
+
|
|
19
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
20
|
+
# @param auth_data [Hash] authorization data
|
|
21
|
+
# @return [Boolean] true if command is authorized
|
|
22
|
+
# @raise [CommandNotAuthorized] if command is not authorized
|
|
23
|
+
def call(command, auth_data)
|
|
24
|
+
singleton_class.current_span&.add_attributes({ 'command' => command.to_json })
|
|
25
|
+
|
|
26
|
+
check_principal_id_present(auth_data)
|
|
27
|
+
singleton_class.current_span&.add_event('Principal Id Checked')
|
|
28
|
+
|
|
29
|
+
resource = load_resource(command)
|
|
30
|
+
singleton_class.current_span&.add_event('Resource Loaded')
|
|
31
|
+
|
|
32
|
+
decision = authorize(command, resource, auth_data)
|
|
33
|
+
singleton_class.current_span&.add_event('Cerbos Decision', attributes: { 'decision' => decision.to_json })
|
|
34
|
+
|
|
35
|
+
return true if decision.allow_all?
|
|
36
|
+
|
|
37
|
+
raise_command_unauthorized_error!(decision)
|
|
38
|
+
end
|
|
39
|
+
otl_trackable :call, OpenTelemetry::OtlSpan::OtlData.new(span_name: 'Cerbos Authorize Command')
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# @param decision [Cerbos::Output::CheckResources::Result]
|
|
44
|
+
# @raise [CommandNotAuthorized]
|
|
45
|
+
def raise_command_unauthorized_error!(decision)
|
|
46
|
+
msg = 'You are not allowed to execute this command'
|
|
47
|
+
singleton_class.current_span&.status = ::OpenTelemetry::Trace::Status.error(msg)
|
|
48
|
+
|
|
49
|
+
raise self::CommandNotAuthorized.new(msg, extra: { decision: decision.outputs.map(&:value) })
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param auth_data [Hash] authorization data
|
|
53
|
+
# @return [Boolean] true if identity_id is present in auth_data
|
|
54
|
+
# @raise [CommandNotAuthorized] if identity_id is not present in auth_data
|
|
55
|
+
def check_principal_id_present(auth_data)
|
|
56
|
+
return true if principal_id(auth_data)
|
|
57
|
+
|
|
58
|
+
msg = 'Missing identity id in JWT token auth_data'
|
|
59
|
+
singleton_class.current_span&.status = ::OpenTelemetry::Trace::Status.error(msg)
|
|
60
|
+
raise self::CommandNotAuthorized, msg
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
64
|
+
# @return [ActiveRecord::Base] resource to authorize
|
|
65
|
+
# @raise [StandardError] if RESOURCE[:name] or RESOURCE[:read_model] is not defined
|
|
66
|
+
def load_resource(command)
|
|
67
|
+
unless defined?(self::RESOURCE) && self::RESOURCE[:name] && self::RESOURCE[:read_model]
|
|
68
|
+
message =
|
|
69
|
+
'Your CommandCerbosAuthorizer subclass needs to define RESOURCE[:name] and RESOURCE[:read_model] constant'
|
|
70
|
+
raise StandardError, message
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
read_model(command).find_by(id: command.send("#{self::RESOURCE[:name]}_id"))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns the appropriate read model class for the command.
|
|
77
|
+
# Uses the draft read model when the command is an edit template command
|
|
78
|
+
# and a draft_read_model is configured in RESOURCE.
|
|
79
|
+
#
|
|
80
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
81
|
+
# @return [Class] the read model class
|
|
82
|
+
def read_model(command)
|
|
83
|
+
return self::RESOURCE[:draft_read_model] if command.metadata&.dig(:edit_template_command) && self::RESOURCE[:draft_read_model]
|
|
84
|
+
|
|
85
|
+
self::RESOURCE[:read_model]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @param command [Yes::Core::Command]
|
|
89
|
+
# @param resource [ActiveRecord::Base]
|
|
90
|
+
# @param auth_data [Hash]
|
|
91
|
+
# @return [Cerbos::Output::CheckResources::Result]
|
|
92
|
+
def authorize(...)
|
|
93
|
+
singleton_class.current_span&.add_event('Authorization Started')
|
|
94
|
+
payload = cerbos_payload(...)
|
|
95
|
+
singleton_class.current_span&.add_event('Cerbos Payload Built')
|
|
96
|
+
|
|
97
|
+
singleton_class.with_otl_span('Authorize request to Cerbos') do
|
|
98
|
+
cerbos_client.check_resource(**payload)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
103
|
+
# @param resource [ActiveRecord::Base] resource to authorize
|
|
104
|
+
# @param auth_data [Hash] authorization data
|
|
105
|
+
# @return [Hash] payload for Cerbos check_resource
|
|
106
|
+
def cerbos_payload(command, resource, auth_data)
|
|
107
|
+
{
|
|
108
|
+
principal: principal_data(auth_data).deep_merge(attributes: { command_payload: command.payload }),
|
|
109
|
+
resource: resource_data(resource, command),
|
|
110
|
+
actions: actions(command),
|
|
111
|
+
include_metadata: Yes::Core.configuration.cerbos_commands_authorizer_include_metadata
|
|
112
|
+
}.deep_symbolize_keys.tap { singleton_class.current_span&.set_attribute('cerbos_payload', _1.to_json) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @param resource [ActiveRecord::Base] resource to authorize
|
|
116
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
117
|
+
# @return [Hash] resource data for Cerbos check_resource
|
|
118
|
+
def resource_data(resource, command)
|
|
119
|
+
attribs = resource_attributes(resource, command)
|
|
120
|
+
{
|
|
121
|
+
kind: resource_kind(resource, attribs),
|
|
122
|
+
scope: scope(command),
|
|
123
|
+
id: resource_id(resource),
|
|
124
|
+
attributes: attribs
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param resource [ActiveRecord::Base] resource to authorize
|
|
129
|
+
# @param attribs [Hash] resource attributes
|
|
130
|
+
# @return [String] resource kind for Cerbos check_resource
|
|
131
|
+
def resource_kind(resource, attribs)
|
|
132
|
+
(attribs.values.any? { !_1.nil? } || attribs.empty?) && resource&.id ? self::RESOURCE[:name] : new_resource_id
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# @param resource [ActiveRecord::Base] resource to authorize
|
|
136
|
+
# @return [String] resource id for Cerbos check_resource
|
|
137
|
+
def resource_id(resource)
|
|
138
|
+
resource&.id || new_resource_id
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @param resource [ActiveRecord::Base] resource to authorize
|
|
142
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
143
|
+
# @return [Hash] resource attributes for Cerbos check_resource
|
|
144
|
+
def resource_attributes(resource, _command)
|
|
145
|
+
resource&.try(:auth_attributes)&.as_json || {}
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# @param auth_data [Hash] authorization data
|
|
149
|
+
# @return [Hash] principal data for Cerbos check_resource
|
|
150
|
+
def principal_data(auth_data)
|
|
151
|
+
Yes::Core.configuration.cerbos_principal_data_builder.call(
|
|
152
|
+
auth_data.with_indifferent_access
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @param auth_data [Hash]
|
|
157
|
+
# @return [String]
|
|
158
|
+
def principal_id(auth_data)
|
|
159
|
+
auth_data.with_indifferent_access[:identity_id]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# @return [String] new resource id
|
|
163
|
+
def new_resource_id
|
|
164
|
+
"#{self::RESOURCE[:name]}:#{NEW_RESOURCE_ID}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
168
|
+
# @return [Array<String>] actions for Cerbos check_resource
|
|
169
|
+
def actions(command)
|
|
170
|
+
[command.class.to_s.gsub(/::Commands?/, '').split('::').last.underscore]
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @param command [Yes::Core::Command] command to authorize
|
|
174
|
+
# @return [String] scope for Cerbos check_resource
|
|
175
|
+
def scope(command)
|
|
176
|
+
command.class.to_s.split('::').first.underscore
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yes
|
|
4
|
+
module Core
|
|
5
|
+
module Authorization
|
|
6
|
+
# @abstract Read model authorizer base class. Subclass and override call method to implement
|
|
7
|
+
# a custom authorizer.
|
|
8
|
+
class ReadModelAuthorizer
|
|
9
|
+
NotAuthorized = Class.new(Yes::Core::Error)
|
|
10
|
+
|
|
11
|
+
# Implement this method to authorize a read model.
|
|
12
|
+
# Needs to return true if read model is authorized, otherwise raise NotAuthorized.
|
|
13
|
+
# @param record [ApplicationRecord] record to authorize
|
|
14
|
+
# @param auth_data [Hash] authorization data
|
|
15
|
+
# @return [Boolean] true if read model is authorized
|
|
16
|
+
def self.call(_record, _auth_data)
|
|
17
|
+
raise NotAuthorized
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|