chatops-controller 3.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (53) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +204 -0
  3. data/lib/chatops-controller.rb +1 -0
  4. data/lib/chatops.rb +21 -0
  5. data/lib/chatops/controller.rb +209 -0
  6. data/lib/chatops/controller/rspec.rb +5 -0
  7. data/lib/chatops/controller/test_case.rb +5 -0
  8. data/lib/chatops/controller/test_case_helpers.rb +91 -0
  9. data/lib/chatops/controller/version.rb +3 -0
  10. data/spec/dummy/README.rdoc +28 -0
  11. data/spec/dummy/Rakefile +6 -0
  12. data/spec/dummy/app/assets/javascripts/application.js +13 -0
  13. data/spec/dummy/app/assets/stylesheets/application.css +15 -0
  14. data/spec/dummy/app/controllers/application_controller.rb +5 -0
  15. data/spec/dummy/app/helpers/application_helper.rb +2 -0
  16. data/spec/dummy/app/views/layouts/application.html.erb +14 -0
  17. data/spec/dummy/bin/bundle +3 -0
  18. data/spec/dummy/bin/rails +4 -0
  19. data/spec/dummy/bin/rake +4 -0
  20. data/spec/dummy/bin/setup +29 -0
  21. data/spec/dummy/config.ru +4 -0
  22. data/spec/dummy/config/application.rb +24 -0
  23. data/spec/dummy/config/boot.rb +5 -0
  24. data/spec/dummy/config/database.yml +25 -0
  25. data/spec/dummy/config/environment.rb +5 -0
  26. data/spec/dummy/config/environments/development.rb +41 -0
  27. data/spec/dummy/config/environments/production.rb +79 -0
  28. data/spec/dummy/config/environments/test.rb +46 -0
  29. data/spec/dummy/config/initializers/assets.rb +13 -0
  30. data/spec/dummy/config/initializers/backtrace_silencers.rb +7 -0
  31. data/spec/dummy/config/initializers/cookies_serializer.rb +3 -0
  32. data/spec/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  33. data/spec/dummy/config/initializers/inflections.rb +16 -0
  34. data/spec/dummy/config/initializers/mime_types.rb +4 -0
  35. data/spec/dummy/config/initializers/session_store.rb +3 -0
  36. data/spec/dummy/config/initializers/wrap_parameters.rb +14 -0
  37. data/spec/dummy/config/locales/en.yml +23 -0
  38. data/spec/dummy/config/routes.rb +56 -0
  39. data/spec/dummy/config/secrets.yml +22 -0
  40. data/spec/dummy/db/development.sqlite3 +0 -0
  41. data/spec/dummy/db/schema.rb +16 -0
  42. data/spec/dummy/db/test.sqlite3 +0 -0
  43. data/spec/dummy/log/test.log +3830 -0
  44. data/spec/dummy/public/404.html +67 -0
  45. data/spec/dummy/public/422.html +67 -0
  46. data/spec/dummy/public/500.html +66 -0
  47. data/spec/dummy/public/favicon.ico +0 -0
  48. data/spec/dummy/spec +1 -0
  49. data/spec/lib/chatops/controller_spec.rb +376 -0
  50. data/spec/rails_helper.rb +65 -0
  51. data/spec/spec_helper.rb +92 -0
  52. data/spec/support/json_response.rb +28 -0
  53. metadata +195 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: eb59e0c9f0d9fcac1924c0faa12c5b0bfb9e8762
