Files
curo1305 747303246a docs(04): create phase 4 plan (9 plans, 7 waves)
Folders, Sharing, Quotas & Document UX — plans verified (0 blockers,
2 non-blocking warnings). Covers FOLD-01..05, SHARE-01..05, SEC-08/09,
ADMIN-06, DOC-01/02.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 18:20:16 +02:00

14 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
04-folders-sharing-quotas-document-ux 05 execute 4
04-03
04-04
backend/api/documents.py
backend/api/auth.py
true
DOC-01
DOC-02
FOLD-04
truths artifacts key_links
GET /api/documents/{id}/content streams document bytes from MinIO through FastAPI
Range header requests return 206 with correct Content-Range header
Admin cannot access the streaming proxy — get_regular_user dep returns 403
No presigned URL is generated or exposed in the proxy response
Share recipients can access document content via the proxy
PATCH /api/auth/me/preferences stores and returns pdf_open_mode preference
path provides
backend/api/documents.py GET /api/documents/{id}/content streaming proxy with Range header support
path provides
backend/api/auth.py PATCH /api/auth/me/preferences endpoint for pdf_open_mode
from to via pattern
backend/api/documents.py backend/storage/minio_backend.py get_storage_backend().get_object(doc.object_key) — bytes fetched directly, not via presigned URL get_object
from to via pattern
backend/api/documents.py backend/db/models.py Share model for _can_access_document() recipient check Share.recipient_id
Add the PDF streaming proxy to the documents API (DOC-02) and the pdf_open_mode preferences endpoint to the auth API (D-10, DOC-01). These can be implemented together since they both modify existing API modules and neither depends on the other.

Purpose: Complete document content access — both the proxy stream and the user preference for how to open PDFs. Output: Streaming proxy endpoint in documents.py + preferences PATCH in auth.py.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/04-folders-sharing-quotas-document-ux/04-CONTEXT.md @.planning/phases/04-folders-sharing-quotas-document-ux/04-PATTERNS.md @.planning/phases/04-folders-sharing-quotas-document-ux/04-RESEARCH.md @backend/api/documents.py @backend/api/auth.py @backend/storage/minio_backend.py @backend/db/models.py Task 1: Add GET /api/documents/{id}/content streaming proxy to documents.py backend/api/documents.py backend/api/documents.py — read the entire file; identify current imports (Request, StreamingResponse if already present; func, Select, etc.); find the endpoint list to determine where to insert the new endpoint; confirm existing UUID parse pattern and get_regular_user usage backend/storage/minio_backend.py — read to confirm the exact name and signature of get_object(); confirm it returns bytes (not a stream object) backend/db/models.py — search for the Share class definition; confirm Share.document_id and Share.recipient_id column names GET /api/documents/{id}/content: - MUST use get_regular_user dep (admin role → 403 — Pitfall 3, CRITICAL) - Parse doc_id as UUID → 404 "Document not found" on ValueError - Load document via session.get(Document, uid) → 404 if None - Access check: if doc.user_id == current_user.id → proceed; else query Share where Share.document_id == doc.id AND Share.recipient_id == current_user.id; if no share → 404 "Document not found" - Fetch bytes: `file_bytes = await get_storage_backend().get_object(doc.object_key)` — this calls MinIO directly, NO presigned URL - Build base headers: content-type = doc.content_type, content-disposition = `inline; filename="{doc.filename}"`, accept-ranges = "bytes", content-length = str(file_size) - Range header handling: if request.headers.get("range") is truthy, call _parse_range() → on valid range: add content-range header, set content-length to chunk length, return StreamingResponse(iter([chunk]), status_code=206, headers=headers) - No range: return StreamingResponse(iter([file_bytes]), status_code=200, headers=headers) - _parse_range() is a module-level helper function, not an endpoint (defined above the endpoint)
DOC-01 note: GET /api/documents/{id} already returns extracted_text — verify this is still included in the existing response; add it to the response if absent (read the actual endpoint to check before modifying).
Modify backend/api/documents.py.
Add to imports if not already present: `from fastapi import Request` (alongside existing FastAPI imports), `from fastapi.responses import StreamingResponse`, `from fastapi import status`. Confirm `from db.models import Share` is in imports (added in plan 04-03 for the is_shared subquery — if absent, add it now).

Define _parse_range() helper function at module level (above the stream_document_content endpoint). Implement exactly as specified in the behavior block and RESEARCH.md Pattern 3: parse "bytes=X-Y" format, handle open-ended ranges (end = file_size - 1 when Y is empty), raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE) on invalid range.

Add the stream_document_content endpoint after the existing document endpoints. Implement the access check using an inline query for Share (do NOT call a separate helper function — the access check is in the handler body for clarity and to avoid test mocking complexity).

CRITICAL: endpoint decorator uses `get_regular_user` not `get_current_user`. This is verified by the security agent and by test_content_stream_admin_403.

