etna 0.1.44 → 0.1.45

Sign up to get free protection for your applications and to get access to all the features.
data/lib/commands.rb CHANGED
@@ -2,6 +2,7 @@ require 'date'
2
2
  require 'logger'
3
3
  require 'rollbar'
4
4
  require 'tempfile'
5
+ require 'securerandom'
5
6
  require_relative 'helpers'
6
7
  require 'yaml'
7
8
 
@@ -88,6 +89,123 @@ class EtnaApp
88
89
  end
89
90
  end
90
91
 
92
+ class Metis
93
+ include Etna::CommandExecutor
94
+ string_flags << "--project-name"
95
+ string_flags << "--bucket-name"
96
+ string_flags << "--path"
97
+ ROLLING_COUNT = 14
98
+
99
+ class PutArchive < Etna::Command
100
+ include WithEtnaClients
101
+
102
+ def execute(file, project_name:, bucket_name:, path:)
103
+ @project_name = project_name
104
+ @bucket_name = bucket_name
105
+ basename = ::File.basename(path)
106
+ dir = ::File.dirname(path)
107
+
108
+ puts "Creating archive folder"
109
+ metis_client.create_folder(Etna::Clients::Metis::CreateFolderRequest.new(
110
+ project_name: project_name,
111
+ bucket_name: bucket_name,
112
+ folder_path: dir
113
+ ))
114
+
115
+ puts "Listing archive folder"
116
+ files = metis_client.list_folder(Etna::Clients::Metis::ListFolderRequest.new(
117
+ project_name: project_name,
118
+ bucket_name: bucket_name,
119
+ folder_path: dir
120
+ )).files.all
121
+
122
+ if files.select { |f| f.file_name == basename }.length > 0
123
+ timestr = DateTime.now.strftime("%Y%m%d_%H%M")
124
+ puts "Backing up #{dir}/#{basename} to #{dir}/#{basename}.#{timestr}"
125
+ metis_client.rename_file(Etna::Clients::Metis::RenameFileRequest.new(
126
+ project_name: project_name,
127
+ bucket_name: bucket_name,
128
+ file_path: ::File.join(dir, basename),
129
+ new_bucket_name: bucket_name,
130
+ new_file_path: ::File.join(dir, "#{basename}.#{timestr}"),
131
+ ))
132
+ end
133
+
134
+ create_upload_workflow.do_upload(
135
+ ::File.open(file, "r"),
136
+ path
137
+ ) do |progress|
138
+ case progress[0]
139
+ when :error
140
+ puts("Error while uploading: #{progress[1].to_s}")
141
+ else
142
+ end
143
+ end
144
+ puts "Completed upload"
145
+
146
+ backups = files.select { |f| f.file_name != basename }.sort_by(&:file_name).reverse
147
+ if backups.length > ROLLING_COUNT
148
+ backups.slice(ROLLING_COUNT..-1).reverse.each do |f|
149
+ puts "Removing rolling back up #{f.file_name}"
150
+ metis_client.delete_file(Etna::Clients::Metis::DeleteFileRequest.new(
151
+ project_name: project_name,
152
+ bucket_name: bucket_name,
153
+ file_path: ::File.join(dir, f.file_name)
154
+ ))
155
+ end
156
+ end
157
+ end
158
+
159
+ def create_upload_workflow
160
+ Etna::Clients::Metis::MetisUploadWorkflow.new(
161
+ metis_client: @metis_client,
162
+ metis_uid: SecureRandom.hex,
163
+ project_name: @project_name,
164
+ bucket_name: @bucket_name,
165
+ max_attempts: 3
166
+ )
167
+ end
168
+ end
169
+
170
+ class PullArchive < Etna::Command
171
+ include WithEtnaClients
172
+
173
+ def execute(project_name:, bucket_name:, path:)
174
+ @project_name = project_name
175
+ @bucket_name = bucket_name
176
+ basename = ::File.basename(path)
177
+ dir = ::File.dirname(path)
178
+
179
+ files = metis_client.list_folder(Etna::Clients::Metis::ListFolderRequest.new(
180
+ project_name: project_name,
181
+ bucket_name: bucket_name,
182
+ folder_path: dir
183
+ )).files.all
184
+
185
+ files.sort_by(&:file_name).reverse
186
+
187
+ archived = files.select { |f| f.file_name == basename }.first
188
+ if archived.nil?
189
+ archived = files.first
190
+ end
191
+
192
+ tmp = Tempfile.new('download', Dir.pwd)
193
+ begin
194
+ puts "Downloading to #{basename} from #{archived.file_name}"
195
+ metis_client.download_file(archived) do |chunk|
196
+ tmp.write(chunk)
197
+ end
198
+
199
+ # Atomic operation -- ensure we only place the file in position when it successfully loads
200
+ ::File.rename(tmp.path, ::File.join(::Dir.pwd, basename))
201
+ rescue
202
+ tmp.close!
203
+ raise
204
+ end
205
+ end
206
+ end
207
+ end
208
+
91
209
  class Administrate
