""" 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"], )