Files
kite/.planning/phases/04-folders-sharing-quotas-document-ux/04-05-PLAN.md
T
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

241 lines
14 KiB
Markdown

---
phase: 04-folders-sharing-quotas-document-ux
plan: 05
type: execute
wave: 4
depends_on:
- "04-03"
- "04-04"
files_modified:
- backend/api/documents.py
- backend/api/auth.py
autonomous: true
requirements:
- DOC-01
- DOC-02
- FOLD-04
must_haves:
truths:
- "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"
artifacts:
- path: "backend/api/documents.py"
provides: "GET /api/documents/{id}/content streaming proxy with Range header support"
- path: "backend/api/auth.py"
provides: "PATCH /api/auth/me/preferences endpoint for pdf_open_mode"
key_links:
- from: "backend/api/documents.py"
to: "backend/storage/minio_backend.py"
via: "get_storage_backend().get_object(doc.object_key) — bytes fetched directly, not via presigned URL"
pattern: "get_object"
- from: "backend/api/documents.py"
to: "backend/db/models.py"
via: "Share model for _can_access_document() recipient check"
pattern: "Share.recipient_id"
---
<objective>
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.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<interfaces>
<!-- Key interfaces the executor needs. Extracted from codebase. -->
<!-- From backend/storage/minio_backend.py — get_object signature (read the actual file to confirm):
async def get_object(self, object_key: str) -> bytes:
# wraps self._client.get_object via asyncio.to_thread; returns raw bytes
-->
<!-- Range header parse pattern (from RESEARCH.md Pattern 3):
def _parse_range(range_header: str, file_size: int) -> tuple[int, int]:
try:
h = range_header.replace("bytes=", "").split("-")
start = int(h[0]) if h[0] != "" else 0
end = int(h[1]) if h[1] != "" else file_size - 1
except (ValueError, IndexError):
raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
if start > end or start < 0 or end >= file_size:
raise HTTPException(status.HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE)
return start, end
-->
<!-- _can_access_document pattern (from RESEARCH.md Pattern 4):
async def _can_access_document(session, doc, current_user_id):
if doc.user_id == current_user_id: return True
result = await session.execute(select(Share).where(Share.document_id == doc.id, Share.recipient_id == current_user_id))
return result.scalar_one_or_none() is not None
-->
<!-- User.pdf_open_mode column (added in migration 0004):
pdf_open_mode: Mapped[str] # server_default = "in_app"; allowed: "in_app" | "new_tab"
-->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Add GET /api/documents/{id}/content streaming proxy to documents.py</name>
<files>backend/api/documents.py</files>
<read_first>
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
</read_first>
<behavior>
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).
</behavior>
<action>
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.
</action>
<verify>
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_documents.py -x -v --no-header 2>&1 | tail -30</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>Streaming proxy delivers bytes via StreamingResponse; admin blocked; Range headers supported; no presigned URL exposure.</done>
</task>
<task type="auto">
<name>Task 2: Add PATCH /api/auth/me/preferences endpoint for pdf_open_mode</name>
<files>backend/api/auth.py</files>
<read_first>
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)
</read_first>
<behavior>
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
</behavior>
<action>
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.
</action>
<verify>
<automated>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</automated>
</verify>
<acceptance_criteria>
- `/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
</acceptance_criteria>
<done>PATCH /api/auth/me/preferences stores pdf_open_mode; GET returns current value; existing auth tests unaffected.</done>
</task>
</tasks>
<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>
<verification>
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
</verification>
<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>
<output>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-05-SUMMARY.md` when done.
</output>