From f006c00d493cbd19500a6035a991e65b24472a63 Mon Sep 17 00:00:00 2001 From: curo1305 Date: Sat, 30 May 2026 10:39:47 +0200 Subject: [PATCH] docs(05): create UAT gap closure plans 09-11 Three new plans address all 6 diagnosed gaps from 05-UAT.md: - 05-09: cloud document open (fetch+Blob URL), re-analyze (cloud-aware Celery task), and edit (PATCH /api/documents/{id}) - 05-10: OAuth initiate JSON response fix, Nextcloud custom endpoint edit round-trip, Edit button on ERROR rows, confirmation text overflow - 05-11: admin hard-delete with admin-password confirmation (backend UserDeleteConfirm model + frontend inline panel) Co-Authored-By: Claude Sonnet 4.6 --- .planning/ROADMAP.md | 11 +- .../05-cloud-storage-backends/05-09-PLAN.md | 187 ++++++++++++ .../05-cloud-storage-backends/05-10-PLAN.md | 218 ++++++++++++++ .../05-cloud-storage-backends/05-11-PLAN.md | 265 ++++++++++++++++++ 4 files changed, 679 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/05-cloud-storage-backends/05-09-PLAN.md create mode 100644 .planning/phases/05-cloud-storage-backends/05-10-PLAN.md create mode 100644 .planning/phases/05-cloud-storage-backends/05-11-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 11a2e51..a456280 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -219,7 +219,7 @@ Before any phase is marked complete, all three gates must pass: 4. A user can disconnect a cloud backend; credentials are permanently deleted from the DB and a subsequent attempt to use that backend returns an appropriate error — no orphaned data remains 5. An admin API response for a user's cloud connections returns only `provider, display_name, connected_at, status` — the `credentials_enc` column is never present in any serialized response -**Plans**: 8 plans +**Plans**: 11 plans (8 original + 3 UAT gap closure) **Wave 1** — Test scaffold + dependencies @@ -250,11 +250,18 @@ Before any phase is marked complete, all three gates must pass: - [x] 05-08-PLAN.md — AppSidebar cloud section + CloudProviderTreeItem + CloudFolderTreeItem + human checkpoint +**Wave 8** — UAT gap closure (parallel, all independent) + +- [ ] 05-09-PLAN.md — Cloud document open/re-analyze/edit: authenticated fetch+Blob URL, cloud-aware Celery task, PATCH /api/documents/{id} +- [ ] 05-10-PLAN.md — OAuth initiate fix (JSON response), Nextcloud custom endpoint edit round-trip, Edit button on ERROR rows, confirmation text overflow +- [ ] 05-11-PLAN.md — Admin hard-delete with password confirmation: UserDeleteConfirm backend model + inline frontend panel + **Phase gates (must pass before Phase 5 is complete):** - [x] `pytest -v` — zero failures; SSRF prevention on WebDAV/Nextcloud user-supplied URLs; credential encryption/decryption round-trip; admin response never exposes `credentials_enc`; OAuth invalid_grant handling - [x] Security agent: SSRF allowlist verification; credential key derivation correctness; connection status never leaks raw credential values - [x] Bandit + pip audit + npm audit all clean +- [ ] UAT gaps 05-09, 05-10, 05-11 resolved and re-tested **UI hint**: yes @@ -268,4 +275,4 @@ Before any phase is marked complete, all three gates must pass: | 2. Users & Authentication | 5/5 | Complete | 2026-05-22 | | 3. Document Migration & Multi-User Isolation | 5/5 | Complete | 2026-05-25 | | 4. Folders, Sharing, Quotas & Document UX | 9/9 | Complete | 2026-05-28 | -| 5. Cloud Storage Backends | 8/8 | Complete | 2026-05-29 | +| 5. Cloud Storage Backends | 8/11 | UAT gap closure in progress | — | diff --git a/.planning/phases/05-cloud-storage-backends/05-09-PLAN.md b/.planning/phases/05-cloud-storage-backends/05-09-PLAN.md new file mode 100644 index 0000000..4921487 --- /dev/null +++ b/.planning/phases/05-cloud-storage-backends/05-09-PLAN.md @@ -0,0 +1,187 @@ +--- +phase: 05-cloud-storage-backends +plan: 09 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/api/documents.py + - backend/tasks/document_tasks.py + - frontend/src/api/client.js + - frontend/src/components/documents/DocumentPreviewModal.vue + - frontend/src/views/DocumentView.vue + - backend/tests/test_cloud.py +autonomous: true +requirements: [CLOUD-03, CLOUD-05, CLOUD-07] +gap_closure: true + +must_haves: + truths: + - "Opening a cloud document proxies bytes through the backend and renders without 401" + - "Re-analyzing a cloud document retrieves file bytes from the cloud backend, not MinIO" + - "PATCH /api/documents/{id} accepts filename and folder_id and persists the change" + - "Frontend fetch-with-blob-URL approach works in DocumentPreviewModal and DocumentView" + artifacts: + - path: "backend/api/documents.py" + provides: "PATCH /{doc_id} endpoint accepting {filename, folder_id}" + - path: "backend/tasks/document_tasks.py" + provides: "Cloud-aware re-analyze: routes to correct backend by doc.storage_backend" + - path: "frontend/src/components/documents/DocumentPreviewModal.vue" + provides: "Authenticated fetch + Blob URL for document preview" + - path: "frontend/src/views/DocumentView.vue" + provides: "Authenticated fetch + Blob URL for document open" + key_links: + - from: "frontend/src/components/documents/DocumentPreviewModal.vue" + to: "/api/documents/{id}/content" + via: "fetch with Authorization header → Blob URL" + - from: "backend/tasks/document_tasks.py" + to: "get_storage_backend_for_document" + via: "doc.storage_backend != 'minio' branch" +--- + + +Fix three independent root causes that prevent cloud documents from being opened, re-analyzed, or edited. + +Purpose: Cloud-stored documents are inaccessible after upload because (1) the preview uses an unauthenticated iframe src, (2) the Celery re-analyze task hardcodes MinIO, and (3) no PATCH endpoint exists for document metadata. + +Output: All three flows work end-to-end for cloud documents. Three new/updated tests pass. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/05-cloud-storage-backends/05-UAT.md + + + + + +From backend/api/documents.py: +- router = APIRouter(prefix="/api/documents") +- get_regular_user dep: rejects admins (403), requires active user +- Document ORM fields: id, user_id, filename, folder_id, storage_backend, object_key +- Ownership check pattern: `if doc.user_id != current_user.id: raise HTTPException(404)` +- Existing PATCH for folder move lives in backend/api/folders.py with separate router + +From backend/tasks/document_tasks.py: +- `_run(document_id)` calls `get_storage_backend()` unconditionally (returns MinIO backend) +- `doc.storage_backend` column holds "minio" | "google_drive" | "onedrive" | "nextcloud" | "webdav" +- Session is opened fresh per task: `async with AsyncSessionLocal() as session:` +- User is fetched: `user = await session.get(User, doc.user_id)` +- `get_storage_backend_for_document(doc, user, session)` already exists in storage/__init__.py + +From backend/storage/__init__.py (inferred from documents.py usage): +- `get_storage_backend_for_document(doc, user, session)` → returns correct backend instance + +From frontend/src/api/client.js: +- `request(path, options)` injects Authorization: Bearer header automatically +- Returns parsed JSON; for binary responses a different approach is needed +- `getDocumentContentUrl(docId)` currently returns a plain URL string (no auth) + + + + + + Task 1: Add PATCH /api/documents/{doc_id} endpoint + cloud-aware Celery re-analyze + backend/api/documents.py, backend/tasks/document_tasks.py, backend/tests/test_cloud.py + + - PATCH /api/documents/{doc_id} with body {filename?: str, folder_id?: uuid | null} returns 200 with updated document dict; non-owner gets 404; admin gets 403 (get_regular_user). + - PATCH with only filename updates filename, folder_id unchanged. + - PATCH with folder_id=null moves document to root (no folder). + - Re-analyze task: when doc.storage_backend != "minio", calls get_storage_backend_for_document(doc, user, session) instead of get_storage_backend(); if user is None returns {"status": "missing_user"}; on CloudConnectionError returns {"status": "extract_failed", "error": "cloud backend error"}. + - Re-analyze task: MinIO path (storage_backend == "minio") unchanged. + + + In backend/api/documents.py, add a DocumentPatch Pydantic model with optional fields `filename: Optional[str] = None` and `folder_id: Optional[uuid.UUID] = None` — use a sentinel default of `_UNSET = object()` or `Optional[uuid.UUID]` with a custom flag; the cleanest approach for nullable-vs-absent folder_id is to use `model_fields_set` to distinguish "not provided" from "set to null". Accept the body and update only the fields present in `model_fields_set`. Validate that at least one field is provided (422 if body is empty). Apply ownership guard (doc.user_id != current_user.id → 404). Return the updated document dict using the same shape as GET /api/documents/{id} (call `storage.get_metadata(session, doc_id)` after commit). + + In backend/tasks/document_tasks.py, in `_run()`, replace the unconditional `backend = get_storage_backend()` block with: + - If `doc.storage_backend` is None or `doc.storage_backend == "minio"`: use `get_storage_backend()` (existing MinIO path). + - Else: use `get_storage_backend_for_document(doc, user, session)`. If user is None (doc.user_id is None), return `{"document_id": document_id, "status": "missing_user"}`. Import `get_storage_backend_for_document` at the top of `_run()` using a deferred import (same pattern as other imports in this file: `from storage import get_storage_backend_for_document`). Catch `CloudConnectionError` from the get_object call and return `{"document_id": document_id, "status": "extract_failed", "error": "cloud backend error"}` (do NOT include the raw provider error message). + + In backend/tests/test_cloud.py, add three tests: + 1. `test_patch_document_filename` — create a document, PATCH with {filename: "renamed.pdf"}, assert 200 and updated filename. + 2. `test_patch_document_wrong_owner` — create two users, try to PATCH user A's document as user B, assert 404. + 3. `test_reanalyze_cloud_document_routes_to_cloud_backend` — mock `get_storage_backend_for_document` to return an AsyncMock backend, set doc.storage_backend="nextcloud", call `_run(doc_id)` directly, assert the mock backend's `get_object` was called and MinIO backend's `get_object` was NOT called. + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_cloud.py::test_patch_document_filename tests/test_cloud.py::test_patch_document_wrong_owner tests/test_cloud.py::test_reanalyze_cloud_document_routes_to_cloud_backend -v + + Three tests pass. PATCH /api/documents/{id} returns 200 for valid owner, 404 for wrong owner. Re-analyze task uses cloud backend for cloud documents. + + + + Task 2: Authenticated document preview — fetch-with-Blob-URL in frontend + frontend/src/api/client.js, frontend/src/components/documents/DocumentPreviewModal.vue, frontend/src/views/DocumentView.vue + + In frontend/src/api/client.js, add a new exported function `fetchDocumentContent(docId)` that calls `fetch(/api/documents/${docId}/content, { headers: { Authorization: Bearer ${authStore.accessToken} }, credentials: "include" })`. On 401, attempt one refresh via `authStore.refresh()` and retry (same pattern as `request()`). Return the raw `Response` object (not parsed JSON) so callers can call `.blob()` on it. Do NOT use the existing `request()` helper because it calls `res.json()` unconditionally. + + In frontend/src/components/documents/DocumentPreviewModal.vue: + - Remove the computed `proxyUrl` that returns a raw URL string. + - Add reactive state: `blobUrl = ref(null)`, `loadError = ref(null)`, `loading = ref(true)`. + - On `onMounted` (and watch `props.doc.id` for changes), call `fetchDocumentContent(props.doc.id)` from the API client, then `response.blob()`, then `URL.createObjectURL(blob)` and assign to `blobUrl`. + - In the template, change `iframe :src="proxyUrl"` to `iframe :src="blobUrl"` with a `v-if="blobUrl"`. + - Show a loading spinner while `loading` is true and `blobUrl` is null. + - Show an error message if `loadError` is set. + - On `onUnmounted`, call `URL.revokeObjectURL(blobUrl.value)` to release the object URL. + + In frontend/src/views/DocumentView.vue: + - Locate any `window.open()` call that opens `/api/documents/{id}/content` directly. + - Replace it with: call `fetchDocumentContent(docId)`, get the blob, create an object URL, then `window.open(objectUrl)`. After a short delay (e.g. `setTimeout(() => URL.revokeObjectURL(objectUrl), 60000)`), revoke the URL. + - Import `fetchDocumentContent` from the API client. + + Note: the `request()` helper in client.js already handles 401 → refresh → retry. The new `fetchDocumentContent` must replicate only the auth injection + single retry, not the JSON parsing. Keep it simple: use the `useAuthStore` lazy import pattern already in `request()`. + + + cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5 + + Frontend build passes with zero errors. DocumentPreviewModal and DocumentView use fetchDocumentContent. No unauthenticated src= URLs remain for the /content endpoint. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| frontend→/api/documents/{id}/content | Previously browser-navigated; now fetch() with Bearer — closes the unauthenticated access gap | +| PATCH /api/documents/{id} body | User-supplied filename and folder_id — validated via Pydantic; ownership enforced | +| Celery task → cloud backend | Task now instantiates cloud backend inside worker process using DB-resident credentials | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-05-09-01 | Spoofing | PATCH /documents/{id} | mitigate | get_regular_user enforced; admin → 403; wrong owner → 404 | +| T-05-09-02 | Information Disclosure | PATCH response | mitigate | storage.get_metadata() whitelist used for response — no credentials_enc or password_hash | +| T-05-09-03 | Tampering | Celery task cloud credentials | mitigate | get_storage_backend_for_document loads credentials from DB inside task's own session — no credentials in broker message | +| T-05-09-04 | Information Disclosure | fetchDocumentContent Blob URL | accept | Blob URL is same-origin, revoked on unmount — no persistent exposure | +| T-05-09-SC | Tampering | npm/pip installs | mitigate | No new packages installed in this plan | + + + +After both tasks complete: +- `pytest backend/tests/test_cloud.py -v` — all three new tests pass, no regressions +- `npm run build` — zero errors +- Manual: open a cloud document in the app — preview loads without 401 +- Manual: re-analyze a cloud document — task completes without NoSuchKey error +- Manual: rename a cloud document — PATCH returns 200 with updated filename + + + +- PATCH /api/documents/{id} is callable with {filename} or {folder_id} and returns the updated document +- Re-analyze Celery task calls the cloud backend for cloud documents (not MinIO) +- DocumentPreviewModal uses fetch + Blob URL, no unauthenticated iframe src +- DocumentView uses fetch + Blob URL, no window.open with raw /content URL +- All three new pytest tests pass; full suite has zero new failures + + + +Create `.planning/phases/05-cloud-storage-backends/05-09-SUMMARY.md` when done + diff --git a/.planning/phases/05-cloud-storage-backends/05-10-PLAN.md b/.planning/phases/05-cloud-storage-backends/05-10-PLAN.md new file mode 100644 index 0000000..330d424 --- /dev/null +++ b/.planning/phases/05-cloud-storage-backends/05-10-PLAN.md @@ -0,0 +1,218 @@ +--- +phase: 05-cloud-storage-backends +plan: 10 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/api/cloud.py + - frontend/src/components/settings/SettingsCloudTab.vue + - frontend/src/components/cloud/CloudCredentialModal.vue + - frontend/src/components/ui/ConfirmBlock.vue + - backend/tests/test_cloud.py +autonomous: true +requirements: [CLOUD-01, CLOUD-04] +gap_closure: true + +must_haves: + truths: + - "Clicking Connect on Google Drive or OneDrive initiates OAuth without a 401" + - "Nextcloud custom endpoint is preserved when re-editing an existing connection" + - "Edit button appears on ERROR-state Nextcloud/WebDAV rows" + - "Disconnect confirmation text renders fully within its container without overflow" + artifacts: + - path: "backend/api/cloud.py" + provides: "oauth_initiate returns JSON {url} (200) instead of RedirectResponse (302)" + - path: "frontend/src/components/settings/SettingsCloudTab.vue" + provides: "handleConnect uses fetch() + Bearer header; Edit button in ERROR template block" + - path: "frontend/src/components/cloud/CloudCredentialModal.vue" + provides: "watch handler detects custom endpoint on edit and repopulates showAdvanced + customEndpoint" + - path: "frontend/src/components/ui/ConfirmBlock.vue" + provides: "break-words on message paragraph" + key_links: + - from: "frontend/src/components/settings/SettingsCloudTab.vue" + to: "/api/cloud/oauth/initiate/{provider}" + via: "fetch() with Authorization header → data.url → window.location.href" + - from: "backend/api/cloud.py" + to: "oauth_initiate handler" + via: "returns JSONResponse({url: authorization_url}) status 200" +--- + + +Fix four cloud settings UI gaps: OAuth initiate 401, Nextcloud custom endpoint lost on edit, missing Edit button on ERROR rows, and confirmation text overflow. + +Purpose: Users cannot connect OAuth providers (Google Drive/OneDrive) due to a bare browser navigation that carries no auth header. Nextcloud connections with custom endpoints lose their configuration on re-edit. ERROR-state connections cannot be edited without removing first. + +Output: OAuth flow initiates correctly, Nextcloud edit round-trips custom endpoint, ERROR rows have Edit button, confirmation text wraps. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/05-cloud-storage-backends/05-UAT.md + + + + + +From backend/api/cloud.py (current oauth_initiate): +- Route: GET /api/cloud/oauth/initiate/{provider} +- Currently returns: RedirectResponse(url=authorization_url, status_code=302) +- Requires: Depends(get_regular_user) +- State token stored in Redis; OAuth URL constructed from google/onedrive SDKs +- Change: return JSONResponse({"url": authorization_url}) with status 200 + +From frontend/src/components/settings/SettingsCloudTab.vue (current handleConnect): +- OAUTH_PROVIDERS = new Set(['google_drive', 'onedrive']) +- Currently: window.location.href = `/api/cloud/oauth/initiate/${provider.key}` +- Change: fetch with Authorization header, then window.location.href = data.url + +From frontend/src/api/client.js: +- The `request()` function is JSON-only (calls res.json()). For OAuth initiate, we want JSON back (the {url} object), so `request()` can be used directly. +- Export: `initiateOAuth(provider)` → calls request(`/api/cloud/oauth/initiate/${provider}`) + +From frontend/src/components/cloud/CloudCredentialModal.vue (watch handler, lines 191-209): +- Nextcloud edit: extracts only hostname with regex match[1], clears customEndpoint +- Bug: if stored server_url does NOT match /remote.php/dav/files/{username}/ pattern, the custom endpoint is silently lost +- Fix: compare resolvedServerUrl (auto-constructed) to existing.server_url; if they differ, populate customEndpoint with the stored URL and set showAdvanced=true + +From frontend/src/components/settings/SettingsCloudTab.vue (ERROR template, lines 88-96): +- Only has Remove button +- Add Edit button identical to the one in the ACTIVE block (same v-if guard: !OAUTH_PROVIDERS.has(provider.key)) + +From frontend/src/components/ui/ConfirmBlock.vue: +- The message

