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>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
---
|
||||
phase: 04-folders-sharing-quotas-document-ux
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- "04-01"
|
||||
- "04-02"
|
||||
- "04-03"
|
||||
files_modified:
|
||||
- backend/api/shares.py
|
||||
- backend/main.py
|
||||
autonomous: true
|
||||
requirements:
|
||||
- SHARE-01
|
||||
- SHARE-02
|
||||
- SHARE-03
|
||||
- SHARE-04
|
||||
- SHARE-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "User can share a document with another user by their exact handle"
|
||||
- "GET /api/shares/received returns documents shared with the current user (virtual folder)"
|
||||
- "Recipient's quota is never modified by a share operation"
|
||||
- "Share revocation is immediate: DELETE /api/shares/{id} with owner assertion"
|
||||
- "Sharing the same document with the same user twice returns 409"
|
||||
- "Wrong-owner revocation attempt returns 404 (IDOR prevention)"
|
||||
artifacts:
|
||||
- path: "backend/api/shares.py"
|
||||
provides: "POST /api/shares, GET /api/shares, GET /api/shares/received, DELETE /api/shares/{id}"
|
||||
exports: ["router"]
|
||||
key_links:
|
||||
- from: "backend/api/shares.py"
|
||||
to: "backend/services/audit.py"
|
||||
via: "write_audit_log called after share grant and revoke"
|
||||
pattern: "write_audit_log"
|
||||
- from: "backend/api/shares.py"
|
||||
to: "backend/db/models.py"
|
||||
via: "Share ORM model; User.handle for recipient lookup"
|
||||
pattern: "Share.owner_id|Share.recipient_id|User.handle"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement the document sharing API: grant share by handle, list owned shares per document,
|
||||
list "shared with me" virtual folder, and revoke share with IDOR protection.
|
||||
|
||||
Purpose: Deliver SHARE-01 through SHARE-05 and the security invariant test for share IDOR.
|
||||
Output: backend/api/shares.py + main.py registration.
|
||||
</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
|
||||
@backend/api/documents.py
|
||||
@backend/api/admin.py
|
||||
@backend/db/models.py
|
||||
@backend/deps/auth.py
|
||||
@backend/services/audit.py
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key interfaces the executor needs. Extracted from codebase. -->
|
||||
|
||||
<!-- From backend/db/models.py — Share model (read the actual file to confirm column names):
|
||||
class Share(Base):
|
||||
__tablename__ = "shares"
|
||||
id: Mapped[uuid.UUID] # primary key
|
||||
document_id: Mapped[uuid.UUID] # FK to documents.id
|
||||
owner_id: Mapped[uuid.UUID] # FK to users.id (document owner who grants share)
|
||||
recipient_id: Mapped[uuid.UUID] # FK to users.id (recipient)
|
||||
permission: Mapped[str] # "view" (only value for Phase 4, D-07)
|
||||
created_at: Mapped[datetime]
|
||||
# UniqueConstraint on (document_id, recipient_id) — triggers IntegrityError on duplicate share
|
||||
-->
|
||||
|
||||
<!-- From backend/db/models.py — User.handle:
|
||||
handle: Mapped[str] = mapped_column(String, unique=True, nullable=False)
|
||||
-->
|
||||
|
||||
<!-- Handle lookup pattern (from PATTERNS.md):
|
||||
result = await session.execute(select(User).where(User.handle == body.recipient_handle))
|
||||
recipient = result.scalar_one_or_none()
|
||||
if recipient is None:
|
||||
raise HTTPException(404, "User not found")
|
||||
-->
|
||||
|
||||
<!-- Share IDOR assertion (from RESEARCH.md Pitfall 4 — CRITICAL):
|
||||
share = await session.get(Share, share_id)
|
||||
if share is None or share.owner_id != current_user.id:
|
||||
raise HTTPException(404, "Share not found")
|
||||
-->
|
||||
|
||||
<!-- "Shared with me" query (from PATTERNS.md):
|
||||
stmt = (
|
||||
select(Document)
|
||||
.join(Share, Share.document_id == Document.id)
|
||||
.where(Share.recipient_id == current_user.id)
|
||||
.order_by(Document.created_at.desc())
|
||||
)
|
||||
-->
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create backend/api/shares.py — full sharing API</name>
|
||||
<files>backend/api/shares.py, backend/main.py</files>
|
||||
<read_first>
|
||||
backend/db/models.py — read the Share class definition fully; confirm column names (document_id, owner_id, recipient_id, permission); check for UniqueConstraint on (document_id, recipient_id); confirm User.handle column name
|
||||
backend/api/documents.py — read the UUID parse pattern (try: uid = uuid.UUID(doc_id) except ValueError: raise HTTPException(404)) and the ownership assertion pattern to replicate exactly
|
||||
backend/api/admin.py — read lines 140-170 for the handle/user lookup pattern used in admin endpoints
|
||||
backend/services/audit.py — verify the write_audit_log signature (already created in plan 04-03)
|
||||
backend/main.py — read the include_router section; note current router list to add shares_router
|
||||
</read_first>
|
||||
<behavior>
|
||||
POST /api/shares:
|
||||
- Body: {document_id: str, recipient_handle: str}
|
||||
- Auth: get_regular_user
|
||||
- Parse document_id as UUID → 404 on invalid
|
||||
- Assert document.user_id == current_user.id → 404 (per D-16 ownership rule)
|
||||
- Look up User by recipient_handle (exact match) → 404 "User not found" if absent (D-04)
|
||||
- Prevent self-share: if recipient.id == current_user.id → 400 "Cannot share with yourself"
|
||||
- Create Share(document_id=uid, owner_id=current_user.id, recipient_id=recipient.id, permission="view")
|
||||
- Catch IntegrityError → 409 "Document already shared with this user"
|
||||
- Call write_audit_log: event_type="share.granted", resource_id=uid, metadata_={"recipient_id": str(recipient.id)}
|
||||
- Return 201 with share JSON {id, document_id, owner_id, recipient_id, permission, created_at}
|
||||
|
||||
GET /api/shares?document_id={id} (list shares owned by current user for a document):
|
||||
- Auth: get_regular_user
|
||||
- Assert document.user_id == current_user.id → 404
|
||||
- Return list of shares for this document: [{id, recipient_id, recipient_handle, permission, created_at}]
|
||||
- Join Share with User on Share.recipient_id = User.id to get recipient handle
|
||||
|
||||
GET /api/shares/received (virtual "Shared with me" folder — D-06):
|
||||
- Auth: get_regular_user
|
||||
- Return documents shared WITH current_user (Share.recipient_id == current_user.id)
|
||||
- Response: [{doc metadata fields}, owner_handle] — no quota impact, no share_count on this view
|
||||
- Does NOT return extracted_text (Pitfall 7 — shared docs list shows metadata only)
|
||||
- Returns: {items: [{id, filename, content_type, size_bytes, created_at, owner_handle}]}
|
||||
|
||||
DELETE /api/shares/{share_id}:
|
||||
- Auth: get_regular_user
|
||||
- Parse share_id as UUID → 404 on invalid
|
||||
- CRITICAL: assert share.owner_id == current_user.id → 404 "Share not found" if mismatch (Pitfall 4, IDOR)
|
||||
- Delete share; commit
|
||||
- Call write_audit_log: event_type="share.revoked", resource_id=share.document_id, metadata_={"recipient_id": str(share.recipient_id)}
|
||||
- Return 204
|
||||
</behavior>
|
||||
<action>
|
||||
Create backend/api/shares.py. APIRouter(prefix="/api/shares", tags=["shares"]).
|
||||
|
||||
Imports: `from __future__ import annotations`, `import uuid`, `from typing import Optional`, `from fastapi import APIRouter, Depends, HTTPException, Query, Request`, `from pydantic import BaseModel`, `from sqlalchemy import select`, `from sqlalchemy.exc import IntegrityError`, `from sqlalchemy.ext.asyncio import AsyncSession`, `from db.models import Document, Share, User`, `from deps.auth import get_regular_user`, `from deps.db import get_db`, `from services.audit import write_audit_log`.
|
||||
|
||||
Pydantic request model: ShareCreate(document_id: str, recipient_handle: str).
|
||||
|
||||
Implement all four endpoints in order: POST /api/shares, GET /api/shares, GET /api/shares/received, DELETE /api/shares/{share_id}.
|
||||
|
||||
The GET /api/shares/received endpoint MUST be defined BEFORE DELETE /api/shares/{share_id} in the router — otherwise FastAPI will route GET /api/shares/received as DELETE with share_id="received" (path parameter conflict).
|
||||
|
||||
CRITICAL security requirement: DELETE /api/shares/{share_id} MUST check `share.owner_id != current_user.id` → 404 (not merely that the share exists). This is the IDOR test that the security agent will verify.
|
||||
|
||||
ip_address extraction: `request.headers.get("X-Forwarded-For") or (request.client.host if request.client else None)`.
|
||||
|
||||
After creating shares.py, modify backend/main.py: add `from api.shares import router as shares_router` and `app.include_router(shares_router)`.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_shares.py -x -v --no-header 2>&1 | tail -30</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- backend/api/shares.py exists with all four endpoint functions
|
||||
- GET /api/shares/received is defined BEFORE DELETE /api/shares/{share_id} in the file (grep line numbers confirm ordering)
|
||||
- DELETE /api/shares/{share_id} checks `share.owner_id != current_user.id` → 404 (grep: `share.owner_id != current_user.id` or `share.owner_id == current_user.id` inverted with raise)
|
||||
- IntegrityError → 409 for duplicate share (grep: `IntegrityError` and `409` in shares.py)
|
||||
- write_audit_log called for share.granted and share.revoked (grep: `write_audit_log` appears at least twice)
|
||||
- GET /api/shares/received response does NOT include extracted_text field (grep: `extracted_text` absent from the received endpoint's return dict)
|
||||
- `python -c "from api.shares import router"` exits 0
|
||||
- test_share_revoke_wrong_owner_404 turns green (xpass) or remains xfail — not FAILED
|
||||
- `cd backend && python -m pytest tests/ -x --no-header 2>&1 | grep -E "^FAILED"` returns nothing
|
||||
</acceptance_criteria>
|
||||
<done>Shares API is implemented; IDOR protection on revoke confirmed; full test suite passes.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Client → POST /api/shares | Untrusted document_id and recipient_handle; document ownership asserted; handle is exact-match lookup only |
|
||||
| Client → DELETE /api/shares/{id} | Untrusted share_id; ownership asserted via share.owner_id == current_user.id |
|
||||
| Client → GET /api/shares/received | Returns only metadata, never extracted_text; scoped to current_user as recipient |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-04-04-01 | Elevation of Privilege | POST /api/shares | mitigate | get_regular_user dep: admin cannot share documents |
|
||||
| T-04-04-02 | Information Disclosure | Share IDOR — DELETE /api/shares/{id} | mitigate | Ownership assertion: share.owner_id == current_user.id → 404 if mismatch (not 403 — prevents ID enumeration); test_share_revoke_wrong_owner_404 validates this |
|
||||
| T-04-04-03 | Information Disclosure | GET /api/shares/received leaking extracted_text | mitigate | Received endpoint returns metadata only: id, filename, content_type, size_bytes, created_at, owner_handle — extracted_text is explicitly excluded |
|
||||
| T-04-04-04 | Information Disclosure | Recipient quota modified by share | mitigate | Share creation MUST NOT touch quotas table; no quota UPDATE in shares.py |
|
||||
| T-04-04-05 | Denial of Service | Duplicate share flooding | mitigate | UniqueConstraint(document_id, recipient_id) → IntegrityError → 409; no unbounded inserts |
|
||||
| T-04-04-06 | Information Disclosure | Share reveals document existence to non-recipients | mitigate | Ownership assertion on POST /api/shares: only document owner can grant; ID enumeration blocked by 404 |
|
||||
| T-04-SC | Tampering | npm/pip/cargo installs | accept | No new packages installed in this plan |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
1. IDOR test: `cd backend && python -m pytest tests/test_shares.py::test_share_revoke_wrong_owner_404 -v`
|
||||
2. Full suite: `cd backend && python -m pytest tests/ -v --no-header 2>&1 | grep -E "FAILED|ERROR"`
|
||||
3. Route order grep: `grep -n "def list_shared_with_me\|def delete_share\|received\|share_id" backend/api/shares.py`
|
||||
4. extracted_text absence: `grep -n "extracted_text" backend/api/shares.py` — expect empty (no results)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All four share endpoints implement the behavior contract from CONTEXT.md D-04..D-07
|
||||
- IDOR protection: DELETE /api/shares/{id} with wrong owner returns 404 (not 200/204)
|
||||
- test_shares.py 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-04-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user