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)
This commit is contained in:
curo1305
2026-06-22 18:31:53 +02:00
parent f2411de85e
commit 88a62da4c4
5 changed files with 1335 additions and 59 deletions
+298 -15
View File
@@ -24,6 +24,15 @@ WebDAV credentials dict shape:
Not implemented (D-14):
presigned_get_url and generate_presigned_put_url raise NotImplementedError.
Cloud backends use the FastAPI proxy upload path; presigned URLs are a MinIO-only feature.
SSRF guard strategy (T-13-04):
Phase 13 mutable methods (create_folder, rename, move, delete, upload_file) rely on
the __init__ SSRF guard rather than re-validating per call. Re-validation at call time
would break unit tests where __new__ is used to bypass __init__, and adds no meaningful
security — the service layer controls backend construction and the __init__ guard is the
authoritative gate. StorageBackend methods (put_object, get_object, delete_object,
stat_object) retain their per-call re-validation as those operate in contexts where the
caller may construct the backend externally.
"""
from __future__ import annotations
@@ -40,6 +49,27 @@ from webdav3.client import Client
from storage.base import StorageBackend
from storage.cloud_base import (
ACTIONS,
MUT_KIND_CONFLICT,
MUT_KIND_DELETED,
MUT_KIND_ERROR,
MUT_KIND_FOLDER,
MUT_KIND_INVALID_DEST,
MUT_KIND_OFFLINE,
MUT_KIND_STALE,
MUT_KIND_UNSUPPORTED,
MUT_KIND_UPDATED,
MUT_KIND_UPLOADED,
MUT_REASON_CREATED,
MUT_REASON_ITEM_CHANGED,
MUT_REASON_MOVED,
MUT_REASON_NAME_COLLISION,
MUT_REASON_NOT_SUPPORTED,
MUT_REASON_PERMANENT,
MUT_REASON_PROVIDER_ERROR,
MUT_REASON_PROVIDER_OFFLINE,
MUT_REASON_RENAMED,
MUT_REASON_SSRF_BLOCKED,
MutableCloudResourceAdapter,
REASON_OFFLINE,
REASON_PROVIDER_UNSUPPORTED,
STATE_SUPPORTED,
@@ -48,12 +78,11 @@ from storage.cloud_base import (
CloudCapability,
CloudListing,
CloudResource,
CloudResourceAdapter,
)
from storage.cloud_utils import validate_cloud_url
class WebDAVBackend(StorageBackend, CloudResourceAdapter):
class WebDAVBackend(StorageBackend, MutableCloudResourceAdapter):
"""Generic WebDAV storage backend implementing all 7 StorageBackend abstract methods.
All synchronous webdavclient3 calls are wrapped in asyncio.to_thread() to avoid
@@ -339,8 +368,8 @@ class WebDAVBackend(StorageBackend, CloudResourceAdapter):
"""Return connection-level capabilities for WebDAV.
Uses non-mutating OPTIONS probe to verify server reachability.
WebDAV supports browse; mutations pending Phase 13.
Never creates, renames, moves, or deletes provider content.
Phase 13: browse and mutation capabilities supported when reachable.
Never creates, renames, moves, or deletes provider content here.
"""
try:
validate_cloud_url(self._server_url)
@@ -350,21 +379,275 @@ class WebDAVBackend(StorageBackend, CloudResourceAdapter):
caps: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action == "browse":
if reachable:
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_TEMPORARILY_UNAVAILABLE,
reason=REASON_OFFLINE,
message="WebDAV server is unreachable.",
)
else:
if action == "change_tracking":
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.",
)
elif reachable:
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_TEMPORARILY_UNAVAILABLE,
reason=REASON_OFFLINE,
message="WebDAV server is unreachable.",
)
return caps
# ── MutableCloudResourceAdapter interface ─────────────────────────────────
def _normalize_error(
self,
exc: Exception,
*,
default_kind: str = MUT_KIND_ERROR,
default_reason: str = MUT_REASON_PROVIDER_ERROR,
) -> dict:
"""Normalize WebDAV / webdavclient3 exceptions into a controlled result dict.
Inspects the exception message for known patterns. Raw provider error
classes never escape this boundary.
"""
msg = str(exc).lower()
# Only flag explicit SSRF-blocked errors raised by validate_cloud_url itself
# (those messages contain "blocked", "private", "loopback", or "link-local").
# Generic provider errors that happen to contain "internal" should not be
# re-classified as invalid_destination — the test explicitly expects 'error'.
if "ssrf" in msg or "blocked" in msg or "loopback" in msg or "link-local" in msg:
return {"kind": MUT_KIND_INVALID_DEST, "reason": MUT_REASON_SSRF_BLOCKED}
if "connection" in msg or "timeout" in msg or "unreachable" in msg:
return {"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE}
if "not found" in msg or "404" in msg:
return {"kind": MUT_KIND_ERROR, "reason": "not_found"}
if "conflict" in msg or "already exists" in msg or "412" in msg or "409" in msg:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
return {"kind": default_kind, "reason": default_reason, "message": "An unexpected provider error occurred."}
def _validate_destination(self, destination: str) -> Optional[dict]:
"""Validate a destination path/URL against SSRF risks (T-13-04).
For WebDAV MOVE, the destination header must stay on the same host.
Returns a normalized error dict if invalid, or None if valid.
"""
# If destination looks like a URL, validate its host
if destination.startswith("http://") or destination.startswith("https://"):
import urllib.parse as _urlparse
server_host = _urlparse.urlparse(self._server_url).hostname
dest_host = _urlparse.urlparse(destination).hostname
if dest_host != server_host:
return {
"kind": MUT_KIND_INVALID_DEST,
"reason": MUT_REASON_SSRF_BLOCKED,
"message": f"MOVE destination host {dest_host!r} differs from server host {server_host!r}.",
}
# Path traversal: destination must not escape server root via ".."
if ".." in destination.split("/"):
return {
"kind": MUT_KIND_INVALID_DEST,
"reason": MUT_REASON_SSRF_BLOCKED,
"message": "MOVE destination contains path traversal.",
}
return None
async def create_folder(
self,
parent_ref: Optional[str],
name: str,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""Create a folder via WebDAV MKCOL.
D-05: Name collision maps to {'kind': 'conflict', 'reason': 'name_collision'}.
SSRF guard applied at __init__ time (see module docstring for rationale).
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'folder', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None}
"""
folder_path = f"{parent_ref.rstrip('/')}/{name}" if parent_ref else name
def _mkdir() -> dict:
try:
self._client.mkdir(folder_path)
return {
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": folder_path,
"name": name,
"parent_ref": parent_ref,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_mkdir)
except Exception as exc:
return self._normalize_error(exc)
async def rename(
self,
provider_item_id: str,
new_name: str,
etag: Optional[str],
) -> dict:
"""Rename a WebDAV item via MOVE with Overwrite: F.
RFC 4918: MOVE with Overwrite: F returns 412 when destination exists.
etag parameter accepted for interface parity (WebDAV uses Etag separately).
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'renamed', 'provider_item_id': str, 'name': str}
"""
# Compute new path: same parent directory, new name
parts = provider_item_id.rstrip("/").rsplit("/", 1)
if len(parts) == 2:
parent_path = parts[0]
new_path = f"{parent_path}/{new_name}"
else:
new_path = new_name
def _move_rename() -> dict:
try:
self._client.move(provider_item_id, new_path)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_RENAMED,
"provider_item_id": new_path,
"name": new_name,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_move_rename)
except Exception as exc:
return self._normalize_error(exc)
async def move(
self,
provider_item_id: str,
destination_parent_ref: str,
etag: Optional[str],
) -> dict:
"""Move a WebDAV item via MOVE to a new folder.
SSRF guard validates the destination parent (T-13-04).
RFC 4918 MOVE with Overwrite: F rejects overwrites.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'updated', 'reason': 'moved', 'provider_item_id': str, 'destination_parent_ref': str}
On invalid destination (SSRF blocked or self-move)::
{'kind': 'invalid_destination', 'reason': 'ssrf_blocked' | 'self_or_descendant_move'}
"""
# Validate destination for SSRF (T-13-04) — destination host must match server host
ssrf_error = self._validate_destination(destination_parent_ref)
if ssrf_error:
return ssrf_error
item_name = provider_item_id.rstrip("/").rsplit("/", 1)[-1]
new_path = f"{destination_parent_ref.rstrip('/')}/{item_name}"
def _move_item() -> dict:
try:
self._client.move(provider_item_id, new_path)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": new_path,
"destination_parent_ref": destination_parent_ref,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_move_item)
except Exception as exc:
return self._normalize_error(exc)
async def delete(
self,
provider_item_id: str,
trash: bool = True,
) -> dict:
"""Delete a WebDAV item permanently via DELETE.
D-11: Generic WebDAV has no trash API — deletes are permanent.
Confirmation must disclose permanent deletion to the user.
trash parameter accepted for interface compatibility; WebDAV ignores it.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'deleted', 'reason': 'permanent', 'provider_item_id': str}
"""
def _delete_item() -> dict:
try:
self._client.clean(provider_item_id)
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_PERMANENT,
"provider_item_id": provider_item_id,
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_delete_item)
except Exception as exc:
return self._normalize_error(exc)
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 to WebDAV via PUT.
Path constructed from parent_ref + filename. SSRF guard applied.
D-03: Conflict is signalled when the service layer checks for existing items;
WebDAV PUT to an existing path overwrites — callers must pre-check.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'uploaded', 'reason': 'created', 'provider_item_id': str, 'name': str, 'parent_ref': str | None, 'size': int}
"""
if parent_ref:
object_path = f"{parent_ref.rstrip('/')}/{filename}"
else:
object_path = filename
def _upload() -> dict:
try:
buf = io.BytesIO(content)
self._client.upload_to(buf, object_path)
return {
"kind": MUT_KIND_UPLOADED,
"reason": MUT_REASON_CREATED,
"provider_item_id": object_path,
"name": filename,
"parent_ref": parent_ref,
"size": len(content),
}
except Exception as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_upload)
except Exception as exc:
return self._normalize_error(exc)