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