From d8d755d4c560727919cb2ae4140f5d9fc796a7f3 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 26 Jun 2026 15:51:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(user)=20:=20archivage=20au=20lieu=20de=20su?= =?UTF-8?q?ppression=20+=20r=C3=A9paration=20des=20r=C3=A9f=C3=A9rences=20?= =?UTF-8?q?orphelines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- config/packages/security.yaml | 2 + migrations/Version20260626153721.php | 31 ++++ src/Command/RestoreMissingUsersCommand.php | 133 ++++++++++++++++++ src/Module/Core/Domain/Entity/User.php | 24 +++- .../ExcludeArchivedUserExtension.php | 34 +++++ .../State/Processor/UserArchiveProcessor.php | 53 +++++++ .../Security/ArchivedUserChecker.php | 28 ++++ 7 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 migrations/Version20260626153721.php create mode 100644 src/Command/RestoreMissingUsersCommand.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/Extension/ExcludeArchivedUserExtension.php create mode 100644 src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserArchiveProcessor.php create mode 100644 src/Module/Core/Infrastructure/Security/ArchivedUserChecker.php diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 24cb6de..fdfe255 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -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 diff --git a/migrations/Version20260626153721.php b/migrations/Version20260626153721.php new file mode 100644 index 0000000..9cbc873 --- /dev/null +++ b/migrations/Version20260626153721.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE "user" ADD archived BOOLEAN DEFAULT false NOT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP archived'); + } +} diff --git a/src/Command/RestoreMissingUsersCommand.php b/src/Command/RestoreMissingUsersCommand.php new file mode 100644 index 0000000..9688392 --- /dev/null +++ b/src/Command/RestoreMissingUsersCommand.php @@ -0,0 +1,133 @@ +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; + } +} diff --git a/src/Module/Core/Domain/Entity/User.php b/src/Module/Core/Domain/Entity/User.php index 45b940e..2b9ccca 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -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 */ public function getRoles(): array { diff --git a/src/Module/Core/Infrastructure/ApiPlatform/Extension/ExcludeArchivedUserExtension.php b/src/Module/Core/Infrastructure/ApiPlatform/Extension/ExcludeArchivedUserExtension.php new file mode 100644 index 0000000..85d063a --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Extension/ExcludeArchivedUserExtension.php @@ -0,0 +1,34 @@ +getRootAliases()[0]; + $queryBuilder->andWhere(sprintf('%s.archived = false', $alias)); + } +} diff --git a/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserArchiveProcessor.php b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserArchiveProcessor.php new file mode 100644 index 0000000..2bc7257 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/State/Processor/UserArchiveProcessor.php @@ -0,0 +1,53 @@ + + */ +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; + } +} diff --git a/src/Module/Core/Infrastructure/Security/ArchivedUserChecker.php b/src/Module/Core/Infrastructure/Security/ArchivedUserChecker.php new file mode 100644 index 0000000..7d31a22 --- /dev/null +++ b/src/Module/Core/Infrastructure/Security/ArchivedUserChecker.php @@ -0,0 +1,28 @@ +isArchived()) { + throw new CustomUserMessageAccountStatusException('This account has been archived.'); + } + } + + public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {} +}