1 # Copyright (C) The Arvados Authors. All rights reserved.
3 # SPDX-License-Identifier: AGPL-3.0
5 class PermissionTable < ActiveRecord::Migration[5.0]
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).
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.
21 ActiveRecord::Base.connection.execute "DROP MATERIALIZED VIEW IF EXISTS materialized_permission_view;"
22 drop_table :permission_refresh_lock
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|
31 add_index :trashed_groups, :group_uuid, :unique => true
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)
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
49 project_subtree(uuid, trash_at) as (
50 values (starting_uuid, starting_trash_at)
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)
55 select uuid, trash_at from project_subtree;
59 ActiveRecord::Base.connection.execute %{
60 create or replace function compute_trashed ()
61 returns table (uuid varchar(27), trash_at timestamp)
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.
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-_______________'
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()")
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
86 # For details on how this table is used to apply permissions to
87 # queries, see app/models/arvados_model.rb#readable_by
89 create_table :materialized_permissions, :id => false do |t|
93 t.boolean :traverse_owned
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'
98 ActiveRecord::Base.connection.execute %{
99 create or replace function should_traverse_owned (starting_uuid varchar(27),
100 starting_perm integer)
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
110 select starting_uuid like '_____-j7d0g-_______________' or
111 (starting_uuid like '_____-tpzed-_______________' and starting_perm >= 3);
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.
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
127 select users.owner_uuid as tail_uuid, users.uuid as head_uuid, (3) as val from users
129 select links.tail_uuid,
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
138 where links.link_class='permission'
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)
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).
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.
166 Yields the set of objects that are potentially affected, and
167 their permission levels granted by having starting_perm on
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).
174 Used by the compute_permission_subgraph function.
177 traverse_graph(target_uuid, val, traverse_owned) as (
178 values (starting_uuid, starting_perm,
179 should_traverse_owned(starting_uuid, starting_perm))
181 (select edges.head_uuid,
184 case traverse_graph.traverse_owned
188 case (edges.tail_uuid = override_edge_tail AND
189 edges.head_uuid = override_edge_head)
190 when true then override_edge_perm
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);
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)
209 /* perm_origin_uuid: The object that 'gets' or 'has' the permission.
211 starting_uuid: The starting object the permission applies to.
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
218 This function is broken up into a number of clauses, described
221 Note on query optimization:
223 Each clause in a "with" statement is called a "common table
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
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.
238 The query optimizer does sort the temporary tables for later use in
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.
249 /* Gets the initial set of objects potentially affected by the
250 permission change, using search_permission_graph.
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,
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
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,
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)),
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
287 select * from additional_perms
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.
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,
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)
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
313 select * from user_identity_perms
316 /* The actual query that produces rows to be added or removed
317 from the materialized_permissions table. This is the clever
322 * Permissions are transitive (with some special cases involving
323 users, this is controlled by the traverse_owned flag).
325 * A user object can only gain permissions via an inbound edge,
326 or appearing in the graph.
328 * The materialized_permissions table includes the permission
329 each user has on the tail end of each inbound edge.
331 * The all_perms subquery has permissions for each object in the
332 subgraph reachable from certain origin (tail end of an edge).
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.
343 * Finally, because users always have permission on themselves, the
344 query also makes sure those permission rows are always
347 select v.user_uuid, v.target_uuid, max(v.perm_level), bool_or(v.traverse_owned) from
350 least(u.val, m.perm_level) as perm_level,
352 from all_perms as u, materialized_permissions as m
353 where u.perm_origin_uuid = m.target_uuid AND m.traverse_owned
355 select perm_origin_uuid as user_uuid, target_uuid, val as perm_level, traverse_owned
357 where all_perms.perm_origin_uuid like '_____-tpzed-_______________') as v
358 group by v.user_uuid, v.target_uuid
363 # Populate the materialized_permissions by traversing permissions
364 # starting at each user.
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
374 drop_table :materialized_permissions
375 drop_table :trashed_groups
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;"
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,
392 ((pv.val = 3) OR (groups.uuid IS NOT NULL)) AS follow,
393 (0)::smallint AS trashed,
394 (0)::smallint AS followtrash
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)
400 SELECT groups.owner_uuid,
405 WHEN ((groups.trash_at IS NOT NULL) AND (groups.trash_at < clock_timestamp())) THEN 1
410 ), perm(val, follow, user_uuid, target_uuid, trashed) AS (
411 SELECT (3)::smallint AS val,
413 (users.uuid)::character varying(32) AS user_uuid,
414 (users.uuid)::character varying(32) AS target_uuid,
415 (0)::smallint AS trashed
418 SELECT (LEAST((perm_1.val)::integer, edges.val))::smallint AS val,
421 (edges.head_uuid)::character varying(32) AS target_uuid,
422 ((GREATEST((perm_1.trashed)::integer, edges.trashed) * edges.followtrash))::smallint AS trashed
424 JOIN perm_edges edges ON ((perm_1.follow AND ((edges.tail_uuid)::text = (perm_1.target_uuid)::text))))
426 SELECT perm.user_uuid,
428 max(perm.val) AS perm_level,
430 WHEN true THEN perm.target_uuid
431 ELSE NULL::character varying
432 END AS target_owner_uuid,
433 max(perm.trashed) AS trashed
435 GROUP BY perm.user_uuid, perm.target_uuid,
437 WHEN true THEN perm.target_uuid
438 ELSE NULL::character varying
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
448 ActiveRecord::Base.connection.execute 'REFRESH MATERIALIZED VIEW materialized_permission_view;'