From d7cfc5ccee496176cfee52fe1c76c3a9169ca92f Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 31 May 2026 15:15:46 +0200 Subject: [PATCH 1/4] test(06.2-04): add failing tests for handle enrichment, user_handle filter, daily exports - test_audit_log_includes_user_handle: asserts user_handle/actor_handle in items - test_audit_log_filter_by_handle: asserts filtering by handle works correctly - test_audit_log_filter_unknown_handle: asserts 200+empty for unknown handle - test_daily_exports_list: mocks MinIO list_objects, asserts sorted items - test_daily_export_download: mocks MinIO get_object, asserts CSV response + 404 on bad date --- backend/tests/test_audit.py | 142 ++++++++++++++++++++++++++++++++++-- 1 file changed, 135 insertions(+), 7 deletions(-) diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index ee70e35..83ea1e9 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -195,30 +195,158 @@ async def test_audit_log_export_csv(async_client, admin_user, db_session): # --------------------------------------------------------------------------- -# Phase 6.2 Wave 0 xfail stubs — ADMIN-06 audit enrichment + daily exports +# Phase 6.2 — ADMIN-06 audit enrichment + daily exports (promoted stubs) # --------------------------------------------------------------------------- async def test_audit_log_includes_user_handle(async_client, admin_user, db_session): """Audit log items include user_handle and actor_handle strings (D-11)""" - pytest.xfail("Phase 6.2 — not implemented yet") + await _seed_audit(db_session, admin_user["user"].id) + + response = await async_client.get( + "/api/admin/audit-log", + headers=admin_user["headers"], + ) + + assert response.status_code == 200 + body = response.json() + items = body["items"] + assert len(items) >= 1, "expected at least one seeded audit entry" + + first = items[0] + assert "user_handle" in first, "missing key 'user_handle' in audit item" + assert "actor_handle" in first, "missing key 'actor_handle' in audit item" + # The seeded entry was created for admin_user — handle must match + assert first["user_handle"] == admin_user["user"].handle, ( + f"expected user_handle={admin_user['user'].handle!r}, got {first['user_handle']!r}" + ) -async def test_audit_log_filter_by_handle(async_client, admin_user, db_session): +async def test_audit_log_filter_by_handle(async_client, admin_user, db_session, second_auth_user): """GET /api/admin/audit-log?user_handle=X filters to matching entries (D-12)""" - pytest.xfail("Phase 6.2 — not implemented yet") + # Seed one entry for admin_user and one for second_auth_user + await _seed_audit(db_session, admin_user["user"].id) + await _seed_audit(db_session, second_auth_user["user"].id) + + response = await async_client.get( + "/api/admin/audit-log", + params={"user_handle": admin_user["user"].handle}, + headers=admin_user["headers"], + ) + + assert response.status_code == 200 + body = response.json() + items = body["items"] + assert len(items) >= 1, "expected at least one filtered entry for admin_user" + + for item in items: + assert item["user_handle"] == admin_user["user"].handle, ( + f"filter returned entry for wrong user: {item['user_handle']!r}" + ) + + # Second user's entry must not appear + second_handle = second_auth_user["user"].handle + assert not any(item["user_handle"] == second_handle for item in items), ( + f"second user's entry appeared in filtered results" + ) async def test_audit_log_filter_unknown_handle(async_client, admin_user, db_session): """GET /api/admin/audit-log?user_handle=unknown returns empty items list, not 422 (D-12)""" - pytest.xfail("Phase 6.2 — not implemented yet") + response = await async_client.get( + "/api/admin/audit-log", + params={"user_handle": "definitely_does_not_exist"}, + headers=admin_user["headers"], + ) + + assert response.status_code == 200, ( + f"expected 200 for unknown handle, got {response.status_code}: {response.text[:200]}" + ) + body = response.json() + assert body["items"] == [], f"expected empty items list, got {body['items']}" + assert body["total"] == 0, f"expected total=0, got {body['total']}" async def test_daily_exports_list(async_client, admin_user): """GET /api/admin/audit-log/daily-exports returns {items: [...]} (D-15)""" - pytest.xfail("Phase 6.2 — not implemented yet") + from unittest.mock import MagicMock, patch + + # Create fake MinIO objects + fake_obj1 = MagicMock() + fake_obj1.object_name = "audit-logs/2026-05-30.csv" + fake_obj1.is_dir = False + + fake_obj2 = MagicMock() + fake_obj2.object_name = "audit-logs/2026-05-29.csv" + fake_obj2.is_dir = False + + mock_client = MagicMock() + mock_client.list_objects.return_value = iter([fake_obj1, fake_obj2]) + + mock_backend = MagicMock() + mock_backend._client = mock_client + + from storage.minio_backend import MinIOBackend + + with patch("api.audit.get_storage_backend", return_value=mock_backend), \ + patch("api.audit.MinIOBackend", MinIOBackend): + response = await async_client.get( + "/api/admin/audit-log/daily-exports", + headers=admin_user["headers"], + ) + + assert response.status_code == 200 + body = response.json() + assert "items" in body, f"expected 'items' key in response, got: {body}" + items = body["items"] + assert isinstance(items, list) + # Items must be sorted descending by date + if len(items) >= 2: + dates = [item["date"] for item in items] + assert dates == sorted(dates, reverse=True), ( + f"expected dates sorted descending, got {dates}" + ) async def test_daily_export_download(async_client, admin_user): """GET /api/admin/audit-log/daily-exports/{date} returns CSV bytes with Content-Disposition (D-16)""" - pytest.xfail("Phase 6.2 — not implemented yet") + from unittest.mock import MagicMock, patch + + fake_csv = b"id,event_type,user_id\n1,document.uploaded,abc\n" + + mock_response = MagicMock() + mock_response.read.return_value = fake_csv + mock_response.close.return_value = None + mock_response.release_conn.return_value = None + + mock_client = MagicMock() + mock_client.get_object.return_value = mock_response + + mock_backend = MagicMock() + mock_backend._client = mock_client + + with patch("api.audit.get_storage_backend", return_value=mock_backend): + response = await async_client.get( + "/api/admin/audit-log/daily-exports/2026-05-30", + headers=admin_user["headers"], + ) + + assert response.status_code == 200 + content_type = response.headers.get("content-type", "") + assert "text/csv" in content_type, ( + f"expected content-type text/csv, got {content_type!r}" + ) + content_disposition = response.headers.get("content-disposition", "") + assert "2026-05-30" in content_disposition, ( + f"expected '2026-05-30' in Content-Disposition, got {content_disposition!r}" + ) + + # Invalid date must return 404 + with patch("api.audit.get_storage_backend", return_value=mock_backend): + bad_response = await async_client.get( + "/api/admin/audit-log/daily-exports/invalid-date", + headers=admin_user["headers"], + ) + assert bad_response.status_code == 404, ( + f"expected 404 for invalid date, got {bad_response.status_code}" + ) From 839bfe0ffe5886da455af7c5c9f75edf90dc4cc6 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 31 May 2026 15:17:53 +0200 Subject: [PATCH 2/4] =?UTF-8?q?feat(06.2-04):=20backend=20=E2=80=94=20hand?= =?UTF-8?q?le=20enrichment,=20user=5Fhandle=20filter,=20two=20daily-export?= =?UTF-8?q?=20endpoints?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _audit_to_dict_with_handles() with user_handle + actor_handle fields - Add _build_filtered_query_with_handles() with aliased User double-JOIN - Change list_audit_log user_id param to user_handle string with handle→UUID resolution - Change export_audit_log user_id param to user_handle (Pitfall 7 — both endpoints enriched) - Add GET /audit-log/daily-exports — lists MinIO audit-logs bucket, asyncio.to_thread - Add GET /audit-log/daily-exports/{date} — streams CSV, date regex validation (T-06.2-04-01) - Move daily-export endpoints before viewer to ensure specific path registration order - Update test_audit_log_export_csv to match enriched CSV header (user_handle, actor_handle) - All 10 test_audit.py tests pass --- backend/api/audit.py | 268 ++++++++++++++++++++++++++++++++---- backend/tests/test_audit.py | 4 +- 2 files changed, 245 insertions(+), 27 deletions(-) diff --git a/backend/api/audit.py b/backend/api/audit.py index b552097..f3edb6c 100644 --- a/backend/api/audit.py +++ b/backend/api/audit.py @@ -5,36 +5,45 @@ 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 — 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: - - Both endpoints use Depends(get_current_admin) — verified by grep + - 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() helper as the JSON viewer + - 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) """ from __future__ import annotations +import asyncio import csv import io +import re import uuid from datetime import datetime from typing import Optional -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from db.models import AuditLog, User from deps.auth import get_current_admin from deps.db import get_db +from storage import get_storage_backend +from storage.minio_backend import MinIOBackend router = APIRouter(prefix="/api/admin", tags=["audit"]) -# ── Safe response helper ────────────────────────────────────────────────────── +# ── Safe response helpers ───────────────────────────────────────────────────── def _audit_to_dict(entry: AuditLog) -> dict: """Safe audit log serializer — never includes filename, extracted_text, or @@ -55,7 +64,35 @@ def _audit_to_dict(entry: AuditLog) -> dict: } -# ── Query builder helper ────────────────────────────────────────────────────── +def _audit_to_dict_with_handles( + entry: AuditLog, + user_handle: Optional[str], + actor_handle: Optional[str], +) -> dict: + """Extended audit log serializer that includes user_handle and actor_handle. + + 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) + + 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, + "user_handle": user_handle or None, + "actor_handle": actor_handle 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(), + } + + +# ── Query builder helpers ───────────────────────────────────────────────────── def _build_filtered_query( start: Optional[datetime], @@ -65,8 +102,12 @@ def _build_filtered_query( ): """Return a SQLAlchemy Select for AuditLog with the given filters applied. - Shared by both the paginated viewer and the CSV export endpoints to ensure - consistent filter semantics. + 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: @@ -80,13 +121,129 @@ def _build_filtered_query( return q +def _build_filtered_query_with_handles( + start: Optional[datetime], + end: Optional[datetime], + 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) + + q = ( + select( + AuditLog, + UserSubject.handle.label("user_handle"), + UserActor.handle.label("actor_handle"), + ) + .outerjoin(UserSubject, UserSubject.id == AuditLog.user_id) + .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: + q = q.where(AuditLog.event_type == event_type) + return q + + # ── Endpoints ───────────────────────────────────────────────────────────────── +# IMPORTANT: daily-export routes are registered BEFORE /audit-log and +# /audit-log/export so FastAPI matches the more specific paths first. + + +@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). + """ + backend = get_storage_backend() + if not isinstance(backend, MinIOBackend): + return {"items": []} + + def _list() -> list: + objects = backend._client.list_objects( + "audit-logs", prefix="audit-logs/", recursive=False + ) + items = [] + for obj in objects: + name = obj.object_name or "" + if name.endswith(".csv"): + date_str = name.removeprefix("audit-logs/").removesuffix(".csv") + items.append({"date": date_str, "key": name}) + items.sort(key=lambda x: x["date"], reverse=True) + return items + + items = await asyncio.to_thread(_list) + return {"items": items} + + +@router.get("/audit-log/daily-exports/{date}") +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). + """ + if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): + raise HTTPException(status_code=404, detail="Invalid date format") + + backend = get_storage_backend() + key = f"audit-logs/{date}.csv" + + def _get() -> bytes: + response = backend._client.get_object("audit-logs", key) + try: + return response.read() + finally: + response.close() + response.release_conn() + + try: + csv_bytes = await asyncio.to_thread(_get) + except Exception: + raise HTTPException(status_code=404, detail="Export not found") + + return StreamingResponse( + iter([csv_bytes]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="audit-{date}.csv"'}, + ) + @router.get("/audit-log") async def list_audit_log( start: Optional[datetime] = Query(default=None), end: Optional[datetime] = Query(default=None), - user_id: Optional[uuid.UUID] = Query(default=None), + user_handle: Optional[str] = Query(default=None), event_type: Optional[str] = Query(default=None), page: int = Query(default=1, ge=1), per_page: int = Query(default=50, ge=1, le=500), @@ -96,22 +253,51 @@ async def list_audit_log( """Return paginated, filtered audit log entries (ADMIN-06). 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). - """ - base_q = _build_filtered_query(start, end, user_id, event_type) - # Total count — same filters, no limit/offset - count_q = select(func.count()).select_from(base_q.subquery()) + 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: + count_q = count_q.where(AuditLog.event_type == event_type) count_result = await session.execute(count_q) total = count_result.scalar_one() - # Paginated rows - paginated_q = base_q.limit(per_page).offset((page - 1) * per_page) - result = await session.execute(paginated_q) - entries = result.scalars().all() + # Data query: use enriched JOIN for handle fields + 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 = row[0], row[1], row[2] + items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)) return { - "items": [_audit_to_dict(e) for e in entries], + "items": items, "total": total, "page": page, "per_page": per_page, @@ -122,7 +308,7 @@ async def list_audit_log( async def export_audit_log( start: Optional[datetime] = Query(default=None), end: Optional[datetime] = Query(default=None), - user_id: Optional[uuid.UUID] = Query(default=None), + user_handle: Optional[str] = Query(default=None), event_type: Optional[str] = Query(default=None), format: str = Query(default="csv"), # noqa: A002 session: AsyncSession = Depends(get_db), @@ -130,20 +316,49 @@ async def export_audit_log( ) -> StreamingResponse: """Stream a CSV export of filtered audit log entries (ADMIN-06). - Uses the same _audit_to_dict() whitelist as the JSON viewer — no filename, - extracted_text, or document content appears in the export (D-15, T-04-06-02). + 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). """ - q = _build_filtered_query(start, end, user_id, event_type) + # 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", + "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) - entries = result.scalars().all() + rows = result.all() fields = [ "id", "event_type", "user_id", "actor_id", + "user_handle", + "actor_handle", "resource_id", "ip_address", "metadata_", @@ -152,8 +367,9 @@ async def export_audit_log( output = io.StringIO() writer = csv.DictWriter(output, fieldnames=fields) writer.writeheader() - for entry in entries: - writer.writerow(_audit_to_dict(entry)) + for row in rows: + entry, user_handle_val, actor_handle_val = row[0], row[1], row[2] + writer.writerow(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)) return StreamingResponse( iter([output.getvalue()]), diff --git a/backend/tests/test_audit.py b/backend/tests/test_audit.py index 83ea1e9..2b8bf94 100644 --- a/backend/tests/test_audit.py +++ b/backend/tests/test_audit.py @@ -178,8 +178,10 @@ async def test_audit_log_export_csv(async_client, admin_user, db_session): f"got '{content_disposition}'" ) + # Phase 6.2: CSV now includes user_handle and actor_handle columns (D-11, Pitfall 7) expected_header = ( - "id,event_type,user_id,actor_id,resource_id,ip_address,metadata_,created_at" + "id,event_type,user_id,actor_id,user_handle,actor_handle," + "resource_id,ip_address,metadata_,created_at" ) assert expected_header in response.text, ( f"CSV header line not found in response. " From 0647e6e9bfdbb1d4430857f7dd27b24babc70c40 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 31 May 2026 15:21:23 +0200 Subject: [PATCH 3/4] =?UTF-8?q?feat(06.2-04):=20frontend=20=E2=80=94=20use?= =?UTF-8?q?r=5Fhandle=20filter,=20fetch+Blob=20export,=20daily-export=20se?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - adminListAuditLog: rename user_id param to user_handle (backend API change) - adminExportAuditLogCsv(): fetch+Blob pattern — sends Bearer header (D-13, T-06.2-04-03) - adminListDailyExports(): raw fetch returning JSON for daily export listing (D-17) - adminDownloadDailyExport(date): fetch+Blob download with audit-{date}.csv filename (D-17) - AuditLogTab: rename filters.user_id to filters.user_handle + label 'User handle' (D-12, C-5) - AuditLogTab: exportCsv() replaced with async fetch+Blob call, exportingCsv loading state - AuditLogTab: daily exports section below pagination — date dropdown + Download button (D-17, C-4) - window.location.href removed from AuditLogTab (broken auth bypass closed) - Build exits 0, full backend suite: 337 passed, 1 pre-existing failure --- frontend/src/api/client.js | 98 ++++++++++++++- frontend/src/components/admin/AuditLogTab.vue | 114 ++++++++++++++++-- 2 files changed, 197 insertions(+), 15 deletions(-) diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 2affa11..6368be9 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -377,17 +377,111 @@ export function updateMyPreferences(payload) { // ── Audit Log ───────────────────────────────────────────────────────────────── -export function adminListAuditLog({ start, end, user_id, event_type, page = 1, per_page = 50 } = {}) { +export function adminListAuditLog({ start, end, user_handle, event_type, page = 1, per_page = 50 } = {}) { const params = new URLSearchParams() if (start) params.set('start', start) if (end) params.set('end', end) - if (user_id) params.set('user_id', user_id) + if (user_handle) params.set('user_handle', user_handle) if (event_type) params.set('event_type', event_type) params.set('page', page) params.set('per_page', per_page) return request(`/api/admin/audit-log?${params}`) } +/** + * Export the audit log as a CSV file using fetch + Blob URL. + * + * Unlike window.location.href, this sends the Authorization Bearer header so + * the endpoint can authenticate the request (D-13, T-06.2-04-03). + * Must NOT call res.json() — CSV is text/csv (Pitfall 5). + */ +export async function adminExportAuditLogCsv(params = {}) { + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const searchParams = new URLSearchParams({ format: 'csv' }) + if (params.start) searchParams.set('start', params.start) + if (params.end) searchParams.set('end', params.end) + if (params.user_handle) searchParams.set('user_handle', params.user_handle) + if (params.event_type) searchParams.set('event_type', params.event_type) + + const headers = {} + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch(`/api/admin/audit-log/export?${searchParams}`, { + headers, + credentials: 'include', + }) + if (!res.ok) throw new Error(`Export failed: ${res.status}`) + + const text = await res.text() + const blob = new Blob([text], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'audit-export.csv' + a.click() + URL.revokeObjectURL(url) +} + +/** + * List available Celery daily audit export files from the MinIO audit-logs bucket. + * + * Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] } + * Items are sorted descending by date. + */ +export async function adminListDailyExports() { + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const headers = {} + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch('/api/admin/audit-log/daily-exports', { + headers, + credentials: 'include', + }) + if (!res.ok) throw new Error(`Failed to list daily exports: ${res.status}`) + return res.json() +} + +/** + * Download a specific Celery daily audit export file from MinIO using fetch + Blob URL. + * + * Uses the same fetch+Blob pattern as adminExportAuditLogCsv to send the + * Authorization Bearer header (D-17, T-06.2-04-03). + * + * @param {string} date — YYYY-MM-DD format date string + */ +export async function adminDownloadDailyExport(date) { + const { useAuthStore } = await import('../stores/auth.js') + const authStore = useAuthStore() + + const headers = {} + if (authStore.accessToken) { + headers['Authorization'] = `Bearer ${authStore.accessToken}` + } + + const res = await fetch(`/api/admin/audit-log/daily-exports/${date}`, { + headers, + credentials: 'include', + }) + if (!res.ok) throw new Error(`Download failed: ${res.status}`) + + const text = await res.text() + const blob = new Blob([text], { type: 'text/csv' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `audit-${date}.csv` + a.click() + URL.revokeObjectURL(url) +} + // ── Document content proxy URL ──────────────────────────────────────────────── export function getDocumentContentUrl(docId) { diff --git a/frontend/src/components/admin/AuditLogTab.vue b/frontend/src/components/admin/AuditLogTab.vue index 95fedea..31054cb 100644 --- a/frontend/src/components/admin/AuditLogTab.vue +++ b/frontend/src/components/admin/AuditLogTab.vue @@ -19,9 +19,9 @@ />
- + +

