cased-ruby 0.3.3 → 0.4.0

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