Wave 0: 11 xfail stubs across test_shares/test_documents/test_audit Wave 1 (parallel): SHARE-05 badge + SHARE-03 permission control; cloud-delete propagation Wave 2: audit handle enrichment, user_handle filter, CSV fetch+Blob, daily-export UI Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
26 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06.2 | 04 | execute | 2 |
|
|
true |
|
|
Purpose: Admins can now see who performed actions by name (not UUID), filter by handle without 422 errors, download exports that actually arrive (not a 401 from window.location.href), and access the Celery-generated daily export files from the admin panel.
Output: Modified audit.py (handle JOIN, user_handle filter, two new endpoints), modified AuditLogTab.vue (filter label, fetch+Blob exportCsv, daily-export section), new client.js functions, five promoted test stubs.
<execution_context> @/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md @/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md </execution_context>
@/Users/nik/Documents/Progamming/document_scanner/.planning/ROADMAP.md @/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-CONTEXT.mdFrom backend/api/audit.py (current state): def audit_to_dict(entry: AuditLog) -> dict: # Returns: id, event_type, user_id, actor_id, resource_id, ip_address, metadata, created_at # Does NOT return user_handle or actor_handle
def _build_filtered_query(start, end, user_id: Optional[uuid.UUID], event_type): # Accepts user_id as UUID type — FastAPI validates this via Query(Optional[uuid.UUID]) # This type annotation causes FastAPI to 422 on non-UUID strings
@router.get("/audit-log") async def list_audit_log( user_id: Optional[uuid.UUID] = Query(default=None), # BUG: must change to Optional[str] ... )
@router.get("/audit-log/export") async def export_audit_log( user_id: Optional[uuid.UUID] = Query(default=None), # BUG: same fix needed ... )
Both endpoints must be updated to accept user_handle: Optional[str]
From backend/db/models.py (User model — key fields): User.id: UUID User.handle: str (unique, indexed)
From backend/tasks/audit_tasks.py line 79: key = f"audit-logs/{yesterday.isoformat()}.csv"
MinIO bucket: "audit-logs"
Key pattern: "audit-logs/YYYY-MM-DD.csv"
From backend/storage/__init__.py: def get_storage_backend() -> StorageBackend: # Returns MinIOBackend; has ._client attribute (Minio SDK instance)
From backend/storage/minio_backend.py:
_client: Minio SDK instance
_client.list_objects(bucket, prefix, recursive) → synchronous iterator
_client.get_object(bucket, key) → response with .read() and .release_conn()
From frontend/src/api/client.js (existing patterns):
request() wrapper: always calls res.json() — NOT for CSV responses
fetchDocumentContent() at lines 399-428: raw fetch() pattern with Authorization header
export async function fetchDocumentContent(docId, options = {})
From frontend/src/components/admin/AuditLogTab.vue (current state):
filters reactive object: { start, end, user_id, event_type }
exportCsv() at lines 185-192: uses window.location.href (broken)
fetchLog() sends user_id: filters.user_id to adminListAuditLog()
Table renders: entry.user_handle || entry.user_id || '—' (line 89 — already expects handle)
From .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md: C-4: Daily Exports Section — below pagination block, border-t separator C-5: User filter label change from "User" to "User handle" Copywriting: section label "Daily exports", dropdown label "Select date", button "Download"
Task 1: Backend — handle enrichment, user_handle filter, two daily-export endpoints backend/api/audit.py, backend/tests/test_audit.py - backend/api/audit.py — read the full file; understand _audit_to_dict(), _build_filtered_query(), both existing endpoints and their exact Query parameter signatures; understand how both endpoints share _build_filtered_query - backend/db/models.py — search for "class User" and "class AuditLog" to confirm handle field and user_id/actor_id FK field names - backend/storage/__init__.py — read lines 32-50 (get_storage_backend factory) to understand how to get the MinIOBackend instance for the daily-export endpoints; confirm _client attribute - backend/tasks/audit_tasks.py — read lines 78-86 to confirm the MinIO bucket name ("audit-logs") and key pattern ("audit-logs/YYYY-MM-DD.csv") - backend/tests/test_audit.py — read the full file to understand _seed_audit helper, admin_user fixture, and existing test patterns before promoting stubs - .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md — Pattern 3 (aliased double-JOIN), Pattern 4 (handle-to-UUID resolution), Pattern 6 (list_objects), Pattern 7 (daily export streaming), Pitfall 4 (COUNT query breaks after JOIN), Pitfall 6 (date regex), Pitfall 7 (both endpoints must use enriched function) - test_audit_log_includes_user_handle: Seed an audit entry for admin_user. GET /api/admin/audit-log. Assert each item in items has keys "user_handle" and "actor_handle". Assert the first item's user_handle matches admin_user["user"].handle (not None for a seeded entry). - test_audit_log_filter_by_handle: Seed one entry for admin_user. Seed one entry for a second distinct user. GET /api/admin/audit-log?user_handle={admin_user.handle}. Assert items contains only entries matching admin_user (user_handle == admin_user.handle). Seeded second entry must not appear. - test_audit_log_filter_unknown_handle: GET /api/admin/audit-log?user_handle=definitely_does_not_exist. Assert status 200. Assert response body items == []. Assert total == 0. Assert no 422 error. - test_daily_exports_list: Mock MinIOBackend._client.list_objects to return fake objects (or patch get_storage_backend and its _client). GET /api/admin/audit-log/daily-exports. Assert status 200. Assert response has "items" key. Items sorted descending by date. - test_daily_export_download: Mock MinIOBackend._client.get_object to return fake CSV bytes. GET /api/admin/audit-log/daily-exports/2026-05-30. Assert status 200. Assert Content-Type: text/csv. Assert Content-Disposition header contains "2026-05-30". Also test GET /api/admin/audit-log/daily-exports/invalid-date returns 404. Make these changes to backend/api/audit.py:CHANGE 1 — Add SQLAlchemy aliased imports and User import check:
Add from sqlalchemy.orm import aliased to the imports if not already present. Confirm User is already imported from db.models.
CHANGE 2 — New helper _audit_to_dict_with_handles():
Add a new function _audit_to_dict_with_handles(entry: AuditLog, user_handle: Optional[str], actor_handle: Optional[str]) -> dict that returns the same dict as _audit_to_dict(entry) PLUS two additional keys: "user_handle": user_handle or None and "actor_handle": actor_handle or None. Do NOT remove or rename _audit_to_dict — preserve it as a fallback.
CHANGE 3 — New query builder _build_filtered_query_with_handles():
Add function _build_filtered_query_with_handles(start, end, user_uuid, event_type) that builds a multi-column select:
UserSubject = aliased(User)
UserActor = aliased(User)
stmt = (
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())
)
Apply the same start/end/user_uuid/event_type filters as the original _build_filtered_query. Return the statement. This is a standalone function, NOT replacing _build_filtered_query (the old function stays for the count query — see Pitfall 4).
CHANGE 4 — Update list_audit_log endpoint:
Change user_id: Optional[uuid.UUID] = Query(default=None) to user_handle: Optional[str] = Query(default=None).
Add handle-to-UUID resolution logic before executing the main query (Pattern 4 from RESEARCH.md):
user_uuid: Optional[uuid.UUID] = None
if user_handle:
result = await session.execute(select(User.id).where(User.handle == user_handle))
uid = result.scalar_one_or_none()
if uid is None:
return {"items": [], "total": 0, "page": page, "per_page": per_page}
user_uuid = uid
For the count query, use the ORIGINAL _build_filtered_query(start, end, user_uuid, event_type) to avoid the COUNT subquery problem (Pitfall 4). Count query is unchanged.
For the data query, use _build_filtered_query_with_handles(start, end, user_uuid, event_type). Add .limit(per_page).offset((page - 1) * per_page). Execute. Iterate result.all() as tuples: for row in rows: entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]. Build each item with _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val).
CHANGE 5 — Update export_audit_log endpoint:
Apply the same user_handle→user_uuid resolution (identical block as above). Use _build_filtered_query_with_handles for the data query. Iterate rows as tuples. Use _audit_to_dict_with_handles for CSV serialization. Add "user_handle" and "actor_handle" to the fields list for the CSV DictWriter. This satisfies Pitfall 7 (both endpoints must use enriched function).
CHANGE 6 — Add two new endpoints for daily exports:
Before the existing endpoints, add necessary imports: import asyncio, import re. The StreamingResponse import should already be present.
Add endpoint @router.get("/audit-log/daily-exports"):
- Auth:
_admin: User = Depends(get_current_admin) - No session param needed (MinIO call only)
- Body: get the MinIO backend via
from storage import get_storage_backend; from storage.minio_backend import MinIOBackend; backend = get_storage_backend(). If not MinIOBackend, return{"items": []}. - Define inner
_list() -> list[dict]function (synchronous) that callsbackend._client.list_objects("audit-logs", prefix="audit-logs/", recursive=False), iterates objects, filters.endswith(".csv"), extracts date fromobj.object_name.removeprefix("audit-logs/").removesuffix(".csv"), builds{"date": date_str, "key": obj.object_name}, sorts by date descending. - Execute:
items = await asyncio.to_thread(_list) - Return
{"items": items}
Add endpoint @router.get("/audit-log/daily-exports/{date}"):
- Auth:
_admin: User = Depends(get_current_admin) - Path param:
date: str - Date validation (Pitfall 6 / D-16):
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): raise HTTPException(404, "Invalid date format") - Get backend, construct key =
f"audit-logs/{date}.csv" - Define inner
_get() -> bytes(synchronous):response = backend._client.get_object("audit-logs", key); try: return response.read(); finally: response.close(); response.release_conn() - Execute: wrap in
try: csv_bytes = await asyncio.to_thread(_get); except Exception: raise HTTPException(404, "Export not found") - Return
StreamingResponse(iter([csv_bytes]), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="audit-{date}.csv"'})
CRITICAL: The two new endpoints must be placed BEFORE the existing @router.get("/audit-log/export") and @router.get("/audit-log") in the file, because FastAPI routes are matched in registration order. The path /audit-log/daily-exports is more specific than /audit-log and must be registered first. Or, at minimum, place them before the @router.get("/audit-log") GET handler.
Then in backend/tests/test_audit.py: promote all five xfail stubs. Use unittest.mock.patch to mock storage.get_storage_backend for the daily-export endpoint tests, returning a mock MinIOBackend with a _client mock.
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_audit.py::test_audit_log_includes_user_handle tests/test_audit.py::test_audit_log_filter_by_handle tests/test_audit.py::test_audit_log_filter_unknown_handle tests/test_audit.py::test_daily_exports_list tests/test_audit.py::test_daily_export_download -x -v 2>&1 | tail -25
- All five promoted tests pass
- grep "_audit_to_dict_with_handles" backend/api/audit.py returns at least 2 matches (definition + both endpoint usages — Pitfall 7)
- grep "user_handle" backend/api/audit.py returns at least 4 matches
- grep "daily-exports" backend/api/audit.py returns 2 matches (two new endpoints)
- grep "fullmatch" backend/api/audit.py returns a match (date regex validation)
- pytest tests/test_audit.py -x -q exits 0
CHANGE 1 — frontend/src/api/client.js: add three new functions Follow the exact fetch+Blob pattern from fetchDocumentContent (lines 399-428) — NOT using the request() wrapper.
Add adminExportAuditLogCsv(params = {}):
- Import useAuthStore lazily (same pattern as fetchDocumentContent)
- Build URLSearchParams with format=csv; add start, end, event_type if provided; add user_handle if provided (NOT user_id — the backend param is now user_handle)
- Raw fetch to
/api/admin/audit-log/export?${searchParams}with Authorization Bearer header and credentials: 'include' - On !res.ok: throw Error(
Export failed: ${res.status}) const text = await res.text()(NOT res.json())- Create Blob([text], { type: 'text/csv' }), URL.createObjectURL, create
<a>element, set href + download='audit-export.csv', click, URL.revokeObjectURL
Add adminListDailyExports():
- Raw fetch to
/api/admin/audit-log/daily-exportswith Authorization Bearer header - On !res.ok: throw Error
- Return
await res.json()— this endpoint returns JSON
Add adminDownloadDailyExport(date):
- Raw fetch to
/api/admin/audit-log/daily-exports/${date}with Authorization Bearer header and credentials: 'include' - On !res.ok: throw Error(
Download failed: ${res.status}) const text = await res.text()- Blob + URL.createObjectURL +
<a>click with download=audit-${date}.csv+ revokeObjectURL
CHANGE 2 — frontend/src/components/admin/AuditLogTab.vue: three UI changes
CHANGE 2a — User filter label and binding (per D-12, C-5):
In the filters reactive object, rename user_id: '' to user_handle: ''.
In the fetchLog() function, change user_id: filters.user_id || undefined to user_handle: filters.user_handle || undefined.
In the template filter bar, change the label text from "User" to "User handle". Change v-model="filters.user_id" to v-model="filters.user_handle".
Update adminListAuditLog() call to pass user_handle not user_id (check the existing call signature in fetchLog).
CHANGE 2b — Fix exportCsv() (per D-13):
Replace the entire body of function exportCsv() with an async call to api.adminExportAuditLogCsv({...}). Change the function declaration to async function exportCsv(). Pass current filter values: start: filters.start || undefined, end: filters.end || undefined, user_handle: filters.user_handle || undefined, event_type: filters.event_type || undefined. Add a ref exportingCsv (boolean, default false) and set it true/false around the call. On error: show an alert or set an error ref with "Export failed. Please try again."
CHANGE 2c — Add daily exports section (per D-17, C-4 from UI-SPEC): Add new reactive state in script setup:
dailyExportsref (Array, default [])loadingExportsref (boolean, default false)selectedExportDateref (string, default '')downloadingExportref (boolean, default false)exportsErrorref (string, default null)
Add loadDailyExports() async function that calls await api.adminListDailyExports() and populates dailyExports.value from data.items. Set loadingExports accordingly. Call loadDailyExports() inside onMounted() alongside the existing fetchLog() call.
Add downloadDailyExport() async function that calls await api.adminDownloadDailyExport(selectedExportDate.value). Set downloadingExport true/false. On error: set exportsError.value = "Download failed. Please try again.".
In the template, add the daily-export section below the pagination block, following C-4 markup from UI-SPEC:
- Section separator:
<div class="border-t border-gray-100 mt-6 pt-6"> - Section label:
<h3 class="text-sm font-semibold text-gray-700 mb-3">Daily exports</h3> - Loading state:
<p v-if="loadingExports" class="text-sm text-gray-400">Loading exports…</p> - Empty state:
<p v-else-if="dailyExports.length === 0" class="text-sm text-gray-400 italic">No daily exports available.</p> - Controls row (v-else):
<div class="flex items-end gap-3"><select v-model="selectedExportDate" class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"><option value="" disabled>Choose a date</option><option v-for="exp in dailyExports" :key="exp.date" :value="exp.date">{{ exp.date }}</option>
<button @click="downloadDailyExport" :disabled="!selectedExportDate || downloadingExport" class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors">- Loading spinner inline when downloadingExport (same animate-spin pattern as ShareModal)
- "Download" text otherwise
- Error display:
<p v-if="exportsError" class="text-xs text-red-600 mt-2">{{ exportsError }}</p>cd /Users/nik/Documents/Progamming/document_scanner/frontend && grep -n "adminExportAuditLogCsv|adminListDailyExports|adminDownloadDailyExport" src/api/client.js | head -10grep "adminExportAuditLogCsv" frontend/src/api/client.jsreturns a matchgrep "adminListDailyExports" frontend/src/api/client.jsreturns a matchgrep "adminDownloadDailyExport" frontend/src/api/client.jsreturns a matchgrep "window.location.href" frontend/src/components/admin/AuditLogTab.vuereturns NO match (broken export removed)grep "Daily exports" frontend/src/components/admin/AuditLogTab.vuereturns a matchgrep "User handle" frontend/src/components/admin/AuditLogTab.vuereturns a matchgrep "user_handle" frontend/src/components/admin/AuditLogTab.vuereturns at least 2 matches (filter binding + fetchLog param)- No build errors:
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>" | head -10returns empty
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| browser → GET /api/admin/audit-log/daily-exports/{date} | date path param is user-supplied; must not allow MinIO key injection |
| api/audit.py → MinIO | asyncio.to_thread isolates sync SDK from the async event loop |
| AuditLogTab → /api/admin/audit-log/export | fetch() must carry Bearer header; window.location.href cannot |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-06.2-04-01 | Tampering | Date path parameter injection | mitigate | re.fullmatch(r"\d{4}-\d{2}-\d{2}", date) validates before f"audit-logs/{date}.csv" key construction — rejects any non-date string including path traversal sequences (Pitfall 6 from RESEARCH.md) |
| T-06.2-04-02 | Elevation of Privilege | Unauthenticated daily-export access | mitigate | Both new endpoints use _admin: User = Depends(get_current_admin) — regular users receive 403, unauthenticated receive 401 |
| T-06.2-04-03 | Information Disclosure | Audit log CSV token bypass via window.location.href | mitigate | exportCsv() replaced with fetch()+Blob pattern that sends Authorization Bearer header — no unauthenticated CSV download possible |
| T-06.2-04-04 | Information Disclosure | user_handle in audit response leaks PII | accept | handle is already public within the platform (users are identified by handle in sharing UI); admin view of handles is consistent with existing admin privileges |
| T-06.2-04-05 | Denial of Service | list_objects blocking event loop | mitigate | asyncio.to_thread(_list) wraps synchronous Minio iterator — event loop is not blocked |
| T-06.2-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
| </threat_model> |
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_audit.py -x -q
Expected: exits 0, all 9 tests pass (4 pre-existing + 5 promoted).
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest -v 2>&1 | tail -20
Expected: zero failures.
Phase gate — full suite:
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest -v 2>&1 | grep -E "passed|failed|error" | tail -5
Frontend:
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>" | head -10
Security spot-checks:
grep "window.location.href" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue
# Expected: no output (bug removed)
grep "fullmatch" /Users/nik/Documents/Progamming/document_scanner/backend/api/audit.py
# Expected: matches the date regex line
grep "get_current_admin" /Users/nik/Documents/Progamming/document_scanner/backend/api/audit.py
# Expected: 4 matches (2 existing endpoints + 2 new endpoints)
<success_criteria>
- Audit log JSON response includes user_handle and actor_handle — confirmed by test_audit_log_includes_user_handle
- user_handle filter returns correct filtered results — confirmed by test_audit_log_filter_by_handle
- Unknown handle returns empty (not 422) — confirmed by test_audit_log_filter_unknown_handle
- Daily export list endpoint returns sorted items — confirmed by test_daily_exports_list
- Daily export download streams CSV with regex-validated date — confirmed by test_daily_export_download
- AuditLogTab exportCsv() uses fetch+Blob (window.location.href removed)
- AuditLogTab user filter labeled "User handle"
- AuditLogTab has Daily exports section with date dropdown and Download button
- All 9 test_audit.py tests pass
- Full pytest suite exits 0 </success_criteria>