Tagging as cache subsystem because this is where it ultimately goes wrong when incorrect code is written.
Problem/Motivation
AccessResult::andIf() and ::orIf() each have possible combinations that make the passed in access result obsolete. This leads to whatever was used to build the second access result to run for no reason at all. In case of chained calls, it may even lead to the second check's cacheability making it into the result even though the second check never applied.
Examples are andIf() running for forbidden+forbidden, neutral+neutral and neutral+allowed. Following the current logic in andIf(), none of the aforementioned scenarios use the second access result at all.
Steps to reproduce
Example code:
$result = AccessResult::allowedIfHasPermission($account, 'access user profiles')->andIf(
AccessResult::allowedIf($entity->isActive())->addCacheableDependency($entity)
If you don't have the permission, the second part should never run as at most it could result in neutral+allowed, which doesn't do anything with the second result anyways.
Proposed resolution
Introduce AccessResult::orAllowedIf($closure), along with andAllowedIf, orForbiddenIf and andForbiddenIf. These only call orIf or andIf internally if the second part could actually influence the outcome. The closure should return a boolean and is passed in a $cacheable_metadata that can be used to specify any cacheable metadata originating from the check.
Then we should convert core to use the new methods and deprecate orIf() and andIf() as public API. Because it's (re)used internally, we could still keep it as private code. After all implementations have been converted, we could then make the cut in a major version and move andIf() and orIf() to protected or private methods.
Remaining tasks
- Introduce new methods
- Deprecate old methods
- Start cleaning up core in a gazillion places
- Make old methods internal
User interface changes
/
API changes
Introduction of four new methods. Over time deprecation of two old methods.
Data model changes
/
Release notes snippet
TBD
| Comment | File | Size | Author |
|---|---|---|---|
| #34 | 3452631-nr-bot.txt | 3.26 KB | needs-review-queue-bot |
| #22 | 3452631-nr-bot.txt | 1.05 KB | needs-review-queue-bot |
Issue fork drupal-3452631
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #3
catch+1. The syntax for
orIfandandIfhas always seemed overly verbose especially when you try to avoid the situation described above, this would make things much closer to actual PHP operators where code after||or&&only runs when necessary.Comment #4
kristiaanvandeneyndeThis will need a bit more love. If we simply use andIfClosure and orIfClosure, we can only avoid calling the closure if the original access result was forbidden. Any other scenario requires comparing the current to the other result, even if we may end up not using the other result. In the current proposal we'd still have to call the other closure to do some inspection.
So perhaps we need something that can signal intent. The example code in the IS could also not call the callable when the original result is N, because it knows the outcome of the closure cannot be F and therefore has no impact.
Comment #5
kristiaanvandeneyndeStarting to think we need:
Perhaps also andAllowedIfHasPermission(string $permission) and orAllowedIfHasPermission(string $permission);
Then the IS would look like:
All of these would internally do the sanity checks where the other code does not have to run and only then yield to andIf() and orIf() with the result of the callable. This leaves andIf() and orIf() intact whereas the original thought was to get rid of them, so maybe we need to revisit that or make the original methods protected or private.
Comment #6
kristiaanvandeneyndePushed a proof of concept. Keep in mind that these helper functions slightly differ from how andIf() and orIf() behave internally.
The whole point was not to run dead code or add dead cacheable metadata. The trade off being that we may end up not trying to make uncacheable results cacheable, which I assume is an edge case that would occur far less than the current situation where cacheable metadata is needlessly added.
Comment #8
smustgrave commentedAppears to have already received feedback on MR.
Comment #9
kristiaanvandeneyndeFeedback addressed.
Comment #10
smustgrave commented@mxr576 wonder your thoughts on the responses in the threads?
Comment #11
kristiaanvandeneynde@smustgrave we had another round of feedback. I am on the fence about two changes (predicate name and a more explicit param documentation.
Once @mxr576 and I agree on the final open issue, we can apply the 3 suggestions and this can be considered accepted and we can add some cases to AccessResultTest
Comment #12
kristiaanvandeneyndeOkay we are in agreement now. So if a committer could review, we can add tests when the approach is approved.
Comment #13
kingdutchThe below was for an outdated issue summary and the answer to the question asked is "no" :)
What is the reason we're introducing new methods? Especially since we want to removeandifandorIf. That means we expect all future code to be more verbose and redundant in being named after its argument type (which, unless there's multiple alternatives, is generally considered an anti-pattern).Could we achieve the same thing through a deprecation dance which changes the parameter types but not the method names?orIfandandIfare both already statically typed toAccessResultInterfaceso we could first widen them tocallable|AccessResultInterface, deprecate on aAccessResultInterfacebeing thrown and then later dropping theAccessResultInterfacetype altogether to allow only callables. That leaves the final codebase still with the succinctorIfandandIfbut with the desired callable type.Comment #14
kristiaanvandeneyndeI need to update the IS to reflect the latest MR, but the explanation still stands:
Could we achieve the same thing through a deprecation dance which changes the parameter types but not the method names?
You mean check if the passed in argument is of type Closure and then run the new code, otherwise run the old code with a deprecation warning?
Comment #15
catchThis is no longer the case, the methods are ::orAllowedIf() and ::andAllowedIf() in the MR, but the issue summary is out of date.
Moving to needs work for an issue summary update.
Comment #16
kristiaanvandeneyndeYeah I can see where the confusion is coming from. Wrote a quick update.
Comment #17
mxr576Thanks for the IS update!
Well, remaining tasks should be scoped to this ticket, isn't it? From 2nd point they are potential follow up tasks to me that can be opened once these new APIs landed.
Speaking of new APIs, probably this should be added to release notes highlights.
Comment #18
acbramley commentedLeft a few comments on the... comments 😅 but generally speaking I like this idea a lot.
Comment #19
kristiaanvandeneyndeUpdated the comments, thanks for the review
Comment #20
smustgrave commentedSeems there are some still open threads. Am I correct in that.
Comment #21
kristiaanvandeneyndeOne is bike-shedding about a variable name: Closure vs Predicate. We agreed to let the committers have the final say. The other two threads I just resolved as one was explained by the issue summary and the other both @mxr576 and I agreed to keep as is, as it makes more sense that way.
So I'd say it's ready for review as all threads are either resolved or resolvable by a committer.
Comment #22
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #23
needs-review-queue-bot commentedFalse positive
Comment #24
catchI'm very +1 on the general approach here, it will be both a performance and DX improvement.
It would be good to convert at least one core implementation of this so it's easier to see what a before/after looks like (will probably also help with writing the change record when we do that).
Comment #25
kristiaanvandeneyndeOkay so I added two types of examples and discovered a few things while doing so.
First of all, by not adding the new methods to the AccessResultInterface, my IDE complained that calling them is potentially polymorphic as the test class UncacheableTestAccessResult does not have the new methods. So either we add them using the 1:1 rule or we leave it as is but face issues when people have their own versions of AccessResult.
As to the examples:
Point .2 led to another interesting bit. We could actually still benefit from an orIfCallable() and andIfCallable() method for cases where two unknowns (A, N or F) are combined because in both andIf() and orIf()'s case, nothing will happen if the original one is F.
So a combination of 2 entity access checks can still be optimized when the first check returns F.
Comment #26
kristiaanvandeneyndeThinking about this further, I am really not a fan of the concept of orIf and andIf and any possible variation thereof in general. It will always lead to too much code being ran if implemented incorrectly. In #25.2 I demonstrated how some code needs to be refactored, but that code would be far more performant if written this way:
So the old code would run both checks and always add the dependency. The new code in the MR always runs the entity access check (the most expensive part) but only conditionally adds the dependency.
The above code, however, always adds the cheap dependency, but only runs the expensive entity access check if need be. This is still the best possible way of doing this, but not achievable with any of the four methods introduced in this issue due to the fact that an entity access check can return any of A, N or F.
Comment #27
kristiaanvandeneyndeAnother bit to rant about, but maybe this should become an issue of its own. From FieldUpdateActionBase:
If the very first check returns Forbidden, this will still run ALL the other checks even if it can never affect the outcome. Better code would not run the loop if the initial result is F and break the loop it the compound result became F during the loop. Core and contrib are littered with these types of poorly performing access results and I feel it's partly because we gave them the andIf() and orIf() methods, making it so developers don't seem to know the impact of what's going on.
It's like the entity query accessCheck() method story where we ended up forcing it being set to make developers aware of something. We should consider doing something similar here.
Comment #28
kristiaanvandeneyndeTests are failing on jsonapi (again...) because its ResourceTestBase wrongfully assumes that an access denied will always have the same reason:
With our changes we can now return an access denied for entity view displays without having to run the manual permission check. But jsonapi tests are too rigid for that. Not sure how to fix this as it probably requires a significant rewrite of jsonapi tests.
Comment #29
kristiaanvandeneyndeOpened up #3494703: ResourceTestBase::getExpectedUnauthorizedAccessMessage() should be removed or replaced with something smarter.
I really don't have the energy right now to fix these test failures, maybe someone else can take over. Getting blocked by jsonapi tests is becoming a recurring theme and it's really demoralizing.
Comment #30
mxr576This issue inspired me to start working on something I had planned for a long time - a generic Binary and Trinary logic implementation for Drupal with built-in cacheability and lazy evaluation. The first version is now available here: https://git.drupalcode.org/project/stdlib/-/tree/1.x/src/Logic?ref_type=...
My motivation was simple: I found myself writing too much code where I either had to use `AccessResult` to propagate a binary/trinary value *with cacheability* or resorted to constructs like this:
This new approach aims to streamline such logic while maintaining Drupal’s caching best practices and also incorporates DX tweaks and improvements for the sake of higher cache hit rates like what this issue is about. Feedback and contributions are welcome!
Comment #31
kristiaanvandeneyndeWould be nice if you had a readme with examples in there. It's quite a lot of code to read through. Either way, the goal here is to add to AccessResult so that we can slowly move from one approach to another. Call it a band-aid.
The real question is indeed whether we need to completely rethink AccessResult as a whole, but this issue is not for that.
Comment #32
mxr576+1, example usages and docs will come, I was just excited to have this finally published and shared with internally/externally.
I think we should merge changes in this issue that already improves the situation and think about how to slowly kill and replace AccessResult in Drupal 14/15.
Comment #33
kristiaanvandeneyndeI think we should merge changes in this issue that already improves the situation and think about how to slowly kill and replace AccessResult in Drupal 14/15.
Exactly. Make it "less worse" now, make it "great" (i.e. dead?) later.
Comment #34
needs-review-queue-bot commentedThe Needs Review Queue Bot tested this issue. It fails the Drupal core commit checks. Therefore, this issue status is now "Needs work".
This does not mean that the patch necessarily needs to be re-rolled or the MR rebased. Read the Issue Summary, the issue tags and the latest discussion here to determine what needs to be done.
Consult the Drupal Contributor Guide to find step-by-step guides for working with issues.
Comment #35
pcambra