Complete Section 1 security/auth hardening
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user