CRITICAL: NEVER call presigned_get_url() or any presigned URL method inside this endpoint. Bytes must flow directly from MinIO through FastAPI.
cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_documents.py -x -v --no-header 2>&1 | tail -30 - `GET /api/documents/{id}/content` endpoint exists in documents.py (grep: `async def stream_document_content` or similar) - Endpoint uses `get_regular_user` dep, NOT `get_current_user` (grep: `Depends(get_regular_user)` on the content endpoint; no `get_current_user` on content endpoint) - _parse_range() helper exists at module level (grep: `def _parse_range`) - StreamingResponse imported and used (grep: `StreamingResponse` in documents.py) - No presigned URL call in the handler (grep: `presigned` absent from stream_document_content function body) - Share access check present (grep: `Share.recipient_id` or `recipient_id` in documents.py) - test_content_stream_admin_403 turns green (xpass) or remains xfail — not FAILED - test_content_stream_no_presigned_url turns green or remains xfail — not FAILED - `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing Streaming proxy delivers bytes via StreamingResponse; admin blocked; Range headers supported; no presigned URL exposure. Task 2: Add PATCH /api/auth/me/preferences endpoint for pdf_open_mode backend/api/auth.py backend/api/auth.py — read the entire file; identify the /api/auth/me GET endpoint (existing); find the router prefix and how it is declared; confirm get_current_user (not get_regular_user) is used for /me endpoints (admin can read/update their own preferences); find the existing import block to add pydantic BaseModel if needed backend/db/models.py — confirm users.pdf_open_mode column name and that it exists (added by migration 0004) PATCH /api/auth/me/preferences: - Auth: get_current_user (both regular users and admins can set their own PDF preference — D-10) - Body: {pdf_open_mode: str} — validated to be one of ["in_app", "new_tab"] - If pdf_open_mode not in allowed values → 422 (Pydantic validation handles this via Literal type or custom validator) - Update current_user.pdf_open_mode = body.pdf_open_mode - session.add(current_user); await session.commit() - Return 200: {pdf_open_mode: current_user.pdf_open_mode}
GET /api/auth/me/preferences (optional but recommended for frontend to load initial value):
- Auth: get_current_user
- Return 200: {pdf_open_mode: current_user.pdf_open_mode}
- If pdf_open_mode column is missing from User model (migration not yet run), return {"pdf_open_mode": "in_app"} as default
Modify backend/api/auth.py.
Add a Pydantic request model: PreferencesUpdate with field `pdf_open_mode: Literal["in_app", "new_tab"]`. Import `Literal` from typing.

Add two new endpoints at the end of the router function list:
1. GET /api/auth/me/preferences — returns {pdf_open_mode: str}. Uses `get_current_user` dep. Reads current_user.pdf_open_mode. If AttributeError (column not yet in ORM map, e.g., test env without migration), return {"pdf_open_mode": "in_app"}.
2. PATCH /api/auth/me/preferences — accepts PreferencesUpdate body. Updates current_user.pdf_open_mode. Commits. Returns {pdf_open_mode: updated value}.

Both endpoints are placed after the existing /api/auth/me endpoint. No new router prefix needed — they use the same auth router.

Do NOT break any existing auth tests. Do NOT modify any existing endpoint.
cd /Users/nik/Documents/Progamming/document_scanner/backend && python -c "from api.auth import router; print([r.path for r in router.routes])" 2>&1 - `/api/auth/me/preferences` appears in auth router routes (grep: `me/preferences` in auth.py) - PATCH endpoint validates pdf_open_mode via Literal["in_app", "new_tab"] (grep: `Literal` and `pdf_open_mode` in auth.py) - `python -c "from api.auth import router"` exits 0 - Existing auth tests still pass: `cd backend && python -m pytest tests/test_auth_api.py -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing - Full suite: `cd backend && python -m pytest -v --no-header 2>&1 | grep -E "^FAILED"` returns nothing PATCH /api/auth/me/preferences stores pdf_open_mode; GET returns current value; existing auth tests unaffected.

<threat_model>

Trust Boundaries

Boundary Description
Client → GET /api/documents/{id}/content Untrusted doc_id; access checked via ownership or active share; bytes flow from MinIO through FastAPI only
Client → Range header Untrusted byte ranges; _parse_range() validates start/end bounds against file_size; invalid range → 416
Client → PATCH /api/auth/me/preferences Untrusted pdf_open_mode value; Pydantic Literal validates allowlist

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-04-05-01 Broken Access Control GET /api/documents/{id}/content — admin access mitigate MUST use get_regular_user dep: admin role → 403; test_content_stream_admin_403 validates; security agent checks
T-04-05-02 Information Disclosure Presigned URL exposure in proxy response mitigate presigned_get_url() NEVER called in stream_document_content; bytes fetched via get_object() directly; test_content_stream_no_presigned_url validates
T-04-05-03 Information Disclosure Range header bypass — out-of-bounds access mitigate _parse_range() validates: start <= end, start >= 0, end < file_size; → 416 on any violation
T-04-05-04 Information Disclosure Non-recipient accessing shared document via proxy mitigate Access check: doc.user_id == current_user.id OR active Share.recipient_id == current_user.id; neither → 404
T-04-05-05 Tampering pdf_open_mode mass assignment mitigate Pydantic Literal["in_app", "new_tab"] enforces strict allowlist; no other user fields touched by preferences endpoint
T-04-SC Tampering npm/pip/cargo installs accept No new packages installed in this plan
</threat_model>
1. Security test: `cd backend && python -m pytest tests/test_documents.py::test_content_stream_admin_403 tests/test_documents.py::test_content_stream_no_presigned_url -v` 2. Full suite: `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"` 3. Admin dep grep: `grep -A5 "content" backend/api/documents.py | grep "get_regular_user"` — must match 4. Presigned URL grep: `grep -n "presigned" backend/api/documents.py` — confirms absence from stream handler

<success_criteria>

  • GET /api/documents/{id}/content proxies bytes from MinIO; admin gets 403; Range → 206; no presigned URL
  • PATCH /api/auth/me/preferences stores valid pdf_open_mode; GET returns current value
  • test_content_stream_* tests turn green or remain xfail; zero FAILED
  • Full pytest suite green </success_criteria>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-05-SUMMARY.md` when done.