proxes 0.9.8 → 0.9.9

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/ditty/components/proxes.rb +89 -0
  3. data/lib/proxes/controllers/permissions.rb +41 -0
  4. data/lib/proxes/controllers/search.rb +55 -0
  5. data/lib/proxes/controllers/status.rb +115 -0
  6. data/lib/proxes/forwarder.rb +49 -0
  7. data/lib/proxes/helpers/indices.rb +33 -0
  8. data/lib/proxes/loggers/elasticsearch.rb +10 -0
  9. data/lib/proxes/middleware/error_handling.rb +64 -0
  10. data/lib/proxes/middleware/metrics.rb +25 -0
  11. data/lib/proxes/middleware/security.rb +59 -0
  12. data/lib/proxes/models/permission.rb +55 -0
  13. data/lib/proxes/policies/permission_policy.rb +37 -0
  14. data/lib/proxes/policies/request/bulk_policy.rb +24 -0
  15. data/lib/proxes/policies/request/cat_policy.rb +12 -0
  16. data/lib/proxes/policies/request/create_policy.rb +15 -0
  17. data/lib/proxes/policies/request/index_policy.rb +19 -0
  18. data/lib/proxes/policies/request/root_policy.rb +13 -0
  19. data/lib/proxes/policies/request/search_policy.rb +14 -0
  20. data/lib/proxes/policies/request/snapshot_policy.rb +15 -0
  21. data/lib/proxes/policies/request/stats_policy.rb +12 -0
  22. data/lib/proxes/policies/request_policy.rb +62 -0
  23. data/lib/proxes/policies/search_policy.rb +29 -0
  24. data/lib/proxes/policies/status_policy.rb +21 -0
  25. data/lib/proxes/request.rb +84 -0
  26. data/lib/proxes/request/bulk.rb +40 -0
  27. data/lib/proxes/request/cat.rb +32 -0
  28. data/lib/proxes/request/create.rb +33 -0
  29. data/lib/proxes/request/index.rb +33 -0
  30. data/lib/proxes/request/root.rb +11 -0
  31. data/lib/proxes/request/search.rb +37 -0
  32. data/lib/proxes/request/snapshot.rb +17 -0
  33. data/lib/proxes/request/stats.rb +35 -0
  34. data/lib/proxes/services/es.rb +34 -0
  35. data/lib/proxes/services/listener.rb +29 -0
  36. data/lib/proxes/services/search.rb +45 -0
  37. data/lib/proxes/version.rb +5 -0
  38. data/migrate/20170209_permissions.rb +13 -0
  39. data/migrate/20170416_user_specific_permissions.rb +9 -0
  40. data/public/browserconfig.xml +9 -0
  41. data/public/manifest.json +25 -0
  42. data/views/index.haml +1 -0
  43. data/views/layout.haml +60 -0
  44. metadata +44 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19cf2985a0f5c1cc1e76a9e0acd7319ed6e600a8f43b0fe34ddb99ddad7a47f7
4
- data.tar.gz: ae63df70c4d596af1fd8ec87656ffbc05e35b433c0719ac1ae7497978f30bc17
3
+ metadata.gz: 9f06c68171f233ccce5d7cb06ab76f6503254ae8159c8906a53170af8e6e4b78
4
+ data.tar.gz: c4022844adcbf0d5740fe3b78f841a2be8c0db6b6d182a9d383f79ceb6d28299
5
5
  SHA512:
