Samad14 commited on
Commit
bd279b4
·
1 Parent(s): e47db42

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 CHANGED
@@ -1,4 +1,34 @@
 
 
 
 
 
1
  from slowapi import Limiter
2
  from slowapi.util import get_remote_address
3
 
4
- limiter = Limiter(key_func=get_remote_address, default_limits=["30/minute"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- async def create_docking_job(body: DockingJobCreate, user_id: str = Depends(require_user_id)):
 
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
- async def run_pipeline_v2(req: PipelineV2RunRequest):
 
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(check_daily_limit)])
24
- async def run_pipeline(req: PipelineRunRequest, user_id: str | None = Depends(get_user_id)):
 
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
- async def run_sequencing(req: SequencingRequest, user_id: str = Depends(require_user_id)):
 
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 check_daily_limit(request: Request) -> None:
35
- user_id = _extract_user_id_from_request(request)
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/jobs"
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
- total = int(content_range.split("/")[-1])
59
-
60
  except HTTPException:
61
  raise
62
  except Exception as e:
63
- logger.warning(f"Rate limit check skipped (Supabase unreachable): {e}")
 
 
 
 
 
 
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 IST.",
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")