Problem/Motivation
When caching something per role, we're usually doing that because of checking permissions. But that's an indirect representation of the permissions. That means that if the set of permissions assigned to a role changes, that anything that is cached per role is not invalidated. Which is a security issue.
Proposed resolution
Add user.permissions
cache context, which uses the existing PermissionsHash(Interface)
.
Use this new cache context whenever varying something by permissions.
Remaining tasks
TBD
User interface changes
None.
API changes
Removed AccessResult::cachePerRole()
Added AccessResult::cachePerPermissions()
Original report by Wim Leers
This is just an idea I had when reading #21, but thinking about this some more, maybe we should add a cache tag whenever we add a cache context, so that its no longer the one adding the cache contexts to also add the cache tags, which might not even be known at that time or double the logic.
Just food for thought and not in this issue for sure.
Wim Leers, in #2158003-30: Remove Block Cache API in favor of blocks returning #cache with cache tags, a year earlier (while reading, substitute "granularities" for "contexts"):
Keeping cache granularities, having them affect cache keys & cache tags
Overall, it looks like it might be better to not merge "cache granularity" with "cache keys"? When caching, "cache granularity" should indeed be converted to "cache keys". But there's an important distinction:
- Cache tags need to be bubbled up, because they indicate "what the contained things are", so if a parent gets cached, then it must be tagged with the union of all nested cache tags.
- Cache keys, on the other hand, don't need to be bubbled up — they couldn't be, it'd be nonsensical. Only the current thing knows *what* it is and *how* to generate a unique key.
- Cache granularity is something in between cache tags and cache keys. Like cache tags, they need to be bubbled up, because if something grandchild in the current render array needs to be per-user, then the grandparent needs to be per-user too! And because it needs to be per-user, it must also affect the cache key (if not, then something cached for user A would be served to user B). However … and we haven't addressed this yet … it should in fact also affect the cache tags: if the granularity includes
CACHE_PER_ROLE
, then changing anything about the role might cause this cache entry to be stale. The mechanism for that is cache tags.So, in short:
- Cache tags: for cache invalidation.
- Cache keys: for cache lookup.
- Cache granularity: for request contexts, which affects both cache invalidation and cache lookup.
Finally, at the very least some cache granularities are missing, such as
DRUPAL_CACHE_PER_LANGUAGE
(currently, entity render caching is language-agnostic… which means that it will unfortunately serve the same cache entry regardless of current language — easily fixable, but what if UI translations change? Since there's no cache tags for languages, you'll have to clear all the caches. Keeping cache granularities and have them affect cache keys & tags would solve that.) But more likely, we're missing the ability to *add* cache granularities, such asDRUPAL_CACHE_PER_COUNTRY
.
Comment | File | Size | Author |
---|---|---|---|
#25 | user_permissions_cc-2428703-25.patch | 62.36 KB | Wim Leers |
Comments
Comment #1
Wim LeersComment #2
Wim LeersComment #3
Wim LeersThis blocks #2428837: Adding/updating interface translations should invalidate page & render caches, which is major.
Comment #4
Wim LeersThis also soft-blocks #2453341: Contact forms don't have necessary cache contexts & tags; flood control should work on validate, not on view.
Comment #5
Wim LeersI've been thinking some more about this. Also discussed briefly/superficially with @effulgentsia.
I'm now more convinced than before that this is necessary. It's necessary for every cache context that itself depends on something configurable.
Clear examples:
user.roles
: when the permissions for a role change (i.e. the role object is modified), then any output that varies by role should be invalidatedmenu.active_trail
: when a menu item is moved in the menu tree (i.e. the menu object is modified), then any output that varies by menu active trail should be invalidatedlanguage
(not including content language, language-type specific cache contexts will be available once #2432837: Make cache contexts hierarchical (e.g. 'user' is more specific than 'user.roles') lands): when a translation is modified, then any output that varies by that language should be invalidatedtheme
: when the settings of a theme change, then any output that varies by theme should be invalidated (this is effectively already the case because we decided in #2040135: Caches dependent on simple config are only invalidated on form submissions to make themes reuse therendered
cache tag)But there are also clear examples where you might think cache tags should be associated, but that's not actually true:
user
: when a user is modified, the variation doesn't actually change, since a user always remains the same user (the UID doesn't change), only properties of the user change, like the user nameAnd then the ones that are completely surpriseless are the ones that don't involve any calculations: e.g.
url
.Comment #6
Wim LeersInitial patch, marked as do-not-test because this will cause lots of tests to fail, since this will cause more cache tags to be added automatically. We don't need a green patch, we need a discussion about this.
Comment #7
Wim LeersOops.
Comment #8
effulgentsia CreditAttribution: effulgentsia at Acquia commented+1 to this issue. The examples in #5 make a lot of sense.
What about reusing CacheDependencyInterface once #2444231: Fix CacheableInterface so it makes sense (was: "Make Config objects & Entities implement CacheableInterface + add BubbleableMetadata::createFromCacheableObject()") lands? I think max_age might apply here too, right? The interesting one would be a cache context implementing getCacheContexts(). That might be kind of crazy.
I don't follow how this is a counter example. For every example, there are always some changes where the tag invalidates more aggressively than it needs to. For example, you can update a role's label, and that would invalidate things that it probably doesn't need to, but that's just because we have to pick some granularity at which to tag, and we picked config name/entity rather than per-config-key. Similarly, changing a user's name might or might not require invalidation of a per-user cached item: why should we treat that differently from changing a role's label? I think it's better to invalidate in this case than not.
Comment #9
Wim LeersImpossible, because:
CalculatedCacheContextInterface
, for which we need a parameter to know which cache tag(s) to returnBut when you're rendering something of the user, it's your responsibility to associate the corresponding cache tag.
Note that for all four clear examples I gave in #5, we're not associating cache tags in case the role label, menu name, language name or theme name is being displayed. We're associating the cache tag because we're showing things OTHER THAN role label/menu name/language name/theme name that depend on those objects. We're showing/hiding things based on a role, showing a different menu tree depending on the active menu trail, showing a different interface translation depending on the language (this one is the closest to your analogy) and showing a different logo depending on the theme settings.
Comment #10
effulgentsia CreditAttribution: effulgentsia at Acquia commentedDiscussed with Wim, and yeah, I can see now that the most common use case of the 'user' context is "view own *" permissions, and those don't depend on the user's name or any other fields, so not requiring a user tag on those could be nice.
Comment #11
catchSo #9 and #10 make me a bit nervous about explicitly linking contexts and tags.
As well as the role vs. user vs. permissions argument, language is also similar. We don't associate the language cache tag because the language is rendered, but because the translation is rendered. t() doesn't allow us to automatically assign a cache tag, but if it did we'd probably do it from there. Same with access results I think.
If we look at contrib examples, if there's an 'organic group membership' cache context, do we add cache tags for each organic group with that or not?
So agreed with the need for the actual tags, but not 100% on the API-level support.
Comment #12
Wim LeersTrue. But since #2432837: Make cache contexts hierarchical (e.g. 'user' is more specific than 'user.roles'), we have
'languages:' . LanguageInterface:TYPE_INTERFACE
as a cache context. If we can associate a per-translation cache tag with that cache context. Together with this issue, we'd be able to fix that cleanly like that.(See the related #2428837: Adding/updating interface translations should invalidate page & render caches.)
Interesting; this is related to something Fabianx was talking about WRT #2449459: [PP-1] Make URLs cache context aware: he thinks #2450993: Rendered Cache Metadata created during the main controller request gets lost could be a solution there. I think that if we'd solve that, then perhaps what you're suggesting (
t()
bubbling a cache context) would actually be possible.When:
I don't know enough about organic groups to know whether it makes sense to associate a cache tag with the corresponding cache context.
I see that argument. But then I have to ask:
. The above is the simplest possible thing that can work AFAICS.I think you're saying we don't need this API; that it's always up to the developer to remember to associate both the context and necessary cache tags?
I think that for some of the most common use cases, that's feasible: "user has permission?" checking, for which the
AccessResult
class can automatically generate the necessary cache tags. #2444231: Fix CacheableInterface so it makes sense (was: "Make Config objects & Entities implement CacheableInterface + add BubbleableMetadata::createFromCacheableObject()") will make it clean/simple to apply the cacheability of an access result to a render array. So that common use case could be taken care of.That leaves
'languages:' . LanguageInterface::TYPE_INTERFACE
as the biggest concern, since it's likely going to be used the widest. Especially when/if #2453059: Set default render cache contexts: 'theme' + 'languages:' . LanguageInterface::TYPE_INTERFACE lands in its current form (that issue will add the interface language cache context to every render array). The only alternative solution for that that I see is makingt()
bubble a cache tag (at which point it no longer makes sense to associate the interface language cache context by default, since usingt()
could automatically bubble it — that's be ideal, actually, if we can make it happen).Comment #13
Wim LeersThe consequences of choosing not to do this issue can now be seen for
AccessResult::cachePerRole()
and about half a dozen unit tests at #2453341: Contact forms don't have necessary cache contexts & tags; flood control should work on validate, not on view, interdiffs in comments 15 and 17.Comment #14
catchYes that's what it feels like.
With Organic groups - the cache context would be equivalent to user roles - so when the user's group membership changes, there's no need to invalidate the cached content - the user will just get end up seeing a different contextual version of it (I assume, it wouldn't hurt to ask the OG maintainers if there's some other use case).
With LanguageInterface::TYPE_INTERFACE yes ideally t() would bubble the cache context, and we could then bubble the cache tag too there, whether that's actually doable or not is a different issue. But if the cache context is set on every render array, then why can't the tag also be set by default too? We already set the 'rendered' cache tag.
Comment #15
Wim Leers… but when looking at a listing of things whose access is determined by role, and thus by permissions, and the permissions that a role has access to, then there is the need to invalidate the cached listing, since it is keyed by the role IDs, and those roles now have access to a different set of content.
Well, then it's no longer a static list of cache tags (
['rendered']
), but one that depends on the current request: if the current interface language is French, then theRenderer
would have to associate the cache tag for that translation. In other words: the cache tag to associate varies by cache context.Which again points at how closely related cache contexts & tags can be!
So while that sounds nice at first sight, I'm not fully convinced. Any additional default cache context would then require the logic of
Renderer
to be modified.Comment #16
Wim LeersI had an IRC conversation with catch.
We came to a few realizations:
user.roles
cache context different from the others listed in #5, is the fact that it's an "invisible" cache context: it doesn't have a direct representation on the page itself. The interface language (translation), active menu trail (rendered menu) and theme (rendered templates) do.In other words: an alternative solution for this issue, is to replace
user.roles
withuser.permissions
(and potentially even supportuser.permissions:<permission>
).Comment #17
Wim Leerscatch and I agreed it'd be beneficial for this discussion if we saw what that'd look like. So, working on a patch. Attached are the basic changes necessary. Now updating all of the
AccessResult::cachePerRole()
consumers.EDIT: part of the next step snuck in:
Please ignore this.
Comment #19
Wim LeersIn this reroll, two access checkers using
::cachePerRole()
have been converted.CommentAccessControlHandler
— this used::cachePerRole()
whenever permission-checking was combined with other things to check; a call to::cachePerRole()
ensured the same cacheability was applied as using::allowedIfHasPermission()
. But with the proposed shift (see #16 + #17) touser.permissions:<perm>
, that no longer works. Either we'll have::addCacheContexts(['user.permissions:' . $permission])
all over the place, or we convert to::allowedIfHasPermission()
. I chose the latterContactPageAccess
It's a bit annoying to do, and it's definitely annoying to review, but it seems doable. It's also easy to document:
.Assigning to catch for feedback.
Comment #20
catchThis change doesn't seem too bad.
This seems potentially expensive though.
Comment #21
Wim LeersYes, except that this is currently even happening for every single
::hasPermission()
check, so it actually shouldn't be a significant extra cost:Comment #22
catchDoh. Good point.
Comment #23
Fabianx CreditAttribution: Fabianx for Drupal Association commentedFrom IRC:
I am +1 to making this user.permissions, but re-using the existing permissionsHash and not providing per permission, because roles has been granular enough for years.
So we win:
- better granularity (roles:2,3 and roles:2 might in reality have the same hash) - but for the default implementation not worse than roles.
- permissions per user work correctly (with caching) [ contrib could decide to provide permissions per user ]
- easier implementation
Comment #24
Wim Leers+1 to #23. Less invasive, simpler conceptually and: no dozens of permission cache contexts per page load!
Comment #25
Wim LeersImplemented the direction outlined in #23/#24. (Took many rerolls, did those in #2459067: Testing issue for #2428703.)
Reverted all the changes to access checks & access control handlers from prior patches, instead, we're now replacing
::cachePerRole()
with::cachePerPermissions()
. That makes for a much easier to review patch. OnlyRoleAccessCheck(Test)
really depended on the role, all other usages depended on permissions, and have thus been replaced.No interdiff, because it's almost completely different — an interdiff would make it harder to review.
Comment #26
Wim LeersUpdating title + IS.
Comment #27
Fabianx CreditAttribution: Fabianx for Drupal Association commentedRTBC, This looks great to me and is a way better solution than before.
Do we need to deprecate user.roles for the security aspect of this?
Needs a change record.
Comment #28
catchuser.roles is still valid if you really genuinely have code that depends on the role. Not sure what, maybe something in environment status that shows only when the user has an administrator role assigned. However there's definitely potential for confusion there.
Comment #29
Wim LeersCore still uses it in two places:
Here: an access check that checks whether the user has the
authenticated
role or not.And here: an access check that checks whether the user has a certain set of roles or not.
I think there actually isn't. 99% of the time, you're checking permissions, so you want to use
user.permissions
. So it's actually clearer, simpler.Only when relying only whether the current user has a certain role or not, you need
user.roles
, i.e. when using role-based access, NOT permission-based access.Of course, we are collectively so very used to varying per role, that that may get in the way. But to further communicate that you'd rarely need that, this also removes
AccessResult::cachePerRole()
in favor ofAccessResult::cachePerPermissions()
.Comment #30
Fabianx CreditAttribution: Fabianx for Drupal Association commented#29: I think if we update the existing change records and remove all user.roles from default examples, we should be fine.
Yes, it is only by habit that one can do this wrong.
Btw. follow-up, but should this authenticated check not use:
user.roles:authenticated now?
Nvm, it already does. Yeah!
So yes, authenticated vs. anonymous continues likely to be the highest user of the user.roles cache context as you might want to display things differently based if a user is logged in or not, while user.roles as a general context is likely less useful now, which is fine.
Comment #31
Fabianx CreditAttribution: Fabianx for Drupal Association commentedComment #32
Wim LeersCR created: https://www.drupal.org/node/2459373.
#31: thanks for adding that tag!
Comment #34
catchCommitted/pushed to 8.0.x, thanks!
Comment #35
Wim LeersYAY!
CR published.
This unblocked:
Comment #36
Wim LeersAlso updated the handbook: https://www.drupal.org/developing/api/8/cache/contexts
Comment #38
David_Rothstein CreditAttribution: David_Rothstein as a volunteer commentedSo as discovered in the linked issue (in particular #2417895-33: AccountPermissionsCacheContext/PermissionsHashGenerator must special case user 1, since permissions don't apply to it), there seem to be a few places in core that still use
user.roles
alone for permission-related caches; we probably want to fix those in a followup.Also coming up in that issue is whether any security-related additions that are being added there for
user.permissions
caching should also be applied touser.roles
. I say yes, given the potential for confusion (and the very late date at which this change was made in Drupal core).Based on that I would suggest that if this (from the issue summary here) is considered a security issue:
Then it would also make sense to add protection for that, i.e. invalidate role-based caches when permissions change... Although personally I don't see that as a security issue necessarily, just a normal bug.