Base de travail V7.8 reprise du répertoire de travail

Commit technique : reproduit à l'identique l'état non commité de la copie
de travail (sécurisation V7.8, CSP à nonce, Session, migration des droits)
afin que le travail d'ergonomie qui suit soit isolable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PTA
2026-08-10 16:32:10 +02:00
parent 57bd8da5c8
commit 66cc8c8ade
30 changed files with 1015 additions and 141 deletions

22
.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
# Le Dockerfile fait COPY . /var/www/html : tout ce qui est listé ici reste
# hors de l'image, donc hors de la racine web et hors des couches publiées.
.env
.env.*
!.env.example
.git
.gitignore
.dockerignore
# Historique et documentation de version : inutiles dans l'image.
README*.md
CLAUDE.md
# Journaux, sauvegardes et artefacts locaux
*.log
*.sql.gz
*.dump
logs/
tmp/
.idea/
.vscode/
.DS_Store

View File

@@ -4,12 +4,17 @@ APP_PORT=8080
TZ=Europe/Paris
# MariaDB
# Remplacer impérativement les deux mots de passe avant tout déploiement.
DB_NAME=pta
DB_USER=pta
DB_PASSWORD=pta
DB_ROOT_PASSWORD=pta
DB_PASSWORD=a_remplacer
DB_ROOT_PASSWORD=a_remplacer_aussi
# Débogage : 1 renvoie le détail des exceptions au navigateur. Laisser à 0.
PTA_DEBUG=0
# phpMyAdmin
# 127.0.0.1 = uniquement accessible depuis le serveur lui-même.
# phpMyAdmin demande désormais un identifiant MariaDB à chaque connexion.
PMA_BIND=127.0.0.1
PMA_PORT=8081

