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
This commit is contained in:
curo1305
2026-06-22 18:43:50 +02:00
parent 88a62da4c4
commit 6784d3bdb7
3 changed files with 471 additions and 6 deletions
+21 -1
View File
@@ -25,6 +25,26 @@ from storage.minio_backend import MinIOBackend
router = APIRouter(prefix="/api/admin", tags=["audit"]) router = APIRouter(prefix="/api/admin", tags=["audit"])
_VALID_EVENT_PREFIXES = frozenset({"auth", "document", "folder", "share", "admin", "cloud"}) _VALID_EVENT_PREFIXES = frozenset({"auth", "document", "folder", "share", "admin", "cloud"})
# Fields that must never appear in admin audit log responses (T-13-02)
_AUDIT_SCRUB_KEYS = frozenset({
"access_token", "refresh_token", "credentials_enc",
"client_secret", "client_id", "password",
})
def _scrub_audit_metadata(metadata: Optional[dict]) -> Optional[dict]:
"""Remove credential fields from audit metadata before returning to admin (T-13-02).
The admin audit log must never surface raw tokens or credential fields even if
a bug caused them to be written to metadata_. This scrub is a defence-in-depth
gate applied to every audit row regardless of how the metadata was written.
"""
if not metadata or not isinstance(metadata, dict):
return metadata
return {k: v for k, v in metadata.items() if k not in _AUDIT_SCRUB_KEYS}
_CSV_FIELDS = [ _CSV_FIELDS = [
"id", "id",
"event_type", "event_type",
@@ -48,7 +68,7 @@ def _audit_base_fields(entry: AuditLog) -> dict:
"actor_id": str(entry.actor_id) if entry.actor_id else None, "actor_id": str(entry.actor_id) if entry.actor_id else None,
"resource_id": str(entry.resource_id) if entry.resource_id else None, "resource_id": str(entry.resource_id) if entry.resource_id else None,
"ip_address": str(entry.ip_address) if entry.ip_address else None, "ip_address": str(entry.ip_address) if entry.ip_address else None,
"metadata_": entry.metadata_, "metadata_": _scrub_audit_metadata(entry.metadata_), # T-13-02: credential scrub
"created_at": entry.created_at.isoformat(), "created_at": entry.created_at.isoformat(),
} }
+137 -5
View File
@@ -601,6 +601,120 @@ async def rename_connection(
return {"id": str(conn.id), "display_name": body.display_name} return {"id": str(conn.id), "display_name": body.display_name}
@router.post("/connections/{connection_id}/reconnect", status_code=status.HTTP_200_OK)
@account_limiter.limit("100/minute")
async def reconnect_connection(
connection_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Reconnect an AUTH_FAILED connection in-place.
CONN-01: Patches the existing CloudConnection row — no new row created.
CONN-02: Re-encrypts credentials (refreshed if possible, original otherwise).
CONN-03: Response never exposes raw credentials, tokens, or provider URLs.
D-14: Cached cloud items are preserved as stale (not deleted).
Ownership verified via service layer (T-13-01).
"""
request.state.current_user = current_user
from services.cloud_operations import reconnect_connection as _svc_reconnect
from services.cloud_items import ConnectionNotFound
from services.cloud_cache import invalidate_provider_cache # lazy import
try:
result = await _svc_reconnect(
session,
connection_id=connection_id,
user_id=current_user.id,
master_key=_master_key(),
)
except ConnectionNotFound:
raise HTTPException(status_code=404, detail="Connection not found")
# Invalidate provider cache so the next browse is fresh (D-14)
invalidate_provider_cache(str(current_user.id), result["provider"])
_ip = get_client_ip(request)
await write_audit_log(
session,
event_type="cloud.reconnect",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=connection_id,
ip_address=_ip,
metadata_={"provider": result["provider"], "connection_id": str(connection_id)},
)
return result
@router.get("/connections/{connection_id}/health", status_code=status.HTTP_200_OK)
@account_limiter.limit("100/minute")
async def get_connection_health(
connection_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Return connection health status.
D-12: Connection health is available explicitly (not inferred from browse).
Response includes 'status': 'healthy' | 'degraded' | 'auth_failed' | 'offline'.
Response never exposes credentials (T-13-02).
Ownership verified via service layer (T-13-01).
"""
request.state.current_user = current_user
from services.cloud_operations import get_connection_health as _svc_health
from services.cloud_items import ConnectionNotFound
try:
return await _svc_health(
session,
connection_id=connection_id,
user_id=current_user.id,
)
except ConnectionNotFound:
raise HTTPException(status_code=404, detail="Connection not found")
@router.post("/connections/{connection_id}/test", status_code=status.HTTP_200_OK)
@account_limiter.limit("100/minute")
async def test_connection(
connection_id: uuid.UUID,
request: Request,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user),
) -> dict:
"""Run an explicit connection health probe and update connection status.
D-13: Explicit Test action triggers a real health probe against the provider.
No probing on every folder navigation. Updates the connection status row.
Response includes 'status' field (same vocabulary as health endpoint).
Response never exposes credentials (T-13-02).
Ownership verified via service layer (T-13-01).
"""
request.state.current_user = current_user
from services.cloud_operations import test_connection as _svc_test
from services.cloud_items import ConnectionNotFound
try:
return await _svc_test(
session,
connection_id=connection_id,
user_id=current_user.id,
master_key=_master_key(),
)
except ConnectionNotFound:
raise HTTPException(status_code=404, detail="Connection not found")
@router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/connections/{connection_id}", status_code=status.HTTP_204_NO_CONTENT)
@account_limiter.limit("100/minute") @account_limiter.limit("100/minute")
async def delete_connection( async def delete_connection(
@@ -609,13 +723,22 @@ async def delete_connection(
session: AsyncSession = Depends(get_db), session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_regular_user), current_user: User = Depends(get_regular_user),
) -> None: ) -> None:
"""Disconnect a cloud connection.""" """Disconnect a cloud connection.
request.state.current_user = current_user
conn = await _get_owned_connection(session, connection_id, current_user.id)
D-16: Removes credentials_enc and all connection-scoped CloudItems.
Provider files are NOT deleted — only cached metadata is cleaned up.
Ownership verified via service layer (T-13-01).
"""
request.state.current_user = current_user
# Resolve connection first to get provider for cache invalidation and audit
conn = await _get_owned_connection(session, connection_id, current_user.id)
provider = conn.provider provider = conn.provider
from services.cloud_cache import invalidate_provider_cache # lazy import from services.cloud_cache import invalidate_provider_cache # lazy import
from services.cloud_operations import disconnect_connection as _svc_disconnect
from services.cloud_items import ConnectionNotFound
invalidate_provider_cache(str(current_user.id), provider) invalidate_provider_cache(str(current_user.id), provider)
@@ -631,8 +754,17 @@ async def delete_connection(
metadata_={"provider": provider}, metadata_={"provider": provider},
) )
await session.delete(conn) # Use service layer for explicit cascade delete (D-16, T-13-12)
await session.commit() try:
await _svc_disconnect(
session,
connection_id=connection_id,
user_id=current_user.id,
)
except ConnectionNotFound:
# Connection was already deleted between the ownership check and the
# service call — this is idempotent, return 204 normally
pass
@router.get("/folders/{provider}/{folder_id:path}") @router.get("/folders/{provider}/{folder_id:path}")
+313
View File
@@ -0,0 +1,313 @@
"""
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()