fix: enforce run ownership on all mutation endpoints

Add require_run_owner helper in auth.py that enforces ownership on
mutation endpoints. Unowned (legacy) runs are now read-only.

Applied ownership checks to:
- All 4 encounter mutation endpoints
- Both boss result mutation endpoints
- Run update/delete endpoints
- All 5 genlocke mutation endpoints (via first leg's run owner)

Also sets owner_id on run creation in genlockes.py (create_genlocke,
advance_leg) and adds 22 comprehensive ownership enforcement tests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 13:28:32 +01:00
parent a12958ae32
commit eeb1609452
8 changed files with 660 additions and 39 deletions

View File

@@ -1,3 +1,5 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import delete as sa_delete
@@ -6,7 +8,7 @@ from sqlalchemy import update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.core.auth import AuthUser, require_auth
from app.core.auth import AuthUser, require_auth, require_run_owner
from app.core.database import get_session
from app.models.encounter import Encounter
from app.models.evolution import Evolution
@@ -16,6 +18,7 @@ from app.models.genlocke_transfer import GenlockeTransfer
from app.models.nuzlocke_run import NuzlockeRun
from app.models.pokemon import Pokemon
from app.models.route import Route
from app.models.user import User
from app.schemas.genlocke import (
AddLegRequest,
AdvanceLegRequest,
@@ -41,6 +44,38 @@ from app.services.families import build_families, resolve_base_form
router = APIRouter()
async def _check_genlocke_owner(
genlocke_id: int,
user: AuthUser,
session: AsyncSession,
) -> None:
"""
Verify user owns the genlocke via the first leg's run.
Raises 404 if the genlocke doesn't exist.
Raises 403 if the first leg has a run with a different owner.
Raises 403 if the first leg has an unowned run (read-only legacy data).
"""
# First check if genlocke exists
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
leg_result = await session.execute(
select(GenlockeLeg)
.where(GenlockeLeg.genlocke_id == genlocke_id, GenlockeLeg.leg_order == 1)
.options(selectinload(GenlockeLeg.run))
)
first_leg = leg_result.scalar_one_or_none()
if first_leg is None or first_leg.run is None:
raise HTTPException(
status_code=403,
detail="Cannot modify genlocke: no run found for first leg",
)
require_run_owner(first_leg.run, user)
@router.get("", response_model=list[GenlockeListItem])
async def list_genlockes(session: AsyncSession = Depends(get_session)):
result = await session.execute(
@@ -440,7 +475,7 @@ async def get_genlocke_lineages(
async def create_genlocke(
data: GenlockeCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
if not data.game_ids:
raise HTTPException(status_code=400, detail="At least one game is required")
@@ -455,6 +490,13 @@ async def create_genlocke(
if missing:
raise HTTPException(status_code=404, detail=f"Games not found: {missing}")
# Ensure user exists in local DB
user_id = UUID(user.id)
db_user = await session.get(User, user_id)
if db_user is None:
db_user = User(id=user_id, email=user.email or "")
session.add(db_user)
# Create genlocke
genlocke = Genlocke(
name=data.name.strip(),
@@ -481,6 +523,7 @@ async def create_genlocke(
first_game = found_games[data.game_ids[0]]
first_run = NuzlockeRun(
game_id=first_game.id,
owner_id=user_id,
name=f"{data.name.strip()} \u2014 Leg 1",
status="active",
rules=data.nuzlocke_rules,
@@ -571,8 +614,10 @@ async def advance_leg(
leg_order: int,
data: AdvanceLegRequest | None = None,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
# Load genlocke with legs
result = await session.execute(
select(Genlocke)
@@ -653,9 +698,10 @@ async def advance_leg(
else:
current_leg.retired_pokemon_ids = []
# Create a new run for the next leg
# Create a new run for the next leg, preserving owner from current run
new_run = NuzlockeRun(
game_id=next_leg.game_id,
owner_id=current_run.owner_id,
name=f"{genlocke.name} \u2014 Leg {next_leg.leg_order}",
status="active",
rules=genlocke.nuzlocke_rules,
@@ -826,8 +872,10 @@ async def update_genlocke(
genlocke_id: int,
data: GenlockeUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
result = await session.execute(
select(Genlocke)
.where(Genlocke.id == genlocke_id)
@@ -863,8 +911,10 @@ async def update_genlocke(
async def delete_genlocke(
genlocke_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
@@ -895,8 +945,10 @@ async def add_leg(
genlocke_id: int,
data: AddLegRequest,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
genlocke = await session.get(Genlocke, genlocke_id)
if genlocke is None:
raise HTTPException(status_code=404, detail="Genlocke not found")
@@ -938,8 +990,10 @@ async def remove_leg(
genlocke_id: int,
leg_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
await _check_genlocke_owner(genlocke_id, user, session)
result = await session.execute(
select(GenlockeLeg).where(
GenlockeLeg.id == leg_id,