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.
6.7 KiB
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.
# ❌ 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.pyettests/test_permissions.py. Ils sont la référence exécutable : si ce document et eux divergent, ce sont eux qui ont raison.