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:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user