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"])
_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(),
}
+137 -5
View File
@@ -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}")