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\"`
64 include_old_versions: {
68 description: 'If true, include past versions of collections in the listing.',
70 exclude_home_project: {
74 description: "If true, exclude contents of the user's home project from the listing.
75 Calling this method with this flag set is how clients enumerate objects shared
76 with the current user.",
82 def self._create_requires_parameters
90 description: 'If true, cluster permission will not be updated immediately, but instead at the next configured update interval.',
96 def self._update_requires_parameters
104 description: 'If true, cluster permission will not be updated immediately, but instead at the next configured update interval.',
112 @object = model_class.new(resource_attrs.merge({async_permissions_update: true}))
122 attrs_to_update = resource_attrs.reject { |k, v|
123 [:kind, :etag, :href].index k
124 }.merge({async_permissions_update: true})
125 @object.update!(attrs_to_update)
133 def render_404_if_no_object
134 if params[:action] == 'contents'
142 elsif (@object = User.where(uuid: params[:uuid]).first)
143 # "Home" pseudo-project
153 def self._contents_method_description
154 "List objects that belong to a group."
158 @orig_select = @select
159 load_searchable_objects
161 :kind => "arvados#objectList",
166 :items => @objects.as_api_response(nil)
168 if params[:count] != 'none'
169 list[:items_available] = @items_available
172 list[:included] = @extra_included.as_api_response(nil, {select: @orig_select})
177 def self._shared_method_description
178 "List groups that the current user can access via permission links."
182 # The purpose of this endpoint is to return the toplevel set of
183 # groups which are *not* reachable through a direct ownership
184 # chain of projects starting from the current user account. In
185 # other words, groups which to which access was granted via a
186 # permission link or chain of links.
188 # This also returns (in the "included" field) the objects that own
189 # those projects (users or non-project groups).
191 # The intended use of this endpoint is to support clients which
192 # wish to browse those projects which are visible to the user but
193 # are not part of the "home" project.
195 load_limit_offset_order_params
198 @objects = exclude_home Group.readable_by(*@read_users), Group
200 apply_where_limit_order_params
202 if @include.include?("owner_uuid")
203 owners = @objects.map(&:owner_uuid).to_set
204 @extra_included ||= []
205 [Group, User].each do |klass|
206 @extra_included += klass.readable_by(*@read_users).where(uuid: owners.to_a).to_a
210 if @include.include?("container_uuid")
211 @extra_included ||= []
212 container_uuids = @objects.map { |o|
213 o.respond_to?(:container_uuid) ? o.container_uuid : nil
214 }.compact.to_set.to_a
215 @extra_included += Container.where(uuid: container_uuids).to_a
221 def self._shared_requires_parameters
222 self._index_requires_parameters.merge(
227 description: "A string naming referenced objects to include in the `included` field of the response. Supported values are:
239 def load_include_param
240 @include = params[:include]
241 if @include.nil? || @include == ""
243 elsif @include.is_a?(String) && @include.start_with?('[')
244 @include = SafeJSON.load(@include).to_set
245 elsif @include.is_a?(String)
246 @include = Set[@include]
248 return send_error("'include' parameter must be a string or array", status: 422)
252 def load_searchable_objects
256 # Reload the orders param, this time without prefixing unqualified
257 # columns ("name" => "groups.name"). Here, unqualified orders
258 # apply to each table being searched, not "groups".
259 load_limit_offset_order_params(fill_table_names: false)
261 # Trick apply_where_limit_order_params into applying suitable
262 # per-table values. *_all are the real ones we'll apply to the
266 # save the orders from the current request as determined by load_param,
267 # but otherwise discard them because we're going to be getting objects
269 request_orders = @orders.clone
272 request_filters = @filters
274 klasses = [Group, ContainerRequest, Workflow, Collection]
276 table_names = Hash[klasses.collect { |k| [k, k.table_name] }]
278 disabled_methods = Rails.configuration.API.DisabledAPIs
279 avail_klasses = table_names.select{|k, t| !disabled_methods[t+'.index']}
280 klasses = avail_klasses.keys
282 request_filters.each do |col, op, val|
283 if col.index('.') && !table_names.values.include?(col.split('.', 2)[0])
284 raise ArgumentError.new("Invalid attribute '#{col}' in filter")
289 request_filters.each do |col,op,val|
291 (val.is_a?(Array) ? val : [val]).each do |type|
292 type = type.split('#')[-1]
293 type[0] = type[0].capitalize
294 wanted_klasses << type
301 if params['recursive']
302 filter_by_owner[:owner_uuid] = [@object.uuid] + @object.descendant_project_uuids
304 filter_by_owner[:owner_uuid] = @object.uuid
307 if params['exclude_home_project']
308 raise ArgumentError.new "Cannot use 'exclude_home_project' with a parent object"
312 # Check that any fields in @select are valid for at least one class
315 klasses.each do |klass|
316 all_attributes.concat klass.selectable_attributes
318 if klasses.include?(ContainerRequest) && @include.include?("container_uuid")
319 all_attributes.concat Container.selectable_attributes
321 @select.each do |check|
322 if !all_attributes.include? check
323 raise ArgumentError.new "Invalid attribute '#{check}' in select"
327 any_selections = @select
329 included_by_uuid = {}
334 klasses.each do |klass|
335 # if klasses are specified, skip all other klass types
336 next if wanted_klasses.any? and !wanted_klasses.include?(klass.to_s)
338 # don't process rest of object types if we already have needed number of objects
339 break if params['count'] == 'none' and all_objects.size >= limit_all
341 # If the currently requested orders specifically match the
342 # table_name for the current klass, apply that order.
343 # Otherwise, order by recency.
345 request_orders.andand.find { |r| r =~ /^#{klass.table_name}\./i || r !~ /\./ } ||
346 klass.default_orders.join(", ")
348 @select = select_for_klass any_selections, klass, false
350 where_conds = filter_by_owner
351 if klass == Collection && @select.nil?
352 @select = klass.selectable_attributes - ["manifest_text", "unsigned_manifest_text"]
354 where_conds = where_conds.merge(group_class: ["project","filter"])
357 # Make signed manifest_text not selectable because controller
358 # currently doesn't know to sign it.
360 @select = @select - ["manifest_text"]
363 @filters = request_filters.map do |col, op, val|
366 elsif (col = col.split('.', 2))[0] == klass.table_name
373 @objects = klass.readable_by(*@read_users, {
374 :include_trash => params[:include_trash],
375 :include_old_versions => params[:include_old_versions]
376 }).order(request_order).where(where_conds)
378 if params['exclude_home_project']
379 @objects = exclude_home @objects, klass
382 # Adjust the limit based on number of objects fetched so far
383 klass_limit = limit_all - all_objects.count
387 apply_where_limit_order_params klass
388 rescue ArgumentError => e
389 if e.inspect =~ /Invalid attribute '.+' for operator '.+' in filter/ or
390 e.inspect =~ /Invalid attribute '.+' for subproperty filter/
391 error_by_class[klass.name] = e
399 # This actually fetches the objects
400 klass_object_list = object_list(model_class: klass)
402 # The appropriate @offset for querying the next table depends on
403 # how many matching rows in this table were skipped due to the
404 # current @offset. If we retrieved any items (or @offset is
405 # already zero), then clearly exactly @offset rows were skipped,
406 # and the correct @offset for the next table is zero.
407 # Otherwise, we need to count all matching rows in the current
408 # table, and subtract that from @offset. If our previous query
409 # used count=none, we will need an additional query to get that
411 if params['count'] == 'none' and @offset > 0 and klass_object_list[:items].length == 0
412 # Just get the count.
413 klass_object_list[:items_available] = @objects.
414 except(:limit).except(:offset).
415 count(@distinct ? :id : '*')
418 klass_items_available = klass_object_list[:items_available]
419 if klass_items_available.nil?
420 # items_available may be nil if count=none and a non-zero
421 # number of items were returned. One of these cases must be true:
423 # items returned >= limit, so we won't go to the next table, offset doesn't matter
424 # items returned < limit, so we want to start at the beginning of the next table, offset = 0
428 # We have the exact count,
429 @items_available += klass_items_available
430 @offset = [@offset - klass_items_available, 0].max
433 # Add objects to the list of objects to be returned.
434 all_objects += klass_object_list[:items]
436 if klass_object_list[:limit] < klass_limit
437 # object_list() had to reduce @limit to comply with
438 # max_index_database_read. From now on, we'll do all queries
439 # with limit=0 and just accumulate items_available.
440 limit_all = all_objects.count
443 if @include.include?("owner_uuid")
444 owners = klass_object_list[:items].map {|i| i[:owner_uuid]}.to_set
445 [Group, User].each do |ownerklass|
446 ownerklass.readable_by(*@read_users).where(uuid: owners.to_a).each do |ow|
447 included_by_uuid[ow.uuid] = ow
452 if @include.include?("container_uuid") && klass == ContainerRequest
453 containers = klass_object_list[:items].collect { |cr| cr[:container_uuid] }.to_set
454 Container.where(uuid: containers.to_a).each do |c|
455 included_by_uuid[c.uuid] = c
460 # Only error out when every searchable object type errored out
461 if !any_success && error_by_class.size > 0
462 error_msg = error_by_class.collect do |klass, err|
463 "#{err} on object type #{klass}"
465 raise ArgumentError.new(error_msg)
469 @extra_included = included_by_uuid.values
472 @objects = all_objects
477 def exclude_home objectlist, klass
478 # select records that are readable by current user AND
479 # the owner_uuid is a user (but not the current user) OR
480 # the owner_uuid is not readable by the current user
481 # the owner_uuid is a group but group_class is not a project
483 read_parent_check = if current_user.is_admin
486 "NOT EXISTS(SELECT 1 FROM #{PERMISSION_VIEW} WHERE "+
487 "user_uuid=(:user_uuid) AND target_uuid=#{klass.table_name}.owner_uuid AND perm_level >= 1) OR "
490 objectlist.where("#{klass.table_name}.owner_uuid IN (SELECT users.uuid FROM users WHERE users.uuid != (:user_uuid)) OR "+
492 "EXISTS(SELECT 1 FROM groups as gp where gp.uuid=#{klass.table_name}.owner_uuid and gp.group_class != 'project')",
493 user_uuid: current_user.uuid)