Problem/Motivation

Created as a follow-up to #2350939: Implement a generic revision UI.

There are several problems that result from the Revert and Delete actions being available for the current revision.

  1. The user interface break slightly, with the Revert/Delete split/drop button being directly to the right of the "Current revision" text, all under the single "Operations" column.
  2. Following through the Revert action technically works, in that a new "revert" revision is created, practically nothing has changed because the "current revision" is the current state of the content. In other words, reverting the current revision is illogical because there is nothing to revert to.
  3. Following through the Delete action results in an error and White Screen of Death, which leaves the entity in a broken and unrecoverable state.

Screenshot of the revision list showing the current revision.

The current solution to this is that every entity implementing the Generic Revision UI must include logic in its own access checker to ensure that the Revert and Delete actions cannot be accessed. Example from #1984588: Add Block Content revision UI:

'revert' => AccessResult::allowedIfHasPermissions($account, [
  'administer blocks',
  'revert any ' . $bundle . ' block content revisions',
], 'OR')->orIf(AccessResult::forbiddenIf($entity->isDefaultRevision() && $entity->isLatestRevision())),
'delete revision' => AccessResult::allowedIfHasPermissions($account, [
  'administer blocks',
  'delete any ' . $bundle . ' block content revisions',
], 'OR')->orIf(AccessResult::forbiddenIf($entity->isDefaultRevision() && $entity->isLatestRevision())),

