mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
0a3410140d
The reassignment target is one installation-wide id used for every erasure, and the picker offers clients deliberately: erasing a client and handing their files to another client is what the setting is for. Applied to a staff account the same id means something else. A staff library is usually the whole installation's, so a client named there inherits all of it — through an unattended scheduled job, with no per-account confirmation, because this is the default rather than a choice somebody makes at the moment of deleting. So a staff account's content may only go to staff. With nobody valid to hand it to, handleContent() already cascades, which keeps the existing promise that content is never orphaned — it now also never becomes a disclosure. Not in the settings validation, which is where it looks like it belongs. That runs when the target is chosen, and whose account will be erased later is not knowable then. Both halves are only in hand here. Found while checking a list from the portal session, who had it as one where() on `type`. That would have been too broad: it would also have stopped a client's files reaching another client, which is the case the setting exists to serve. The condition is on the account being erased, not on the target alone.
139 lines
5.6 KiB
PHP
139 lines
5.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Modules\Identity\Erasure;
|
|
|
|
use App\Models\User;
|
|
use App\Modules\Audit\Action;
|
|
use App\Modules\Audit\ActivityLog;
|
|
use App\Modules\Audit\ActivityLogger;
|
|
use App\Modules\Files\DeletedAccountContent;
|
|
use App\Modules\Identity\UserType;
|
|
use App\Modules\Platform\Settings\Setting;
|
|
use App\Modules\Platform\Settings\Settings;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
/**
|
|
* Permanent, GDPR-grade account erasure: removes the account row
|
|
* entirely and anonymizes the person's identifying snapshots in the
|
|
* activity log. Non-personal facts (actions, timestamps, account type)
|
|
* are kept for security statistics.
|
|
*/
|
|
class AccountEraser
|
|
{
|
|
public function __construct(
|
|
private readonly ActivityLogger $activity,
|
|
private readonly Settings $settings,
|
|
private readonly DeletedAccountContent $content,
|
|
) {}
|
|
|
|
public function erase(User $user): void
|
|
{
|
|
DB::transaction(function () use ($user): void {
|
|
// Decide what happens to the files and folders this account
|
|
// owned *before* the row goes: an admin deleting an account is
|
|
// asked per account, but this runs unattended from cron, so it
|
|
// follows the configured default rather than leaving the content
|
|
// ownerless (the FKs' nullOnDelete would otherwise orphan it).
|
|
$this->handleContent($user);
|
|
|
|
// Anonymize snapshots: entries stay, names go. The actor_id
|
|
// FK nulls itself on forceDelete; actor_type remains so the
|
|
// log can still say "a deleted staff account".
|
|
ActivityLog::query()
|
|
->where('actor_id', $user->id)
|
|
->update(['actor_name' => null]);
|
|
|
|
ActivityLog::query()
|
|
->where('subject_type', $user->getMorphClass())
|
|
->where('subject_id', $user->id)
|
|
->update(['subject_name' => null]);
|
|
|
|
// Entries that carry the name in context, not in the FK-backed
|
|
// snapshot columns: the self-deletion (UserDeleted) and denial
|
|
// records, plus the content-handling records this very erasure
|
|
// just wrote and any left by an earlier admin deletion of the
|
|
// same person. Their :target — a still-active inheritor — is a
|
|
// different account and is deliberately left alone.
|
|
ActivityLog::query()
|
|
->whereIn('action', [
|
|
Action::UserDeleted->value,
|
|
Action::ClientDenied->value,
|
|
Action::AccountContentCascadeDeleted->value,
|
|
Action::AccountContentReassigned->value,
|
|
])
|
|
->get()
|
|
->filter(fn (ActivityLog $entry): bool => ($entry->context['name'] ?? null) === $user->name)
|
|
->each(function (ActivityLog $entry): void {
|
|
$context = $entry->context;
|
|
$context['name'] = null;
|
|
$entry->forceFill(['context' => $context])->save();
|
|
});
|
|
|
|
$type = $user->type->value;
|
|
|
|
$user->forceDelete();
|
|
|
|
$this->activity->logSystem(Action::AccountErased, ['type' => $type]);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Reassign the account's content to the configured fallback, or cascade
|
|
* it away. Reassign needs a fallback that still exists, is active, and is
|
|
* not the account being erased; when it does not (never chosen, since
|
|
* deactivated, deleted), the safe choice is to cascade rather than orphan
|
|
* — so content is never left ownerless whatever the configuration.
|
|
*/
|
|
private function handleContent(User $user): void
|
|
{
|
|
if ($this->settings->get(Setting::AccountErasureContentAction) === 'reassign') {
|
|
$fallback = $this->fallbackFor($user);
|
|
|
|
if ($fallback !== null) {
|
|
$result = $this->content->reassignTo($user, $fallback);
|
|
$this->activity->logSystem(Action::AccountContentReassigned, ['name' => $user->name, 'target' => $fallback->name, ...$result]);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
$result = $this->content->cascadeDelete($user);
|
|
$this->activity->logSystem(Action::AccountContentCascadeDeleted, ['name' => $user->name, ...$result]);
|
|
}
|
|
|
|
/**
|
|
* The account configured to inherit erased content, or null when
|
|
* there is nobody valid to hand it to — in which case handleContent()
|
|
* cascades, because orphaning is never the answer.
|
|
*
|
|
* **A staff member's content may only go to staff.** The target is one
|
|
* installation-wide id used for every erasure, and the picker offers
|
|
* clients on purpose: erasing a client and handing their files to
|
|
* another client is what the setting is for. Applied to a *staff*
|
|
* account the same id means something else entirely — a staff library
|
|
* is usually the whole installation's, and a client named there would
|
|
* inherit all of it, in one unattended scheduled job.
|
|
*
|
|
* The check cannot live in the settings validation, which is where it
|
|
* would otherwise belong: that runs when the target is chosen, and
|
|
* whose account will be erased later is not knowable then. So it is
|
|
* asked here, where both halves are in hand.
|
|
*/
|
|
private function fallbackFor(User $user): ?User
|
|
{
|
|
$id = (int) $this->settings->get(Setting::AccountErasureReassignTo);
|
|
|
|
if ($id === 0) {
|
|
return null;
|
|
}
|
|
|
|
return User::query()
|
|
->where('active', true)
|
|
->whereKeyNot($user->id)
|
|
->when($user->isStaff(), fn ($query) => $query->where('type', UserType::Staff))
|
|
->find($id);
|
|
}
|
|
}
|