durable_parameters 0.2.3

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 (56) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +853 -0
  4. data/Rakefile +29 -0
  5. data/app/params/account_params.rb.example +38 -0
  6. data/app/params/application_params.rb +16 -0
  7. data/lib/durable_parameters/adapters/hanami.rb +138 -0
  8. data/lib/durable_parameters/adapters/rage.rb +124 -0
  9. data/lib/durable_parameters/adapters/rails.rb +280 -0
  10. data/lib/durable_parameters/adapters/sinatra.rb +91 -0
  11. data/lib/durable_parameters/core/application_params.rb +334 -0
  12. data/lib/durable_parameters/core/configuration.rb +83 -0
  13. data/lib/durable_parameters/core/forbidden_attributes_protection.rb +48 -0
  14. data/lib/durable_parameters/core/parameters.rb +643 -0
  15. data/lib/durable_parameters/core/params_registry.rb +110 -0
  16. data/lib/durable_parameters/core.rb +15 -0
  17. data/lib/durable_parameters/log_subscriber.rb +34 -0
  18. data/lib/durable_parameters/railtie.rb +65 -0
  19. data/lib/durable_parameters/version.rb +7 -0
  20. data/lib/durable_parameters.rb +41 -0
  21. data/lib/generators/rails/USAGE +12 -0
  22. data/lib/generators/rails/durable_parameters_controller_generator.rb +17 -0
  23. data/lib/generators/rails/templates/controller.rb +94 -0
  24. data/lib/legacy/action_controller/application_params.rb +235 -0
  25. data/lib/legacy/action_controller/parameters.rb +524 -0
  26. data/lib/legacy/action_controller/params_registry.rb +108 -0
  27. data/lib/legacy/active_model/forbidden_attributes_protection.rb +40 -0
  28. data/test/action_controller_required_params_test.rb +36 -0
  29. data/test/action_controller_tainted_params_test.rb +29 -0
  30. data/test/active_model_mass_assignment_taint_protection_test.rb +25 -0
  31. data/test/application_params_array_test.rb +245 -0
  32. data/test/application_params_edge_cases_test.rb +361 -0
  33. data/test/application_params_test.rb +893 -0
  34. data/test/controller_generator_test.rb +31 -0
  35. data/test/core_parameters_test.rb +2376 -0
  36. data/test/durable_parameters_test.rb +115 -0
  37. data/test/enhanced_error_messages_test.rb +120 -0
  38. data/test/gemfiles/Gemfile.rails-3.0.x +14 -0
  39. data/test/gemfiles/Gemfile.rails-3.1.x +14 -0
  40. data/test/gemfiles/Gemfile.rails-3.2.x +14 -0
  41. data/test/log_on_unpermitted_params_test.rb +49 -0
  42. data/test/metadata_validation_test.rb +294 -0
  43. data/test/multi_parameter_attributes_test.rb +38 -0
  44. data/test/parameters_core_methods_test.rb +503 -0
  45. data/test/parameters_integration_test.rb +553 -0
  46. data/test/parameters_permit_test.rb +491 -0
  47. data/test/parameters_require_test.rb +9 -0
  48. data/test/parameters_taint_test.rb +98 -0
  49. data/test/params_registry_concurrency_test.rb +422 -0
  50. data/test/params_registry_test.rb +112 -0
  51. data/test/permit_by_model_test.rb +227 -0
  52. data/test/raise_on_unpermitted_params_test.rb +32 -0
  53. data/test/test_helper.rb +38 -0
  54. data/test/transform_params_edge_cases_test.rb +526 -0
  55. data/test/transformation_test.rb +360 -0
  56. metadata +223 -0