has class "text-sm text-gray-700" — add "break-words" to it +- The wrapper div in SettingsCloudTab.vue (lines 100-113) needs "w-full overflow-hidden" on the outer div + + + + + + Task 1: Fix OAuth initiate — return JSON URL instead of 302 redirect + backend/api/cloud.py, backend/tests/test_cloud.py + + - GET /api/cloud/oauth/initiate/google_drive with valid Bearer token returns 200 JSON {url: "https://accounts.google.com/..."}. + - GET /api/cloud/oauth/initiate/onedrive with valid Bearer token returns 200 JSON {url: "https://login.microsoftonline.com/..."}. + - GET /api/cloud/oauth/initiate/invalid_provider returns 400 (unchanged). + - GET /api/cloud/oauth/initiate/google_drive with no auth returns 401 or 403 (unchanged behavior — get_regular_user enforces this). + + + In backend/api/cloud.py, change the `oauth_initiate` handler: + - Remove `response_class=RedirectResponse` from the `@router.get` decorator. + - Replace the two `return RedirectResponse(url=authorization_url, status_code=302)` statements (one for google_drive, one for onedrive) with `from fastapi.responses import JSONResponse` (already imported via fastapi) and return `JSONResponse({"url": authorization_url})`. + - The state token generation, Redis storage, and authorization URL construction are unchanged. + - Update the return type annotation if present. + + In backend/tests/test_cloud.py, add test `test_oauth_initiate_returns_json_url`: + - Creates a regular user + token. + - Mocks Redis setex (the app state redis client). + - For google_drive provider: mocks `google_auth_oauthlib.flow.Flow.from_client_config` to return a mock whose `authorization_url` returns ("https://accounts.google.com/test", "state123"). + - Calls GET /api/cloud/oauth/initiate/google_drive with Bearer header. + - Asserts response.status_code == 200. + - Asserts response.json()["url"].startswith("https://accounts.google.com/"). + - Also adds `test_oauth_initiate_requires_auth`: calls without token, asserts 401 or 403. + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_cloud.py::test_oauth_initiate_returns_json_url tests/test_cloud.py::test_oauth_initiate_requires_auth -v + + Both tests pass. oauth_initiate returns 200 JSON {url}. No RedirectResponse in handler. + + + + Task 2: Frontend OAuth fetch, Nextcloud edit fix, Edit on ERROR, text overflow + frontend/src/components/settings/SettingsCloudTab.vue, frontend/src/components/cloud/CloudCredentialModal.vue, frontend/src/components/ui/ConfirmBlock.vue, frontend/src/api/client.js + + ### 1. client.js — add initiateOAuth helper + Export `initiateOAuth(provider)` that calls `request(`/api/cloud/oauth/initiate/${provider}`)`. This uses the existing `request()` helper which injects the Bearer header and handles 401 → refresh → retry. + + ### 2. SettingsCloudTab.vue — fix handleConnect for OAuth providers + Replace the current `window.location.href = /api/cloud/oauth/initiate/${provider.key}` line in `handleConnect` with: + ``` + const data = await initiateOAuth(provider.key) + window.location.href = data.url + ``` + Import `initiateOAuth` from `../../api/client.js`. Wrap in try/catch; on error show a toast or set a reactive `oauthError` ref displayed above the provider list. + + ### 3. SettingsCloudTab.vue — add Edit button to ERROR template block + In the `` template block (currently lines 88-96), add an Edit button before the Remove button, mirroring the ACTIVE block exactly: + ```html + + ``` + + ### 4. SettingsCloudTab.vue — fix confirmation wrapper overflow + Add `w-full overflow-hidden` to the outer `

