747303246a
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>
241 lines
14 KiB
Markdown
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>
|