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).
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
endIn 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 disableRule 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
endYou must configure a cache store to use this feature:
ActionPolicy.cache_store = MyCacheStore.newOr, in Rails:
# config/application.rb (or config/environments/<environment>.rb)
Rails.application.configure do |config|
config.action_policy.cache_store = :redis_cache_store
endCache 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
endNOTE: #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
Accessadded/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
endCase #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
endCase #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