super_admin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +216 -0
  3. data/Rakefile +30 -0
  4. data/app/assets/stylesheets/super_admin/application.css +15 -0
  5. data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
  6. data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
  7. data/app/controllers/super_admin/application_controller.rb +89 -0
  8. data/app/controllers/super_admin/associations_controller.rb +136 -0
  9. data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
  10. data/app/controllers/super_admin/base_controller.rb +133 -0
  11. data/app/controllers/super_admin/dashboard_controller.rb +29 -0
  12. data/app/controllers/super_admin/exports_controller.rb +109 -0
  13. data/app/controllers/super_admin/resources_controller.rb +201 -0
  14. data/app/dashboards/super_admin/base_dashboard.rb +200 -0
  15. data/app/errors/super_admin/configuration_error.rb +6 -0
  16. data/app/helpers/super_admin/application_helper.rb +84 -0
  17. data/app/helpers/super_admin/exports_helper.rb +16 -0
  18. data/app/helpers/super_admin/resources_helper.rb +204 -0
  19. data/app/helpers/super_admin/route_helper.rb +7 -0
  20. data/app/javascript/super_admin/application.js +263 -0
  21. data/app/jobs/super_admin/application_job.rb +4 -0
  22. data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
  23. data/app/mailers/super_admin/application_mailer.rb +6 -0
  24. data/app/models/super_admin/application_record.rb +5 -0
  25. data/app/models/super_admin/audit_log.rb +35 -0
  26. data/app/models/super_admin/csv_export.rb +67 -0
  27. data/app/services/super_admin/auditing.rb +74 -0
  28. data/app/services/super_admin/authorization.rb +113 -0
  29. data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
  30. data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
  31. data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
  32. data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
  33. data/app/services/super_admin/csv_export_creator.rb +45 -0
  34. data/app/services/super_admin/dashboard_registry.rb +90 -0
  35. data/app/services/super_admin/dashboard_resolver.rb +100 -0
  36. data/app/services/super_admin/filter_builder.rb +185 -0
  37. data/app/services/super_admin/form_builder.rb +59 -0
  38. data/app/services/super_admin/form_fields/array_field.rb +35 -0
  39. data/app/services/super_admin/form_fields/association_field.rb +146 -0
  40. data/app/services/super_admin/form_fields/base_field.rb +53 -0
  41. data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
  42. data/app/services/super_admin/form_fields/date_field.rb +15 -0
  43. data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
  44. data/app/services/super_admin/form_fields/enum_field.rb +27 -0
  45. data/app/services/super_admin/form_fields/factory.rb +102 -0
  46. data/app/services/super_admin/form_fields/nested_field.rb +120 -0
  47. data/app/services/super_admin/form_fields/number_field.rb +29 -0
  48. data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
  49. data/app/services/super_admin/model_inspector.rb +182 -0
  50. data/app/services/super_admin/queries/base_query.rb +45 -0
  51. data/app/services/super_admin/queries/filter_query.rb +188 -0
  52. data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
  53. data/app/services/super_admin/queries/search_query.rb +146 -0
  54. data/app/services/super_admin/queries/sort_query.rb +41 -0
  55. data/app/services/super_admin/resource_configuration.rb +63 -0
  56. data/app/services/super_admin/resource_exporter.rb +78 -0
  57. data/app/services/super_admin/resource_query.rb +40 -0
  58. data/app/services/super_admin/resources/association_inspector.rb +112 -0
  59. data/app/services/super_admin/resources/collection_presenter.rb +63 -0
  60. data/app/services/super_admin/resources/context.rb +63 -0
  61. data/app/services/super_admin/resources/filter_params.rb +29 -0
  62. data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
  63. data/app/services/super_admin/resources/value_normalizer.rb +121 -0
  64. data/app/services/super_admin/sensitive_attributes.rb +166 -0
  65. data/app/views/layouts/super_admin.html.erb +74 -0
  66. data/app/views/super_admin/audit_logs/index.html.erb +143 -0
  67. data/app/views/super_admin/dashboard/index.html.erb +79 -0
  68. data/app/views/super_admin/exports/index.html.erb +84 -0
  69. data/app/views/super_admin/exports/show.html.erb +57 -0
  70. data/app/views/super_admin/resources/_form.html.erb +42 -0
  71. data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
  72. data/app/views/super_admin/resources/edit.html.erb +37 -0
  73. data/app/views/super_admin/resources/index.html.erb +189 -0
  74. data/app/views/super_admin/resources/new.html.erb +31 -0
  75. data/app/views/super_admin/resources/show.html.erb +106 -0
  76. data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
  77. data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
  78. data/app/views/super_admin/shared/_flash.html.erb +55 -0
  79. data/app/views/super_admin/shared/_form_field.html.erb +35 -0
  80. data/app/views/super_admin/shared/_navigation.html.erb +92 -0
  81. data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
  82. data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
  83. data/config/importmap.rb +4 -0
  84. data/config/initializers/rack_attack.rb +134 -0
  85. data/config/initializers/super_admin.rb +117 -0
  86. data/config/locales/super_admin.en.yml +197 -0
  87. data/config/locales/super_admin.fr.yml +197 -0
  88. data/config/routes.rb +22 -0
  89. data/lib/generators/super_admin/dashboard_generator.rb +50 -0
  90. data/lib/generators/super_admin/install_generator.rb +58 -0
  91. data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
  92. data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
  93. data/lib/generators/super_admin/templates/super_admin.rb +58 -0
  94. data/lib/super_admin/dashboard_creator.rb +256 -0
  95. data/lib/super_admin/engine.rb +53 -0
  96. data/lib/super_admin/install_task.rb +96 -0
  97. data/lib/super_admin/version.rb +3 -0
  98. data/lib/super_admin.rb +7 -0
  99. data/lib/tasks/super_admin_tasks.rake +38 -0
  100. metadata +239 -0
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Resources
5
+ # Provides helpers around ActiveRecord associations for SuperAdmin screens.
6
+ class AssociationInspector
7
+ class << self
8
+ def preloadable_names(model_class)
9
+ model_class.reflect_on_all_associations.filter_map do |association|
10
+ next unless %i[belongs_to has_one].include?(association.macro)
11
+ options = association.options || {}
12
+ next if options[:polymorphic] || options[:through]
13
+
14
+ association.name
15
+ end
16
+ end
17
+ end
18
+
19
+ def initialize(resource)
20
+ @resource = resource
21
+ end
22
+
23
+ def has_many_counts(associations)
24
+ has_many_assocs = associations.select { |assoc| assoc.macro == :has_many }
25
+ return {} if has_many_assocs.empty?
26
+
27
+ # Optimize: use counter_cache when available, batch count for others
28
+ counts = {}
29
+ to_query = []
30
+
31
+ has_many_assocs.each do |association|
32
+ counter_method = "#{association.name}_count"
33
+ if @resource.respond_to?(counter_method)
34
+ # Use counter_cache column if available (no query)
35
+ counts[association.name] = @resource.public_send(counter_method)
36
+ else
37
+ to_query << association
38
+ end
39
+ end
40
+
41
+ # Batch count remaining associations to reduce queries
42
+ if to_query.any?
43
+ batch_counts = batch_count_associations(to_query)
44
+ counts.merge!(batch_counts)
45
+ end
46
+
47
+ counts
48
+ rescue StandardError => error
49
+ Rails.logger.warn(
50
+ "[SuperAdmin::Resources::AssociationInspector] Failed to count associations for #{@resource.class}##{@resource.id}: #{error.class} - #{error.message}"
51
+ )
52
+ has_many_assocs.each_with_object({}) { |assoc, h| h[assoc.name] = 0 }
53
+ end
54
+
55
+ private
56
+
57
+ # Batch count multiple associations in parallel to reduce total queries
58
+ # Falls back to individual counts if batch counting fails
59
+ def batch_count_associations(associations)
60
+ # For small numbers of associations (1-2), individual queries are fine
61
+ return individual_counts(associations) if associations.size <= 2
62
+
63
+ # Try batch counting with concurrent queries
64
+ results = {}
65
+ threads = associations.map do |association|
66
+ Thread.new do
67
+ begin
68
+ count = count_for(association)
69
+ [ association.name, count ]
70
+ rescue StandardError => error
71
+ Rails.logger.debug(
72
+ "[SuperAdmin::AssociationInspector] Failed to count #{association.name}: #{error.message}"
73
+ )
74
+ [ association.name, 0 ]
75
+ end
76
+ end
77
+ end
78
+
79
+ threads.each do |thread|
80
+ name, count = thread.value
81
+ results[name] = count
82
+ end
83
+
84
+ results
85
+ rescue StandardError
86
+ # Fallback to individual counts if threading fails
87
+ individual_counts(associations)
88
+ end
89
+
90
+ def individual_counts(associations)
91
+ associations.each_with_object({}) do |association, counts|
92
+ counts[association.name] = count_for(association)
93
+ rescue StandardError
94
+ counts[association.name] = 0
95
+ end
96
+ end
97
+
98
+ def count_for(association)
99
+ association_proxy = @resource.association(association.name)
100
+ scope = association_proxy.scope
101
+
102
+ # Clean up scope to get a simple count
103
+ scope = scope.except(:select) if scope.respond_to?(:except)
104
+ scope = scope.unscope(:order) if scope.respond_to?(:unscope)
105
+ scope = scope.limit(nil) if scope.respond_to?(:limit)
106
+ scope = scope.offset(nil) if scope.respond_to?(:offset)
107
+
108
+ scope.count
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Resources
5
+ # Provides helper methods to work with resource collections (filtering, exports, pagination state).
6
+ class CollectionPresenter
7
+ attr_reader :context, :params
8
+
9
+ delegate :model_class, :resource_param, to: :context
10
+
11
+ def initialize(context:, params:)
12
+ @context = context
13
+ @params = params
14
+ end
15
+
16
+ def filter_definitions
17
+ SuperAdmin::FilterBuilder.definitions_for(model_class)
18
+ end
19
+
20
+ def filter_params
21
+ @filter_params ||= FilterParams.new(model_class, params[:filters]).to_h
22
+ end
23
+
24
+ def scope
25
+ @scope ||= begin
26
+ base_scope = SuperAdmin::ResourceQuery.filtered_scope(
27
+ model_class,
28
+ search: params[:search],
29
+ sort: params[:sort],
30
+ direction: params[:direction],
31
+ filters: filter_params
32
+ )
33
+
34
+ # Apply eager loading to avoid N+1 queries
35
+ includes = SuperAdmin::DashboardResolver.collection_includes_for(model_class)
36
+ includes.any? ? base_scope.includes(includes) : base_scope
37
+ end
38
+ end
39
+
40
+ def preserved_params
41
+ {}.tap do |hash|
42
+ hash[:search] = params[:search] if params[:search].present?
43
+ hash[:sort] = params[:sort] if params[:sort].present?
44
+ hash[:direction] = params[:direction] if params[:direction].present?
45
+ hash[:filters] = filter_params if filter_params.present?
46
+ end
47
+ end
48
+
49
+ def queue_export!(user, attributes)
50
+ SuperAdmin::CsvExportCreator.call(
51
+ user: user,
52
+ model_class: model_class,
53
+ resource: resource_param,
54
+ search: params[:search],
55
+ sort: params[:sort],
56
+ direction: params[:direction],
57
+ filters: filter_params,
58
+ attributes: attributes
59
+ )
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Resources
5
+ # Wraps information about the requested resource to avoid leaking controller logic.
6
+ class Context
7
+ attr_reader :resource_param
8
+
9
+ def initialize(resource_param)
10
+ @resource_param = resource_param.to_s
11
+ end
12
+
13
+ def valid?
14
+ SuperAdmin::ModelInspector.find_model(resource_param).present?
15
+ end
16
+
17
+ def resource_name
18
+ resource_param
19
+ end
20
+
21
+ def singular_name
22
+ resource_name.singularize
23
+ end
24
+
25
+ def plural_name
26
+ resource_name.pluralize
27
+ end
28
+
29
+ def model_class
30
+ @model_class ||= begin
31
+ klass = SuperAdmin::ModelInspector.find_model(resource_param)
32
+ raise NameError, "Unrecognized resource '#{resource_param}'" unless klass
33
+
34
+ klass
35
+ end
36
+ end
37
+
38
+ def dashboard
39
+ SuperAdmin::DashboardResolver.dashboard_for(model_class)
40
+ end
41
+
42
+ def displayable_attributes
43
+ SuperAdmin::DashboardResolver.collection_attributes_for(model_class)
44
+ end
45
+
46
+ def show_attributes
47
+ SuperAdmin::DashboardResolver.show_attributes_for(model_class)
48
+ end
49
+
50
+ def editable_attributes
51
+ SuperAdmin::DashboardResolver.form_attributes_for(model_class)
52
+ end
53
+
54
+ def human_model_name(count: 1)
55
+ model_class.model_name.human(count: count)
56
+ end
57
+
58
+ def param_key
59
+ model_class.model_name.param_key
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Resources
5
+ # Sanitizes filter parameters for a resource collection.
6
+ class FilterParams
7
+ def initialize(model_class, raw_filters)
8
+ @model_class = model_class
9
+ @raw_filters = raw_filters
10
+ end
11
+
12
+ def to_h
13
+ return {} if @raw_filters.blank?
14
+
15
+ parameters = ensure_parameters(@raw_filters)
16
+ permitted_keys = SuperAdmin::FilterBuilder.permitted_param_keys(@model_class)
17
+ parameters.permit(*permitted_keys).to_h
18
+ end
19
+
20
+ private
21
+
22
+ def ensure_parameters(filters)
23
+ return filters if filters.is_a?(ActionController::Parameters)
24
+
25
+ ActionController::Parameters.new(filters)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module SuperAdmin
6
+ module Resources
7
+ # Computes the strong parameters list for a given resource, including nested attributes.
8
+ class PermittedAttributes
9
+ attr_reader :model_class
10
+
11
+ def initialize(model_class)
12
+ @model_class = model_class
13
+ end
14
+
15
+ # Make the class callable, returning the list of permitted attribute names
16
+ def call
17
+ attribute_names
18
+ end
19
+
20
+ def permit(params)
21
+ params.require(param_key).permit(*attribute_names)
22
+ end
23
+
24
+ def attribute_names
25
+ @attribute_names ||= begin
26
+ dashboard_form_attrs = SuperAdmin::DashboardResolver.form_attributes_for(model_class)
27
+
28
+ direct_attributes = SuperAdmin::ResourceConfiguration
29
+ .editable_attributes(model_class)
30
+ .reject { |attr| attr.to_s.end_with?("_attributes") }
31
+ .map(&:to_sym)
32
+
33
+ allowed_direct = if dashboard_form_attrs.present?
34
+ dashboard_form_attrs
35
+ .reject { |attr| attr.to_s.end_with?("_attributes") }
36
+ .map(&:to_sym)
37
+ else
38
+ []
39
+ end
40
+
41
+ if allowed_direct.present?
42
+ direct_attributes &= allowed_direct
43
+ end
44
+
45
+ # Filter out sensitive attributes for security (defense-in-depth)
46
+ direct_attributes = SuperAdmin::SensitiveAttributes.filter(
47
+ direct_attributes,
48
+ model_class: model_class,
49
+ allowlist: allowed_direct
50
+ )
51
+
52
+ direct_attributes + nested_attribute_definitions(dashboard_form_attrs)
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def param_key
59
+ model_class.model_name.param_key
60
+ end
61
+
62
+ def nested_attribute_definitions(dashboard_form_attrs)
63
+ allowlist = if dashboard_form_attrs.present?
64
+ dashboard_form_attrs.select { |attr| attr.to_s.end_with?("_attributes") }.map do |attr|
65
+ attr.to_s.delete_suffix("_attributes").to_sym
66
+ end.to_set
67
+ end
68
+
69
+ allowlist = nil if allowlist&.empty?
70
+
71
+ Array(model_class.nested_attributes_options).filter_map do |association_name, options|
72
+ if allowlist && !allowlist.include?(association_name.to_sym)
73
+ next
74
+ end
75
+
76
+ reflection = model_class.reflect_on_association(association_name)
77
+ next unless reflection
78
+
79
+ nested_keys = SuperAdmin::ResourceConfiguration
80
+ .editable_attributes(reflection.klass)
81
+ .reject { |attr| attr.to_s.end_with?("_attributes") }
82
+ .map(&:to_sym)
83
+
84
+ # Filter out sensitive attributes from nested attributes too
85
+ nested_keys = SuperAdmin::SensitiveAttributes.filter(
86
+ nested_keys,
87
+ model_class: reflection.klass
88
+ )
89
+
90
+ nested_keys -= [ reflection.foreign_key.to_sym ] if reflection.respond_to?(:foreign_key)
91
+ nested_keys << :id unless nested_keys.include?(:id)
92
+
93
+ # Always permit `_destroy` so nested forms can request deletions, even
94
+ # when the association does not explicitly enable allow_destroy. Rails
95
+ # will ignore the flag if the association disallows it, but permitting
96
+ # the parameter keeps the API consistent across nested resources.
97
+ nested_keys << :_destroy unless nested_keys.include?(:_destroy)
98
+
99
+ { "#{association_name}_attributes".to_sym => nested_keys }
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module SuperAdmin
6
+ module Resources
7
+ # Normalizes permitted attribute values before assignment, applying
8
+ # lightweight casting for data types that HTML forms cannot express directly.
9
+ class ValueNormalizer
10
+ ARRAY_DELIMITER_REGEX = /[,\n]/.freeze
11
+
12
+ def initialize(model_class, params)
13
+ @model_class = model_class
14
+ @params = params
15
+ end
16
+
17
+ # Returns a normalized copy of the permitted parameters.
18
+ def normalize
19
+ return params unless params.is_a?(ActionController::Parameters) && params.permitted?
20
+
21
+ normalize_params_for(model_class, params)
22
+ params
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :model_class, :params
28
+
29
+ def normalize_params_for(current_model, current_params)
30
+ current_params.keys.each do |key|
31
+ string_key = key.to_s
32
+ value = current_params[key]
33
+
34
+ if nested_attribute?(string_key)
35
+ normalize_nested_attribute(current_model, string_key, value)
36
+ next
37
+ end
38
+
39
+ column = current_model.columns_hash[string_key]
40
+ next unless column
41
+
42
+ if array_column?(column)
43
+ current_params[key] = normalize_array_value(column, value)
44
+ end
45
+ end
46
+ end
47
+
48
+ def nested_attribute?(key)
49
+ key.end_with?("_attributes")
50
+ end
51
+
52
+ def normalize_nested_attribute(current_model, key, value)
53
+ association_name = key.delete_suffix("_attributes")
54
+ reflection = current_model.reflect_on_association(association_name)
55
+ return unless reflection
56
+
57
+ case value
58
+ when ActionController::Parameters
59
+ normalize_params_for(reflection.klass, value)
60
+ when Array
61
+ value.each do |entry|
62
+ next unless entry.is_a?(ActionController::Parameters)
63
+
64
+ normalize_params_for(reflection.klass, entry)
65
+ end
66
+ end
67
+ end
68
+
69
+ def array_column?(column)
70
+ column.respond_to?(:array) && column.array
71
+ end
72
+
73
+ def normalize_array_value(column, value)
74
+ array = case value
75
+ when String
76
+ parse_array_string(value)
77
+ when Array
78
+ value.compact
79
+ else
80
+ Array(value)
81
+ end
82
+
83
+ cast_array_elements(array, column)
84
+ end
85
+
86
+ def parse_array_string(value)
87
+ value.to_s.split(ARRAY_DELIMITER_REGEX).map(&:strip).reject(&:blank?)
88
+ end
89
+
90
+ def cast_array_elements(array, column)
91
+ array.filter_map do |element|
92
+ next if element.blank?
93
+
94
+ cast_element(element, column)
95
+ end
96
+ end
97
+
98
+ def cast_element(element, column)
99
+ return element unless element.is_a?(String)
100
+
101
+ stripped = element.strip
102
+ return nil if stripped.blank?
103
+
104
+ case column.type
105
+ when :integer, :bigint
106
+ Integer(stripped, exception: false) || stripped
107
+ when :float
108
+ Float(stripped, exception: false) || stripped
109
+ when :decimal
110
+ BigDecimal(stripped)
111
+ when :boolean
112
+ ActiveModel::Type::Boolean.new.cast(stripped) || stripped
113
+ else
114
+ stripped
115
+ end
116
+ rescue ArgumentError, TypeError
117
+ stripped
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module SensitiveAttributes
5
+ DEFAULT_SENSITIVE_PATTERNS = %w[
6
+ password
7
+ password_digest
8
+ password_confirmation
9
+ encrypted_password
10
+ reset_password_token
11
+ reset_password_sent_at
12
+ remember_token
13
+ remember_created_at
14
+ authentication_token
15
+ access_token
16
+ refresh_token
17
+ api_key
18
+ api_secret
19
+ token
20
+ secret
21
+ secret_key
22
+ secret_token
23
+ private_key
24
+ otp_secret
25
+ otp_secret_key
26
+ encrypted_otp_secret
27
+ encrypted_otp_secret_iv
28
+ encrypted_otp_secret_salt
29
+ confirmation_token
30
+ confirmed_at
31
+ confirmation_sent_at
32
+ unconfirmed_email
33
+ unlock_token
34
+ locked_at
35
+ failed_attempts
36
+ encrypted_
37
+ crypted_
38
+ cipher_
39
+ ].freeze
40
+
41
+ DEFAULT_ROLE_PATTERNS = %w[
42
+ admin
43
+ superadmin
44
+ super_admin
45
+ role
46
+ roles
47
+ permission
48
+ permissions
49
+ can_
50
+ is_admin
51
+ is_superadmin
52
+ ].freeze
53
+
54
+ DEFAULT_SYSTEM_PATTERNS = %w[
55
+ created_at
56
+ updated_at
57
+ deleted_at
58
+ discarded_at
59
+ lock_version
60
+ ].freeze
61
+
62
+ class << self
63
+ def default_patterns
64
+ @default_patterns ||= (
65
+ DEFAULT_SENSITIVE_PATTERNS +
66
+ DEFAULT_ROLE_PATTERNS +
67
+ DEFAULT_SYSTEM_PATTERNS
68
+ ).map { |pattern| pattern.to_s.downcase }.freeze
69
+ end
70
+
71
+ def configured_patterns
72
+ @configured_patterns ||= begin
73
+ custom = Array(SuperAdmin.configuration.additional_sensitive_attributes)
74
+ .map { |pattern| pattern.to_s.downcase }
75
+
76
+ (default_patterns + custom).uniq.freeze
77
+ end
78
+ end
79
+
80
+ def sensitive?(attribute_name)
81
+ attr_str = attribute_name.to_s.downcase
82
+
83
+ configured_patterns.any? do |pattern|
84
+ if pattern.end_with?("_")
85
+ attr_str.start_with?(pattern)
86
+ else
87
+ attr_str == pattern ||
88
+ attr_str.start_with?("#{pattern}_") ||
89
+ attr_str.end_with?("_#{pattern}") ||
90
+ attr_str.include?("_#{pattern}_")
91
+ end
92
+ end
93
+ end
94
+
95
+ def filter(attributes, model_class: nil, allowlist: [])
96
+ case attributes
97
+ when Hash
98
+ filter_hash(attributes)
99
+ when Array
100
+ filter_attribute_array(attributes, model_class: model_class, allowlist: allowlist)
101
+ else
102
+ filter_attribute_array(Array(attributes), model_class: model_class, allowlist: allowlist)
103
+ end
104
+ end
105
+
106
+ def reset!
107
+ @default_patterns = nil
108
+ @configured_patterns = nil
109
+ end
110
+
111
+ private
112
+
113
+ def filter_hash(payload)
114
+ payload.each_with_object({}) do |(key, value), result|
115
+ result_key = preserve_key_type(key)
116
+
117
+ result[result_key] = case value
118
+ when Hash
119
+ filter_hash(value)
120
+ when Array
121
+ value.map { |entry| entry.is_a?(Hash) ? filter_hash(entry) : filtered_value(result_key, entry) }
122
+ else
123
+ filtered_value(result_key, value)
124
+ end
125
+ end
126
+ end
127
+
128
+ def filter_attribute_array(attributes, model_class:, allowlist: [])
129
+ allowed = Array(allowlist).map { |attr| to_symbol(attr) }.compact
130
+
131
+ attributes.each_with_object([]) do |attr, result|
132
+ case attr
133
+ when Hash
134
+ filtered_hash = attr.each_with_object({}) do |(key, value), memo|
135
+ memo[to_symbol(key)] = filter_attribute_array(Array(value), model_class: model_class, allowlist: [])
136
+ end
137
+ result << filtered_hash
138
+ else
139
+ attr_sym = to_symbol(attr)
140
+ next if attr_sym.nil?
141
+
142
+ if allowed.include?(attr_sym) || !sensitive?(attr_sym)
143
+ result << attr_sym
144
+ elsif model_class
145
+ Rails.logger.debug(
146
+ "[SuperAdmin::SensitiveAttributes] Filtered sensitive attribute '#{attr_sym}' from #{model_class.name} permitted parameters"
147
+ )
148
+ end
149
+ end
150
+ end
151
+ end
152
+
153
+ def filtered_value(key, value)
154
+ sensitive?(key) ? "[FILTERED]" : value
155
+ end
156
+
157
+ def preserve_key_type(key)
158
+ key.is_a?(String) ? key : to_symbol(key)
159
+ end
160
+
161
+ def to_symbol(key)
162
+ key.to_sym if key.respond_to?(:to_sym)
163
+ end
164
+ end
165
+ end
166
+ end