92
210
  include Etna::CommandExecutor
93
211
 
@@ -50,41 +50,8 @@ module Etna::Application
50
50
  Etna::Application.register(self)
51
51
  end
52
52
 
53
- # a <- b
54
- def deep_merge(a, b)
55
- if a.is_a?(Hash)
56
- if b.is_a?(Hash)
57
- b.keys.each do |b_key|
58
- a[b_key] = deep_merge(a[b_key], b[b_key])
59
- end
60
-
61
- return a
62
- end
63
- end
64
-
65
- a.nil? ? b : a
66
- end
67
-
68
53
  def configure(opts)
69
- @config = opts
70
-
71
- # Apply environmental variables of the form "ETNA__x__y__z_FILE"
72
- # by reading the given file.
73
- prefix = "ETNA__"
74
- ENV.keys.select { |k| k.start_with?(prefix) && k.end_with?("_FILE") }.each do |key|
75
- path = key.sub(/_FILE/, '').split("__", -1)
76
- path.shift # drop the first, just app name
77
-
78
- target = @config
79
- while path.length > 1
80
- n = path.shift
81
- target = (target[n.downcase.to_sym] ||= {})
82
- end
83
-
84
- v = YAML.load(File.read(ENV[key]))
85
- target[path.last.downcase.to_sym] =
86
- deep_merge(target[path.last.downcase.to_sym], v)
87
- end
54
+ @config = Etna::EnvironmentVariables.load_from_env('ETNA', root: opts) { |path, value| load_config_from_env_path(path, value) }
88
55
 
89
56
  if (rollbar_config = config(:rollbar)) && rollbar_config[:access_token]
90
57
  Rollbar.configure do |config|
@@ -93,6 +60,14 @@ module Etna::Application
93
60
  end
94
61
  end
95
62
 
63
+ def load_config_from_env_path(path, value)
64
+ return nil if path.empty?
65
+ return nil unless path.last.end_with?('_file')
66
+ path.last.sub!(/_file$/, '')
67
+
68
+ [path.map(&:to_sym), YAML.load(File.read(value))]
69
+ end
70
+
96
71
  def setup_yabeda
97
72
  application = self.id
98
73
  Yabeda.configure do
@@ -186,6 +161,10 @@ module Etna::Application
186
161
  (ENV["#{self.class.name.upcase}_ENV"] || :development).to_sym
187
162
  end
188
163
 
164
+ def test?
165
+ environment == "test"
166
+ end
167
+
189
168
  def id
190
169
  ENV["APP_NAME"] || self.class.name.snake_case.split(/::/).last
191
170
  end
data/lib/etna/auth.rb CHANGED
@@ -18,12 +18,16 @@ module Etna
18
18
  if [ approve_noauth(request), approve_hmac(request), approve_user(request) ].all?{|approved| !approved}
19
19
  return fail_or_redirect(request)
20
20
  end
21
-
21
+
22
22
  @app.call(request.env)
23
23
  end
24
24
 
25
25
  private
