docs: les deux documents d exploitation qui manquaient

DOC-001 demandait quatre documents courts. Deux existaient — installation
(deployment.md) et restauration (database-restore.md). Les deux autres
n existaient pas.

docs/roles-and-permissions.md decrit le comportement **implemente**, pas
celui qu on souhaiterait, et note les endroits ou les deux divergent : un
gerant gere toutes les equipes mais pas toutes les selections, asymetrie
presente depuis toujours et ecrite nulle part ailleurs. Le piege du double
rattachement coach/equipe y est en toutes lettres, avec le motif a ne
jamais reintroduire.

Le document dit aussi qui a raison en cas de desaccord : les tests. Une
documentation d autorisation qui se contredit avec le code est pire que
pas de documentation, parce qu on la croit.

docs/incident-runbook.md part du symptome, pas du composant. Le reflexe
d ouverture est la reference affichee sur la page d erreur — c est ce que
l identifiant de requete rend possible, et sans un endroit qui le dise, la
fonctionnalite ne sert a personne.

Trois affirmations ont ete verifiees contre le code avant d etre ecrites,
et deux etaient fausses : le corps de /health en echec dit
database="error" et non "disconnected", et les evenements de verrouillage
s appellent login.failure et account.throttled. La liste complete des
quatorze evenements de auth.log est maintenant dans le manuel.

