Skip to content

Latest commit

 

History

History
293 lines (211 loc) · 9.7 KB

caching.md

File metadata and controls

293 lines (211 loc) · 9.7 KB

Caching

Action Policy aims to be as performant as possible. One of the ways to accomplish that is to include a comprehensive caching system.

There are several cache layers available: rule-level memoization, local (instance-level) memoization, and external cache (through cache stores).

Cache layers

Policy memoization

Per-instance

There could be a situation when you need to apply the same policy to the same record multiple times during the action (e.g., request). For example:

# app/controllers/posts_controller.rb
class PostsController < ApplicationController
  def show
    @post = Post.find(params[:id])
    authorize! @post
    render :show
  end
end
# app/views/posts/show.html.erb
<h1><%= @post.title %>

<% if allowed_to?(:edit?, @post) %>
  <%= link_to "Edit", @post %>
<% end %>

<% if allowed_to?(:destroy?, @post) %>
  <%= link_to "Delete", @post, method: :delete %>
<% end %>

In the above example, we need to use the same policy three times. Action Policy re-uses the policy instance to avoid unnecessary object allocation.

We rely on the following assumptions:

  • parent object (e.g., a controller instance) is ephemeral, i.e., it is a short-lived object
  • all authorizations use the same authorization context.

We use record.policy_cache_key with fallback to record.cache_key or record.object_id as a part of policy identifier in the local store.

NOTE: policies memoization is an extension for ActionPolicy::Behaviour and could be included with ActionPolicy::Behaviours::Memoized.

NOTE: memoization is automatically included into Rails controllers integration, but not included into channels integration, since channels are long-lived objects.

Per-thread

Consider a more complex situation:

# app/controllers/comments_controller.rb
class CommentsController < ApplicationController
  def index
    # all comments for all posts
    @comments = Comment.all
  end
end
# app/views/comments/index.html.erb
<% @comments.each do |comment| %>
  <li><%= comment.text %>
    <% if allowed_to?(:edit?, comment) %>
      <%= link_to comment, "Edit" %>
    <% end %>
  </li>
<% end %>
# app/policies/comment_policy.rb
class CommentPolicy < ApplicationPolicy
  def edit?
    user.admin? || (user.id == record.id) ||
      allowed_to?(:manage?, record.post)
  end
end

In some cases, we have to initialize two policies for each comment: one for the comment itself and one for the comment's post (in the allowed_to? call).

