docs(03): create Phase 3 execution plan — document migration & multi-user isolation

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>
This commit is contained in:
curo1305
2026-05-23 13:36:28 +02:00
parent 1ba578c7f6
commit fdc32d431d
10 changed files with 3940 additions and 15 deletions
@@ -0,0 +1,348 @@
---
phase: 03-document-migration-multi-user-isolation
plan: 03
type: execute
wave: 3
depends_on:
- 03-02
files_modified:
- backend/deps/auth.py
- backend/api/documents.py
- backend/api/topics.py
- backend/api/admin.py
- backend/services/storage.py
- backend/services/classifier.py
autonomous: true
requirements:
- SEC-04
- DOC-04
must_haves:
truths:
- "Every /api/documents/* handler requires a valid Bearer JWT and a non-admin user role; admins receive 403 on every document endpoint"
- "Cross-user access to a document (by manipulating the doc_id path parameter) returns 404 — never reveals that the document exists"
- "Newly uploaded Document rows have user_id = current_user.id; object_key embeds str(current_user.id) (no more 'null-user' sentinel)"
- "GET /api/topics returns the union of system topics (user_id IS NULL) and the requesting user's own topics — never another user's topics"
- "POST /api/admin/topics requires admin role; creates a Topic with user_id = NULL"
- "Regular POST /api/topics creates a Topic with user_id = current_user.id"
- "Atomic quota path in /confirm runs unconditionally (no more None-user fallback) for every authenticated upload"
artifacts:
- path: "backend/deps/auth.py"
provides: "get_regular_user dependency (current_user with admin-403)"
exports:
- "get_regular_user"
- path: "backend/api/documents.py"
provides: "All handlers wired with get_regular_user dep + ownership assertion (doc.user_id == current_user.id else 404)"
contains: "get_regular_user"
- path: "backend/api/topics.py"
provides: "All handlers wired with get_current_user dep + namespace-scoped queries"
contains: "or_"
- path: "backend/api/admin.py"
provides: "POST /api/admin/topics endpoint (admin-only system topic creation)"
contains: "/admin/topics"
- path: "backend/services/storage.py"
provides: "load_topics_for_user(session, user_id) + create_topic accepts user_id"
exports:
- "load_topics_for_user"
- path: "backend/services/classifier.py"
provides: "classify_document passes user_id through topic lookup and create_topic calls"
contains: "load_topics_for_user"
key_links:
- from: "backend/api/documents.py"
to: "backend/deps/auth.py"
via: "current_user: User = Depends(get_regular_user) on every handler"
pattern: "Depends\\(get_regular_user\\)"
- from: "backend/api/documents.py"
to: "documents table"
via: "ownership assertion: if doc is None or doc.user_id != current_user.id → 404"
pattern: "doc\\.user_id != current_user\\.id"
- from: "backend/api/topics.py"
to: "topics table"
via: "WHERE (Topic.user_id == current_user.id OR Topic.user_id IS NULL)"
pattern: "or_\\(Topic\\.user_id"
- from: "backend/services/classifier.py"
to: "backend/services/storage.py"
via: "create_topic(session, name, user_id=doc.user_id) to keep AI-suggested topics in the user namespace"
pattern: "create_topic.*user_id"
---
<objective>
Enforce per-user isolation across all `/api/documents/*` and `/api/topics/*` endpoints. Add the `get_regular_user` dependency (rejects admin), wire it into every documents handler with ownership assertions (404 on cross-user access per D-16), add `get_current_user` to topics with namespace-scoped queries (D-17), and create the admin-only `POST /api/admin/topics` endpoint (D-09). Remove the Wave 2 `null-user` placeholder from upload-url so newly created Documents carry the authenticated user_id.
Purpose: Closes Phase 2 D-07 / Phase 3 SC4 — admins return 403, cross-user returns 404. The atomic quota path from Plan 03-02 finally runs for real authenticated traffic.
Output: 6 file modifications. After this plan, the backend enforces full multi-user isolation for documents and topics; only Plan 03-04 (settings retirement + per-user AI) remains for the backend.
</objective>
<execution_context>
@/Users/nik/.claude/get-shit-done/workflows/execute-plan.md
@/Users/nik/.claude/get-shit-done/templates/summary.md
</execution_context>
<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-02-SUMMARY.md
@CLAUDE.md
@backend/deps/auth.py
@backend/api/documents.py
@backend/api/topics.py
@backend/api/admin.py
@backend/services/storage.py
@backend/services/classifier.py
<interfaces>
<!-- Contracts the executor needs without re-reading the codebase. -->
From backend/deps/auth.py (Phase 2):
async def get_current_user(credentials, session) -> User # 401 on invalid/expired
async def get_current_admin(user = Depends(get_current_user)) -> User # 403 on non-admin
This plan adds:
async def get_regular_user(user = Depends(get_current_user)) -> User # 403 on admin
From backend/db/models.py:
Document.user_id # NOT NULL after Plan 03-01 migration
Topic.user_id # nullable: NULL = system topic; UUID = per-user
Topic UniqueConstraint("user_id", "name", name="uq_topics_user_name")
From backend/services/storage.py (current — Plan 03-02 has removed save_upload):
async def create_topic(session, name, description="", color="#6366f1") -> dict
async def load_topics(session) -> list[dict]
async def list_metadata(session, topic=None) -> list[dict]
async def get_metadata(session, doc_id) -> Optional[dict]
async def delete_document(session, doc_id) -> bool # already decrements quota for non-None user_id
This plan changes:
create_topic gains user_id: uuid.UUID | None = None parameter (deduplication scoped to user namespace)
NEW: load_topics_for_user(session, user_id) -> list[dict]
From backend/services/classifier.py (current — Plan 03-04 changes signature for AI provider; this plan only changes topic plumbing):
async def classify_document(session, doc_id, topic_names=None) -> list[str]
— uses storage.load_topics(session) for the topic list and storage.create_topic(session, name) for new suggestions
async def suggest_topics_for_document(session, doc_id) -> list[str]
</interfaces>
</context>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → /api/documents/* | Authenticated user supplies doc_id path param; ownership assertion gates every read/write |
| browser → /api/topics/* | Authenticated user supplies topic ops; namespace filter prevents cross-user topic enumeration |
| admin token → /api/documents/* | Admin role is explicitly rejected with 403 — admins cannot read document content per CLAUDE.md |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-11 | Information Disclosure | Cross-user document access via doc_id manipulation | mitigate | Every handler taking doc_id asserts `doc is None or doc.user_id != current_user.id` and raises 404 (not 403) per D-16 / SEC-04 — attacker cannot distinguish "not found" from "wrong owner" |
| T-03-12 | Elevation of Privilege | Admin reading user document content | mitigate | get_regular_user dependency rejects admin with 403; applied to every /api/documents/* handler; PATTERNS.md "Auth Guard Pattern" |
| T-03-13 | Information Disclosure | Cross-user topic enumeration | mitigate | All topic queries filter by `or_(Topic.user_id == current_user.id, Topic.user_id.is_(None))`; create_topic dedup scoped by user_id (RESEARCH.md Finding 6 Risk 5) |
| T-03-14 | Elevation of Privilege | Regular user creating system topic | mitigate | POST /api/admin/topics requires `Depends(get_current_admin)`; regular POST /api/topics forces user_id=current_user.id |
| T-03-15 | Tampering | object_key forged with another user's UUID prefix | mitigate | object_key is computed server-side using `str(current_user.id)` — never accepts user-supplied prefix; CLAUDE.md "Every document/folder endpoint asserts resource.user_id == current_user.id" |
| T-03-16 | Spoofing | Anonymous traffic to /api/documents/* after auth wiring | mitigate | HTTPBearer auto_error=True raises 403 when no Authorization header present; get_current_user raises 401 on invalid/expired token |
| T-03-SC | Tampering | pip installs | mitigate | No new package installs |
</threat_model>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add get_regular_user dependency; wire auth + ownership + real user_id into /api/documents/*</name>
<files>backend/deps/auth.py, backend/api/documents.py, backend/services/storage.py</files>
<read_first>
- backend/deps/auth.py — current get_current_user, get_current_admin signatures + docstrings (model for get_regular_user)
- backend/api/documents.py — current state after Plan 03-02 (upload-url, confirm, list, get, delete, classify); locate the `"null-user"` placeholder and the `if doc.user_id is not None:` Wave 2 fallback
- backend/services/storage.py — current list_metadata signature (must accept user_id filter); delete_document; get_metadata
- backend/db/models.py — Document.user_id (now NOT NULL post Plan 03-01)
- .planning/phases/03-document-migration-multi-user-isolation/03-CONTEXT.md — D-16 (404 not 403, admin 403)
- .planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md — "Auth Guard Pattern", "Ownership Assertion (SEC-04)"
- .planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md — Finding 7 (404 vs 403)
</read_first>
<behavior>
- get_regular_user is a FastAPI dependency that wraps get_current_user and raises 403 if user.role == "admin"
- Every handler in api/documents.py adds `current_user: User = Depends(get_regular_user)` parameter
- upload-url replaces `"null-user"` in object_key with `str(current_user.id)` and sets `Document.user_id = current_user.id`
- confirm endpoint removes the `if doc.user_id is not None:` Wave 2 fallback — the atomic quota path runs for every confirm
- confirm endpoint adds ownership assertion before the stat_object call: `if doc is None or doc.user_id != current_user.id: raise HTTPException(404, "Document not found")`
- list endpoint filters by user_id = current_user.id (services.storage.list_metadata accepts user_id param)
- get endpoint asserts `doc.user_id == current_user.id` else 404
- delete endpoint asserts ownership then calls services.storage.delete_document (which decrements quota — Plan 03-02 already wired this; remove the `if doc.user_id is not None:` guard there too)
- classify endpoint asserts ownership
- services.storage.list_metadata adds required `user_id: uuid.UUID` parameter and `Document.user_id == user_id` filter
- services.storage.delete_document removes the `if doc.user_id is not None:` guard (now always non-None)
- Tests test_cross_user_access_404, test_admin_cannot_access_documents, test_documents_require_auth transition from xfail → pass
</behavior>
<action>
Modify `backend/deps/auth.py`: append a new dependency function:
```
async def get_regular_user(user: User = Depends(get_current_user)) -> User:
"""Reject admin accounts on all /api/documents/* endpoints (D-16, SC4).
Admin accounts cannot access document content (CLAUDE.md + SEC-04).
Returns 403 (not 404) — the admin knows document endpoints exist.
"""
if user.role == "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin accounts cannot access document content",
)
return user
```
Modify `backend/api/documents.py`:
- Add imports `from db.models import User` and `from deps.auth import get_regular_user`.
- In POST `/upload-url`: add `current_user: User = Depends(get_regular_user)` parameter; change `object_key = f"null-user/{doc_id}/...` to `object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"`; change Document insert to `user_id=current_user.id`.
- In POST `/{doc_id}/confirm`: add `current_user: User = Depends(get_regular_user)` parameter. After loading doc by id, add `if doc is None or doc.user_id != current_user.id: raise HTTPException(status_code=404, detail="Document not found")` (this replaces the simpler 404 check). Remove the `if doc.user_id is not None:` Wave 2 branch entirely — the atomic quota update path runs unconditionally. The else-branch that returned `used_bytes=0` for None-user is deleted.
- In GET `""` (list): add `current_user: User = Depends(get_regular_user)` parameter; change `await storage.list_metadata(session, topic=topic)` to `await storage.list_metadata(session, user_id=current_user.id, topic=topic)`.
- In GET `/{doc_id}`: add `current_user: User = Depends(get_regular_user)` parameter; replace the existing `meta = await storage.get_metadata(...)` flow with `doc = await session.get(Document, uuid.UUID(doc_id))`; check `if doc is None or doc.user_id != current_user.id: raise HTTPException(404, "Document not found")`; then call `meta = await storage.get_metadata(session, doc_id)` for the response shape (or replace with `_doc_to_dict` if more direct).
- In DELETE `/{doc_id}`: add `current_user: User = Depends(get_regular_user)` parameter; load Document via `session.get(Document, uuid.UUID(doc_id))` and assert ownership before calling `storage.delete_document(session, doc_id)`; on `doc is None or doc.user_id != current_user.id` raise 404 (do not return success).
- In POST `/{doc_id}/classify`: add `current_user: User = Depends(get_regular_user)` parameter; assert ownership via `doc = await session.get(Document, uuid.UUID(doc_id)); if doc is None or doc.user_id != current_user.id: raise HTTPException(404, "Document not found")` before calling classifier.
Modify `backend/services/storage.py`:
- Change `list_metadata` signature from `(session, topic=None)` to `(session, user_id: uuid.UUID, topic: Optional[str] = None)`. Update the `stmt = select(Document).order_by(...)` block to insert `.where(Document.user_id == user_id)` before any topic join.
- Remove the `if doc.user_id is not None:` guard from `delete_document` added in Plan 03-02 — the quota decrement now always runs (post-migration, user_id is NOT NULL).
</action>
<verify>
<automated>cd backend && pytest tests/test_documents.py::test_cross_user_access_404 tests/test_documents.py::test_admin_cannot_access_documents tests/test_documents.py::test_documents_require_auth tests/test_documents.py::test_upload_url_endpoint tests/test_documents.py::test_confirm_endpoint tests/test_quota.py -x -q 2>&1 | tail -20 && grep -c "get_regular_user" backend/deps/auth.py && grep -c "Depends(get_regular_user)" backend/api/documents.py && grep -v '^[[:space:]]*#' backend/api/documents.py | grep -c "doc.user_id != current_user.id" && grep -c "null-user" backend/api/documents.py</automated>
</verify>
<done>
`get_regular_user` defined in deps/auth.py. `Depends(get_regular_user)` present in every handler of api/documents.py (>= 6 occurrences expected: upload-url, confirm, list, get, delete, classify). `null-user` literal NOT present in api/documents.py (count == 0). Ownership assertion `doc.user_id != current_user.id` appears in at least 4 handlers. All 6 listed tests pass or xpass.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Wire get_current_user + namespace filter into /api/topics/* + add POST /api/admin/topics + propagate user_id through classifier topic ops</name>
<files>backend/api/topics.py, backend/api/admin.py, backend/services/storage.py, backend/services/classifier.py</files>
<read_first>
- backend/api/topics.py — current handler structure (will be modified)
- backend/api/admin.py — pattern for `_admin: User = Depends(get_current_admin)` and Pydantic model definitions (UserCreate, AiConfigUpdate)
- backend/services/storage.py — current `create_topic` (lines ~328-355), `load_topics` (lines ~289-295), `update_topic`, `delete_topic` signatures
- backend/services/classifier.py — current `classify_document` body lines 33-58; `storage.load_topics` and `storage.create_topic` call sites
- .planning/phases/03-document-migration-multi-user-isolation/03-CONTEXT.md — D-08, D-09, D-10, D-11, D-17
- .planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md — Topic namespace query (lines ~401-409)
- .planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md — Finding 6 (namespace query + dedup scoping + Risk 5)
</read_first>
<behavior>
- GET /api/topics requires get_current_user; query filters `or_(Topic.user_id == current_user.id, Topic.user_id.is_(None))` via new service `load_topics_for_user`
- POST /api/topics requires get_current_user; creates Topic with user_id = current_user.id (never NULL)
- PATCH /api/topics/{id} requires get_current_user; the topic must belong to the user (user_id == current_user.id) — otherwise 404 (NOT 403 — same info-leak rationale as docs)
- DELETE /api/topics/{id} requires get_current_user; same ownership rule. System topics (user_id IS NULL) cannot be edited or deleted via /api/topics/*
- POST /api/topics/suggest requires get_current_user; existing classifier path
- POST /api/admin/topics (NEW in api/admin.py) requires get_current_admin; creates Topic with user_id = NULL
- services.storage.create_topic gains `user_id: uuid.UUID | None = None` parameter; dedup query is scoped: `WHERE lower(name) = :name AND user_id IS [NOT DISTINCT FROM] :user_id`
- services.storage adds `load_topics_for_user(session, user_id) -> list[dict]` using the or_/is_ filter
- services.classifier.classify_document replaces `storage.load_topics(session)` with `storage.load_topics_for_user(session, doc.user_id)` and passes `user_id=doc.user_id` into `storage.create_topic` for AI-suggested topics
- Tests test_topic_namespace, test_admin_create_system_topic, test_regular_user_cannot_create_system_topic, test_topics_require_auth transition from xfail → pass
</behavior>
<action>
Modify `backend/api/topics.py`. Add imports `from db.models import User` and `from deps.auth import get_current_user`. For every existing handler add `current_user: User = Depends(get_current_user)` as a parameter:
- GET `""` (list_topics): replace `await storage.load_topics(session)` with `await storage.load_topics_for_user(session, user_id=current_user.id)`. Existing topic_doc_counts call: the doc count must also be scoped to current user — pass `user_id=current_user.id` into a new `storage.topic_doc_counts(session, user_id=current_user.id)` (add user filter to that helper as part of this task — add `Document.user_id == user_id` join condition).
- POST `""` (create_topic): pass `user_id=current_user.id` into `storage.create_topic(...)`.
- PATCH `/{topic_id}` (update_topic): before calling `storage.update_topic`, load the Topic and assert `topic.user_id == current_user.id` (no editing of system topics or other users' topics) — else 404. Implementation: `t = await session.get(Topic, uuid.UUID(topic_id)); if t is None or t.user_id != current_user.id: raise HTTPException(404, "Topic not found")`.
- DELETE `/{topic_id}` (delete_topic): same ownership assertion before delete.
- POST `/suggest` (suggest_topics): just add the dep; classifier handles the namespace.
Modify `backend/api/admin.py`. Add a new Pydantic model `class SystemTopicCreate(BaseModel): name: str; description: str = ""; color: str = "#6366f1"`. Add a new endpoint:
```
@router.post("/topics", status_code=status.HTTP_201_CREATED)
async def create_system_topic(
body: SystemTopicCreate,
session: AsyncSession = Depends(get_db),
_admin: User = Depends(get_current_admin),
) -> dict:
"""Admin creates a system topic visible to all users (D-09).
System topics have user_id = NULL (per D-08 namespace model).
"""
from services import storage
topic = await storage.create_topic(
session, body.name, body.description, body.color, user_id=None
)
return topic
```
Import additions: add `from db.models import Topic` if not already imported (Topic is needed for any future admin topic edit/delete; this plan only adds POST).
Modify `backend/services/storage.py`:
1. Add `from sqlalchemy import or_` to the existing `from sqlalchemy import select, delete` import line.
2. Add `import uuid` if not already at top (it is — verify).
3. Modify `create_topic` signature: `async def create_topic(session, name, description="", color="#6366f1", user_id: uuid.UUID | None = None) -> dict`. Replace the dedup query with a namespace-scoped lookup. Because PostgreSQL needs `IS NOT DISTINCT FROM` semantics to compare NULLs but SQLite does not implement `IS NOT DISTINCT FROM`, use a branching approach:
```
if user_id is None:
q = await session.execute(
select(Topic).where(
sql_func.lower(Topic.name) == name.lower(),
Topic.user_id.is_(None),
)
)
else:
q = await session.execute(
select(Topic).where(
sql_func.lower(Topic.name) == name.lower(),
Topic.user_id == user_id,
)
)
```
On insert, set `Topic(name=name, description=description, color=color, user_id=user_id)`.
4. Add new function (after `load_topics`):
```
async def load_topics_for_user(session, user_id: uuid.UUID) -> list:
"""Return system topics (user_id IS NULL) + user's own topics, ordered by name (D-17, DOC-04)."""
q = await session.execute(
select(Topic).where(
or_(Topic.user_id == user_id, Topic.user_id.is_(None))
).order_by(Topic.name)
)
return [
{"id": str(t.id), "name": t.name, "description": t.description, "color": t.color}
for t in q.scalars()
]
```
Append `"load_topics_for_user"` to `__all__`.
5. Modify `topic_doc_counts` to optionally scope by user: add `user_id: uuid.UUID | None = None` param; if non-None, join Document and filter by `Document.user_id == user_id` (so a user only sees the count of their own documents tagged to each topic).
Modify `backend/services/classifier.py`:
1. In `classify_document` (line ~39): replace `all_topics = await storage.load_topics(session)` with `all_topics = await storage.load_topics_for_user(session, user_id=doc.user_id)`. To access `doc.user_id`, fetch the Document at the top of the function: add `from db.models import Document; from sqlalchemy.ext.asyncio import AsyncSession; doc = await session.get(Document, uuid.UUID(doc_id))` (`uuid` is already imported via meta path; if not, add `import uuid`).
2. Replace `await storage.create_topic(session, name.strip())` (line ~52) with `await storage.create_topic(session, name.strip(), user_id=doc.user_id)` to put AI-suggested topics in the user namespace (D-11).
3. In `suggest_topics_for_document`: also replace `storage.load_topics(session)` if called (it is not, per current code) — only `provider.suggest_topics` is invoked, no change needed.
</action>
<verify>
<automated>cd backend && pytest tests/test_topics.py::test_topic_namespace tests/test_topics.py::test_admin_create_system_topic tests/test_topics.py::test_regular_user_cannot_create_system_topic tests/test_topics.py::test_topics_require_auth tests/test_topics.py -x -q 2>&1 | tail -20 && grep -c "load_topics_for_user" backend/services/storage.py && grep -c "load_topics_for_user" backend/services/classifier.py && grep -c "or_(Topic.user_id" backend/services/storage.py && grep -c "/topics" backend/api/admin.py && grep -c "Depends(get_current_user)" backend/api/topics.py</automated>
</verify>
<done>
All 4 topic-namespace tests pass or xpass. Existing topic tests (`test_list_topics_empty`, `test_create_topic`, etc.) still pass after auth wiring (they may need fixture updates — surface as a separate concern if they regress). `load_topics_for_user` is defined in storage.py and imported in classifier.py. `Depends(get_current_user)` appears in api/topics.py >= 5 times (list, create, update, delete, suggest). `POST /api/admin/topics` exists in api/admin.py.
</done>
</task>
</tasks>
<verification>
- Cross-user access returns 404: `cd backend && pytest tests/test_documents.py::test_cross_user_access_404 -x -q`
- Admin gets 403 on documents: `cd backend && pytest tests/test_documents.py::test_admin_cannot_access_documents -x -q`
- Anonymous gets rejected: `cd backend && pytest tests/test_documents.py::test_documents_require_auth -x -q`
- Topic namespace isolation: `cd backend && pytest tests/test_topics.py::test_topic_namespace -x -q`
- Admin-only system topic creation: `cd backend && pytest tests/test_topics.py::test_admin_create_system_topic tests/test_topics.py::test_regular_user_cannot_create_system_topic -x -q`
- No null-user sentinel remains in api/documents.py: `cd backend && grep -c "null-user" backend/api/documents.py` returns 0
- Atomic quota path unconditional: `cd backend && grep -v '^[[:space:]]*#' backend/api/documents.py | grep -c "if doc.user_id is not None" | head -1` returns 0
</verification>
<success_criteria>
- get_regular_user dependency defined in deps/auth.py
- Every /api/documents/* handler injects Depends(get_regular_user) and asserts ownership (404 on cross-user)
- Every /api/topics/* handler injects Depends(get_current_user); queries are namespace-scoped
- POST /api/admin/topics exists and requires admin
- services.storage.create_topic accepts user_id; dedup scoped by namespace
- services.storage.load_topics_for_user implemented; consumed by classifier
- classify_document creates AI-suggested topics in the user namespace
- Phase 2 SC5 deferred item (admin JWT → 403 on documents) is fulfilled
</success_criteria>
<output>
Create `.planning/phases/03-document-migration-multi-user-isolation/03-03-SUMMARY.md` when done — list every endpoint now requiring auth, the ownership-assertion pattern (404 not 403), and any existing topic/document tests that needed fixture updates to handle the new auth requirement.
</output>