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å)
|
## Åpne spørsmål (ikke besluttet ennå)
|
||||||
|
|
||||||
Disse må avklares før eller under de relevante fasene:
|
Disse må avklares før eller under de relevante fasene:
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
rettigheter (sette oppstilling), ikke bare være et flagg.
|
rettigheter (sette oppstilling), ikke bare være et flagg.
|
||||||
|
|
||||||
### Blind draw (skjult lagoppstilling)
|
### 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
|
- Kapteinene låser oppstillingen skjult; matchene avsløres samtidig når begge er
|
||||||
ferdige.
|
ferdige.
|
||||||
- **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst →
|
- **Skjemakonsekvens:** oppstillinger trenger en tilstand «utkast → låst →
|
||||||
|
|
@ -48,7 +48,7 @@
|
||||||
e.l. Ikke bygget.
|
e.l. Ikke bygget.
|
||||||
|
|
||||||
### Forenklet scoreføring (uten slagtall)
|
### 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 /
|
- Ønske: kunne registrere ENTEN slag per hull ELLER bare «Lag rød vant hullet /
|
||||||
delt».
|
delt».
|
||||||
- **Skjemakonsekvens:** dagens `hole_score.gross_strokes` er NOT NULL. En ren
|
- **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,
|
- Live leaderboard og chat som oppdateres uten refresh. Vi har leaderboard-viewet,
|
||||||
men ikke sanntidsleveringen. Valg: WebSockets vs. polling.
|
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)
|
## 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