26
26
 
27
+ def janus
28
+ @janus ||= Etna::JanusUtils.new
29
+ end
30
+
27
31
  def application
28
32
  @application ||= Etna::Application.instance
29
33
  end
@@ -61,7 +65,7 @@ module Etna
61
65
  application.config(:auth_redirect).chomp('/') + '/login'
62
66
  )
63
67
  uri.query = URI.encode_www_form(refer: request.url)
64
- return [ 302, { 'Location' => uri.to_s }, [] ]
68
+ Etna::Redirect(request).to(uri.to_s)
65
69
  end
66
70
 
67
71
  def approve_noauth(request)
@@ -77,52 +81,26 @@ module Etna
77
81
  return true if route && route.ignore_janus?
78
82
 
79
83
  # process task tokens
80
- payload['task'] ? valid_task_token?(token) : true
81
- end
82
-
83
- def resource_projects(token)
84
- return [] unless has_janus_config?
85
-
86
- janus_client(token).get_projects.projects.select do |project|
87
- project.resource
88
- end
89
- rescue
90
- # If encounter any issue with Janus, we'll return no resource projects
91
- []
92
- end
93
-
94
- def janus_client(token)
95
- Etna::Clients::Janus.new(
96
- token: token,
97
- host: application.config(:janus)[:host]
98
- )
84
+ payload[:task] ? janus.valid_task_token?(token) : true
99
85
  end
100
86
 
101
- def valid_task_token?(token)
102
- return false unless has_janus_config?
103
-
104
- response = janus_client(token).validate_task_token
105
-
106
- return false unless response.code == '200'
107
-
108
- return true
87
+ def symbolize_payload_keys(payload)
88
+ payload.map{|k,v| [k.to_sym, v]}.to_h
109
89
  end
110
90
 
111
- def has_janus_config?
112
- application.config(:janus) && application.config(:janus)[:host]
91
+ def permissions(payload)
92
+ Etna::Permissions.from_encoded_permissions(payload[:perm])
113
93
  end
114
94
 
115
95
  def update_payload(payload, token, request)
116
96
  route = server.find_route(request)
117
97
 
118
- payload = payload.map{|k,v| [k.to_sym, v]}.to_h
119
-
120
98
  return payload unless route
121
99
 
122
100
  begin
123
- permissions = Etna::Permissions.from_encoded_permissions(payload[:perm])
101
+ permissions = permissions(payload)
124
102
 
125
- resource_projects(token).each do |resource_project|
103
+ janus.resource_projects(token).each do |resource_project|
126
104
  permissions.add_permission(
127
105
  Etna::Permission.new('v', resource_project.project_name)
128
106
  )
@@ -141,6 +119,8 @@ module Etna
141
119
  begin
142
120
  payload, header = application.sign.jwt_decode(token)
143
121
 
122
+ payload = symbolize_payload_keys(payload)
123
+
144
124
  return false unless janus_approved?(payload, token, request)