` that wraps the `` component (the div at the current line starting `v-if="confirmRemoveId === connectionFor(provider.key)?.id"`). This div needs to break out of the flex row — it is already rendered outside the `flex items-center` row as a sibling block, so adding `w-full` and `overflow-hidden` is sufficient. + + ### 5. CloudCredentialModal.vue — fix Nextcloud edit custom endpoint pre-population + In the watch handler on `props.show`, after the existing logic that sets `serverBase.value` by extracting the hostname: + - Compute what the auto-constructed URL would be using the extracted hostname and `existing.connection_username`: + `const autoUrl = match ? \`${match[1]}/remote.php/dav/files/${encodeURIComponent(existing.connection_username ?? '')}/\` : ''` + - If `existing.server_url !== autoUrl` (the stored URL doesn't match the auto-constructed pattern) AND `existing.server_url` has a path beyond just the hostname, then: + - Set `showAdvanced.value = true` + - Set `customEndpoint.value = existing.server_url` + - Otherwise leave `showAdvanced.value = false` and `customEndpoint.value = ''` (existing behavior). + + ### 6. ConfirmBlock.vue — add break-words to message paragraph + Change `

` to `

`. + + + cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5 + + Frontend build passes with zero errors. All four UI changes are applied: OAuth uses fetch, ERROR rows have Edit button, Nextcloud watch handler preserves custom endpoint, ConfirmBlock message has break-words. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| frontend→/api/cloud/oauth/initiate | Now goes through fetch() with Bearer header instead of bare browser navigation | +| OAuth URL returned to frontend | URL is generated by the backend OAuth library and stored state in Redis — frontend only receives the URL string | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-05-10-01 | Spoofing | oauth_initiate auth | mitigate | get_regular_user still enforced — only authenticated users receive the OAuth URL | +| T-05-10-02 | Information Disclosure | OAuth URL in JSON response | accept | URL is a standard OAuth authorization URL with CSRF state token — no credentials in the URL | +| T-05-10-03 | Tampering | OAuth state token | mitigate | State token generated server-side (secrets.token_urlsafe(32)), stored in Redis with TTL 1800, single-use (deleted in callback) — unchanged from original design | +| T-05-10-04 | Spoofing | Nextcloud custom endpoint re-edit | accept | Pre-populated values come from encrypted DB credentials decrypted server-side and returned as server_url field — not user-alterable before display | +| T-05-10-SC | Tampering | npm/pip installs | mitigate | No new packages installed in this plan | + + + +After both tasks complete: +- `pytest backend/tests/test_cloud.py::test_oauth_initiate_returns_json_url backend/tests/test_cloud.py::test_oauth_initiate_requires_auth -v` +- `npm run build` — zero errors +- Manual: click Connect on Google Drive — browser navigates to accounts.google.com (not localhost 401) +- Manual: edit Nextcloud connection with custom endpoint — Advanced section opens with endpoint pre-filled +- Manual: connection in ERROR state — Edit button is visible +- Manual: disconnect confirmation text wraps within its container + + + +- oauth_initiate returns 200 JSON {url} (not 302 redirect) +- Two new pytest tests pass for OAuth initiate +- handleConnect in SettingsCloudTab uses fetch() + initiateOAuth(); no window.location.href to /api path +- ERROR status template block has Edit button for non-OAuth providers +- CloudCredentialModal watch handler repopulates customEndpoint and showAdvanced when stored URL differs from auto-constructed pattern +- ConfirmBlock message paragraph has break-words class +- Full frontend build and pytest suite have zero new failures + + + +Create `.planning/phases/05-cloud-storage-backends/05-10-SUMMARY.md` when done + diff --git a/.planning/phases/05-cloud-storage-backends/05-11-PLAN.md b/.planning/phases/05-cloud-storage-backends/05-11-PLAN.md new file mode 100644 index 0000000..c62e282 --- /dev/null +++ b/.planning/phases/05-cloud-storage-backends/05-11-PLAN.md @@ -0,0 +1,265 @@ +--- +phase: 05-cloud-storage-backends +plan: 11 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/api/admin.py + - frontend/src/api/client.js + - frontend/src/components/admin/AdminUsersTab.vue + - backend/tests/test_admin.py +autonomous: true +requirements: [ADMIN-02, SEC-09] +gap_closure: true + +must_haves: + truths: + - "Admin can permanently delete a non-admin user after entering their own admin password" + - "Backend verifies the admin password before executing the delete" + - "Delete purges cloud connections + MinIO objects + all DB rows (existing SEC-09 code runs)" + - "Frontend presents an inline confirmation panel with admin password field before calling DELETE" + - "Incorrect admin password returns 403 without deleting the user" + artifacts: + - path: "backend/api/admin.py" + provides: "UserDeleteConfirm Pydantic model; delete_user handler reads admin_password from body and verifies it" + - path: "frontend/src/api/client.js" + provides: "adminDeleteUser(id, adminPassword) calling DELETE /api/admin/users/{id}" + - path: "frontend/src/components/admin/AdminUsersTab.vue" + provides: "Inline delete confirmation panel with admin password field, mirroring confirmDeactivate pattern" + key_links: + - from: "frontend/src/components/admin/AdminUsersTab.vue" + to: "DELETE /api/admin/users/{id}" + via: "adminDeleteUser(id, adminPassword)" + - from: "backend/api/admin.py delete_user" + to: "services.auth.verify_password" + via: "verify_password(body.admin_password, admin.password_hash)" +--- + + +Add admin hard-delete with password confirmation: a backend body model that verifies the admin's own password before permanent deletion, and a frontend inline confirmation panel with password field. + +Purpose: The backend delete endpoint exists and correctly purges all user data, but it accepts no authentication proof for the destructive action. There is also no frontend UI to trigger it. + +Output: Admin can initiate deletion from the Users tab, enter their password in an inline panel, and the backend verifies the password before deleting. Incorrect password is rejected with 403. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/05-cloud-storage-backends/05-UAT.md + + + + + +From backend/api/admin.py: +- Existing delete_user handler: DELETE /api/admin/users/{user_id}, status 204, no request body +- Already purges cloud connections + MinIO objects, writes audit log (SEC-09 — do NOT change this logic) +- Uses Depends(get_current_admin) → resolves to User ORM instance as `_admin` +- verify_password not currently imported; services.auth exports it: `from services.auth import verify_password` +- The handler must add: parse body as UserDeleteConfirm, call verify_password(body.admin_password, _admin.password_hash), raise 403 on failure + +From services/auth.py (existing pattern from admin.py imports): +- `hash_password(plain: str) -> str` +- `verify_password(plain: str, hashed: str) -> bool` — uses pwdlib Argon2 + +From frontend/src/components/admin/AdminUsersTab.vue (confirmDeactivate pattern to mirror): +- `confirmDeactivate = ref(null)` tracks which user ID is awaiting confirmation +- `startDeactivate(id)` sets confirmDeactivate = id +- Inline panel in renders when `confirmDeactivate === user.id` +- Panel has confirm + cancel buttons +- Model to follow: add parallel state `confirmDelete = ref(null)`, `deletePassword = ref('')`, `deleteError = ref(null)` + +From frontend/src/api/client.js: +- All admin functions follow: request(`/api/admin/users/${id}/...`, { method, headers, body }) +- DELETE with body: `request(\`/api/admin/users/${id}\`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ admin_password: adminPassword }) })` + + + + + + Task 1: Backend — UserDeleteConfirm model + password verification in delete_user + backend/api/admin.py, backend/tests/test_admin.py + + - DELETE /api/admin/users/{id} with correct admin_password in body returns 204 and user is deleted. + - DELETE /api/admin/users/{id} with wrong admin_password returns 403 {"detail": "Invalid admin password"} and user is NOT deleted. + - DELETE /api/admin/users/{id} with no body returns 422 (Pydantic validation). + - Cannot delete admin accounts (existing guard: 400 "Cannot delete admin accounts") — unchanged. + - Cannot delete non-existent user (existing guard: 404) — unchanged. + - Audit log entry written for successful delete (existing code) — unchanged. + - Cloud credentials purged before DB delete (existing SEC-09 code) — unchanged. + + + In backend/api/admin.py: + 1. Add `UserDeleteConfirm` Pydantic model in the Request models section: + ```python + class UserDeleteConfirm(BaseModel): + admin_password: str + ``` + 2. Add `from services.auth import verify_password` to the existing imports from services.auth (currently imports `hash_password, revoke_all_refresh_tokens`). + 3. Modify the `delete_user` handler signature to accept the body: + - Change `async def delete_user(user_id, request, session, _admin)` to also accept `body: UserDeleteConfirm`. + - FastAPI will parse the JSON body automatically. + 4. Add password verification BEFORE any deletion logic (fail fast): + ```python + if not verify_password(body.admin_password, _admin.password_hash): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invalid admin password", + ) + ``` + 5. All existing deletion logic (cloud purge, MinIO purge, audit log, session.delete) is unchanged. + + In backend/tests/test_admin.py, add three tests: + 1. `test_delete_user_correct_password` — create admin + regular user, call DELETE with correct admin password, assert 204, assert user no longer in GET /admin/users. + 2. `test_delete_user_wrong_password` — same setup, call DELETE with wrong password, assert 403, assert user still in GET /admin/users (not deleted). + 3. `test_delete_user_no_body` — call DELETE with no body (or empty body), assert 422. + + Use the existing `_create_user_and_token(session, role="admin")` pattern from test_cloud.py (or the conftest admin_user fixture if available). + + + cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_admin.py::test_delete_user_correct_password tests/test_admin.py::test_delete_user_wrong_password tests/test_admin.py::test_delete_user_no_body -v + + Three tests pass. Delete with correct password returns 204. Delete with wrong password returns 403 and user survives. Delete with no body returns 422. + + + + Task 2: Frontend — adminDeleteUser API function + inline delete confirmation panel + frontend/src/api/client.js, frontend/src/components/admin/AdminUsersTab.vue + + ### 1. client.js — add adminDeleteUser + Export `adminDeleteUser(id, adminPassword)`: + ```javascript + export function adminDeleteUser(id, adminPassword) { + return request(`/api/admin/users/${id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ admin_password: adminPassword }), + }) + } + ``` + + ### 2. AdminUsersTab.vue — add delete confirmation state + In `