cased-ruby 0.3.3 → 0.4.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +20 -15
  3. data/README.md +135 -54
  4. data/bin/cli +14 -0
  5. data/cased-ruby.gemspec +2 -0
  6. data/lib/cased.rb +1 -0
  7. data/lib/cased/cli.rb +13 -0
  8. data/lib/cased/cli/asciinema/file.rb +108 -0
  9. data/lib/cased/cli/asciinema/writer.rb +69 -0
  10. data/lib/cased/cli/authentication.rb +31 -0
  11. data/lib/cased/cli/identity.rb +43 -0
  12. data/lib/cased/cli/interactive_session.rb +121 -0
  13. data/lib/cased/cli/log.rb +27 -0
  14. data/lib/cased/cli/recorder.rb +58 -0
  15. data/lib/cased/cli/session.rb +292 -0
  16. data/lib/cased/clients.rb +7 -3
  17. data/lib/cased/config.rb +58 -0
  18. data/lib/cased/http/client.rb +13 -8
  19. data/lib/cased/http/error.rb +5 -2
  20. data/lib/cased/query.rb +6 -3
  21. data/lib/cased/version.rb +1 -1
  22. data/vendor/cache/activesupport-6.1.3.gem +0 -0
  23. data/vendor/cache/concurrent-ruby-1.1.8.gem +0 -0
  24. data/vendor/cache/faraday-1.3.0.gem +0 -0
  25. data/vendor/cache/faraday-net_http-1.0.1.gem +0 -0
  26. data/vendor/cache/i18n-1.8.9.gem +0 -0
  27. data/vendor/cache/json-2.5.1.gem +0 -0
  28. data/vendor/cache/jwt-2.2.2.gem +0 -0
  29. data/vendor/cache/ruby2_keywords-0.0.4.gem +0 -0
  30. data/vendor/cache/subprocess-1.5.4.gem +0 -0
  31. data/vendor/cache/tzinfo-2.0.4.gem +0 -0
  32. data/vendor/cache/zeitwerk-2.4.2.gem +0 -0
  33. metadata +51 -11
  34. data/vendor/cache/activesupport-6.0.3.4.gem +0 -0
  35. data/vendor/cache/concurrent-ruby-1.1.7.gem +0 -0
  36. data/vendor/cache/faraday-1.1.0.gem +0 -0
  37. data/vendor/cache/i18n-1.8.5.gem +0 -0
  38. data/vendor/cache/json-2.3.1.gem +0 -0
  39. data/vendor/cache/ruby2_keywords-0.0.2.gem +0 -0
  40. data/vendor/cache/thread_safe-0.3.6.gem +0 -0
  41. data/vendor/cache/tzinfo-1.2.7.gem +0 -0
  42. data/vendor/cache/zeitwerk-2.4.0.gem +0 -0
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cased
6
+ module CLI
7
+ # Spec: https://github.com/asciinema/asciinema/blob/develop/doc/asciicast-v2.md
8
+ module Asciinema
9
+ class Writer
10
+ VERSION = 2
11
+
12
+ attr_accessor :width
13
+ attr_accessor :height
14
+ attr_reader :command
15
+ attr_reader :stream
16
+ attr_reader :started_at
17
+ attr_reader :finished_at
18
+
19
+ def initialize(command: [], width: 80, height: 24)
20
+ @command = command
21
+ @width = width
22
+ @height = height
23
+ @stream = []
24
+ @started_at = Time.now
25
+ end
26
+
27
+ def <<(output)
28
+ stream << [Time.now - started_at, 'o', output]
29
+ end
30
+
31
+ def time
32
+ @started_at = Time.now
33
+ ret = yield
34
+ @finished_at = Time.now
35
+ ret
36
+ end
37
+
38
+ def to_cast
39
+ # In the event we didn't run the writer in a #time block, we should
40
+ # set the finished time if it isn't set.
41
+ @finished_at ||= Time.now
42
+
43
+ File.new(header, stream).to_cast
44
+ end
45
+
46
+ def header
47
+ {
48
+ 'version' => VERSION,
49
+ 'env' => {
50
+ 'SHELL' => ENV['SHELL'],
51
+ 'TERM' => ENV['TERM'],
52
+ },
53
+ 'width' => width,
54
+ 'height' => height,
55
+ 'command' => command.join(' '),
56
+ }.tap do |h|
57
+ if started_at
58
+ h['timestamp'] = started_at.to_i
59
+ end
60
+
61
+ if started_at && finished_at
62
+ h['duration'] = finished_at - started_at
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+
5
+ module Cased
6
+ module CLI
7
+ class Authentication
8
+ attr_reader :directory
9
+ attr_reader :credentials_path
10
+ attr_writer :token
11
+
12
+ def initialize(token: nil)
13
+ @token = token || Cased.config.guard_user_token
14
+ @directory = Pathname.new(File.expand_path('~/.cguard'))
15
+ @credentials_path = @directory.join('credentials')
16
+ end
17
+
18
+ def exists?
19
+ !token.nil?
20
+ end
21
+
22
+ def token
23
+ @token ||= begin
24
+ credentials_path.read
25
+ rescue Errno::ENOENT
26
+ nil
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cased
4
+ module CLI
5
+ class Identity
6
+ def initialize
7
+ @timeout = 30
8
+ end
9
+
10
+ def identify
11
+ response = Cased.clients.cli.post('cli/applications/users/identify')
12
+ case response.status
13
+ when 201 # Created
14
+ url = response.body.fetch('url')
15
+ Cased::CLI::Log.log 'To login, please visit:'
16
+ puts url
17
+ poll(response.body['api_url'])
18
+ when 401 # Unauthorized
19
+ false
20
+ end
21
+ end
22
+
23
+ def poll(poll_url)
24
+ count = 0
25
+ user_id = nil
26
+ ip_address = nil
27
+
28
+ while user_id.nil?
29
+ count += 1
30
+ response = Cased.clients.cli.get(poll_url)
31
+ if response.success?
32
+ user_id = response.body.dig('user', 'id')
33
+ ip_address = response.body.fetch('ip_address')
34
+ else
35
+ sleep 1
36
+ end
37
+ end
38
+
39
+ [user_id, ip_address]
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cased/cli/session'
4
+
5
+ module Cased
6
+ module CLI
7
+ # InteractiveSession is responsible for initiating a Cased CLI session and
8
+ # responding to all its possible states.
9
+ #
10
+ # InteractiveSession is intended to be used where a TTY is present to handle
11
+ # the entire flow from authentication, reason required, waiting for
12
+ # approval, canceled, or timed out.
13
+ class InteractiveSession
14
+ def self.start(reason: nil, command: nil, metadata: {})
15
+ return Cased::CLI::Session.current if Cased::CLI::Session.current&.approved?
16
+
17
+ Cased::CLI::Log.log 'Running under Cased CLI.'
18
+
19
+ new(reason: reason, command: command, metadata: metadata).create
20
+ end
21
+
22
+ attr_reader :session
23
+
24
+ def initialize(reason: nil, command: nil, metadata: {})
25
+ @session = Cased::CLI::Session.new(
26
+ reason: reason,
27
+ command: command,
28
+ metadata: metadata,
29
+ )
30
+ end
31
+
32
+ def create
33
+ signal_handler = Signal.trap('SIGINT') do
34
+ if session.requested?
35
+ Cased::CLI::Log.log 'Exiting and canceling request…'
36
+ session.cancel
37
+ exit 0
38
+ elsif signal_handler.respond_to?(:call)
39
+ # We need to call the original handler if we exit this interactive
40
+ # session successfully
41
+ signal_handler.call
42
+ else
43
+ raise Interrupt
44
+ end
45
+ end
46
+
47
+ if session.create
48
+ handle_state(session.state)
49
+ elsif session.reauthenticate?
50
+ Cased::CLI::Log.log "You must re-authenticate with Cased due to recent changes to this application's settings."
51
+
52
+ identity = Cased::CLI::Identity.new
53
+ token, ip_address = identity.identify
54
+ session.authentication.token = token
55
+ session.forwarded_ip_address = ip_address
56
+
57
+ create
58
+ elsif session.unauthorized?
59
+ if session.authentication.exists?
60
+ Cased::CLI::Log.log "Existing credentials at #{session.authentication.credentials_path} are not valid."
61
+ else
62
+ Cased::CLI::Log.log "Could not find credentials at #{session.authentication.credentials_path}, looking up now…"
63
+ end
64
+
65
+ identity = Cased::CLI::Identity.new
66
+ token, ip_address = identity.identify
67
+ session.authentication.token = token
68
+ session.forwarded_ip_address = ip_address
69
+
70
+ create
71
+ elsif session.reason_required?
72
+ reason_prompt && create
73
+ else
74
+ Cased::CLI::Log.log 'Could not start CLI session.'
75
+ exit 1 if Cased.config.guard_deny_if_unreachable?
76
+ end
77
+
78
+ session
79
+ end
80
+
81
+ private
82
+
83
+ def reason_prompt
84
+ print Cased::CLI::Log.string 'Please enter a reason for access: '
85
+ session.reason = STDIN.gets.chomp
86
+ end
87
+
88
+ def wait_for_approval
89
+ sleep 1
90
+ session.refresh && handle_state(session.state)
91
+ end
92
+
93
+ def waiting_for_approval_message
94
+ return if defined?(@waiting_for_approval_message_displayed)
95
+
96
+ motd = session.guard_application.dig('settings', 'message_of_the_day')
97
+ waiting_message = motd.blank? ? 'Approval request sent…' : motd
98
+ Cased::CLI::Log.log "#{waiting_message} (id: #{session.id})"
99
+ @waiting_for_approval_message_displayed = true
100
+ end
101
+
102
+ def handle_state(state)
103
+ case state
104
+ when 'approved'
105
+ Cased::CLI::Log.log 'CLI session has been approved'
106
+ session.record
107
+ when 'requested'
108
+ waiting_for_approval_message
109
+ wait_for_approval
110
+ when 'denied'
111
+ Cased::CLI::Log.log 'CLI session has been denied'
112
+ exit 1
113
+ when 'timed_out'
114
+ Cased::CLI::Log.log 'CLI session has timed out'
115
+ when 'canceled'
116
+ Cased::CLI::Log.log 'CLI session has been canceled'
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cased
4
+ module CLI
5
+ module Log
6
+ CLEAR = "\e[0m"
7
+ YELLOW = "\e[33m"
8
+ BOLD = "\e[1m"
9
+
10
+ def self.string(text)
11
+ [color('[cased]', YELLOW, true), text].join(' ')
12
+ end
13
+
14
+ def self.log(text)
15
+ puts string(text)
16
+ ensure
17
+ STDOUT.flush
18
+ end
19
+
20
+ def self.color(text, color, bold = false)
21
+ color = self.class.const_get(color.upcase) if color.is_a?(Symbol)
22
+ bold = bold ? BOLD : ''
23
+ "#{bold}#{color}#{text}#{CLEAR}"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'subprocess'
4
+
5
+ module Cased
6
+ module CLI
7
+ class Recorder
8
+ KEY = 'CASED_CLI_RECORDING'
9
+ TRUE = '1'
10
+
11
+ attr_reader :command
12
+ attr_reader :events
13
+ attr_reader :started_at
14
+ attr_reader :width
15
+ attr_reader :height
16
+ attr_reader :options
17
+ attr_accessor :writer
18
+
19
+ # @return [Boolean] if CLI session is being recorded.
20
+ def self.recording?
21
+ ENV[KEY] == TRUE
22
+ end
23
+
24
+ def initialize(command, env: {})
25
+ @command = command
26
+ @events = []
27
+ @width = Subprocess.check_output(%w[tput cols]).strip.to_i
28
+ @height = Subprocess.check_output(%w[tput lines]).strip.to_i
29
+
30
+ subprocess_env = ENV.to_h.dup
31
+ subprocess_env[KEY] = TRUE
32
+ subprocess_env.merge!(env)
33
+ @writer = Cased::CLI::Asciinema::Writer.new(
34
+ command: command,
35
+ width: width,
36
+ height: height,
37
+ )
38
+
39
+ @options = {
40
+ stdout: Subprocess::PIPE,
41
+ env: subprocess_env,
42
+ }
43
+ end
44
+
45
+ def start
46
+ writer.time do
47
+ Subprocess.check_call(command, options) do |t|
48
+ t.communicate do |stdout, _stderr|
49
+ STDOUT.write(stdout)
50
+
51
+ writer << stdout.gsub("\n", "\r\n")
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,292 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cased/cli/authentication'
4
+ require 'cased/cli/identity'
5
+ require 'cased/cli/recorder'
6
+ require 'cased/model'
7
+
8
+ module Cased
9
+ module CLI
10
+ class Session
11
+ include Cased::Model
12
+
13
+ def self.find(guard_session_id)
14
+ authentication = Cased::CLI::Authentication.new
15
+
16
+ response = Cased.clients.cli.get("cli/sessions/#{guard_session_id}", user_token: authentication.token)
17
+ new.tap do |session|
18
+ session.session = response.body
19
+ end
20
+ end
21
+
22
+ # If we're inside of a recorded session we can lookup the session
23
+ # we're in.
24
+ def self.current
25
+ @current ||= if ENV['GUARD_SESSION_ID']
26
+ Cased::CLI::Session.find(ENV['GUARD_SESSION_ID'])
27
+ end
28
+ end
29
+
30
+ def self.current?
31
+ current.present?
32
+ end
33
+
34
+ class << self
35
+ attr_writer :current
36
+ end
37
+
38
+ # @return [Cased::CLI::Authentication]
39
+ attr_reader :authentication
40
+
41
+ # Public: The CLI session ID
42
+ # @example
43
+ # session.id #=> "guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
44
+ # @return [String, nil]
45
+ attr_reader :id
46
+
47
+ # Public: The CLI session web URL
48
+ # @example
49
+ # session.url #=> "https://api.cased.com/cli/programs/ruby/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
50
+ # @return [String, nil]
51
+ attr_reader :url
52
+
53
+ # Public: The CLI session API URL
54
+ # @example
55
+ # session.api_url #=> "https://api.cased.com/cli/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
56
+ # @return [String, nil]
57
+ attr_reader :api_url
58
+
59
+ # Public: The CLI session record API URL
60
+ # @example
61
+ # session.api_record_url #=> "https://api.cased.com/cli/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ/record"
62
+ # @return [String, nil]
63
+ attr_reader :api_record_url
64
+
65
+ # Public: The current state the CLI session is in
66
+ # @example
67
+ # session.api_url #=> "approved"
68
+ # @return [String, nil]
69
+ attr_reader :state
70
+
71
+ # Public: Command that invoked CLI session.
72
+ # @example
73
+ # session.command #=> "/usr/local/bin/rails console"
74
+ # @return [String]
75
+ attr_accessor :command
76
+
77
+ # Public: Additional user supplied metadata about the CLI session.
78
+ # @example
79
+ # session.metadata #=> {"hostname" => "Mac.local"}
80
+ # @return [Hash]
81
+ attr_accessor :metadata
82
+
83
+ # Public: The user supplied reason for the CLI session for taking place.
84
+ # @example
85
+ # session.reason #=> "Investigating customer support ticket."
86
+ # @return [String, nil]
87
+ attr_accessor :reason
88
+
89
+ # Public: The forwarded IP V4 or IP V6 address of the user that initiated
90
+ # the CLI session.
91
+ #
92
+ # @example
93
+ # session.forwarded_ip_address #=> "1.1.1.1"
94
+ # @return [String, nil]
95
+ attr_accessor :forwarded_ip_address
96
+
97
+ # Public: The client's IP V4 or IP V6 address that initiated the CLI session.
98
+ # @example
99
+ # session.ip_address #=> "1.1.1.1"
100
+ # @return [String, nil]
101
+ attr_reader :ip_address
102
+
103
+ # Public: The Cased user that requested the CLI session.
104
+ # @example
105
+ # session.requester #=> {"id" => "user_1oFqlROLNRGVLOXJSsHkJiVmylr"}
106
+ # @return [Hash, nil]
107
+ attr_reader :requester
108
+
109
+ # Public: The Cased user that requested the CLI session.
110
+ # @example
111
+ # session.responded_at #=> "2021-02-10 12:08:44 -0800"
112
+ # @return [Time, nil]
113
+ attr_reader :responded_at
114
+
115
+ # Public: The Cased user that responded to the CLI session.
116
+ # @example
117
+ # session.responder #=> {"id" => "user_1oFqlROLNRGVLOXJSsHkJiVmylr"}
118
+ # @return [Hash, nil]
119
+ attr_reader :responder
120
+
121
+ # Public: The CLI application that the CLI session belongs to.
122
+ # @example
123
+ # session.guard_application #=> {"id" => "guard_application_1oFqltbMqSEtJQKRCAYQNrQoXsS"}
124
+ # @return [Hash, nil]
125
+ attr_reader :guard_application
126
+
127
+ def initialize(reason: nil, command: nil, metadata: {}, authentication: nil)
128
+ @authentication = authentication || Cased::CLI::Authentication.new
129
+ @reason = reason
130
+ @command = command || [$PROGRAM_NAME, *ARGV].join(' ')
131
+ @metadata = metadata
132
+ @requester = {}
133
+ @responder = {}
134
+ @guard_application = {}
135
+ end
136
+
137
+ def to_s
138
+ command
139
+ end
140
+
141
+ def to_param
142
+ id
143
+ end
144
+
145
+ def session=(session)
146
+ @error = nil
147
+ @id = session.fetch('id')
148
+ @api_url = session.fetch('api_url')
149
+ @api_record_url = session.fetch('api_record_url')
150
+ @url = session.fetch('url')
151
+ @state = session.fetch('state')
152
+ @command = session.fetch('command')
153
+ @metadata = session.fetch('metadata')
154
+ @reason = session.fetch('reason')
155
+ @forwarded_ip_address = session.fetch('forwarded_ip_address')
156
+ @ip_address = session.fetch('ip_address')
157
+ @requester = session.fetch('requester')
158
+ @responded_at = session['responded_at']
159
+ @responder = session['responder'] || {}
160
+ @guard_application = session.fetch('guard_application')
161
+ end
162
+
163
+ def requested?
164
+ state == 'requested'
165
+ end
166
+
167
+ def approved?
168
+ state == 'approved'
169
+ end
170
+
171
+ def denied?
172
+ state == 'denied'
173
+ end
174
+
175
+ def canceled?
176
+ state == 'canceled'
177
+ end
178
+
179
+ def timed_out?
180
+ state == 'timed_out'
181
+ end
182
+
183
+ def refresh
184
+ return false unless api_url
185
+
186
+ response = Cased.clients.cli.get(api_url, user_token: authentication.token)
187
+ self.session = response.body
188
+ end
189
+
190
+ def error?
191
+ !error.nil?
192
+ end
193
+
194
+ def success?
195
+ id && !error?
196
+ end
197
+
198
+ def reason_required?
199
+ error == :reason_required || guard_application.dig('settings', 'reason_required')
200
+ end
201
+
202
+ def unauthorized?
203
+ error == :unauthorized
204
+ end
205
+
206
+ def reauthenticate?
207
+ error == :reauthenticate
208
+ end
209
+
210
+ def record_output?
211
+ guard_application.dig('settings', 'record_output') || false
212
+ end
213
+
214
+ def record
215
+ return false unless recordable? && record_output?
216
+
217
+ Cased::CLI::Log.log 'CLI session is now recording'
218
+
219
+ recorder = Cased::CLI::Recorder.new(command.split(' '), env: {
220
+ 'GUARD_SESSION_ID' => id,
221
+ 'GUARD_APPLICATION_ID' => guard_application.fetch('id'),
222
+ 'GUARD_USER_TOKEN' => requester.fetch('id'),
223
+ })
224
+ recorder.start
225
+
226
+ Cased.clients.cli.put(api_record_url,
227
+ recording: recorder.writer.to_cast,
228
+ user_token: authentication.token)
229
+
230
+ Cased::CLI::Log.log 'CLI session recorded'
231
+ end
232
+
233
+ def create
234
+ return false unless id.nil?
235
+
236
+ response = Cased.clients.cli.post('cli/sessions',
237
+ user_token: authentication.token,
238
+ forwarded_ip_address: forwarded_ip_address,
239
+ reason: reason,
240
+ metadata: metadata,
241
+ command: command)
242
+ if response.success?
243
+ self.session = response.body
244
+ else
245
+ case response.body['error']
246
+ when 'reason_required'
247
+ @error = :reason_required
248
+ when 'unauthorized'
249
+ @error = :unauthorized
250
+ when 'reauthenticate'
251
+ @error = :reauthenticate
252
+ else
253
+ @error = true
254
+ return false
255
+ end
256
+ end
257
+
258
+ response.success?
259
+ end
260
+
261
+ def cancel
262
+ response = Cased.clients.cli.post("#{api_url}/cancel", user_token: authentication.token)
263
+ self.session = response.body
264
+
265
+ canceled?
266
+ end
267
+
268
+ def cased_category
269
+ :cli
270
+ end
271
+
272
+ def cased_id
273
+ id
274
+ end
275
+
276
+ def cased_context(category: cased_category)
277
+ {
278
+ "#{category}_id".to_sym => cased_id,
279
+ category.to_sym => to_s,
280
+ }
281
+ end
282
+
283
+ def recordable?
284
+ STDOUT.isatty
285
+ end
286
+
287
+ private
288
+
289
+ attr_reader :error
290
+ end
291
+ end
292
+ end