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

  1. Put JSON:API into writeable mode.
  2. Configure the site to use the "Delete the account and make its content belong to the Anonymous user" method of cancelling accounts.
  3. Ensure "authenticated users" have the permission to "Cancel own user account".
  4. Create a user account.
  5. Log-in as the new user.
  6. Create some content as the new user.
  7. 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}).
  8. Log-in as an admin.
  9. 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

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

GuyPaddock created an issue. See original summary.

guypaddock’s picture

As a workaround, I was able to override the controller for User DELETE requests in a custom module.

Here's how:

Controller (src/Controller/DeleteUserEntityResource.php)


namespace Drupal\my_module\Controller;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\jsonapi\ResourceResponse;
use Drupal\user\UserInterface;

/**
 * Custom controller for handling DELETE requests on User entities via JSON:API.
 */
class DeleteUserEntityResource {

  /**
   * The Drupal module handler interface.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * The settings for the user module.
   *
   * @var \Drupal\Core\Config\Config
   */
  protected $userSettings;

  /**
   * Constructor for DeleteUserEntityResource.
   *
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   The Drupal module handler; used to invoke hooks.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The Drupal configuration factory; used to obtain site-wide user deletion
   *   settings.
   */
  public function __construct(ModuleHandlerInterface $module_handler,
                              ConfigFactoryInterface $config_factory) {
    $this->moduleHandler = $module_handler;
    $this->userSettings  = $config_factory->get('user.settings');
  }

  /**
   * Deletes an individual user entity.
   *
   * @param \Drupal\user\UserInterface $entity
   *   The user account being deleted.
   *
   * @return \Drupal\jsonapi\ResourceResponse
   *   The response.
   *
   * @noinspection PhpUnused
   */
  public function deleteIndividual(UserInterface $entity): ResourceResponse {
    $cancel_method = $this->getCancelMethod();

    // Allow other modules to act.
    if ($cancel_method != 'user_cancel_delete') {
      $this->getModuleHandler()->invokeAll(
        'user_cancel', [
          [],
          $entity,
          $cancel_method,
        ]
      );
    }

    // Actually cancel the account.
    _user_cancel([], $entity, $cancel_method);

    return new ResourceResponse(NULL, 204);
  }

  /**
   * Gets the Drupal module handler, for invoking hooks.
   *
   * @return \Drupal\Core\Extension\ModuleHandlerInterface
   *   The module handler instance.
   */
  protected function getModuleHandler(): ModuleHandlerInterface {
    return $this->moduleHandler;
  }

  /**
   * Gets the method the site was configured to use to cancel user accounts.
   *
   * @return string
   *   The machine name of the cancellation method.
   */
  protected function getCancelMethod(): string {
    return $this->userSettings->get('cancel_method') ?? 'user_cancel_block';
  }

}

Route Subscriber (src/Routing/UserDeleteRerouter.php)


namespace Drupal\my_module\Routing;

use Drupal\Core\Routing\RouteSubscriberBase;
use Drupal\jsonapi\Routing\Routes;
use Symfony\Component\Routing\RouteCollection;

/**
 * A route subscriber to re-route DELETE requests for User entities.
 *
 * This is a workaround for DDO-3228000. The default handling of DELETE does not
 * obey the cancellation method set on the site. As a result, deleting a user
 * through JSON:API normally deletes ALL content that the user created. We do not 
 * want this; we want to anonymize the user's information.
 */
class UserDeleteRerouter extends RouteSubscriberBase {

  /**
   * {@inheritdoc}
   */
  protected function alterRoutes(RouteCollection $collection): RouteCollection {
    foreach ($collection as $route_name => $route) {
      if ($route_name === 'jsonapi.user--user.individual.delete') {
        // Override controller for User DELETE requests, and route to our custom
        // controller.
        $route->setDefault(
          '_controller',
          'jsonapi.user_delete_resource:deleteIndividual'
        );
      }
    }

    return $collection;
  }

}

