feat(05-05): add cloud credential cleanup on admin user deletion (SEC-09)

- Import CloudConnection and get_storage_backend_for_document into admin.py
- In delete_user: query all CloudConnection rows for the target user before MinIO cleanup
- For each connection: query documents with matching storage_backend, call delete_object
  best-effort (catch + ignore exceptions — same pattern as MinIO cleanup)
- Explicit session.delete(conn) for each CloudConnection row before user row deletion
- session.flush() after connection deletes to order SQL before user DELETE
- write_audit_log(event_type="cloud.credentials_purged") with providers list metadata
- Cloud cleanup runs BEFORE existing MinIO cleanup: credentials still available to build
  cloud backend instances for delete_object calls (SEC-09)
- No orphaned credentials_enc rows after account deletion (SEC-09 satisfied)
This commit is contained in:
curo1305
2026-05-29 00:59:10 +02:00
parent f509c37611
commit d85a09719e
+39 -3
View File
@@ -33,12 +33,12 @@ from pydantic import BaseModel, EmailStr, field_validator
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import Document, Quota, RefreshToken, Topic, User from db.models import CloudConnection, Document, Quota, RefreshToken, Topic, User
from deps.auth import get_current_admin from deps.auth import get_current_admin
from deps.db import get_db from deps.db import get_db
from services.audit import write_audit_log from services.audit import write_audit_log
from services.auth import hash_password, revoke_all_refresh_tokens from services.auth import hash_password, revoke_all_refresh_tokens
from storage import get_storage_backend from storage import get_storage_backend, get_storage_backend_for_document
router = APIRouter(prefix="/api/admin", tags=["admin"]) router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -488,7 +488,43 @@ async def delete_user(
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None) _ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
# SEC-09: collect all user documents and delete MinIO objects BEFORE DB delete # SEC-09 (cloud): purge cloud-stored documents and credentials BEFORE DB delete.
# Must run before MinIO cleanup so that credentials are still available to build
# the cloud backend instances for delete_object calls.
cloud_conns_result = await session.execute(
select(CloudConnection).where(CloudConnection.user_id == user_id)
)
cloud_conns = cloud_conns_result.scalars().all()
for conn in cloud_conns:
# Delete cloud objects stored in this provider for this user
cloud_docs_result = await session.execute(
select(Document).where(
Document.user_id == user_id,
Document.storage_backend == conn.provider,
)
)
for doc in cloud_docs_result.scalars().all():
try:
backend = await get_storage_backend_for_document(doc, user, session)
await backend.delete_object(doc.object_key)
except Exception:
pass # Best-effort cloud object cleanup; deletion proceeds regardless
# Purge the credentials row (FK cascade would also remove it, but explicit
# deletion here guarantees credentials_enc is gone before commit — SEC-09)
await session.delete(conn)
if cloud_conns:
await session.flush() # Flush connection deletes before user delete
await write_audit_log(
session,
event_type="cloud.credentials_purged",
user_id=user_id,
actor_id=_admin.id,
resource_id=user_id,
ip_address=_ip,
metadata_={"providers": [c.provider for c in cloud_conns]},
)
# SEC-09 (minio): collect all user documents and delete MinIO objects BEFORE DB delete
docs_result = await session.execute( docs_result = await session.execute(
select(Document).where(Document.user_id == user_id) select(Document).where(Document.user_id == user_id)
) )