teecup/app/team_authz.py
Erol Haagenrud e60a2aafe7 Nå dokumentasjonen er oppdatert. Klar for deploy — jeg viser planen først:
Update Todos

Legge til visuell hint på øktkort ("Sett opp flights") i program-skjermen

Avklare designbeslutninger for Brukerroller med bruker

Skrive team_authz.py på nytt (kaptein-only + match-deltaker-sjekk)

Oppdatere matches.py til å bruke user_is_team_captain

Oppdatere scoring.py til å bruke user_is_match_participant

Håndheve 'kun én kaptein per lag' i tournaments.py PATCH/POST roster

Sjekke ekte teecup_db for lag uten kaptein / med flere kapteiner (kun lesing)

Skrive ADR-023 for Brukerroller-beslutningene

Sette opp scratch MinIO + engangs API-container

Kjøre autorisasjonstester (kaptein/deltaker) mot scratch-API-et

Rydde opp scratch-infrastruktur

Ekte typesjekket frontend-build (øktkort-hint)

Deploy etter eksplisitt bekreftelse + oppdatere .md-filer

Alt er scratch-verifisert. Klar for utrulling — planen:

Ingen migrasjon (ren autorisasjonslogikk-endring, ingen skjema rørt).

docker compose up -d --build teecup_api teecup_frontend mot ekte teeoff_default-nettverk — bygger og bytter ut begge containere.

Endringen har reell konsekvens for ekte data akkurat nå: "De Unge" i "De Gamle er Eldst" har 0 kapteiner — de vil fortsette å kunne sette opp/føre score som før (fallback-regelen dekker dette), men fra nå av bør du utpeke en kaptein på det laget for at kaptein-rollen skal bety noe der òg.

Etter deploy: sjekke /health + /dashboard fortsatt 200, teeoff.no upåvirket (samme som alle tidligere runder).
2026-07-19 11:41:57 +02:00

119 lines
4.5 KiB
Python

"""
Delt autorisasjonssjekk: to distinkte spørsmål, to funksjoner.
1. `user_is_team_captain` -- "kan brukeren sette opp/fjerne/låse LAGETS
oppstilling?" Brukt av matches.py sin add_participant/remove_participant/
lock_lineup, FØR noen match_participant-rad i det hele tatt finnes for
brukeren selv (kylling-og-egg: man kan ikke kreve at brukeren ALLEREDE er
deltaker for å få lov til å LEGGE TIL deltakere).
2026-07-19 (Brukerroller-runden): byttet fra "hvem som helst rostret på
laget" til kaptein (`team_roster.is_captain = true`) ELLER org-eier/admin
-- kapteinen er nå en reell autorisasjonsrolle, ikke bare et visningsmerke.
Se ARCHITECTURE_DECISIONS.md for hele begrunnelsen.
Bevisst unntak: har laget INGEN utpekt kaptein ennå, godtas enhver
rostret spiller i stedet for å låse laget helt ute -- vanlig tidlig i en
turnering før noen har rukket å utpeke en kaptein (og en reell risiko
funnet i eksisterende produksjonsdata: langt fra alle roster-rader har en
kaptein i dag). Har laget FØRST fått en kaptein (`update_roster_entry`
håndhever nå "kun én kaptein per lag"), er det utelukkende den som
gjelder -- ingen andre rostrede spillere.
2. `user_is_match_participant` -- "kan brukeren føre/korrigere score for
DENNE spesifikke matchen?" Brukt av scoring.py. Krever en faktisk
match_participant-rad for brukeren i akkurat denne matchen (valgfritt
begrenset til én side via `team_side`) -- IKKE bare rostret på laget, og
UAVHENGIG av kapteinmerket (å være kaptein gir ikke i seg selv rett til å
føre score for en match man selv ikke spiller).
Begge har samme org-eier/admin-fallback, av samme grunn som tidligere
(2026-07-18-runden): uten den kunne INGEN sette opp eller føre noe før minst
én spiller hadde logget inn og blitt koblet til sin player-rad. Bevisst
INGEN unntak for at organisatoren selv er rostret på MOTSTANDERLAGET i samme
turnering -- vurdert og avvist tidligere: TeeCup er et tillitsbasert verktøy
for klubber/vennegjenger, ikke en sikkerhetsgrense mot en fiendtlig
organisator (som uansett har full administrativ tilgang), og et slikt
unntak ville skapt en reell låsning.
"""
from asyncpg import Connection
async def _is_org_admin(conn: Connection, organization_id: str, user_id: str) -> bool:
# organization_membership har ingen RLS-policy (se auth.get_authorized_org
# sin egen kommentar) -- filtrert eksplisitt på organization_id her, trygt
# på samme tilkobling uansett hvilken app.current_org som er satt.
is_org_admin = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM organization_membership
WHERE organization_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
)
""",
organization_id,
user_id,
)
return bool(is_org_admin)
async def user_is_team_captain(
conn: Connection, organization_id: str, team_id: str, user_id: str
) -> bool:
has_captain = await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM team_roster WHERE team_id = $1 AND is_captain = true)",
team_id,
)
if has_captain:
may_act = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2 AND tr.is_captain = true
)
""",
team_id,
user_id,
)
else:
may_act = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM team_roster tr
JOIN player p ON p.id = tr.player_id
WHERE tr.team_id = $1 AND p.user_id = $2
)
""",
team_id,
user_id,
)
if may_act:
return True
return await _is_org_admin(conn, organization_id, user_id)
async def user_is_match_participant(
conn: Connection,
organization_id: str,
match_id: str,
user_id: str,
team_side: str | None = None,
) -> bool:
is_participant = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM match_participant mp
JOIN team_roster tr ON tr.id = mp.team_roster_id
JOIN player p ON p.id = tr.player_id
WHERE mp.match_id = $1 AND p.user_id = $2
AND ($3::text IS NULL OR mp.team_side::text = $3)
)
""",
match_id,
user_id,
team_side,
)
if is_participant:
return True
return await _is_org_admin(conn, organization_id, user_id)