In short, each implementation must include something to the effect of AccessResult::forbiddenIf($entity->isDefaultRevision() && $entity->isLatestRevision() in the access checker, once for the revert operation, and a second time for the delete revision operation.

There are two issues here:

  1. It's reasonable to assume that some developers will forget or not realise they need to include this in their access checkers, resulting in some implementation potentially being buggy.
  2. The fact that every implementation needs to include what is in effect the same lines of code suggests that overall developer effort would be reduced by providing this logic in an access checker out of the box, as well as lowering the barrier to entry and reducing the likelihood of bugs in contrib/custom implementations.

Proposed resolution

Remove isDefaultRevision and isLatestRevision checks from entity type specific access control handlers (e.g BlockContentAccessControlHandler)
Add check for isDefaultRevision for revert and delete revision operations in generic EntityAccessControlHandler and return a forbidden
Improve and fix test coverage for these operations, including expanding EntityTest access tests.

Remaining tasks

Final review

Issue fork drupal-3323788

Command icon 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

AaronMcHale created an issue. See original summary.

aaronmchale’s picture

It would probably help everyone if we could resolve this prior to 10.1 release.

Quoting myself from #2350939-307: Implement a generic revision UI:

Here's what I'm thinking, and I'm going to use Media as an example.

The media entity uses MediaAccessControlHandler, which extends EntityAccessControlHandler.

What if we introduce a new helper class which extends EntityAccessControlHandler, something like ContentEntityAccessControlHandler or if we want to be more specific EntityAccessControlHandlerWithRevision. In that class we override access or checkAccess (whichever is most appropriate) to implement the logic for revert and delete. That way, we don't break BC for any existing implementations while also providing the boilerplate code.

Having said that, EntityAccessControlHandler::access already has some logic for if the Entity is an instance of RevisionableInterface, so maybe we could get away with just adding this to the existing access method, or maybe we move that existing logic into the new class that I described.

As a further thought, Route Providers have established the convention of having a method for every route, the interface doesn't prescribe such a structure but it's now an established pattern. Maybe we do something similar with Access Control Handlers? It might make things a little cleaner if we had a method for each operation. Maybe we even match the method names to the route provider class method names, just an idea, might not make sense in practice. If we did that though, it might work in our favour because it would mean restructuring, introducing new classes and likely deprecating the existing ones.

In any case, this is all things that we can further discuss in follow-up issues.

Version: 10.1.x-dev » 11.x-dev

Drupal core is moving towards using a “main” branch. As an interim step, a new 11.x branch has been opened, as Drupal.org infrastructure cannot currently fully support a branch named main. New developments and disruptive changes should now be targeted for the 11.x branch, which currently accepts only minor-version allowed changes. For more information, see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

berdir’s picture

One note from a related slack thread, I'm not sure if an (only) access-based implementation for this is really the right thing. It's not really about access, it's about data consistency.

\Drupal\node\Controller\NodeController::revisionOverview() handles that directly in there, and I think the default implementation could too. That said, I suppose (also?) implementing it as an access check could help with exposed additional interfaces like jsonapi or something..

Additionally, it probably makes sense to throw an exception on the API level if trying to do it anyway.

yash.rode’s picture

Assigned: Unassigned » yash.rode
Status: Active » Needs work
Issue tags: +Needs tests
Related issues: +#2911977: Add Media revision UI

Implementing #2 as this is blocking #2911977: Add Media revision UI

yash.rode’s picture

I implemented the approach discussed in #2, and it will work without any BC breaks, to test it I used the newly created helper class for block content. If we don't want to rely on access based solution as stated in #4 I will need some help with how to implement that.

yash.rode’s picture

Assigned: yash.rode » Unassigned
Status: Needs work » Needs review
smustgrave’s picture

Status: Needs review » Needs work
Issue tags: +Needs steps to reproduce

This will need test coverage, as previously tagged.

But tried replicating on 11.x with nodes and content blocks and I don't see the revert or delete for the current revision without using the patch. So adding steps to reproduce.

yash.rode’s picture

Status: Needs work » Needs review
Issue tags: -Needs steps to reproduce

Steps to reproduce:
1. with the current patch applied, In \Drupal\block_content\BlockContentAccessControlHandler extend EntityAccessControlHandler instead of EntityAccessControlHandlerWithRevision.
2. You should be able to see the revert and delete button for recent revision for block.
3. Now revert this change and go back to the initial stage with just the patch applied.
4. you should not see the revert and delete button for latest revision.

I think \Drupal\Tests\block_content\Functional\BlockContentRevisionRevertTest::testRevertForm
and \Drupal\Tests\block_content\Functional\BlockContentRevisionDeleteTest::testDeleteForm already provides test coverage for this. So I am not sure if we need a separate test.

yash.rode’s picture

Issue tags: -Needs tests

Described this in #10

smustgrave’s picture

So this issue currently doesn't exist unless it's setup?

1. with the current patch applied, In \Drupal\block_content\BlockContentAccessControlHandler extend EntityAccessControlHandler instead of EntityAccessControlHandlerWithRevision.

yash.rode’s picture

No, because we already have AccessResult::forbiddenIf($entity->isDefaultRevision() && $entity->isLatestRevision() in \Drupal\block_content\BlockContentAccessControlHandler::checkAccess, and we want to remove it from there.

phenaproxima’s picture

I suggest we do this, as suggested by #2:

EntityAccessControlHandler::access already has some logic for if the Entity is an instance of RevisionableInterface, so maybe we could get away with just adding this to the existing access method, or maybe we move that existing logic into the new class that I described.

To me, this would reduce API surface and code duplication, and also protect all entity types from the data integrity issues caused by this bug. That might technically be considered a behavior-breaking BC change, but if the current behavior is capable of breaking entities and making them unusable, that's data loss and probably a sufficient justification for fixing the broken behavior.

yash.rode’s picture

I think it should be fine to do that in `access()` for two reasons.
1. We need to call the `checkAccess()` method at the start of every override of `checkAccess()` and check the returned result is forbidden, if yes, then return forbidden at the start. But we want to do that in the parent class.
2. We are skipping the caching stuff only for the case where it is latest and default for delete or revert operation, in which case it's anyway going to be forbidden. So, I am not sure if that case is going to be a problem.

berdir’s picture

Yeah, checkAccess is usually not calling the parent and calling it and only respecting forbidden is very arbitrary.

I'm still not convinced that this is logic that should be done through the access API. At least we should additionally prevent this with an exception directly in the storage. See \Drupal\Core\Entity\EntityStorageBase::doPreSave() and \Drupal\Core\Entity\ContentEntityStorageBase::doPreSave() for existing examples that through exceptions if a save (or delete revision in this case) operation would cause data integrity issues.

This does not need extra test coverage IMHO, it's a refactoring of an existing check and already tested as the test fails in https://www.drupal.org/pift-ci-job/2714026 show.

yash.rode’s picture

Addressed @Berdir's feedback and test fix.

berdir’s picture

Status: Needs review » Needs work

Reviewed, this is not correct indeed. Not sure if my feedback was misunderstood, because what I asked for does in fact already exist and does not require a change. The tests added/changed here are either incomplete or incorrect about the default revision behavior if there are forward revisions.

The notes about missing/bogus test coverage around node revisions and the deleteRevision() check are just documenting my findings as I was going through this, it doesn't need to be done here. But it might make sense to have a follow-up to add better test coverage for deleteRevision() to avoid breaking this in the future.

For the node revision UI, we already have an issue to use the new generic code, which I guess will then also cover to unify node with block_content/media.

yash.rode’s picture

Status: Needs work » Needs review

Reverted the change and created a follow-up.

berdir’s picture

Status: Needs review » Needs work

The access check still has the same incorrect default AND latest check that is not correct. Since this is new code, we should make sure that is covered here with tests.

yash.rode’s picture

Status: Needs work » Needs review

If we remove that default AND latest check, how are we going to achieve what we want to do in this issue? Or we want it to be just default?

berdir’s picture

> Or we want it to be just default?

Yes, this.

I messed up my formatting, but what I wrote previously is
> The same can also easily be done in the UI on the MR, and you get the delete/revert operations next to the current revision message.

So install content_moderation, enable it for custom blocks, then create a draft of a published block, then it must not be possible to delete the default revision.

And then either a UI or a kernel test (which doesn't need content moderation, it just needs to create an entity with setnewRevision(TRUE) + isDefaultRevision(FALSE)) where you verify that. Very much like the node issue you created, so that keeps working once we switch node to this.

yash.rode’s picture

I have changed it to just check default and on creating a draft of published custom block. I can see the revert and delete button, but I am not able to delete the published revision. That's what we are expecting, right? If yes, I will continue to write the test.

berdir’s picture

StatusFileSize
new34.58 KB

You should see delete and revert for the draft, but not the current revision, yes. Testing manually, I think this is also broken on blocks in HEAD, I guess you just moved that logic. If you compare it with node, it's different (and node is doing it correctly)

yash.rode’s picture

Sorry, It is working as expected now I had missed something. Working on test.

narendrar’s picture

Both functional tests looks good to me.
Q: Does this change require a CR?

berdir’s picture

Category: Task » Bug report
Status: Needs review » Reviewed & tested by the community

I think this is ready now.

Some notes/summary:

* I'm changing it to a bug, because it's not just making the existing check in block_content generic, it's fixing it as well as it is currently wrong.
* Still feels a bit strange that the logic for this is part of access checking, but we have the API level check as well where we throw an exception (but no existing tests for that, but that's not in scope). The advantage of doing it in access means we kind of have an API for this that for example jsonapi could check too.
* Node does this in its controller currently, but that will change when node starts using the generic UI.
* The force revert stuff was bogus, even without the access check, you can't produce a data consistency issue due to the exception. I think that's why this is only a normal bugfix and not major. You could argue that the test coverage for it should be in the generic test entity type and not block_content, but I don't feel so strongly about it.
* I was confused about the time change in the delete confirmation messages, but that's because we now delete a different revision, before it was a previous revision, now it's latest.

acbramley made their first commit to this issue’s fork.

acbramley’s picture

Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs tests

Caught a bug while testing this, that obviously needs test coverage (checking for revert or delete revision access when $return_as_object is FALSE)

yash.rode’s picture

Status: Needs work » Needs review
Issue tags: -Needs tests

Added test coverage.

aaronmchale’s picture

You could argue that the test coverage for it should be in the generic test entity type and not block_content, but I don't feel so strongly about it.

I think it does make more sense for the test coverage to be in the generic test entities. Having the test coverage in the block_content module for something which is being made a generic implementation feels weird.

smustgrave’s picture

Status: Needs review » Needs work

Catching up and reading the comments seems like the consensus is to move those tests to be generic.

If there's an issue with block_content should that be fixed first before this?

yash.rode’s picture

Status: Needs work » Needs review

I think we can rely on this test coverage because we will be adding similar tests for node and media also after they start using generic UI. Anyway, we can't skip test coverage for node, media, and block content, so if we write a test for generic it will get repeated.
If there is any strong reason for writing tests in generic, we can do that.

yash.rode’s picture

Testing for generic.

yash.rode’s picture

We already have the test coverage for this in generic, \Drupal\FunctionalTests\Entity\RevisionDeleteFormTest::testAccessDeleteDefault and \Drupal\FunctionalTests\Entity\RevisionRevertFormTest. The tests were already passing because of
\Drupal\Core\Entity\ContentEntityStorageBase::deleteRevision Which had isDefault() check on database level. To check, we can comment this part as well as comment the part we added in \Drupal\Core\Entity\EntityAccessControlHandler::access-- \Drupal\FunctionalTests\Entity\RevisionDeleteFormTest::testAccessDeleteDefault should fail and uncommenting the part we added in this issue should pass the test.

Other than that, I think we should keep the changes to the block content's test, according to #3375168: Improve test coverage around node revisions and the deleteRevision().

acbramley’s picture

Status: Needs review » Reviewed & tested by the community

Great work @yash.rode this is ready to go again.

lauriii’s picture

Assigned: Unassigned » lauriii
Status: Reviewed & tested by the community » Needs work
Issue tags: +Needs issue summary update, +Needs tests
StatusFileSize
new1.45 KB

I think it would be great to also get an issue summary update that describes the bug we are fixing, and the changes we are making.

If we need to make changes to code outside of the changes introduced here, it sounds like that the test coverage might not be sufficient. Since we are introducing new access controls to the generic entity access control handler, it should be tested outside of the implementations.

We do have pre-existing test coverage for the API itself in a test entity type. Couldn't we utilize these by removing $entity->isDefaultRevision() check from \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess to make it rely on the generic access control handler? It looks like \Drupal\entity_test\EntityTestAccessControlHandler::checkAccess is preventing.

Looks like the test coverage introduced in #2350939: Implement a generic revision UI has a mistake in a critical part which makes it fail to test what it's supposed to 🙈 I'll work on this a bit. Here's a patch that should fail.

acbramley’s picture

Issue summary: View changes
Status: Needs work » Needs review
Issue tags: -Needs issue summary update, -Needs tests

New tests look good, IS is updated.

Nice catch on that test bug!

lauriii’s picture

Assigned: lauriii » Unassigned

Looks like I forgot to unassign this. Thanks for the issue summary update and for moving this to needs review @acbramley!

smustgrave’s picture

Status: Needs review » Reviewed & tested by the community

Issue summary reads clear now.

Think this is ready!

  • lauriii committed 117e716d on 11.x
    Issue #3323788 by yash.rode, lauriii, acbramley, Berdir, AaronMcHale,...
lauriii’s picture

Status: Reviewed & tested by the community » Fixed

Committed 117e716 and pushed to 11.x. Thanks!

Technically, I think this could be backported but it's probably safer to do this in a minor only due to the changes in the access control handler.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

amateescu’s picture

Folks who worked on this issue might be interested in this followup: #3440304: The default revision can not be reverted (to) when there are pending revisions