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

@@ -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": [
{