banacle 0.1.1

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.
@@ -0,0 +1,138 @@
1
+ require 'banacle/slack'
2
+ require 'banacle/slash_command/command'
3
+
4
+ module Banacle
5
+ module InteractiveMessage
6
+ class Renderer
7
+ def self.render(params, command)
8
+ new(params, command).render
9
+ end
10
+
11
+ def initialize(params, command)
12
+ @params = params
13
+ @command = command
14
+ end
15
+
16
+ attr_reader :params, :command
17
+
18
+ def render
19
+ payload = JSON.parse(params["payload"], symbolize_names: true)
20
+ action = Slack::Action.new(payload[:actions].first)
21
+
22
+ if action.approved?
23
+ render_approved_message(payload, command)
24
+ elsif action.rejected?
25
+ render_rejected_message(payload, command)
26
+ elsif action.cancelled?
27
+ render_cancelled_message(payload, command)
28
+ else
29
+ # Do nothing
30
+ end
31
+ end
32
+
33
+ protected
34
+
35
+ # override
36
+ def authenticated_user?
37
+ true
38
+ end
39
+
40
+ private
41
+
42
+ def render_approved_message(payload, command)
43
+ unless valid_approver?
44
+ return render_error("you cannot approve the request by yourself")
45
+ end
46
+
47
+ if authenticated_user?
48
+ result = command.execute
49
+
50
+ text = original_message_text
51
+ text += ":white_check_mark: *<@#{actioner_id}> approved this request*\n"
52
+ text += "Result:\n"
53
+ text += "```\n"
54
+ text += result
55
+ text += "```"
56
+
57
+ render_replacing_message(text)
58
+ else
59
+ render_error("you are not permitted to approve the request")
60
+ end
61
+ end
62
+
63
+ def render_rejected_message(payload, command)
64
+ unless valid_rejector?
65
+ return render_error("you cannot reject the request by yourself")
66
+ end
67
+
68
+ if authenticated_user?
69
+ text = original_message_text
70
+ text += ":no_entry_sign: *<@#{actioner_id}> rejected this request*"
71
+
72
+ render_replacing_message(text)
73
+ else
74
+ render_error("you are not permitted to reject the request")
75
+ end
76
+ end
77
+
78
+ def render_cancelled_message(payload, command)
79
+ unless valid_canceller?
80
+ return render_error("you cannot cancel the request by other than the requester")
81
+ end
82
+
83
+ text = original_message_text
84
+ text += "\nThe request was cancelled."
85
+
86
+ render_replacing_message(text)
87
+ end
88
+
89
+ def render_replacing_message(text)
90
+ Slack::Response.new(
91
+ response_type: "in_channel",
92
+ replace_original: true,
93
+ text: text,
94
+ ).to_json
95
+ end
96
+
97
+ def render_error(error)
98
+ Slack::Response.new(
99
+ response_type: "ephemeral",
100
+ replace_original: false,
101
+ text: "An error occurred: #{error}",
102
+ ).to_json
103
+ end
104
+
105
+ def valid_approver?
106
+ ENV['BANACLE_SKIP_VALIDATION'] || !self_actioned?
107
+ end
108
+
109
+ def valid_rejector?
110
+ ENV['BANACLE_SKIP_VALIDATION'] || !self_actioned?
111
+ end
112
+
113
+ def valid_canceller?
114
+ ENV['BANACLE_SKIP_VALIDATION'] || self_actioned?
115
+ end
116
+
117
+ def self_actioned?
118
+ requester_id == actioner_id
119
+ end
120
+
121
+ def requester_id
122
+ original_message_text.match(/\A<@([^>]+)>/)[1]
123
+ end
124
+
125
+ def actioner_id
126
+ payload[:user][:id]
127
+ end
128
+
129
+ def original_message_text
130
+ payload[:original_message][:text]
131
+ end
132
+
133
+ def payload
134
+ @payload ||= JSON.parse(params["payload"], symbolize_names: true)
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,191 @@
1
+ module Banacle
2
+ module Slack
3
+ Response = Struct.new(:response_type, :replace_original, :text, :attachments, keyword_init: true) do
4
+ class ValidationError < StandardError; end
5
+
6
+ def initialize(*args)
7
+ super
8
+ self.set_default!
9
+ self.validate!
10
+ self
11
+ end
12
+
13
+ def set_default!
14
+ self.response_type ||= "in_channel"
15
+ self.replace_original = true if self.replace_original.nil?
16
+ self.text ||= ""
17
+ self.attachments ||= []
18
+ end
19
+
20
+ def validate!
21
+ unless self.replace_original.is_a?(TrueClass) || self.replace_original.is_a?(FalseClass)
22
+ raise ValidationError.new("replace_original must be TrueClass or FalseClass")
23
+ end
24
+
25
+ %i(response_type text).each do |label|
26
+ unless self.send(label).is_a?(String)
27
+ raise ValidationError.new("#{attr} must be String")
28
+ end
29
+ end
30
+
31
+ attachments.each do |a|
32
+ unless a.is_a?(Slack::Attachment)
33
+ raise ValidationError.new("One of attachments #{a.inspect} must be Slack::Attachment")
34
+ end
35
+ end
36
+ end
37
+
38
+ def as_json
39
+ self.to_h.tap { |h| h[:attachments] = h[:attachments].map(&:as_json) }
40
+ end
41
+
42
+ def to_json
43
+ as_json.to_json
44
+ end
45
+ end
46
+
47
+ Attachment = Struct.new(:text, :fallback, :callback_id, :color, \
48
+ :attachment_type, :actions, keyword_init: true) do
49
+ class ValidationError < StandardError; end
50
+
51
+ def initialize(*args)
52
+ super
53
+ self.set_default!
54
+ self.validate!
55
+ self
56
+ end
57
+
58
+ def set_default!
59
+ self.text ||= ''
60
+ self.fallback ||= ''
61
+ self.callback_id ||= ''
62
+ self.color ||= ''
63
+ self.attachment_type ||= ''
64
+ self.actions ||= []
65
+ end
66
+
67
+ def validate!
68
+ %i(text fallback callback_id color attachment_type).each do |label|
69
+ unless self.send(label).is_a?(String)
70
+ raise ValidationError.new("#{attr} must be String")
71
+ end
72
+ end
73
+
74
+ self.actions.each do |a|
75
+ unless a.is_a?(Slack::Action)
76
+ raise ValidationError.new("One of actions #{a.inspect} must be Slack::Action")
77
+ end
78
+ end
79
+ end
80
+
81
+ def as_json
82
+ self.to_h.tap { |h| h[:actions] = h[:actions].map(&:as_json) }
83
+ end
84
+ end
85
+
86
+ Action = Struct.new(:name, :text, :style, :type, :value, :confirm, keyword_init: true) do
87
+ class ValidationError < StandardError; end
88
+
89
+ def self.approve_button
90
+ self.build_button('approve', style: 'primary', confirm: Confirm.approve)
91
+ end
92
+
93
+ def self.reject_button
94
+ self.build_button('reject', style: 'danger')
95
+ end
96
+
97
+ def self.cancel_button
98
+ self.build_button('cancel')
99
+ end
100
+
101
+ def self.build_button(value, style: 'default', confirm: nil)
102
+ self.build(value, style, 'button', confirm)
103
+ end
104
+
105
+ def self.build(value, style, type, confirm)
106
+ self.new(name: value, text: value.capitalize, style: style, type: type, value: value, confirm: confirm)
107
+ end
108
+
109
+ def initialize(*args)
110
+ super
111
+ self.set_default!
112
+ self.validate!
113
+ self
114
+ end
115
+
116
+ def set_default!
117
+ self.name ||= ''
118
+ self.text ||= ''
119
+ self.style ||= ''
120
+ self.type ||= ''
121
+ self.value ||= ''
122
+ end
123
+
124
+ def validate!
125
+ %i(name text style type value).each do |label|
126
+ unless self.send(label).is_a?(String)
127
+ raise ValidationError.new("#{attr} must be String")
128
+ end
129
+ end
130
+
131
+ if self.confirm && !self.confirm.is_a?(Confirm)
132
+ raise ValidationError.new("confirm must be Slack::Confirm")
133
+ end
134
+ end
135
+
136
+ def approved?
137
+ self.value == 'approve'
138
+ end
139
+
140
+ def rejected?
141
+ self.value == 'reject'
142
+ end
143
+
144
+ def cancelled?
145
+ self.value == 'cancel'
146
+ end
147
+
148
+ def as_json
149
+ self.to_h.tap do |h|
150
+ if h[:confirm]
151
+ h[:confirm] = h[:confirm].as_json
152
+ else
153
+ h.delete(:confirm)
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ Confirm = Struct.new(:title, :text, :ok_text, :dismiss_text, keyword_init: true) do
160
+ def self.approve
161
+ self.new(text: 'The operation will be performed immediately.')
162
+ end
163
+
164
+ def initialize(*args)
165
+ super
166
+ self.set_default!
167
+ self.validate!
168
+ self
169
+ end
170
+
171
+ def set_default!
172
+ self.title ||= 'Are you sure?'
173
+ self.text ||= ''
174
+ self.ok_text ||= 'Yes'
175
+ self.dismiss_text ||= 'No'
176
+ end
177
+
178
+ def validate!
179
+ %i(title text ok_text dismiss_text).each do |label|
180
+ unless self.send(label).is_a?(String)
181
+ raise ValidationError.new("#{attr} must be String")
182
+ end
183
+ end
184
+ end
185
+
186
+ def as_json
187
+ self.to_h
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,32 @@
1
+ require 'base64'
2
+ require 'openssl'
3
+
4
+ module Banacle
5
+ class SlackValidator
6
+ SLACK_SIGNING_SECRET_VERSION = 'v0'.freeze
7
+
8
+ def self.valid_signature?(request)
9
+ new.valid_signature?(request)
10
+ end
11
+
12
+ def valid_signature?(request)
13
+ body = request.env["rack.request.form_vars"]
14
+ slack_signature = request.env["HTTP_X_SLACK_SIGNATURE"]
15
+ slack_timestamp = request.env["HTTP_X_SLACK_REQUEST_TIMESTAMP"]
16
+
17
+ # https://api.slack.com/docs/verifying-requests-from-slack#verification_token_deprecation
18
+ if (slack_timestamp.to_i - Time.now.to_i).abs > 60 * 5
19
+ return false
20
+ end
21
+
22
+ sig_basestring = "#{SLACK_SIGNING_SECRET_VERSION}:#{slack_timestamp}:#{body}"
23
+ digest = OpenSSL::HMAC.hexdigest("SHA256", signing_secret, sig_basestring)
24
+
25
+ slack_signature == "#{SLACK_SIGNING_SECRET_VERSION}=#{digest}"
26
+ end
27
+
28
+ def signing_secret
29
+ ENV.fetch('BANACLE_SLACK_SIGNING_SECRET')
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,100 @@
1
+ require 'ipaddr'
2
+
3
+ require 'banacle/aws_wrapper/vpc'
4
+ require 'banacle/slash_command/error'
5
+ require 'banacle/slash_command/command'
6
+
7
+ module Banacle
8
+ module SlashCommand
9
+ class Builder
10
+ class InvalidActionError < Error; end
11
+ class InvalidRegionError < Error; end
12
+ class InvalidVpcError < Error; end
13
+ class InvalidCidrBlockError < Error; end
14
+
15
+ def self.build(action:, region:, vpc_id_or_name:, cidr_blocks:)
16
+ new(action: action, region: region, vpc_id_or_name: vpc_id_or_name, cidr_blocks: cidr_blocks).build
17
+ end
18
+
19
+ def initialize(action:, region:, vpc_id_or_name:, cidr_blocks:)
20
+ @action = action
21
+ @region = region
22
+ @vpc_id_or_name = vpc_id_or_name
23
+ @cidr_blocks = cidr_blocks
24
+ end
25
+
26
+ attr_reader :action, :region, :vpc_id_or_name, :cidr_blocks
27
+ attr_accessor :vpc_id
28
+
29
+ def build
30
+ validate!
31
+ set_vpc_id!
32
+ normalize_cidr_blocks!
33
+
34
+ Command.new(action: action, region: region, vpc_id: vpc_id, cidr_blocks: cidr_blocks)
35
+ end
36
+
37
+ def validate!
38
+ validate_action!
39
+ validate_region!
40
+ validate_vpc_id_or_name!
41
+ validate_cidr_blocks!
42
+ end
43
+
44
+ def validate_action!
45
+ if !action || action.empty?
46
+ raise InvalidActionError.new("action is required")
47
+ end
48
+
49
+ unless Command::PERMITTED_ACTIONS.include?(action)
50
+ raise InvalidActionError.new("permitted actions are: (#{Command::PERMITTED_ACTIONS.join("|")})")
51
+ end
52
+ end
53
+
54
+ def validate_region!
55
+ if !region || region.empty?
56
+ raise InvalidRegionError.new("region is required")
57
+ end
58
+ end
59
+
60
+ def validate_vpc_id_or_name!
61
+ unless vpc_id_or_name
62
+ raise InvalidVpcError.new("vpc_id or vpc_name is required with #{action} action")
63
+ end
64
+ end
65
+
66
+ def validate_cidr_blocks!
67
+ if !cidr_blocks || cidr_blocks.empty?
68
+ raise InvalidVpcError.new("at least one cidr_block is required with #{action} action")
69
+ end
70
+
71
+ cidr_blocks.each do |cidr_block|
72
+ begin
73
+ IPAddr.new(cidr_block)
74
+ rescue IPAddr::InvalidAddressError
75
+ raise InvalidCidrBlockError.new("#{cidr_block} is invalid address")
76
+ end
77
+ end
78
+ end
79
+
80
+ def set_vpc_id!
81
+ begin
82
+ self.vpc_id = AwsWrapper::Vpc.resolve_vpc_id(region, vpc_id_or_name)
83
+ rescue AwsWrapper::Vpc::InvalidRegionError
84
+ raise InvalidRegionError.new("specified region: #{region} is invalid")
85
+ end
86
+
87
+ unless vpc_id
88
+ raise InvalidVpcError.new("specified vpc: #{vpc_id_or_name} in #{region} not found")
89
+ end
90
+ end
91
+
92
+ def normalize_cidr_blocks!
93
+ cidr_blocks.map! do |c|
94
+ ip = IPAddr.new(c)
95
+ "#{ip}/#{ip.prefix}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,75 @@
1
+ require 'banacle/aws_wrapper/nacl'
2
+ require 'banacle/aws_wrapper/vpc'
3
+
4
+ module Banacle
5
+ module SlashCommand
6
+ class Command
7
+ CREATE_ACTION = 'create'.freeze
8
+ DELETE_ACTION = 'delete'.freeze
9
+
10
+ PERMITTED_ACTIONS = [CREATE_ACTION, DELETE_ACTION].freeze
11
+
12
+ def initialize(action:, region:, vpc_id:, cidr_blocks:)
13
+ @action = action
14
+ @region = region
15
+ @vpc_id = vpc_id
16
+ @cidr_blocks = cidr_blocks
17
+ end
18
+
19
+ attr_reader :action, :region, :vpc_id, :cidr_blocks
20
+
21
+ def execute
22
+ case action
23
+ when CREATE_ACTION
24
+ create_nacl
25
+ when DELETE_ACTION
26
+ delete_nacl
27
+ else
28
+ # Do nothing
29
+ end
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ action: action,
35
+ region: region,
36
+ vpc_id: vpc_id,
37
+ cidr_blocks: cidr_blocks,
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def create_nacl
44
+ results = AwsWrapper::Nacl.create_network_acl_ingress_entries(
45
+ region: region,
46
+ vpc_id: vpc_id,
47
+ cidr_blocks: cidr_blocks,
48
+ )
49
+
50
+ format_results(results)
51
+ end
52
+
53
+ def delete_nacl
54
+ results = AwsWrapper::Nacl.delete_network_acl_entries(
55
+ region: region,
56
+ vpc_id: vpc_id,
57
+ cidr_blocks: cidr_blocks,
58
+ )
59
+
60
+ format_results(results)
61
+ end
62
+
63
+ def format_results(results)
64
+ results.map do |cidr_block, result|
65
+ t = "#{action} DENY #{cidr_block} => "
66
+ if result.status
67
+ t += "succeeded"
68
+ else
69
+ t += "error: #{result.error}"
70
+ end
71
+ end.join("\n")
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,6 @@
1
+ module Banacle
2
+ module SlashCommand
3
+ class Error < StandardError
4
+ end
5
+ end
6
+ end
@@ -0,0 +1,42 @@
1
+ require 'banacle/slash_command/error'
2
+ require 'banacle/slash_command/builder'
3
+
4
+ module Banacle
5
+ module SlashCommand
6
+ class Parser
7
+ class ParseError < Error; end
8
+
9
+ def self.parse(text)
10
+ new.parse(text)
11
+ end
12
+
13
+ #
14
+ # /banacle (create|delete) [region] [vpc_id or vpc_name] [cidr_block1,cidr_block2,...]
15
+ #
16
+ def parse(text)
17
+ elems = text.split(" ")
18
+ action, region, vpc_id_or_name, cidr_blocks_str = elems
19
+
20
+ unless action
21
+ raise ParseError.new("action is required")
22
+ end
23
+
24
+ unless region
25
+ raise ParseError.new("region is required")
26
+ end
27
+
28
+ cidr_blocks = []
29
+ if cidr_blocks_str
30
+ cidr_blocks = cidr_blocks_str.split(",")
31
+ end
32
+
33
+ SlashCommand::Builder.build(
34
+ action: action,
35
+ region: region,
36
+ vpc_id_or_name: vpc_id_or_name,
37
+ cidr_blocks: cidr_blocks,
38
+ )
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,56 @@
1
+ require 'banacle/slack'
2
+ require 'banacle/slash_command/builder'
3
+ require 'banacle/slash_command/command'
4
+
5
+ module Banacle
6
+ module SlashCommand
7
+ class Renderer
8
+ def self.render(params, command)
9
+ new.render(params, command)
10
+ end
11
+
12
+ def self.render_error(error)
13
+ new.render_error(error)
14
+ end
15
+
16
+ def render(params, command)
17
+ render_approval_request(params, command)
18
+ end
19
+
20
+ def render_error(error)
21
+ Slack::Response.new(
22
+ response_type: "ephemeral",
23
+ text: "An error occurred: #{error}",
24
+ ).to_json
25
+ end
26
+
27
+ def render_approval_request(params, command)
28
+ text = <<-EOS
29
+ <@#{params["user_id"]}> wants to *#{command.action} NACL DENY entry* under the following conditions:
30
+ ```
31
+ #{JSON.pretty_generate(command.to_h)}
32
+ ```
33
+ EOS
34
+
35
+ Slack::Response.new(
36
+ response_type: "in_channel",
37
+ text: text,
38
+ attachments: [
39
+ Slack::Attachment.new(
40
+ text: "*Approval Request*",
41
+ fallback: "TBD",
42
+ callback_id: "banacle_approval_request",
43
+ color: "#3AA3E3",
44
+ attachment_type: "default",
45
+ actions: [
46
+ Slack::Action.approve_button,
47
+ Slack::Action.reject_button,
48
+ Slack::Action.cancel_button,
49
+ ]
50
+ ),
51
+ ],
52
+ ).to_json
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,3 @@
1
+ module Banacle
2
+ VERSION = "0.1.1"
3
+ end
data/lib/banacle.rb ADDED
@@ -0,0 +1,2 @@
1
+ require "banacle/version"
2
+ require "banacle/app"