Files
kite/backend/services/cloud_operations.py
T
curo1305 6784d3bdb7 feat(13-03): add cloud operations service and reconnect/health/test routes
- Create backend/services/cloud_operations.py as the single Phase 13 orchestration seam:
  get_connection_health (D-12), test_connection (D-13), reconnect_connection (CONN-01/02/03,
  D-14), disconnect_connection (D-16, explicit CloudItem cascade for SQLite compatibility)
- Add POST /connections/{id}/reconnect route (CONN-01/02/03, D-14)
- Add GET /connections/{id}/health route (D-12, T-13-02)
- Add POST /connections/{id}/test route (D-13, T-13-02)
- Update DELETE /connections/{id} to use service-layer disconnect with explicit
  CloudItem deletion (D-16; covers FK-cascade-less environments like SQLite tests)
- Fix (Rule 2 - T-13-02): add _scrub_audit_metadata() to admin audit log API to
  remove credential fields before returning audit rows to admin callers
- All 14 reconnect tests pass + 110 provider-contract tests pass
- Pre-existing failures in test_cloud_mutations and test_extractor are unrelated
2026-06-22 18:43:50 +02:00

314 lines
12 KiB
Python

"""
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()