5 plans across 5 sequential waves covering: Alembic migration 0003 (null-user cleanup, NOT NULL constraint, quota reconciliation), presigned MinIO PUT upload flow with atomic quota enforcement, auth guards on all document/topic endpoints, flat-file settings retirement + per-user AI classification, and frontend quota bar with 3-step XHR upload progress. Verification passed across all 12 dimensions. All 8 phase requirements covered (STORE-03/04/05/06, SEC-04, DOC-03/04/05). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
26 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-document-migration-multi-user-isolation | 04 | execute | 4 |
|
|
true |
|
|
Purpose: Phase 3 SC5 (each user's AI classification uses the provider/model assigned to that user by the admin) cannot pass while load_settings() exists. This is the final backend plan for Phase 3. Output: 10 file modifications (3 backend code, 1 backend route removal, 1 backend test update, 1 backend main.py, 3 frontend code, 1 backend config). After this plan, the entire Phase 3 backend is multi-user-isolated and admin-controlled.
<execution_context> @/Users/nik/.claude/get-shit-done/workflows/execute-plan.md @/Users/nik/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-document-migration-multi-user-isolation/03-CONTEXT.md @.planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md @.planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md @.planning/phases/03-document-migration-multi-user-isolation/03-VALIDATION.md @.planning/phases/03-document-migration-multi-user-isolation/03-03-SUMMARY.md @CLAUDE.md@backend/config.py @backend/services/classifier.py @backend/services/storage.py @backend/tasks/document_tasks.py @backend/api/settings.py @backend/main.py @backend/ai/__init__.py @backend/tests/test_settings.py @frontend/src/views/SettingsView.vue @frontend/src/stores/settings.js @frontend/src/api/client.js
From backend/db/models.py: User.ai_provider: Mapped[Optional[str]] # nullable text — set by admin via PATCH /api/admin/users/{id}/ai-config User.ai_model: Mapped[Optional[str]] # nullable text
From backend/ai/__init__.py (current — RESEARCH.md Finding 12 / A4): def get_provider(settings: dict) -> AIProvider
Accepts dict with keys: "active_provider": str, "providers": {: {"model": str, "api_key"?: str, "base_url"?: str}}
Branches on active_provider for anthropic / openai / ollama / lmstudio
From backend/tasks/document_tasks.py (current):
async def _run(document_id: str) -> dict
— fetches Document by id
— calls topics = await classifier.classify_document(session, document_id) # no ai_provider/ai_model yet
From backend/services/classifier.py (post Plan 03-03):
async def classify_document(session, doc_id, topic_names=None) -> list[str]
— currently calls settings = storage.load_settings(); provider = get_provider(settings)
— must change to provider = get_provider({"active_provider": ai_provider, "providers": {ai_provider: {"model": ai_model}}})
From backend/config.py (current): SETTINGS_FILE = Path(settings.data_dir) / "settings.json" # to be removed DEFAULT_SYSTEM_PROMPT = "..." # to be removed (kept as module const in classifier.py) DEFAULT_SETTINGS = {...} # to be removed class Settings(BaseSettings): ... # extend with new fields
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| user → /api/settings | Endpoint removed; previous attack surface eliminated |
| Celery task → users table | Task resolves AI config via doc.user_id → users row; no user-controlled provider/model input |
| admin → PATCH /api/admin/users/{id}/ai-config | Existing Phase 2 admin endpoint is the only write path to user.ai_provider / user.ai_model |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-03-17 | Elevation of Privilege | User changing their own AI provider | mitigate | /api/settings is removed (D-12); only PATCH /api/admin/users/{id}/ai-config (admin-only) writes user.ai_provider/ai_model |
| T-03-18 | Information Disclosure | settings.json flat file persisted to disk with API keys | mitigate | services.storage.load_settings/save_settings deleted; settings.json no longer read or written; API keys live in env vars only |
| T-03-19 | Tampering | Celery task accepting ai_provider in task signature | mitigate | Task signature unchanged (just document_id); ai_provider/ai_model resolved INSIDE the task via DB lookup so a malicious broker message cannot inject provider |
| T-03-20 | Information Disclosure | system_prompt env var in container logs | accept | SYSTEM_PROMPT is a static instruction string with no PII; documented as low-sensitivity |
| T-03-21 | Repudiation | Frontend SettingsView still attempts old API calls | mitigate | Remove getSettings/patchSettings/testProvider/getDefaultPrompt from api/client.js; gut stores/settings.js; SettingsView shows static message — no API calls |
| T-03-SC | Tampering | pip installs | mitigate | No new package installs |
| </threat_model> |
Modify `backend/services/classifier.py`:
1. At module top, add `_DEFAULT_SYSTEM_PROMPT = """You are a document classification assistant..."""` with the verbatim string from the old `config.DEFAULT_SYSTEM_PROMPT`.
2. Add imports `import uuid` and `from db.models import Document` and `from config import settings as app_settings`.
3. Modify `classify_document` signature:
```
async def classify_document(
session: AsyncSession,
doc_id: str,
topic_names: list[str] | None = None,
ai_provider: str | None = None,
ai_model: str | None = None,
) -> list[str]:
```
Replace `settings = storage.load_settings(); system_prompt = settings.get("system_prompt", ""); provider = get_provider(settings)` with:
```
_ai_provider = ai_provider or app_settings.default_ai_provider
_ai_model = ai_model or app_settings.default_ai_model
system_prompt = app_settings.system_prompt or _DEFAULT_SYSTEM_PROMPT
_settings = {
"active_provider": _ai_provider,
"providers": {_ai_provider: {"model": _ai_model}},
}
provider = get_provider(_settings)
```
4. Modify `suggest_topics_for_document` with the same signature change (`ai_provider`, `ai_model` kwargs) and same `_settings` construction in place of `storage.load_settings()`.
5. Ensure no remaining `storage.load_settings()` call exists anywhere in this file.
Modify `backend/services/storage.py`:
1. Remove the import `from config import DEFAULT_SETTINGS, SETTINGS_FILE` (line 36) — replace with nothing (config import is no longer needed in this module).
2. Remove `import copy` and `import json` from top imports (no longer needed).
3. Remove `load_settings()`, `save_settings()`, `mask_api_key()`, `settings_masked()` function definitions (lines ~416-449).
4. Remove these from `__all__`: `"load_settings", "save_settings", "mask_api_key", "settings_masked"`.
Modify `backend/tasks/document_tasks.py` `_run` function:
1. After `doc = await session.get(Document, doc_uuid)` and the None check, insert:
```
from db.models import User
from config import settings as app_settings
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
ai_model = (user.ai_model if user else None) or app_settings.default_ai_model
```
2. Replace `topics = await classifier.classify_document(session, document_id)` with `topics = await classifier.classify_document(session, document_id, ai_provider=ai_provider, ai_model=ai_model)`.
Delete `backend/api/settings.py` entirely. Use the `rm` shell equivalent or write an empty stub — preferable: delete the file. (Executor: use `git rm backend/api/settings.py` or `os.remove` then commit.)
Modify `backend/main.py`:
1. Remove `from api.settings import router as settings_router` (line 18).
2. Remove `app.include_router(settings_router)` (line 174).
Modify `backend/tests/test_settings.py`:
1. Delete all existing tests (they reference the removed endpoint and storage functions).
2. Keep only the single test from Plan 03-01:
```
async def test_settings_endpoint_removed(async_client):
"""D-12: /api/settings endpoint is removed in Phase 3."""
resp = await async_client.get("/api/settings")
assert resp.status_code == 404
```
Remove the `@pytest.mark.xfail` marker — this test should now pass green.
cd backend && pytest tests/test_settings.py tests/test_classifier.py::test_per_user_provider tests/test_classifier.py::test_celery_task_uses_user_provider tests/test_classifier.py::test_default_provider_fallback -x -q 2>&1 | tail -30 && test ! -f backend/api/settings.py && echo "settings.py deleted" && ! grep -q "load_settings" backend/services/storage.py && echo "load_settings removed" && grep -c "default_ai_provider" backend/config.py && grep -c "_DEFAULT_SYSTEM_PROMPT" backend/services/classifier.py && grep -c "user.ai_provider" backend/tasks/document_tasks.py
`backend/api/settings.py` file does not exist. `backend/services/storage.py` contains no `load_settings`, `save_settings`, `mask_api_key`, `settings_masked` (grep returns 0). `backend/config.py` contains `default_ai_provider` and `default_ai_model`. `backend/services/classifier.py` contains `_DEFAULT_SYSTEM_PROMPT` and accepts `ai_provider` kwarg. `backend/tasks/document_tasks.py` contains `user.ai_provider`. test_settings_endpoint_removed and the 3 classifier tests pass.
Task 2: Frontend — strip settings store/API and replace SettingsView with admin-managed placeholder
frontend/src/views/SettingsView.vue, frontend/src/stores/settings.js, frontend/src/api/client.js
- frontend/src/views/SettingsView.vue — current form structure; replace entirely with placeholder card
- frontend/src/stores/settings.js — actions to remove (fetchSettings, save, testConnection, resetPrompt)
- frontend/src/api/client.js — getSettings, patchSettings, testProvider, getDefaultPrompt functions (lines ~110-132)
- frontend/src/router/index.js — verify /settings route still exists (do not remove — UI-SPEC Risk 6 says keep route with placeholder)
- .planning/phases/03-document-migration-multi-user-isolation/03-UI-SPEC.md — Phase 3 spec inherits Phase 2 design system; no specific SettingsView spec — use minimal card matching existing card style
- .planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md — Finding 11 (frontend retirement scope), Risk 6 (UX regression mitigation)
- frontend/src/views/SettingsView.vue is replaced with a static placeholder: a heading "AI Configuration" and a card explaining "AI configuration is managed by your admin." Add a secondary link or text directing users to contact their admin. No form, no API calls, no useSettingsStore import
- frontend/src/stores/settings.js is reduced to an empty defineStore that exports no actions, OR deleted entirely. Plan: delete the file and update any imports (SettingsView no longer imports it; check if anything else imports it — grep). If other files import it, gut the file to a minimal noop store
- frontend/src/api/client.js removes the four functions: getSettings, patchSettings, testProvider, getDefaultPrompt (lines ~110-132 in current file). Adds a new export getMyQuota() that hits GET /api/auth/me/quota (used by QuotaBar in Plan 03-05 — pre-emptively add here so Plan 03-05 is a pure UI plan)
- Visiting /settings in the browser shows the admin-managed placeholder without any console errors
- SettingsView.vue uses only the Phase 2 design system (existing classes; text-sm / text-xl / bg-white / border-gray-200 / rounded-xl) — UI-SPEC inheritance
Rewrite `frontend/src/views/SettingsView.vue` with the placeholder content. Template structure:
```vue
Settings
Account-level options for your DocuVault workspace.
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">AI configuration</h3>
<p class="text-sm text-gray-600">
AI provider and model are managed by your administrator. Contact your admin
to request changes to which AI provider is used for your documents.
</p>
</section>
</div>
</template>
<script setup>
// SettingsView is a static placeholder after Phase 3 D-12 settings retirement.
// No store usage, no API calls — AI config is admin-only via /api/admin/users/{id}/ai-config.
</script>
```
Remove any `<style>` blocks if present in the original file.
Delete `frontend/src/stores/settings.js`. Before deleting, grep the frontend tree for imports of `'../stores/settings.js'` or `'@/stores/settings.js'`. If any consumer exists outside SettingsView.vue, instead gut the file to:
```js
// stores/settings.js was retired in Phase 3 D-12 — kept as a minimal no-op to avoid breaking imports.
// Remove this file in a future cleanup once all consumers are updated.
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', () => {
return {}
})
```
Otherwise delete the file.
Modify `frontend/src/api/client.js`:
1. Remove the four functions in the `// ── Settings ──` section: `getSettings`, `patchSettings`, `testProvider`, `getDefaultPrompt` (lines ~110-132). Remove the section header comment too.
2. Add a new function for the quota endpoint (used by Plan 03-05's QuotaBar). Place it under a new `// ── Quota ──` section (or under `// ── Auth ──`):
```js
export function getMyQuota() {
return request('/api/auth/me/quota')
}
```
3. Add the upload-flow API helpers used by Plan 03-05:
```js
export function getUploadUrl(filename, contentType) {
return request('/api/documents/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, content_type: contentType }),
})
}
export function confirmUpload(documentId) {
return request(`/api/documents/${documentId}/confirm`, { method: 'POST' })
}
```
Place these under the `// ── Documents ──` section near the existing `uploadDocument` function. Leave `uploadDocument` in place for now (Plan 03-05 will remove it when the documents store no longer calls it).
cd frontend && grep -c "managed by your administrator" src/views/SettingsView.vue && ! grep -q "useSettingsStore" src/views/SettingsView.vue && ! grep -q "getSettings\|patchSettings\|testProvider\|getDefaultPrompt" src/api/client.js && grep -c "getMyQuota\|getUploadUrl\|confirmUpload" src/api/client.js
SettingsView.vue is the placeholder card (no form, no store). api/client.js has getMyQuota + getUploadUrl + confirmUpload exports and no getSettings/patchSettings/testProvider/getDefaultPrompt. stores/settings.js is deleted OR gutted to a no-op store. Visiting /settings in the dev server renders the card with no console errors (manual check during Plan 03-05 verification — not required here).
- Settings endpoint removed: `cd backend && pytest tests/test_settings.py::test_settings_endpoint_removed -x -q`
- Per-user AI classification: `cd backend && pytest tests/test_classifier.py::test_per_user_provider tests/test_classifier.py::test_celery_task_uses_user_provider tests/test_classifier.py::test_default_provider_fallback -x -q`
- No load_settings reference remains: `cd backend && grep -rn 'load_settings\|save_settings' backend --include='*.py' | grep -v tests/` returns no hits
- Frontend settings imports clean: `cd frontend && grep -rn 'getSettings\|patchSettings\|testProvider\|getDefaultPrompt' src/` returns no hits
- All Phase 3 tests still green: `cd backend && pytest -x -q`
<success_criteria>
- backend/api/settings.py deleted
- backend/main.py no longer registers settings_router
- backend/services/storage.py no longer contains load_settings/save_settings/mask_api_key/settings_masked
- backend/services/classifier.py classify_document accepts ai_provider and ai_model kwargs; reads no flat file
- backend/tasks/document_tasks.py resolves user.ai_provider via DB lookup and passes to classifier
- backend/config.py exposes system_prompt, default_ai_provider, default_ai_model fields and no longer defines SETTINGS_FILE/DEFAULT_SETTINGS/DEFAULT_SYSTEM_PROMPT
- frontend/src/views/SettingsView.vue shows the admin-managed placeholder
- frontend/src/api/client.js exports getMyQuota, getUploadUrl, confirmUpload and no longer exports settings functions
- All Plan 03-01 stub tests for DOC-03 / DOC-05 / D-12 pass </success_criteria>