README : index des documents, et deux points d etat corriges — TRUSTED_PROXY
est desormais une variable, et l absence de validation des inscriptions par
le staff est nommee comme decision en attente.
This commit is contained in:
GGThed
2026-08-11 15:40:30 -04:00
parent 709e8a5d51
commit 7a1dab21cd
3 changed files with 395 additions and 2 deletions
+19 -2
View File
@@ -94,14 +94,31 @@ Ce qui **n'est pas** fait, pour que personne ne s'y fie :
à un modèle est absente de la production.
- **Les secrets de l'historique git ne sont pas révoqués** — jeton du bot,
mot de passe PostgreSQL, `SECRET_KEY`.
- **`trusted_proxy='*'`** dans `wsgi.py` : l'en-tête `X-Forwarded-For` est
- **`trusted_proxy='*'`** reste le défaut : l'en-tête `X-Forwarded-For` est
accepté de n'importe quelle source, donc la limitation par IP est
contournable. À régler avec la topologie réelle du déploiement.
contournable. C'est désormais la variable `TRUSTED_PROXY` plutôt qu'une
constante — `docs/deployment.md` donne la valeur pour chaque topologie.
- **Les comptes créés par le formulaire d'inscription sont actifs
immédiatement** : il n'y a pas d'étape de validation par le staff. Décision
de produit en attente, voir `docs/roles-and-permissions.md`.
- **L'identité Discord** transite encore par un champ caché du formulaire
d'inscription : elle n'est pas prouvée par le passage OAuth2.
`docs/security-checklist.md` détaille la liste avant mise en production.
### Documentation
| Document | Pour |
|---|---|
| `docs/deployment.md` | Installer, déployer, revenir en arrière |
| `docs/roles-and-permissions.md` | Qui peut faire quoi, et où c'est décidé |
| `docs/database-restore.md` | Sauvegarder et restaurer |
| `docs/database-schema.md` | Sortir de `create_all()` : relevé, Alembic, migrations |
| `docs/incident-runbook.md` | Quand quelque chose ne va pas |
| `docs/architecture.md` | Diagrammes |
| `docs/translations.md` | Ajouter ou corriger une traduction |
| `docs/security-checklist.md` | Avant une mise en production |
---
## Intégration Discord
+204
View File
@@ -0,0 +1,204 @@
# Manuel d'incident
À lire quand quelque chose ne va pas. Chaque section : le symptôme, où
regarder, quoi faire.
> **Le réflexe préalable** — demander la **référence** affichée sur la page
> d'erreur (`Référence à indiquer si vous signalez ce problème`). C'est
> l'identifiant de requête ; il apparaît dans chaque ligne de journal produite
> par cette requête, entre crochets.
>
> ```bash
> grep '\[a1b2c3d4e5f60718\]' logs/*.log
> ```
## Où sont les journaux
| Fichier | Contenu |
|---|---|
| `logs/app.log` | Tout, à partir de `LOG_LEVEL` (défaut `INFO`) |
| `logs/errors.log` | `ERROR` et au-dessus, avec les traces |
| `logs/auth.log` | Connexions, échecs, verrous, changements de rôle, suppressions de compte, refus d'inscription |
| Console Pterodactyl | Les mêmes lignes que `app.log` — le handler console est actif en production, délibérément |
| `C:\nginx\logs\error.log` | Ce qui n'a jamais atteint l'application |
Format : `[date] NIVEAU [module:ligne] [id-de-requête] message`. Un `-` à la
place de l'identifiant signifie « hors requête » : démarrage, bot Discord,
planificateur.
### Les événements de `auth.log`
Tout est en `clé=valeur`, dans l'ordre, donc greppable sans dépendance JSON.
| Événement | Quand |
|---|---|
| `login.success` | Connexion réussie |
| `login.failure` | Mot de passe faux sur un compte existant |
| `login.failure.unknown_user` | Identifiant inconnu |
| `login.rejected.deactivated` | Bons identifiants, compte désactivé |
| `account.throttled` | Délai d'attente déclenché après des échecs répétés |
| `logout` | Déconnexion |
| `account.registered` | Inscription réussie |
| `account.registration_refused` | Inscription filtrée — `reason=honeypot`, `too-fast` ou `no-form-issued` |
| `account.created_by_admin` | Compte créé depuis l'administration |
| `account.updated` | Fiche modifiée |
| `account.role_changed` | Changement de rôle, avec l'ancien et le nouveau |
| `account.password_changed` | Changement par la personne elle-même |
| `account.password_reset_by_admin` | Réinitialisation par un président |
| `account.deleted` | Suppression, avec le nombre de fichiers de contrat retirés |
Le champ `ip=` vient de `request.remote_addr`, donc de `X-Forwarded-For`.
Tant que `TRUSTED_PROXY=*`, **c'est une indication et pas une preuve**.
## Le site ne répond pas
1. **`/health` répond-il ?**
```bash
curl -i https://<hôte>/health
```
- Pas de réponse du tout → nginx est tombé, ou l'application n'écoute plus.
Vérifier la console Pterodactyl.
- `503` → l'application tourne mais la base ne répond pas. Voir plus bas.
- `200` mais le site est inutilisable → le problème est dans nginx ou dans
le DNS, pas dans l'application.
2. **L'application est-elle démarrée ?** Console Pterodactyl. Au démarrage elle
imprime `Starting Waitress server on <hôte>:<port>`.
3. **Un déploiement vient-il d'avoir lieu ?** C'est la cause la plus fréquente.
Voir « Revenir en arrière » dans `docs/deployment.md`.
## `/health` renvoie 503
La base ne répond pas. Le corps de la réponse le dit :
```json
{"status": "unhealthy", "database": "error"}
```
Le corps ne contient **jamais** l'erreur du pilote : elle porte l'hôte, le nom
de la base et l'utilisateur de la chaîne de connexion, et `/health` n'est pas
authentifié. La trace complète est dans `logs/errors.log`.
- La base est hébergée sur Render : vérifier son état côté Render en premier.
- `DATABASE_URL` a-t-elle changé ? Un mot de passe tourné et pas reporté
produit exactement ça.
- **Piège connu** : `DATABASE_URL=postgresql://…` seul ne démarre pas —
SQLAlchemy y cherche psycopg **2**, le projet épingle psycopg **3**.
`normalise_database_url()` nomme le pilote, donc les deux formes marchent ;
si vous voyez `ModuleNotFoundError: psycopg2`, c'est que le code qui
normalise n'a pas été déployé.
## Plus aucune notification Discord
Le bot tourne dans un **fil du même processus** que le site. Quand il meurt,
les pages continuent d'être servies et toutes les notifications s'arrêtent.
C'est précisément ce qui est resté invisible longtemps.
1. `/health` le rapporte :
```json
{"discord_bot": {"running": false, ...}}
```
Rapporté et **non fatal** : un club sans rappels Discord est dégradé, pas
hors service, et un 503 le sortirait du répartiteur de charge pour ça.
2. Chercher les livraisons manquées :
```bash
grep 'not delivered' logs/app.log
```
Trois causes, qui ne se traitent pas pareil :
- `their direct messages are closed` → **définitif**. La personne doit
autoriser les messages privés des membres du serveur. Réessayer ne sert à
rien.
- `does not exist` → l'identifiant Discord sur le compte est faux ou le
compte a été supprimé. À corriger dans la fiche de la personne.
- autre chose → passager, côté Discord.
3. Le bilan du lot quotidien :
```bash
grep 'Daily reminders' logs/app.log
```
`17 of 20 delivered, 3 failed` — les trois sont nommées juste au-dessus.
4. Redémarrer le processus relance le bot. Les réactions en attente survivent
au redémarrage (`discord_pending.json`), sauf si ce fichier a été mis de
côté pour corruption — auquel cas le journal le dit et donne le nom du
fichier de quarantaine.
## Quelqu'un est verrouillé dehors
Cinq échecs consécutifs déclenchent un délai qui double ensuite. Le compte
n'est pas bloqué définitivement.
```bash
grep -E 'login\.failure|account\.throttled' logs/auth.log | grep '<identifiant>'
```
- **Le verrou se lève tout seul.** Le délai est dans le message.
- Un président peut réinitialiser le mot de passe depuis la fiche du compte,
ce qui remet le compteur à zéro.
- **Beaucoup de verrous sur des comptes différents en même temps** → quelqu'un
essaie des mots de passe. Le champ `ip=` est une indication et **pas une
preuve** tant que `TRUSTED_PROXY=*` (voir `OPS-002` dans
`docs/deployment.md`) : il est falsifiable à chaque requête.
## Une page renvoie 500
1. Récupérer la référence auprès de la personne, ou la dernière trace :
```bash
tail -50 logs/errors.log
```
2. La trace complète y est. Elle n'est jamais montrée à l'utilisateur.
3. Si c'est arrivé juste après un déploiement, revenir en arrière d'abord et
diagnostiquer ensuite.
## Le style ou le JavaScript ne se chargent plus
Les statiques sont servis par nginx avec un cache de 30 jours, ce qui n'est
sûr que parce que leurs URL portent une estampille (`?v=<mtime>`).
- **Après un déploiement, la page a l'ancien style** → l'estampille n'a pas
changé, donc le fichier n'a pas été déployé. Vérifier :
```bash
curl -s https://<hôte>/auth/login | grep -o 'style.css?v=[0-9]*'
```
- **404 sur `/static/…`** → le chemin `alias` du bloc `location /static/` de
`nginx.conf` ne pointe pas au bon endroit.
- **Une page est cassée et la console du navigateur parle de CSP** → un bloc
`<script>` a été ajouté sans `nonce="{{ csp_nonce }}"`. Sans nonce il ne
s'exécute pas, et **rien dans les journaux ne le signale**.
- **Une ressource de CDN ne charge plus** → une empreinte `integrity` ne
correspond plus. Le navigateur refuse le fichier en silence côté serveur.
Cas normal : quelqu'un a monté la version sans recalculer l'empreinte.
## Les données semblent fausses
**Une colonne existe dans le modèle et pas dans la base.** `create_all()` ne
fait jamais d'`ALTER`, donc c'est possible et ce n'est pas rare :
```bash
python app/supporting_scripts/schema_report.py --url "$DATABASE_URL"
```
Lecture seule. Le rapport dit exactement ce qui manque. Voir
`docs/database-schema.md`.
## Restaurer
Procédure complète dans `docs/database-restore.md`. Deux règles :
- restaurer **d'abord sur une copie**, jamais directement par-dessus la
production ;
- une sauvegarde qu'on n'a jamais restaurée est une hypothèse, pas une
sauvegarde.
## À faire une fois, et pas encore fait
Ces points sont ouverts et connus. Ils ne sont pas des incidents, ils en
causeront.
| Point | Pourquoi c'est urgent |
|---|---|
| Révoquer `SECRET_KEY` et le jeton du bot Discord | Les deux sont dans l'historique git des deux dépôts, et dans le HEAD du miroir GitHub. Avec la `SECRET_KEY`, on forge une session valide pour n'importe quel compte |
| Vérifier le compte `admin`/`password` | `python app/supporting_scripts/schema_report.py --check-seed-accounts` |
| Régler `HOST` et `TRUSTED_PROXY` | Tant que c'est `*`, la limite de débit et le journal d'authentification sont falsifiables |
+172
View File
@@ -0,0 +1,172 @@
# Rôles et permissions
Qui peut faire quoi, et où c'est décidé. Ce document décrit le comportement
**implémenté**, pas celui qu'on souhaiterait : quand les deux divergent, c'est
noté.
## Les cinq rôles
| Rôle | Nom dans le code | En français, dans l'interface |
|---|---|---|
| Président | `admin` | président |
| Gérant | `manager` | gérant |
| Coach | `coach` | coach |
| Recruteur | `scout` | recruteur |
| Joueur | `player` | joueur |
Le rôle est une **colonne discriminante** : `users.role` décide de la classe
Python que SQLAlchemy instancie (`Admin`, `Manager`, …). C'est pourquoi
l'autorisation se fait par `isinstance()` et non par comparaison de chaînes,
et pourquoi changer un rôle passe par un `UPDATE` explicite suivi d'une
relecture de l'instance — voir le long commentaire dans `edit_user`.
## Capacités globales
Lues sur l'objet utilisateur. Défaut hérité de `User` : **tout est `False`**,
chaque sous-classe ouvre ce qui la concerne. Ajouter un rôle sans rien
surcharger donne donc un compte qui ne peut rien, ce qui est le bon sens de
défaut.
| Capacité | Président | Gérant | Coach | Recruteur | Joueur |
|---|:--:|:--:|:--:|:--:|:--:|
| `can_manage_users` | ✅ | — | — | — | — |
| `can_manage_teams` | ✅ | ✅ | — | — | — |
| `can_manage_tryouts` | ✅ | ✅ | ✅ | — | — |
| `can_schedule_matches` | ✅ | ✅ | ✅ | — | — |
| `can_evaluate` | ✅ | ✅ | ✅ | ✅ | — |
Le recruteur évalue et ne gère rien : c'est le seul rôle en lecture-plus-
évaluation.
## Capacités portant sur un objet
Les deux qui comptent, parce qu'elles répondent différemment selon l'objet.
### `can_manage_this_tryout(tryout)`
| Rôle | Règle |
|---|---|
| Président | toujours vrai |
| Gérant | `tryout.created_by == moi` **ou** `tryout.manager_id == moi` |
| Coach | délègue à `coach_manages_tryout()` — voir plus bas |
| Recruteur, Joueur | toujours faux |
Un gérant ne gère donc **pas** toutes les sélections, alors qu'il gère toutes
les équipes. L'asymétrie est dans le code depuis toujours ; elle est peut-être
voulue, elle n'est écrite nulle part ailleurs qu'ici.
### `can_manage_this_org_team(org_team)`
| Rôle | Règle |
|---|---|
| Président | toujours vrai |
| Gérant | **toujours vrai** — toutes les équipes, pas seulement les siennes |
| Coach | rattaché à l'équipe, par la relation m2m **ou** par la colonne héritée |
| Recruteur, Joueur | toujours faux |
## Le piège : deux façons d'être rattaché
C'est le point le plus coûteux de tout le modèle d'autorisation, et il est
encore ouvert (`ARCH-001`).
Un coach est rattaché à une équipe de **deux** manières :
```
OrgTeam.coach_id colonne d'origine, un seul coach
OrgTeam.coaches relation many-to-many, ajoutée ensuite
```
**Les deux sont encore alimentées à l'écriture.** Ne lire que `coach_id`
excluait silencieusement tout coach qui n'était pas le premier de son équipe ;
y ajouter un `.first()` excluait un coach de toutes ses équipes sauf une. Les
deux défauts étaient actifs en production.
Même duplication pour les gérants (`manager_id` / `managers`) et pour les
sélections (`Tryout.coach_id` / `Tryout.coaches` / `target_org_team_id`).
**Règle absolue : ne jamais réintroduire ce motif.**
```python
# ❌ jamais
OrgTeam.query.filter_by(coach_id=user.id).first()
# ✅ toujours
from app.permissions import coach_org_teams
coach_org_teams(user)
```
`app/permissions.py` est **le** point de vérité. Toute question « ce coach
peut-il… ? » ou « quelles équipes… ? » passe par lui. Il considère les deux
rattachements et toutes les équipes, ce qui rend la duplication inoffensive
**en lecture**. L'écriture crée toujours les deux.
`ARCH-001` fera disparaître les colonnes héritées — migration de données, donc
bloqué sur Alembic (`DB-002`, voir `docs/database-schema.md`).
## Les fonctions à appeler
Toutes dans `app/permissions.py`, toutes prenant l'utilisateur agissant en
argument explicite plutôt que de lire `current_user` — ce qui les rend
appelables depuis les modèles, depuis le bot Discord et depuis les tests, sans
contexte de requête.
| Fonction | Répond à |
|---|---|
| `coach_org_teams(coach)` | Les équipes de ce coach, les deux rattachements confondus |
| `manager_org_teams(manager)` | Idem pour un gérant |
| `attached_org_teams(user)` | Les équipes auxquelles cette personne est rattachée, quel que soit son rôle |
| `visible_org_teams(user)` | Ce qu'elle a le droit de voir |
| `can_manage_org_team(user, team)` | Peut-elle administrer cette équipe |
| `coach_player_ids(coach)` | Les joueurs qu'il encadre |
| `coach_can_access_player(coach, id)` | Peut-il lire la fiche de ce joueur |
| `can_manage_player_contract(user, id)` | Peut-elle déposer un contrat pour lui |
| `coach_tryouts(coach)` | Les sélections qu'il gère |
| `coach_manages_tryout(coach, tryout)` | Gère-t-il celle-ci |
## Ce que voit chaque rôle
`get_visible_tryouts()`, sur l'objet utilisateur :
| Rôle | Sélections visibles |
|---|---|
| Président | toutes |
| Recruteur | toutes |
| Gérant | celles qu'il a créées ou dont il est le gérant |
| Coach | celles qu'il gère, via `coach_tryouts()` |
| Joueur | celles où il est inscrit, plus celles ouvertes à son équipe |
## Inscription et activation
**Un compte créé par le formulaire d'inscription est actif immédiatement.**
`is_active_account` vaut `True` par défaut ; il n'existe aucune étape de
validation par le staff.
Ce qui filtre les inscriptions aujourd'hui est un champ piège et un délai
minimal (`SEC-AUTH-008`, `app/routes/auth.py`), plus une limite de débit de
20 par heure et par IP. C'est calibré contre le pourriel de masse, pas contre
quelqu'un qui lit la page.
**C'est une décision de produit en attente** : faut-il que le staff active les
comptes ? Tant qu'elle n'est pas prise, n'importe qui peut créer un compte
joueur et voir ce qu'un joueur voit.
## Désactiver un compte
`is_active_account = False` prend effet **à la requête suivante** :
`User.is_active` est surchargé pour le lire, et Flask-Login le consulte à
chaque requête. Une session ouverte ne survit donc pas à la désactivation.
## Le dernier président
Deux garde-fous, parce qu'aucune interface ne permet de les défaire ensuite :
- un président ne peut pas changer **son propre** rôle ;
- le **dernier président actif** ne peut pas être rétrogradé.
## Ce qui n'est pas couvert ici
- La matrice détaillée par domaine fonctionnel de l'audit du 2026-08-07 vit
dans `audit/2026-08-07-MATRICE-AUTORISATIONS.md`, non versionné.
- Les tests d'autorisation sont dans `tests/test_authorization.py` et
`tests/test_permissions.py`. Ils sont la référence exécutable : si ce
document et eux divergent, **ce sont eux qui ont raison**.