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

12 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 04 execute 3
04-01
04-02
04-03
backend/api/shares.py
backend/main.py
true
SHARE-01
SHARE-02
SHARE-03
SHARE-04
SHARE-05
truths artifacts key_links
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)
path provides exports
backend/api/shares.py POST /api/shares, GET /api/shares, GET /api/shares/received, DELETE /api/shares/{id}
router
from to via pattern
backend/api/shares.py backend/services/audit.py write_audit_log called after share grant and revoke write_audit_log
from to via pattern
backend/api/shares.py backend/db/models.py Share ORM model; User.handle for recipient lookup Share.owner_id|Share.recipient_id|User.handle
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.

<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 @backend/api/documents.py @backend/api/admin.py @backend/db/models.py @backend/deps/auth.py @backend/services/audit.py Task 1: Create backend/api/shares.py — full sharing API backend/api/shares.py, backend/main.py 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 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
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)`.
cd /Users/nik/Documents/Progamming/document_scanner/backend && python -m pytest tests/test_shares.py -x -v --no-header 2>&1 | tail -30 - 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 Shares API is implemented; IDOR protection on revoke confirmed; full test suite passes.

<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>
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)

<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>
Create `.planning/phases/04-folders-sharing-quotas-document-ux/04-04-SUMMARY.md` when done.