]> git.arvados.org - arvados.git/blob - services/api/app/controllers/arvados/v1/groups_controller.rb
22785: Fix ambiguous column name in join query.
[arvados.git] / services / api / app / controllers / arvados / v1 / groups_controller.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 require "trashable"
6
7 class Arvados::V1::GroupsController < ApplicationController
8   include TrashableController
9
10   before_action :load_include_param, only: [:shared, :contents]
11   skip_before_action :find_object_by_uuid, only: :shared
12   skip_before_action :render_404_if_no_object, only: :shared
13
14   def self._index_requires_parameters
15     (super rescue {}).
16       merge({
17         include_trash: {
18           type: 'boolean',
19           required: false,
20           default: false,
21           description: "Include items whose `is_trashed` attribute is true.",
22         },
23       })
24   end
25
26   def self._show_requires_parameters
27     (super rescue {}).
28       merge({
29         include_trash: {
30           type: 'boolean',
31           required: false,
32           default: false,
33           description: "Return group/project even if its `is_trashed` attribute is true.",
34         },
35       })
36   end
37
38   def self._contents_requires_parameters
39     _index_requires_parameters.merge(
40       {
41         uuid: {
42           type: 'string',
43           required: false,
44           default: '',
45           description: "If given, limit the listing to objects owned by the
46 user or group with this UUID.",
47         },
48         recursive: {
49           type: 'boolean',
50           required: false,
51           default: false,
52           description: 'If true, include contents from child groups recursively.',
53         },
54         include: {
55           type: 'array',
56           required: false,
57           description: "An array of referenced objects to include in the `included` field of the response. Supported values in the array are:
58
59   * `\"container_uuid\"`
60   * `\"owner_uuid\"`
61   * `\"collection_uuid\"`
62
63 ",
64         },
65         include_old_versions: {
66           type: 'boolean',
67           required: false,
68           default: false,
69           description: 'If true, include past versions of collections in the listing.',
70         },
71         exclude_home_project: {
72           type: "boolean",
73           required: false,
74           default: false,
75           description: "If true, exclude contents of the user's home project from the listing.
76 Calling this method with this flag set is how clients enumerate objects shared
77 with the current user.",
78         },
79       }
80     )
81   end
82
83   def self._create_requires_parameters
84     super.merge(
85       {
86         async: {
87           required: false,
88           type: 'boolean',
89           location: 'query',
90           default: false,
91           description: 'If true, cluster permission will not be updated immediately, but instead at the next configured update interval.',
92         }
93       }
94     )
95   end
96
97   def self._update_requires_parameters
98     super.merge(
99       {
100         async: {
101           required: false,
102           type: 'boolean',
103           location: 'query',
104           default: false,
105           description: 'If true, cluster permission will not be updated immediately, but instead at the next configured update interval.',
106         }
107       }
108     )
109   end
110
111   def create
112     if params[:async]
113       @object = model_class.new(resource_attrs.merge({async_permissions_update: true}))
114       @object.save!
115       render_accepted
116     else
117       super
118     end
119   end
120
121   def update
122     if params[:async]
123       attrs_to_update = resource_attrs.reject { |k, v|
124         [:kind, :etag, :href].index k
125       }.merge({async_permissions_update: true})
126       @object.update!(attrs_to_update)
127       @object.save!
128       render_accepted
129     else
130       super
131     end
132   end
133
134   def render_404_if_no_object
135     if params[:action] == 'contents'
136       if !params[:uuid]
137         # OK!
138         @object = nil
139         true
140       elsif @object
141         # Project group
142         true
143       elsif (@object = User.where(uuid: params[:uuid]).first)
144         # "Home" pseudo-project
145         true
146       else
147         super
148       end
149     else
150       super
151     end
152   end
153
154   def self._contents_method_description
155     "List objects that belong to a group."
156   end
157
158   def contents
159     @orig_select = @select
160     load_searchable_objects
161     list = {
162       :kind => "arvados#objectList",
163       :etag => "",
164       :self_link => "",
165       :offset => @offset,
166       :limit => @limit,
167       :items => @objects.as_api_response(nil)
168     }
169     if params[:count] != 'none'
170       list[:items_available] = @items_available
171     end
172     if @extra_included
173       if @orig_select.nil?
174         @orig_select = User.selectable_attributes.concat(
175           Group.selectable_attributes,
176           Container.selectable_attributes,
177           Collection.selectable_attributes - ["unsigned_manifest_text"])
178       end
179       @orig_select = @orig_select - ["manifest_text"]
180       list[:included] = @extra_included.as_api_response(nil, {select: @orig_select})
181     end
182     send_json(list)
183   end
184
185   def self._shared_method_description
186     "List groups that the current user can access via permission links."
187   end
188
189   def shared
190     # The purpose of this endpoint is to return the toplevel set of
191     # groups which are *not* reachable through a direct ownership
192     # chain of projects starting from the current user account.  In
193     # other words, groups which to which access was granted via a
194     # permission link or chain of links.
195     #
196     # This also returns (in the "included" field) the objects that own
197     # those projects (users or non-project groups).
198     #
199     # The intended use of this endpoint is to support clients which
200     # wish to browse those projects which are visible to the user but
201     # are not part of the "home" project.
202
203     load_limit_offset_order_params
204     load_filters_param
205
206     @objects = exclude_home Group.readable_by(*@read_users), Group
207
208     apply_where_limit_order_params
209
210     if @include.include?("owner_uuid")
211       owners = @objects.map(&:owner_uuid).to_set
212       @extra_included ||= []
213       [Group, User].each do |klass|
214         @extra_included += klass.readable_by(*@read_users).where(uuid: owners.to_a).to_a
215       end
216     end
217
218     if @include.include?("container_uuid")
219       @extra_included ||= []
220       container_uuids = @objects.map { |o|
221         o.respond_to?(:container_uuid) ? o.container_uuid : nil
222       }.compact.to_set.to_a
223       @extra_included += Container.where(uuid: container_uuids).to_a
224     end
225
226     if @include.include?("collection_uuid")
227       @extra_included ||= []
228       collection_uuids = @objects.map { |o|
229         o.respond_to?(:collection_uuid) ? o.collection_uuid : nil
230       }.compact.to_set.to_a
231       @extra_included += Collection.where(uuid: collection_uuids).to_a
232     end
233
234     index
235   end
236
237   def self._shared_requires_parameters
238     self._index_requires_parameters.merge(
239       {
240         include: {
241           type: 'string',
242           required: false,
243           description: "A string naming referenced objects to include in the `included` field of the response. Supported values are:
244
245   * `\"owner_uuid\"`
246
247 ",
248         },
249       }
250     )
251   end
252
253   protected
254
255   def load_include_param
256     @include = params[:include]
257     if @include.nil? || @include == ""
258       @include = Set[]
259     elsif @include.is_a?(String) && @include.start_with?('[')
260       @include = SafeJSON.load(@include).to_set
261     elsif @include.is_a?(String)
262       @include = Set[@include]
263     else
264       return send_error("'include' parameter must be a string or array", status: 422)
265     end
266   end
267
268   def load_searchable_objects
269     all_objects = []
270     @items_available = 0
271
272     # Reload the orders param, this time without prefixing unqualified
273     # columns ("name" => "groups.name"). Here, unqualified orders
274     # apply to each table being searched, not just "groups", as
275     # fill_table_names would assume. Instead, table names are added
276     # inside the klasses loop below (see request_order).
277     load_limit_offset_order_params(fill_table_names: false)
278
279     # Trick apply_where_limit_order_params into applying suitable
280     # per-table values. *_all are the real ones we'll apply to the
281     # aggregate set.
282     limit_all = @limit
283     offset_all = @offset
284     # save the orders from the current request as determined by load_param,
285     # but otherwise discard them because we're going to be getting objects
286     # from many models
287     request_orders = @orders.clone
288     @orders = []
289
290     request_filters = @filters
291
292     klasses = [Group, ContainerRequest, Workflow, Collection]
293
294     table_names = Hash[klasses.collect { |k| [k, k.table_name] }]
295
296     disabled_methods = Rails.configuration.API.DisabledAPIs
297     avail_klasses = table_names.select{|k, t| !disabled_methods[t+'.index']}
298     klasses = avail_klasses.keys
299
300     request_filters.each do |col, op, val|
301       if col.index('.')
302         filter_table = col.split('.', 2)[0]
303         # singular "container" is valid as a special case for
304         # filtering container requests by their associated
305         # container_uuid, similarly singular "collection" for
306         # workflows.
307         if filter_table != "container" && filter_table != "collection" && !table_names.values.include?(filter_table)
308           raise ArgumentError.new("Invalid attribute '#{col}' in filter")
309         end
310       end
311     end
312
313     wanted_klasses = []
314     request_filters.each do |col,op,val|
315       if op == 'is_a'
316         (val.is_a?(Array) ? val : [val]).each do |type|
317           type = type.split('#')[-1]
318           type[0] = type[0].capitalize
319           wanted_klasses << type
320         end
321       end
322     end
323
324     filter_by_owner = {}
325     if @object
326       if params['recursive']
327         filter_by_owner[:owner_uuid] = [@object.uuid] + @object.descendant_project_uuids
328       else
329         filter_by_owner[:owner_uuid] = @object.uuid
330       end
331
332       if params['exclude_home_project']
333         raise ArgumentError.new "Cannot use 'exclude_home_project' with a parent object"
334       end
335     end
336
337     # Check that any fields in @select are valid for at least one class
338     if @select
339       all_attributes = []
340       klasses.each do |klass|
341         all_attributes.concat klass.selectable_attributes
342       end
343       if klasses.include?(ContainerRequest) && @include.include?("container_uuid")
344         all_attributes.concat Container.selectable_attributes
345       end
346       if klasses.include?(Workflow) && @include.include?("collection_uuid")
347         all_attributes.concat Collection.selectable_attributes
348       end
349       @select.each do |check|
350         if !all_attributes.include? check
351           raise ArgumentError.new "Invalid attribute '#{check}' in select"
352         end
353       end
354     end
355     any_selections = @select
356
357     included_by_uuid = {}
358
359     error_by_class = {}
360     any_success = false
361
362     klasses.each do |klass|
363       # if klasses are specified, skip all other klass types
364       next if wanted_klasses.any? and !wanted_klasses.include?(klass.to_s)
365
366       # don't process rest of object types if we already have needed number of objects
367       break if params['count'] == 'none' and all_objects.size >= limit_all
368
369       # If the currently requested orders specifically match the
370       # table_name for the current klass, apply that order.
371       # Otherwise, order by recency.
372       request_order = request_orders.andand.map do |r|
373         if r =~ /^#{klass.table_name}\./i
374           r
375         elsif r !~ /\./
376           # If the caller provided an unqualified column like
377           # "created_by desc", but we might be joining another table
378           # that also has that column, so we need to specify that we
379           # mean this table.
380           klass.table_name + '.' + r
381         else
382           # Only applies to a different table / object type.
383           nil
384         end
385       end.compact
386       request_order = optimize_orders(request_order, model_class: klass)
387
388       @select = select_for_klass any_selections, klass, false
389
390       where_conds = filter_by_owner
391       if klass == Collection && @select.nil?
392         @select = klass.selectable_attributes - ["manifest_text", "unsigned_manifest_text"]
393       elsif klass == Group
394         where_conds = where_conds.merge(group_class: ["project","filter"])
395       end
396
397       # Make signed manifest_text not selectable because controller
398       # currently doesn't know to sign it.
399       if @select
400         @select = @select - ["manifest_text"]
401       end
402
403       @filters = request_filters.map do |col, op, val|
404         if !col.index('.')
405           [col, op, val]
406         elsif (colsp = col.split('.', 2))[0] == klass.table_name
407           [colsp[1], op, val]
408         elsif klass == ContainerRequest && colsp[0] == "container"
409           [col, op, val]
410         elsif klass == Workflow && colsp[0] == "collection"
411           [col, op, val]
412         else
413           nil
414         end
415       end.compact
416
417       @objects = klass.readable_by(*@read_users, {
418           :include_trash => params[:include_trash],
419           :include_old_versions => params[:include_old_versions]
420         }).order(request_order).where(where_conds)
421
422       if params['exclude_home_project']
423         @objects = exclude_home @objects, klass
424       end
425
426       # Adjust the limit based on number of objects fetched so far
427       klass_limit = limit_all - all_objects.count
428       @limit = klass_limit
429
430       begin
431         apply_where_limit_order_params klass
432       rescue ArgumentError => e
433         if e.inspect =~ /Invalid attribute '.+' for operator '.+' in filter/ or
434           e.inspect =~ /Invalid attribute '.+' for subproperty filter/
435           error_by_class[klass.name] = e
436           next
437         end
438         raise
439       else
440         any_success = true
441       end
442
443       # This actually fetches the objects
444       klass_object_list = object_list(model_class: klass)
445
446       # The appropriate @offset for querying the next table depends on
447       # how many matching rows in this table were skipped due to the
448       # current @offset. If we retrieved any items (or @offset is
449       # already zero), then clearly exactly @offset rows were skipped,
450       # and the correct @offset for the next table is zero.
451       # Otherwise, we need to count all matching rows in the current
452       # table, and subtract that from @offset. If our previous query
453       # used count=none, we will need an additional query to get that
454       # count.
455       if params['count'] == 'none' and @offset > 0 and klass_object_list[:items].length == 0
456         # Just get the count.
457         klass_object_list[:items_available] = @objects.
458                                                 except(:limit).except(:offset).
459                                                 count(@distinct ? :id : '*')
460       end
461
462       klass_items_available = klass_object_list[:items_available]
463       if klass_items_available.nil?
464         # items_available may be nil if count=none and a non-zero
465         # number of items were returned.  One of these cases must be true:
466         #
467         # items returned >= limit, so we won't go to the next table, offset doesn't matter
468         # items returned < limit, so we want to start at the beginning of the next table, offset = 0
469         #
470         @offset = 0
471       else
472         # We have the exact count,
473         @items_available += klass_items_available
474         @offset = [@offset - klass_items_available, 0].max
475       end
476
477       # Add objects to the list of objects to be returned.
478       all_objects += klass_object_list[:items]
479
480       if klass_object_list[:limit] < klass_limit
481         # object_list() had to reduce @limit to comply with
482         # max_index_database_read. From now on, we'll do all queries
483         # with limit=0 and just accumulate items_available.
484         limit_all = all_objects.count
485       end
486
487       if @include.include?("owner_uuid")
488         owners = klass_object_list[:items].map {|i| i[:owner_uuid]}.to_set
489         [Group, User].each do |ownerklass|
490           ownerklass.readable_by(*@read_users).where(uuid: owners.to_a).each do |ow|
491             included_by_uuid[ow.uuid] = ow
492           end
493         end
494       end
495
496       if @include.include?("container_uuid") && klass == ContainerRequest
497         containers = klass_object_list[:items].collect { |cr| cr[:container_uuid] }.to_set
498         Container.where(uuid: containers.to_a).each do |c|
499           included_by_uuid[c.uuid] = c
500         end
501       end
502
503       if @include.include?("collection_uuid") && klass == Workflow
504         collections = klass_object_list[:items].collect { |wf| wf[:collection_uuid] }.to_set
505         Collection.where(uuid: collections.to_a).each do |c|
506           included_by_uuid[c.uuid] = c
507         end
508       end
509     end
510
511     # Only error out when every searchable object type errored out
512     if !any_success && error_by_class.size > 0
513       error_msg = error_by_class.collect do |klass, err|
514         "#{err} on object type #{klass}"
515       end.join("\n")
516       raise ArgumentError.new(error_msg)
517     end
518
519     if !@include.empty?
520       @extra_included = included_by_uuid.values
521     end
522
523     @objects = all_objects
524     @limit = limit_all
525     @offset = offset_all
526   end
527
528   def exclude_home objectlist, klass
529     # select records that are readable by current user AND
530     #   the owner_uuid is a user (but not the current user) OR
531     #   the owner_uuid is not readable by the current user
532     #   the owner_uuid is a group but group_class is not a project
533
534     read_parent_check = if current_user.is_admin
535                           ""
536                         else
537                           "NOT EXISTS(SELECT 1 FROM #{PERMISSION_VIEW} WHERE "+
538                             "user_uuid=(:user_uuid) AND target_uuid=#{klass.table_name}.owner_uuid AND perm_level >= 1) OR "
539                         end
540
541     objectlist.where("#{klass.table_name}.owner_uuid IN (SELECT users.uuid FROM users WHERE users.uuid != (:user_uuid)) OR "+
542                      read_parent_check+
543                      "EXISTS(SELECT 1 FROM groups as gp where gp.uuid=#{klass.table_name}.owner_uuid and gp.group_class != 'project')",
544                      user_uuid: current_user.uuid)
545   end
546 end