15720: Warn about empty ManagementToken or SystemRootToken.
[arvados.git] / apps / workbench / app / models / arvados_base.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 class ArvadosBase
6   include ActiveModel::Validations
7   include ActiveModel::Conversion
8   include ActiveModel::Serialization
9   include ActiveModel::Dirty
10   include ActiveModel::AttributeAssignment
11   extend ActiveModel::Naming
12
13   Column = Struct.new("Column", :name)
14
15   attr_accessor :attribute_sortkey
16   attr_accessor :create_params
17
18   class Error < StandardError; end
19
20   module Type
21     class Hash < ActiveModel::Type::Value
22       def type
23         :hash
24       end
25
26       def default_value
27         {}
28       end
29
30       private
31       def cast_value(value)
32         (value.class == String) ? ::JSON.parse(value) : value
33       end
34     end
35
36     class Array < ActiveModel::Type::Value
37       def type
38         :array
39       end
40
41       def default_value
42         []
43       end
44
45       private
46       def cast_value(value)
47         (value.class == String) ? ::JSON.parse(value) : value
48       end
49     end
50   end
51
52   def self.arvados_api_client
53     ArvadosApiClient.new_or_current
54   end
55
56   def arvados_api_client
57     ArvadosApiClient.new_or_current
58   end
59
60   def self.uuid_infix_object_kind
61     @@uuid_infix_object_kind ||=
62       begin
63         infix_kind = {}
64         arvados_api_client.discovery[:schemas].each do |name, schema|
65           if schema[:uuidPrefix]
66             infix_kind[schema[:uuidPrefix]] =
67               'arvados#' + name.to_s.camelcase(:lower)
68           end
69         end
70
71         # Recognize obsolete types.
72         infix_kind.
73           merge('mxsvm' => 'arvados#pipelineTemplate', # Pipeline
74                 'uo14g' => 'arvados#pipelineInstance', # PipelineInvocation
75                 'ldvyl' => 'arvados#group') # Project
76       end
77   end
78
79   def initialize raw_params={}, create_params={}
80     self.class.permit_attribute_params(raw_params)
81     @create_params = create_params
82     @attribute_sortkey ||= {
83       'id' => nil,
84       'name' => '000',
85       'owner_uuid' => '002',
86       'event_type' => '100',
87       'link_class' => '100',
88       'group_class' => '100',
89       'tail_uuid' => '101',
90       'head_uuid' => '102',
91       'object_uuid' => '102',
92       'summary' => '104',
93       'description' => '104',
94       'properties' => '150',
95       'info' => '150',
96       'created_at' => '200',
97       'modified_at' => '201',
98       'modified_by_user_uuid' => '202',
99       'modified_by_client_uuid' => '203',
100       'uuid' => '999',
101     }
102     @loaded_attributes = {}
103     attributes = self.class.columns.map { |c| [c.name.to_sym, nil] }.to_h.merge(raw_params)
104     attributes.symbolize_keys.each do |name, value|
105       send("#{name}=", value)
106     end
107   end
108
109   def self.columns
110     @discovered_columns = [] if !defined?(@discovered_columns)
111     return @discovered_columns if @discovered_columns.andand.any?
112     @attribute_info ||= {}
113     schema = arvados_api_client.discovery[:schemas][self.to_s.to_sym]
114     return @discovered_columns if schema.nil?
115     schema[:properties].each do |k, coldef|
116       case k
117       when :etag, :kind
118         attr_reader k
119       else
120         if coldef[:type] == coldef[:type].downcase
121           # boolean, integer, etc.
122           @discovered_columns << column(k, coldef[:type])
123         else
124           # Hash, Array
125           @discovered_columns << column(k, coldef[:type], coldef[:type].constantize.new)
126         end
127         attr_reader k
128         @attribute_info[k] = coldef
129       end
130     end
131     @discovered_columns
132   end
133
134   def new_record?
135     # dup method doesn't reset the uuid attr
136     @uuid.nil? || @new_record || false
137   end
138
139   def initialize_dup(other)
140     super
141     @new_record = true
142     @created_at = nil
143   end
144
145   def self.column(name, sql_type = nil, default = nil, null = true)
146     caster = case sql_type
147               when 'integer'
148                 ActiveModel::Type::Integer
149               when 'string', 'text'
150                 ActiveModel::Type::String
151               when 'float'
152                 ActiveModel::Type::Float
153               when 'datetime'
154                 ActiveModel::Type::DateTime
155               when 'boolean'
156                 ActiveModel::Type::Boolean
157               when 'Hash'
158                 ArvadosBase::Type::Hash
159               when 'Array'
160                 ArvadosBase::Type::Array
161               when 'jsonb'
162                 ArvadosBase::Type::Hash
163               else
164                 raise ArvadosBase::Error.new("Type unknown: #{sql_type}")
165             end
166     define_method "#{name}=" do |val|
167       val = default if val.nil?
168       casted_value = caster.new.cast(val)
169       attribute_will_change!(name) if send(name) != casted_value
170       set_attribute_after_cast(name, casted_value)
171     end
172     Column.new(name.to_s)
173   end
174
175   def set_attribute_after_cast(name, casted_value)
176     instance_variable_set("@#{name}", casted_value)
177   end
178
179   def [](attr_name)
180     begin
181       send(attr_name)
182     rescue
183       Rails.logger.debug "BUG: access non-loaded attribute #{attr_name}"
184       nil
185     end
186   end
187
188   def []=(attr_name, attr_val)
189     send("#{attr_name}=", attr_val)
190   end
191
192   def self.attribute_info
193     self.columns
194     @attribute_info
195   end
196
197   def self.find(uuid, opts={})
198     if uuid.class != String or uuid.length < 27 then
199       raise 'argument to find() must be a uuid string. Acceptable formats: warehouse locator or string with format xxxxx-xxxxx-xxxxxxxxxxxxxxx'
200     end
201
202     if self == ArvadosBase
203       # Determine type from uuid and defer to the appropriate subclass.
204       return resource_class_for_uuid(uuid).find(uuid, opts)
205     end
206
207     # Only do one lookup on the API side per {class, uuid, workbench
208     # request} unless {cache: false} is given via opts.
209     cache_key = "request_#{Thread.current.object_id}_#{self.to_s}_#{uuid}"
210     if opts[:cache] == false
211       Rails.cache.write cache_key, arvados_api_client.api(self, '/' + uuid)
212     end
213     hash = Rails.cache.fetch cache_key do
214       arvados_api_client.api(self, '/' + uuid)
215     end
216     new.private_reload(hash)
217   end
218
219   def self.find?(*args)
220     find(*args) rescue nil
221   end
222
223   def self.order(*args)
224     ArvadosResourceList.new(self).order(*args)
225   end
226
227   def self.filter(*args)
228     ArvadosResourceList.new(self).filter(*args)
229   end
230
231   def self.where(*args)
232     ArvadosResourceList.new(self).where(*args)
233   end
234
235   def self.limit(*args)
236     ArvadosResourceList.new(self).limit(*args)
237   end
238
239   def self.select(*args)
240     ArvadosResourceList.new(self).select(*args)
241   end
242
243   def self.with_count(*args)
244     ArvadosResourceList.new(self).with_count(*args)
245   end
246
247   def self.distinct(*args)
248     ArvadosResourceList.new(self).distinct(*args)
249   end
250
251   def self.include_trash(*args)
252     ArvadosResourceList.new(self).include_trash(*args)
253   end
254
255   def self.recursive(*args)
256     ArvadosResourceList.new(self).recursive(*args)
257   end
258
259   def self.eager(*args)
260     ArvadosResourceList.new(self).eager(*args)
261   end
262
263   def self.all
264     ArvadosResourceList.new(self)
265   end
266
267   def self.permit_attribute_params raw_params
268     # strong_parameters does not provide security in Workbench: anyone
269     # who can get this far can just as well do a call directly to our
270     # database (Arvados) with the same credentials we use.
271     #
272     # The following permit! is necessary even with
273     # "ActionController::Parameters.permit_all_parameters = true",
274     # because permit_all does not permit nested attributes.
275     if !raw_params.is_a? ActionController::Parameters
276       raw_params = ActionController::Parameters.new(raw_params)
277     end
278     raw_params.permit!
279   end
280
281   def self.create raw_params={}, create_params={}
282     x = new(permit_attribute_params(raw_params), create_params)
283     x.save
284     x
285   end
286
287   def self.create! raw_params={}, create_params={}
288     x = new(permit_attribute_params(raw_params), create_params)
289     x.save!
290     x
291   end
292
293   def self.table_name
294     self.name.underscore.pluralize.downcase
295   end
296
297   def update_attributes raw_params={}
298     assign_attributes(self.class.permit_attribute_params(raw_params))
299     save
300   end
301
302   def update_attributes! raw_params={}
303     assign_attributes(self.class.permit_attribute_params(raw_params))
304     save!
305   end
306
307   def save
308     obdata = {}
309     self.class.columns.each do |col|
310       # Non-nil serialized values must be sent because we can't tell
311       # whether they've changed. Other than that, any given attribute
312       # is either unchanged (in which case there's no need to send its
313       # old value in the update/create command) or has been added to
314       # #changed by ActiveRecord's #attr= method.
315       if changed.include? col.name or
316           ([Hash, Array].include?(attributes[col.name].class) and
317            @loaded_attributes[col.name])
318         obdata[col.name.to_sym] = self.send col.name
319       end
320     end
321     obdata.delete :id
322     postdata = { self.class.to_s.underscore => obdata }
323     if etag
324       postdata['_method'] = 'PUT'
325       obdata.delete :uuid
326       resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
327     else
328       if @create_params
329         @create_params = @create_params.to_unsafe_hash if @create_params.is_a? ActionController::Parameters
330         postdata.merge!(@create_params)
331       end
332       resp = arvados_api_client.api(self.class, '', postdata)
333     end
334     return false if !resp[:etag] || !resp[:uuid]
335
336     # set read-only non-database attributes
337     @etag = resp[:etag]
338     @kind = resp[:kind]
339
340     # attributes can be modified during "save" -- we should update our copies
341     resp.keys.each do |attr|
342       if self.respond_to? "#{attr}=".to_sym
343         self.send(attr.to_s + '=', resp[attr.to_sym])
344       end
345     end
346
347     changes_applied
348     @new_record = false
349
350     self
351   end
352
353   def save!
354     self.save or raise Exception.new("Save failed")
355   end
356
357   def persisted?
358     (!new_record? && !destroyed?) ? true : false
359   end
360
361   def destroyed?
362     !(new_record? || etag || uuid)
363   end
364
365   def destroy
366     if etag || uuid
367       postdata = { '_method' => 'DELETE' }
368       resp = arvados_api_client.api(self.class, '/' + uuid, postdata)
369       resp[:etag] && resp[:uuid] && resp
370     else
371       true
372     end
373   end
374
375   def links(*args)
376     o = {}
377     o.merge!(args.pop) if args[-1].is_a? Hash
378     o[:link_class] ||= args.shift
379     o[:name] ||= args.shift
380     o[:tail_uuid] = self.uuid
381     if all_links
382       return all_links.select do |m|
383         ok = true
384         o.each do |k,v|
385           if !v.nil?
386             test_v = m.send(k)
387             if (v.respond_to?(:uuid) ? v.uuid : v.to_s) != (test_v.respond_to?(:uuid) ? test_v.uuid : test_v.to_s)
388               ok = false
389             end
390           end
391         end
392         ok
393       end
394     end
395     @links = arvados_api_client.api Link, '', { _method: 'GET', where: o, eager: true }
396     @links = arvados_api_client.unpack_api_response(@links)
397   end
398
399   def all_links
400     return @all_links if @all_links
401     res = arvados_api_client.api Link, '', {
402       _method: 'GET',
403       where: {
404         tail_kind: self.kind,
405         tail_uuid: self.uuid
406       },
407       eager: true
408     }
409     @all_links = arvados_api_client.unpack_api_response(res)
410   end
411
412   def reload
413     private_reload(self.uuid)
414   end
415
416   def private_reload(uuid_or_hash)
417     raise "No such object" if !uuid_or_hash
418     if uuid_or_hash.is_a? Hash
419       hash = uuid_or_hash
420     else
421       hash = arvados_api_client.api(self.class, '/' + uuid_or_hash)
422     end
423     hash.each do |k,v|
424       @loaded_attributes[k.to_s] = true
425       if self.respond_to?(k.to_s + '=')
426         self.send(k.to_s + '=', v)
427       else
428         # When ArvadosApiClient#schema starts telling us what to expect
429         # in API responses (not just the server side database
430         # columns), this sort of awfulness can be avoided:
431         self.instance_variable_set('@' + k.to_s, v)
432         if !self.respond_to? k
433           singleton = class << self; self end
434           singleton.send :define_method, k, lambda { instance_variable_get('@' + k.to_s) }
435         end
436       end
437     end
438     @all_links = nil
439     changes_applied
440     @new_record = false
441     self
442   end
443
444   def to_param
445     uuid
446   end
447
448   def initialize_copy orig
449     super
450     forget_uuid!
451   end
452
453   def attributes
454     kv = self.class.columns.collect {|c| c.name}.map {|key| [key, send(key)]}
455     kv.to_h
456   end
457
458   def attributes_for_display
459     self.attributes.reject { |k,v|
460       attribute_sortkey.has_key?(k) and !attribute_sortkey[k]
461     }.sort_by { |k,v|
462       attribute_sortkey[k] or k
463     }
464   end
465
466   def class_for_display
467     self.class.to_s.underscore.humanize
468   end
469
470   def self.class_for_display
471     self.to_s.underscore.humanize
472   end
473
474   # Array of strings that are names of attributes that should be rendered as textile.
475   def textile_attributes
476     []
477   end
478
479   def self.creatable?
480     current_user.andand.is_active && api_exists?(:create)
481   end
482
483   def self.goes_in_projects?
484     false
485   end
486
487   # can this class of object be copied into a project?
488   # override to false on indivudal model classes for which this should not be true
489   def self.copies_to_projects?
490     self.goes_in_projects?
491   end
492
493   def editable?
494     (current_user and current_user.is_active and
495      (current_user.is_admin or
496       current_user.uuid == self.owner_uuid or
497       new_record? or
498       (respond_to?(:writable_by) ?
499        writable_by.include?(current_user.uuid) :
500        (ArvadosBase.find(owner_uuid).writable_by.include? current_user.uuid rescue false)))) or false
501   end
502
503   def deletable?
504     editable?
505   end
506
507   def self.api_exists?(method)
508     arvados_api_client.discovery[:resources][self.to_s.underscore.pluralize.to_sym].andand[:methods].andand[method]
509   end
510
511   # Array of strings that are the names of attributes that can be edited
512   # with X-Editable.
513   def editable_attributes
514     self.class.columns.map(&:name) -
515       %w(created_at modified_at modified_by_user_uuid modified_by_client_uuid updated_at)
516   end
517
518   def attribute_editable?(attr, ever=nil)
519     if not editable_attributes.include?(attr.to_s)
520       false
521     elsif not (current_user.andand.is_active)
522       false
523     elsif attr == 'uuid'
524       current_user.is_admin
525     elsif ever
526       true
527     else
528       editable?
529     end
530   end
531
532   def self.resource_class_for_uuid(uuid, opts={})
533     if uuid.is_a? ArvadosBase
534       return uuid.class
535     end
536     unless uuid.is_a? String
537       return nil
538     end
539     if opts[:class].is_a? Class
540       return opts[:class]
541     end
542     if uuid.match(/^[0-9a-f]{32}(\+[^,]+)*(,[0-9a-f]{32}(\+[^,]+)*)*$/)
543       return Collection
544     end
545     resource_class = nil
546     uuid.match(/^[0-9a-z]{5}-([0-9a-z]{5})-[0-9a-z]{15}$/) do |re|
547       resource_class ||= arvados_api_client.
548         kind_class(self.uuid_infix_object_kind[re[1]])
549     end
550     if opts[:referring_object] and
551         opts[:referring_attr] and
552         opts[:referring_attr].match(/_uuid$/)
553       resource_class ||= arvados_api_client.
554         kind_class(opts[:referring_object].
555                    attributes[opts[:referring_attr].
556                               sub(/_uuid$/, '_kind')])
557     end
558     resource_class
559   end
560
561   def resource_param_name
562     self.class.to_s.underscore
563   end
564
565   def friendly_link_name lookup=nil
566     (name if self.respond_to? :name) || default_name
567   end
568
569   def content_summary
570     self.class_for_display
571   end
572
573   def selection_label
574     friendly_link_name
575   end
576
577   def self.default_name
578     self.to_s.underscore.humanize
579   end
580
581   def controller
582     (self.class.to_s.pluralize + 'Controller').constantize
583   end
584
585   def controller_name
586     self.class.to_s.tableize
587   end
588
589   # Placeholder for name when name is missing or empty
590   def default_name
591     if self.respond_to? :name
592       "New #{class_for_display.downcase}"
593     else
594       uuid
595     end
596   end
597
598   def owner
599     ArvadosBase.find(owner_uuid) rescue nil
600   end
601
602   protected
603
604   def forget_uuid!
605     self.uuid = nil
606     @etag = nil
607     self
608   end
609
610   def self.current_user
611     Thread.current[:user] ||= User.current if Thread.current[:arvados_api_token]
612     Thread.current[:user]
613   end
614   def current_user
615     self.class.current_user
616   end
617 end