feat(client-portal) : phase 1 foundations — ROLE_CLIENT hardening + ClientTicket (back)

LST-69 (3.2) phase 1. New ClientPortal module + security foundations for the
client portal (spec docs/superpowers/specs/2026-03-15-client-portal-design.md).

- Security: User::getRoles() no longer adds ROLE_USER to ROLE_CLIENT users;
  role_hierarchy ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT]. Existing Task/Project/
  Client/TimeEntry/metadata endpoints already required ROLE_USER -> a pure
  ROLE_CLIENT is walled off (verified: 403).
- User (Core): client (ManyToOne ClientInterface, SET NULL) + allowedProjects
  (ManyToMany ProjectInterface). UserInterface extended (getClient/
  getAllowedProjects).
- New ClientTicket entity (module ClientPortal) + enums + repository + API with
  per-client isolation (ClientTicketProvider: own tickets ∩ allowedProjects),
  per-project numbering under advisory lock (rejects if user.client null),
  status transition rules. ClientTicketInterface contract for Task/TaskDocument.
- TaskDocument generalized: task nullable + clientTicket (CASCADE) + CHECK;
  per-role access. Task.clientTicket exposed in task:read.
- Additive migration; demo client fixtures.
- Tenancy tests assert the isolation invariant (a client never sees another
  client's tickets) rather than brittle absolute counts (shared test DB).

178 tests green, mapping valid, cs-fixer clean.
This commit is contained in:
Matthieu
2026-06-21 00:46:26 +02:00
parent f4ffc02028
commit 808a290845
24 changed files with 1337 additions and 33 deletions
+50
View File
@@ -10,6 +10,9 @@ use App\Module\Absence\Domain\Entity\AbsencePolicy;
use App\Module\Absence\Domain\Entity\AbsenceRequest;
use App\Module\Absence\Domain\Enum\AbsenceStatus;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
use App\Module\Core\Application\Rbac\RbacSeeder;
use App\Module\Core\Domain\Entity\User;
use App\Module\Directory\Domain\Entity\Client;
@@ -215,6 +218,53 @@ class AppFixtures extends Fixture
$projectInterne->setWorkflow($standardWorkflow);
$manager->persist($projectInterne);
// Client portal users (ROLE_CLIENT) — linked to a client + allowed projects.
$clientUserLiot = new User();
$clientUserLiot->setUsername('client-liot');
$clientUserLiot->setFirstName('Camille');
$clientUserLiot->setLastName('LIOT');
$clientUserLiot->setRoles(['ROLE_CLIENT']);
$clientUserLiot->setPassword($this->passwordHasher->hashPassword($clientUserLiot, 'client-liot'));
$clientUserLiot->setClient($clientLiot);
$clientUserLiot->addAllowedProject($projectSirh);
$manager->persist($clientUserLiot);
$clientUserAcme = new User();
$clientUserAcme->setUsername('client-acme');
$clientUserAcme->setFirstName('Sophie');
$clientUserAcme->setLastName('ACME');
$clientUserAcme->setRoles(['ROLE_CLIENT']);
$clientUserAcme->setPassword($this->passwordHasher->hashPassword($clientUserAcme, 'client-acme'));
$clientUserAcme->setClient($clientAcme);
$clientUserAcme->addAllowedProject($projectCrm);
$manager->persist($clientUserAcme);
// Demo client tickets.
$ticketLiot = new ClientTicket();
$ticketLiot->setNumber(1);
$ticketLiot->setType(ClientTicketType::Bug);
$ticketLiot->setTitle('Erreur lors de l\'export des congés');
$ticketLiot->setDescription('L\'export PDF des congés échoue avec une erreur 500.');
$ticketLiot->setUrl('https://app.example.com/sirh/conges');
$ticketLiot->setStatus(ClientTicketStatus::New);
$ticketLiot->setProject($projectSirh);
$ticketLiot->setSubmittedBy($clientUserLiot);
$ticketLiot->setCreatedAt(new DateTimeImmutable());
$ticketLiot->setUpdatedAt(new DateTimeImmutable());
$manager->persist($ticketLiot);
$ticketAcme = new ClientTicket();
$ticketAcme->setNumber(1);
$ticketAcme->setType(ClientTicketType::Improvement);
$ticketAcme->setTitle('Ajouter un filtre par commercial');
$ticketAcme->setDescription('Pouvoir filtrer la liste des opportunités par commercial assigné.');
$ticketAcme->setStatus(ClientTicketStatus::InProgress);
$ticketAcme->setProject($projectCrm);
$ticketAcme->setSubmittedBy($clientUserAcme);
$ticketAcme->setCreatedAt(new DateTimeImmutable());
$ticketAcme->setUpdatedAt(new DateTimeImmutable());
$manager->persist($ticketAcme);
// Task Efforts
$effortS = new TaskEffort();
$effortS->setLabel('S');
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal;
use App\Shared\Domain\Module\ModuleInterface;
final class ClientPortalModule implements ModuleInterface
{
public static function id(): string
{
return 'client-portal';
}
public static function label(): string
{
return 'Portail client';
}
public static function isRequired(): bool
{
return false;
}
/**
* Permissions RBAC fin du Module ClientPortal.
*
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
* reste pilotée par ROLE_CLIENT/ROLE_ADMIN sur les opérations.
*
* @return list<array{code: string, label: string}>
*/
public static function permissions(): array
{
return [
['code' => 'client-portal.tickets.view', 'label' => 'Voir les tickets client'],
['code' => 'client-portal.tickets.manage', 'label' => 'Gérer les tickets client'],
];
}
}
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Entity;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Enum\ClientTicketType;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketNumberProcessor;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketProvider;
use App\Module\ClientPortal\Infrastructure\ApiPlatform\State\ClientTicketStatusProcessor;
use App\Module\ClientPortal\Infrastructure\Doctrine\DoctrineClientTicketRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
#[ApiResource(
operations: [
new GetCollection(
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Get(
security: "is_granted('ROLE_CLIENT') or is_granted('ROLE_ADMIN')",
provider: ClientTicketProvider::class,
),
new Post(
security: "is_granted('ROLE_CLIENT')",
processor: ClientTicketNumberProcessor::class,
),
new Patch(
security: "is_granted('ROLE_ADMIN')",
processor: ClientTicketStatusProcessor::class,
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
),
],
normalizationContext: ['groups' => ['client_ticket:read']],
denormalizationContext: ['groups' => ['client_ticket:write']],
order: ['createdAt' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['project' => 'exact', 'status' => 'exact', 'submittedBy' => 'exact'])]
#[Auditable]
#[ORM\Entity(repositoryClass: DoctrineClientTicketRepository::class)]
#[ORM\Table(name: 'client_ticket')]
#[ORM\UniqueConstraint(name: 'uniq_client_ticket_project_number', columns: ['project_id', 'number'])]
#[ORM\Index(name: 'idx_client_ticket_project', columns: ['project_id'])]
#[ORM\Index(name: 'idx_client_ticket_submitted_by', columns: ['submitted_by_id'])]
#[ORM\Index(name: 'idx_client_ticket_status_project', columns: ['status', 'project_id'])]
class ClientTicket implements ClientTicketInterface, TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $id = null;
/** Incremental number, unique per project (formatted CT-XXX in the UI). */
#[ORM\Column(type: 'integer')]
#[Groups(['client_ticket:read', 'task:read'])]
private ?int $number = null;
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketType::class)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\NotNull]
private ?ClientTicketType $type = null;
#[ORM\Column(length: 255)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
#[Assert\NotBlank]
private ?string $title = null;
#[ORM\Column(type: 'text')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\NotBlank]
private ?string $description = null;
/** Displayed only when type = bug (page concerned by the bug). */
#[ORM\Column(length: 1024, nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $url = null;
#[ORM\Column(type: 'string', length: 16, enumType: ClientTicketStatus::class)]
#[Groups(['client_ticket:read', 'client_ticket:write', 'task:read'])]
private ClientTicketStatus $status = ClientTicketStatus::New;
/** Manager comment set when the status changes (mandatory when rejecting). */
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
private ?string $statusComment = null;
#[ORM\ManyToOne(targetEntity: ProjectInterface::class)]
#[ORM\JoinColumn(name: 'project_id', nullable: false, onDelete: 'CASCADE')]
#[Groups(['client_ticket:read', 'client_ticket:write'])]
#[Assert\NotNull]
private ?ProjectInterface $project = null;
/** Client user who submitted the ticket. ON DELETE SET NULL — keep history. */
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
#[ORM\JoinColumn(name: 'submitted_by_id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['client_ticket:read'])]
private ?UserInterface $submittedBy = null;
public function getId(): ?int
{
return $this->id;
}
public function getNumber(): ?int
{
return $this->number;
}
public function setNumber(int $number): static
{
$this->number = $number;
return $this;
}
public function getTypeEnum(): ?ClientTicketType
{
return $this->type;
}
public function setType(?ClientTicketType $type): static
{
$this->type = $type;
return $this;
}
public function getType(): string
{
return $this->type?->value ?? '';
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(?string $title): static
{
$this->title = $title;
return $this;
}
public function getDescription(): ?string
{
return $this->description;
}
public function setDescription(?string $description): static
{
$this->description = $description;
return $this;
}
public function getUrl(): ?string
{
return $this->url;
}
public function setUrl(?string $url): static
{
$this->url = $url;
return $this;
}
public function getStatusEnum(): ClientTicketStatus
{
return $this->status;
}
public function setStatus(ClientTicketStatus $status): static
{
$this->status = $status;
return $this;
}
public function getStatus(): string
{
return $this->status->value;
}
public function getStatusComment(): ?string
{
return $this->statusComment;
}
public function setStatusComment(?string $statusComment): static
{
$this->statusComment = $statusComment;
return $this;
}
public function getProject(): ?ProjectInterface
{
return $this->project;
}
public function setProject(?ProjectInterface $project): static
{
$this->project = $project;
return $this;
}
public function getSubmittedBy(): ?UserInterface
{
return $this->submittedBy;
}
public function setSubmittedBy(?UserInterface $submittedBy): static
{
$this->submittedBy = $submittedBy;
return $this;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Enum;
enum ClientTicketStatus: string
{
case New = 'new';
case InProgress = 'in_progress';
case Done = 'done';
case Rejected = 'rejected';
public function label(): string
{
return match ($this) {
self::New => 'Nouveau',
self::InProgress => 'En cours',
self::Done => 'Terminé',
self::Rejected => 'Rejeté',
};
}
/**
* Whether a transition from this status to $target is allowed.
*
* All transitions are allowed except `done` -> `new` and `rejected` -> `new`.
*/
public function canTransitionTo(self $target): bool
{
if (self::New === $target && (self::Done === $this || self::Rejected === $this)) {
return false;
}
return true;
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Enum;
enum ClientTicketType: string
{
case Bug = 'bug';
case Improvement = 'improvement';
case Other = 'other';
public function label(): string
{
return match ($this) {
self::Bug => 'Bug',
self::Improvement => 'Amélioration',
self::Other => 'Autre',
};
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Domain\Repository;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
interface ClientTicketRepositoryInterface
{
public function findById(int $id): ?ClientTicket;
/**
* Highest ticket number currently used on a given project, behind a
* PostgreSQL advisory transaction lock so concurrent inserts serialize.
* Returns 0 if the project has no ticket yet. Must run inside a transaction.
*/
public function findMaxNumberByProjectForUpdate(int $projectId): int;
}
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Handles creation of a client ticket (POST).
*
* - Rejects users whose `client` is null (an admin inheriting ROLE_CLIENT via
* the role hierarchy cannot create a ticket).
* - Enforces that the target project belongs to the user's allowed projects.
* - Sets submittedBy, status = new, timestamps.
* - Generates the per-project incremental number behind an advisory lock so the
* unique constraint `(project_id, number)` is never violated by concurrency.
*
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketNumberProcessor implements ProcessorInterface
{
/**
* @param ProcessorInterface<ClientTicket, ClientTicket> $persistProcessor
*/
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
private ProcessorInterface $persistProcessor,
private ClientTicketRepositoryInterface $repository,
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$user = $this->security->getUser();
assert($user instanceof UserInterface);
// An admin must not be able to create a ticket even though ROLE_ADMIN
// inherits ROLE_CLIENT in the role hierarchy.
if (null === $user->getClient()) {
throw new AccessDeniedHttpException('Only client users can submit tickets.');
}
$project = $data->getProject();
if (!$project instanceof ProjectInterface) {
throw new UnprocessableEntityHttpException('A project is required.');
}
if (!$this->userMayAccessProject($user, $project)) {
throw new AccessDeniedHttpException('You are not allowed to submit tickets on this project.');
}
$data->setSubmittedBy($user);
$data->setStatus(ClientTicketStatus::New);
$data->setStatusComment(null);
$now = new DateTimeImmutable();
$data->setCreatedAt($now);
$data->setUpdatedAt($now);
return $this->entityManager->wrapInTransaction(function () use ($data, $project, $operation, $uriVariables, $context): ClientTicket {
$maxNumber = $this->repository->findMaxNumberByProjectForUpdate((int) $project->getId());
$data->setNumber($maxNumber + 1);
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
assert($result instanceof ClientTicket);
return $result;
});
}
private function userMayAccessProject(UserInterface $user, ProjectInterface $project): bool
{
foreach ($user->getAllowedProjects() as $allowed) {
if ($allowed->getId() === $project->getId()) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Provider for ClientTicket read operations.
*
* - ROLE_ADMIN: no restriction.
* - ROLE_CLIENT: only tickets the user submitted, and only on projects the
* user is allowed to access (allowedProjects).
*
* @implements ProviderInterface<ClientTicket>
*/
final readonly class ClientTicketProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private Security $security,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|ClientTicket|null
{
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$repo = $this->entityManager->getRepository(ClientTicket::class);
// Single item.
if (isset($uriVariables['id'])) {
$ticket = $repo->find($uriVariables['id']);
if (null === $ticket) {
return null;
}
if ($isAdmin) {
return $ticket;
}
if ($ticket->getSubmittedBy() !== $user) {
return null;
}
if (!$this->userMayAccessProject($user, $ticket->getProject())) {
return null;
}
return $ticket;
}
// Collection.
$qb = $repo->createQueryBuilder('t')
->orderBy('t.createdAt', 'DESC')
;
if (!$isAdmin) {
$qb->andWhere('t.submittedBy = :user')->setParameter('user', $user);
$allowedIds = $this->allowedProjectIds($user);
if ([] === $allowedIds) {
return [];
}
$qb->andWhere('IDENTITY(t.project) IN (:allowedProjects)')
->setParameter('allowedProjects', $allowedIds)
;
}
$filters = $context['filters'] ?? [];
if (isset($filters['project'])) {
$qb->andWhere('IDENTITY(t.project) = :project')
->setParameter('project', self::extractId($filters['project']))
;
}
if (isset($filters['status'])) {
$qb->andWhere('t.status = :status')->setParameter('status', $filters['status']);
}
if ($isAdmin && isset($filters['submittedBy'])) {
$qb->andWhere('t.submittedBy = :submittedBy')
->setParameter('submittedBy', self::extractId($filters['submittedBy']))
;
}
return $qb->getQuery()->getResult();
}
private function userMayAccessProject(UserInterface $user, ?ProjectInterface $project): bool
{
if (null === $project) {
return false;
}
return in_array($project->getId(), $this->allowedProjectIds($user), true);
}
/**
* @return list<int>
*/
private function allowedProjectIds(UserInterface $user): array
{
$ids = [];
foreach ($user->getAllowedProjects() as $project) {
$id = $project->getId();
if (null !== $id) {
$ids[] = $id;
}
}
return $ids;
}
private static function extractId(string $value): int
{
return is_numeric($value) ? (int) $value : (int) basename($value);
}
}
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\ApiPlatform\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Enum\ClientTicketStatus;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
/**
* Handles status changes on a client ticket (PATCH, ROLE_ADMIN only).
*
* - Rejects the forbidden transitions `done` -> `new` and `rejected` -> `new`.
* - Requires a statusComment when moving to the `rejected` status.
* - Refreshes updatedAt.
*
* @implements ProcessorInterface<ClientTicket, ClientTicket>
*/
final readonly class ClientTicketStatusProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ClientTicket
{
assert($data instanceof ClientTicket);
$newStatus = $data->getStatusEnum();
$previous = $context['previous_data'] ?? null;
$oldStatus = $previous instanceof ClientTicket ? $previous->getStatusEnum() : null;
if (null !== $oldStatus && !$oldStatus->canTransitionTo($newStatus)) {
throw new UnprocessableEntityHttpException(sprintf(
'Transition from "%s" to "%s" is not allowed.',
$oldStatus->value,
$newStatus->value,
));
}
if (ClientTicketStatus::Rejected === $newStatus && '' === trim((string) $data->getStatusComment())) {
throw new UnprocessableEntityHttpException('A status comment is required to reject a ticket.');
}
$data->setUpdatedAt(new DateTimeImmutable());
$this->entityManager->persist($data);
$this->entityManager->flush();
return $data;
}
}
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Module\ClientPortal\Infrastructure\Doctrine;
use App\Module\ClientPortal\Domain\Entity\ClientTicket;
use App\Module\ClientPortal\Domain\Repository\ClientTicketRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ClientTicket>
*/
final class DoctrineClientTicketRepository extends ServiceEntityRepository implements ClientTicketRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ClientTicket::class);
}
public function findById(int $id): ?ClientTicket
{
return $this->find($id);
}
public function findMaxNumberByProjectForUpdate(int $projectId): int
{
$conn = $this->getEntityManager()->getConnection();
// Use a PostgreSQL advisory lock (project ID as lock key) instead of
// FOR UPDATE because FOR UPDATE is not allowed with aggregate functions
// in PostgreSQL. The lock is held until the surrounding transaction ends.
$conn->executeStatement(
'SELECT pg_advisory_xact_lock(:project)',
['project' => $projectId],
);
$result = $conn->fetchOne(
'SELECT COALESCE(MAX(number), 0) FROM client_ticket WHERE project_id = :project',
['project' => $projectId],
);
return (int) $result;
}
}
+68 -2
View File
@@ -18,7 +18,9 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\UserPasswordHasherProcessor
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Attribute\AuditIgnore;
use App\Shared\Domain\Contract\ClientInterface;
use App\Shared\Domain\Contract\LeaveProfileInterface;
use App\Shared\Domain\Contract\ProjectInterface;
use App\Shared\Domain\Contract\UserInterface as SharedUserInterface;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
@@ -173,11 +175,34 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
#[Groups(['user:rbac:read', 'user:rbac:write'])]
private Collection $directPermissions;
// --- Client portal fields ---
/** Client this user belongs to. null = internal user, set = client user. */
#[ORM\ManyToOne(targetEntity: ClientInterface::class)]
#[ORM\JoinColumn(name: 'client_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:read', 'user:write'])]
private ?ClientInterface $client = null;
/**
* Projects a client user is allowed to access (a subset of the client's projects).
*
* @var Collection<int, ProjectInterface>
*/
#[ORM\ManyToMany(targetEntity: ProjectInterface::class)]
#[ORM\JoinTable(
name: 'user_allowed_projects',
joinColumns: [new ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
inverseJoinColumns: [new ORM\JoinColumn(name: 'project_id', referencedColumnName: 'id', onDelete: 'CASCADE')],
)]
#[Groups(['me:read', 'user:read', 'user:write'])]
private Collection $allowedProjects;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->allowedProjects = new ArrayCollection();
}
public function getId(): ?int
@@ -229,8 +254,13 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
/** @return list<string> */
public function getRoles(): array
{
$roles = $this->roles;
$roles[] = 'ROLE_USER';
$roles = $this->roles;
// A client user must NOT inherit ROLE_USER (which would grant access to
// the internal application). Only non-client users get ROLE_USER.
if (!in_array('ROLE_CLIENT', $roles, true)) {
$roles[] = 'ROLE_USER';
}
return array_values(array_unique($roles));
}
@@ -454,6 +484,42 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface, SharedU
$this->directPermissions->removeElement($permission);
}
public function getClient(): ?ClientInterface
{
return $this->client;
}
public function setClient(?ClientInterface $client): static
{
$this->client = $client;
return $this;
}
/**
* @return Collection<int, ProjectInterface>
*/
public function getAllowedProjects(): Collection
{
return $this->allowedProjects;
}
public function addAllowedProject(ProjectInterface $project): static
{
if (!$this->allowedProjects->contains($project)) {
$this->allowedProjects->add($project);
}
return $this;
}
public function removeAllowedProject(ProjectInterface $project): static
{
$this->allowedProjects->removeElement($project);
return $this;
}
/**
* Permissions effectives = union (rôles RBAC → permissions) (permissions directes), triée, dédupliquée.
*
@@ -19,6 +19,7 @@ use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskCalendarPr
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskNumberProcessor;
use App\Module\ProjectManagement\Infrastructure\Doctrine\DoctrineTaskRepository;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\TaskInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Contract\UserInterface;
@@ -162,6 +163,16 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
#[Groups(['task:read', 'task:write'])]
private ?TaskRecurrence $recurrence = null;
/**
* Optional manual link to a client ticket. Exposed (number/type/status/title)
* in task:read so the kanban can show the linked-ticket icon without giving
* ROLE_USER access to the /api/client_tickets collection.
*/
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['task:read', 'task:write'])]
private ?ClientTicketInterface $clientTicket = null;
public function __construct()
{
$this->tags = new ArrayCollection();
@@ -440,6 +451,18 @@ class Task implements TaskInterface, TimestampableInterface, BlamableInterface
return $this;
}
public function getClientTicket(): ?ClientTicketInterface
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicketInterface $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
#[Assert\Callback]
public function validateScheduledDates(ExecutionContextInterface $context): void
{
@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Post;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProcessor;
use App\Module\ProjectManagement\Infrastructure\ApiPlatform\State\TaskDocumentProvider;
use App\Module\ProjectManagement\Infrastructure\EventListener\TaskDocumentListener;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
@@ -21,10 +22,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER')", provider: TaskDocumentProvider::class),
new GetCollection(paginationEnabled: false, security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Get(security: "is_granted('ROLE_USER') or is_granted('ROLE_CLIENT')", provider: TaskDocumentProvider::class),
new Post(
security: "is_granted('ROLE_ADMIN')",
security: "is_granted('ROLE_ADMIN') or is_granted('ROLE_CLIENT')",
processor: TaskDocumentProcessor::class,
deserialize: false,
),
@@ -34,9 +35,11 @@ use Symfony\Component\Serializer\Attribute\Groups;
denormalizationContext: ['groups' => ['task_document:write']],
order: ['id' => 'DESC'],
)]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact'])]
#[ApiFilter(SearchFilter::class, properties: ['task' => 'exact', 'clientTicket' => 'exact'])]
#[ORM\Entity]
#[ORM\EntityListeners([TaskDocumentListener::class])]
// A document must be attached to either a task or a client ticket.
#[ORM\Table(name: 'task_document')]
class TaskDocument
{
#[ORM\Id]
@@ -46,10 +49,16 @@ class TaskDocument
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Task::class, inversedBy: 'documents')]
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
#[ORM\JoinColumn(name: 'task_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write'])]
private ?Task $task = null;
/** Client ticket this document is attached to (alternative to task). */
#[ORM\ManyToOne(targetEntity: ClientTicketInterface::class)]
#[ORM\JoinColumn(name: 'client_ticket_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['task_document:read', 'task_document:write', 'client_ticket:read'])]
private ?ClientTicketInterface $clientTicket = null;
#[ORM\Column(length: 255)]
#[Groups(['task_document:read', 'task:read'])]
private ?string $originalName = null;
@@ -100,6 +109,18 @@ class TaskDocument
return $this;
}
public function getClientTicket(): ?ClientTicketInterface
{
return $this->clientTicket;
}
public function setClientTicket(?ClientTicketInterface $clientTicket): static
{
$this->clientTicket = $clientTicket;
return $this;
}
public function getOriginalName(): ?string
{
return $this->originalName;
@@ -14,6 +14,8 @@ use App\Module\Integration\Domain\Service\FileSource;
use App\Module\Integration\Domain\Service\SharePathResolver;
use App\Module\ProjectManagement\Domain\Entity\Task;
use App\Module\ProjectManagement\Domain\Entity\TaskDocument;
use App\Shared\Domain\Contract\ClientTicketInterface;
use App\Shared\Domain\Contract\UserInterface;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
@@ -75,11 +77,12 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
*/
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): TaskDocument
{
// Défense en profondeur : l'opération Post est déjà protégée par ROLE_ADMIN, mais on
// re-vérifie ici pour que les deux chemins (upload ET lien partage) restent sûrs si la
// configuration de sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedHttpException('Creating task documents requires admin privileges.');
// Défense en profondeur : l'opération Post est déjà protégée par
// ROLE_ADMIN ou ROLE_CLIENT, mais on re-vérifie ici pour que les deux
// chemins (upload ET lien partage) restent sûrs si la configuration de
// sécurité de l'opération venait à changer.
if (!$this->security->isGranted('ROLE_ADMIN') && !$this->security->isGranted('ROLE_CLIENT')) {
throw new AccessDeniedHttpException('Creating documents requires admin or client privileges.');
}
$request = $this->requestStack->getCurrentRequest();
@@ -136,8 +139,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
throw new BadRequestHttpException('File size exceeds 50 MB limit.');
}
$task = $this->resolveTask($request->request->get('task', ''));
// Use server-detected MIME type (finfo), not the client-supplied one
$originalName = $file->getClientOriginalName();
$mimeType = $file->getMimeType() ?: 'application/octet-stream';
@@ -157,7 +158,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
$file->move($this->uploadDir, $fileName);
$document = new TaskDocument();
$document->setTask($task);
$this->attachTarget($document, $request);
$document->setOriginalName($originalName);
$document->setFileName($fileName);
$document->setMimeType($mimeType);
@@ -168,15 +169,6 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
private function createShareLink(Request $request, string $rawSharePath): TaskDocument
{
$taskIri = $request->request->get('task');
if (!is_string($taskIri) || '' === $taskIri) {
$payload = json_decode($request->getContent() ?: '{}', true);
$taskIri = is_array($payload) ? ($payload['task'] ?? '') : '';
}
$task = $this->resolveTask((string) $taskIri);
try {
$path = $this->pathResolver->normalizeRelative($rawSharePath);
} catch (InvalidPathException) {
@@ -198,7 +190,7 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
}
$document = new TaskDocument();
$document->setTask($task);
$this->attachTarget($document, $request);
$document->setOriginalName($entry->name);
$document->setSharePath($path);
$document->setMimeType($entry->mimeType);
@@ -233,12 +225,61 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
return null;
}
private function resolveTask(string $taskIri): Task
/**
* Attaches the document to a task OR a client ticket, enforcing per-role
* access. Exactly one of the two targets must be provided.
*
* - ROLE_ADMIN may attach to any task or any client ticket.
* - ROLE_CLIENT may only attach to a client ticket they submitted, and may
* never attach to a task.
*/
private function attachTarget(TaskDocument $document, Request $request): void
{
if ('' === $taskIri) {
throw new BadRequestHttpException('A task IRI is required.');
$taskIri = $this->readField($request, 'task');
$clientTicketIri = $this->readField($request, 'clientTicket');
if ('' === $taskIri && '' === $clientTicketIri) {
throw new BadRequestHttpException('A task or a clientTicket IRI is required.');
}
if ('' !== $taskIri && '' !== $clientTicketIri) {
throw new BadRequestHttpException('Provide either a task or a clientTicket, not both.');
}
$isClient = $this->security->isGranted('ROLE_CLIENT') && !$this->security->isGranted('ROLE_ADMIN');
if ('' !== $clientTicketIri) {
$document->setClientTicket($this->resolveClientTicket($clientTicketIri, $isClient));
return;
}
if ($isClient) {
throw new AccessDeniedHttpException('Client users can only attach documents to a client ticket.');
}
$document->setTask($this->resolveTask($taskIri));
}
private function readField(Request $request, string $field): string
{
$value = $request->request->get($field);
if (is_string($value) && '' !== $value) {
return $value;
}
if (str_contains((string) $request->headers->get('Content-Type'), 'application/json')) {
$payload = json_decode($request->getContent() ?: '{}', true);
if (is_array($payload) && isset($payload[$field]) && is_string($payload[$field])) {
return $payload[$field];
}
}
return '';
}
private function resolveTask(string $taskIri): Task
{
$task = $this->entityManager->getRepository(Task::class)->find((int) basename($taskIri));
if (null === $task) {
@@ -247,4 +288,24 @@ final readonly class TaskDocumentProcessor implements ProcessorInterface
return $task;
}
private function resolveClientTicket(string $ticketIri, bool $isClient): ClientTicketInterface
{
$ticket = $this->entityManager->getRepository(ClientTicketInterface::class)->find((int) basename($ticketIri));
if (null === $ticket) {
throw new BadRequestHttpException('Client ticket not found.');
}
if ($isClient) {
$user = $this->security->getUser();
assert($user instanceof UserInterface);
if ($ticket->getSubmittedBy() !== $user) {
throw new AccessDeniedHttpException('You can only attach documents to your own tickets.');
}
}
return $ticket;
}
}
@@ -12,6 +12,12 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Provider for TaskDocument read operations.
*
* - ROLE_ADMIN: every document.
* - ROLE_USER: documents attached to a task (task IS NOT NULL).
* - ROLE_CLIENT: documents attached to a client ticket the user submitted.
*
* @implements ProviderInterface<TaskDocument>
*/
final readonly class TaskDocumentProvider implements ProviderInterface
@@ -26,25 +32,56 @@ final readonly class TaskDocumentProvider implements ProviderInterface
$user = $this->security->getUser();
assert($user instanceof UserInterface);
$isAdmin = $this->security->isGranted('ROLE_ADMIN');
$isClient = $this->security->isGranted('ROLE_CLIENT');
$repo = $this->entityManager->getRepository(TaskDocument::class);
// Single item
// Single item.
if (isset($uriVariables['id'])) {
return $repo->find($uriVariables['id']);
$document = $repo->find($uriVariables['id']);
if (null === $document) {
return null;
}
if ($isAdmin) {
return $document;
}
if ($isClient) {
$ticket = $document->getClientTicket();
return null !== $ticket && $ticket->getSubmittedBy() === $user ? $document : null;
}
// ROLE_USER: task-linked documents only.
return null !== $document->getTask() ? $document : null;
}
// Collection
// Collection.
$qb = $repo->createQueryBuilder('d')
->orderBy('d.id', 'DESC')
;
// Apply filters from query parameters
if ($isClient && !$isAdmin) {
$qb->innerJoin('d.clientTicket', 'ct')
->andWhere('ct.submittedBy = :user')
->setParameter('user', $user)
;
} elseif (!$isAdmin) {
// ROLE_USER: only documents attached to a task.
$qb->andWhere('d.task IS NOT NULL');
}
$filters = $context['filters'] ?? [];
if (isset($filters['task'])) {
$qb->andWhere('d.task = :task')
->setParameter('task', self::extractId($filters['task']))
;
}
if (isset($filters['clientTicket'])) {
$qb->andWhere('d.clientTicket = :clientTicket')
->setParameter('clientTicket', self::extractId($filters['clientTicket']))
;
}
return $qb->getQuery()->getResult();
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Contrat de LECTURE d'un ticket client, consommé hors du module ClientPortal.
*
* Permet à ProjectManagement (Task, TaskDocument) de référencer un ticket
* client sans dépendre directement de l'entité concrète du module ClientPortal.
*/
interface ClientTicketInterface
{
public function getId(): ?int;
public function getNumber(): ?int;
public function getType(): string;
public function getStatus(): string;
public function getTitle(): ?string;
}
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Shared\Domain\Contract;
use Doctrine\Common\Collections\Collection;
/**
* Contrat de LECTURE de l'identité, consommé hors du module Core.
* Les écritures (setPassword, setters HR…) restent sur le concret Core\Domain\Entity\User.
@@ -29,4 +31,16 @@ interface UserInterface
/** @return list<string> */
public function getEffectivePermissions(): array;
/**
* Client this user belongs to, or null for an internal user.
*/
public function getClient(): ?ClientInterface;
/**
* Projects a client user is allowed to access.
*
* @return Collection<int, ProjectInterface>
*/
public function getAllowedProjects(): Collection;
}