Refactor backend and frontend cleanup paths

This commit is contained in:
curo1305
2026-06-16 11:50:17 +02:00
parent 6b56763689
commit e97ca164d7
29 changed files with 1106 additions and 2280 deletions
+120 -218
View File
@@ -1,23 +1,4 @@
"""
Admin audit log API endpoints for DocuVault.
All handlers require get_current_admin (ADMIN-06, SEC-07) — regular users
receive 403 Forbidden.
Implements:
GET /api/admin/audit-log — paginated, filtered audit log viewer
GET /api/admin/audit-log/export — CSV streaming export with same filters
GET /api/admin/audit-log/daily-exports — list available Celery daily export files
GET /api/admin/audit-log/daily-exports/{date} — stream a specific daily export CSV
Security invariants:
- All endpoints use Depends(get_current_admin) — verified by grep
- _audit_to_dict() is a pure whitelist: no filename, extracted_text,
password_hash, or credentials_enc can appear in responses (ADMIN-06, D-15)
- CSV export uses the same _audit_to_dict_with_handles() helper as the JSON viewer
- Date path parameter validated against YYYY-MM-DD regex before MinIO key
construction — prevents path traversal (T-06.2-04-01, Pitfall 6)
"""
"""Admin audit log API endpoints."""
from __future__ import annotations
import asyncio
@@ -44,17 +25,22 @@ from storage.minio_backend import MinIOBackend
router = APIRouter(prefix="/api/admin", tags=["audit"])
_VALID_EVENT_PREFIXES = frozenset({"auth", "document", "folder", "share", "admin", "cloud"})
_CSV_FIELDS = [
"id",
"event_type",
"user_id",
"actor_id",
"user_handle",
"actor_handle",
"user_email",
"resource_id",
"ip_address",
"metadata_",
"created_at",
]
# ── Safe response helpers ─────────────────────────────────────────────────────
def _audit_to_dict(entry: AuditLog) -> dict:
"""Safe audit log serializer — never includes filename, extracted_text, or
document content (ADMIN-06, D-15).
Whitelist: id, event_type, user_id, actor_id, resource_id, ip_address,
metadata_, created_at. No other keys are possible.
"""
def _audit_base_fields(entry: AuditLog) -> dict:
return {
"id": entry.id,
"event_type": entry.event_type,
@@ -67,38 +53,49 @@ def _audit_to_dict(entry: AuditLog) -> dict:
}
def _audit_to_dict(entry: AuditLog) -> dict:
"""Whitelisted audit serializer shared with the daily export task."""
return _audit_base_fields(entry)
def _audit_to_dict_with_handles(
entry: AuditLog,
user_handle: Optional[str],
actor_handle: Optional[str],
user_email: Optional[str] = None,
) -> dict:
"""Extended audit log serializer that includes user_handle, actor_handle, and user_email.
Returns the same fields as _audit_to_dict() plus:
- user_handle: str | None (the handle of the user who owns the entry)
- actor_handle: str | None (the handle of the actor who performed the event)
- user_email: str | None (the email of the user who owns the entry)
Used by both the JSON viewer and CSV export endpoints (Pitfall 7 — both
endpoints must use the enriched function).
"""
return {
"id": entry.id,
"event_type": entry.event_type,
"user_id": str(entry.user_id) if entry.user_id else None,
"actor_id": str(entry.actor_id) if entry.actor_id else None,
data = _audit_base_fields(entry)
data.update({
"user_handle": user_handle or None,
"actor_handle": actor_handle or None,
"user_email": user_email or 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_,
"created_at": entry.created_at.isoformat(),
}
})
return data
# ── Query builder helpers ─────────────────────────────────────────────────────
def _validate_event_type(event_type: Optional[str]) -> None:
if event_type is not None and event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
def _apply_audit_filters(
query,
start: Optional[datetime],
end: Optional[datetime],
user_uuid: Optional[uuid.UUID],
event_type: Optional[str],
):
_validate_event_type(event_type)
if start is not None:
query = query.where(AuditLog.created_at >= start)
if end is not None:
query = query.where(AuditLog.created_at <= end)
if user_uuid is not None:
query = query.where(AuditLog.user_id == user_uuid)
if event_type is not None:
query = query.where(AuditLog.event_type.like(f"{event_type}.%"))
return query
def _build_filtered_query(
start: Optional[datetime],
@@ -106,27 +103,13 @@ def _build_filtered_query(
user_id: Optional[uuid.UUID],
event_type: Optional[str],
):
"""Return a SQLAlchemy Select for AuditLog with the given filters applied.
Shared by count queries in both the paginated viewer and the CSV export
endpoints to ensure consistent filter semantics.
NOTE: This function selects AuditLog only (no JOIN). It is used for COUNT
queries to avoid the subquery ambiguity that arises with multi-column JOINs
(Pitfall 4). Data queries use _build_filtered_query_with_handles() instead.
"""
q = select(AuditLog).order_by(AuditLog.created_at.desc())
if start is not None:
q = q.where(AuditLog.created_at >= start)
if end is not None:
q = q.where(AuditLog.created_at <= end)
if user_id is not None:
q = q.where(AuditLog.user_id == user_id)
if event_type is not None:
if event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
return q
return _apply_audit_filters(
select(AuditLog).order_by(AuditLog.created_at.desc()),
start,
end,
user_id,
event_type,
)
def _build_filtered_query_with_handles(
@@ -135,15 +118,6 @@ def _build_filtered_query_with_handles(
user_uuid: Optional[uuid.UUID],
event_type: Optional[str],
):
"""Return a multi-column Select that joins User twice for handle enrichment.
Yields (AuditLog, user_handle: str|None, actor_handle: str|None) tuples.
Uses SQLAlchemy aliased() to join User twice without collision:
- UserSubject: resolves user_id FK → handle
- UserActor: resolves actor_id FK → handle
outerjoin() ensures entries with NULL user_id or actor_id are still returned.
"""
UserSubject = aliased(User)
UserActor = aliased(User)
@@ -158,37 +132,68 @@ def _build_filtered_query_with_handles(
.outerjoin(UserActor, UserActor.id == AuditLog.actor_id)
.order_by(AuditLog.created_at.desc())
)
if start is not None:
q = q.where(AuditLog.created_at >= start)
if end is not None:
q = q.where(AuditLog.created_at <= end)
if user_uuid is not None:
q = q.where(AuditLog.user_id == user_uuid)
if event_type is not None:
if event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
return q
return _apply_audit_filters(q, start, end, user_uuid, event_type)
# ── Endpoints ─────────────────────────────────────────────────────────────────
# IMPORTANT: daily-export routes are registered BEFORE /audit-log and
# /audit-log/export so FastAPI matches the more specific paths first.
async def _resolve_user_uuid(session: AsyncSession, user_handle: Optional[str]) -> uuid.UUID | None:
if not user_handle:
return None
result = await session.execute(select(User.id).where(User.handle == user_handle))
return result.scalar_one_or_none()
async def _count_audit_log(
session: AsyncSession,
start: Optional[datetime],
end: Optional[datetime],
user_uuid: Optional[uuid.UUID],
event_type: Optional[str],
) -> int:
count_q = _apply_audit_filters(
select(func.count(AuditLog.id)).where(True),
start,
end,
user_uuid,
event_type,
)
result = await session.execute(count_q)
return result.scalar_one()
def _audit_rows_to_dicts(rows) -> list[dict]:
return [_audit_to_dict_with_handles(row[0], row[1], row[2], row[3]) for row in rows]
def _csv_response(csv_text: str, filename: str = "audit-export.csv") -> StreamingResponse:
return StreamingResponse(
iter([csv_text]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
def _empty_csv_response() -> StreamingResponse:
output = io.StringIO()
csv.DictWriter(output, fieldnames=_CSV_FIELDS).writeheader()
return _csv_response(output.getvalue())
def _audit_csv_response(rows) -> StreamingResponse:
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=_CSV_FIELDS)
writer.writeheader()
for record in _audit_rows_to_dicts(rows):
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
writer.writerow(record)
return _csv_response(output.getvalue())
@router.get("/audit-log/daily-exports")
async def list_daily_exports(
_admin: User = Depends(get_current_admin),
) -> dict:
"""List available Celery daily audit export files from MinIO (D-15).
Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] }
Items are sorted descending by date.
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
Event loop safety: list_objects() is synchronous; wrapped in asyncio.to_thread
to avoid blocking the event loop (T-06.2-04-05).
"""
"""List available Celery daily audit export files from MinIO."""
backend = get_storage_backend()
if not isinstance(backend, MinIOBackend):
return {"items": []}
@@ -215,15 +220,7 @@ async def download_daily_export(
date: str,
_admin: User = Depends(get_current_admin),
) -> StreamingResponse:
"""Stream a specific Celery daily audit export file from MinIO (D-16).
The date path parameter is validated against YYYY-MM-DD regex before
MinIO key construction to prevent path traversal (T-06.2-04-01, Pitfall 6).
Returns: StreamingResponse with Content-Type: text/csv.
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
"""
"""Stream a specific Celery daily audit export file from MinIO."""
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
raise HTTPException(status_code=404, detail="Invalid date format")
@@ -263,56 +260,18 @@ async def list_audit_log(
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Return paginated, filtered audit log entries (ADMIN-06).
"""Return paginated, filtered audit log entries."""
user_uuid = await _resolve_user_uuid(session, user_handle)
if user_handle and user_uuid is None:
return {"items": [], "total": 0, "page": page, "per_page": per_page}
Response: { items: [...], total: int, page: int, per_page: int }
Each item includes user_handle and actor_handle alongside UUID fields (D-11).
Entries never contain filename, extracted_text, or document content (D-15).
user_handle filter: accepts a plain string handle and resolves to UUID
internally. Returns empty results (not 422) for unknown handles (D-12).
"""
# Handle-to-UUID resolution (D-12, Pattern 4)
user_uuid: Optional[uuid.UUID] = None
if user_handle:
handle_result = await session.execute(
select(User.id).where(User.handle == user_handle)
)
uid = handle_result.scalar_one_or_none()
if uid is None:
# No user with that handle — return empty results (D-12)
return {"items": [], "total": 0, "page": page, "per_page": per_page}
user_uuid = uid
# Count query: use the plain _build_filtered_query (no JOIN) to avoid
# COUNT ambiguity on multi-column subqueries (Pitfall 4)
count_q = select(func.count(AuditLog.id)).where(True)
if start is not None:
count_q = count_q.where(AuditLog.created_at >= start)
if end is not None:
count_q = count_q.where(AuditLog.created_at <= end)
if user_uuid is not None:
count_q = count_q.where(AuditLog.user_id == user_uuid)
if event_type is not None:
if event_type not in _VALID_EVENT_PREFIXES:
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
count_q = count_q.where(AuditLog.event_type.like(f"{event_type}.%"))
count_result = await session.execute(count_q)
total = count_result.scalar_one()
# Data query: use enriched JOIN for handle fields
total = await _count_audit_log(session, start, end, user_uuid, event_type)
data_q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
data_q = data_q.limit(per_page).offset((page - 1) * per_page)
result = await session.execute(data_q)
rows = result.all()
items = []
for row in rows:
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val))
return {
"items": items,
"items": _audit_rows_to_dicts(result.all()),
"total": total,
"page": page,
"per_page": per_page,
@@ -329,68 +288,11 @@ async def export_audit_log(
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> StreamingResponse:
"""Stream a CSV export of filtered audit log entries (ADMIN-06).
"""Stream a CSV export of filtered audit log entries."""
user_uuid = await _resolve_user_uuid(session, user_handle)
if user_handle and user_uuid is None:
return _empty_csv_response()
Uses the same _audit_to_dict_with_handles() whitelist as the JSON viewer —
includes user_handle and actor_handle; no filename, extracted_text, or
document content appears in the export (D-15, T-04-06-02, Pitfall 7).
Returns StreamingResponse with Content-Disposition: attachment; filename=audit-export.csv.
user_handle filter: same handle-to-UUID resolution as the viewer (D-12).
"""
# Handle-to-UUID resolution (D-12) — same logic as list_audit_log
user_uuid: Optional[uuid.UUID] = None
if user_handle:
handle_result = await session.execute(
select(User.id).where(User.handle == user_handle)
)
uid = handle_result.scalar_one_or_none()
if uid is None:
# Unknown handle — return empty CSV
empty_output = io.StringIO()
fields = [
"id", "event_type", "user_id", "actor_id", "user_handle", "actor_handle",
"user_email", "resource_id", "ip_address", "metadata_", "created_at",
]
writer = csv.DictWriter(empty_output, fieldnames=fields)
writer.writeheader()
return StreamingResponse(
iter([empty_output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
)
user_uuid = uid
# Data query with handle enrichment (Pitfall 7 — export must use enriched function)
q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
result = await session.execute(q)
rows = result.all()
fields = [
"id",
"event_type",
"user_id",
"actor_id",
"user_handle",
"actor_handle",
"user_email",
"resource_id",
"ip_address",
"metadata_",
"created_at",
]
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=fields)
writer.writeheader()
for row in rows:
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val)
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
writer.writerow(record)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
)
return _audit_csv_response(result.all())