16007: Lots and lots lots of method documentation via code comments.
authorPeter Amstutz <peter.amstutz@curii.com>
Fri, 22 May 2020 19:00:22 +0000 (15:00 -0400)
committerPeter Amstutz <peter.amstutz@curii.com>
Fri, 29 May 2020 02:31:26 +0000 (22:31 -0400)
Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz@curii.com>

services/api/app/models/arvados_model.rb
services/api/db/migrate/20200501150153_permission_table.rb
services/api/lib/refresh_permission_view.rb

index e168cc608efe2fd9b9eae8c7dfa1d42660c32b9a..cc6f7816aa75aeeb168a18514665e46967bdd79c 100644 (file)
@@ -285,6 +285,9 @@ class ArvadosModel < ApplicationRecord
     sql_conds = nil
     user_uuids = users_list.map { |u| u.uuid }
 
+    # For details on how the trashed_groups table is constructed, see
+    # see db/migrate/20200501150153_permission_table.rb
+
     exclude_trashed_records = ""
     if !include_trash and (sql_table == "groups" or sql_table == "collections") then
       # Only include records that are not trashed
@@ -306,6 +309,13 @@ class ArvadosModel < ApplicationRecord
         trashed_check = "AND target_uuid NOT IN (SELECT group_uuid FROM #{TRASHED_GROUPS} where trash_at <= statement_timestamp())"
       end
 
+      # The core of the permission check is a join against the
+      # materialized_permissions table to determine if the user has at
+      # least read permission to either the object itself or its
+      # direct owner.  See
+      # db/migrate/20200501150153_permission_table.rb for details on
+      # how the permissions are computed.
+
       # Note: it is possible to combine the direct_check and
       # owner_check into a single EXISTS() clause, however it turns
       # out query optimizer doesn't like it and forces a sequential
@@ -318,7 +328,22 @@ class ArvadosModel < ApplicationRecord
       direct_check = "#{sql_table}.uuid IN (SELECT target_uuid FROM #{PERMISSION_VIEW} "+
                      "WHERE user_uuid IN (:user_uuids) AND perm_level >= 1 #{trashed_check})"
 
-      # Match a read permission link from the user to the record's owner_uuid
+      # Match a read permission for the user to the record's
+      # owner_uuid.  This is so we can have a permissions table that
+      # mostly consists of users and groups (projects are a type of
+      # group) and not have to compute and list user permission to
+      # every single object in the system.
+      #
+      # Don't do this for API keys (special behavior) or groups
+      # (already covered by direct_check).
+      #
+      # The traverse_owned flag indicates whether the permission to
+      # read an object also implies transitive permission to read
+      # things the object owns.  The situation where this is important
+      # are determining if we can read an object owned by another
+      # user.  This makes it possible to have permission to read the
+      # user record without granting permission to read things the
+      # other user owns.
       owner_check = ""
       if sql_table != "api_client_authorizations" and sql_table != "groups" then
         owner_check = "OR #{sql_table}.owner_uuid IN (SELECT target_uuid FROM #{PERMISSION_VIEW} "+
index 51216b9c23b30398c14cf2bb2385dd47946cd49e..d62141a7678a848d4bb4c2bfee1b4428a8fdedc2 100644 (file)
@@ -4,18 +4,42 @@
 
 class PermissionTable < ActiveRecord::Migration[5.0]
   def up
+    # This is a major migration.  We are replacing the
+    # materialized_permission_view, which is fully recomputed any time
+    # a permission changes (and becomes very expensive as the number
+    # of users/groups becomes large), with a new strategy that only
+    # recomputes permissions for the subset of objects that are
+    # potentially affected by the addition or removal of a permission
+    # relationship (i.e. ownership or a permission link).
+    #
+    # This also disentangles the concept of "trashed groups" from the
+    # permissions system.  Updating trashed items follows a similar
+    # (but less complicated) strategy to updating permissions, so it
+    # may be helpful to look at that first.
+    #
+
     ActiveRecord::Base.connection.execute "DROP MATERIALIZED VIEW IF EXISTS materialized_permission_view;"
     drop_table :permission_refresh_lock
 
-    create_table :materialized_permissions, :id => false do |t|
-      t.string :user_uuid
-      t.string :target_uuid
-      t.integer :perm_level
-      t.boolean :traverse_owned
+    # This table stores the set of trashed groups and their trash_at
+    # time.  Used to exclude trashed projects and their contents when
+    # getting object listings.
+    create_table :trashed_groups, :id => false do |t|
+      t.string :group_uuid
+      t.datetime :trash_at
     end