6
- metadata.gz: 57f5d54d14b06f89f2067a0f6ca18e0dc08307a18d156d7748569a1b9ef1b1feef54e4650218957555b7c028491899fdc1be2874d8317b2850c39824a3313634
7
- data.tar.gz: 7089ca97c7dd821183d82248c6820bb59d5c76b9d61f547855a1930548bb6ba13407690b74f799f13311ea7d53c1088b92b26832f2b7b33ddcf92feb78514912
6
+ metadata.gz: 9b7d4ad313c188b7dd2e9db490870828bf6b6c2590c15349ccc58d167bc29c6f1e455e3706a6202cb78d072a1b105308c0c9004cb239d06cefb4fe0ae951e7cf
7
+ data.tar.gz: 3ed39b26565420d629984d4e932bce2b239369554b84d0daf2744cca469a65ec634ee17f514790b11d8a5a4d7833d23b15e55519051fdec046371d9b0e49524d
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ditty'
4
+
5
+ module Ditty
6
+ class ProxES
7
+ def self.load
8
+ controllers = File.expand_path('../../proxes/controllers', __dir__)
9
+ Dir.glob("#{controllers}/*.rb").each { |f| require f }
10
+ require 'proxes/models/permission'
11
+ require 'proxes/services/listener'
12
+ end
13
+
14
+ def self.migrations
15
+ File.expand_path('../../../migrate', __dir__)
16
+ end
17
+
18
+ def self.view_folder
19
+ File.expand_path('../../../views', __dir__)
20
+ end
21
+
22
+ def self.public_folder
23
+ File.expand_path('../../../public', __dir__)
24
+ end
25
+
26
+ def self.routes
27
+ load
28
+ {
29
+ '/search' => ::ProxES::Search,
30
+ '/status' => ::ProxES::Status,
31
+ '/permissions' => ::ProxES::Permissions
32
+ }
33
+ end
34
+
35
+ def self.navigation
36
+ load
37
+ [
38
+ { order: 0, link: '/status/check', text: 'Status Check', target: ::ProxES::Status, icon: 'dashboard' },
39
+ { order: 1, link: '/search', text: 'Search', target: ::ProxES::Status, icon: 'search' },
40
+ { order: 15, link: '/permissions', text: 'Permissions', target: ::ProxES::Permission, icon: 'check-square' }
41
+ ]
42
+ end
43
+
44
+ def self.seeder
45
+ proc do
46
+ require 'ditty/models/user'
47
+ require 'ditty/models/role'
48
+ require 'proxes/models/permission'
49
+
50
+ sa = ::Ditty::Role.find_or_create(name: 'super_admin')
51
+ %w[GET POST PUT DELETE HEAD OPTIONS INDEX].each do |verb|
52
+ ::ProxES::Permission.find_or_create(role: sa, verb: verb, pattern: '.*')
53
+ end
54
+
55
+ # Admin Role
56
+ ::Ditty::Role.find_or_create(name: 'admin')
57
+
58
+ # User Role
59
+ user_role = ::Ditty::Role.find_or_create(name: 'user')
60
+ ::ProxES::Permission.find_or_create(role: user_role, verb: 'GET', pattern: '/_cluster/stats')
61
+ ::ProxES::Permission.find_or_create(role: user_role, verb: 'GET', pattern: '/_nodes')
62
+ ::ProxES::Permission.find_or_create(role: user_role, verb: 'GET', pattern: '/_nodes/stats')
63
+ ::ProxES::Permission.find_or_create(role: user_role, verb: 'GET', pattern: '/_stats')
64
+ ::ProxES::Permission.find_or_create(role: user_role, verb: 'INDEX', pattern: 'user-{user.id}')
65
+
66
+ # Kibana Specific
67
+ anon = ::Ditty::User.find_or_create(email: 'anonymous@proxes.io')
68
+ anon.remove_role user_role
69
+ anon_role = ::Ditty::Role.find_or_create(name: 'anonymous')
70
+ anon.add_role anon_role unless anon.role?('anonymous')
71
+ ::ProxES::Permission.find_or_create(role: anon_role, verb: 'GET', pattern: '/.kibana/config/*')
72
+ ::ProxES::Permission.find_or_create(role: anon_role, verb: 'INDEX', pattern: '.kibana')
73
+
74
+ kibana = ::Ditty::Role.find_or_create(name: 'kibana')
75
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'INDEX', pattern: '.kibana')
76
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'HEAD', pattern: '/')
77
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'GET', pattern: '/_nodes*')
78
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'GET', pattern: '/_cluster/health*')
79
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'GET', pattern: '/_cluster/settings*')
80
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'POST', pattern: '/_mget')
81
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'POST', pattern: '/_search')
82
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'POST', pattern: '/_msearch')
83
+ ::ProxES::Permission.find_or_create(role: kibana, verb: 'POST', pattern: '/_refresh')
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ Ditty::Components.register_component(:proxes, Ditty::ProxES)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ditty/controllers/component'
4
+ require 'proxes/models/permission'
5
+ require 'ditty/policies/user_policy'
6
+ require 'ditty/policies/role_policy'
7
+ require 'proxes/policies/permission_policy'
8
+
9
+ module ProxES
10
+ class Permissions < Ditty::Component
11
+ set model_class: Permission
12
+
13
+ FILTERS = [
14
+ { name: :user, field: 'user.email' },
15
+ { name: :role, field: 'role.name' },
16
+ { name: :verb }
17
+ ].freeze
18
+
19
+ SEARCHABLE = %i[pattern].freeze
20
+
21
+ helpers do
22
+ def user_options
23
+ policy_scope(::Ditty::User).as_hash(:email, :email)
24
+ end
25
+
26
+ def role_options
27
+ policy_scope(::Ditty::Role).as_hash(:name, :name)
28
+ end
29
+
30
+ def verb_options
31
+ ProxES::Permission.verbs
32
+ end
33
+ end
34
+
35
+ def find_template(views, name, engine, &block)
36
+ super(views, name, engine, &block) # Root
37
+ super(::Ditty::ProxES.view_folder, name, engine, &block) # This Component
38
+ super(::Ditty::App.view_folder, name, engine, &block) # Ditty
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/core_ext/object/blank'
4
+ require 'ditty/controllers/application'
5
+ require 'proxes/services/search'
6
+ require 'proxes/policies/search_policy'
7
+
8
+ module ProxES
9
+ class Search < Ditty::Application
10
+ set base_path: "#{settings.map_path}/search"
11
+
12
+ get '/' do
13
+ authorize self, :search
14
+
15
+ param :page, Integer, min: 0, default: 1
16
+ param :count, Integer, min: 0, default: 25
17
+ from = ((params[:page] - 1) * params[:count])
18
+ params[:q] = '*' if params[:q].blank?
19
+ result = ProxES::Services::Search.search(params[:q], index: params[:indices], from: from, size: params[:count])
20
+ haml :"#{view_location}/index",
21
+ locals: {
22
+ title: 'Search',
23
+ indices: ProxES::Services::Search.indices,
24
+ fields: ProxES::Services::Search.fields(index: params[:indices], names_only: true),
25
+ result: result
26
+ }
27
+ end
28
+
29
+ get '/fields/?:indices?/?' do
30
+ authorize self, :fields
31
+
32
+ param :names_only, Boolean, default: false
33
+ json ProxES::Services::Search.fields index: params[:indices], names_only: params[:names_only]
34
+ end
35
+
36
+ get '/indices/?' do
37
+ authorize self, :indices
38
+
39
+ json ProxES::Services::Search.indices
40
+ end
41
+
42
+ get '/values/:field/?:indices?/?' do |field|
43
+ authorize self, :values
44
+
45
+ param :size, Integer, min: 0, default: 25
46
+ json ProxES::Services::Search.values(field, size: params[:size], index: params[:indices])
47
+ end
48
+
49
+ def find_template(views, name, engine, &block)
50
+ super(views, name, engine, &block) # Root
51
+ super(::Ditty::ProxES.view_folder, name, engine, &block) # This Component
52
+ super(::Ditty::App.view_folder, name, engine, &block) # Ditty
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ditty/controllers/application'
4
+ require 'proxes/policies/status_policy'
5
+ require 'proxes/services/es'
6
+
7
+ module ProxES
8
+ class Status < Ditty::Application
9
+ helpers ProxES::Services::ES
10
+
11
+ def find_template(views, name, engine, &block)
12
+ super(views, name, engine, &block) # Root
13
+ super(::Ditty::ProxES.view_folder, name, engine, &block) # This Component
14
+ super(::Ditty::App.view_folder, name, engine, &block) # Ditty
15
+ end
16
+
17
+ # This provides a URL that can be polled by a monitoring system. It will return
18
+ # 200 OK if all the checks pass, or 500 if any of the checks fail.
19
+ get '/check' do
20
+ checks = []
21
+ begin
22
+ health = client.cluster.health level: 'cluster'
23
+ checks << { text: 'Cluster Reachable', passed: true, value: health['cluster_name'] }
24
+ checks << { text: 'Cluster Health', passed: health['status'] == 'green', value: health['status'] }
25
+
26
+ node_stats = client.nodes.stats
27
+
28
+ master_nodes = []
29
+ data_nodes = []
30
+ ingestion_nodes = []
31
+ node_stats['nodes'].each_value do |node|
32
+ if node['roles']
33
+ master_nodes << node['name'] if node['roles'].include? 'master'
34
+ data_nodes << node['name'] if node['roles'].include? 'data'
35
+ ingestion_nodes << node['name'] if node['roles'].include? 'ingest'
36
+ elsif node['attributes']
37
+ master_nodes << node['name'] unless node['attributes']['master'] == 'false'
38
+ data_nodes << node['name'] unless node['attributes']['data'] == 'false'
39
+ ingestion_nodes << node['name'] unless node['attributes']['ingest'] == 'false'
40
+ elsif node['settings']
41
+ master_nodes << node['name'] unless node['settings']['node']['master'] == 'false'
42
+ data_nodes << node['name'] unless node['settings']['node']['data'] == 'false'
43
+ ingestion_nodes << node['name'] unless node['settings']['node']['ingest'] == 'false'
44
+ end
45
+ end
46
+ checks << {
47
+ text: 'Master Nodes',
48
+ passed: master_nodes.count > 0,
49
+ value: master_nodes.count > 0 ? master_nodes.sort : 'None'
50
+ }
51
+ checks << {
52
+ text: 'Data Nodes',
53
+ passed: data_nodes.count > 0,
54
+ value: data_nodes.count > 0 ? data_nodes.sort : 'None'
55
+ }
56
+ checks << {
57
+ text: 'Ingestion Nodes',
58
+ passed: true,
59
+ value: ingestion_nodes.count > 0 ? ingestion_nodes.sort : 'None'
60
+ }
61
+
62
+ jvm_values = []
63
+ jvm_passed = true
64
+ node_stats['nodes'].each_value do |node|
65
+ jvm_values << "#{node['name']}: #{node['jvm']['mem']['heap_used_percent']}%"
66
+ jvm_passed = false if node['jvm']['mem']['heap_used_percent'] > 85
67
+ end
68
+ checks << { text: 'Node JVM Heap', passed: jvm_passed, value: jvm_values.sort }
69
+
70
+ fs_values = []
71
+ fs_passed = true
72
+ node_stats['nodes'].each_value do |node|
73
+ next if node['attributes'] && node['attributes']['data'] == 'false'
74
+ next if node['roles'] && node['roles'].include?('data') == false
75
+ stats = node['fs']['total']
76
+ left = stats['available_in_bytes'] / stats['total_in_bytes'].to_f * 100
77
+ fs_values << "#{node['name']}: #{format('%.02f', left)}% Free"
78
+ fs_passed = false if left < 10
79
+ end
80
+ checks << { text: 'Node File Systems', passed: fs_passed, value: fs_values.sort }
81
+
82
+ cpu_values = []
83
+ cpu_passed = true
84
+ node_stats['nodes'].each_value do |node|
85
+ value = (node['os']['cpu_percent'] || node['os']['cpu']['percent'])
86
+ cpu_values << "#{node['name']}: #{value}"
87
+ cpu_passed = false if value.to_i > 70
88
+ end
89
+ checks << { text: 'Node CPU Usage', passed: cpu_passed, value: cpu_values.sort }
90
+
91
+ memory_values = []
92
+ memory_sum = 0
93
+ node_stats['nodes'].each_value do |node|
94
+ memory_sum += node['os']['mem']['used_percent']
95
+ memory_values << "#{node['name']}: #{node['os']['mem']['used_percent']}"
96
+ end
97
+ memory_passed = (memory_sum / memory_values.size).to_i < 100
98
+ checks << { text: 'Node Memory Usage', passed: memory_passed, value: memory_values.sort }
99
+ rescue Faraday::Error => e
100
+ checks << { text: 'Cluster Reachable', passed: false, value: e.message }
101
+ end
102
+
103
+ status checks.find { |c| c[:passed] == false } ? 500 : 200
104
+
105
+ respond_to do |format|
106
+ format.html do
107
+ haml :'status/check', locals: { title: 'Status Check', checks: checks }
108
+ end
109
+ format.json do
110
+ json checks
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,49 @@
1
+ require 'proxes/services/es'
2
+ require 'net/http/persistent'
3
+ require 'singleton'
4
+ require 'rack'
5
+
6
+ module ProxES
7
+ # A lot of code in this comes from Rack::Proxy
8
+ class Forwarder
9
+ include Singleton
10
+ include ProxES::Services::ES
11
+
12
+ def call(env)
13
+ source = Rack::Request.new(env)
14
+ response = conn.send(source.request_method.downcase) do |req|
15
+ source_body = body_from(source)
16
+ req.body = source_body if source_body
17
+ req.url source.fullpath == '' ? URI.parse(env['REQUEST_URI']).request_uri : source.fullpath
18
+ end
19
+ mangle response
20
+ end
21
+
22
+ def mangle(response)
23
+ headers = (response.respond_to?(:headers) && response.headers) || self.class.normalize_headers(response.to_hash)
24
+ body = response.body || ['']
25
+ body = [body] unless body.respond_to?(:each)
26
+
27
+ # Not sure where this is coming from, but it causes timeouts on the client
28
+ headers.delete('transfer-encoding')
29
+ # Ensure that the content length rack middleware kicks in
30
+ headers.delete('content-length')
31
+
32
+ [response.status, headers, body]
33
+ end
34
+
35
+ def body_from(request)
36
+ return nil if request.body.nil? || (Kernel.const_defined?('::Puma::NullIO') && request.body.is_a?(Puma::NullIO))
37
+ request.body.read.tap { |_r| request.body.rewind }
38
+ end
39
+
40
+ class << self
41
+ def normalize_headers(headers)
42
+ mapped = headers.map do |k, v|
43
+ [k, v.is_a?(Array) ? v.join("\n") : v]
44
+ end
45
+ Rack::Utils::HeaderHash.new Hash[mapped]
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support'
4
+ require 'active_support/core_ext/object/blank'
5
+
6
+ module ProxES
7
+ module Helpers
8
+ module Indices
9
+ def filter(asked, against)
10
+ return against.map { |a| a.gsub(/\.\*/, '*') } if asked == ['*'] || asked.blank?
11
+
12
+ answer = []
13
+ against.each do |pattern|
14
+ answer.concat(asked.select { |idx| idx =~ /#{pattern}/ })
15
+ end
16
+ answer
17
+ end
18
+
19
+ def patterns
20
+ return [] if user.nil?
21
+ patterns_for('INDEX').map do |permission|
22
+ return nil if permission.pattern.blank?
23
+ permission.pattern.gsub(/\{user.(.*)\}/) { |_match| user.send(Regexp.last_match[1].to_sym) }
24
+ end.compact
25
+ end
26
+
27
+ def patterns_for(action)
28
+ return [] if user.nil?
29
+ Permission.for_user(user, action)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'logger'
4
+
5
+ module ProxES
6
+ module Loggers
7
+ class Elasticsearch < ::Logger
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'wisper'
4
+ require 'proxes/request'
5
+ require 'ditty/services/logger'
6
+
7
+ module ProxES
8
+ module Middleware
9
+ class ErrorHandling
10
+ attr_reader :logger
11
+
12
+ include Wisper::Publisher
13
+
14
+ def initialize(app, logger = nil)
15
+ @app = app
16
+ @logger = logger || ::Ditty::Services::Logger.instance
17
+ end
18
+
19
+ def call(env)
20
+ request = ProxES::Request.from_env(env)
21
+ response = @app.call env
22
+ broadcast(:es_request_failed, request, response) unless (200..299).cover?(response[0])
23
+ response
24
+ rescue Errno::EHOSTUNREACH
25
+ error 'Could not reach Elasticsearch at ' + ENV['ELASTICSEARCH_URL']
26
+ rescue Errno::ECONNREFUSED, Faraday::ConnectionFailed, SocketError
27
+ error 'Elasticsearch not listening at ' + ENV['ELASTICSEARCH_URL']
28
+ rescue Pundit::NotAuthorizedError, Ditty::Helpers::NotAuthenticated => e
29
+ broadcast(:es_request_denied, request, e)
30
+ log_not_authorized request
31
+ raise e if env['APP_ENV'] == 'development'
32
+ return [401, {}, []] if request.head?
33
+ request.html? && request.user.nil? ? login_and_redirect(request) : error('Not Authorized', 401)
34
+ rescue StandardError => e
35
+ broadcast(:es_request_denied, request, e)
36
+ log_not_authorized request
37
+ raise e if env['APP_ENV'] == 'development'
38
+ return [403, {}. []] if request.head?
39
+ error 'Forbidden', 403
40
+ end
41
+
42
+ def log_not_authorized(request)
43
+ user = request.user ? request.user.email : 'unauthenticated request'
44
+ logger.error "Access denied for #{user} by security layer: #{request.detail}"
45
+ end
46
+
47
+ # Response Helpers
48
+ def error(message, code = 500)
49
+ headers = { 'Content-Type' => 'application/json' }
50
+ headers['WWW-Authenticate'] = 'Basic realm="security"' if code == 401
51
+ [code, headers, ['{"error":"' + message + '"}']]
52
+ end
53
+
54
+ def login_and_redirect(request)
55
+ request.session['omniauth.origin'] = request.url unless request.url == '/_proxes/auth/login'
56
+ redirect '/_proxes/auth/login'
57
+ end
58
+
59
+ def redirect(destination, code = 302)
60
+ [code, { 'Location' => destination }, []]
61
+ end
62
+ end
63
+ end
64
+ end