action_policy icon indicating copy to clipboard operation
action_policy copied to clipboard

Caching behavior when calling a policy from another via `#allowed_to?`

Open summera opened this issue 2 months ago • 3 comments

@palkan thanks for all your work on this, I'm enjoying using it. I've read through the caching section in the docs and based on what's written in the per-thread caching section, I was expecting policies that are instantiated by calls via allowed_to? from other policies to be cached in the Thread store, but for some reason, that is not what I'm seeing. I have a use case somewhat similar to your comments example in the docs where a policy is delegating to another policy via allowed_to?.

To be more specific, I have something like this:

class OrganizationMembershipPolicy < ApplicationPolicy
  def update?
    if record.owner?
      allowed_to?(:manage_owner_memberships?, record.organization)
    else
      allowed_to?(:manage_memberships?, record.organization)
    end
  end
end

class OrganizationPolicy < ApplicationPolicy
  def manage_memberships?
    membership.owner? || membership.admin?
  end

  def manage_owner_memberships?
    membership.owner?
  end

  # #membership_for makes a call to the database to fetch the membership for the given user
  def membership
    @membership ||= record.membership_for(user)
  end
end

When I fetch many organization memberships for the same organization, iterate through them, and call allowed_to?(:update, organization_membership), the policy for the organization is not cached in the Thread store. Each time I call allowed_to?(:update, organization_membership) a new organization policy is instantiated, which results in an N+1 query due to the membership being fetched in the OrganizationPolicy.

So, if I were to have this in my controller:

organization = Organization.first
organization.organization_memberships.each do |organization_membership|
  allowed_to?(:update?, organization_membership)
end

Each iteration is instantiating a new organization policy even though every organization_membership belongs to the same organization. I've added a binding.pry and inspected the Thread store and I see that OrganizationMembershipPolicy instances are stored, but not OrganizationPolicy instances.

Is this intended or am I missing something?

summera avatar Oct 05 '25 02:10 summera

Hey,

Thanks for the detailed report.

Is this intended or am I missing something?

Yes, that's how it's designed.

Per-thread cache works in the context of authorization behavior, i.e., from where you invoke policies. Policies themselves are not behaviors, they're low-level units of authorization work. There is no any cache at the policy level.

However, I don't see why we can't include ActionPolicy::PerThreadCache and benefit from it. I think, it's worth trying.

palkan avatar Oct 08 '25 14:10 palkan

Per-thread cache works in the context of authorization behavior, i.e., from where you invoke policies. Policies themselves are not behaviors, they're low-level units of authorization work. There is no any cache at the policy level.

Got it, thanks 👍

However, I don't see why we can't include ActionPolicy::PerThreadCache and benefit from it. I think, it's worth trying.

Sounds good. In cases where the policies are used in channels (or similar long-lived contexts), we'd want to avoid including this, correct?

summera avatar Oct 08 '25 16:10 summera

In cases where the policies are used in channels (or similar long-lived contexts), we'd want to avoid including this, correct?

It's fine to use in channels, since they use a Rails executor and we reset the cache on every unit of work (there is no a long-lived thread associated with a connection; there is a pool of threads used to perform actions).

palkan avatar Oct 16 '25 12:10 palkan