Problem/Motivation
Drupal performs access checks on multiple occasions during a typical request-response cycle. The first time immediately after routing (from within the AccessAwareRouter
) on the incoming request. But also during the rendering phase, a great number of checks are performed in order to determine if certain page elements should be rendered or not. A common use-case is when rendering menu-links. Those access checks are not applied to the current incoming request, but to potential future requests.
Currently the access-manager takes a Symfony Request object as a parameter for its check()
method. On the first sight this seems very obvious but it has the rather nasty consequence that a fake request object has to be created when running access-checks before e.g. rendering links. This has proven to be cumbersome and rather costly (#2078855: Request::create() is slow, use a helper to duplicate instead).
Additionally there is only one access-check in core which genuinely requires a request object, and only should ever be run against the incoming request: The CsrfAccessCheck
. All the other access checks can be rewritten in a way such that they only depend on the route defaults, the raw parameters and the upcasted parameters.
Therefore let's reverse the current situation and only pass in the request object to the access checkers when checking the incoming request. Access check services wishing to act on the incoming request need to declare that via a service tag attribute. When checking potential future requests those checks will not be run.
This allows us to streamline AccessManager::checkNamedRoute()
and remove RequestHelper::duplicate()
.
Proposed resolution
- Instead of a
Request
object, pass aRouteMatch
to access-check methods by default. - Access check services wishing to act on the incoming request need to declare that using the service tag attribute
needs_incoming_request
. Use it forCsrfAccessCheck
- Fix all access checks which do not genuinely need the request-object
- Remove the
_controller_request
request argument workaround fromAccessAwareRouter
andCsrfAccessCheck
- Adapt unit-tests, no changes are to be expected in web-tests
Remaining tasks
Review.
User interface changes
None.
API changes
- Removes
RequestHelper::duplicate()
- Introduces
AccessManagerInterface::checkRequest()
- Changes
check(Route $route, Request $request, AccountInterface $account)
tofunction check(RouteMatchInterface $route_match, AccountInterface $account, Request $request = NULL)
onAccessManagerInterface
- Removes
$request
-parameter fromAccessManagerInterface::checkNamedRoute()
- Adds optional
$needs_incoming_request
toAccessManagerInterface::addCheckService()
Comment | File | Size | Author |
---|---|---|---|
#64 | interdiff.txt | 1.82 KB | znerol |
#64 | 2331079-use-route-match-in-access-checks-64.diff | 122.85 KB | znerol |
#61 | 2331079-use-route-match-in-access-checks-61.diff | 121.89 KB | znerol |
#57 | 2331079-use-route-match-in-access-checks-57.diff | 115.11 KB | znerol |
#57 | interdiff.txt | 1.62 KB | znerol |
Comments
Comment #1
znerol CreditAttribution: znerol commentedComment #3
dawehnerThis is just an idea how to determine whether to call the ones with request or not.
Comment #5
znerol CreditAttribution: znerol commentedHow about making the request optional in the access-checks? Attached interdiff is against #1.
With this patch it is possible to enable/disable themes, therefore it looks like the CSRF access check is working again. Additionally I was able to remove something looking like an ugly hack from
AccessAwareRouter::checkAccess()
.Comment #6
dawehnerMh, in case we do that, I would suggest to add a dedicated method on the route match
Mh, I would like to avoid that the access checkers itself have to be aware of that.
The main reason why we have that complex code here is that you maybe have a not-existing route in the request, are you sure this will fly?
Comment #8
znerol CreditAttribution: znerol commentedThanks for the feedback @dawehner.
#6.1 is easy to solve properly, its just a quick and dirty method to illustrate what I'm after.
Regarding #6.2, this is in fact the important decision. The patch just makes current behavior more explicit. This does not mean that we should keep it that way though. Given that we already are using optional arguments in many access-checkers, it feels natural to also make the request optional.
Regarding #6.3
git log -S _controller_request
turns up two issues: #1798296: Integrate CSRF link token directly into routing system and #2322809: Do not depend on event subscribers for security: Tighten routing security by access checking in matchRequest. Therefore I'm pretty sure that the_controller_request
request attribute has been introduce specifically in order to be able to figure out whether the csrf access check is executed against the current request (attribute present) or against a fake-request (attribute absent). Thetry...catch
block is only there such that it is possible to remove the request-attribute in any case after the access-check did run.Comment #9
dawehnerNot sure whether you had a look at #3 but this skipped those access checkers, in case they require the request for the non-existance of the request.
This is at least the best DX from the perspective of the module developer.
You are absolute right, so we kinda have solved that implicit now, by distinct request aware and not request aware access checkers.
Comment #10
znerol CreditAttribution: znerol commented@dawehner: The approach from #3 does not work because the
AccessArgumentsResolver
will throw an exception if therequest
isNULL
. We'd need to filter the checks before trying to resolve the arguments.Fixed the
AccessArgumentsResolver
and reinstated the workaround for #2265939: [sechole] AccessManager::checkNamedRoute() leaks attributes from current request, even though that should not be necessary anymore.Comment #11
tim.plunkettWell #2265939: [sechole] AccessManager::checkNamedRoute() leaks attributes from current request went in. Do we have an @todo for a new issue? Or do you intend to fix it in this issue?
Comment #12
znerol CreditAttribution: znerol commentedI'm currently trying to figure out the scope of this issue here. If removing the workaround turns out to be complicated, then I'll open a follow-up.
Comment #13
znerol CreditAttribution: znerol commentedFix path based breadcrumb builder. Shouldn't that thing be replaced with a menu-based breadcrumb builder instead?
Comment #16
znerol CreditAttribution: znerol commentedFix config translation access checks.
Comment #18
znerol CreditAttribution: znerol commentedThis is a solution for the workaround pointed out in #6.1: Generalize the access-arguments resolver into a component and introduce a factory for parametrizing it. Then pass the parametrized argument resolver down to the
checkAll()
,checkAny()
andperformCheck()
methods.I will fix the remaining tests when we agree on the approach. It also would be possible to break out the bigger things like the fixes for config translation and the changes to the access-arguments resolver to separate issues if necessary.
Comment #19
znerol CreditAttribution: znerol commentedComment #20
znerol CreditAttribution: znerol commentedForgot to reinstate a use statement.
Comment #22
znerol CreditAttribution: znerol commentedFix the fatal test fail by just converting
Drupal\Tests\Core\Access\AccessArgumentsResolverTest<\code> to
\Drupal\Tests\Component\Utility\ArgumentsResolverTest<\code>.
Comment #23
znerol CreditAttribution: znerol commentedRe #11: Try to remove the workaround for #2265939: [sechole] AccessManager::checkNamedRoute() leaks attributes from current request.
Comment #26
znerol CreditAttribution: znerol commentedMh, #23 was the interdiff.
Comment #28
znerol CreditAttribution: znerol commentedContentTranslationOverviewAccess
should not attempt to retrieve theentity_type_id
from the route match parameters but simply receive it through a method parameter. Like this it is always resolved properly by the argument resolver. This should fix the test failures in content translation web tests.Comment #29
znerol CreditAttribution: znerol commentedLet's roll back the massive changes in config translation access checkers and only modify what is really necessary.
Comment #32
znerol CreditAttribution: znerol commentedAdd route defaults before upcasting arguments in
AccessManager::checkNamedRoute()
. Hopefully this will get us to zero test-failurs in web tests again.Comment #34
znerol CreditAttribution: znerol commentedLet's try again. This partially reverts #32. We need to add the defaults in both places, i.e. in
AccessManager::checkNamedRoute()
as well as inAccessArgumentsResolverFactory::getArgumentsResolver()
.Comment #35
znerol CreditAttribution: znerol commentedComment #37
znerol CreditAttribution: znerol commentedRestore commented out
set_error_handler
andset_exception_handler
... tsts.Comment #39
znerol CreditAttribution: znerol commentedNow that web tests pass, let's remove another workaround from
CsrfAccessCheck::access()
: Introduce theneeds_incoming_request
service tag attribute and only run access-checks marked that way on the incoming request. This allows us to remove the guesswork-code which is currently inflating the csrf access check.Comment #41
znerol CreditAttribution: znerol commentedDo not instantiate the arguments resolver when there are no access checks but simply deny access.
Comment #42
znerol CreditAttribution: znerol commentedFix current unit-tests, still need to add test-coverage for the new
AccessArgumentsResolverFactory
class and the newAccessManager::checkRequest
method.Comment #44
catchDidn't review the whole patch yet, but in general really, really like this.
Comment #45
znerol CreditAttribution: znerol commentedComment #46
znerol CreditAttribution: znerol commentedComment #47
znerol CreditAttribution: znerol commentedReroll after #2330929: Remove the request from parameter converters and #2302563: Access check Url objects.
Comment #48
dawehnerI really like how this turned out, got a lot of understanding already from our discussion.
I'd still like some interface to ensure that.
Do we need all those three methods to be public? I though having just checkedNamedRoute and checkRequest would be enough
<3
What is the reason to check for the existence of the source language?
I wonder whether you have configured git to show moves in the patch files, see https://www.drupal.org/documentation/git/configure
It is great to have a helper method!
Comment #49
znerol CreditAttribution: znerol commentedI actually tried that but I could not come up with a solution which fits the current architecture.
The most natural point to examine the marker-interface would be in
AccessManager::loadCheck()
. Regrettably this is too late because at that point in time it is already decided which checks are to be run.The other place where the marker interface could be checked is from within
RegisterAccessChecksPass::process()
but that does not buy us much. We would have to keep the$needs_incoming_request
parameter of theAccessManager::addCheckService()
call in that case.We could make
check()
a protected method. But that would mean we'd have to rewrite all the test-coverage. I'm okay with doing that, but in order to keep risk to a minimum and reviewability at a sane level, let's defer that to a follow-up.We only want to deny access when a language is
locked
but we want to allow access when it is missing (yes, really).Comment #50
dawehnerAgreed.
He, well, is there a reason this has to be changed here?
Comment #51
znerol CreditAttribution: znerol commentedThe patch does not change the behavior. The new
empty()
check is necessary after the removal of the call to$mapper->populateFromRequest()
and$mapper->getLanguageWithFallback()
fromConfigTranslationOverviewAccess::access()
.ConfigTranslationFormAccess
inherits fromConfigTranslationOverviewAccess
which is really ugly. Earlier versions of the patch (before #29) contained a complete rewrite of those access-checkers, but in order to keep changes to a digestible volume I decided to roll them back and only include the bare minimum.Comment #52
dawehnerGreat work!
Comment #53
alexpottAbove are some changes to make... I was thinking about doing them on commit but I think we need a list of all CRs that need an update eg https://www.drupal.org/node/1851490 so that once committed someone can fix them. I also think we need to ensure (if necessary) documentation on d.o is updated once this gets in.
Comment #54
znerol CreditAttribution: znerol commentedAlso found https://www.drupal.org/node/2117181 (Non reusable access checkers can be defined directly in the routing definition). The example there (toolbar) still exists in HEAD and is not touched by this patch. Looks like I failed to convert all access checkers.
Comment #55
znerol CreditAttribution: znerol commentedgit grep _custom_access:
turns up the following custom access checks:The only one which currently requires a request is
ToolbarController::checkSubTreeAccess
, but that can be easily rewritten.The reason why this was not caught by the test-suite is that the
toolbar.subtrees
route is never actively used in the PHP-part, i.e. nothing tries to print an access-checked URL for that route. Instead the path is constructed directly in the JavaScript. This means thatToolbarController::checkSubTreeAccess()
is currently only ever run on an incoming request where the request object is always available.The following handbook page also needs an update: https://www.drupal.org/node/2122195 (Access checking on routes)
Comment #56
dawehnerYeah I think it it totally fine to limit custom access checkers for just route match based ones. One big reason is that this callback
is considered as a lazy excuse anyway, so it is okay to not have the full freedom.
Comment #57
znerol CreditAttribution: znerol commentedOh, right. We actually should remove the request from
CustomAccessCheck
then.Comment #58
dawehnerI really like the amount of cleanups provided by this patch though, well, this will probably not land before #2287071: Add cacheability metadata to access checks so that you need to rework quite a lot here.
Comment #59
Wim LeersAmazing simplification and clean-up!
This will indeed unfortunately conflict with #2287071, but fortunately they touch the same files for very different reasons, so the conflict should be doable to resolve.
Comment #60
catchI just committed [##2287071] so this will need a rebase. Adding "avoid commit conflicts" since I would have put this in before any other patch that wasn't 250kb..
Comment #61
znerol CreditAttribution: znerol commentedLet's see whether I got this right.
Comment #62
Wim LeersWow, that was fast!
I created a diff locally between the patches in #57 and #61, and all changes looked sane from a first read-through.
Comment #64
znerol CreditAttribution: znerol commentedTry again.
Comment #65
znerol CreditAttribution: znerol commentedComment #66
dawehnerAwesome!
@znerol
Still curious about your git config, see above.
Comment #67
alexpottNice work on the quick reroll and back to rtbc. Let's update those change records.
Committed d617be8 and pushed to 8.0.x. Thanks!
Comment #69
tstoecklerThe config translation changes here are wrong. Surely config_translation could be refactored to be less dependant on the request object and instead utilize a
RouteMatch
or whatever is fancy these days. As it stands, though,ConfigMapperInterface::populateFromRequest()
must be run in the access checker. Not running it will lead to very hard to catch bugs in multilingual scenarios. In the same light theempty()
check is not correct. If there is no source language then access should not be granted.Since we didn't write any tests for this complicated behavior it's our own fault that this regressed and thus I also do not suggest rolling this back. I will open a follow-up where I will try to write a test to prove my point.
Comment #70
znerol CreditAttribution: znerol commentedI disagree. The access check does not depend on the populated mapper.
Dito, translation should be possible even though a source language is missing.
Update: Oh, you are in fact right in the second point - translation should only be granted if the source language exists, except if it is english, then it should be always granted.
Comment #71
tstoecklerOpened #2340533: Regression: ConfigTranslation(Form|Overview)Access grant access if the source language of entity mappers is locked and should not.
Comment #72
tstoecklerConfig translation accounts for that fallback, see
ConfigMapperInterface::getLanguageWithFallback()
.This is not a matter of opinion. It does depend on the populated mapper as described in the follow-up. Sorry to be so blunt, but you are wrong here.
Comment #73
BerdirAnother thing, this broke Page Manager's way of doing access checks.
Page manager currently uses _entity_create in combination with a hardcoded id that is not coming from the route but specified as a default.
That is in $request->attributes, but does not end up in $routeMatch->getParameters() (nor getRawParameters()).
The result is that the access check is now failing as it can't find the entity anymore.
Any suggestions on how to fix this? Would be sad if we'd have to go with custom access checks, because we also use _entity_view, so if that at some point stops working too, page_manager will be sad.
Comment #74
znerol CreditAttribution: znerol commentedRe #73 summary of IRC discussion: Page manager generates route defaults with an underscore prefix. However, per convention those are not considered as paremeters and therefore are not available from
RouteMatch::getParameters()
. See also that answer on Drupal SE.Comment #75
tim.plunkettI think we need to fix RouteMatch. I needed to prefix _page to prevent conflicts, see #2284157: Use _page instead of plain 'page' in route defaults.