Problem/Motivation
In user account settings (/admin/config/people/accounts), admins can set how user accounts should be disabled or deleted by changing the "When cancelling a user account" setting. Drupal's default for this is "Disable the account and keep its content." (user_cancel_block).
In addition, if users are granted the permission to "Cancel own user account" (cancel account) and JSON:API is put into read/write mode (i.e. not read-only), users can delete their own profiles by performing a DELETE request against /jsonapi/user/{UUID}. This is desirable and very useful if JSON:API is being used for a headless Drupal site (e.g. to power an app) that for compliance reasons (e.g. GDPR or CCPA) needs to provide users with a way to terminate their accounts.
However, if an account is deleted via JSON:API in this way, it also removes all content created by the user who is being deleted, even if that's not the way the site has been configured to handle user account deletions. In our case, we want to handle these requests by anonymizing the user's content rather than deleting it.
Steps to reproduce
- Put JSON:API into writeable mode.
- Configure the site to use the "Delete the account and make its content belong to the Anonymous user" method of cancelling accounts.
- Ensure "authenticated users" have the permission to "Cancel own user account".
- Create a user account.
- Log-in as the new user.
- Create some content as the new user.
- Using Postman or a similar tool, perform a DELETE request against the JSON:API user endpoint for the new user (e.g.
DELETE /jsonapi/user/{UUID_OF_USER}). - Log-in as an admin.
- Attempt to find the content the user created.
The content will be gone because it gets deleted with the user via node_user_predelete. Meanwhile, hook_user_delete and _user_cancel() (and, consequently, node_user_cancel()) never get invoked.
Proposed resolution
Route user account deletion performed via JSON:API so that hook_user_delete and _user_cancel() get invoked, applying the site-wide 'cancel_method' setting from user.settings.
Remaining tasks
User interface changes
API changes
Data model changes
Release notes snippet
Issue fork drupal-3228000
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 #2
guypaddock commentedAs a workaround, I was able to override the controller for User
DELETErequests in a custom module.Here's how:
Controller (
src/Controller/DeleteUserEntityResource.php)Route Subscriber (
src/Routing/UserDeleteRerouter.php)Service Definition (
*.services.yml)Comment #3
avpadernoThe code to delete a user account could call
user_cancel(). That's what the controller for the email links to cancel an account does inUserController::confirmCancel().I could understand the objection that
user_cancel()is for cancelling an account (which means a user started it from the site user interface), not deleting an account (which could be done without any user starting it).Do we want to always cancel an account, instead of making a distinction between cancelling an account and deleting an account?
Comment #4
xjmThis is on the borderline between major and critical. The case for it being major is that there is a workaround as described in #2, but on the other hand it is unexpected, permanent data loss and one shouldn't need custom code to prevent data loss. Erring on the side of critical. Thank you for reporting this!
Comment #5
xjm8.9.x is in security support only, so moving to 9.2.x which is the lowest branch where we would commit a fix. (Upgrade to Drupal 9 ASAP as there are only 81 days until Drupal 8 is completely end-of-life with no security coverage whatsoever!) :)
Comment #6
guypaddock commentedThanks, xjm! I appreciate it.
I just realized I had not added a step about granting the "Cancel own user account" permission; I've now added that. The description of the permission states: "Note: content may be kept, unpublished, deleted or transferred to the anonymous user depending on the configured user settings." so I agree with your assessment that this could be a critical issue since admins would not realize that users could use this to circumvent the settings, resulting in data loss.
Comment #7
guypaddock commentedComment #8
guypaddock commentedComment #9
guypaddock commentedAdded a note that
hook_user_deleteis actually the important piece here.Comment #10
guypaddock commentedComment #11
bbralaHi @GuyPaddock, first of all thanks for reporting this issue. Users and JSON:API seem to have some history of having some issues working correctly together. This is another example of that.
In my opinion when a user is deleted in the JSON:API it should follow the settings of the Drupal installation. JSON:API should be a API representation of the installation, including that setting.
I remembered someone else mentioning this a while back, but this unfortunately didn't trigger us to investigate more. See the relevant slack thread. In a way the mentioned #2953748: user_delete() should not remove the content created by user could be seen as an related issue, where a simple delete on the User entity results in the deletion of all the related content.
The question then becomes, where should this be fixed.
cancel_methodas defined in the user settings, regardless of which code calls for a deletion.Guess the easiest way to fix it is to make JSON:API concise with how the backend works. But I think the correct way is not to delete everything of a user but respect the site settings regardless of who calls for deletion.
Comment #12
bbralaIf we are to fix this in JSON:API while we wait for #2953748: user_delete() should not remove the content created by user the fix is adding an extra condition in the
EntityResource::deleteIndividual()method so it respects the site settings.But i feel there should also be a way to call the different cancel methods so you COULD delete the user if you want. I can image in a decoupled situation you might want to cancel by default but also have the option to anonimize or delete all related content.
Comment #13
wim leersIsn't this a bug in
Usermodule? 🤔Comment #14
bbralaWell, if you look at the related issue, it is currently marked as a feature request? The thing is, before this, the interface was really the only way to do this, we now supplied a new way to call DELETE and suddenly we are loosing the settings and options from the normal delete form from the backend.
I could argue its a bug there, or it's an oversight here. That why i'm kinda looking for ideas on how to view this issue. I think if we try to get the entity changed that this will be a lot more effort to get committed though.
Comment #15
bradjones1I agree that the definitive fix is probably in User. In so far as, I don't think json:api module wants to be in the business of encapsulating entity-specific logic (core as it is in Drupal) but rather let the entity's handlers do that.
Comment #16
bbralaTrue that. But jsonapi has multiple places we're special cases for the user entities are spread. So there is precedent, even though its not ideaal at all.
Comment #17
avpadernoEither
user_cancel()andUser::delete()are merged, which means there isn't anymore a distinction between cancelling an account or deleted it, or modules need to calluser_cancel()orUser::delete()depending on which one is considered more correct.I am not sure an account should always be cancelled instead of deleted. (It would also make an entity class depend on the config.factory service, which doesn't happen for other entity classes.)
Comment #18
bbralaComment #20
bbralaAfter discussion with @win-leers and @e0ipso on this issue we decided to go ahead and fix this in jsonapi.
Comment #22
bbralaAdded tests that test the different options. 3 out of the 4 should fail since the current implementation is the same as the
user_cancel_deletecancel method.Comment #23
bbralaComment #24
bbralaCreated the appropiate changes, unfortunately there is some new arguments to service, so I added some deprecation notices.
Comment #25
larowlanComment #26
larowlanleft a review, I think we need to have a follow up to remove the 'json api knows about the specifics of various entity-types' and introduce a proper entity-type level handler API for JSON:API
Comment #27
bbralaComment #28
bbralaOk, i've looked into the failure. This is quite logical.
When a user is "deleted" it by default is only blocked now. This means the second delete test will not get a 404 response.
I think it's safe to change the settings for
testDeleteIndividualto use theuser_cancel_deletemethod, since that is what was tested there before this MR.The added tests for the different ways you can cancel/delete should cover the other methods.
Comment #29
avpadernoComment #30
larowlanlooking good, left one minor comment for a todo pointing to the followup
Comment #31
bradjones1Comment #32
bradjones1I have reviewed the MR, the above discussion and trade-offs, and tested this locally. I believe it is eligible to be RTBC.
My only general note on the code is that we "should" be using DI for invoking the config factory and module handler, however that point is addressed in the MR discussion regarding this being a stopgap to fix a critical bug, and the follow-up for adding jsonapi-specific handlers is a more definitive fix. Changing the constructor or other methods' signatures for what is otherwise a stopgap would be an unnecessary API change. (E.g., the site I am working on now has reason to decorate/extend the entity resource (similar to, but more complex than
jsonapi_extras'jsonapi_defaultsmodule) and leaving the methods alone for now is good for those similarly situated.Comment #33
alexpottI think we need to ensure that _user_cancel_session_regenerate() is called too. I.e. if we're using json cookie auth...
I.e we need to do something like:
Comment #34
larowlanI think this should be the responsibility of the user storage handler, so e.g. graphql, rest etc can also benefit
Comment #35
alexpott@larowlan I agree that there should be some central code for services to call but the implementation added here is not doing the whole thing. I think we should either postpone this on adding something to user storage or we do the whole thing properly for JSON API and then refactor to the new code when it exists.
Comment #36
larowlanGiven this is a critical with major data loss, I think that's the best course of action.
Can we get that code added, plus a follow-up and a @todo.
Thanks
Comment #37
bbralaI've had a discussion with @larowlan around this issue. Your first comment shouldn't be a problem. The comment on the merge request seems to be a bigger challenge. With help of @larowlan i hope to send in an update this friday, or at least a conclusion.
Comment #38
larowlanComment #39
larowlanComment #40
larowlanOk, so I've gone down a rabbit hole and back.
On one hand, this code in core is ancient and really in need of a refresh, but on the other hand - its not the job of JSON:API to drag up 12 year old plus issues.
In my travels, I came across
\Drupal\user\Controller\UserController::confirmCancelwhich is used toand in there is does this:
So I think that's a clue to what we could try here.
Going to try that in the branch.
Comment #41
larowlanPushed a batch process approach that passes tests
Comment #42
larowlanThat change passed tests, and should respect batch handling from group module *I think*
Comment #43
bbralaTested manually as follows:
rootThis is working as expected yay!
Would be nice to get this merged asap :)
Comment #44
bbralaSettings to RTBC again.
Comment #45
catchCouple of questions on the MR.
Comment #46
wim leersWow.
Wow.
I have strong concerns about calling
batch_process()and expecting it to complete within a single request (so, usually within 30 seconds).Comment #47
bbralaWim I understand your performance issue. But that is currently is already in the code. It will delete ALL content related to the user. This will also break on very large sets. We don't introduce a new problem, we just make sure we aren't loosing data.
In mobile so can't really dive into the MR right now :)
Comment #48
wim leersAre you sure? I don't see any mentions of "batch" in
core/modules/jsonapi? 🤔Comment #49
bbrala@Wim Leers it uses the delete hook of user. This deletes all related content, not even in a batch at all. So yeah. I'm quite sure :)
Comment #50
bbralaThas logic actually happend here: node_user_predelete and comment_user_predelete. As mentioned in the summary :)
Comment #51
bbralaI've pushed 2 more commits to the branch, mostly adding a test and removed some dulpicate code. Did resolve most threads also.
Comment #52
bbralaRebased on 9.2.x
Comment #53
bbralaComment #54
larowlanLeft a comment on how I think you may be able to access the success/fail
Comment #55
bbralaI actually dumped the
$batchvariable after the batch processing and it was null. I think this happend inbatch.incline 484.Comment #56
larowlan@bbrala pinged me about the above, batch.inc line 484 sets $batch to NULL when the batch is finished, so there doesn't seem to be a way to check if it succeeded or not - putting back to RTBC on that basis, I don't think there's much more we can do here.
Comment #57
alexpottFWIW the json api spec does have something to say about long running jobs - see https://jsonapi.org/recommendations/#asynchronous-processing - but in order to move to something like that we need to have queue processing and a queue runner that does not just run on cron... fun.
Comment #58
alexpottCommitted and pushed 9d8e7da4fc to 9.3.x and 7c0c951f76 to 9.2.x. Thanks!
Comment #61
larowlan@bbrala can we get some follow-ups for implementing the queue processing support in JSON:API?
I'll handle opening an issue for generic queue processing outside cron.
Comment #62
larowlan#3068764: Refactor cron queue processing into its own class seems the place for generic queue processing (non cron)
Comment #63
bbralaIssue for exposing queues/jobs in json:api.
And nice find @larowlan ;)
Comment #64
wim leersWow, pushing #3068764: Refactor cron queue processing into its own class and #1189464: Add an 'instant' queue runner forward is a nice unexpected but very welcome side effect of this issue! 🤩🥳
Comment #65
guypaddock commentedMany thanks, all!