Service Definition (*.services.yml)

services:
  jsonapi.user_delete_resource:
    class: Drupal\my_module\Controller\DeleteUserEntityResource
    arguments:
      - '@module_handler'
      - '@config.factory'

  route_subscriber.user_delete_rerouter:
    class: 'Drupal\my_module\Routing\UserDeleteRerouter'
    tags:
      - { name: 'event_subscriber' }
avpaderno’s picture

Issue summary: View changes

The code to delete a user account could call user_cancel(). That's what the controller for the email links to cancel an account does in UserController::confirmCancel().

$account_data = $this->userData->get('user', $user->id());

// …

$edit = [
  'user_cancel_notify' => isset($account_data['cancel_notify']) ? $account_data['cancel_notify'] : $this->config('user.settings')         ->get('notify.status_canceled'),
];
user_cancel($edit, $user->id(), $account_data['cancel_method']);

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?

xjm’s picture

Priority: Normal » Critical

This 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!

xjm’s picture

Version: 8.9.x-dev » 9.2.x-dev

8.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!) :)

guypaddock’s picture

Issue summary: View changes

Thanks, 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.

guypaddock’s picture

Issue summary: View changes
guypaddock’s picture

Issue summary: View changes
guypaddock’s picture

Issue summary: View changes

Added a note that hook_user_delete is actually the important piece here.

guypaddock’s picture

Issue summary: View changes
bbrala’s picture

Hi @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.

  1. Do we fix this in JSON:API, by calling the method in the user module.
  2. Or is this an issue that when an user is deleted it should always respect the cancel_method as 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.

bbrala’s picture

If 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.

wim leers’s picture

Isn't this a bug in User module? 🤔

bbrala’s picture

Well, 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.

bradjones1’s picture

I 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.

bbrala’s picture

True 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.

avpaderno’s picture

Either user_cancel() and User::delete() are merged, which means there isn't anymore a distinction between cancelling an account or deleted it, or modules need to call user_cancel() or User::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.)

bbrala’s picture

Assigned: Unassigned » bbrala

bbrala credited e0ipso.

bbrala’s picture

After discussion with @win-leers and @e0ipso on this issue we decided to go ahead and fix this in jsonapi.

bbrala’s picture

Added tests that test the different options. 3 out of the 4 should fail since the current implementation is the same as the user_cancel_delete cancel method.

bbrala’s picture

Status: Active » Needs review
bbrala’s picture

Assigned: bbrala » Unassigned

Created the appropiate changes, unfortunately there is some new arguments to service, so I added some deprecation notices.

larowlan’s picture

larowlan’s picture

left 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

bbrala’s picture

bbrala’s picture

Ok, 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 testDeleteIndividual to use the user_cancel_delete method, 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.

avpaderno’s picture

Title: Users Deleted via JSON:API DELETE Do Not Obey Site-wide 'cancel_method' in User Settings » Users deleted via JSON:API DELETE don't follow the site-wide cancel_method in the user settings
larowlan’s picture

Status: Needs review » Needs work

looking good, left one minor comment for a todo pointing to the followup

bradjones1’s picture

Status: Needs work » Needs review
bradjones1’s picture

Status: Needs review » Reviewed & tested by the community

I 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_defaults module) and leaving the methods alone for now is good for those similarly situated.

alexpott’s picture

Status: Reviewed & tested by the community » Needs work

I 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:

  // After cancelling account, ensure that user is logged out.
  if ($account->id() == \Drupal::currentUser()->id()) {
    _user_cancel_session_regenerate();
  }
larowlan’s picture

I think we need to ensure that _user_cancel_session_regenerate() is called too. I.e. if we're using json cookie auth...

I think this should be the responsibility of the user storage handler, so e.g. graphql, rest etc can also benefit

alexpott’s picture

@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.

larowlan’s picture

we do the whole thing properly for JSON API and then refactor to the new code when it exists

Given 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

bbrala’s picture

I'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.

larowlan’s picture

