Incorporate the permanent user deletion sprinkle

This commit is contained in:
2023-07-25 14:21:42 +01:00
parent 243987382a
commit 50b8e02839
18 changed files with 934 additions and 66 deletions

View File

@@ -0,0 +1,350 @@
<?php
/*
* AVSDev UF Tweaks (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks
* @license https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks/blob/master/LICENSE.md (LGPL-3.0 License)
*/
namespace UserFrosting\Sprinkle\UFTweaks\Controller;
use Illuminate\Database\Capsule\Manager as Capsule;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use UserFrosting\Fortress\RequestDataTransformer;
use UserFrosting\Fortress\RequestSchema;
use UserFrosting\Fortress\ServerSideValidator;
use UserFrosting\Sprinkle\Core\Controller\SimpleController;
use UserFrosting\Support\Exception\BadRequestException;
use UserFrosting\Support\Exception\ForbiddenException;
use UserFrosting\Support\Exception\NotFoundException;
/**
* Controller class for deleted user related requests, including restoring and permanently deleting users.
*
* @author Craig Williams (https://avsdev.uk)
*/
class DeletedUserController extends SimpleController
{
/**
* Processes the request to permanently delete a deleted user.
*
* Permanently deletes the specified user.
* Before doing so, checks that:
* 1. The current user has permission to delete the requested user;
* 2. The submitted data is valid.
* This route requires authentication (and should generally be limited to admins or the root user).
*
* Request type: DELETE
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws NotFoundException If user is not found
* @throws ForbiddenException If user is not authorized to access page
* @throws BadRequestException
*/
public function deletePermanent(Request $request, Response $response, $args)
{
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Support\Repository\Repository $config */
$config = $this->ci->config;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
$user = $this->getDeletedUserFromParams($args);
// If the user doesn't exist, return 404
if (!$user) {
throw new NotFoundException();
}
// Access-controlled page
if (!$authorizer->checkAccess($currentUser, 'permanently_delete_user', [
'user' => $user,
])) {
throw new ForbiddenException();
}
// Config-controlled page
if (!$config['users.allow_permanent_delete']) {
$ms->addMessageTranslated('danger', 'USER.PERMANENT_DELETE_DISABLED');
return $response->withJson([], 400);
}
$user_name = $user->user_name;
// Begin transaction - DB will be rolled back if an exception occurs
Capsule::transaction(function () use ($user, $user_name, $currentUser, $config) {
$user->forceDelete();
unset($user);
// Create activity record
$this->ci->userActivityLogger->info("User {$currentUser->user_name} permenetly deleted user {$user_name}.", [
'type' => 'user_delete',
'user_id' => $currentUser->id,
]);
});
$ms->addMessageTranslated('success', 'USER.PERMANENT_DELETION_SUCCESSFUL', [
'user_name' => $user_name,
]);
return $response->withJson([], 200);
}
/**
* Restores a deleted user
*
* Before doing so, checks that:
* 1. The current user has permission to restore the requested user;
* 2. The submitted data is valid.
* This route requires authentication (and should generally be limited to admins or the root user).
*
* Request type: PUT
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws NotFoundException If user is not found
* @throws ForbiddenException If user is not authorized to access page
* @throws BadRequestException
*/
public function restore(Request $request, Response $response, $args)
{
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
$user = $this->getDeletedUserFromParams($args);
// If the user doesn't exist, return 404
if (!$user) {
throw new NotFoundException();
}
// Access-controlled page
if (!$authorizer->checkAccess($currentUser, 'restore_deleted_user', [
'user' => $user,
])) {
throw new ForbiddenException();
}
// Begin transaction - DB will be rolled back if an exception occurs
Capsule::transaction(function () use ($user, $currentUser) {
$user->restore();
// Create activity record
$this->ci->userActivityLogger->info("User {$currentUser->user_name} restored deleted user {$user->user_name}.", [
'type' => 'user_restore',
'user_id' => $currentUser->id,
]);
});
$ms->addMessageTranslated('success', 'USER.RESTORE_SUCCESSFUL', [
'user_name' => $user->user_name,
]);
return $response->withJson([], 200);
}
/**
* Returns a list of deleted users.
*
* Generates a list of deleted users, optionally paginated, sorted and/or filtered.
* This page requires authentication.
*
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws ForbiddenException If user is not authorized to access page
*/
public function getListDeleted(Request $request, Response $response, $args)
{
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
// Access-controlled page
if (!$authorizer->checkAccess($currentUser, 'uri_deleted_users')) {
throw new ForbiddenException();
}
// GET parameters
$params = $request->getQueryParams();
$sprunje = $classMapper->createInstance('user_sprunje', $classMapper, $params);
$sprunje->extendQuery(function ($query) {
return $query->onlyTrashed();
});
// Be careful how you consume this data - it has not been escaped and contains untrusted user-supplied content.
// For example, if you plan to insert it into an HTML DOM, you must escape it on the client side (or use client-side templating).
return $sprunje->toResponse($response);
}
/**
* Get permanent deletion confirmation modal.
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws NotFoundException If user is not found
* @throws ForbiddenException If user is not authorized to access page
* @throws BadRequestException
*/
public function getModalConfirmPermanentDelete(Request $request, Response $response, $args)
{
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
/** @var \UserFrosting\Support\Repository\Repository $config */
$config = $this->ci->config;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
/** @var \UserFrosting\Sprinkle\Core\Alert\AlertStream $ms */
$ms = $this->ci->alerts;
// GET parameters
$params = $request->getQueryParams();
$user = $this->getDeletedUserFromParams($params);
// If the user doesn't exist, return not found
if (!$user) {
throw new NotFoundException();
}
// Access-controlled page
if (!$authorizer->checkAccess($currentUser, 'permanently_delete_user', [
'user' => $user,
])) {
throw new ForbiddenException();
}
// Config-controlled page
if (!$config['users.allow_permanent_delete']) {
$exception = new BadRequestException();
$exception->addUserMessage('USER.PERMANENT_DELETE_DISABLED');
throw $exception;
}
return $this->ci->view->render($response, 'modals/confirm-permanently-delete-user.html.twig', [
'user' => $user,
'form' => [
'action' => "api/users/u/{$user->user_name}/permanent",
],
]);
}
/**
* Renders the user listing page for deleted users.
*
* This page renders a table of delete users, with dropdown menus for admin actions for each user.
* Actions typically include: restore user, permanently delete etc.
* This page requires authentication.
*
* Request type: GET
*
* @param Request $request
* @param Response $response
* @param array $args
*
* @throws ForbiddenException If user is not authorized to access page
*/
public function pageListDeleted(Request $request, Response $response, $args)
{
/** @var \UserFrosting\Sprinkle\Account\Authorize\AuthorizationManager $authorizer */
$authorizer = $this->ci->authorizer;
/** @var \UserFrosting\Sprinkle\Account\Database\Models\Interfaces\UserInterface $currentUser */
$currentUser = $this->ci->currentUser;
// Access-controlled page
if (!$authorizer->checkAccess($currentUser, 'uri_deleted_users')) {
throw new ForbiddenException();
}
return $this->ci->view->render($response, 'pages/deleted-users.html.twig');
}
/**
* Get User instance from params.
*
* @param string[] $params
*
* @throws BadRequestException
*
* @return User|null
*/
protected function getDeletedUserFromParams(array $params)
{
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = $this->ci->classMapper;
// Load the request schema
$schema = new RequestSchema('schema://requests/user/get-by-username.yaml');
// Whitelist and set parameter defaults
$transformer = new RequestDataTransformer($schema);
$data = $transformer->transform($params);
// Validate, and throw exception on validation errors.
$validator = new ServerSideValidator($schema, $this->ci->translator);
if (!$validator->validate($data)) {
// TODO: encapsulate the communication of error messages from ServerSideValidator to the BadRequestException
$e = new BadRequestException();
foreach ($validator->errors() as $idx => $field) {
foreach ($field as $eidx => $error) {
$e->addUserMessage($error);
}
}
throw $e;
}
// Query to get the user
$query = $classMapper->getClassMapping('user')::where('user_name', $data['user_name'])->withTrashed();
// Run the query
$user = $query->first();
return $user;
}
}

