Update Todos

Utvid team_authz.py: org owner/admin får overstyre uten unntak

Oppdater alle 5 kallsteder i matches.py/scoring.py

Scratch-verifisering: org-admin uten roster kan låse/legge til deltakere/score, vanlig member kan ikke, rostret spiller uendret

Rulle ut live + oppdatere statusdokumenter
Live, verifisert, og dokumentert. Klar for å skrive V0-prompten for blind draw-skjermen når du vil — samme mønster som de forrige, og nå med bekreftet organisator-tilgang selv om ingen kaptein har logget inn ennå.
This commit is contained in:
Erol Haagenrud 2026-07-18 16:19:52 +02:00
parent 0b6e7e4219
commit a46377e7bc
6 changed files with 88 additions and 14 deletions

View file

@ -248,7 +248,9 @@
"Bash(python3 -)", "Bash(python3 -)",
"Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'facilities'\\); print\\(d[:2]\\)\")", "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(len\\(d\\), 'facilities'\\); print\\(d[:2]\\)\")",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/scratch_cookies2.txt)", "Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/scratch_cookies2.txt)",
"Bash(curl -s \"https://teecup.teeoff.no/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a?org=test-org-id&name=Test\")" "Bash(curl -s \"https://teecup.teeoff.no/tournaments/73b6bf38-90cd-4ef7-9535-8b8e108eaa9a?org=test-org-id&name=Test\")",
"Bash(python3 -m py_compile app/team_authz.py app/routers/matches.py app/routers/scoring.py)",
"Bash(rm -f /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch.env /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/scratch_app_pw.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_user.txt /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/minio_pass.txt /tmp/owner_cookies.txt /tmp/member_cookies.txt /tmp/captain_cookies.txt)"
], ],
"additionalDirectories": [ "additionalDirectories": [
"/opt/teeoff/deploy", "/opt/teeoff/deploy",

View file

@ -776,6 +776,29 @@ Ferdig og verifisert:
ALDRI være et eget `<form>` -- bruk `<div>` + eksplisitt ALDRI være et eget `<form>` -- bruk `<div>` + eksplisitt
klikk-/Enter-håndtering. klikk-/Enter-håndtering.
- **Organisator-overstyring i `team_authz.py`, LIVE (2026-07-18):** reist av
brukeren rett før blind draw-skjermen skulle designes: `app/team_authz.py`
sin `user_may_act_for_team` krevde tidligere en `team_roster`-rad med
lenket bruker-konto — ingen vei for organisatoren til å låse/legge til
deltakere/føre score hvis ingen spiller hadde logget inn ennå (vanlig
tidlig i en turnering). Utvidet til også å godta org-eier/admin
(`organization_membership.role IN ('owner','admin')`), på ETHVERT lag,
ingen unntak for at organisatoren selv er rostret på motstanderlaget.
**Det unntaket ble bevisst vurdert og avvist** (brukeren spurte selv om
det, jeg anbefalte det opprinnelig, men vi kom sammen frem til at det ville
skapt en verre låsning: er organisatoren spillende på Lag A og
Lag B heller ikke har noen innlogget spiller, ville Lag B blitt helt
låst ute) — TeeCup er et tillitsbasert klubb-/vennegjeng-verktøy, ikke en
sikkerhetsgrense mot en fiendtlig organisator som uansett allerede ser
begge lags fulle troppe-liste (blind draw skjuler kun selve
kamp-paringen). Alle 5 kallsteder oppdatert (matches.py sin
add_participant/lock_lineup, scoring.py sin submit_hole_score/
submit_hole_result). **Verifisert grundig i scratch:** org-eier uten
roster kan nå låse BEGGE lag + føre score, en vanlig 'member'-rolle
fortsatt blokkert (uendret), en rostret spiller fungerer uendret
uavhengig av org-rolle. `test_isolation.sql` 12/12. Ingen migrasjon (ren
Python-endring) — kun `teecup_api` redeployet, `teeoff.no` upåvirket.
Neste steg: Neste steg:
1. Blind draw-skjermen (neste i "bygg i rekkefølgen ting brukes", gjenopptatt 1. Blind draw-skjermen (neste i "bygg i rekkefølgen ting brukes", gjenopptatt
etter ADR-019-sidespranget) — deretter scorekort, leaderboard. Samme etter ADR-019-sidespranget) — deretter scorekort, leaderboard. Samme

View file

@ -309,11 +309,26 @@
bevisst IKKE «kun én kaptein per lag» — flere spillere kan i dag merkes bevisst IKKE «kun én kaptein per lag» — flere spillere kan i dag merkes
kaptein samtidig på samme lag. Bør revurderes samtidig med resten av dette kaptein samtidig på samme lag. Bør revurderes samtidig med resten av dette
punktet, ikke løses isolert i roster-endepunktet. punktet, ikke løses isolert i roster-endepunktet.
- **Nytt 2026-07-18, ✅ BYGGET:** `app/team_authz.py` sin `user_may_act_for_team`
(brukt av lås/deltakere i matches.py OG score-skriving i scoring.py) gir nå
også org-eier/admin (`organization_membership.role IN ('owner','admin')`)
samme rettigheter som en rostret spiller, på ETHVERT lag — reist av
brukeren rett før blind draw-skjermen: uten dette kunne INGEN sette opp
eller låse et lag før minst én spiller hadde logget inn og blitt koblet.
Bevisst INGEN unntak for at organisatoren selv er rostret på
MOTSTANDERLAGET (vurdert og avvist — se team_authz.py sin docstring for
full begrunnelse: tillitsbasert verktøy, organisator ser uansett begge
rostre allerede, og et unntak ville skapt en reell låsning der ingen
kunne sette opp motstanderlaget om det heller ikke har en innlogget
spiller). Verifisert i scratch: org-eier uten roster kan nå låse begge
lag + føre score, vanlig 'member'-rolle fortsatt blokkert, rostret
spiller uendret. Rullet ut live samme dag.
### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16) ### Scoring-autorisasjon: hvem fører, hvem korrigerer, hvem lukker (rejst 2026-07-16)
- **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over. - **Status:** ❓ trenger beslutning — direkte oppfølger av «Brukerroller» over.
- **Hvem fører score i dag:** alle med en `team_roster`-rad på laget (samme - **Hvem fører score i dag:** alle med en `team_roster`-rad på laget, ELLER
minimale grense som deltaker/lås, se ADR-013-relatert kode). Ikke org-eier/admin (utvidet 2026-07-18, se «Brukerroller» over) — samme
minimale grense som deltaker/lås, se ADR-013-relatert kode. Ikke
kaptein-only, ikke begrenset til de(n) som faktisk spiller matchen. kaptein-only, ikke begrenset til de(n) som faktisk spiller matchen.
- **Hvem kan korrigere en ført score i dag:** akkurat de samme — skriving er en - **Hvem kan korrigere en ført score i dag:** akkurat de samme — skriving er en
upsert (`ON CONFLICT ... DO UPDATE`), så en korrigering er ikke skilt fra en upsert (`ON CONFLICT ... DO UPDATE`), så en korrigering er ikke skilt fra en

View file

@ -267,7 +267,7 @@ async def add_participant(
if tee_course_id is None or tee_course_id != session["course_id"]: if tee_course_id is None or tee_course_id != session["course_id"]:
raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.") raise app_error(400, "OUT_OF_SCOPE", "tee_id tilhører ikke øktens bane.")
if not await user_may_act_for_team(conn, expected_team_id, user.user_id): if not await user_may_act_for_team(conn, organization_id, expected_team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
locked = await locked_team_ids(conn, match["session_id"]) locked = await locked_team_ids(conn, match["session_id"])
@ -338,7 +338,7 @@ async def lock_lineup(
if session is None: if session is None:
raise app_error(404, "NOT_FOUND", "Økten finnes ikke.") raise app_error(404, "NOT_FOUND", "Økten finnes ikke.")
if not await user_may_act_for_team(conn, body.team_id, user.user_id): if not await user_may_act_for_team(conn, organization_id, body.team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
# UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved # UNIQUE(session_id, team_id) gir 409 via translate_db_errors ved

View file

@ -272,7 +272,7 @@ async def submit_hole_score(
400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side." 400, "MISMATCHED_SIDE", "match_participant_id tilhører ikke angitt side."
) )
if not await user_may_act_for_team(conn, expected_team_id, user.user_id): if not await user_may_act_for_team(conn, organization_id, expected_team_id, user.user_id):
raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.") raise app_error(403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på dette laget.")
if body.match_participant_id is not None: if body.match_participant_id is not None:
@ -365,8 +365,8 @@ async def submit_hole_result(
# Begge lag kan rapportere et hull-resultat (hvilken som helst side kan # Begge lag kan rapportere et hull-resultat (hvilken som helst side kan
# vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene. # vinne/tape/dele) -- brukeren må være rostret på ETT av de to lagene.
if not ( if not (
await user_may_act_for_team(conn, match["team_a_id"], user.user_id) await user_may_act_for_team(conn, organization_id, match["team_a_id"], user.user_id)
or await user_may_act_for_team(conn, match["team_b_id"], user.user_id) or await user_may_act_for_team(conn, organization_id, match["team_b_id"], user.user_id)
): ):
raise app_error( raise app_error(
403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på noen av lagene i denne matchen." 403, "NOT_ROSTERED_ON_TEAM", "Du er ikke rostret på noen av lagene i denne matchen."

View file

@ -1,16 +1,33 @@
""" """
Delt autorisasjonssjekk: er brukeren rostret et gitt lag? Delt autorisasjonssjekk: kan brukeren handle vegne av et gitt lag?
Brukt av både matches.py (deltaker/lås-skriving) og scoring.py (score-skriving) Brukt av både matches.py (deltaker/lås-skriving) og scoring.py (score-skriving).
se plan for begrunnelsen (bevisst IKKE kapteins-only ennå, FEATURE_BACKLOG).
To uavhengige veier inn:
1. Rostret laget -- spiller med `player.user_id` koblet nettopp DENNE
team_roster-raden (den opprinnelige, fortsatt gjeldende regelen).
2. Org-eier/admin i organisasjonen laget tilhører. Lagt til 2026-07-18: uten
dette kunne INGEN sette opp eller låse et lag før minst én spiller hadde
logget inn og blitt koblet -- vanlig tidlig i en turnering, og
organisatoren (som uansett allerede ser begge lags fulle troppe-liste,
blind draw skjuler kun selve kamp-paringen) satt fast.
Bevisst INGEN unntak for at organisatoren selv er rostret
MOTSTANDERLAGET i samme turnering -- vurdert og avvist: TeeCup er et
tillitsbasert verktøy for klubber/vennegjenger, ikke en sikkerhets-
grense mot en fiendtlig organisator (som uansett har full administrativ
tilgang), og et slikt unntak ville skapt en reell låsning (ingen kan
sette opp MOTSTANDERLAGET om det heller ikke har noen innlogget spiller
-- verre enn problemet det skulle løse).
""" """
from asyncpg import Connection from asyncpg import Connection
async def user_may_act_for_team(conn: Connection, team_id: str, user_id: str) -> bool: async def user_may_act_for_team(
"""Brukeren har en team_roster-rad på laget.""" conn: Connection, organization_id: str, team_id: str, user_id: str
return await conn.fetchval( ) -> bool:
is_rostered = await conn.fetchval(
""" """
SELECT EXISTS ( SELECT EXISTS (
SELECT 1 FROM team_roster tr SELECT 1 FROM team_roster tr
@ -21,3 +38,20 @@ async def user_may_act_for_team(conn: Connection, team_id: str, user_id: str) ->
team_id, team_id,
user_id, user_id,
) )
if is_rostered:
return True
# 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)