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 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 */ private function rolesDelegues(): array { return [ ['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'], ['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'], ]; } }