fix(authz): une seule regle pour l'acces coach vers joueur
SEC-AUTHZ-004 et SEC-AUTHZ-005. La meme question -- ce coach peut-il agir
sur ce joueur ? -- recevait cinq reponses differentes selon la route :
teams.py:add_player_note verifiait l'appartenance via TeamPlayer
users.py, 4 routes de notes ne verifiaient rien au-dela d'isinstance
contract.py:can_view interrogeait la colonne heritee coach_id, et
traitait un team_id nul comme un joker
Consequences levees
- tout coach pouvait ecrire une note nominative sur tout joueur du club.
Ces notes sont visibles par le joueur concerne.
- tout coach figurant dans OrgTeam.coach_id pouvait lire n'importe quel
contrat sans equipe rattachee. Or upload_contract laisse team_id nul des
que le joueur n'appartient a aucune equipe : la condition
`not self.team_id or ...` ouvrait donc largement.
- symetriquement, un coach rattache uniquement par la relation
many-to-many ne voyait aucun contrat.
app/permissions.py
Premier pas concret vers ARCH-002, sans refonte : un module unique, pas
une couche de services. coach_org_team_ids() lit la relation m2m ET la
colonne heritee, donc le deuxieme coach d'une equipe cesse d'etre
invisible. coach_can_access_player() accorde l'acces si le joueur est sur
une equipe du coach, ou inscrit a un tryout qu'il gere, ou participant a
un match de ce tryout.
14 tests, dont deux verifient que les chemins legitimes fonctionnent
toujours : un coach note bien son propre joueur, et voit bien son contrat.
Note : la regle metier retenue -- equipe OU tryout -- est une lecture du
comportement existant, pas une decision produit. Si le club attend autre
chose, c'est desormais un seul endroit a changer.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,238 @@
|
||||
"""Coach ↔ player access rules.
|
||||
|
||||
SEC-AUTHZ-004 and SEC-AUTHZ-005. The same question — "may this coach act on
|
||||
this player?" — was answered five different ways across the codebase:
|
||||
|
||||
teams.py:add_player_note checked TeamPlayer membership
|
||||
users.py, four note routes checked nothing beyond isinstance(Coach)
|
||||
contract.py:can_view checked the legacy coach_id column, and
|
||||
treated a null team_id as a wildcard
|
||||
|
||||
app/permissions.py now holds one rule, and it reads both the many-to-many
|
||||
relationship and the legacy column — so the second coach of a team is no
|
||||
longer invisible to it (ARCH-002).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import (
|
||||
Contract, OrgTeam, PersonalNote, TeamPlayer, Tryout, TryoutRegistration,
|
||||
)
|
||||
from app.permissions import coach_can_access_player, coach_org_team_ids
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def team_factory(app):
|
||||
def _make(name, *, coach_id=None, legacy_coach_id=None, player_ids=()):
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
|
||||
team = OrgTeam(name=name, created_by=coach_id or legacy_coach_id,
|
||||
coach_id=legacy_coach_id)
|
||||
db.session.add(team)
|
||||
db.session.flush()
|
||||
if coach_id:
|
||||
team.coaches.append(db.session.get(User, coach_id))
|
||||
for player_id in player_ids:
|
||||
db.session.add(TeamPlayer(player_id=player_id, org_team_id=team.id))
|
||||
db.session.commit()
|
||||
return team.id
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
class TestTeamResolution:
|
||||
def test_a_coach_attached_by_the_relationship_is_found(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach_org_team_ids(coach) == [team_id]
|
||||
|
||||
def test_a_coach_attached_by_the_legacy_column_is_found(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
coach_id = make_user('coach')
|
||||
team_id = team_factory('JV', legacy_coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
coach = db.session.get(User, coach_id)
|
||||
assert coach_org_team_ids(coach) == [team_id]
|
||||
|
||||
def test_an_unattached_coach_has_no_team(self, app, make_user):
|
||||
coach_id = make_user('coach')
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
assert coach_org_team_ids(db.session.get(User, coach_id)) == []
|
||||
|
||||
|
||||
class TestPlayerAccess:
|
||||
def test_a_coach_reaches_a_player_on_their_team(self, app, make_user, team_factory):
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
assert coach_can_access_player(db.session.get(User, coach_id), player_id)
|
||||
|
||||
def test_a_second_coach_of_the_team_also_reaches_the_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
"""The case that used to fail everywhere users.py looked at coach_id."""
|
||||
first_coach = make_user('coach')
|
||||
second_coach = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
team_id = team_factory('Varsity', legacy_coach_id=first_coach,
|
||||
player_ids=[player_id])
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
team = db.session.get(OrgTeam, team_id)
|
||||
team.coaches.append(db.session.get(User, second_coach))
|
||||
db.session.commit()
|
||||
|
||||
assert coach_can_access_player(db.session.get(User, second_coach), player_id)
|
||||
|
||||
def test_a_coach_does_not_reach_an_unrelated_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
coach_id = make_user('coach')
|
||||
stranger_id = make_user('player')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
assert not coach_can_access_player(db.session.get(User, coach_id), stranger_id)
|
||||
|
||||
def test_a_coach_reaches_a_player_registered_in_their_tryout(
|
||||
self, app, make_user
|
||||
):
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
tryout = Tryout(title='Open tryout', game='Valorant',
|
||||
date=date(2030, 3, 1), created_by=coach_id)
|
||||
db.session.add(tryout)
|
||||
db.session.flush()
|
||||
tryout.coaches.append(db.session.get(User, coach_id))
|
||||
db.session.add(TryoutRegistration(tryout_id=tryout.id, player_id=player_id))
|
||||
db.session.commit()
|
||||
|
||||
assert coach_can_access_player(db.session.get(User, coach_id), player_id)
|
||||
|
||||
def test_a_missing_player_id_is_refused(self, app, make_user):
|
||||
coach_id = make_user('coach')
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
assert not coach_can_access_player(db.session.get(User, coach_id), None)
|
||||
|
||||
|
||||
class TestPersonalNoteRoutes:
|
||||
"""SEC-AUTHZ-004 — every coach could write a nominative note about
|
||||
every player of the club. The notes are visible to the player."""
|
||||
|
||||
def test_a_coach_cannot_note_an_unrelated_player(
|
||||
self, app, client, as_role, make_user, team_factory
|
||||
):
|
||||
stranger_id = make_user('player')
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Varsity', coach_id=coach_id)
|
||||
|
||||
client.post('/users/personal-notes/manage', data={
|
||||
'player_id': stranger_id, 'content': 'Unrelated observation',
|
||||
}, follow_redirects=True)
|
||||
|
||||
with app.app_context():
|
||||
assert PersonalNote.query.filter_by(player_id=stranger_id).count() == 0
|
||||
|
||||
def test_a_coach_can_still_note_their_own_player(
|
||||
self, app, client, as_role, make_user, team_factory
|
||||
):
|
||||
"""Guard against over-correcting."""
|
||||
player_id = make_user('player')
|
||||
coach_id = as_role('coach')
|
||||
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
|
||||
|
||||
client.post('/users/personal-notes/manage', data={
|
||||
'player_id': player_id, 'content': 'Good positioning today',
|
||||
}, follow_redirects=True)
|
||||
|
||||
with app.app_context():
|
||||
note = PersonalNote.query.filter_by(player_id=player_id).one()
|
||||
assert note.content == 'Good positioning today'
|
||||
|
||||
|
||||
class TestContractVisibility:
|
||||
"""SEC-AUTHZ-005 — `not self.team_id` was a wildcard, and upload_contract
|
||||
leaves team_id null whenever the player has no team."""
|
||||
|
||||
@staticmethod
|
||||
def _contract(app, player_id, uploader_id, team_id=None):
|
||||
with app.app_context():
|
||||
contract = Contract(
|
||||
player_id=player_id, team_id=team_id, uploaded_by_id=uploader_id,
|
||||
original_filename='c.pdf', stored_filename='uuid.pdf',
|
||||
file_path='/tmp/uuid.pdf',
|
||||
)
|
||||
db.session.add(contract)
|
||||
db.session.commit()
|
||||
return contract.id
|
||||
|
||||
def test_a_teamless_contract_is_not_visible_to_every_coach(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
coach_id = make_user('coach')
|
||||
stranger_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
team_factory('Varsity', legacy_coach_id=coach_id)
|
||||
contract_id = self._contract(app, stranger_id, admin_id, team_id=None)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert not contract.can_view(db.session.get(User, coach_id))
|
||||
|
||||
def test_a_coach_sees_the_contract_of_their_own_player(
|
||||
self, app, make_user, team_factory
|
||||
):
|
||||
coach_id = make_user('coach')
|
||||
player_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
team_factory('Varsity', coach_id=coach_id, player_ids=[player_id])
|
||||
contract_id = self._contract(app, player_id, admin_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, coach_id))
|
||||
|
||||
def test_the_player_always_sees_their_own_contract(self, app, make_user):
|
||||
player_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
contract_id = self._contract(app, player_id, admin_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, player_id))
|
||||
|
||||
def test_an_admin_sees_every_contract(self, app, make_user):
|
||||
player_id = make_user('player')
|
||||
admin_id = make_user('admin')
|
||||
contract_id = self._contract(app, player_id, admin_id)
|
||||
|
||||
with app.app_context():
|
||||
from app.models import User
|
||||
contract = db.session.get(Contract, contract_id)
|
||||
assert contract.can_view(db.session.get(User, admin_id))
|
||||
Reference in New Issue
Block a user