active_element 0.0.3 → 0.0.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Gemfile.lock +3 -7
  4. data/active_element.gemspec +1 -1
  5. data/app/assets/javascripts/active_element/application.js +3 -1
  6. data/app/assets/javascripts/active_element/popover.js +6 -0
  7. data/app/assets/javascripts/active_element/setup.js +12 -0
  8. data/app/assets/javascripts/active_element/{search_field.js → text_search_field.js} +2 -2
  9. data/app/assets/javascripts/active_element/toast.js +8 -0
  10. data/app/assets/stylesheets/active_element/application.scss +65 -1
  11. data/app/controllers/active_element/application_controller.rb +12 -18
  12. data/app/views/active_element/components/form/_label.html.erb +2 -2
  13. data/app/views/active_element/components/form/_templates.html.erb +8 -8
  14. data/app/views/active_element/components/form.html.erb +3 -3
  15. data/app/views/active_element/components/table/_collection_row.html.erb +3 -3
  16. data/app/views/active_element/components/table/collection.html.erb +1 -1
  17. data/app/views/active_element/components/table/item.html.erb +3 -3
  18. data/app/views/active_element/navbar/_menu.html.erb +2 -2
  19. data/app/views/layouts/active_element.html.erb +21 -23
  20. data/config/routes.rb +10 -1
  21. data/lib/active_element/components/button.rb +6 -41
  22. data/lib/active_element/components/form.rb +12 -3
  23. data/lib/active_element/components/text_search/active_record_authorization.rb +13 -0
  24. data/lib/active_element/components/text_search/authorization.rb +117 -0
  25. data/lib/active_element/components/text_search/component.rb +118 -0
  26. data/lib/active_element/components/text_search/sql.rb +107 -0
  27. data/lib/active_element/components/text_search.rb +23 -0
  28. data/lib/active_element/components/util/decorator.rb +2 -2
  29. data/lib/active_element/components/util/record_path.rb +84 -0
  30. data/lib/active_element/components/util.rb +7 -2
  31. data/lib/active_element/components.rb +1 -0
  32. data/lib/active_element/controller_action.rb +9 -10
  33. data/lib/active_element/controller_interface.rb +78 -0
  34. data/lib/active_element/permissions_check.rb +33 -29
  35. data/lib/active_element/permissions_report.rb +57 -0
  36. data/lib/active_element/route.rb +1 -1
  37. data/lib/active_element/routes.rb +1 -1
  38. data/lib/active_element/version.rb +1 -1
  39. data/lib/active_element.rb +24 -10
  40. data/lib/tasks/active_element.rake +1 -16
  41. data/rspec-documentation/pages/Components/Tables.md +2 -2
  42. data/rspec-documentation/spec_helper.rb +1 -1
  43. metadata +19 -12
  44. data/app/controllers/active_element/text_searches_controller.rb +0 -189
  45. data/lib/active_element/active_record_text_search_authorization.rb +0 -12
  46. data/lib/active_element/colorized_string.rb +0 -33
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ module TextSearch
6
+ # Manages authorization for text search, ensures model is configured for text search and
7
+ # that user has correct permissions.
8
+ class Authorization
9
+ include Paintbrush
10
+
11
+ class << self
12
+ def permission_for(model:, field:)
13
+ "can_text_search_#{application_name}_#{model.name.underscore.pluralize}_with_#{field}"
14
+ end
15
+
16
+ private
17
+
18
+ def application_name
19
+ RailsComponent.new(::Rails).application_name
20
+ end
21
+ end
22
+
23
+ def initialize(model:, params:, user:, search_columns:, result_columns:)
24
+ @model = model
25
+ @params = params
26
+ @user = user
27
+ @search_columns = search_columns
28
+ @result_columns = result_columns
29
+ end
30
+
31
+ def authorized?
32
+ return true if authorized_model? && authorized_user?
33
+ return false.tap { ActiveElement.warning(message) } unless Rails.env.development?
34
+
35
+ ActiveElement.warning(development_message)
36
+ true
37
+ end
38
+
39
+ def message(colorize: true)
40
+ [
41
+ missing_authorization_message(colorize: colorize),
42
+ missing_permissions_message(colorize: colorize)
43
+ ].compact.join(paintbrush(colorize: colorize) { red '. ' })
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :model, :params, :user, :search_columns, :result_columns
49
+
50
+ def development_message
51
+ paintbrush { green "Bypassed text search authorization in development environment: #{yellow message}" }
52
+ end
53
+
54
+ def missing_permissions
55
+ (search_columns + result_columns).reject { |column| user_permitted?(column) }
56
+ .map { |column| permission_for(column.name) }
57
+ .uniq
58
+ .sort
59
+ end
60
+
61
+ def missing_permissions_message(colorize:)
62
+ return nil if missing_permissions.empty?
63
+
64
+ paintbrush(colorize: colorize) { red "Missing permissions: #{yellow missing_permissions.join(', ')}" }
65
+ end
66
+
67
+ def missing_authorization_message(colorize:)
68
+ return nil if authorized_model?
69
+
70
+ paintbrush(colorize: colorize) do
71
+ red "Missing model authorization for #{cyan model_name} with: " \
72
+ "#{green search_fields.join(', ')}, providing: " \
73
+ "#{green result_fields.join(', ')}"
74
+ end
75
+ end
76
+
77
+ def search_fields
78
+ params[:attributes]
79
+ end
80
+
81
+ def model_name
82
+ model&.name || params[:model]
83
+ end
84
+
85
+ def result_fields
86
+ (params[:attributes] + [params[:value]]).uniq
87
+ end
88
+
89
+ def user_permitted?(column)
90
+ user&.permissions&.include?(permission_for(column.name))
91
+ end
92
+
93
+ def authorized_user?
94
+ missing_permissions.empty?
95
+ end
96
+
97
+ def permission_for(field)
98
+ self.class.permission_for(model: model, field: field)
99
+ end
100
+
101
+ def authorized_model?
102
+ TextSearch.authorized_text_searches.any? do |authorized_model, search_fields, result_fields|
103
+ next false unless authorized_model == model
104
+ next false unless authorized_fields?(search_columns, Array(search_fields))
105
+ next false unless authorized_fields?(result_columns, Array(result_fields))
106
+
107
+ true
108
+ end
109
+ end
110
+
111
+ def authorized_fields?(columns, fields)
112
+ columns.all? { |column| fields.map(&:to_sym).include?(column.name.to_sym) }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ module TextSearch
6
+ # Used by auto-complete search field for executing a text search on the provided model and
7
+ # attributes.
8
+ #
9
+ # The user must have a permission configured for each field used in the search:
10
+ # `can_text_search_<application_name>_<models>_with_<field>`
11
+ #
12
+ # A model must call `authorize_active_element_text_search` to enable text search. e.g.:
13
+ #
14
+ # class MyModel < ApplicationRecord
15
+ # authorize_active_element_text_search with: [:id, :email],
16
+ # providing: [:id, :first_name, :last_name, :email]
17
+ # end
18
+ #
19
+ # This allows searching using the `name` `email` columns and permits returning each matching
20
+ # record's `id`, `first_name`, `last_name`, and `email` values.
21
+ #
22
+ # This complexity exists to ensure that authenticated users can only retrieve specific
23
+ # database values that are explicitly configured, as well as ensuring that users cannot
24
+ # search arbitrary columns. Requiring this logic in the model is intended to reduce
25
+ # likelihood of DoS vulnerabilities if users are able to search unindexed columns.
26
+ #
27
+ # Note that the `/_active_element_text_search` endpoint added to each controller
28
+ # necessarily receives arbitrary arguments. Configuring a form to only fetch certain values
29
+ # does not restrict potential parameters, so a strict permissions and model configuration
30
+ # system is required to govern access to database queries.
31
+ #
32
+ class Component
33
+ DEFAULT_LIMIT = 50
34
+
35
+ def initialize(controller:)
36
+ @controller = controller
37
+ @params = controller.params
38
+ end
39
+
40
+ def response
41
+ return unverified_parameters unless verified_parameters?
42
+ return unverified_model unless verified_model?
43
+ return unauthorized unless authorization.authorized?
44
+
45
+ { json: { results: results, request_id: controller.params[:request_id] }, status: :created }
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :controller, :params
51
+
52
+ def verified_parameters?
53
+ %i[model attributes value query].all? { |parameter| params[parameter].present? }
54
+ end
55
+
56
+ def unverified_parameters
57
+ { json: { message: 'Must provide parameters: [model, attributes, value, query] for text search.' },
58
+ status: :unprocessable_entity }
59
+ end
60
+
61
+ def verified_model?
62
+ [model, sql.search_columns, sql.value_column].all?(&:present?)
63
+ end
64
+
65
+ def unverified_model
66
+ { json: { message: authorization.message(colorize: false) }, status: :unprocessable_entity }
67
+ end
68
+
69
+ def unauthorized
70
+ { json: { message: authorization.message(colorize: false) }, status: :forbidden }
71
+ end
72
+
73
+ def sql
74
+ @sql ||= Sql.new(
75
+ model: model,
76
+ query: params[:query],
77
+ value: params[:value],
78
+ attributes: params[:attributes]
79
+ )
80
+ end
81
+
82
+ def results
83
+ @results ||= model.where(*sql.whereclause)
84
+ .limit(limit)
85
+ .pluck(sql.value_column.name, *sql.search_columns.map(&:name))
86
+ .map { |value, *attributes| result(value, attributes) }
87
+ .uniq
88
+ end
89
+
90
+ def result(value, attributes)
91
+ { value: value, attributes: attributes.reject { |attribute| attribute == value } }
92
+ end
93
+
94
+ def model
95
+ @model ||= params[:model].camelize(:upper).safe_constantize
96
+ end
97
+
98
+ def authorization
99
+ @authorization ||= TextSearch::Authorization.new(
100
+ model: model,
101
+ params: params,
102
+ user: controller.active_element.current_user,
103
+ search_columns: sql.search_columns.compact,
104
+ result_columns: (sql.search_columns + [sql.value_column]).compact
105
+ )
106
+ end
107
+
108
+ def query
109
+ params[:query]
110
+ end
111
+
112
+ def limit
113
+ DEFAULT_LIMIT
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ module TextSearch
6
+ # Encapsulates generation of database adapter-specific sanitized SQL queries for performing
7
+ # full text searches. Identifies columns and adapters where `LIKE` or `ILIKE` can be
8
+ # applied and generates a whereclause according to the provided parameters.
9
+ #
10
+ # Receives an ActiveRecord model class, a query (a string used to match results), attribute
11
+ # columns to match against, and a value column to return in the results.
12
+ #
13
+ # Inspects ActiveRecord metadata to match field names to column objects.
14
+ class Sql
15
+ def initialize(model:, query:, value:, attributes:)
16
+ @model = model
17
+ @query = query
18
+ @value = value
19
+ @attributes = attributes
20
+ end
21
+
22
+ def value_column
23
+ return nil if value.blank?
24
+
25
+ @value_column ||= model&.columns&.find { |column| column.name == value }
26
+ end
27
+
28
+ def search_columns
29
+ return [] if attributes.blank?
30
+
31
+ @search_columns ||= attributes.map { |attribute| column_for(attribute) }.compact
32
+ end
33
+
34
+ def whereclause
35
+ clauses = search_columns.map { |column| "#{column.name} #{operator(column)} ?" }
36
+ [clauses.join(' OR '), search_columns.map { |column| search_param(column) }].flatten
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :model, :query, :value, :attributes
42
+
43
+ def column_for(attribute)
44
+ matched_column = model&.columns&.find { |column| column.name == attribute }
45
+ return nil if matched_column.blank?
46
+
47
+ compatible_column?(matched_column) ? matched_column : nil
48
+ end
49
+
50
+ def operator(column)
51
+ case column.type
52
+ when :string
53
+ %w[Mysql2 SQLite].include?(model.connection.adapter_name) ? 'LIKE' : 'ILIKE'
54
+ else
55
+ '='
56
+ end
57
+ end
58
+
59
+ def compatible_column?(column) # rubocop:disable Metrics/MethodLength
60
+ case column.type
61
+ when :string
62
+ true
63
+ when :integer
64
+ integer?
65
+ when :float
66
+ float?
67
+ when :decimal
68
+ decimal?
69
+ else
70
+ Rails.logger.info("Skipping query `#{query}` for incompatible column: #{column.name}")
71
+ false
72
+ end
73
+ end
74
+
75
+ def integer?
76
+ Integer(query)
77
+ true
78
+ rescue ArgumentError
79
+ false
80
+ end
81
+
82
+ def float?
83
+ Float(query)
84
+ true
85
+ rescue ArgumentError
86
+ false
87
+ end
88
+
89
+ def decimal?
90
+ BigDecimal(query)
91
+ true
92
+ rescue ArgumentError
93
+ false
94
+ end
95
+
96
+ def search_param(column)
97
+ case column.type
98
+ when :string
99
+ "#{query}%"
100
+ else
101
+ query
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'text_search/active_record_authorization'
4
+ require_relative 'text_search/sql'
5
+ require_relative 'text_search/component'
6
+ require_relative 'text_search/authorization'
7
+
8
+ module ActiveElement
9
+ module Components
10
+ # Provides back end for live text search components.
11
+ module TextSearch
12
+ @authorized_text_searches = []
13
+
14
+ class << self
15
+ attr_reader :authorized_text_searches
16
+
17
+ def register_authorized_text_search(model:, with:, providing:)
18
+ authorized_text_searches << [model, with, providing]
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -27,7 +27,7 @@ module ActiveElement
27
27
  component.controller.render_to_string(partial: decorator_path(sti: sti), locals: locals)
