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)
This commit is contained in:
curo1305
2026-06-26 21:54:28 +02:00
parent cfba896a44
commit a0d5c1d6c4
3 changed files with 306 additions and 1 deletions
+151 -1
View File
@@ -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(