docs: add shared module map to CLAUDE.md, SECURITY.md, planning artifacts

- CLAUDE.md: add Code Standards section with backend and frontend shared
  module maps, component architecture rules, duplication checklist, and
  no-dead-code enforcement rule
- SECURITY.md: Phase 02 + 03 security audit results (all threats CLOSED)
- .planning: update milestone audit, config, and add plan/UAT files for
  phases 01, 02-06, and 06.2-05

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-06-02 16:10:59 +02:00
parent cce70b2ef6
commit eaa3399ec0
7 changed files with 1282 additions and 215 deletions
+2 -1
View File
@@ -13,7 +13,8 @@
"test_gate": true, "test_gate": true,
"security_check": true, "security_check": true,
"bugfix_max_lines": 50, "bugfix_max_lines": 50,
"require_root_cause_fix": true "require_root_cause_fix": true,
"_auto_chain_active": false
}, },
"ship": { "ship": {
"pr_body_sections": [ "pr_body_sections": [
@@ -0,0 +1,63 @@
---
status: testing
phase: 01-infrastructure-foundation
source: 01-01-SUMMARY.md, 01-02-SUMMARY.md, 01-03-SUMMARY.md, 01-04-SUMMARY.md, 01-05-SUMMARY.md
started: 2026-05-31T00:00:00Z
updated: 2026-05-31T00:00:00Z
---
## Current Test
<!-- OVERWRITE each test - shows where we are -->
number: 1
name: Cold Start Smoke Test
expected: |
Kill any running containers. Run `docker compose down -v` to clear all volumes and state.
Then run `docker compose up --build -d`. All 5 services (postgres, minio, redis, backend,
celery-worker) should come up as `Up (healthy)` with no errors. Hit `GET /health` and get
back `{"status":"ok","checks":{"postgres":"ok","minio":"ok"}}` — live data from a fresh start.
awaiting: user response
## Tests
### 1. Cold Start Smoke Test
expected: Kill any running containers. Run `docker compose down -v` to clear all volumes and state. Then run `docker compose up --build -d`. All 5 services (postgres, minio, redis, backend, celery-worker) should come up as `Up (healthy)` with no errors. Hit `GET /health` and get back `{"status":"ok","checks":{"postgres":"ok","minio":"ok"}}` — live data from a fresh start.
result: [pending]
### 2. Database Migration Applies Cleanly
expected: Run `cd backend && alembic upgrade head`. It should exit 0 with output `Running upgrade -> 0001`. All 11 tables (users, quotas, refresh_tokens, folders, documents, topics, document_topics, shares, audit_log, cloud_connections, groups) should be present in PostgreSQL.
result: [pending]
### 3. Health Endpoint Reports OK
expected: `GET /health` (or `curl http://localhost:8000/health`) returns HTTP 200 with `{"status":"ok","checks":{"postgres":"ok","minio":"ok"}}`. Both postgres and minio checks show "ok".
result: [pending]
### 4. Document Upload Stored in PostgreSQL + MinIO
expected: Upload any text or PDF file via `POST /documents` (multipart form, field `file`). Response contains a UUID `id` and `original_name` matching the filename. Checking PostgreSQL shows one row in `documents` with `object_key` starting with `null-user/`. Checking the MinIO `docuvault` bucket shows the object is present.
result: [pending]
### 5. Celery Background Task Processes Upload
expected: After uploading a document, Celery should automatically pick up the `extract_and_classify` task. Within a few seconds, `docker compose logs celery-worker` shows `Task tasks.document_tasks.extract_and_classify[...] succeeded`. No `FAILED` task entries appear.
result: [pending]
### 6. Document Delete Removes from Both Stores
expected: `DELETE /documents/{id}` (using the UUID from the upload) returns `{"success": true}`. The document row is gone from PostgreSQL. The object is gone from the MinIO `docuvault` bucket.
result: [pending]
### 7. MinIO Object Key Contains No Original Filename
expected: After uploading a file named something recognizable (e.g. `invoice-q3.pdf`), inspect the object key in MinIO. The key should be in the form `null-user/{uuid}/{uuid}.pdf` — the word "invoice", "q3", or any part of the original filename must NOT appear in the key.
result: [pending]
## Summary
total: 7
passed: 0
issues: 0
pending: 7
skipped: 0
blocked: 0
## Gaps
[none yet]
@@ -0,0 +1,340 @@
---
phase: 02-users-authentication
plan: "06"
type: execute
wave: 1
depends_on: ["02-05"]
files_modified:
- backend/api/admin.py
- backend/tests/test_admin_api.py
- frontend/src/router/index.js
- frontend/src/App.vue
- frontend/src/views/SettingsView.vue
- frontend/src/components/auth/TotpEnrollment.vue
- frontend/package.json
autonomous: true
requirements: [AUTH-03, AUTH-04, AUTH-05, SEC-01, SEC-03, ADMIN-01, ADMIN-07]
gap_closure: true
source_doc: 02-UAT.md
must_haves:
truths:
- "Admin can create a new user via POST /api/admin/users without HTTP 500"
- "Login, register, and password-reset pages show AuthLayout only — no sidebar, no user identity footer"
- "After logout the sidebar is gone — the user lands on the login page with AuthLayout"
- "Non-admin user navigating to /admin is redirected to /"
- "TOTP enrollment step 1 shows a scannable QR image, not a text link"
- "TOTP enrollment option is accessible from a tab within /settings (Account tab)"
artifacts:
- path: "backend/api/admin.py"
provides: "create_user handler with await session.flush() before write_audit_log()"
contains: "await session.flush()"
- path: "backend/tests/test_admin_api.py"
provides: "regression test confirming audit_log FK ordering is safe"
contains: "test_create_user_writes_audit_log"
- path: "frontend/src/router/index.js"
provides: "meta: { layout: 'auth' } on auth routes; meta: { requiresAdmin: true } on /admin; beforeEach role guard"
contains: "requiresAdmin"
- path: "frontend/src/App.vue"
provides: "Layout-aware root — renders AuthLayout for auth routes, app shell for all others"
contains: "AuthLayout"
- path: "frontend/src/views/SettingsView.vue"
provides: "Account tab that embeds AccountView content (2FA, change password, sign-out-all)"
contains: "account"
- path: "frontend/src/components/auth/TotpEnrollment.vue"
provides: "QR image rendered from qrUri using qrcode library"
contains: "QRCode"
- path: "frontend/package.json"
provides: "qrcode package installed"
contains: "qrcode"
key_links:
- from: "frontend/src/App.vue"
to: "frontend/src/layouts/AuthLayout.vue"
via: "v-if route.meta.layout === 'auth' conditional import"
pattern: "AuthLayout"
- from: "frontend/src/router/index.js"
to: "frontend/src/stores/auth.js"
via: "beforeEach reads authStore.user?.role"
pattern: "requiresAdmin.*role"
- from: "frontend/src/components/auth/TotpEnrollment.vue"
to: "qrcode"
via: "import QRCode from 'qrcode'; QRCode.toDataURL(qrUri.value)"
pattern: "toDataURL"
---
<objective>
Close five UAT gaps discovered in Phase 02 that block critical auth flows from passing.
Purpose: Phase 02 is marked complete in STATE.md, but the UAT revealed a blocker (admin 500) and four major issues (sidebar on auth pages, orphaned account view, missing admin route guard, missing QR code). These gaps prevent TOTP enrollment, leaks user identity on public pages, and allows non-admins to reach the admin panel. This plan fixes all five gaps.
Output: Five concrete fixes — one backend single-line verification + regression test, and four frontend changes — that make UAT tests 4, 6, 7, 9, and 14 pass.
Note: GAP 1 (admin create_user HTTP 500) was already fixed during plan 02-04 execution. The `await session.flush()` is present at admin.py:247 and a regression test `test_create_user_writes_audit_log` exists in test_admin_api.py. Task 1 below verifies the fix is correct and confirms the test passes rather than making any code change.
</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/02-users-authentication/02-05-SUMMARY.md
</context>
<interfaces>
From frontend/src/stores/auth.js:
accessToken ref(null) — JWT, memory only
user ref(null) — { id, handle, email, role, totp_enabled }
refresh() async function — uses httpOnly cookie; called in beforeEach on page reload
From frontend/src/layouts/AuthLayout.vue:
Template: <div class="min-h-screen bg-gray-50 flex items-center justify-center">
<router-view /> ← renders the auth page card
</div>
Note: AuthLayout already contains its own <router-view /> — App.vue must NOT add
a second one when AuthLayout is active.
From frontend/src/App.vue (current):
Unconditionally renders <AppSidebar /> + <router-view /> — no layout switch.
Import: import AppSidebar from './components/layout/AppSidebar.vue'
From frontend/src/router/index.js (current):
Auth routes have only meta: { public: true } — no layout hint.
/admin route has no meta at all.
beforeEach: checks accessToken only; never reads user.role.
From frontend/src/components/auth/TotpEnrollment.vue:
step ref: 'setup' | 'verify' | 'backup-codes'
qrUri ref: provisioning_uri from api.totpSetup() — valid otpauth:// URI
In 'verify' step, currently renders <a :href="qrUri"> text link.
qrcode is not installed (confirmed: package.json has no qrcode entry).
From frontend/src/views/SettingsView.vue:
Tabs array: [{ id: 'preferences' }, { id: 'ai' }, { id: 'cloud' }]
activeTab ref defaults to 'preferences'.
Pattern: v-if="activeTab === 'preferences'" renders <SettingsPreferencesTab />
AccountView content to merge: Account info section, 2FA section (TotpEnrollment),
Change password form, Sessions (sign-out-all). All logic lives in AccountView.vue
script setup — reuse the same components (TotpEnrollment, ConfirmBlock, PasswordStrengthBar).
</interfaces>
<tasks>
<task type="auto">
<name>Task 1: Verify backend fix + regression test for admin create_user (GAP 1)</name>
<files>backend/api/admin.py, backend/tests/test_admin_api.py</files>
<action>
Confirm the fix is present and the regression test passes. Do not change any code unless the verification below fails.
Step 1 — Verify the flush is present: Read backend/api/admin.py lines 239260. Confirm `await session.flush()` appears after `session.add(quota)` and before the `write_audit_log()` call. If the line is missing, add it immediately after `session.add(quota)` (single-line change, matching the comment pattern already used in bootstrap_admin and auth/register).
Step 2 — Verify the regression test exists: Read backend/tests/test_admin_api.py. Confirm `test_create_user_writes_audit_log` exists and checks that POST /api/admin/users returns 201 AND that an audit_log row with event_type='admin.user_created' exists. If the test is absent or only checks status_code without asserting the audit log row, add or extend it.
Step 3 — Run the test: `cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_admin_api.py::test_create_user_writes_audit_log -v`. It must pass.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_admin_api.py::test_create_user_writes_audit_log -v</automated>
</verify>
<done>test_create_user_writes_audit_log passes; session.flush() confirmed present before write_audit_log() in create_user handler</done>
</task>
<task type="auto">
<name>Task 2: Auth route layout switching + admin role guard (GAPs 2, 3, 4)</name>
<files>frontend/src/router/index.js, frontend/src/App.vue</files>
<action>
Three changes, two files:
--- frontend/src/router/index.js ---
Change 1 — Add layout hint to all four auth routes. Set `meta: { public: true, layout: 'auth' }` on:
- /login
- /register
- /password-reset
- /password-reset/confirm
Change 2 — Add admin guard to /admin route. Change:
`{ path: '/admin', component: () => import('../views/AdminView.vue') }`
to:
`{ path: '/admin', component: () => import('../views/AdminView.vue'), meta: { requiresAdmin: true } }`
Change 3 — Extend beforeEach to check requiresAdmin. The existing guard ends after the try/catch. After that block, add:
if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') {
return { path: '/' }
}
This runs after the silent refresh attempt (so authStore.user is populated) and before the route renders. Do not alter any existing logic — append only.
--- frontend/src/App.vue ---
Replace the entire file content with a layout-aware version:
- Import both AuthLayout and AppSidebar.
- Import useRoute from vue-router.
- In the template: use a v-if/v-else on `route.meta.layout === 'auth'`:
- When true: render `<AuthLayout />` only. AuthLayout already contains its own
`<router-view />` — do NOT add another router-view here.
- When false (all other routes): render the original app shell:
`<div class="flex h-screen overflow-hidden"><AppSidebar /><main class="flex-1 overflow-y-auto"><router-view /></main></div>`
- Keep the existing onMounted topicsStore.fetchTopics() call.
- AuthLayout is a local component; import it as:
`import AuthLayout from './layouts/AuthLayout.vue'`
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
</verify>
<done>
- Build exits 0.
- Vite output contains no "component not found" or "missing import" warnings.
- frontend/src/App.vue imports AuthLayout and uses route.meta.layout conditionally.
- frontend/src/router/index.js has meta.layout:'auth' on all four auth routes and meta.requiresAdmin:true on /admin with the role check in beforeEach.
</done>
</task>
<task type="auto">
<name>Task 3: AccountView merged into SettingsView as Account tab + QR code in TotpEnrollment (GAPs 3 and 5)</name>
<files>frontend/src/views/SettingsView.vue, frontend/src/components/auth/TotpEnrollment.vue, frontend/package.json</files>
<action>
Three changes:
--- frontend/package.json ---
Add `"qrcode": "^1.5.4"` to the `dependencies` section (not devDependencies — it is a runtime library). Run `npm install` in the frontend directory after editing.
--- frontend/src/components/auth/TotpEnrollment.vue ---
In the 'verify' step block, replace the `<a :href="qrUri">` link block with a QR image. Use the qrcode library to generate a data URL:
1. Add an import at the top of the script setup block:
`import QRCode from 'qrcode'`
2. Add a ref for the QR data URL:
`const qrDataUrl = ref('')`
3. In startSetup(), after setting `qrUri.value = data.provisioning_uri`, generate the QR image:
`qrDataUrl.value = await QRCode.toDataURL(qrUri.value, { width: 200, margin: 1 })`
4. In the 'verify' step template, replace the entire `<div class="bg-white border...">` block
(the one containing the `<a :href="qrUri">` link) with:
`<img v-if="qrDataUrl" :src="qrDataUrl" alt="TOTP QR code" class="w-48 h-48 rounded-xl border border-gray-200" />`
Keep the manual secret display section (the `<code>` block) immediately below the image
so users who cannot scan still have the fallback.
The QRCode.toDataURL call returns a Promise<string> with a data:image/png;base64,... URL.
The img tag renders it inline without any server round-trip.
--- frontend/src/views/SettingsView.vue ---
Add an "Account" tab to SettingsView that embeds the AccountView content directly.
1. Add the tab entry to the tabs array (between 'preferences' and 'ai', or append after 'cloud' — append at the end is fine):
`{ id: 'account', label: 'Account' }`
2. Add a new tab panel below the existing three:
`<SettingsAccountTab v-if="activeTab === 'account'" />`
3. Create the new component file at:
`frontend/src/components/settings/SettingsAccountTab.vue`
This component contains exactly the content from AccountView.vue:
- The four sections (Account information, Two-factor authentication, Change password, Sessions)
- All script setup logic (changePassword, disableTotp, onTotpEnrolled, signOutAll, all refs)
- All imports (useAuthStore, api, PasswordStrengthBar, TotpEnrollment, ConfirmBlock, AppSpinner)
Remove the outer `<div class="p-8 max-w-2xl mx-auto">` wrapper and `<h2>Account settings</h2>`
heading — SettingsView already provides the page chrome.
4. In SettingsView.vue script setup, add the import:
`import SettingsAccountTab from '../components/settings/SettingsAccountTab.vue'`
5. Update the /account route in frontend/src/router/index.js to redirect to settings:
`{ path: '/account', redirect: '/settings' }`
(Remove the lazy import of AccountView from the route — the view is now embedded in Settings.)
This ensures any bookmark or back-navigation to /account silently lands on /settings.
Do NOT delete AccountView.vue — leave it in place (the redirect makes it unreachable from the router, not deleted from disk).
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | tail -5</automated>
</verify>
<done>
- Build exits 0.
- frontend/package.json contains "qrcode" in dependencies.
- frontend/src/components/auth/TotpEnrollment.vue imports QRCode and renders an img tag in the verify step.
- frontend/src/views/SettingsView.vue has an Account tab rendering SettingsAccountTab.
- frontend/src/components/settings/SettingsAccountTab.vue exists with 2FA, change password, and sign-out-all sections.
- /account route redirects to /settings.
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| router guard | Unauthenticated or non-admin client navigates directly to /admin |
| layout selection | Auth page accidentally renders app shell leaking user identity |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-GAP-01 | Elevation of Privilege | router/index.js beforeEach | mitigate | requiresAdmin meta + role check; if authStore.user?.role !== 'admin' → redirect to / |
| T-02-GAP-02 | Information Disclosure | App.vue AppSidebar | mitigate | Conditional layout: auth routes render AuthLayout only; sidebar absent on all public routes |
| T-02-GAP-03 | Tampering | admin.py create_user flush order | accept | Already mitigated: await session.flush() present before write_audit_log(); regression test confirms FK ordering |
| T-02-GAP-SC | Tampering | npm install qrcode | mitigate | qrcode@1.5.x is the canonical npm package (weekly downloads 20M+); no server dependency; LEGITIMACY: VERIFIED |
</threat_model>
<verification>
Run all checks from the project root:
```bash
# Backend regression test (GAP 1 fix confirmed)
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest tests/test_admin_api.py -v -k "create_user"
# Full backend suite — zero failures
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest -v
# Frontend build — exits 0
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build
# Frontend test suite — exits 0
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm test
# Confirm layout guard is wired
grep -n "layout.*auth\|AuthLayout" /Users/nik/Documents/Progamming/document_scanner/frontend/src/App.vue
grep -n "layout.*auth" /Users/nik/Documents/Progamming/document_scanner/frontend/src/router/index.js | wc -l
# expect 4 (login, register, password-reset, password-reset/confirm)
# Confirm admin route guard
grep -n "requiresAdmin\|role.*admin" /Users/nik/Documents/Progamming/document_scanner/frontend/src/router/index.js
# Confirm QR library installed
grep "qrcode" /Users/nik/Documents/Progamming/document_scanner/frontend/package.json
# Confirm QR image rendered (not a link)
grep -n "toDataURL\|qrDataUrl\|img.*qr" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/auth/TotpEnrollment.vue
# Confirm Account tab in SettingsView
grep -n "account\|SettingsAccountTab" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/SettingsView.vue
```
</verification>
<success_criteria>
1. `pytest tests/test_admin_api.py::test_create_user_writes_audit_log` passes — confirms audit_log FK ordering is correct under PostgreSQL
2. Visiting /login, /register, /password-reset renders AuthLayout (no sidebar, no user identity) — confirmed by App.vue v-if on route.meta.layout
3. Non-admin authenticated user navigating to /admin is redirected to / — confirmed by beforeEach requiresAdmin check
4. SettingsView has an Account tab containing TotpEnrollment, change password form, and sign-out-all; /account redirects to /settings
5. TotpEnrollment 'verify' step renders an `<img>` tag sourced from QRCode.toDataURL(qrUri) — no `<a href="otpauth://...">` link in production path
6. `pytest -v` in backend passes with zero failures
7. `npm run build` in frontend exits 0
8. `npm test` in frontend exits 0
</success_criteria>
<output>
Create `.planning/phases/02-users-authentication/02-06-SUMMARY.md` when done.
</output>
@@ -0,0 +1,404 @@
---
phase: "06.2"
plan: "05"
type: execute
wave: 3
depends_on:
- "06.2-04"
files_modified:
- frontend/src/views/AccountView.vue
- frontend/src/components/admin/AdminUsersTab.vue
- frontend/src/views/CloudFolderView.vue
- frontend/src/components/admin/AuditLogTab.vue
autonomous: true
gap_closure: true
requirements:
- SHARE-03
- ADMIN-06
must_haves:
truths:
- "User can see their own @handle in Account settings — enabling them to share their handle with others for document sharing"
- "Admin can see each user's handle in the Users tab — enabling handle lookup for support and sharing"
- "Cloud folder browser shows an actionable error when no cloud connection exists — directing user to Settings"
- "Audit log entries display @alice style handles (@ prefix present)"
- "Export CSV button shows active filter count when filters are set — user understands export scope before clicking"
- "Clear filters button in Audit Log tab resets all filters and re-fetches unfiltered data"
artifacts:
- path: "frontend/src/views/AccountView.vue"
provides: "Handle row in Account information section"
contains: "authStore.user?.handle"
- path: "frontend/src/components/admin/AdminUsersTab.vue"
provides: "Handle column in users table"
contains: "user.handle"
- path: "frontend/src/views/CloudFolderView.vue"
provides: "Actionable no-connection error message"
contains: "Settings"
- path: "frontend/src/components/admin/AuditLogTab.vue"
provides: "@ prefix on handles, Clear filters button, active filter count indicator"
contains: "clearFilters"
key_links:
- from: "AccountView.vue"
to: "authStore.user"
via: "authStore.user?.handle (already present in /api/auth/me response)"
pattern: "authStore.user\\?.handle"
- from: "AdminUsersTab.vue user row"
to: "adminListUsers() response"
via: "user.handle (backend returns handle in GET /api/admin/users)"
pattern: "user\\.handle"
- from: "AuditLogTab.vue entry.user_handle"
to: "rendered cell"
via: "template expression with @ prefix"
pattern: "'@' \\+ entry.user_handle"
---
<objective>
Close the four UAT-diagnosed gaps from 06.2-UAT.md: (1) user handle invisible in account settings and admin user list, (2) cloud folder browser shows unhelpful error when no connection exists, (3) audit log handle entries missing @ prefix, (4) CSV export gives no indication of active filters and no way to clear them.
Purpose: These gaps block real usage — users cannot share documents because handles are invisible, cloud storage is unusable with no diagnostic guidance, audit logs look wrong without @ prefixes, and CSV exports silently export filtered (possibly empty) data.
Output: Four targeted frontend changes across four files. No backend changes required.
</objective>
<execution_context>
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-UAT.md
</execution_context>
<context>
@/Users/nik/Documents/Progamming/document_scanner/.planning/ROADMAP.md
@/Users/nik/Documents/Progamming/document_scanner/.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-CONTEXT.md
<interfaces>
<!-- Extracted from codebase — executor needs no further exploration. -->
From frontend/src/views/AccountView.vue (Account information section, lines 8-24):
<!-- Currently renders email and role only -->
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="font-semibold text-gray-800 mb-4">Account information</h3>
<div class="space-y-2 text-sm text-gray-700">
<div><span class="text-gray-500">Email:</span> {{ authStore.user?.email }}</div>
<div class="flex items-center gap-2">
<span class="text-gray-500">Role:</span>
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold" ...>
{{ authStore.user?.role }}
</span>
</div>
</div>
</section>
<!-- authStore.user shape (from GET /api/auth/me): { id, email, handle, role, totp_enabled } -->
<!-- authStore.user?.handle is already available — just not rendered -->
From frontend/src/components/admin/AdminUsersTab.vue (table head, lines 113-119):
<thead>
<tr class="bg-gray-50 text-left">
<th ...>Email</th>
<th ...>Role</th>
<th ...>Status</th>
<th ...>Created</th>
<th ...>Actions</th>
</tr>
</thead>
<!-- user object shape returned by adminListUsers(): { id, handle, email, role, is_active, totp_enabled, created_at } -->
<!-- user.handle is present in the API response (confirmed: admin.py line 63 returns "handle") -->
From frontend/src/views/CloudFolderView.vue (load function, lines 126-137):
async function load() {
loading.value = true
error.value = ''
try {
const data = await api.getCloudFolders(provider.value, folderId.value ?? 'root')
items.value = data.items ?? []
} catch (e) {
error.value = e.message || 'Failed to load folder contents'
} finally {
loading.value = false
}
}
<!-- Error rendered in template (line 36-39): -->
<div v-else-if="error" class="text-sm text-red-500 py-8 text-center">
{{ error }}
<button @click="load" class="ml-2 text-indigo-600 hover:underline">Retry</button>
</div>
<!-- Backend returns 404 with detail "No active connection" when no cloud provider connected -->
<!-- getCloudFolders() throws; e.message is whatever the request() wrapper extracts from the response -->
From frontend/src/components/admin/AuditLogTab.vue (relevant section):
<!-- Line 95 — current handle cell (no @ prefix): -->
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
<!-- Filters reactive object (lines 188-193): -->
const filters = reactive({
start: '',
end: '',
user_handle: '',
event_type: '',
})
<!-- Filter bar (lines 4-62): Apply filters button at line 44, Export CSV button at line 51 -->
<!-- applyFilters() (line 220): resets page to 1, calls fetchLog() -->
<!-- No clearFilters() function exists yet -->
<!-- No active filter count indicator exists yet near Export CSV button -->
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Show handle in AccountView and AdminUsersTab</name>
<files>frontend/src/views/AccountView.vue, frontend/src/components/admin/AdminUsersTab.vue</files>
<action>
CHANGE 1 — frontend/src/views/AccountView.vue
In the "Account information" section (lines 8-24), add a Username row immediately after the Email row and before the Role row. The new row follows the same pattern as the Email row:
<div><span class="text-gray-500">Username:</span> @{{ authStore.user?.handle }}</div>
Place this line between the email div and the role div. The @ is a literal character prepended to the handle value so users immediately recognise it as their sharing handle. No script changes needed — authStore.user?.handle is already available.
CHANGE 2 — frontend/src/components/admin/AdminUsersTab.vue
Add a "Handle" column to the users table so admins can look up other users' handles.
In the `<thead>` row (after the Email th and before the Role th), add:
<th class="px-4 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wider">Handle</th>
In the `<tbody>` rows (after the email `<td>` and before the role `<td>`), add:
<td class="px-4 py-3 text-gray-500 font-mono text-xs">{{ user.handle ? '@' + user.handle : '—' }}</td>
No script changes needed — adminListUsers() already returns handle in the user object (confirmed in backend/api/admin.py line 63).
</action>
<verify>
<automated>grep -n "authStore.user?.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/AccountView.vue && grep -n "user\.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AdminUsersTab.vue | grep -v "handle:"</automated>
</verify>
<done>
- `grep "authStore.user?.handle" frontend/src/views/AccountView.vue` returns a match in the template section
- `grep "user\.handle" frontend/src/components/admin/AdminUsersTab.vue` returns a match in both thead and tbody
- `cd frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>"` returns no output
</done>
</task>
<task type="auto">
<name>Task 2: Actionable cloud connection error and audit log @ prefix</name>
<files>frontend/src/views/CloudFolderView.vue, frontend/src/components/admin/AuditLogTab.vue</files>
<action>
CHANGE 1 — frontend/src/views/CloudFolderView.vue
Replace the generic error handler in the `load()` function with one that distinguishes "no connection" from a general error. The backend returns a response whose error detail contains "No active connection" (or HTTP 404) when no cloud provider is connected.
Replace the catch block in load():
} catch (e) {
const msg = e.message || ''
if (msg.toLowerCase().includes('no active connection') || msg.includes('404') || msg.toLowerCase().includes('not found')) {
error.value = 'No cloud provider connected. Go to Settings to connect a cloud storage account.'
} else {
error.value = msg || 'Failed to load folder contents.'
}
}
Also update the error template block (lines 36-39) to add a Settings link. Replace the existing error div with:
<div v-else-if="error" class="text-sm text-red-500 py-8 text-center">
<p>{{ error }}</p>
<div class="flex items-center justify-center gap-3 mt-2">
<router-link
to="/settings"
class="text-indigo-600 hover:underline text-sm"
>
Go to Settings
</router-link>
<button @click="load" class="text-indigo-600 hover:underline text-sm">
Retry
</button>
</div>
</div>
Confirm `router-link` is usable here — `useRouter` and `useRoute` are already imported from 'vue-router' in the script setup.
CHANGE 2 — frontend/src/components/admin/AuditLogTab.vue
Change the user handle cell (line 95) from:
{{ entry.user_handle || entry.user_id || '—' }}
to:
{{ entry.user_handle ? '@' + entry.user_handle : (entry.user_id || '—') }}
This is a template-only one-liner change. No script changes required.
</action>
<verify>
<automated>grep -n "No cloud provider connected" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/CloudFolderView.vue && grep -n "'@' + entry.user_handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue</automated>
</verify>
<done>
- `grep "No cloud provider connected" frontend/src/views/CloudFolderView.vue` returns a match
- `grep "Go to Settings" frontend/src/views/CloudFolderView.vue` returns a match
- `grep "'@' + entry.user_handle" frontend/src/components/admin/AuditLogTab.vue` returns a match
- `grep "entry.user_handle || entry.user_id" frontend/src/components/admin/AuditLogTab.vue` returns NO match (old pattern gone)
- `cd frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>"` returns no output
</done>
</task>
<task type="auto">
<name>Task 3: Clear filters button and active filter count indicator in AuditLogTab</name>
<files>frontend/src/components/admin/AuditLogTab.vue</files>
<action>
Add two UX improvements to AuditLogTab.vue to make the CSV export scope transparent.
CHANGE 1 — Add clearFilters() function in the script setup section:
Add the following function after the existing applyFilters() function:
function clearFilters() {
filters.start = ''
filters.end = ''
filters.user_handle = ''
filters.event_type = ''
page.value = 1
fetchLog()
}
Also add a computed property (or inline expression) for active filter count. Add this computed after the clearFilters() function:
import { computed } from 'vue' // add computed to the existing vue import if not present
const activeFilterCount = computed(() => {
let count = 0
if (filters.start) count++
if (filters.end) count++
if (filters.user_handle) count++
if (filters.event_type) count++
return count
})
NOTE: `computed` must be added to the existing `import { ref, reactive, onMounted }` line at the top of the script. Change it to `import { ref, reactive, onMounted, computed }`.
CHANGE 2 — Add "Clear filters" button to filter bar in the template:
In the filter bar (the `<div class="flex flex-wrap gap-3 mb-4 items-end">` block), add a "Clear filters" button immediately after the existing "Apply filters" button. Only show it when filters are active:
<button
v-if="activeFilterCount > 0"
@click="clearFilters"
class="border border-gray-300 text-gray-500 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
Clear filters
</button>
CHANGE 3 — Add active filter count indicator near Export CSV button:
Wrap the existing Export CSV button in a relative container and add a badge showing the active filter count when non-zero. Replace the standalone Export CSV button block with:
<div class="relative inline-flex flex-col items-start gap-1">
<button
@click="exportCsv"
:disabled="exportingCsv"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors disabled:opacity-50"
>
<span v-if="exportingCsv" class="flex items-center gap-1">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Exporting…
</span>
<span v-else>Export CSV</span>
</button>
<span
v-if="activeFilterCount > 0"
class="text-xs text-amber-600"
>
{{ activeFilterCount }} filter{{ activeFilterCount !== 1 ? 's' : '' }} active
</span>
</div>
The amber text "N filter(s) active" sits directly below the Export CSV button so users see at a glance that the download will be scoped. The existing `<p v-if="exportError">` block remains unchanged immediately after this new wrapper div.
</action>
<verify>
<automated>grep -n "clearFilters\|activeFilterCount\|Clear filters\|filters active" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue | head -15</automated>
</verify>
<done>
- `grep "clearFilters" frontend/src/components/admin/AuditLogTab.vue` returns at least 2 matches (definition + @click binding)
- `grep "activeFilterCount" frontend/src/components/admin/AuditLogTab.vue` returns at least 3 matches (computed definition + v-if + template text)
- `grep "Clear filters" frontend/src/components/admin/AuditLogTab.vue` returns a match in the template
- `grep "filters active" frontend/src/components/admin/AuditLogTab.vue` returns a match
- `grep "computed" frontend/src/components/admin/AuditLogTab.vue` returns a match in the import line
- `cd frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>"` returns no output
</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| AccountView → authStore.user | handle is read from in-memory Pinia store — never from localStorage; no user-supplied input involved |
| CloudFolderView → error message | error text originates from backend API response; rendered via Vue template auto-escaping (no innerHTML) — XSS risk mitigated |
| AuditLogTab → entry.user_handle | handle value from API response rendered via Vue template auto-escaping — no innerHTML |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-06.2-05-01 | Information Disclosure | Handle visible in AccountView | accept | Handle is already a public-within-platform identifier (used as share target); displaying it to the owning user is expected and correct |
| T-06.2-05-02 | Information Disclosure | Handle visible in AdminUsersTab | accept | Admin already has access to email; handle is lower-sensitivity than email; admin-only endpoint already enforces get_current_admin |
| T-06.2-05-03 | XSS | Cloud error message rendered from API response | mitigate | Vue template auto-escaping prevents XSS; the error string is interpolated via {{ }} not v-html — no raw HTML injection possible |
| T-06.2-05-04 | XSS | @ + entry.user_handle rendered in table | mitigate | String concatenation in Vue template expression is auto-escaped — not v-html |
| T-06.2-05-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan — frontend-only template and script changes only |
</threat_model>
<verification>
After all three tasks complete:
Build check (no errors):
```
cd /Users/nik/Documents/Progamming/document_scanner/frontend && npm run build 2>&1 | grep -i "error" | grep -v "^>" | head -10
```
Expected: no output.
Gap 1 — Handle in AccountView:
```
grep -n "authStore.user?.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/AccountView.vue
```
Expected: match in template section.
Gap 1 — Handle in AdminUsersTab:
```
grep -n "user\.handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AdminUsersTab.vue
```
Expected: at least 2 matches (thead + tbody).
Gap 2 — Cloud actionable error:
```
grep -n "No cloud provider connected\|Go to Settings" /Users/nik/Documents/Progamming/document_scanner/frontend/src/views/CloudFolderView.vue
```
Expected: 2 matches.
Gap 3 — Audit log @ prefix:
```
grep -n "'@' + entry.user_handle" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue
```
Expected: 1 match.
Gap 4 — Clear filters + filter count:
```
grep -c "clearFilters\|activeFilterCount" /Users/nik/Documents/Progamming/document_scanner/frontend/src/components/admin/AuditLogTab.vue
```
Expected: 5 or more matches total.
Backend test suite unaffected (no backend changes):
```
cd /Users/nik/Documents/Progamming/document_scanner/backend && pytest -x -q 2>&1 | tail -5
```
Expected: exits 0, same pass count as before this plan.
</verification>
<success_criteria>
- Account settings page shows the user's own @handle in the Account information section
- Admin Users tab includes a Handle column showing @handle for every user row
- Cloud folder browser shows "No cloud provider connected. Go to Settings to connect a cloud storage account." (with a Settings link) when backend returns a no-connection error
- Audit log table renders @alice style handles (@ prefix present on all non-null handles)
- AuditLogTab has a "Clear filters" button (visible only when at least one filter is active) that resets all filters and re-fetches
- Export CSV button area shows "N filter(s) active" in amber text when one or more filters are set
- `npm run build` exits 0 with no errors
- Backend pytest suite still passes (no regressions — this plan touches only frontend files)
</success_criteria>
<output>
Create `.planning/phases/06.2-close-v1-sharing-cloud-delete-csv-export-gaps/06.2-05-SUMMARY.md` when done.
</output>
+273 -214
View File
@@ -1,30 +1,46 @@
--- ---
milestone: v1.0 milestone: v1.0
audited: 2026-05-30 audited: "2026-05-30T00:00:00Z"
status: gaps_found status: gaps_found
scores: scores:
requirements: 48/54 requirements: 44/54
phases_verified: 2/5 phases_verified: 2/6
integration_blockers: 3 integration_blockers: 4
integration_warnings: 6 integration_warnings: 7
flows_complete: 2/4 flows_complete: 3/6
gaps: gaps:
requirements: requirements:
- id: "SHARE-02" - id: "SHARE-02"
status: "partial" status: "partial"
phase: "4" phase: "4"
claimed_by_plans: ["04-01-PLAN.md", "04-04-PLAN.md"] claimed_by_plans: ["04-04-PLAN.md", "06.1-01-PLAN.md"]
completed_by_plans: ["04-04-SUMMARY.md (Sharing API SHARE-01..05 in commit log)"] completed_by_plans: ["06.1-01-PLAN.md"]
verification_status: "missing" verification_status: "gaps_found"
evidence: "Grant/revocation/list work. But GET /api/documents/{id} enforces doc.user_id == current_user.id — share recipients get 404 on metadata, blocking the document detail view." evidence: "Two distinct bugs: (1) backend/api/documents.py line 542 checks doc.user_id != current_user.id and raises 404 — share recipients get 404 on GET /api/documents/{id} despite having a valid Share record; (2) SharedView.vue accesses share.document?.original_name, share.shared_by, share.document?.created_at but /api/shares/received returns a flat object with filename/owner_handle/created_at — all metadata fields render blank."
- id: "DOC-01" - id: "DOC-01"
status: "partial" status: "partial"
phase: "4" phase: "4"
claimed_by_plans: ["04-01-PLAN.md"] claimed_by_plans: ["04-01-PLAN.md", "04-09-PLAN.md"]
completed_by_plans: ["04-09-SUMMARY.md (implicit)"] completed_by_plans: ["04-09-PLAN.md"]
verification_status: "missing" verification_status: "missing"
evidence: "Owners can view document metadata and extracted text. Share recipients who navigate to /document/{id} get 404 because documents.py:542 checks ownership only, not share grants." evidence: "Owners can view document metadata and extracted text. Share recipients cannot — documents.py:542 enforces ownership-only check, returning 404 for recipients who navigate to /document/{id}. No share-grant lookup performed before the 404."
- id: "SHARE-03"
status: "partial"
phase: "4"
claimed_by_plans: ["04-04-PLAN.md", "06.1-01-PLAN.md"]
completed_by_plans: ["06.1-01-PLAN.md"]
verification_status: "gaps_found"
evidence: "ShareCreate model has no permission field. grant_share hardcodes permission='view'. No PATCH /api/shares/{id} endpoint exists to change permission after creation. SHARE-03 requires 'owner controls permission level' — only the 'view-only default' half is satisfied. The stored permission field in the shares table cannot be changed through any API endpoint."
- id: "SHARE-05"
status: "partial"
phase: "4"
claimed_by_plans: ["04-04-PLAN.md", "06.1-01-PLAN.md"]
completed_by_plans: ["06.1-01-PLAN.md"]
verification_status: "gaps_found"
evidence: "is_shared computed per document in documents.py lines 433-445 and 498-510 (two separate DB subqueries per list request). Zero occurrences of is_shared in any .vue or .js file. DocumentCard.vue has no visual indicator for shared documents. 06.1-VALIDATION added test_share_indicator_in_owner_list which confirms is_shared=True in the API response — but no frontend component reads or renders it."
- id: "STORE-06" - id: "STORE-06"
status: "partial" status: "partial"
@@ -32,323 +48,366 @@ gaps:
claimed_by_plans: ["03-02-PLAN.md"] claimed_by_plans: ["03-02-PLAN.md"]
completed_by_plans: ["03-02-SUMMARY.md (STORE-06)"] completed_by_plans: ["03-02-SUMMARY.md (STORE-06)"]
verification_status: "missing" verification_status: "missing"
evidence: "services/storage.delete_document() always calls MinIOBackend.delete_object() regardless of doc.storage_backend, then decrements MinIO quota. Cloud-stored documents never incremented MinIO quota (D-11), so deletion incorrectly decrements it. Actual cloud provider files are not deleted on user-initiated document delete — they become orphaned." evidence: "MinIO path: services/storage.py:168-175 implements atomic CASE WHEN quota decrement — correct for MinIO documents. Cloud path: delete_document() calls self._backend().delete_object(doc.object_key) where _backend() always returns MinIOBackend regardless of doc.storage_backend. Cloud-stored documents: (1) MinIO delete_object gets NoSuchKey (silently swallowed); (2) MinIO quota decremented even though no quota was charged at cloud upload; (3) actual file in Google Drive / OneDrive / Nextcloud / WebDAV is never deleted. Additionally, test_delete_decrements_quota is @pytest.mark.xfail(strict=False) — ROADMAP phase gate requires INTEGRATION=1 confirmation against live PostgreSQL."
- id: "SEC-09" - id: "SEC-09"
status: "partial" status: "partial"
phase: "4" phase: "4"
claimed_by_plans: ["04-07-PLAN.md", "05-05-PLAN.md"] claimed_by_plans: ["04-07-PLAN.md", "05-05-PLAN.md"]
completed_by_plans: ["04-07-SUMMARY.md (SEC-09 MinIO cleanup)", "05-05-SUMMARY.md (SEC-09 cloud cleanup)"] completed_by_plans: ["04-07-SUMMARY.md", "05-05-SUMMARY.md"]
verification_status: "partial — Phase 5 VERIFICATION.md confirms admin delete path; user delete path unverified" verification_status: "partial"
evidence: "Admin-initiated delete (admin.py lines 522546) correctly purges CloudConnection rows before MinIO cleanup. User-initiated document delete (services/storage.delete_document) does not call get_storage_backend_for_document — cloud provider files are orphaned when a user deletes a cloud-stored document." evidence: "Admin-initiated account deletion (admin.py lines 518-565) correctly purges all CloudConnection rows and calls delete_user_files() before MinIO+DB cleanup — SEC-09 satisfied for the account deletion path. However, user-initiated document deletion (services/storage.delete_document) does not call get_storage_backend_for_document — cloud provider files are orphaned when a user deletes a cloud-stored document."
- id: "ADMIN-06" - id: "ADMIN-06"
status: "partial" status: "partial"
phase: "4" phase: "4"
claimed_by_plans: ["04-06-PLAN.md"] claimed_by_plans: ["04-06-PLAN.md", "06.1-02-PLAN.md"]
completed_by_plans: ["04-06-SUMMARY.md (audit.py)", "04-02-SUMMARY.md (GIN index, audit-logs bucket)"] completed_by_plans: ["06.1-02-PLAN.md"]
verification_status: "missing" verification_status: "gaps_found"
evidence: "GET /api/admin/audit-log JSON viewer works end-to-end. GET /api/admin/audit-log/export: AuditLogTab.vue uses window.location.href for the export button, which does not send the Authorization: Bearer header. get_current_admin requires HTTPBearer — export always returns 403." evidence: "GET /api/admin/audit-log JSON viewer works end-to-end. Filter behavioral tests pass (test_audit_log_filter_by_event_type added in commit 451fff1). GET /api/admin/audit-log/export: AuditLogTab.vue:191 uses window.location.href which sends no Authorization: Bearer header. get_current_admin requires HTTPBearer — CSV export always returns 403."
- id: "CLOUD-03" - id: "CLOUD-03"
status: "partial" status: "partial"
phase: "5" phase: "5"
claimed_by_plans: ["05-05-PLAN.md", "05-09-PLAN.md"] claimed_by_plans: ["05-06-PLAN.md"]
completed_by_plans: ["05-05-SUMMARY.md (CLOUD-03)", "05-06-SUMMARY.md (CLOUD-03)"] completed_by_plans: ["05-06-SUMMARY.md"]
verification_status: "Phase 5 VERIFICATION.md: SATISFIED" verification_status: "human_needed"
evidence: "PATCH /api/users/me/default-storage endpoint exists and is registered in main.py. updateDefaultStorage() is defined in client.js. However, no Vue component imports or calls updateDefaultStorage(). No UI selector for default backend in SettingsCloudTab.vue or any other component. Default storage (minio) can only be changed via direct API call, not through the UI." evidence: "PATCH /api/users/me/default-storage fully implemented (cloud.py:927, registered in main.py). updateDefaultStorage() exported from client.js:448. However, updateDefaultStorage() is never imported or called by any Vue component. SettingsCloudTab.vue renders cloud connections but has no radio/select to change the default storage backend. Users cannot change their default backend through the UI."
- id: "FOLD-01"
status: "partial"
phase: "4"
claimed_by_plans: ["04-03-PLAN.md"]
completed_by_plans: ["04-03-PLAN.md"]
verification_status: "missing"
evidence: "_folder_to_dict() in folders.py:65 returns {id, name, parent_id, user_id, created_at} — no doc_count field. FolderDeleteModal.vue:31 and FolderRow.vue:32 display folder.doc_count ?? 0. FOLD-01 requires 'delete confirms content count before proceeding' — confirmation always shows '0 documents' regardless of actual folder content."
- id: "FOLD-05"
status: "partial"
phase: "4"
claimed_by_plans: ["04-03-PLAN.md"]
completed_by_plans: ["04-03-PLAN.md"]
verification_status: "missing"
evidence: "SearchBar rendered with v-if='currentFolderId' in FileManagerView.vue — hidden at the root level (no folder selected). Users browsing the root document library have no search input. FOLD-05 requires 'Full-text search across user's documents' — search is only available when inside a folder, not at root."
integration: integration:
- blocker: "SHARE-02 / DOC-01 — Share recipient document detail blocked" - blocker: "SHARE-02 / DOC-01 — Share recipient blocked from document detail"
description: "documents.py line 542: `if doc is None or doc.user_id != current_user.id: raise HTTPException(404)`. Recipients with valid share records cannot access GET /api/documents/{id} they see 404 despite being able to stream /content." description: "documents.py:542 checks doc.user_id != current_user.id HTTPException(404). No share-grant lookup performed. Recipients with valid Share records cannot access GET /api/documents/{id}, breaking the shared document detail view. SharedView.vue also accesses wrong field paths (share.document?.original_name instead of share.filename etc.) — all metadata renders blank even on the list."
- blocker: "STORE-06 / SEC-09 — Cloud document delete corrupts MinIO quota and orphans cloud files" - blocker: "STORE-06 / SEC-09 — Cloud document delete corrupts quota and orphans files"
description: "services/storage.delete_document() calls self._backend().delete_object(doc.object_key) where _backend() always returns MinIOBackend. Cloud-stored documents: (1) MinIO delete_object silently fails (NoSuchKey); (2) MinIO quota decremented even though no quota was charged at upload; (3) actual file in Google Drive / OneDrive / Nextcloud / WebDAV is never deleted." description: "services/storage.delete_document() calls self._backend().delete_object() where _backend() always returns MinIOBackend. Cloud-stored docs: MinIO delete_object silently fails (NoSuchKey), MinIO quota decremented unconditionally, actual cloud provider file never deleted."
- blocker: "ADMIN-06 — Audit log CSV export always returns 403" - blocker: "ADMIN-06 — Audit log CSV export always returns 403"
description: "AuditLogTab.vue line 191: `window.location.href = '/api/admin/audit-log/export?${params}'`. Browser navigation strips Authorization: Bearer header. Backend requires get_current_admin (HTTPBearer). All admin CSV export clicks result in 403." description: "AuditLogTab.vue:191 uses window.location.href for CSV export. Browser navigation strips Authorization: Bearer header. Backend endpoint requires HTTPBearer. All CSV export clicks result in 403."
- blocker: "CLOUD-03 — Default storage UI orphaned"
description: "updateDefaultStorage() exported from client.js but never called by any component. No frontend UI exists to change the default storage backend."
flows: flows:
- name: "Recipient views shared document" - name: "Recipient views shared document detail"
breaks_at: "GET /api/documents/{id} — ownership check excludes recipients" breaks_at: "documents.py:542 ownership-only check"
affected_requirements: ["SHARE-02", "DOC-01"] affected_requirements: ["SHARE-02", "DOC-01"]
- name: "User deletes cloud document" - name: "User deletes cloud-stored document"
breaks_at: "services/storage.delete_document() — MinIO backend hardcoded" breaks_at: "services/storage.delete_document() — MinIO backend hardcoded"
affected_requirements: ["STORE-06", "SEC-09"] affected_requirements: ["STORE-06", "SEC-09"]
- name: "Admin exports audit log" - name: "Admin exports audit log as CSV"
breaks_at: "AuditLogTab.vue window.location.href drops Bearer token" breaks_at: "AuditLogTab.vue:191 window.location.href drops Bearer token"
affected_requirements: ["ADMIN-06"] affected_requirements: ["ADMIN-06"]
- name: "User selects default cloud storage backend"
breaks_at: "No UI component calls updateDefaultStorage()"
affected_requirements: ["CLOUD-03"]
tech_debt: tech_debt:
- phase: "02-users-authentication"
items:
- "Phase 2 VERIFICATION.md status=gaps_found (gap: admin JWT → 403 on documents). Gap was closed by Phase 3 (get_regular_user dep added to all /api/documents/* handlers) but no re-verification was run for Phase 2."
- phase: "01-infrastructure-foundation" - phase: "01-infrastructure-foundation"
items: items:
- "No VERIFICATION.md — phase marked complete but never formally verified." - "No VERIFICATION.md exists (phase not formally verified by gsd-verifier)"
- "VALIDATION.md: nyquist_compliant: true, audited 2026-05-30"
- phase: "02-users-authentication"
items:
- "VERIFICATION.md exists (gaps_found 4/5) — SC5 gap closed by Phase 3, no re-verification run"
- "No VALIDATION.md — Nyquist compliance MISSING for Phase 2"
- "4 human verification items pending: TOTP enrollment e2e, password reset email, sign out all devices, admin panel visuals"
- phase: "03-document-migration-multi-user-isolation" - phase: "03-document-migration-multi-user-isolation"
items: items:
- "No VERIFICATION.md — phase marked complete but never formally verified." - "No VERIFICATION.md exists (phase not formally verified)"
- "VALIDATION.md: nyquist_compliant: false, status: draft — Nyquist PARTIAL"
- "Document.user_id ORM column has nullable=True but DB has NOT NULL constraint (migration 0003 alters it) — ORM/schema drift"
- "test_delete_decrements_quota is xfail(strict=False) on SQLite — INTEGRATION=1 gate requires live PostgreSQL to confirm"
- phase: "04-folders-sharing-quotas-document-ux" - phase: "04-folders-sharing-quotas-document-ux"
items: items:
- "No VERIFICATION.md — phase marked complete but never formally verified." - "No VERIFICATION.md exists (phase not formally verified)"
- "VALIDATION.md: nyquist_compliant: false, status: draft — Nyquist PARTIAL"
- phase: "04-folders-sharing-quotas-document-ux" - "AdminView.vue has no frontend role guard — unauthenticated-role users who navigate to /admin see full UI (all backend calls return 403 but no redirect occurs)"
items: - "FOLD-01: _folder_to_dict() omits doc_count; delete confirmation always shows 0 documents"
- "SharedView.vue renders share.document?.created_at and size_bytes, but /api/shares/received returns a flat object (no nested document key) — date/size lines never render for recipients." - "FOLD-05: SearchBar hidden at root level (v-if='currentFolderId')"
- "SHARE-05: is_shared computed per document (2 DB subqueries) but never rendered in any Vue component"
- phase: "03-document-migration-multi-user-isolation" - "SHARE-03: permission hardcoded 'view', no PATCH endpoint to change it"
items:
- "Document.user_id ORM column has nullable=True (models.py) but DB has NOT NULL constraint (migration 0003). ORM divergence from actual schema."
- phase: "05-cloud-storage-backends" - phase: "05-cloud-storage-backends"
items: items:
- "cloud.py module docstring claims all endpoints use get_regular_user but OAuth callback intentionally omits it (state-token auth). Misleading, though behavior is correct." - "VERIFICATION.md: human_needed — 6 items require live cloud credentials (Google OAuth, OneDrive OAuth, live Nextcloud/WebDAV server)"
- "CLOUD-05 REQUIRES_REAUTH transitions work for OAuth providers (Google Drive, OneDrive). Nextcloud/WebDAV credential failures produce generic 502 — no REQUIRES_REAUTH state transition. Requirement is OAuth-specific so this is spec-compliant, but a user experience gap." - "VALIDATION.md: nyquist_compliant: true, audited 2026-05-30"
- "_doc_to_dict() omits storage_backend and folder_id — document list response cannot distinguish cloud vs local docs." - "CLOUD-05 REQUIRES_REAUTH transition implemented for OAuth providers only (Google Drive, OneDrive). Nextcloud/WebDAV credential failures produce generic 502 — no REQUIRES_REAUTH state for non-OAuth backends. Spec-compliant but UX gap."
- "_doc_to_dict() omits storage_backend and folder_id — document list response cannot distinguish cloud vs local documents"
- "CLOUD-03: updateDefaultStorage() exported but no UI element calls it"
- phase: "06.1-close-v1-audit-gaps"
items:
- "VERIFICATION.md: stale (written before commit 451fff1 which added audit filter test). 06.1-VALIDATION.md supersedes."
- "VALIDATION.md: nyquist_compliant: true, gaps_found: 3, gaps_resolved: 2, gaps_manual: 1"
- "STORE-06 INTEGRATION=1 gate: manual-only — requires live PostgreSQL Docker stack to confirm"
- "conftest.py WR-03: dependency_overrides not cleared on exception in async_client fixture (low-probability correctness gap)"
- phase: "all" - phase: "all"
items: items:
- "REQUIREMENTS.md checkboxes are stale: many implemented requirements still show [ ]. Last updated 2026-05-21, before any execution." - "REQUIREMENTS.md checkboxes are stale — 22 satisfied requirements still show [ ]. Not maintained during execution."
- "CLAUDE.md specifies ES256 (ECDSA P-256) JWT algorithm, email_hmac deterministic index, and fgp token fingerprint binding. None are implemented HS256 used, email stored in plaintext, no fingerprint claim. These are v2 hardening items outside the 54 formal v1 REQ-IDs." - "CLAUDE.md specifies ES256 JWT algorithm, email_hmac deterministic index, fgp token fingerprint claim — none implemented (HS256, plaintext email, no fingerprint). Outside 54 v1 REQ-IDs; v2 hardening scope."
nyquist: nyquist:
compliant_phases: ["01-infrastructure-foundation", "05-cloud-storage-backends"] compliant_phases: [1, 5, "6.1"]
partial_phases: ["03-document-migration-multi-user-isolation", "04-folders-sharing-quotas-document-ux"] partial_phases: [3, 4]
missing_phases: ["02-users-authentication"] missing_phases: [2]
overall: partial overall: partial
delta_2026-05-30: "Phase 1 upgraded from partial → compliant after gsd-validate-phase 1: STORE-07 concurrent test added, test_confirm_endpoint unblocked, alembic tests moved to manual-only."
--- ---
# DocuVault v1.0 — Milestone Audit Report # DocuVault v1.0 — Milestone Audit Report
**Milestone:** v1.0
**Audited:** 2026-05-30 **Audited:** 2026-05-30
**Status:** ⚠ gaps_found **Phases audited:** 1, 2, 3, 4, 5, 6.1 (Phase 6 not started — excluded)
**Score:** 48/54 requirements satisfied (89%) **Status:** ⚠ GAPS FOUND
--- ---
## Executive Summary ## Executive Summary
All 5 phases executed and marked complete. Phase 5 was formally verified (7/7 truths confirmed). Phase 2 has a VERIFICATION.md with one gap that was closed by Phase 3. Phases 1, 3, and 4 have no VERIFICATION.md. All 6 planned v1 phases executed and marked complete. Phase 5 formally verified (7/7 truths, human_needed). Phase 2 VERIFICATION.md status=gaps_found (4/5); gap confirmed closed by Phase 3. Phases 1, 3, 4 have no VERIFICATION.md. Phase 6.1 VALIDATION.md supersedes its stale VERIFICATION.md.
The integration checker found **3 blockers** and **6 warnings** through cross-phase wiring analysis. Six requirements are partially unsatisfied. The integration check found **4 blockers** and **7 warnings**. Ten requirements are partially satisfied, primarily due to frontend wiring gaps and a cloud-delete path defect.
| Metric | Score |
|--------|-------|
| Requirements satisfied | 44/54 (81%) |
| Requirements partial | 10/54 (19%) |
| Requirements unsatisfied | 0/54 |
| Phases formally verified | 2/6 (Phases 2, 5) |
| Nyquist compliant | 3/6 phases |
| Test gate | 309 passed, 1 pre-existing failure (test_extract_docx — missing python-docx module; unrelated to milestone scope) |
--- ---
## Requirements Coverage (3-Source Cross-Reference) ## Requirements Coverage
*Sources: VERIFICATION.md (where present) + SUMMARY.md requirements-completed frontmatter + REQUIREMENTS.md traceability + codebase verification* ### Satisfied (44/54)
### Phase 1 — Infrastructure Foundation (3/3 satisfied) | Phase | REQ-IDs | Count |
|-------|---------|-------|
| 1 — Infrastructure | STORE-01, STORE-02, STORE-07 | 3 |
| 2 — Auth | AUTH-01, AUTH-02, AUTH-03, AUTH-04, AUTH-05, AUTH-06, AUTH-07, AUTH-08, SEC-01, SEC-02, SEC-03, SEC-05, SEC-06, SEC-07, ADMIN-01, ADMIN-02, ADMIN-03, ADMIN-04, ADMIN-05, ADMIN-07 | 20 |
| 3 — Documents | STORE-03, STORE-04, STORE-05, STORE-08, SEC-04, DOC-03, DOC-04, DOC-05 | 8 |
| 4 — Folders/Sharing | FOLD-02, FOLD-03, FOLD-04, SHARE-01, SHARE-04, SEC-08, DOC-02 | 7 |
| 5 — Cloud | CLOUD-01, CLOUD-02, CLOUD-04, CLOUD-05, CLOUD-06, CLOUD-07 | 6 |
| REQ-ID | Description | REQUIREMENTS.md | SUMMARY.md | VERIFICATION.md | Status | **Total satisfied: 44**
|--------|-------------|-----------------|------------|-----------------|--------|
| STORE-01 | PostgreSQL + MinIO migration | [ ] (stale) | 01-03 ✓ | MISSING | ✅ satisfied |
| STORE-02 | MinIO key schema {user_id}/{doc_id}/{uuid4()}{ext} | [ ] (stale) | not claimed (implicit in 01-04) | MISSING | ✅ satisfied (confirmed in minio_backend.py:75) |
| STORE-07 | Stateless backend | [ ] (stale) | 01-03 ✓ | MISSING | ✅ satisfied (no BackgroundTasks, Celery used) |
### Phase 2 — Users & Authentication (20/20 satisfied) Notable confirmations from integration check:
- **STORE-08**: Zero `BackgroundTasks` usages remain; all async work runs through Celery (`document_tasks.py`, `email_tasks.py`).
- **DOC-02**: PDF proxy chain complete — `fetchDocumentContent()` → Bearer-authenticated `GET /api/documents/{id}/content``get_storage_backend_for_document()` → byte stream → blob URL.
- **SEC-07**: `get_regular_user` raises 403 for admin role on all `/api/documents/*` endpoints — Phase 2 gap confirmed closed by Phase 3.
- **SEC-08**: `CloudConnectionOut` whitelist (provider, display_name, connected_at, status only) used at `cloud.py:637,661``credentials_enc` excluded from all responses.
*Note: Phase 2 VERIFICATION.md status=gaps_found (4/5). The gap (admin JWT → 403 on /api/documents/*) was closed in Phase 3 by adding get_regular_user dep. Effectively all Phase 2 requirements are satisfied.* ---
| REQ-ID | Description | Status | ### Partial (10/54) — Blockers and Warnings
|--------|-------------|--------|
| AUTH-01 | Register (Argon2 + HIBP) | ✅ satisfied |
| AUTH-02 | JWT session + httpOnly cookie | ✅ satisfied |
| AUTH-03 | TOTP enrollment + backup codes | ✅ satisfied |
| AUTH-04 | Login via TOTP or backup code | ✅ satisfied |
| AUTH-05 | Password reset email | ✅ satisfied |
| AUTH-06 | Sign out all devices | ✅ satisfied |
| AUTH-07 | Refresh token family revocation | ✅ satisfied |
| AUTH-08 | TOTP single-use within window | ✅ satisfied |
| SEC-01 | CSRF (SameSite=Strict + origin validation) | ✅ satisfied |
| SEC-02 | Rate limiting on auth endpoints | ✅ satisfied |
| SEC-03 | Parameterized queries / ORM only | ✅ satisfied |
| SEC-05 | Security headers (CSP, X-Frame-Options, X-Content-Type-Options) | ✅ satisfied |
| SEC-06 | Constant-time comparison for token verification | ✅ satisfied |
| SEC-07 | Admin role dep + admin blocked from doc content | ✅ satisfied (gap closed Phase 3) |
| ADMIN-01 | Admin creates user | ✅ satisfied |
| ADMIN-02 | Admin deactivates user | ✅ satisfied |
| ADMIN-03 | Admin initiates password reset | ✅ satisfied |
| ADMIN-04 | Admin adjusts user quotas | ✅ satisfied |
| ADMIN-05 | Admin assigns AI provider/model | ✅ satisfied |
| ADMIN-07 | No admin impersonation | ✅ satisfied |
### Phase 3 — Document Migration & Multi-User Isolation (8/9 satisfied) | REQ-ID | Phase | Severity | Root Cause |
|--------|-------|----------|------------|
| REQ-ID | Description | Status | | **SHARE-02** | 4 | BLOCKER | Recipients get 404 on `GET /api/documents/{id}` (ownership check only). SharedView.vue field names wrong (blank metadata display). |
|--------|-------------|--------| | **DOC-01** | 4 | BLOCKER | Owners: ✅. Share recipients: 404 at `documents.py:542` (`doc.user_id != current_user.id`, no share-grant check). |
| STORE-03 | Atomic quota enforcement at upload | ✅ satisfied | | **STORE-06** | 3 | BLOCKER | MinIO delete-path correct. Cloud delete-path: MinIO backend called unconditionally → quota corrupted, cloud file orphaned. |
| STORE-04 | Quota bar (80%/95% warnings) | ✅ satisfied | | **SEC-09** | 4 | BLOCKER | Admin account deletion: ✅. User-initiated document delete: cloud provider file not deleted (only MinIO attempted). |
| STORE-05 | Upload rejected at quota limit | ✅ satisfied | | **ADMIN-06** | 4 | BLOCKER | JSON audit viewer: ✅. CSV export: `window.location.href` drops Bearer header → 403. |
| STORE-06 | Quota decremented on document delete | ⚠️ partial — cloud docs decrement MinIO quota they never incremented; cloud provider file not deleted | | **SHARE-03** | 4 | WARNING | `permission="view"` hardcoded. No `PATCH /api/shares/{id}` endpoint. Owner cannot change permission level. |
| STORE-08 | BackgroundTasks replaced with Celery | ✅ satisfied | | **SHARE-05** | 4 | WARNING | `is_shared` computed per document (2 DB subqueries / request) but never rendered by any Vue component. |
| SEC-04 | File access via DB lookup only | ✅ satisfied | | **CLOUD-03** | 5 | WARNING | `PATCH /api/users/me/default-storage` implemented. `updateDefaultStorage()` exported but no UI calls it. |
| DOC-03 | AI provider/model from admin-assigned DB field | ✅ satisfied | | **FOLD-01** | 4 | WARNING | `_folder_to_dict()` omits `doc_count`. Delete confirmation modal always shows "0 documents". |
| DOC-04 | System + per-user topic overrides | ✅ satisfied | | **FOLD-05** | 4 | WARNING | `SearchBar` hidden at root (`v-if="currentFolderId"`). Full-text search unavailable in root document library. |
| DOC-05 | Classification uses user's assigned AI config | ✅ satisfied |
### Phase 4 — Folders, Sharing, Quotas & Document UX (11/15 satisfied)
| REQ-ID | Description | Status |
|--------|-------------|--------|
| FOLD-01 | Folder CRUD with count confirmation | ✅ satisfied |
| FOLD-02 | Move documents between folders | ✅ satisfied |
| FOLD-03 | Breadcrumb navigation | ✅ satisfied |
| FOLD-04 | Document list sort | ✅ satisfied |
| FOLD-05 | Full-text search via tsvector | ✅ satisfied |
| SHARE-01 | Share by user handle | ✅ satisfied |
| SHARE-02 | "Shared with me" folder; no quota for recipient | ⚠️ partial — recipient can stream /content but GET /api/documents/{id} returns 404 (ownership-only check) |
| SHARE-03 | View-only default sharing | ✅ satisfied |
| SHARE-04 | Share revocation | ✅ satisfied |
| SHARE-05 | Shared indicator in owner's list | ✅ satisfied |
| SEC-08 | credentials_enc excluded from all serializers | ✅ satisfied |
| SEC-09 | Account deletion purges cloud files | ⚠️ partial — admin delete path correct; user-initiated document delete does not purge cloud provider files |
| ADMIN-06 | Admin audit log viewer | ⚠️ partial — JSON viewer works; CSV export returns 403 (Bearer header dropped by window.location.href) |
| DOC-01 | View document metadata and extracted text | ⚠️ partial — owners: ✅; share recipients: 404 at GET /api/documents/{id} |
| DOC-02 | In-browser PDF preview (bytes proxied, no presigned URLs) | ✅ satisfied |
### Phase 5 — Cloud Storage Backends (6/7 satisfied)
| REQ-ID | Description | Status |
|--------|-------------|--------|
| CLOUD-01 | Connect OneDrive, Google Drive, Nextcloud, WebDAV | ✅ satisfied |
| CLOUD-02 | HKDF per-user credential encryption | ✅ satisfied |
| CLOUD-03 | Local and cloud storage coexist; user selects default | ⚠️ partial — coexist: ✅; select default: API exists but no UI component calls it |
| CLOUD-04 | Connection status display (ACTIVE/REQUIRES_REAUTH/ERROR) | ✅ satisfied |
| CLOUD-05 | invalid_grant transitions to REQUIRES_REAUTH | ✅ satisfied (OAuth providers; WebDAV/Nextcloud don't use OAuth) |
| CLOUD-06 | Disconnect; credentials permanently deleted from DB | ✅ satisfied |
| CLOUD-07 | StorageBackend ABC + factory | ✅ satisfied |
--- ---
## Phase Verification Status ## Phase Verification Status
| Phase | VERIFICATION.md | Status | Score | Notes | | Phase | VERIFICATION.md | Status | Notes |
|-------|-----------------|--------|-------|-------| |-------|----------------|--------|-------|
| 01 — Infrastructure Foundation | ❌ MISSING | Unverified | — | No formal verification run | | 01 — Infrastructure Foundation | ❌ MISSING | Unverified | Phase marked complete; no formal verification run |
| 02 — Users & Authentication | ✅ Present | gaps_found (4/5) | 4/5 | Gap closed by Phase 3 (get_regular_user on /api/documents/*) | | 02 — Users & Authentication | ✅ exists | gaps_found (4/5) | SC5 gap (admin JWT → 403 on docs) closed by Ph3; confirmed by integration check |
| 03 — Document Migration | ❌ MISSING | Unverified | — | No formal verification run | | 03 — Document Migration | ❌ MISSING | Unverified | Phase marked complete; no formal verification run |
| 04 — Folders, Sharing, Quotas | ❌ MISSING | Unverified | — | No formal verification run | | 04 — Folders, Sharing, Quotas | ❌ MISSING | Unverified | Phase marked complete; no formal verification run |
| 05 — Cloud Storage Backends | ✅ Present | human_needed | 7/7 | 6 human UAT items (cloud credentials required) | | 05 — Cloud Storage Backends | ✅ exists | human_needed (7/7) | All must-haves verified; 6 human UAT items require live cloud credentials |
| 6.1 — Gap Closure | ✅ exists (stale) | Superseded by VALIDATION.md | 06.1-VALIDATION.md: gaps_found 3, resolved 2, manual 1 |
--- ---
## Nyquist Compliance (Validation Coverage) ## Nyquist Coverage
| Phase | VALIDATION.md | nyquist_compliant | Action | | Phase | VALIDATION.md | nyquist_compliant | Action |
|-------|---------------|-------------------|--------| |-------|---------------|-------------------|--------|
| 01 — Infrastructure Foundation | ✅ exists (compliant) | ✅ true | — *(validated 2026-05-30)* | | 01 — Infrastructure Foundation | ✅ audited 2026-05-30 | `true` | None |
| 02 — Users & Authentication | ❌ missing | — | `/gsd:validate-phase 2` | | 02 — Users & Authentication | ❌ MISSING | — | `/gsd:validate-phase 2` |
| 03 — Document Migration | ✅ exists (draft) | false | `/gsd:validate-phase 3` | | 03 — Document Migration | ✅ exists, status: draft | `false` | `/gsd:validate-phase 3` |
| 04 — Folders, Sharing, Quotas | ✅ exists (draft) | false | `/gsd:validate-phase 4` | | 04 — Folders, Sharing, Quotas | ✅ exists, status: draft | `false` | `/gsd:validate-phase 4` |
| 05 — Cloud Storage Backends | ✅ exists (complete) | ✅ true | | | 05 — Cloud Storage Backends | ✅ audited 2026-05-30 | `true` | None |
| 6.1 — Gap Closure | ✅ audited 2026-05-30 | `true` | None |
--- ---
## Critical Blockers (3) ## Critical Blockers (5)
### BLOCKER-1 — Share Recipient Cannot View Document Metadata ### BLOCKER-1 — Share Recipient Cannot View Document Metadata (SHARE-02, DOC-01)
**Affected Requirements:** SHARE-02, DOC-01
**File:** `backend/api/documents.py` line 542 **File:** `backend/api/documents.py` line 542
**Root cause:** `if doc is None or doc.user_id != current_user.id: raise HTTPException(404)` — no share-grant check. **Root cause:** `if doc is None or doc.user_id != current_user.id: raise HTTPException(404)` — no share-grant check.
**Broken flow:** SharedView.vue → click shared item → DocumentView.vue → `api.getDocument(id)` → 404 for recipient. **Broken flow:** SharedView.vue → click shared item → DocumentView.vue → `getDocument(id)` → 404 for recipient despite valid Share record.
**Fix:** Add share-grant check to `get_document()`: if `doc.user_id != current_user.id`, query `Share` table for `(document_id=doc_id, recipient_id=current_user.id)` and allow if found. **Secondary bug:** SharedView.vue accesses `share.document?.original_name`, `share.shared_by`, `share.document?.created_at` but `/api/shares/received` returns a flat object (`filename`, `owner_handle`, `created_at`). All metadata renders blank even on the list.
**Fix (backend):** In `get_document()`, after the ownership 404, add: check `Share` table for `(document_id=doc_id, recipient_id=current_user.id)` and allow if found.
**Fix (frontend):** In `SharedView.vue`, update field access to match flat response shape.
### BLOCKER-2 — Cloud Document Delete Corrupts Quota and Orphans Files ### BLOCKER-2 — Cloud Document Delete Corrupts Quota and Orphans Files (STORE-06, SEC-09)
**Affected Requirements:** STORE-06, SEC-09
**File:** `backend/services/storage.py` (delete_document function) **File:** `backend/services/storage.py` (delete_document function)
**Root cause:** `self._backend().delete_object(doc.object_key)` always uses MinIOBackend regardless of `doc.storage_backend`. Then decrements MinIO quota unconditionally. **Root cause:** `self._backend().delete_object(doc.object_key)` always uses MinIOBackend regardless of `doc.storage_backend`. Then decrements MinIO quota unconditionally.
**Broken flow:** User uploads to Google Drive (quota=0) → deletes document → `delete_object()` gets NoSuchKey on MinIO (silently swallowed) quota decremented below actual MinIO usage → actual Google Drive file never deleted. **Impact:** Cloud-stored documents: (1) MinIO `delete_object` gets NoSuchKey (silently swallowed), (2) MinIO quota decremented below actual usage, (3) actual cloud provider file never deleted → GDPR Article 17 obligation not met for cloud storage.
**Fix:** Use `get_storage_backend_for_document(doc, session)` in `delete_document()` (same pattern as admin delete). Gate quota decrement on `doc.storage_backend == "minio"`. **Fix:** Use `get_storage_backend_for_document(doc, session)` in `delete_document()`. Gate quota decrement on `doc.storage_backend == "minio"`.
### BLOCKER-3 — Admin Audit Log CSV Export Always Returns 403 ### BLOCKER-3 — Admin Audit Log CSV Export Always Returns 403 (ADMIN-06)
**Affected Requirements:** ADMIN-06
**File:** `frontend/src/components/admin/AuditLogTab.vue` line 191 **File:** `frontend/src/components/admin/AuditLogTab.vue` line 191
**Root cause:** `window.location.href = '/api/admin/audit-log/export?${params}'` — browser navigation strips Authorization: Bearer header. `get_current_admin` requires HTTPBearer. **Root cause:** `window.location.href = '/api/admin/audit-log/export?${params}'` — browser navigation strips the `Authorization: Bearer` header. `get_current_admin` requires `HTTPBearer`.
**Broken flow:** Admin clicks "Export CSV" → 403 Forbidden. **Fix:** Replace `window.location.href` with `fetch()` using `Authorization: Bearer ${accessToken}`, then create a Blob URL for download. The `fetchDocumentContent()` pattern in `client.js` is the correct model.
**Fix:** Use `fetch()` with `Authorization: Bearer ${accessToken}` header and download the blob via `URL.createObjectURL()`, or pass the access token as a query param (less secure but simple).
### BLOCKER-4 — Default Storage Backend Has No Frontend UI (CLOUD-03)
**File:** `frontend/src/components/settings/SettingsCloudTab.vue`
**Root cause:** `updateDefaultStorage()` is exported from `client.js:448` but never imported or called by any component. `SettingsCloudTab.vue` has no UI control to select a default backend.
**Fix:** Add a "Set as default" button or radio to each connected provider row in `SettingsCloudTab.vue`; wire it to `updateDefaultStorage(provider)`.
--- ---
## Warnings (6) ## Warnings (7)
| # | Severity | Description | Requirement | | # | Description | Requirement |
|---|----------|-------------|-------------| |---|-------------|-------------|
| W-1 | Medium | SharedView.vue uses `share.document?.created_at` but /api/shares/received returns flat objects — date/size lines never render | SHARE-02 | | W-1 | `is_shared` computed per document (2 subqueries per list request) but no Vue component renders it | SHARE-05 |
| W-2 | Medium | `updateDefaultStorage()` defined in client.js but never called; no default-backend UI selector exists | CLOUD-03 | | W-2 | SHARE-03: `permission` hardcoded to `"view"`; no `PATCH /api/shares/{id}` endpoint | SHARE-03 |
| W-3 | Low | `_doc_to_dict()` omits `storage_backend` and `folder_id` — list response cannot distinguish cloud vs local docs | CLOUD-03 | | W-3 | `_folder_to_dict()` omits `doc_count` — delete confirmation modal always shows "0 documents" | FOLD-01 |
| W-4 | Low | `Document.user_id` ORM column has `nullable=True` but DB has `NOT NULL` constraint (migration 0003) — ORM/schema drift | STORE-03 | | W-4 | `SearchBar` hidden at root level (`v-if="currentFolderId"`) — search unavailable in root library | FOLD-05 |
| W-5 | Low | cloud.py module docstring says all endpoints use `get_regular_user` but OAuth callback intentionally omits it | — | | W-5 | `Document.user_id` ORM column `nullable=True`; DB has `NOT NULL` constraint (migration 0003) — ORM/schema drift | STORE-03 |
| W-6 | Info | CLAUDE.md specifies ES256, email_hmac, fgp fingerprint claim — none implemented (HS256, plaintext email, no fingerprint). Outside 54 v1 REQ-IDs. | v2 scope | | W-6 | `AdminView.vue` has no frontend role guard — regular users who navigate to `/admin` see full UI; backend returns 403 but no redirect | — |
| W-7 | CLAUDE.md specifies ES256 JWT, `email_hmac` index, `fgp` fingerprint claim — none implemented (HS256, plaintext email, no fingerprint). v2 hardening scope, outside 54 v1 REQ-IDs. | v2 |
--- ---
## Tech Debt by Phase ## E2E Flow Results
**Phase 01:** No VERIFICATION.md written. | Flow | Status | Break Point |
**Phase 02:** VERIFICATION.md status=gaps_found; Phase 3 closed the gap but no re-verification was run. |------|--------|-------------|
**Phase 03:** No VERIFICATION.md written. Document.user_id ORM nullable divergence. | MinIO upload → quota updated → Celery AI classification | ✅ COMPLETE | — |
**Phase 04:** No VERIFICATION.md written. VALIDATION.md in draft state. | Password reset → TOTP gate on next login | ✅ COMPLETE | — |
**Phase 05:** CLOUD-05 REQUIRES_REAUTH transition not implemented for WebDAV/Nextcloud (spec-compliant; quality gap). | Cloud upload → authenticated content proxy (blob URL) | ✅ COMPLETE | — |
**All:** REQUIREMENTS.md checkboxes not maintained during execution — many satisfied requirements still show `[ ]`. | Share document → "Shared with me" list → recipient views detail | ❌ BROKEN | `documents.py:542` ownership-only check + SharedView field mismatch |
| User deletes cloud-stored document → files purged | ❌ BROKEN | `delete_document()` hardcodes MinIOBackend |
| Admin views audit log → exports CSV | ⚠️ PARTIAL | JSON viewer works; CSV export → 403 (no Bearer in `window.location.href`) |
--- ---
## Integration Wiring Summary ## Integration Wiring Summary (47 connections)
| Connection | Status | | Connection | Status |
|------------|--------| |------------|--------|
| Auth deps (get_regular_user / get_current_admin) on all protected endpoints | ✅ All wired | | Auth deps (`get_regular_user` / `get_current_admin`) on all Phase 3-5 endpoints | ✅ All wired (verified across documents.py, folders.py, shares.py, cloud.py, audit.py) |
| Phase 2 admin gap (admin JWT → 403 on /api/documents/*) | ✅ Closed in Phase 3 | | Phase 2 admin gap (admin JWT → 403 on `/api/documents/*`) | ✅ Closed in Phase 3 by `get_regular_user` |
| Atomic quota at upload (MinIO path) | ✅ Wired | | Atomic quota at upload (MinIO path) | ✅ Wired (`documents.py:342-346`) |
| Atomic quota decrement at delete (MinIO path only) | ⚠️ Cloud path broken | | Atomic quota decrement at delete (MinIO path) | ✅ Wired (`services/storage.py:168-175`) |
| Cloud document content proxy (authenticated fetch) | ✅ Wired | | Atomic quota decrement at delete (cloud path) | ❌ Not wired — MinIOBackend hardcoded |
| Admin delete: cloud cleanup before MinIO before DB | ✅ Wired (SEC-09) | | Cloud document content proxy (authenticated fetch → blob URL) | ✅ Wired |
| User-initiated doc delete: cloud provider cleanup | ❌ Not wired (STORE-06, SEC-09) | | Admin delete: cloud cleanup → MinIO cleanup → DB delete | ✅ Wired (`admin.py:518-565`) |
| Share recipient access to /content | ✅ Wired | | User-initiated doc delete: cloud provider cleanup | ❌ Not wired |
| Share recipient access to GET /documents/{id} | ❌ Ownership check blocks recipients | | Share recipient access to `/api/documents/{id}/content` | ✅ Wired (content proxy uses `get_storage_backend_for_document`) |
| Share recipient access to `GET /api/documents/{id}` metadata | ❌ Ownership check blocks recipients |
| Admin audit log JSON viewer | ✅ Wired end-to-end | | Admin audit log JSON viewer | ✅ Wired end-to-end |
| Admin audit log CSV export | ❌ Bearer header dropped | | Admin audit log CSV export | ❌ Bearer header dropped by `window.location.href` |
| Default storage backend selection UI | ❌ Client function orphaned, no UI | | Default storage backend selection UI | ❌ `updateDefaultStorage()` orphaned no UI calls it |
| HKDF credential encryption throughout cloud flows | ✅ Wired | | HKDF credential encryption through all cloud flows | ✅ Wired |
| All routers registered in main.py | ✅ Confirmed | | `write_audit_log()` called from documents (3), shares (2), folders (3), cloud (4) | ✅ All wired |
| All API routers registered in `main.py` | ✅ Confirmed |
| `get_storage_backend_for_document()` factory in content proxy + Celery task | ✅ Wired |
| `SHARE-05 is_shared` computed in API → frontend | ❌ Computed, never rendered |
--- ---
## Remediation Plan ## Tech Debt Summary
3 blockers require closure phases (or targeted inline fixes). In priority order: **Phase 01:** No VERIFICATION.md. VALIDATION.md compliant.
### Gap 1 — Share recipient metadata access (BLOCKER-1) **Phase 02:** VERIFICATION.md gaps_found (Phase 2 SC5 closed by Phase 3). No VALIDATION.md (Nyquist MISSING for Phase 2). 4 human verification items pending.
Affects: SHARE-02, DOC-01
Effort: Small — add share-grant check to `get_document()` in `documents.py` (~15 lines)
### Gap 2 — Cloud document delete (BLOCKER-2) **Phase 03:** No VERIFICATION.md. VALIDATION.md draft (Nyquist PARTIAL). `Document.user_id` ORM nullable vs DB NOT NULL drift. `test_delete_decrements_quota` xfail — INTEGRATION=1 required.
Affects: STORE-06, SEC-09
Effort: Medium — refactor `delete_document()` in `services/storage.py` to use `get_storage_backend_for_document()` and conditionally decrement quota (~30 lines)
### Gap 3 — Admin audit log CSV export (BLOCKER-3) **Phase 04:** No VERIFICATION.md. VALIDATION.md draft (Nyquist PARTIAL). AdminView.vue missing frontend role guard. Multiple UI gaps (FOLD-01, FOLD-05, SHARE-05, SHARE-03, SHARE-02 field names).
Affects: ADMIN-06
Effort: Small — change `window.location.href` to `fetch()` with Bearer header and blob download in `AuditLogTab.vue` (~20 lines)
These three fixes are small enough to close as a single gap-closure phase or inline as part of `/gsd:complete-milestone v1.0` pre-work. **Phase 05:** VERIFICATION.md human_needed (must-haves all confirmed). VALIDATION.md compliant. CLOUD-03 UI orphaned. CLOUD-05 REQUIRES_REAUTH only for OAuth providers.
**Phase 06.1:** VERIFICATION.md stale (superseded). VALIDATION.md compliant. STORE-06 manual gate pending. conftest.py WR-03 teardown gap.
**Cross-cutting:** REQUIREMENTS.md checkboxes not maintained (22 satisfied reqs still show `[ ]`). CLAUDE.md v2 hardening items (ES256, email_hmac, fgp fingerprint) not yet implemented.
---
## Remediation Guide
**Run Nyquist validation first (may close some verification gaps retroactively):**
```
/gsd:validate-phase 2
/gsd:validate-phase 3
/gsd:validate-phase 4
```
**Then insert closure phases for remaining blockers:**
```
/clear then:
/gsd:phase --insert 6.2 "Close v1 sharing + cloud-delete + CSV export gaps"
/gsd:discuss-phase 6.2
/gsd:plan-phase 6.2
/gsd:execute-phase 6.2
```
Suggested scope for Phase 6.2 (all small fixes, could ship as one phase):
| Fix | Files | Effort | REQ-IDs |
|-----|-------|--------|---------|
| Share recipient access: add share-grant check to `get_document()` | `documents.py:542` | ~15 lines | SHARE-02, DOC-01 |
| Fix SharedView.vue field names | `SharedView.vue` | ~10 lines | SHARE-02 |
| Cloud delete: use `get_storage_backend_for_document()` + gate quota decrement | `services/storage.py` | ~25 lines | STORE-06, SEC-09 |
| Audit CSV export: fetch() + Bearer + blob download | `AuditLogTab.vue:191` | ~20 lines | ADMIN-06 |
| Default storage UI: "Set as default" button in SettingsCloudTab | `SettingsCloudTab.vue` | ~30 lines | CLOUD-03 |
| Add `is_shared` indicator to DocumentCard.vue | `DocumentCard.vue` | ~15 lines | SHARE-05 |
| Add `doc_count` to `_folder_to_dict()` | `folders.py:65` | ~10 lines | FOLD-01 |
| Remove `v-if="currentFolderId"` gate from SearchBar | `FileManagerView.vue` | ~5 lines | FOLD-05 |
| Add `PATCH /api/shares/{id}` permission endpoint | `shares.py` | ~30 lines | SHARE-03 |
| Add frontend role guard to AdminView route or component | `router/index.js` or `AdminView.vue` | ~10 lines | — |
| Confirm `test_delete_decrements_quota` under INTEGRATION=1 | `test_quota.py:196` | manual | STORE-06 |
---
**Also available:**
- `cat .planning/v1.0-MILESTONE-AUDIT.md` — this report
- `/gsd:complete-milestone v1.0` — proceed with gaps noted (accept as tech debt)
--- ---
_Audited: 2026-05-30_ _Audited: 2026-05-30_
_Auditor: Claude (gsd-audit-milestone)_ _Auditor: Claude (gsd-audit-milestone)_
_Integration checker: gsd-integration-checker_ _Integration checker: gsd-integration-checker (155 tool calls, 47 connections verified)_
+67
View File
@@ -24,6 +24,73 @@ DocuVault is a multi-user SaaS document management platform built on FastAPI (Py
- Every document/folder endpoint asserts `resource.user_id == current_user.id` - Every document/folder endpoint asserts `resource.user_id == current_user.id`
- All DB queries via ORM / parameterized statements — zero raw string interpolation - All DB queries via ORM / parameterized statements — zero raw string interpolation
## Code Standards (Non-Negotiable)
### Core principle
**Things that look the same to the user are the same in code.** Local file navigation and cloud file navigation share one component. Sidebar folder trees and cloud trees share one component. Format helpers exist once. If you are about to write the same logic a second time, extract it first.
### Backend: shared module map
Before adding a helper, check if it belongs in an existing shared module:
| Module | What lives here |
|---|---|
| `backend/deps/utils.py` | `get_client_ip(request)`, `parse_uuid(value)` — request-parsing helpers used across all routers |
| `backend/storage/exceptions.py` | `CloudConnectionError` — single canonical definition; all files import from here |
| `backend/ai/utils.py` | `strip_code_fences`, `parse_classification`, `parse_suggestions` — AI response parsing shared by all providers |
| `backend/services/auth.py` | `validate_password_strength(password)` — raises `ValueError`; routers catch and re-raise as `HTTPException` |
**Rules:**
- No router may define `_ip()`, `_get_ip()`, or any other local variant of `get_client_ip`. Import from `deps.utils`.
- No router may define its own `CloudConnectionError`. Import from `storage.exceptions`.
- No AI provider may define its own `_strip_code_fences` or `_parse_*`. Import from `ai.utils`.
- No API file may define `_validate_password_strength`. Import from `services.auth`.
- Service layer raises `ValueError` (or domain exceptions), never `HTTPException`. Only the router layer raises `HTTPException`.
### Frontend: shared module map
| Module | What lives here |
|---|---|
| `src/utils/formatters.js` | `formatDate`, `formatSize`, `providerColor`, `providerBg`, `providerLabel` |
| `src/components/ui/TreeItem.vue` | Generic expand/collapse tree node — all sidebar tree items wrap this |
| `src/components/storage/StorageBrowser.vue` | Unified file browser grid — used by both `FileManagerView` and `CloudFolderView` |
**Rules:**
- No component may define its own `formatDate` or `formatSize`. Always import from `utils/formatters.js`.
- No component may define its own `providerColor` or `providerBg`. Always import from `utils/formatters.js`.
- No new tree sidebar component may implement its own expand/collapse state. It must wrap `TreeItem.vue`.
- `StorageBrowser.vue` is the single file browser. Do not create a parallel file grid anywhere.
- `FileManagerView` and `CloudFolderView` are thin data-providers: they feed props into `StorageBrowser` and handle emitted events. They contain no layout or grid logic of their own.
### Component architecture
```
View (thin data-provider)
└── Smart component (StorageBrowser, AdminUsersTab, etc.)
└── Dumb/presentational components (DocumentCard, FolderTreeItem, etc.)
```
- Views own stores and route params. They pass data down as props and handle emitted events.
- Smart components own layout, interactions, and internal state. They emit events upward; they do not call stores directly (exception: read-only lookups like topic color).
- Presentational components receive everything as props and emit actions.
- Props that are passed from parent to child are never mutated with `v-model` — use `:model-value` + `@update:modelValue` and emit upward.
### No dead code
- Files with no active route and no active import are deleted immediately — not commented out, not kept "just in case".
- `HomeView.vue` and `FolderView.vue` are deleted. Do not recreate them.
- Any file that becomes unreferenced after a refactor must be deleted in the same commit.
### Duplication checklist (run before writing new code)
1. Does a shared utility already exist for this logic? (Check the module map above.)
2. Does this component already exist? (Search `components/` before creating.)
3. Is this logic already in a Pinia store? (Check `stores/` before duplicating in a view.)
4. If none of the above: create the shared module first, then use it everywhere that needs it.
---
## GSD Workflow ## GSD Workflow
This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts live in `.planning/`. This project uses the GSD (Get Shit Done) planning workflow. Planning artifacts live in `.planning/`.
+133
View File
@@ -0,0 +1,133 @@
# SECURITY.md — Phase 02 + Phase 03
**Audit date:** 2026-06-01
**Phase 02:** users-authentication (plans 0106) — previously audited, result SECURED
**Phase 03:** document-migration-multi-user-isolation (plans 0105)
**ASVS Level:** L2
**Auditor:** gsd-security-auditor (claude-sonnet-4-6)
---
## Phase 02 Threat Verification (reproduced from previous audit)
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-02-01 | Spoofing | mitigate | CLOSED | `services/auth.py:93``payload.get("typ") != "access"` raises ValueError after JWT decode; prevents password-reset tokens from being accepted as access tokens |
| T-02-02 | Spoofing | mitigate | CLOSED | `services/auth.py:181-185` — on revoked token reuse: `revoke_all_refresh_tokens()` called, `send_security_alert_email.delay()` enqueued, `ValueError("token_family_revoked")` raised |
| T-02-03 | Tampering | mitigate | CLOSED | `services/auth.py:310``code_hash=hash_password(code)` (Argon2); `services/auth.py:338``verify_password(code, row.code_hash)` constant-time comparison via pwdlib |
| T-02-04 | Repudiation | mitigate | CLOSED | `services/auth.py:397-408` — checks `admin_email`/`admin_password` set; `select(User).limit(1)` guards idempotency; logs WARNING when env vars missing |
| T-02-05 | Info Disclosure | mitigate | CLOSED | `services/auth.py:360``sha1[:5]` prefix only sent to HIBP URL; suffix compared locally with `hmac.compare_digest` |
| T-02-06 | DoS | accept | CLOSED | Accepted: `services/auth.py:369-371``httpx timeout=5.0`, `except Exception: logger.warning(…); return False` (fail-open) |
| T-02-07 | EoP | mitigate | CLOSED | `deps/auth.py:87``if user.role != "admin": raise HTTPException(403, "Admin access required")` |
| T-02-08 | EoP | mitigate | CLOSED | `api/admin.py` — no route containing `/impersonate`, `/login-as`, or any code path setting JWT `sub` to a different user; verified by grep (0 matches) |
| T-02-SC | Tampering | mitigate | CLOSED | `backend/requirements.txt:23-26``PyJWT>=2.8.0`, `pwdlib[argon2]>=0.2.1`, `pyotp>=2.9.0`, `slowapi>=0.1.9` all pinned |
| T-02-09 | Spoofing | mitigate | CLOSED | `api/auth.py:248` — identical detail `"Incorrect email or password"` for both non-existent email (`user is None`) and wrong password branches |
| T-02-10 | Spoofing | mitigate | CLOSED | `api/auth.py:673-676` — always returns 202 with `"If an account exists…"` message regardless of whether email was found |
| T-02-11 | Tampering | mitigate | CLOSED | `main.py:100``samesite="strict"` on refresh cookie (`api/auth.py:100`); `main.py:47-61``OriginValidationMiddleware` rejects non-GET/HEAD/OPTIONS requests with Origin not in `settings.cors_origins` |
| T-02-12 | Info Disclosure | accept | CLOSED | Accepted: `stores/auth.js``accessToken = ref(null)` (Pinia memory only); grep returns 0 hits for `localStorage`/`sessionStorage` |
| T-02-13 | DoS | mitigate | CLOSED | `api/auth.py:121,195,326``@limiter.limit("10/minute")` on register/login/refresh; `api/auth.py:215-224` — per-account Redis counter `login_attempts:{email}` capped at 10 in 15 min |
| T-02-14 | Info Disclosure | mitigate | CLOSED | `main.py:32-40``SecurityHeadersMiddleware` sets `Content-Security-Policy`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff` on every response |
| T-02-15 | Tampering | mitigate | CLOSED | `main.py:124``allow_origins=settings.cors_origins`; grep for `allow_origins=["*"]` returns 0 matches |
| T-02-16 | EoP | mitigate | CLOSED | `api/auth.py:259-260``if user.password_must_change: return {"requires_password_change": True, "user_id": …}` — no tokens issued, no cookie set |
| T-02-17 | Spoofing | mitigate | CLOSED | `services/auth.py:262-270` — Redis key `totp_used:{user_id}:{code}`, pre-check before verify, set with `ex=90` after valid code |
| T-02-18 | Spoofing | mitigate | CLOSED | `services/auth.py:330,345` — only queries `BackupCode.used_at.is_(None)`; sets `used_at = datetime.now(timezone.utc)` on first use |
| T-02-19 | Info Disclosure | mitigate | CLOSED | `api/auth.py:594-609` — plaintext codes returned once from `POST /totp/enable`; `services/auth.py:310` stores as Argon2 hashes |
| T-02-20 | EoP | mitigate | CLOSED | `services/auth.py:125-126``decode_password_reset_token` checks `payload.get("typ") != "password-reset"` raises ValueError |
| T-02-21 | EoP | mitigate | CLOSED | `api/auth.py:730``POST /password-reset/confirm` returns `{"message": "Password updated. Please sign in."}` only; no `access_token` in response |
| T-02-22 | Info Disclosure | mitigate | CLOSED | `api/auth.py:673``# Always return 202` comment and return statement outside the `if user is not None` block |
| T-02-23 | Tampering | accept | CLOSED | Accepted: pyotp internal string compare; rate limiting (10/min on `/totp/enable`) is primary defense |
| T-02-24 | Spoofing | mitigate | CLOSED | `frontend/src/components/auth/ConfirmBlock.vue` exists with `confirmed`/`cancelled` emits; AccountView wires `@confirmed` to `logoutAll()` call |
| T-02-25 | DoS | mitigate | CLOSED | `api/auth.py:565``@limiter.limit("10/minute")` on `POST /api/auth/totp/enable` |
| T-02-26A | EoP | mitigate | CLOSED | `api/admin.py``grep -c get_current_admin` returns 12; every handler has `_admin: User = Depends(get_current_admin)` |
| T-02-26B | Spoofing | mitigate | CLOSED | `services/auth.py:330``BackupCode.used_at.is_(None)` filter; used codes are invisible to subsequent `verify_backup_code()` calls |
| T-02-27A | Info Disclosure | mitigate | CLOSED | `api/admin.py:75-90``_user_to_dict()` whitelist: `id, handle, email, role, is_active, totp_enabled, ai_provider, ai_model, password_must_change, created_at` — no `password_hash`, `credentials_enc`, or `totp_secret` |
| T-02-27B | Spoofing | mitigate | CLOSED | `api/auth.py:215-224` — per-account Redis counter incremented before TOTP/backup_code branch; applies to all login paths |
| T-02-28 | EoP | mitigate | CLOSED | `api/admin.py` — grep for `impersonate`/`login.as`/`login_as` returns 0 matches |
| T-02-29 | DoS | mitigate | CLOSED | `api/admin.py:305-316` — COUNT query on `(role='admin', is_active=True)` before deactivation; raises HTTP 400 if `active_admin_count <= 1` |
| T-02-30A | Tampering | mitigate | CLOSED | `api/admin.py:348,377``status_code=HTTP_202_ACCEPTED`; returns `{"message": "Password reset email sent"}`; no token in response |
| T-02-30B | EoP | mitigate | CLOSED | `frontend/src/components/layout/AppSidebar.vue:189``v-if="authStore.user?.role === 'admin'"` on Admin router-link |
| T-02-31A | Info Disclosure | accept | CLOSED | Accepted: quota endpoint exposes `limit_bytes`/`used_bytes` — admin operational data, no PII, no document content |
| T-02-31B | EoP | mitigate | CLOSED | `frontend/src/components/admin/AdminUsersTab.vue` — grep for `impersonate`/`loginAs`/`login-as` returns 0 matches; same for AdminQuotasTab and AdminAiConfigTab |
| T-02-32A | EoP | mitigate | CLOSED | `api/admin.py:255``password_must_change=True` in `User(…)` constructor on `POST /api/admin/users` |
| T-02-32B | Info Disclosure | mitigate | CLOSED | `frontend/src/components/admin/AdminUsersTab.vue` — no `password_hash`, `credentials_enc`, or `totp_secret` bound in template; all fields come from `_user_to_dict()` whitelist |
| T-02-33 | Tampering | mitigate | CLOSED | `frontend/src/components/admin/AdminUsersTab.vue:153-174``v-if="confirmDeactivate === user.id"` shows inline block with `{{ user.email }}` before calling `adminDeactivateUser(id)` |
| T-02-34 | DoS | accept | CLOSED | Accepted: admin is trusted role; no rate limit on `POST /api/admin/users` is intentional |
| T-02-GAP-01 | EoP | mitigate | CLOSED | `frontend/src/router/index.js:42``meta: { requiresAdmin: true }` on `/admin` route; `router/index.js:91-93``if (to.meta.requiresAdmin && authStore.user?.role !== 'admin') return { path: '/' }` |
| T-02-GAP-02 | Info Disclosure | mitigate | CLOSED | `frontend/src/App.vue:2``<AuthLayout v-if="route.meta.layout === 'auth'" />` renders no sidebar on auth routes |
| T-02-GAP-03 | Tampering | accept | CLOSED | Accepted (already mitigated): `api/admin.py:265``await session.flush()` present before `write_audit_log()` in `create_user`; regression test `test_create_user_writes_audit_log` passes |
| T-02-GAP-SC | Tampering | mitigate | CLOSED | `frontend/package.json:13``"qrcode": "^1.5.4"` — canonical npm package (20M+ weekly downloads); no server-side dependency |
---
## Phase 03 Threat Verification
**Audit date:** 2026-06-01
**Plans audited:** 03-01 through 03-05
**Result:** OPEN_THREATS — 4 accepted-risk entries not yet documented (see Accepted Risks Log below)
| Threat ID | Category | Disposition | Status | Evidence |
|-----------|----------|-------------|--------|----------|
| T-03-01 | Tampering | mitigate | CLOSED | `backend/migrations/versions/0003_multi_user_isolation.py:56-88``null_user_objects` collected via SELECT before DELETE (line 56-57); each `client.remove_object()` wrapped in `try/except Exception: pass` (line 85-88); partial MinIO failure leaves only orphans |
| T-03-02 | DoS | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-03 | Info Disclosure | mitigate | CLOSED | `backend/tests/conftest.py:221``token = create_access_token(str(user_id), "user")` uses standard `services.auth.create_access_token` (test secret_key from Settings); async_client fixture clears `app.dependency_overrides` in teardown (line 155); no token values logged anywhere in conftest |
| T-03-04 | Spoofing | mitigate | CLOSED | `backend/api/documents.py:112-113``suffix = Path(body.filename).suffix.lower()`, `object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"` — object_key computed server-side; `body.filename` stored in `Document.filename` DB column only; extension from `Path().suffix.lower()` |
| T-03-05 | Tampering | mitigate | CLOSED | `backend/api/documents.py:327``size = await get_storage_backend().stat_object(doc.object_key)` — size from MinIO stat, not from client; client body contains no size field; confirm endpoint has no `body` parameter beyond doc_id path param |
| T-03-06 | DoS | mitigate | CLOSED | `backend/api/documents.py:341-351``UPDATE quotas SET used_bytes = used_bytes + :delta WHERE user_id = :uid AND (used_bytes + :delta) <= limit_bytes RETURNING used_bytes, limit_bytes`; `row = result.fetchone()`; `if row is None:` → HTTP 413 (lines 353-374) |
| T-03-07 | Info Disclosure | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-08 | Repudiation | mitigate | CLOSED | `backend/tasks/document_tasks.py:132-177``cleanup_abandoned_uploads` Celery task exists; `_cleanup_abandoned()` selects `Document.status == "pending"` and `Document.created_at < cutoff` (1 hour); `backend/celery_app.py:43-46``beat_schedule` entry with `_timedelta(minutes=30)` |
| T-03-09 | Info Disclosure | mitigate | CLOSED | `docker-compose.yml:26``MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173}` — explicit non-wildcard origin; env var defaults to specific origin. Note: implementation uses `FRONTEND_URL` instead of plan's `CORS_ORIGINS` — both default to `http://localhost:5173`; wildcard exclusion is confirmed |
| T-03-10 | Tampering | mitigate | CLOSED | `backend/storage/minio_backend.py:54-60``self._public_client = Minio(endpoint=(public_endpoint or endpoint), ...)` — dual client instantiated in `__init__`; `generate_presigned_put_url` uses `self._public_client` (line 154); `stat_object` uses `self._client` (line 169) |
| T-03-11 | Info Disclosure | mitigate | CLOSED | `backend/api/documents.py` — ownership assertion pattern `if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, ...)` appears at: confirm (line 322-323), get (line 545-546), delete (line 633-634), classify (line 702-703), patch (line 579-580), content (line 767); all raise 404 not 403 |
| T-03-12 | EoP | mitigate | CLOSED | `backend/deps/auth.py:95-109``get_regular_user` raises HTTP 403 if `user.role == "admin"`; `backend/api/documents.py``Depends(get_regular_user)` present on all 7 document handlers: upload-url (line 99), upload (line 143), confirm (line 302), list (line 416), get (line 530), patch (line 557), delete (line 613), classify (line 688), content (line 742) |
| T-03-13 | Info Disclosure | mitigate | CLOSED | `backend/services/storage.py:270-282` (load_topics_for_user) — `or_(Topic.user_id == user_id, Topic.user_id.is_(None))` filter; `backend/api/topics.py:44``storage.load_topics_for_user(session, user_id=current_user.id)`; `backend/api/topics.py:64``storage.create_topic(..., user_id=current_user.id)` |
| T-03-14 | EoP | mitigate | CLOSED | `backend/api/admin.py:602-622``POST /api/admin/topics` with `_admin: User = Depends(get_current_admin)`, creates `Topic(user_id=None)`; `backend/api/topics.py:63-64` — regular `POST /api/topics` forces `user_id=current_user.id` |
| T-03-15 | Tampering | mitigate | CLOSED | `backend/api/documents.py:113``object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"` — prefix always `str(current_user.id)`; no user-supplied prefix accepted; `null-user` sentinel confirmed absent (grep returns 0 for "null-user" in documents.py) |
| T-03-16 | Spoofing | mitigate | CLOSED | `backend/deps/auth.py:35``security = HTTPBearer()` (auto_error=True default) raises 403 on missing Authorization header; `get_current_user` raises 401 (lines 52-55) on invalid/expired token |
| T-03-17 | EoP | mitigate | CLOSED | `backend/api/settings.py` does not exist (confirmed absent); `backend/main.py` contains no `settings_router` import or `include_router` for settings; only admin endpoint writes `user.ai_provider`/`user.ai_model` |
| T-03-18 | Info Disclosure | mitigate | CLOSED | `backend/services/storage.py` — grep for `load_settings`/`save_settings`/`mask_api_key`/`settings_masked` returns 0 matches in non-comment lines; comment at line 12 references removal but no function bodies present |
| T-03-19 | Tampering | mitigate | CLOSED | `backend/tasks/document_tasks.py:62-64``user = await session.get(User, doc.user_id) if doc.user_id else None; ai_provider = (user.ai_provider if user else None) or app_settings.default_ai_provider`; task signature is `extract_and_classify(document_id: str)` — no provider in broker message |
| T-03-20 | Info Disclosure | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-21 | Repudiation | mitigate | CLOSED | `frontend/src/api/client.js` — grep for `getSettings`/`patchSettings`/`testProvider`/`getDefaultPrompt` returns 0 matches; `SettingsView.vue` imports only `SettingsPreferencesTab`, `SettingsAiTab`, `SettingsCloudTab`, `SettingsAccountTab` — no old settings store; `SettingsAiTab.vue` contains no API calls (static read-only display) |
| T-03-22 | Info Disclosure | mitigate | CLOSED | `frontend/src/stores/documents.js:24-25``xhr.setRequestHeader('Content-Type', ...)` only; comment `// NOTE: no Authorization header — presigned URL is self-authenticating (T-03-22)`; no `setRequestHeader('Authorization', ...)` call present |
| T-03-23 | Spoofing | mitigate | CLOSED | `frontend/src/components/upload/UploadProgress.vue:27,30``item.quotaError.rejected_bytes`, `item.quotaError.used_bytes`, `item.quotaError.limit_bytes` all sourced from server 413 response body (via `err.payload` from `api/client.js`); no local `file.size` calculation |
| T-03-24 | DoS | accept | CLOSED | Documented in Accepted Risks Log below |
| T-03-25 | Tampering | mitigate | CLOSED | `frontend/src/stores/documents.js:70``const rowKey = \`${file.name}__${Date.now()}\`` — composite key prevents collision for same-filename concurrent uploads |
| T-03-26 | Repudiation | mitigate | CLOSED | `frontend/src/stores/auth.js:144-149` — `fetchQuota()` wraps `api.getMyQuota()` in `try { ... } catch { // Silently ignore }`; last-known values preserved on error; `QuotaBar.vue` hides via `v-if="!loadFailed"` on catch |
| T-03-SC (×5) | Tampering | mitigate | CLOSED | No new pip or npm package installs in any of plans 03-01 through 03-05; all packages already pinned from Phase 1/2 |
---
## Accepted Risks Log
| Risk ID | Component | Accepted Risk | Rationale |
|---------|-----------|---------------|-----------|
| T-02-06 | HIBP network call | Fail-open on network error — auth proceeds | `httpx timeout=5s`; logging warning; HIBP unavailability must not block legitimate logins |
| T-02-12 | Access token in JavaScript | Token held in Pinia `ref()` memory | Lost on page refresh (by design); refresh endpoint uses httpOnly cookie to reissue |
| T-02-23 | TOTP constant-time compare | pyotp uses Python string compare | Rate limiting (10/min on `/totp/enable`) is the primary defense; 6-digit TOTP window makes brute force impractical within the rate window |
| T-02-31A | Quota endpoint | Admin can view `limit_bytes`/`used_bytes` | No PII; no document content; operational necessity for quota management |
| T-02-34 | Admin user creation | No rate limit on `POST /api/admin/users` | Admin is a trusted role; rate limiting would hinder legitimate bulk user provisioning |
| T-02-GAP-03 | admin.py create_user flush order | Already mitigated — documented as accepted | `session.flush()` present at `admin.py:265`; regression test confirms FK ordering |
| T-03-02 | Alembic migration when MinIO unreachable | Migration may leave MinIO objects undeleted if MinIO is unreachable at migration time | Migration runs only after docker-compose health checks confirm MinIO is ready (backend service `depends_on: minio: condition: service_healthy`); if MinIO is down, deployment is blocked before migration runs; orphaned objects are harmless (no DB row references them); retry on next deploy |
| T-03-07 | Presigned URL in application logs | 15-minute TTL presigned URL may appear in debug logs | TTL is 15 minutes; only `document_id` (not full URL) is logged at the document endpoint level; low risk for v1; full log redaction deferred to Phase 4/5 |
| T-03-20 | SYSTEM_PROMPT env var in container logs | `settings.system_prompt` value visible in container startup logs if log level includes config dump | `SYSTEM_PROMPT` is a static AI instruction string with no PII, no credentials, no secrets; container log exposure of this value has no security impact |
| T-03-24 | Concurrent browser uploads exhaust memory | Multiple simultaneous large-file uploads could exhaust browser memory | XHR-based upload streams bytes natively without buffering in JavaScript memory; browser natively handles the file stream; v1 acceptance — concurrent upload limits are a UX concern, not a security concern |
---
## Unregistered Threat Flags
None. All `## Threat Flags` sections in plans 03-01 through 03-05 summaries report no new attack surface beyond the registered threat IDs.
---
## Notes
### Phase 03 Audit Notes
- **T-03-09 env var deviation:** The implementation uses `MINIO_API_CORS_ALLOW_ORIGIN: ${FRONTEND_URL:-http://localhost:5173}` instead of the plan's `${CORS_ORIGINS:-http://localhost:5173}`. Both reference an env var that defaults to a specific origin (not wildcard). The security invariant (no wildcard) is upheld.
- **T-03-21 SettingsView evolution:** By Phase 5, `SettingsView.vue` has been evolved beyond the Phase 3 static placeholder to include tabs for AI Configuration, Cloud Storage, and Account management. The threat T-03-21 concerned removal of old flat-file settings API calls (`getSettings`/`patchSettings`/`testProvider`/`getDefaultPrompt`). These are confirmed absent. The Phase 5 additions are a separate attack surface covered by Phase 5 threat models.
- **T-03-11 ownership assertion pattern:** The `if doc is None or doc.user_id != current_user.id` combined check is present on all 7 document handlers. The combined check (None OR wrong-owner) correctly returns 404 in both cases, preventing information leakage about document existence.
- **CASE WHEN vs GREATEST():** The quota decrement in `services/storage.py` uses `CASE WHEN used_bytes > :delta THEN used_bytes - :delta ELSE 0 END` instead of `GREATEST(0, used_bytes - :delta)`. This is semantically equivalent and provides SQLite test compatibility. The behavior on PostgreSQL is identical.