Skip to content

[Security] Cache voters that will always abstain #43066

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Oct 28, 2021

Conversation

jderusse
Copy link
Member

Q A
Branch? 5.4
Bug fix? no
New feature? yes
Deprecations? no
Tickets -
License MIT
Doc PR todo

The current implementation of the AccessDecisionManager is to iterate over all the Voters, and stops when the strategy is met.

When an application performs a lot of checks (ie. an Admin shows a list of "blog post" and shows buttons to users that are allowed to "edit", "delete", "publish", ... ie {% if is_granted('blog_delete', item) %}DELETE{% endif %}). In that case, the number of calls to the VoterInterface::vote methods grows exponentially. At some point, adding a new Voter to handle a new case (maybe not related to our blog posts) will have a performance impact

Moreover in most of the time, a voter looks like:

protected function supports($attribute, $subject)
{
  return \in_array($attribute, ['BLOG_DELETE', 'BLOG_UPDATE'], true) && $subject instanceof BlogPost;
}

We could leverage this, to cache the list of voters that need to be called or can be ignored

In the same way symfony/serializer provides a CacheableSupportsMethodInterface I suggest to add a new CacheableAbstainVotesInterface that will remember voters that will always abstain vote on a given attribute or a given object's type
When the Voter does not implements the interface OR returns false will provide the current behavior: The voter is always called.

How to use this PR:

class TokenVoter extends Voter implements CacheableAbstainVotesInterface
{
    public const ATTRIBUTES = [
        'UPDATE',
        'DELETE',
        'REGENERATE',
    ];

    public function willAlwaysAbstainOnAttribute(string $attribute): bool
    {
        return !\in_array($attribute, self::ATTRIBUTES, true);
    }

    public function willAlwaysAbstainOnObjectType(string $objectType): bool
    {
        return !is_a(Token::class, $objectType);
    }

    protected function supports($attribute, $subject)
    {
        return \in_array($attribute, self::ATTRIBUTES, true) && $subject instanceof Token;
    }

    protected function voteOnAttribute($attribute, $webhookToken, TokenInterface $token)
    {
...
    }
}

Results:
For 40 Voters and calling 500 times isGranted(variousAttributes, variousObjects) it get -40% perf gain !

Opinionated choices in that PR:

  • Only for string attributes: handling all cases (resources, callable, ...) would add too much complexity
  • Only for decision with a single attribute: handling mix of string/null/whatever would add too much complexity

TODO

  • tests (waiting for feedback on the global design of this feature)
  • Doc

@ro0NL
Copy link
Contributor

ro0NL commented Sep 16, 2021

Should we explore dedicated PHP8 class attributes?

IIUC eg. #[VotesOn(attributes, class)] enables compling a warm cache?

@carsonbot carsonbot changed the title Cache voters that will always abstain [Security] Cache voters that will always abstain Sep 16, 2021
@jderusse jderusse added this to the 5.4 milestone Sep 16, 2021
@nicolas-grekas
Copy link
Member

#[VotesOn(attributes, class)] enables compling a warm cache

I don't know if we need an attribute for that, but what about a method that exposes what a voter cares about @jderusse, instead of the current approach?

@stof
Copy link
Member

stof commented Sep 17, 2021

@jderusse what about voters that vote only on null subjects (for instance the RoleVoter) ?

@ro0NL
Copy link
Contributor

ro0NL commented Sep 17, 2021

@nicolas-grekas i generally like attributes enforcing static/cachable rules to us :) but runtime api also fits yes. As an end user taking care of such issues, an attribute looks elegant IMHO.

@stof
Copy link
Member

stof commented Sep 17, 2021

An attribute might make the architecture of the AccessDecisionManager harder, if we want to avoid calling Reflection on each decide() call. A $voter instanceof CacheableAbstainVotesInterface check is much more efficient than reading attributes at runtime. Having to implement a caching layer for the metadata of the caching layer seems weird to me.

@ro0NL
Copy link
Contributor

ro0NL commented Sep 17, 2021

Understood. I meant levaraging DI at compile time.

Writing another set of support method implementations already bugs/confuses me somewhat. But it'll be the edge case... WFM.

@jderusse
Copy link
Member Author

what about a method that exposes what a voter cares about instead of the current approach?

Yeah, I hesitated with this approach: but it can not work for voters that use a pattern for attributes: ie RoleVoter => ROLES_whatever (Which one of the much-used Voters)