-    add_index :materialized_permissions, [:user_uuid, :target_uuid], unique: true, name: 'permission_user_target'
-    add_index :materialized_permissions, [:target_uuid], unique: false, name: 'permission_target'
+    add_index :trashed_groups, :group_uuid, :unique => true
 
+    #
+    # Starting from a project, recursively traverse all the projects
+    # underneath it and return a set of project uuids and trash_at
+    # times (may be null).  The initial trash_at can be a timestamp or
+    # null.  The trash_at time propagates downward to groups it owns,
+    # i.e. when a group is trashed, everything underneath it in the
+    # ownership hierarchy is also considered trashed.  However, this
+    # is fact is recorded in the trashed_groups table, not by updating
+    # trash_at field in the groups table.
+    #
     ActiveRecord::Base.connection.execute %{
 create or replace function project_subtree_with_trash_at (starting_uuid varchar(27), starting_trash_at timestamp)
 returns table (target_uuid varchar(27), trash_at timestamp)
@@ -33,12 +57,9 @@ WITH RECURSIVE
 $$;
 }
 
-    create_table :trashed_groups, :id => false do |t|
-      t.string :group_uuid
-      t.datetime :trash_at
-    end
-    add_index :trashed_groups, :group_uuid, :unique => true
-
+    # Helper function to populate trashed_groups table. This starts
+    # with each group owned by a user and computes the subtree under
+    # that group to find any groups that are trashed.
     ActiveRecord::Base.connection.execute %{
 create or replace function compute_trashed ()
 returns table (uuid varchar(27), trash_at timestamp)
@@ -51,13 +72,39 @@ select ps.target_uuid as group_uuid, ps.trash_at from groups,
 $$;
 }
 
+    # Now populate the table.  For a non-test databse this is the only
+    # time this ever happens, after this the trash table is updated
+    # incrementally.  See app/models/group.rb#update_trash
     ActiveRecord::Base.connection.execute("INSERT INTO trashed_groups select * from compute_trashed()")
 
+
+    # The table to store the flattened permissions.  This is almost
+    # exactly the same as the old materalized_permission_view except
+    # that the target_owner_uuid colunm in the view is now just a
+    # boolean traverse_owned (the column was only ever tested for null
+    # or non-null).
+    #
+    # For details on how this table is used to apply permissions to
+    # queries, see app/models/arvados_model.rb#readable_by
+    #
+    create_table :materialized_permissions, :id => false do |t|
+      t.string :user_uuid
+      t.string :target_uuid
+      t.integer :perm_level
+      t.boolean :traverse_owned
+    end
+    add_index :materialized_permissions, [:user_uuid, :target_uuid], unique: true, name: 'permission_user_target'
+    add_index :materialized_permissions, [:target_uuid], unique: false, name: 'permission_target'
+
+    # Helper function.  Determines if permission on an object implies
+    # transitive permission to things the object owns.  This is always
+    # true for groups, but only true for users when the permission
+    # level is can_manage.
     ActiveRecord::Base.connection.execute %{
 create or replace function should_traverse_owned (starting_uuid varchar(27),
                                                   starting_perm integer)
   returns bool
-STABLE
+IMMUTABLE
 language SQL
 as $$
 select starting_uuid like '_____-j7d0g-_______________' or
@@ -65,6 +112,14 @@ select starting_uuid like '_____-j7d0g-_______________' or
 $$;
 }
 
+    # Merge all permission relationships into a single view.  This
+    # consists of: groups (projects) owning things, users owning
+    # things, and explicit permission links.
+    #
+    # Fun fact, a SQL view gets inlined into the query where it is
+    # used, this enables the query planner to inject constraints, so
+    # when using the view we only look up edges we plan to traverse
+    # and avoid a brute force computation of all edges.
     ActiveRecord::Base.connection.execute %{
 create view permission_graph_edges as
   select groups.owner_uuid as tail_uuid, groups.uuid as head_uuid, (3) as val from groups
@@ -83,16 +138,22 @@ union all
       where links.link_class='permission'
 }
 