@@ -0,0 +1,524 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+ require 'bigdecimal'
5
+ require 'stringio'
6
+
7
+ require 'active_support/concern'
8
+ require 'active_support/core_ext/hash/indifferent_access'
9
+ require 'active_support/core_ext/array/wrap'
10
+ require 'action_controller'
11
+ require 'action_dispatch/http/upload'
12
+
13
+ module ActionController
14
+ # Exception raised when a required parameter is missing or empty.
15
+ #
16
+ # @example
17
+ # params.require(:user)
18
+ # # => ActionController::ParameterMissing: param is missing or the value is empty: user
19
+ class ParameterMissing < IndexError
20
+ # @return [Symbol, String] the name of the missing parameter
21
+ attr_reader :param
22
+
23
+ # Initialize a new ParameterMissing exception
24
+ #
25
+ # @param param [Symbol, String] the name of the missing parameter
26
+ def initialize(param)
27
+ @param = param
28
+ super("param is missing or the value is empty: #{param}")
29
+ end
30
+ end unless defined?(ParameterMissing)
31
+
32
+ # Exception raised when unpermitted parameters are detected and configured to raise.
33
+ #
34
+ # @example
35
+ # params.permit(:name)
36
+ # # With params containing :admin => true
37
+ # # => ActionController::UnpermittedParameters: found unpermitted parameters: admin
38
+ class UnpermittedParameters < IndexError
39
+ # @return [Array<String>] the names of the unpermitted parameters
40
+ attr_reader :params
41
+
42
+ # Initialize a new UnpermittedParameters exception
43
+ #
44
+ # @param params [Array<String>] the names of the unpermitted parameters
45
+ def initialize(params)
46
+ @params = params
47
+ super("found unpermitted parameters: #{params.join(', ')}")
48
+ end
49
+ end unless defined?(UnpermittedParameters)
50
+
51
+ # Strong Parameters implementation for Action Controller.
52
+ #
53
+ # This class provides a whitelist-based approach to mass assignment protection,
54
+ # requiring explicit permission for parameters before they can be used in
55
+ # Active Model mass assignments.
56
+ #
57
+ # @example Basic usage
58
+ # params = ActionController::Parameters.new(user: { name: 'John', admin: true })
59
+ # params.require(:user).permit(:name)
60
+ # # => <ActionController::Parameters {"name"=>"John"} permitted: true>
61
+ class Parameters < ActiveSupport::HashWithIndifferentAccess
62
+ # @return [Boolean] whether this Parameters object is permitted
63
+ attr_accessor :permitted
64
+ alias permitted? permitted
65
+
66
+ # @return [Symbol, String, nil] the key used in the last require() call
67
+ attr_accessor :required_key
68
+
69
+ cattr_accessor :action_on_unpermitted_parameters, instance_accessor: false
70
+
71
+ # Parameters that are never considered unpermitted.
72
+ # These are added by Rails and are of no concern for security.
73
+ NEVER_UNPERMITTED_PARAMS = %w[controller action].freeze
74
+
75
+ # Initialize a new Parameters object.
76
+ #
77
+ # @param attributes [Hash, nil] the initial attributes
78
+ def initialize(attributes = nil)
79
+ super(attributes)
80
+ @permitted = false
81
+ @required_key = nil
82
+ end
83
+
84
+ # Mark this Parameters object and all nested Parameters as permitted.
85
+ #
86
+ # Use with extreme caution as this allows all current and future attributes
87
+ # to be mass-assigned. Should only be used when you fully trust the source
88
+ # of the parameters.
89
+ #
90
+ # @return [Parameters] self
91
+ # @example
92
+ # params.require(:log_entry).permit!
93
+ def permit!
94
+ each_pair do |key, value|
95
+ value = convert_hashes_to_parameters(key, value)
96
+ Array.wrap(value).each do |_|
97
+ _.permit! if _.respond_to?(:permit!)
98
+ end
99
+ end
100
+
101
+ @permitted = true
102
+ self
103
+ end
104
+
105
+ # Ensure that a parameter is present and not empty.
106
+ #
107
+ # If the parameter is missing or empty, raises ParameterMissing exception.
108
+ # If the parameter is present and is a Parameters object, tracks the key
109
+ # for later use by transform_params.
110
+ #
111
+ # @param key [Symbol, String] the parameter key to require
112
+ # @return [Object] the parameter value
113
+ # @raise [ParameterMissing] if the parameter is missing or empty
114
+ # @example
115
+ # params.require(:user)
116
+ # params.require(:user).permit(:name, :email)
117
+ def require(key)
118
+ value = self[key].presence || raise(ActionController::ParameterMissing.new(key))
119
+ # Track the required key so transform_params can infer the params class
120
+ if value.is_a?(Parameters)
121
+ value.required_key = key
122
+ end
123
+ value
124
+ end
125
+
126
+ alias required require
127
+
128
+ # Create a new Parameters object with only the specified keys permitted.
129
+ #
130
+ # Filters can be symbols/strings for scalar values, or hashes for nested
131
+ # parameters. Only explicitly permitted parameters will be included in
132
+ # the returned object.
133
+ #
134
+ # @param filters [Array<Symbol, String, Hash>] the keys to permit
135
+ # @return [Parameters] a new Parameters object containing only permitted keys
136
+ # @example Permit scalar values
137
+ # params.permit(:name, :age)
138
+ # @example Permit nested parameters
139
+ # params.permit(:name, emails: [], friends: [:name, { family: [:name] }])
140
+ def permit(*filters)
141
+ params = self.class.new
142
+
143
+ filters.flatten.each do |filter|
144
+ case filter
145
+ when Symbol, String
146
+ permitted_scalar_filter(params, filter)
147
+ when Hash
148
+ hash_filter(params, filter)
149
+ end
150
+ end
151
+
152
+ unpermitted_parameters!(params) if self.class.action_on_unpermitted_parameters
153
+
154
+ params.permit!
155
+ end
156
+
157
+ # Transform and permit parameters using a declarative params class.
158
+ #
159
+ # This method provides a declarative approach to parameter filtering using
160
+ # params classes defined in app/params/. It automatically looks up the
161
+ # appropriate params class based on the required_key if not explicitly provided.
162
+ #
163
+ # @param params_class [Class, Symbol, nil] optional params class or :__infer__ (default)
164
+ # - Class: Use this specific params class (e.g., UserParams)
165
+ # - :__infer__: Infer from required_key (e.g., :user -> UserParams)
166
+ # - nil: Skip lookup and return empty permitted params
167
+ # @param options [Hash] metadata and configuration options
168
+ # @option options [Symbol, String] :action Action name for action-specific filtering
169
+ # @option options [Array<Symbol>] :additional_attrs Additional attributes to permit
170
+ # @option options [Object] :current_user Current user (always allowed, no declaration needed)
171
+ # @return [Parameters] permitted parameters with only allowed attributes
172
+ # @raise [ArgumentError] if undeclared metadata keys are passed
173
+ #
174
+ # @example Basic usage with inference
175
+ # params.require(:user).transform_params
176
+ # # Looks up UserParams automatically
177
+ #
178
+ # @example Explicit params class
179
+ # params.require(:user).transform_params(AdminUserParams)
180
+ #
181
+ # @example With action-specific filtering
182
+ # params.require(:post).transform_params(action: :create)
183
+ #
184
+ # @example With additional attributes
185
+ # params.require(:user).transform_params(additional_attrs: [:temp_token])
186
+ #
187
+ # @example With metadata
188
+ # params.require(:account).transform_params(
189
+ # current_user: current_user,
190
+ # ip_address: request.ip,
191
+ # role: current_user.role
192
+ # )
193
+ # # Note: :ip_address and :role must be declared in AccountParams using `metadata :ip_address, :role`
194
+ #
195
+ # @note Any metadata keys other than :current_user must be explicitly declared
196
+ # in the params class using the `metadata` DSL method.
197
+ def transform_params(params_class = :__infer__, **options)
198
+ # Extract known options (these don't need to be declared as metadata)
199
+ action = options[:action]
200
+ additional_attrs = options[:additional_attrs] || []
201
+
202
+ # Infer params class from required_key if not explicitly provided
203
+ if params_class == :__infer__
204
+ if @required_key
205
+ params_class = ParamsRegistry.lookup(@required_key)
206
+ else
207
+ # If no required_key and no explicit params_class, return empty permitted params
208
+ return self.class.new.permit!
209
+ end
210
+ end
211
+
212
+ # Handle case where params_class is nil (explicitly passed or not registered)
213
+ if params_class.nil?
214
+ return self.class.new.permit!
215
+ end
216
+
217
+ # Validate metadata keys (excluding known options)
218
+ metadata_keys = options.keys - [:action, :additional_attrs]
219
+ validate_metadata_keys!(params_class, metadata_keys)
220
+
221
+ # Get permitted attributes and apply them
222
+ permitted_attrs = params_class.permitted_attributes(action: action)
223
+ permitted_attrs += additional_attrs
224
+ permit(*permitted_attrs)
225
+ end
226
+
227
+ # Legacy alias for backwards compatibility.
228
+ #
229
+ # @deprecated Use {#transform_params} instead
230
+ # @param model_name [Symbol, String] the model name to look up
231
+ # @param action [Symbol, String, nil] optional action name
232
+ # @param additional_attrs [Array<Symbol>] additional attributes to permit
233
+ # @return [Parameters] permitted parameters
234
+ def permit_by_model(model_name, action: nil, additional_attrs: [])
235
+ params_class = ParamsRegistry.lookup(model_name)
236
+ transform_params(params_class, action: action, additional_attrs: additional_attrs)
237
+ end
238
+
239
+ # Access a parameter value by key, converting hashes to Parameters objects.
240
+ #
241
+ # @param key [Symbol, String] the parameter key
242
+ # @return [Object] the parameter value
243
+ def [](key)
244
+ convert_hashes_to_parameters(key, super)
245
+ end
246
+
247
+ # Fetch a parameter value by key, raising ParameterMissing if not found.
248
+ #
249
+ # @param key [Symbol, String] the parameter key
250
+ # @param args additional arguments passed to Hash#fetch
251
+ # @return [Object] the parameter value
252
+ # @raise [ParameterMissing] if the key is not found
253
+ def fetch(key, *args)
254
+ convert_hashes_to_parameters(key, super, false)
255
+ rescue KeyError, IndexError
256
+ raise ActionController::ParameterMissing.new(key)
257
+ end
258
+
259
+ # Create a new Parameters object containing only the specified keys.
260
+ #
261
+ # @param keys [Array<Symbol, String>] the keys to include
262
+ # @return [Parameters] a new Parameters object with only the specified keys
263
+ # @example
264
+ # params.slice(:name, :email)
265
+ def slice(*keys)
266
+ # Manually slice the hash since ActiveSupport 3.0 might not have slice
267
+ sliced = {}
268
+ keys.each do |key|
269
+ sliced[key] = self[key] if has_key?(key)
270
+ end
271
+
272
+ self.class.new(sliced).tap do |new_instance|
273
+ new_instance.instance_variable_set(:@permitted, @permitted)
274
+ new_instance.instance_variable_set(:@required_key, @required_key)
275
+ end
276
+ end
277
+
278
+ # Create a new Parameters object excluding the specified keys.
279
+ #
280
+ # @param keys [Array<Symbol, String>] the keys to exclude
281
+ # @return [Parameters] a new Parameters object without the specified keys
282
+ # @example
283
+ # params.except(:password, :password_confirmation)
284
+ def except(*keys)
285
+ # Return a new Parameters instance excluding the specified keys
286
+ excepted = dup
287
+ keys.each { |key| excepted.delete(key) }
288
+ excepted
289
+ end
290
+
291
+ # Create a duplicate of this Parameters object.
292
+ #
293
+ # @return [Parameters] a new Parameters object with the same data and state
294
+ def dup
295
+ self.class.new(self).tap do |duplicate|
296
+ duplicate.default = default
297
+ duplicate.instance_variable_set(:@permitted, @permitted)
298
+ duplicate.instance_variable_set(:@required_key, @required_key)
299
+ end
300
+ end
301
+
302
+ protected
303
+ def convert_value(value)
304
+ if value.class == Hash
305
+ self.class.new_from_hash_copying_default(value)
306
+ elsif value.is_a?(Array)
307
+ value.dup.replace(value.map { |e| convert_value(e) })
308
+ else
309
+ value
310
+ end
311
+ end
312
+
313
+ private
314
+
315
+ def convert_hashes_to_parameters(key, value, assign_if_converted=true)
316
+ converted = convert_value_to_parameters(value)
317
+ self[key] = converted if assign_if_converted && !converted.equal?(value)
318
+ converted
319
+ end
320
+
321
+ def convert_value_to_parameters(value)
322
+ if value.is_a?(Array)
323
+ value.map { |_| convert_value_to_parameters(_) }
324
+ elsif value.is_a?(Parameters) || !value.is_a?(Hash)
325
+ value
326
+ else
327
+ self.class.new(value)
328
+ end
329
+ end
330
+
331
+ #
332
+ # --- Filtering ----------------------------------------------------------
333
+ #
334
+
335
+ # Whitelist of permitted scalar types for parameter filtering.
336
+ #
337
+ # These types are considered safe for mass assignment and include types
338
+ # commonly used in XML and JSON requests. String is first to optimize
339
+ # the common case through short-circuit evaluation.
340
+ #
341
+ # Note: DateTime inherits from Date, so it's implicitly included via Date.
342
+ #
343
+ # @note If you modify this list, please update the README documentation.
344
+ PERMITTED_SCALAR_TYPES = [
345
+ String,
346
+ Symbol,
347
+ NilClass,
348
+ Numeric,
349
+ TrueClass,
350
+ FalseClass,
351
+ Date,
352
+ Time,
353
+ # DateTimes are Dates, we document the type but avoid the redundant check.
354
+ StringIO,
355
+ IO,
356
+ ActionDispatch::Http::UploadedFile,
357
+ Rack::Test::UploadedFile
358
+ ].freeze
359
+
360
+ # Check if a value is a permitted scalar type.
361
+ #
362
+ # @param value [Object] the value to check
363
+ # @return [Boolean] true if value is a permitted scalar type
364
+ def permitted_scalar?(value)
365
+ PERMITTED_SCALAR_TYPES.any? { |type| value.is_a?(type) }
366
+ end
367
+
368
+ # Check if a value is an array containing only permitted scalars.
369
+ #
370
+ # @param value [Object] the value to check
371
+ # @return [Boolean, nil] true if array of permitted scalars, nil otherwise
372
+ def array_of_permitted_scalars?(value)
373
+ return unless value.is_a?(Array)
374
+
375
+ value.all? { |element| permitted_scalar?(element) }
376
+ end
377
+
378
+ def permitted_scalar_filter(params, key)
379
+ if has_key?(key) && permitted_scalar?(self[key])
380
+ params[key] = self[key]
381
+ end
382
+
383
+ keys.grep(/\A#{Regexp.escape(key.to_s)}\(\d+[if]?\)\z/).each do |key|
384
+ if permitted_scalar?(self[key])
385
+ params[key] = self[key]
386
+ end
387
+ end
388
+ end
389
+
390
+ def array_of_permitted_scalars_filter(params, key, hash = self)
391
+ if hash.has_key?(key) && array_of_permitted_scalars?(hash[key])
392
+ params[key] = hash[key]
393
+ end
394
+ end
395
+
396
+ def hash_filter(params, filter)
397
+ filter = filter.with_indifferent_access
398
+
399
+ # Slicing filters out non-declared keys.
400
+ slice(*filter.keys).each do |key, value|
401
+ next unless value
402
+
403
+ if filter[key] == []
404
+ # Declaration {:comment_ids => []}.
405
+ array_of_permitted_scalars_filter(params, key)
406
+ else
407
+ # Declaration {:user => :name} or {:user => [:name, :age, {:adress => ...}]}.
408
+ params[key] = each_element(value) do |element, index|
409
+ if element.is_a?(Hash)
410
+ element = self.class.new(element) unless element.respond_to?(:permit)
411
+ element.permit(*Array.wrap(filter[key]))
412
+ elsif filter[key].is_a?(Hash) && filter[key][index] == []
413
+ array_of_permitted_scalars_filter(params, index, value)
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
419
+
420
+ def each_element(value)
421
+ if value.is_a?(Array)
422
+ value.map { |el| yield el }.compact
423
+ # fields_for on an array of records uses numeric hash keys.
424
+ elsif fields_for_style?(value)
425
+ hash = value.class.new
426
+ value.each { |k,v| hash[k] = yield(v, k) }
427
+ hash
428
+ else
429
+ yield value
430
+ end
431
+ end
432
+
433
+ def fields_for_style?(object)
434
+ object.is_a?(Hash) && object.all? { |k, v| k =~ /\A-?\d+\z/ && v.is_a?(Hash) }
435
+ end
436
+
437
+ def unpermitted_parameters!(params)
438
+ return unless self.class.action_on_unpermitted_parameters
439
+
440
+ unpermitted_keys = unpermitted_keys(params)
441
+
442
+ if unpermitted_keys.any?
443
+ case self.class.action_on_unpermitted_parameters
444
+ when :log
445
+ name = "unpermitted_parameters.action_controller"
446
+ ActiveSupport::Notifications.instrument(name, :keys => unpermitted_keys)
447
+ when :raise
448
+ raise ActionController::UnpermittedParameters.new(unpermitted_keys)
449
+ end
450
+ end
451
+ end
452
+
453
+ def unpermitted_keys(params)
454
+ self.keys - params.keys - NEVER_UNPERMITTED_PARAMS
455
+ end
456
+
457
+ # Validate that all provided metadata keys are allowed by the params class.
458
+ #
459
+ # @param params_class [Class] the params class to validate against
460
+ # @param metadata_keys [Array<Symbol>] the metadata keys to validate
461
+ # @raise [ArgumentError] if any metadata keys are not declared
462
+ # @return [void]
463
+ def validate_metadata_keys!(params_class, metadata_keys)
464
+ return if metadata_keys.empty?
465
+
466
+ disallowed_keys = metadata_keys.reject { |key| params_class.metadata_allowed?(key) }
467
+
468
+ return unless disallowed_keys.any?
469
+
470
+ # Build a helpful error message
471
+ keys_list = disallowed_keys.map(&:inspect).join(', ')
472
+ class_name = params_class.name
473
+
474
+ raise ArgumentError, <<~ERROR.strip
475
+ Metadata key(s) #{keys_list} not allowed for #{class_name}.
476
+
477
+ To fix this, declare them in your params class:
478
+
479
+ class #{class_name} < ApplicationParams
480
+ metadata #{disallowed_keys.map(&:inspect).join(', ')}
481
+ end
482
+
483
+ Note: :current_user is always implicitly allowed and doesn't need to be declared.
484
+ ERROR
485
+ end
486
+ end
487
+
488
+ # Controller integration module for Strong Parameters.
489
+ #
490
+ # This module provides the params method to controllers and handles
491
+ # ParameterMissing exceptions with a 400 Bad Request response.
492
+ #
493
+ # @example
494
+ # class ApplicationController < ActionController::Base
495
+ # include ActionController::StrongParameters
496
+ # end
497
+ module StrongParameters
498
+ extend ActiveSupport::Concern
499
+
500
+ included do
501
+ rescue_from(ActionController::ParameterMissing) do |parameter_missing_exception|
502
+ render text: "Required parameter missing: #{parameter_missing_exception.param}",
503
+ status: :bad_request
504
+ end
505
+ end
506
+
507
+ # Access request parameters as a Parameters object.
508
+ #
509
+ # @return [Parameters] the request parameters wrapped in a Parameters object
510
+ def params
511
+ @_params ||= Parameters.new(request.parameters)
512
+ end
513
+
514
+ # Set the parameters for this request.
515
+ #
516
+ # @param val [Hash, Parameters] the parameters to set
517
+ # @return [Parameters] the parameters
518
+ def params=(val)
519
+ @_params = val.is_a?(Hash) ? Parameters.new(val) : val
520
+ end
521
+ end
522
+ end
523
+
524
+ ActiveSupport.on_load(:action_controller) { include ActionController::StrongParameters }
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionController
4
+ # Singleton registry for storing and retrieving param class definitions.
5
+ #
6
+ # ParamsRegistry provides a central location to register and look up params
7
+ # classes for models. This enables automatic inference of params classes in
8
+ # transform_params based on the model name.
9
+ #
10
+ # @example Registering a params class
11
+ # ParamsRegistry.register(:user, UserParams)
12
+ #
13
+ # @example Looking up a params class
14
+ # ParamsRegistry.lookup(:user) # => UserParams
15
+ #
16
+ # @example Getting permitted attributes
17
+ # ParamsRegistry.permitted_attributes_for(:user, action: :create)
18
+ class ParamsRegistry
19
+ class << self
20
+ # Register a params class for a model.
21
+ #
22
+ # The model name is normalized (underscored and symbolized) before storage.
23
+ #
24
+ # @param model_name [String, Symbol] the model name (e.g., 'User', 'Account')
25
+ # @param params_class [Class] the params class (e.g., UserParams)
26
+ # @return [Class] the registered params class
27
+ # @example
28
+ # ParamsRegistry.register(:user, UserParams)
29
+ # ParamsRegistry.register('BlogPost', BlogPostParams)
30
+ def register(model_name, params_class)
31
+ registry[normalize_key(model_name)] = params_class
32
+ end
33
+
34
+ # Look up the params class for a model.
35
+ #
36
+ # @param model_name [String, Symbol] the model name
37
+ # @return [Class, nil] the params class or nil if not found
38
+ # @example
39
+ # ParamsRegistry.lookup(:user) # => UserParams
40
+ # ParamsRegistry.lookup(:unknown) # => nil
41
+ def lookup(model_name)
42
+ registry[normalize_key(model_name)]
43
+ end
44
+
45
+ # Get permitted attributes for a model.
46
+ #
47
+ # @param model_name [String, Symbol] the model name
48
+ # @param action [Symbol, String, nil] optional action name for filtering
49
+ # @return [Array<Symbol, Hash>] array of permitted attributes
50
+ # @example
51
+ # ParamsRegistry.permitted_attributes_for(:user)
52
+ # ParamsRegistry.permitted_attributes_for(:post, action: :create)
53
+ def permitted_attributes_for(model_name, action: nil)
54
+ params_class = lookup(model_name)
55
+ return [] unless params_class
56
+
57
+ params_class.permitted_attributes(action: action)
58
+ end
59
+
60
+ # Check if a model has registered params.
61
+ #
62
+ # @param model_name [String, Symbol] the model name
63
+ # @return [Boolean] true if registered, false otherwise
64
+ # @example
65
+ # ParamsRegistry.registered?(:user) # => true
66
+ # ParamsRegistry.registered?(:unknown) # => false
67
+ def registered?(model_name)
68
+ registry.key?(normalize_key(model_name))
69
+ end
70
+
71
+ # Clear the registry.
72
+ #
73
+ # This is primarily useful for testing to ensure a clean slate between tests.
74
+ #
75
+ # @return [Hash] the empty registry
76
+ def clear!
77
+ registry.clear
78
+ end
79
+
80
+ # Get all registered model names.
81
+ #
82
+ # @return [Array<String>] array of model names as strings
83
+ # @example
84
+ # ParamsRegistry.registered_models # => ["user", "post", "comment"]
85
+ def registered_models
86
+ registry.keys.map(&:to_s)
87
+ end
88
+
89
+ private
90
+
91
+ # @return [Hash<Symbol, Class>] the internal registry hash
92
+ def registry
93
+ @registry ||= {}
94
+ end
95
+
96
+ # Normalize a key by underscoring and symbolizing it.
97
+ #
98
+ # @param key [String, Symbol] the key to normalize
99
+ # @return [Symbol] the normalized key
100
+ # @example
101
+ # normalize_key('BlogPost') # => :blog_post
102
+ # normalize_key(:user) # => :user
103
+ def normalize_key(key)
104
+ key.to_s.underscore.to_sym
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveModel
4
+ # Exception raised when attempting mass assignment with unpermitted parameters.
5
+ #
6
+ # This exception is raised when ActionController::Parameters are used in
7
+ # mass assignment without being explicitly permitted using permit() or permit!().
8
+ #
9
+ # @example
10
+ # User.new(params[:user])
11
+ # # => ActiveModel::ForbiddenAttributes (if :user params not permitted)
12
+ class ForbiddenAttributes < StandardError
13
+ end
14
+
15
+ # Protection module for Active Model mass assignment.
16
+ #
17
+ # This module overrides sanitize_for_mass_assignment to check that
18
+ # ActionController::Parameters objects are marked as permitted before
19
+ # allowing mass assignment.
20
+ #
21
+ # @example Include in a model
22
+ # class Post < ActiveRecord::Base
23
+ # include ActiveModel::ForbiddenAttributesProtection
24
+ # end
25
+ module ForbiddenAttributesProtection
26
+ # Check if parameters are permitted before mass assignment.
27
+ #
28
+ # @param options [Array] mass assignment options, first element should be attributes hash
29
+ # @return [Object] result of super if permitted
30
+ # @raise [ForbiddenAttributes] if parameters are not permitted
31
+ def sanitize_for_mass_assignment(*options)
32
+ new_attributes = options.first
33
+ if !new_attributes.respond_to?(:permitted?) || new_attributes.permitted?
34
+ super
35
+ else
36
+ raise ActiveModel::ForbiddenAttributes
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,36 @@
1
+ require 'test_helper'
2
+
3
+ class BooksController < ActionController::Base
4
+ def create
5
+ params.require(:book).require(:name)
6
+ head :ok
7
+ rescue ActionController::ParameterMissing => e
8
+ render plain: e.message, status: :bad_request
9
+ end
10
+ end
11
+
12
+ class ActionControllerRequiredParamsTest < ActionController::TestCase
13
+ tests BooksController
14
+
15
+ setup do
16
+ @routes = Rails.application.routes
17
+ end
18
+
19
+ def test_missing_required_parameters_will_raise_exception
20
+ post :create, params: { :magazine => { :name => "Mjallo!" } }
21
+ assert_response :bad_request
22
+
23
+ post :create, params: { :book => { :title => "Mjallo!" } }
24
+ assert_response :bad_request
25
+ end
26
+
27
+ def test_required_parameters_that_are_present_will_not_raise
28
+ post :create, params: { :book => { :name => "Mjallo!" } }
29
+ assert_response :ok
30
+ end
31
+
32
+ def test_missing_parameters_will_be_mentioned_in_the_response
33
+ post :create, params: { :magazine => { :name => "Mjallo!" } }
34
+ assert_includes response.body, "book"
35
+ end
36
+ end