chatops-controller 3.0.3

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 (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