4
+ data.tar.gz: e7f3395d1105eccabb0e1552e5580a57c04c40fe
5
+ SHA512:
6
+ metadata.gz: 949d8c2702e391ffe3d1eef5bd45f5a2741a81ff5e1684ed9d376bb07a1ffd5378e29a60c6e654611da25ca9f95d2798a5cd4db503420f698243c2efd6f7e004
7
+ data.tar.gz: d1d3878610807932e90a3925dbadb9f873098dd3d1e9810ffb37fdc4dffef268707c9fa15f4bd40667f6fe980ab1e25da07d234d6b56983c16d6a57d68d1255c
@@ -0,0 +1,204 @@
1
+ # Chatops Controller
2
+
3
+ Rails helpers for easy and well-tested Chatops RPC. See the [protocol docs](docs/protocol-description.md)
4
+ for background information on Chatops RPC.
5
+
6
+ A minimal controller example:
7
+
8
+ ```ruby
9
+ class ChatopsController < ApplicationController
10
+ include ::Chatops::Controller
11
+
12
+ # The default chatops RPC prefix. Clients may replace this.
13
+ chatops_namespace :echo
14
+
15
+ chatop :echo,
16
+ /(?<text>.*)?/,
17
+ "<text> - Echo some text back" do
18
+ jsonrpc_success "Echoing back to you: #{jsonrpc_params[:text]}"
19
+ end
20
+ end
21
+ ```
22
+
23
+ Some routing boilerplate is required in `config/routes.rb`:
24
+
25
+ ```ruby
26
+ Rails.application.routes.draw do
27
+ # Replace the controller: argument with your controller's name
28
+ post "/_chatops/:chatop", controller: "chatops", action: :execute_chatop
29
+ get "/_chatops" => "chatops#list"
30
+ end
31
+ ```
32
+
33
+ It's easy to test:
34
+
35
+ ```ruby
36
+ class MyControllerTestCase < ActionController::TestCase
37
+ include Chatops::Controller::TestCaseHelpers
38
+ before do
39
+ chatops_prefix "echo"
40
+ chatops_auth!
41
+ end
42
+
43
+ def test_it_works
44
+ chat "echo foo bar baz"
45
+ assert_equal "foo bar baz", chatop_response
46
+ end
47
+ end
48
+ ```
49
+
50
+ Before you deploy, add the RPC authentication tokens to your app's environment,
51
+ below.
52
+
53
+ You're all done. Try `.echo foo`, and you should see your client respond with
54
+ `Echoing back to you: foo`.
55
+
56
+ A hubot client implementation is available at
57
+ <https://github.com/hubot-scripts/hubot-chatops-rpc>
58
+
59
+ ## Usage
60
+
61
+ #### Namespaces
62
+
63
+ Every chatops controller has a namespace. All commands associated with this
64
+ controller will be displayed with `.<namespace>` in chat. The namespace is a
65
+ default chatops RPC prefix and may be overridden by a client.
66
+
67
+ ```
68
+ chatops_namespace :foo
69
+ ```
70
+
71
+ #### Creating Chatops
72
+
73
+ Creating a chatop is a DSL:
74
+
75
+ ```ruby
76
+ chatop :echo,
77
+ /(?<text>.*)?/,
78
+ "<text> - Echo some text back" do
79
+ jsonrpc_success "Echoing back to you: #{jsonrpc_params[:text]}"
80
+ end
81
+ ```
82
+
83
+ In this example, we've created a chatop called `echo`. The next argument is a
84
+ regular expression with [named
85
+ captures](http://ruby-doc.org/core-1.9.3/Regexp.html#method-i-named_captures).
86
+ In this example, only one capture group is available, `text`.
87
+
88
+ The next line is a string, which is a single line of help that will be displayed
89
+ in chat for `.echo`.
90
+
91
+ The DSL takes a block, which is the code that will run when the chat robot sees
92
+ this regex. Arguments will be available in the `params` hash. `params[:user]`
93
+ and `params[:room_id]` are special, and will be set by the client. `user` will
94
+ always be the login of the user typing the command, and `room_id` will be where
95
+ it was typed.
96
+
97
+ You can return `jsonrpc_success` with a string to return text to chat. If you
98
+ have an input validation or other handle-able error, you can use
99
+ `jsonrpc_failure` to send a helpful error message.
100
+
101
+ Chatops are regular old rails controller actions, and you can use niceties like
102
+ `before_action` and friends. `before_action :echo, :load_user` for the above
103
+ case would call `load_user` before running `echo`.
104
+
105
+ ## Authentication
106
+
107
+ Authentication uses the Chatops v3 public key signing protocol. You'll need
108
+ two environment variables to use this protocol:
109
+
110
+ `CHATOPS_AUTH_PUBLIC_KEY` is the public key of your chatops client in PEM
111
+ format. This environment variable will be the contents of a `.pub` file,
112
+ newlines and all.
113
+
114
+ `CHATOPS_AUTH_BASE_URL` is the base URL of your server as the chatops client
115
+ sees it. This is specified as an environment variable since rails will trust
116
+ client headers about a forwarded hostname. For example, if your chatops client
117
+ has added the url `https://example.com/_chatops`, you'd set this to
118
+ `https://example.com`.
119
+
120
+ You can also optionally set `CHATOPS_AUTH_ALT_PUBLIC_KEY` to a second public key
121
+ which will be accepted. This is helpful when rolling keys.
122
+
123
+ ## Rails compatibility
124
+
125
+ This gem is intended to work with rails 4.x and 5.x. If you find a version
126
+ with a problem, please report it in an issue.
127
+
128
+ ## Development
129
+
130
+ Changes are welcome. Getting started:
131
+
132
+ ```
133
+ script/bootstrap
134
+ script/test
135
+ ```
136
+
137
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution instructions.
138
+
139
+ ## Upgrading from early versions
140
+
141
+ Early versions of RPC chatops had two major changes:
142
+
143
+ ##### Using Rails' dynamic `:action` routing, which was deprecated in Rails 5.
144
+
145
+ To work around this, you need to update your router boilerplate:
146
+
147
+ This:
148
+
149
+ ```ruby
150
+ post "/_chatops/:action", controller: "chatops"
151
+ ```
152
+
153
+ Becomes this:
154
+
155
+ ```ruby
156
+ post "/_chatops/:chatop", controller: "chatops" action: :execute_chatop
157
+ ```
158
+
159
+ ##### Adding a prefix
160
+
161
+ Version 2 of the Chatops RPC protocol assumes a unique prefix for each endpoint. This decision was made for several reasons:
162
+
163
+ * The previous suffix-based system creates semantic ambiguities with keyword arguments
164
+ * Prefixes allow big improvements to `.help`
165
+ * Prefixes make regex-clobbering impossible
166
+
167
+ To upgrade to version 2, upgrade to version 2.x of this gem. To migrate:
168
+
169
+ * Migrate your chatops to remove any prefixes you have:
170
+
171
+ ```ruby
172
+ chatop :foo, "help", /ci build whatever/, do "yay" end
173
+ ```
174
+
175
+ Becomes:
176
+
177
+ ```ruby
178
+ chatop :foo, "help", /build whatever/, do "yay" end
179
+ ```
180
+
181
+ * Update your tests:
182
+
183
+ ```ruby
184
+ chat "ci build foobar"
185
+ ```
186
+
187
+ Becomes:
188
+
189
+ ```ruby
190
+ chat "build foobar"
191
+ # or
192
+ chatops_prefix "ci"
193
+ chat "ci build foobar"
194
+ ```
195
+
196
+ ##### Using public key authentication
197
+
198
+ Previous versions used a `CHATOPS_ALT_AUTH_TOKEN` as a shared secret. This form
199
+ of authentication was deprecated and the public key form used above is now
200
+ used instead.
201
+
202
+ ### License
203
+
204
+ MIT. See the accompanying LICENSE file.
@@ -0,0 +1 @@
1
+ require 'chatops/controller'
@@ -0,0 +1,21 @@
1
+ module Chatops
2
+ def self.public_key
3
+ ENV[public_key_env_var_name]
4
+ end
5
+
6
+ def self.public_key_env_var_name
7
+ "CHATOPS_AUTH_PUBLIC_KEY"
8
+ end
9
+
10
+ def self.alt_public_key
11
+ ENV["CHATOPS_AUTH_ALT_PUBLIC_KEY"]
12
+ end
13
+
14
+ def self.auth_base_url
15
+ ENV[auth_base_url_env_var_name]
16
+ end
17
+
18
+ def self.auth_base_url_env_var_name
19
+ "CHATOPS_AUTH_BASE_URL"
20
+ end
21
+ end
@@ -0,0 +1,209 @@
1
+ require "chatops"
2
+
3
+ module Chatops
4
+ module Controller
5
+ class ConfigurationError < StandardError ; end
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ before_action :ensure_valid_chatops_url, if: :should_authenticate_chatops?
10
+ before_action :ensure_valid_chatops_timestamp, if: :should_authenticate_chatops?
11
+ before_action :ensure_valid_chatops_signature, if: :should_authenticate_chatops?
12
+ before_action :ensure_valid_chatops_nonce, if: :should_authenticate_chatops?
13
+ before_action :ensure_chatops_authenticated, if: :should_authenticate_chatops?
14
+ before_action :ensure_user_given
15
+ before_action :ensure_method_exists
16
+ end
17
+
18
+ def list
19
+ chatops = self.class.chatops
20
+ chatops.each { |name, hash| hash[:path] = name }
21
+ render :json => {
22
+ namespace: self.class.chatops_namespace,
23
+ help: self.class.chatops_help,
24
+ error_response: self.class.chatops_error_response,
25
+ methods: chatops,
26
+ version: "3" }
27
+ end
28
+
29
+ def process(*args)
30
+ scrubbed_params = jsonrpc_params.except(
31
+ :user, :method, :controller, :action, :params, :room_id)
32
+
33
+ scrubbed_params.each { |k, v| params[k] = v }
34
+
35
+ if params[:chatop].present?
36
+ params[:action] = params[:chatop]
37
+ args[0] = params[:action]
38
+ unless self.respond_to?(params[:chatop].to_sym)
39
+ raise AbstractController::ActionNotFound
40
+ end
41
+ end
42
+
43
+ super *args
44
+ rescue AbstractController::ActionNotFound
45
+ return jsonrpc_method_not_found
46
+ end
47
+
48
+ def execute_chatop
49
+ # This needs to exist for route declarations, but we'll be overriding
50
+ # things in #process to make a method the action.
51
+ end
52
+
53
+ protected
54
+
55
+ def jsonrpc_params
56
+ params["params"] || {}
57
+ end
58
+
59
+ def jsonrpc_success(message)
60
+ jsonrpc_response :result => message.to_s
61
+ end
62
+ alias_method :chatop_send, :jsonrpc_success
63
+
64
+ def jsonrpc_parse_error
65
+ jsonrpc_error(-32700, 500, "Parse error")
66
+ end
67
+
68
+ def jsonrpc_invalid_request
69
+ jsonrpc_error(-32600, 400, "Invalid request")
70
+ end
71
+
72
+ def jsonrpc_method_not_found
73
+ jsonrpc_error(-32601, 404, "Method not found")
74
+ end
75
+
76
+ def jsonrpc_invalid_params(message)
77
+ message ||= "Invalid parameters"
78
+ jsonrpc_error(-32602, 400, message.to_s)
79
+ end
80
+ alias_method :jsonrpc_failure, :jsonrpc_invalid_params
81
+
82
+ def jsonrpc_error(number, http_status, message)
83
+ jsonrpc_response({ :error => { :code => number, :message => message.to_s } }, http_status)
84
+ end
85
+
86
+ def jsonrpc_response(hash, http_status = nil)
87
+ http_status ||= 200
88
+ render :status => http_status,
89
+ :json => { :jsonrpc => "2.0",
90
+ :id => params[:id] }.merge(hash)
91
+ end
92
+
93
+ def ensure_user_given
94
+ return true unless chatop_names.include?(params[:action].to_sym)
95
+ return true if params[:user].present?
96
+ jsonrpc_invalid_params("A username must be supplied as 'user'")
97
+ end
98
+
99
+ def ensure_chatops_authenticated
100
+ body = request.raw_post || ""
101
+ signature_string = [@chatops_url, @chatops_nonce, @chatops_timestamp, body].join("\n")
102
+ # We return this just to aid client debugging.
103
+ response.headers["Chatops-Signature-String"] = Base64.strict_encode64(signature_string)
104
+ raise ConfigurationError.new("You need to add a client's public key in .pem format via #{Chatops.public_key_env_var_name}") unless Chatops.public_key.present?
105
+ if signature_valid?(Chatops.public_key, @chatops_signature, signature_string) ||
106
+ signature_valid?(Chatops.alt_public_key, @chatops_signature, signature_string)
107
+ return true
108
+ end
109
+ return render :status => :forbidden, :plain => "Not authorized"
110
+ end
111
+
112
+ def ensure_valid_chatops_url
113
+ unless Chatops.auth_base_url.present?
114
+ raise ConfigurationError.new("You need to set the server's base URL to authenticate chatops RPC via #{Chatops.auth_base_url_env_var_name}")
115
+ end
116
+ if Chatops.auth_base_url[-1] == "/"
117
+ raise ConfigurationError.new("Don't include a trailing slash in #{Chatops.auth_base_url_env_var_name}; the rails path will be appended and it must match exactly.")
118
+ end
119
+ @chatops_url = Chatops.auth_base_url + request.path
120
+ end
121
+
122
+ def ensure_valid_chatops_nonce
123
+ @chatops_nonce = request.headers["Chatops-Nonce"]
124
+ return render :status => :forbidden, :plain => "A Chatops-Nonce header is required" unless @chatops_nonce.present?
125
+ end
126
+
127
+ def ensure_valid_chatops_signature
128
+ signature_header = request.headers["Chatops-Signature"]
129
+
130
+ begin
131
+ # "Chatops-Signature: Signature keyid=foo,signature=abc123" => { "keyid"" => "foo", "signature" => "abc123" }
132
+ signature_items = signature_header.split(" ", 2)[1].split(",").map { |item| item.split("=", 2) }.to_h
133
+ @chatops_signature = signature_items["signature"]
134
+ rescue NoMethodError
135
+ # The signature header munging, if something's amiss, can produce a `nil` that raises a
136
+ # no method error. We'll just carry on; the nil signature will raise below
137
+ end
138
+
139
+ unless @chatops_signature.present?
140
+ return render :status => :forbidden, :plain => "Failed to parse signature header"
141
+ end
142
+ end
143
+
144
+ def ensure_valid_chatops_timestamp
145
+ @chatops_timestamp = request.headers["Chatops-Timestamp"]
146
+ time = Time.iso8601(@chatops_timestamp)
147
+ if !(time > 1.minute.ago && time < 1.minute.from_now)
148
+ return render :status => :forbidden, :plain => "Chatops timestamp not within 1 minute of server time: #{@chatops_timestamp} vs #{Time.now.utc.iso8601}"
149
+ end
150
+ rescue ArgumentError, TypeError
151
+ # time parsing or missing can raise these
152
+ return render :status => :forbidden, :plain => "Invalid Chatops-Timestamp: #{@chatops_timestamp}"
153
+ end
154
+
155
+ def request_is_chatop?
156
+ (chatop_names + [:list]).include?(params[:action].to_sym)
157
+ end
158
+
159
+ def chatops_test_auth?
160
+ Rails.env.test? && request.env["CHATOPS_TESTING_AUTH"]
161
+ end
162
+
163
+ def should_authenticate_chatops?
164
+ request_is_chatop? && !chatops_test_auth?
165
+ end
166
+
167
+ def signature_valid?(key_string, signature, signature_string)
168
+ return false unless key_string.present?
169
+ digest = OpenSSL::Digest::SHA256.new
170
+ decoded_signature = Base64.decode64(signature)
171
+ public_key = OpenSSL::PKey::RSA.new(key_string)
172
+ public_key.verify(digest, decoded_signature, signature_string)
173
+ end
174
+
175
+ def ensure_method_exists
176
+ return jsonrpc_method_not_found unless (chatop_names + [:list]).include?(params[:action].to_sym)
177
+ end
178
+
179
+ def chatop_names
180
+ self.class.chatops.keys
181
+ end
182
+
183
+ module ClassMethods
184
+ def chatop(method_name, regex, help, &block)
185
+ chatops[method_name] = { help: help,
186
+ regex: regex.source,
187
+ params: regex.names }
188
+ define_method method_name, &block
189
+ end
190
+
191
+ %w{namespace help error_response}.each do |setting|
192
+ method_name = "chatops_#{setting}".to_sym
193
+ variable_name = "@#{method_name}".to_sym
194
+ define_method method_name do |*args|
195
+ assignment = args.first
196
+ if assignment.present?
197
+ instance_variable_set variable_name, assignment
198
+ end
199
+ instance_variable_get variable_name.to_sym
200
+ end
201
+ end
202
+
203
+ def chatops
204
+ @chatops ||= {}
205
+ @chatops
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,5 @@
1
+ require "chatops/controller/test_case"
2
+
3
+ RSpec.configure do |config|
4
+ config.include Chatops::Controller::TestCaseHelpers
5
+ end