feat(06-05): trusted-proxy get_client_ip, per-account rate limiter, promote 8 xfail tests (D-11/D-12/D-13)

- D-11: replace get_client_ip body with trusted-proxy CIDR check (127/8, 172.16/12,
  192.168/16, ::1/128); untrusted peers always return their own IP, preventing XFF
  spoofing by external callers
- D-12: create services/rate_limiting.py with _account_key() keyed by user.id
  (falls back to peer IP when no authenticated user in request.state); exports
  account_limiter = Limiter(key_func=_account_key)
- Wire: auth.py switches from get_remote_address to get_client_ip; main.py imports
  account_limiter; all 9 documents.py endpoints and 7 cloud.py endpoints decorated
  @account_limiter.limit("100/minute") with request.state.current_user = current_user
  as the first handler statement (A1 ordering invariant)
- Promote all 8 xfail stubs to real assertions; add autouse fixture in conftest.py
  to reset MemoryStorage between tests preventing cross-test 429 contamination

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-04 18:27:49 +02:00
co-authored by Claude Sonnet 4.6
parent eaa3399ec0
commit a826738e18
8 changed files with 301 additions and 13 deletions
+25 -1
View File
@@ -38,6 +38,7 @@ from deps.auth import get_regular_user
from deps.db import get_db
from services import classifier, storage
from services.audit import write_audit_log
from services.rate_limiting import account_limiter
from storage import get_storage_backend, get_storage_backend_for_document
from storage.cloud_utils import decrypt_credentials
from tasks.document_tasks import extract_and_classify
@@ -86,7 +87,9 @@ class DocumentPatch(BaseModel):
# ── POST /api/documents/upload-url ───────────────────────────────────────────
@router.post("/upload-url")
@account_limiter.limit("100/minute")
async def request_upload_url(
request: Request,
body: UploadUrlRequest,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
@@ -101,6 +104,7 @@ async def request_upload_url(
stored in DB only (CLAUDE.md MinIO key schema).
T-03-15: object_key prefix is always the authenticated user's id — never user-supplied.
"""
request.state.current_user = current_user
doc_id = uuid.uuid4()
suffix = Path(body.filename).suffix.lower()
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
@@ -127,11 +131,12 @@ async def request_upload_url(
# ── POST /api/documents/upload ────────────────────────────────────────────────
@router.post("/upload")
@account_limiter.limit("100/minute")
async def upload_document(
request: Request,
file: UploadFile = File(...),
target_backend: str = Form("minio"),
cloud_folder_path: str = Form(None),
request: Request = None,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
@@ -153,6 +158,7 @@ async def upload_document(
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist → 422 on invalid value
T-05-06-02: CloudConnectionError detail message never includes provider error detail
"""
request.state.current_user = current_user
if target_backend == "minio":
# MinIO: generate a presigned URL for client-side PUT (existing flow reused)
doc_id = uuid.uuid4()
@@ -288,6 +294,7 @@ async def upload_document(
# ── POST /api/documents/{doc_id}/confirm ─────────────────────────────────────
@router.post("/{doc_id}/confirm")
@account_limiter.limit("100/minute")
async def confirm_upload(
doc_id: str,
request: Request,
@@ -306,6 +313,7 @@ async def confirm_upload(
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
T-03-11: ownership assertion — cross-user access returns 404 (D-16).
"""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
@@ -397,7 +405,9 @@ async def confirm_upload(
# ── GET /api/documents ────────────────────────────────────────────────────────
@router.get("")
@account_limiter.limit("100/minute")
async def list_documents(
request: Request,
topic: Optional[str] = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
@@ -421,6 +431,7 @@ async def list_documents(
Backward-compat: when sort/order/folder_id/q are not provided, behaviour
is identical to the pre-Phase-4 implementation.
"""
request.state.current_user = current_user
# If no new params used, fall through to the legacy storage.list_metadata path
# to preserve full backward compatibility with topic filtering.
if folder_id is None and q is None and sort == "date" and order == "desc":
@@ -519,7 +530,9 @@ async def list_documents(
# ── GET /api/documents/{doc_id} ───────────────────────────────────────────────
@router.get("/{doc_id}")
@account_limiter.limit("100/minute")
async def get_document(
request: Request,
doc_id: str,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
@@ -529,6 +542,7 @@ async def get_document(
D-16: requires authenticated regular user. Asserts ownership — cross-user
access returns 404 (not 403) to avoid information leakage (T-03-11).
"""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
@@ -563,7 +577,9 @@ async def get_document(
# ── PATCH /api/documents/{doc_id} ────────────────────────────────────────────
@router.patch("/{doc_id}")
@account_limiter.limit("100/minute")
async def patch_document(
request: Request,
doc_id: str,
body: DocumentPatch,
session: AsyncSession = Depends(get_db),
@@ -579,6 +595,7 @@ async def patch_document(
At least one field must be provided — empty body returns 422.
folder_id=null moves the document to the root (no folder).
"""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
@@ -614,6 +631,7 @@ async def patch_document(
# ── DELETE /api/documents/{doc_id} ───────────────────────────────────────────
@router.delete("/{doc_id}")
@account_limiter.limit("100/minute")
async def delete_document(
doc_id: str,
request: Request,
@@ -633,6 +651,7 @@ async def delete_document(
D-16: requires authenticated regular user. Asserts ownership — cross-user
delete returns 404 (not 403) to avoid information leakage (T-03-11).
"""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
@@ -691,7 +710,9 @@ async def delete_document(
# ── POST /api/documents/{doc_id}/classify ────────────────────────────────────
@router.post("/{doc_id}/classify")
@account_limiter.limit("100/minute")
async def classify_document(
request: Request,
doc_id: str,
body: dict = {},
session: AsyncSession = Depends(get_db),
@@ -702,6 +723,7 @@ async def classify_document(
D-16: requires authenticated regular user. Asserts ownership — cross-user
classify returns 404 (not 403) to avoid information leakage (T-03-11).
"""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError:
@@ -744,6 +766,7 @@ def _parse_range(range_header: str, file_size: int) -> tuple:
# ── GET /api/documents/{doc_id}/content ──────────────────────────────────────
@router.get("/{doc_id}/content")
@account_limiter.limit("100/minute")
async def stream_document_content(
doc_id: str,
request: Request,
@@ -763,6 +786,7 @@ async def stream_document_content(
Accept-Ranges: bytes
Content-Length: <size>
"""
request.state.current_user = current_user
try:
uid = uuid.UUID(doc_id)
except ValueError: