16007: Now switch over to incremental permissions (WIP)
authorPeter Amstutz <peter.amstutz@curii.com>
Sat, 9 May 2020 02:08:36 +0000 (22:08 -0400)
committerPeter Amstutz <peter.amstutz@curii.com>
Sat, 9 May 2020 02:08:36 +0000 (22:08 -0400)
Arvados-DCO-1.1-Signed-off-by: Peter Amstutz <peter.amstutz@curii.com>

services/api/app/models/group.rb
services/api/app/models/link.rb
services/api/app/models/user.rb
services/api/db/migrate/20200501150153_permission_table.rb
services/api/test/performance/permission_test.rb
services/api/test/test_helper.rb

index e5760938adccc6fabbe962e75818e84929e641d0..3ba7c4b96a2da4e9706ab9d91eb80b076dd2765a 100644 (file)
@@ -17,9 +17,15 @@ class Group < ArvadosModel
   attribute :properties, :jsonbHash, default: {}
 
   validate :ensure_filesystem_compatible_name
-  after_create :invalidate_permissions_cache
-  after_update :maybe_invalidate_permissions_cache
   before_create :assign_name
+  after_create :update_permissions
+  after_create :update_trash
+
+  after_update :update_permissions
+  after_update :update_trash
+
+  after_destroy :clear_permissions_and_trash
+
 
   api_accessible :user, extend: :common do |t|
     t.add :name
@@ -38,8 +44,8 @@ class Group < ArvadosModel
     super if group_class == 'project'
   end
 
-  def maybe_invalidate_permissions_cache
-    if trash_at_changed? or owner_uuid_changed?
+  def update_trash
+    if trash_at_changed? or owner_uuid_changed? or (new_record? and !trash_at.nil?)
       # The group was added or removed from the trash.
       #
       # Strategy:
@@ -67,56 +73,20 @@ insert into trashed_groups (group_uuid, trash_at)
 on conflict (group_uuid) do update set trash_at=EXCLUDED.trash_at;
 }
     end
-
-    if uuid_changed? or owner_uuid_changed?
-      # This can change users' permissions on other groups as well as
-      # this one.
-      invalidate_permissions_cache
-    end
   end
 
-  def invalidate_permissions_cache
-    # Ensure a new group can be accessed by the appropriate users
-    # immediately after being created.
-    User.invalidate_permissions_cache self.async_permissions_update
-
+  def update_permissions
     if new_record? or owner_uuid_changed?
-      # 1. Compute set (group, permission) implied by traversing
-      #    graph starting at this group
-      # 2. Find links from outside the graph that point inside
-      # 3. We now have the full set of permissions for a subset of groups.
-      # 3. Upsert each permission in our subset (user, group, val)
-      # 4. Delete permissions from table not in our computed subset.
-
-      temptable_perm_from_start = "perm_from_start_#{rand(2**64).to_s(10)}"
-      ActiveRecord::Base.connection.exec_query %{
-create temporary table #{temptable_perm_from_start} on commit drop
-as select $1::varchar as starting_uuid, target_uuid, val
-from search_permission_graph($1::varchar, 3::smallint)
-},
-                                               'Group.search_permissions',
-                                               [[nil, self.uuid]]
+      User.update_permissions self.owner_uuid, self.uuid, 3
+    end
+  end
 
