teecup/app/routers/organizations.py
Erol Haagenrud 89a803140e Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt

Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)

app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere

app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering

app/email.py: 2FA-kode og invitasjons-maler

app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti

Frontend: login-form passord-modus + 2FA-skjermer

Frontend: kontoinnstillinger + org-medlemsstyring-skjerm

Ekte typesjekket frontend-build

Scratch-verifisere hele auth-løpet grundig (backend)

Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.

Klar til utrulling mot ekte systemer:

Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00

426 lines
16 KiB
Python

"""
Organisasjon-bootstrap (opprett en ny organisasjon og bli owner).
Eneste stedet i API-et som setter inn en `organization`-rad. Bruker
`get_current_user` (IKKE `get_authorized_org`, som forutsetter en ALLEREDE
eksisterende org-medlemskap -- sirkulært for akkurat denne handlingen, samme
resonnement som lag-/roster-opprettelse i oppsett-runden).
Selvrefererende RLS-bootstrap: `org_self`-policyen (etter migrasjon 005) er
`USING (id = app_current_org())` uten eksplisitt WITH CHECK, som Postgres
speiler dynamisk til INSERT-sjekken. Genererer derfor org-ens uuid FØR
innsetting og setter `app.current_org` til NØYAKTIG den verdien via den
allerede eksisterende `org_connection()` -- da er WITH CHECK trivielt sann
(id == current_org), og `teecup_app` (NOSUPERUSER/NOBYPASSRLS) kan sette inn
sin egen FØRSTE organisasjonsrad uten noen egen privilegert tilkobling.
Verifisert med en negativ kontroll (mismatchende id blir avvist) i
scratch-testingen -- se plan.
"""
import traceback
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, EmailStr, Field
from ..auth import CurrentUser, get_authorized_org, get_current_user, get_superadmin_user
from ..config import settings
from ..db import org_connection
from ..email import send_organization_invitation_email
from ..errors import app_error, translate_db_errors
router = APIRouter()
class OrganizationCreate(BaseModel):
name: str = Field(min_length=1, max_length=200)
class OrganizationOut(BaseModel):
id: str
name: str
role: str
@router.post("/orgs", response_model=OrganizationOut, status_code=201)
async def create_organization(
body: OrganizationCreate,
user: CurrentUser = Depends(get_current_user),
) -> OrganizationOut:
new_id = str(uuid.uuid4())
async with org_connection(new_id) as conn, translate_db_errors():
row = await conn.fetchrow(
"INSERT INTO organization (id, name) VALUES ($1, $2) RETURNING id::text, name",
new_id,
body.name,
)
await conn.execute(
"INSERT INTO organization_membership (organization_id, user_id, role) VALUES ($1, $2, 'owner')",
new_id,
user.user_id,
)
return OrganizationOut(id=row["id"], name=row["name"], role="owner")
class OrganizationUpdate(BaseModel):
"""Alle felt valgfrie -- kun de som faktisk sendes med blir endret
(exclude_unset, se update_organization). Foreløpig kun de to feltene
landingssiden trenger (ADR-018) -- ikke en generell org-innstillings-
endepunkt."""
public_profile: bool | None = None
slug: str | None = Field(default=None, pattern="^[a-z0-9]+(-[a-z0-9]+)*$")
class OrganizationSettings(BaseModel):
id: str
name: str
public_profile: bool
slug: str | None
@router.patch("/orgs/{organization_id}", response_model=OrganizationSettings)
async def update_organization(
body: OrganizationUpdate,
organization_id: str = Depends(get_authorized_org),
) -> OrganizationSettings:
updates = body.model_dump(exclude_unset=True)
if not updates:
raise app_error(400, "VALIDATION_FAILED", "Ingen felt å oppdatere.")
# Trygt mot injeksjon: nøklene kommer KUN fra OrganizationUpdate sine
# egne feltnavn (fast, kjent hviteliste).
set_clauses = [f"{key} = ${i}" for i, key in enumerate(updates, start=1)]
values = list(updates.values())
values.append(organization_id)
async with org_connection(organization_id) as conn, translate_db_errors():
row = await conn.fetchrow(
f"""
UPDATE organization SET {', '.join(set_clauses)}
WHERE id = ${len(values)}
RETURNING id::text, name, public_profile, slug
""",
*values,
)
if row is None:
raise app_error(404, "NOT_FOUND", "Organisasjonen finnes ikke.")
return OrganizationSettings(**dict(row))
# ---------------------------------------------------------------------------
# Medlemskap (ADR-022) -- rollestyring, frasi seg eierskap, forlate org.
# ---------------------------------------------------------------------------
async def _caller_role(conn, organization_id: str, user_id: str) -> str | None:
return await conn.fetchval(
"SELECT role FROM organization_membership WHERE organization_id = $1 AND user_id = $2",
organization_id,
user_id,
)
async def _owner_count_locked(conn, organization_id: str) -> int:
"""Låser alle eier-rader for organisasjonen for resten av transaksjonen --
TOCTOU-trygt mot at to samtidige nedgraderinger begge tror det finnes
minst to eiere. Samme mønster som ADR-011s to-lags-grense."""
rows = await conn.fetch(
"SELECT id FROM organization_membership WHERE organization_id = $1 AND role = 'owner' FOR UPDATE",
organization_id,
)
return len(rows)
_ROLE_RANK = {"member": 0, "admin": 1, "owner": 2}
class MembershipOut(BaseModel):
id: str
user_id: str
email: str
display_name: str
role: str
@router.get("/orgs/{organization_id}/memberships", response_model=list[MembershipOut])
async def list_memberships(organization_id: str = Depends(get_authorized_org)) -> list[MembershipOut]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT om.id::text AS id, om.user_id::text AS user_id, om.role,
u.email::text AS email, u.display_name
FROM organization_membership om
JOIN app_user u ON u.id = om.user_id
WHERE om.organization_id = $1
ORDER BY u.display_name
""",
organization_id,
)
return [MembershipOut(**dict(r)) for r in rows]
class MembershipUpdate(BaseModel):
role: str = Field(pattern="^(owner|admin|member)$")
@router.patch("/orgs/{organization_id}/memberships/{membership_id}", response_model=MembershipOut)
async def update_membership(
membership_id: str,
body: MembershipUpdate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> MembershipOut:
"""Eier kan endre ENHVER rolle. En bruker kan alltid SENKE sin egen rolle
selv (frasi seg eierskap er selvbetjent, ADR-022 Beslutning C) -- men
ALDRI heve den selv, det ville vært en privilegie-eskaleringsvei.
Avviser med 409 LAST_OWNER hvis endringen ville latt organisasjonen stå
igjen uten noen eier."""
async with org_connection(organization_id) as conn, translate_db_errors():
target = await conn.fetchrow(
"""
SELECT id::text AS id, user_id::text AS user_id, role
FROM organization_membership WHERE id = $1 AND organization_id = $2
FOR UPDATE
""",
membership_id,
organization_id,
)
if target is None:
raise app_error(404, "NOT_FOUND", "Medlemskapet finnes ikke.")
is_self = target["user_id"] == user.user_id
if is_self:
if _ROLE_RANK[body.role] >= _ROLE_RANK[target["role"]]:
raise app_error(
403, "NOT_ORG_OWNER", "Du kan bare senke din egen rolle selv, ikke heve den."
)
else:
caller_role = await _caller_role(conn, organization_id, user.user_id)
if caller_role != "owner":
raise app_error(403, "NOT_ORG_OWNER", "Kun organisasjonseiere kan endre andres rolle.")
if target["role"] == "owner" and body.role != "owner":
if await _owner_count_locked(conn, organization_id) <= 1:
raise app_error(409, "LAST_OWNER", "Organisasjonen må ha minst én eier.")
row = await conn.fetchrow(
"""
WITH updated AS (
UPDATE organization_membership SET role = $1 WHERE id = $2
RETURNING id, user_id, role
)
SELECT updated.id::text AS id, updated.user_id::text AS user_id, updated.role,
u.email::text AS email, u.display_name
FROM updated JOIN app_user u ON u.id = updated.user_id
""",
body.role,
membership_id,
)
return MembershipOut(**dict(row))
@router.delete("/orgs/{organization_id}/memberships/{membership_id}", status_code=204)
async def delete_membership(
membership_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
"""Eier kan fjerne ethvert medlem. Enhver bruker kan fjerne SEG SELV
(forlate organisasjonen). Avviser med 409 LAST_OWNER hvis den siste
eieren prøver å fjernes/forlate uten å ha overført eierskap først."""
async with org_connection(organization_id) as conn:
target = await conn.fetchrow(
"""
SELECT user_id::text AS user_id, role
FROM organization_membership WHERE id = $1 AND organization_id = $2
FOR UPDATE
""",
membership_id,
organization_id,
)
if target is None:
raise app_error(404, "NOT_FOUND", "Medlemskapet finnes ikke.")
is_self = target["user_id"] == user.user_id
if not is_self:
caller_role = await _caller_role(conn, organization_id, user.user_id)
if caller_role != "owner":
raise app_error(403, "NOT_ORG_OWNER", "Kun organisasjonseiere kan fjerne andre medlemmer.")
if target["role"] == "owner":
if await _owner_count_locked(conn, organization_id) <= 1:
raise app_error(
409,
"LAST_OWNER",
"Organisasjonen må ha minst én eier -- overfør eierskap før du forlater.",
)
await conn.execute("DELETE FROM organization_membership WHERE id = $1", membership_id)
# ---------------------------------------------------------------------------
# Invitasjoner (ADR-022 Beslutning B) -- e-post-basert, ingen egen
# klikkbar aksept-lenke. Godtas automatisk ved neste innlogging med
# matchende e-post (app/routers/auth.py sin accept_pending_invitations_by_email).
# ---------------------------------------------------------------------------
class InvitationCreate(BaseModel):
email: EmailStr
role: str = Field(pattern="^(owner|admin|member)$")
class InvitationOut(BaseModel):
id: str
email: str
role: str
invited_by: str
created_at: str
expires_at: str
@router.post(
"/orgs/{organization_id}/invitations", response_model=InvitationOut, status_code=201
)
async def create_invitation(
body: InvitationCreate,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> InvitationOut:
async with org_connection(organization_id) as conn, translate_db_errors():
caller_role = await _caller_role(conn, organization_id, user.user_id)
if caller_role not in ("owner", "admin"):
raise app_error(403, "NOT_ORG_OWNER", "Kun eiere og administratorer kan invitere.")
# Minste-privilegium (ADR-022 Beslutning B): en admin kan gi noen
# tilgang som medlem, men ALDRI gi noen eier-/adminrettigheter --
# det ville vært en privilegie-eskaleringsvei.
if caller_role == "admin" and body.role != "member":
raise app_error(
403, "NOT_ORG_OWNER", "Administratorer kan kun invitere som medlem, ikke som eier/admin."
)
org_name = await conn.fetchval("SELECT name FROM organization WHERE id = $1", organization_id)
inviter = await conn.fetchrow(
"SELECT display_name, preferred_locale FROM app_user WHERE id = $1", user.user_id
)
row = await conn.fetchrow(
"""
INSERT INTO organization_invitation
(organization_id, email, role, invited_by, expires_at)
VALUES ($1, $2, $3, $4, now() + interval '14 days')
RETURNING id::text, email::text, role, invited_by::text AS invited_by,
created_at::text, expires_at::text
""",
organization_id,
body.email.lower(),
body.role,
user.user_id,
)
if settings.SMTP_CONFIGURED:
try:
await send_organization_invitation_email(
body.email.lower(), org_name, body.role, inviter["display_name"], inviter["preferred_locale"]
)
except Exception:
# Se app/email.py sitt mønster -- driftsfeil i utsendingen skal
# aldri hindre selve invitasjonen fra å bli opprettet.
traceback.print_exc()
elif settings.DEV_LOG_MAGIC_LINKS:
print(
f"[DEV] Invitasjon til {body.email.lower()} for {org_name} som {body.role}", flush=True
)
return InvitationOut(**dict(row))
@router.get("/orgs/{organization_id}/invitations", response_model=list[InvitationOut])
async def list_invitations(organization_id: str = Depends(get_authorized_org)) -> list[InvitationOut]:
async with org_connection(organization_id) as conn:
rows = await conn.fetch(
"""
SELECT id::text, email::text, role, invited_by::text AS invited_by,
created_at::text, expires_at::text
FROM organization_invitation
WHERE organization_id = $1 AND consumed_at IS NULL AND expires_at > now()
ORDER BY created_at DESC
""",
organization_id,
)
return [InvitationOut(**dict(r)) for r in rows]
@router.delete("/orgs/{organization_id}/invitations/{invitation_id}", status_code=204)
async def revoke_invitation(
invitation_id: str,
organization_id: str = Depends(get_authorized_org),
user: CurrentUser = Depends(get_current_user),
) -> None:
async with org_connection(organization_id) as conn:
invitation = await conn.fetchrow(
"SELECT role FROM organization_invitation WHERE id = $1 AND organization_id = $2",
invitation_id,
organization_id,
)
if invitation is None:
raise app_error(404, "NOT_FOUND", "Invitasjonen finnes ikke.")
caller_role = await _caller_role(conn, organization_id, user.user_id)
allowed = caller_role == "owner" or (caller_role == "admin" and invitation["role"] == "member")
if not allowed:
raise app_error(403, "NOT_ORG_OWNER", "Du har ikke rettigheter til å oppheve denne invitasjonen.")
await conn.execute("DELETE FROM organization_invitation WHERE id = $1", invitation_id)
# ---------------------------------------------------------------------------
# Superadmin (ADR-022 Beslutning D) -- kun for kontoer med is_super_admin
# satt DIREKTE i databasen (aldri via noe API). Kan sette medlemskap på
# ENHVER organisasjon, uavhengig av eget medlemskap -- bevisst avgrenset
# til nøyaktig dette (ikke generell tilgang til andres turnering-/spillerdata).
# ---------------------------------------------------------------------------
class SuperadminMembershipSet(BaseModel):
email: EmailStr
role: str = Field(pattern="^(owner|admin|member)$")
@router.post("/superadmin/orgs/{organization_id}/memberships", response_model=MembershipOut)
async def superadmin_set_membership(
organization_id: str,
body: SuperadminMembershipSet,
_admin: CurrentUser = Depends(get_superadmin_user),
) -> MembershipOut:
async with org_connection(organization_id) as conn, translate_db_errors():
target_user = await conn.fetchrow(
"SELECT id::text AS id, display_name FROM app_user WHERE email = $1", body.email.lower()
)
if target_user is None:
raise app_error(
404,
"NOT_FOUND",
"Fant ingen bruker med denne e-posten -- personen må ha logget inn minst én gang først.",
)
row = await conn.fetchrow(
"""
INSERT INTO organization_membership (organization_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (organization_id, user_id) DO UPDATE SET role = EXCLUDED.role
RETURNING id::text, user_id::text, role
""",
organization_id,
target_user["id"],
body.role,
)
return MembershipOut(
id=row["id"],
user_id=row["user_id"],
role=row["role"],
email=body.email.lower(),
display_name=target_user["display_name"],
)