Files
team-tryouts/docs/roles-and-permissions.md
T
cedrick2711 2e5903b131
CI - Security, Lint & Tests / validate (push) Canceled after 0s
Ajout du joueur responsable
2026-09-27 14:25:29 -04:00

8.0 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

can_manage_team_matches(team)

Plus étroite que can_manage_this_org_team, et c'est voulu : elle répond à « peut-on gérer les matchs et les entraînements de cette équipe ? ».

Rôle Règle
Président, Gérant toujours vrai
Coach rattaché à l'équipe (m2m ou colonne héritée)
Joueur responsable vrai pour sa seule équipe
Recruteur, Joueur faux

Le joueur responsable

Un joueur responsable est un joueur qu'un président désigne, depuis la page Équipes, sur la ligne de son équipe (étoile à côté du bouton de retrait). Il peut planifier, modifier et supprimer les matchs et entraînements de cette équipe, et rien d'autre : ni l'effectif, ni les notes, ni le statut partant/remplaçant, qui restent réservés au personnel.

Le drapeau vit sur team_players.is_team_representative (voir docs/database-schema.md). La désignation est la décision du président — can_designate_team_representative(), vrai seulement pour Admin — pour qu'un responsable ne puisse pas se reproduire lui-même.

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
can_manage_team_matches(user, team) Peut-elle gérer les matchs et entraînements de cette équipe
is_team_representative(user, team) Est-elle le responsable désigné de cette équipe
representative_org_teams(user) Les équipes dont elle est le responsable
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.