cased-ruby 0.3.3 → 0.4.4

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