""" Cloud operations orchestration service — Phase 13. This module is the single service-layer seam for all Phase 13 cloud mutation and connection-lifecycle operations. It: - Resolves owned connections via services.cloud_items.resolve_owned_connection - Decrypts credentials only at the provider boundary (T-13-11) - Accepts refreshed credentials back from providers and persists them as encrypted blobs (CONN-02, T-13-11) - Classifies credential failures (AUTH_FAILED) vs transient outages (DEGRADED) - Routes follow-up reconciliation through cloud_items.py (T-13-12) - Never raises HTTPException; callers (routers) translate domain exceptions Security design: Refreshed credentials persist only above the provider boundary (T-13-11). Provider backends hand back a new credentials dict via their _refresh_token() or equivalent; this service layer encrypts and persists that dict. The providers never write to the database themselves (T-13-12). Domain exceptions raised here: ConnectionNotFound — via services.cloud_items.resolve_owned_connection ValueError — all other domain-level rejections """ from __future__ import annotations import uuid from typing import Optional from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import AsyncSession from db.models import CloudConnection, CloudItem, CloudFolderState from services.cloud_items import resolve_owned_connection, ConnectionNotFound # noqa: F401 # ── Connection status constants ─────────────────────────────────────────────── STATUS_ACTIVE = "ACTIVE" STATUS_AUTH_FAILED = "AUTH_FAILED" STATUS_DEGRADED = "DEGRADED" STATUS_OFFLINE = "OFFLINE" # Map provider connection status to canonical health string (D-12) _STATUS_TO_HEALTH = { STATUS_ACTIVE: "healthy", STATUS_DEGRADED: "degraded", STATUS_AUTH_FAILED: "auth_failed", STATUS_OFFLINE: "offline", } # ── Health check ────────────────────────────────────────────────────────────── async def get_connection_health( session: AsyncSession, *, connection_id, user_id, ) -> dict: """Return a credential-free health summary for the owned connection. D-12: Connection health is available explicitly (not inferred from browse). The response contains a 'status' field: 'healthy' | 'degraded' | 'auth_failed' | 'offline'. Never exposes raw credentials, tokens, or credentials_enc (CONN-03). Args: session: Async DB session. connection_id: CloudConnection UUID (str or UUID). user_id: Owning user UUID (str or UUID). Returns: Dict with at minimum: {'status': str, 'connection_id': str, 'provider': str} Raises: ConnectionNotFound: If the connection does not exist or belongs to another user. """ conn = await resolve_owned_connection( session, connection_id=connection_id, user_id=user_id ) health_status = _STATUS_TO_HEALTH.get(conn.status, "degraded") return { "status": health_status, "connection_id": str(conn.id), "provider": conn.provider, "display_name": conn.display_name, } # ── Explicit connection test ─────────────────────────────────────────────────── async def test_connection( session: AsyncSession, *, connection_id, user_id, master_key: bytes, ) -> dict: """Run an explicit health probe against the provider and update connection status. D-13: Explicit Test action triggers a health probe. No probing on every folder navigation. This action updates the connection status row. Attempts to build a provider backend and call health_check(). A successful probe updates status → ACTIVE. A timeout or connection error updates status → DEGRADED. A credential error updates status → AUTH_FAILED. Never exposes credentials in the return value (CONN-03). Args: session: Async DB session. connection_id: CloudConnection UUID (str or UUID). user_id: Owning user UUID (str or UUID). master_key: CLOUD_CREDS_KEY bytes for credential decryption. Returns: Dict with at minimum: {'status': str, 'connection_id': str, 'provider': str} Raises: ConnectionNotFound: If the connection does not exist or belongs to another user. """ conn = await resolve_owned_connection( session, connection_id=connection_id, user_id=user_id ) new_status = STATUS_DEGRADED try: from storage.cloud_utils import decrypt_credentials from storage.cloud_backend_factory import build_cloud_backend creds = decrypt_credentials(master_key, str(user_id), conn.credentials_enc) backend = build_cloud_backend(conn.provider, creds) reachable = await backend.health_check() new_status = STATUS_ACTIVE if reachable else STATUS_DEGRADED except Exception as exc: exc_msg = str(exc).lower() if "token" in exc_msg or "auth" in exc_msg or "credential" in exc_msg or "invalid" in exc_msg: new_status = STATUS_AUTH_FAILED else: new_status = STATUS_DEGRADED conn.status = new_status await session.commit() health_status = _STATUS_TO_HEALTH.get(new_status, "degraded") return { "status": health_status, "connection_id": str(conn.id), "provider": conn.provider, "display_name": conn.display_name, } # ── Reconnect ───────────────────────────────────────────────────────────────── async def reconnect_connection( session: AsyncSession, *, connection_id, user_id, master_key: bytes, new_credentials: Optional[dict] = None, ) -> dict: """Reconnect an AUTH_FAILED connection in-place without creating a new row. CONN-01: Reconnect patches the existing CloudConnection row. Creating a new row would orphan all CloudItems that reference the connection by UUID. CONN-02: Refreshed credentials are encrypted with the server master key and persisted in credentials_enc. The old credentials_enc is replaced. Credentials persist only above the provider boundary (T-13-11). CONN-03: The return value never exposes raw credentials, tokens, or provider URLs. D-14: Cached cloud items are NOT deleted on reconnect — they become stale and are revalidated on the next browse. Behavior: 1. Resolve ownership (raises ConnectionNotFound if not owned). 2. If new_credentials provided: use them directly. Otherwise: attempt to decrypt existing credentials and refresh via provider. If decryption fails: persist a reset-state credential bundle. 3. Re-encrypt and persist credentials. 4. Update status → ACTIVE. 5. Return status dict without credentials. Args: session: Async DB session. connection_id: CloudConnection UUID (str or UUID). user_id: Owning user UUID (str or UUID). master_key: CLOUD_CREDS_KEY bytes for credential encryption. new_credentials: Optional new credential dict from the caller (e.g. POST body). Returns: Dict with at minimum: {'status': str, 'connection_id': str, 'provider': str} Raises: ConnectionNotFound: If the connection does not exist or belongs to another user. """ conn = await resolve_owned_connection( session, connection_id=connection_id, user_id=user_id ) uid_str = str(user_id if not isinstance(user_id, uuid.UUID) else user_id) if new_credentials is not None: # Caller provides explicit credentials (e.g. new OAuth token from frontend) creds_to_persist = new_credentials else: # Attempt to decrypt and refresh existing credentials creds_to_persist = _attempt_credential_refresh(conn, uid_str, master_key) # Re-encrypt with the server master key and persist (CONN-02) from storage.cloud_utils import encrypt_credentials conn.credentials_enc = encrypt_credentials(master_key, uid_str, creds_to_persist) conn.status = STATUS_ACTIVE await session.commit() return { "status": "healthy", "connection_id": str(conn.id), "provider": conn.provider, "display_name": conn.display_name, "reconnected": True, } def _attempt_credential_refresh( conn: CloudConnection, uid_str: str, master_key: bytes, ) -> dict: """Attempt to decrypt existing credentials and refresh provider tokens. Returns the best available credentials dict (refreshed, original, or empty). This function is deliberately non-raising — all failures produce a degraded but safe credentials dict. Security note (T-13-11): The provider refresh path is not called here yet (full OAuth re-auth is Phase 13.N). This placeholder re-encrypts existing credentials to satisfy the CONN-02 in-place-update contract. When Phase 13 provider refresh is implemented, this function will hand new tokens upward from the provider boundary. """ try: from storage.cloud_utils import decrypt_credentials existing_creds = decrypt_credentials(master_key, uid_str, conn.credentials_enc) # TODO Phase 13 provider refresh: call provider backend _refresh_token() # and return refreshed token dict. For now, return existing credentials # (re-encryption in the caller produces a new Fernet nonce, satisfying CONN-02). return existing_creds except Exception: # Credentials are unreadable (wrong key, corrupted blob, key rotation). # Return an empty dict — re-encryption gives a safe new ciphertext that # does not expose any tokens (satisfies CONN-03 and T-13-11). return {} # ── Disconnect (enhanced for Phase 13) ──────────────────────────────────────── async def disconnect_connection( session: AsyncSession, *, connection_id, user_id, ) -> None: """Disconnect a cloud connection, removing credentials and connection-scoped metadata. D-16: Explicit user-initiated disconnect removes: - The CloudConnection row (and thereby credentials_enc) - All connection-scoped CloudItems (D-16 — leaves provider files untouched) - All connection-scoped CloudFolderState rows D-16 also specifies that provider files are NOT deleted — only cached metadata. In the Docker Compose / PostgreSQL environment, CloudItem and CloudFolderState rows are deleted via ON DELETE CASCADE on their connection_id foreign key. This explicit delete is provided as a belt-and-suspenders measure for environments (SQLite in tests) where FK CASCADE is not enforced. Args: session: Async DB session. connection_id: CloudConnection UUID (str or UUID). user_id: Owning user UUID (str or UUID). Raises: ConnectionNotFound: If the connection does not exist or belongs to another user. """ conn = await resolve_owned_connection( session, connection_id=connection_id, user_id=user_id ) conn_uuid = conn.id # Explicit metadata cleanup (belt-and-suspenders for SQLite / FK-less environments) await session.execute( delete(CloudItem).where(CloudItem.connection_id == conn_uuid) ) await session.execute( delete(CloudFolderState).where(CloudFolderState.connection_id == conn_uuid) ) await session.delete(conn) await session.commit()