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.
- checksums.yaml +4 -4
- data/Gemfile.lock +20 -15
- data/README.md +14 -41
- data/bin/cli +14 -0
- data/cased-ruby.gemspec +2 -0
- data/lib/cased.rb +1 -0
- data/lib/cased/cli.rb +13 -0
- data/lib/cased/cli/asciinema/file.rb +108 -0
- data/lib/cased/cli/asciinema/writer.rb +67 -0
- data/lib/cased/cli/authentication.rb +31 -0
- data/lib/cased/cli/identity.rb +38 -0
- data/lib/cased/cli/interactive_session.rb +84 -0
- data/lib/cased/cli/log.rb +25 -0
- data/lib/cased/cli/recorder.rb +57 -0
- data/lib/cased/cli/session.rb +278 -0
- data/lib/cased/clients.rb +7 -3
- data/lib/cased/config.rb +40 -0
- data/lib/cased/http/client.rb +13 -6
- data/lib/cased/http/error.rb +5 -2
- data/lib/cased/query.rb +6 -3
- data/lib/cased/version.rb +1 -1
- data/vendor/cache/activesupport-6.1.3.gem +0 -0
- data/vendor/cache/concurrent-ruby-1.1.8.gem +0 -0
- data/vendor/cache/faraday-1.3.0.gem +0 -0
- data/vendor/cache/faraday-net_http-1.0.1.gem +0 -0
- data/vendor/cache/i18n-1.8.9.gem +0 -0
- data/vendor/cache/json-2.5.1.gem +0 -0
- data/vendor/cache/jwt-2.2.2.gem +0 -0
- data/vendor/cache/ruby2_keywords-0.0.4.gem +0 -0
- data/vendor/cache/subprocess-1.5.4.gem +0 -0
- data/vendor/cache/tzinfo-2.0.4.gem +0 -0
- data/vendor/cache/zeitwerk-2.4.2.gem +0 -0
- metadata +51 -11
- data/vendor/cache/activesupport-6.0.3.4.gem +0 -0
- data/vendor/cache/concurrent-ruby-1.1.7.gem +0 -0
- data/vendor/cache/faraday-1.1.0.gem +0 -0
- data/vendor/cache/i18n-1.8.5.gem +0 -0
- data/vendor/cache/json-2.3.1.gem +0 -0
- data/vendor/cache/ruby2_keywords-0.0.2.gem +0 -0
- data/vendor/cache/thread_safe-0.3.6.gem +0 -0
- data/vendor/cache/tzinfo-1.2.7.gem +0 -0
- 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
|