28
28
  rescue ActionView::MissingTemplate
29
29
  if sti
30
- component.controller.missing_template_store[decorator_path] = true
30
+ component.controller.active_element.missing_template_store[decorator_path] = true
31
31
  default_decorated_value
32
32
  else
33
33
  render(sti: true)
@@ -51,7 +51,7 @@ module ActiveElement
51
51
  end
52
52
 
53
53
  def missing_template?
54
- component.controller.missing_template_store[decorator_path].present?
54
+ component.controller.active_element.missing_template_store[decorator_path].present?
55
55
  end
56
56
 
57
57
  def decorator_path(sti: false)
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ module Components
5
+ module Util
6
+ # Generates Rails paths from records, used to generate form actions and links for Rails RESTful routes.
7
+ class RecordPath
8
+ def initialize(record:, controller:, type: nil)
9
+ @record = record
10
+ @controller = controller
11
+ @type = type&.to_sym || controller.action_name&.to_sym
12
+ end
13
+
14
+ def path
15
+ record_path || sti_record_path
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :record, :controller, :type
21
+
22
+ def namespace_prefix
23
+ # XXX: We guess the namespace from the current controller's module name. This will work
24
+ # most of the time but will break if the current record's controller exists in a different
25
+ # namespace to the current controller, e.g. `BackEndAdmin::UsersController` and
26
+ # `FrontEndAdmin::ThemesController` - if `FrontEndAdmin::ThemesController` renders a
27
+ # collection of `User` objects, the "show" path will be wrong:
28
+ # `front_end_admin_user_path`. Maybe descend through the full controller class tree to
29
+ # find a best match ?
30
+ namespace = controller.class.name.deconstantize.underscore
31
+ return nil if namespace.blank?
32
+
33
+ "#{namespace}_"
34
+ end
35
+
36
+ def record_path
37
+ return nil if record.nil?
38
+
39
+ controller.helpers.public_send(default_record_path, record)
40
+ rescue NoMethodError
41
+ controller.helpers.public_send(sti_record_path, record)
42
+ end
43
+
44
+ def default_record_path
45
+ "#{record_path_prefix}#{namespace_prefix}#{record_name}_path"
46
+ end
47
+
48
+ def sti_record_path
49
+ "#{record_path_prefix}#{namespace_prefix}#{sti_record_name}_path"
50
+ end
51
+
52
+ def record_name
53
+ return Util.record_name(record) unless pluralize?
54
+
55
+ Util.record_name(record)&.pluralize
56
+ end
57
+
58
+ def sti_record_name
59
+ return Util.sti_record_name(record) unless pluralize?
60
+
61
+ Util.sti_record_name(record)
62
+ end
63
+
64
+ def record_path_prefix
65
+ case type
66
+ when :edit, :update
67
+ 'edit_'
68
+ when :new, :create
69
+ 'new_'
70
+ end
71
+ end
72
+
73
+ def pluralize?
74
+ case type
75
+ when :index, :create
76
+ true
77
+ else
78
+ false
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'util/i18n'
4
+ require_relative 'util/record_path'
4
5
  require_relative 'util/record_mapping'
