diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3e3141a..0259a63 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -101,7 +101,12 @@ "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_rls.txt http://127.0.0.1:8099/auth/me)", "Bash(curl -s -o /dev/null -X POST -H 'Content-Type: application/json' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_rls.txt -d '{\"name\":\"Bug-repro-cup-2\"}' http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_rls.txt http://127.0.0.1:8099/orgs/11111111-1111-1111-1111-111111111111/tournaments)", - "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies_rls.txt)" + "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies_rls.txt)", + "Bash(python3 -m py_compile /opt/teecup/app/routers/organizations.py /opt/teecup/app/main.py)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt -X POST -H 'Content-Type: application/json' -d '{\"name\":\"Høstcupen\"}' http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/auth/me)", + "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies_org.txt __TRACKED_VAR__/cookies_org2.txt)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 2f8e56b..2208bef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,15 +123,30 @@ Ferdig og verifisert: slår derfor opp hvert org-navn ett om gangen via `org_connection()` (N+1, N = antall org-er brukeren tilhører) — dette er riktig løsning, ikke en omvei. +- **Organisasjon-bootstrap bygget og verifisert (2026-07-16):** nytt + `POST /orgs` (`app/routers/organizations.py`) — det ENESTE stedet i API-et + som setter inn en `organization`-rad. Fant under statusgjennomgang at dette + manglet helt (alle tidligere org-er var seedet med superbruker-SQL). Ingen + ny migrasjon. Selvrefererende RLS-bootstrap bekreftet å fungere: generer + org-ens uuid i Python, sett `app.current_org` til nøyaktig den via + eksisterende `org_connection()`, sett inn `organization`-raden med samme + id — `org_self`s implisitte `WITH CHECK` blir da trivielt sann, ingen + privilegert tilkobling nødvendig (i motsetning til hva 002s kommentar + antydet). Verifisert med 5 tester inkl. en negativ kontroll (mismatchende + id avvist med `insufficient_privilege`) og full kryss-org-isolasjon mellom + to uavhengig opprettede organisasjoner. Neste steg: -1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot +1. Ekte SMTP-utsending av magic-link (i dag: dev-only logging bak + `TEECUP_DEV_LOG_MAGIC_LINKS`) — krever ekte e-post-credentials, ikke noe + som kan scratch-testes uten dem. +2. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot `teecup_db` med `teecup_app`, rute via eksisterende Caddy til `teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup` monteres, ikke bare `app/`, fordi `handicap_engine.py` er et toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn - med samme relative plassering.) Ekte SMTP-utsending av magic-link må også - kobles inn før dette går live (i dag: dev-only logging bak - `TEECUP_DEV_LOG_MAGIC_LINKS`). -2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, - siden 004/005 nå er tatt av auth og RLS-fiksen). + med samme relative plassering.) +3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, + siden 004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE + startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — API-et alene + er ikke en brukbar nettside. diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index 55cf617..863de88 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -29,6 +29,34 @@ | Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. | | Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. Ekte SMTP-utsending gjenstår (i dag: dev-only logging). | | RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. | +| Organisasjon-bootstrap (opprette ny org via API) | ✅ | `POST /orgs`, `app/routers/organizations.py`. Se detaljer under. | + +--- + +### Organisasjon-bootstrap — ✅ BYGGET OG VERIFISERT 2026-07-16 +- Gjennomgang av alle routere hadde avdekket at INGEN endepunkt opprettet en + `organization`-rad — i alle testrunder denne økten var organisasjoner satt + inn direkte med superbruker-SQL. En ekte førstegangsbruker hadde ingen vei + til å opprette klubben/bedriften sin og bli owner. Reelt blokkerende, ikke + en utsettbar produktbeslutning. +- **Løsning:** nytt `POST /orgs {"name": ...}`, autorisert med + `get_current_user` (ikke `get_authorized_org` — sirkulært før org-en + finnes). Ingen ny migrasjon nødvendig. +- **Selvrefererende RLS-bootstrap bekreftet å fungere** (kommentaren i 002 om + en "privilegert sti" var ALDRI bygget og viste seg unødvendig): generer + org-ens uuid i Python FØR innsetting, sett `app.current_org` til nøyaktig + den verdien via den eksisterende `org_connection()`, sett så inn + `organization`-raden med samme id. `org_self`-policyens implisitte + `WITH CHECK` (id = `app_current_org()`) blir da trivielt sann. + `teecup_app` (NOSUPERUSER/NOBYPASSRLS) trenger altså INGEN egen privilegert + tilkobling for å bootstrappe sin egen første organisasjonsrad. +- **Verifisert med 5 tester**, inkludert en negativ kontroll som beviser + mekanismen er presis, ikke et RLS-hull: forsøkte å sette inn en + organisasjon med en MISMATCHENDE id (annen enn `app.current_org`) — avvist + med `insufficient_privilege`, som forventet. Også bekreftet: ny org fungerer + normalt med eksisterende endepunkter (GET/POST tournaments), dukker opp + riktig i `/auth/me`, og full kryss-org-isolasjon holder mellom to + uavhengig opprettede organisasjoner. --- diff --git a/app/main.py b/app/main.py index ed0184b..ebaecad 100644 --- a/app/main.py +++ b/app/main.py @@ -15,7 +15,7 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from .db import init_pool, close_pool -from .routers import auth, matches, players, scoring, tournaments +from .routers import auth, matches, organizations, players, scoring, tournaments @asynccontextmanager @@ -30,6 +30,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="TeeCup API", lifespan=lifespan) app.include_router(auth.router) +app.include_router(organizations.router) app.include_router(players.router) app.include_router(tournaments.router) app.include_router(matches.router) diff --git a/app/routers/organizations.py b/app/routers/organizations.py new file mode 100644 index 0000000..627c8ea --- /dev/null +++ b/app/routers/organizations.py @@ -0,0 +1,61 @@ +""" +Organisasjon-bootstrap (opprett en ny organisasjon og bli owner). + +Eneste stedet i API-et som setter inn en `organization`-rad. Bruker +`get_current_user` (IKKE `get_authorized_org`, som forutsetter en ALLEREDE +eksisterende org-medlemskap -- sirkulært for akkurat denne handlingen, samme +resonnement som lag-/roster-opprettelse i oppsett-runden). + +Selvrefererende RLS-bootstrap: `org_self`-policyen (etter migrasjon 005) er +`USING (id = app_current_org())` uten eksplisitt WITH CHECK, som Postgres +speiler dynamisk til INSERT-sjekken. Genererer derfor org-ens uuid FØR +innsetting og setter `app.current_org` til NØYAKTIG den verdien via den +allerede eksisterende `org_connection()` -- da er WITH CHECK trivielt sann +(id == current_org), og `teecup_app` (NOSUPERUSER/NOBYPASSRLS) kan sette inn +sin egen FØRSTE organisasjonsrad uten noen egen privilegert tilkobling. +Verifisert med en negativ kontroll (mismatchende id blir avvist) i +scratch-testingen -- se plan. +""" + +import uuid + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, Field + +from ..auth import CurrentUser, get_current_user +from ..db import org_connection +from ..errors import translate_db_errors + +router = APIRouter() + + +class OrganizationCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + + +class OrganizationOut(BaseModel): + id: str + name: str + role: str + + +@router.post("/orgs", response_model=OrganizationOut, status_code=201) +async def create_organization( + body: OrganizationCreate, + user: CurrentUser = Depends(get_current_user), +) -> OrganizationOut: + new_id = str(uuid.uuid4()) + + async with org_connection(new_id) as conn, translate_db_errors(): + row = await conn.fetchrow( + "INSERT INTO organization (id, name) VALUES ($1, $2) RETURNING id::text, name", + new_id, + body.name, + ) + await conn.execute( + "INSERT INTO organization_membership (organization_id, user_id, role) VALUES ($1, $2, 'owner')", + new_id, + user.user_id, + ) + + return OrganizationOut(id=row["id"], name=row["name"], role="owner")