Merge branch 'master' into 14873-api-rails5-upgrade
[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   skip_before_action :find_object_by_uuid, only: :shared
11   skip_before_action :render_404_if_no_object, only: :shared
12
13   def self._index_requires_parameters
14     (super rescue {}).
15       merge({
16         include_trash: {
17           type: 'boolean', required: false, description: "Include items whose is_trashed attribute is true."
18         },
19       })
20   end
21
22   def self._contents_requires_parameters
23     params = _index_requires_parameters.
24       merge({
25               uuid: {
26                 type: 'string', required: false, default: nil
27               },
28               recursive: {
29                 type: 'boolean', required: false, description: 'Include contents from child groups recursively.'
30               },
31             })
32     params.delete(:select)
33     params
34   end
35
36   def self._create_requires_parameters
37     super.merge(
38       {
39         async: {
40           required: false,
41           type: 'boolean',
42           location: 'query',
43           default: false,
44           description: 'defer permissions update'
45         }
46       }
47     )
48   end
49
50   def self._update_requires_parameters
51     super.merge(
52       {
53         async: {
54           required: false,
55           type: 'boolean',
56           location: 'query',
57           default: false,
58           description: 'defer permissions update'
59         }
60       }
61     )
62   end
63
64   def create
65     if params[:async]
66       @object = model_class.new(resource_attrs.merge({async_permissions_update: true}))
67       @object.save!
68       render_accepted
69     else
70       super
71     end
72   end
73
74   def update
75     if params[:async]
76       attrs_to_update = resource_attrs.reject { |k, v|
77         [:kind, :etag, :href].index k
78       }.merge({async_permissions_update: true})
79       @object.update_attributes!(attrs_to_update)
80       @object.save!
81       render_accepted
82     else
83       super
84     end
85   end
86
87   def render_404_if_no_object
88     if params[:action] == 'contents'
89       if !params[:uuid]
90         # OK!
91         @object = nil
92         true
93       elsif @object
94         # Project group
95         true
96       elsif (@object = User.where(uuid: params[:uuid]).first)
97         # "Home" pseudo-project
98         true
99       else
100         super
101       end
102     else
103       super
104     end
105   end
106
107   def contents
108     load_searchable_objects
109     list = {
110       :kind => "arvados#objectList",
111       :etag => "",
112       :self_link => "",
113       :offset => @offset,
114       :limit => @limit,
115       :items_available => @items_available,
116       :items => @objects.as_api_response(nil)
117     }
118     if @extra_included
119       list[:included] = @extra_included.as_api_response(nil, {select: @select})
120     end
121     send_json(list)
122   end
123
124   def shared
125     # The purpose of this endpoint is to return the toplevel set of
126     # groups which are *not* reachable through a direct ownership
127     # chain of projects starting from the current user account.  In
128     # other words, groups which to which access was granted via a
129     # permission link or chain of links.
130     #
131     # This also returns (in the "included" field) the objects that own
132     # those projects (users or non-project groups).
133     #
134     #
135     # The intended use of this endpoint is to support clients which
136     # wish to browse those projects which are visible to the user but
137     # are not part of the "home" project.
138
139     load_limit_offset_order_params
140     load_filters_param
141
142     @objects = exclude_home Group.readable_by(*@read_users), Group
143
144     apply_where_limit_order_params
145
146     if params["include"] == "owner_uuid"
147       owners = @objects.map(&:owner_uuid).to_set
148       @extra_included = []
149       [Group, User].each do |klass|
150         @extra_included += klass.readable_by(*@read_users).where(uuid: owners.to_a).to_a
151       end
152     end
153
154     index
155   end
156
157   def self._shared_requires_parameters
158     rp = self._index_requires_parameters
159     rp[:include] = { type: 'string', required: false }
160     rp
161   end
162
163   protected
164
165   def load_searchable_objects
166     all_objects = []
167     @items_available = 0
168
169     # Reload the orders param, this time without prefixing unqualified
170     # columns ("name" => "groups.name"). Here, unqualified orders
171     # apply to each table being searched, not "groups".
172     load_limit_offset_order_params(fill_table_names: false)
173
174     # Trick apply_where_limit_order_params into applying suitable
175     # per-table values. *_all are the real ones we'll apply to the
176     # aggregate set.
177     limit_all = @limit
178     offset_all = @offset
179     # save the orders from the current request as determined by load_param,
180     # but otherwise discard them because we're going to be getting objects
181     # from many models
182     request_orders = @orders.clone
183     @orders = []
184
185     request_filters = @filters
186
187     klasses = [Group,
188      Job, PipelineInstance, PipelineTemplate, ContainerRequest, Workflow,
189      Collection,
190      Human, Specimen, Trait]
191
192     table_names = Hash[klasses.collect { |k| [k, k.table_name] }]
193
194     disabled_methods = Rails.configuration.disable_api_methods
195     avail_klasses = table_names.select{|k, t| !disabled_methods.include?(t+'.index')}
196     klasses = avail_klasses.keys
197
198     request_filters.each do |col, op, val|
199       if col.index('.') && !table_names.values.include?(col.split('.', 2)[0])
200         raise ArgumentError.new("Invalid attribute '#{col}' in filter")
201       end
202     end
203
204     wanted_klasses = []
205     request_filters.each do |col,op,val|
206       if op == 'is_a'
207         (val.is_a?(Array) ? val : [val]).each do |type|
208           type = type.split('#')[-1]
209           type[0] = type[0].capitalize
210           wanted_klasses << type
211         end
212       end
213     end
214
215     filter_by_owner = {}
216     if @object
217       if params['recursive']
218         filter_by_owner[:owner_uuid] = [@object.uuid] + @object.descendant_project_uuids
219       else
220         filter_by_owner[:owner_uuid] = @object.uuid
221       end
222
223       if params['exclude_home_project']
224         raise ArgumentError.new "Cannot use 'exclude_home_project' with a parent object"
225       end
226     end
227
228     included_by_uuid = {}
229
230     seen_last_class = false
231     klasses.each do |klass|
232       @offset = 0 if seen_last_class  # reset offset for the new next type being processed
233
234       # if current klass is same as params['last_object_class'], mark that fact
235       seen_last_class = true if((params['count'].andand.==('none')) and
236                                 (params['last_object_class'].nil? or
237                                  params['last_object_class'].empty? or
238                                  params['last_object_class'] == klass.to_s))
239
240       # if klasses are specified, skip all other klass types
241       next if wanted_klasses.any? and !wanted_klasses.include?(klass.to_s)
242
243       # don't reprocess klass types that were already seen
244       next if params['count'] == 'none' and !seen_last_class
245
246       # don't process rest of object types if we already have needed number of objects
247       break if params['count'] == 'none' and all_objects.size >= limit_all
248
249       # If the currently requested orders specifically match the
250       # table_name for the current klass, apply that order.
251       # Otherwise, order by recency.
252       request_order =
253         request_orders.andand.find { |r| r =~ /^#{klass.table_name}\./i || r !~ /\./ } ||
254         klass.default_orders.join(", ")
255
256       @select = nil
257       where_conds = filter_by_owner
258       if klass == Collection
259         @select = klass.selectable_attributes - ["manifest_text"]
260       elsif klass == Group
261         where_conds = where_conds.merge(group_class: "project")
262       end
263
264       @filters = request_filters.map do |col, op, val|
265         if !col.index('.')
266           [col, op, val]
267         elsif (col = col.split('.', 2))[0] == klass.table_name
268           [col[1], op, val]
269         else
270           nil
271         end
272       end.compact
273
274       @objects = klass.readable_by(*@read_users, {:include_trash => params[:include_trash]}).
275                  order(request_order).where(where_conds)
276
277       if params['exclude_home_project']
278         @objects = exclude_home @objects, klass
279       end
280
281       klass_limit = limit_all - all_objects.count
282       @limit = klass_limit
283       apply_where_limit_order_params klass
284       klass_object_list = object_list(model_class: klass)
285       klass_items_available = klass_object_list[:items_available] || 0
286       @items_available += klass_items_available
287       @offset = [@offset - klass_items_available, 0].max
288       all_objects += klass_object_list[:items]
289
290       if klass_object_list[:limit] < klass_limit
291         # object_list() had to reduce @limit to comply with
292         # max_index_database_read. From now on, we'll do all queries
293         # with limit=0 and just accumulate items_available.
294         limit_all = all_objects.count
295       end
296
297       if params["include"] == "owner_uuid"
298         owners = klass_object_list[:items].map {|i| i[:owner_uuid]}.to_set
299         [Group, User].each do |ownerklass|
300           ownerklass.readable_by(*@read_users).where(uuid: owners.to_a).each do |ow|
301             included_by_uuid[ow.uuid] = ow
302           end
303         end
304       end
305     end
306
307     if params["include"]
308       @extra_included = included_by_uuid.values
309     end
310
311     @objects = all_objects
312     @limit = limit_all
313     @offset = offset_all
314   end
315
316   protected
317
318   def exclude_home objectlist, klass
319     # select records that are readable by current user AND
320     #   the owner_uuid is a user (but not the current user) OR
321     #   the owner_uuid is not readable by the current user
322     #   the owner_uuid is a group but group_class is not a project
323
324     read_parent_check = if current_user.is_admin
325                           ""
326                         else
327                           "NOT EXISTS(SELECT 1 FROM #{PERMISSION_VIEW} WHERE "+
328                             "user_uuid=(:user_uuid) AND target_uuid=#{klass.table_name}.owner_uuid AND perm_level >= 1) OR "
329                         end
330
331     objectlist.where("#{klass.table_name}.owner_uuid IN (SELECT users.uuid FROM users WHERE users.uuid != (:user_uuid)) OR "+
332                      read_parent_check+
333                      "EXISTS(SELECT 1 FROM groups as gp where gp.uuid=#{klass.table_name}.owner_uuid and gp.group_class != 'project')",
334                      user_uuid: current_user.uuid)
335   end
336
337 end