fix(absences) : durcissement RGPD des données RH des utilisateurs

Suite à la revue de conformité du module absences.

Fuite corrigée : GET /api/users et /api/users/{id} n'avaient aucun contrôle
d'accès alors que le groupe user:list exposait les données RH/familiales
(date d'embauche, contrat, soldes de CP, rôles…). Tout utilisateur authentifié
pouvait donc lire ces informations sur tous ses collègues.
- chaque champ RH (isEmployee, hireDate, endDate, contractType, workTimeRatio,
  annualLeaveDays, referencePeriodStart, initialLeaveBalance) ainsi que roles
  est désormais exposé via #[ApiProperty(security: "is_granted('ROLE_ADMIN') or
  object == user")] : visible uniquement par un admin ou par l'utilisateur
  lui-même. id et username restent publics (sélecteurs d'assigné, avatars).

Minimisation : suppression de familySituation et nbChildren, collectés et
exposés (form RH, API, outil MCP) mais utilisés par aucun calcul.
- entité User + enum FamilySituation + migration de drop des colonnes
- Serializer MCP, update-user (MCP), EmployeeDrawer, DTO, fixtures, i18n

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-22 14:28:48 +02:00
parent 2b148fa65a
commit 11fdf8d1bf
9 changed files with 47 additions and 122 deletions

View File

@@ -24,13 +24,6 @@
empty-option-label=""
group-class="w-full"
/>
<MalioSelect
v-model="form.familySituation"
:label="$t('absences.admin.employees.fields.familySituation')"
:options="familyOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioInputText
v-model="form.workTimeRatio"
:label="$t('absences.admin.employees.fields.workTimeRatio')"
@@ -51,11 +44,6 @@
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
input-class="w-full"
/>
<MalioInputText
v-model="form.nbChildren"
:label="$t('absences.admin.employees.fields.nbChildren')"
input-class="w-full"
/>
<div class="col-span-full mt-2 flex justify-end">
<MalioButton
@@ -70,7 +58,7 @@
</template>
<script setup lang="ts">
import type { ContractType, FamilySituation, UserData } from '~/services/dto/user-data'
import type { ContractType, UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
const props = defineProps<{
@@ -101,24 +89,14 @@ const contractOptions = [
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
]
const familyOptions = [
{ label: t('absences.admin.employees.family.celibataire'), value: 'CELIBATAIRE' },
{ label: t('absences.admin.employees.family.marie'), value: 'MARIE' },
{ label: t('absences.admin.employees.family.pacse'), value: 'PACSE' },
{ label: t('absences.admin.employees.family.divorce'), value: 'DIVORCE' },
{ label: t('absences.admin.employees.family.veuf'), value: 'VEUF' },
]
const form = reactive({
hireDate: null as string | null,
endDate: null as string | null,
contractType: null as ContractType | null,
familySituation: null as FamilySituation | null,
workTimeRatio: '1.0',
annualLeaveDays: '25',
referencePeriodStart: '06-01',
initialLeaveBalance: '0',
nbChildren: '0',
})
function hydrate(u: UserData | null) {
@@ -126,12 +104,10 @@ function hydrate(u: UserData | null) {
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
form.contractType = u.contractType ?? null
form.familySituation = u.familySituation ?? null
form.workTimeRatio = String(u.workTimeRatio ?? 1)
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
form.nbChildren = String(u.nbChildren ?? 0)
}
watch(() => props.modelValue, (isOpen) => {
@@ -147,12 +123,10 @@ async function save() {
hireDate: form.hireDate || null,
endDate: form.endDate || null,
contractType: form.contractType,
familySituation: form.familySituation,
workTimeRatio: Number(form.workTimeRatio) || 1,
annualLeaveDays: Number(form.annualLeaveDays) || 0,
referencePeriodStart: form.referencePeriodStart || '06-01',
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
nbChildren: Number(form.nbChildren) || 0,
})
emit('saved')
open.value = false

View File

@@ -703,12 +703,10 @@
"hireDate": "Date d'embauche",
"endDate": "Date de sortie",
"contractType": "Type de contrat",
"familySituation": "Situation familiale",
"workTimeRatio": "Temps de travail (ex : 1.0)",
"annualLeaveDays": "CP annuels (jours)",
"referencePeriodStart": "Début période réf. (MM-DD)",
"initialLeaveBalance": "Solde CP initial",
"nbChildren": "Nombre d'enfants"
"initialLeaveBalance": "Solde CP initial"
},
"contract": {
"cdi": "CDI",
@@ -716,13 +714,6 @@
"stage": "Stage",
"alternance": "Alternance",
"autre": "Autre"
},
"family": {
"celibataire": "Célibataire",
"marie": "Marié(e)",
"pacse": "Pacsé(e)",
"divorce": "Divorcé(e)",
"veuf": "Veuf(ve)"
}
}
},

View File

@@ -1,5 +1,4 @@
export type ContractType = 'CDI' | 'CDD' | 'STAGE' | 'ALTERNANCE' | 'AUTRE'
export type FamilySituation = 'CELIBATAIRE' | 'MARIE' | 'PACSE' | 'DIVORCE' | 'VEUF'
export type UserData = {
id: number
@@ -17,8 +16,6 @@ export type UserData = {
annualLeaveDays?: number
referencePeriodStart?: string
initialLeaveBalance?: number
familySituation?: FamilySituation | null
nbChildren?: number
}
export type UserWrite = {
@@ -34,6 +31,4 @@ export type UserWrite = {
annualLeaveDays?: number
referencePeriodStart?: string
initialLeaveBalance?: number
familySituation?: FamilySituation | null
nbChildren?: number
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* RGPD / minimisation : suppression des colonnes user.family_situation et
* user.nb_children. Ces données (situation familiale, nombre d'enfants)
* étaient collectées et exposées mais n'étaient utilisées par aucun calcul.
*/
final class Version20260522140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Drop unused HR fields user.family_situation and user.nb_children (data minimisation)';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS family_situation');
$this->addSql('ALTER TABLE "user" DROP COLUMN IF EXISTS nb_children');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE "user" ADD family_situation VARCHAR(16) DEFAULT NULL');
$this->addSql('ALTER TABLE "user" ADD nb_children INT NOT NULL DEFAULT 0');
}
}

View File

@@ -24,7 +24,6 @@ use App\Entity\ZimbraConfiguration;
use App\Enum\AbsenceStatus;
use App\Enum\AbsenceType;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Enum\RecurrenceType;
use App\Enum\StatusCategory;
use DateTimeImmutable;
@@ -670,20 +669,15 @@ class AppFixtures extends Fixture
$admin->setIsEmployee(true);
$admin->setHireDate(new DateTimeImmutable('2020-01-15'));
$admin->setContractType(ContractType::Cdi);
$admin->setFamilySituation(FamilySituation::Married);
$admin->setNbChildren(2);
$userAlice->setIsEmployee(true);
$userAlice->setHireDate(new DateTimeImmutable('2022-09-01'));
$userAlice->setContractType(ContractType::Cdi);
$userAlice->setFamilySituation(FamilySituation::Single);
$userBob->setIsEmployee(true);
$userBob->setHireDate(new DateTimeImmutable('2023-03-10'));
$userBob->setContractType(ContractType::Cdd);
$userBob->setWorkTimeRatio(0.8);
$userBob->setFamilySituation(FamilySituation::Pacsed);
$userBob->setNbChildren(1);
// Paid-leave balances for the current reference period (June 1st → May 31st)
$cpPeriod = '2025-2026';

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
@@ -11,7 +12,6 @@ use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Repository\UserRepository;
use App\State\MeProvider;
use App\State\UserPasswordHasherProcessor;
@@ -57,6 +57,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/** @var list<string> */
#[ORM\Column]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private array $roles = [];
@@ -76,54 +77,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 255, nullable: true)]
private ?string $avatarFileName = null;
// --- HR / absence management fields ---
// --- HR / absence management fields (readable only by an admin or the user themselves) ---
/** Whether this user is an employee subject to absence management. */
#[ORM\Column]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private bool $isEmployee = false;
/** Hiring date — start of paid-leave acquisition. */
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?DateTimeImmutable $hireDate = null;
#[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: ContractType::class)]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?ContractType $contractType = null;
/** Work-time ratio: 1.0 = full time, 0.8 = 4 days out of 5. */
#[ORM\Column(type: Types::FLOAT)]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $workTimeRatio = 1.0;
/** Yearly paid-leave entitlement in worked days (default 25 = jours ouvrés). */
#[ORM\Column(type: Types::FLOAT)]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $annualLeaveDays = 25.0;
/** Reference period start as MM-DD (default 06-01, 1st of June). */
#[ORM\Column(length: 5)]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private string $referencePeriodStart = '06-01';
/** Paid-leave already acquired when the module is rolled out. */
#[ORM\Column(type: Types::FLOAT)]
#[ApiProperty(security: "is_granted('ROLE_ADMIN') or object == user")]
#[Groups(['me:read', 'user:list', 'user:write'])]
private float $initialLeaveBalance = 0.0;
#[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: FamilySituation::class)]
#[Groups(['me:read', 'user:list', 'user:write'])]
private ?FamilySituation $familySituation = null;
#[ORM\Column]
#[Groups(['me:read', 'user:list', 'user:write'])]
private int $nbChildren = 0;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
@@ -338,28 +339,4 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function getFamilySituation(): ?FamilySituation
{
return $this->familySituation;
}
public function setFamilySituation(?FamilySituation $familySituation): static
{
$this->familySituation = $familySituation;
return $this;
}
public function getNbChildren(): int
{
return $this->nbChildren;
}
public function setNbChildren(int $nbChildren): static
{
$this->nbChildren = $nbChildren;
return $this;
}
}

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enum;
enum FamilySituation: string
{
case Single = 'CELIBATAIRE';
case Married = 'MARIE';
case Pacsed = 'PACSE';
case Divorced = 'DIVORCE';
case Widowed = 'VEUF';
public function label(): string
{
return match ($this) {
self::Single => 'Célibataire',
self::Married => 'Marié(e)',
self::Pacsed => 'Pacsé(e)',
self::Divorced => 'Divorcé(e)',
self::Widowed => 'Veuf(ve)',
};
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Mcp\Tool\Reference;
use App\Enum\ContractType;
use App\Enum\FamilySituation;
use App\Mcp\Tool\Serializer;
use App\Repository\UserRepository;
use DateTimeImmutable;
@@ -17,7 +16,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf;
#[McpTool(name: 'update-user', description: 'Update a user HR/profile fields (admin). Does NOT change password or roles. contractType = CDI|CDD|STAGE|ALTERNANCE|AUTRE. familySituation = CELIBATAIRE|MARIE|PACSE|DIVORCE|VEUF. hireDate/endDate as YYYY-MM-DD. referencePeriodStart as MM-DD (e.g. 06-01).')]
#[McpTool(name: 'update-user', description: 'Update a user HR/profile fields (admin). Does NOT change password or roles. contractType = CDI|CDD|STAGE|ALTERNANCE|AUTRE. hireDate/endDate as YYYY-MM-DD. referencePeriodStart as MM-DD (e.g. 06-01).')]
class UpdateUserTool
{
public function __construct(
@@ -36,8 +35,6 @@ class UpdateUserTool
?float $annualLeaveDays = null,
?string $referencePeriodStart = null,
?float $initialLeaveBalance = null,
?string $familySituation = null,
?int $nbChildren = null,
): string {
if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
@@ -75,15 +72,6 @@ class UpdateUserTool
if (null !== $initialLeaveBalance) {
$user->setInitialLeaveBalance($initialLeaveBalance);
}
if (null !== $familySituation) {
$user->setFamilySituation(
FamilySituation::tryFrom($familySituation)
?? throw new InvalidArgumentException(sprintf('Unknown family situation "%s".', $familySituation)),
);
}
if (null !== $nbChildren) {
$user->setNbChildren($nbChildren);
}
$this->entityManager->flush();

View File

@@ -389,8 +389,6 @@ final class Serializer
'annualLeaveDays' => $u->getAnnualLeaveDays(),
'referencePeriodStart' => $u->getReferencePeriodStart(),
'initialLeaveBalance' => $u->getInitialLeaveBalance(),
'familySituation' => $u->getFamilySituation()?->value,
'nbChildren' => $u->getNbChildren(),
];
}
}