Org-bootstrap er bygget og verifisert. POST /orgs (app/routers/organizations.py) er nå det eneste stedet i API-et som setter inn en organization-rad — ingen ny migrasjon trengtes.

Det viktigste funnet: kommentaren i 002_roles_and_grants.sql om at bootstrap trenger "en privilegert sti" stemte ikke. Den selvrefererende triksen fungerte på første forsøk: generer org-ens uuid i Python før innsetting, sett app.current_org til nøyaktig den via den allerede eksisterende org_connection(), sett så inn raden med samme id — org_selfs implisitte WITH CHECK blir da trivielt sann. teecup_app (NOSUPERUSER/NOBYPASSRLS) trengte ingen egen privilegert tilkobling.

Alle 5 tester fra planen bestått, inkludert den viktigste — en negativ kontroll som beviser mekanismen er presis og ikke et RLS-hull: forsøk på å sette inn en organisasjon med en mismatchende id ble avvist med insufficient_privilege. I tillegg: ny org fungerer normalt med eksisterende endepunkter, dukker riktig opp i /auth/me, og full kryss-org-isolasjon holder mellom to uavhengig opprettede organisasjoner.

CLAUDE.md og FEATURE_BACKLOG.md er oppdatert.
This commit is contained in:
Erol Haagenrud 2026-07-16 20:41:34 +02:00
parent 9a42a2e585
commit 62017a1b0d
5 changed files with 118 additions and 8 deletions

View file

@ -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 -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 -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(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)"
] ]
} }
} }

View file

@ -123,15 +123,30 @@ Ferdig og verifisert:
slår derfor opp hvert org-navn ett om gangen via `org_connection()` (N+1, 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 N = antall org-er brukeren tilhører) — dette er riktig løsning, ikke en
omvei. 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: 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_db` med `teecup_app`, rute via eksisterende Caddy til
`teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup` `teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
monteres, ikke bare `app/`, fordi `handicap_engine.py` er et monteres, ikke bare `app/`, fordi `handicap_engine.py` er et
toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn
med samme relative plassering.) Ekte SMTP-utsending av magic-link må også med samme relative plassering.)
kobles inn før dette går live (i dag: dev-only logging bak 3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006,
`TEECUP_DEV_LOG_MAGIC_LINKS`). siden 004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE
2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — API-et alene
siden 004/005 nå er tatt av auth og RLS-fiksen). er ikke en brukbar nettside.

View file

@ -29,6 +29,34 @@
| Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. | | 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). | | 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. | | 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.
--- ---

View file

@ -15,7 +15,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from .db import init_pool, close_pool 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 @asynccontextmanager
@ -30,6 +30,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="TeeCup API", lifespan=lifespan) app = FastAPI(title="TeeCup API", lifespan=lifespan)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(organizations.router)
app.include_router(players.router) app.include_router(players.router)
app.include_router(tournaments.router) app.include_router(tournaments.router)
app.include_router(matches.router) app.include_router(matches.router)

View file

@ -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")