@jderusse what about voters that vote only on null subjects (for instance the RoleVoter) ?

This will work: the current approach is to let voters reporting "I will always abstain for this attribute" and/or "I will always abstain for this object", everything else will always be called.

If voters don't care about subjects (like RoleVoter) they can return false telling I will **not** always abstain... which means call me, in all case.

The code will still be optimized because the voter will not be called when the attribute does not start with ROLE_

note: to be accurate RoleVoter does not vote "only on null subject", they vote on "whatever the content of the subject".

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, except the naming :)

What about CacheableVoterInterface with supportsAttribute() and supportsType() methods?

@jderusse
Copy link
Member Author

Cmments addressed, and tests added

Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 comments + fabbot and good to me!

@jderusse jderusse force-pushed the voter-cache branch 2 times, most recently from 2d557cc to 6508fbb Compare October 20, 2021 22:21
@jderusse jderusse force-pushed the voter-cache branch 3 times, most recently from 33390fe to ef8f125 Compare October 21, 2021 06:48
Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

found some typos, but still 👍 on my side
/cc @symfony/mergers

Copy link
Member

@lyrixx lyrixx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One minor comment, otherwise 👍🏼 thanks !

@fabpot
Copy link
Member

fabpot commented Oct 28, 2021

Thank you @jderusse.

@dkarlovi
Copy link
Contributor

dkarlovi commented Nov 4, 2021

@jderusse @nicolas-grekas

With the renaming of the interface and methods, the semantics seem reversed when you first look at it.

Since the interface is now called CacheableVoterInterface, the methods support*() returning false to enable feel inverted. My expectation would be that my voter becomes cacheable if it returns true (opts in), just like the serializer equivalent does.

@stof
Copy link
Member

stof commented Nov 4, 2021

support* is not about whether the voter is cacheable, but whether it supports that permission (and so must be called for it)

@jderusse jderusse deleted the voter-cache branch November 4, 2021 09:31
@dkarlovi
Copy link
Contributor

dkarlovi commented Nov 4, 2021

@stof in that case the interface is misnamed, IMO, the original names seem more appropriate.

@nicolas-grekas
Copy link
Member

The original names were confusing to me. Feels like double negation: CacheableAbstainVotesInterface::willAlwaysAbstainOnAttribute raises a "wtf I am supposed to implement inside that?!?". Supports* method are common and straightforward.

I'm not against renaming CacheableVoterInterface if you have a better proposal. Do you?

@dkarlovi
Copy link
Contributor

dkarlovi commented Nov 4, 2021

if you have a better proposal. Do you?

It depends who gets to define "better". As I said, the weird part here is returning false to opt-in to being cacheable.

@jderusse
Copy link
Member Author

jderusse commented Nov 4, 2021

thing is, the feature is about improving performance when something IS NOT able to process the payload.

Having a negation will always be confusing.

One could use CacheableVoterInterface::doesNotSupportAttribute or UnCacheableVoterInterface::supportAttribute that will always be a cognitive load.

In my opinion support* is the way to go: It's not a negation, and consistent with what we do in many other place (including the AbstractVoter)

If we need to change something it should be the name of the interface. Would be happy to make a PR if somebody have a suggestion.

@javiereguiluz
Copy link
Member

javiereguiluz commented Nov 4, 2021

I'm on a phone so I can't explain on detail, but to me the current implementation is very intuitive because I understand it as follows:

Symfony: Hey voter, do you support ARTICLE_EDIT attribute?
Voter: no (returns FALSE to say that)
Symfony: OK. I will never call you again for this attribute then.

@dkarlovi
Copy link
Contributor

dkarlovi commented Nov 4, 2021

This means the voter cacheability is a side-effect of this detection, it's inferred and not directly stated. Your votes is not becoming "cacheable", it's becoming "more capable of explaining what it knows how to work with".. which is the information then used to make it cacheable.

It basically has "extended / detailed supports" support.

@wouterj
Copy link
Member

wouterj commented Nov 4, 2021

I must say that I quite liked the willAlwaysAbstainOnAttribute() and willAlwaysAbstainOnObjectType(): it's crystal clear what information is needed from the voter in order to judge whether we should cache it.
I.e. I must only return true if I always 100% of the cases abstain on this attribute. We loose this a bit with the current generic name of supportsAttribute() imho.

That said, this little nuance can be fulfilled easily by documentation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.