fix(user) : archivage au lieu de suppression + réparation des références orphelines
Un user supprimé physiquement laissait des références orphelines (task.assignee, time entries, notifications) car les FK vers "user" ont été créées NOT VALID lors du refactor modular-monolith : elles n'ont jamais nettoyé les orphelins legacy. La sérialisation API Platform d'une tâche embarquant un assignee inexistant levait une EntityNotFoundException non rattrapable (HTTP 500 sur tout PATCH/GET de ces tickets). - User::$archived (bool) + migration (soft delete) - Delete de User -> UserArchiveProcessor : archive (archived=true, apiToken vidé) au lieu de supprimer, préservant l'intégrité référentielle - ArchivedUserChecker : login bloqué pour un user archivé (firewalls login + api) - ExcludeArchivedUserExtension : archivés exclus de GET /api/users (assignation), les références existantes restent sérialisées normalement - Commande app:restore-missing-users : recrée (en archivés) les users encore référencés mais supprimés, restaurant l'intégrité sans perte de données. Idempotente, option --dry-run. À lancer une fois en prod après déploiement.
This commit is contained in:
@@ -22,6 +22,7 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||
login_throttling:
|
||||
max_attempts: 5
|
||||
interval: '1 minute'
|
||||
@@ -41,6 +42,7 @@ security:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Module\Core\Infrastructure\Security\ArchivedUserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Adds a soft-delete flag on user. Deleting a user now archives it instead of
|
||||
* removing the row, preserving referential integrity (tasks, time entries,
|
||||
* notifications…). Existing users are kept active (archived = false).
|
||||
*/
|
||||
final class Version20260626153721 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add archived flag on user (soft delete)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" ADD archived BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE "user" DROP archived');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
/**
|
||||
* Recreates user rows that are still referenced by other tables but no longer
|
||||
* exist (legacy hard-deletes performed before the foreign keys enforced
|
||||
* ON DELETE SET NULL / CASCADE). Recreated accounts are archived: their data
|
||||
* (tasks, time entries, notifications…) is preserved and references become
|
||||
* valid again, fixing the serialization crash (EntityNotFoundException), but
|
||||
* the accounts cannot log in and are hidden from selectable user lists.
|
||||
*
|
||||
* Idempotent and non-destructive — nothing is deleted.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:restore-missing-users',
|
||||
description: 'Recreate (as archived) users that are still referenced but were hard-deleted, to restore referential integrity',
|
||||
)]
|
||||
final class RestoreMissingUsersCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->addOption('dry-run', null, InputOption::VALUE_NONE, 'List missing user ids without recreating them');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$dryRun = (bool) $input->getOption('dry-run');
|
||||
|
||||
// 1. Discover every (table, column) that references "user" via a foreign key.
|
||||
$references = $this->connection->fetchAllAssociative(<<<'SQL'
|
||||
SELECT t.relname AS child_table, a.attname AS child_col
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_class rt ON rt.oid = c.confrelid
|
||||
JOIN unnest(c.conkey) WITH ORDINALITY AS k(attnum, ord) ON true
|
||||
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
|
||||
WHERE c.contype = 'f' AND rt.relname = 'user'
|
||||
ORDER BY t.relname, a.attname
|
||||
SQL);
|
||||
|
||||
// 2. Collect distinct orphan ids across all those columns.
|
||||
$missingIds = [];
|
||||
foreach ($references as $ref) {
|
||||
$table = $ref['child_table'];
|
||||
$col = $ref['child_col'];
|
||||
|
||||
$ids = $this->connection->fetchFirstColumn(sprintf(
|
||||
'SELECT DISTINCT %1$s FROM %2$s WHERE %1$s IS NOT NULL AND %1$s NOT IN (SELECT id FROM "user")',
|
||||
$this->connection->quoteIdentifier($col),
|
||||
$this->connection->quoteIdentifier($table),
|
||||
));
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$missingIds[(int) $id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$missingIds = array_keys($missingIds);
|
||||
sort($missingIds);
|
||||
|
||||
$io->section(sprintf('%d foreign-key column(s) scanned', count($references)));
|
||||
|
||||
if ([] === $missingIds) {
|
||||
$io->success('No missing users referenced. Nothing to restore.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$io->writeln(sprintf('Missing user id(s): %s', implode(', ', $missingIds)));
|
||||
|
||||
if ($dryRun) {
|
||||
$io->note('Dry run — no user recreated.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 3. Recreate each missing user as an archived placeholder, preserving its id.
|
||||
$hash = $this->passwordHasher->hashPassword(new User(), bin2hex(random_bytes(16)));
|
||||
$created = 0;
|
||||
|
||||
foreach ($missingIds as $id) {
|
||||
$this->connection->executeStatement(
|
||||
<<<'SQL'
|
||||
INSERT INTO "user"
|
||||
(id, username, first_name, last_name, roles, password, created_at,
|
||||
is_employee, work_time_ratio, annual_leave_days, reference_period_start,
|
||||
initial_leave_balance, archived)
|
||||
VALUES
|
||||
(:id, :username, :firstName, :lastName, :roles, :password, NOW(),
|
||||
false, 1.0, 25.0, '06-01', 0.0, true)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
SQL,
|
||||
[
|
||||
'id' => $id,
|
||||
'username' => sprintf('deleted-user-%d', $id),
|
||||
'firstName' => 'Compte',
|
||||
'lastName' => sprintf('supprimé #%d', $id),
|
||||
'roles' => json_encode(['ROLE_USER'], JSON_THROW_ON_ERROR),
|
||||
'password' => $hash,
|
||||
],
|
||||
);
|
||||
++$created;
|
||||
$io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id));
|
||||
}
|
||||
|
||||
$io->success(sprintf('%d user(s) restored as archived. References are valid again — no data lost.', $created));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Module\Core\Domain\Enum\ContractType;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\MeProvider;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserArchiveProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
|
||||
use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
@@ -47,7 +48,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
),
|
||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: UserPasswordHasherProcessor::class),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN')", processor: UserArchiveProcessor::class),
|
||||
new Get(
|
||||
uriTemplate: '/users/{id}/rbac',
|
||||
security: "is_granted('core.users.manage')",
|
||||
@@ -111,6 +112,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
private ?string $avatarFileName = null;
|
||||
|
||||
/**
|
||||
* Soft-delete flag. Archived users are kept for referential integrity
|
||||
* (tasks, time entries, notifications…) but cannot log in and are hidden
|
||||
* from selectable user lists.
|
||||
*/
|
||||
#[ORM\Column(options: ['default' => false])]
|
||||
#[Groups(['me:read', 'user:list'])]
|
||||
private bool $archived = false;
|
||||
|
||||
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
|
||||
|
||||
/** Whether this user is an employee subject to absence management. */
|
||||
@@ -228,6 +238,18 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
|
||||
return (string) $this->username;
|
||||
}
|
||||
|
||||
public function isArchived(): bool
|
||||
{
|
||||
return $this->archived;
|
||||
}
|
||||
|
||||
public function setArchived(bool $archived): static
|
||||
{
|
||||
$this->archived = $archived;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getRoles(): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\Extension;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
|
||||
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
/**
|
||||
* Hides archived (soft-deleted) users from the `/users` collection so they are
|
||||
* no longer offered as assignees/collaborators, while existing references to
|
||||
* them (already stored on tasks, time entries…) keep resolving normally.
|
||||
*/
|
||||
final class ExcludeArchivedUserExtension implements QueryCollectionExtensionInterface
|
||||
{
|
||||
public function applyToCollection(
|
||||
QueryBuilder $queryBuilder,
|
||||
QueryNameGeneratorInterface $queryNameGenerator,
|
||||
string $resourceClass,
|
||||
?Operation $operation = null,
|
||||
array $context = [],
|
||||
): void {
|
||||
if (User::class !== $resourceClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
$alias = $queryBuilder->getRootAliases()[0];
|
||||
$queryBuilder->andWhere(sprintf('%s.archived = false', $alias));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Processor;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
|
||||
use function assert;
|
||||
|
||||
/**
|
||||
* Soft-delete processor wired on the User `Delete` operation: instead of
|
||||
* removing the row (which would orphan every task / time entry / notification
|
||||
* referencing it and break their serialization), the user is archived. The
|
||||
* account is kept for referential integrity but can no longer log in
|
||||
* (ArchivedUserChecker) and is hidden from selectable user lists
|
||||
* (ExcludeArchivedUserExtension).
|
||||
*
|
||||
* @implements ProcessorInterface<User, null|User>
|
||||
*/
|
||||
final readonly class UserArchiveProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $em,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ?User
|
||||
{
|
||||
assert($data instanceof User);
|
||||
|
||||
// Prevent an admin from archiving (locking out) their own account.
|
||||
$current = $this->security->getUser();
|
||||
if ($current instanceof User && $current->getId() === $data->getId()) {
|
||||
throw new AccessDeniedHttpException('You cannot archive your own account.');
|
||||
}
|
||||
|
||||
if ($data->isArchived()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data->setArchived(true);
|
||||
$data->setApiToken(null);
|
||||
$this->em->flush();
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\Core\Infrastructure\Security;
|
||||
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
/**
|
||||
* Rejects authentication for archived (soft-deleted) users, both at password
|
||||
* login and on every JWT-authenticated request, so an archived account is
|
||||
* effectively locked out while its data is preserved.
|
||||
*/
|
||||
final class ArchivedUserChecker implements UserCheckerInterface
|
||||
{
|
||||
public function checkPreAuth(UserInterface $user, ?TokenInterface $token = null): void
|
||||
{
|
||||
if ($user instanceof User && $user->isArchived()) {
|
||||
throw new CustomUserMessageAccountStatusException('This account has been archived.');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
|
||||
}
|
||||
Reference in New Issue
Block a user