Files
pta/app/Core/Access.php
2026-08-20 13:22:49 +02:00

493 lines
18 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
{
/** Lecture seule sur son propre planning. */
public const ROLE_AGENT = 'AGENT';
/** Édition des brouillons de son seul lieu d'affectation. */
public const ROLE_RESPONSABLE = 'RESPONSABLE_STRUCTURE';
/** Périmètre complet ; seul rôle habilité à valider un planning. */
public const ROLE_SERVICE = 'SERVICE_ENFANCE';
private const SESSION_KEY = 'pta_access';
/**
* Caches par requête : les vues d'accès sont interrogées une seule fois par
* rôle et par requête HTTP. Rien n'est mis en cache entre deux requêtes, pour
* qu'une modification SQL des droits s'applique sans redéploiement.
*
* @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();
}
/**
* Vrai si la sélection en session est complète et pointe encore vers une
* identité active. Un agent ou un lieu désactivé en base fait donc retomber
* l'utilisateur sur role.php à la requête suivante.
*/
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;
}
/** Rôle en session, ou null si aucun rôle valide n'est sélectionné. */
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;
}
/** Agent sélectionné (rôle AGENT uniquement), ou 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;
}
/**
* Lieu de référence de la session.
*
* Pour un AGENT, il n'est pas stocké en session mais dérivé de son
* rattachement en base : l'agent ne le choisit pas, et le déplacer d'un lieu
* à l'autre doit prendre effet immédiatement.
*/
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;
}
/**
* Enregistre la sélection de rôle en session.
*
* ATTENTION : ceci est un sélecteur, PAS une authentification — l'utilisateur
* choisit librement son identité. C'est la faille ouverte la plus importante
* du projet ; un passage en production doit remplacer cet appel par une
* authentification réelle, sans toucher au reste de la mécanique de droits.
*
* @throws \DomainException Si le rôle est inconnu ou l'identité inactive.
*/
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;
}
// Regénération de l'identifiant de session à chaque changement de rôle :
// parade classique à la fixation de session.
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']);
}
/** Abandonne la sélection courante (« changer de rôle »). */
public function clear(): void
{
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true);
}
/**
* Profil affichable de la session, publié tel quel dans window.PTA_CONFIG
* par layouts/footer.php et réutilisé par les en-têtes de PDF.
*
* @return array<string, mixed> role, role_label, label, agent_id,
* structure_id, permissions, et selon le rôle
* matricule / structure_name / structure_code.
*/
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];
}
/** Raccourci de lecture d'une permission ; absente = refusée. */
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];
}
/**
* $page est la clé logique de la page (« planning », « dashboard »…), telle
* qu'inscrite dans acces_role_page et passée à runPage().
*/
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',
};
}
/**
* Garde des pages HTML : redirige au lieu de renvoyer une erreur, puis
* TERMINE le script. Sans rôle configuré on renvoie vers le sélecteur ;
* page interdite, on renvoie vers l'accueil du rôle avec access_denied=1
* pour que l'interface puisse expliquer le refus.
*/
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;
}
}
/**
* Garde des endpoints JSON : émet un 401/403 JSON et TERMINE le script.
* Toutes les gardes require*() ci-dessous en dérivent.
*
* @param array<int, string> $roles Rôles acceptés.
*/
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']);
}
}
/** Réservé au Service Enfance : validation, référentiels, administration. */
public function requireService(): void
{
$this->requireRoles([self::ROLE_SERVICE]);
}
/** Rôles autorisés à modifier un brouillon (l'agent est en lecture seule). */
public function requireDraftEditor(): void
{
$this->requireRoles([self::ROLE_RESPONSABLE, self::ROLE_SERVICE]);
}
/** Consultation d'un lieu : hors Service Enfance, seul le lieu de rattachement. */
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']);
}
}
/** Édition dans un lieu : un responsable est confiné au sien. */
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']);
}
}
/**
* Consultation d'un agent. Un responsable y a droit dès que l'agent est
* planifié chez lui, même s'il y est rattaché ailleurs : un agent peut
* travailler sur plusieurs lieux la même semaine (v_acces_agent_par_structure).
*/
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']);
}
/**
* Édition d'un créneau, dont le périmètre est déduit du planning porteur.
* Un identifiant inconnu donne un 404 avant tout contrôle de rôle plus fin.
*/
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);
}
/** Édition d'un planning identifié, périmètre déduit de son lieu. */
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);
}
/**
* Lieu à pré-sélectionner et à verrouiller dans l'interface, ou null quand
* l'utilisateur peut choisir librement (Service Enfance).
*/
public function editableStructureId(): ?int
{
return $this->role() === self::ROLE_RESPONSABLE ? $this->structureId() : null;
}
/** Libellé lisible du rôle, affiché dans l'en-tête et sur les PDF. */
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é',
};
}
/** Les vues v_acces_identite_* ne listent que les identités ACTIVES. */
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;
}
/**
* Fiche de l'agent en session.
*
* @return array<string, mixed>|null
*/
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;
}
/**
* Fiche du lieu en session.
*
* @return array<string, mixed>|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;
}
/**
* Vrai si l'agent apparaît dans le périmètre du responsable : rattachement
* par défaut OU présence d'un planning dans son lieu.
*/
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;
}
}