2026-07-16 14:38:42 +02:00
|
|
|
"""
|
2026-07-19 11:41:57 +02:00
|
|
|
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.
|
2026-07-16 14:38:42 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from asyncpg import Connection
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 22:26:45 +02:00
|
|
|
async def is_org_admin(conn: Connection, organization_id: str, user_id: str) -> bool:
|
2026-07-18 16:19:52 +02:00
|
|
|
# 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.
|
2026-07-19 22:26:45 +02:00
|
|
|
result = await conn.fetchval(
|
2026-07-18 16:19:52 +02:00
|
|
|
"""
|
|
|
|
|
SELECT EXISTS (
|
|
|
|
|
SELECT 1 FROM organization_membership
|
|
|
|
|
WHERE organization_id = $1 AND user_id = $2 AND role IN ('owner', 'admin')
|
|
|
|
|
)
|
|
|
|
|
""",
|
|
|
|
|
organization_id,
|
|
|
|
|
user_id,
|
|
|
|
|
)
|
2026-07-19 22:26:45 +02:00
|
|
|
return bool(result)
|
2026-07-19 11:41:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-19 22:26:45 +02:00
|
|
|
return await is_org_admin(conn, organization_id, user_id)
|
2026-07-19 11:41:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-07-19 22:26:45 +02:00
|
|
|
return await is_org_admin(conn, organization_id, user_id)
|
|
|
|
|
|
|
|
|
|
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan og be om bekreftelse før utrulling mot ekte teecup_db/containere
Bygge sekundær e-postadresse (enkelt tilfelle, ADR-032-mønster)
Bygge HCP-historikk over tid
Deltaker-tilgang til lag-chat/scorekort er bygget og grundig scratch-verifisert (43/43 sjekker, test_isolation.sql fortsatt 12/12). Kort oppsummert hva som er gjort:
Backend — den blanke org-medlemskaps-sperren (get_authorized_org) er fjernet fra ni endepunkter (lag-chat lese/skrive/slette, scorekort-lesing, slag/hull-resultat-innsending, walkover på match- og turneringsnivå, match-/lag-/økt-listing, bane-hull) og erstattet med de faktiske, allerede eksisterende autorisasjonssjekkene (user_is_rostered_on_team, user_is_match_participant, user_is_team_captain — alle uendret) — pluss et nytt eksplisitt org-medlem ELLER turnering-deltaker-OR på de endepunktene som tidligere ikke hadde noen finkornet sjekk i det hele tatt (der en ren fjerning ville åpnet dem for enhver innlogget bruker). /auth/me viser nå også my_session_id/my_match_id per rad i "Mine runder".
Frontend — "Mine runder"-kortet har fått to nye handlingslenker: "Lag-chat" (alltid) og "Scorekort" (når spilleren har en aktiv match).
Verifisert grundig: en rostret spiller UTEN organisasjonsmedlemskap får nå riktig tilgang til alt dette; en helt fremmed bruker avvises fortsatt overalt; org-eier beholder full tilgang som før; lag-chatten er fortsatt ekte privat (org-eier fortsatt avvist der, med vilje); kryss-org-isolasjon holder; og en rostret-men-ikke-kaptein spiller ble korrekt avvist fra walkover (fant samtidig en eksisterende, dokumentert fallback-oppførsel — et lag uten utpekt kaptein godtar enhver rostret spiller — som viste seg å fungere akkurat som tiltenkt).
2026-07-22 05:46:47 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 09:36:52 +02:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-07-19 22:26:45 +02:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
)
|