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:
@@ -5,7 +5,7 @@ from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.auth import AuthUser, require_admin, require_auth
|
||||
from app.core.auth import AuthUser, require_admin, require_auth, require_run_owner
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_battle import BossBattle
|
||||
from app.models.boss_pokemon import BossPokemon
|
||||
@@ -344,12 +344,14 @@ async def create_boss_result(
|
||||
run_id: int,
|
||||
data: BossResultCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
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)
|
||||
|
||||
boss = await session.get(BossBattle, data.boss_battle_id)
|
||||
if boss is None:
|
||||
raise HTTPException(status_code=404, detail="Boss battle not found")
|
||||
@@ -425,8 +427,14 @@ async def delete_boss_result(
|
||||
run_id: int,
|
||||
result_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
_user: AuthUser = Depends(require_auth),
|
||||
user: AuthUser = Depends(require_auth),
|
||||
):
|
||||
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)
|
||||
|
||||
result = await session.execute(
|
||||
select(BossResult).where(
|
||||
BossResult.id == result_id, BossResult.run_id == run_id
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user, require_auth
|
||||
from app.core.auth import AuthUser, get_current_user, require_auth, require_run_owner
|
||||
from app.core.database import get_session
|
||||
from app.models.boss_result import BossResult
|
||||
from app.models.encounter import Encounter
|
||||
@@ -181,31 +181,17 @@ def _build_run_response(run: NuzlockeRun) -> RunResponse:
|
||||
)
|
||||
|
||||
|
||||
def _check_run_access(
|
||||
run: NuzlockeRun, user: AuthUser | None, require_owner: bool = False
|
||||
) -> None:
|
||||
def _check_run_read_access(run: NuzlockeRun, user: AuthUser | None) -> None:
|
||||
"""
|
||||
Check if user can access the run.
|
||||
Check if user can read the run.
|
||||
Raises 403 for private runs if user is not owner.
|
||||
If require_owner=True, always requires ownership (for mutations).
|
||||
Unowned runs are readable by everyone (legacy).
|
||||
"""
|
||||
if run.owner_id is None:
|
||||
# Unowned runs are accessible by everyone (legacy)
|
||||
if require_owner:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only the run owner can perform this action"
|
||||
)
|
||||
return
|
||||
|
||||
user_id = UUID(user.id) if user else None
|
||||
|
||||
if require_owner:
|
||||
if user_id != run.owner_id:
|
||||
raise HTTPException(
|
||||
status_code=403, detail="Only the run owner can perform this action"
|
||||
)
|
||||
return
|
||||
|
||||
if run.visibility == RunVisibility.PRIVATE and user_id != run.owner_id:
|
||||
raise HTTPException(status_code=403, detail="This run is private")
|
||||
|
||||
@@ -301,7 +287,7 @@ async def get_run(
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check visibility access
|
||||
_check_run_access(run, user)
|
||||
_check_run_read_access(run, user)
|
||||
|
||||
# Check if this run belongs to a genlocke
|
||||
genlocke_context = None
|
||||
@@ -375,8 +361,7 @@ async def update_run(
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check ownership for mutations (unowned runs allow anyone for backwards compat)
|
||||
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||
require_run_owner(run, user)
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
@@ -484,8 +469,7 @@ async def delete_run(
|
||||
if run is None:
|
||||
raise HTTPException(status_code=404, detail="Run not found")
|
||||
|
||||
# Check ownership for deletion (unowned runs allow anyone for backwards compat)
|
||||
_check_run_access(run, user, require_owner=run.owner_id is not None)
|
||||
require_run_owner(run, user)
|
||||
|
||||
# Block deletion if run is linked to a genlocke leg
|
||||
leg_result = await session.execute(
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@@ -105,3 +106,20 @@ async def require_admin(
|
||||
detail="Admin access required",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_run_owner(run: NuzlockeRun, user: AuthUser) -> None:
|
||||
"""
|
||||
Verify user owns the run. Raises 403 if not owner.
|
||||
Unowned (legacy) runs are read-only and reject all mutations.
|
||||
"""
|
||||
if run.owner_id is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="This run has no owner and cannot be modified",
|
||||
)
|
||||
if UUID(user.id) != run.owner_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only the run owner can perform this action",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user