feat(06.2-03): backend — cloud-aware delete routing + skip_quota + remove_only param

- storage.delete_document gains skip_quota=False param; quota decrement gated on it
- DELETE /api/documents/{id} gains remove_only=bool query param
- Cloud docs (storage_backend != minio): attempt cloud backend delete_object first
  - On failure: return HTTP 200 {success: false, cloud_delete_failed: true} (not 4xx)
  - On success or remove_only: delete DB row with skip_quota=True
- Cloud creds/exception message never included in response body (T-06.2-03-02)
- Promote 3 xfail stubs to real tests (propagates, failure, remove_only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-31 15:09:44 +02:00
parent e812922a26
commit 95c7ed786a
3 changed files with 139 additions and 23 deletions
+27 -5
View File
@@ -26,7 +26,7 @@ from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, UploadFile, File, status
from fastapi.responses import StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select, text, func
from sqlalchemy.ext.asyncio import AsyncSession
@@ -605,13 +605,18 @@ async def patch_document(
async def delete_document(
doc_id: str,
request: Request,
remove_only: bool = Query(default=False),
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Delete a document and decrement quota atomically.
services.storage.delete_document handles the atomic quota decrement
(STORE-06, D-07) via GREATEST(0, used_bytes - delta) SQL.
For cloud-stored documents:
- Default path: attempt cloud provider delete first; on failure return
{success: false, cloud_delete_failed: true} (HTTP 200) so the frontend
can offer a "Remove from app" fallback (T-06.2-03-02).
- remove_only=true: skip cloud delete, remove DB row only, skip quota decrement.
- Cloud docs always use skip_quota=True (never charged MinIO quota, T-06.2-03-01).
D-16: requires authenticated regular user. Asserts ownership — cross-user
delete returns 404 (not 403) to avoid information leakage (T-03-11).
@@ -625,12 +630,29 @@ async def delete_document(
if doc is None or doc.user_id != current_user.id:
raise HTTPException(404, "Document not found")
# Capture audit metadata before delete removes the row
is_cloud = doc.storage_backend != "minio"
_doc_size = doc.size_bytes
_doc_id = doc.id
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
ok = await storage.delete_document(session, doc_id)
# Cloud routing: attempt provider delete unless remove_only is set
if is_cloud and not remove_only:
try:
cloud_backend = await get_storage_backend_for_document(doc, current_user, session)
await cloud_backend.delete_object(doc.object_key)
except Exception as exc:
import sys
print(f"[cloud-delete] provider error: {exc}", file=sys.stderr)
return JSONResponse(
status_code=200,
content={
"success": False,
"cloud_delete_failed": True,
"detail": "Cloud provider delete failed. You can remove from app only.",
},
)
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud)
if not ok:
raise HTTPException(404, "Document not found")