fix(authz): finir SEC-16, que mon propre correctif avait laisse a moitie

La vague K a pose un schema sur create_team et edit_team et a laisse cinq
routes soeurs du meme fichier lire int(request.form.get(...)) : add_coach,
add_manager, remove_coach, remove_manager et add_player. Un identifiant non
numerique y etait un 500 dans chacune.

C'est exactement la lecon que ce projet repete depuis la vague D -- corriger
un motif fautif dans une seule couche le laisse dans les autres -- et cette
fois c'est le correctif lui-meme qui l'a commise. Notee comme telle.

Deux defauts de plus, trouves en finissant.

add_player ecrivait status tel quel dans une colonne NOT NULL String(20). Et
toggle_player_status lit "substitute si status == starter, sinon starter" :
une valeur inconnue devenait donc starter a la premiere bascule, c'est-a-dire
promouvait son porteur. Liste blanche dans TEAM_PLAYER_STATUSES.

Et aucune de ces routes ne regardait is_active_account. La requete qui
alimente la liste deroulante des joueurs ne le filtrait pas non plus, alors
que les deux requetes juste au-dessus, coachs et gerants, le posaient -- deux
lignes d'ecart, meme fichier. Un compte desactive etait donc propose et
accepte, alors que is_active_account est precisement ce qui dit que la
personne a quitte le club. Meme oubli dans tryouts.py.

_staff_member delegue desormais a _assignable au lieu de repeter isinstance :
deux fonctions du meme fichier repondant differemment a "ce compte peut-il
prendre ce role" est la forme de tous les defauts qu'a eus ce module.

Verifie par mutation : retirer le controle d'activite ou la liste blanche
fait tomber trois tests.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
GGThed
2026-08-11 20:42:26 -04:00
co-authored by Claude Opus 5
parent 838b247649
commit 20dabecd67
8 changed files with 633 additions and 361 deletions
+80 -32
View File
@@ -29,7 +29,7 @@ from app.models import (
User,
)
from app.permissions import visible_org_teams
from app.validators import OrgTeamSchema
from app.validators import OrgTeamSchema, TeamPlayerSchema, TeamStaffSchema
teams_bp = Blueprint('teams', __name__, url_prefix='/teams')
@@ -55,7 +55,12 @@ def list_teams():
managers = (
User.query.filter_by(role='manager', is_active_account=True).order_by(User.username).all()
)
all_players = User.query.filter_by(role='player').order_by(User.username).all()
# is_active_account, like the two queries above it. Without it the "add
# player" select offered accounts that had been deactivated, and
# add_player accepted them.
all_players = (
User.query.filter_by(role='player', is_active_account=True).order_by(User.username).all()
)
return render_template(
'pages/teams.html',
teams=teams,
@@ -119,27 +124,58 @@ def my_teams():
return render_template('pages/my_teams.html', team_data=team_data, now=now)
def _staff_member(user_id, expected_class):
"""The user behind an id, only if they hold the role being assigned.
def _posted(schema):
"""Load a form through `schema`, or None when it will not load.
Returns None for a missing id, an unknown id, or an account of the wrong
role. That last case is the point (SEC-16): the id comes from a `<select>`
the browser rendered, so it is a value the client chooses, and nothing
checked it in two of the three places that used it. A forged submission
could therefore list a player among a team's coaches — the same defect
wave G fixed in `tryouts.py`, left standing here.
The five assignment routes below each answer a bad field with their own
flash and a redirect to the same page, so a shared "it did not validate"
return is enough; the field-level message is flashed on the way out.
"""
try:
return schema.load(form_payload())
except ValidationError as err:
flash_validation_errors(err)
return None
def _assignable(user, expected_class):
"""Whether this account may be given a role on a team.
Deactivated accounts were offered by the selects and accepted by the
routes. `is_active_account` is what stops someone logging in — a person
who has left the club — so putting them on a roster contradicts the one
control that says they are gone. The listings filtered it for coaches and
managers and not for players, two lines apart, which is how it went
unnoticed.
"""
return isinstance(user, expected_class) and bool(user.is_active_account)
def _staff_member(user_id, expected_class):
"""The user behind an id, only if they may hold the role being assigned.
Returns None for a missing id, an unknown id, an account of the wrong
role, or a deactivated one. The role check is the point (SEC-16): the id
comes from a `<select>` the browser rendered, so it is a value the client
chooses, and nothing checked it in two of the three places that used it.
A forged submission could therefore list a player among a team's coaches
— the same defect wave G fixed in `tryouts.py`, left standing here.
Defers to `_assignable` rather than repeating `isinstance`: two functions
in one file answering "may this account take this role" differently is
the shape of every defect this module has had.
Args:
user_id: Already an int or None, thanks to OrgTeamSchema.
user_id: Already an int or None, thanks to the schema.
expected_class: Coach or Manager.
Returns:
User | None: The account, when it is of the expected role.
User | None: The account, when it may take the role.
"""
if not user_id:
return None
user = db.session.get(User, user_id)
return user if isinstance(user, expected_class) else None
return user if user and _assignable(user, expected_class) else None
@teams_bp.route('/create', methods=['POST'])
@@ -305,13 +341,15 @@ def add_coach(team_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id')
if not coach_id:
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['coach_id']:
flash(_('Please select a coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
coach = User.query.get_or_404(int(coach_id))
if not isinstance(coach, Coach):
coach = db.session.get(User, data['coach_id'])
if not coach or not _assignable(coach, Coach):
flash(_('Only coaches can be assigned as coach.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -346,13 +384,15 @@ def add_manager(team_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id')
if not manager_id:
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['manager_id']:
flash(_('Please select a manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
manager = User.query.get_or_404(int(manager_id))
if not isinstance(manager, Manager):
manager = db.session.get(User, data['manager_id'])
if not manager or not _assignable(manager, Manager):
flash(_('Only managers can be assigned as manager.'), 'danger')
return redirect(url_for('teams.list_teams'))
@@ -387,9 +427,12 @@ def remove_coach(team_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
coach_id = request.form.get('coach_id')
if coach_id:
coach = User.query.get(int(coach_id))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if data['coach_id']:
coach = db.session.get(User, data['coach_id'])
if coach and team.coaches.filter_by(id=coach.id).first():
team.coaches.remove(coach)
if team.coach_id == coach.id:
@@ -412,9 +455,12 @@ def remove_manager(team_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
manager_id = request.form.get('manager_id')
if manager_id:
manager = User.query.get(int(manager_id))
data = _posted(TeamStaffSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if data['manager_id']:
manager = db.session.get(User, data['manager_id'])
if manager and team.managers.filter_by(id=manager.id).first():
team.managers.remove(manager)
if team.manager_id == manager.id:
@@ -437,14 +483,16 @@ def add_player(team_id):
flash(_('Permission denied.'), 'danger')
return redirect(url_for('teams.list_teams'))
player_id = request.form.get('player_id')
status = request.form.get('status', 'starter')
if not player_id:
data = _posted(TeamPlayerSchema())
if data is None:
return redirect(url_for('teams.list_teams'))
if not data['player_id']:
flash(_('Please select a player.'), 'danger')
return redirect(url_for('teams.list_teams'))
player = User.query.get_or_404(int(player_id))
if not isinstance(player, Player):
status = data['status']
player = db.session.get(User, data['player_id'])
if not player or not _assignable(player, Player):
flash(_('Can only assign players to teams.'), 'danger')
return redirect(url_for('teams.list_teams'))