merge(06.2): integrate code review fixes from gsd-reviewfix/06.2-2490
This commit is contained in:
@@ -36,7 +36,8 @@ findings:
|
||||
warning: 8
|
||||
info: 5
|
||||
total: 20
|
||||
status: issues_found
|
||||
status: fixed
|
||||
fixed_at: 2026-06-01T00:00:00Z
|
||||
---
|
||||
|
||||
# Phase 06.2: Code Review Report
|
||||
@@ -68,6 +69,7 @@ Seven blocker-level issues were found:
|
||||
|
||||
### CR-01: Audit log event-type filter always returns zero results — feature non-functional
|
||||
|
||||
**Status:** fixed — commit a3ad36c
|
||||
**File:** `frontend/src/components/admin/AuditLogTab.vue:37-41`
|
||||
|
||||
**Issue:** The filter `<select>` emits bare category strings (`"auth"`, `"document"`, `"folder"`, `"share"`, `"admin"`). The backend applies exact equality (`AuditLog.event_type == event_type`) at `backend/api/audit.py:120, 159, 284`. All actual event types use dot-namespaced format: `"auth.login"`, `"document.uploaded"`, `"share.granted"`, `"admin.user_created"`, etc. An exact match of `"auth"` against `"auth.login"` never fires. Selecting any category silently returns an empty list — the entire filter feature is non-functional.
|
||||
@@ -91,6 +93,7 @@ if event_type is not None:
|
||||
|
||||
### CR-02: `download_daily_export` accesses `backend._client` without MinIOBackend guard — AttributeError on non-MinIO deployments
|
||||
|
||||
**Status:** fixed — commit 50859bb
|
||||
**File:** `backend/api/audit.py:219-228`
|
||||
|
||||
**Issue:** `list_daily_exports` (line 182) correctly guards with `isinstance(backend, MinIOBackend)` and returns empty for non-MinIO storage. `download_daily_export` at line 219 calls `get_storage_backend()` and directly accesses `backend._client` with no type guard. On Google Drive, OneDrive, Nextcloud, or WebDAV storage backends, `_client` does not exist — an `AttributeError` is raised and swallowed by the broad `except Exception` at line 232, returning a misleading 404 "Export not found" to the admin.
|
||||
@@ -105,6 +108,7 @@ key = f"audit-logs/{date}.csv"
|
||||
|
||||
### CR-03: CSV export serializes `metadata_` as Python repr — not valid JSON
|
||||
|
||||
**Status:** fixed — commit 792d463
|
||||
**File:** `backend/api/audit.py:372`
|
||||
|
||||
**Issue:** `csv.DictWriter.writerow()` calls `str()` on values it cannot natively serialize. `entry.metadata_` is a Python `dict` (SQLAlchemy deserializes JSONB to native Python), producing `{'size_bytes': 100}` — Python repr with single quotes — rather than valid JSON `{"size_bytes": 100}`. Any downstream consumer that parses the `metadata_` column as JSON will fail. The test `test_audit_log_export_csv` does not assert on the cell content so this bug passes the test suite.
|
||||
@@ -122,6 +126,7 @@ for row in rows:
|
||||
|
||||
### CR-04: Three audit export functions bypass 401-refresh-retry — exports silently break on token expiry
|
||||
|
||||
**Status:** fixed — commit 3fa7e8b
|
||||
**File:** `frontend/src/api/client.js:398-483`
|
||||
|
||||
**Issue:** `adminExportAuditLogCsv`, `adminListDailyExports`, and `adminDownloadDailyExport` all use raw `fetch()` with no 401-refresh-then-retry logic. When the 15-minute access token expires mid-session, all three functions throw immediately (`Error("Export failed: 401")`) with no session recovery. The auth store is not cleared, so the user cannot distinguish a token expiry from a network error. The `request()` helper (lines 27-30) and `fetchDocumentContent()` (lines 520-529) both implement this retry correctly.
|
||||
@@ -148,6 +153,7 @@ if (res.status === 401 && !options?._retry) {
|
||||
|
||||
### CR-05: UUID format mismatch in quota SQL — quota enforcement unreliable in `confirm_upload`
|
||||
|
||||
**Status:** fixed — commit 653cb3a
|
||||
**File:** `backend/api/documents.py:348-356`
|
||||
|
||||
**Issue:** The atomic quota `UPDATE` in `confirm_upload` strips dashes from the user UUID:
|
||||
@@ -167,6 +173,7 @@ PostgreSQL stores UUIDs in native `uuid` type (dashed format). Binding a 32-hex-
|
||||
|
||||
### CR-06: `Content-Disposition` filename not RFC 5987-encoded — header injection via special characters
|
||||
|
||||
**Status:** fixed — commit 1a34209
|
||||
**File:** `backend/api/documents.py:791`
|
||||
|
||||
**Issue:**
|
||||
@@ -188,6 +195,7 @@ headers = {
|
||||
|
||||
### CR-07: `PATCH /api/shares/{share_id}` writes no audit log — permission escalations unrecorded
|
||||
|
||||
**Status:** fixed — commit 1f2cec9
|
||||
**File:** `backend/api/shares.py:246-270`
|
||||
|
||||
**Issue:** `update_share_permission` changes the effective access level on a document share (e.g. `"view"` → `"edit"`) but writes no audit log entry. Every other share mutation — `grant_share` (logs `share.granted`) and `revoke_share` (logs `share.revoked`) — writes to the audit log. A permission escalation on a high-value document is therefore invisible in the ADMIN-06 audit trail. The endpoint also has no `Request` parameter, so IP address cannot be captured.
|
||||
@@ -223,6 +231,7 @@ async def update_share_permission(
|
||||
|
||||
### WR-01: `generateRandomPassword` discards 4 random chars and appends a fixed suffix
|
||||
|
||||
**Status:** fixed — commit 1cba903
|
||||
**File:** `frontend/src/components/admin/AdminUsersTab.vue:299-302`
|
||||
|
||||
**Issue:**
|
||||
@@ -235,6 +244,7 @@ The 16-char random password is truncated to 12, then `'A1!'` is always appended.
|
||||
|
||||
### WR-02: `format` query parameter on `/audit-log/export` is accepted but ignored — dead parameter
|
||||
|
||||
**Status:** fixed — commit 683670a
|
||||
**File:** `backend/api/audit.py:313`
|
||||
|
||||
**Issue:** `format: str = Query(default="csv")` is declared but the variable `format` is never read in the handler. Any caller passing `?format=json` receives a CSV response with HTTP 200 and no error. This is misleading API design — the parameter should either be used or removed.
|
||||
@@ -246,6 +256,7 @@ format: Literal["csv"] = Query(default="csv"), # noqa: A002
|
||||
|
||||
### WR-03: Pagination "Next" button uses wrong heuristic — breaks when total is exact multiple of page size
|
||||
|
||||
**Status:** fixed — commit 2542c81
|
||||
**File:** `frontend/src/components/admin/AuditLogTab.vue:137,266`
|
||||
|
||||
**Issue:** The "Next" button is disabled when `entries.value.length < perPage`. If the last page has exactly `perPage` entries, the button remains enabled. Clicking it fetches an empty page and leaves the user on a blank audit log view with the page counter incremented. The `total` ref is populated from `data.total` but is never used for pagination control.
|
||||
@@ -266,6 +277,7 @@ function nextPage() {
|
||||
|
||||
### WR-04: `loadDailyExports` swallows errors silently — admin sees "no exports" instead of error message
|
||||
|
||||
**Status:** fixed — commit 2542c81
|
||||
**File:** `frontend/src/components/admin/AuditLogTab.vue:289-299`
|
||||
|
||||
**Issue:** The catch block sets `dailyExports.value = []` but never sets `exportsError.value`. An admin whose MinIO bucket is unreachable sees "No daily exports available" — identical to a legitimately empty bucket — with no error indication.
|
||||
@@ -280,6 +292,7 @@ function nextPage() {
|
||||
|
||||
### WR-05: `URL.revokeObjectURL` called synchronously before browser download begins — potential silent cancellation
|
||||
|
||||
**Status:** fixed — commit 3fa7e8b (combined with CR-04)
|
||||
**File:** `frontend/src/api/client.js:425-426,481-482`
|
||||
|
||||
**Issue:** Both CSV download functions call `a.click()` then immediately `URL.revokeObjectURL(url)`. The `click()` is asynchronous relative to the OS download manager handoff; revoking before the handoff is complete can silently cancel the download on some browser/OS combinations. The `<a>` element is also never appended to the DOM, which causes silent failure in Firefox.
|
||||
@@ -294,6 +307,7 @@ setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
|
||||
### WR-06: `listShares` uses raw template string for query params — inconsistent with all other API functions
|
||||
|
||||
**Status:** fixed — commit 9e8f8d5
|
||||
**File:** `frontend/src/api/client.js:353`
|
||||
|
||||
**Issue:**
|
||||
@@ -312,6 +326,7 @@ export function listShares(docId) {
|
||||
|
||||
### WR-07: `X-Forwarded-For` used as trusted client IP without validation — IP spoofing in audit logs
|
||||
|
||||
**Status:** fixed — commit 50b6e7f
|
||||
**File:** `backend/api/admin.py:249,301,411,456,517`, `backend/api/documents.py:379,635`, `backend/api/shares.py:67`
|
||||
|
||||
**Issue:** All audit log IP captures use:
|
||||
@@ -324,6 +339,7 @@ request.headers.get("X-Forwarded-For") or (request.client.host if request.client
|
||||
|
||||
### WR-08: `storage.delete_document` commits inside the service, then `delete_document` API handler commits again — split-transaction audit log risk
|
||||
|
||||
**Status:** fixed — commit 2072c3d
|
||||
**File:** `backend/api/documents.py:654-668` and `backend/services/storage.py:182`
|
||||
|
||||
**Issue:** `storage.delete_document` calls `await session.commit()` at line 182, which ends the transaction. The API handler then calls `write_audit_log` and `await session.commit()` at lines 659-668, which commits in a *separate* transaction. If any statement between the two commits raises an exception, the document row is gone but the audit log entry is never written — a silent gap in the audit trail. This is a transaction atomicity violation.
|
||||
|
||||
+29
-11
@@ -52,6 +52,24 @@ _PASSWORD_DETAIL = (
|
||||
)
|
||||
|
||||
|
||||
# ── IP extraction helper ──────────────────────────────────────────────────────
|
||||
|
||||
def _ip(request: Request) -> Optional[str]:
|
||||
"""Extract best-effort client IP from request for audit logging.
|
||||
|
||||
TRUST BOUNDARY: X-Forwarded-For is a client-controlled header and can be
|
||||
forged by any caller. This value is used for forensic audit logging only —
|
||||
not for authentication or access control decisions. In production, deploy
|
||||
behind a trusted reverse proxy (e.g. nginx with
|
||||
`proxy_set_header X-Forwarded-For $remote_addr;`) which overwrites this
|
||||
header with the real remote IP before it reaches FastAPI, or use a
|
||||
trusted-proxy middleware that validates the source CIDR.
|
||||
"""
|
||||
return request.headers.get("X-Forwarded-For") or (
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
|
||||
|
||||
# ── Safe response helper ──────────────────────────────────────────────────────
|
||||
|
||||
def _user_to_dict(user: User) -> dict:
|
||||
@@ -246,14 +264,14 @@ async def create_user(
|
||||
session.add(quota)
|
||||
await session.flush() # persist User + Quota before audit_log FK references them
|
||||
# D-13: admin user created event
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip_addr = _ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.user_created",
|
||||
user_id=new_user.id,
|
||||
actor_id=_admin.id,
|
||||
resource_id=new_user.id,
|
||||
ip_address=_ip,
|
||||
ip_address=_ip_addr,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -298,7 +316,7 @@ async def update_user_status(
|
||||
detail="Cannot deactivate the only admin",
|
||||
)
|
||||
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip_addr = _ip(request)
|
||||
user.is_active = body.is_active
|
||||
|
||||
if not body.is_active:
|
||||
@@ -315,7 +333,7 @@ async def update_user_status(
|
||||
user_id=user.id,
|
||||
actor_id=_admin.id,
|
||||
resource_id=user.id,
|
||||
ip_address=_ip,
|
||||
ip_address=_ip_addr,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -408,7 +426,7 @@ async def update_user_quota(
|
||||
else None
|
||||
)
|
||||
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip_addr = _ip(request)
|
||||
old_limit = quota.limit_bytes
|
||||
quota.limit_bytes = body.limit_bytes
|
||||
session.add(quota)
|
||||
@@ -420,7 +438,7 @@ async def update_user_quota(
|
||||
user_id=user_id,
|
||||
actor_id=_admin.id,
|
||||
resource_id=None,
|
||||
ip_address=_ip,
|
||||
ip_address=_ip_addr,
|
||||
metadata_={"old_bytes": old_limit, "new_bytes": body.limit_bytes},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -453,7 +471,7 @@ async def update_ai_config(
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip_addr = _ip(request)
|
||||
user.ai_provider = body.ai_provider
|
||||
user.ai_model = body.ai_model
|
||||
session.add(user)
|
||||
@@ -465,7 +483,7 @@ async def update_ai_config(
|
||||
user_id=user_id,
|
||||
actor_id=_admin.id,
|
||||
resource_id=None,
|
||||
ip_address=_ip,
|
||||
ip_address=_ip_addr,
|
||||
metadata_={"provider": body.ai_provider, "model": body.ai_model},
|
||||
)
|
||||
await session.commit()
|
||||
@@ -514,7 +532,7 @@ async def delete_user(
|
||||
detail="Cannot delete admin accounts",
|
||||
)
|
||||
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
_ip_addr = _ip(request)
|
||||
|
||||
# SEC-09 (cloud): purge cloud-stored documents and credentials BEFORE DB delete.
|
||||
# Must run before MinIO cleanup so that credentials are still available to build
|
||||
@@ -548,7 +566,7 @@ async def delete_user(
|
||||
user_id=user_id,
|
||||
actor_id=_admin.id,
|
||||
resource_id=user_id,
|
||||
ip_address=_ip,
|
||||
ip_address=_ip_addr,
|
||||
metadata_={"providers": [c.provider for c in cloud_conns]},
|
||||
)
|
||||
|
||||
@@ -572,7 +590,7 @@ async def delete_user(
|
||||
user_id=user_id,
|
||||
actor_id=_admin.id,
|
||||
resource_id=user_id,
|
||||
ip_address=_ip,
|
||||
ip_address=_ip_addr,
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
|
||||
+11
-6
@@ -23,10 +23,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -117,7 +118,7 @@ def _build_filtered_query(
|
||||
if user_id is not None:
|
||||
q = q.where(AuditLog.user_id == user_id)
|
||||
if event_type is not None:
|
||||
q = q.where(AuditLog.event_type == event_type)
|
||||
q = q.where(AuditLog.event_type.like(f"{event_type}%"))
|
||||
return q
|
||||
|
||||
|
||||
@@ -156,7 +157,7 @@ def _build_filtered_query_with_handles(
|
||||
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)
|
||||
q = q.where(AuditLog.event_type.like(f"{event_type}%"))
|
||||
return q
|
||||
|
||||
|
||||
@@ -217,6 +218,8 @@ async def download_daily_export(
|
||||
raise HTTPException(status_code=404, detail="Invalid date format")
|
||||
|
||||
backend = get_storage_backend()
|
||||
if not isinstance(backend, MinIOBackend):
|
||||
raise HTTPException(status_code=404, detail="Export not found")
|
||||
key = f"audit-logs/{date}.csv"
|
||||
|
||||
def _get() -> bytes:
|
||||
@@ -281,7 +284,7 @@ async def list_audit_log(
|
||||
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_q = count_q.where(AuditLog.event_type.like(f"{event_type}%"))
|
||||
count_result = await session.execute(count_q)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
@@ -310,7 +313,7 @@ async def export_audit_log(
|
||||
end: Optional[datetime] = Query(default=None),
|
||||
user_handle: Optional[str] = Query(default=None),
|
||||
event_type: Optional[str] = Query(default=None),
|
||||
format: str = Query(default="csv"), # noqa: A002
|
||||
format: Literal["csv"] = Query(default="csv"), # noqa: A002
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
@@ -369,7 +372,9 @@ async def export_audit_log(
|
||||
writer.writeheader()
|
||||
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))
|
||||
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)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
|
||||
@@ -21,6 +21,7 @@ to all handlers. The doc.user_id=None guard in /confirm is a Wave 2 placeholder.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -345,7 +346,7 @@ async def confirm_upload(
|
||||
" AND (used_bytes + :delta) <= limit_bytes "
|
||||
"RETURNING used_bytes, limit_bytes"
|
||||
),
|
||||
{"delta": size, "uid": str(doc.user_id).replace("-", "")},
|
||||
{"delta": size, "uid": str(doc.user_id)},
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
@@ -353,7 +354,7 @@ async def confirm_upload(
|
||||
# Quota exceeded — fetch current quota state for the 413 body
|
||||
quota_result = await session.execute(
|
||||
text("SELECT used_bytes, limit_bytes FROM quotas WHERE user_id = :uid"),
|
||||
{"uid": str(doc.user_id).replace("-", "")},
|
||||
{"uid": str(doc.user_id)},
|
||||
)
|
||||
q = quota_result.fetchone()
|
||||
# Delete the pending Document row and best-effort remove the MinIO object
|
||||
@@ -376,6 +377,9 @@ async def confirm_upload(
|
||||
|
||||
doc.status = "uploaded"
|
||||
# D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02)
|
||||
# TRUST BOUNDARY: X-Forwarded-For is client-controlled — for audit logging only,
|
||||
# not for auth/access control. Use a trusted reverse proxy in production to
|
||||
# overwrite this header with the real remote IP before it reaches FastAPI.
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -632,6 +636,9 @@ async def delete_document(
|
||||
is_cloud = doc.storage_backend != "minio"
|
||||
_doc_size = doc.size_bytes
|
||||
_doc_id = doc.id
|
||||
# TRUST BOUNDARY: X-Forwarded-For is client-controlled — for audit logging only,
|
||||
# not for auth/access control. Use a trusted reverse proxy in production to
|
||||
# overwrite this header with the real remote IP before it reaches FastAPI.
|
||||
_ip = request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)
|
||||
|
||||
# Cloud routing: attempt provider delete unless remove_only is set
|
||||
@@ -651,11 +658,13 @@ async def delete_document(
|
||||
},
|
||||
)
|
||||
|
||||
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud)
|
||||
# auto_commit=False defers the commit so the audit log write below happens
|
||||
# in the same transaction — avoids the split-transaction gap (WR-08).
|
||||
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
# D-13: document deleted event — written AFTER successful delete, size_bytes only (T-04-07-02)
|
||||
# D-13: document deleted event — written in the same transaction as the delete (WR-08).
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.deleted",
|
||||
@@ -786,9 +795,10 @@ async def stream_document_content(
|
||||
) from exc
|
||||
file_size = len(file_bytes)
|
||||
|
||||
safe_name = urllib.parse.quote(doc.filename, safe='')
|
||||
headers = {
|
||||
"content-type": doc.content_type,
|
||||
"content-disposition": f'inline; filename="{doc.filename}"',
|
||||
"content-disposition": f"inline; filename*=UTF-8''{safe_name}",
|
||||
"accept-ranges": "bytes",
|
||||
"content-length": str(file_size),
|
||||
}
|
||||
|
||||
+21
-1
@@ -63,7 +63,16 @@ class SharePermissionPatch(BaseModel):
|
||||
|
||||
|
||||
def _ip(request: Request) -> Optional[str]:
|
||||
"""Extract best-effort client IP from request (behind proxy or direct)."""
|
||||
"""Extract best-effort client IP from request (behind proxy or direct).
|
||||
|
||||
TRUST BOUNDARY: X-Forwarded-For is a client-controlled header and can be
|
||||
forged by any caller. This value is used for forensic audit logging only —
|
||||
not for authentication or access control decisions. In production, deploy
|
||||
behind a trusted reverse proxy (e.g. nginx with
|
||||
`proxy_set_header X-Forwarded-For $remote_addr;`) which overwrites this
|
||||
header with the real remote IP before it reaches FastAPI, or use a
|
||||
trusted-proxy middleware that validates the source CIDR.
|
||||
"""
|
||||
return request.headers.get("X-Forwarded-For") or (
|
||||
request.client.host if request.client else None
|
||||
)
|
||||
@@ -247,6 +256,7 @@ async def list_shared_with_me(
|
||||
async def update_share_permission(
|
||||
share_id: str,
|
||||
body: SharePermissionPatch,
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
) -> dict:
|
||||
@@ -265,6 +275,16 @@ async def update_share_permission(
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
|
||||
share.permission = body.permission
|
||||
|
||||
await write_audit_log(
|
||||
session=session,
|
||||
event_type="share.permission_changed",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=share.document_id,
|
||||
ip_address=_ip(request),
|
||||
metadata_={"share_id": str(share.id), "new_permission": body.permission},
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return {"id": str(share.id), "permission": share.permission}
|
||||
|
||||
@@ -140,7 +140,12 @@ async def list_metadata(
|
||||
return rows
|
||||
|
||||
|
||||
async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool = False) -> bool:
|
||||
async def delete_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
skip_quota: bool = False,
|
||||
auto_commit: bool = True,
|
||||
) -> bool:
|
||||
"""Delete a document's MinIO object and its PostgreSQL row.
|
||||
|
||||
Returns False if the document is not found; True on success.
|
||||
@@ -149,6 +154,11 @@ async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool =
|
||||
|
||||
skip_quota=True skips the quota decrement — used for cloud-stored documents
|
||||
that were never charged against the user's MinIO quota (T-06.2-03-01).
|
||||
|
||||
auto_commit=False defers the session.commit() to the caller, allowing the
|
||||
caller to write an audit log entry in the same transaction before committing
|
||||
(avoids the split-transaction gap where a failed audit write loses the record
|
||||
while the document row is already gone).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
@@ -179,7 +189,8 @@ async def delete_document(session: AsyncSession, doc_id: str, skip_quota: bool =
|
||||
)
|
||||
|
||||
await session.delete(doc)
|
||||
await session.commit()
|
||||
if auto_commit:
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+37
-20
@@ -350,7 +350,8 @@ export function updateSharePermission(shareId, permission) {
|
||||
}
|
||||
|
||||
export function listShares(docId) {
|
||||
return request(`/api/shares?document_id=${docId}`)
|
||||
const params = new URLSearchParams({ document_id: docId })
|
||||
return request(`/api/shares?${params}`)
|
||||
}
|
||||
|
||||
export function deleteShare(shareId) {
|
||||
@@ -395,7 +396,7 @@ export function adminListAuditLog({ start, end, user_handle, event_type, page =
|
||||
* 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 = {}) {
|
||||
export async function adminExportAuditLogCsv(params = {}, _retry = false) {
|
||||
const { useAuthStore } = await import('../stores/auth.js')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -414,6 +415,18 @@ export async function adminExportAuditLogCsv(params = {}) {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.status === 401 && !_retry) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return adminExportAuditLogCsv(params, true)
|
||||
} catch {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Export failed: ${res.status}`)
|
||||
|
||||
const text = await res.text()
|
||||
@@ -422,8 +435,10 @@ export async function adminExportAuditLogCsv(params = {}) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'audit-export.csv'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,22 +446,10 @@ export async function adminExportAuditLogCsv(params = {}) {
|
||||
*
|
||||
* Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] }
|
||||
* Items are sorted descending by date.
|
||||
* Routes through request() which has built-in 401-refresh-retry logic.
|
||||
*/
|
||||
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()
|
||||
export function adminListDailyExports() {
|
||||
return request('/api/admin/audit-log/daily-exports')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -457,7 +460,7 @@ export async function adminListDailyExports() {
|
||||
*
|
||||
* @param {string} date — YYYY-MM-DD format date string
|
||||
*/
|
||||
export async function adminDownloadDailyExport(date) {
|
||||
export async function adminDownloadDailyExport(date, _retry = false) {
|
||||
const { useAuthStore } = await import('../stores/auth.js')
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -470,6 +473,18 @@ export async function adminDownloadDailyExport(date) {
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (res.status === 401 && !_retry) {
|
||||
try {
|
||||
await authStore.refresh()
|
||||
return adminDownloadDailyExport(date, true)
|
||||
} catch {
|
||||
authStore.accessToken = null
|
||||
authStore.user = null
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`)
|
||||
|
||||
const text = await res.text()
|
||||
@@ -478,8 +493,10 @@ export async function adminDownloadDailyExport(date) {
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `audit-${date}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
}
|
||||
|
||||
// ── Document content proxy URL ────────────────────────────────────────────────
|
||||
|
||||
@@ -290,16 +290,38 @@ const newUser = reactive({
|
||||
})
|
||||
|
||||
function generateRandomPassword() {
|
||||
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||
const upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
const lower = 'abcdefghijkmnpqrstuvwxyz'
|
||||
const digits = '23456789'
|
||||
const special = '!@#$%^&*'
|
||||
const charset = upper + lower + digits + special // 64 chars — 256 % 64 === 0, no modulo bias
|
||||
|
||||
// Generate 16 random positions
|
||||
const arr = new Uint8Array(16)
|
||||
crypto.getRandomValues(arr)
|
||||
let pw = ''
|
||||
for (const byte of arr) {
|
||||
pw += charset[byte % charset.length]
|
||||
const chars = Array.from(arr, byte => charset[byte % charset.length])
|
||||
|
||||
// Inject one guaranteed character from each required class at random positions
|
||||
// using four additional random bytes to pick the injection positions.
|
||||
const posArr = new Uint8Array(8)
|
||||
crypto.getRandomValues(posArr)
|
||||
const required = [
|
||||
upper[posArr[0] % upper.length],
|
||||
lower[posArr[1] % lower.length],
|
||||
digits[posArr[2] % digits.length],
|
||||
special[posArr[3] % special.length],
|
||||
]
|
||||
// Place each required char at a distinct position (0..3) in the array
|
||||
for (let i = 0; i < 4; i++) {
|
||||
chars[i] = required[i]
|
||||
}
|
||||
// Ensure all character classes are represented
|
||||
pw = pw.slice(0, 12) + 'A1!'
|
||||
return pw
|
||||
// Shuffle using Fisher-Yates with the last 4 random bytes as seeds
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = posArr[4 + (i % 4)] % (i + 1)
|
||||
;[chars[i], chars[j]] = [chars[j], chars[i]]
|
||||
}
|
||||
|
||||
return chars.join('')
|
||||
}
|
||||
|
||||
function generatePassword() {
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<span class="text-sm text-gray-500">Page {{ page }}</span>
|
||||
<button
|
||||
@click="nextPage"
|
||||
:disabled="entries.length < perPage"
|
||||
:disabled="page * perPage >= total"
|
||||
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
@@ -263,7 +263,7 @@ function prevPage() {
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (entries.value.length >= perPage) {
|
||||
if (page.value * perPage < total.value) {
|
||||
page.value++
|
||||
fetchLog()
|
||||
}
|
||||
@@ -294,6 +294,7 @@ async function loadDailyExports() {
|
||||
dailyExports.value = data.items ?? []
|
||||
} catch (e) {
|
||||
dailyExports.value = []
|
||||
exportsError.value = 'Failed to load daily exports. Please try again.'
|
||||
} finally {
|
||||
loadingExports.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user