5
6
  require_relative 'util/field_mapping'
6
7
  require_relative 'util/form_field_mapping'
@@ -18,15 +19,19 @@ module ActiveElement
18
19
  end
19
20
 
20
21
  def self.record_name(record)
21
- record&.try(:model_name)&.try(&:singular) || record.class.name.demodulize.underscore
22
+ record&.try(:model_name)&.try(&:singular) || default_record_name(record)
22
23
  end
23
24
 
24
25
  def self.sti_record_name(record)
25
- return nil unless record.class.respond_to?(:inheritance_column)
26
+ return default_record_name(record) unless record.class.respond_to?(:inheritance_column)
26
27
 
27
28
  record&.class&.superclass&.model_name&.singular if record&.try(record.class.inheritance_column).present?
28
29
  end
29
30
 
31
+ def self.default_record_name(record)
32
+ record.class.name.demodulize.underscore
33
+ end
34
+
30
35
  def self.json_pretty_print(json)
31
36
  theme = Rouge::Themes::Base16.mode(:light)
32
37
  formatter = Rouge::Formatters::HTMLLinewise.new(Rouge::Formatters::HTMLInline.new(theme))
@@ -12,6 +12,7 @@ require_relative 'components/button'
12
12
  require_relative 'components/tabs'
13
13
  require_relative 'components/tab'
