Files
kite/backend/storage/cloud_base.py
T
curo1305 88a62da4c4 feat(13-03): extend cloud contract with mutable adapter and provider implementations
- Add MutableCloudResourceAdapter abstract class to cloud_base.py with Phase 13
  mutable-operation vocabulary (MUT_KIND_* / MUT_REASON_* constants, PreviewSupport)
- Add abstract methods: create_folder, rename, move, delete, upload_file + _normalize_error
- Update GoogleDriveBackend to implement MutableCloudResourceAdapter with Drive-scope
  methods; prefer trash() for delete (D-11); update SCOPES to drive (D-17)
- Update OneDriveBackend to implement MutableCloudResourceAdapter; use
  PublicClientApplication for refresh_token flow; Graph DELETE is always permanent (D-11)
- Update WebDAVBackend to implement MutableCloudResourceAdapter; SSRF guard via __init__
  (not per-call) for mutable methods; _validate_destination for MOVE SSRF (T-13-04)
- NextcloudBackend inherits all mutable methods from WebDAVBackend (no changes needed)
- Add build_mutable_cloud_adapter() to cloud_backend_factory.py
- All 184 tests pass (test_cloud_backends.py + test_cloud_provider_contract.py)
2026-06-22 18:31:53 +02:00

566 lines
20 KiB
Python