28
.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Configuration locale : contient les mots de passe MariaDB.
# Seul .env.example doit être versionné.
.env
.env.*
!.env.example
# Dumps et sauvegardes de base
*.sql.gz
*.dump
database/backup/
database/*_backup*.sql
# Journaux et fichiers temporaires
*.log
logs/
tmp/
# Dépendances éventuelles
/vendor/
/node_modules/
# Artefacts d'éditeur et de système
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*~

188
CLAUDE.md Normal file
View File

@@ -0,0 +1,188 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Contexte
PTA (Planning de Travail Annualisé) — application PHP 8.3 multipage (MVC maison) pour la
planification annualisée des agents d'accueils de loisirs périscolaires/extrascolaires.
**Aucune dépendance** : pas de Composer, pas de npm, pas de framework, pas de bundler, pas de
suite de tests. Uniquement PHP + PDO/MySQL + JavaScript vanilla. Ne pas introduire de dépendance
sans raison forte : le PDF (`app/Services/SimplePdf.php`) et l'autoloader (`app/autoload.php`) sont
volontairement réécrits à la main.
**Tout est en français** : identifiants SQL (`creneau_horaire`, `id_agent`, `statut`), messages
utilisateur, commentaires. Conserver cette langue pour tout nouveau code.
## Commandes
```bash
# Démarrage complet (nginx :8080, phpMyAdmin :8081, MariaDB, php-fpm)
cp .env.example .env # puis renseigner DB_PASSWORD / DB_ROOT_PASSWORD
docker compose up -d --build
# IMPORTANT : le code source est COPIÉ dans l'image au build (Dockerfile), il n'est pas monté.
# Toute modification PHP/JS/CSS exige un rebuild des DEUX services :
# (nginx sert lui-même les assets statiques depuis sa propre copie de /var/www/html)
docker compose up -d --build php nginx
docker compose logs -f php nginx db
docker compose ps
docker compose down # arrêt en conservant les données
docker compose down -v # DÉTRUIT le volume MariaDB
# Accès SQL direct
docker compose exec db mariadb -upta -p pta
# Vérification syntaxique (il n'existe ni linter ni tests automatisés)
find . -name '*.php' -not -path './.git/*' -exec php -l {} \;
```
Le schéma `database/pta_prod_schema_mariadb.sql` n'est joué **qu'à la création initiale du volume**
MariaDB (`docker-entrypoint-initdb.d`). Sur une base existante, toute évolution de schéma doit être
appliquée manuellement en SQL — il n'y a pas d'outil de migration.
## Architecture
### Deux types de points d'entrée, tous à la racine ou dans `api/`
Il n'y a pas de routeur. Chaque URL correspond à un fichier PHP réel de 5 à 10 lignes.
**Pages HTML** — `planning.php`, `agents.php`, `annual.php`, `pta.php`, `teams.php`, `coverage.php`,
`structures.php`, `pending.php`, `vacations.php`, `administration.php` :
```php
require __DIR__ . '/page_bootstrap.php';
runPage('planning'); // -> PageController::planning()
```
`page_bootstrap.php` attrape les `Throwable` et affiche une page d'erreur HTML.
**Endpoints JSON/PDF** — `api/*.php` plus `save_planning.php`, `validate_planning.php`,
`reopen_planning.php` (ces trois-là sont restés à la racine pour raisons historiques) :
```php
JsonResponse::bootstrap(); // handlers d'erreur -> JSON, jamais du HTML
$pdo = require dirname(__DIR__) . '/app/bootstrap.php';
(new PlanningController($pdo))->show();
```
`JsonResponse::bootstrap()` convertit warnings et exceptions en réponses JSON 500 — indispensable,
car le JS refuse toute réponse non-JSON. **Les endpoints PDF ne l'appellent pas** (ils émettent des
octets binaires via `PdfResponse::inline()`).
Pour ajouter un endpoint : créer le fichier mince dans `api/`, ajouter la méthode au contrôleur.
Pour ajouter une page : fichier racine + méthode dans `PageController` + vue `app/Views/pages/` +
partiel `app/Views/partials/` + module JS + **inscription de la clé dans `Access::allowedPages()`
et dans le tableau `$navigation` de `app/Views/layouts/header.php`**.
### Chaîne d'un appel
```
api/x.php → Controller (Access + Csrf + Request) → Model (SQL PDO) → JsonResponse::send()
page.php → PageController → Models → Views/pages/x.php
├── layouts/header.php (nav filtrée par rôle)
├── partials/x.php (markup)
└── layouts/footer.php (injecte window.PTA_CONFIG + scripts)
```
`app/bootstrap.php` démarre la session, ouvre le PDO (`db.php` ← `config.php` ← variables
d'environnement `PTA_DB_*`) et appelle `SchemaModel::assertCurrent()` **à chaque requête**. Ajouter
une table ou une colonne obligatoire impose de mettre à jour les listes de `SchemaModel`, sinon
l'application entière refuse de démarrer.
### Rôles et périmètres (`app/Core/Access.php`)
Trois rôles stockés en session : `AGENT` (lecture seule sur soi), `RESPONSABLE_STRUCTURE` (édition
des brouillons de son seul lieu), `SERVICE_ENFANCE` (tout, seul habilité à valider).
`role.php` est un **sélecteur de rôle, pas une authentification** — l'utilisateur choisit librement
son identité. C'est la faille ouverte la plus importante du projet ; toute évolution vers la
production doit remplacer cette sélection par un compte authentifié, sans toucher au reste de la
mécanique de droits.
**`Access.php` ne contient aucune règle de droit** : tout est lu dans les vues SQL créées par
`database/migration_v7_8_securite_acces.sql` (voir `README_V7_8.md`). Modifier un droit se fait en
SQL dans `acces_role_page` / `acces_role_permission`, sans redéploiement. Une permission absente de
la base vaut « refusé ». `SchemaModel` vérifie la présence des sept vues au démarrage et fait
échouer l'application si la migration manque — c'est volontairement fail-closed.
Les contrôles sont doublés : l'UI masque, mais le serveur tranche. Toute méthode de contrôleur
commence par un garde `requireService()`, `requireDraftEditor()`, `requireStructureEdit($id)`,
`requireAgentView($id)`, `requireEntryEdit($id)`, `requirePlanningEdit($id)`… Ces gardes émettent
directement un 401/403 JSON et coupent l'exécution. **Ne jamais ajouter un endpoint mutant sans
garde ni sans `Csrf::assertPayload($payload)`.**
CSRF : le jeton voyage **dans le corps JSON** (`csrf_token`), pas dans un en-tête. Côté JS :
`csrf_token: window.PTA_CONFIG.csrfToken`.
### Contraintes de sécurité à respecter
- **CSP à nonce** (`app/Core/Security.php`) : `script-src 'self' 'nonce-…'`, sans `unsafe-inline`.
Tout `<script>` inline ajouté à une vue doit porter `nonce="<?= $cspNonce ?>"`, et aucun
`onclick=` / `style=` inline ne fonctionnera.
- **`PTA.utils.escapeHtml` échappe aussi les guillemets** et est donc valable en contexte texte
comme en contexte attribut. Ne pas la réimplémenter localement : c'est une variante incomplète
qui avait introduit un XSS stocké.
- **Ordre des blocs nginx** : nginx retient la première regex correspondante. Tout nouveau refus
doit être placé **avant** `location ~ \.php$`, sinon il est sans effet.
- `PTA_DEBUG` reste à `0`. Les détails d'exception passent par `Security::reportThrowable()`, qui
journalise côté serveur et ne renvoie rien au client.
- `session_start()` ne doit jamais être appelé directement : passer par `App\Core\Session::start()`.
### JavaScript
Pas de bundler ni de module ES. Chaque fichier est une IIFE qui lit le global `window.PTA` publié
par `core.js` (`config`, `elements`, `state`, `shared`, `utils`, `modules`) et s'enregistre :
```js
PTA.modules.agentOverview = { init, load };
```
`PageController` décide quels scripts et quels modules charger (tableaux `scripts` / `modules`),
`footer.php` les injecte, puis `page-bootstrap.js` appelle `init()` sur chaque module déclaré et
applique les filtres d'URL (`structure_id`, `agent_id`, `week`, `month`, `focus`). Les modules se
rafraîchissent entre eux via l'appel optionnel `PTA.modules.autre?.refresh?.({ silent: true })`.
Les assets sont versionnés à la main : `assets/style.css?v=7.7` et `assets/js/*.js?v=7.7` dans
`layouts/header.php` / `layouts/footer.php`. **Bumper ce numéro après toute modification de JS/CSS**,
sinon les navigateurs et le `expires 7d` de nginx servent l'ancienne version.
### Modèle métier
- Le **quota annuel** suit l'**année civile** (01/01 → 31/12), jamais l'année scolaire ni la date de
contrat : 1 607 h de référence proratisées par la quotité (`QuotaModel::REFERENCE_MINUTES`).
- Un `planning` = un agent × une semaine ISO × un lieu, statut `BROUILLON` ou `VALIDE`. Il contient
des `creneau_horaire` porteurs d'un `motif_planning`. Le flag `compte_dans_quota` du motif décide
de l'imputation (CONGE, AUTRE_ABSENCE et JNT ne comptent pas).
- Un agent peut être planifié sur plusieurs lieux la même semaine ; `id_structure` sur l'agent n'est
qu'un rattachement par défaut. D'où les contrôles de chevauchement inter-structures.
- Les vacances scolaires (`periode_vacance`) déterminent le contexte périscolaire/extrascolaire ;
elles sont importées depuis l'API publique data.education.gouv.fr via
`SchoolHolidayApiService` (le conteneur php a donc besoin d'un accès sortant).
### Piège : logique de validation dupliquée
`database/pta_prod_schema_mariadb.sql` définit des vues (`v_suivi_quota_annuel`,
`v_plannings_en_attente_validation`, …) et des procédures (`sp_controler_planning`,
`sp_valider_planning`) — **aucune n'est appelée par le code PHP**. Les contrôles réellement
appliqués vivent dans `PlanningController::validate()`, `AnnualOverviewController::validateYear()`
et `PtaModel`. Modifier une règle métier impose de traiter le PHP ; le SQL correspondant est du
patrimoine à ne pas prendre pour la source de vérité.
Les contrôles renvoyés au client suivent partout la forme `{niveau: ERREUR|ALERTE|INFO, code,
message}` : une `ERREUR` bloque (422), une `ALERTE` demande une confirmation explicite
(409 + rappel avec `force_quota: true`).
## Points d'attention
- nginx interdit l'accès direct à `/app/`, `/database/`, `/docker/`, aux fichiers d'amorçage et aux
extensions de configuration (`docker/nginx/default.conf`) — ces règles doivent suivre toute
réorganisation de l'arborescence.
- phpMyAdmin n'écoute que sur `127.0.0.1` et exige un identifiant MariaDB. Ne jamais redéfinir
`PMA_USER` / `PMA_PASSWORD` dans le compose : cela rouvre une session sans mot de passe.
- Le mot de passe MariaDB du `.env` est encore la valeur d'exemple : à changer avant tout
déploiement réel.
- Les `README_V7_*.md` documentent chacun une version successive et servent d'historique métier —
utiles pour comprendre une décision, mais seul le code fait foi.

View File

@@ -64,6 +64,12 @@ Voir `README_V7.md` pour le nouveau socle PTA annuel, les équipes des mercredis
La page `annual.php` reproduit l’esprit du tableau annuel historique : douze mois côte à côte, codes AP/PP/AE/PE, couleurs métier, vacances scolaires et accès direct à la semaine à corriger. Voir `README_V7_2.md`.
## V7.8 — Sécurité et droits en base
Les correctifs de sécurité et le déplacement de la matrice des droits dans des
vues SQL sont décrits dans `README_V7_8.md`. La migration
`database/migration_v7_8_securite_acces.sql` est obligatoire.
## V7.4 — Rôles et droits d’accès
La première arrivée ouvre `role.php`. Les droits Agent, Responsable de structure et Service Enfance sont vérifiés dans les pages et les contrôleurs API. Voir `README_V7_4.md`.

View File

@@ -32,7 +32,12 @@ Application :
http://IP_DU_SERVEUR:8080
```
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut.
phpMyAdmin est volontairement publié sur `127.0.0.1:8081` par défaut et **exige
un identifiant MariaDB** à chaque connexion. Ne pas définir `PMA_USER` /
`PMA_PASSWORD` dans le compose : ces variables font basculer phpMyAdmin en
`auth_type=config`, c'est-à-dire une session déjà ouverte pour quiconque
atteint le port.
Sur le serveur lui-même :
```text

134
README_V7_8.md Normal file
View File

@@ -0,0 +1,134 @@
# PTA V7.8 — Correctifs de sécurité et descente des droits en base
Cette version ne change aucune règle métier. Elle corrige les failles relevées
lors de l'audit et déplace l'intégralité des règles d'accès dans la base.
## Failles corrigées
### 1. phpMyAdmin ouvert sans mot de passe (critique)
`docker-compose.yml` fournissait `PMA_USER` et `PMA_PASSWORD` au conteneur
phpMyAdmin. Ces deux variables font basculer phpMyAdmin en `auth_type=config` :
toute personne atteignant le port arrivait sur une session **déjà
authentifiée**, avec accès complet en lecture et en écriture à la base. Le
`.env` publiait de surcroît ce port sur `0.0.0.0`, donc sur tout le réseau.
Les deux variables ont été retirées et le port est revenu sur `127.0.0.1`.
phpMyAdmin réclame désormais un identifiant MariaDB à chaque connexion. Pour y
accéder depuis un poste distant : `ssh -L 8081:127.0.0.1:8081 utilisateur@serveur`.
### 2. XSS stocké via les attributs HTML
`PTA.utils.escapeHtml` s'appuyait sur `textContent` / `innerHTML`, qui
n'échappent ni `"` ni `'`. La fonction était pourtant utilisée dans une
vingtaine de contextes d'attribut (`value="…"`, `title="…"`, `aria-label="…"`).
Un libellé contenant un guillemet permettait de sortir de l'attribut et
d'injecter du script.
Les vecteurs réalistes étaient les libellés de vacances scolaires — importés
depuis une API externe — ainsi que les noms de lieux, les noms d'agents, les
libellés de diplôme et les commentaires PTA.
`escapeHtml` échappe désormais les cinq caractères et reste valable en contexte
texte comme en contexte attribut. L'implémentation dupliquée `escapeAttr` de
`annual-overview.js` a été supprimée au profit de la fonction commune.
### 3. Règles de refus nginx sans effet
Les blocs interdisant `config.php`, `db.php` et `page_bootstrap.php` étaient
placés **après** `location ~ \.php$`. nginx retenant la première expression
régulière correspondante, ces refus n'ont jamais été appliqués : les trois
fichiers d'amorçage étaient exécutables depuis le navigateur.
L'ordre des blocs a été corrigé et la couverture élargie (`/docker/`, fichiers
`.env`, `.sql`, `.ini`, `.log`, `.md`).
### 4. Divulgation d'informations techniques
`PTA_DEBUG` valait `1` par défaut : le message des exceptions — requêtes SQL,
noms de tables, chemins serveur — était renvoyé au navigateur. Les pages
d'erreur de `page_bootstrap.php` et de `role.php` affichaient de la même façon
`getMessage()`, et `db.php` y ajoutait l'hôte, l'utilisateur et le nom de la
base issus du message PDO.
Le mode debug est désormais désactivé par défaut. Tout détail technique est
journalisé côté serveur via `App\Core\Security::reportThrowable()` et n'atteint
le client que si `PTA_DEBUG` est explicitement activé.
### 5. Absence d'en-têtes de sécurité
Les réponses HTML portent maintenant une `Content-Security-Policy` **à nonce** :
`script-src 'self' 'nonce-…'`, sans `unsafe-inline`. PTA ne contient aucun
gestionnaire d'événement inline ni style inline, la politique peut donc rester
stricte. S'y ajoutent `X-Content-Type-Options`, `X-Frame-Options`,
`Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy` et, en
HTTPS, `Strict-Transport-Security`.
Tout nouveau `<script>` inline doit porter `nonce="<?= $cspNonce ?>"`, sinon le
navigateur refusera de l'exécuter.
### 6. Session et CSRF
`session_start()` était appelé à trois endroits sans paramètres de cookie.
`App\Core\Session::start()` centralise le démarrage et impose `HttpOnly`,
`SameSite=Lax`, `use_strict_mode` et `secure` en HTTPS — y compris hors du
conteneur Docker, où le `php.ini` fourni ne s'applique pas.
Le changement de rôle (`role.php?action=reset`), qui modifiait l'état de la
session via un simple GET, exige désormais le jeton CSRF. Le jeton est
régénéré à chaque changement de rôle.
### 7. Secrets versionnables
Le dépôt n'avait ni `.gitignore` ni `.dockerignore`. Le `.env` contenant les
mots de passe MariaDB était prêt à être commité, et `COPY . /var/www/html`
copiait `.env` et `.git` dans l'image publiée. Les deux fichiers ont été créés.
## Descente des droits en base
`app/Core/Access.php` ne contient plus aucune règle : la matrice des droits et
les périmètres de visibilité sont lus dans des vues SQL.
| Vue | Rôle |
| ------------------------------ | ----------------------------------------------------------- |
| `v_acces_role_page` | pages autorisées par rôle |
| `v_acces_role_permission` | permissions fonctionnelles par rôle |
| `v_acces_identite_agent` | identités d'agents actifs sélectionnables |
| `v_acces_identite_structure` | lieux d'affectation actifs sélectionnables |
| `v_acces_agent_par_structure` | quels agents un lieu peut consulter (rattachement ou planif.) |
| `v_acces_perimetre_creneau` | lieu et planning d'un créneau, avant modification |
| `v_acces_perimetre_planning` | lieu et agent d'un planning, avant validation |
Les deux premières s'appuient sur les tables `acces_role_page` et
`acces_role_permission` : **modifier un droit se fait désormais en SQL, sans
redéploiement**. Une permission absente de la base vaut « refusé ».
`SchemaModel::assertCurrent()` vérifie la présence des sept vues au démarrage.
Si la migration n'a pas été jouée, l'application refuse de démarrer plutôt que
de tourner sans contrôle de périmètre.
## Installation
1. Sauvegarder la base.
2. Exécuter une seule fois :
```sql
SOURCE database/migration_v7_8_securite_acces.sql;
```
3. Reconstruire les images, phpMyAdmin compris :
```bash
docker compose up -d --build php nginx
docker compose up -d --force-recreate phpmyadmin
```
## Reste à traiter
Le sélecteur de `role.php` n'est toujours **pas une authentification** :
n'importe quel visiteur peut choisir « Service Enfance » et obtenir le contrôle
complet de l'application. Tant que ce point n'est pas traité, aucune des
corrections ci-dessus ne protège une instance exposée sur Internet.
Le mot de passe MariaDB doit également être changé : le `.env` reprend encore la
valeur d'exemple.

View File

@@ -9,6 +9,7 @@ use App\Core\Csrf;
use App\Core\JsonResponse;
use App\Core\PdfResponse;
use App\Core\Request;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\QuotaModel;
use App\Models\StructureModel;
@@ -410,7 +411,7 @@ final class AgentController
} catch (Throwable $e) {
JsonResponse::send(500, [
'error' => 'Impossible de créer l’agent.',
'details' => (getenv('PTA_DEBUG') ?: '1') !== '0' ? $e->getMessage() : null,
'details' => Security::debugEnabled() ? $e->getMessage() : null,
]);
}

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\MotifModel;
use App\Models\StructureModel;
@@ -177,10 +178,12 @@ final class PageController extends Controller
private function renderPage(string $view, string $title, string $activePage, array $data = []): void
{
Security::sendHtmlHeaders();
$profile = $this->access->profile();
$this->render('pages/' . $view, array_merge([
'pageTitle' => $title,
'activePage' => $activePage,
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'scripts' => [],
'modules' => [],

View File

@@ -7,6 +7,7 @@ namespace App\Controllers;
use App\Core\Access;
use App\Core\Controller;
use App\Core\Csrf;
use App\Core\Security;
use App\Models\AgentModel;
use App\Models\StructureModel;
use PDO;
@@ -21,8 +22,13 @@ final class RoleController extends Controller
{
$access = new Access($this->pdo);
// Le changement de rôle modifie l'état de la session : il est protégé
// par le jeton CSRF au même titre que les écritures de l'API.
if (($_GET['action'] ?? '') === 'reset') {
$access->clear();
$provided = (string) ($_GET['csrf_token'] ?? '');
if ($provided !== '' && hash_equals(Csrf::ensureToken(), $provided)) {
$access->clear();
}
header('Location: role.php', true, 302);
exit;
}
@@ -47,8 +53,10 @@ final class RoleController extends Controller
}
}
Security::sendHtmlHeaders();
$this->render('pages/role', [
'pageTitle' => 'Choisir votre accès',
'cspNonce' => Security::nonce(),
'csrfToken' => Csrf::ensureToken(),
'agents' => (new AgentModel($this->pdo))->allActive(),
'structures' => (new StructureModel($this->pdo))->allActive(),

View File

@@ -6,6 +6,14 @@ 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';
@@ -14,11 +22,15 @@ final class Access
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)
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
Session::start();
}
public function isConfigured(): bool
@@ -85,13 +97,16 @@ final class Access
$selection['structure_id'] = $structureId;
}
$_SESSION[self::SESSION_KEY] = $selection;
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]);
unset($_SESSION[self::SESSION_KEY], $_SESSION['csrf_token']);
session_regenerate_id(true);
}
@@ -128,39 +143,61 @@ final class Access
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();
$service = $role === self::ROLE_SERVICE;
$responsable = $role === self::ROLE_RESPONSABLE;
if ($role === null) {
return [];
}
return [
'read_only' => $role === self::ROLE_AGENT,
'can_edit_draft' => $service || $responsable,
'can_edit_cross_structure' => $service,
'can_validate' => $service,
'can_reopen' => $service || $responsable,
'can_use_templates' => $service,
'can_copy_full_week' => $service,
'can_view_pending' => $service,
'can_manage_coverage' => $service,
'can_manage_agents' => $service,
'can_manage_structures' => $service,
'can_manage_vacations' => $service,
'can_manage_teams' => $service,
'can_manage_pta' => $service,
'can_forecast_next_year' => $service,
];
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
{
return match ($this->role()) {
self::ROLE_AGENT => ['agents', 'structures'],
self::ROLE_RESPONSABLE => ['planning', 'structures', 'agents', 'coverage'],
self::ROLE_SERVICE => ['planning', 'pending', 'coverage', 'structures', 'agents', 'annual', 'pta', 'teams', 'vacations', 'administration'],
default => [],
};
$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
@@ -248,10 +285,7 @@ final class Access
return;
}
$stmt = $this->pdo->prepare(
'SELECT p.id_structure
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
WHERE c.id_creneau = :id LIMIT 1'
'SELECT id_structure FROM v_acces_perimetre_creneau WHERE id_creneau = :id LIMIT 1'
);
$stmt->execute(['id' => $entryId]);
$structureId = $stmt->fetchColumn();
@@ -267,7 +301,9 @@ final class Access
if ($this->role() === self::ROLE_SERVICE) {
return;
}
$stmt = $this->pdo->prepare('SELECT id_structure FROM planning WHERE id_planning = :id LIMIT 1');
$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) {
@@ -293,14 +329,14 @@ final class Access
private function activeAgentExists(int $agentId): bool
{
$stmt = $this->pdo->prepare('SELECT 1 FROM agent WHERE id_agent = :id AND actif = TRUE LIMIT 1');
$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 structure WHERE id_structure = :id AND actif = TRUE LIMIT 1');
$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;
}
@@ -312,11 +348,9 @@ final class Access
return null;
}
$stmt = $this->pdo->prepare(
'SELECT a.id_agent, a.matricule, a.nom, a.prenom, a.id_structure,
s.nom AS structure_nom
FROM agent a
LEFT JOIN structure s ON s.id_structure = a.id_structure
WHERE a.id_agent = :id AND a.actif = TRUE LIMIT 1'
'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;
@@ -330,8 +364,8 @@ final class Access
}
$stmt = $this->pdo->prepare(
'SELECT id_structure, code, nom
FROM structure
WHERE id_structure = :id AND actif = TRUE LIMIT 1'
FROM v_acces_identite_structure
WHERE id_structure = :id LIMIT 1'
);
$stmt->execute(['id' => $structureId]);
return $stmt->fetch() ?: null;
@@ -344,25 +378,11 @@ final class Access
return false;
}
$stmt = $this->pdo->prepare(
'SELECT 1
FROM agent a
WHERE a.id_agent = :agent_id
AND a.actif = TRUE
AND (
a.id_structure = :structure_id_default
OR EXISTS (
SELECT 1 FROM planning p
WHERE p.id_agent = a.id_agent
AND p.id_structure = :structure_id_planning
)
)
'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_default' => $structureId,
'structure_id_planning' => $structureId,
]);
$stmt->execute(['agent_id' => $agentId, 'structure_id' => $structureId]);
return $stmt->fetchColumn() !== false;
}
}

View File

@@ -8,9 +8,7 @@ final class Csrf
{
public static function ensureToken(): string
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
Session::start();
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));

View File

@@ -21,18 +21,14 @@ final class JsonResponse
});
set_exception_handler(static function (\Throwable $exception): void {
error_log(sprintf(
'[PTA API] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
// Security::reportThrowable journalise systématiquement le détail
// côté serveur et ne le renvoie au client que si PTA_DEBUG est
// explicitement activé. Par défaut, rien ne fuit.
$payload = ['error' => 'Une erreur serveur est survenue.'];
if ((getenv('PTA_DEBUG') ?: '1') !== '0') {
if (Security::debugEnabled()) {
$payload['details'] = $exception->getMessage();
}
Security::reportThrowable($exception, 'Erreur API.');
self::send(500, $payload);
});
@@ -42,6 +38,8 @@ final class JsonResponse
{
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
}
http_response_code($status);

89
app/Core/Security.php Normal file
View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* En-têtes de sécurité des réponses HTML.
*
* La CSP est nominative : aucun script inline n'est autorisé sans le nonce
* généré à chaque requête. PTA ne contient aucun gestionnaire d'événement
* inline (onclick=, onchange=…) ni style inline, la politique peut donc rester
* stricte sans dégrader l'interface.
*/
final class Security
{
private static ?string $nonce = null;
public static function nonce(): string
{
if (self::$nonce === null) {
self::$nonce = base64_encode(random_bytes(16));
}
return self::$nonce;
}
public static function sendHtmlHeaders(): void
{
if (headers_sent()) {
return;
}
$nonce = self::nonce();
$policy = [
"default-src 'self'",
"base-uri 'self'",
"object-src 'none'",
"frame-ancestors 'none'",
"form-action 'self'",
"img-src 'self' data:",
"style-src 'self'",
"font-src 'self'",
// Aucune requête sortante depuis le navigateur : les appels à
// data.education.gouv.fr sont faits côté serveur.
"connect-src 'self'",
sprintf("script-src 'self' 'nonce-%s'", $nonce),
];
header('Content-Security-Policy: ' . implode('; ', $policy));
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Referrer-Policy: no-referrer');
header('Permissions-Policy: geolocation=(), camera=(), microphone=(), payment=()');
header('Cross-Origin-Opener-Policy: same-origin');
if (Session::isHttps()) {
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
}
/**
* Vrai uniquement si le mode debug a été activé explicitement.
* Par défaut, aucun détail technique ne sort du serveur.
*/
public static function debugEnabled(): bool
{
return in_array(strtolower((string) (getenv('PTA_DEBUG') ?: '0')), ['1', 'true', 'on', 'yes'], true);
}
/**
* Journalise l'exception côté serveur et renvoie le message à afficher.
* Le détail technique n'est rendu que si PTA_DEBUG est explicitement actif.
*/
public static function reportThrowable(\Throwable $exception, string $publicMessage): string
{
error_log(sprintf(
'[PTA] %s: %s in %s:%d',
get_class($exception),
$exception->getMessage(),
$exception->getFile(),
$exception->getLine()
));
return self::debugEnabled()
? $publicMessage . ' Détail : ' . $exception->getMessage()
: $publicMessage;
}
}

59
app/Core/Session.php Normal file
View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Démarrage unique et durci de la session PHP.
*
* Les mêmes garanties sont posées ici et dans docker/php/php.ini : le php.ini
* ne s'applique qu'au conteneur fourni, alors que ce code protège aussi les
* déploiements sur un serveur PHP existant dont la configuration est inconnue.
*/
final class Session
{
private static bool $started = false;
public static function start(): void
{
if (self::$started || session_status() === PHP_SESSION_ACTIVE) {
self::$started = true;
return;
}
if (!headers_sent()) {
ini_set('session.use_only_cookies', '1');
ini_set('session.use_strict_mode', '1');
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'domain' => '',
'secure' => self::isHttps(),
'httponly' => true,
'samesite' => 'Lax',
]);
}
session_start();
self::$started = true;
}
/**
* Vrai si la requête courante arrive en HTTPS, directement ou via un proxy
* de confiance. Sans cette détection, le cookie de session serait marqué
* « secure » sur une installation HTTP et la session ne fonctionnerait plus.
*/
public static function isHttps(): bool
{
if (($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string) $_SERVER['HTTPS']) !== 'off') {
return true;
}
if ((int) ($_SERVER['SERVER_PORT'] ?? 0) === 443) {
return true;
}
return strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
}
}

