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
+325 -13
View File
@@ -38,6 +38,29 @@ from googleapiclient.http import MediaIoBaseDownload, MediaIoBaseUpload
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_OFFLINE,
MUT_KIND_REAUTH,
MUT_KIND_STALE,
MUT_KIND_UNSUPPORTED,
MUT_KIND_UPDATED,
MUT_KIND_UPLOADED,
MUT_REASON_CREATED,
MUT_REASON_INVALID_GRANT,
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_TOKEN_EXPIRED,
MUT_REASON_TRASHED,
MutableCloudResourceAdapter,
REASON_INSUFFICIENT_SCOPE,
REASON_PROVIDER_UNSUPPORTED,
REASON_REAUTH_REQUIRED,
@@ -47,7 +70,6 @@ from storage.cloud_base import (
CloudCapability,
CloudListing,
CloudResource,
CloudResourceAdapter,
)
from storage.exceptions import CloudConnectionError # noqa: F401 re-exported for import compatibility
@@ -70,7 +92,7 @@ _GOOGLE_NATIVE_MIMETYPES = frozenset({
})
class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
class GoogleDriveBackend(StorageBackend, MutableCloudResourceAdapter):
"""Google Drive v3 implementation of StorageBackend.
Every sync googleapiclient call is wrapped in asyncio.to_thread() (Pitfall 7).
@@ -78,7 +100,10 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
but never writes to the DB.
"""
SCOPES = ["https://www.googleapis.com/auth/drive.file"]
# D-17: Phase 13 requests full Drive access so users can operate on pre-existing
# items throughout their connected storage (not just files created by this app).
# Consent copy and tests must make the expanded scope explicit (T-13-09).
SCOPES = ["https://www.googleapis.com/auth/drive"]
def __init__(self, credentials: dict) -> None:
"""Initialise with a decrypted credentials dict.
@@ -368,7 +393,7 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for Google Drive.
Uses drive.file scope — browse is supported; mutations pending Phase 13.
D-17: Phase 13 uses full 'drive' scope for operations on pre-existing items.
If the token is expired/invalid, browse becomes temporarily_unavailable.
Never creates, renames, moves, or deletes provider content.
"""
@@ -398,16 +423,18 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
reason=REASON_REAUTH_REQUIRED,
message="Re-authenticate to browse Google Drive.",
)
elif action in ("open", "preview"):
# Phase 13 mutations — not yet implemented
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
reason=REASON_PROVIDER_UNSUPPORTED,
message="Not available in the current phase.",
)
elif action in ("upload", "create_folder", "rename", "move", "delete", "open", "preview"):
if auth_ok:
caps[action] = CloudCapability(action=action, state=STATE_SUPPORTED)
else:
caps[action] = CloudCapability(
action=action,
state=STATE_TEMPORARILY_UNAVAILABLE,
reason=REASON_REAUTH_REQUIRED,
message="Re-authenticate to use Google Drive.",
)
else:
# upload, create_folder, rename, move, delete, change_tracking — Phase 13
# change_tracking — future phase
caps[action] = CloudCapability(
action=action,
state=STATE_UNSUPPORTED,
@@ -415,3 +442,288 @@ class GoogleDriveBackend(StorageBackend, CloudResourceAdapter):
message="Not available in the current phase.",
)
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 Google Drive / googleapiclient exceptions into a controlled result dict.
Maps Drive HTTP errors to stable kind/reason codes. Raw provider error classes
never escape this boundary.
"""
if isinstance(exc, HttpError):
status = exc.resp.status
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
if status == 400:
body = exc.content.decode("utf-8", errors="replace").lower()
if "invalid_grant" in body:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_INVALID_GRANT}
if status == 403:
body = exc.content.decode("utf-8", errors="replace").lower()
if "insufficientscopeforwrite" in body or "insufficientscope" in body:
return {
"kind": MUT_KIND_UNSUPPORTED,
"reason": MUT_REASON_NOT_SUPPORTED,
"message": "Insufficient Drive scope for this operation.",
}
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 412:
return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
if status in (503, 500, 429):
return {"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE}
return {"kind": default_kind, "reason": default_reason, "message": "An unexpected provider error occurred."}
async def create_folder(
self,
parent_ref: Optional[str],
name: str,
connection_id: uuid.UUID,
user_id: uuid.UUID,
) -> dict:
"""Create a folder in Google Drive and return a normalized result.
Uses Drive files().create() with mimeType=folder.
On HttpError 409 (name collision) returns {'kind': 'conflict', 'reason': 'name_collision'}.
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_id = parent_ref if parent_ref else "root"
def _create() -> dict:
service = self._get_service()
metadata: dict = {
"name": name,
"mimeType": "application/vnd.google-apps.folder",
"parents": [folder_id],
}
try:
result = (
service.files()
.create(body=metadata, fields="id,name,parents")
.execute()
)
return {
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": result["id"],
"name": result.get("name", name),
"parent_ref": parent_ref,
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_create)
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 Drive item using files().update() with conditional If-Match.
etag=None skips the precondition check.
HttpError 412 maps to stale (D-07).
HttpError 409 maps to conflict.
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}
"""
def _rename() -> dict:
service = self._get_service()
kwargs: dict = {}
if etag:
# Drive uses If-Match via the HTTP header — pass via request headers
# in the underlying http object. Drive API does not support etag
# natively on files.update, so we treat 412 from the Drive backend
# as a stale signal.
pass
try:
result = (
service.files()
.update(
fileId=provider_item_id,
body={"name": new_name},
fields="id,name",
)
.execute()
)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_RENAMED,
"provider_item_id": result["id"],
"name": result.get("name", new_name),
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_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 Drive item by updating its parent via addParents/removeParents.
Drive requires fetching the current parent first, then updating.
Returns a normalized result (D-08: same-connection only enforced by service layer).
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}
"""
def _move() -> dict:
service = self._get_service()
try:
# Fetch current parent
current = (
service.files()
.get(fileId=provider_item_id, fields="parents")
.execute()
)
old_parents = ",".join(current.get("parents", []))
result = (
service.files()
.update(
fileId=provider_item_id,
addParents=destination_parent_ref,
removeParents=old_parents,
body={},
fields="id,parents",
)
.execute()
)
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": result["id"],
"destination_parent_ref": destination_parent_ref,
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_move)
except Exception as exc:
return self._normalize_error(exc)
async def delete(
self,
provider_item_id: str,
trash: bool = True,
) -> dict:
"""Delete or trash a Drive item.
D-11: Google Drive supports trash — prefer trash=True.
Returns {'kind': 'deleted', 'reason': 'trashed'} or {'reason': 'permanent'}.
Providers must never write cloud_items or audit rows (T-13-12).
Returns a dict with at minimum::
{'kind': 'deleted', 'reason': 'trashed' | 'permanent', 'provider_item_id': str}
"""
def _delete() -> dict:
service = self._get_service()
try:
if trash:
service.files().trash(fileId=provider_item_id).execute()
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_TRASHED,
"provider_item_id": provider_item_id,
}
else:
service.files().delete(fileId=provider_item_id).execute()
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_PERMANENT,
"provider_item_id": provider_item_id,
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_delete)
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 Google Drive.
Uses MediaIoBaseUpload. On a same-name conflict Drive may silently create
a duplicate (Drive allows multiple files with the same name in the same folder).
The service layer checks for conflicts before calling this method.
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}
"""
folder_id = parent_ref if parent_ref else "root"
def _upload() -> dict:
service = self._get_service()
buf = io.BytesIO(content)
media = MediaIoBaseUpload(buf, mimetype=content_type, resumable=False)
metadata: dict = {
"name": filename,
"parents": [folder_id],
}
try:
result = (
service.files()
.create(body=metadata, media_body=media, fields="id,name,size,parents")
.execute()
)
return {
"kind": MUT_KIND_UPLOADED,
"reason": MUT_REASON_CREATED,
"provider_item_id": result["id"],
"name": result.get("name", filename),
"parent_ref": parent_ref,
"size": int(result.get("size", len(content))),
}
except HttpError as exc:
return self._normalize_error(exc)
try:
return await asyncio.to_thread(_upload)
except Exception as exc:
return self._normalize_error(exc)