feat(api): production security, cors hardening, latency optimizations, postman artifacts & frontend documentation

This commit is contained in:
2026-08-31 13:48:54 +05:30
parent 4e21d617ce
commit e91cdbae6e
48 changed files with 3742 additions and 65 deletions
+29 -2
View File
@@ -7,6 +7,9 @@ from app.database.database import get_db
from app.schemas.documents import DocumentExtractionResponse
from app.services.onboarding_service import OnboardingService
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from app.core.security import get_auth_context, AuthContext, check_rate_limit
router = APIRouter(
prefix="/v1/documents",
tags=["Medical Documents"],
@@ -20,8 +23,13 @@ router = APIRouter(
async def upload_medical_document(
session_id: str,
file: UploadFile = File(...),
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=10, window_sec=60)
onboarding_service = OnboardingService()
gemini_client = GeminiClient()
@@ -29,9 +37,28 @@ async def upload_medical_document(
if not session:
raise HTTPException(status_code=404, detail="Onboarding session not found")
state = onboarding_service.get_state(db, session_id)
file_bytes = await file.read()
# 1b. Verify Session Ownership (IDOR Prevention)
if session.patient_id != auth.patient_id and auth.role != "admin":
raise HTTPException(
status_code=403,
detail="Forbidden: You do not have authorization to upload documents to this session.",
)
# Enforce MIME type and File Size Limits (10MB MAX)
allowed_mimes = ["image/jpeg", "image/png", "image/webp", "application/pdf"]
mime_type = file.content_type or "application/pdf"
if mime_type not in allowed_mimes:
raise HTTPException(
status_code=400,
detail=f"Unsupported document format: {mime_type}. Allowed formats: JPEG, PNG, WEBP, PDF.",
)
file_bytes = await file.read()
if len(file_bytes) > 10 * 1024 * 1024:
raise HTTPException(
status_code=413,
detail="File size exceeds maximum allowed limit of 10MB.",
)
patient_context = {
"id": session.patient_id,
+78 -9
View File
@@ -1,4 +1,5 @@
import json
import time
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException
@@ -36,6 +37,10 @@ from app.services.result_extraction_service import (
from app.services.voice_service import VoiceService
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.core.security import get_auth_context, AuthContext, check_rate_limit
router = APIRouter(
prefix="/v1/onboarding",
tags=["Patient Onboarding"],
@@ -48,14 +53,22 @@ router = APIRouter(
)
async def start_onboarding(
request: StartOnboardingRequest,
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=30, window_sec=60)
onboarding_service = OnboardingService()
session_id = f"ONB-{uuid4().hex[:8].upper()}"
# Use authenticated patient identity
patient_id = request.patient.id or auth.patient_id
session = OnboardingSession(
session_id=session_id,
patient_id=request.patient.id,
patient_id=patient_id,
patient_name=request.patient.name,
age=request.patient.age,
gender=request.patient.gender,
@@ -108,16 +121,24 @@ async def start_onboarding(
async def send_message(
session_id: str,
request: SendMessageRequest,
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=30, window_sec=60)
t0 = time.perf_counter()
onboarding_service = OnboardingService()
ai_service = AIOnboardingService()
# 1. Get session
t_sess_start = time.perf_counter()
session = onboarding_service.get_session(
db,
session_id,
)
t_sess_end = time.perf_counter()
if not session:
raise HTTPException(
@@ -125,6 +146,13 @@ async def send_message(
detail="Onboarding session not found",
)
# 1b. Verify Session Ownership (IDOR Prevention)
if session.patient_id != auth.patient_id and auth.role != "admin":
raise HTTPException(
status_code=403,
detail="Forbidden: You do not have authorization to access or modify this patient onboarding session.",
)
# 2. Check session
if session.status != "in_progress":
raise HTTPException(
@@ -142,12 +170,15 @@ async def send_message(
)
# 4. Get state
t_state_start = time.perf_counter()
state = onboarding_service.get_state(
db,
session_id,
)
t_state_end = time.perf_counter()
# 5. Get conversation history
t_hist_start = time.perf_counter()
messages = (
db.query(OnboardingMessage)
.filter(
@@ -167,14 +198,17 @@ async def send_message(
}
for message in messages
]
t_hist_end = time.perf_counter()
# 6. Send to AI
t_ai_start = time.perf_counter()
ai_response = await ai_service.process_message(
session=session,
state=state,
conversation_history=conversation_history,
user_message=request.message,
)
t_ai_end = time.perf_counter()
# 7. Update state
current_state = json.loads(
@@ -205,7 +239,9 @@ async def send_message(
if ai_response.get("completed"):
session.status = "completed"
t_db_start = time.perf_counter()
db.commit()
t_db_end = time.perf_counter()
# 10. Save AI message
onboarding_service.save_message(
@@ -216,12 +252,20 @@ async def send_message(
stage=session.stage,
)
t_total_end = time.perf_counter()
total_ms = round((t_total_end - t0) * 1000, 2)
total_sec = round(t_total_end - t0, 3)
print(f"[PERF PROFILE - send_message] SESSION_LOOKUP: {(t_sess_end - t_sess_start)*1000:.2f}ms | STATE_LOAD: {(t_state_end - t_state_start)*1000:.2f}ms | HIST_LOAD: {(t_hist_end - t_hist_start)*1000:.2f}ms | AI_PROCESS: {(t_ai_end - t_ai_start)*1000:.2f}ms | DB_COMMIT: {(t_db_end - t_db_start)*1000:.2f}ms | TOTAL: {total_ms}ms")
return SendMessageResponse(
success=True,
session_id=session_id,
stage=session.stage,
message=ai_response["reply"],
status=session.status,
response_time_ms=total_ms,
response_time_sec=total_sec,
)
@@ -232,8 +276,10 @@ async def send_message(
async def complete_onboarding(
session_id: str,
request: CompleteOnboardingRequest,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
start_time = time.time()
onboarding_service = OnboardingService()
completion_service = CompletionService()
result_service = ResultExtractionService()
@@ -251,6 +297,13 @@ async def complete_onboarding(
detail="Onboarding session not found",
)
# 1b. Verify Session Ownership (IDOR Prevention)
if session.patient_id != auth.patient_id and auth.role != "admin":
raise HTTPException(
status_code=403,
detail="Forbidden: You do not have authorization to complete this onboarding session.",
)
# 2. Load state
state = onboarding_service.get_state(
db,
@@ -297,19 +350,35 @@ async def complete_onboarding(
# 7. Patient confirmed -> submit to existing patient / hospital API
submission = await submission_service.submit(result)
if submission.get("success"):
# 8. Successful submission -> clean up temporary SQLite session
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
db.delete(session)
db.commit()
if isinstance(result, dict):
result["onboarding_completed"] = True
if session.status == "completed":
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="submitted_and_cleaned",
status="completed",
result=result,
message="Onboarding successfully completed, saved to patient record, and temporary session cleaned.",
message="Onboarding session is already completed.",
response_time_ms=round((time.time() - start_time) * 1000, 2),
response_time_sec=round(time.time() - start_time, 3),
)
if submission.get("success"):
# Mark session completed in DB instead of hard-deleting
session.status = "completed"
session.stage = "confirmation"
db.commit()
elapsed = time.time() - start_time
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
result=result,
message="Onboarding successfully completed and saved to patient record.",
response_time_ms=round(elapsed * 1000, 2),
response_time_sec=round(elapsed, 3),
)
# If submission failed, preserve SQLite session for retry
@@ -5,7 +5,7 @@ import time
from uuid import uuid4
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
@@ -32,6 +32,8 @@ from app.services.patient_submission_service import PatientSubmissionService
from app.services.result_extraction_service import ResultExtractionService
from app.services.voice_service import VoiceService
from app.core.security import get_auth_context, AuthContext, check_rate_limit
router = APIRouter(
prefix="/v1/voice-onboarding",
tags=["Voice Onboarding"],
@@ -44,8 +46,12 @@ router = APIRouter(
)
async def start_voice_onboarding(
request: StartVoiceOnboardingRequest,
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=15, window_sec=60)
start_time = time.time()
try:
onboarding_service = OnboardingService()
@@ -60,9 +66,11 @@ async def start_voice_onboarding(
name="General Medicine / Primary Care (Auto-Triage)",
)
patient_id = request.patient.id or auth.patient_id
session = OnboardingSession(
session_id=session_id,
patient_id=request.patient.id,
patient_id=patient_id,
patient_name=request.patient.name,
age=request.patient.age,
gender=request.patient.gender,
@@ -131,8 +139,12 @@ async def voice_turn(
session_id: str,
audio: Optional[UploadFile] = File(None),
text_fallback: Optional[str] = Form(None),
req_obj: Request = None,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
if req_obj and req_obj.client:
check_rate_limit(req_obj.client.host, limit=20, window_sec=60)
start_time = time.time()
onboarding_service = OnboardingService()
voice_service = VoiceService()
@@ -146,10 +158,21 @@ async def voice_turn(
detail="Voice onboarding session not found",
)
if session.status != "in_progress":
# 1b. IDOR Session Ownership Check
if session.patient_id != auth.patient_id and auth.role != "admin":
raise HTTPException(
status_code=403,
detail="Forbidden: You do not have authorization to access or modify this voice session.",
)
if session.status == "completed":
session.status = "in_progress"
db.commit()
if session.status not in ["in_progress", "completed"]:
raise HTTPException(
status_code=400,
detail="Voice onboarding session is not active",
detail="Voice onboarding session is closed or submitted.",
)
# 2. Fast-path transcript: use real-time Web Speech text_fallback first for zero-latency processing
@@ -244,6 +267,11 @@ async def voice_turn(
# 6. Update Structured Clinical State
current_state = json.loads(state.state_json)
updates = ai_response.get("state_updates", {})
if hasattr(updates, "model_dump"):
updates = updates.model_dump()
elif not isinstance(updates, dict):
updates = {}
for k, v in updates.items():
if v is not None:
if isinstance(v, list) and len(v) == 0:
@@ -348,6 +376,7 @@ async def get_audio(audio_id: str):
async def complete_voice_onboarding(
session_id: str,
request: CompleteOnboardingRequest,
auth: AuthContext = Depends(get_auth_context),
db: Session = Depends(get_db),
):
start_time = time.time()
@@ -363,6 +392,13 @@ async def complete_voice_onboarding(
detail="Voice onboarding session not found",
)
# 1b. Verify Session Ownership (IDOR Prevention)
if session.patient_id != auth.patient_id and auth.role != "admin":
raise HTTPException(
status_code=403,
detail="Forbidden: You do not have authorization to complete this voice session.",
)
state = onboarding_service.get_state(db, session_id)
# Check if there is any conversation to summarize
@@ -389,6 +425,20 @@ async def complete_voice_onboarding(
resp_ms = round(elapsed * 1000, 2)
resp_sec = round(elapsed, 3)
if isinstance(result, dict):
result["onboarding_completed"] = True
if session.status == "completed":
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="completed",
result=result,
message="Voice onboarding session is already completed.",
response_time_ms=resp_ms,
response_time_sec=resp_sec,
)
if not request.confirmed:
return CompleteOnboardingResponse(
success=True,
@@ -403,15 +453,14 @@ async def complete_voice_onboarding(
submission = await submission_service.submit(result)
if submission.get("success"):
db.query(OnboardingMessage).filter(OnboardingMessage.session_id == session_id).delete()
db.query(OnboardingState).filter(OnboardingState.session_id == session_id).delete()
db.delete(session)
session.status = "completed"
session.stage = "confirmation"
db.commit()
return CompleteOnboardingResponse(
success=True,
session_id=session_id,
status="submitted_and_cleaned",
status="completed",
result=result,
message="Voice onboarding successfully saved to patient record.",
response_time_ms=resp_ms,