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.
Files changed (128) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +69 -0
  5. data/lib/yes/core/active_job_serializers/command_group_serializer.rb +29 -0
  6. data/lib/yes/core/active_job_serializers/dry_struct_serializer.rb +57 -0
  7. data/lib/yes/core/aggregate/draftable.rb +205 -0
  8. data/lib/yes/core/aggregate/dsl/attribute_data.rb +37 -0
  9. data/lib/yes/core/aggregate/dsl/attribute_definer.rb +54 -0
  10. data/lib/yes/core/aggregate/dsl/attribute_definers/aggregate.rb +36 -0
  11. data/lib/yes/core/aggregate/dsl/attribute_definers/standard.rb +36 -0
  12. data/lib/yes/core/aggregate/dsl/class_name_convention.rb +80 -0
  13. data/lib/yes/core/aggregate/dsl/class_resolvers/authorizer.rb +132 -0
  14. data/lib/yes/core/aggregate/dsl/class_resolvers/base.rb +80 -0
  15. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer.rb +30 -0
  16. data/lib/yes/core/aggregate/dsl/class_resolvers/command/authorizer_factory.rb +34 -0
  17. data/lib/yes/core/aggregate/dsl/class_resolvers/command/base.rb +38 -0
  18. data/lib/yes/core/aggregate/dsl/class_resolvers/command/cerbos_authorizer.rb +114 -0
  19. data/lib/yes/core/aggregate/dsl/class_resolvers/command/command.rb +70 -0
  20. data/lib/yes/core/aggregate/dsl/class_resolvers/command/event.rb +88 -0
  21. data/lib/yes/core/aggregate/dsl/class_resolvers/command/guard_evaluator.rb +84 -0
  22. data/lib/yes/core/aggregate/dsl/class_resolvers/command/simple_authorizer.rb +50 -0
  23. data/lib/yes/core/aggregate/dsl/class_resolvers/command/state_updater.rb +46 -0
  24. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model.rb +75 -0
  25. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_filter.rb +88 -0
  26. data/lib/yes/core/aggregate/dsl/class_resolvers/read_model_serializer.rb +76 -0
  27. data/lib/yes/core/aggregate/dsl/command_data.rb +54 -0
  28. data/lib/yes/core/aggregate/dsl/command_definer.rb +263 -0
  29. data/lib/yes/core/aggregate/dsl/command_shortcut_expander.rb +233 -0
  30. data/lib/yes/core/aggregate/dsl/constant_resolver.rb +67 -0
  31. data/lib/yes/core/aggregate/dsl/method_definers/attribute/accessor.rb +28 -0
  32. data/lib/yes/core/aggregate/dsl/method_definers/attribute/aggregate_accessor.rb +36 -0
  33. data/lib/yes/core/aggregate/dsl/method_definers/attribute/base.rb +42 -0
  34. data/lib/yes/core/aggregate/dsl/method_definers/command/base.rb +42 -0
  35. data/lib/yes/core/aggregate/dsl/method_definers/command/can_command.rb +41 -0
  36. data/lib/yes/core/aggregate/dsl/method_definers/command/command.rb +50 -0
  37. data/lib/yes/core/aggregate/has_authorizer.rb +86 -0
  38. data/lib/yes/core/aggregate/has_read_model.rb +169 -0
  39. data/lib/yes/core/aggregate/read_model_rebuilder.rb +40 -0
  40. data/lib/yes/core/aggregate/shared_read_model_rebuilder.rb +158 -0
  41. data/lib/yes/core/aggregate.rb +404 -0
  42. data/lib/yes/core/authentication_error.rb +8 -0
  43. data/lib/yes/core/authorization/cerbos_client_provider.rb +27 -0
  44. data/lib/yes/core/authorization/command_authorizer.rb +40 -0
  45. data/lib/yes/core/authorization/command_cerbos_authorizer.rb +182 -0
  46. data/lib/yes/core/authorization/read_model_authorizer.rb +22 -0
  47. data/lib/yes/core/authorization/read_models_authorizer.rb +49 -0
  48. data/lib/yes/core/authorization/read_request_authorizer.rb +32 -0
  49. data/lib/yes/core/authorization/read_request_cerbos_authorizer.rb +112 -0
  50. data/lib/yes/core/command.rb +35 -0
  51. data/lib/yes/core/command_handling/aggregate_tracker.rb +33 -0
  52. data/lib/yes/core/command_handling/command_executor.rb +171 -0
  53. data/lib/yes/core/command_handling/command_handler.rb +124 -0
  54. data/lib/yes/core/command_handling/event_publisher.rb +189 -0
  55. data/lib/yes/core/command_handling/guard_evaluator.rb +165 -0
  56. data/lib/yes/core/command_handling/guard_runner.rb +76 -0
  57. data/lib/yes/core/command_handling/payload_proxy.rb +159 -0
  58. data/lib/yes/core/command_handling/read_model_recovery_service.rb +264 -0
  59. data/lib/yes/core/command_handling/read_model_revision_guard.rb +198 -0
  60. data/lib/yes/core/command_handling/read_model_updater.rb +103 -0
  61. data/lib/yes/core/command_handling/state_updater.rb +113 -0
  62. data/lib/yes/core/commands/bus.rb +46 -0
  63. data/lib/yes/core/commands/group.rb +135 -0
  64. data/lib/yes/core/commands/group_response.rb +13 -0
  65. data/lib/yes/core/commands/helper.rb +126 -0
  66. data/lib/yes/core/commands/notifier.rb +65 -0
  67. data/lib/yes/core/commands/processor.rb +137 -0
  68. data/lib/yes/core/commands/response.rb +63 -0
  69. data/lib/yes/core/commands/stateless/group_handler.rb +186 -0
  70. data/lib/yes/core/commands/stateless/group_response.rb +15 -0
  71. data/lib/yes/core/commands/stateless/handler.rb +292 -0
  72. data/lib/yes/core/commands/stateless/handler_helpers.rb +321 -0
  73. data/lib/yes/core/commands/stateless/response.rb +14 -0
  74. data/lib/yes/core/commands/stateless/subject.rb +41 -0
  75. data/lib/yes/core/commands/validator.rb +28 -0
  76. data/lib/yes/core/configuration.rb +432 -0
  77. data/lib/yes/core/data_decryptor.rb +59 -0
  78. data/lib/yes/core/data_encryptor.rb +60 -0
  79. data/lib/yes/core/encryption_metadata.rb +33 -0
  80. data/lib/yes/core/error.rb +14 -0
  81. data/lib/yes/core/error_messages.rb +37 -0
  82. data/lib/yes/core/event.rb +222 -0
  83. data/lib/yes/core/event_class_resolver.rb +40 -0
  84. data/lib/yes/core/generators/read_models/add_pending_update_tracking_generator.rb +43 -0
  85. data/lib/yes/core/generators/read_models/templates/add_pending_update_tracking.rb.erb +122 -0
  86. data/lib/yes/core/generators/read_models/templates/migration.rb.erb +9 -0
  87. data/lib/yes/core/generators/read_models/update_generator.rb +147 -0
  88. data/lib/yes/core/jobs/read_model_recovery_job.rb +219 -0
  89. data/lib/yes/core/middlewares/encryptor.rb +48 -0
  90. data/lib/yes/core/middlewares/timestamp.rb +29 -0
  91. data/lib/yes/core/middlewares/with_indifferent_access.rb +22 -0
  92. data/lib/yes/core/models/application_record.rb +9 -0
  93. data/lib/yes/core/open_telemetry/otl_span.rb +110 -0
  94. data/lib/yes/core/open_telemetry/trackable.rb +101 -0
  95. data/lib/yes/core/payload_store/base.rb +33 -0
  96. data/lib/yes/core/payload_store/errors.rb +13 -0
  97. data/lib/yes/core/payload_store/lookup.rb +44 -0
  98. data/lib/yes/core/process_managers/access_token_client.rb +107 -0
  99. data/lib/yes/core/process_managers/base.rb +40 -0
  100. data/lib/yes/core/process_managers/command_runner.rb +109 -0
  101. data/lib/yes/core/process_managers/service_client.rb +57 -0
  102. data/lib/yes/core/process_managers/state.rb +118 -0
  103. data/lib/yes/core/railtie.rb +58 -0
  104. data/lib/yes/core/read_model/builder.rb +267 -0
  105. data/lib/yes/core/read_model/event_handler.rb +64 -0
  106. data/lib/yes/core/read_model/filter.rb +118 -0
  107. data/lib/yes/core/read_model/filter_query_builder.rb +104 -0
  108. data/lib/yes/core/serializer.rb +21 -0
  109. data/lib/yes/core/subscriptions.rb +94 -0
  110. data/lib/yes/core/test_support/event_helpers.rb +27 -0
  111. data/lib/yes/core/test_support/jwt_helpers.rb +30 -0
  112. data/lib/yes/core/test_support/subscriptions_helper.rb +88 -0
  113. data/lib/yes/core/test_support/test_helper.rb +27 -0
  114. data/lib/yes/core/test_support.rb +5 -0
  115. data/lib/yes/core/transaction_details.rb +90 -0
  116. data/lib/yes/core/type_lookup.rb +88 -0
  117. data/lib/yes/core/types.rb +110 -0
  118. data/lib/yes/core/utils/aggregate_shortcuts.rb +164 -0
  119. data/lib/yes/core/utils/caller_utils.rb +37 -0
  120. data/lib/yes/core/utils/command_utils.rb +226 -0
  121. data/lib/yes/core/utils/error_notifier.rb +101 -0
  122. data/lib/yes/core/utils/event_name_resolver.rb +67 -0
  123. data/lib/yes/core/utils/exponential_retrier.rb +180 -0
  124. data/lib/yes/core/utils/hash_utils.rb +63 -0
  125. data/lib/yes/core/version.rb +7 -0
  126. data/lib/yes/core.rb +85 -0
  127. data/lib/yes.rb +0 -0
  128. 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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yes
4
+ module Core
5
+ # Raised when authentication fails in API controllers
6
+ class AuthenticationError < Error; end
7
+ end
8
+ 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