This commit is contained in:
PTA
2026-08-25 15:12:11 +02:00
parent 44730eefca
commit 1a67080268
25 changed files with 2646 additions and 231 deletions

View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Access;
use App\Core\Csrf;
use App\Core\Journal;
use App\Core\JsonResponse;
use App\Core\Request;
use App\Models\CompteModel;
use DomainException;
use PDO;
/**
* Jetons d'accès de terrain (V7.27) : le Service Enfance équipe ses agents et
* ses responsables de lieu.
*
* POURQUOI CE CONTRÔLEUR EXISTE À CÔTÉ DE DsiController : c'est le Service
* Enfance qui sait qu'un agent est arrivé, qu'un responsable a perdu son jeton
* ou qu'un remplaçant s'en va. Passer par la DSI pour chaque mouvement ajoutait
* un délai sans ajouter de contrôle. La DSI conserve tout : ses propres comptes,
* ceux du Service Enfance, le journal, la purge.
*
* LA FRONTIÈRE DE PRIVILÈGE, à ne jamais élargir :
*
* - self::ROLES_DELEGUES ne contient QUE AGENT et RESPONSABLE_STRUCTURE.
* Y ajouter SERVICE_ENFANCE ferait du rôle son propre distributeur — une
* auto-promotion sans contrôle externe ; y ajouter DSI lui ouvrirait le
* journal, c'est-à-dire la trace de ses propres actions ;
* - toute méthode qui désigne un compte existant passe par
* compteDelegue() AVANT d'agir. Un identifiant de compte privilégié est
* refusé en 403, jamais silencieusement ignoré ;
* - la liste blanche est doublée en base
* (database/migration_v7_9h_jetons_service_enfance.sql) : une frontière de
* privilège ne doit pas dépendre d'un seul des deux côtés.
*
* Les deux règles de DsiController restent valables ici :
* 1. un jeton en clair ne sort qu'AU MOMENT où il vient d'être produit ;
* 2. toute action est journalisée en catégorie ADMINISTRATION, sans jamais
* recopier le jeton dans la trace.
*/
final class AccessAccountController
{
/**
* Les seuls rôles dont le Service Enfance distribue les jetons.
* Lire l'en-tête de classe avant d'y toucher.
*/
private const ROLES_DELEGUES = [Access::ROLE_AGENT, Access::ROLE_RESPONSABLE];
private Access $access;
private CompteModel $comptes;
public function __construct(private PDO $pdo)
{
$this->access = new Access($pdo);
$this->comptes = new CompteModel($pdo);
}
/**
* GET api/acces_comptes.php — comptes de terrain et identités sans compte.
*
* Paramètres GET : role (dans la liste déléguée), actif, q.
*/
public function comptes(): void
{
$this->access->requireService();
$comptes = $this->comptes->lister([
'roles' => self::ROLES_DELEGUES,
'role' => (string) ($_GET['role'] ?? ''),
'actif' => (string) ($_GET['actif'] ?? ''),
'q' => (string) ($_GET['q'] ?? ''),
]);
JsonResponse::send(200, [
'comptes' => $comptes,
// v_acces_compte_manquant ne produit que des identités AGENT et
// RESPONSABLE_STRUCTURE : elle est déjà dans le périmètre délégué.
'manquants' => $this->comptes->manquants(),
'roles' => $this->rolesDelegues(),
]);
}
/**
* POST api/acces_compte_creer.php — crée un compte de terrain et renvoie son
* jeton.
*
* Le libellé n'est pas saisi : pour ces deux rôles il est TOUJOURS déduit de
* l'identité (nom de l'agent, nom du lieu), par CompteModel. Un titulaire ne
* peut donc pas être maquillé sous un autre nom dans la liste.
*
* Charge utile : role, agent_id, structure_id, commentaire, csrf_token.
*/
public function creerCompte(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
$this->assertRoleDelegue($role);
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
try {
$resultat = $this->comptes->creer(
$role,
$agentId !== null ? (int) $agentId : null,
$structureId !== null ? (int) $structureId : null,
'',
$commentaire !== '' ? $commentaire : null
);
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
$compte = $resultat['compte'];
Journal::administration('COMPTE_CREE', [
'cible_table' => 'compte_acces',
'cible_id' => (int) $compte['id_compte'],
'description' => sprintf(
'Création du compte d’accès « %s » (%s) par le Service Enfance.',
(string) $compte['identite_libelle'],
(string) $compte['role']
),
'contexte' => [
'role' => $compte['role'],
'prefixe' => $compte['jeton_prefixe'],
'canal' => 'DELEGATION_SERVICE_ENFANCE',
],
]);
JsonResponse::send(201, [
'message' => sprintf('Compte créé pour %s. Transmettez le jeton : il ne sera plus affiché.', (string) $compte['identite_libelle']),
'compte' => $compte,
'jeton' => $resultat['jeton'],
]);
}
/**
* POST api/acces_compte_jeton.php — régénère le jeton d'un compte de terrain.
*
* C'est la procédure du jeton perdu comme du jeton compromis : l'ancien
* cesse immédiatement de fonctionner.
*
* Charge utile : compte_id, csrf_token.
*/
public function regenererJeton(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
$compte = $this->compteDelegue($compteId);
try {
$jeton = $this->comptes->regenererJeton($compteId);
} catch (DomainException $e) {
JsonResponse::send(404, ['error' => $e->getMessage()]);
return;
}
$apres = $this->comptes->trouver($compteId) ?? $compte;
Journal::administration('JETON_REGENERE', [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'description' => sprintf(
'Régénération du jeton de « %s » par le Service Enfance : l’ancien jeton est révoqué.',
(string) $apres['identite_libelle']
),
'contexte' => [
'role' => $apres['role'],
'prefixe' => $apres['jeton_prefixe'] ?? null,
'canal' => 'DELEGATION_SERVICE_ENFANCE',
],
]);
JsonResponse::send(200, [
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
'compte' => $apres,
'jeton' => $jeton,
]);
}
/**
* POST api/acces_compte_statut.php — active, désactive ou supprime un compte
* de terrain.
*
* La suppression efface le compte mais PAS ses traces : le journal conserve
* le libellé figé de l'acteur.
*
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
* csrf_token.
*/
public function changerStatutCompte(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
$action = strtoupper(trim((string) ($payload['action'] ?? '')));
if (!in_array($action, ['ACTIVER', 'DESACTIVER', 'SUPPRIMER'], true)) {
JsonResponse::send(422, ['error' => 'Action inconnue.']);
}
// La fiche est lue AVANT l'opération, et sa lecture vaut contrôle de
// périmètre : après une suppression, il n'y aurait plus ni rôle à
// vérifier ni libellé à écrire dans le journal. Le compte de la session
// en cours est hors périmètre par construction (c'est un compte
// SERVICE_ENFANCE), il n'y a donc pas d'auto-désactivation à empêcher.
$compte = $this->compteDelegue($compteId);
try {
if ($action === 'SUPPRIMER') {
$this->comptes->supprimer($compteId);
} else {
$this->comptes->definirActif($compteId, $action === 'ACTIVER');
}
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
$libelles = [
'ACTIVER' => 'Réactivation',
'DESACTIVER' => 'Désactivation',
'SUPPRIMER' => 'Suppression',
];
Journal::administration('COMPTE_' . $action, [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'description' => sprintf(
'%s du compte d’accès « %s » (%s) par le Service Enfance.',
$libelles[$action],
(string) $compte['identite_libelle'],
(string) $compte['role']
),
'contexte' => [
'role' => $compte['role'],
'prefixe' => $compte['jeton_prefixe'],
'canal' => 'DELEGATION_SERVICE_ENFANCE',
],
]);
JsonResponse::send(200, [
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
]);
}
/**
* POST api/acces_comptes_manquants.php — équipe d'un coup toutes les
* identités actives sans compte.
*
* Sans danger d'élargissement : v_acces_compte_manquant ne connaît que des
* agents et des lieux, jamais un compte privilégié. La réponse porte tous
* les jetons produits, en clair — seule occasion de les lire.
*
* Charge utile : csrf_token.
*/
public function genererJetonsManquants(): void
{
$this->access->requireService();
Request::requireMethod('POST');
$payload = Request::json();
Csrf::assertPayload($payload);
try {
$produits = $this->comptes->genererManquants();
} catch (DomainException $e) {
JsonResponse::send(422, ['error' => $e->getMessage()]);
return;
}
Journal::administration('JETONS_GENERES_EN_LOT', [
'cible_table' => 'compte_acces',
'cible_id' => null,
'description' => sprintf(
'Génération de %d jeton(s) de terrain par le Service Enfance.',
count($produits)
),
'contexte' => ['nombre' => count($produits), 'canal' => 'DELEGATION_SERVICE_ENFANCE'],
]);
JsonResponse::send(200, [
'message' => $produits === []
? 'Tous les agents et tous les lieux actifs disposent déjà d’un compte.'
: sprintf('%d jeton(s) généré(s). Distribuez-les maintenant : ils ne seront plus affichés.', count($produits)),
'jetons' => $produits,
]);
}
/**
* Relit un compte et REFUSE tout ce qui sort du périmètre délégué.
*
* Le refus est un 403 explicite et non un 404 : masquer l'existence du
* compte n'apporterait rien (le Service Enfance sait parfaitement que la DSI
* existe) et rendrait le message incompréhensible à l'utilisateur de bonne
* foi qui a suivi un lien périmé.
*
* @return array<string, mixed> Le compte, garanti dans le périmètre.
*/
private function compteDelegue(int $compteId): array
{
$compte = $this->comptes->trouver($compteId);
if ($compte === null) {
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
}
if (!in_array((string) $compte['role'], self::ROLES_DELEGUES, true)) {
Journal::refus('COMPTE_HORS_PERIMETRE', 'Tentative de gestion d’un compte privilégié hors délégation.', [
'cible_table' => 'compte_acces',
'cible_id' => $compteId,
'contexte' => ['role_cible' => $compte['role'], 'statut_http' => 403],
]);
JsonResponse::send(403, [
'error' => 'Les comptes Service Enfance et DSI relèvent de la seule DSI.',
'code' => 'ACCESS_DENIED',
]);
}
return $compte;
}
/** Refuse un rôle demandé hors périmètre délégué, avant toute écriture. */
private function assertRoleDelegue(string $role): void
{
if (in_array($role, self::ROLES_DELEGUES, true)) {
return;
}
Journal::refus('ROLE_HORS_PERIMETRE', 'Tentative de création d’un compte hors délégation.', [
'cible_table' => 'compte_acces',
'cible_id' => null,
'contexte' => ['role_demande' => $role, 'statut_http' => 403],
]);
JsonResponse::send(403, [
'error' => 'Vous ne pouvez créer que des comptes Agent et Responsable de structure. Les comptes Service Enfance et DSI relèvent de la DSI.',
'code' => 'ACCESS_DENIED',
]);
}
/**
* Rôles proposés à la création, avec leur libellé.
*
* @return array<int, array{code: string, libelle: string, identite: string}>
*/
private function rolesDelegues(): array
{
return [
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
];
}
}