14
14
  require_relative 'components/json'
15
+ require_relative 'components/text_search'
15
16
 
16
17
  module ActiveElement
17
18
  # Standardised HTML components to be used in admin front ends.
@@ -3,15 +3,17 @@
3
3
  module ActiveElement
4
4
  # Processes all controller actions, verifies permissions, issues redirects, etc.
5
5
  class ControllerAction
6
+ include Paintbrush
7
+
6
8
  def initialize(controller)
7
9
  @controller = controller
8
10
  end
9
11
 
10
12
  def process_action
11
- Rails.logger.info("[#{log_tag}] #{colorized_permissions_message}")
13
+ Rails.logger.info("#{ActiveElement.log_tag} #{colorized_permissions_message}")
12
14
  return if verified_permissions?
13
15
 
14
- warn "[#{log_tag}] #{colorized_permissions_message}" if Rails.env.test?
16
+ warn "#{log_tag} #{colorized_permissions_message}" if Rails.env.test?
15
17
  return controller.redirect_to redirect_path if redirect_to_default_landing_page?
16
18
 
17
19
  render_forbidden
@@ -22,6 +24,7 @@ module ActiveElement
22
24
  attr_reader :controller
23
25
 
24
26
  def verified_permissions?
27
+ return true unless controller.active_element.authorize?
25
28
  return @verified_permissions if defined?(@verified_permissions)
