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/phpunit.dist.xml b/phpunit.dist.xml index 817239b..d90d6fb 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -13,6 +13,10 @@ + + diff --git a/src/Command/RestoreMissingUsersCommand.php b/src/Command/RestoreMissingUsersCommand.php new file mode 100644 index 0000000..7a0e3e6 --- /dev/null +++ b/src/Command/RestoreMissingUsersCommand.php @@ -0,0 +1,139 @@ +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) { + $inserted = $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, + ], + ); + + // ON CONFLICT may have skipped an already-present row — only count real inserts. + if ($inserted > 0) { + ++$created; + $io->writeln(sprintf(' ✓ user #%d recreated (archived)', $id)); + } else { + $io->writeln(sprintf(' • user #%d already present — skipped', $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..e0a8d1b 100644 --- a/src/Module/Core/Domain/Entity/User.php +++ b/src/Module/Core/Domain/Entity/User.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Module\Core\Domain\Entity; +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; @@ -13,6 +15,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 +50,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')", @@ -63,6 +66,9 @@ use Symfony\Component\Serializer\Attribute\Groups; ], denormalizationContext: ['groups' => ['user:write']], )] +// Archived users are hidden from the default /users collection by +// ExcludeArchivedUserExtension; an admin can still list them with ?archived=true. +#[ApiFilter(BooleanFilter::class, properties: ['archived'])] #[Auditable] #[ORM\Entity(repositoryClass: DoctrineUserRepository::class)] #[ORM\Table(name: '`user`')] @@ -111,6 +117,16 @@ 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])] + #[ApiProperty(security: "is_granted('ROLE_ADMIN')")] + #[Groups(['me:read', 'user:list', 'user:write'])] + 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 +244,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..a3b27a6 --- /dev/null +++ b/src/Module/Core/Infrastructure/ApiPlatform/Extension/ExcludeArchivedUserExtension.php @@ -0,0 +1,49 @@ +security->isGranted('ROLE_ADMIN')) { + return; + } + + $alias = $queryBuilder->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 {} +} diff --git a/tests/Functional/Module/Core/UserArchiveApiTest.php b/tests/Functional/Module/Core/UserArchiveApiTest.php new file mode 100644 index 0000000..5d0de72 --- /dev/null +++ b/tests/Functional/Module/Core/UserArchiveApiTest.php @@ -0,0 +1,145 @@ +get(EntityManagerInterface::class); + $target = $this->createUser($em, 'archive-target-'.uniqid()); + $em->flush(); + $targetId = $target->getId(); + + $this->loginAdmin($client); + $client->request('DELETE', '/api/users/'.$targetId); + + self::assertResponseStatusCodeSame(204); + + $em->clear(); + $reloaded = $em->getRepository(User::class)->find($targetId); + self::assertInstanceOf(User::class, $reloaded, 'Row must still exist (soft delete)'); + self::assertTrue($reloaded->isArchived(), 'User must be flagged archived'); + self::assertNull($reloaded->getApiToken(), 'API token must be cleared on archive'); + } + + public function testAdminCannotArchiveOwnAccount(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + $this->loginAdmin($client); + $adminId = $this->userId('admin'); + + $client->request('DELETE', '/api/users/'.$adminId); + + self::assertResponseStatusCodeSame(403); + + $em->clear(); + $admin = $em->getRepository(User::class)->find($adminId); + self::assertFalse($admin->isArchived(), 'Admin must not have archived itself'); + } + + public function testArchivedUserIsHiddenFromDefaultCollection(): void + { + $client = self::createClient(); + $username = $this->createArchivedUser(); + + $this->loginAdmin($client); + $client->request('GET', '/api/users', server: ['HTTP_ACCEPT' => 'application/json']); + + self::assertResponseIsSuccessful(); + $usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username'); + self::assertNotContains($username, $usernames, 'Archived user must not appear in the default list'); + } + + public function testAdminCanListArchivedUsersViaFilter(): void + { + $client = self::createClient(); + $username = $this->createArchivedUser(); + + $this->loginAdmin($client); + $client->request('GET', '/api/users?archived=true', server: ['HTTP_ACCEPT' => 'application/json']); + + self::assertResponseIsSuccessful(); + $usernames = array_column(json_decode($client->getResponse()->getContent(), true), 'username'); + self::assertContains($username, $usernames, 'Admin must be able to list archived users via ?archived=true'); + } + + public function testAdminCanRestoreUserViaPatch(): void + { + $client = self::createClient(); + $em = self::getContainer()->get(EntityManagerInterface::class); + + $user = $this->createUser($em, 'restore-target-'.uniqid()); + $user->setArchived(true); + $em->flush(); + $userId = $user->getId(); + $em->clear(); + + $this->loginAdmin($client); + $client->request('PATCH', '/api/users/'.$userId, server: [ + 'CONTENT_TYPE' => 'application/merge-patch+json', + ], content: json_encode(['archived' => false])); + + self::assertResponseIsSuccessful(); + + $em->clear(); + $reloaded = $em->getRepository(User::class)->find($userId); + self::assertFalse($reloaded->isArchived(), 'Admin PATCH must be able to un-archive a user'); + } + + private function createArchivedUser(): string + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $username = 'archived-'.uniqid(); + $user = $this->createUser($em, $username); + $user->setArchived(true); + $em->flush(); + $em->clear(); + + return $username; + } + + private function createUser(EntityManagerInterface $em, string $username): User + { + $user = new User(); + $user->setUsername($username); + $user->setPassword('x'); + $user->setRoles(['ROLE_USER']); + $em->persist($user); + + return $user; + } + + private function loginAdmin(KernelBrowser $client): void + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => 'admin']); + self::assertInstanceOf(User::class, $user); + $client->loginUser($user); + } + + private function userId(string $username): int + { + $em = self::getContainer()->get(EntityManagerInterface::class); + $user = $em->getRepository(User::class)->findOneBy(['username' => $username]); + self::assertInstanceOf(User::class, $user); + + return $user->getId(); + } +} diff --git a/tests/Unit/Module/Core/Infrastructure/Security/ArchivedUserCheckerTest.php b/tests/Unit/Module/Core/Infrastructure/Security/ArchivedUserCheckerTest.php new file mode 100644 index 0000000..8700e1f --- /dev/null +++ b/tests/Unit/Module/Core/Infrastructure/Security/ArchivedUserCheckerTest.php @@ -0,0 +1,43 @@ +setArchived(true); + + $this->expectException(CustomUserMessageAccountStatusException::class); + + new ArchivedUserChecker()->checkPreAuth($user); + } + + public function testActiveUserPassesPreAuth(): void + { + $user = new User()->setArchived(false); + + new ArchivedUserChecker()->checkPreAuth($user); + + $this->addToAssertionCount(1); + } + + public function testNonAppUserIsIgnored(): void + { + // A user that is not our entity must not be rejected by this checker. + new ArchivedUserChecker()->checkPreAuth(new InMemoryUser('someone', null)); + + $this->addToAssertionCount(1); + } +}