Utgangspunktet
This commit is contained in:
parent
5873db18d1
commit
1b5362201e
11 changed files with 607 additions and 2 deletions
55
002_roles_and_grants.sql
Normal file
55
002_roles_and_grants.sql
Normal file
|
|
@ -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.
|
||||
98
003_scoring_and_blinddraw.sql
Normal file
98
003_scoring_and_blinddraw.sql
Normal file
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
71
app/auth.py
Normal file
71
app/auth.py
Normal file
|
|
@ -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
|
||||
34
app/config.py
Normal file
34
app/config.py
Normal file
|
|
@ -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:<passord>@<host>:<port>/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()
|
||||
81
app/db.py
Normal file
81
app/db.py
Normal file
|
|
@ -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
|
||||
80
app/main.py
Normal file
80
app/main.py
Normal file
|
|
@ -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"])
|
||||
4
app/requirements.txt
Normal file
4
app/requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
fastapi>=0.111
|
||||
uvicorn[standard]>=0.30
|
||||
asyncpg>=0.29
|
||||
pydantic>=2.7
|
||||
89
test_isolation.sql
Normal file
89
test_isolation.sql
Normal file
|
|
@ -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 '============================================'
|
||||
Loading…
Reference in a new issue