-        # Get a set of permission by searching the graph and following
-        # ownership and permission links.
-        #
-        # edges() - a subselect with the union of ownership and permission links
-        #
-        # traverse_graph() - recursive query, from the starting node,
-        # self-join with edges to find outgoing permissions.
-        # Re-runs the query on new rows until there are no more results.
-        # This accomplishes a breadth-first search of the permission graph.
-        #
+    # From starting_uuid, perform a recursive self-join on the edges
+    # to follow chains of permissions.  This is a breadth-first search
+    # of the permission graph.  Permission is propagated across edges,
+    # which may narrow the permission for subsequent links (eg I start
+    # at can_manage but when traversing a can_read link everything
+    # touched through that link will only be can_read).
+    #
+    # Yields the set of objects that are potentially affected, and
+    # their permission levels granted by having starting_perm on
+    # starting_uuid.
+    #
+    # If starting_uuid is a user, this computes the entire set of
+    # permissions for that user (because it returns everything that is
+    # reachable by that user).
+    #
+    # Used by compute_permission_subgraph below.
     ActiveRecord::Base.connection.execute %{
 create or replace function search_permission_graph (starting_uuid varchar(27),
                                                     starting_perm integer)
@@ -119,6 +180,55 @@ WITH RECURSIVE
 $$;
 }
 
