- 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)
566 lines
20 KiB
Python
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.",
|
|
}
|