145
125
  return request.env['etna.user'] = Etna::User.new(
146
126
  update_payload(payload, token, request),
@@ -115,6 +115,10 @@ module Etna
115
115
  def resource
116
116
  !!@raw[:resource]
117
117
  end
118
+
119
+ def requires_agreement
120
+ !!@raw[:requires_agreement]
121
+ end
118
122
  end
119
123
 
120
124
  class TokenResponse
@@ -59,7 +59,7 @@ module Etna
59
59
  end
60
60
 
61
61
  def create_magma_project_record!
62
- magma_client.update(Etna::Clients::Magma::UpdateRequest.new(
62
+ magma_client.update_json(Etna::Clients::Magma::UpdateRequest.new(
63
63
  project_name: project_name,
64
64
  revisions: {
65
65
  'project' => { project_name => { name: project_name } },
@@ -1,4 +1,5 @@
1
1
  require 'erb'
2
+ require 'net/smtp'
2
3
 
3
4
  module Etna
4
5
  class Controller
@@ -44,7 +45,7 @@ module Etna
44
45
  rescue Exception => e
45
46
  error = e
46
47
  ensure
47
- log_request
48
+ log_request if !@request.env['etna.dont_log'] || error
48
49
  return handle_error(error) if error
49
50
  end
50
51
 
@@ -76,6 +77,24 @@ module Etna
76
77
  end
77
78
  end
78
79
 
80
+ def send_email(to_name, to_email, subject, content)
81
+ message = <<MESSAGE_END
82
+ From: Data Library <noreply@janus>
83
+ To: #{to_name} <#{to_email}>
84
+ Subject: #{subject}
85
+
86
+ #{content}
87
+ MESSAGE_END
88
+
89
+ unless @server.send(:application).test?
90
+ Net::SMTP.start('smtp.ucsf.edu') do |smtp|
91
+ smtp.send_message message, 'noreply@janus', to_email
92
+ end
93
+ end
94
+ rescue => e
95
+ @logger.log_error(e)
96
+ end
97
+
79
98
  def require_params(*params)
80
99
  missing_params = params.reject{|p| @params.key?(p) }
81
100
  raise Etna::BadRequest, "Missing param #{missing_params.join(', ')}" unless missing_params.empty?
data/lib/etna/cwl.rb CHANGED
@@ -559,6 +559,14 @@ module Etna
559
559
  def id
560
560
  @attributes['id']
561
561
  end
562
+
563
+ def type
564
+ @attributes['type']
565
+ end
566
+
567
+ def format
568
+ @attributes['format']
569
+ end
562
570
  end
563
571
 
564
572
  class WorkflowInputParameter < Cwl
@@ -0,0 +1,50 @@
1
+ module Etna
2
+ module EnvironmentVariables
3
+ # a <- b
4
+ def self.deep_merge(a, b)
5
+ if a.is_a?(Hash)
6
+ if b.is_a?(Hash)
7
+ b.keys.each do |b_key|
8
+ a[b_key] = deep_merge(a[b_key], b[b_key])
9
+ end
10
+
11
+ return a
12
+ end
13
+ end
14
+
15
+ a.nil? ? b : a
16
+ end
17
+
18
+ def self.load_from_env(prefix, root: {}, env: ENV, downcase: true, sep: '__', &path_to_value_mapper)
19
+ env.keys.each do |key|
20
+ next unless key.start_with?(prefix + sep)
21
+
22
+ path = key.split(sep, -1)
23
+ path.shift
24
+ if downcase
25
+ path.each(&:downcase!)
26
+ end
27
+
28
+ result = path_to_value_mapper.call(path, env[key])
29
+ next unless result
30
+
31
+ path, value = result
32
+
33
+ if path.empty?
34
+ root = EnvironmentVariables.deep_merge(root, value)
35
+ next
36
+ end
37
+
38
+ target = root
39
+ while path.length > 1
40
+ n = path.shift
41
+ target = (target[n] ||= {})
42
+ end
43
+
44
+ target[path.last] = EnvironmentVariables.deep_merge(target[path.last], value)
45
+ end
46
+
47
+ root
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,57 @@
1
+ module Etna
2
+ class Injection
3
+ # Extend into class
4
+ module FromHash
5
+ def from_hash(hash, hash_has_string_keys, rest: nil, key_rest: nil)
6
+ ::Etna::Injection.inject_new(self, hash ,hash_has_string_keys, rest: rest, key_rest: key_rest) do |missing_p|
7
+ raise "required argument '#{missing_p}' of #{self.name} is missing!"
8
+ end
9
+ end
10
+ end
11
+
12
+ def self.inject_new(cls, hash, hash_has_string_keys, rest: nil, key_rest: nil, &missing_req_param_cb)
13
+ args, kwds = prep_args(
14
+ cls.instance_method(:initialize), hash, hash_has_string_keys,
15
+ rest: rest, key_rest: key_rest, &missing_req_param_cb
16
+ )
17
+
18
+ cls.new(*args, **kwds)
19
+ end
20
+
21
+ def self.prep_args(method, hash, hash_has_string_keys, rest: nil, key_rest: nil, &missing_req_param_cb)
22
+ new_k_params = {}
23
+ new_p_params = []
24
+
25
+ method.parameters.each do |type, p_key|
26
+ h_key = hash_has_string_keys ? p_key.to_s : p_key
27
+ if type == :rest && rest
28
+ new_p_params.append(*rest)
29
+ elsif type == :keyrest && key_rest
30
+ new_k_params.update(key_rest)
31
+ elsif type == :req || type == :opt
32
+ if hash.include?(h_key)
33
+ new_p_params << hash[h_key]
34
+ elsif type == :req
35
+ if block_given?
36
+ new_p_params << missing_req_param_cb.call(h_key)
37
+ else
38
+ new_p_params << nil
39
+ end
40
+ end
41
+ elsif type == :keyreq || type == :key
42
+ if hash.include?(h_key)
43
+ new_k_params[p_key] = hash[h_key]
44
+ elsif type == :keyreq
45
+ if block_given?
46
+ new_k_params[p_key] = missing_req_param_cb.call(h_key)
47
+ else
48
+ new_k_params[p_key] = nil
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ [new_p_params, new_k_params]
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,55 @@
1
+ # Utility class to work with Janus for authz and authn
2
+ module Etna
3
+ class JanusUtils
4
+ def initialize
5
+ end
6
+
7
+ def projects(token)
8
+ return [] unless has_janus_config?
9
+
10
+ janus_client(token).get_projects.projects
11
+ rescue
12
+ # If encounter any issue with Janus, we'll return no projects
13
+ []
14
+ end
15
+
16
+ def resource_projects(token)
17
+ projects(token).select do |project|
18
+ !!project.resource && !project.requires_agreement
19
+ end
20
+ end
21
+
22
+ def community_projects(token)
23
+ projects(token).select do |project|
24
+ !!project.resource && !!project.requires_agreement
25
+ end
26
+ end
27
+
28
+ def janus_client(token)
29
+ Etna::Clients::Janus.new(
30
+ token: token,
31
+ host: application.config(:janus)[:host],
32
+ )
33
+ end
34
+
35
+ def valid_task_token?(token)
36
+ return false unless has_janus_config?
37
+
38
+ response = janus_client(token).validate_task_token
39
+
40
+ return false unless response.code == "200"
41
+
42
+ return true
43
+ end
44
+
45
+ private
46
+
47
+ def application
48
+ @application ||= Etna::Application.instance
49
+ end
50
+
51
+ def has_janus_config?
52
+ application.config(:janus) && application.config(:janus)[:host]
53
+ end
54
+ end
55
+ end
@@ -50,6 +50,10 @@ module Etna
50
50
  @permissions << permission
51
51
  end
52
52
 
53
+ def any?(&block)
54
+ @permissions.any?(&block)
55
+ end
56
+
53
57
  private
54
58
 
55
59
  def current_project_names
@@ -64,6 +68,7 @@ module Etna
64
68
  "A" => :admin,
65
69
  "E" => :editor,
66
70
  "V" => :viewer,
71
+ "G" => :guest
67
72
  }
68
73
 
69
74
  def initialize(role_key, project_name)
@@ -0,0 +1,36 @@
1
+ module Etna
2
+ def self.Redirect(req)
3
+ Redirect.new(req)
4
+ end
5
+
6
+ class Redirect
7
+ def initialize(request)
8
+ @request = request
9
+ end
10
+
11
+ def to(path, &block)
12
+ return Rack::Response.new(
13
+ [ { errors: [ 'Cannot redirect out of domain' ] }.to_json ], 422,
14
+ { 'Content-Type' => 'application/json' }
15
+ ).finish unless matches_domain?(path)
16
+
17
+ response = Rack::Response.new
18
+
19
+ response.redirect(path.gsub("http://", "https://"), 302)
20
+
21
+ yield response if block_given?
22
+
23
+ response.finish
24
+ end
25
+
26
+ private
27
+
28
+ def matches_domain?(path)
29
+ top_domain(@request.hostname) == top_domain(URI(path).host)
30
+ end
31
+
32
+ def top_domain(host_name)
33
+ host_name.split('.')[-2..-1].join('.')
34
+ end
35
+ end
36
+ end
data/lib/etna/route.rb CHANGED
@@ -15,6 +15,7 @@ module Etna
15
15
  @block = block
16
16
  @match_ext = options[:match_ext]
17
17
  @log_redact_keys = options[:log_redact_keys]
18
+ @dont_log = options[:dont_log]
18
19
  end
19
20
 
20
21
  def to_hash
@@ -41,7 +42,7 @@ module Etna
41
42
  if params
42
43
  PARAM_TYPES.reduce(route) do |path,pat|
43
44
  path.gsub(pat) do
44
- URI.encode( params[$1.to_sym], UNSAFE)
45
+ params[$1.to_sym].split('/').map { |c| URI.encode_www_form_component(c) }.join('/')
45
46
  end
46
47
  end
47
48
  else
@@ -123,10 +124,19 @@ module Etna
123
124
  update_params(request)
124
125
 
125
126
  unless authorized?(request)
127
+ if cc_available?(request)
128
+ if request.content_type == 'application/json'
129
+ return [ 403, { 'Content-Type' => 'application/json' }, [ { error: 'You are forbidden from performing this action, but you can visit the project home page and request access.' }.to_json ] ]
130
+ else
131
+ return cc_redirect(request)
132
+ end
133
+ end
134
+
126
135
  return [ 403, { 'Content-Type' => 'application/json' }, [ { error: 'You are forbidden from performing this action.' }.to_json ] ]
127
136
  end
128
137
 
129
138
  request.env['etna.redact_keys'] = @log_redact_keys
139
+ request.env['etna.dont_log'] = @dont_log
130
140
 
131
141
  if @action
132
142
  controller, action = @action.split('#')
@@ -160,6 +170,23 @@ module Etna
160
170
 
161
171
  private
162
172
 
173
+ def janus
174
+ @janus ||= Etna::JanusUtils.new
175
+ end
176
+
177
+ # If the application asks for a code of conduct redirect
178
+ def cc_redirect(request, msg = 'You are unauthorized')
179
+ return [ 401, { 'Content-Type' => 'text/html' }, [msg] ] unless application.config(:auth_redirect)
180
+
181
+ params = request.env['rack.request.params']
182
+
183
+ uri = URI(
184
+ application.config(:auth_redirect).chomp('/') + "/#{params[:project_name]}/cc"
185
+ )
186
+ uri.query = URI.encode_www_form(refer: request.url)
187
+ Etna::Redirect(request).to(uri.to_s)
188
+ end
189
+
163
190
  def application
164
191
  @application ||= Etna::Application.instance
165
192
  end
@@ -189,6 +216,24 @@ module Etna
189
216
  end
190
217
  end
191
218
 
219
+ def cc_available?(request)
220
+ user = request.env['etna.user']
221
+
222
+ return false unless user
223
+
224
+ params = request.env['rack.request.params']
225
+
226
+ return false unless params[:project_name]
227
+
228
+ # Only check for a CC if the user does not currently have permissions
229
+ # for the project
230
+ return false if user.permissions.keys.include?(params[:project_name])
231
+
232
+ !janus.community_projects(user.token).select do |project|
233
+ project.project_name == params[:project_name]
234
+ end.first.nil?
235
+ end
236
+
192
237
  def hmac_authorized?(request)
193
238
  # either there is no hmac requirement, or we have a valid hmac
194
239
  !@auth[:hmac] || request.env['etna.hmac']&.valid?
@@ -211,7 +256,7 @@ module Etna
211
256
  Hash[
212
257
  match.names.map(&:to_sym).zip(
213
258
  match.captures.map do |capture|
214
- URI.decode(capture)
259
+ capture.split('/').map {|c| URI.decode_www_form_component(c) }.join('/')
215
260
  end
216
261
  )
217
262
  ]