rails-approvals 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +164 -0
- data/Rakefile +18 -0
- data/app/assets/config/rails_approvals_manifest.js +1 -0
- data/app/assets/stylesheets/rails/approvals/application.css +15 -0
- data/app/controllers/rails/approvals/application_controller.rb +6 -0
- data/app/controllers/rails/approvals/slack_controller.rb +56 -0
- data/app/helpers/rails/approvals/application_helper.rb +6 -0
- data/app/jobs/rails/approvals/application_job.rb +6 -0
- data/app/mailers/rails/approvals/application_mailer.rb +8 -0
- data/app/models/rails/approvals/application_record.rb +7 -0
- data/app/models/rails/approvals/request.rb +88 -0
- data/app/models/rails/approvals/slack_message.rb +107 -0
- data/app/models/rails/approvals/slack_response.rb +32 -0
- data/app/views/layouts/rails/approvals/application.html.erb +15 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20210624220156_create_rails_approvals_requests.rb +21 -0
- data/lib/rails/approvals.rb +88 -0
- data/lib/rails/approvals/engine.rb +68 -0
- data/lib/rails/approvals/railtie.rb +12 -0
- data/lib/rails/approvals/version.rb +5 -0
- data/lib/tasks/rails/approvals_tasks.rake +4 -0
- metadata +110 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: c0e56b07cc8c4d9151cccbee4622d717b3ba527397e8f12b8cac425fff17a62d
|
4
|
+
data.tar.gz: acd5ac125333c3bb721c1acdb4c756601d88ed4760014b108e28f4006755811b
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6feac08e64f30352c6dcfaba67ef7adf37ee1db1bf2cc9fdf9a1f6395337e6c03106a3687667091f46ddcdd25e127f61572531484a5ab9158daa6ffe71e58018
|
7
|
+
data.tar.gz: 34789331f20daeea988c3533ed191be9f14ed4339ddbcc2723a1877ab4f41bd8510f3ae9813173644aa4c2d367384102a49c22806436463ba6185c801a278a87
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2021 Garrett Bjerkhoel
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
# Rails::Approvals
|
2
|
+
|
3
|
+
Add approval processes for Rails console access, running database migrations, and more in production. Be notified of approval requests and respond to them directly in Slack.
|
4
|
+
|
5
|
+
<img width="868" alt="CleanShot 2021-06-25 at 20 37 49@2x" src="https://user-images.githubusercontent.com/79995/123500785-5eda4a00-d5f5-11eb-9c39-5b704f62d2b5.png">
|
6
|
+
|
7
|
+
## Installation
|
8
|
+
|
9
|
+
Rails::Approvals requires a Slack application installed in your Slack workspace. The Slack application gives Rails::Approvals the ability to post approval requests to your configured Slack channel and other workspace users can respond to approval requests.
|
10
|
+
|
11
|
+
This guide will walk you through the process of installing the gem, configuring Slack, and `rails-approvals` to meet your needs.
|
12
|
+
|
13
|
+
### Install the rails-approvals gem
|
14
|
+
|
15
|
+
First, you must add the following line to your application's Gemfile to install `rails-approvals`:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem 'rails-approvals'
|
19
|
+
```
|
20
|
+
|
21
|
+
And then execute:
|
22
|
+
|
23
|
+
```bash
|
24
|
+
$ bundle
|
25
|
+
```
|
26
|
+
|
27
|
+
Or add it `rails-approvals` automatically to your `Gemfile` with:
|
28
|
+
|
29
|
+
```bash
|
30
|
+
$ bundle add rails-approvals
|
31
|
+
```
|
32
|
+
|
33
|
+
### Create Slack application
|
34
|
+
|
35
|
+
Now that you have the gem installed, it's time to create the Rails Approvals Slack application for your Slack workspace. To create a Slack application you must be a Slack workspace administrator.
|
36
|
+
|
37
|
+
Using the link below a new Slack application will be prefilled with all settings and scopes required for Rails::Approvals to work. Slack will prompt you to verify the permissions that will be granted before you create the Slack application.
|
38
|
+
|
39
|
+
<a href="https://api.slack.com/apps?new_app=1&manifest_json=%7B%0A%20%20%22_metadata%22%3A%20%7B%0A%20%20%20%20%22major_version%22%3A%201%2C%0A%20%20%20%20%22minor_version%22%3A%201%0A%20%20%7D%2C%0A%20%20%22display_information%22%3A%20%7B%0A%20%20%20%20%22name%22%3A%20%22Rails%20Approvals%22%0A%20%20%7D%2C%0A%20%20%22features%22%3A%20%7B%0A%20%20%20%20%22app_home%22%3A%20%7B%0A%20%20%20%20%20%20%22home_tab_enabled%22%3A%20false%2C%0A%20%20%20%20%20%20%22messages_tab_enabled%22%3A%20true%2C%0A%20%20%20%20%20%20%22messages_tab_read_only_enabled%22%3A%20true%0A%20%20%20%20%7D%2C%0A%20%20%20%20%22bot_user%22%3A%20%7B%0A%20%20%20%20%20%20%22display_name%22%3A%20%22Rails%20Approvals%22%2C%0A%20%20%20%20%20%20%22always_online%22%3A%20false%0A%20%20%20%20%7D%0A%20%20%7D%2C%0A%20%20%22oauth_config%22%3A%20%7B%0A%20%20%20%20%22scopes%22%3A%20%7B%0A%20%20%20%20%20%20%22bot%22%3A%20%5B%0A%20%20%20%20%20%20%20%20%22chat%3Awrite%22%0A%20%20%20%20%20%20%5D%0A%20%20%20%20%7D%0A%20%20%7D%2C%0A%20%20%22settings%22%3A%20%7B%0A%20%20%20%20%22interactivity%22%3A%20%7B%0A%20%20%20%20%20%20%22is_enabled%22%3A%20true%2C%0A%20%20%20%20%20%20%22request_url%22%3A%20%22https%3A%2F%2Fwebsite.com%2Frails%2Fapprovals%2Fslack%2Fwebhook%22%0A%20%20%20%20%7D%2C%0A%20%20%20%20%22org_deploy_enabled%22%3A%20false%2C%0A%20%20%20%20%22socket_mode_enabled%22%3A%20false%0A%20%20%7D%0A%7D%0A"><img alt="Add to Slack" height="40" width="139" src="https://platform.slack-edge.com/img/add_to_slack@2x.png" /></a>
|
40
|
+
|
41
|
+
Later in this installation guide you will be instructed to configure the webhook URL that Rails::Approvals needs to handle approval request responses within Slack's settings.
|
42
|
+
|
43
|
+
If you'd like to setup the Slack application manually you can do so following [Setup Slack Application](#setup-slack-application) guide below.
|
44
|
+
|
45
|
+
### Configuring rails-approvals
|
46
|
+
|
47
|
+
Rails::Approvals needs three things to work:
|
48
|
+
|
49
|
+
1. The **Slack Bot User OAuth Token** generated after installing the Slack application to your workspace. This lets the gem publish messages to your configured Slack channel.
|
50
|
+
1. The **Webhook signing secret** generated by Slack.
|
51
|
+
1. The **Slack channel** you'd like to send approval requests to.
|
52
|
+
|
53
|
+
Each of these can be configured by environment variables or manually in your environment file. We strongly do not recommend checking in any API tokens into version control and using environment variables to configure them.
|
54
|
+
|
55
|
+
```ruby
|
56
|
+
Rails.application.configure do
|
57
|
+
# Enabled by default in production. If you'd like to enable approvals in
|
58
|
+
# staging or other environments you can do so here.
|
59
|
+
config.rails.approvals.enabled = true
|
60
|
+
|
61
|
+
# Can be configured with RAILS_APPROVALS_SLACK_CHANNEL by default, or provided
|
62
|
+
# explicitely.
|
63
|
+
config.rails.approvals.slack.channel = "#rails-approvals"
|
64
|
+
|
65
|
+
# Can be configured with RAILS_APPROVALS_SLACK_TOKEN. Strongly do not
|
66
|
+
# recommended checking this into version control.
|
67
|
+
config.rails.approvals.slack.token = ENV['RAILS_APPROVALS_SLACK_TOKEN']
|
68
|
+
|
69
|
+
# Can be configured with RAILS_APPROVALS_SLACK_SIGNING_SECRET. Strongly do not
|
70
|
+
# recommended checking this into version control.
|
71
|
+
config.rails.approvals.slack.signing_secret = ENV['RAILS_APPROVALS_SLACK_SIGNING_SECRET']
|
72
|
+
end
|
73
|
+
```
|
74
|
+
|
75
|
+
There are [additional settings](https://github.com/cased/rails-approvals/blob/main/lib/rails/approvals/engine.rb) you can configure should you like, such as:
|
76
|
+
|
77
|
+
- How long approval requests are valid for (defaults to 10 minutes)
|
78
|
+
- If the user is prompted to identify who they are (defaults to $USER)
|
79
|
+
- If a reason is required.
|
80
|
+
|
81
|
+
### Mounting the Rails::Approvals engine
|
82
|
+
|
83
|
+
When you respond to approval requests within Slack, Slack will deliver a webhook
|
84
|
+
message to your configured application to permit or deny access accordingly. Rails::Approvals includes a built in controller to verify the message from Slack using the required signing secret, lookup the approval request, and handle the approved/denied response.
|
85
|
+
|
86
|
+
You will want to mount the `Rails::Approvals::Engine` within your `config/routes.rb` file:
|
87
|
+
|
88
|
+
```ruby
|
89
|
+
Rails.application.routes.draw do
|
90
|
+
mount Rails::Approvals::Engine => "/rails/approvals"
|
91
|
+
|
92
|
+
# existing routes here
|
93
|
+
end
|
94
|
+
```
|
95
|
+
|
96
|
+
For Slack to know where to send approval request responses you must provide a webhook URL. Using the URL below, replace `example.com` with your application's domain and enter it within the **Interactivity & Shortcuts** section of your Slack application settings:
|
97
|
+
|
98
|
+
```
|
99
|
+
https://example.com/rails/approvals/slack/webhook
|
100
|
+
```
|
101
|
+
|
102
|
+
### Run the database migration
|
103
|
+
|
104
|
+
Rails::Approvals uses an ActiveRecord model to keep track of all pending approval requests, who requested them, the reason provided and more. Install and run the required database migration below:
|
105
|
+
|
106
|
+
```
|
107
|
+
bin/rails railsapprovals:install:migrations
|
108
|
+
bin/rails db:migrate
|
109
|
+
```
|
110
|
+
|
111
|
+
You are welcome to check out the [migration](https://github.com/cased/rails-approvals/blob/main/db/migrate/20210624220156_create_rails_approvals_requests.rb) before running
|
112
|
+
it.
|
113
|
+
|
114
|
+
### Deploy
|
115
|
+
|
116
|
+
Now that you've installed `rails-approvals`, setup your Slack application & installed it to your workspace, you're ready to go!
|
117
|
+
|
118
|
+
## How does Rails::Approvals work?
|
119
|
+
|
120
|
+
Rails::Approvals works by adding a blocking approval request before a Rails console can be started.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
module Rails
|
124
|
+
module Approvals
|
125
|
+
class Railtie < ::Rails::Railtie
|
126
|
+
console do
|
127
|
+
Rails::Approvals.start!
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
```
|
133
|
+
|
134
|
+
An [`Rails::Approvals::Request`](https://github.com/cased/rails-approvals/blob/main/app/models/rails/approvals/request.rb) record is created which publishes the approval request to Slack and waits for someone to respond.
|
135
|
+
|
136
|
+
When an approval request is ✅ _approved_, the console session will continue as normal. When an approval response is 🛑 _denied_ or ⚠️ _times out_, the process will exit immediately.
|
137
|
+
|
138
|
+
## Setup Slack Application
|
139
|
+
|
140
|
+
If you'd like to create your Slack application manually, you can do so by following the instructions below:
|
141
|
+
|
142
|
+
1. Create a [new Slack application](https://api.slack.com/apps?new_app=1) for your desired Slack workspace.
|
143
|
+
1. Next, under **Features**, select **OAuth & Permissions**.
|
144
|
+
1. Add the `chat:write` scope under **Bot Token Scopes**. This is the only permission you need.
|
145
|
+
1. Now that you've added the required permission for Rails::Approvals to work, you must install the new application in your Slack workspace.
|
146
|
+
1. Under **Settings**, select **Install App**.
|
147
|
+
1. Install your Slack application to your workspace by following the prompt after clicking **Install to Workspace**.
|
148
|
+
1. Copy the **Bot User OAuth Token** and configure a `RAILS_APPROVALS_SLACK_TOKEN` environment variable for your application.
|
149
|
+
1. Next, under **Features**, select **Interactivity & Shortcuts**.
|
150
|
+
1. Enable **Interactivity** and provide the **Request URL** per the [webhook URL instructions above](#mounting-the-railsapprovals-engine).
|
151
|
+
1. Next, under **Settings**, select **Basic Information**.
|
152
|
+
1. Copy the **Signing Secret** under **App Credentials** and configure your `RAILS_APPROVALS_SLACK_SIGNING_SECRET` environment variable.
|
153
|
+
|
154
|
+
## Contributing
|
155
|
+
|
156
|
+
1. Fork it ( https://github.com/cased/rails-approvals/fork )
|
157
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
158
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
159
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
160
|
+
5. Create a new Pull Request
|
161
|
+
|
162
|
+
## License
|
163
|
+
|
164
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require "bundler/setup"
|
2
|
+
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
4
|
+
load "rails/tasks/engine.rake"
|
5
|
+
|
6
|
+
load "rails/tasks/statistics.rake"
|
7
|
+
|
8
|
+
require "bundler/gem_tasks"
|
9
|
+
|
10
|
+
require "rake/testtask"
|
11
|
+
|
12
|
+
Rake::TestTask.new(:test) do |t|
|
13
|
+
t.libs << 'test'
|
14
|
+
t.pattern = 'test/**/*_test.rb'
|
15
|
+
t.verbose = false
|
16
|
+
end
|
17
|
+
|
18
|
+
task default: :test
|
@@ -0,0 +1 @@
|
|
1
|
+
//= link_directory ../stylesheets/rails/approvals .css
|
@@ -0,0 +1,15 @@
|
|
1
|
+
/*
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
3
|
+
* listed below.
|
4
|
+
*
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
7
|
+
*
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
11
|
+
* It is generally better to create a new file per style scope.
|
12
|
+
*
|
13
|
+
*= require_tree .
|
14
|
+
*= require_self
|
15
|
+
*/
|
@@ -0,0 +1,56 @@
|
|
1
|
+
module Rails
|
2
|
+
module Approvals
|
3
|
+
class SlackController < ApplicationController
|
4
|
+
skip_before_action :verify_authenticity_token
|
5
|
+
before_action :verify_slack_request
|
6
|
+
|
7
|
+
# Make sure to handle errors from request and return.
|
8
|
+
unless Rails.env.development?
|
9
|
+
rescue_from StandardError do |_exception|
|
10
|
+
render json: {
|
11
|
+
response_type: 'ephemeral',
|
12
|
+
replace_original: false,
|
13
|
+
text: "Sorry, that didn't work. Please try again.",
|
14
|
+
}
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def create
|
19
|
+
payload = JSON.parse(params[:payload])
|
20
|
+
response = SlackResponse.new(payload)
|
21
|
+
|
22
|
+
if response.perform
|
23
|
+
head :ok
|
24
|
+
else
|
25
|
+
head :bad_request
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def verify_slack_request
|
32
|
+
return if valid_slack_request?
|
33
|
+
|
34
|
+
head :unauthorized
|
35
|
+
end
|
36
|
+
|
37
|
+
def valid_slack_request?
|
38
|
+
timestamp = request.headers['X-Slack-Request-Timestamp']
|
39
|
+
|
40
|
+
# Prevent replay attacks by only processing requests from the last 5
|
41
|
+
# minutes
|
42
|
+
if Time.zone.at(timestamp.to_i) < 5.minutes.ago
|
43
|
+
return false
|
44
|
+
end
|
45
|
+
|
46
|
+
signature = request.headers['X-Slack-Signature']
|
47
|
+
basestring = ['v0', timestamp, request.body.read].join(':')
|
48
|
+
signing_secret = Rails.application.config.rails.approvals.slack.signing_secret
|
49
|
+
hd = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, basestring)
|
50
|
+
computed_signature = ['v0', hd].join('=')
|
51
|
+
|
52
|
+
computed_signature == signature
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Rails
|
2
|
+
module Approvals
|
3
|
+
class Request < ApplicationRecord
|
4
|
+
enum state: {
|
5
|
+
requested: 102,
|
6
|
+
approved: 202,
|
7
|
+
denied: 401,
|
8
|
+
timed_out: 408,
|
9
|
+
canceled: 410,
|
10
|
+
}
|
11
|
+
|
12
|
+
validates :requester, presence: true
|
13
|
+
validates :reason, presence: ::Rails.application.config.rails.approvals.reasons.required
|
14
|
+
|
15
|
+
before_validation :prompt_for_requester
|
16
|
+
before_validation :prompt_for_reason
|
17
|
+
after_commit :publish_message_to_slack, on: %i[create update]
|
18
|
+
|
19
|
+
def self.request!
|
20
|
+
create!(
|
21
|
+
requester: ENV['USER'],
|
22
|
+
command: [$PROGRAM_NAME, *ARGV].join(' '),
|
23
|
+
)
|
24
|
+
end
|
25
|
+
|
26
|
+
def expired?
|
27
|
+
return false unless requested?
|
28
|
+
|
29
|
+
timeout_duration = Rails.application.config.rails.approvals.timeout_duration
|
30
|
+
return false if !timeout_duration
|
31
|
+
|
32
|
+
unless timeout_duration.is_a?(Numeric) || timeout_duration.is_a?(ActiveSupport::Duration)
|
33
|
+
raise ArgumentError, "expected ActiveSupport::Duration or Numeric value"
|
34
|
+
end
|
35
|
+
|
36
|
+
expires_at = created_at + Rails.application.config.rails.approvals.timeout_duration
|
37
|
+
expires_at <= Time.zone.now
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def prompt_for_requester
|
43
|
+
return if requester? && !Rails.application.config.rails.approvals.requester.prompt
|
44
|
+
|
45
|
+
prompt = TTY::Prompt.new
|
46
|
+
response = prompt.multiline("Who are you?", help: '(Press Ctrl+D or Ctrl+Z to submit)')
|
47
|
+
|
48
|
+
self.requester = response.join("\n")
|
49
|
+
end
|
50
|
+
|
51
|
+
def prompt_for_reason
|
52
|
+
# We already have a reason, prompting for one is unnecessary.
|
53
|
+
return if reason?
|
54
|
+
|
55
|
+
reason_required = ::Rails.application.config.rails.approvals.reasons.required
|
56
|
+
reason_prompt = ::Rails.application.config.rails.approvals.reasons.prompt
|
57
|
+
return if !reason_required && !reason_prompt
|
58
|
+
|
59
|
+
prompt = TTY::Prompt.new
|
60
|
+
response = prompt.multiline("Please enter a reason for running #{command}:", help: '(Press Ctrl+D or Ctrl+Z to submit)')
|
61
|
+
|
62
|
+
self.reason = response.join("\n")
|
63
|
+
end
|
64
|
+
|
65
|
+
def publish_message_to_slack
|
66
|
+
# The initial request has already been sent to Slack, we now need to
|
67
|
+
# update it as the request state has changed.
|
68
|
+
if slack_message_ts?
|
69
|
+
Rails::Approvals.slack.chat_update(
|
70
|
+
channel: slack_channel_id,
|
71
|
+
ts: slack_message_ts,
|
72
|
+
blocks: SlackMessage.new(self).as_json,
|
73
|
+
)
|
74
|
+
else
|
75
|
+
message = Rails::Approvals.slack.chat_postMessage(
|
76
|
+
channel: Rails.application.config.rails.approvals.slack.channel,
|
77
|
+
blocks: SlackMessage.new(self).as_json,
|
78
|
+
)
|
79
|
+
|
80
|
+
update!(
|
81
|
+
slack_message_ts: message.ts,
|
82
|
+
slack_channel_id: message.channel,
|
83
|
+
)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
module Rails
|
2
|
+
module Approvals
|
3
|
+
class SlackMessage
|
4
|
+
attr_reader :request
|
5
|
+
|
6
|
+
def initialize(request)
|
7
|
+
@request = request
|
8
|
+
end
|
9
|
+
|
10
|
+
def as_json
|
11
|
+
metadata_fields = [].tap do |metadata|
|
12
|
+
if request.reason?
|
13
|
+
metadata << {
|
14
|
+
type: 'mrkdwn',
|
15
|
+
text: "*Reason:*\n#{request.reason}",
|
16
|
+
}
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
blocks = [
|
21
|
+
{
|
22
|
+
type: 'section',
|
23
|
+
text: {
|
24
|
+
type: 'mrkdwn',
|
25
|
+
text: "*#{request.requester}* is requesting to run *#{request.command}*.",
|
26
|
+
},
|
27
|
+
},
|
28
|
+
]
|
29
|
+
|
30
|
+
if metadata_fields.any?
|
31
|
+
blocks << {
|
32
|
+
type: 'section',
|
33
|
+
fields: metadata_fields,
|
34
|
+
}
|
35
|
+
end
|
36
|
+
|
37
|
+
blocks << {
|
38
|
+
type: 'divider',
|
39
|
+
}
|
40
|
+
|
41
|
+
if request.requested?
|
42
|
+
global_id = request.to_global_id
|
43
|
+
blocks << {
|
44
|
+
type: "actions",
|
45
|
+
block_id: global_id.to_s,
|
46
|
+
elements: [
|
47
|
+
{
|
48
|
+
type: "button",
|
49
|
+
text: {
|
50
|
+
type: "plain_text",
|
51
|
+
emoji: true,
|
52
|
+
text: "Approve"
|
53
|
+
},
|
54
|
+
style: "primary",
|
55
|
+
value: "approve"
|
56
|
+
},
|
57
|
+
{
|
58
|
+
type: "button",
|
59
|
+
text: {
|
60
|
+
type: "plain_text",
|
61
|
+
emoji: true,
|
62
|
+
text: "Deny"
|
63
|
+
},
|
64
|
+
style: "danger",
|
65
|
+
value: "deny"
|
66
|
+
}
|
67
|
+
]
|
68
|
+
}
|
69
|
+
elsif request.approved?
|
70
|
+
blocks << {
|
71
|
+
type: 'section',
|
72
|
+
text: {
|
73
|
+
type: 'mrkdwn',
|
74
|
+
text: "✅ *#{request.responder}* has approved the session.",
|
75
|
+
},
|
76
|
+
}
|
77
|
+
elsif request.denied?
|
78
|
+
blocks << {
|
79
|
+
type: 'section',
|
80
|
+
text: {
|
81
|
+
type: 'mrkdwn',
|
82
|
+
text: "🛑 *#{request.responder}* has denied the session.",
|
83
|
+
},
|
84
|
+
}
|
85
|
+
elsif request.canceled?
|
86
|
+
blocks << {
|
87
|
+
type: 'section',
|
88
|
+
text: {
|
89
|
+
type: 'mrkdwn',
|
90
|
+
text: "Request was canceled by *#{request.requester}*.",
|
91
|
+
},
|
92
|
+
}
|
93
|
+
elsif request.timed_out?
|
94
|
+
blocks << {
|
95
|
+
type: 'section',
|
96
|
+
text: {
|
97
|
+
type: 'mrkdwn',
|
98
|
+
text: '🛑 Session timed out.',
|
99
|
+
},
|
100
|
+
}
|
101
|
+
end
|
102
|
+
|
103
|
+
blocks
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Rails
|
2
|
+
module Approvals
|
3
|
+
class SlackResponse
|
4
|
+
attr_reader :payload
|
5
|
+
attr_reader :responder
|
6
|
+
|
7
|
+
def initialize(payload)
|
8
|
+
@payload = payload
|
9
|
+
@responder = payload['user']['username']
|
10
|
+
end
|
11
|
+
|
12
|
+
def perform
|
13
|
+
payload['actions'].all? do |action|
|
14
|
+
request = GlobalID::Locator.locate(action['block_id'])
|
15
|
+
|
16
|
+
case action['value']
|
17
|
+
when 'approve'
|
18
|
+
request.update!(
|
19
|
+
responder: responder,
|
20
|
+
state: :approved,
|
21
|
+
)
|
22
|
+
when 'deny'
|
23
|
+
request.update!(
|
24
|
+
responder: responder,
|
25
|
+
state: :denied,
|
26
|
+
)
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<title>Rails approvals</title>
|
5
|
+
<%= csrf_meta_tags %>
|
6
|
+
<%= csp_meta_tag %>
|
7
|
+
|
8
|
+
<%= stylesheet_link_tag "rails/approvals/application", media: "all" %>
|
9
|
+
</head>
|
10
|
+
<body>
|
11
|
+
|
12
|
+
<%= yield %>
|
13
|
+
|
14
|
+
</body>
|
15
|
+
</html>
|
data/config/routes.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
class CreateRailsApprovalsRequests < ActiveRecord::Migration[6.1]
|
2
|
+
def change
|
3
|
+
create_table :rails_approvals_requests do |t|
|
4
|
+
t.integer :state, null: false, default: 102
|
5
|
+
t.string :command, null: true
|
6
|
+
t.text :reason, null: true
|
7
|
+
t.string :requester, null: false
|
8
|
+
|
9
|
+
# Responder
|
10
|
+
t.string :responder, null: true
|
11
|
+
t.datetime :responded_at, null: true, precision: 6
|
12
|
+
|
13
|
+
# The timestamp necessary for rails-approvals to update the request
|
14
|
+
# message.
|
15
|
+
t.string :slack_channel_id, null: true
|
16
|
+
t.string :slack_message_ts, null: true
|
17
|
+
|
18
|
+
t.timestamps null: false, precision: 6
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
require "rails/approvals/version"
|
2
|
+
require "rails/approvals/engine"
|
3
|
+
require "rails/approvals/railtie"
|
4
|
+
require "tty/prompt"
|
5
|
+
require "slack-ruby-client"
|
6
|
+
|
7
|
+
module Rails
|
8
|
+
module Approvals
|
9
|
+
def self.start!
|
10
|
+
# We only want to start an approval request if it's enabled.
|
11
|
+
return unless ::Rails.application.config.rails.approvals.enabled
|
12
|
+
|
13
|
+
if Rails.application.config.rails.approvals.slack.token.blank?
|
14
|
+
msg = <<~MSG
|
15
|
+
Please provide your Slack API token either in the `RAILS_APPROVALS_SLACK_TOKEN` environment variable, or configured in your environment file:
|
16
|
+
|
17
|
+
Rails.application.configure do
|
18
|
+
config.rails.approvals.enabled = true
|
19
|
+
config.rails.approvals.slack.channel = "#rails-approvals"
|
20
|
+
config.rails.approvals.slack.token = "your-token-here"
|
21
|
+
end
|
22
|
+
MSG
|
23
|
+
|
24
|
+
puts msg
|
25
|
+
exit 1
|
26
|
+
end
|
27
|
+
|
28
|
+
request = Rails::Approvals.await
|
29
|
+
case
|
30
|
+
when request.approved?
|
31
|
+
puts "✅ Request to run #{request.command} approved by #{request.responder}."
|
32
|
+
when request.denied?
|
33
|
+
puts "🛑 Request to run #{request.command} denied by #{request.responder}."
|
34
|
+
exit 1
|
35
|
+
when request.timed_out?
|
36
|
+
puts "⚠️ Request to run #{request.command} timed out."
|
37
|
+
exit 1
|
38
|
+
when request.canceled?
|
39
|
+
puts "👋 You canceled your approval request to run #{request.command}."
|
40
|
+
exit 0
|
41
|
+
end
|
42
|
+
rescue TTY::Reader::InputInterrupt
|
43
|
+
exit 0
|
44
|
+
rescue Slack::Web::Api::Errors::ChannelNotFound
|
45
|
+
channel = Rails.application.config.rails.approvals.slack.channel
|
46
|
+
puts "Rails::Approvals was configured to send approval requests to #{channel} in your Slack workspace but it either does not exist or the Slack workspace user needs to be invited to the room."
|
47
|
+
exit 1
|
48
|
+
end
|
49
|
+
|
50
|
+
def self.await
|
51
|
+
request = Rails::Approvals::Request.request!
|
52
|
+
poll = request.requested?
|
53
|
+
canceled = false
|
54
|
+
|
55
|
+
Signal.trap('SIGINT') do
|
56
|
+
# Stop the polling
|
57
|
+
poll = false
|
58
|
+
|
59
|
+
# Canceling the request inside of a trap will fail if it results in
|
60
|
+
# any log output. Cancel the request outside of the scope of the trap
|
61
|
+
# and everything will work as expected.
|
62
|
+
canceled = true
|
63
|
+
end
|
64
|
+
|
65
|
+
while poll
|
66
|
+
if request.expired?
|
67
|
+
request.timed_out!
|
68
|
+
break
|
69
|
+
end
|
70
|
+
|
71
|
+
request.reload
|
72
|
+
poll = request.requested?
|
73
|
+
|
74
|
+
sleep 0.1
|
75
|
+
end
|
76
|
+
|
77
|
+
if canceled
|
78
|
+
request.canceled!
|
79
|
+
end
|
80
|
+
|
81
|
+
request
|
82
|
+
end
|
83
|
+
|
84
|
+
def self.slack
|
85
|
+
@slack ||= Slack::Web::Client.new(token: Rails.application.config.rails.approvals.slack.token)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,68 @@
|
|
1
|
+
module Rails
|
2
|
+
module Approvals
|
3
|
+
class Engine < ::Rails::Engine
|
4
|
+
isolate_namespace Rails::Approvals
|
5
|
+
|
6
|
+
engine_name 'railsapprovals'
|
7
|
+
|
8
|
+
config.rails = ActiveSupport::OrderedOptions.new
|
9
|
+
config.rails.approvals = ActiveSupport::OrderedOptions.new
|
10
|
+
config.rails.approvals.enabled = ::Rails.env.production?
|
11
|
+
|
12
|
+
# How long you'd like approvals to last before they timed out and have to
|
13
|
+
# be requested again. Can disable timeouts by setting this to `false`.
|
14
|
+
config.rails.approvals.timeout_duration = 10.minutes
|
15
|
+
|
16
|
+
# Requester
|
17
|
+
config.rails.approvals.requester = ActiveSupport::OrderedOptions.new
|
18
|
+
|
19
|
+
# Some environments can be trusted that the user will be present in the
|
20
|
+
# USER environment variable. In environments where there are not per-user
|
21
|
+
# accounts or shared environments such as Heroku, prompting the user for
|
22
|
+
# who they are may be necessary.
|
23
|
+
#
|
24
|
+
# This is enabled by default for users who've installed the Dyno metadata
|
25
|
+
# buildpack: https://devcenter.heroku.com/articles/dyno-metadata
|
26
|
+
heroku = ENV['HEROKU_APP_NAME'].present?
|
27
|
+
|
28
|
+
# If you are on AWS and have not set up per-user accounts, prompts will be
|
29
|
+
# enabled by default.
|
30
|
+
default_user = %w[ec2-user root].include?(ENV['USER'])
|
31
|
+
|
32
|
+
config.rails.approvals.requester.prompt = heroku || default_user
|
33
|
+
|
34
|
+
# Reason
|
35
|
+
config.rails.approvals.reasons = ActiveSupport::OrderedOptions.new
|
36
|
+
config.rails.approvals.reasons.required = true
|
37
|
+
|
38
|
+
# When a reason is required, this option is ignored. If a reason is not
|
39
|
+
# required, you can choose to prompt for a reason or not.
|
40
|
+
config.rails.approvals.reasons.prompt = true
|
41
|
+
|
42
|
+
# Slack
|
43
|
+
config.rails.approvals.slack = ActiveSupport::OrderedOptions.new
|
44
|
+
|
45
|
+
# The Slack channel you wish to send approval requests to. If the channel
|
46
|
+
# is private you must invite the bot user associated with your Slack
|
47
|
+
# application before rails-approvals can send messages to the channel.
|
48
|
+
#
|
49
|
+
# @example
|
50
|
+
# #approvals
|
51
|
+
config.rails.approvals.slack.channel = ENV['RAILS_APPROVALS_SLACK_CHANNEL']
|
52
|
+
|
53
|
+
# For rails-approvals to be authenticated for your Slack workspace, you
|
54
|
+
# must provide the Bot User OAuth Token obtained after installing the
|
55
|
+
# Slack application to your workspace.
|
56
|
+
#
|
57
|
+
# @see https://api.slack.com/apps/$app/oauth
|
58
|
+
config.rails.approvals.slack.token = ENV['RAILS_APPROVALS_SLACK_TOKEN']
|
59
|
+
|
60
|
+
# The signing secret is used to verify the webhook message from Slack
|
61
|
+
# before handling it. You can obtain this from the Basic Information tab
|
62
|
+
# within your application's Settings.
|
63
|
+
#
|
64
|
+
# @see https://api.slack.com/apps/$appid/general
|
65
|
+
config.rails.approvals.slack.signing_secret = ENV['RAILS_APPROVALS_SLACK_SIGNING_SECRET']
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
metadata
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: rails-approvals
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Garrett Bjerkhoel
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-06-26 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rails
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: 6.0.0
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: 6.0.0
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: slack-ruby-client
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: 0.15.0
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: 0.15.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: tty-prompt
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 0.23.0
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 0.23.0
|
55
|
+
description: Add an approval process to rails console in production.
|
56
|
+
email:
|
57
|
+
- me@garrettbjerkhoel.com
|
58
|
+
executables: []
|
59
|
+
extensions: []
|
60
|
+
extra_rdoc_files: []
|
61
|
+
files:
|
62
|
+
- MIT-LICENSE
|
63
|
+
- README.md
|
64
|
+
- Rakefile
|
65
|
+
- app/assets/config/rails_approvals_manifest.js
|
66
|
+
- app/assets/stylesheets/rails/approvals/application.css
|
67
|
+
- app/controllers/rails/approvals/application_controller.rb
|
68
|
+
- app/controllers/rails/approvals/slack_controller.rb
|
69
|
+
- app/helpers/rails/approvals/application_helper.rb
|
70
|
+
- app/jobs/rails/approvals/application_job.rb
|
71
|
+
- app/mailers/rails/approvals/application_mailer.rb
|
72
|
+
- app/models/rails/approvals/application_record.rb
|
73
|
+
- app/models/rails/approvals/request.rb
|
74
|
+
- app/models/rails/approvals/slack_message.rb
|
75
|
+
- app/models/rails/approvals/slack_response.rb
|
76
|
+
- app/views/layouts/rails/approvals/application.html.erb
|
77
|
+
- config/routes.rb
|
78
|
+
- db/migrate/20210624220156_create_rails_approvals_requests.rb
|
79
|
+
- lib/rails/approvals.rb
|
80
|
+
- lib/rails/approvals/engine.rb
|
81
|
+
- lib/rails/approvals/railtie.rb
|
82
|
+
- lib/rails/approvals/version.rb
|
83
|
+
- lib/tasks/rails/approvals_tasks.rake
|
84
|
+
homepage: https://github.com/cased/rails-console-approval
|
85
|
+
licenses:
|
86
|
+
- MIT
|
87
|
+
metadata:
|
88
|
+
homepage_uri: https://github.com/cased/rails-console-approval
|
89
|
+
source_code_uri: https://github.com/cased/rails-console-approval
|
90
|
+
changelog_uri: https://github.com/cased/rails-console-approval/tags
|
91
|
+
post_install_message:
|
92
|
+
rdoc_options: []
|
93
|
+
require_paths:
|
94
|
+
- lib
|
95
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
96
|
+
requirements:
|
97
|
+
- - ">="
|
98
|
+
- !ruby/object:Gem::Version
|
99
|
+
version: '0'
|
100
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
101
|
+
requirements:
|
102
|
+
- - ">="
|
103
|
+
- !ruby/object:Gem::Version
|
104
|
+
version: '0'
|
105
|
+
requirements: []
|
106
|
+
rubygems_version: 3.0.3
|
107
|
+
signing_key:
|
108
|
+
specification_version: 4
|
109
|
+
summary: Add an approval process to rails console in production.
|
110
|
+
test_files: []
|