26
29
 
27
30
  (@verified_permissions = permissions_check.permitted?)
@@ -50,8 +53,8 @@ module ActiveElement
50
53
 
51
54
  def permissions_check
52
55
  @permissions_check ||= PermissionsCheck.new(
53
- required: controller.class.active_element_permissions,
54
- actual: controller.current_user&.permissions,
56
+ required: controller.class.active_element.permissions,
57
+ actual: controller.active_element.current_user&.permissions,
55
58
  controller_path: controller.controller_path,
56
59
  action_name: controller.action_name,
57
60
  rails_component: rails_component
@@ -60,7 +63,7 @@ module ActiveElement
60
63
 
61
64
  def routes
62
65
  @routes ||= Routes.new(
63
- permissions: controller.current_user.permissions,
66
+ permissions: controller.active_element.current_user&.permissions,
64
67
  rails_component: rails_component
65
68
  )
66
69
  end
@@ -71,7 +74,7 @@ module ActiveElement
71
74
  else
72
75
  (rails_component.environment == 'test' ? :yellow : :red)
73
76
  end
74
- ColorizedString.new(permissions_check.message, color: color).value
77
+ paintbrush { public_send(color, permissions_check.message) }
75
78
  end
76
79
 
77
80
  def redirect_to_default_landing_page?
@@ -83,9 +86,5 @@ module ActiveElement
83
86
  def rails_component
84
87
  @rails_component ||= RailsComponent.new(::Rails)
85
88
  end
86
-
87
- def log_tag
88
- ColorizedString.new('ActiveElement', color: :cyan).value
89
- end
90
89
  end
91
90
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveElement
4
+ # Provides the `active_element` object available on all controller instance/class methods.
5
+ # Encapsulates core functionality such as `authenticate_with`, `permit_action`, and `component`
6
+ # without polluting application controller namespace.
7
+ class ControllerInterface
8
+ attr_reader :missing_template_store, :current_user
9
+
10
+ @state = {}
11
+
12
+ class << self
13
+ attr_reader :state
14
+ end
15
+
16
+ def initialize(controller_class, controller_instance = nil)
17
+ @controller_class = controller_class
18
+ @controller_instance = controller_instance
19
+ initialize_state
20
+ @missing_template_store = {}
21
+ @authorize = false
22
+ end
23
+
24
+ def authorize?
25
+ @authorize
26
+ end
27
+
28
+ def application_name
29
+ RailsComponent.new(::Rails).application_name
30
+ end
31
+
32
+ def authenticate_with(&block)
33
+ state[:authenticator] = block
34
+ end
35
+
36
+ def authorize_with(&block)
37
+ @authorize = true
38
+ @current_user = block.call
39
+ end
40
+
41
+ def authenticate
42
+ authenticator&.call
43
+ end
44
+
45
+ def permit_action(action, with: nil, always: false)
46
+ raise ArgumentError, "Must specify `with: '<permission>'` or `always: true`" unless with.present? || always
47
+ raise ArgumentError, 'Cannot specify both `with` and `always: true`' if with.present? && always
48
+
49
+ state[:permissions] << { with: with, always: always, action: action }
50
+ end
51
+
52
+ def authenticator
53
+ state[:authenticator]
54
+ end
55
+
56
+ def permissions
57
+ state.fetch(:permissions)
58
+ end
59
+
60
+ def component
61
+ return (@component ||= ActiveElement::Component.new(controller_instance)) unless controller_instance.nil?
62
+
63
+ raise ArgumentError, 'Attempted to use ActiveElement component from a controller class method.'
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :controller_class, :controller_instance
69
+
70
+ def initialize_state
71
+ self.class.state[controller_class] ||= { permissions: [], authenticator: nil }
72
+ end
73
+
74
+ def state
75
+ self.class.state[controller_class]
76
+ end
77
+ end
78
+ end