+    # This is the key function.
+    #
+    # perm_origin_uuid: The object that 'gets' or 'has' the permission.
+    #
+    # starting_uuid: The starting object the permission applies to.
+    #
+    # starting_perm: The permission that perm_origin_uuid 'has' on starting_uuid
+    # One of 1, 2, 3 for can_read, can_write, can_manage
+    # respectively, or 0 to revoke permissions.
+    #
+    # This function is broken up into a number of phases.
+    #
+    # 1. perm_from_start: Gets the initial set of objects potentially
+    # affected by the permission change, using
+    # search_permission_graph.
+    #
+    # 2. additional_perms: Finds other inbound edges that grant
+    # permissions on the objects in perm_from_start, and computes
+    # permissions that originate from those.  This is required to
+    # handle the case where there is more than one path through which
+    # a user gets permission to an object.  For example, a user owns a
+    # project and also shares it can_read with a group the user
+    # belongs to, adding the can_read link must not overwrite the
+    # existing can_manage permission granted by ownership.
+    #
+    # 3. partial_perms: Combine the permissions computed in the first two phases.
+    #
+    # 4. user_identity_perms: If there are any users in the set of
+    # potentially affected objects and the user's owner was not
+    # traversed, recompute permissions for that user.  This is
+    # required because users always have permission to themselves
+    # (identity property) which would be missing from the permission
+    # set if the user was traversed while computing permissions for
+    # another object.
+    #
+    # 5. all_perms: Combines perm_from_start, additional_perms, and user_identity_perms.
+    #
+    # 6. The actual query that produces rows to be added or removed
+    # from the materialized_permissions table.  This is the clever
+    # bit.
+    #
+    # Key insight: because permissions are transitive (unless
+    # traverse_owned is false), by knowing the permissions granted
+    # from all the "origins" (perm_origin_uuid, tail_uuid of links
+    # where head_uuid is in our potentially affected set, etc) we can
+    # join with the materialized_permissions table to get user
+    # permissions on those origins, and apply that to the whole graph
+    # of objects reached through that origin.
+    #
     ActiveRecord::Base.connection.execute %{
 create or replace function compute_permission_subgraph (perm_origin_uuid varchar(27),
                                                         starting_uuid varchar(27),
@@ -128,9 +238,9 @@ STABLE
 language SQL
 as $$
 with
-perm_from_start(perm_origin_uuid, target_uuid, val, traverse_owned) as (
-  select perm_origin_uuid, target_uuid, val, traverse_owned
-    from search_permission_graph(starting_uuid, starting_perm)),
+  perm_from_start(perm_origin_uuid, target_uuid, val, traverse_owned) as (
+    select perm_origin_uuid, target_uuid, val, traverse_owned
+      from search_permission_graph(starting_uuid, starting_perm)),
 
   additional_perms(perm_origin_uuid, target_uuid, val, traverse_owned) as (
     select edges.tail_uuid as perm_origin_uuid, ps.target_uuid, ps.val,
index 3901a1f2206f440ddd66e14f5b01387b67049bf3..c0d5c1b694391326fcc9594cb1d5e5a778f743b2 100644 (file)
@@ -19,29 +19,92 @@ from users, lateral search_permission_graph(users.uuid, 3) as g where g.val > 0
 end
 
 def refresh_trashed
+    ActiveRecord::Base.connection.execute("LOCK TABLE #{TRASHED_GROUPS}")
   ActiveRecord::Base.connection.execute("DELETE FROM #{TRASHED_GROUPS}")
   ActiveRecord::Base.connection.execute("INSERT INTO #{TRASHED_GROUPS} select * from compute_trashed()")
 end
 
 def update_permissions perm_origin_uuid, starting_uuid, perm_level, check=false
-  # Update a subset of the permission graph
-  # perm_level is the inherited permission
+  #
+  # Update a subset of the permission table affected by adding or
+  # removing a particular permission relationship (ownership or a
+  # permission link).
+  #
+  # perm_origin_uuid: This is the object that 'gets' the permission.
+  # It is the owner_uuid or tail_uuid.
+  #
+  # starting_uuid: The object we are computing permission for (or head_uuid)
+  #
+  # perm_level: The level of permission that perm_origin_uuid gets for starting_uuid.
+  #
   # perm_level is a number from 0-3
   #   can_read=1
   #   can_write=2
   #   can_manage=3
-  #   call with perm_level=0 to revoke permissions
+  #   or call with perm_level=0 to revoke permissions
   #
-  # 1. Compute set (group, permission) implied by traversing
-  #    graph starting at this group
-  # 2. Find links from outside the graph that point inside
-  # 3. For each starting uuid, get the set of permissions from the
-  #    materialized permission table
-  # 3. Delete permissions from table not in our computed subset.
-  # 4. Upsert each permission in our subset (user, group, val)
+  # check: for testing/debugging only, compare the result of the
+  # incremental update against a full table recompute.  Throws an
+  # error if the contents are not identical (ie they produce different
+  # permission results)
 
+  # Theory of operation
+  #
+  # Give a change in a specific permission relationship, we recompute
+  # the set of permissions (for all users) that could possibly be
+  # affected by that relationship.  For example, if a project is
+  # shared with another user, we recompute all permissions for all
+  # projects in the hierarchy.  This returns a set of updated
+  # permissions, which we stash in a temporary table.
+  #
+  # Then, for each user_uuid/target_uuid in the updated permissions
+  # result set we insert/update a permission row in
+  # materialized_permissions, and delete any rows that exist in
+  # materialized_permissions that are not in the result set or have
+  # perm_level=0.
+  #
+  # see db/migrate/20200501150153_permission_table.rb for details on
+  # how the permissions are computed.
+
+  # "Conflicts with the ROW EXCLUSIVE, SHARE UPDATE EXCLUSIVE, SHARE
+  # ROW EXCLUSIVE, EXCLUSIVE, and ACCESS EXCLUSIVE lock modes. This
+  # mode protects a table against concurrent data changes."
   ActiveRecord::Base.connection.execute "LOCK TABLE #{PERMISSION_VIEW} in SHARE MODE"
 
+  # Workaround for
+  # BUG #15160: planner overestimates number of rows in join when there are more than 200 rows coming from CTE
+  # https://www.postgresql.org/message-id/152395805004.19366.3107109716821067806@wrigleys.postgresql.org
+  #
+  # For a crucial join in the compute_permission_subgraph() query, the
+  # planner mis-estimates the number of rows in a Common Table
+  # Expression (CTE, this is a subquery in a WITH clause) and as a
+  # result it chooses the wrong join order.  The join starts with the
+  # permissions table because it mistakenly thinks
+  # count(materalized_permissions) < count(new computed permissions)
+  # when actually it is the other way around.
+  #
+  # Because of the incorrect join order, it choose the wrong join
+  # strategy (merge join, which works best when two tables are roughly
+  # the same size).  As a workaround, we can tell it not to use that
+  # join strategy, this causes it to pick hash join instead, which
+  # turns out to be a bit better.  However, because the join order is
+  # still wrong, we don't get the full benefit of the index.
+  #
+  # This is very unfortunate because it makes the query performance
+  # dependent on the size of the materalized_permissions table, when
+  # the goal of this design was to make permission updates scale-free
+  # and only depend on the number of permissions affected and not the
+  # total table size.  In several hours of researching I wasn't able
+  # to find a way to force the correct join order, so I'm calling it
+  # here and I have to move on.
+  #
+  # This is apparently addressed in Postgres 12, but I developed &
+  # tested this on Postgres 9.6, so in the future we should reevaluate
+  # the performance & query plan on Postgres 12.
+  #
+  # https://git.furworks.de/opensourcemirror/postgresql/commit/a314c34079cf06d05265623dd7c056f8fa9d577f
+  #
+  # Disable merge join for just this query (also local for this transaction), then reenable it.
   ActiveRecord::Base.connection.exec_query "SET LOCAL enable_mergejoin to false;"
 
   temptable_perms = "temp_perms_#{rand(2**64).to_s(10)}"