From a0d5c1d6c4a08b223d9ab2f9c3c8bebdf474ce2c Mon Sep 17 00:00:00 2001 From: curo1305 Date: Fri, 26 Jun 2026 21:54:28 +0200 Subject: [PATCH] feat(14.1-02): CloudItemDetailOut schema + resolve_owned_cloud_item_detail + GET detail route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- backend/api/cloud/operations.py | 81 +++++++++++++++++ backend/api/cloud/schemas.py | 74 ++++++++++++++++ backend/services/cloud_items.py | 152 +++++++++++++++++++++++++++++++- 3 files changed, 306 insertions(+), 1 deletion(-) diff --git a/backend/api/cloud/operations.py b/backend/api/cloud/operations.py index 8897d5f..9650bcd 100644 --- a/backend/api/cloud/operations.py +++ b/backend/api/cloud/operations.py @@ -47,6 +47,7 @@ from fastapi.responses import JSONResponse, Response from sqlalchemy.ext.asyncio import AsyncSession from api.cloud.schemas import ( + CloudItemDetailOut, CreateFolderRequest, MoveItemRequest, MutationResultOut, @@ -59,7 +60,9 @@ 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, ) @@ -280,6 +283,84 @@ async def open_cloud_file( } +# ── 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: diff --git a/backend/api/cloud/schemas.py b/backend/api/cloud/schemas.py index 88da3bf..a8d9806 100644 --- a/backend/api/cloud/schemas.py +++ b/backend/api/cloud/schemas.py @@ -13,6 +13,10 @@ Phase 13 additions: Phase 14 additions: - CacheStatusOut: aggregate cache usage + settings response (T-14-02, T-14-08) - CacheSettingsUpdateRequest: PATCH body for updating analysis settings/cache limit + +Phase 14.1 additions: + - CloudItemDetailOut: owner-scoped cloud item detail with analysis fields (D-05) + - force field on AnalysisEnqueueRequest: bypass already_current check (D-11) """ from __future__ import annotations @@ -22,6 +26,66 @@ from typing import List, Optional from pydantic import BaseModel, Field, field_validator +# ── Phase 14.1 cloud item detail schema ────────────────────────────────────── + +class CloudItemDetailOut(BaseModel): + """Owner-scoped cloud item detail with analysis fields and source metadata. + + Strict allowlist: object_key, credentials_enc, version_key, and raw provider + URLs are absent by design (T-14.1-03, mirrors CacheStatusOut T-14-02 pattern). + + Fields: + id: DocuVault stable UUID (str). + provider_item_id: Provider-side opaque item identifier. + name: File or folder name. + kind: "file" | "folder". + parent_ref: Provider parent reference (opaque string, no URL). + content_type: MIME type from provider metadata. + size: Provider-reported byte count (provider_size). + modified_at: Provider-reported last modification timestamp. + etag: Provider entity tag for version comparison. + provider: Connection provider string (e.g. "google_drive"). + display_name: Human-readable connection display name. + location: Human path (path_snapshot) or parent_ref — no provider URL. + analysis_status: "pending" | "indexed" | "stale" | "failed" | ... + semantic_index_status: "none" | "indexed" | ... + extracted_text: Text extracted during analysis (None when not yet analyzed). + topics: List of topic names linked to this item (empty when not analyzed). + capabilities: Per-action capability map (action → CloudCapabilityOut). + unsupported_analysis_reason: Reason why analysis is unsupported (None when supported). + is_stale: True when analysis_status == "stale" (D-07 convenience flag). + """ + + # Core identity (no object_key, credentials_enc, version_key — T-14.1-03) + id: str + provider_item_id: str + name: str + kind: str + parent_ref: Optional[str] = None + + # Provider metadata + content_type: Optional[str] = None + size: Optional[int] = None + modified_at: Optional[datetime] = None + etag: Optional[str] = None + + # Connection source metadata (D-04) — no raw provider URLs + provider: str + display_name: str + location: Optional[str] = None # path_snapshot or parent_ref, never a provider URL + + # Analysis fields (D-05, D-07, D-08) + analysis_status: str = "pending" + semantic_index_status: str = "none" + extracted_text: Optional[str] = None + topics: List[str] = [] + + # Action availability (D-05, D-16) + capabilities: dict[str, CloudCapabilityOut] = {} + unsupported_analysis_reason: Optional[str] = None # populated when analyze is unsupported + is_stale: bool = False # True when analysis_status == "stale" (D-07) + + # ── Capability / item schemas ───────────────────────────────────────────────── class CloudCapabilityOut(BaseModel): @@ -285,6 +349,7 @@ class AnalysisEnqueueRequest(BaseModel): provider_item_ids: Required for file/selection/folder scope. recursive: Expand folder subtree recursively. failure_behavior: "pause_batch" (default) | "continue_item" (D-11). + force: When True, bypass already_current check and re-queue indexed items (D-11). """ scope: str = Field(..., description="file | selection | folder | connection") @@ -294,6 +359,15 @@ class AnalysisEnqueueRequest(BaseModel): default="pause_batch", description="pause_batch | continue_item", ) + force: bool = Field( + default=False, + description=( + "When True, bypass the already_current check and re-queue supported items " + "even if their version_key matches a prior indexed run. " + "Preserves existing idempotent behavior when False (default). " + "No provider mutation is performed regardless of this flag (ANALYZE-07)." + ), + ) # ── Phase 14 analysis response schemas ──────────────────────────────────────── diff --git a/backend/services/cloud_items.py b/backend/services/cloud_items.py index e9ef7b4..4251924 100644 --- a/backend/services/cloud_items.py +++ b/backend/services/cloud_items.py @@ -14,7 +14,7 @@ from __future__ import annotations import uuid from dataclasses import dataclass, field from datetime import datetime, timezone -from typing import Optional, Sequence +from typing import List, Optional, Sequence from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession @@ -33,6 +33,47 @@ class CloudItemNotFound(ValueError): """Cloud item does not exist for the given owner/connection.""" +# ── Cloud item detail dataclass ─────────────────────────────────────────────── + +@dataclass +class CloudItemDetail: + """Credential-free detail payload for a single cloud item. + + Built by resolve_owned_cloud_item_detail from DB metadata only — no byte + hydration is performed (T-14.1-06 / CACHE-03). + + Forbidden fields: object_key, credentials_enc, version_key, raw provider URLs. + """ + + # Core item identity + id: str + provider_item_id: str + name: str + kind: str + parent_ref: Optional[str] + + # Provider metadata + content_type: Optional[str] + size: Optional[int] + modified_at: Optional[datetime] + etag: Optional[str] + + # Connection source metadata (D-04) — no raw provider URLs or credentials + provider: str + display_name: str + location: Optional[str] # path_snapshot or parent_ref; never a provider URL + + # Analysis fields (D-05, D-07, D-08) + analysis_status: str + semantic_index_status: str + extracted_text: Optional[str] + topics: List[str] + + # Capability / unsupported-analysis convenience fields (D-16) + is_stale: bool + unsupported_analysis_reason: Optional[str] + + # ── Connection resolution ───────────────────────────────────────────────────── async def resolve_owned_connection( @@ -62,6 +103,115 @@ async def resolve_owned_connection( return conn +# ── Cloud item detail resolution ───────────────────────────────────────────── + +async def resolve_owned_cloud_item_detail( + session: AsyncSession, + *, + user_id, + connection_id, + provider_item_id: str, +) -> CloudItemDetail: + """Return owner-scoped cloud item detail from DB metadata only. + + Performs three DB reads: + 1. resolve_owned_connection — verifies the connection is owned by user_id. + 2. Select CloudItem by (connection_id, provider_item_id, user_id). + 3. Select CloudItemTopic→Topic join for topic names. + + No provider bytes are downloaded. No get_object or hydrate_and_cache_bytes + is called (T-14.1-06, CACHE-03). This function is metadata-only. + + Args: + session: Active async SQLAlchemy session. + user_id: Authenticated user UUID. + connection_id: Cloud connection UUID. + provider_item_id: Provider-side opaque item identifier. + + Returns: + CloudItemDetail dataclass populated from DB rows. + + Raises: + ConnectionNotFound: Connection does not exist or belongs to another user. + CloudItemNotFound: Cloud item does not exist for the given connection/owner. + """ + from db.models import CloudItemTopic, Topic # local import avoids circular + + # 1. Ownership gate (T-14.1-04, T-14.1-06) + conn = await resolve_owned_connection( + session, connection_id=connection_id, user_id=user_id + ) + + uid = user_id if isinstance(user_id, uuid.UUID) else uuid.UUID(str(user_id)) + cid = connection_id if isinstance(connection_id, uuid.UUID) else uuid.UUID(str(connection_id)) + + # 2. Resolve the cloud item (must belong to same user) + result = await session.execute( + select(CloudItem).where( + CloudItem.connection_id == cid, + CloudItem.provider_item_id == provider_item_id, + CloudItem.user_id == uid, + CloudItem.deleted_at.is_(None), + ) + ) + item = result.scalars().first() + if item is None: + raise CloudItemNotFound( + f"Cloud item {provider_item_id!r} not found in connection {connection_id!r}" + ) + + # 3. Resolve topic names via CloudItemTopic→Topic join (metadata-only) + topics_result = await session.execute( + select(Topic.name).join( + CloudItemTopic, CloudItemTopic.topic_id == Topic.id + ).where( + CloudItemTopic.cloud_item_id == item.id + ) + ) + topic_names: List[str] = list(topics_result.scalars().all()) + + # 4. Derive convenience / source metadata fields + + # location: prefer path_snapshot (human path), fall back to parent_ref (opaque) + # — never expose raw provider URLs in this field (T-14.1-03) + location: Optional[str] = getattr(item, "path_snapshot", None) or item.parent_ref + + # unsupported_analysis_reason: populated when the item cannot be analyzed + # Uses the same logic as cloud_analysis._is_supported without importing that module + unsupported_reason: Optional[str] = None + if item.kind == "folder": + unsupported_reason = "Folders cannot be analyzed" + else: + # Simple extension/MIME check duplicated here to avoid a cross-service import. + # The authoritative supported-type check lives in services.cloud_analysis._is_supported. + from services.cloud_analysis import _is_supported as _ca_is_supported + if not _ca_is_supported(item): + unsupported_reason = "File type is not supported for analysis" + + is_stale = (item.analysis_status == "stale") + + return CloudItemDetail( + id=str(item.id), + provider_item_id=item.provider_item_id, + name=item.name, + kind=item.kind, + parent_ref=item.parent_ref, + content_type=item.content_type, + size=item.provider_size, + modified_at=item.modified_at, + etag=item.etag, + provider=conn.provider, + display_name=conn.display_name_override or conn.display_name, + location=location, + analysis_status=item.analysis_status, + semantic_index_status=item.semantic_index_status, + extracted_text=item.extracted_text, + topics=topic_names, + is_stale=is_stale, + unsupported_analysis_reason=unsupported_reason, + ) + + # ── Item listing ────────────────────────────────────────────────────────────── async def list_cloud_children(