Files
kite/backend/api/cloud/operations.py
T
curo1305 a0d5c1d6c4 feat(14.1-02): CloudItemDetailOut schema + resolve_owned_cloud_item_detail + GET detail route
- Add CloudItemDetailOut allowlist schema to backend/api/cloud/schemas.py
  (excludes credentials_enc, object_key, version_key, raw provider URLs — T-14.1-03)
- Add CloudItemDetail dataclass and resolve_owned_cloud_item_detail service helper
  to backend/services/cloud_items.py (owner-scoped, metadata-only, no byte hydration)
- Add GET /connections/{id}/items/{item_id:path}/detail route to operations.py
  with get_regular_user (admin 403), ConnectionNotFound → 404, CloudItemNotFound → 404
- All 8 test_cloud_detail_parity tests now pass (D-05, D-07, D-16, T-14.1-03/04/06)
2026-06-26 21:54:28 +02:00

1423 lines
57 KiB
Python

"""
Phase 13 cloud content and mutation endpoints (Phase 14 Plan 06: byte cache integration).
Owner-scoped routes for opening, previewing, downloading, renaming, moving,
deleting, creating folders, and uploading cloud files. All routes are
connection-ID-keyed and call the service layer only; no provider SDK calls
are made here.
Security invariants (T-13-14):
- Provider credentials, raw provider URLs, and tokens must never appear in
any response body or header. Typed kind/reason codes are the only way the
frontend learns about provider outcomes.
- Binary-only preview enforced by PreviewSupport.is_supported() (D-18).
- Cross-connection moves are rejected before the adapter is called (D-08).
- Self-destination moves are rejected before the adapter is called (D-09).
- No health probe triggered by folder navigation (D-13).
- cache entries (object_key) are never returned in any response (T-14-02).
Phase 14 Plan 06 byte cache integration:
- preview_cloud_file and download_cloud_file route provider bytes through
hydrate_and_cache_bytes — cache hits avoid a provider round-trip.
- The cache pin lifecycle protects active preview/download entries from LRU
eviction during the response stream.
- cache_limit_bytes is read from UserAnalysisSettings (default 512 MB).
- All byte storage is behind the MinIO private bucket — object keys are
never exposed.
Route family:
GET /connections/{id}/items/{item_id}/open — D-02 authorized access URL
GET /connections/{id}/items/{item_id}/preview — D-18 binary-only preview
PATCH /connections/{id}/items/{item_id}/rename — D-05, D-07
POST /connections/{id}/items/{item_id}/move — D-08, D-09
DELETE /connections/{id}/items/{item_id} — D-10, D-11
POST /connections/{id}/folders — D-05, D-06
POST /connections/{id}/items/upload — D-03, D-04
Admin users are blocked by get_regular_user (T-13-01).
"""
from __future__ import annotations
import urllib.parse
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import JSONResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from api.cloud.schemas import (
CloudItemDetailOut,
CreateFolderRequest,
MoveItemRequest,
MutationResultOut,
RenameItemRequest,
)
from config import settings
from db.models import User
from deps.auth import get_regular_user
from deps.db import get_db
from deps.utils import get_client_ip
from services.audit import write_audit_log
from services.cloud_items import (
CloudItemNotFound,
ConnectionNotFound,
resolve_owned_cloud_item_detail,
update_folder_state,
upsert_cloud_item,
)
from services.cloud_operations import keep_both_name
from services.rate_limiting import account_limiter
from storage.cloud_base import (
CloudResource,
MUT_KIND_CONFLICT,
MUT_KIND_DELETED,
MUT_KIND_FOLDER,
MUT_KIND_INVALID_DEST,
MUT_KIND_OFFLINE,
MUT_KIND_REAUTH,
MUT_KIND_STALE,
MUT_KIND_UNSUPPORTED,
MUT_KIND_UPDATED,
MUT_KIND_UPLOADED,
MUT_REASON_ITEM_CHANGED,
MUT_REASON_NAME_COLLISION,
MUT_REASON_PERMANENT,
MUT_REASON_PROVIDER_ERROR,
MUT_REASON_SELF_MOVE,
MUT_REASON_TOKEN_EXPIRED,
MUT_REASON_TRASHED,
PreviewSupport,
)
# Maximum collision retry attempts for create-folder (D-06)
_CREATE_FOLDER_MAX_RETRIES = 5
router = APIRouter()
# ── Helpers ───────────────────────────────────────────────────────────────────
def _master_key() -> bytes:
return settings.cloud_creds_key.encode()
async def _resolve_and_get_adapter(
session: AsyncSession,
connection_id: uuid.UUID,
user_id: uuid.UUID,
):
"""Resolve owned connection and return a mutable adapter.
Raises HTTPException 404 if the connection is not owned by user_id.
Raises HTTPException 401 (reauth_required) if credential decryption fails.
"""
from services.cloud_items import resolve_owned_connection
from storage.cloud_utils import decrypt_credentials
from storage.cloud_backend_factory import build_mutable_cloud_adapter
try:
conn = await resolve_owned_connection(
session, connection_id=connection_id, user_id=user_id
)
except ConnectionNotFound:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
try:
creds = decrypt_credentials(_master_key(), str(user_id), conn.credentials_enc)
except Exception:
# Credential decryption failure = credentials corrupted or key mismatch.
# This is a reauth scenario, not a server error — return typed reauth body.
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Credential decryption failed — reconnect this connection",
)
try:
adapter = build_mutable_cloud_adapter(conn.provider, creds)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
)
return conn, adapter
def _make_minio_cache_wrapper(backend):
"""Return a lightweight MinIO wrapper for use with hydrate_and_cache_bytes.
The wrapper adapts the MinIOBackend instance to the interface expected by
hydrate_and_cache_bytes: async get_object(key), put_object(key, data, content_type),
and delete_object(key). This avoids duplicating the wrapper class at every
call site (Plan 06, DRY principle).
"""
import io as _io
import asyncio as _asyncio
class _MinIOCacheWrapper:
def __init__(self, b):
self._b = b
async def get_object(self, key: str) -> bytes:
return await self._b.get_object(key)
async def put_object(self, key: str, data: bytes, content_type: str = None) -> None:
buf = _io.BytesIO(data)
await _asyncio.to_thread(
self._b._client.put_object,
self._b._bucket,
key,
buf,
length=len(data),
content_type=content_type or "application/octet-stream",
)
async def delete_object(self, key: str) -> None:
try:
await self._b.delete_object(key)
except Exception:
pass
return _MinIOCacheWrapper(backend)
def _mutation_error_json(result: dict, http_status: int) -> JSONResponse:
"""Return a typed JSON error response for a failed mutation.
T-13-14: Returns {kind, reason} at the top level — never nested under 'detail'.
This lets the frontend read body.kind without special-casing HTTPException format.
"""
kind = result.get("kind", "error")
reason = result.get("reason", MUT_REASON_PROVIDER_ERROR)
return JSONResponse(
status_code=http_status,
content={"kind": kind, "reason": reason},
)
def _mutation_error_response(result: dict) -> JSONResponse:
"""Translate a normalized mutation result dict into a typed JSON response.
Used for non-success mutation results that need an HTTP error status.
Returns JSONResponse (not HTTPException) so kind/reason appear at the top
level of the response body rather than nested under 'detail'.
"""
kind = result.get("kind", "error")
if kind == MUT_KIND_STALE:
return _mutation_error_json(result, status.HTTP_409_CONFLICT)
if kind == MUT_KIND_CONFLICT:
return _mutation_error_json(result, status.HTTP_409_CONFLICT)
if kind == MUT_KIND_INVALID_DEST:
return _mutation_error_json(result, status.HTTP_409_CONFLICT)
if kind == MUT_KIND_REAUTH:
return _mutation_error_json(result, status.HTTP_401_UNAUTHORIZED)
if kind == MUT_KIND_UNSUPPORTED:
return JSONResponse(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
content={"kind": "unsupported_operation", "reason": "provider_unsupported"},
)
# offline / error — 503
return _mutation_error_json(result, status.HTTP_503_SERVICE_UNAVAILABLE)
# ── Open endpoint ─────────────────────────────────────────────────────────────
@router.get("/connections/{connection_id}/items/{item_id:path}/open")
@account_limiter.limit("100/minute")
async def open_cloud_file(
connection_id: uuid.UUID,
item_id: str,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Return a DocuVault-authorized open URL for a cloud file.
D-02: Provider credentials and raw provider URLs are never exposed in the
response. The caller must navigate to the returned url — never window.open
to a raw provider URL.
T-13-14: Response shape is stable — {kind: 'open', url: '<docuvault-url>'}.
T-13-01: Owner-scoped via get_regular_user + resolve_owned_connection.
"""
request.state.current_user = current_user
# Verify ownership — 404 for foreign user (IDOR, T-13-01)
try:
from services.cloud_items import resolve_owned_connection
await resolve_owned_connection(
session, connection_id=connection_id, user_id=current_user.id
)
except ConnectionNotFound:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
# Return a DocuVault-scoped authorized URL — the download route below serves bytes.
# This URL is backend-scoped: no raw provider URL or credentials ever cross to the client.
authorized_url = (
f"{settings.backend_url}/api/cloud/connections/{connection_id}"
f"/items/{item_id}/download"
)
await write_audit_log(
session,
event_type="cloud.file_opened",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=get_client_ip(request),
metadata_={
"connection_id": str(connection_id),
"provider_item_id": item_id,
},
)
await session.commit()
return {
"kind": "open",
"url": authorized_url,
"reason": "authorized",
}
# ── Cloud item detail endpoint ────────────────────────────────────────────────
@router.get(
"/connections/{connection_id}/items/{item_id:path}/detail",
response_model=CloudItemDetailOut,
)
@account_limiter.limit("120/minute")
async def get_cloud_item_detail(
connection_id: uuid.UUID,
item_id: str,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> CloudItemDetailOut:
"""Return owner-scoped cloud item detail with analysis fields and source metadata.
Endpoint contract (Phase 14.1 Plan 02):
- Returns extracted_text, analysis_status, semantic_index_status, topics,
provider, display_name, location, capabilities, and unsupported_analysis_reason.
- Foreign user gets 404 (IDOR protection — T-14.1-04).
- Admin is blocked by get_regular_user (T-14.1-04).
- No provider bytes are downloaded — metadata-only DB reads (T-14.1-06, CACHE-03).
- response_model=CloudItemDetailOut enforces allowlist — no credentials_enc,
object_key, version_key, or raw provider URLs can leak (T-14.1-03).
D-05: Once analysis completes, detail shows extracted text, topics, and status.
D-07: Stale items retain prior extracted_text and topics; is_stale=True signals state.
D-16: unsupported_analysis_reason present when analysis is not possible.
"""
request.state.current_user = current_user
try:
detail = await resolve_owned_cloud_item_detail(
session,
user_id=current_user.id,
connection_id=connection_id,
provider_item_id=item_id,
)
except ConnectionNotFound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Connection not found",
)
except CloudItemNotFound:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Cloud item not found",
)
# Map the credential-free dataclass to the allowlist response schema.
# capabilities is left empty here — the detail surface does not call the provider
# for live capability resolution (that would require credential decryption, which
# violates the metadata-only constraint). An empty dict is safe: the frontend can
# infer available actions from analysis_status and unsupported_analysis_reason.
return CloudItemDetailOut(
id=detail.id,
provider_item_id=detail.provider_item_id,
name=detail.name,
kind=detail.kind,
parent_ref=detail.parent_ref,
content_type=detail.content_type,
size=detail.size,
modified_at=detail.modified_at,
etag=detail.etag,
provider=detail.provider,
display_name=detail.display_name,
location=detail.location,
analysis_status=detail.analysis_status,
semantic_index_status=detail.semantic_index_status,
extracted_text=detail.extracted_text,
topics=detail.topics,
capabilities={},
unsupported_analysis_reason=detail.unsupported_analysis_reason,
is_stale=detail.is_stale,
)
# ── Preview endpoint ──────────────────────────────────────────────────────────
def _unsupported_preview_response(connection_id: uuid.UUID, item_id: str, reason: str) -> dict:
"""Return a typed unsupported-preview JSON body (D-18, T-13-14)."""
return {
"kind": "unsupported_preview",
"reason": reason,
"url": (
f"{settings.backend_url}/api/cloud/connections/{connection_id}"
f"/items/{item_id}/download"
),
}
@router.get("/connections/{connection_id}/items/{item_id:path}/preview")
@account_limiter.limit("100/minute")
async def preview_cloud_file(
connection_id: uuid.UUID,
item_id: str,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Stream a binary preview of a supported cloud file.
D-18: Only supported binary file formats are previewed. Google Workspace and
Microsoft Office document rendering/editing are explicitly excluded.
Unsupported formats return a typed {kind: 'unsupported_preview', reason:
'unsupported_format'} body so the frontend can route to the authorized
download fallback (D-02).
The response streams inline (Content-Disposition: inline) — never as a
browser-to-device download (Content-Disposition: attachment).
T-13-14: No provider URL, token, or credential appears in any response.
T-13-01: Owner-scoped via get_regular_user + resolve_owned_connection.
Ownership check is performed before any credential decryption — this keeps
the endpoint safe even when credentials cannot be decrypted (e.g. test fixtures
with a different encryption key).
"""
request.state.current_user = current_user
# Step 1: Ownership check (no credential decryption yet — T-13-01, IDOR gate)
try:
from services.cloud_items import resolve_owned_connection
await resolve_owned_connection(
session, connection_id=connection_id, user_id=current_user.id
)
except ConnectionNotFound:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
# Step 2: Resolve content type from cached cloud item metadata
from sqlalchemy import select
from db.models import CloudItem
result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == item_id,
CloudItem.user_id == current_user.id,
CloudItem.deleted_at.is_(None),
)
)
cloud_item = result.scalar_one_or_none()
content_type = cloud_item.content_type if cloud_item else None
# Step 3: D-18 binary preview gate — unsupported formats use authorized download fallback.
# No credential decryption is attempted for unsupported types.
if not PreviewSupport.is_supported(content_type):
return _unsupported_preview_response(connection_id, item_id, "unsupported_format")
# Step 4: Resolve the adapter (requires credential decryption).
try:
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
except HTTPException:
raise
except Exception:
return _unsupported_preview_response(connection_id, item_id, "provider_error")
# Step 5: Fetch bytes via the byte cache (PATTERNS.md lifecycle, Plan 06).
# hydrate_and_cache_bytes: cache hit → read from MinIO without a provider call;
# cache miss → fetch from provider, store in MinIO, pin for duration of response.
try:
from services.cloud_cache import hydrate_and_cache_bytes
from services.cloud_analysis_versioning import compute_version_key
from services.cloud_analysis_settings import (
get_or_create_user_analysis_settings,
DEFAULT_CACHE_LIMIT_BYTES,
)
from storage import get_storage_backend
# Compute the version key from CloudItem metadata (no provider call required).
version_key = compute_version_key(
provider_item_id=item_id,
version=cloud_item.version if cloud_item else None,
etag=cloud_item.etag if cloud_item else None,
size=cloud_item.provider_size if cloud_item else None,
modified_at=str(cloud_item.modified_at) if (cloud_item and cloud_item.modified_at) else None,
content_type=content_type,
) if cloud_item else None
# Resolve user's cache byte limit.
try:
user_settings = await get_or_create_user_analysis_settings(
session, user_id=current_user.id
)
cache_limit = user_settings.cloud_cache_limit_bytes
except Exception:
cache_limit = DEFAULT_CACHE_LIMIT_BYTES
if cloud_item and version_key:
# MinIO client wrapper for cache operations.
_backend = get_storage_backend()
minio_wrapper = _make_minio_cache_wrapper(_backend)
file_bytes, _ = await hydrate_and_cache_bytes(
session,
user_id=current_user.id,
connection_id=connection_id,
cloud_item_id=cloud_item.id,
provider_item_id=item_id,
version_key=version_key,
content_type=content_type,
fetch_fn=lambda: adapter.get_object(item_id),
minio_client=minio_wrapper,
cache_limit_bytes=cache_limit,
)
else:
# No CloudItem row or no version key — fetch directly from provider.
file_bytes = await adapter.get_object(item_id)
except HTTPException:
raise
except Exception:
return _unsupported_preview_response(connection_id, item_id, "provider_error")
# Stream inline — never as attachment (D-18)
return Response(
content=file_bytes,
media_type=content_type or "application/octet-stream",
headers={"Content-Disposition": "inline"},
)
# ── Authorized download (fallback for open and unsupported preview) ────────────
@router.get("/connections/{connection_id}/items/{item_id:path}/download")
@account_limiter.limit("100/minute")
async def download_cloud_file(
connection_id: uuid.UUID,
item_id: str,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
):
"""Authorized download fallback for cloud files.
D-02: The download route is DocuVault-controlled — no raw provider URL or
token crosses to the client. Bytes are fetched server-side and streamed.
This is the target of both open and unsupported-preview fallback URLs.
T-13-01: Owner-scoped via get_regular_user + resolve_owned_connection.
"""
request.state.current_user = current_user
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
# Resolve filename from metadata if available
from sqlalchemy import select
from db.models import CloudItem
meta_result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == item_id,
CloudItem.user_id == current_user.id,
CloudItem.deleted_at.is_(None),
)
)
cloud_item = meta_result.scalar_one_or_none()
filename = cloud_item.name if cloud_item else item_id
content_type = (cloud_item.content_type if cloud_item else None) or "application/octet-stream"
# Fetch bytes via the byte cache (Plan 06, PATTERNS.md cache lifecycle).
# hydrate_and_cache_bytes: cache hit → read from MinIO without a provider call;
# cache miss → fetch from provider, store in MinIO, pin for duration of response.
try:
from services.cloud_cache import hydrate_and_cache_bytes
from services.cloud_analysis_versioning import compute_version_key
from services.cloud_analysis_settings import (
get_or_create_user_analysis_settings,
DEFAULT_CACHE_LIMIT_BYTES,
)
from storage import get_storage_backend
version_key = compute_version_key(
provider_item_id=item_id,
version=cloud_item.version if cloud_item else None,
etag=cloud_item.etag if cloud_item else None,
size=cloud_item.provider_size if cloud_item else None,
modified_at=str(cloud_item.modified_at) if (cloud_item and cloud_item.modified_at) else None,
content_type=content_type if content_type != "application/octet-stream" else None,
) if cloud_item else None
try:
user_settings = await get_or_create_user_analysis_settings(
session, user_id=current_user.id
)
cache_limit = user_settings.cloud_cache_limit_bytes
except Exception:
cache_limit = DEFAULT_CACHE_LIMIT_BYTES
if cloud_item and version_key:
_backend = get_storage_backend()
minio_wrapper = _make_minio_cache_wrapper(_backend)
file_bytes, _ = await hydrate_and_cache_bytes(
session,
user_id=current_user.id,
connection_id=connection_id,
cloud_item_id=cloud_item.id,
provider_item_id=item_id,
version_key=version_key,
content_type=content_type if content_type != "application/octet-stream" else None,
fetch_fn=lambda: adapter.get_object(item_id),
minio_client=minio_wrapper,
cache_limit_bytes=cache_limit,
)
else:
# No CloudItem metadata — fetch directly from provider.
file_bytes = await adapter.get_object(item_id)
except HTTPException:
raise
except Exception as exc:
result_dict = getattr(adapter, "_normalize_error", lambda e: {})(exc)
if result_dict.get("kind") == MUT_KIND_REAUTH:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Provider authentication required — please reconnect this connection",
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Provider temporarily unavailable — please try again",
)
# RFC 6266 percent-encoding — prevents header injection via special characters
# (newlines, semicolons, etc.) that str.replace('"', "'") does not cover.
encoded_filename = urllib.parse.quote(filename, safe=" !#$&'()*+,-./:;<=>?@[]^_`{|}~")
return Response(
content=file_bytes,
media_type=content_type,
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}"},
)
# ── Create folder ─────────────────────────────────────────────────────────────
@router.post("/connections/{connection_id}/folders", status_code=status.HTTP_201_CREATED)
@account_limiter.limit("100/minute")
async def create_cloud_folder(
connection_id: uuid.UUID,
body: CreateFolderRequest,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Create a folder in connected cloud storage.
D-05: Name collision auto-suffix: 'Projects (1)', 'Projects (2)', etc.
D-06: Bounded retry up to _CREATE_FOLDER_MAX_RETRIES when a concurrent client
takes the candidate name between check and mutation.
D-07: Stale precondition returned by the provider stops the mutation, marks the
parent folder as non-fresh, and returns typed {kind: 'stale'} for retry.
Returns {kind: 'folder', provider_item_id, name, parent_ref} on success.
After success, the new folder is upserted into cloud_items and the parent folder
state is invalidated before the response is returned (reconcile-before-return).
T-13-01: Owner-scoped. T-13-14: No credentials in response.
"""
request.state.current_user = current_user
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
candidate_name = body.name
last_result: dict = {}
for attempt in range(_CREATE_FOLDER_MAX_RETRIES + 1):
result = await adapter.create_folder(
parent_ref=body.parent_ref,
name=candidate_name,
connection_id=connection_id,
user_id=current_user.id,
)
last_result = result
kind = result.get("kind")
if kind == MUT_KIND_FOLDER:
# Success: reconcile before returning (T-13-24, T-13-26)
provider_item_id = result.get("provider_item_id")
resolved_name = result.get("name", candidate_name)
resolved_parent_ref = result.get("parent_ref", body.parent_ref)
if provider_item_id:
resource = CloudResource(
id=uuid.uuid4(),
provider_item_id=provider_item_id,
connection_id=connection_id,
user_id=current_user.id,
name=resolved_name,
kind="folder",
parent_ref=resolved_parent_ref,
)
# Upsert gives the new folder stable row identity in cloud_items
await upsert_cloud_item(session, user_id=str(current_user.id), resource=resource)
# Invalidate parent folder so next browse triggers a provider re-list
invalidate_parent = resolved_parent_ref if resolved_parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=invalidate_parent,
refresh_state="warning",
error_code="create_folder_mutated",
error_message="Folder contents changed by create-folder — re-listing required.",
)
await write_audit_log(
session,
event_type="cloud.folder_created",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=get_client_ip(request),
metadata_={
"connection_id": str(connection_id),
"provider_item_id": provider_item_id,
"name": resolved_name,
"parent_ref": resolved_parent_ref,
},
)
await session.commit()
return {
"kind": "folder",
"provider_item_id": provider_item_id,
"name": resolved_name,
"parent_ref": resolved_parent_ref,
}
if kind == MUT_KIND_CONFLICT and attempt < _CREATE_FOLDER_MAX_RETRIES:
# D-05/D-06: auto-suffix with counter and retry
candidate_name = keep_both_name(body.name, attempt + 1)
continue
if kind == MUT_KIND_STALE:
# D-07: Stop mutation, mark parent folder as non-fresh, return typed stale result
stale_parent = body.parent_ref if body.parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=stale_parent,
refresh_state="warning",
error_code="stale_listing",
error_message="Parent folder changed externally — re-listing required before retry.",
)
await session.commit()
return _mutation_error_response(result)
# All other non-success kinds (offline, reauth, unsupported, conflict exhausted)
break
# Unsupported operation — return typed body instead of 500
if last_result.get("kind") == MUT_KIND_UNSUPPORTED:
return {
"kind": "unsupported_operation",
"reason": "provider_unsupported",
}
return _mutation_error_response(last_result)
# ── Rename ────────────────────────────────────────────────────────────────────
@router.patch("/connections/{connection_id}/items/{item_id:path}/rename")
@account_limiter.limit("100/minute")
async def rename_cloud_item(
connection_id: uuid.UUID,
item_id: str,
body: RenameItemRequest,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Rename a cloud file or folder.
D-05: Rename collision is surfaced to the user (they chose the name explicitly).
D-07: etag guards against operating on stale metadata. On mismatch:
stops mutation, marks parent folder as non-fresh, returns
{kind: 'stale', reason: 'item_changed'} (HTTP 409) so the frontend
re-lists before retrying.
On success: upserts the updated item name in cloud_items and invalidates the
parent folder state before returning (reconcile-before-return, T-13-26).
Returns {kind: 'renamed', provider_item_id, name} on success.
T-13-01: Owner-scoped. T-13-14: No credentials in response.
"""
request.state.current_user = current_user
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
result = await adapter.rename(
provider_item_id=item_id,
new_name=body.new_name,
etag=body.etag,
)
kind = result.get("kind")
if kind == MUT_KIND_UPDATED:
# Success: reconcile the updated name in cloud_items before returning (T-13-26)
resolved_provider_item_id = result.get("provider_item_id", item_id)
resolved_name = result.get("name", body.new_name)
# Resolve the parent_ref from the existing CloudItem so we can invalidate it
from sqlalchemy import select
from db.models import CloudItem
existing_result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == item_id,
CloudItem.user_id == current_user.id,
CloudItem.deleted_at.is_(None),
)
)
existing_item = existing_result.scalar_one_or_none()
item_parent_ref = existing_item.parent_ref if existing_item else None
item_kind = existing_item.kind if existing_item else "file"
item_content_type = existing_item.content_type if existing_item else None
item_size = existing_item.provider_size if existing_item else None
# Upsert the item with the new name — uses provider_item_id as stable identity key
resource = CloudResource(
id=uuid.uuid4(),
provider_item_id=resolved_provider_item_id,
connection_id=connection_id,
user_id=current_user.id,
name=resolved_name,
kind=item_kind,
parent_ref=item_parent_ref,
content_type=item_content_type,
size=item_size,
)
await upsert_cloud_item(session, user_id=str(current_user.id), resource=resource)
# Invalidate the parent folder so the next browse reflects the renamed item
invalidate_parent = item_parent_ref if item_parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=invalidate_parent,
refresh_state="warning",
error_code="rename_mutated",
error_message="Folder contents changed by rename — re-listing required.",
)
await write_audit_log(
session,
event_type="cloud.item_renamed",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=get_client_ip(request),
metadata_={
"connection_id": str(connection_id),
"provider_item_id": resolved_provider_item_id,
"new_name": resolved_name,
},
)
await session.commit()
return {
"kind": "renamed",
"provider_item_id": resolved_provider_item_id,
"name": resolved_name,
}
if kind == MUT_KIND_STALE:
# D-07: Stop mutation, mark parent folder as non-fresh, return typed stale result.
# Resolve parent_ref from existing CloudItem metadata if available.
from sqlalchemy import select
from db.models import CloudItem
stale_result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == item_id,
CloudItem.user_id == current_user.id,
CloudItem.deleted_at.is_(None),
)
)
stale_item = stale_result.scalar_one_or_none()
stale_parent = stale_item.parent_ref if stale_item else None
stale_parent_ref = stale_parent if stale_parent is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=stale_parent_ref,
refresh_state="warning",
error_code="stale_listing",
error_message="Item changed externally — re-listing required before retry.",
)
await session.commit()
return _mutation_error_response(result)
return _mutation_error_response(result)
# ── Move ──────────────────────────────────────────────────────────────────────
async def _is_descendant_destination(
session: AsyncSession,
connection_id: uuid.UUID,
user_id: uuid.UUID,
source_item_id: str,
dest_parent_ref: str,
) -> bool:
"""Return True if dest_parent_ref is a descendant of source_item_id.
D-09: The backend must independently reject descendant destinations even if
the frontend pre-screened them. Walks the ancestor chain of dest_parent_ref
through the cloud_items table to detect cycles.
This is a metadata-only check — no provider calls are made.
"""
from sqlalchemy import select
from db.models import CloudItem
visited: set[str] = set()
current_ref: str | None = dest_parent_ref
while current_ref is not None:
if current_ref in visited:
# Cycle in ancestor chain — defensive guard
break
visited.add(current_ref)
if current_ref == source_item_id:
return True
# Look up the parent of current_ref
result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == current_ref,
CloudItem.user_id == user_id,
CloudItem.deleted_at.is_(None),
)
)
parent_item = result.scalar_one_or_none()
current_ref = parent_item.parent_ref if parent_item else None
return False
@router.post("/connections/{connection_id}/items/{item_id:path}/move")
@account_limiter.limit("100/minute")
async def move_cloud_item(
connection_id: uuid.UUID,
item_id: str,
body: MoveItemRequest,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Move a cloud item within the same connection.
D-08: Cross-connection moves are rejected before calling the adapter.
D-09: Self and descendant destinations are rejected before calling the adapter.
D-07: Stale precondition failures stop the move, refresh the source folder, and
return typed {kind: 'stale', reason: 'item_changed'} for retry.
On success: upserts the item's new parent_ref in cloud_items, invalidates both
source and destination folder states, and writes a metadata-only audit row before
the response is returned (reconcile-before-return, T-13-26, T-13-29).
Returns {kind: 'moved', provider_item_id, parent_ref} on success.
Returns {kind: 'invalid_destination', reason: '...'} for invalid moves (HTTP 409).
T-13-01: Owner-scoped. T-13-14: No credentials in response.
"""
request.state.current_user = current_user
# D-08: Cross-connection move rejection (before any adapter call)
if body.destination_connection_id is not None:
dest_conn_id = str(body.destination_connection_id).strip()
source_conn_id = str(connection_id)
if dest_conn_id and dest_conn_id != source_conn_id:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"kind": "invalid_destination", "reason": "cross_connection"},
)
# D-09: Self-destination rejection (before any adapter call)
if body.destination_parent_ref == item_id:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"kind": "invalid_destination", "reason": MUT_REASON_SELF_MOVE},
)
# D-09: Descendant destination rejection — walk ancestor chain via cloud_items metadata.
# This is a belt-and-suspenders check independent of provider-side validation.
if body.destination_parent_ref:
is_descendant = await _is_descendant_destination(
session,
connection_id=connection_id,
user_id=current_user.id,
source_item_id=item_id,
dest_parent_ref=body.destination_parent_ref,
)
if is_descendant:
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"kind": "invalid_destination", "reason": "descendant_destination"},
)
# Resolve existing item metadata before the provider call (for reconciliation on success)
from sqlalchemy import select
from db.models import CloudItem
existing_result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == item_id,
CloudItem.user_id == current_user.id,
CloudItem.deleted_at.is_(None),
)
)
existing_item = existing_result.scalar_one_or_none()
source_parent_ref = existing_item.parent_ref if existing_item else None
item_kind = existing_item.kind if existing_item else "file"
item_name = existing_item.name if existing_item else item_id
item_content_type = existing_item.content_type if existing_item else None
item_size = existing_item.provider_size if existing_item else None
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
result = await adapter.move(
provider_item_id=item_id,
destination_parent_ref=body.destination_parent_ref,
etag=body.etag,
)
kind = result.get("kind")
if kind == MUT_KIND_UPDATED:
# Success: reconcile before returning (T-13-26, T-13-29)
resolved_provider_item_id = result.get("provider_item_id", item_id)
resolved_dest_parent_ref = result.get("destination_parent_ref", body.destination_parent_ref)
# Upsert item with new parent_ref — preserves stable row identity
resource = CloudResource(
id=uuid.uuid4(),
provider_item_id=resolved_provider_item_id,
connection_id=connection_id,
user_id=current_user.id,
name=item_name,
kind=item_kind,
parent_ref=resolved_dest_parent_ref,
content_type=item_content_type,
size=item_size,
)
await upsert_cloud_item(session, user_id=str(current_user.id), resource=resource)
# Invalidate source folder (item was removed from here)
invalidate_source = source_parent_ref if source_parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=invalidate_source,
refresh_state="warning",
error_code="move_mutated",
error_message="Folder contents changed by move — re-listing required.",
)
# Invalidate destination folder (item was added here)
invalidate_dest = resolved_dest_parent_ref if resolved_dest_parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=invalidate_dest,
refresh_state="warning",
error_code="move_mutated",
error_message="Folder contents changed by move — re-listing required.",
)
# Write metadata-only audit row (T-13-02, T-13-29)
await write_audit_log(
session,
event_type="cloud.item_moved",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=get_client_ip(request),
metadata_={
"connection_id": str(connection_id),
"provider_item_id": resolved_provider_item_id,
"old_parent_ref": source_parent_ref,
"new_parent_ref": resolved_dest_parent_ref,
},
)
await session.commit()
return {
"kind": "moved",
"provider_item_id": resolved_provider_item_id,
"parent_ref": resolved_dest_parent_ref,
}
if kind == MUT_KIND_STALE:
# D-07: Stop mutation, mark source folder as non-fresh, return typed stale result
stale_source = source_parent_ref if source_parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=stale_source,
refresh_state="warning",
error_code="stale_listing",
error_message="Item changed externally — re-listing required before retry.",
)
await session.commit()
return _mutation_error_response(result)
return _mutation_error_response(result)
# ── Delete ────────────────────────────────────────────────────────────────────
@router.delete("/connections/{connection_id}/items/{item_id:path}", status_code=status.HTTP_200_OK)
@account_limiter.limit("100/minute")
async def delete_cloud_item(
connection_id: uuid.UUID,
item_id: str,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Delete a cloud item.
D-10: Files get a standard confirmation; folders warn that nested contents are included.
D-11: Prefers the provider's trash/recycle-bin when supported. Returns
{kind: 'deleted', reason: 'trashed'} or {kind: 'deleted', reason: 'permanent'}.
On success: marks the parent folder non-fresh and writes a metadata-only audit row
before the response is returned (reconcile-before-return, T-13-29).
T-13-01: Owner-scoped. T-13-14: No credentials or provider URLs in response.
"""
request.state.current_user = current_user
# Pre-fetch item metadata for disclosure (D-10) and reconciliation
from sqlalchemy import select
from db.models import CloudItem
meta_result = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == item_id,
CloudItem.user_id == current_user.id,
CloudItem.deleted_at.is_(None),
)
)
existing_item = meta_result.scalar_one_or_none()
item_kind = existing_item.kind if existing_item else "file"
item_parent_ref = existing_item.parent_ref if existing_item else None
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
result = await adapter.delete(provider_item_id=item_id, trash=True)
kind = result.get("kind")
reason = result.get("reason")
if kind == MUT_KIND_DELETED:
# Success: reconcile before returning (T-13-29)
# CR-04: Soft-delete the CloudItem row so that queries filtering on
# deleted_at.is_(None) (upload conflict check, preview, download) no longer
# "see" the deleted file. Without this, the row persists until the next full
# provider re-list, causing false conflict hits on immediate re-upload.
from datetime import datetime, timezone
from sqlalchemy import update as sa_update
await session.execute(
sa_update(CloudItem)
.where(
CloudItem.connection_id == connection_id,
CloudItem.provider_item_id == item_id,
CloudItem.user_id == current_user.id,
CloudItem.deleted_at.is_(None),
)
.values(deleted_at=datetime.now(timezone.utc))
)
# Invalidate parent folder so next browse reflects the deletion
invalidate_parent = item_parent_ref if item_parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=invalidate_parent,
refresh_state="warning",
error_code="delete_mutated",
error_message="Folder contents changed by delete — re-listing required.",
)
# Write metadata-only audit row — only after authoritative success (T-13-02, T-13-29)
delete_reason = reason or MUT_REASON_PERMANENT
await write_audit_log(
session,
event_type="cloud.item_deleted",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=get_client_ip(request),
metadata_={
"connection_id": str(connection_id),
"provider_item_id": result.get("provider_item_id", item_id),
"delete_kind": delete_reason,
"item_kind": item_kind,
},
)
await session.commit()
# D-10: Disclose folder-vs-file delete semantics to the frontend
return {
"kind": "deleted",
"reason": delete_reason,
"provider_item_id": result.get("provider_item_id", item_id),
"item_kind": item_kind,
"is_folder": item_kind == "folder",
}
return _mutation_error_response(result)
# ── Upload ────────────────────────────────────────────────────────────────────
@router.post("/connections/{connection_id}/items/upload", status_code=status.HTTP_200_OK)
@account_limiter.limit("100/minute")
async def upload_cloud_file(
connection_id: uuid.UUID,
request: Request,
file: UploadFile = File(...),
parent_ref: Optional[str] = Form(None),
filename: Optional[str] = Form(None),
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Upload a file to a cloud folder.
D-03: A same-name upload never overwrites silently. Returns
{kind: 'conflict', reason: 'name_collision'} so the frontend shows the
conflict resolution dialog. The conflict check uses cached cloud_items
metadata first (fast path) before attempting the provider upload.
D-04: Caller manages the sequential upload queue; this endpoint handles one file.
Returns {kind: 'uploaded', provider_item_id, name, parent_ref, size} on success.
T-13-01: Owner-scoped. T-13-14: No provider URL or credentials in response.
"""
request.state.current_user = current_user
# Ownership check before credential decryption (T-13-01, IDOR gate)
try:
from services.cloud_items import resolve_owned_connection
await resolve_owned_connection(
session, connection_id=connection_id, user_id=current_user.id
)
except ConnectionNotFound:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
upload_filename = filename or file.filename or "upload"
# D-03: Check cached metadata for same-name item in parent folder.
# This is the fast-path conflict detection: if we have a cached item with
# the same name under the same parent_ref, signal conflict before attempting
# the provider upload. The provider adapter also enforces this independently.
from sqlalchemy import select
from db.models import CloudItem
conflict_check = await session.execute(
select(CloudItem).where(
CloudItem.connection_id == connection_id,
CloudItem.user_id == current_user.id,
CloudItem.name == upload_filename,
CloudItem.parent_ref == parent_ref,
CloudItem.deleted_at.is_(None),
)
)
if conflict_check.scalar_one_or_none() is not None:
return {
"kind": "conflict",
"reason": MUT_REASON_NAME_COLLISION,
"existing_name": upload_filename,
}
# Proceed with provider upload (requires credential decryption)
conn, adapter = await _resolve_and_get_adapter(session, connection_id, current_user.id)
content_type = file.content_type or "application/octet-stream"
content = await file.read()
result = await adapter.upload_file(
parent_ref=parent_ref,
filename=upload_filename,
content=content,
content_type=content_type,
connection_id=connection_id,
user_id=current_user.id,
)
kind = result.get("kind")
if kind == MUT_KIND_UPLOADED:
# Route authoritative upload success through centralized reconciliation
# before returning (Plan 06, Task 1: T-13-19).
# upsert_cloud_item gives the new item stable row identity in cloud_items,
# so navigation can show it immediately without a full provider re-list.
# update_folder_state invalidates the parent folder so the next browse
# triggers a provider re-list and reflects the new content truthfully.
# These calls are intentionally in-request — the response is not returned
# until reconciliation completes (reconcile-before-return contract).
provider_item_id = result.get("provider_item_id")
resolved_name = result.get("name", upload_filename)
resolved_parent_ref = result.get("parent_ref", parent_ref)
resolved_size = result.get("size", len(content))
if provider_item_id:
from storage.cloud_base import CloudResource
from services.cloud_items import upsert_cloud_item, update_folder_state
resource = CloudResource(
id=uuid.uuid4(),
provider_item_id=provider_item_id,
connection_id=connection_id,
user_id=current_user.id,
name=resolved_name,
kind="file",
parent_ref=resolved_parent_ref,
content_type=content_type,
size=resolved_size,
)
# DB mutation block: upsert + folder-state invalidation + audit + commit.
# CR-03: Wrapped in try/except so that a DB failure after a successful
# provider upload is reported distinctly rather than propagating as an
# unhandled 500. The file IS on the provider; the metadata recording
# failed. The frontend receives a typed error so it can surface the
# discrepancy rather than silently losing the upload record.
try:
# Upsert item — establishes stable row identity for navigation
await upsert_cloud_item(session, user_id=str(current_user.id), resource=resource)
# Invalidate parent folder so next browse triggers a provider re-list.
# We do NOT call apply_listing_and_finalize — that requires a full provider
# listing which we don't have. Instead we mark the folder stale with a
# controlled reason so the browse endpoint knows to re-fetch.
invalidate_parent = resolved_parent_ref if resolved_parent_ref is not None else ""
await update_folder_state(
session,
user_id=str(current_user.id),
connection_id=str(connection_id),
parent_ref=invalidate_parent,
refresh_state="warning",
error_code="upload_mutated",
error_message="Folder contents changed by upload — re-listing required.",
)
# Write metadata-only audit row in the same transaction (Plan 06, Task 2: T-13-20).
# Only authoritative upload success reaches this path. Non-success outcomes
# (conflict, offline, reauth_required) are handled in separate branches below
# and never call write_audit_log — preserving T-13-21 (no false success events).
# The audit payload is metadata-only: no provider URLs, tokens, bytes, or
# document text are included (T-13-02).
await write_audit_log(
session,
event_type="cloud.file_uploaded",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=get_client_ip(request),
metadata_={
"connection_id": str(connection_id),
"provider_item_id": provider_item_id,
"filename": resolved_name,
"size_bytes": resolved_size,
"parent_ref": resolved_parent_ref,
},
)
await session.commit()
except Exception:
# Provider upload succeeded but DB recording failed.
# Roll back any partial writes and return a typed error so the frontend
# knows the file is on the provider but DocuVault has no metadata record.
await session.rollback()
return JSONResponse(
status_code=status.HTTP_207_MULTI_STATUS,
content={
"kind": "provider_success_db_error",
"reason": "metadata_record_failed",
"provider_item_id": provider_item_id,
"message": (
"File uploaded to provider but DocuVault metadata recording failed. "
"Refresh the folder to re-sync."
),
},
)
return {
"kind": "uploaded",
"provider_item_id": provider_item_id,
"name": resolved_name,
"parent_ref": resolved_parent_ref,
"size": resolved_size,
}
if kind == MUT_KIND_CONFLICT:
# D-03: Return conflict kind for same-name detection — never overwrite silently
return {
"kind": "conflict",
"reason": MUT_REASON_NAME_COLLISION,
"existing_name": result.get("existing_name", upload_filename),
}
return _mutation_error_response(result)