-      temptable_additional_perms = "additional_perms_#{rand(2**64).to_s(10)}"
-      ActiveRecord::Base.connection.exec_query %{
-create temporary table #{temptable_additional_perms} on commit drop
-as select links.tail_uuid as starting_uuid, ps.target_uuid, ps.val
-  from links, lateral search_permission_graph(links.head_uuid::varchar, CASE
-WHEN links.name = 'can_read' THEN 1::smallint
-WHEN links.name = 'can_login' THEN 1::smallint
-WHEN links.name = 'can_write' THEN 2::smallint
-WHEN links.name = 'can_manage' THEN 3::smallint
-END) as ps
-where links.link_class='permission' and
-  links.tail_uuid not in (select target_uuid from #{temptable_perm_from_start}) and
-  links.head_uuid in (select target_uuid from #{temptable_perm_from_start})
-}
+  def clear_permissions_and_trash
+    User.update_permissions self.owner_uuid, self.uuid, 0
+    ActiveRecord::Base.connection.exec_query %{
+delete from trashed_groups where group_uuid=$1
+}, "Group.clear_trash", [[nil, self.uuid]]
 
-      q1 = ActiveRecord::Base.connection.exec_query "select * from #{temptable_perm_from_start}"
-      q1.each do |r|
-        #puts r
-      end
-    end
   end
 
   def assign_name
index ad7800fe679cb91936bde76f00566873cb369419..9e52c05e648fefb38658b60ec8e00ab950ae8905 100644 (file)
@@ -11,12 +11,12 @@ class Link < ArvadosModel
   # already know how to properly treat them.
   attribute :properties, :jsonbHash, default: {}
 
+  validate :name_links_are_obsolete
   before_create :permission_to_attach_to_objects
   before_update :permission_to_attach_to_objects
-  after_update :maybe_invalidate_permissions_cache
-  after_create :maybe_invalidate_permissions_cache
-  after_destroy :maybe_invalidate_permissions_cache
-  validate :name_links_are_obsolete
+  after_update :update_permissions
+  after_create :update_permissions
+  after_destroy :clear_permissions
 
   api_accessible :user, extend: :common do |t|
     t.add :tail_uuid
@@ -64,15 +64,22 @@ class Link < ArvadosModel
     false
   end
 
-  def maybe_invalidate_permissions_cache
+  PERM_LEVEL = {
+    'can_read' => 1,
+    'can_login' => 1,
+    'can_write' => 2,
+    'can_manage' => 3,
+  }
+
+  def update_permissions
+    if self.link_class == 'permission'
+      User.update_permissions tail_uuid, head_uuid, PERM_LEVEL[name]
+    end
+  end
+
+  def clear_permissions
     if self.link_class == 'permission'
-      # Clearing the entire permissions cache can generate many
-      # unnecessary queries if many active users are not affected by
-      # this change. In such cases it would be better to search cached
-      # permissions for head_uuid and tail_uuid, and invalidate the
-      # cache for only those users. (This would require a browseable
-      # cache.)
-      User.invalidate_permissions_cache
+      User.update_permissions tail_uuid, head_uuid, 0
     end
   end
 
index a344919667375fe6a58797ec4cd5ce0d23bc51ba..7edad3ecbd589dee12de6eb3cf3fa50cd6278ccf 100644 (file)
@@ -3,7 +3,6 @@
 # SPDX-License-Identifier: AGPL-3.0
 
 require 'can_be_an_owner'
-require 'refresh_permission_view'
 
 class User < ArvadosModel
   include HasUuid
@@ -32,15 +31,16 @@ class User < ArvadosModel
   before_create :set_initial_username, :if => Proc.new { |user|
     user.username.nil? and user.email
   }
+  after_create :update_permissions
   after_create :setup_on_activate
   after_create :add_system_group_permission_link
-  after_create :invalidate_permissions_cache
   after_create :auto_setup_new_user, :if => Proc.new { |user|
     Rails.configuration.Users.AutoSetupNewUsers and
     (user.uuid != system_user_uuid) and
     (user.uuid != anonymous_user_uuid)
   }
   after_create :send_admin_notifications
+  after_update :update_permissions
   after_update :send_profile_created_notification
   after_update :sync_repository_names, :if => Proc.new { |user|
     (user.uuid != system_user_uuid) and
@@ -48,6 +48,7 @@ class User < ArvadosModel
     (not user.username_was.nil?)
   }
 
+
   has_many :authorized_keys, :foreign_key => :authorized_user_uuid, :primary_key => :uuid
   has_many :repositories, foreign_key: :owner_uuid, primary_key: :uuid
 
@@ -143,12 +144,125 @@ class User < ArvadosModel
     true
   end
 
-  def self.invalidate_permissions_cache(async=false)
-    refresh_permission_view(async)
+  def update_permissions
+    if owner_uuid_changed?
+      puts "Update permissions for #{uuid} #{new_record?}"
+    User.printdump %{
+select * from materialized_permissions where user_uuid='#{uuid}'
+}
+    puts "---"
+    User.update_permissions self.owner_uuid, self.uuid, 3
+    User.printdump %{
+select * from materialized_permissions where user_uuid='#{uuid}'
+}
+
+    end
+  end
+
+  def self.printdump qr
+    q1 = ActiveRecord::Base.connection.exec_query qr
+    q1.each do |r|
+      puts r
+    end
   end
 
-  def invalidate_permissions_cache
-    User.invalidate_permissions_cache
+  def self.update_permissions perm_origin_uuid, starting_uuid, perm_level
+    # Update a subset of the permission graph
+    # perm_level is the inherited permission
+    # 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
+    #
+    # 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)
+
+    ## testinging
+    puts "What's in there now for #{starting_uuid}"
+    printdump %{
+select * from materialized_permissions where user_uuid='#{starting_uuid}'
+}
+
+    puts "search_permission_graph #{perm_origin_uuid} #{starting_uuid}, #{perm_level}"
+    printdump %{
+select '#{perm_origin_uuid}'::varchar as perm_origin_uuid, target_uuid, val, traverse_owned from search_permission_graph('#{starting_uuid}', #{perm_level})
+}
+
+    puts "Perms out"
+    printdump %{
+with
+perm_from_start(perm_origin_uuid, target_uuid, val, traverse_owned) as (
+  select  '#{perm_origin_uuid}'::varchar, target_uuid, val, traverse_owned
+    from search_permission_graph('#{starting_uuid}', #{perm_level}))
+
+(select materialized_permissions.user_uuid, u.target_uuid, max(least(materialized_permissions.perm_level, u.val)), bool_or(u.traverse_owned)
+  from perm_from_start as u
+  join materialized_permissions on (u.perm_origin_uuid = materialized_permissions.target_uuid)
+  where materialized_permissions.traverse_owned
+  group by materialized_permissions.user_uuid, u.target_uuid)
+union
+  select target_uuid as user_uuid, target_uuid, 3, true
+    from perm_from_start where target_uuid like '_____-tpzed-_______________'
+}
+    ## end
+
+    temptable_perms = "temp_perms_#{rand(2**64).to_s(10)}"
+    ActiveRecord::Base.connection.exec_query %{
+create temporary table #{temptable_perms} on commit drop
+as select * from compute_permission_subgraph($1, $2, $3)
+},
+                                             'Group.search_permissions',
+                                             [[nil, perm_origin_uuid],
+                                              [nil, starting_uuid],
+                                              [nil, perm_level]]
+
+    q1 = ActiveRecord::Base.connection.exec_query %{
+select * from #{temptable_perms}
+}
+    puts "recomputed perms was #{perm_origin_uuid} #{starting_uuid}, #{perm_level}"
+    q1.each do |r|
+      puts r
+    end
+
+    ActiveRecord::Base.connection.exec_query %{
+delete from materialized_permissions where
+  target_uuid in (select target_uuid from #{temptable_perms}) and
+  (user_uuid not in (select user_uuid from #{temptable_perms} where target_uuid=materialized_permissions.target_uuid)
+   or user_uuid in (select user_uuid from #{temptable_perms} where target_uuid=materialized_permissions.target_uuid and perm_level=0))
+}
+
+    ActiveRecord::Base.connection.exec_query %{
+insert into materialized_permissions (user_uuid, target_uuid, perm_level, traverse_owned)
+  select user_uuid, target_uuid, val as perm_level, traverse_owned from #{temptable_perms}
+on conflict (user_uuid, target_uuid) do update set perm_level=EXCLUDED.perm_level, traverse_owned=EXCLUDED.traverse_owned;
+}
+
+    # for testing only - make a copy of the table and compare it to the one generated
+    # using a full permission recompute
+#     temptable_compare = "compare_perms_#{rand(2**64).to_s(10)}"
+#     ActiveRecord::Base.connection.exec_query %{
+# create temporary table #{temptable_compare} on commit drop as select * from materialized_permissions
+# }
+
+    # Ensure a new group can be accessed by the appropriate users
+    # immediately after being created.
+    #User.invalidate_permissions_cache
+
+#     q1 = ActiveRecord::Base.connection.exec_query %{
+# select count(*) from materialized_permissions
+# }
+#     puts "correct version #{q1.first}"
+
+#     q2 = ActiveRecord::Base.connection.exec_query %{
+# select count(*) from #{temptable_compare}
+# }
+#     puts "incremental update #{q2.first}"
   end
 
   # Return a hash of {user_uuid: group_perms}
@@ -328,6 +442,8 @@ class User < ArvadosModel
       raise "user does not exist" if !new_user
       raise "cannot merge to an already merged user" if new_user.redirect_to_user_uuid
 
+      User.update_permissions self.owner_uuid, self.uuid, 0
+
       # If 'self' is a remote user, don't transfer authorizations
       # (i.e. ability to access the account) to the new user, because
       # that gives the remote site the ability to access the 'new'
@@ -402,7 +518,8 @@ class User < ArvadosModel
       if redirect_to_new_user
         update_attributes!(redirect_to_user_uuid: new_user.uuid, username: nil)
       end
-      invalidate_permissions_cache
+      User.update_permissions self.owner_uuid, self.uuid, 3
+      User.update_permissions new_user.owner_uuid, new_user.uuid, 3
     end
   end
 
@@ -660,6 +777,7 @@ class User < ArvadosModel
 
   # add the user to the 'All users' group
   def create_user_group_link
+    puts "In create_user_group_link"
     return (Link.where(tail_uuid: self.uuid,
                        head_uuid: all_users_group[:uuid],
                        link_class: 'permission',
index 296549bb72be447ea2e26f030707d79c2203cde5..425a9505fada85963398d9e703af8ff96248d87c 100644 (file)
@@ -6,6 +6,7 @@ class PermissionTable < ActiveRecord::Migration[5.0]
       t.integer :perm_level
       t.boolean :traverse_owned
     end
+    add_index :materialized_permissions, [:user_uuid, :target_uuid], unique: true, name: 'permission_user_target'
 
     ActiveRecord::Base.connection.execute %{
 create or replace function project_subtree (starting_uuid varchar(27))
@@ -71,8 +72,9 @@ $$;
         # This accomplishes a breadth-first search of the permission graph.
         #
         ActiveRecord::Base.connection.execute %{
-create or replace function search_permission_graph (starting_uuid varchar(27), starting_perm integer)
-returns table (target_uuid varchar(27), val integer, traverse_owned bool)
+create or replace function search_permission_graph (starting_uuid varchar(27),
+                                                    starting_perm integer)
+  returns table (target_uuid varchar(27), val integer, traverse_owned bool)
 STABLE
 language SQL
 as $$
@@ -105,6 +107,43 @@ WITH RECURSIVE edges(tail_uuid, head_uuid, val) as (
 $$;
 }
 
+        ActiveRecord::Base.connection.execute %{
+create or replace function compute_permission_subgraph (perm_origin_uuid varchar(27),
+                                                        starting_uuid varchar(27),
+                                                        starting_perm integer)
+returns table (user_uuid varchar(27), target_uuid varchar(27), val integer, traverse_owned bool)
+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)),
+
+  additional_perms(perm_origin_uuid, target_uuid, val, traverse_owned) as (
+    select links.tail_uuid as perm_origin_uuid, ps.target_uuid, ps.val, true
+      from links, lateral search_permission_graph(links.head_uuid,
+                        CASE
+                          WHEN links.name = 'can_read'   THEN 1
+                          WHEN links.name = 'can_login'  THEN 1
+                          WHEN links.name = 'can_write'  THEN 2
+                          WHEN links.name = 'can_manage' THEN 3
+                        END) as ps
+      where links.link_class='permission' and
+        links.tail_uuid not in (select target_uuid from perm_from_start) and
+        links.head_uuid in (select target_uuid from perm_from_start))
+
+select materialized_permissions.user_uuid, u.target_uuid, max(least(u.val, materialized_permissions.perm_level)), bool_or(u.traverse_owned)
+  from ((select * from perm_from_start) union (select * from additional_perms)) as u
+  join materialized_permissions on (u.perm_origin_uuid = materialized_permissions.target_uuid)
+  where materialized_permissions.traverse_owned
+  group by materialized_permissions.user_uuid, u.target_uuid
+union
+  select target_uuid as user_uuid, target_uuid, 3, true
+    from perm_from_start where target_uuid like '_____-tpzed-_______________'
+$$;
+     }
+
     ActiveRecord::Base.connection.execute "DROP MATERIALIZED VIEW IF EXISTS materialized_permission_view;"
 
   end
@@ -116,5 +155,6 @@ $$;
     ActiveRecord::Base.connection.execute "DROP function project_subtree_with_trash_at (varchar, timestamp);"
     ActiveRecord::Base.connection.execute "DROP function compute_trashed ();"
     ActiveRecord::Base.connection.execute "DROP function search_permission_graph(varchar, integer);"
+    ActiveRecord::Base.connection.execute "DROP function compute_permission_subgraph (varchar, varchar, integer);"
   end
 end
index a0605f97e72c1a749ccec12b46cd1a406417e2f0..81edf488a78c543fe651b6eec53a16a761590392 100644 (file)
@@ -40,7 +40,8 @@ class PermissionPerfTest < ActionDispatch::IntegrationTest
                    end
                  end
                end
-               User.invalidate_permissions_cache
+               #User.invalidate_permissions_cache
+               do_refresh_permission_view
              end
            end)
     end
index cee618557ea01515060f71755669c744715ab1b1..c6ea0b320fdac3a0183c15ae1d37603cf4ef9e8e 100644 (file)
@@ -2,6 +2,8 @@
 #
 # SPDX-License-Identifier: AGPL-3.0
 
+require 'refresh_permission_view'
+
 ENV["RAILS_ENV"] = "test"
 unless ENV["NO_COVERAGE_TEST"]
   begin
@@ -60,12 +62,6 @@ class ActiveSupport::TestCase
   include ArvadosTestSupport
   include CurrentApiClient
 
-  setup do
-    do_refresh_permission_view
-    ActiveRecord::Base.connection.execute("DELETE FROM #{TRASHED_GROUPS}")
-    ActiveRecord::Base.connection.execute("INSERT INTO #{TRASHED_GROUPS} select * from compute_trashed()")
-  end
-
   teardown do
     Thread.current[:api_client_ip_address] = nil
     Thread.current[:api_client_authorization] = nil
@@ -213,4 +209,6 @@ class ActionDispatch::IntegrationTest
 end
 
 # Ensure permissions are computed from the test fixtures.
-User.invalidate_permissions_cache
+do_refresh_permission_view
+ActiveRecord::Base.connection.execute("DELETE FROM #{TRASHED_GROUPS}")
+ActiveRecord::Base.connection.execute("INSERT INTO #{TRASHED_GROUPS} select * from compute_trashed()")