"""
Provider-neutral cloud resource capability and mutation contract for DocuVault.
Defines the stable vocabulary (action keys, capability states, reason codes),
immutable normalized value types (CloudCapability, CloudResource, CloudListing,
mutation result types), and the abstract adapter interfaces:
- CloudResourceAdapter — read-only browse/capability interface (Phase 12).
- MutableCloudResourceAdapter — mutable-operation interface (Phase 13).
Design decisions (12-CONTEXT.md D-06 through D-10, 13-CONTEXT.md D-01 through D-18):
- Structurally unsupported actions remain visible but greyed out (D-06).
- Capability discovery must never mutate provider content (D-08).
- Permanent vs. temporary limitations have distinct visual states (D-09).
- Mutable operations return typed result dicts — routers never parse raw
provider error shapes (T-13-10).
- Preview support is explicit and binary-only; Google Workspace and Office
document preview/editing are excluded from Phase 13 (D-18).
- Providers are never given permission to write audit rows or cloud_items
directly — that is the service layer's responsibility (T-13-12).
Credentials are never fields on any value type.
"""
from __future__ import annotations
import uuid
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
# ── Action keys ───────────────────────────────────────────────────────────────
ACTIONS = frozenset({
"browse",
"open",
"preview",
"upload",
"create_folder",
"rename",
"move",
"delete",
"change_tracking",
})
# ── Capability states ─────────────────────────────────────────────────────────
STATE_SUPPORTED = "supported"
STATE_UNSUPPORTED = "unsupported"
STATE_TEMPORARILY_UNAVAILABLE = "temporarily_unavailable"
CAPABILITY_STATES = frozenset({
STATE_SUPPORTED,
STATE_UNSUPPORTED,
STATE_TEMPORARILY_UNAVAILABLE,
})
# ── Stable reason codes ───────────────────────────────────────────────────────
REASON_PROVIDER_UNSUPPORTED = "provider_unsupported"
REASON_INSUFFICIENT_SCOPE = "insufficient_scope"
REASON_READ_ONLY = "read_only"
REASON_REAUTH_REQUIRED = "reauth_required"
REASON_OFFLINE = "offline"
REASON_ITEM_RESTRICTED = "item_restricted"
KNOWN_REASONS = frozenset({
REASON_PROVIDER_UNSUPPORTED,
REASON_INSUFFICIENT_SCOPE,
REASON_READ_ONLY,
REASON_REAUTH_REQUIRED,
REASON_OFFLINE,
REASON_ITEM_RESTRICTED,
})
# ── Normalized value types ────────────────────────────────────────────────────
@dataclass(frozen=True)
class CloudCapability:
"""Normalized capability descriptor for a single action.
state: one of the three CAPABILITY_STATES constants.
reason: one of the KNOWN_REASONS codes when state != supported; None otherwise.
message: controlled adapter output — safe for display; never raw provider text.
Invariants enforced at construction:
- state must be a known capability state.
- reason and message are only present when state is not supported.
- reason, when present, must be a known reason code.
"""
action: str
state: str
reason: Optional[str] = None
message: Optional[str] = None
def __post_init__(self) -> None:
if self.state not in CAPABILITY_STATES:
raise ValueError(
f"Unknown capability state {self.state!r}; "
f"must be one of {sorted(CAPABILITY_STATES)}"
)
if self.action not in ACTIONS:
raise ValueError(
f"Unknown action {self.action!r}; must be one of {sorted(ACTIONS)}"
)
if self.state == STATE_SUPPORTED:
if self.reason is not None or self.message is not None:
raise ValueError(
"reason and message must be None when state is 'supported'"
)
else:
if self.reason is not None and self.reason not in KNOWN_REASONS:
raise ValueError(
f"Unknown reason code {self.reason!r}; "
f"must be one of {sorted(KNOWN_REASONS)}"
)
@dataclass(frozen=True)
class CloudResource:
"""Normalized cloud item metadata — provider-independent representation.
id: stable DocuVault UUID for this item; survives provider rename/move.
provider_item_id: opaque provider-assigned identifier (e.g. Drive file ID).
connection_id: UUID of the CloudConnection that owns this item.
user_id: UUID of the owning user (security boundary).
name: display name as reported by the provider.
kind: "file" or "folder".
parent_ref: opaque provider reference to the parent folder; None at root.
content_type: MIME type for files; None for folders.
size: provider-reported size in bytes; None if not reported or for folders.
modified_at: provider-reported modification time; None if not available.
etag: provider etag or version string for change detection; None if absent.
capabilities: per-item capability overrides merged with connection defaults.
"""
id: uuid.UUID
provider_item_id: str
connection_id: uuid.UUID
user_id: uuid.UUID
name: str
kind: str # "file" | "folder"
parent_ref: Optional[str] = None
content_type: Optional[str] = None
size: Optional[int] = None
modified_at: Optional[datetime] = None
etag: Optional[str] = None
capabilities: dict[str, CloudCapability] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.kind not in ("file", "folder"):
raise ValueError(f"kind must be 'file' or 'folder', got {self.kind!r}")
for action, cap in self.capabilities.items():
if not isinstance(cap, CloudCapability):
raise TypeError(
f"capabilities[{action!r}] must be a CloudCapability instance"
)
@dataclass(frozen=True)
class CloudListing:
"""Result of listing a folder from a provider.
items: normalized CloudResource children of the listed folder.
complete: True when the listing represents the full authoritative contents
(safe to mark unseen children deleted on reconciliation).
False when the listing is partial, paginated, or failed — retained rows
must NOT be marked deleted.
next_page_token: opaque token for continuation; None when exhausted.
"""
items: tuple[CloudResource, ...]
complete: bool = True
next_page_token: Optional[str] = None
def __post_init__(self) -> None:
# Coerce list to tuple for immutability
object.__setattr__(self, "items", tuple(self.items))
# ── Abstract adapter ──────────────────────────────────────────────────────────
class CloudResourceAdapter(ABC):
"""Read-only cloud resource adapter contract for Phase 12.
Phase 12 interface is intentionally limited to browse and capability
discovery. No method here creates, renames, moves, or deletes provider
content. Mutation methods are added in Phase 13 on a separate subclass
interface.
Credentials are passed at construction time (or via dependency injection)
and are never exposed as fields or return values.
"""
@abstractmethod
async def list_folder(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
parent_ref: Optional[str] = None,
page_token: Optional[str] = None,
) -> CloudListing:
"""List the direct children of a folder.
parent_ref=None lists the connection root.
Returns a CloudListing with complete=False on partial or failed fetches.
Raises CloudConnectionError (from storage.exceptions) on auth failures.
Never mutates provider content as a side effect.
"""
...
@abstractmethod
async def get_capabilities(
self,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for all defined ACTIONS.
The returned dict must contain an entry for every action in ACTIONS.
Probing must not create, rename, move, or delete any provider content.
"""
...
def merge_item_capabilities(
self,
connection_caps: dict[str, CloudCapability],
item_caps: dict[str, CloudCapability],
) -> dict[str, CloudCapability]:
"""Merge per-item overrides onto connection defaults.
Item capabilities take precedence over connection capabilities.
Actions absent from item_caps fall back to connection_caps.
The result always contains every action in ACTIONS.
"""
merged: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action in item_caps:
merged[action] = item_caps[action]
elif action in connection_caps:
merged[action] = connection_caps[action]
else:
# Safe default: unsupported with no reason
merged[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="This action is not supported by the provider.",
)
return merged
# ── Mutable operation result kinds ────────────────────────────────────────────
# Stable kind vocabulary for mutation result dicts (T-13-10).
# Routers and service layers must match these constants — never raw provider strings.
MUT_KIND_FOLDER = "folder" # create_folder succeeded
MUT_KIND_UPLOADED = "uploaded" # upload_file succeeded
MUT_KIND_UPDATED = "updated" # rename / move succeeded
MUT_KIND_DELETED = "deleted" # delete succeeded
MUT_KIND_CONFLICT = "conflict" # name collision detected
MUT_KIND_STALE = "stale" # etag mismatch / item changed externally
MUT_KIND_OFFLINE = "offline" # provider temporarily unreachable
MUT_KIND_REAUTH = "reauth_required" # credential failure (not transient)
MUT_KIND_UNSUPPORTED = "unsupported" # provider does not support this operation
MUT_KIND_INVALID_DEST = "invalid_destination" # bad move destination (SSRF / self)
MUT_KIND_ERROR = "error" # unclassified provider error
MUT_KINDS = frozenset({
MUT_KIND_FOLDER, MUT_KIND_UPLOADED, MUT_KIND_UPDATED, MUT_KIND_DELETED,
MUT_KIND_CONFLICT, MUT_KIND_STALE, MUT_KIND_OFFLINE, MUT_KIND_REAUTH,
MUT_KIND_UNSUPPORTED, MUT_KIND_INVALID_DEST, MUT_KIND_ERROR,
})
# Stable reason codes for mutation results
MUT_REASON_CREATED = "created"
MUT_REASON_RENAMED = "renamed"
MUT_REASON_MOVED = "moved"
MUT_REASON_TRASHED = "trashed"
MUT_REASON_PERMANENT = "permanent"
MUT_REASON_NAME_COLLISION = "name_collision"
MUT_REASON_ITEM_CHANGED = "item_changed"
MUT_REASON_PROVIDER_OFFLINE = "provider_offline"
MUT_REASON_TOKEN_EXPIRED = "token_expired"
MUT_REASON_INVALID_GRANT = "invalid_grant"
MUT_REASON_NOT_SUPPORTED = "not_supported"
MUT_REASON_SSRF_BLOCKED = "ssrf_blocked"
MUT_REASON_SELF_MOVE = "self_or_descendant_move"
MUT_REASON_PROVIDER_ERROR = "provider_error"
# ── Preview support declaration ───────────────────────────────────────────────
class PreviewSupport:
"""Explicit binary-only preview support declaration (D-18).
Providers declare support here rather than inferring it from MIME quirks.
Google Workspace and Microsoft Office document rendering/editing are
explicitly excluded from Phase 13 preview scope.
"""
# MIME types with explicit binary preview support (D-18, Phase 13)
SUPPORTED_BINARY_PREVIEW_TYPES: frozenset[str] = frozenset({
"application/pdf",
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"text/plain",
"text/html",
"text/css",
"text/javascript",
"application/json",
"application/xml",
"text/xml",
"text/markdown",
"text/csv",
})
# MIME types explicitly excluded from Phase 13 preview (D-18)
EXCLUDED_FROM_PREVIEW: frozenset[str] = frozenset({
# Google Workspace formats — require export, out of Phase 13 scope
"application/vnd.google-apps.document",
"application/vnd.google-apps.spreadsheet",
"application/vnd.google-apps.presentation",
"application/vnd.google-apps.form",
"application/vnd.google-apps.drawing",
# Microsoft Office formats — require Office rendering, out of Phase 13 scope
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/msword",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
})
@classmethod
def is_supported(cls, content_type: Optional[str]) -> bool:
"""Return True if the MIME type has explicit binary preview support.
Returns False for excluded formats and unknown types.
Phase 13 unsupported formats fall back to the authorized download
path (D-02); they are never opened via raw provider URLs.
"""
if content_type is None:
return False
# Strip parameters (e.g. "text/plain; charset=utf-8" -> "text/plain")
base_type = content_type.split(";")[0].strip().lower()
if base_type in cls.EXCLUDED_FROM_PREVIEW:
return False
return base_type in cls.SUPPORTED_BINARY_PREVIEW_TYPES
# ── Abstract mutable adapter ──────────────────────────────────────────────────
class MutableCloudResourceAdapter(CloudResourceAdapter):
"""Phase 13 mutable-operation contract extending the read-only adapter.
Defines the provider-neutral mutation interface for Phase 13:
create_folder, rename, move, delete, and upload_file.
Contract guarantees (T-13-10, T-13-11, T-13-12):
- Every mutation method returns a normalized result dict with at minimum
{'kind': <MUT_KIND_*>, 'reason': <MUT_REASON_*>}. Routers and service
layers must never parse raw provider error classes — normalization
happens inside the adapter.
- Providers never write cloud_items or audit rows. Those responsibilities
belong exclusively to the service layer (cloud_operations.py /
cloud_items.py).
- Refreshed credentials are returned upward via the result dict rather
than stored by the provider (T-13-11). The service layer is the only
place that persists encrypted credentials.
"""
@abstractmethod
async def create_folder(
self,
parent_ref: Optional[str],
name: str,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""Create a folder in the provider and return a normalized result.
Returns a dict with at minimum::
{
'kind': 'folder',
'reason': 'created',
'provider_item_id': str, # opaque new folder ID
'name': str, # actual name used (may differ on collision)
'parent_ref': str | None,
}
On name collision::
{'kind': 'conflict', 'reason': 'name_collision'}
On transient provider failure::
{'kind': 'offline', 'reason': 'provider_offline'}
On auth failure::
{'kind': 'reauth_required', 'reason': 'token_expired' | 'invalid_grant'}
On unsupported operation::
{'kind': 'unsupported', 'reason': 'not_supported'}
"""
...
@abstractmethod
async def rename(
self,
provider_item_id: str,
new_name: str,
etag: Optional[str],
) -> dict:
"""Rename a provider item and return a normalized result.
etag is used for conditional write (D-07 stale detection).
A None etag skips the precondition check.
Returns a dict with at minimum::
{
'kind': 'updated',
'reason': 'renamed',
'provider_item_id': str,
'name': str,
}
On stale metadata (etag mismatch)::
{'kind': 'stale', 'reason': 'item_changed'}
On name collision::
{'kind': 'conflict', 'reason': 'name_collision'}
"""
...
@abstractmethod
async def move(
self,
provider_item_id: str,
destination_parent_ref: str,
etag: Optional[str],
) -> dict:
"""Move a provider item to a new parent folder.
destination_parent_ref must be on the same connection — cross-connection
moves are rejected by the service layer, but providers should also reject
obviously invalid destinations (SSRF guard for WebDAV, D-09).
Returns a dict with at minimum::
{
'kind': 'updated',
'reason': 'moved',
'provider_item_id': str,
'destination_parent_ref': str,
}
On stale metadata (etag mismatch)::
{'kind': 'stale', 'reason': 'item_changed'}
On invalid destination (SSRF blocked or self-move)::
{'kind': 'invalid_destination', 'reason': 'ssrf_blocked' | 'self_or_descendant_move'}
"""
...
@abstractmethod
async def delete(
self,
provider_item_id: str,
trash: bool = True,
) -> dict:
"""Delete a provider item (prefer trash/recycle-bin when supported, D-11).
trash=True: use the provider's trash/recycle-bin when available.
trash=False: permanently delete.
Returns a dict with at minimum::
{
'kind': 'deleted',
'reason': 'trashed' | 'permanent',
'provider_item_id': str,
}
On auth failure::
{'kind': 'reauth_required', 'reason': 'token_expired' | 'invalid_grant'}
"""
...
@abstractmethod
async def upload_file(
self,
parent_ref: Optional[str],
filename: str,
content: bytes,
content_type: str,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""Upload a file into the provider folder identified by parent_ref.
content is raw bytes — never streamed out to provider URLs or logged.
connection_id and user_id are passed for audit trail and reconciliation.
Returns a dict with at minimum::
{
'kind': 'uploaded',
'reason': 'created',
'provider_item_id': str,
'name': str,
'parent_ref': str | None,
'size': int,
}
On name collision::
{'kind': 'conflict', 'reason': 'name_collision', 'existing_name': str}
On auth failure::
{'kind': 'reauth_required', 'reason': 'token_expired' | 'invalid_grant'}
"""
...
def _normalize_error(
self,
exc: Exception,
*,
default_kind: str = MUT_KIND_ERROR,
default_reason: str = MUT_REASON_PROVIDER_ERROR,
) -> dict:
"""Normalize a provider exception into a controlled result dict.
Provider-specific error classes must never escape this boundary.
Subclasses should override to handle their own error types before
calling super() for the fallback.
Args:
exc: The provider exception to normalize.
default_kind: Fallback kind when the exception is unclassified.
default_reason: Fallback reason when the exception is unclassified.
Returns:
A normalized result dict with at minimum {'kind': ..., 'reason': ...}.
"""
return {
"kind": default_kind,
"reason": default_reason,
"message": "An unexpected provider error occurred.",
}