teecup/app/team_authz.py
Erol Haagenrud facefd9951 Begge implementert, browserverifisert og live:
Lenke scorekort → statistikk lagt til nederst på scorekortsiden — symmetrisk med den eksisterende lenken tilbake.
Prikker for mottatte slag i stedet for "−N"-tekst, i live-registreringen (begge grid-typer) — verifisert med en HCP 28-spiller som viste nøyaktig 2 prikker på de rette hullene.
Selve spørsmålet om å slå alt sammen til én visning er bevisst latt åpent til dere har fått testet med ekte brukere — notert tydelig i FEATURE_BACKLOG.md slik at det ikke går i glemmeboken.
2026-07-30 09:36:52 +02:00

243 lines
9.6 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.
result = 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(result)
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)
async def is_org_member(conn: Connection, organization_id: str, user_id: str) -> bool:
"""ETHVERT medlemskap (ikke bare owner/admin, se is_org_admin over) --
brukt som den ene halvparten av OR-et som (2026-07-21, deltaker-tilgang-
runden) erstattet den tidligere blanke get_authorized_org-sperren på
read-endepunkter som selv ikke har noen mer finkornet sjekk (list_teams,
list_sessions, get_scorecard) -- bevarer eksisterende org-medlemmers
tilgang uendret, samtidig som en ikke-medlem deltaker slipper gjennom
via user_is_tournament_participant under."""
return bool(
await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM organization_membership WHERE organization_id = $1 AND user_id = $2)",
organization_id,
user_id,
)
)
async def user_is_org_player(conn: Connection, organization_id: str, user_id: str) -> bool:
"""Er brukeren koblet til NOEN spillerprofil i denne organisasjonen (ikke
nødvendigvis en gitt turnering) -- brukt for lavsensitiv data som
bane-/hull-info (courses.py sin list_holes), der en full
turnering-deltaker-sjekk ville vært unødvendig presis for hva som
faktisk beskyttes (par/stroke-index, ikke spillerdata)."""
return bool(
await conn.fetchval(
"SELECT EXISTS (SELECT 1 FROM player WHERE organization_id = $1 AND user_id = $2)",
organization_id,
user_id,
)
)
async def user_is_tournament_participant(
conn: Connection, organization_id: str, tournament_id: str, user_id: str
) -> bool:
"""Har brukerens koblede spillerprofil (ADR-017 Beslutning B) en
registrering ELLER en rostret plass i NØYAKTIG denne turneringen?
Flyttet hit fra registration.py 2026-07-21 (het `is_participant` der) --
org-scopede endepunkter (matches.py/scoring.py/tournaments.py) kan ikke
importere fra registration.py uten sirkulær import (registration.py
importerer FRA disse tre for øvrig), men alle tre importerer allerede
fritt fra denne (dependency-frie) modulen. registration.py importerer nå
denne i stedet for sin egen kopi."""
return bool(
await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM player p
WHERE p.organization_id = $1 AND p.user_id = $2
AND (
EXISTS (
SELECT 1 FROM tournament_registration tr
WHERE tr.player_id = p.id AND tr.tournament_id = $3
)
OR EXISTS (
SELECT 1 FROM team_roster tro
JOIN team t ON t.id = tro.team_id
WHERE tro.player_id = p.id AND t.tournament_id = $3
)
)
)
""",
organization_id,
user_id,
tournament_id,
)
)
async def user_is_own_tournament_participant(
conn: Connection, organization_id: str, tournament_participant_id: str, user_id: str
) -> bool:
"""Individuelle turneringer (ADR-037): er brukeren SELV spilleren bak
denne `tournament_participant`-raden (via `player.user_id`)?
Mirror av `user_is_match_participant`, men uten `team_side` -- en
individuell turnering har ingen lag å begrense siden til. Brukt av
`individual_tournaments.py` sin `update_hole` (2026-07-30): før denne
kunne ETHVERT org-medlem skrive score for EN HVILKEN SOM HELST
deltaker, ikke bare sin egen -- samme klasse hull `user_is_match_
participant` lukket for lagturneringer i ADR-023. Kun scoring er
strammet inn her; runde-/deltaker-oppsett (opprett runde, legg til/
fjern turnering-/rundedeltaker) forblir bevisst på org-medlemsnivå,
samme presedens som `session`-opprettelse og `team_roster`-tilføyelse
i tournaments.py -- ingen av dem er captain-/admin-gatet heller, kun
selve VALGET AV HVEM SOM SPILLER EN GITT MATCH/RUNDE og selve
scoreregistreringen er det."""
is_self = await conn.fetchval(
"""
SELECT EXISTS (
SELECT 1 FROM tournament_participant tp
JOIN player p ON p.id = tp.player_id
WHERE tp.id = $1 AND p.user_id = $2
)
""",
tournament_participant_id,
user_id,
)
if is_self:
return True
return await is_org_admin(conn, organization_id, user_id)
async def user_is_rostered_on_team(conn: Connection, team_id: str, user_id: str) -> bool:
"""Kun for lag-chat (ADR-025 Beslutning C) -- BEVISST INGEN org-admin-
fallback, ulikt de to funksjonene over. «Det hemmelige rommet» er ekte
privat: kun spillere faktisk rostret på laget, aldri organisatoren, uansett
org-rolle. Ikke gjenbruk denne for autorisasjon utenfor chat."""
return bool(
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,
)
)