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 {} +}