Spaces:
Sleeping
Sleeping
Phase 1: harden rate limits — JWT-aware keys, per-endpoint caps, daily limits on all job types
Browse files- bioai-platform/backend/app/deps.py +31 -1
- bioai-platform/backend/app/routers/docking.py +6 -3
- bioai-platform/backend/app/routers/pipeline_v2.py +6 -3
- bioai-platform/backend/app/routers/pipelines.py +7 -4
- bioai-platform/backend/app/routers/sequencing.py +5 -3
- bioai-platform/backend/app/services/rate_limit.py +55 -11
bioai-platform/backend/app/deps.py
CHANGED
|
@@ -1,4 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from slowapi import Limiter
|
| 2 |
from slowapi.util import get_remote_address
|
| 3 |
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
|
| 5 |
+
from fastapi import Request
|
| 6 |
from slowapi import Limiter
|
| 7 |
from slowapi.util import get_remote_address
|
| 8 |
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _rate_limit_key(request: Request) -> str:
|
| 13 |
+
"""Use user ID from JWT for authenticated requests, fall back to IP."""
|
| 14 |
+
auth = request.headers.get("Authorization", "")
|
| 15 |
+
if auth.startswith("Bearer "):
|
| 16 |
+
try:
|
| 17 |
+
token = auth[7:]
|
| 18 |
+
parts = token.split(".")
|
| 19 |
+
if len(parts) == 3:
|
| 20 |
+
payload = parts[1]
|
| 21 |
+
padding = 4 - len(payload) % 4
|
| 22 |
+
if padding != 4:
|
| 23 |
+
payload += "=" * padding
|
| 24 |
+
decoded = base64.urlsafe_b64decode(payload)
|
| 25 |
+
claims = json.loads(decoded)
|
| 26 |
+
uid = claims.get("sub")
|
| 27 |
+
if uid:
|
| 28 |
+
return f"user:{uid}"
|
| 29 |
+
except Exception:
|
| 30 |
+
pass
|
| 31 |
+
return get_remote_address(request)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
limiter = Limiter(key_func=_rate_limit_key, default_limits=["60/minute"])
|
bioai-platform/backend/app/routers/docking.py
CHANGED
|
@@ -3,13 +3,15 @@ from __future__ import annotations
|
|
| 3 |
import json
|
| 4 |
import math
|
| 5 |
import re
|
| 6 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
from typing import Any, Optional
|
| 9 |
|
| 10 |
from app.services.supabase import get_client
|
| 11 |
from app.services.auth import require_user_id
|
| 12 |
from app.services.ssrf import validate_url
|
|
|
|
|
|
|
| 13 |
|
| 14 |
router = APIRouter(prefix="/api/docking", tags=["Docking"])
|
| 15 |
_TABLE = "docking_jobs"
|
|
@@ -663,8 +665,9 @@ def _summarize_pose_interactions(protein_pdb: str, output_pdbqt: str) -> list[di
|
|
| 663 |
# API endpoints
|
| 664 |
# ---------------------------------------------------------------------------
|
| 665 |
|
| 666 |
-
@router.post("/run", response_model=DockingJobResponse)
|
| 667 |
-
|
|
|
|
| 668 |
supabase = get_client()
|
| 669 |
_prune_old(supabase)
|
| 670 |
|
|
|
|
| 3 |
import json
|
| 4 |
import math
|
| 5 |
import re
|
| 6 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 7 |
from pydantic import BaseModel, Field
|
| 8 |
from typing import Any, Optional
|
| 9 |
|
| 10 |
from app.services.supabase import get_client
|
| 11 |
from app.services.auth import require_user_id
|
| 12 |
from app.services.ssrf import validate_url
|
| 13 |
+
from app.services.rate_limit import check_daily_limit_docking
|
| 14 |
+
from app.deps import limiter
|
| 15 |
|
| 16 |
router = APIRouter(prefix="/api/docking", tags=["Docking"])
|
| 17 |
_TABLE = "docking_jobs"
|
|
|
|
| 665 |
# API endpoints
|
| 666 |
# ---------------------------------------------------------------------------
|
| 667 |
|
| 668 |
+
@router.post("/run", response_model=DockingJobResponse, dependencies=[Depends(check_daily_limit_docking)])
|
| 669 |
+
@limiter.limit("5/minute")
|
| 670 |
+
async def create_docking_job(request: Request, body: DockingJobCreate, user_id: str = Depends(require_user_id)):
|
| 671 |
supabase = get_client()
|
| 672 |
_prune_old(supabase)
|
| 673 |
|
bioai-platform/backend/app/routers/pipeline_v2.py
CHANGED
|
@@ -10,11 +10,13 @@ import uuid
|
|
| 10 |
from datetime import datetime, timezone
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
-
from fastapi import APIRouter, HTTPException
|
| 14 |
from pydantic import BaseModel, Field
|
| 15 |
from litellm import acompletion
|
| 16 |
|
| 17 |
from app.config import settings
|
|
|
|
|
|
|
| 18 |
from app.integrations.ncbi import blast as ncbi_blast
|
| 19 |
from app.integrations.ncbi.parser import parse_blast_xml
|
| 20 |
from app.services.validators import validate_fasta
|
|
@@ -59,8 +61,9 @@ class PipelineV2RunRequest(BaseModel):
|
|
| 59 |
steps: list[str] = Field(default_factory=lambda: list(STEP_ORDER), description="Steps to run")
|
| 60 |
|
| 61 |
|
| 62 |
-
@router.post("/run")
|
| 63 |
-
|
|
|
|
| 64 |
validation = validate_fasta(req.sequence, "blast")
|
| 65 |
if not validation.valid:
|
| 66 |
raise HTTPException(status_code=400, detail=validation.error)
|
|
|
|
| 10 |
from datetime import datetime, timezone
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 14 |
from pydantic import BaseModel, Field
|
| 15 |
from litellm import acompletion
|
| 16 |
|
| 17 |
from app.config import settings
|
| 18 |
+
from app.deps import limiter
|
| 19 |
+
from app.services.rate_limit import check_daily_limit_pipelines
|
| 20 |
from app.integrations.ncbi import blast as ncbi_blast
|
| 21 |
from app.integrations.ncbi.parser import parse_blast_xml
|
| 22 |
from app.services.validators import validate_fasta
|
|
|
|
| 61 |
steps: list[str] = Field(default_factory=lambda: list(STEP_ORDER), description="Steps to run")
|
| 62 |
|
| 63 |
|
| 64 |
+
@router.post("/run", dependencies=[Depends(check_daily_limit_pipelines)])
|
| 65 |
+
@limiter.limit("3/minute")
|
| 66 |
+
async def run_pipeline_v2(request: Request, req: PipelineV2RunRequest):
|
| 67 |
validation = validate_fasta(req.sequence, "blast")
|
| 68 |
if not validation.valid:
|
| 69 |
raise HTTPException(status_code=400, detail=validation.error)
|
bioai-platform/backend/app/routers/pipelines.py
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
from pydantic import BaseModel
|
| 3 |
from app.services.validators import validate_fasta
|
| 4 |
from app.pipeline.definitions.protein_analysis import get_pipeline_definition
|
| 5 |
from app.services.supabase import get_supabase
|
| 6 |
-
from app.services.rate_limit import check_daily_limit
|
| 7 |
from app.services.auth import get_user_id
|
| 8 |
from app.models.responses import PipelineRunResponse, PipelineDefinitionResponse
|
|
|
|
| 9 |
from datetime import datetime, timezone
|
| 10 |
import uuid
|
| 11 |
|
|
|
|
| 12 |
router = APIRouter()
|
| 13 |
|
| 14 |
|
|
@@ -20,8 +22,9 @@ class PipelineRunRequest(BaseModel):
|
|
| 20 |
query_accession: str = ""
|
| 21 |
|
| 22 |
|
| 23 |
-
@router.post("/run", response_model=PipelineRunResponse, dependencies=[Depends(
|
| 24 |
-
|
|
|
|
| 25 |
validation = validate_fasta(req.sequence, "blast")
|
| 26 |
if not validation.valid:
|
| 27 |
raise HTTPException(status_code=400, detail=validation.error)
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 2 |
from pydantic import BaseModel
|
| 3 |
from app.services.validators import validate_fasta
|
| 4 |
from app.pipeline.definitions.protein_analysis import get_pipeline_definition
|
| 5 |
from app.services.supabase import get_supabase
|
| 6 |
+
from app.services.rate_limit import check_daily_limit, check_daily_limit_pipelines
|
| 7 |
from app.services.auth import get_user_id
|
| 8 |
from app.models.responses import PipelineRunResponse, PipelineDefinitionResponse
|
| 9 |
+
from app.deps import limiter
|
| 10 |
from datetime import datetime, timezone
|
| 11 |
import uuid
|
| 12 |
|
| 13 |
+
|
| 14 |
router = APIRouter()
|
| 15 |
|
| 16 |
|
|
|
|
| 22 |
query_accession: str = ""
|
| 23 |
|
| 24 |
|
| 25 |
+
@router.post("/run", response_model=PipelineRunResponse, dependencies=[Depends(check_daily_limit_pipelines)])
|
| 26 |
+
@limiter.limit("3/minute")
|
| 27 |
+
async def run_pipeline(request: Request, req: PipelineRunRequest, user_id: str | None = Depends(get_user_id)):
|
| 28 |
validation = validate_fasta(req.sequence, "blast")
|
| 29 |
if not validation.valid:
|
| 30 |
raise HTTPException(status_code=400, detail=validation.error)
|
bioai-platform/backend/app/routers/sequencing.py
CHANGED
|
@@ -6,13 +6,14 @@ import uuid
|
|
| 6 |
from datetime import datetime, timezone, timedelta
|
| 7 |
from typing import Optional
|
| 8 |
|
| 9 |
-
from fastapi import APIRouter, HTTPException, Depends
|
| 10 |
from pydantic import BaseModel
|
| 11 |
|
| 12 |
from app.deps import limiter
|
| 13 |
from app.services.supabase import get_supabase
|
| 14 |
from app.services.auth import require_user_id
|
| 15 |
from app.services.ssrf import validate_url
|
|
|
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
router = APIRouter(prefix="/api/sequencing", tags=["sequencing"])
|
|
@@ -131,8 +132,9 @@ async def _worker(job_id: str) -> None:
|
|
| 131 |
VALID_DEMO = {"synthetic", "demo", "test"}
|
| 132 |
|
| 133 |
|
| 134 |
-
@router.post("/run")
|
| 135 |
-
|
|
|
|
| 136 |
if not req.fastq_url.strip():
|
| 137 |
raise HTTPException(400, detail="fastq_url is required")
|
| 138 |
if req.fastq_url.lower() not in VALID_DEMO:
|
|
|
|
| 6 |
from datetime import datetime, timezone, timedelta
|
| 7 |
from typing import Optional
|
| 8 |
|
| 9 |
+
from fastapi import APIRouter, HTTPException, Depends, Request
|
| 10 |
from pydantic import BaseModel
|
| 11 |
|
| 12 |
from app.deps import limiter
|
| 13 |
from app.services.supabase import get_supabase
|
| 14 |
from app.services.auth import require_user_id
|
| 15 |
from app.services.ssrf import validate_url
|
| 16 |
+
from app.services.rate_limit import check_daily_limit_sequencing
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
router = APIRouter(prefix="/api/sequencing", tags=["sequencing"])
|
|
|
|
| 132 |
VALID_DEMO = {"synthetic", "demo", "test"}
|
| 133 |
|
| 134 |
|
| 135 |
+
@router.post("/run", dependencies=[Depends(check_daily_limit_sequencing)])
|
| 136 |
+
@limiter.limit("3/minute")
|
| 137 |
+
async def run_sequencing(request: Request, req: SequencingRequest, user_id: str = Depends(require_user_id)):
|
| 138 |
if not req.fastq_url.strip():
|
| 139 |
raise HTTPException(400, detail="fastq_url is required")
|
| 140 |
if req.fastq_url.lower() not in VALID_DEMO:
|
bioai-platform/backend/app/services/rate_limit.py
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import base64
|
| 2 |
import json
|
| 3 |
import logging
|
|
@@ -31,16 +38,12 @@ def _extract_user_id_from_request(request: Request) -> str | None:
|
|
| 31 |
return None
|
| 32 |
|
| 33 |
|
| 34 |
-
async def
|
| 35 |
-
|
| 36 |
-
if not user_id:
|
| 37 |
-
logger.info("Rate limit check skipped: no authenticated user session")
|
| 38 |
-
return
|
| 39 |
-
|
| 40 |
try:
|
| 41 |
today = date.today().isoformat()
|
| 42 |
url = (
|
| 43 |
-
f"{settings.SUPABASE_URL}/rest/v1/
|
| 44 |
f"?user_id=eq.{user_id}"
|
| 45 |
f"&created_at=gte.{today}T00:00:00"
|
| 46 |
f"&select=id"
|
|
@@ -55,21 +58,62 @@ async def check_daily_limit(request: Request) -> None:
|
|
| 55 |
resp.raise_for_status()
|
| 56 |
|
| 57 |
content_range = resp.headers.get("content-range", "*/0")
|
| 58 |
-
|
| 59 |
-
|
| 60 |
except HTTPException:
|
| 61 |
raise
|
| 62 |
except Exception as e:
|
| 63 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
return
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
if total >= settings.DAILY_LIMIT:
|
| 67 |
raise HTTPException(
|
| 68 |
status_code=429,
|
| 69 |
detail={
|
| 70 |
"error": "daily_limit_exceeded",
|
| 71 |
-
"message": f"You've used all {settings.DAILY_LIMIT} daily analyses. Resets at midnight
|
| 72 |
"used": total,
|
| 73 |
"limit": settings.DAILY_LIMIT,
|
| 74 |
},
|
| 75 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rate limiting helpers.
|
| 2 |
+
|
| 3 |
+
- `check_daily_limit` counts today's jobs across all three tables.
|
| 4 |
+
- `check_daily_limit_pipelines`, `check_daily_limit_docking`, `check_daily_limit_sequencing`
|
| 5 |
+
count per-table for tighter per-feature caps.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
import base64
|
| 9 |
import json
|
| 10 |
import logging
|
|
|
|
| 38 |
return None
|
| 39 |
|
| 40 |
|
| 41 |
+
async def _count_today_jobs(user_id: str, table: str) -> int:
|
| 42 |
+
"""Count today's jobs for a user in a specific table."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
try:
|
| 44 |
today = date.today().isoformat()
|
| 45 |
url = (
|
| 46 |
+
f"{settings.SUPABASE_URL}/rest/v1/{table}"
|
| 47 |
f"?user_id=eq.{user_id}"
|
| 48 |
f"&created_at=gte.{today}T00:00:00"
|
| 49 |
f"&select=id"
|
|
|
|
| 58 |
resp.raise_for_status()
|
| 59 |
|
| 60 |
content_range = resp.headers.get("content-range", "*/0")
|
| 61 |
+
return int(content_range.split("/")[-1])
|
|
|
|
| 62 |
except HTTPException:
|
| 63 |
raise
|
| 64 |
except Exception as e:
|
| 65 |
+
logger.warning("Daily count check skipped (table %s): %s", table, e)
|
| 66 |
+
return 0
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
async def _enforce_limit(request: Request, table: str, limit: int, label: str) -> None:
|
| 70 |
+
user_id = _extract_user_id_from_request(request)
|
| 71 |
+
if not user_id:
|
| 72 |
return
|
| 73 |
|
| 74 |
+
total = await _count_today_jobs(user_id, table)
|
| 75 |
+
if total >= limit:
|
| 76 |
+
raise HTTPException(
|
| 77 |
+
status_code=429,
|
| 78 |
+
detail={
|
| 79 |
+
"error": "daily_limit_exceeded",
|
| 80 |
+
"message": f"You've used all {limit} daily {label}. Resets at midnight UTC.",
|
| 81 |
+
"used": total,
|
| 82 |
+
"limit": limit,
|
| 83 |
+
},
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
async def check_daily_limit(request: Request) -> None:
|
| 88 |
+
"""Global daily limit across all tables (existing behavior)."""
|
| 89 |
+
user_id = _extract_user_id_from_request(request)
|
| 90 |
+
if not user_id:
|
| 91 |
+
return
|
| 92 |
+
|
| 93 |
+
tables = ["jobs", "docking_jobs", "sequencing_jobs"]
|
| 94 |
+
total = 0
|
| 95 |
+
for t in tables:
|
| 96 |
+
total += await _count_today_jobs(user_id, t)
|
| 97 |
+
|
| 98 |
if total >= settings.DAILY_LIMIT:
|
| 99 |
raise HTTPException(
|
| 100 |
status_code=429,
|
| 101 |
detail={
|
| 102 |
"error": "daily_limit_exceeded",
|
| 103 |
+
"message": f"You've used all {settings.DAILY_LIMIT} daily analyses. Resets at midnight UTC.",
|
| 104 |
"used": total,
|
| 105 |
"limit": settings.DAILY_LIMIT,
|
| 106 |
},
|
| 107 |
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
async def check_daily_limit_pipelines(request: Request) -> None:
|
| 111 |
+
await _enforce_limit(request, "jobs", 10, "pipeline runs")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
async def check_daily_limit_docking(request: Request) -> None:
|
| 115 |
+
await _enforce_limit(request, "docking_jobs", 10, "docking jobs")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
async def check_daily_limit_sequencing(request: Request) -> None:
|
| 119 |
+
await _enforce_limit(request, "sequencing_jobs", 5, "sequencing jobs")
|