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