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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user