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 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-05-30 10:39:47 +02:00
parent 7691477c6d
commit f006c00d49
4 changed files with 679 additions and 2 deletions
@@ -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)"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/05-cloud-storage-backends/05-UAT.md
</context>
<interfaces>
<!-- Key contracts the executor needs. -->
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 <td> 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 }) })`
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Backend — UserDeleteConfirm model + password verification in delete_user</name>
<files>backend/api/admin.py, backend/tests/test_admin.py</files>
<behavior>
- 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.
</behavior>
<action>
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).
</action>
<verify>
<automated>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</automated>
</verify>
<done>Three tests pass. Delete with correct password returns 204. Delete with wrong password returns 403 and user survives. Delete with no body returns 422.</done>
</task>
<task type="auto">
<name>Task 2: Frontend — adminDeleteUser API function + inline delete confirmation panel</name>
<files>frontend/src/api/client.js, frontend/src/components/admin/AdminUsersTab.vue</files>
<action>
### 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 `<script setup>`, add alongside the existing `confirmDeactivate` state:
- `const confirmDelete = ref(null)` — holds the user ID awaiting delete confirmation
- `const deletePassword = ref('')` — the admin password input
- `const deleteError = ref(null)` — error message for wrong password
Add functions:
- `startDelete(id)`: sets `confirmDelete.value = id`, clears `deletePassword.value` and `deleteError.value`, and sets `confirmDeactivate.value = null` (cannot have both panels open at once).
- `cancelDelete()`: sets `confirmDelete.value = null`, clears password + error.
- `confirmDoDelete(id)`: sets `pendingAction[id] = true`, calls `await api.adminDeleteUser(id, deletePassword.value)`, on success removes the user from `users.value` and calls `cancelDelete()`. On error, sets `deleteError.value = e.message`. Always clears `pendingAction[id]` in finally.
### 3. AdminUsersTab.vue — template: Delete button in action column
In the `<template v-else-if="user.is_active">` block (the normal active user actions), add a Delete button after the Deactivate button:
```html
<span class="text-gray-300">·</span>
<button
@click="startDelete(user.id)"
class="text-red-800 hover:text-red-900 text-sm font-semibold"
>
Delete
</button>
```
For deactivated users (the `<template v-else>` block), also add a Delete button after Reactivate, using the same markup.
### 4. AdminUsersTab.vue — template: inline delete confirmation panel
Replace the existing `<div v-if="confirmDeactivate === user.id">` inline panel pattern: add a second conditional panel for delete below it (as a sibling `<div>` within the `<td>`):
```html
<div v-if="confirmDelete === user.id" class="space-y-2">
<p class="text-xs text-red-700 font-semibold">
Permanently delete <span class="font-bold">{{ user.email }}</span>?
This will erase all their documents, cloud connections, and quota data. This cannot be undone.
</p>
<div>
<label class="block text-xs text-gray-700 mb-1 font-semibold">Your admin password to confirm</label>
<input
v-model="deletePassword"
type="password"
autocomplete="current-password"
placeholder="Admin password"
class="block w-full rounded-lg px-2 py-1.5 text-xs border border-red-300 focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500 transition-colors"
@keydown.enter.prevent="confirmDoDelete(user.id)"
/>
</div>
<p v-if="deleteError" class="text-xs text-red-600">{{ deleteError }}</p>
<div class="flex items-center gap-2">
<button
@click="confirmDoDelete(user.id)"
:disabled="pendingAction[user.id] || !deletePassword"
class="text-red-700 hover:text-red-800 text-sm font-semibold disabled:opacity-50"
>
<span v-if="pendingAction[user.id]" class="flex items-center gap-1">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Deleting…
</span>
<span v-else>Delete permanently</span>
</button>
<span class="text-gray-300">·</span>
<button @click="cancelDelete" class="text-gray-500 hover:text-gray-700 text-sm">
Cancel
</button>
</div>
</div>
```
The delete panel and deactivate panel are mutually exclusive: `startDelete` clears `confirmDeactivate`, and `startDeactivate` should also clear `confirmDelete` (add `confirmDelete.value = null` to `startDeactivate`).
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
</verify>
<done>Frontend build passes with zero errors. AdminUsersTab has Delete button and inline confirmation panel with password field. adminDeleteUser exported from client.js.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| DELETE /api/admin/users/{id} body | Admin password sent in JSON body — verified server-side with Argon2 before any deletion |
| admin password in transit | Sent over HTTPS (production); never stored, logged, or returned in any response |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-05-11-01 | Elevation of Privilege | DELETE /admin/users/{id} | mitigate | Requires get_current_admin (admin role) AND correct admin password verification via pwdlib Argon2 — two-factor confirmation |
| T-05-11-02 | Information Disclosure | Wrong password error message | mitigate | 403 "Invalid admin password" — does not confirm whether user exists (user existence already checked first but 403 is returned regardless to prevent oracle) |
| T-05-11-03 | Tampering | admin_password in request body | mitigate | Pydantic UserDeleteConfirm validates presence; verify_password uses constant-time Argon2 comparison (pwdlib) |
| T-05-11-04 | Repudiation | User deletion audit trail | mitigate | write_audit_log("admin.user_deleted") written before session.delete — existing code preserved unchanged |
| T-05-11-05 | Denial of Service | Repeated wrong-password delete attempts | accept | Admin endpoints already rate-limited at application level; admin accounts are trusted actors |
| T-05-11-SC | Tampering | npm/pip installs | mitigate | No new packages installed in this plan |
</threat_model>
<verification>
After both tasks complete:
- `pytest backend/tests/test_admin.py::test_delete_user_correct_password backend/tests/test_admin.py::test_delete_user_wrong_password backend/tests/test_admin.py::test_delete_user_no_body -v`
- `npm run build` — zero errors
- Full pytest suite: `pytest -v` — zero new failures
- Manual: open Admin panel → Users tab, confirm Delete button visible per user row
- Manual: click Delete, enter correct admin password → user removed from list
- Manual: click Delete, enter wrong password → error message shown, user not removed
- Security: verify admin_password not present in any audit log entry
</verification>
<success_criteria>
- UserDeleteConfirm model added to admin.py
- delete_user verifies admin password via verify_password before proceeding
- Wrong password returns 403 without deleting user
- adminDeleteUser(id, adminPassword) exported from client.js
- AdminUsersTab has Delete button on active and deactivated rows
- Inline password confirmation panel appears on Delete click, mutually exclusive with deactivate panel
- Three new backend tests pass; full test suite has zero new failures; frontend build clean
</success_criteria>
<output>
Create `.planning/phases/05-cloud-storage-backends/05-11-SUMMARY.md` when done
</output>