That is an example of a N+1 authorization problem, which in its turn could easily cause a N+1 query problem (if PostPolicy#manage? makes database queries). Sounds terrible, doesn't it?

It is likely that many comments belong to the same post. If so, we can move our memoization one level up and use local thread store.

Action Policy provides ActionPolicy::Behaviours::ThreadMemoized module with this functionality (included into Rails controllers integration by default).

If you want to add this behavior to your custom authorization-aware class, you should care about cleaning up the thread store manually (by calling ActionPolicy::PerThreadCache.clear_all).

NOTE: per-thread cache is disabled by default in test environment (when either RACK_ENV or RAILS_ENV environment variable is equal to "test"). You can turn it on (or off) by setting:

ActionPolicy::PerThreadCache.enabled = true # or false to disable

Rule cache

Per-instance

There could be a situation when the same rule is called multiple times for the same policy instance (for example, when using aliases).

In that case, Action Policy invokes the rule method only once, remembers the result, and returns it immediately for the subsequent calls.

NOTE: rule results memoization is available only if you inherit from ActionPolicy::Base or include ActionPolicy::Policy::CachedApply into your ApplicationPolicy.

Using the cache store

Some policy rules might be performance-heavy, e.g., make complex database queries.

In that case, it makes sense to cache the rule application result for a long time (not just for the duration of a request).

Action Policy provides a way to use cache stores for that. You have to explicitly define which rules you want to cache in your policy class. For example:

class StagePolicy < ApplicationPolicy
  # mark show? rule to be cached
  cache :show?
  # you can also provide store-specific options
  # cache :show?, expires_in: 1.hour

  def show?
    full_access? ||
      user.stage_permissions.where(
        stage_id: record.id
      ).exists?
  end

  private

  def full_access?
    !record.funnel.is_private? ||
      user.permissions
        .where(
          funnel_id: record.funnel_id,
          full_access: true
        ).exists?
  end
end

You must configure a cache store to use this feature:

ActionPolicy.cache_store = MyCacheStore.new

Or, in Rails:

# config/application.rb (or config/environments/<environment>.rb)
Rails.application.configure do |config|
  config.action_policy.cache_store = :redis_cache_store
end

Cache store must provide at least a #read(key) and #write(key, value, **options) methods.

NOTE: cache store also should take care of serialiation/deserialization since the value is ExecutionResult instance (which contains also some additional information, e.g. failure reasons). Rails cache store supports serialization/deserialization out-of-the-box.

By default, Action Policy builds a cache key using the following scheme (defined in #rule_cache_key(rule) method):

"#{cache_namespace}/#{context_cache_key}" \
"/#{record.policy_cache_key}/#{policy.class.name}/#{rule}"

Where cache_namespace is equal to "acp:#{MAJOR_GEM_VERSION}.#{MINOR_GEM_VERSION}", and context_cache_key is a concatenation of all authorization contexts cache keys (in the same order as they are defined in the policy class).

If any object does not respond to #policy_cache_key, we fallback to #cache_key (or #cache_key_with_version for modern Rails versions). If #cache_key is not defined, an ArgumentError is raised.

NOTE: if your #cache_key method is performance-heavy (e.g. like the ActiveRecord::Relation's one), we recommend to explicitly define the #policy_cache_key method on the corresponding class to avoid unnecessary load. See also action_policy#55.

You can define your own rule_cache_key / cache_namespace / context_cache_key methods for policy class to override this logic.

You can also use the #cache instance method to cache arbitrary values in you policies:

class ApplicationPolicy < ActionPolicy::Base
  # Suppose that a user has many roles each having an array of permissions
  def permissions
    cache(user) { user.roles.pluck(:permissions).flatten.uniq }
  end

  # You can pass multiple cache key "parts"
  def account_permissions(account)
    cache(user, account) { user.account_roles.where(account: account).pluck(:permissions).flatten.uniq }
  end
end

NOTE: #cache method uses the same cache key generation logic as rules caching (described above).

Invalidation

There no one-size-fits-all solution for invalidation. It highly depends on your business logic.

Case #1: no invalidation required.

First of all, you should try to avoid manual invalidation at all. That could be achieved by using elaborate cache keys.

Let's consider an example.

Suppose that your users have roles (i.e. User.belongs_to :role) and you give access to resources through the Access model (i.e. Resource.has_many :accesses).

Then you can do the following:

  • Keep tracking the last Access added/updated/deleted for resource (e.g. Access.belongs_to :accessessable, touch: :access_updated_at)
  • Use the following cache keys:
class User
  def policy_cache_key
    "user::#{id}::#{role_id}"
  end
end

class Resource
  def policy_cache_key
    "#{resource.class.name}::#{id}::#{access_updated_at}"
  end
end

Case #2: discarding all cache at once.

That's pretty easy: just override cache_namespace method in your ApplicationPolicy with the new value:

class ApplicationPolicy < ActionPolicy::Base
  # It's a good idea to store the changing part in the constant
  CACHE_VERSION = "v2".freeze

  # or even from the env variable
  # CACHE_VERSION = ENV.fetch("POLICY_CACHE_VERSION", "v2").freeze

  def cache_namespace
    "action_policy::#{CACHE_VERSION}"
  end
end

Case #3: discarding some keys.

That is an alternative approach to crafting cache keys.

If you have a limited number of places in your application where you update access control, you can invalidate policies cache manually. If your cache store supports delete_matched command (deleting keys using a wildcard), you can try the following:

class ApplicationPolicy < ActionPolicy::Base
  # Define custom cache key generator
  def cache_key(rule)
    "policy_cache/#{user.id}/#{self.class.name}/#{record.id}/#{rule}"
  end
end

class Access < ApplicationRecord
  belongs_to :resource
  belongs_to :user

  after_commit :cleanup_policy_cache, on: [:create, :destroy]

  def cleanup_policy_cache
    # Clear cache for the corresponding user-record pair
    ActionPolicy.cache_store.delete_matched(
      "policy_cache/#{user_id}/#{ResourcePolicy.name}/#{resource_id}/*"
    )
  end
end

class User < ApplicationRecord
  belongs_to :role

  after_commit :cleanup_policy_cache, on: [:update], if: :role_id_changed?

  def cleanup_policy_cache
    # Clear all policies cache for user
    ActionPolicy.cache_store.delete_matched(
      "policy_cache/#{user_id}/*"
    )
  end
end