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:
+80
-32
@@ -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'))
|
||||
|
||||
|
||||
@@ -303,7 +303,14 @@ def view_tryout(tryout_id):
|
||||
|
||||
all_players = None
|
||||
if can_edit:
|
||||
all_players = User.query.filter_by(role='player').order_by(User.username).all()
|
||||
# is_active_account, like the manager and coach queries in this same
|
||||
# module. Offering a deactivated account in a roster select
|
||||
# contradicts the one control that says the person has left.
|
||||
all_players = (
|
||||
User.query.filter_by(role='player', is_active_account=True)
|
||||
.order_by(User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
matches = (
|
||||
Match.query.filter_by(tryout_id=tryout_id).order_by(Match.date, Match.start_time).all()
|
||||
|
||||
Reference in New Issue
Block a user