77944eb6ee823e210ee2de2f8c6bf0fcd08a4e1a
[arvados.git] / services / api / db / migrate / 20200501150153_permission_table.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 class PermissionTable < ActiveRecord::Migration[5.0]
6   def up
7     # This is a major migration.  We are replacing the
8     # materialized_permission_view, which is fully recomputed any time
9     # a permission changes (and becomes very expensive as the number
10     # of users/groups becomes large), with a new strategy that only
11     # recomputes permissions for the subset of objects that are
12     # potentially affected by the addition or removal of a permission
13     # relationship (i.e. ownership or a permission link).
14     #
15     # This also disentangles the concept of "trashed groups" from the
16     # permissions system.  Updating trashed items follows a similar
17     # (but less complicated) strategy to updating permissions, so it
18     # may be helpful to look at that first.
19     #
20
21     ActiveRecord::Base.connection.execute "DROP MATERIALIZED VIEW IF EXISTS materialized_permission_view;"
22     drop_table :permission_refresh_lock
23
24     # This table stores the set of trashed groups and their trash_at
25     # time.  Used to exclude trashed projects and their contents when
26     # getting object listings.
27     create_table :trashed_groups, :id => false do |t|
28       t.string :group_uuid
29       t.datetime :trash_at
30     end
31     add_index :trashed_groups, :group_uuid, :unique => true
32
33     ActiveRecord::Base.connection.execute %{
34 create or replace function project_subtree_with_trash_at (starting_uuid varchar(27), starting_trash_at timestamp)
35 returns table (target_uuid varchar(27), trash_at timestamp)
36 STABLE
37 language SQL
38 as $$
39 /* Starting from a project, recursively traverse all the projects
40   underneath it and return a set of project uuids and trash_at times
41   (may be null).  The initial trash_at can be a timestamp or null.
42   The trash_at time propagates downward to groups it owns, i.e. when a
43   group is trashed, everything underneath it in the ownership
44   hierarchy is also considered trashed.  However, this is fact is
45   recorded in the trashed_groups table, not by updating trash_at field
46   in the groups table.
47 */
48 WITH RECURSIVE
49         project_subtree(uuid, trash_at) as (
50         values (starting_uuid, starting_trash_at)
51         union
52         select groups.uuid, LEAST(project_subtree.trash_at, groups.trash_at)
53           from groups join project_subtree on (groups.owner_uuid = project_subtree.uuid)
54         )
55         select uuid, trash_at from project_subtree;
56 $$;
57 }
58
59     ActiveRecord::Base.connection.execute %{
60 create or replace function compute_trashed ()
61 returns table (uuid varchar(27), trash_at timestamp)
62 STABLE
63 language SQL
64 as $$
65 /* Helper function to populate trashed_groups table. This starts with
66    each group owned by a user and computes the subtree under that
67    group to find any groups that are trashed.
68 */
69 select ps.target_uuid as group_uuid, ps.trash_at from groups,
70   lateral project_subtree_with_trash_at(groups.uuid, groups.trash_at) ps
71   where groups.owner_uuid like '_____-tpzed-_______________'
72 $$;
73 }
74
75     # Now populate the table.  For a non-test databse this is the only
76     # time this ever happens, after this the trash table is updated
77     # incrementally.  See app/models/group.rb#update_trash
78     ActiveRecord::Base.connection.execute("INSERT INTO trashed_groups select * from compute_trashed()")
79
80     # The table to store the flattened permissions.  This is almost
81     # exactly the same as the old materalized_permission_view except
82     # that the target_owner_uuid colunm in the view is now just a
83     # boolean traverse_owned (the column was only ever tested for null
84     # or non-null).
85     #
86     # For details on how this table is used to apply permissions to
87     # queries, see app/models/arvados_model.rb#readable_by
88     #
89     create_table :materialized_permissions, :id => false do |t|
90       t.string :user_uuid
91       t.string :target_uuid
92       t.integer :perm_level
93       t.boolean :traverse_owned
94     end
95     add_index :materialized_permissions, [:user_uuid, :target_uuid], unique: true, name: 'permission_user_target'
96     add_index :materialized_permissions, [:target_uuid], unique: false, name: 'permission_target'
97
98     ActiveRecord::Base.connection.execute %{
99 create or replace function should_traverse_owned (starting_uuid varchar(27),
100                                                   starting_perm integer)
101   returns bool
102 IMMUTABLE
103 language SQL
104 as $$
105 /* Helper function.  Determines if permission on an object implies
106    transitive permission to things the object owns.  This is always
107    true for groups, but only true for users when the permission level
108    is can_manage.
109 */
110 select starting_uuid like '_____-j7d0g-_______________' or
111        (starting_uuid like '_____-tpzed-_______________' and starting_perm >= 3);
112 $$;
113 }
114
115     # Merge all permission relationships into a single view.  This
116     # consists of: groups (projects) owning things, users owning
117     # things, and explicit permission links.
118     #
119     # Fun fact, a SQL view gets inlined into the query where it is
120     # used, this enables the query planner to inject constraints, so
121     # when using the view we only look up edges we plan to traverse
122     # and avoid a brute force computation of all edges.
123     ActiveRecord::Base.connection.execute %{
124 create view permission_graph_edges as
125   select groups.owner_uuid as tail_uuid, groups.uuid as head_uuid, (3) as val from groups
126 union all
127   select users.owner_uuid as tail_uuid, users.uuid as head_uuid, (3) as val from users
128 union all
129   select links.tail_uuid,
130          links.head_uuid,
131          CASE
132            WHEN links.name = 'can_read'   THEN 1
133            WHEN links.name = 'can_login'  THEN 1
134            WHEN links.name = 'can_write'  THEN 2
135            WHEN links.name = 'can_manage' THEN 3
136           END as val
137       from links
138       where links.link_class='permission'
139 }
140
141     ActiveRecord::Base.connection.execute %{
142 create or replace function search_permission_graph (starting_uuid varchar(27),
143                                                     starting_perm integer,
144                                                     override_edge_tail varchar(27) default null,
145                                                     override_edge_head varchar(27) default null,
146                                                     override_edge_perm integer default null)
147   returns table (target_uuid varchar(27), val integer, traverse_owned bool)
148 STABLE
149 language SQL
150 as $$
151 /*
152   From starting_uuid, perform a recursive self-join on the edges
153   to follow chains of permissions.  This is a breadth-first search
154   of the permission graph.  Permission is propagated across edges,
155   which may narrow the permission for subsequent links (eg I start
156   at can_manage but when traversing a can_read link everything
157   touched through that link will only be can_read).
158
159   When revoking a permission, we follow the chain of permissions but
160   with a permissions level of 0.  The update on the permissions table
161   has to happen _before_ the permission is actually removed, because
162   we need to be able to traverse the edge before it goes away.  When
163   we do that, we also need to traverse it at the _new_ permission
164   level - this is what override_edge_tail/head/perm are for.
165
166   Yields the set of objects that are potentially affected, and
167   their permission levels granted by having starting_perm on
168   starting_uuid.
169
170   If starting_uuid is a user, this computes the entire set of
171   permissions for that user (because it returns everything that is
172   reachable by that user).
173
174   Used by the compute_permission_subgraph function.
175 */
176 WITH RECURSIVE
177         traverse_graph(target_uuid, val, traverse_owned) as (
178             values (starting_uuid, starting_perm,
179                     should_traverse_owned(starting_uuid, starting_perm))
180           union
181             (select edges.head_uuid,
182                       least(edges.val,
183                             traverse_graph.val,
184                             case traverse_graph.traverse_owned
185                               when true then null
186                               else 0
187                             end,
188                             case (edges.tail_uuid = override_edge_tail AND
189                                   edges.head_uuid = override_edge_head)
190                                when true then override_edge_perm
191                                else null
192                             end),
193                     should_traverse_owned(edges.head_uuid, edges.val)
194              from permission_graph_edges as edges, traverse_graph
195              where traverse_graph.target_uuid = edges.tail_uuid))
196         select target_uuid, max(val), bool_or(traverse_owned) from traverse_graph
197         group by (target_uuid);
198 $$;
199 }
200
201     ActiveRecord::Base.connection.execute %{
202 create or replace function compute_permission_subgraph (perm_origin_uuid varchar(27),
203                                                         starting_uuid varchar(27),
204                                                         starting_perm integer)
205 returns table (user_uuid varchar(27), target_uuid varchar(27), val integer, traverse_owned bool)
206 STABLE
207 language SQL
208 as $$
209 /* perm_origin_uuid: The object that 'gets' or 'has' the permission.
210
211    starting_uuid: The starting object the permission applies to.
212
213    starting_perm: The permission that perm_origin_uuid 'has' on
214                   starting_uuid One of 1, 2, 3 for can_read,
215                   can_write, can_manage respectively, or 0 to revoke
216                   permissions.
217
218    This function is broken up into a number of clauses, described
219    below.
220
221    Note on query optimization:
222
223    Each clause in a "with" statement is called a "common table
224    expression" or CTE.
225
226    In Postgres, they are evaluated in sequence and results of each CTE
227    is stored in a temporary table.  This means Postgres does not
228    propagate constraints from later subqueries to earlier subqueries
229    when they are CTEs.
230
231    This is a problem if, for example, a later subquery chooses 10
232    items out of a set of 1000000 defined by an earlier subquery,
233    because it will always compute all 1000000 rows even if the query
234    on the 1000000 rows could have been constrained.  This is why
235    permission_graph_edges is a view -- views are inlined so and can be
236    optimized using external constraints.
237
238    The query optimizer does sort the temporary tables for later use in
239    joins.
240
241    Final note, this query would have been almost impossible to write
242    (and certainly impossible to read) without splitting it up using
243    SQL "with" but unfortunately it also stumbles into a frustrating
244    Postgres optimizer bug, see
245    lib/refresh_permission_view.rb#update_permissions
246    for details and a partial workaround.
247 */
248 with
249   /* Gets the initial set of objects potentially affected by the
250      permission change, using search_permission_graph.
251   */
252   perm_from_start(perm_origin_uuid, target_uuid, val, traverse_owned) as (
253     select perm_origin_uuid, target_uuid, val, traverse_owned
254       from search_permission_graph(starting_uuid,
255                                    starting_perm,
256                                    perm_origin_uuid,
257                                    starting_uuid,
258                                    starting_perm)),
259
260   /* Finds other inbound edges that grant permissions on the objects
261      in perm_from_start, and computes permissions that originate from
262      those.  This is required to handle the case where there is more
263      than one path through which a user gets permission to an object.
264      For example, a user owns a project and also shares it can_read
265      with a group the user belongs to, adding the can_read link must
266      not overwrite the existing can_manage permission granted by
267      ownership.
268   */
269   additional_perms(perm_origin_uuid, target_uuid, val, traverse_owned) as (
270     select edges.tail_uuid as perm_origin_uuid, ps.target_uuid, ps.val,
271            should_traverse_owned(ps.target_uuid, ps.val)
272       from permission_graph_edges as edges,
273            lateral search_permission_graph(edges.head_uuid,
274                                            edges.val,
275                                            perm_origin_uuid,
276                                            starting_uuid,
277                                            starting_perm) as ps
278       where (not (edges.tail_uuid = perm_origin_uuid and
279                  edges.head_uuid = starting_uuid)) and
280             edges.tail_uuid not in (select target_uuid from perm_from_start) and
281             edges.head_uuid in (select target_uuid from perm_from_start)),
282
283   /* Combines the permissions computed in the first two phases. */
284   partial_perms(perm_origin_uuid, target_uuid, val, traverse_owned) as (
285       select * from perm_from_start
286     union all
287       select * from additional_perms
288   ),
289
290   /* If there are any users in the set of potentially affected objects
291      and the user's owner was not traversed, recompute permissions for
292      that user.  This is required because users always have permission
293      to themselves (identity property) which would be missing from the
294      permission set if the user was traversed while computing
295      permissions for another object.
296   */
297   user_identity_perms(perm_origin_uuid, target_uuid, val, traverse_owned) as (
298     select users.uuid as perm_origin_uuid, ps.target_uuid, ps.val, ps.traverse_owned
299       from users, lateral search_permission_graph(users.uuid,
300                                                   3,
301                                                   perm_origin_uuid,
302                                                   starting_uuid,
303                                                   starting_perm) as ps
304       where (users.owner_uuid not in (select target_uuid from partial_perms) or
305              users.owner_uuid = users.uuid) and
306       users.uuid in (select target_uuid from partial_perms)
307   ),
308
309   /* Combines all the computed permissions into one table. */
310   all_perms(perm_origin_uuid, target_uuid, val, traverse_owned) as (
311       select * from partial_perms
312     union
313       select * from user_identity_perms
314   )
315
316   /* The actual query that produces rows to be added or removed
317      from the materialized_permissions table.  This is the clever
318      bit.
319
320      Key insights:
321
322      * Permissions are transitive (with some special cases involving
323        users, this is controlled by the traverse_owned flag).
324
325      * A user object can only gain permissions via an inbound edge,
326        or appearing in the graph.
327
328      * The materialized_permissions table includes the permission
329        each user has on the tail end of each inbound edge.
330
331      * The all_perms subquery has permissions for each object in the
332        subgraph reachable from certain origin (tail end of an edge).
333
334      * Therefore, for each user, we can compute user permissions on
335        each object in subgraph by determining the permission the user
336        has on each origin (tail end of an edge), joining that with the
337        perm_origin_uuid column of all_perms, and taking the least() of
338        the origin edge or all_perms val (because of the "least
339        permission on the path" rule).  If an object was reachable by
340        more than one path (appears with more than one origin), we take
341        the max() of the computed permissions.
342
343      * Finally, because users always have permission on themselves, the
344        query also makes sure those permission rows are always
345        returned.
346   */
347   select v.user_uuid, v.target_uuid, max(v.perm_level), bool_or(v.traverse_owned) from
348     (select m.user_uuid,
349          u.target_uuid,
350          least(u.val, m.perm_level) as perm_level,
351          u.traverse_owned
352       from all_perms as u, materialized_permissions as m
353            where u.perm_origin_uuid = m.target_uuid AND m.traverse_owned
354     union all
355       select perm_origin_uuid as user_uuid, target_uuid, val as perm_level, traverse_owned
356         from all_perms
357         where all_perms.perm_origin_uuid like '_____-tpzed-_______________') as v
358     group by v.user_uuid, v.target_uuid
359 $$;
360      }
361
362     #
363     # Populate the materialized_permissions by traversing permissions
364     # starting at each user.
365     #
366     ActiveRecord::Base.connection.execute %{
367 INSERT INTO materialized_permissions
368 select users.uuid, g.target_uuid, g.val, g.traverse_owned
369 from users, lateral search_permission_graph(users.uuid, 3) as g where g.val > 0
370 }
371   end
372
373   def down
374     drop_table :materialized_permissions
375     drop_table :trashed_groups
376
377     ActiveRecord::Base.connection.execute "DROP function project_subtree_with_trash_at (varchar, timestamp);"
378     ActiveRecord::Base.connection.execute "DROP function compute_trashed ();"
379     ActiveRecord::Base.connection.execute "DROP function search_permission_graph(varchar, integer, varchar, varchar, integer);"
380     ActiveRecord::Base.connection.execute "DROP function compute_permission_subgraph (varchar, varchar, integer);"
381     ActiveRecord::Base.connection.execute "DROP function should_traverse_owned(varchar, integer);"
382     ActiveRecord::Base.connection.execute "DROP view permission_graph_edges;"
383
384     ActiveRecord::Base.connection.execute(%{
385 CREATE MATERIALIZED VIEW materialized_permission_view AS
386  WITH RECURSIVE perm_value(name, val) AS (
387          VALUES ('can_read'::text,(1)::smallint), ('can_login'::text,1), ('can_write'::text,2), ('can_manage'::text,3)
388         ), perm_edges(tail_uuid, head_uuid, val, follow, trashed) AS (
389          SELECT links.tail_uuid,
390             links.head_uuid,
391             pv.val,
392             ((pv.val = 3) OR (groups.uuid IS NOT NULL)) AS follow,
393             (0)::smallint AS trashed,
394             (0)::smallint AS followtrash
395            FROM ((public.links
396              LEFT JOIN perm_value pv ON ((pv.name = (links.name)::text)))
397              LEFT JOIN public.groups ON (((pv.val < 3) AND ((groups.uuid)::text = (links.head_uuid)::text))))
398           WHERE ((links.link_class)::text = 'permission'::text)
399         UNION ALL
400          SELECT groups.owner_uuid,
401             groups.uuid,
402             3,
403             true AS bool,
404                 CASE
405                     WHEN ((groups.trash_at IS NOT NULL) AND (groups.trash_at < clock_timestamp())) THEN 1
406                     ELSE 0
407                 END AS "case",
408             1
409            FROM public.groups
410         ), perm(val, follow, user_uuid, target_uuid, trashed) AS (
411          SELECT (3)::smallint AS val,
412             true AS follow,
413             (users.uuid)::character varying(32) AS user_uuid,
414             (users.uuid)::character varying(32) AS target_uuid,
415             (0)::smallint AS trashed
416            FROM public.users
417         UNION
418          SELECT (LEAST((perm_1.val)::integer, edges.val))::smallint AS val,
419             edges.follow,
420             perm_1.user_uuid,
421             (edges.head_uuid)::character varying(32) AS target_uuid,
422             ((GREATEST((perm_1.trashed)::integer, edges.trashed) * edges.followtrash))::smallint AS trashed
423            FROM (perm perm_1
424              JOIN perm_edges edges ON ((perm_1.follow AND ((edges.tail_uuid)::text = (perm_1.target_uuid)::text))))
425         )
426  SELECT perm.user_uuid,
427     perm.target_uuid,
428     max(perm.val) AS perm_level,
429         CASE perm.follow
430             WHEN true THEN perm.target_uuid
431             ELSE NULL::character varying
432         END AS target_owner_uuid,
433     max(perm.trashed) AS trashed
434    FROM perm
435   GROUP BY perm.user_uuid, perm.target_uuid,
436         CASE perm.follow
437             WHEN true THEN perm.target_uuid
438             ELSE NULL::character varying
439         END
440   WITH NO DATA;
441 }
442     )
443
444     add_index :materialized_permission_view, [:trashed, :target_uuid], name: 'permission_target_trashed'
445     add_index :materialized_permission_view, [:user_uuid, :trashed, :perm_level], name: 'permission_target_user_trashed_level'
446     create_table :permission_refresh_lock
447
448     ActiveRecord::Base.connection.execute 'REFRESH MATERIALIZED VIEW materialized_permission_view;'
449   end
450 end