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.
- checksums.yaml +7 -0
- data/README.md +216 -0
- data/Rakefile +30 -0
- data/app/assets/stylesheets/super_admin/application.css +15 -0
- data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
- data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
- data/app/controllers/super_admin/application_controller.rb +89 -0
- data/app/controllers/super_admin/associations_controller.rb +136 -0
- data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
- data/app/controllers/super_admin/base_controller.rb +133 -0
- data/app/controllers/super_admin/dashboard_controller.rb +29 -0
- data/app/controllers/super_admin/exports_controller.rb +109 -0
- data/app/controllers/super_admin/resources_controller.rb +201 -0
- data/app/dashboards/super_admin/base_dashboard.rb +200 -0
- data/app/errors/super_admin/configuration_error.rb +6 -0
- data/app/helpers/super_admin/application_helper.rb +84 -0
- data/app/helpers/super_admin/exports_helper.rb +16 -0
- data/app/helpers/super_admin/resources_helper.rb +204 -0
- data/app/helpers/super_admin/route_helper.rb +7 -0
- data/app/javascript/super_admin/application.js +263 -0
- data/app/jobs/super_admin/application_job.rb +4 -0
- data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
- data/app/mailers/super_admin/application_mailer.rb +6 -0
- data/app/models/super_admin/application_record.rb +5 -0
- data/app/models/super_admin/audit_log.rb +35 -0
- data/app/models/super_admin/csv_export.rb +67 -0
- data/app/services/super_admin/auditing.rb +74 -0
- data/app/services/super_admin/authorization.rb +113 -0
- data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
- data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
- data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
- data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
- data/app/services/super_admin/csv_export_creator.rb +45 -0
- data/app/services/super_admin/dashboard_registry.rb +90 -0
- data/app/services/super_admin/dashboard_resolver.rb +100 -0
- data/app/services/super_admin/filter_builder.rb +185 -0
- data/app/services/super_admin/form_builder.rb +59 -0
- data/app/services/super_admin/form_fields/array_field.rb +35 -0
- data/app/services/super_admin/form_fields/association_field.rb +146 -0
- data/app/services/super_admin/form_fields/base_field.rb +53 -0
- data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
- data/app/services/super_admin/form_fields/date_field.rb +15 -0
- data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
- data/app/services/super_admin/form_fields/enum_field.rb +27 -0
- data/app/services/super_admin/form_fields/factory.rb +102 -0
- data/app/services/super_admin/form_fields/nested_field.rb +120 -0
- data/app/services/super_admin/form_fields/number_field.rb +29 -0
- data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
- data/app/services/super_admin/model_inspector.rb +182 -0
- data/app/services/super_admin/queries/base_query.rb +45 -0
- data/app/services/super_admin/queries/filter_query.rb +188 -0
- data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
- data/app/services/super_admin/queries/search_query.rb +146 -0
- data/app/services/super_admin/queries/sort_query.rb +41 -0
- data/app/services/super_admin/resource_configuration.rb +63 -0
- data/app/services/super_admin/resource_exporter.rb +78 -0
- data/app/services/super_admin/resource_query.rb +40 -0
- data/app/services/super_admin/resources/association_inspector.rb +112 -0
- data/app/services/super_admin/resources/collection_presenter.rb +63 -0
- data/app/services/super_admin/resources/context.rb +63 -0
- data/app/services/super_admin/resources/filter_params.rb +29 -0
- data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
- data/app/services/super_admin/resources/value_normalizer.rb +121 -0
- data/app/services/super_admin/sensitive_attributes.rb +166 -0
- data/app/views/layouts/super_admin.html.erb +74 -0
- data/app/views/super_admin/audit_logs/index.html.erb +143 -0
- data/app/views/super_admin/dashboard/index.html.erb +79 -0
- data/app/views/super_admin/exports/index.html.erb +84 -0
- data/app/views/super_admin/exports/show.html.erb +57 -0
- data/app/views/super_admin/resources/_form.html.erb +42 -0
- data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
- data/app/views/super_admin/resources/edit.html.erb +37 -0
- data/app/views/super_admin/resources/index.html.erb +189 -0
- data/app/views/super_admin/resources/new.html.erb +31 -0
- data/app/views/super_admin/resources/show.html.erb +106 -0
- data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
- data/app/views/super_admin/shared/_flash.html.erb +55 -0
- data/app/views/super_admin/shared/_form_field.html.erb +35 -0
- data/app/views/super_admin/shared/_navigation.html.erb +92 -0
- data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
- data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
- data/config/importmap.rb +4 -0
- data/config/initializers/rack_attack.rb +134 -0
- data/config/initializers/super_admin.rb +117 -0
- data/config/locales/super_admin.en.yml +197 -0
- data/config/locales/super_admin.fr.yml +197 -0
- data/config/routes.rb +22 -0
- data/lib/generators/super_admin/dashboard_generator.rb +50 -0
- data/lib/generators/super_admin/install_generator.rb +58 -0
- data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
- data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
- data/lib/generators/super_admin/templates/super_admin.rb +58 -0
- data/lib/super_admin/dashboard_creator.rb +256 -0
- data/lib/super_admin/engine.rb +53 -0
- data/lib/super_admin/install_task.rb +96 -0
- data/lib/super_admin/version.rb +3 -0
- data/lib/super_admin.rb +7 -0
- data/lib/tasks/super_admin_tasks.rake +38 -0
- 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
|