Complete Section 1 security/auth hardening

This commit is contained in:
dwindown
2026-04-30 11:35:56 +07:00
parent 432ffbcdb9
commit 12d2d9458f
15 changed files with 863 additions and 232 deletions

View File

@@ -14,6 +14,12 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.core.auth import (
AuthContext,
ensure_website_scope_matches,
get_auth_context,
require_website_auth,
)
from app.models import Item, Session, Tryout
from app.services.cat_selection import (
CATSelectionError,
@@ -106,7 +112,8 @@ class CATTestResponse(BaseModel):
)
async def get_next_item_endpoint(
session_id: str,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
auth: AuthContext = Depends(get_auth_context),
) -> NextItemResponse:
"""
Get the next item for a session.
@@ -116,8 +123,13 @@ async def get_next_item_endpoint(
Calls appropriate selection function based on selection_mode.
Returns item or completion status.
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get session
session_query = select(Session).where(Session.session_id == session_id)
session_query = select(Session).where(
Session.session_id == session_id,
Session.website_id == website_id,
)
session_result = await db.execute(session_query)
session = session_result.scalar_one_or_none()
@@ -126,6 +138,11 @@ async def get_next_item_endpoint(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Session does not belong to this authenticated user",
)
if session.is_completed:
return NextItemResponse(
@@ -214,7 +231,8 @@ async def get_next_item_endpoint(
async def submit_answer_endpoint(
session_id: str,
request: SubmitAnswerRequest,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
auth: AuthContext = Depends(get_auth_context),
) -> SubmitAnswerResponse:
"""
Submit an answer for an item.
@@ -224,8 +242,13 @@ async def submit_answer_endpoint(
Updates theta estimate.
Records response time.
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get session
session_query = select(Session).where(Session.session_id == session_id)
session_query = select(Session).where(
Session.session_id == session_id,
Session.website_id == website_id,
)
session_result = await db.execute(session_query)
session = session_result.scalar_one_or_none()
@@ -234,6 +257,11 @@ async def submit_answer_endpoint(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Session {session_id} not found"
)
if auth.role == "student" and session.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Session does not belong to this authenticated user",
)
if session.is_completed:
raise HTTPException(
@@ -242,7 +270,11 @@ async def submit_answer_endpoint(
)
# Get item
item_query = select(Item).where(Item.id == request.item_id)
item_query = select(Item).where(
Item.id == request.item_id,
Item.website_id == session.website_id,
Item.tryout_id == session.tryout_id,
)
item_result = await db.execute(item_query)
item = item_result.scalar_one_or_none()
@@ -296,7 +328,8 @@ async def submit_answer_endpoint(
)
async def test_cat_endpoint(
request: CATTestRequest,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
auth: AuthContext = Depends(get_auth_context),
) -> CATTestResponse:
"""
Test CAT selection algorithm.
@@ -304,10 +337,13 @@ async def test_cat_endpoint(
Simulates CAT selection for a tryout and returns
the sequence of selected items with theta progression.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
ensure_website_scope_matches(website_id, request.website_id)
# Verify tryout exists
tryout_query = select(Tryout).where(
Tryout.tryout_id == request.tryout_id,
Tryout.website_id == request.website_id
Tryout.website_id == website_id
)
tryout_result = await db.execute(tryout_query)
tryout = tryout_result.scalar_one_or_none()
@@ -315,14 +351,14 @@ async def test_cat_endpoint(
if not tryout:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tryout {request.tryout_id} not found for website {request.website_id}"
detail=f"Tryout {request.tryout_id} not found for website {website_id}"
)
# Run simulation
result = await simulate_cat_selection(
db,
tryout_id=request.tryout_id,
website_id=request.website_id,
website_id=website_id,
initial_theta=request.initial_theta,
selection_mode=request.selection_mode,
max_items=request.max_items,
@@ -346,13 +382,19 @@ async def test_cat_endpoint(
)
async def get_session_status_endpoint(
session_id: str,
db: AsyncSession = Depends(get_db)
db: AsyncSession = Depends(get_db),
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""
Get session status for admin monitoring.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
# Get session
session_query = select(Session).where(Session.session_id == session_id)
session_query = select(Session).where(
Session.session_id == session_id,
Session.website_id == website_id,
)
session_result = await db.execute(session_query)
session = session_result.scalar_one_or_none()