teecup/app/team_authz.py
Erol Haagenrud 20ae4fe6ea Backend, frontend og Caddy-endringen er alle klare og verifisert (Caddy-syntaksen validert OK). Dette er en stor runde — her er full plan for utrulling:
1. Migrasjon — kjør 013_messaging.sql mot ekte teecup_db (ny message-tabell, RLS, ingen endring av eksisterende data).

2. Backend + frontend — docker compose up -d --build teecup_api teecup_frontend.

3. Caddy — legger til /ws/*-ruten i /opt/teeoff/deploy/Caddyfile (allerede skrevet og syntaks-validert). Som i alle tidligere runder som har rørt denne filen: en graceful reload plukker historisk IKKE opp endringen (stale bind-mount-inode), så det trengs en full docker restart teeoff_caddy — det gir noen sekunders nedetid for teeoff.no også, ikke bare teecup.teeoff.no.

Etter alt dette: sjekke /health + /dashboard → 200, en reell WebSocket-tilkobling fungerer over wss://teecup.teeoff.no/ws/..., og teeoff.no er tilbake på 200.
2026-07-19 22:26:45 +02:00

139 lines
5.2 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 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,
)
)