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:
+21
-1
@@ -25,6 +25,26 @@ from storage.minio_backend import MinIOBackend
|
||||
router = APIRouter(prefix="/api/admin", tags=["audit"])
|
||||
|
||||
_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 = [
|
||||
"id",
|
||||
"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,
|
||||
"resource_id": str(entry.resource_id) if entry.resource_id 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(),
|
||||
}
|
||||
|
||||
|
||||
@@ -601,6 +601,120 @@ async def rename_connection(
|
||||
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)
|
||||
@account_limiter.limit("100/minute")
|
||||
async def delete_connection(
|
||||
@@ -609,13 +723,22 @@ async def delete_connection(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> None:
|
||||
"""Disconnect a cloud connection."""
|
||||
request.state.current_user = current_user
|
||||
conn = await _get_owned_connection(session, connection_id, current_user.id)
|
||||
"""Disconnect a cloud connection.
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
@@ -631,8 +754,17 @@ async def delete_connection(
|
||||
metadata_={"provider": provider},
|
||||
)
|
||||
|
||||
await session.delete(conn)
|
||||
await session.commit()
|
||||
# Use service layer for explicit cascade delete (D-16, T-13-12)
|
||||
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}")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user