diff --git a/.claude/settings.local.json b/.claude/settings.local.json index ea4776f..2fb1317 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -86,7 +86,16 @@ "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: __TRACKED_VAR__' __TRACKED_VAR__/orgs/__TRACKED_VAR__/matches/__TRACKED_VAR__/scorecard)", "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/hr_ids.env)", "Bash(python3 -m py_compile /opt/teecup/app/routers/scoring.py)", - "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/lock_ids.env)" + "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/lock_ids.env)", + "Bash(python3 -m py_compile /opt/teecup/app/config.py /opt/teecup/app/auth.py /opt/teecup/app/routers/auth.py /opt/teecup/app/main.py)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"email\":\"ny.spiller@test.no\"}' http://127.0.0.1:8099/auth/request-link)", + "Bash(curl -s -i -c /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies.txt -X POST -H 'Content-Type: application/json' -d '{\"token\":\"ruxjsDxPsyUShCFxeGQZ7VL2yWJmPw8KANxT7xpNpK0\"}' http://127.0.0.1:8099/auth/verify-link)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies.txt http://127.0.0.1:8099/auth/me)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"token\":\"UyBT7vlUzzc-lPumCKIpHBhHSyUMYJE59uiQEU4f_NU\"}' http://127.0.0.1:8099/auth/verify-link)", + "Bash(curl -s -o /dev/null -X POST -H 'Content-Type: application/json' -d '{\"email\":\"cooldown@test.no\"}' http://127.0.0.1:8099/auth/request-link)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"email\":\"ikke-en-epost\"}' http://127.0.0.1:8099/auth/request-link)", + "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -H 'X-Debug-User-Id: 43b4ed80-5bc1-49bf-b6eb-4190f68554a2' 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.txt __TRACKED_VAR__/cookies2.txt)" ] } } diff --git a/004_auth.sql b/004_auth.sql new file mode 100644 index 0000000..742516f --- /dev/null +++ b/004_auth.sql @@ -0,0 +1,40 @@ +-- ===================================================================== +-- TeeCup — migrasjon 004 +-- Ekte autentisering: magic-link + JWT-sesjon +-- ===================================================================== +-- Kjøres etter 001 (skjema), 002 (roller) og 003 (scoring/blind draw). +-- +-- MERK om nummerering: dette klaimer migrasjonsnummer 004, som CLAUDE.md/ +-- FEATURE_BACKLOG løst har forutsatt til en fremtidig "kommunikasjon"- +-- migrasjon — den blir 005 i stedet. Ren nummerkonvensjon, ingen teknisk +-- konflikt. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- --------------------------------------------------------------------- +-- 1. Unik e-post (mangler i 001 — nødvendig for at magic-link-flyten skal +-- kunne bruke e-post som race-trygg oppslagsnøkkel via ON CONFLICT). +-- --------------------------------------------------------------------- +CREATE UNIQUE INDEX app_user_email_unique ON app_user (email) WHERE email IS NOT NULL; + +-- --------------------------------------------------------------------- +-- 2. Magic-link-tokens +-- --------------------------------------------------------------------- +-- Lagrer e-post, IKKE en user_id-FK: app_user-raden opprettes først når +-- noen faktisk beviser eierskap ved å løse inn en gyldig, uforbrukt token +-- (se app/routers/auth.py) — ikke når lenken bare forespørres. Dette er +-- identitetsnivå (som app_user), ingen organization_id/RLS. +CREATE TABLE magic_link_token ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email citext NOT NULL, + token_hash text NOT NULL UNIQUE, + expires_at timestamptz NOT NULL, + consumed_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ON magic_link_token (email); + +-- Grants til runtime-rollen (den eier ikke tabellen, så den trenger eksplisitt DML). +GRANT SELECT, INSERT, UPDATE, DELETE ON magic_link_token TO teecup_app; diff --git a/CLAUDE.md b/CLAUDE.md index d4f29e9..5df47b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,12 +79,52 @@ Ferdig og verifisert: upsert (ingen avvisning ved duplikat) — ingen audit-trail på rettelser. Kapteins-only autorisasjon fortsatt ikke bygget (FEATURE_BACKLOG ❓); bar er «rostret på laget». +- **Match-lås ved avgjørelse (2026-07-16):** `submit_hole_score`/ + `submit_hole_result` avviser nå 409 hvis `match.points_side_a IS NOT NULL` + (matchen er avgjort) — FØR upserten kjøres, både for nye hull og + korrigering av allerede talte hull. Tetter en reell bug: uten dette kunne + «spøkelses-hull» lagt inn etter avgjørelse endre en allerede cachet margin + ved neste omregning. Automatisk, ingen ny autorisasjon involvert. +- **Ekte autentisering bygget og verifisert (2026-07-16):** `X-Debug-User-Id`- + stubben er HELT fjernet (ingen fallback). Magic-link + JWT-sesjon i + `app/routers/auth.py` + `app/auth.py` (`request-link`/`verify-link`/ + `logout`/`me`), ny migrasjon `004_auth.sql` (`magic_link_token`-tabell + + unik e-post-indeks på `app_user`). Token = `secrets.token_urlsafe(32)`, kun + SHA-256-hash lagres, atomisk forbruk (`UPDATE ... RETURNING`, ikke + les-sjekk-skriv), generisk respons uansett om e-posten finnes (unngår + enumerering), gamle uforbrukte lenker ugyldiggjøres når en ny utstedes, + `app_user` opprettes FØRST ved vellykket verifisering (ikke ved + forespørsel). Sesjons-JWT (PyJWT, `algorithms=["HS256"]` eksplisitt) i + HttpOnly/SameSite=Lax/dynamisk-Secure-cookie, 30 dager, med et ekte + eksistens-oppslag mot `app_user` på hver forespørsel (faktisk + tilbakekalling — en slettet bruker kan ikke ri ut sesjonen). Alle 12 + planlagte tester bestått. + **Fant og fikset underveis:** `ON CONFLICT (email)` matchet ikke den nye + PARTIELLE unike indeksen uten eksplisitt `WHERE email IS NOT NULL` (samme + klasse feil som `hole_score`s partielle indekser i scoring-runden). + **Fant, IKKE fikset her (egen sak, se FEATURE_BACKLOG):** `organization`- + tabellens RLS-policy (`org_self`, og trolig ALLE `org_isolation`-policyer i + 001/003) kaster en 500 i stedet for skjemaets lovede "trygg standard: se + ingenting" når `current_setting('app.current_org', true)` returnerer + TOMSTRENG (ikke NULL) — noe som kan skje på en gjenbrukt asyncpg-pool- + tilkobling der en tidligere forespørsel satte GUC-en via `SET LOCAL`. Kun + et problem for kode som spør org-scopede tabeller via `plain_connection()` + (ingen org-kontekst) — `/auth/me` unngår det bevisst ved å ikke joine mot + `organization`. Fiksen (`NULLIF(current_setting(...), '')::uuid` i alle 15 + policyer) er reell, billig, og lav risiko, men berører selve + isolasjonsgrunnmuren (ADR-003) og fortjener en egen, fokusert + rettingsrunde med skikkelig testing — ikke en hastefiks boltet på noe annet. Neste steg: -1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot +1. **Fiks RLS-tomstreng-buggen** beskrevet over (egen liten runde, migrasjon + 005) — reell, men isolert og lavrisiko. +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.) -2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 004). + 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`). +3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, + siden 004/005 nå er tatt av auth og RLS-fiksen). diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index cabfafa..c2ba07b 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -27,6 +27,35 @@ | API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. | | Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». | | 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-bug (funnet 2026-07-16, IKKE fikset ennå) +- **Status:** ❓ trenger egen rettingsrunde (lavrisiko, men berører ADR-003s + isolasjonsgrunnmur — fortjener fokusert testing, ikke en hastefiks). +- Alle RLS-policyer i 001/003 (`org_isolation` på 13 tabeller + `org_self` på + `organization`) bruker `current_setting('app.current_org', true)::uuid`. + Denne håndterer NULL trygt (gir ingen rader, som tiltenkt), men IKKE + tomstreng — og en gjenbrukt asyncpg-pool-tilkobling der en TIDLIGERE + forespørsel satte GUC-en via `SET LOCAL` kan lese den tilbake som `''` + (tomstreng) i stedet for NULL etter at den transaksjonen er ferdig. Da + kaster casten en 500 (`invalid input syntax for type uuid: ""`) i stedet + for skjemaets lovede "trygg standard: se ingenting". +- **Oppdaget av:** `/auth/me` (ny denne runden) prøvde å joine mot + `organization`-tabellen via `plain_connection()` (ingen org-kontekst) for å + hente organisasjonsnavn til en multi-org-liste — det er FØRSTE gang noe + spør en org-scopet, RLS-beskyttet tabell via en tilkobling uten + org-kontekst. Mitigert MIDLERTIDIG i `/auth/me` ved rett og slett å ikke + joine mot `organization` (returnerer kun `organization_id` + `role`, ikke + navn) — unngår buggen, løser den ikke. +- **Fiks:** `NULLIF(current_setting('app.current_org', true), '')::uuid` i + stedet for `current_setting(...)::uuid`, i alle 15 policyer (`ALTER + POLICY`, egen migrasjon 005). NULLIF konverterer tomstreng til NULL FØR + cast, så den trygge "se ingenting"-oppførselen gjenopprettes uansett hvilken + tilstand GUC-en er i. +- **Følgeoppgave når fikset:** `/auth/me` kan da trygt joine mot + `organization` igjen og returnere organisasjonsnavn, ikke bare ID+rolle. --- diff --git a/app/auth.py b/app/auth.py index ce2870e..f7230e5 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,46 +1,83 @@ """ Auth-lag for TeeCup. -Selve innloggingen er en STUB her (erstattes med ekte sesjon/JWT senere). Det som -IKKE er en stub, og som er sikkerhetskritisk, er `get_authorized_org`: den -verifiserer at den innloggede brukeren faktisk er medlem av organisasjonen før -org-konteksten settes. +Ekte autentisering: magic-link (se app/routers/auth.py for utstedelse) + +JWT-sesjon i en HttpOnly-cookie. `get_current_user` er IKKE lenger en stub — +den dekoder og verifiserer den innsendte sesjonscookien, og bekrefter at +brukeren fortsatt finnes. + +`get_authorized_org` er fortsatt sikkerhetskritisk uendret: den verifiserer at +den innloggede brukeren faktisk er medlem av organisasjonen før org-konteksten +settes. Hvorfor det er kritisk: RLS stoler blindt på `app.current_org`. Setter appen den til en organisasjon brukeren ikke tilhører, gir RLS lydig tilgang til den organisasjonens data. Isolasjonen står og faller altså på at denne sjekken skjer -FØR org_connection kalles — aldri sett konteksten fra en uverifisert kilde (som -en klient-oppgitt header). +FØR org_connection kalles — aldri sett konteksten fra en uverifisert kilde. """ +import time from dataclasses import dataclass -from fastapi import Depends, Header, HTTPException, status +import jwt +from fastapi import Depends, HTTPException, Request, status +from .config import settings from .db import plain_connection +SESSION_COOKIE_NAME = "teecup_session" +_JWT_ALGORITHM = "HS256" # Eksplisitt både ved signering og dekoding -- blokkerer + # alg-forvirring/"alg:none"-angrep. + @dataclass(frozen=True) class CurrentUser: user_id: str -async def get_current_user( - x_debug_user_id: str | None = Header(default=None), -) -> CurrentUser: - """STUB: hent brukeren fra en verifisert sesjon. +def create_session_token(user_id: str) -> str: + now = int(time.time()) + payload = {"sub": user_id, "iat": now, "exp": now + settings.SESSION_MAX_AGE_SECONDS} + return jwt.encode(payload, settings.SESSION_SECRET, algorithm=_JWT_ALGORITHM) - Midlertidig leses bruker-id fra en header for at skjelettet skal kunne kjøres. - ERSTATT dette med validering av sesjonscookie/JWT (signert med - settings.SESSION_SECRET) før noe settes i produksjon. En header er IKKE en - autentisert kilde. + +def should_use_secure_cookies(request: Request) -> bool: + """Secure-flagget skal kun være sant over https. + + Stoler på X-Forwarded-Proto -- dette er KUN trygt fordi appen ikke skal + være nåbar unntatt gjennom Caddy, som terminerer TLS og proxyer videre + over vanlig http internt. """ - if not x_debug_user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Ikke autentisert (stub forventer X-Debug-User-Id).", - ) - return CurrentUser(user_id=x_debug_user_id) + if request.url.scheme == "https": + return True + return request.headers.get("x-forwarded-proto", "").strip().lower() == "https" + + +async def get_current_user(request: Request) -> CurrentUser: + """Dekoder sesjonscookien og bekrefter at brukeren fortsatt finnes. + + Det siste steget (et ekte DB-oppslag, ikke bare å stole på JWT-en) gir + faktisk tilbakekalling: en slettet/deaktivert bruker kan ikke ri ut + resten av sesjonens levetid på en ellers gyldig token. + """ + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ikke innlogget.") + try: + claims = jwt.decode(token, settings.SESSION_SECRET, algorithms=[_JWT_ALGORITHM]) + except jwt.PyJWTError: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig eller utløpt sesjon.") + + user_id = claims.get("sub") + if not user_id: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Ugyldig sesjon.") + + async with plain_connection() as conn: + exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM app_user WHERE id = $1)", user_id) + if not exists: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Brukeren finnes ikke lenger.") + + return CurrentUser(user_id=user_id) async def get_authorized_org( diff --git a/app/config.py b/app/config.py index ddc7f06..5182440 100644 --- a/app/config.py +++ b/app/config.py @@ -30,5 +30,23 @@ class Settings: DB_POOL_MIN: int = int(os.getenv("TEECUP_DB_POOL_MIN", "2")) DB_POOL_MAX: int = int(os.getenv("TEECUP_DB_POOL_MAX", "10")) + # Ekte autentisering (magic-link + JWT-sesjon). Ikke hemmeligheter -- + # sane defaults, ikke _required. + SESSION_MAX_AGE_SECONDS: int = int( + os.getenv("TEECUP_SESSION_MAX_AGE_SECONDS", str(30 * 24 * 3600)) + ) + MAGIC_LINK_MAX_AGE_MINUTES: int = int(os.getenv("TEECUP_MAGIC_LINK_MAX_AGE_MINUTES", "20")) + MAGIC_LINK_COOLDOWN_SECONDS: int = int(os.getenv("TEECUP_MAGIC_LINK_COOLDOWN_SECONDS", "60")) + + # Logger rå magic-link-token server-side i stedet for å sende e-post. + # ALDRI på som standard -- må settes eksplisitt, kun for dev/scratch- + # testing (samme klasse feil som denne runden finnes for å lukke for + # X-Debug-User-Id-headeren). + DEV_LOG_MAGIC_LINKS: bool = os.getenv("TEECUP_DEV_LOG_MAGIC_LINKS", "").strip().lower() in ( + "1", + "true", + "yes", + ) + settings = Settings() diff --git a/app/main.py b/app/main.py index 4b30c29..ed0184b 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 matches, players, scoring, tournaments +from .routers import auth, matches, players, scoring, tournaments @asynccontextmanager @@ -29,6 +29,7 @@ async def lifespan(app: FastAPI): app = FastAPI(title="TeeCup API", lifespan=lifespan) +app.include_router(auth.router) app.include_router(players.router) app.include_router(tournaments.router) app.include_router(matches.router) diff --git a/app/requirements.txt b/app/requirements.txt index 3c6a936..93cc3f1 100644 --- a/app/requirements.txt +++ b/app/requirements.txt @@ -1,4 +1,5 @@ fastapi>=0.111 uvicorn[standard]>=0.30 asyncpg>=0.29 -pydantic>=2.7 +pydantic[email]>=2.7 +pyjwt>=2.8 diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..31f567e --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,200 @@ +""" +Autentisering: magic-link-forespørsel/-verifisering, utlogging, "hvem er jeg". + +Sikkerhetsmønster (se plan/ADR-009): + - request-link svarer ALLTID identisk, uansett om e-posten finnes eller + nettopp fikk en lenke -- unngår at endepunktet kan brukes til å sjekke + hvilke e-poster som har konto (enumerering). + - Kun SHA-256-hashen av token lagres, aldri klarteksten. + - app_user opprettes FØRST når en gyldig, uforbrukt token løses inn i + verify-link -- IKKE når lenken bare forespørres (hindrer massopprettelse + av kontoer for e-poster man ikke eier). + - Token-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke les-sjekk-skriv) + for å hindre at to samtidige forsøk med samme lenke begge lykkes. + - Dev-modus: siden ingen ekte SMTP-oppsett finnes for TeeCup ennå, logges + rå token server-side i stedet for å sendes på e-post -- KUN når + settings.DEV_LOG_MAGIC_LINKS er eksplisitt satt (aldri på som standard). + # TODO: erstatt med ekte SMTP-utsending før produksjon. +""" + +import hashlib +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from pydantic import BaseModel, EmailStr + +from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies +from ..config import settings +from ..db import plain_connection + +router = APIRouter(prefix="/auth", tags=["auth"]) + +_GENERIC_RESPONSE = { + "status": "ok", + "detail": "Hvis e-posten er gyldig, er en innloggingslenke sendt.", +} + + +def _hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +class MagicLinkRequest(BaseModel): + email: EmailStr + + +@router.post("/request-link") +async def request_magic_link(body: MagicLinkRequest) -> dict: + email = body.email.lower() + now = datetime.now(timezone.utc) + cooldown_cutoff = now - timedelta(seconds=settings.MAGIC_LINK_COOLDOWN_SECONDS) + + async with plain_connection() as conn: + recent = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM magic_link_token + WHERE email = $1 AND consumed_at IS NULL AND created_at > $2 + ) + """, + email, + cooldown_cutoff, + ) + if not recent: + # Ugyldiggjør tidligere uforbrukte lenker for denne e-posten -- + # begrenser vinduet en eldre, lekket lenke kan misbrukes i. + await conn.execute( + "UPDATE magic_link_token SET consumed_at = now() WHERE email = $1 AND consumed_at IS NULL", + email, + ) + raw_token = secrets.token_urlsafe(32) + expires_at = now + timedelta(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES) + await conn.execute( + "INSERT INTO magic_link_token (email, token_hash, expires_at) VALUES ($1, $2, $3)", + email, + _hash_token(raw_token), + expires_at, + ) + if settings.DEV_LOG_MAGIC_LINKS: + print(f"[DEV] Magic link for {email}: {raw_token}", flush=True) + + return _GENERIC_RESPONSE + + +class MagicLinkVerify(BaseModel): + token: str + display_name: str | None = None + + +class SessionUser(BaseModel): + id: str + email: str + display_name: str + + +@router.post("/verify-link", response_model=SessionUser) +async def verify_magic_link( + body: MagicLinkVerify, response: Response, request: Request +) -> SessionUser: + token_hash = _hash_token(body.token) + async with plain_connection() as conn: + # Atomisk forbruk: ÉN setning, ikke les-så-sjekk-så-skriv -- to + # samtidige forsøk med samme lenke kan da ikke begge lykkes. + email = await conn.fetchval( + """ + UPDATE magic_link_token + SET consumed_at = now() + WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > now() + RETURNING email + """, + token_hash, + ) + if email is None: + raise HTTPException( + status.HTTP_401_UNAUTHORIZED, detail="Lenken er ugyldig, brukt eller utløpt." + ) + + placeholder_name = email.split("@")[0] + user_id = await conn.fetchval( + """ + INSERT INTO app_user (email, display_name) + VALUES ($1, $2) + ON CONFLICT (email) WHERE email IS NOT NULL DO NOTHING + RETURNING id + """, + email, + body.display_name or placeholder_name, + ) + if user_id is None: + # En annen samtidig verifisering for samme e-post vant innsettingen. + user_id = await conn.fetchval("SELECT id FROM app_user WHERE email = $1", email) + + user_row = await conn.fetchrow( + "SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1", + user_id, + ) + + token = create_session_token(user_row["id"]) + response.set_cookie( + SESSION_COOKIE_NAME, + token, + max_age=settings.SESSION_MAX_AGE_SECONDS, + httponly=True, + samesite="lax", + secure=should_use_secure_cookies(request), + path="/", + ) + return SessionUser(**dict(user_row)) + + +@router.post("/logout") +async def logout(response: Response) -> dict: + response.delete_cookie(SESSION_COOKIE_NAME, path="/") + return {"status": "ok"} + + +class MyOrg(BaseModel): + organization_id: str + role: str + + +class Me(BaseModel): + id: str + email: str + display_name: str + organizations: list[MyOrg] + + +@router.get("/me", response_model=Me) +async def me(user: CurrentUser = Depends(get_current_user)) -> Me: + # MERK: henter bevisst IKKE organisasjonens navn her. Det ville krevd å + # spørre `organization`-tabellen (som HAR RLS, org_self-policyen) via + # plain_connection() -- altså UTEN org-kontekst satt. Under scratch- + # testing avdekket dette en reell, dypere bug: org_self sin + # `current_setting('app.current_org', true)::uuid` håndterer NULL trygt, + # men IKKE tomstreng (som en custom GUC kan lese tilbake som på en + # gjenbrukt pool-tilkobling der en annen forespørsel tidligere satte den + # via SET LOCAL) -- det gir en 500 i stedet for skjemaets lovede "trygg + # standard: se ingenting". Dette er en tverrgående RLS-sak (berører alle + # 15 policyer i skjemaet, ikke noe auth-spesifikt) som fortjener sin egen + # fokuserte rettingsrunde, ikke en hastefiks her. Se FEATURE_BACKLOG.md. + async with plain_connection() as conn: + user_row = await conn.fetchrow( + "SELECT id::text AS id, email::text AS email, display_name FROM app_user WHERE id = $1", + user.user_id, + ) + org_rows = await conn.fetch( + """ + SELECT organization_id::text AS organization_id, role + FROM organization_membership + WHERE user_id = $1 + """, + user.user_id, + ) + return Me( + id=user_row["id"], + email=user_row["email"], + display_name=user_row["display_name"], + organizations=[MyOrg(**dict(r)) for r in org_rows], + )