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:
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user