update
This commit is contained in:
394
app/Controllers/DsiController.php
Normal file
394
app/Controllers/DsiController.php
Normal file
@@ -0,0 +1,394 @@
|
||||
<?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 App\Models\JournalModel;
|
||||
use DomainException;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Espace DSI (V7.11) : comptes d'accès, jetons et journal d'activité.
|
||||
*
|
||||
* C'est de l'administration TECHNIQUE, pas fonctionnelle. La DSI distribue les
|
||||
* accès et lit les traces ; elle ne voit ni planning, ni quota, ni agent —
|
||||
* acces_role_page ne lui ouvre qu'une seule page.
|
||||
*
|
||||
* DEUX RÈGLES PROPRES À CE CONTRÔLEUR :
|
||||
* 1. un jeton en clair ne sort d'ici QU'AU MOMENT où il vient d'être produit
|
||||
* (création, régénération, génération en lot). Aucune lecture ne le
|
||||
* retourne, parce que la base ne l'a plus ;
|
||||
* 2. toute action y est journalisée en catégorie ADMINISTRATION, y compris la
|
||||
* purge du journal — et sans jamais recopier le jeton dans la trace.
|
||||
*/
|
||||
final class DsiController
|
||||
{
|
||||
private Access $access;
|
||||
private CompteModel $comptes;
|
||||
private JournalModel $journal;
|
||||
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
$this->access = new Access($pdo);
|
||||
$this->comptes = new CompteModel($pdo);
|
||||
$this->journal = new JournalModel($pdo);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_comptes.php — comptes d'accès et identités sans compte.
|
||||
*
|
||||
* Paramètres GET : role, actif, q.
|
||||
*/
|
||||
public function comptes(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$comptes = $this->comptes->lister([
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'actif' => (string) ($_GET['actif'] ?? ''),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'comptes' => $comptes,
|
||||
'manquants' => $this->comptes->manquants(),
|
||||
// Tant qu'un compte porte encore le jeton public de la migration,
|
||||
// l'interface doit le crier : c'est une porte ouverte documentée.
|
||||
'jeton_installation_actif' => $this->comptes->installationEnCours(),
|
||||
'roles' => $this->rolesDisponibles(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_creer.php — crée un compte et renvoie son jeton.
|
||||
*
|
||||
* Charge utile : role, agent_id, structure_id, libelle, commentaire,
|
||||
* csrf_token.
|
||||
*/
|
||||
public function creerCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$role = strtoupper(trim((string) ($payload['role'] ?? '')));
|
||||
$agentId = filter_var($payload['agent_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$structureId = filter_var($payload['structure_id'] ?? null, FILTER_VALIDATE_INT) ?: null;
|
||||
$libelle = trim((string) ($payload['libelle'] ?? ''));
|
||||
$commentaire = trim((string) ($payload['commentaire'] ?? ''));
|
||||
|
||||
try {
|
||||
$resultat = $this->comptes->creer(
|
||||
$role,
|
||||
$agentId !== null ? (int) $agentId : null,
|
||||
$structureId !== null ? (int) $structureId : null,
|
||||
$libelle,
|
||||
$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).',
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
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/dsi_compte_jeton.php — régénère le jeton d'un compte.
|
||||
*
|
||||
* L'ancien jeton cesse de fonctionner immédiatement : c'est la procédure à
|
||||
* suivre pour un jeton perdu comme pour un jeton compromis.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function regenererJeton(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$jeton = $this->comptes->regenererJeton($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(404, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
$compte = $this->comptes->trouver($compteId) ?? [];
|
||||
Journal::administration('JETON_REGENERE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Régénération du jeton de « %s » : l’ancien jeton est révoqué.',
|
||||
(string) ($compte['identite_libelle'] ?? 'compte inconnu')
|
||||
),
|
||||
'contexte' => ['prefixe' => $compte['jeton_prefixe'] ?? null],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => 'Nouveau jeton généré. L’ancien ne fonctionne plus.',
|
||||
'compte' => $compte,
|
||||
'jeton' => $jeton,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_mot_de_passe.php — efface le mot de passe d'un compte
|
||||
* privilégié.
|
||||
*
|
||||
* La DSI ne REMPLACE pas le mot de passe, elle le RETIRE : le titulaire en
|
||||
* choisira un nouveau à sa prochaine connexion, avec son jeton. C'est ce qui
|
||||
* garantit que personne d'autre que lui ne l'a jamais connu — y compris la
|
||||
* personne qui vient de le réinitialiser.
|
||||
*
|
||||
* Charge utile : compte_id, csrf_token.
|
||||
*/
|
||||
public function reinitialiserMotDePasse(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$compteId = (int) (filter_var($payload['compte_id'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
|
||||
try {
|
||||
$compte = $this->comptes->reinitialiserMotDePasse($compteId);
|
||||
} catch (DomainException $e) {
|
||||
JsonResponse::send(422, ['error' => $e->getMessage()]);
|
||||
return;
|
||||
}
|
||||
|
||||
Journal::administration('MOT_DE_PASSE_REINITIALISE', [
|
||||
'cible_table' => 'compte_acces',
|
||||
'cible_id' => $compteId,
|
||||
'description' => sprintf(
|
||||
'Mot de passe de « %s » effacé : le titulaire en définira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf(
|
||||
'Mot de passe effacé pour %s. Son jeton reste valable : il en choisira un nouveau à sa prochaine connexion.',
|
||||
(string) $compte['identite_libelle']
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_compte_statut.php — active, désactive ou supprime un compte.
|
||||
*
|
||||
* La suppression efface le compte mais PAS ses traces : le journal conserve
|
||||
* le libellé figé de l'acteur (clé étrangère en ON DELETE SET NULL).
|
||||
*
|
||||
* Charge utile : compte_id, action (ACTIVER | DESACTIVER | SUPPRIMER),
|
||||
* csrf_token.
|
||||
*/
|
||||
public function changerStatutCompte(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
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 : après une suppression, il n'y
|
||||
// aurait plus de libellé à écrire dans le journal.
|
||||
$compte = $this->comptes->trouver($compteId);
|
||||
if ($compte === null) {
|
||||
JsonResponse::send(404, ['error' => 'Compte introuvable.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Se retirer soi-même son propre accès en pleine session est une erreur
|
||||
// de manipulation, jamais une intention : on la refuse.
|
||||
if ($compteId === $this->access->accountId() && $action !== 'ACTIVER') {
|
||||
JsonResponse::send(422, ['error' => 'Vous ne pouvez pas désactiver ni supprimer le compte avec lequel vous êtes connecté.']);
|
||||
}
|
||||
|
||||
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).',
|
||||
$libelles[$action],
|
||||
(string) $compte['identite_libelle'],
|
||||
(string) $compte['role']
|
||||
),
|
||||
'contexte' => ['role' => $compte['role'], 'prefixe' => $compte['jeton_prefixe']],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%s effectuée pour %s.', $libelles[$action], (string) $compte['identite_libelle']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_comptes_manquants.php — équipe d'un coup toutes les identités
|
||||
* actives sans compte.
|
||||
*
|
||||
* C'est l'outil de MISE EN SERVICE : à l'installation, aucun agent ni
|
||||
* responsable n'a de jeton, donc personne ne peut entrer. La réponse porte
|
||||
* tous les jetons produits, en clair — c'est la seule occasion de les lire,
|
||||
* et elle est faite pour être imprimée ou copiée avant de refermer l'écran.
|
||||
*
|
||||
* Charge utile : csrf_token.
|
||||
*/
|
||||
public function genererJetonsManquants(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
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) pour les identités sans compte.', count($produits)),
|
||||
'contexte' => ['nombre' => count($produits)],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => $produits === []
|
||||
? 'Toutes les identités actives 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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET api/dsi_journal.php — recherche paginée dans le journal.
|
||||
*
|
||||
* Paramètres GET : categorie, resultat, role, id_compte, q, du, au, page,
|
||||
* taille.
|
||||
*/
|
||||
public function journal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
|
||||
$resultat = $this->journal->rechercher([
|
||||
'categorie' => (string) ($_GET['categorie'] ?? ''),
|
||||
'resultat' => (string) ($_GET['resultat'] ?? ''),
|
||||
'role' => (string) ($_GET['role'] ?? ''),
|
||||
'id_compte' => (int) ($_GET['id_compte'] ?? 0),
|
||||
'q' => (string) ($_GET['q'] ?? ''),
|
||||
'du' => (string) ($_GET['du'] ?? ''),
|
||||
'au' => (string) ($_GET['au'] ?? ''),
|
||||
'page' => (int) ($_GET['page'] ?? 1),
|
||||
'taille' => (int) ($_GET['taille'] ?? JournalModel::TAILLE_PAGE),
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, array_merge($resultat, [
|
||||
'statistiques' => $this->journal->statistiques(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST api/dsi_journal_purge.php — supprime les entrées les plus anciennes.
|
||||
*
|
||||
* La purge est elle-même journalisée, et l'est APRÈS coup : sa propre trace
|
||||
* ne peut donc pas être emportée par la purge qu'elle décrit.
|
||||
*
|
||||
* Charge utile : jours (minimum 30), csrf_token.
|
||||
*/
|
||||
public function purgerJournal(): void
|
||||
{
|
||||
$this->access->requireDsi();
|
||||
Request::requireMethod('POST');
|
||||
$payload = Request::json();
|
||||
Csrf::assertPayload($payload);
|
||||
|
||||
$jours = (int) (filter_var($payload['jours'] ?? null, FILTER_VALIDATE_INT) ?: 0);
|
||||
if ($jours < 30) {
|
||||
JsonResponse::send(422, ['error' => 'La purge ne peut pas descendre sous 30 jours de conservation.']);
|
||||
}
|
||||
|
||||
$supprimees = $this->journal->purger($jours);
|
||||
|
||||
Journal::administration('JOURNAL_PURGE', [
|
||||
'cible_table' => 'journal_activite',
|
||||
'cible_id' => null,
|
||||
'description' => sprintf('Purge du journal : %d entrée(s) de plus de %d jours supprimée(s).', $supprimees, $jours),
|
||||
'contexte' => ['jours' => $jours, 'supprimees' => $supprimees],
|
||||
]);
|
||||
|
||||
JsonResponse::send(200, [
|
||||
'message' => sprintf('%d entrée(s) supprimée(s).', $supprimees),
|
||||
'supprimees' => $supprimees,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rôles proposés à la création d'un compte, avec leur libellé.
|
||||
*
|
||||
* @return array<int, array{code: string, libelle: string, identite: string}>
|
||||
*/
|
||||
private function rolesDisponibles(): array
|
||||
{
|
||||
return [
|
||||
['code' => Access::ROLE_AGENT, 'libelle' => 'Agent', 'identite' => 'AGENT'],
|
||||
['code' => Access::ROLE_RESPONSABLE, 'libelle' => 'Responsable de structure', 'identite' => 'STRUCTURE'],
|
||||
['code' => Access::ROLE_SERVICE, 'libelle' => 'Service Enfance (administrateur)', 'identite' => 'LIBELLE'],
|
||||
['code' => Access::ROLE_DSI, 'libelle' => 'DSI', 'identite' => 'LIBELLE'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user