Files
pta/app/Core/Access.php
PTA 4201894e28 V7.10 — Tableau de bord, guide, semaine type et enveloppe de formation
Reprise dans le répertoire de production du travail mené dans le worktree
ux-service-enfance :

- tableau de bord Service Enfance (dashboard.php) et navigation regroupée ;
- guide d'utilisation (faq.php) accessible depuis toute la navigation ;
- éditeur de semaine type mis en avant sur l'écran de saisie ;
- droit à l'enveloppe de formation par agent, en remplacement de la case
  « formation déjà prévue dans la répartition annuelle » ;
- correctif de « [object Object] » sur les cartes de quota du PTA ;
- motif de travail par défaut à la saisie, heures travaillées complètes
  dans les PDF.

Le docker-compose.yml de production est conservé : celui du worktree décrit
une pile de test (conteneurs et volume suffixés « test », ports 9080/9081),
qui aurait fait redémarrer l'application sur un volume MariaDB vide.

Les migrations database/migration_v7_9*.sql sont obligatoires ; le service
db-migrate les applique au déploiement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:53:04 +02:00

398 lines
14 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Core;
use PDO;
/**
* Contrôle des rôles et des périmètres.
*
* Aucune règle de droit n'est écrite ici : les pages autorisées, les
* permissions fonctionnelles et les périmètres de visibilité sont lus dans les
* vues SQL créées par database/migration_v7_8_securite_acces.sql. Cette classe
* ne fait que poser la question à la base et appliquer la réponse.
*/
final class Access
{
public const ROLE_AGENT = 'AGENT';
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
private const SESSION_KEY = 'pta_access';
/** @var array<string, array<string, bool>> */
private array $permissionCache = [];
/** @var array<string, array<int, string>> */
private array $pageCache = [];
public function __construct(private PDO $pdo)
{
Session::start();
}
public function isConfigured(): bool
{
$role = $this->role();
if ($role === self::ROLE_AGENT) {
return $this->agentId() !== null && $this->agentRecord() !== null;
}
if ($role === self::ROLE_RESPONSABLE) {
return $this->structureId() !== null && $this->structureRecord() !== null;
}
return $role === self::ROLE_SERVICE;
}
public function role(): ?string
{
$role = strtoupper(trim((string) ($_SESSION[self::SESSION_KEY]['role'] ?? '')));
return in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)
? $role
: null;
}
public function agentId(): ?int
{
$value = filter_var($_SESSION[self::SESSION_KEY]['agent_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
return $value === false ? null : (int) $value;
}
public function structureId(): ?int
{
if ($this->role() === self::ROLE_AGENT) {
$agent = $this->agentRecord();
return isset($agent['id_structure']) && $agent['id_structure'] !== null
? (int) $agent['id_structure']
: null;
}
$value = filter_var($_SESSION[self::SESSION_KEY]['structure_id'] ?? null, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1],
]);
return $value === false ? null : (int) $value;
}
public function select(string $role, ?int $agentId = null, ?int $structureId = null): void
{
$role = strtoupper(trim($role));
if (!in_array($role, [self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE], true)) {
throw new \DomainException('Rôle invalide.');
}
$selection = ['role' => $role, 'agent_id' => null, 'structure_id' => null];
if ($role === self::ROLE_AGENT) {
if (!$agentId || !$this->activeAgentExists($agentId)) {
throw new \DomainException('Sélectionnez un agent actif.');
}
$selection['agent_id'] = $agentId;
} elseif ($role === self::ROLE_RESPONSABLE) {
if (!$structureId || !$this->activeStructureExists($structureId)) {
throw new \DomainException('Sélectionnez un lieu d’affectation actif.');
}
$selection['structure_id'] = $structureId;
}
session_regenerate_id(true);
$_SESSION[self::SESSION_KEY] = $selection;
// Le jeton CSRF suit le cycle de vie de la session : un changement de
// rôle invalide le jeton précédent.
unset($_SESSION['csrf_token']);
}
public function clear(): void
{
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true);
}
public function profile(): array
{
$role = $this->role();
$permissions = $this->permissions();
$profile = [
'role' => $role,
'role_label' => $this->roleLabel(),
'label' => $this->roleLabel(),
'agent_id' => $this->agentId(),
'structure_id' => $this->structureId(),
'permissions' => $permissions,
];
if ($role === self::ROLE_AGENT) {
$agent = $this->agentRecord();
if ($agent !== null) {
$profile['label'] = trim((string) $agent['prenom'] . ' ' . (string) $agent['nom']);
$profile['matricule'] = $agent['matricule'];
$profile['structure_name'] = $agent['structure_nom'];
$profile['structure_id'] = $agent['id_structure'] !== null ? (int) $agent['id_structure'] : null;
}
} elseif ($role === self::ROLE_RESPONSABLE) {
$structure = $this->structureRecord();
if ($structure !== null) {
$profile['label'] = (string) $structure['nom'];
$profile['structure_name'] = (string) $structure['nom'];
$profile['structure_code'] = (string) $structure['code'];
}
}
return $profile;
}
/**
* Permissions fonctionnelles lues dans v_acces_role_permission.
* Une permission absente de la base vaut « refusé ».
*
* @return array<string, bool>
*/
public function permissions(): array
{
$role = $this->role();
if ($role === null) {
return [];
}
if (!isset($this->permissionCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT permission, autorise FROM v_acces_role_permission WHERE role = :role'
);
$stmt->execute(['role' => $role]);
$permissions = [];
foreach ($stmt->fetchAll() as $row) {
$permissions[(string) $row['permission']] = (bool) $row['autorise'];
}
$this->permissionCache[$role] = $permissions;
}
return $this->permissionCache[$role];
}
public function hasPermission(string $permission): bool
{
return $this->permissions()[$permission] ?? false;
}
/**
* Pages autorisées lues dans v_acces_role_page.
*
* @return array<int, string>
*/
public function allowedPages(): array
{
$role = $this->role();
if ($role === null) {
return [];
}
if (!isset($this->pageCache[$role])) {
$stmt = $this->pdo->prepare(
'SELECT page FROM v_acces_role_page WHERE role = :role ORDER BY ordre_affichage, page'
);
$stmt->execute(['role' => $role]);
$this->pageCache[$role] = array_map('strval', $stmt->fetchAll(PDO::FETCH_COLUMN));
}
return $this->pageCache[$role];
}
public function canAccessPage(string $page): bool
{
return in_array($page, $this->allowedPages(), true);
}
/**
* Page d'atterrissage du rôle.
*
* Le Service Enfance arrive sur son tableau de bord plutôt que sur l'écran
* de saisie : son travail commence par le contrôle, pas par la saisie. Le
* repli sur « planning » est indispensable, car la page « dashboard » n'est
* autorisée que si la migration V7.9 a été jouée — sans ce repli,
* requirePage() renverrait indéfiniment vers une page interdite.
*/
public function homeUrl(): string
{
return match ($this->role()) {
self::ROLE_AGENT => 'agents.php?agent_id=' . (int) $this->agentId(),
self::ROLE_RESPONSABLE => 'planning.php?structure_id=' . (int) $this->structureId(),
self::ROLE_SERVICE => $this->canAccessPage('dashboard') ? 'dashboard.php' : 'planning.php',
default => 'role.php',
};
}
public function requirePage(string $page): void
{
if (!$this->isConfigured()) {
header('Location: role.php', true, 302);
exit;
}
if (!$this->canAccessPage($page)) {
header('Location: ' . $this->homeUrl() . (str_contains($this->homeUrl(), '?') ? '&' : '?') . 'access_denied=1', true, 302);
exit;
}
}
public function requireRoles(array $roles): void
{
if (!$this->isConfigured()) {
JsonResponse::send(401, ['error' => 'Choisissez votre rôle avant de continuer.', 'code' => 'ROLE_REQUIRED']);
}
if (!in_array($this->role(), $roles, true)) {
JsonResponse::send(403, ['error' => 'Votre rôle ne permet pas cette action.', 'code' => 'ACCESS_DENIED']);
}
}
public function requireService(): void
{
$this->requireRoles([self::ROLE_SERVICE]);
}
public function requireDraftEditor(): void
{
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
}
public function requireStructureView(int $structureId): void
{
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
if ($this->role() !== self::ROLE_SERVICE && $this->structureId() !== $structureId) {
JsonResponse::send(403, ['error' => 'Vous ne pouvez consulter que le planning de votre lieu de rattachement.', 'code' => 'STRUCTURE_SCOPE']);
}
}
public function requireStructureEdit(int $structureId): void
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_RESPONSABLE && $this->structureId() !== $structureId) {
JsonResponse::send(403, ['error' => 'Vous ne pouvez modifier que les brouillons de votre lieu.', 'code' => 'STRUCTURE_SCOPE']);
}
}
public function requireAgentView(int $agentId): void
{
$this->requireRoles([self::ROLE_AGENT, self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
if ($this->role() === self::ROLE_SERVICE) {
return;
}
if ($this->role() === self::ROLE_AGENT && $this->agentId() === $agentId) {
return;
}
if ($this->role() === self::ROLE_RESPONSABLE && $this->responsibleCanViewAgent($agentId)) {
return;
}
JsonResponse::send(403, ['error' => 'Vous ne pouvez pas consulter le planning de cet agent.', 'code' => 'AGENT_SCOPE']);
}
public function requireEntryEdit(int $entryId): void
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_SERVICE) {
return;
}
$stmt = $this->pdo->prepare(
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
);
$stmt->execute(['id' => $entryId]);
$structureId = $stmt->fetchColumn();
if ($structureId === false) {
JsonResponse::send(404, ['error' => 'Créneau introuvable.']);
}
$this->requireStructureEdit((int) $structureId);
}
public function requirePlanningEdit(int $planningId): void
{
$this->requireDraftEditor();
if ($this->role() === self::ROLE_SERVICE) {
return;
}
$stmt = $this->pdo->prepare(
'SELECT id_structure FROM v_acces_perimetre_planning WHERE id_planning = :id LIMIT 1'
);
$stmt->execute(['id' => $planningId]);
$structureId = $stmt->fetchColumn();
if ($structureId === false) {
JsonResponse::send(404, ['error' => 'Planning introuvable.']);
}
$this->requireStructureEdit((int) $structureId);
}
public function editableStructureId(): ?int
{
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
}
private function roleLabel(): string
{
return match ($this->role()) {
self::ROLE_AGENT => 'Agent',
self::ROLE_RESPONSABLE => 'Responsable de structure',
self::ROLE_SERVICE => 'Service Enfance',
default => 'Rôle non sélectionné',
};
}
private function activeAgentExists(int $agentId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_agent WHERE id_agent = :id LIMIT 1');
$stmt->execute(['id' => $agentId]);
return $stmt->fetchColumn() !== false;
}
private function activeStructureExists(int $structureId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM v_acces_identite_structure WHERE id_structure = :id LIMIT 1');
$stmt->execute(['id' => $structureId]);
return $stmt->fetchColumn() !== false;
}
private function agentRecord(): ?array
{
$agentId = $this->agentId();
if ($agentId === null) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id_agent, matricule, nom, prenom, id_structure, structure_nom
FROM v_acces_identite_agent
WHERE id_agent = :id LIMIT 1'
);
$stmt->execute(['id' => $agentId]);
return $stmt->fetch() ?: null;
}
private function structureRecord(): ?array
{
$structureId = $this->structureId();
if ($structureId === null) {
return null;
}
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom
FROM v_acces_identite_structure
WHERE id_structure = :id LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
return $stmt->fetch() ?: null;
}
private function responsibleCanViewAgent(int $agentId): bool
{
$structureId = $this->structureId();
if ($structureId === null) {
return false;
}
$stmt = $this->pdo->prepare(
'SELECT 1 FROM v_acces_agent_par_structure
WHERE id_agent = :agent_id AND id_structure_autorisee = :structure_id
LIMIT 1'
);
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
return $stmt->fetchColumn() !== false;
}
}