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