4adc77d8cc
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>
394 lines
26 KiB
Markdown
394 lines
26 KiB
Markdown
---
|
|
phase: "06.2"
|
|
plan: "04"
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- "06.2-02"
|
|
- "06.2-03"
|
|
files_modified:
|
|
- backend/api/audit.py
|
|
- frontend/src/components/admin/AuditLogTab.vue
|
|
- frontend/src/api/client.js
|
|
- backend/tests/test_audit.py
|
|
autonomous: true
|
|
requirements:
|
|
- ADMIN-06
|
|
must_haves:
|
|
truths:
|
|
- "Audit log JSON viewer returns user_handle and actor_handle alongside user_id and actor_id"
|
|
- "GET /api/admin/audit-log?user_handle=X filters to entries for that user"
|
|
- "GET /api/admin/audit-log?user_handle=nonexistent returns empty items list, not 422"
|
|
- "CSV export button in AuditLogTab downloads a file via fetch+Blob (not window.location.href)"
|
|
- "GET /api/admin/audit-log/daily-exports returns sorted list of available export dates"
|
|
- "GET /api/admin/audit-log/daily-exports/{date} streams the CSV for that date"
|
|
- "Daily exports section in AuditLogTab shows date dropdown + Download button"
|
|
- "Date path parameter validated against YYYY-MM-DD regex before MinIO key construction"
|
|
artifacts:
|
|
- path: "backend/api/audit.py"
|
|
provides: "handle-enriched query; user_handle filter; two daily-export endpoints"
|
|
contains: "_audit_to_dict_with_handles"
|
|
- path: "frontend/src/api/client.js"
|
|
provides: "adminExportAuditLogCsv(), adminListDailyExports(), adminDownloadDailyExport()"
|
|
contains: "adminExportAuditLogCsv"
|
|
- path: "frontend/src/components/admin/AuditLogTab.vue"
|
|
provides: "fixed exportCsv(), daily exports section, user_handle filter label"
|
|
contains: "Daily exports"
|
|
key_links:
|
|
- from: "backend/api/audit.py list_audit_log"
|
|
to: "User table (aliased twice)"
|
|
via: "outerjoin on user_id and actor_id FKs"
|
|
pattern: "outerjoin.*UserSubject|outerjoin.*UserActor"
|
|
- from: "backend/api/audit.py list_daily_exports"
|
|
to: "MinIO audit-logs bucket"
|
|
via: "asyncio.to_thread(_list)"
|
|
pattern: "asyncio.to_thread"
|
|
- from: "frontend/src/components/admin/AuditLogTab.vue:exportCsv"
|
|
to: "adminExportAuditLogCsv() in client.js"
|
|
via: "fetch() + Blob URL — no window.location.href"
|
|
pattern: "adminExportAuditLogCsv"
|
|
---
|
|
|
|
<objective>
|
|
Close the ADMIN-06 gaps in a single vertical slice: user handles in audit log responses, handle-based filter, fixed CSV export download, and a new daily-export listing + download UI.
|
|
|
|
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.
|
|
</objective>
|
|
|
|
<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>
|
|
|
|
<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.md
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
|
|
|
From 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"
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Backend — handle enrichment, user_handle filter, two daily-export endpoints</name>
|
|
<files>backend/api/audit.py, backend/tests/test_audit.py</files>
|
|
<read_first>
|
|
- 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)
|
|
</read_first>
|
|
<behavior>
|
|
- 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.
|
|
</behavior>
|
|
<action>
|
|
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):
|
|
```python
|
|
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 calls `backend._client.list_objects("audit-logs", prefix="audit-logs/", recursive=False)`, iterates objects, filters `.endswith(".csv")`, extracts date from `obj.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.
|
|
</action>
|
|
<verify>
|
|
<automated>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</automated>
|
|
</verify>
|
|
<done>
|
|
- 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
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Frontend — user_handle filter, fetch+Blob export, daily-export section</name>
|
|
<files>frontend/src/components/admin/AuditLogTab.vue, frontend/src/api/client.js</files>
|
|
<read_first>
|
|
- frontend/src/components/admin/AuditLogTab.vue — read the full file; understand filters reactive object (filters.user_id must become filters.user_handle), fetchLog() which passes params to adminListAuditLog(), exportCsv() (broken window.location.href on lines 185-192), pagination block location (where to add the new daily-export section below it)
|
|
- frontend/src/api/client.js — read lines 395-435 (fetchDocumentContent — the fetch+Blob reference pattern); search for "adminListAuditLog" to find its current implementation; note that request() wrapper always calls res.json() and must NOT be used for CSV responses
|
|
- .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-RESEARCH.md — Pattern 5 (fetch+Blob URL for CSV), Pattern 6 (adminListDailyExports signature), Pattern 7 (adminDownloadDailyExport)
|
|
- .planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UI-SPEC.md — C-4 (daily exports section markup), C-5 (user filter label), Copywriting Contract (section copy), State Inventory (loading/empty/populated states)
|
|
</read_first>
|
|
<action>
|
|
Make two file changes:
|
|
|
|
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-exports` with 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:
|
|
- `dailyExports` ref (Array, default [])
|
|
- `loadingExports` ref (boolean, default false)
|
|
- `selectedExportDate` ref (string, default '')
|
|
- `downloadingExport` ref (boolean, default false)
|
|
- `exportsError` ref (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>`
|
|
</action>
|
|
<verify>
|
|
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && grep -n "adminExportAuditLogCsv\|adminListDailyExports\|adminDownloadDailyExport" src/api/client.js | head -10</automated>
|
|
</verify>
|
|
<done>
|
|
- `grep "adminExportAuditLogCsv" frontend/src/api/client.js` returns a match
|
|
- `grep "adminListDailyExports" frontend/src/api/client.js` returns a match
|
|
- `grep "adminDownloadDailyExport" frontend/src/api/client.js` returns a match
|
|
- `grep "window.location.href" frontend/src/components/admin/AuditLogTab.vue` returns NO match (broken export removed)
|
|
- `grep "Daily exports" frontend/src/components/admin/AuditLogTab.vue` returns a match
|
|
- `grep "User handle" frontend/src/components/admin/AuditLogTab.vue` returns a match
|
|
- `grep "user_handle" frontend/src/components/admin/AuditLogTab.vue` returns 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 -10` returns empty
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<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>
|
|
|
|
<verification>
|
|
After both tasks complete:
|
|
|
|
```
|
|
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)
|
|
```
|
|
</verification>
|
|
|
|
<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>
|
|
|
|
<output>
|
|
Create `.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-04-SUMMARY.md` when done.
|
|
</output>
|