Refactor backend and frontend cleanup paths
This commit is contained in:
@@ -11,13 +11,9 @@ from deps.auth import get_current_admin
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from api.admin.shared import _user_to_dict
|
||||
|
||||
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
class QuotaUpdate(BaseModel):
|
||||
limit_bytes: int
|
||||
|
||||
@@ -28,9 +24,6 @@ class QuotaUpdate(BaseModel):
|
||||
raise ValueError("limit_bytes must be greater than 0")
|
||||
return v
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users/{user_id}/quota")
|
||||
async def get_user_quota(
|
||||
user_id: uuid.UUID,
|
||||
|
||||
+16
-45
@@ -21,12 +21,8 @@ from api.admin.shared import _user_to_dict
|
||||
|
||||
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
_DEFAULT_QUOTA_BYTES = 104857600
|
||||
|
||||
_DEFAULT_QUOTA_BYTES = 104857600 # 100 MB free-tier default (D-06)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
handle: str
|
||||
@@ -51,20 +47,21 @@ class UserAiConfigUpdate(BaseModel):
|
||||
|
||||
|
||||
class SystemTopicCreate(BaseModel):
|
||||
"""Request model for admin system topic creation (D-09)."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
color: str = "#6366f1"
|
||||
|
||||
|
||||
class UserDeleteConfirm(BaseModel):
|
||||
"""Admin password confirmation required before hard-deleting a user (ADMIN-02, T-05-11-01)."""
|
||||
|
||||
admin_password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
async def _get_user_or_404(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
@@ -111,7 +108,7 @@ async def create_user(
|
||||
role=body.role,
|
||||
is_active=True,
|
||||
totp_enabled=False,
|
||||
password_must_change=True, # ADMIN-01: force password change on first login
|
||||
password_must_change=True,
|
||||
)
|
||||
session.add(new_user)
|
||||
|
||||
@@ -121,8 +118,7 @@ async def create_user(
|
||||
used_bytes=0,
|
||||
)
|
||||
session.add(quota)
|
||||
await session.flush() # persist User + Quota before audit_log FK references them
|
||||
# D-13: admin user created event
|
||||
await session.flush()
|
||||
_ip_addr = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -151,11 +147,8 @@ async def update_user_status(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
# Guard: cannot deactivate the only remaining active admin (T-02-29)
|
||||
if not body.is_active and user.role == "admin":
|
||||
count_result = await session.execute(
|
||||
select(func.count(User.id)).where(
|
||||
@@ -174,9 +167,7 @@ async def update_user_status(
|
||||
user.is_active = body.is_active
|
||||
|
||||
if not body.is_active:
|
||||
# Revoke all refresh tokens on deactivation
|
||||
await revoke_all_refresh_tokens(session, user.id)
|
||||
# Revoke any pre-deactivation access tokens still within their TTL (T-7.2-01)
|
||||
await request.app.state.redis.set(
|
||||
f"user_nbf:{user.id}",
|
||||
int(time.time()),
|
||||
@@ -185,7 +176,6 @@ async def update_user_status(
|
||||
|
||||
session.add(user)
|
||||
|
||||
# D-13: user deactivated/activated event
|
||||
_event = "admin.user_deactivated" if not body.is_active else "admin.user_activated"
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -211,9 +201,7 @@ async def initiate_password_reset(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
from services.auth import create_password_reset_token # noqa: PLC0415
|
||||
from config import settings as _settings # noqa: PLC0415
|
||||
@@ -236,16 +224,13 @@ async def update_ai_config(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
_ip_addr = get_client_ip(request)
|
||||
user.ai_provider = body.ai_provider
|
||||
user.ai_model = body.ai_model
|
||||
session.add(user)
|
||||
|
||||
# D-13: AI provider assigned event
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.ai_provider_assigned",
|
||||
@@ -273,19 +258,14 @@ async def delete_user(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
# T-05-11-01: Verify admin password before performing any destructive action.
|
||||
# Fail fast — no DB reads for the target user until the admin is confirmed.
|
||||
if not verify_password(body.admin_password, _admin.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid admin password",
|
||||
)
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
# T-04-07-04: Cannot delete admin accounts
|
||||
if user.role == "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -294,15 +274,11 @@ async def delete_user(
|
||||
|
||||
_ip_addr = get_client_ip(request)
|
||||
|
||||
# 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,
|
||||
@@ -314,12 +290,10 @@ async def delete_user(
|
||||
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)
|
||||
pass
|
||||
await session.delete(conn)
|
||||
if cloud_conns:
|
||||
await session.flush() # Flush connection deletes before user delete
|
||||
await session.flush()
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="cloud.credentials_purged",
|
||||
@@ -330,7 +304,6 @@ async def delete_user(
|
||||
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(
|
||||
select(Document).where(Document.user_id == user_id)
|
||||
)
|
||||
@@ -341,9 +314,8 @@ async def delete_user(
|
||||
try:
|
||||
await storage.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # Best-effort MinIO cleanup; DB deletion proceeds regardless
|
||||
pass
|
||||
|
||||
# D-13: audit log BEFORE deleting the user row (user FK still valid at flush time)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.user_deleted",
|
||||
@@ -354,7 +326,6 @@ async def delete_user(
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
# Delete user record (CASCADE removes quota, documents, refresh_tokens, etc.)
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user