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
426 lines
16 KiB
Python
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"],
|
|
)
|