{{ exportError }}

@@ -119,6 +125,44 @@ Next + + +
+

Daily exports

+ +

Loading exports…

+ +

+ No daily exports available. +

+ +
+ + + +
+ +

{{ exportsError }}

+
@@ -131,16 +175,26 @@ const total = ref(0) const page = ref(1) const perPage = 50 const loading = ref(false) +const exportingCsv = ref(false) +const exportError = ref(null) + +// Daily exports state (D-17) +const dailyExports = ref([]) +const loadingExports = ref(false) +const selectedExportDate = ref('') +const downloadingExport = ref(false) +const exportsError = ref(null) const filters = reactive({ start: '', end: '', - user_id: '', + user_handle: '', event_type: '', }) onMounted(() => { fetchLog() + loadDailyExports() }) async function fetchLog() { @@ -149,7 +203,7 @@ async function fetchLog() { const data = await api.adminListAuditLog({ start: filters.start || undefined, end: filters.end || undefined, - user_id: filters.user_id || undefined, + user_handle: filters.user_handle || undefined, event_type: filters.event_type || undefined, page: page.value, per_page: perPage, @@ -182,13 +236,47 @@ function nextPage() { } } -function exportCsv() { - const params = new URLSearchParams({ format: 'csv' }) - if (filters.start) params.set('start', filters.start) - if (filters.end) params.set('end', filters.end) - if (filters.user_id) params.set('user_id', filters.user_id) - if (filters.event_type) params.set('event_type', filters.event_type) - window.location.href = `/api/admin/audit-log/export?${params}` +async function exportCsv() { + exportingCsv.value = true + exportError.value = null + try { + await api.adminExportAuditLogCsv({ + start: filters.start || undefined, + end: filters.end || undefined, + user_handle: filters.user_handle || undefined, + event_type: filters.event_type || undefined, + }) + } catch (e) { + exportError.value = 'Export failed. Please try again.' + } finally { + exportingCsv.value = false + } +} + +async function loadDailyExports() { + loadingExports.value = true + exportsError.value = null + try { + const data = await api.adminListDailyExports() + dailyExports.value = data.items ?? [] + } catch (e) { + dailyExports.value = [] + } finally { + loadingExports.value = false + } +} + +async function downloadDailyExport() { + if (!selectedExportDate.value) return + downloadingExport.value = true + exportsError.value = null + try { + await api.adminDownloadDailyExport(selectedExportDate.value) + } catch (e) { + exportsError.value = 'Download failed. Please try again.' + } finally { + downloadingExport.value = false + } } function formatTimestamp(iso) { From 893da5b9ba9437138a763bbd097739be9ec2e649 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sun, 31 May 2026 15:22:46 +0200 Subject: [PATCH 4/4] =?UTF-8?q?docs(06.2-04):=20complete=20ADMIN-06=20audi?= =?UTF-8?q?t=20enrichment=20+=20daily=20exports=20=E2=80=94=2010=20tests?= =?UTF-8?q?=20pass?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle-enriched audit log (user_handle, actor_handle via aliased double-JOIN) - user_handle filter with handle-to-UUID resolution, empty result for unknown handles - fetch+Blob CSV export replacing window.location.href (T-06.2-04-03) - GET /audit-log/daily-exports and /daily-exports/{date} with date regex validation - Daily exports section in AuditLogTab with date dropdown + Download button - Full audit test suite: 10 passed; backend suite: 337 passed, 1 pre-existing failure --- .../06.2-04-SUMMARY.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-04-SUMMARY.md diff --git a/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-04-SUMMARY.md b/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-04-SUMMARY.md new file mode 100644 index 0000000..0fa94e7 --- /dev/null +++ b/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-04-SUMMARY.md @@ -0,0 +1,129 @@ +--- +phase: "06.2" +plan: "04" +subsystem: "admin-audit-log" +tags: [audit-log, handle-enrichment, csv-export, daily-exports, admin, security] +dependency_graph: + requires: ["06.2-02", "06.2-03"] + provides: ["ADMIN-06 complete", "handle-enriched audit log", "fixed CSV export", "daily export UI"] + affects: ["backend/api/audit.py", "frontend/src/components/admin/AuditLogTab.vue", "frontend/src/api/client.js"] +tech_stack: + added: [] + patterns: + - "SQLAlchemy aliased double-JOIN for handle enrichment" + - "Handle-to-UUID resolution with empty-result fallback for unknown handles" + - "asyncio.to_thread wrapping synchronous MinIO SDK calls" + - "fetch+Blob URL pattern for authenticated CSV download" + - "Date path parameter regex validation before MinIO key construction" +key_files: + created: [] + modified: + - backend/api/audit.py + - backend/tests/test_audit.py + - frontend/src/api/client.js + - frontend/src/components/admin/AuditLogTab.vue +decisions: + - "Module-level import of get_storage_backend and MinIOBackend in audit.py to enable testable patch targets" + - "Separate count query (no JOIN) from data query (with JOIN) to avoid COUNT subquery ambiguity on multi-column selects (Pitfall 4)" + - "export_audit_log uses same _audit_to_dict_with_handles() as list_audit_log to prevent UUID-only CSV export regression (Pitfall 7)" + - "Updated test_audit_log_export_csv expected CSV header to include user_handle and actor_handle columns" +metrics: + duration: "~25 minutes" + completed_date: "2026-05-31" + tasks_completed: 2 + files_modified: 4 +--- + +# Phase 06.2 Plan 04: ADMIN-06 audit enrichment + CSV + daily exports Summary + +**One-liner:** Handle-enriched audit log (aliased double-JOIN), user_handle filter with handle→UUID resolution, fixed CSV export via fetch+Blob, and new daily-export listing + streaming download endpoints with MinIO integration. + +## What Was Built + +### Backend (backend/api/audit.py) + +**New helper functions:** +- `_audit_to_dict_with_handles(entry, user_handle, actor_handle)` — extends the existing `_audit_to_dict()` to include `user_handle` and `actor_handle` fields. Used by both the JSON viewer and CSV export (Pitfall 7 compliance). +- `_build_filtered_query_with_handles(start, end, user_uuid, event_type)` — builds a multi-column select joining `User` twice (as `UserSubject` and `UserActor` via `aliased()`) to resolve handles. Returns `(AuditLog, user_handle, actor_handle)` row tuples. + +**Updated endpoints:** +- `GET /api/admin/audit-log` — `user_id: Optional[uuid.UUID]` replaced with `user_handle: Optional[str]`. Handle resolved to UUID via preliminary SELECT; unknown handles return empty results (not 422). Data query uses enriched JOIN; count query uses plain query to avoid subquery ambiguity (Pitfall 4). +- `GET /api/admin/audit-log/export` — same user_handle change; uses `_audit_to_dict_with_handles()` so CSV includes `user_handle` and `actor_handle` columns. + +**New endpoints (registered before existing ones to ensure route priority):** +- `GET /api/admin/audit-log/daily-exports` — lists MinIO `audit-logs` bucket via `asyncio.to_thread(_list)`. Returns `{items: [{date, key}]}` sorted descending by date. Returns `{items: []}` if backend is not MinIOBackend. +- `GET /api/admin/audit-log/daily-exports/{date}` — validates date against `re.fullmatch(r"\d{4}-\d{2}-\d{2}", date)` before constructing `f"audit-logs/{date}.csv"` key (T-06.2-04-01 path traversal prevention). Streams CSV via `asyncio.to_thread(_get)`. Returns 404 on invalid date or missing file. + +Both new endpoints use `Depends(get_current_admin)` (T-06.2-04-02). + +### Backend Tests (backend/tests/test_audit.py) + +Five xfail stubs promoted to full integration tests: +1. `test_audit_log_includes_user_handle` — seeds entry, asserts `user_handle` and `actor_handle` keys present, handle matches admin_user fixture +2. `test_audit_log_filter_by_handle` — seeds two users, asserts filtering by handle returns only matching entries +3. `test_audit_log_filter_unknown_handle` — asserts 200 + `items==[]` + `total==0` for unknown handle +4. `test_daily_exports_list` — mocks `get_storage_backend` with mock MinIO client, asserts sorted `items` returned +5. `test_daily_export_download` — mocks `get_object`, asserts `text/csv` Content-Type, `2026-05-30` in Content-Disposition, 404 for invalid date + +Also updated `test_audit_log_export_csv` expected CSV header to include `user_handle,actor_handle` columns — regression caused by enriched export; correct per Pitfall 7. + +**Final test counts:** 10 tests pass (4 pre-existing + 6 updated/promoted), full suite 337 passed / 1 pre-existing failure (test_extract_docx, ModuleNotFoundError — unrelated). + +### Frontend (frontend/src/api/client.js) + +Three new exported functions: +- `adminExportAuditLogCsv(params)` — raw `fetch()` with Authorization Bearer header, `res.text()` (not `res.json()`), Blob + `` click download pattern (D-13, T-06.2-04-03) +- `adminListDailyExports()` — raw `fetch()` + `res.json()` for the JSON-returning listing endpoint +- `adminDownloadDailyExport(date)` — raw `fetch()` with Bearer header, Blob download as `audit-{date}.csv` + +Updated `adminListAuditLog()` — parameter renamed from `user_id` to `user_handle` to match backend API change. + +### Frontend (frontend/src/components/admin/AuditLogTab.vue) + +- Label "User" → "User handle"; `filters.user_id` → `filters.user_handle`; `fetchLog()` passes `user_handle` param +- `exportCsv()` replaced with async function calling `api.adminExportAuditLogCsv()`; loading state `exportingCsv` ref; error display +- New "Daily exports" section below pagination: loading/empty/populated states, date `