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

@@ -20,7 +20,7 @@ from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.responses import HTMLResponse, RedirectResponse
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED
from starlette.status import HTTP_303_SEE_OTHER, HTTP_401_UNAUTHORIZED, HTTP_429_TOO_MANY_REQUESTS
from app.core.config import get_settings
from app.database import get_db
@@ -53,9 +53,13 @@ settings = get_settings()
router = APIRouter(prefix="/admin", tags=["admin-web"])
SESSION_COOKIE = "access_token"
CSRF_COOKIE = "admin_csrf_token"
SESSION_PREFIX = "admin:session:"
IMPORT_PREVIEW_PREFIX = "admin:import-preview:"
IMPORT_PREVIEW_TTL_SECONDS = 900
LOGIN_RATE_LIMIT_PREFIX = "admin:login:attempts:"
LOGIN_RATE_LIMIT_MAX_ATTEMPTS = 10
LOGIN_RATE_LIMIT_WINDOW_SECONDS = 300
_admin_redis = None
@@ -153,10 +157,27 @@ def _render_auth_page(
</main>
</body>
</html>"""
return HTMLResponse(html, status_code=status_code)
csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
csrf_input = f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
html = re.sub(
r'(<form[^>]*method="post"[^>]*>)',
r"\1" + csrf_input,
html,
flags=re.IGNORECASE,
)
response = HTMLResponse(html, status_code=status_code)
response.set_cookie(
CSRF_COOKIE,
csrf_token,
path="/admin",
httponly=False,
secure=settings.ENVIRONMENT == "production",
samesite="lax",
)
return response
def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
def _render_admin_page(request: Request, title: str, page_title: str, body: str) -> HTMLResponse:
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -213,7 +234,46 @@ def _render_admin_page(title: str, page_title: str, body: str) -> HTMLResponse:
</div>
</body>
</html>"""
return HTMLResponse(html)
csrf_token = request.cookies.get(CSRF_COOKIE) or secrets.token_urlsafe(24)
csrf_input = f'<input type="hidden" name="csrf_token" value="{escape(csrf_token)}">'
html = re.sub(
r'(<form[^>]*method="post"[^>]*>)',
r"\1" + csrf_input,
html,
flags=re.IGNORECASE,
)
response = HTMLResponse(html)
response.set_cookie(
CSRF_COOKIE,
csrf_token,
path="/admin",
httponly=False,
secure=settings.ENVIRONMENT == "production",
samesite="lax",
)
return response
def _verify_csrf(request: Request, csrf_token: str | None) -> None:
cookie_token = request.cookies.get(CSRF_COOKIE)
if not cookie_token or not csrf_token:
raise HTTPException(status_code=403, detail="CSRF validation failed")
if not secrets.compare_digest(cookie_token, csrf_token):
raise HTTPException(status_code=403, detail="CSRF validation failed")
async def _enforce_csrf(request: Request) -> None:
form = await request.form()
_verify_csrf(request, form.get("csrf_token"))
async def _csrf_route_guard(request: Request) -> None:
if request.method.upper() != "POST":
return
await _enforce_csrf(request)
router.dependencies.append(Depends(_csrf_route_guard))
def _table(headers: list[str], rows: list[list[Any]]) -> str:
@@ -1052,10 +1112,58 @@ async def login_submit(
password: str = Form(...),
remember_me: str | None = Form(None),
):
if _admin_redis is None:
body = """
<div class="error">Admin backend is temporarily unavailable. Please try again.</div>
<form method="post" action="/admin/login" autocomplete="off">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
<button type="submit">Sign in</button>
</form>
"""
return _render_auth_page(
request,
"Admin Login",
"Use the configured admin credentials to access the dashboard.",
body,
status_code=503,
)
client_ip = request.client.host if request.client else "unknown"
rate_limit_key = f"{LOGIN_RATE_LIMIT_PREFIX}{client_ip}"
attempts_raw = await _admin_redis.get(rate_limit_key)
attempts = int(attempts_raw) if attempts_raw else 0
if attempts >= LOGIN_RATE_LIMIT_MAX_ATTEMPTS:
body = """
<div class="error">Too many login attempts. Please wait a few minutes and try again.</div>
<form method="post" action="/admin/login" autocomplete="off">
<label for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username">
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password">
<label class="row"><input type="checkbox" name="remember_me" __REMEMBER_ME_CHECKED__> Remember me</label>
<button type="submit">Sign in</button>
</form>
"""
return _render_auth_page(
request,
"Admin Login",
"Use the configured admin credentials to access the dashboard.",
body,
status_code=HTTP_429_TOO_MANY_REQUESTS,
)
if not (
secrets.compare_digest(username, settings.ADMIN_USERNAME)
and secrets.compare_digest(password, settings.ADMIN_PASSWORD)
):
attempts = await _admin_redis.incr(rate_limit_key)
if attempts == 1:
await _admin_redis.expire(rate_limit_key, LOGIN_RATE_LIMIT_WINDOW_SECONDS)
body = f"""
<div class="error">Invalid username or password.</div>
<form method="post" action="/admin/login" autocomplete="off">
@@ -1075,11 +1183,21 @@ async def login_submit(
status_code=HTTP_401_UNAUTHORIZED,
)
await _admin_redis.delete(rate_limit_key)
expire = settings.ADMIN_SESSION_EXPIRE_SECONDS
response = _dashboard_redirect()
secure_cookie = settings.ENVIRONMENT == "production"
if remember_me == "on":
expire = max(expire, 3600 * 24 * 30)
response.set_cookie("remember_me", "on", expires=expire, path="/admin")
response.set_cookie(
"remember_me",
"on",
expires=expire,
path="/admin",
secure=secure_cookie,
samesite="lax",
)
else:
response.delete_cookie("remember_me", path="/admin")
@@ -1090,6 +1208,7 @@ async def login_submit(
expires=expire,
path="/admin",
httponly=True,
secure=secure_cookie,
samesite="lax",
)
await _admin_redis.set(f"{SESSION_PREFIX}{token}", settings.ADMIN_USERNAME, ex=expire)
@@ -1179,7 +1298,7 @@ async def dashboard_view(request: Request, db: AsyncSession = Depends(get_db)):
</div>
<p style="margin-top:20px"><a href="/admin/ai-playground">Open AI Playground</a></p>
"""
return _render_admin_page("IRT Bank Soal Admin", "Dashboard", body)
return _render_admin_page(request, "IRT Bank Soal Admin", "Dashboard", body)
@router.get("/websites", include_in_schema=False)
@@ -1191,7 +1310,7 @@ async def websites_view(request: Request, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
@router.post("/websites", include_in_schema=False)
@@ -1217,7 +1336,7 @@ async def websites_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
if not normalized_url.startswith(("http://", "https://")):
result = await db.execute(select(Website).order_by(Website.id.asc()))
@@ -1228,7 +1347,7 @@ async def websites_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
website = Website(site_name=normalized_name, site_url=normalized_url)
db.add(website)
@@ -1244,7 +1363,7 @@ async def websites_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
@@ -1252,7 +1371,7 @@ async def websites_submit(
websites,
success=f"Website added successfully with ID {website.id}.",
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
@router.get("/websites/{website_id}/edit", include_in_schema=False)
@@ -1270,10 +1389,10 @@ async def website_edit_view(
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
body = _website_edit_form_body(website)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
@router.post("/websites/{website_id}/edit", include_in_schema=False)
@@ -1293,7 +1412,7 @@ async def website_edit_submit(
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
normalized_name = site_name.strip()
normalized_url = site_url.strip().rstrip("/")
@@ -1305,7 +1424,7 @@ async def website_edit_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
if not normalized_url.startswith(("http://", "https://")):
body = _website_edit_form_body(
@@ -1314,7 +1433,7 @@ async def website_edit_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
website.site_name = normalized_name
website.site_url = normalized_url
@@ -1328,14 +1447,14 @@ async def website_edit_submit(
site_name=site_name,
site_url=site_url,
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
await db.refresh(website)
body = _website_edit_form_body(
website,
success=f"Website #{website.id} updated successfully.",
)
return _render_admin_page("Edit Website", "Edit Website", body)
return _render_admin_page(request, "Edit Website", "Edit Website", body)
@router.post("/websites/{website_id}/delete", include_in_schema=False)
@@ -1353,7 +1472,7 @@ async def website_delete_submit(
result = await db.execute(select(Website).order_by(Website.id.asc()))
websites = list(result.scalars().all())
body = _websites_form_body(websites, error=f"Website not found: {website_id}")
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
deleted_label = f"{website.site_name} ({website.site_url})"
await db.delete(website)
@@ -1365,7 +1484,7 @@ async def website_delete_submit(
websites,
success=f"Website deleted successfully: {deleted_label}",
)
return _render_admin_page("Websites", "Websites", body)
return _render_admin_page(request, "Websites", "Websites", body)
@router.get("/tryout-import", include_in_schema=False)
@@ -1377,7 +1496,7 @@ async def tryout_import_view(request: Request, db: AsyncSession = Depends(get_db
websites = await _load_websites(db)
snapshots = await _recent_snapshots(db)
body = _tryout_import_form_body(websites, snapshots)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.post("/tryout-import/preview", include_in_schema=False)
@@ -1401,7 +1520,7 @@ async def tryout_import_preview(
error="File must be .json format.",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
payload_bytes = await file.read()
@@ -1414,7 +1533,7 @@ async def tryout_import_preview(
error="File must be UTF-8 encoded JSON.",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
except json.JSONDecodeError as exc:
body = _tryout_import_form_body(
websites,
@@ -1422,7 +1541,7 @@ async def tryout_import_preview(
error=f"Invalid JSON file: {exc}",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
preview = await preview_tryout_json_import(payload, website_id, db)
@@ -1433,7 +1552,7 @@ async def tryout_import_preview(
error=str(exc),
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
preview_token = uuid.uuid4().hex
await _admin_redis.set(
@@ -1449,7 +1568,7 @@ async def tryout_import_preview(
preview_token=preview_token,
upload_filename=file.filename or "",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.post("/tryout-import", include_in_schema=False)
@@ -1474,7 +1593,7 @@ async def tryout_import_submit(
error="Preview token expired. Upload the JSON again and preview before importing.",
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
try:
payload = json.loads(payload_text)
@@ -1488,7 +1607,7 @@ async def tryout_import_submit(
error=str(exc),
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
except Exception:
await db.rollback()
raise
@@ -1506,7 +1625,7 @@ async def tryout_import_submit(
),
selected_website_id=website_id,
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
@router.get("/snapshot-questions", include_in_schema=False)
@@ -1528,11 +1647,11 @@ async def snapshot_questions_view(
snapshots,
error=f"Snapshot not found: {snapshot_id}",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
@router.post("/snapshot-questions/promote-bulk", include_in_schema=False)
@@ -1555,7 +1674,7 @@ async def snapshot_question_promote_bulk(
snapshots,
error=f"Snapshot not found: {snapshot_id}",
)
return _render_admin_page("Tryout Import", "Tryout Import", body)
return _render_admin_page(request, "Tryout Import", "Tryout Import", body)
if not snapshot_question_ids:
questions, promoted_items_by_slot, _ = await _load_snapshot_question_context(snapshot, db)
@@ -1565,7 +1684,7 @@ async def snapshot_question_promote_bulk(
promoted_items_by_slot,
error="Select at least one snapshot question to promote.",
)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
question_result = await db.execute(
select(TryoutSnapshotQuestion).where(
@@ -1607,7 +1726,7 @@ async def snapshot_question_promote_bulk(
success_message += f" Latest basis item ID: {created_items[-1].id}."
body = _snapshot_questions_body(snapshot, questions, promoted_items_by_slot, success=success_message)
return _render_admin_page("Snapshot Questions", "Snapshot Questions", body)
return _render_admin_page(request, "Snapshot Questions", "Snapshot Questions", body)
@router.get("/calibration-status", include_in_schema=False)
@@ -1637,7 +1756,7 @@ async def calibration_status_view(request: Request, db: AsyncSession = Depends(g
["Tryout ID", "Name", "Total Items", "Calibrated", "Calibration %", "Ready for IRT"],
rows,
)
return _render_admin_page("Calibration Status", "Calibration Status", body)
return _render_admin_page(request, "Calibration Status", "Calibration Status", body)
@router.get("/item-statistics", include_in_schema=False)
@@ -1672,7 +1791,7 @@ async def item_statistics_view(request: Request, db: AsyncSession = Depends(get_
["Level", "Total Items", "Calibrated", "Calibration %", "Responses", "Avg Correctness"],
rows,
)
return _render_admin_page("Item Statistics", "Item Statistics", body)
return _render_admin_page(request, "Item Statistics", "Item Statistics", body)
@router.get("/session-overview", include_in_schema=False)
@@ -1702,7 +1821,7 @@ async def session_overview_view(request: Request, db: AsyncSession = Depends(get
["Session ID", "WP User", "Tryout", "Completed", "Mode", "Benar", "NM", "NN", "Theta"],
rows,
)
return _render_admin_page("Session Overview", "Session Overview", body)
return _render_admin_page(request, "Session Overview", "Session Overview", body)
@router.get("/basis-items", include_in_schema=False)
@@ -1719,7 +1838,7 @@ async def basis_items_view(request: Request, db: AsyncSession = Depends(get_db))
)
basis_items = list(result.scalars().all())
body = _basis_items_list_body(basis_items)
return _render_admin_page("Basis Items", "Basis Items", body)
return _render_admin_page(request, "Basis Items", "Basis Items", body)
@router.get("/basis-items/{basis_item_id}", include_in_schema=False)
@@ -1752,7 +1871,7 @@ async def basis_item_workspace_view(
.limit(200)
)
body = _basis_items_list_body(list(result.scalars().all()))
return _render_admin_page("Basis Items", "Basis Items", body)
return _render_admin_page(request, "Basis Items", "Basis Items", body)
run_result = await db.execute(
select(AIGenerationRun)
@@ -1794,7 +1913,7 @@ async def basis_item_workspace_view(
family_stats,
filters,
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -1856,7 +1975,7 @@ async def basis_item_generate_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -1951,7 +2070,7 @@ async def basis_item_generate_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -2016,7 +2135,7 @@ async def basis_item_review_bulk(
filters,
success=f"Applied status '{action}' to selected variants.",
)
return _render_admin_page(
return _render_admin_page(request,
f"Basis Item #{basis_item.id}",
f"Basis Item Workspace #{basis_item.id}",
body,
@@ -2202,7 +2321,7 @@ async def ai_playground_view(request: Request, db: AsyncSession = Depends(get_db
generated_variants=generated_variants,
basis_item_id=str(basis_item_id or ""),
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground/seed-demo", include_in_schema=False)
@@ -2225,7 +2344,7 @@ async def ai_playground_seed_demo(request: Request, db: AsyncSession = Depends(g
generated_variants=generated_variants,
basis_item_id=str(demo_item.id),
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground", include_in_schema=False)
@@ -2268,7 +2387,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if target_level not in {"mudah", "sulit"}:
body = _ai_form_body(
@@ -2286,7 +2405,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if not validate_ai_model(ai_model):
body = _ai_form_body(
@@ -2304,7 +2423,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
result = await db.execute(select(Item).where(Item.id == basis_item_id))
basis_item = result.scalar_one_or_none()
@@ -2324,7 +2443,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if basis_item.level != "sedang":
body = _ai_form_body(
@@ -2342,7 +2461,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if generation_count < 1 or generation_count > 50:
body = _ai_form_body(
@@ -2360,7 +2479,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
run_id = await create_generation_run(
basis_item_id=basis_item.id,
@@ -2428,7 +2547,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
body = _ai_form_body(
True,
@@ -2451,7 +2570,7 @@ async def ai_playground_submit(
include_note_for_admin=note_for_admin,
include_note_in_prompt=note_in_prompt,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground/save", include_in_schema=False)
@@ -2483,7 +2602,7 @@ async def ai_playground_save(
error="Only mudah or sulit generated items can be saved from the playground.",
basis_items=basis_items,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
try:
options = json.loads(options_json)
@@ -2494,7 +2613,7 @@ async def ai_playground_save(
error="Generated options payload is invalid.",
basis_items=basis_items,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
from app.schemas.ai import GeneratedQuestion
@@ -2521,7 +2640,7 @@ async def ai_playground_save(
error="Failed to save generated item. Check server logs for the database error.",
basis_items=basis_items,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
await db.commit()
updated_stats = await get_ai_stats(db)
@@ -2535,7 +2654,7 @@ async def ai_playground_save(
target_level=target_level,
ai_model=ai_model,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.post("/ai-playground/review-bulk", include_in_schema=False)
@@ -2564,7 +2683,7 @@ async def ai_playground_review_bulk(
generation_runs=generation_runs,
generated_variants=generated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
if not item_ids:
body = _ai_form_body(
@@ -2575,7 +2694,7 @@ async def ai_playground_review_bulk(
generation_runs=generation_runs,
generated_variants=generated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
result = await db.execute(
select(Item).where(Item.id.in_(item_ids), Item.generated_by == "ai")
@@ -2590,7 +2709,7 @@ async def ai_playground_review_bulk(
generation_runs=generation_runs,
generated_variants=generated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
reviewed_at = datetime.now(timezone.utc)
for item in items:
@@ -2612,7 +2731,7 @@ async def ai_playground_review_bulk(
generation_runs=updated_runs,
generated_variants=updated_variants,
)
return _render_admin_page("AI Playground", "AI Playground", body)
return _render_admin_page(request, "AI Playground", "AI Playground", body)
@router.get("/tryout/list", include_in_schema=False)

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()

144
app/core/auth.py Normal file
View File

@@ -0,0 +1,144 @@
"""
Token-based authentication helpers for website-scoped access control.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
from dataclasses import dataclass
from typing import Optional
from fastapi import Header, HTTPException, status
from app.core.config import get_settings
settings = get_settings()
@dataclass
class AuthContext:
website_id: int
role: str
wp_user_id: Optional[str] = None
def _b64url_encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _b64url_decode(raw: str) -> bytes:
padding = "=" * (-len(raw) % 4)
return base64.urlsafe_b64decode((raw + padding).encode("ascii"))
def issue_access_token(
website_id: int,
role: str = "student",
wp_user_id: str | None = None,
expires_in_seconds: int = 3600,
) -> str:
payload = {
"website_id": int(website_id),
"role": role,
"wp_user_id": wp_user_id,
"exp": int(time.time()) + int(expires_in_seconds),
}
payload_bytes = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
payload_b64 = _b64url_encode(payload_bytes)
sig = hmac.new(settings.SECRET_KEY.encode("utf-8"), payload_b64.encode("ascii"), hashlib.sha256).digest()
return f"{payload_b64}.{_b64url_encode(sig)}"
def decode_access_token(token: str) -> AuthContext:
try:
payload_b64, sig_b64 = token.split(".", 1)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token format",
) from exc
expected_sig = hmac.new(
settings.SECRET_KEY.encode("utf-8"),
payload_b64.encode("ascii"),
hashlib.sha256,
).digest()
provided_sig = _b64url_decode(sig_b64)
if not hmac.compare_digest(provided_sig, expected_sig):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token signature",
)
try:
payload = json.loads(_b64url_decode(payload_b64).decode("utf-8"))
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token payload",
) from exc
exp = int(payload.get("exp", 0))
if exp <= int(time.time()):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token has expired",
)
website_id = payload.get("website_id")
role = payload.get("role")
if website_id is None or not role:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token missing required claims",
)
return AuthContext(
website_id=int(website_id),
role=str(role),
wp_user_id=payload.get("wp_user_id"),
)
def get_auth_context(
authorization: str | None = Header(None, alias="Authorization"),
) -> AuthContext:
if authorization is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authorization header is required",
)
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid Authorization header format. Use: Bearer {token}",
)
return decode_access_token(parts[1])
def require_website_auth(
auth: AuthContext,
allowed_roles: set[str] | None = None,
) -> int:
if allowed_roles is not None and auth.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions for this endpoint",
)
return auth.website_id
def ensure_website_scope_matches(
auth_website_id: int,
payload_website_id: int,
) -> None:
if int(auth_website_id) != int(payload_website_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="website_id in payload must match authenticated website scope",
)

45
app/core/rate_limit.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Lightweight in-process rate limiting helpers.
"""
from __future__ import annotations
import threading
import time
from collections import defaultdict, deque
from fastapi import HTTPException, Request, status
_lock = threading.Lock()
_hits: dict[str, deque[float]] = defaultdict(deque)
def _client_ip(request: Request) -> str:
if request.client and request.client.host:
return request.client.host
return "unknown"
def enforce_rate_limit(
request: Request,
*,
scope: str,
max_requests: int,
window_seconds: int,
) -> None:
now = time.time()
ip = _client_ip(request)
key = f"{scope}:{ip}"
cutoff = now - window_seconds
with _lock:
dq = _hits[key]
while dq and dq[0] <= cutoff:
dq.popleft()
if len(dq) >= max_requests:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Too many requests for {scope}. Please try again later.",
)
dq.append(now)

View File

@@ -40,6 +40,33 @@ from app.routers import (
settings = get_settings()
def validate_security_config() -> None:
"""
Enforce minimum security requirements for production deployments.
"""
if settings.ENVIRONMENT != "production":
return
insecure_secret_values = {
"",
"dev-secret-key-change-in-production",
"your-secret-key-here-change-in-production",
}
if settings.SECRET_KEY in insecure_secret_values:
raise RuntimeError(
"In production, SECRET_KEY must be set to a strong non-default value."
)
if settings.ENABLE_ADMIN and (
not settings.ADMIN_USERNAME
or not settings.ADMIN_PASSWORD
or settings.ADMIN_PASSWORD == "change-me"
):
raise RuntimeError(
"In production with ENABLE_ADMIN=true, ADMIN_USERNAME and ADMIN_PASSWORD must be configured securely."
)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
"""
@@ -47,6 +74,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
Handles startup and shutdown events.
"""
validate_security_config()
# Startup: Initialize database
await init_db()
if settings.ENABLE_ADMIN:

View File

@@ -7,11 +7,18 @@ Admin endpoints for AI question generation playground.
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import get_settings
from app.core.auth import (
AuthContext,
ensure_website_scope_matches,
get_auth_context,
require_website_auth,
)
from app.core.rate_limit import enforce_rate_limit
from app.database import get_db
from app.models.item import Item
from app.schemas.ai import (
@@ -58,8 +65,10 @@ router = APIRouter(prefix="/admin/ai", tags=["admin", "ai-generation"])
},
)
async def generate_preview(
request_http: Request,
request: AIGeneratePreviewRequest,
db: Annotated[AsyncSession, Depends(get_db)],
auth: AuthContext = Depends(get_auth_context),
) -> AIGeneratePreviewResponse:
"""
Generate AI question preview (no database save).
@@ -68,6 +77,14 @@ async def generate_preview(
- **target_level**: Target difficulty (mudah/sulit)
- **ai_model**: OpenRouter model to use (default: qwen/qwen2.5-32b-instruct)
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request_http,
scope="ai.generate_preview",
max_requests=40,
window_seconds=300,
)
# Validate AI model
if not validate_ai_model(request.ai_model):
supported = ", ".join(SUPPORTED_MODELS.keys())
@@ -88,6 +105,7 @@ async def generate_preview(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Basis item not found: {request.basis_item_id}",
)
ensure_website_scope_matches(website_id, basis_item.website_id)
# Validate basis item is sedang level
if basis_item.level != "sedang":
@@ -158,8 +176,10 @@ async def generate_preview(
},
)
async def generate_save(
request_http: Request,
request: AISaveRequest,
db: Annotated[AsyncSession, Depends(get_db)],
auth: AuthContext = Depends(get_auth_context),
) -> AISaveResponse:
"""
Save AI-generated question to database.
@@ -175,6 +195,15 @@ async def generate_save(
- **level**: Difficulty level
- **ai_model**: AI model used for generation
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request_http,
scope="ai.generate_save",
max_requests=40,
window_seconds=300,
)
ensure_website_scope_matches(website_id, request.website_id)
# Verify basis item exists
basis_result = await db.execute(
select(Item).where(Item.id == request.basis_item_id)
@@ -186,6 +215,7 @@ async def generate_save(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Basis item not found: {request.basis_item_id}",
)
ensure_website_scope_matches(website_id, basis_item.website_id)
# Check for duplicate (same tryout, website, slot, level)
existing_result = await db.execute(
@@ -256,10 +286,12 @@ async def generate_save(
)
async def get_stats(
db: Annotated[AsyncSession, Depends(get_db)],
auth: AuthContext = Depends(get_auth_context),
) -> AIStatsResponse:
"""
Get AI generation statistics.
"""
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
stats = await get_ai_stats(db)
return AIStatsResponse(
@@ -276,10 +308,11 @@ async def get_stats(
summary="List supported AI models",
description="Returns list of supported AI models for question generation.",
)
async def list_models() -> dict:
async def list_models(auth: AuthContext = Depends(get_auth_context)) -> dict:
"""
List supported AI models.
"""
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
return {
"models": [
{

View File

@@ -12,12 +12,12 @@ Endpoints:
import os
import tempfile
import json
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.auth import AuthContext, get_auth_context, require_website_auth
from app.core.rate_limit import enforce_rate_limit
from app.database import get_db
from app.models import Website
from app.services.excel_import import (
@@ -35,35 +35,6 @@ from app.services.tryout_json_import import (
router = APIRouter(prefix="/api/v1/import-export", tags=["import-export"])
def get_website_id_from_header(
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
) -> int:
"""
Extract and validate website_id from request header.
Args:
x_website_id: Website ID from header
Returns:
Validated website ID as integer
Raises:
HTTPException: If header is missing or invalid
"""
if x_website_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID header is required",
)
try:
return int(x_website_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID must be a valid integer",
)
async def ensure_website_exists(
website_id: int,
db: AsyncSession,
@@ -85,8 +56,9 @@ async def ensure_website_exists(
description="Parse Excel file and return preview without saving to database.",
)
async def preview_import(
request: Request,
file: UploadFile = File(..., description="Excel file (.xlsx)"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""
Preview Excel import without saving to database.
@@ -104,6 +76,14 @@ async def preview_import(
Raises:
HTTPException: If file format is invalid or parsing fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.preview",
max_requests=30,
window_seconds=300,
)
# Validate file format
if not file.filename or not file.filename.lower().endswith('.xlsx'):
raise HTTPException(
@@ -173,8 +153,9 @@ async def preview_import(
description="Parse Excel file and import questions to database with 100% data integrity.",
)
async def import_questions(
request: Request,
file: UploadFile = File(..., description="Excel file (.xlsx)"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
tryout_id: str = Form(..., description="Tryout identifier"),
db: AsyncSession = Depends(get_db),
) -> dict:
@@ -199,6 +180,14 @@ async def import_questions(
Raises:
HTTPException: If file format is invalid, validation fails, or import fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.questions",
max_requests=20,
window_seconds=300,
)
# Validate file format
if not file.filename or not file.filename.lower().endswith('.xlsx'):
raise HTTPException(
@@ -297,7 +286,7 @@ async def import_questions(
)
async def export_questions(
tryout_id: str,
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
) -> FileResponse:
"""
@@ -320,6 +309,8 @@ async def export_questions(
Raises:
HTTPException: If tryout has no questions or export fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
try:
# Export questions to Excel
output_path = await export_questions_to_excel(
@@ -354,10 +345,18 @@ async def export_questions(
description="Parse a Sejoli tryout export JSON file and show snapshot diff without writing to database.",
)
async def preview_tryout_json(
request: Request,
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
) -> dict:
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.tryout_json_preview",
max_requests=30,
window_seconds=300,
)
if not file.filename or not file.filename.lower().endswith(".json"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -389,10 +388,18 @@ async def preview_tryout_json(
description="Store Sejoli tryout export JSON as read-only snapshot data and upsert normalized reference questions.",
)
async def import_tryout_json(
request: Request,
file: UploadFile = File(..., description="Sejoli tryout export JSON"),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
db: AsyncSession = Depends(get_db),
) -> dict:
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
enforce_rate_limit(
request,
scope="import.tryout_json",
max_requests=20,
window_seconds=300,
)
if not file.filename or not file.filename.lower().endswith(".json"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,

View File

@@ -14,11 +14,17 @@ import os
from datetime import datetime
from typing import List, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import FileResponse
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.schemas.report import (
StudentPerformanceReportOutput,
AggregatePerformanceStatsOutput,
@@ -55,35 +61,6 @@ from app.services.reporting import (
router = APIRouter(prefix="/reports", tags=["reports"])
def get_website_id_from_header(
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
) -> int:
"""
Extract and validate website_id from request header.
Args:
x_website_id: Website ID from header
Returns:
Validated website ID as integer
Raises:
HTTPException: If header is missing or invalid
"""
if x_website_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID header is required",
)
try:
return int(x_website_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID must be a valid integer",
)
# =============================================================================
# Student Performance Report Endpoints
# =============================================================================
@@ -97,7 +74,7 @@ def get_website_id_from_header(
async def get_student_performance_report(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
date_start: Optional[datetime] = None,
date_end: Optional[datetime] = None,
format_type: Literal["individual", "aggregate", "both"] = "both",
@@ -107,6 +84,7 @@ async def get_student_performance_report(
Returns individual student records and/or aggregate statistics.
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
date_range = None
if date_start or date_end:
date_range = {}
@@ -190,7 +168,7 @@ def _convert_student_performance_report(report: StudentPerformanceReport) -> Stu
async def get_item_analysis_report(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
) -> ItemAnalysisReportOutput:
@@ -199,6 +177,7 @@ async def get_item_analysis_report(
Returns item difficulty, discrimination, and information function data.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_item_analysis_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -248,13 +227,14 @@ async def get_item_analysis_report(
async def get_calibration_status_report(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> CalibrationStatusReportOutput:
"""
Get calibration status report.
Returns calibration progress, items awaiting calibration, and IRT readiness status.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_calibration_status_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -313,7 +293,7 @@ async def get_calibration_status_report(
async def get_tryout_comparison_report(
tryout_ids: str, # Comma-separated list
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
group_by: Literal["date", "subject"] = "date",
) -> TryoutComparisonReportOutput:
"""
@@ -321,6 +301,7 @@ async def get_tryout_comparison_report(
Compares tryouts across dates or subjects.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
if len(tryout_id_list) < 2:
@@ -371,12 +352,15 @@ async def get_tryout_comparison_report(
async def create_report_schedule(
request: ReportScheduleRequest,
db: AsyncSession = Depends(get_db),
auth: AuthContext = Depends(get_auth_context),
) -> ReportScheduleResponse:
"""
Schedule a report.
Creates a scheduled report that will be generated automatically.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
ensure_website_scope_matches(website_id, request.website_id)
schedule_id = schedule_report(
report_type=request.report_type,
schedule=request.schedule,
@@ -403,13 +387,14 @@ async def create_report_schedule(
)
async def get_scheduled_report_details(
schedule_id: str,
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> ReportScheduleOutput:
"""
Get scheduled report details.
Returns the configuration and status of a scheduled report.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
scheduled = get_scheduled_report(schedule_id)
if not scheduled:
@@ -446,13 +431,14 @@ async def get_scheduled_report_details(
description="List all scheduled reports for a website.",
)
async def list_scheduled_reports_endpoint(
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> List[ReportScheduleOutput]:
"""
List all scheduled reports.
Returns all scheduled reports for the current website.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
reports = list_scheduled_reports(website_id=website_id)
return [
@@ -480,13 +466,14 @@ async def list_scheduled_reports_endpoint(
)
async def cancel_scheduled_report_endpoint(
schedule_id: str,
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> dict:
"""
Cancel a scheduled report.
Removes the scheduled report from the system.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
scheduled = get_scheduled_report(schedule_id)
if not scheduled:
@@ -528,13 +515,14 @@ async def export_scheduled_report(
schedule_id: str,
format: Literal["csv", "xlsx", "pdf"],
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Export a scheduled report.
Generates the report and returns it as a file download.
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
scheduled = get_scheduled_report(schedule_id)
if not scheduled:
@@ -628,11 +616,12 @@ async def export_student_performance_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
date_start: Optional[datetime] = None,
date_end: Optional[datetime] = None,
):
"""Export student performance report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
date_range = None
if date_start or date_end:
date_range = {}
@@ -676,11 +665,12 @@ async def export_item_analysis_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
filter_by: Optional[Literal["difficulty", "calibrated", "discrimination"]] = None,
difficulty_level: Optional[Literal["mudah", "sedang", "sulit"]] = None,
):
"""Export item analysis report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_item_analysis_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -717,9 +707,10 @@ async def export_calibration_status_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""Export calibration status report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
report = await generate_calibration_status_report(
tryout_id=tryout_id,
website_id=website_id,
@@ -754,10 +745,11 @@ async def export_tryout_comparison_direct(
format: Literal["csv", "xlsx", "pdf"],
tryout_ids: str, # Comma-separated
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
group_by: Literal["date", "subject"] = "date",
):
"""Export tryout comparison report directly."""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
tryout_id_list = [tid.strip() for tid in tryout_ids.split(",")]
if len(tryout_id_list) < 2:

View File

@@ -8,14 +8,18 @@ Endpoints:
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
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.item import Item
from app.models.session import Session
from app.models.tryout import Tryout
@@ -39,35 +43,6 @@ from app.services.ctt_scoring import (
router = APIRouter(prefix="/session", tags=["sessions"])
def get_website_id_from_header(
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
) -> int:
"""
Extract and validate website_id from request header.
Args:
x_website_id: Website ID from header
Returns:
Validated website ID as integer
Raises:
HTTPException: If header is missing or invalid
"""
if x_website_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID header is required",
)
try:
return int(x_website_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID must be a valid integer",
)
@router.post(
"/{session_id}/complete",
response_model=SessionCompleteResponse,
@@ -78,7 +53,7 @@ async def complete_session(
session_id: str,
request: SessionCompleteRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionCompleteResponse:
"""
Complete a session by submitting answers and calculating CTT scores.
@@ -104,6 +79,8 @@ async def complete_session(
Raises:
HTTPException: If session not found, already completed, or validation fails
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get session with tryout relationship
result = await db.execute(
select(Session)
@@ -126,6 +103,11 @@ async def complete_session(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Session is already completed",
)
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",
)
# Get tryout configuration
tryout = session.tryout
@@ -298,7 +280,7 @@ async def complete_session(
async def get_session(
session_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionResponse:
"""
Get session details.
@@ -314,6 +296,8 @@ async def get_session(
Raises:
HTTPException: If session not found
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
result = await db.execute(
select(Session).where(
Session.session_id == session_id,
@@ -327,6 +311,11 @@ async def get_session(
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",
)
return SessionResponse.model_validate(session)
@@ -341,7 +330,7 @@ async def get_session(
async def create_session(
request: SessionCreateRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> SessionResponse:
"""
Create a new session.
@@ -356,13 +345,13 @@ async def create_session(
Raises:
HTTPException: If tryout not found or session already exists
"""
if request.website_id != website_id:
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
ensure_website_scope_matches(website_id, request.website_id)
if auth.role == "student" and request.wp_user_id != auth.wp_user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"Website mismatch between payload and X-Website-ID header: "
f"{request.website_id} != {website_id}"
),
status_code=status.HTTP_403_FORBIDDEN,
detail="wp_user_id must match authenticated user",
)
# Verify tryout exists

View File

@@ -7,14 +7,15 @@ Endpoints:
- GET /tryout: List tryouts for a website
"""
from typing import List, Optional
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import Integer, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database import get_db
from app.core.auth import AuthContext, get_auth_context, require_website_auth
from app.models.item import Item
from app.models.tryout import Tryout
from app.models.tryout_stats import TryoutStats
@@ -29,35 +30,6 @@ from app.schemas.tryout import (
router = APIRouter(prefix="/tryout", tags=["tryouts"])
def get_website_id_from_header(
x_website_id: Optional[str] = Header(None, alias="X-Website-ID"),
) -> int:
"""
Extract and validate website_id from request header.
Args:
x_website_id: Website ID from header
Returns:
Validated website ID as integer
Raises:
HTTPException: If header is missing or invalid
"""
if x_website_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID header is required",
)
try:
return int(x_website_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="X-Website-ID must be a valid integer",
)
@router.get(
"/{tryout_id}/config",
response_model=TryoutConfigResponse,
@@ -67,7 +39,7 @@ def get_website_id_from_header(
async def get_tryout_config(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> TryoutConfigResponse:
"""
Get tryout configuration.
@@ -78,6 +50,8 @@ async def get_tryout_config(
Raises:
HTTPException: If tryout not found
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get tryout with stats
result = await db.execute(
select(Tryout)
@@ -140,7 +114,7 @@ async def update_normalization(
tryout_id: str,
request: NormalizationUpdateRequest,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> NormalizationUpdateResponse:
"""
Update normalization settings for a tryout.
@@ -157,6 +131,8 @@ async def update_normalization(
Raises:
HTTPException: If tryout not found or validation fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
# Get tryout
result = await db.execute(
select(Tryout).where(
@@ -214,7 +190,7 @@ async def update_normalization(
)
async def list_tryouts(
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
) -> List[TryoutConfigBrief]:
"""
List all tryouts for a website.
@@ -226,6 +202,8 @@ async def list_tryouts(
Returns:
List of TryoutConfigBrief
"""
website_id = require_website_auth(auth, allowed_roles={"student", "admin", "system_admin"})
# Get tryouts with stats
result = await db.execute(
select(Tryout)
@@ -255,7 +233,7 @@ async def list_tryouts(
async def get_calibration_status(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Get calibration status for items in a tryout.
@@ -273,6 +251,8 @@ async def get_calibration_status(
Raises:
HTTPException: If tryout not found
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
# Verify tryout exists
tryout_result = await db.execute(
select(Tryout).where(
@@ -324,7 +304,7 @@ async def get_calibration_status(
async def trigger_calibration(
tryout_id: str,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Trigger IRT calibration for all items in a tryout.
@@ -343,6 +323,8 @@ async def trigger_calibration(
Raises:
HTTPException: If tryout not found or calibration fails
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
from app.services.irt_calibration import (
calibrate_all,
CALIBRATION_SAMPLE_THRESHOLD,
@@ -391,7 +373,7 @@ async def trigger_item_calibration(
tryout_id: str,
item_id: int,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
auth: AuthContext = Depends(get_auth_context),
):
"""
Trigger IRT calibration for a single item.
@@ -408,6 +390,8 @@ async def trigger_item_calibration(
Raises:
HTTPException: If tryout or item not found
"""
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
from app.services.irt_calibration import calibrate_item, CALIBRATION_SAMPLE_THRESHOLD
# Verify tryout exists

View File

@@ -10,11 +10,12 @@ Endpoints:
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, status
from fastapi import APIRouter, Depends, HTTPException, Header, Request, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.core.auth import issue_access_token
from app.models.user import User
from app.models.website import Website
from app.schemas.wordpress import (
@@ -36,6 +37,7 @@ from app.services.wordpress_auth import (
WordPressTokenInvalidError,
WebsiteNotFoundError,
)
from app.core.rate_limit import enforce_rate_limit
logger = logging.getLogger(__name__)
@@ -104,6 +106,7 @@ async def get_valid_website(
description="Fetch all users from WordPress API and sync to local database. Requires admin WordPress token.",
)
async def sync_users_endpoint(
request: Request,
db: AsyncSession = Depends(get_db),
website_id: int = Depends(get_website_id_from_header),
authorization: Optional[str] = Header(None, alias="Authorization"),
@@ -129,6 +132,13 @@ async def sync_users_endpoint(
Raises:
HTTPException: If website not found, token invalid, or API error
"""
enforce_rate_limit(
request,
scope="wordpress.sync_users",
max_requests=20,
window_seconds=300,
)
# Validate website exists
await get_valid_website(website_id, db)
@@ -196,6 +206,7 @@ async def sync_users_endpoint(
description="Verify WordPress JWT token and user identity.",
)
async def verify_session_endpoint(
http_request: Request,
request: VerifySessionRequest,
db: AsyncSession = Depends(get_db),
) -> VerifySessionResponse:
@@ -219,6 +230,13 @@ async def verify_session_endpoint(
Raises:
HTTPException: If website not found or API error
"""
enforce_rate_limit(
http_request,
scope="wordpress.verify_session",
max_requests=60,
window_seconds=300,
)
# Validate website exists
await get_valid_website(request.website_id, db)
@@ -253,6 +271,12 @@ async def verify_session_endpoint(
"display_name": wp_user_info.display_name,
"roles": wp_user_info.roles,
},
access_token=issue_access_token(
website_id=request.website_id,
role="student",
wp_user_id=request.wp_user_id,
expires_in_seconds=3600 * 24,
),
)
except WordPressTokenInvalidError as e:

View File

@@ -41,6 +41,10 @@ class VerifySessionResponse(BaseModel):
wp_user_info: Optional[dict[str, Any]] = Field(
default=None, description="WordPress user info from API"
)
access_token: Optional[str] = Field(
default=None,
description="Signed API access token for authenticated website-scoped calls",
)
class SyncUsersRequest(BaseModel):

137
hands-off.md Normal file
View File

@@ -0,0 +1,137 @@
# Yellow Bank Soal Perfection Tasklist
Date: 2026-04-29
Purpose: hands-off development guide for hardening the system, improving correctness, and polishing the admin/user experience.
## 1. Security and Auth
- [x] Add centralized authentication dependencies for student, website admin, and system admin roles.
- [x] Replace raw `X-Website-ID` trust with token-derived website access.
- [x] Require authorization on reports, tryout configuration updates, imports, calibration, and session endpoints.
- [x] Add session ownership checks using verified WordPress identity.
- [x] Add rate limiting for admin login, AI generation, imports, and WordPress verification.
- [x] Add admin login rate limiting (IP-based, Redis-backed attempt window).
- [x] Add CSRF tokens to all admin POST forms.
- [x] Mark admin session cookies `secure` in production.
- [x] Fail production startup when default or empty secrets are used.
- [x] Add tests proving cross-website access is blocked.
- [x] Add token integrity tests (issue/decode, tamper rejection, expiry rejection).
## 2. Session Integrity
- [ ] Verify `submit_answer` item belongs to the session's `website_id` and `tryout_id`.
- [ ] Prevent answer submission for items not issued by `next_item`.
- [ ] Stop returning `correct_answer` during live adaptive sessions.
- [ ] Decide whether explanations should be shown only after completion or never during an active session.
- [ ] Add duplicate-answer validation before DB commit.
- [ ] Make repeated submissions return `409 Conflict` instead of DB errors.
- [ ] Validate or auto-create WordPress users before creating sessions.
- [ ] Add tests for invalid item IDs, foreign-tryout items, repeated answers, and completed sessions.
## 3. Scoring Correctness
- [ ] Revisit CTT `total_bobot_max` logic so earned and max weights use the same item set.
- [ ] Define scoring behavior for mixed-level tryouts.
- [ ] Confirm whether fixed tryouts should require every item to be answered before completion.
- [ ] Add tests for all-correct, all-wrong, partial, mixed-level, missing-bobot, and duplicate-answer cases.
- [ ] Add regression tests for static, dynamic, and hybrid normalization switching.
- [ ] Confirm NM, NN, theta, and report formulas against PRD examples.
- [ ] Add explicit handling for zero/near-zero standard deviation in reporting and normalization.
## 4. Database and Migrations
- [ ] Resolve model/migration drift for item uniqueness indexes.
- [ ] Decide whether items are unique by `(website_id, tryout_id, slot)` or `(website_id, tryout_id, slot, level)`.
- [ ] Align Excel import duplicate detection with the final uniqueness rule.
- [ ] Remove production `create_all` startup behavior or gate it to development only.
- [ ] Add migration smoke tests for fresh database upgrade to head.
- [ ] Add DB constraint tests for FK failures and uniqueness conflicts.
- [ ] Create seed/dev fixtures for websites, users, tryouts, items, and sessions.
- [ ] Document migration rollback expectations.
## 5. API Reliability
- [ ] Standardize error response shape across routers.
- [ ] Convert expected DB constraint failures into clear `400`, `404`, or `409` responses.
- [ ] Add request size limits for Excel and JSON imports.
- [ ] Add structured logging with request IDs.
- [ ] Add health checks that distinguish DB, Redis, WordPress, and OpenRouter status.
- [ ] Add OpenAPI examples for core workflows.
- [ ] Add pagination to list/report endpoints that can grow large.
- [ ] Add timeout and retry policy for external service calls.
## 6. Import and Export
- [ ] Validate website existence before Excel preview and import.
- [ ] Validate tryout existence before Excel question import.
- [ ] Add downloadable validation error reports.
- [ ] Add import preview diff for new records, skipped duplicates, and updates.
- [ ] Clean up generated export temp files after response lifecycle.
- [ ] Add tests for malformed Excel, duplicate slots, invalid p-values, invalid bobot values, and missing tryout.
- [x] Add tests for JSON snapshot import edge cases.
- [ ] Add file size/type hardening beyond extension checks.
## 7. Reporting
- [ ] Persist report schedules in the database instead of process memory.
- [ ] Add real scheduler/worker execution for scheduled reports.
- [ ] Add email delivery or remove recipient fields until delivery is implemented.
- [ ] Add report permission checks.
- [ ] Add tests for empty reports, partial data, and multi-tryout comparisons.
- [ ] Add pagination/export limits for large report datasets.
- [ ] Verify `avg_nn`, pass rate, medians, and standard deviations against fixture data.
- [ ] Add user-facing messages when report data is incomplete.
## 8. Admin UI and UX
- [ ] Add responsive mobile/tablet layout.
- [ ] Add active navigation state and breadcrumbs.
- [ ] Add pagination, sorting, and search to admin tables.
- [ ] Replace destructive browser confirms with safer confirmation modals.
- [ ] Add inline validation and success/error banners that persist after redirects.
- [ ] Add import progress indicators and clearer preview screens.
- [ ] Add empty states with recommended next actions.
- [ ] Improve visual hierarchy for dashboard stats and high-risk actions.
- [ ] Add accessibility pass: labels, focus states, contrast, keyboard navigation.
## 9. Testing and Tooling
- [ ] Add `pyproject.toml` or `pytest.ini` with test config.
- [ ] Add pinned dependency lock workflow.
- [ ] Add `make test`, `make lint`, `make migrate`, and `make dev` commands.
- [ ] Add CI for lint, tests, mapper config, Alembic upgrade, and import smoke tests.
- [ ] Add integration tests using a test database.
- [ ] Add auth boundary tests for every tenant-scoped endpoint.
- [x] Add regression tests for previously found defects.
- [ ] Document the canonical local setup path.
## 10. Production Readiness
- [ ] Validate required secrets in production startup.
- [ ] Document deployment environment variables.
- [ ] Add backup and restore guidance for PostgreSQL.
- [ ] Add observability: logs, metrics, traces, and error monitoring.
- [ ] Add operational runbooks for import failures, calibration failures, WordPress API outages, and AI provider outages.
- [ ] Add Redis availability checks when admin or background jobs are enabled.
- [ ] Add deployment checklist for migrations, admin credentials, CORS, HTTPS, and rollback.
## Suggested Execution Order
1. Security and auth hardening.
2. Session integrity and scoring correctness.
3. Database/migration alignment.
4. Test and tooling foundation.
5. Import/export and reporting reliability.
6. Admin UI/UX polish.
7. Production readiness and operations.
## Definition of Perfect Enough
- [ ] Every tenant-scoped endpoint has an authorization test.
- [ ] Every scoring path has deterministic fixture tests.
- [ ] Fresh database migration to head succeeds in CI.
- [ ] Admin destructive actions are CSRF-protected.
- [ ] Live sessions cannot reveal answers before completion.
- [ ] Imports fail safely with actionable validation output.
- [ ] Reports are reproducible, permissioned, and persisted where scheduled.
- [ ] The app can be installed, tested, migrated, and run from documented commands.

32
tests/test_auth_scope.py Normal file
View File

@@ -0,0 +1,32 @@
from pathlib import Path
import sys
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.auth import ( # noqa: E402
AuthContext,
ensure_website_scope_matches,
require_website_auth,
)
def test_require_website_auth_returns_scoped_website_for_allowed_role():
auth = AuthContext(website_id=5, role="admin", wp_user_id=None)
website_id = require_website_auth(auth, allowed_roles={"admin", "system_admin"})
assert website_id == 5
def test_require_website_auth_rejects_disallowed_role():
auth = AuthContext(website_id=5, role="student", wp_user_id="u1")
with pytest.raises(HTTPException) as exc_info:
require_website_auth(auth, allowed_roles={"admin", "system_admin"})
assert exc_info.value.status_code == 403
def test_cross_website_payload_mismatch_is_blocked():
with pytest.raises(HTTPException) as exc_info:
ensure_website_scope_matches(auth_website_id=10, payload_website_id=11)
assert exc_info.value.status_code == 403

50
tests/test_auth_tokens.py Normal file
View File

@@ -0,0 +1,50 @@
from pathlib import Path
import sys
import time
import pytest
from fastapi import HTTPException
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.auth import decode_access_token, issue_access_token # noqa: E402
def test_issue_and_decode_access_token_round_trip():
token = issue_access_token(
website_id=42,
role="student",
wp_user_id="wp-1001",
expires_in_seconds=3600,
)
auth = decode_access_token(token)
assert auth.website_id == 42
assert auth.role == "student"
assert auth.wp_user_id == "wp-1001"
def test_decode_access_token_rejects_tampered_signature():
token = issue_access_token(
website_id=7,
role="admin",
wp_user_id=None,
expires_in_seconds=3600,
)
payload, signature = token.split(".", 1)
tampered_token = f"{payload}.{'A' if signature[0] != 'A' else 'B'}{signature[1:]}"
with pytest.raises(HTTPException) as exc_info:
decode_access_token(tampered_token)
assert exc_info.value.status_code == 401
def test_decode_access_token_rejects_expired_token():
token = issue_access_token(
website_id=9,
role="student",
wp_user_id="u-1",
expires_in_seconds=-1,
)
time.sleep(0.01)
with pytest.raises(HTTPException) as exc_info:
decode_access_token(token)
assert exc_info.value.status_code == 401