From 1b5362201edd27c00b7a5f0a5dcf74d3ca715dde Mon Sep 17 00:00:00 2001 From: Erol Haagenrud Date: Thu, 16 Jul 2026 07:26:04 +0200 Subject: [PATCH] Utgangspunktet --- 002_roles_and_grants.sql | 55 ++++++++++++++++++++ 003_scoring_and_blinddraw.sql | 98 +++++++++++++++++++++++++++++++++++ ARCHITECTURE_DECISIONS.md | 85 ++++++++++++++++++++++++++++++ FEATURE_BACKLOG.md | 12 ++++- app/__init__.py | 0 app/auth.py | 71 +++++++++++++++++++++++++ app/config.py | 34 ++++++++++++ app/db.py | 81 +++++++++++++++++++++++++++++ app/main.py | 80 ++++++++++++++++++++++++++++ app/requirements.txt | 4 ++ test_isolation.sql | 89 +++++++++++++++++++++++++++++++ 11 files changed, 607 insertions(+), 2 deletions(-) create mode 100644 002_roles_and_grants.sql create mode 100644 003_scoring_and_blinddraw.sql create mode 100644 app/__init__.py create mode 100644 app/auth.py create mode 100644 app/config.py create mode 100644 app/db.py create mode 100644 app/main.py create mode 100644 app/requirements.txt create mode 100644 test_isolation.sql diff --git a/002_roles_and_grants.sql b/002_roles_and_grants.sql new file mode 100644 index 0000000..1dd41ef --- /dev/null +++ b/002_roles_and_grants.sql @@ -0,0 +1,55 @@ +-- ===================================================================== +-- TeeCup — dedikert applikasjonsrolle + grants (migrasjon 002) +-- ===================================================================== +-- Kjøres av en admin/superbruker (f.eks. teeoff_admin). +-- +-- HVORFOR: RLS beskytter IKKE mot en superbruker. Postgres lar superuser og +-- roller med BYPASSRLS hoppe fullstendig over alle policyer. Runtime-appen MÅ +-- derfor koble til med en rolle som verken er superuser eller har BYPASSRLS. +-- Denne rollen eier heller ikke tabellene (tabellene har dessuten FORCE RLS), +-- så den er alltid underlagt policyene. +-- +-- Passord: sett det utenfra, ikke i denne fila. Kjør f.eks.: +-- psql -d teecup_db -v teecup_app_password="$(cat /path/to/secret)" \ +-- -f 002_roles_and_grants.sql +-- (Aldri sjekk inn et ekte passord i git.) +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- --- Rolle (cluster-nivå: opprett bare hvis den ikke finnes) ---------- +-- Eksplisitt NOSUPERUSER / NOBYPASSRLS — dette er hele poenget. +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'teecup_app') THEN + CREATE ROLE teecup_app + LOGIN + PASSWORD :'teecup_app_password' + NOSUPERUSER + NOCREATEDB + NOCREATEROLE + NOBYPASSRLS + INHERIT; + END IF; +END $$; + +-- --- Grants (kjøres mens du er koblet til teecup_db) ------------------ +GRANT CONNECT ON DATABASE teecup_db TO teecup_app; +GRANT USAGE ON SCHEMA public TO teecup_app; + +-- Runtime-appen trenger vanlig DML på domenetabellene, men skal IKKE kunne +-- endre skjema eller policyer (det følger av at den ikke eier tabellene og +-- ikke er superuser). +GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO teecup_app; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO teecup_app; + +-- Samme rettigheter automatisk på fremtidige tabeller/sekvenser laget av +-- migrasjonseieren. +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO teecup_app; +ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT USAGE, SELECT ON SEQUENCES TO teecup_app; + +-- MERK: teecup_app får bevisst IKKE rettigheter til å endre RLS. Bootstrap av +-- nye organisasjoner (INSERT i organization) skjer via en privilegert sti i +-- auth-laget, ikke med runtime-rollen. diff --git a/003_scoring_and_blinddraw.sql b/003_scoring_and_blinddraw.sql new file mode 100644 index 0000000..394e1cf --- /dev/null +++ b/003_scoring_and_blinddraw.sql @@ -0,0 +1,98 @@ +-- ===================================================================== +-- TeeCup — migrasjon 003 +-- Scoring-modus (ADR-012), blind draw (ADR-013), to-lags-format (ADR-011) +-- ===================================================================== +-- Kjøres etter 001 (skjema) og 002 (roller). Idempotent der det er lett å få til. +-- ===================================================================== + +\set ON_ERROR_STOP on + +-- --------------------------------------------------------------------- +-- 1. Scoring-modus per økt (ADR-012) +-- --------------------------------------------------------------------- +-- 'stroke' : spillere taster slag per hull -> hole_score. Motoren utleder +-- netto og hull-resultat. +-- 'hole_result' : registrer bare hvem som vant hullet -> match_hole_result. +ALTER TABLE session + ADD COLUMN scoring_mode text NOT NULL DEFAULT 'stroke' + CHECK (scoring_mode IN ('stroke', 'hole_result')); + + +-- --------------------------------------------------------------------- +-- 2. Forenklet scoreføring: matchresultat per hull (ADR-012) +-- --------------------------------------------------------------------- +-- Brukes når session.scoring_mode = 'hole_result'. winning_side NULL = delt hull. +-- compute_match_state i motoren mates fra enten hole_score (stroke) eller denne. +CREATE TABLE match_hole_result ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + match_id uuid NOT NULL, + hole_number smallint NOT NULL CHECK (hole_number BETWEEN 1 AND 18), + winning_side team_side, -- NULL = delt (halved) + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + FOREIGN KEY (organization_id, match_id) + REFERENCES match(organization_id, id) ON DELETE CASCADE, + UNIQUE (match_id, hole_number) +); + +CREATE INDEX ON match_hole_result (organization_id, match_id); + +CREATE TRIGGER trg_match_hole_result_updated + BEFORE UPDATE ON match_hole_result + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + +-- --------------------------------------------------------------------- +-- 3. Blind draw: lås per lag per økt (ADR-013) +-- --------------------------------------------------------------------- +-- En økt er "avslørt" når det finnes en lineup_lock for BEGGE lag. Appen skjuler +-- motstanderens match_participant til da, og hindrer endring av egen oppstilling +-- etter lås. (Finkornet synlighet i app-laget, ikke RLS — begge lag samme org.) +CREATE TABLE lineup_lock ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL, + session_id uuid NOT NULL, + team_id uuid NOT NULL, + locked_at timestamptz NOT NULL DEFAULT now(), + locked_by uuid REFERENCES app_user(id) ON DELETE SET NULL, + FOREIGN KEY (organization_id, session_id) + REFERENCES session(organization_id, id) ON DELETE CASCADE, + FOREIGN KEY (organization_id, team_id) + REFERENCES team(organization_id, id) ON DELETE CASCADE, + UNIQUE (session_id, team_id) -- ett lås per lag per økt +); + +CREATE INDEX ON lineup_lock (organization_id, session_id); + + +-- --------------------------------------------------------------------- +-- 4. RLS for de nye tabellene (samme org-isolasjon som resten, ADR-003) +-- --------------------------------------------------------------------- +DO $$ +DECLARE t text; +BEGIN + FOREACH t IN ARRAY ARRAY['match_hole_result', 'lineup_lock'] + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY;', t); + EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY;', t); + EXECUTE format($p$ + CREATE POLICY org_isolation ON %I + USING (organization_id = current_setting('app.current_org', true)::uuid) + WITH CHECK (organization_id = current_setting('app.current_org', true)::uuid); + $p$, t); + END LOOP; +END $$; + +-- Grants til runtime-rollen (den eier ikke tabellene, så den trenger eksplisitt DML). +GRANT SELECT, INSERT, UPDATE, DELETE ON match_hole_result TO teecup_app; +GRANT SELECT, INSERT, UPDATE, DELETE ON lineup_lock TO teecup_app; + + +-- --------------------------------------------------------------------- +-- 5. To-lags-format (ADR-011) +-- --------------------------------------------------------------------- +-- MERK: "nøyaktig to lag per turnering" håndheves i app-laget ved +-- turneringsoppsett, ikke som DB-trigger (holder match-modellen generell slik at +-- flere lag / knockout kan komme senere uten dataomskriving). Ingen skjemaendring +-- her — dette er kun en påminnelse om regelen. diff --git a/ARCHITECTURE_DECISIONS.md b/ARCHITECTURE_DECISIONS.md index aedb647..81d1d54 100644 --- a/ARCHITECTURE_DECISIONS.md +++ b/ARCHITECTURE_DECISIONS.md @@ -173,6 +173,91 @@ alternativ kan tilbys senere som konfig per turnering uten skjemaendring. --- +## ADR-009 — Egen innlogging, uavhengig av teeoff + +**Beslutning:** TeeCup har egne brukerkontoer (`app_user`), uavhengig av teeoffs +innlogging. + +**Begrunnelse:** TeeCup selges kommersielt til klubber/bedrifter som kanskje aldri +har hørt om teeoff.no. Delt innlogging ville vært forvirrende for dem og koblet de +to systemenes auth tettere sammen enn ellers ønskelig. Styrker isolasjonen fra +ADR-004. + +**Konsekvens:** Egen auth (registrering, Google/magic-link e.l.), egne secrets +(jf. åpent spørsmål 1), egen sesjonshåndtering. Kaptein- og tilskuer-roller +defineres innenfor TeeCups eget auth-lag. + +--- + +## ADR-010 — Feed krever innlogging (ingen anonym lesesti i v1) + +**Beslutning:** Den «offentlige» runde-feeden er felles for begge lag i +turneringen, men bak innlogging. Ingen verdenssynlig lenke i v1. + +**Begrunnelse:** «Offentlig» betyr her «alle innloggede deltakere i turneringen», +ikke «hvem som helst med en lenke». Dette fjerner den uautentiserte lesestien og +den tunge modereringen (som ellers kreves når innhold er synlig for verden) fra +v1. + +**Konsekvens:** Synlighet modelleres som kanal-scope (lag / turnering). En ekte +verdenssynlig delingslenke kan legges til senere som egen bryter, med moderering, +uten å bygge om. + +--- + +## ADR-011 — Turneringsstruktur: lagformat i v1, bracket som egen fremtidig type + +**Beslutning:** v1 bygges for Ryder Cup-lagformatet (økter → uavhengige matcher → +summerte poeng), og låses til **nøyaktig to lag**. + +**Begrunnelse:** En match/flight er alltid to-sidig. Tre lag ville krevd +kryss-oppgjør (A–B, A–C, B–C), nok spillere til å møte to motstandere samtidig, +balansert oppsett, og tre-veis blind draw — mye kompleksitet for lite gevinst. To +lag er både enklere og riktigere for formatet. Et knockout-bracket (f.eks. 128 +spillere, utslagsmatcher, finale + bronsefinale) er en helt egen turneringstype: +rundene er *avhengige* (vinner av match A møter vinner av match B), og krever +progresjon mellom matcher, seeding, fripass og bronsegren — noe dagens modell ikke +har. + +**Konsekvens:** To-lags-grensen håndheves i app-laget (validering ved +turneringsoppsett), ikke med DB-trigger. Match-modellen holdes generell (to sider, +vilkårlige lag-referanser), så flere lag eller en knockout-type kan komme senere +uten dataomskriving — kjernen (spillere, motor, hull-score, RLS) er typeuavhengig. +Knockout er fanget som egen type i FEATURE_BACKLOG.md. + +--- + +## ADR-012 — To scoring-moduser per økt + +**Beslutning:** Hver økt har en `scoring_mode`: `stroke` (spillere taster slag per +hull) eller `hole_result` (registrer bare hvem som vant hullet / delt). + +**Begrunnelse:** Ønsket fra start: kunne føre score enten detaljert (slag) eller +raskt (tapp «lag rød vant hullet»). De to modusene har ulik datainngang. + +**Konsekvens:** `stroke` skriver til `hole_score` (brutto, motor utleder netto og +hull-resultat). `hole_result` skriver til ny tabell `match_hole_result` +(vinnende side per hull, NULL = delt). Begge mater samme +`compute_match_state` i motoren, så matchstatus beregnes likt uansett modus. + +--- + +## ADR-013 — Blind draw via lås per lag per økt + +**Beslutning:** Lagoppstilling settes skjult; matchene avsløres når BEGGE lag har +låst. Modellert med tabell `lineup_lock` (én rad per lag per økt). + +**Begrunnelse:** Kjerneønske i Ryder Cup-formatet — kapteinene låser i blinde, og +oppgjørene avsløres samtidig. + +**Konsekvens:** `match_participant`-radene (oppstillingen) opprettes per lag, men +motstanderens side skjules i app-laget til det finnes en `lineup_lock` for begge +lag. Etter lås kan egen oppstilling ikke endres. Dette er finkornet synlighet +(lag-nivå) og håndheves i app-/spørrelaget, ikke RLS — begge lag ligger i samme +organisasjon (jf. ADR-010). + +--- + ## Åpne spørsmål (ikke besluttet ennå) Disse må avklares før eller under de relevante fasene: diff --git a/FEATURE_BACKLOG.md b/FEATURE_BACKLOG.md index bcb1a56..55d3d23 100644 --- a/FEATURE_BACKLOG.md +++ b/FEATURE_BACKLOG.md @@ -40,7 +40,7 @@ rettigheter (sette oppstilling), ikke bare være et flagg. ### Blind draw (skjult lagoppstilling) -- **Status:** 📋 planlagt (i din egen utviklingsplan) +- **Status:** 🔨 skjema bygget (migrasjon 003, `lineup_lock`) — app-logikk gjenstår - Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er ferdige. - **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst → @@ -48,7 +48,7 @@ e.l. Ikke bygget. ### Forenklet scoreføring (uten slagtall) -- **Status:** ❓ trenger beslutning (påvirker skjema) +- **Status:** 🔨 skjema bygget (migrasjon 003) — begge moduser besluttet - Ønske: kunne registrere ENTEN slag per hull ELLER bare «Lag rød vant hullet / delt». - **Skjemakonsekvens:** dagens `hole_score.gross_strokes` er NOT NULL. En ren @@ -76,6 +76,14 @@ - Live leaderboard og chat som oppdateres uten refresh. Vi har leaderboard-viewet, men ikke sanntidsleveringen. Valg: WebSockets vs. polling. +### Knockout / cup-turnering (egen turneringstype) +- **Status:** 💤 utsatt (egen fremtidig type, ADR-011) +- Utslagsmatcher: 128 spillere → finale + bronsefinale. Rundene er *avhengige* + (vinner går videre), krever bracket/progresjon, seeding, fripass. IKKE i v1. +- v1 er låst til nøyaktig to lag (Ryder Cup-format). Match-modellen er generell, så + denne typen kan legges til senere uten dataomskriving — den legger bare til et + progresjons-lag. + --- ## Kommunikasjon (under design) diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..ce2870e --- /dev/null +++ b/app/auth.py @@ -0,0 +1,71 @@ +""" +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. + +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). +""" + +from dataclasses import dataclass + +from fastapi import Depends, Header, HTTPException, status + +from .db import plain_connection + + +@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. + + 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. + """ + 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) + + +async def get_authorized_org( + organization_id: str, + user: CurrentUser = Depends(get_current_user), +) -> str: + """Returnerer organization_id KUN hvis brukeren er medlem — ellers 403. + + Medlemskapstabellen er ikke organisasjonsavgrenset, så oppslaget gjøres på en + tilkobling uten org-kontekst. + """ + async with plain_connection() as conn: + is_member = await conn.fetchval( + """ + SELECT EXISTS ( + SELECT 1 FROM organization_membership + WHERE user_id = $1 AND organization_id = $2 + ) + """, + user.user_id, + organization_id, + ) + if not is_member: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Brukeren er ikke medlem av denne organisasjonen.", + ) + return organization_id diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..ddc7f06 --- /dev/null +++ b/app/config.py @@ -0,0 +1,34 @@ +""" +Konfigurasjon for TeeCup-API-et. + +ADR (åpent spørsmål 1): TeeCup bruker EGNE, uavhengige secrets — den skal ikke +falle tilbake på teeoff sine. Manglende variabel skal feile høyt ved oppstart, +ikke stille bruke en usikker standard. +""" + +import os + + +def _required(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise RuntimeError( + f"Miljøvariabelen {name} mangler. TeeCup starter ikke uten den " + f"(ingen fallback til teeoff sine verdier — se ADR)." + ) + return value + + +class Settings: + # Peker på teecup_db, og bruker RUNTIME-rollen teecup_app (ikke teeoff_admin). + # Format: postgresql://teecup_app:@:/teecup_db + DATABASE_URL: str = _required("TEECUP_DATABASE_URL") + + # Egen sesjons-secret, uavhengig av teeoff. + SESSION_SECRET: str = _required("TEECUP_SESSION_SECRET") + + DB_POOL_MIN: int = int(os.getenv("TEECUP_DB_POOL_MIN", "2")) + DB_POOL_MAX: int = int(os.getenv("TEECUP_DB_POOL_MAX", "10")) + + +settings = Settings() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..a06b05f --- /dev/null +++ b/app/db.py @@ -0,0 +1,81 @@ +""" +Databaselag for TeeCup. + +Det kritiske her er hvordan organisasjonskonteksten (`app.current_org`) settes: + + * Den settes med set_config(..., is_local => true), dvs. SET LOCAL — verdien + gjelder KUN inne i den aktive transaksjonen og nullstilles automatisk når + transaksjonen avsluttes. + * Uten dette (en vanlig SET på en pool-tilkobling) ville konteksten blitt + liggende på tilkoblingen når den returneres til poolen, og neste forespørsel + — potensielt en ANNEN organisasjon — ville arvet den. Det ville brutt + isolasjonen ved gjenbruk av tilkoblinger. + +Hver forespørsel som rører organisasjonsdata kjører altså i én transaksjon med +org-konteksten satt lokalt. +""" + +from contextlib import asynccontextmanager +from typing import AsyncIterator + +import asyncpg + +from .config import settings + +_pool: asyncpg.Pool | None = None + + +async def init_pool() -> None: + global _pool + _pool = await asyncpg.create_pool( + dsn=settings.DATABASE_URL, + min_size=settings.DB_POOL_MIN, + max_size=settings.DB_POOL_MAX, + ) + + +async def close_pool() -> None: + global _pool + if _pool is not None: + await _pool.close() + _pool = None + + +def _require_pool() -> asyncpg.Pool: + if _pool is None: + raise RuntimeError("DB-poolen er ikke initialisert (kall init_pool ved oppstart).") + return _pool + + +@asynccontextmanager +async def plain_connection() -> AsyncIterator[asyncpg.Connection]: + """Tilkobling UTEN org-kontekst. + + Brukes kun til auth-laget (f.eks. oppslag i organization_membership, som + ikke er organisasjonsavgrenset). Rør aldri organisasjonsdata her. + """ + pool = _require_pool() + async with pool.acquire() as conn: + yield conn + + +@asynccontextmanager +async def org_connection(organization_id: str) -> AsyncIterator[asyncpg.Connection]: + """Tilkobling med org-kontekst satt lokalt for én transaksjon. + + All lesing/skriving av organisasjonsdata skal gå gjennom denne. RLS filtrerer + da automatisk på organization_id = app.current_org. + + VIKTIG: `organization_id` MÅ på forhånd være verifisert mot brukerens + medlemskap (se auth.get_authorized_org). Denne funksjonen stoler på at + verdien allerede er autorisert. + """ + pool = _require_pool() + async with pool.acquire() as conn: + async with conn.transaction(): + # SET LOCAL via set_config(..., true): nullstilles ved transaksjonsslutt. + await conn.execute( + "SELECT set_config('app.current_org', $1, true)", + str(organization_id), + ) + yield conn diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..19af714 --- /dev/null +++ b/app/main.py @@ -0,0 +1,80 @@ +""" +TeeCup API — skjelett. + +Demonstrerer det org-scopede mønsteret: hver rute under /orgs/{organization_id} +verifiserer medlemskap (get_authorized_org) og kjører deretter mot en tilkobling +med org-konteksten satt lokalt (org_connection). RLS gjør resten. + +Dette er et FUNDAMENT, ikke full CRUD. To ruter er nok til å vise mønsteret; +resten (økter, matcher, score, leaderboard) bygges på samme lest. + +Kjøres i app-containeren (FastAPI + asyncpg + uvicorn): + uvicorn app.main:app --host 0.0.0.0 --port 8000 +""" + +from contextlib import asynccontextmanager + +from fastapi import Depends, FastAPI +from pydantic import BaseModel, Field + +from .auth import get_authorized_org +from .db import init_pool, close_pool, org_connection + + +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_pool() + try: + yield + finally: + await close_pool() + + +app = FastAPI(title="TeeCup API", lifespan=lifespan) + + +class TournamentCreate(BaseModel): + name: str = Field(min_length=1, max_length=200) + + +class Tournament(BaseModel): + id: str + name: str + status: str + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.get("/orgs/{organization_id}/tournaments", response_model=list[Tournament]) +async def list_tournaments( + organization_id: str = Depends(get_authorized_org), +) -> list[Tournament]: + # Ingen manuell 'WHERE organization_id = ...' — RLS filtrerer automatisk. + async with org_connection(organization_id) as conn: + rows = await conn.fetch( + "SELECT id::text, name, status::text FROM tournament ORDER BY created_at DESC" + ) + return [Tournament(id=r["id"], name=r["name"], status=r["status"]) for r in rows] + + +@app.post("/orgs/{organization_id}/tournaments", response_model=Tournament, status_code=201) +async def create_tournament( + body: TournamentCreate, + organization_id: str = Depends(get_authorized_org), +) -> Tournament: + # organization_id settes eksplisitt; WITH CHECK i RLS avviser uansett en verdi + # som ikke matcher konteksten — dobbel sikring. + async with org_connection(organization_id) as conn: + row = await conn.fetchrow( + """ + INSERT INTO tournament (organization_id, name) + VALUES ($1, $2) + RETURNING id::text, name, status::text + """, + organization_id, + body.name, + ) + return Tournament(id=row["id"], name=row["name"], status=row["status"]) diff --git a/app/requirements.txt b/app/requirements.txt new file mode 100644 index 0000000..3c6a936 --- /dev/null +++ b/app/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.111 +uvicorn[standard]>=0.30 +asyncpg>=0.29 +pydantic>=2.7 diff --git a/test_isolation.sql b/test_isolation.sql new file mode 100644 index 0000000..1ccb925 --- /dev/null +++ b/test_isolation.sql @@ -0,0 +1,89 @@ +-- ===================================================================== +-- TeeCup — isolasjonstest (oppførsel, ikke bare skjema) +-- ===================================================================== +-- Beviser at RLS faktisk hindrer én organisasjon i å se en annens data, +-- sett fra runtime-rollen teecup_app (som IKKE er superuser/BYPASSRLS). +-- +-- Kjør som admin/superbruker mot en scratch- eller test-database der +-- 001_initial_schema.sql og 002_roles_and_grants.sql allerede er kjørt: +-- psql -d teecup_scratch -f test_isolation.sql +-- +-- Alt kjøres i én transaksjon som RULLES TILBAKE til slutt — ingen testdata +-- blir liggende igjen. Skriptet stopper med feil hvis isolasjonen svikter. +-- ===================================================================== + +\set ON_ERROR_STOP on + +BEGIN; + +-- --- Seed to organisasjoner + én turnering hver (som privilegert rolle) --- +-- (Superbruker omgår RLS, så innsettingen går uhindret her — det er meningen.) +INSERT INTO organization (id, name) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'Org A'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'Org B'); + +INSERT INTO tournament (organization_id, name) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'A-Cup'), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'B-Cup'); + +-- --- Bytt til runtime-rollen: nå SKAL RLS gjelde --------------------- +SET ROLE teecup_app; +SELECT set_config('app.current_org', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', false); + +-- Test 1: Ser bare Org A sine turneringer (1, ikke 2). +DO $$ +DECLARE n int; +BEGIN + SELECT count(*) INTO n FROM tournament; + IF n <> 1 THEN + RAISE EXCEPTION 'ISOLASJON FEILET (lesing): ser % turneringer, forventet 1', n; + END IF; + RAISE NOTICE 'OK Test 1: ser kun egen organisasjons turneringer (%).', n; +END $$; + +-- Test 2: Ser bare sin egen organisasjonsrad. +DO $$ +DECLARE n int; +BEGIN + SELECT count(*) INTO n FROM organization; + IF n <> 1 THEN + RAISE EXCEPTION 'ISOLASJON FEILET (organization): ser % rader, forventet 1', n; + END IF; + RAISE NOTICE 'OK Test 2: ser kun egen organisasjonsrad (%).', n; +END $$; + +-- Test 3: Kan IKKE sette inn data merket med en annen organisasjon. +-- WITH CHECK skal blokkere det (SQLSTATE 42501). +DO $$ +BEGIN + BEGIN + INSERT INTO tournament (organization_id, name) + VALUES ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'kryss-org-forsøk'); + -- Kommer vi hit, ble det tillatt -> isolasjonen er brutt. + RAISE EXCEPTION 'ISOLASJON FEILET (skriving): fikk sette inn data for annen org'; + EXCEPTION + WHEN insufficient_privilege THEN + RAISE NOTICE 'OK Test 3: WITH CHECK blokkerte kryss-org innsetting.'; + END; +END $$; + +-- Test 4: Kontrollprøve — bytter kontekst til Org B og ser Bs turnering isteden. +SELECT set_config('app.current_org', 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', false); +DO $$ +DECLARE nm text; +BEGIN + SELECT name INTO nm FROM tournament; + IF nm <> 'B-Cup' THEN + RAISE EXCEPTION 'ISOLASJON FEILET (kontekstbytte): forventet B-Cup, fikk %', nm; + END IF; + RAISE NOTICE 'OK Test 4: kontekstbytte gir riktig organisasjons data (%).', nm; +END $$; + +RESET ROLE; +RESET app.current_org; + +ROLLBACK; -- ingen testdata blir liggende igjen + +\echo '============================================' +\echo ' Alle isolasjonstester bestått (se OK-linjer)' +\echo '============================================'