425 lines
15 KiB
Markdown
425 lines
15 KiB
Markdown
---
|
|
phase: 06.2-close-v1-sharing-cloud-delete-csv-export-gaps
|
|
reviewed: 2026-05-31T00:00:00Z
|
|
depth: standard
|
|
files_reviewed: 9
|
|
files_reviewed_list:
|
|
- backend/api/audit.py
|
|
- backend/tests/test_audit.py
|
|
- backend/tests/test_documents.py
|
|
- backend/tests/test_shares.py
|
|
- frontend/src/api/client.js
|
|
- frontend/src/components/admin/AdminUsersTab.vue
|
|
- frontend/src/components/admin/AuditLogTab.vue
|
|
- frontend/src/views/AccountView.vue
|
|
- frontend/src/views/CloudFolderView.vue
|
|
findings:
|
|
critical: 4
|
|
warning: 6
|
|
info: 4
|
|
total: 14
|
|
status: issues_found
|
|
---
|
|
|
|
# Phase 06.2: Code Review Report
|
|
|
|
**Reviewed:** 2026-05-31T00:00:00Z
|
|
**Depth:** standard
|
|
**Files Reviewed:** 9
|
|
**Status:** issues_found
|
|
|
|
## Summary
|
|
|
|
This phase closes v1 gaps across document sharing, cloud delete, and audit/CSV-export
|
|
functionality. The backend audit API is well-structured with appropriate admin guards,
|
|
parameterized queries, and a whitelist serializer that correctly prevents sensitive field
|
|
leakage. The share and document test coverage is comprehensive. However, four critical
|
|
bugs were found that either break features silently in production or expose runtime
|
|
crashes, plus six warnings covering security hygiene and robustness.
|
|
|
|
---
|
|
|
|
## Critical Issues
|
|
|
|
### CR-01: AuditLogTab event_type filter always returns zero results — feature non-functional
|
|
|
|
**File:** `frontend/src/components/admin/AuditLogTab.vue:37-41`
|
|
|
|
**Issue:** The filter dropdown submits bare category prefixes (`"auth"`, `"document"`,
|
|
`"folder"`, `"share"`, `"admin"`) to the backend. The backend applies exact-equality
|
|
filtering (`AuditLog.event_type == event_type` — `backend/api/audit.py:120, 159, 284`).
|
|
Every actual event type in the codebase uses dot-notation:
|
|
`"auth.login"`, `"auth.logout"`, `"document.uploaded"`, `"document.deleted"`,
|
|
`"share.granted"`, `"share.revoked"`, `"admin.user_created"`, `"admin.quota_changed"`, etc.
|
|
|
|
An exact match of `"auth"` against `"auth.login"` never fires. Selecting any category
|
|
from the dropdown silently returns an empty list instead of filtered results — the entire
|
|
filter feature is non-functional.
|
|
|
|
**Fix:** Change the backend filter from exact equality to a prefix `LIKE` match (most
|
|
flexible) or update the dropdown to send exact event-type strings:
|
|
|
|
```python
|
|
# backend/api/audit.py — at all three filter call sites (lines 120, 159, 284):
|
|
if event_type is not None:
|
|
q = q.where(AuditLog.event_type.like(f"{event_type}%"))
|
|
```
|
|
|
|
```python
|
|
# Also fix the count_q inline block in list_audit_log (lines 283-284):
|
|
if event_type is not None:
|
|
count_q = count_q.where(AuditLog.event_type.like(f"{event_type}%"))
|
|
```
|
|
|
|
---
|
|
|
|
### CR-02: `download_daily_export` accesses `backend._client` without a MinIOBackend guard — AttributeError crash on non-MinIO deployments
|
|
|
|
**File:** `backend/api/audit.py:219-228`
|
|
|
|
**Issue:** `list_daily_exports` (line 182) correctly checks
|
|
`isinstance(backend, MinIOBackend)` and returns an empty list for non-MinIO storage.
|
|
`download_daily_export` (line 219) calls `get_storage_backend()` and immediately uses
|
|
`backend._client` with no type guard. On deployments where the primary storage backend
|
|
is Google Drive, OneDrive, or Nextcloud, `backend._client` does not exist on those
|
|
classes, raising `AttributeError`. The broad `except Exception` on line 232 catches
|
|
this and re-raises it as a 404, masking a real misconfiguration entirely — the admin
|
|
sees "Export not found" when the real problem is a storage type mismatch.
|
|
|
|
**Fix:** Mirror the guard from `list_daily_exports`:
|
|
|
|
```python
|
|
@router.get("/audit-log/daily-exports/{date}")
|
|
async def download_daily_export(
|
|
date: str,
|
|
_admin: User = Depends(get_current_admin),
|
|
) -> StreamingResponse:
|
|
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()
|
|
if not isinstance(backend, MinIOBackend): # <-- add this guard
|
|
raise HTTPException(status_code=404, detail="Export not found")
|
|
|
|
key = f"audit-logs/{date}.csv"
|
|
...
|
|
```
|
|
|
|
---
|
|
|
|
### CR-03: CSV export writes raw Python `dict` repr for `metadata_` — not valid JSON
|
|
|
|
**File:** `backend/api/audit.py:372`
|
|
|
|
**Issue:** `csv.DictWriter.writerow()` receives the dict returned by
|
|
`_audit_to_dict_with_handles()`, which includes `"metadata_": entry.metadata_` where
|
|
`entry.metadata_` is a Python `dict` (SQLAlchemy deserializes `JSONB` columns to native
|
|
Python objects). `csv.DictWriter` calls `str()` on values it cannot serialize natively,
|
|
producing Python repr syntax with single quotes (`{'size_bytes': 100}`) rather than
|
|
valid JSON (`{"size_bytes": 100}`). Any downstream system that tries to parse the
|
|
`metadata_` CSV column as JSON will fail.
|
|
|
|
Reproduced:
|
|
```python
|
|
import csv, io
|
|
output = io.StringIO()
|
|
writer = csv.DictWriter(output, fieldnames=["metadata_"])
|
|
writer.writeheader()
|
|
writer.writerow({"metadata_": {"size_bytes": 100}})
|
|
# output: "{'size_bytes': 100}" ← Python repr, NOT JSON
|
|
```
|
|
|
|
**Fix:**
|
|
```python
|
|
import json
|
|
|
|
# In export_audit_log(), at the writerow call site (line 372):
|
|
for row in rows:
|
|
entry, user_handle_val, actor_handle_val = row[0], row[1], row[2]
|
|
record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val)
|
|
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
|
|
writer.writerow(record)
|
|
```
|
|
|
|
The `test_audit_log_export_csv` test does not assert the content of the metadata cell
|
|
so this bug currently passes all tests while corrupting exported data.
|
|
|
|
---
|
|
|
|
### CR-04: Three admin fetch functions lack 401-refresh-retry — silently fail on token expiry
|
|
|
|
**File:** `frontend/src/api/client.js:398-483`
|
|
|
|
**Issue:** `adminExportAuditLogCsv`, `adminListDailyExports`, and
|
|
`adminDownloadDailyExport` all use raw `fetch()` calls without the 401-refresh-then-retry
|
|
logic that `request()` (line 27-30) and `fetchDocumentContent()` (lines 520-529) both
|
|
implement. When the 15-minute access token expires while an admin is on the audit log
|
|
page, all three functions immediately throw (e.g. `Error("Export failed: 401")`) with
|
|
no session recovery. The user's session is still valid (they have a live refresh token)
|
|
but the export/list operations fail hard. The auth store is not cleared, so the user
|
|
cannot tell why the operation failed.
|
|
|
|
Note: `adminListDailyExports` returns JSON and could be routed through `request()`
|
|
directly, which already has the retry logic.
|
|
|
|
**Fix:**
|
|
```js
|
|
// Simplest fix for adminListDailyExports — route through request() which handles 401 retry:
|
|
export function adminListDailyExports() {
|
|
return request('/api/admin/audit-log/daily-exports')
|
|
}
|
|
|
|
// For CSV-returning functions, add the same retry block as fetchDocumentContent:
|
|
// In adminExportAuditLogCsv and adminDownloadDailyExport, replace:
|
|
// if (!res.ok) throw new Error(...)
|
|
// with:
|
|
if (res.status === 401) {
|
|
try {
|
|
await authStore.refresh()
|
|
return adminExportAuditLogCsv(params) // retry
|
|
} catch {
|
|
authStore.accessToken = null
|
|
authStore.user = null
|
|
throw new Error('Session expired')
|
|
}
|
|
}
|
|
if (!res.ok) throw new Error(`Export failed: ${res.status}`)
|
|
```
|
|
|
|
---
|
|
|
|
## Warnings
|
|
|
|
### WR-01: Temporary password generation has a fixed suffix that leaks 3 bytes of entropy
|
|
|
|
**File:** `frontend/src/components/admin/AdminUsersTab.vue:301`
|
|
|
|
**Issue:** `generateRandomPassword()` generates 16 bytes of random data mapped through
|
|
a 65-character charset, then unconditionally overwrites the last 4 characters with the
|
|
literal `'A1!'`:
|
|
```js
|
|
pw = pw.slice(0, 12) + 'A1!'
|
|
```
|
|
The generated password is always exactly 15 characters with the last 3 bytes fixed
|
|
(`A`, `1`, `!`). An attacker who reverse-engineers this algorithm can reduce the
|
|
brute-force search space significantly — the last 3 positions are always known. There is
|
|
also modulo bias: `256 % 65 = 61`, meaning the first 61 characters of the charset are
|
|
slightly overrepresented (each maps from 4 byte values instead of 3). While these
|
|
temporary passwords must be changed on first login, they are the sole credential
|
|
protecting accounts between creation and first use.
|
|
|
|
**Fix:**
|
|
```js
|
|
function generateRandomPassword() {
|
|
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
|
const lower = 'abcdefghijkmnpqrstuvwxyz'
|
|
const digits = '23456789'
|
|
const special = '!@#$%^&*'
|
|
const all = upper + lower + digits + special // 58 chars — 256 % 58 = 24, bias reduced
|
|
const arr = new Uint32Array(16)
|
|
crypto.getRandomValues(arr)
|
|
let chars = Array.from(arr.slice(0, 12), v => all[v % all.length])
|
|
// Guarantee one char from each required class by replacing 4 specific positions
|
|
const positions = new Uint32Array(4)
|
|
crypto.getRandomValues(positions)
|
|
chars[positions[0] % 12] = upper[positions[0] % upper.length]
|
|
chars[positions[1] % 12] = lower[positions[1] % lower.length]
|
|
chars[positions[2] % 12] = digits[positions[2] % digits.length]
|
|
chars[positions[3] % 12] = special[positions[3] % special.length]
|
|
return chars.join('')
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### WR-02: `format` query parameter is accepted but ignored — always returns CSV regardless
|
|
|
|
**File:** `backend/api/audit.py:313`
|
|
|
|
**Issue:** The `export_audit_log` endpoint declares `format: str = Query(default="csv")`
|
|
but the variable `format` is never read in the handler body. Passing `?format=json`
|
|
silently returns a CSV response. The parameter is either dead code or a future extension
|
|
stub. Either way the current behaviour is incorrect: the API contract implies multiple
|
|
formats are accepted when only CSV exists. Any string passes FastAPI's validation,
|
|
meaning callers receive misleading 200 OK responses for unsupported format values.
|
|
|
|
**Fix (if only CSV will ever be supported):** Remove the parameter entirely.
|
|
|
|
**Fix (if future extension is planned):** Validate at the handler level:
|
|
```python
|
|
from typing import Literal
|
|
format: Literal["csv"] = Query(default="csv"), # noqa: A002
|
|
```
|
|
|
|
---
|
|
|
|
### WR-03: Pagination "Next" button uses wrong heuristic — breaks on last page when total is exact multiple of page size
|
|
|
|
**File:** `frontend/src/components/admin/AuditLogTab.vue:137,266`
|
|
|
|
**Issue:** The "Next" button is disabled when `entries.value.length < perPage` (line 137
|
|
in template, line 266 in script). If the last page contains exactly `perPage` (50)
|
|
entries, the button stays enabled. Clicking it dispatches a page request that returns an
|
|
empty items list, leaving the user on a blank page with the page counter incremented. The
|
|
`total` ref is populated from `data.total` (line 227) but is never used for pagination.
|
|
|
|
**Fix:**
|
|
```html
|
|
<!-- Template, line 137: -->
|
|
:disabled="page * perPage >= total"
|
|
```
|
|
```js
|
|
// Script, nextPage():
|
|
function nextPage() {
|
|
if (page.value * perPage < total.value) {
|
|
page.value++
|
|
fetchLog()
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### WR-04: `loadDailyExports` swallows errors silently — admin sees "No daily exports" instead of an error
|
|
|
|
**File:** `frontend/src/components/admin/AuditLogTab.vue:289-299`
|
|
|
|
**Issue:** The `loadDailyExports` catch block sets `dailyExports.value = []` but does
|
|
not set `exportsError.value`. The template renders `<p v-if="exportsError">` for
|
|
download errors, but load failures produce no user-visible message. An admin whose MinIO
|
|
is misconfigured sees "No daily exports available" — identical to a legitimately empty
|
|
bucket — with no indication that an error occurred.
|
|
|
|
**Fix:**
|
|
```js
|
|
} catch (e) {
|
|
dailyExports.value = []
|
|
exportsError.value = 'Failed to load daily exports. Please try again.' // add
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### WR-05: `URL.revokeObjectURL` called synchronously before browser initiates download — potential silent cancellation
|
|
|
|
**File:** `frontend/src/api/client.js:425-426, 481-482`
|
|
|
|
**Issue:** In both `adminExportAuditLogCsv` and `adminDownloadDailyExport`:
|
|
```js
|
|
a.click()
|
|
URL.revokeObjectURL(url) // immediate — race condition
|
|
```
|
|
`a.click()` triggers the download asynchronously. Revoking the blob URL before the
|
|
browser has transferred it to the OS download manager can silently cancel the download
|
|
on slow or memory-constrained devices (Chrome has a documented race here; this is a
|
|
known pattern issue). The anchor element is also not appended to the DOM (`document.body.appendChild(a)`)
|
|
which causes the download to fail entirely in some Firefox versions.
|
|
|
|
**Fix:**
|
|
```js
|
|
document.body.appendChild(a)
|
|
a.click()
|
|
document.body.removeChild(a)
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
```
|
|
|
|
---
|
|
|
|
### WR-06: `listShares` uses raw string interpolation in query string — inconsistent and fragile
|
|
|
|
**File:** `frontend/src/api/client.js:353`
|
|
|
|
**Issue:**
|
|
```js
|
|
export function listShares(docId) {
|
|
return request(`/api/shares?document_id=${docId}`)
|
|
}
|
|
```
|
|
`docId` is always a UUID in normal use, but it is not encoded via `URLSearchParams`.
|
|
All other query-parameter functions in this file (`listDocuments`, `adminListAuditLog`,
|
|
`listFolders`) use `URLSearchParams` consistently. While the practical risk from a UUID
|
|
is low, the pattern is inconsistent and breaks if the value ever contains special
|
|
characters.
|
|
|
|
**Fix:**
|
|
```js
|
|
export function listShares(docId) {
|
|
const params = new URLSearchParams({ document_id: docId })
|
|
return request(`/api/shares?${params}`)
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Info
|
|
|
|
### IN-01: `_build_filtered_query` is defined but never called — dead code
|
|
|
|
**File:** `backend/api/audit.py:97-121`
|
|
|
|
**Issue:** `_build_filtered_query` is documented as providing the COUNT query helper
|
|
(to avoid multi-column JOIN ambiguity), but `list_audit_log` (lines 276-284) manually
|
|
re-implements identical filter logic inline without calling this helper. The function
|
|
is never referenced anywhere in the file. It is dead code that will confuse maintainers.
|
|
|
|
**Fix:** Either delete `_build_filtered_query` or refactor the inline count query in
|
|
`list_audit_log` to use it.
|
|
|
|
---
|
|
|
|
### IN-02: Unused `import re` in `test_documents.py`
|
|
|
|
**File:** `backend/tests/test_documents.py:9`
|
|
|
|
**Issue:** `import re` is present at line 9 but `re.` is never used anywhere in the
|
|
module. Dead import from a previous version of the test file.
|
|
|
|
**Fix:** Remove `import re` from line 9.
|
|
|
|
---
|
|
|
|
### IN-03: `deleteDocumentRemoveOnly` is a trivial one-liner wrapper — unnecessary exported symbol
|
|
|
|
**File:** `frontend/src/api/client.js:80-82`
|
|
|
|
**Issue:**
|
|
```js
|
|
export function deleteDocumentRemoveOnly(id) {
|
|
return deleteDocument(id, true)
|
|
}
|
|
```
|
|
This adds a second exported name for `deleteDocument(id, true)` with no additional
|
|
behaviour. Having two symbols for the same operation creates confusion about which to
|
|
use. The wrapper itself provides no documentation, type safety, or default-arg isolation.
|
|
|
|
**Fix:** Remove the wrapper and call `deleteDocument(id, true)` directly at callsites,
|
|
or keep only the wrapper and make `removeOnly` an implementation detail.
|
|
|
|
---
|
|
|
|
### IN-04: `test_delete_cloud_remove_only` does not assert quota is unchanged after DB-only removal
|
|
|
|
**File:** `backend/tests/test_documents.py:897-925`
|
|
|
|
**Issue:** The test verifies the DB row is removed but does not verify that the user's
|
|
`used_bytes` quota was not decremented. Cloud documents are not quota-tracked, so a
|
|
future regression that incorrectly decrements quota on `remove_only` paths would go
|
|
undetected by this test.
|
|
|
|
**Fix:**
|
|
```python
|
|
# Add after the deleted-row assertion:
|
|
from db.models import Quota
|
|
quota = await db_session.get(Quota, auth_user["user"].id)
|
|
# Cloud docs are not quota-tracked — used_bytes must remain 0
|
|
assert quota.used_bytes == 0, (
|
|
f"remove_only should not decrement quota, got used_bytes={quota.used_bytes}"
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
_Reviewed: 2026-05-31T00:00:00Z_
|
|
_Reviewer: Claude (gsd-code-reviewer)_
|
|
_Depth: standard_
|