View File

@@ -78,5 +78,43 @@ final class SchemaModel extends BaseModel
));
}
}
$this->assertAccessViews();
}
/**
* Les vues d'accès portent l'intégralité des règles de droits. Si elles
* manquent, l'application doit refuser de démarrer plutôt que de tourner
* sans contrôle de périmètre.
*/
private function assertAccessViews(): void
{
$requiredViews = [
'v_acces_role_page',
'v_acces_role_permission',
'v_acces_identite_agent',
'v_acces_identite_structure',
'v_acces_agent_par_structure',
'v_acces_perimetre_creneau',
'v_acces_perimetre_planning',
];
$placeholders = implode(',', array_fill(0, count($requiredViews), '?'));
$stmt = $this->pdo->prepare(
"SELECT TABLE_NAME
FROM information_schema.VIEWS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ($placeholders)"
);
$stmt->execute($requiredViews);
$missing = array_values(array_diff($requiredViews, $stmt->fetchAll(\PDO::FETCH_COLUMN)));
if ($missing) {
throw new \RuntimeException(
'Contrôle d’accès indisponible : vue(s) manquante(s) : '
. implode(', ', $missing)
. '. Exécutez database/migration_v7_8_securite_acces.sql.'
);
}
}
}

View File

@@ -6,9 +6,10 @@
/** @var array $pageConfig */
/** @var array $accessProfile */
/** @var array $permissions */
/** @var string $cspNonce */
?>
</main>
<script>
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
window.PTA_CONFIG = {
csrfToken: <?= json_encode($csrfToken, JSON_THROW_ON_ERROR) ?>,
access: <?= json_encode($accessProfile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>,
@@ -27,10 +28,10 @@ window.PTA_PAGE_CONFIG = <?= json_encode([
'params' => $pageConfig,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE) ?>;
</script>
<script src="assets/js/core.js?v=7.7"></script>
<script src="assets/js/core.js?v=7.8"></script>
<?php foreach ($scripts as $script): ?>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.7"></script>
<script src="assets/js/<?= htmlspecialchars($script) ?>?v=7.8"></script>
<?php endforeach; ?>
<script src="assets/js/page-bootstrap.js?v=7.7"></script>
<script src="assets/js/page-bootstrap.js?v=7.8"></script>
</body>
</html>

View File

@@ -3,6 +3,7 @@
/** @var string $activePage */
/** @var array $allowedPages */
/** @var array $accessProfile */
/** @var string $csrfToken */
$navigation = [
'planning' => ['planning.php', 'Saisie'],
@@ -23,7 +24,7 @@ $navigation = [
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle) ?> — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.7">
<link rel="stylesheet" href="assets/style.css?v=7.8">
</head>
<body>
<main class="app-shell">
@@ -44,7 +45,7 @@ $navigation = [
<small><?= htmlspecialchars((string) $accessProfile['structure_name']) ?></small>
<?php endif; ?>
</div>
<a class="button button-ghost switch-role-link" href="role.php?action=reset">Changer de rôle</a>
<a class="button button-ghost switch-role-link" href="role.php?action=reset&amp;csrf_token=<?= htmlspecialchars(urlencode($csrfToken), ENT_QUOTES) ?>">Changer de rôle</a>
</div>
</header>

View File

@@ -4,6 +4,7 @@
/** @var array $structures */
/** @var string $error */
/** @var array|null $currentProfile */
/** @var string $cspNonce */
?>
<!doctype html>
<html lang="fr">
@@ -11,7 +12,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choisir votre accès — PTA</title>
<link rel="stylesheet" href="assets/style.css?v=7.4">
<link rel="stylesheet" href="assets/style.css?v=7.8">
</head>
<body class="role-page-body">
<main class="role-page-shell">
@@ -97,7 +98,7 @@
<strong>Version de démonstration :</strong> ce sélecteur organise les droits dans l’application, mais ne remplace pas une authentification nominative. En production, le rôle devra être associé au compte connecté.
</aside>
</main>
<script>
<script nonce="<?= htmlspecialchars($cspNonce, ENT_QUOTES) ?>">
(() => {
const agentContext = document.querySelector('#agent-context');
const responsableContext = document.querySelector('#responsable-context');

View File

@@ -10,7 +10,7 @@ $defaultCalendarYear = $currentYear;
<div class="section-heading">
<div>
<h2>Vacances scolaires</h2>
<p class="section-subtitle">Consultez toutes les vacances d’une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
<p class="section-subtitle">Consultez toutes les vacances d'une année civile complète, de janvier à décembre. PTA agrège les deux années scolaires concernées, accepte les périodes publiées pour les élèves ou pour tous les publics, puis vous laisse contrôler et enregistrer vous-même les dates souhaitées.</p>
</div>
</div>
@@ -21,7 +21,7 @@ $defaultCalendarYear = $currentYear;
<div class="selection-grid vacation-filters">
<label class="field">
<span>Académie</span>
<input id="vacation-academy" type="text" maxlength="100" value="Orléans-Tours" placeholder="Ex. Orléans-Tours" list="vacation-academies" autocomplete="off">
<input id="vacation-academy" type="text" maxlength="100" value="Dijon" placeholder="Ex. Dijon" list="vacation-academies" autocomplete="off">
<datalist id="vacation-academies">
<option value="Aix-Marseille"><option value="Amiens"><option value="Besançon"><option value="Bordeaux">
<option value="Clermont-Ferrand"><option value="Créteil"><option value="Dijon"><option value="Grenoble">
@@ -45,8 +45,8 @@ $defaultCalendarYear = $currentYear;
<div id="vacation-preview-panel" class="vacation-preview-panel" hidden>
<div class="section-heading compact-heading">
<div>
<h3>Dates proposées par l’API</h3>
<p class="section-subtitle">Toutes les périodes de l’année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
<h3>Dates proposées par l'API</h3>
<p class="section-subtitle">Toutes les périodes de l'année civile sont proposées, y compris celles rattachées à deux années scolaires différentes. Cochez uniquement celles que vous souhaitez conserver ; les dates restent modifiables avant l’enregistrement.</p>
</div>
<label class="vacation-select-all">
<input id="vacation-select-all" type="checkbox">

View File

@@ -2,11 +2,10 @@
declare(strict_types=1);
use App\Core\Session;
use App\Models\SchemaModel;
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
Session::start();
$pdo = require dirname(__DIR__) . '/db.php';
(new SchemaModel($pdo))->assertCurrent();

View File

@@ -5,12 +5,9 @@
if (!PTA) return;
const { readJsonResponse, apiErrorMessage, escapeHtml, formatDate } = PTA.utils;
const el = (id) => document.getElementById(id);
const escapeAttr = (value) => String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('\"', '&quot;')
.replaceAll("'", '&#039;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
// escapeHtml échappe désormais aussi les guillemets : une seule
// implémentation partagée, valable en contexte texte comme attribut.
const escapeAttr = escapeHtml;
let current = null;
function show(text, type = 'info') {

View File

@@ -44,10 +44,21 @@
const dayNames = ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi'];
/**
* Échappe une valeur pour l'insertion dans du HTML, texte ET attribut.
*
* La sérialisation via textContent/innerHTML n'échappe ni " ni ' : elle est
* sûre entre deux balises, mais permet de sortir d'un attribut value="…" ou
* title="…". PTA insère des libellés de vacances, des noms de lieux et des
* commentaires dans des attributs : les guillemets doivent être échappés.
*/
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = String(value ?? '');
return div.innerHTML;
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function showMessage(text, type = 'info') {

View File

@@ -195,7 +195,7 @@
templateSelect.disabled = false;
templateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
)).join('');
renderSelectedTemplateDetails();
}
@@ -364,7 +364,7 @@
}
periodTemplateSelect.disabled = false;
periodTemplateSelect.innerHTML = '<option value="">Choisir une semaine type</option>' + templates.map((template) => (
`<option value="${template.id}">${escapeHtml(template.name)}</option>`
`<option value="${Number(template.id)}">${escapeHtml(template.name)}</option>`
)).join('');
}

View File

@@ -0,0 +1,201 @@
-- ============================================================================
-- PTA V7.8 — Sécurisation : les règles d'accès descendent en base
-- ============================================================================
-- À exécuter UNE SEULE FOIS sur une base PTA V7.7 existante :
-- SOURCE database/migration_v7_8_securite_acces.sql;
--
-- Objectif : plus aucune règle de périmètre ni de droit n'est reconstruite en
-- PHP. Le contrôleur pose une question à la base, la base répond oui ou non.
-- Toute évolution des droits se fait désormais en SQL, sans redéploiement.
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 1. MATRICE DES DROITS : rôle -> pages et rôle -> permissions
-- ----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS acces_role_page (
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
page VARCHAR(50) NOT NULL,
ordre_affichage SMALLINT UNSIGNED NOT NULL DEFAULT 100,
PRIMARY KEY (role, page)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS acces_role_permission (
role ENUM('AGENT', 'RESPONSABLE_STRUCTURE', 'SERVICE_ENFANCE') NOT NULL,
permission VARCHAR(50) NOT NULL,
autorise BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (role, permission)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Pages autorisées — reprise à l'identique des droits V7.7.
INSERT INTO acces_role_page (role, page, ordre_affichage) VALUES
('AGENT', 'agents', 10),
('AGENT', 'structures', 20),
('RESPONSABLE_STRUCTURE', 'planning', 10),
('RESPONSABLE_STRUCTURE', 'structures', 20),
('RESPONSABLE_STRUCTURE', 'agents', 30),
('RESPONSABLE_STRUCTURE', 'coverage', 40),
('SERVICE_ENFANCE', 'planning', 10),
('SERVICE_ENFANCE', 'pending', 20),
('SERVICE_ENFANCE', 'coverage', 30),
('SERVICE_ENFANCE', 'structures', 40),
('SERVICE_ENFANCE', 'agents', 50),
('SERVICE_ENFANCE', 'annual', 60),
('SERVICE_ENFANCE', 'pta', 70),
('SERVICE_ENFANCE', 'teams', 80),
('SERVICE_ENFANCE', 'vacations', 90),
('SERVICE_ENFANCE', 'administration', 100)
ON DUPLICATE KEY UPDATE ordre_affichage = VALUES(ordre_affichage);
-- Permissions fonctionnelles — reprise à l'identique des droits V7.7.
INSERT INTO acces_role_permission (role, permission, autorise) VALUES
('AGENT', 'read_only', TRUE),
('AGENT', 'can_edit_draft', FALSE),
('AGENT', 'can_edit_cross_structure', FALSE),
('AGENT', 'can_validate', FALSE),
('AGENT', 'can_reopen', FALSE),
('AGENT', 'can_use_templates', FALSE),
('AGENT', 'can_copy_full_week', FALSE),
('AGENT', 'can_view_pending', FALSE),
('AGENT', 'can_manage_coverage', FALSE),
('AGENT', 'can_manage_agents', FALSE),
('AGENT', 'can_manage_structures', FALSE),
('AGENT', 'can_manage_vacations', FALSE),
('AGENT', 'can_manage_teams', FALSE),
('AGENT', 'can_manage_pta', FALSE),
('AGENT', 'can_forecast_next_year', FALSE),
('RESPONSABLE_STRUCTURE', 'read_only', FALSE),
('RESPONSABLE_STRUCTURE', 'can_edit_draft', TRUE),
('RESPONSABLE_STRUCTURE', 'can_edit_cross_structure', FALSE),
('RESPONSABLE_STRUCTURE', 'can_validate', FALSE),
('RESPONSABLE_STRUCTURE', 'can_reopen', TRUE),
('RESPONSABLE_STRUCTURE', 'can_use_templates', FALSE),
('RESPONSABLE_STRUCTURE', 'can_copy_full_week', FALSE),
('RESPONSABLE_STRUCTURE', 'can_view_pending', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_coverage', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_agents', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_structures', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_vacations', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_teams', FALSE),
('RESPONSABLE_STRUCTURE', 'can_manage_pta', FALSE),
('RESPONSABLE_STRUCTURE', 'can_forecast_next_year', FALSE),
('SERVICE_ENFANCE', 'read_only', FALSE),
('SERVICE_ENFANCE', 'can_edit_draft', TRUE),
('SERVICE_ENFANCE', 'can_edit_cross_structure', TRUE),
('SERVICE_ENFANCE', 'can_validate', TRUE),
('SERVICE_ENFANCE', 'can_reopen', TRUE),
('SERVICE_ENFANCE', 'can_use_templates', TRUE),
('SERVICE_ENFANCE', 'can_copy_full_week', TRUE),
('SERVICE_ENFANCE', 'can_view_pending', TRUE),
('SERVICE_ENFANCE', 'can_manage_coverage', TRUE),
('SERVICE_ENFANCE', 'can_manage_agents', TRUE),
('SERVICE_ENFANCE', 'can_manage_structures', TRUE),
('SERVICE_ENFANCE', 'can_manage_vacations', TRUE),
('SERVICE_ENFANCE', 'can_manage_teams', TRUE),
('SERVICE_ENFANCE', 'can_manage_pta', TRUE),
('SERVICE_ENFANCE', 'can_forecast_next_year', TRUE)
ON DUPLICATE KEY UPDATE autorise = VALUES(autorise);
-- ----------------------------------------------------------------------------
-- 2. VUES DE DROITS
-- ----------------------------------------------------------------------------
CREATE OR REPLACE VIEW v_acces_role_page AS
SELECT role, page, ordre_affichage
FROM acces_role_page;
CREATE OR REPLACE VIEW v_acces_role_permission AS
SELECT role, permission, autorise
FROM acces_role_permission;
-- ----------------------------------------------------------------------------
-- 3. VUES D'IDENTITÉ
-- ----------------------------------------------------------------------------
-- Identité complète d'un agent actif : sert à valider la sélection de rôle et
-- à construire le bandeau de profil. Un agent inactif n'apparaît jamais ici,
-- il ne peut donc pas être choisi comme identité.
CREATE OR REPLACE VIEW v_acces_identite_agent AS
SELECT
a.id_agent,
a.matricule,
a.nom,
a.prenom,
a.id_structure,
s.nom AS structure_nom,
s.code AS structure_code
FROM agent a
LEFT JOIN structure s
ON s.id_structure = a.id_structure
AND s.actif = TRUE
WHERE a.actif = TRUE;
-- Lieux d'affectation sélectionnables par un responsable de structure.
CREATE OR REPLACE VIEW v_acces_identite_structure AS
SELECT
s.id_structure,
s.code,
s.nom,
s.type_affectation
FROM structure s
WHERE s.actif = TRUE;
-- ----------------------------------------------------------------------------
-- 4. VUES DE PÉRIMÈTRE
-- ----------------------------------------------------------------------------
-- Quels agents un lieu d'affectation a-t-il le droit de consulter ?
-- Deux origines : le rattachement principal, ou une planification effective
-- sur ce lieu. C'est la règle exacte que le PHP reconstruisait à la main.
CREATE OR REPLACE VIEW v_acces_agent_par_structure AS
SELECT DISTINCT
a.id_agent,
a.id_structure AS id_structure_autorisee,
'RATTACHEMENT' AS origine
FROM agent a
WHERE a.actif = TRUE
AND a.id_structure IS NOT NULL
UNION
SELECT DISTINCT
a.id_agent,
p.id_structure AS id_structure_autorisee,
'PLANIFICATION' AS origine
FROM agent a
INNER JOIN planning p ON p.id_agent = a.id_agent
WHERE a.actif = TRUE;
-- Périmètre d'un créneau : à quel lieu et à quel planning appartient-il ?
-- Utilisé avant toute modification ou suppression d'affectation.
CREATE OR REPLACE VIEW v_acces_perimetre_creneau AS
SELECT
c.id_creneau,
c.id_planning,
p.id_structure,
p.id_agent,
p.statut AS statut_planning,
s.nom AS structure_nom
FROM creneau_horaire c
INNER JOIN planning p ON p.id_planning = c.id_planning
INNER JOIN structure s ON s.id_structure = p.id_structure;
-- Périmètre d'un planning : utilisé avant validation ou réouverture.
CREATE OR REPLACE VIEW v_acces_perimetre_planning AS
SELECT
p.id_planning,
p.id_structure,
p.id_agent,
p.statut AS statut_planning,
s.nom AS structure_nom,
a.nom AS agent_nom,
a.prenom AS agent_prenom
FROM planning p
INNER JOIN structure s ON s.id_structure = p.id_structure
INNER JOIN agent a ON a.id_agent = p.id_agent;
-- ============================================================================
-- Vérification recommandée après import :
-- SELECT role, COUNT(*) FROM acces_role_page GROUP BY role;
-- SELECT * FROM v_acces_agent_par_structure LIMIT 10;
-- ============================================================================

6
db.php
View File

@@ -20,8 +20,12 @@ try {
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
// Le message PDO contient l'hôte, l'utilisateur et le nom de la base :
// il est journalisé côté serveur, jamais propagé vers le navigateur.
error_log(sprintf('[PTA] Connexion base impossible : %s', $e->getMessage()));
throw new RuntimeException(
'Connexion à la base impossible. Vérifiez config.php, le nom de la base et que MySQL est démarré. Détail : ' . $e->getMessage(),
'Connexion à la base impossible. Vérifiez la configuration de la base de données et que le serveur MySQL est démarré.',
0,
$e
);

View File

@@ -38,6 +38,9 @@ services:
PTA_DB_NAME: ${DB_NAME:-pta}
PTA_DB_USER: ${DB_USER:-pta}
PTA_DB_PASS: ${DB_PASSWORD:-change_me}
# Aucun détail technique n'est renvoyé au navigateur tant que ce drapeau
# n'est pas explicitement passé à 1. Ne jamais l'activer en production.
PTA_DEBUG: ${PTA_DEBUG:-0}
TZ: ${TZ:-Europe/Paris}
depends_on:
db:
@@ -66,12 +69,15 @@ services:
environment:
PMA_HOST: db
PMA_PORT: 3306
PMA_USER: ${DB_USER:-pta}
PMA_PASSWORD: ${DB_PASSWORD:-change_me}
# PMA_USER / PMA_PASSWORD sont volontairement absents : leur présence fait
# basculer phpMyAdmin en auth_type=config, c'est-à-dire une session déjà
# authentifiée pour quiconque atteint le port. Sans eux, phpMyAdmin exige
# un identifiant et un mot de passe MariaDB à chaque connexion.
UPLOAD_LIMIT: 64M
TZ: ${TZ:-Europe/Paris}
ports:
# Par défaut phpMyAdmin n'est accessible que depuis la machine hôte.
# phpMyAdmin n'écoute que sur la boucle locale. Pour y accéder à distance,
# passer par un tunnel SSH (voir README_DOCKER.md).
- "${PMA_BIND:-127.0.0.1}:${PMA_PORT:-8081}:80"
depends_on:
db:

View File

@@ -8,8 +8,67 @@ server {
charset utf-8;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$query_string;
# Ne pas annoncer la version du serveur.
server_tokens off;
# ------------------------------------------------------------------------
# ORDRE IMPORTANT
# nginx évalue les expressions régulières dans l'ordre du fichier et retient
# la PREMIÈRE qui correspond. Tous les blocs de refus doivent donc précéder
# le bloc générique « location ~ \.php$ », sinon ils ne s'appliquent jamais
# et les fichiers d'amorçage deviennent exécutables depuis le navigateur.
# ------------------------------------------------------------------------
# Fichiers inclus par l'application, jamais des points d'entrée.
location ~* ^/(config|db|page_bootstrap)\.php$ {
deny all;
return 404;
}
# Classes, modèles, schémas SQL et documentation de version.
location ^~ /app/ {
deny all;
return 404;
}
location ^~ /database/ {
deny all;
return 404;
}
location ^~ /docker/ {
deny all;
return 404;
}
location ~* ^/README[^/]*$ {
deny all;
return 404;
}
# Configuration, dumps et journaux ne doivent jamais être téléchargeables.
location ~* \.(?:env|sql|sql\.gz|ini|log|bak|dump|yml|yaml|md)$ {
deny all;
return 404;
}
# Fichiers et dossiers cachés (.env, .git, .htaccess…).
location ~ /\. {
deny all;
return 404;
}
# ------------------------------------------------------------------------
# Contenus servis
# ------------------------------------------------------------------------
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
# Les réponses PHP portent leurs propres en-têtes de sécurité, CSP à
# nonce comprise (App\Core\Security) ; ici on couvre le statique.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
try_files $uri =404;
}
location ~ \.php$ {
@@ -23,33 +82,13 @@ server {
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param HTTP_PROXY "";
# Les erreurs PHP restent gérées par l'application (JSON ou page HTML) :
# nginx ne les remplace pas par ses propres pages.
fastcgi_intercept_errors off;
fastcgi_read_timeout 120s;
}
# Les classes, modèles et fichiers SQL ne doivent jamais être servis directement.
location ^~ /app/ {
deny all;
return 404;
}
location ^~ /database/ {
deny all;
return 404;
}
location ~* ^/(config\.php|db\.php|page_bootstrap\.php|README[^/]*)$ {
deny all;
return 404;
}
location ~ /\. {
deny all;
return 404;
}
location ~* \.(?:css|js|png|jpg|jpeg|gif|svg|ico|webp)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/app/autoload.php';
use App\Controllers\PageController;
use App\Core\Security;
function runPage(string $method): void
{
@@ -17,10 +18,14 @@ function runPage(string $method): void
$controller->{$method}();
} catch (Throwable $e) {
http_response_code(500);
$message = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
Security::sendHtmlHeaders();
$message = htmlspecialchars(
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de cette page.'),
ENT_QUOTES,
'UTF-8'
);
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
echo '<h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
echo '<p>Vérifiez <code>config.php</code> et le schéma de la base de données.</p></body></html>';
echo '<body><h1>Impossible de démarrer PTA</h1><p>' . $message . '</p>';
echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
}
}

View File

@@ -5,13 +5,20 @@ declare(strict_types=1);
require_once __DIR__ . '/app/autoload.php';
use App\Controllers\RoleController;
use App\Core\Security;
try {
$pdo = require __DIR__ . '/app/bootstrap.php';
(new RoleController($pdo))->index();
} catch (Throwable $e) {
http_response_code(500);
Security::sendHtmlHeaders();
$message = htmlspecialchars(
Security::reportThrowable($e, 'Une erreur technique empêche l’affichage de la sélection du rôle.'),
ENT_QUOTES,
'UTF-8'
);
echo '<!doctype html><html lang="fr"><meta charset="utf-8"><title>Erreur PTA</title>';
echo '<body style="font-family:Arial,sans-serif;padding:32px;max-width:900px;margin:auto">';
echo '<h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</p></body></html>';
echo '<body><h1>Impossible d’ouvrir la sélection du rôle</h1><p>' . $message . '</p>';
echo '<p>Contactez l’administrateur de l’application : le détail de l’erreur a été journalisé.</p></body></html>';
}