larowlan’s picture

larowlan’s picture

Ok, 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::confirmCancel which is used to

Confirms cancelling a user account via an email link

and in there is does this:

$edit = [
          'user_cancel_notify' => isset($account_data['cancel_notify']) ? $account_data['cancel_notify'] : $this->config('user.settings')->get('notify.status_canceled'),
        ];
        user_cancel($edit, $user->id(), $account_data['cancel_method']);
        // Since user_cancel() is not invoked via Form API, batch processing
        // needs to be invoked manually and should redirect to the front page
        // after completion.
        return batch_process('<front>');

So I think that's a clue to what we could try here.

Going to try that in the branch.

larowlan’s picture

Pushed a batch process approach that passes tests

larowlan’s picture

Status: Needs work » Needs review

That change passed tests, and should respect batch handling from group module *I think*

bbrala’s picture

Tested manually as follows:

  1. Fresh drupal installation with jsonapi, groups and basic_auth enabled
  2. Applied merge request
  3. Changed account settings to "Delete the account and make its content belong to the Anonymous user. Reassign its groups to the super administrator."
  4. Created a test user in group administrators (password test)
  5. Created 12 groups owned by that user. 12 because >10 triggers the batch API as mentioned in the linked code by @alexpott).
  6. Sent an delete to that user on a incognito chrome window:
    const response = await fetch('https://drupal-core.dev.swis.nl/jsonapi/user/user/2e28b1f8-ba34-43d5-b1b3-537e0e593168', {
        method: 'DELETE',
        headers: {
            'Accept': 'application/vnd.api+json', 
            'Authorization': 'Basic dGVzdDp0ZXN0'
        }
    });
    console.log(await response.json());
  7. Verified all groups were reassigned to root

This is working as expected yay!

Would be nice to get this merged asap :)

bbrala’s picture

Status: Needs review » Reviewed & tested by the community

Settings to RTBC again.

catch’s picture

Status: Reviewed & tested by the community » Needs review

Couple of questions on the MR.

wim leers’s picture

Status: Needs review » Needs work

Wow.

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.

Wow.

I have strong concerns about calling batch_process() and expecting it to complete within a single request (so, usually within 30 seconds).

bbrala’s picture

Wim 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 :)

wim leers’s picture

But that is currently is already in the code.

Are you sure? I don't see any mentions of "batch" in core/modules/jsonapi? 🤔

bbrala’s picture

@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 :)

bbrala’s picture

Thas logic actually happend here: node_user_predelete and comment_user_predelete. As mentioned in the summary :)

bbrala’s picture

I've pushed 2 more commits to the branch, mostly adding a test and removed some dulpicate code. Did resolve most threads also.

bbrala’s picture

Rebased on 9.2.x

bbrala’s picture

Status: Needs work » Needs review
larowlan’s picture

Left a comment on how I think you may be able to access the success/fail

bbrala’s picture

I actually dumped the $batch variable after the batch processing and it was null. I think this happend in batch.inc line 484.

larowlan’s picture

Status: Needs review » Reviewed & tested by the community

@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.

alexpott’s picture

FWIW 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.

alexpott’s picture

Status: Reviewed & tested by the community » Fixed

Committed and pushed 9d8e7da4fc to 9.3.x and 7c0c951f76 to 9.2.x. Thanks!

  • alexpott committed 9d8e7da on 9.3.x
    Issue #3228000 by bbrala, larowlan, GuyPaddock, Wim Leers, bradjones1,...

  • alexpott committed 7c0c951 on 9.2.x
    Issue #3228000 by bbrala, larowlan, GuyPaddock, Wim Leers, bradjones1,...
larowlan’s picture

@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.

larowlan’s picture

bbrala’s picture

Issue for exposing queues/jobs in json:api.

And nice find @larowlan ;)

wim leers’s picture

Wow, 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! 🤩🥳

guypaddock’s picture

Many thanks, all!

Status: Fixed » Closed (fixed)

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