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

@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import joinedload, 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
@@ -36,13 +36,15 @@ async def create_encounter(
run_id: int,
data: EncounterCreate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
# Validate run exists
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
require_run_owner(run, user)
# Validate route exists and load its children
result = await session.execute(
select(Route)
@@ -139,12 +141,17 @@ async def update_encounter(
encounter_id: int,
data: EncounterUpdate,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found")
run = await session.get(NuzlockeRun, encounter.run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
require_run_owner(run, user)
update_data = data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(encounter, field, value)
@@ -168,12 +175,17 @@ async def update_encounter(
async def delete_encounter(
encounter_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
encounter = await session.get(Encounter, encounter_id)
if encounter is None:
raise HTTPException(status_code=404, detail="Encounter not found")
run = await session.get(NuzlockeRun, encounter.run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
require_run_owner(run, user)
# Block deletion if encounter is referenced by a genlocke transfer
transfer_result = await session.execute(
select(GenlockeTransfer.id).where(
@@ -200,12 +212,15 @@ async def delete_encounter(
async def bulk_randomize_encounters(
run_id: int,
session: AsyncSession = Depends(get_session),
_user: AuthUser = Depends(require_auth),
user: AuthUser = Depends(require_auth),
):
# 1. Validate run
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
require_run_owner(run, user)
if run.status != "active":
raise HTTPException(status_code=400, detail="Run is not active")