Files
kite/backend/api/cloud.py
T
curo1305 b1a136b5be fix(05-12): resolve 3 critical code review findings
CR-01: add `except HTTPException: raise` before broad except in
stream_document_content — prevents 503 (reconnect prompt) from being
swallowed and replaced with misleading 502

CR-02: move pre-flight credential checks BEFORE Redis setex in
oauth_initiate — no orphan state tokens written for unconfigured providers;
also adds onedrive_tenant_id to OneDrive pre-flight condition (WR-02)

CR-03: add CLOUD_CREDS_KEY to celery-worker environment in docker-compose.yml
— worker cannot decrypt cloud credentials without this key; every cloud
document task was silently failing at runtime

WR-03: assert Redis store empty after 400 pre-flight responses in both
new tests — confirms no token leak on misconfigured-provider requests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 18:04:09 +02:00

947 lines
37 KiB
Python

"""
Cloud storage connection management API endpoints for DocuVault.
Endpoints:
GET /api/cloud/oauth/initiate/{provider} — start OAuth flow (Google Drive, OneDrive)
GET /api/cloud/oauth/callback/{provider} — OAuth callback: exchange code, store creds
POST /api/cloud/connections/webdav — connect WebDAV / Nextcloud with credentials
GET /api/cloud/connections — list all active cloud connections
DELETE /api/cloud/connections/{id} — disconnect and purge credentials_enc
GET /api/cloud/folders/{provider}/{folder_id} — lazy-load folder tree (TTL-cached 60s)
PATCH /api/users/me/default-storage — update user's default storage backend
Security invariants:
- ALL endpoints use Depends(get_regular_user) — admin role gets 403 (D-18, D-19)
- credentials_enc column NEVER appears in any API response (CloudConnectionOut whitelist)
- DELETE /connections/{id} returns 404 for wrong-owner connections (prevents ID enumeration, D-19)
- OAuth state token: secrets.token_urlsafe(32), Redis key "oauth_state:{token}", TTL 1800, single-use
- write_audit_log metadata: only {"provider": provider} — no credentials, no tokens (T-05-05-08)
- WebDAV server_url validated via validate_cloud_url() before backend instantiation (T-05-05-05)
"""
from __future__ import annotations
import asyncio
import secrets
import uuid
import urllib.parse
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from api.admin import CloudConnectionOut
from config import settings
from db.models import CloudConnection, User
from deps.auth import get_regular_user
from deps.db import get_db
from services.audit import write_audit_log
from storage.cloud_utils import encrypt_credentials, decrypt_credentials, validate_cloud_url
# ── Router definitions ────────────────────────────────────────────────────────
router = APIRouter(prefix="/api/cloud", tags=["cloud"])
users_router = APIRouter(prefix="/api/users", tags=["users"])
# ── Provider constants ────────────────────────────────────────────────────────
VALID_OAUTH_PROVIDERS = {"google_drive", "onedrive"}
VALID_WEBDAV_PROVIDERS = {"nextcloud", "webdav"}
_DISPLAY_NAMES = {
"google_drive": "Google Drive",
"onedrive": "OneDrive",
"nextcloud": "Nextcloud",
"webdav": "WebDAV server",
}
# ── Request models ────────────────────────────────────────────────────────────
class WebDAVConnectRequest(BaseModel):
"""Request body for POST /api/cloud/connections/webdav."""
server_url: str
username: str
password: str
provider: str # "nextcloud" or "webdav"
class DefaultStorageRequest(BaseModel):
"""Request body for PATCH /api/users/me/default-storage."""
backend: str
# ── _call_cloud_op helper ─────────────────────────────────────────────────────
async def _call_cloud_op(conn: CloudConnection, user: User, session: AsyncSession, op_fn):
"""Wrap a cloud operation with transparent token refresh and invalid_grant handling.
Design (B2, D-05, D-06):
1. Call op_fn() — a zero-argument async callable that performs the cloud operation.
2. On CloudConnectionError(reason="token_expired"): decrypt current credentials,
refresh the access token via the provider, encrypt the new credentials, update
conn.credentials_enc in the DB, rebuild the backend instance, and retry op_fn() once.
3. On CloudConnectionError(reason="invalid_grant"): set conn.status="REQUIRES_REAUTH",
commit, write audit log, raise HTTPException(503).
4. All other exceptions are propagated unchanged.
Args:
conn: The CloudConnection ORM row for the current user + provider.
user: The authenticated User ORM instance.
session: An active AsyncSession (caller owns commit responsibility).
op_fn: An async callable with no arguments. Must capture the backend instance
in its closure. After token refresh, the caller should pass a new op_fn
that captures the rebuilt backend.
"""
from storage.google_drive_backend import CloudConnectionError # lazy import
try:
return await op_fn()
except CloudConnectionError as exc:
if exc.reason == "token_expired":
# Refresh token and retry once
master_key = settings.cloud_creds_key.encode()
credentials = decrypt_credentials(master_key, str(user.id), conn.credentials_enc)
try:
new_credentials = await _refresh_oauth_token(conn.provider, credentials)
except Exception:
# If refresh itself fails, treat as invalid_grant
conn.status = "REQUIRES_REAUTH"
await session.commit()
await write_audit_log(
session,
event_type="cloud.requires_reauth",
user_id=user.id,
actor_id=user.id,
resource_id=conn.id,
ip_address=None,
metadata_={"provider": conn.provider},
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
) from exc
# Persist new credentials
conn.credentials_enc = encrypt_credentials(master_key, str(user.id), new_credentials)
await session.commit()
# Rebuild backend and retry
rebuilt_backend = _build_backend(conn.provider, new_credentials)
async def retried_op():
return await _invoke_backend_op(rebuilt_backend, op_fn)
try:
return await retried_op()
except CloudConnectionError as retry_exc:
if retry_exc.reason == "invalid_grant":
conn.status = "REQUIRES_REAUTH"
await session.commit()
await write_audit_log(
session,
event_type="cloud.requires_reauth",
user_id=user.id,
actor_id=user.id,
resource_id=conn.id,
ip_address=None,
metadata_={"provider": conn.provider},
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
) from retry_exc
raise
elif exc.reason == "invalid_grant":
conn.status = "REQUIRES_REAUTH"
await session.commit()
await write_audit_log(
session,
event_type="cloud.requires_reauth",
user_id=user.id,
actor_id=user.id,
resource_id=conn.id,
ip_address=None,
metadata_={"provider": conn.provider},
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
) from exc
raise
async def _refresh_oauth_token(provider: str, credentials: dict) -> dict:
"""Refresh the OAuth access token for google_drive or onedrive.
Returns an updated credentials dict with new access_token and expiry.
Raises Exception on failure (including invalid_grant from the provider).
"""
if provider == "google_drive":
from google.oauth2.credentials import Credentials # lazy import
from google.auth.transport.requests import Request # lazy import
creds = Credentials(
token=credentials.get("access_token"),
refresh_token=credentials.get("refresh_token"),
token_uri=credentials.get("token_uri", "https://oauth2.googleapis.com/token"),
client_id=credentials.get("client_id"),
client_secret=credentials.get("client_secret"),
)
await asyncio.to_thread(creds.refresh, Request())
new_creds = dict(credentials)
new_creds["access_token"] = creds.token
if creds.expiry:
new_creds["expiry"] = creds.expiry.isoformat()
return new_creds
elif provider == "onedrive":
import msal # lazy import
app = msal.ConfidentialClientApplication(
settings.onedrive_client_id,
client_credential=settings.onedrive_client_secret,
authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}",
)
result = await asyncio.to_thread(
app.acquire_token_by_refresh_token,
credentials.get("refresh_token", ""),
scopes=["Files.ReadWrite", "offline_access"],
)
if "error" in result:
raise Exception(f"Token refresh failed: {result.get('error_description', result['error'])}")
new_creds = dict(credentials)
new_creds["access_token"] = result["access_token"]
if "refresh_token" in result:
new_creds["refresh_token"] = result["refresh_token"]
return new_creds
raise ValueError(f"Cannot refresh token for provider: {provider}")
def _build_backend(provider: str, credentials: dict):
"""Build a cloud backend instance from provider name and credentials dict."""
if provider == "google_drive":
from storage.google_drive_backend import GoogleDriveBackend # lazy import
return GoogleDriveBackend(credentials)
elif provider == "onedrive":
from storage.onedrive_backend import OneDriveBackend # lazy import
return OneDriveBackend(credentials)
elif provider == "nextcloud":
from storage.nextcloud_backend import NextcloudBackend # lazy import
return NextcloudBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
elif provider == "webdav":
from storage.webdav_backend import WebDAVBackend # lazy import
return WebDAVBackend(
credentials["server_url"],
credentials["username"],
credentials["password"],
)
raise ValueError(f"Unknown provider: {provider}")
async def _invoke_backend_op(backend, op_fn):
"""Call op_fn, but the backend in the closure needs to be the rebuilt one.
Since op_fn is already a closure, we call it directly. This helper exists
so the retry path in _call_cloud_op can call op_fn with minimal friction.
"""
return await op_fn()
# ── Helper: upsert CloudConnection ───────────────────────────────────────────
async def _upsert_cloud_connection(
session: AsyncSession,
user_id: uuid.UUID,
provider: str,
credentials_enc: str,
) -> CloudConnection:
"""Insert or update a CloudConnection row for the given user + provider.
If an existing row is found, updates credentials_enc and sets status=ACTIVE.
If no row exists, inserts a new one.
Args:
session: Active async SQLAlchemy session.
user_id: The authenticated user's UUID.
provider: The cloud provider string (e.g. "google_drive").
credentials_enc: The HKDF+Fernet-encrypted credentials string.
Returns:
The CloudConnection ORM instance (added to session, not yet committed).
"""
result = await session.execute(
select(CloudConnection).where(
CloudConnection.user_id == user_id,
CloudConnection.provider == provider,
)
)
conn = result.scalar_one_or_none()
if conn is not None:
conn.credentials_enc = credentials_enc
conn.status = "ACTIVE"
else:
conn = CloudConnection(
id=uuid.uuid4(),
user_id=user_id,
provider=provider,
display_name=_DISPLAY_NAMES.get(provider, provider),
credentials_enc=credentials_enc,
status="ACTIVE",
)
session.add(conn)
return conn
# ── GET /api/cloud/oauth/initiate/{provider} ──────────────────────────────────
@router.get("/oauth/initiate/{provider}")
async def oauth_initiate(
provider: str,
request: Request,
current_user: User = Depends(get_regular_user),
) -> dict:
"""Start the OAuth flow for Google Drive or OneDrive.
Generates a CSRF state token, stores it in Redis with TTL 1800 (30 min),
and returns the provider's authorization URL as JSON so the frontend can
navigate using fetch() with the Bearer header (plan 05-10 fix).
Returns: {"url": "<authorization_url>"}
Security:
- state token is secrets.token_urlsafe(32) — 256 bits of entropy (T-05-05-01)
- Redis key is single-use: deleted in the callback handler (T-05-05-02)
- Only google_drive and onedrive are accepted (T-05-05-06)
- Endpoint requires get_regular_user — no unauthenticated access (T-05-10-01)
"""
from fastapi.responses import JSONResponse # already available via fastapi
if provider not in VALID_OAUTH_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported OAuth provider: {provider}. Valid providers: {sorted(VALID_OAUTH_PROVIDERS)}",
)
# Pre-flight: validate credentials are configured before allocating Redis state
if provider == "google_drive" and (not settings.google_client_id or not settings.google_client_secret):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Google Drive OAuth is not configured on this server. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in your environment.",
)
if provider == "onedrive" and (
not settings.onedrive_client_id
or not settings.onedrive_client_secret
or not settings.onedrive_tenant_id
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="OneDrive OAuth is not configured on this server. Set ONEDRIVE_CLIENT_ID, ONEDRIVE_CLIENT_SECRET, and ONEDRIVE_TENANT_ID in your environment.",
)
state_token = secrets.token_urlsafe(32)
redis_client = request.app.state.redis
await redis_client.setex(f"oauth_state:{state_token}", 1800, str(current_user.id))
redirect_uri = f"{settings.backend_url}/api/cloud/oauth/callback/{provider}"
if provider == "google_drive":
from google_auth_oauthlib.flow import Flow # lazy import
flow = Flow.from_client_config(
{
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
},
scopes=["https://www.googleapis.com/auth/drive.file"],
redirect_uri=redirect_uri,
)
authorization_url, _ = flow.authorization_url(
access_type="offline",
prompt="consent",
state=state_token,
)
return JSONResponse({"url": authorization_url})
elif provider == "onedrive":
import msal # lazy import
app = msal.ConfidentialClientApplication(
settings.onedrive_client_id,
client_credential=settings.onedrive_client_secret,
authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}",
)
auth_url = await asyncio.to_thread(
app.get_authorization_request_url,
scopes=["Files.ReadWrite", "offline_access"],
redirect_uri=redirect_uri,
state=state_token,
)
return JSONResponse({"url": auth_url})
# ── GET /api/cloud/oauth/callback/{provider} ──────────────────────────────────
@router.get("/oauth/callback/{provider}", response_class=RedirectResponse)
async def oauth_callback(
provider: str,
request: Request,
session: AsyncSession = Depends(get_db),
):
"""Handle the OAuth callback from Google Drive or OneDrive.
Validates state token (single-use, from Redis), exchanges the authorization
code for tokens, encrypts credentials, and upserts a CloudConnection row.
On success: redirects to {settings.frontend_url}/settings?cloud_connected={provider}
On any error: redirects to {settings.frontend_url}/settings?cloud_error={message}
Security:
- state token is looked up in Redis (T-05-05-01), deleted on first use (T-05-05-02)
- Missing state → 400 redirect (not silent failure)
- credentials_enc stored, never returned in response
"""
state = request.query_params.get("state")
code = request.query_params.get("code")
error_param = request.query_params.get("error")
frontend_settings = f"{settings.frontend_url}/settings"
try:
if error_param:
raise ValueError(f"OAuth provider returned error: {error_param}")
if provider not in VALID_OAUTH_PROVIDERS:
raise ValueError(f"Unsupported OAuth provider: {provider}")
if not state:
raise ValueError("Missing OAuth state parameter")
redis_client = request.app.state.redis
stored_user_id = await redis_client.get(f"oauth_state:{state}")
if not stored_user_id:
# state is missing or expired — single-use validation failed
return RedirectResponse(
url=f"{frontend_settings}?cloud_error={urllib.parse.quote('Invalid or expired OAuth state. Please try connecting again.')}",
status_code=302,
)
# Delete the Redis key immediately — single-use (T-05-05-02)
await redis_client.delete(f"oauth_state:{state}")
# Parse the user_id from the stored value
if isinstance(stored_user_id, bytes):
stored_user_id = stored_user_id.decode("utf-8")
user_id = uuid.UUID(stored_user_id)
# Load user from DB (the OAuth callback is not authenticated via JWT —
# the state token binds this callback to the correct user session)
user = await session.get(User, user_id)
if user is None or not user.is_active:
raise ValueError("User not found or inactive")
redirect_uri = f"{settings.backend_url}/api/cloud/oauth/callback/{provider}"
master_key = settings.cloud_creds_key.encode()
if provider == "google_drive":
from google_auth_oauthlib.flow import Flow # lazy import
flow = Flow.from_client_config(
{
"web": {
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
},
scopes=["https://www.googleapis.com/auth/drive.file"],
redirect_uri=redirect_uri,
)
await asyncio.to_thread(flow.fetch_token, code=code)
token_credentials = flow.credentials
credentials = {
"access_token": token_credentials.token,
"refresh_token": token_credentials.refresh_token,
"token_uri": token_credentials.token_uri,
"client_id": token_credentials.client_id,
"client_secret": token_credentials.client_secret,
"expiry": token_credentials.expiry.isoformat() if token_credentials.expiry else None,
}
elif provider == "onedrive":
import msal # lazy import
app = msal.ConfidentialClientApplication(
settings.onedrive_client_id,
client_credential=settings.onedrive_client_secret,
authority=f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}",
)
result = await asyncio.to_thread(
app.acquire_token_by_authorization_code,
code,
scopes=["Files.ReadWrite", "offline_access"],
redirect_uri=redirect_uri,
)
if "error" in result:
raise ValueError(f"Token exchange failed: {result.get('error_description', result['error'])}")
credentials = {
"access_token": result["access_token"],
"refresh_token": result.get("refresh_token", ""),
"token_uri": f"https://login.microsoftonline.com/{settings.onedrive_tenant_id}/oauth2/v2.0/token",
"client_id": settings.onedrive_client_id,
"client_secret": settings.onedrive_client_secret,
}
else:
raise ValueError(f"Unsupported OAuth provider: {provider}")
credentials_enc = encrypt_credentials(master_key, str(user_id), credentials)
conn = await _upsert_cloud_connection(session, user_id, provider, credentials_enc)
await session.flush()
await write_audit_log(
session,
event_type="cloud.connected",
user_id=user.id,
actor_id=user.id,
resource_id=conn.id,
ip_address=None,
metadata_={"provider": provider},
)
await session.commit()
return RedirectResponse(
url=f"{frontend_settings}?cloud_connected={provider}",
status_code=302,
)
except Exception as exc:
error_msg = str(exc)
return RedirectResponse(
url=f"{frontend_settings}?cloud_error={urllib.parse.quote(error_msg)}",
status_code=302,
)
# ── POST /api/cloud/connections/webdav ────────────────────────────────────────
@router.post("/connections/webdav", status_code=status.HTTP_201_CREATED)
async def connect_webdav(
body: WebDAVConnectRequest,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Connect a WebDAV or Nextcloud server.
Validates the URL for SSRF, tests the connection with health_check(),
encrypts credentials, and upserts a CloudConnection row.
Security:
- validate_cloud_url() blocks RFC-1918, loopback, and link-local (T-05-05-05)
- health_check() requires a successful PROPFIND before storing credentials
- credentials_enc never returned in response (CloudConnectionOut whitelist)
"""
if body.provider not in VALID_WEBDAV_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported WebDAV provider: {body.provider}. Valid values: {sorted(VALID_WEBDAV_PROVIDERS)}",
)
# SSRF validation (T-05-05-05)
try:
validate_cloud_url(body.server_url)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid server URL: {exc}",
) from exc
# Instantiate backend and run health_check
try:
if body.provider == "nextcloud":
from storage.nextcloud_backend import NextcloudBackend # lazy import
backend = NextcloudBackend(body.server_url, body.username, body.password)
else:
from storage.webdav_backend import WebDAVBackend # lazy import
backend = WebDAVBackend(body.server_url, body.username, body.password)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid server URL: {exc}",
) from exc
try:
ok = await backend.health_check()
if not ok:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Connection test failed — check server URL and credentials",
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Connection test failed — check server URL and credentials: {exc}",
) from exc
credentials = {
"server_url": body.server_url,
"username": body.username,
"password": body.password,
}
master_key = settings.cloud_creds_key.encode()
credentials_enc = encrypt_credentials(master_key, str(current_user.id), credentials)
conn = await _upsert_cloud_connection(session, current_user.id, body.provider, credentials_enc)
await session.flush()
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
await write_audit_log(
session,
event_type="cloud.connected",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=conn.id,
ip_address=_ip,
metadata_={"provider": body.provider},
)
await session.commit()
await session.refresh(conn)
return CloudConnectionOut.model_validate(conn).model_dump()
# ── GET /api/cloud/connections ────────────────────────────────────────────────
@router.get("/connections")
async def list_connections(
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""List all cloud connections for the current user.
Security:
- Only connections owned by current_user.id are returned
- credentials_enc excluded by CloudConnectionOut whitelist (T-05-05-03)
"""
result = await session.execute(
select(CloudConnection).where(CloudConnection.user_id == current_user.id)
)
connections = result.scalars().all()
items = []
master_key = settings.cloud_creds_key.encode()
for conn in connections:
d = CloudConnectionOut.model_validate(conn).model_dump()
if conn.provider in ("nextcloud", "webdav"):
try:
creds = decrypt_credentials(master_key, str(conn.user_id), conn.credentials_enc)
d["server_url"] = creds.get("server_url")
d["connection_username"] = creds.get("username")
except Exception:
pass
items.append(d)
return {"items": items}
# ── GET /api/cloud/connections/{connection_id}/config ────────────────────────
@router.get("/connections/{connection_id}/config")
async def get_connection_config(
connection_id: uuid.UUID,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Return non-secret configuration fields for a WebDAV/Nextcloud connection.
Returns server_url and connection_username (not password) so the frontend
can pre-populate the Edit modal without exposing credentials.
Only applicable to WebDAV / Nextcloud connections (not OAuth providers).
Returns 404 for wrong-owner or unknown connections (prevents ID enumeration).
Returns 400 for OAuth providers (no non-secret config to return).
Security:
- Only connection owned by current_user.id is returned (T-05-05-04)
- password is never included in the response (D-18)
- Returns 404 for wrong-owner connections (prevents ID enumeration)
"""
conn = await session.get(CloudConnection, connection_id)
if conn is None or conn.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
if conn.provider not in VALID_WEBDAV_PROVIDERS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Connection config is only available for WebDAV/Nextcloud connections",
)
master_key = settings.cloud_creds_key.encode()
try:
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
except Exception:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Failed to decrypt connection credentials",
)
# Return non-secret fields only — never expose the password
return {
"id": str(conn.id),
"provider": conn.provider,
"server_url": credentials.get("server_url", ""),
"connection_username": credentials.get("username", ""),
}
# ── DELETE /api/cloud/connections/{connection_id} ─────────────────────────────
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_connection(
connection_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> None:
"""Disconnect a cloud connection and permanently purge credentials_enc.
Returns 404 (not 403) for connections owned by other users to prevent
connection ID enumeration (T-05-05-04, D-19).
On success: connection row is deleted, audit log written, cache invalidated.
"""
conn = await session.get(CloudConnection, connection_id)
# Return 404 for any access failure — prevents ID enumeration (T-05-05-04)
if conn is None or conn.user_id != current_user.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found")
provider = conn.provider
# Invalidate folder listing cache for this provider
from services.cloud_cache import invalidate_provider_cache # lazy import
invalidate_provider_cache(str(current_user.id), provider)
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
await write_audit_log(
session,
event_type="cloud.disconnected",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=conn.id,
ip_address=_ip,
metadata_={"provider": provider},
)
await session.delete(conn)
await session.commit()
# ── GET /api/cloud/folders/{provider}/{folder_id} ─────────────────────────────
@router.get("/folders/{provider}/{folder_id:path}")
async def list_cloud_folders(
provider: str,
folder_id: str,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""List folder contents for a cloud provider (TTL-cached 60 seconds, D-16).
Loads the active CloudConnection for current_user + provider, decrypts
credentials, builds the appropriate backend, and calls list_folder via
the TTL cache.
Returns 404 if no active connection found (prevents enumeration).
"""
all_providers = VALID_OAUTH_PROVIDERS | VALID_WEBDAV_PROVIDERS
if provider not in all_providers:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported provider: {provider}",
)
result = await session.execute(
select(CloudConnection).where(
CloudConnection.user_id == current_user.id,
CloudConnection.provider == provider,
CloudConnection.status == "ACTIVE",
)
)
conn = result.scalar_one_or_none()
if conn is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No active cloud connection found for this provider",
)
master_key = settings.cloud_creds_key.encode()
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
from services.cloud_cache import get_cloud_folders_cached # lazy import
if provider == "google_drive":
from storage.google_drive_backend import GoogleDriveBackend # lazy import
backend = GoogleDriveBackend(credentials)
async def fetch_google_drive() -> list:
"""Fetch Google Drive folder listing via Drive v3 files.list."""
from googleapiclient.discovery import build # lazy import
from google.oauth2.credentials import Credentials # lazy import
import datetime as _dt
expiry_str = credentials.get("expiry")
expiry = None
if expiry_str:
try:
expiry = _dt.datetime.fromisoformat(expiry_str)
except ValueError:
pass
creds = Credentials(
token=credentials.get("access_token"),
refresh_token=credentials.get("refresh_token"),
token_uri=credentials.get("token_uri", "https://oauth2.googleapis.com/token"),
client_id=credentials.get("client_id"),
client_secret=credentials.get("client_secret"),
expiry=expiry,
)
def _list_files():
service = build("drive", "v3", credentials=creds, cache_discovery=False)
query = f"'{folder_id}' in parents and trashed=false"
response = service.files().list(
q=query,
fields="files(id,name,mimeType,size)",
pageSize=200,
).execute()
files = response.get("files", [])
result = []
for f in files:
is_dir = f.get("mimeType") == "application/vnd.google-apps.folder"
result.append({
"id": f["id"],
"name": f["name"],
"is_dir": is_dir,
"size": int(f.get("size", 0)) if not is_dir else 0,
})
return result
return await asyncio.to_thread(_list_files)
items = await get_cloud_folders_cached(
str(current_user.id), provider, folder_id, fetch_google_drive
)
elif provider == "onedrive":
async def fetch_onedrive() -> list:
"""Fetch OneDrive folder listing via Microsoft Graph."""
import httpx # lazy import
access_token = credentials.get("access_token", "")
if folder_id in ("root", ""):
url = "https://graph.microsoft.com/v1.0/me/drive/root/children"
else:
url = f"https://graph.microsoft.com/v1.0/me/drive/items/{folder_id}/children"
async with httpx.AsyncClient() as client:
resp = await client.get(
url,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
result = []
for item in data.get("value", []):
is_dir = "folder" in item
result.append({
"id": item["id"],
"name": item["name"],
"is_dir": is_dir,
"size": item.get("size", 0) if not is_dir else 0,
})
return result
items = await get_cloud_folders_cached(
str(current_user.id), provider, folder_id, fetch_onedrive
)
elif provider in ("nextcloud", "webdav"):
backend = _build_backend(provider, credentials)
# "root" is a frontend sentinel meaning the WebDAV root; translate to ""
webdav_path = "" if folder_id == "root" else folder_id
async def fetch_webdav() -> list:
return await backend.list_folder(webdav_path)
items = await get_cloud_folders_cached(
str(current_user.id), provider, folder_id, fetch_webdav
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Unsupported provider: {provider}",
)
return {"items": items}
# ── PATCH /api/users/me/default-storage ──────────────────────────────────────
@users_router.patch("/me/default-storage")
async def update_default_storage(
body: DefaultStorageRequest,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Update the current user's default storage backend.
The backend value is stored as-is (validated by the frontend dropdown).
Returns the updated default_storage_backend value.
"""
user = await session.get(User, current_user.id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
user.default_storage_backend = body.backend
session.add(user)
await session.commit()
return {"default_storage_backend": user.default_storage_backend}