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
+364 -23
View File
@@ -37,6 +37,28 @@ from config import settings
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,
MutableCloudResourceAdapter,
REASON_PROVIDER_UNSUPPORTED,
REASON_REAUTH_REQUIRED,
STATE_SUPPORTED,
@@ -45,7 +67,6 @@ from storage.cloud_base import (
CloudCapability,
CloudListing,
CloudResource,
CloudResourceAdapter,
)
from storage.google_drive_backend import CloudConnectionError # reuse shared exception
@@ -56,7 +77,7 @@ CHUNK_SIZE = 10 * 1024 * 1024 # 10 MB — above Graph's 4 MB simple upload limi
_GRAPH_HOST = "graph.microsoft.com"
class OneDriveBackend(StorageBackend, CloudResourceAdapter):
class OneDriveBackend(StorageBackend, MutableCloudResourceAdapter):
"""Microsoft Graph / OneDrive implementation of StorageBackend.
Uses MSAL for token management and httpx for async HTTP to Microsoft Graph.
@@ -112,25 +133,28 @@ class OneDriveBackend(StorageBackend, CloudResourceAdapter):
self._credentials = new_creds
async def _refresh_token(self) -> dict | None:
"""Refresh the access token via MSAL.
"""Refresh the access token via MSAL PublicClientApplication.
Wraps the sync MSAL call in asyncio.to_thread() to avoid blocking
the event loop.
Uses PublicClientApplication for personal Microsoft accounts which use
the public OAuth flow. Wraps the sync MSAL call in asyncio.to_thread().
CONN-02: Returns the refreshed credentials dict for the service layer to
encrypt and persist. The adapter itself must not store or encrypt tokens.
Returns:
Updated credentials dict on success.
None if MSAL returns result['error'] == 'invalid_grant' (D-06).
Updated credentials dict (access_token, refresh_token, expires_at) on success.
None if MSAL returns error == 'invalid_grant' (refresh token revoked).
"""
def _msal_refresh() -> dict | None:
app = msal.ConfidentialClientApplication(
app = msal.PublicClientApplication(
client_id=settings.onedrive_client_id,
client_credential=settings.onedrive_client_secret,
authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}",
)
# Note: offline_access must not be mixed with Files.ReadWrite in PublicClientApplication
result = app.acquire_token_by_refresh_token(
self._credentials["refresh_token"],
scopes=["Files.ReadWrite", "offline_access"],
scopes=["https://graph.microsoft.com/Files.ReadWrite"],
)
if result.get("error") == "invalid_grant":
return None # Signal to _ensure_valid_token to raise CloudConnectionError
@@ -392,8 +416,8 @@ class OneDriveBackend(StorageBackend, CloudResourceAdapter):
) -> dict[str, CloudCapability]:
"""Return connection-level capabilities for OneDrive.
Browse is supported when token is valid; mutations pending Phase 13.
Never creates, renames, moves, or deletes provider content.
Browse and Phase 13 mutations are supported when token is valid.
Never creates, renames, moves, or deletes provider content here.
"""
try:
await self._ensure_valid_token()
@@ -410,21 +434,338 @@ class OneDriveBackend(StorageBackend, CloudResourceAdapter):
caps: dict[str, CloudCapability] = {}
for action in ACTIONS:
if action == "browse":
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 browse OneDrive.",
)
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 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 OneDrive.",
)
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 Microsoft Graph / httpx exceptions into a controlled result dict.
Maps Graph HTTP status codes to stable kind/reason codes. Raw provider
error classes never escape this boundary.
"""
status = None
if hasattr(exc, "response") and hasattr(exc.response, "status_code"):
status = exc.response.status_code
elif hasattr(exc, "status_code"):
status = exc.status_code
if status is not None:
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
if status == 403:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_INVALID_GRANT}
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}
if isinstance(exc, CloudConnectionError):
reason = getattr(exc, "reason", MUT_REASON_TOKEN_EXPIRED)
return {"kind": MUT_KIND_REAUTH, "reason": reason}
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 OneDrive using Graph POST /children.
D-05: 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}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
if parent_ref:
url = f"{GRAPH_BASE}/me/drive/items/{parent_ref}/children"
else:
url = f"{GRAPH_BASE}/me/drive/root/children"
try:
async with httpx.AsyncClient() as client:
r = await client.post(
url,
headers={**self._auth_headers(), "Content-Type": "application/json"},
json={"name": name, "folder": {}, "@microsoft.graph.conflictBehavior": "fail"},
timeout=30,
)
if not r.is_success:
status = r.status_code
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
data = r.json()
return {
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": data["id"],
"name": data.get("name", name),
"parent_ref": parent_ref,
}
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 OneDrive item using Graph PATCH with If-Match etag check.
etag is sent as If-Match for stale detection (D-07).
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}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
headers = {**self._auth_headers(), "Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
try:
async with httpx.AsyncClient() as client:
r = await client.patch(
f"{GRAPH_BASE}/me/drive/items/{provider_item_id}",
headers=headers,
json={"name": new_name},
timeout=30,
)
if not r.is_success:
status = r.status_code
if status == 412:
return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
data = r.json()
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_RENAMED,
"provider_item_id": data.get("id", provider_item_id),
"name": data.get("name", new_name),
}
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 OneDrive item to a new parent via Graph PATCH parentReference.
Same-connection enforcement is handled by the service layer (D-08).
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}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
headers = {**self._auth_headers(), "Content-Type": "application/json"}
if etag:
headers["If-Match"] = etag
try:
async with httpx.AsyncClient() as client:
r = await client.patch(
f"{GRAPH_BASE}/me/drive/items/{provider_item_id}",
headers=headers,
json={"parentReference": {"id": destination_parent_ref}},
timeout=30,
)
if not r.is_success:
status = r.status_code
if status == 412:
return {"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
if status == 409:
return {"kind": MUT_KIND_CONFLICT, "reason": MUT_REASON_NAME_COLLISION}
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
data = r.json()
return {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": data.get("id", provider_item_id),
"destination_parent_ref": destination_parent_ref,
}
except Exception as exc:
return self._normalize_error(exc)
async def delete(
self,
provider_item_id: str,
trash: bool = True,
) -> dict:
"""Delete a OneDrive item permanently via Graph DELETE.
D-11: OneDrive Graph API does not expose a REST trash endpoint.
All deletes are permanent — confirmation must say so explicitly.
trash parameter is accepted for interface compatibility but ignored.
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}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
try:
async with httpx.AsyncClient() as client:
r = await client.delete(
f"{GRAPH_BASE}/me/drive/items/{provider_item_id}",
headers=self._auth_headers(),
timeout=30,
)
if not r.is_success and r.status_code != 404:
status = r.status_code
if status == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
return {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_PERMANENT,
"provider_item_id": provider_item_id,
}
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 OneDrive using a resumable upload session.
Uses createUploadSession with conflictBehavior=fail (D-03: no silent overwrite).
409 on the upload maps to conflict result.
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}
"""
try:
await self._ensure_valid_token()
except CloudConnectionError as exc:
return self._normalize_error(exc)
if parent_ref:
remote_path = f"{parent_ref}:/{filename}:"
session_url = f"{GRAPH_BASE}/me/drive/items/{remote_path}/createUploadSession"
else:
session_url = f"{GRAPH_BASE}/me/drive/root:/{filename}:/createUploadSession"
try:
async with httpx.AsyncClient() as client:
session_r = await client.post(
session_url,
headers={**self._auth_headers(), "Content-Type": "application/json"},
json={"item": {"@microsoft.graph.conflictBehavior": "fail"}},
timeout=30,
)
if not session_r.is_success:
if session_r.status_code == 409:
return {
"kind": MUT_KIND_CONFLICT,
"reason": MUT_REASON_NAME_COLLISION,
"existing_name": filename,
}
if session_r.status_code == 401:
return {"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
upload_url = session_r.json()["uploadUrl"]
total_size = len(content)
item_id = ""
offset = 0
while offset < total_size:
chunk = content[offset: offset + CHUNK_SIZE]
chunk_size = len(chunk)
end = offset + chunk_size - 1
chunk_r = await client.put(
upload_url,
content=chunk,
headers={
"Content-Length": str(chunk_size),
"Content-Range": f"bytes {offset}-{end}/{total_size}",
"Content-Type": content_type,
},
timeout=60,
)
if chunk_r.status_code in (200, 201):
item_id = chunk_r.json().get("id", "")
elif not chunk_r.is_success:
return {"kind": MUT_KIND_ERROR, "reason": MUT_REASON_PROVIDER_ERROR}
offset += chunk_size
return {
"kind": MUT_KIND_UPLOADED,
"reason": MUT_REASON_CREATED,
"provider_item_id": item_id,
"name": filename,
"parent_ref": parent_ref,
"size": total_size,
}
except Exception as exc:
return self._normalize_error(exc)