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

@@ -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,