cased-ruby 0.3.3 → 0.4.0

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 +14 -41
  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 +67 -0
  10. data/lib/cased/cli/authentication.rb +31 -0
  11. data/lib/cased/cli/identity.rb +38 -0
  12. data/lib/cased/cli/interactive_session.rb +84 -0
  13. data/lib/cased/cli/log.rb +25 -0
  14. data/lib/cased/cli/recorder.rb +57 -0
  15. data/lib/cased/cli/session.rb +278 -0
  16. data/lib/cased/clients.rb +7 -3
  17. data/lib/cased/config.rb +40 -0
  18. data/lib/cased/http/client.rb +13 -6
  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,38 @@
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
+
27
+ while user_id.nil?
28
+ count += 1
29
+ response = Cased.clients.cli.get(poll_url)
30
+ user_id = response.body.dig('user', 'id')
31
+ sleep 1 if user_id.nil?
32
+ end
33
+
34
+ user_id
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,84 @@
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
+ if session.create
34
+ handle_state(session.state)
35
+ elsif session.unauthorized?
36
+ if session.authentication.exists?
37
+ Cased::CLI::Log.log "Existing credentials at #{session.authentication.credentials_path} are not valid."
38
+ else
39
+ Cased::CLI::Log.log "Could not find credentials at #{session.authentication.credentials_path}, looking up now…"
40
+ end
41
+
42
+ identity = Cased::CLI::Identity.new
43
+ session.authentication.token = identity.identify
44
+
45
+ create
46
+ elsif session.reason_required?
47
+ reason_prompt && create
48
+ else
49
+ Cased::CLI::Log.log 'Could not start CLI session.'
50
+ exit 1 if Cased.config.guard_deny_if_unreachable?
51
+ end
52
+
53
+ session
54
+ end
55
+
56
+ private
57
+
58
+ def reason_prompt
59
+ print Cased::CLI::Log.string 'Please enter a reason for access: '
60
+ session.reason = gets.chomp
61
+ end
62
+
63
+ def wait_for_approval
64
+ session.refresh && handle_state(session.state)
65
+ end
66
+
67
+ def handle_state(state)
68
+ case state
69
+ when 'approved'
70
+ Cased::CLI::Log.log 'CLI session has been approved'
71
+ session.record
72
+ when 'requested'
73
+ wait_for_approval
74
+ when 'denied'
75
+ Cased::CLI::Log.log 'CLI session has been denied'
76
+ when 'timed_out'
77
+ Cased::CLI::Log.log 'CLI session has timed out'
78
+ when 'canceled'
79
+ Cased::CLI::Log.log 'CLI session has been canceled'
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,25 @@
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
+ end
17
+
18
+ def self.color(text, color, bold = false)
19
+ color = self.class.const_get(color.upcase) if color.is_a?(Symbol)
20
+ bold = bold ? BOLD : ''
21
+ "#{bold}#{color}#{text}#{CLEAR}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,57 @@
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
+
10
+ attr_reader :command
11
+ attr_reader :events
12
+ attr_reader :started_at
13
+ attr_reader :width
14
+ attr_reader :height
15
+ attr_reader :options
16
+ attr_accessor :writer
17
+
18
+ # @return [Boolean] if CLI session is being recorded.
19
+ def self.recording?
20
+ ENV[KEY] == '1'
21
+ end
22
+
23
+ def initialize(command, env: {})
24
+ @command = command
25
+ @events = []
26
+ @width = Subprocess.check_output(%w[tput cols]).strip.to_i
27
+ @height = Subprocess.check_output(%w[tput lines]).strip.to_i
28
+
29
+ subprocess_env = ENV.to_h.dup
30
+ subprocess_env[KEY] = '1'
31
+ subprocess_env.merge!(env)
32
+ @writer = Cased::CLI::Asciinema::Writer.new(
33
+ command: command.join(' '),
34
+ width: width,
35
+ height: height,
36
+ )
37
+
38
+ @options = {
39
+ stdout: Subprocess::PIPE,
40
+ env: subprocess_env,
41
+ }
42
+ end
43
+
44
+ def start
45
+ writer.time do
46
+ Subprocess.check_call(command, options) do |t|
47
+ t.communicate do |stdout, _stderr|
48
+ STDOUT.write(stdout)
49
+
50
+ writer << stdout.gsub("\n", "\r\n")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,278 @@
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
+ return @current if defined?(@current)
26
+
27
+ @current = if ENV['GUARD_SESSION_ID']
28
+ Cased::CLI::Session.find(ENV['GUARD_SESSION_ID'])
29
+ end
30
+ end
31
+
32
+ def self.current?
33
+ current.present?
34
+ end
35
+
36
+ class << self
37
+ attr_writer :current
38
+ end
39
+
40
+ # @return [Cased::CLI::Authentication]
41
+ attr_reader :authentication
42
+
43
+ # Public: The CLI session ID
44
+ # @example
45
+ # session.id #=> "guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
46
+ # @return [String, nil]
47
+ attr_reader :id
48
+
49
+ # Public: The CLI session web URL
50
+ # @example
51
+ # session.url #=> "https://api.cased.com/cli/programs/ruby/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
52
+ # @return [String, nil]
53
+ attr_reader :url
54
+
55
+ # Public: The CLI session API URL
56
+ # @example
57
+ # session.api_url #=> "https://api.cased.com/cli/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ"
58
+ # @return [String, nil]
59
+ attr_reader :api_url
60
+
61
+ # Public: The CLI session record API URL
62
+ # @example
63
+ # session.api_record_url #=> "https://api.cased.com/cli/sessions/guard_session_1oFqm5GBQYwhH8pfIpnS0A5QgFJ/record"
64
+ # @return [String, nil]
65
+ attr_reader :api_record_url
66
+
67
+ # Public: The current state the CLI session is in
68
+ # @example
69
+ # session.api_url #=> "approved"
70
+ # @return [String, nil]
71
+ attr_reader :state
72
+
73
+ # Public: Command that invoked CLI session.
74
+ # @example
75
+ # session.command #=> "/usr/local/bin/rails console"
76
+ # @return [String]
77
+ attr_accessor :command
78
+
79
+ # Public: Additional user supplied metadata about the CLI session.
80
+ # @example
81
+ # session.metadata #=> {"hostname" => "Mac.local"}
82
+ # @return [Hash]
83
+ attr_accessor :metadata
84
+
85
+ # Public: The user supplied reason for the CLI session for taking place.
86
+ # @example
87
+ # session.reason #=> "Investigating customer support ticket."
88
+ # @return [String, nil]
89
+ attr_accessor :reason
90
+
91
+ # Public: The client's IP V4 or IP V6 address that initiated the CLI session.
92
+ # @example
93
+ # session.reason #=> "1.1.1.1"
94
+ # @return [String, nil]
95
+ attr_reader :ip_address
96
+
97
+ # Public: The Cased user that requested the CLI session.
98
+ # @example
99
+ # session.requester #=> {"id" => "user_1oFqlROLNRGVLOXJSsHkJiVmylr"}
100
+ # @return [Hash, nil]
101
+ attr_reader :requester
102
+
103
+ # Public: The Cased user that requested the CLI session.
104
+ # @example
105
+ # session.responded_at #=> "2021-02-10 12:08:44 -0800"
106
+ # @return [Time, nil]
107
+ attr_reader :responded_at
108
+
109
+ # Public: The Cased user that responded to the CLI session.
110
+ # @example
111
+ # session.responder #=> {"id" => "user_1oFqlROLNRGVLOXJSsHkJiVmylr"}
112
+ # @return [Hash, nil]
113
+ attr_reader :responder
114
+
115
+ # Public: The CLI application that the CLI session belongs to.
116
+ # @example
117
+ # session.guard_application #=> {"id" => "guard_application_1oFqltbMqSEtJQKRCAYQNrQoXsS"}
118
+ # @return [Hash, nil]
119
+ attr_reader :guard_application
120
+
121
+ def initialize(reason: nil, command: nil, metadata: {}, authentication: nil)
122
+ @authentication = authentication || Cased::CLI::Authentication.new
123
+ @reason = reason
124
+ @command = command
125
+ @metadata = metadata
126
+ @requester = {}
127
+ @responder = {}
128
+ @guard_application = {}
129
+ end
130
+
131
+ def to_s
132
+ command
133
+ end
134
+
135
+ def to_param
136
+ id
137
+ end
138
+
139
+ def session=(session)
140
+ @error = nil
141
+ @id = session.fetch('id')
142
+ @api_url = session.fetch('api_url')
143
+ @api_record_url = session.fetch('api_record_url')
144
+ @url = session.fetch('url')
145
+ @state = session.fetch('state')
146
+ @command = session.fetch('command')
147
+ @metadata = session.fetch('metadata')
148
+ @reason = session.fetch('reason')
149
+ @ip_address = session.fetch('ip_address')
150
+ @requester = session.fetch('requester')
151
+ @responded_at = session['responded_at']
152
+ @responder = session['responder'] || {}
153
+ @guard_application = session.fetch('guard_application')
154
+ end
155
+
156
+ def requested?
157
+ state == 'requested'
158
+ end
159
+
160
+ def approved?
161
+ state == 'approved'
162
+ end
163
+
164
+ def denied?
165
+ state == 'denied'
166
+ end
167
+
168
+ def canceled?
169
+ state == 'canceled'
170
+ end
171
+
172
+ def timed_out?
173
+ state == 'timed_out'
174
+ end
175
+
176
+ def refresh
177
+ return false unless api_url
178
+
179
+ response = Cased.clients.cli.get(api_url, user_token: authentication.token)
180
+ self.session = response.body if response.success?
181
+ end
182
+
183
+ def error?
184
+ !error.nil?
185
+ end
186
+
187
+ def success?
188
+ id && !error?
189
+ end
190
+
191
+ def reason_required?
192
+ error == :reason_required || guard_application.dig('settings', 'reason_required')
193
+ end
194
+
195
+ def unauthorized?
196
+ error == :unauthorized
197
+ end
198
+
199
+ def record_output?
200
+ guard_application.dig('settings', 'record_output') || false
201
+ end
202
+
203
+ def record
204
+ return unless recordable? && record_output?
205
+
206
+ Cased::CLI::Log.log 'CLI session is now recording'
207
+
208
+ recorder = Cased::CLI::Recorder.new(command.split(' '), env: {
209
+ 'GUARD_SESSION_ID' => id,
210
+ 'GUARD_APPLICATION_ID' => guard_application.fetch('id'),
211
+ 'GUARD_USER_TOKEN' => requester.fetch('id'),
212
+ })
213
+ recorder.start
214
+
215
+ Cased.clients.cli.put(api_record_url,
216
+ recording: recorder.writer.to_cast,
217
+ user_token: authentication.token)
218
+
219
+ Cased::CLI::Log.log 'CLI session recorded'
220
+ end
221
+
222
+ def create
223
+ return false unless id.nil?
224
+
225
+ response = Cased.clients.cli.post('cli/sessions',
226
+ user_token: authentication.token,
227
+ reason: reason,
228
+ metadata: metadata,
229
+ command: command)
230
+ if response.success?
231
+ self.session = response.body
232
+ else
233
+ case response.body['error']
234
+ when 'reason_required'
235
+ @error = :reason_required
236
+ when 'unauthorized'
237
+ @error = :unauthorized
238
+ else
239
+ @error = true
240
+ return false
241
+ end
242
+ end
243
+
244
+ response.success?
245
+ end
246
+
247
+ def cancel
248
+ response = Cased.clients.cli.post("#{api_url}/cancel", user_token: authentication.token)
249
+ self.session = response.body if response.success?
250
+
251
+ canceled?
252
+ end
253
+
254
+ def cased_category
255
+ :cli
256
+ end
257
+
258
+ def cased_id
259
+ id
260
+ end
261
+
262
+ def cased_context(category: cased_category)
263
+ {
264
+ "#{category}_id".to_sym => cased_id,
265
+ category.to_sym => to_s,
266
+ }
267
+ end
268
+
269
+ def recordable?
270
+ STDOUT.isatty
271
+ end
272
+
273
+ private
274
+
275
+ attr_reader :error
276
+ end
277
+ end
278
+ end