View File

@@ -0,0 +1,54 @@
<?php
/*
* AVSDev UF Tweaks (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks
* @license https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks/blob/master/LICENSE.md (LGPL-3.0 License)
*/
namespace UserFrosting\Sprinkle\UFTweaks\Database\Migrations\v1_0_0;
use Illuminate\Database\Schema\Blueprint;
use UserFrosting\Sprinkle\Core\Database\Migration;
/**
* Users table migration
* Changes `user_id` column properties to nullable to allow users to be deleted without clearing the audit log for that user.
* Version 1.0.0.
*
* @author Craig Williams (https://avsdev.uk)
*/
class UpdateActivitiesTable extends Migration
{
/**
* {@inheritdoc}
*/
public static $dependencies = [
'UserFrosting\Sprinkle\Account\Database\Migrations\v400\ActivitiesTable',
];
/**
* {@inheritdoc}
*/
public function up()
{
if ($this->schema->hasTable('activities')) {
$this->schema->table('activities', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->nullable()->change();
});
}
}
/**
* {@inheritdoc}
*/
public function down()
{
if ($this->schema->hasTable('activities')) {
$this->schema->table('activities', function (Blueprint $table) {
$table->integer('user_id')->unsigned()->change();
});
}
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* AVSDev UF Tweaks (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks
* @license https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks/blob/master/LICENSE.md (LGPL-3.0 License)
*/
namespace UserFrosting\Sprinkle\UFTweaks\Database\ModelTraits;
trait UserPermanentlyDeletable {
protected function preventActivityPurge() {
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = static::$ci->classMapper;
/** @var \UserFrosting\Support\Repository\Repository $config */
$config = static::$ci->config;
if ($this->isForceDeleting() && !$config['users.purge_activities']) {
// Keep the activity log, but lose the user field
$classMapper->getClassMapping('activity')::query()
->where('user_id', $this->id)
->update(['user_id' => null]);
$this->refresh();
}
return true;
}
public function bootUserPermanentlyDeletable()
{
static::deleting(function ($user) {
if (!$user->preventActivityPurge()) {
return false;
}
return true;
});
}
}

View File

@@ -0,0 +1,85 @@
<?php
/*
* AVSDev UF Tweaks (https://avsdev.uk)
*
* @link https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks
* @license https://git.avsdev.uk/avsdev/sprinkle-uf-tweaks/blob/master/LICENSE.md (LGPL-3.0 License)
*/
namespace UserFrosting\Sprinkle\UFTweaks\Database\Models;
use LogicException;
use UserFrosting\Sprinkle\Account\Database\Models\User as UFUser;
use UserFrosting\Sprinkle\UFTweaks\Database\ModelTraits\UserPermanentlyDeletable;
/**
* User Class.
*
* Extends the UF User object by adding the UserPermanentlyDeletable trait
*
* @author Craig Williams (https://avsdev.uk)
*/
class User extends UFUser
{
use UserPermanentlyDeletable;
/**
* Delete this user from the database, along with any linked roles and activities.
*
* @return bool true if the deletion was successful, false otherwise.
*/
protected function deleteUserAssociations()
{
/** @var \UserFrosting\Sprinkle\Core\Util\ClassMapper $classMapper */
$classMapper = static::$ci->classMapper;
// Remove all role associations
$this->roles()->detach();
// Remove last activity association
$this->lastActivity()->dissociate();
$this->save();
// Remove all user tokens
$this->activities()->delete();
$this->passwordResets()->delete();
$classMapper->getClassMapping('verification')::where('user_id', $this->id)->delete();
$classMapper->getClassMapping('persistence')::where('user_id', $this->id)->delete();
return true;
}
/**
* Delete this user from the database, along with any linked roles and activities.
*
* @param bool $hardDelete Trigger a forceDelete()
*
* @return bool true if the deletion was successful, false otherwise.
*/
public function delete($hardDelete = false)
{
/** @var \UserFrosting\Support\Repository\Repository $config */
$config = $this->ci->config;
if ($hardDelete) {
return $this->forceDelete();
}
if ($this->isForceDeleting()) {
if (!$config['users.allow_permanent_delete']) {
throw new LogicException('Permanent deletion is disabled.');
}
if ($this->fireModelEvent('deleting') === false) {
return false;
}
$this->deleteUserAssociations();
}
$result = parent::delete();
return $result;
}
}

View File

@@ -40,7 +40,7 @@ class DefaultPermissions extends UFDefaultPermissions
*/
protected function getPermissions()
{
$base_permissions = parent::getPermissions();
$basePermissions = parent::getPermissions();
$defaultRoleIds = [
'user' => Role::where('slug', 'user')->first()->id,
@@ -48,66 +48,84 @@ class DefaultPermissions extends UFDefaultPermissions
'site-admin' => Role::where('slug', 'site-admin')->first()->id,
];
return array_merge(
$base_permissions,
[
'uri_role' => new Permission([
'slug' => 'uri_role',
'name' => 'View role',
'conditions' => 'always()',
'description' => 'View the role page of any role.',
]),
'uri_roles' => new Permission([
'slug' => 'uri_roles',
'name' => 'Role management page',
'conditions' => 'always()',
'description' => 'View a page containing a table of roles.',
]),
'uri_permission' => new Permission([
'slug' => 'uri_permission',
'name' => 'View permission',
'conditions' => 'always()',
'description' => 'View the permission page of any permission.',
]),
'uri_permissions' => new Permission([
'slug' => 'uri_permissions',
'name' => 'Permission management page',
'conditions' => 'always()',
'description' => 'View a page containing a table of permissions.',
]),
$newPermissions = [
'uri_role' => new Permission([
'slug' => 'uri_role',
'name' => 'View role',
'conditions' => 'always()',
'description' => 'View the role page of any role.',
]),
'uri_roles' => new Permission([
'slug' => 'uri_roles',
'name' => 'Role management page',
'conditions' => 'always()',
'description' => 'View a page containing a table of roles.',
]),
'uri_permission' => new Permission([
'slug' => 'uri_permission',
'name' => 'View permission',
'conditions' => 'always()',
'description' => 'View the permission page of any permission.',
]),
'uri_permissions' => new Permission([
'slug' => 'uri_permissions',
'name' => 'Permission management page',
'conditions' => 'always()',
'description' => 'View a page containing a table of permissions.',
]),
'create_role' => new Permission([
'slug' => 'create_role',
'name' => 'Create role',
'conditions' => 'always()',
'description' => 'Create a new role.',
]),
'view_role_field' => new Permission([
'slug' => 'view_role_field',
'name' => 'View role',
'conditions' => "in(property,['slug','name','description','permissions','users'])",
'description' => 'View certain properties of any role.',
]),
'update_role_field' => new Permission([
'slug' => 'update_role_field',
'name' => 'Edit role',
'conditions' => "is_master(self.id) || subset(fields,['slug','name','description'])",
'description' => 'Edit basic properties of any role.',
]),
'update_role_permissions' => new Permission([
'slug' => 'update_role_permissions',
'name' => 'Edit role permissions',
'conditions' => "is_master(self.id) || subset(fields,['permissions'])",
'description' => 'Edit permissions of any role.',
]),
'delete_role' => new Permission([
'slug' => 'delete_role',
'name' => 'Delete role',
'conditions' => 'always()',
'description' => 'Delete a role.',
]),
]
);
'create_role' => new Permission([
'slug' => 'create_role',
'name' => 'Create role',
'conditions' => 'always()',
'description' => 'Create a new role.',
]),
'view_role_field' => new Permission([
'slug' => 'view_role_field',
'name' => 'View role',
'conditions' => "in(property,['slug','name','description','permissions','users'])",
'description' => 'View certain properties of any role.',
]),
'update_role_field' => new Permission([
'slug' => 'update_role_field',
'name' => 'Edit role',
'conditions' => "is_master(self.id) || subset(fields,['slug','name','description'])",
'description' => 'Edit basic properties of any role.',
]),
'update_role_permissions' => new Permission([
'slug' => 'update_role_permissions',
'name' => 'Edit role permissions',
'conditions' => "is_master(self.id) || subset(fields,['permissions'])",
'description' => 'Edit permissions of any role.',
]),
'delete_role' => new Permission([
'slug' => 'delete_role',
'name' => 'Delete role',
'conditions' => 'always()',
'description' => 'Delete a role.',
]),
'permanently_delete_user' => new Permission([
'slug' => 'permanently_delete_user',
'name' => 'Permanently delete user',
'conditions' => 'always()',
'description' => 'Provides the permission to permanently delete users.',
]),
'restore_deleted_user' => new Permission([
'slug' => 'restore_deleted_user',
'name' => 'Restore deleted user',
'conditions' => "always()",
'description' => 'Provides the permission to restore deleted users.',
]),
'uri_deleted_users' => new Permission([
'slug' => 'uri_deleted_users',
'name' => 'Deleted users management page',
'conditions' => 'always()',
'description' => 'View a page containing a list of deleted users.',
]),
];
return array_merge($basePermissions, $newPermissions);
}
/**
@@ -150,6 +168,10 @@ class DefaultPermissions extends UFDefaultPermissions
$permissions['uri_permissions']->id,
$permissions['view_role_field']->id,
$permissions['permanently_delete_user']->id,
$permissions['restore_deleted_user']->id,
$permissions['uri_deleted_users']->id,
]);
}

View File

@@ -42,6 +42,7 @@ class ServicesProvider
*/
$container->extend('classMapper', function ($classMapper, $c) {
$classMapper->setClassMapping('activity_sprunje', 'UserFrosting\Sprinkle\UFTweaks\Sprunje\ActivitySprunje');
$classMapper->setClassMapping('user', 'UserFrosting\Sprinkle\UFTweaks\Database\Models\User');
return $classMapper;
});