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:
parent
9a42a2e585
commit
62017a1b0d
5 changed files with 118 additions and 8 deletions
|
|
@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
CLAUDE.md
27
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
61
app/routers/organizations.py
Normal file
61
app/routers/organizations.py
Normal 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")
|
||||
Loading…
Reference in a new issue