1 # Copyright (C) The Arvados Authors. All rights reserved.
3 # SPDX-License-Identifier: AGPL-3.0
7 class Arvados::V1::GroupsController < ApplicationController
8 include TrashableController
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
14 def self._index_requires_parameters
21 description: "Include items whose `is_trashed` attribute is true.",
26 def self._show_requires_parameters
33 description: "Return group/project even if its `is_trashed` attribute is true.",
38 def self._contents_requires_parameters
39 _index_requires_parameters.merge(
45 description: "If given, limit the listing to objects owned by the
46 user or group with this UUID.",
52 description: 'If true, include contents from child groups recursively.',
57 description: "An array of referenced objects to include in the `included` field of the response. Supported values in the array are:
59 * `\"container_uuid\"`
61 * `\"collection_uuid\"`
65 include_old_versions: {
69 description: 'If true, include past versions of collections in the listing.',
71 exclude_home_project: {
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.",
83 def self._create_requires_parameters
91 description: 'If true, cluster permission will not be updated immediately, but instead at the next configured update interval.',
97 def self._update_requires_parameters
105 description: 'If true, cluster permission will not be updated immediately, but instead at the next configured update interval.',
113 @object = model_class.new(resource_attrs.merge({async_permissions_update: true}))
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)
134 def render_404_if_no_object
135 if params[:action] == 'contents'
143 elsif (@object = User.where(uuid: params[:uuid]).first)
144 # "Home" pseudo-project
154 def self._contents_method_description
155 "List objects that belong to a group."
159 @orig_select = @select
160 load_searchable_objects
162 :kind => "arvados#objectList",
167 :items => @objects.as_api_response(nil)
169 if params[:count] != 'none'
170 list[:items_available] = @items_available
174 @orig_select = User.selectable_attributes.concat(
175 Group.selectable_attributes,
176 Container.selectable_attributes,
177 Collection.selectable_attributes - ["unsigned_manifest_text"])
179 @orig_select = @orig_select - ["manifest_text"]
180 list[:included] = @extra_included.as_api_response(nil, {select: @orig_select})
185 def self._shared_method_description
186 "List groups that the current user can access via permission links."
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.
196 # This also returns (in the "included" field) the objects that own
197 # those projects (users or non-project groups).
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.
203 load_limit_offset_order_params
206 @objects = exclude_home Group.readable_by(*@read_users), Group
208 apply_where_limit_order_params
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
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
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
237 def self._shared_requires_parameters
238 self._index_requires_parameters.merge(
243 description: "A string naming referenced objects to include in the `included` field of the response. Supported values are:
255 def load_include_param
256 @include = params[:include]
257 if @include.nil? || @include == ""
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]
264 return send_error("'include' parameter must be a string or array", status: 422)
268 def load_searchable_objects
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)
279 # Trick apply_where_limit_order_params into applying suitable
280 # per-table values. *_all are the real ones we'll apply to the
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
287 request_orders = @orders.clone
290 request_filters = @filters
292 klasses = [Group, ContainerRequest, Workflow, Collection]
294 table_names = Hash[klasses.collect { |k| [k, k.table_name] }]
296 disabled_methods = Rails.configuration.API.DisabledAPIs
297 avail_klasses = table_names.select{|k, t| !disabled_methods[t+'.index']}
298 klasses = avail_klasses.keys
300 request_filters.each do |col, op, val|
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
307 if filter_table != "container" && filter_table != "collection" && !table_names.values.include?(filter_table)
308 raise ArgumentError.new("Invalid attribute '#{col}' in filter")
314 request_filters.each do |col,op,val|
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
326 if params['recursive']
327 filter_by_owner[:owner_uuid] = [@object.uuid] + @object.descendant_project_uuids
329 filter_by_owner[:owner_uuid] = @object.uuid
332 if params['exclude_home_project']
333 raise ArgumentError.new "Cannot use 'exclude_home_project' with a parent object"
337 # Check that any fields in @select are valid for at least one class
340 klasses.each do |klass|
341 all_attributes.concat klass.selectable_attributes
343 if klasses.include?(ContainerRequest) && @include.include?("container_uuid")
344 all_attributes.concat Container.selectable_attributes
346 if klasses.include?(Workflow) && @include.include?("collection_uuid")
347 all_attributes.concat Collection.selectable_attributes
349 @select.each do |check|
350 if !all_attributes.include? check
351 raise ArgumentError.new "Invalid attribute '#{check}' in select"
355 any_selections = @select
357 included_by_uuid = {}
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)
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
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
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
380 klass.table_name + '.' + r
382 # Only applies to a different table / object type.
386 request_order = optimize_orders(request_order, model_class: klass)
388 @select = select_for_klass any_selections, klass, false
390 where_conds = filter_by_owner
391 if klass == Collection && @select.nil?
392 @select = klass.selectable_attributes - ["manifest_text", "unsigned_manifest_text"]
394 where_conds = where_conds.merge(group_class: ["project","filter"])
397 # Make signed manifest_text not selectable because controller
398 # currently doesn't know to sign it.
400 @select = @select - ["manifest_text"]
403 @filters = request_filters.map do |col, op, val|
406 elsif (colsp = col.split('.', 2))[0] == klass.table_name
408 elsif klass == ContainerRequest && colsp[0] == "container"
410 elsif klass == Workflow && colsp[0] == "collection"
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)
422 if params['exclude_home_project']
423 @objects = exclude_home @objects, klass
426 # Adjust the limit based on number of objects fetched so far
427 klass_limit = limit_all - all_objects.count
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
443 # This actually fetches the objects
444 klass_object_list = object_list(model_class: klass)
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
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 : '*')
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:
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
472 # We have the exact count,
473 @items_available += klass_items_available
474 @offset = [@offset - klass_items_available, 0].max
477 # Add objects to the list of objects to be returned.
478 all_objects += klass_object_list[:items]
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
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
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
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
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}"
516 raise ArgumentError.new(error_msg)
520 @extra_included = included_by_uuid.values
523 @objects = all_objects
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
534 read_parent_check = if current_user.is_admin
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 "
541 objectlist.where("#{klass.table_name}.owner_uuid IN (SELECT users.uuid FROM users WHERE users.uuid != (:user_uuid)) OR "+
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)