From 964128e1433c26842114b33760e74756e4e8d4ca Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 25 May 2026 18:43:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(phase-4):=20Sharing=20API=20(SHARE-01..05)?= =?UTF-8?q?=20=E2=80=94=20grant=20by=20handle,=20received=20folder,=20IDOR?= =?UTF-8?q?-safe=20revoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/shares: grant share by recipient_handle; 400 self-share, 404 bad UUID/doc/user, 409 duplicate - GET /api/shares?document_id: list shares owned by current user for a document - GET /api/shares/received: virtual "shared with me" folder — metadata only (no extracted_text) - DELETE /api/shares/{share_id}: revoke with IDOR protection (share.owner_id != current_user.id → 404) - IntegrityError on UniqueConstraint(document_id, recipient_id) → 409 - write_audit_log called for share.granted and share.revoked (D-14) - /received defined before /{share_id} in router to prevent FastAPI path parameter conflict - No quota table touched — recipient quota never modified by share operations (T-04-04-04) --- backend/api/shares.py | 265 ++++++++++++++++++++++++++++++++++++++++++ backend/main.py | 4 + 2 files changed, 269 insertions(+) create mode 100644 backend/api/shares.py diff --git a/backend/api/shares.py b/backend/api/shares.py new file mode 100644 index 0000000..7b87639 --- /dev/null +++ b/backend/api/shares.py @@ -0,0 +1,265 @@ +""" +Sharing API for DocuVault — Phase 4, Plan 04-04. + +Implements SHARE-01 through SHARE-05: + POST /api/shares — grant share by recipient handle + GET /api/shares — list shares owned by current user for a document + GET /api/shares/received — virtual "Shared with me" folder (metadata only) + DELETE /api/shares/{share_id} — revoke share with IDOR protection + +Security invariants: + T-04-04-02: DELETE asserts share.owner_id == current_user.id → 404 on mismatch + T-04-04-03: GET /received returns metadata only — extracted_text is never included + T-04-04-04: No quota table is touched anywhere in this module + T-04-04-05: UniqueConstraint(document_id, recipient_id) → IntegrityError → 409 +""" +from __future__ import annotations + +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +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 + +router = APIRouter(prefix="/api/shares", tags=["shares"]) + + +# ── Request models ──────────────────────────────────────────────────────────── + + +class ShareCreate(BaseModel): + document_id: str + recipient_handle: str + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + + +def _ip(request: Request) -> Optional[str]: + """Extract best-effort client IP from request (behind proxy or direct).""" + return request.headers.get("X-Forwarded-For") or ( + request.client.host if request.client else None + ) + + +# ── POST /api/shares ────────────────────────────────────────────────────────── + + +@router.post("", status_code=status.HTTP_201_CREATED) +async def grant_share( + body: ShareCreate, + request: Request, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> dict: + """Grant document share to a user identified by their handle (SHARE-01, D-04). + + T-04-04-06: Only document owner can grant; 404 prevents ID enumeration. + T-04-04-01: get_regular_user ensures admins cannot invoke this endpoint. + T-04-04-05: Duplicate share → IntegrityError → 409 (no unbounded inserts). + """ + # Parse document_id as UUID (T-03-11 pattern) + try: + uid = uuid.UUID(body.document_id) + except ValueError: + raise HTTPException(status_code=404, detail="Document not found") + + # Ownership assertion — 404 prevents ID enumeration + doc = await session.get(Document, uid) + if doc is None or doc.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Document not found") + + # Recipient lookup by exact handle (D-04) + result = await session.execute( + select(User).where(User.handle == body.recipient_handle) + ) + recipient = result.scalar_one_or_none() + if recipient is None: + raise HTTPException(status_code=404, detail="User not found") + + # Self-share prevention + if recipient.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot share with yourself") + + # Create the share row + share = Share( + document_id=uid, + owner_id=current_user.id, + recipient_id=recipient.id, + permission="view", + ) + session.add(share) + + try: + await session.flush() + except IntegrityError: + await session.rollback() + raise HTTPException( + status_code=409, detail="Document already shared with this user" + ) + + # Audit log — write within same transaction (D-14) + await write_audit_log( + session=session, + event_type="share.granted", + user_id=current_user.id, + actor_id=current_user.id, + resource_id=uid, + ip_address=_ip(request), + metadata_={"recipient_id": str(recipient.id)}, + ) + + await session.commit() + + return { + "id": str(share.id), + "document_id": str(share.document_id), + "owner_id": str(share.owner_id), + "recipient_id": str(share.recipient_id), + "permission": share.permission, + "created_at": share.created_at.isoformat() if share.created_at else None, + } + + +# ── GET /api/shares ─────────────────────────────────────────────────────────── + + +@router.get("") +async def list_shares( + document_id: str = Query(...), + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> dict: + """List shares owned by current user for a specific document (SHARE-01, D-05). + + Only the document owner can list shares — 404 on mismatch or bad UUID. + """ + try: + uid = uuid.UUID(document_id) + except ValueError: + raise HTTPException(status_code=404, detail="Document not found") + + doc = await session.get(Document, uid) + if doc is None or doc.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Document not found") + + # Join Share with User to get recipient handles + stmt = ( + select(Share, User) + .join(User, User.id == Share.recipient_id) + .where(Share.document_id == uid) + ) + result = await session.execute(stmt) + rows = result.all() + + items = [] + for share, recipient in rows: + items.append( + { + "id": str(share.id), + "recipient_id": str(share.recipient_id), + "recipient_handle": recipient.handle, + "permission": share.permission, + "created_at": share.created_at.isoformat() + if share.created_at + else None, + } + ) + + return {"items": items} + + +# ── GET /api/shares/received ────────────────────────────────────────────────── +# CRITICAL: This endpoint MUST be defined BEFORE DELETE /api/shares/{share_id}. +# Defining it after would cause FastAPI to route GET /api/shares/received as +# DELETE with share_id="received" (path parameter conflict). + + +@router.get("/received") +async def list_shared_with_me( + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> dict: + """Return documents shared WITH the current user (virtual "Shared with me" folder — D-06). + + T-04-04-03: Only metadata is returned — extracted_text is never included. + T-04-04-04: No quota is modified. + Response: {items: [{id, filename, content_type, size_bytes, created_at, owner_handle}]} + """ + stmt = ( + select(Document, User) + .join(Share, Share.document_id == Document.id) + .join(User, User.id == Share.owner_id) + .where(Share.recipient_id == current_user.id) + .order_by(Document.created_at.desc()) + ) + result = await session.execute(stmt) + rows = result.all() + + items = [] + for doc, owner in rows: + # T-04-04-03: extracted_text is intentionally excluded here + items.append( + { + "id": str(doc.id), + "filename": doc.filename, + "content_type": doc.content_type, + "size_bytes": doc.size_bytes, + "created_at": doc.created_at.isoformat() if doc.created_at else None, + "owner_handle": owner.handle, + } + ) + + return {"items": items} + + +# ── DELETE /api/shares/{share_id} ───────────────────────────────────────────── + + +@router.delete("/{share_id}", status_code=status.HTTP_204_NO_CONTENT) +async def revoke_share( + share_id: str, + request: Request, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> None: + """Revoke a share. Only the share owner may revoke (SHARE-04, D-07). + + T-04-04-02 IDOR protection: asserts share.owner_id == current_user.id. + Returns 404 (not 403) on mismatch to prevent share ID enumeration. + """ + try: + sid = uuid.UUID(share_id) + except ValueError: + raise HTTPException(status_code=404, detail="Share not found") + + share = await session.get(Share, sid) + # CRITICAL IDOR check: 404 on mismatch (not 403) — prevents ID enumeration + if share is None or share.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Share not found") + + document_id = share.document_id + recipient_id = share.recipient_id + + await session.delete(share) + + # Audit log before commit (D-14 — within the same transaction) + await write_audit_log( + session=session, + event_type="share.revoked", + user_id=current_user.id, + actor_id=current_user.id, + resource_id=document_id, + ip_address=_ip(request), + metadata_={"recipient_id": str(recipient_id)}, + ) + + await session.commit() diff --git a/backend/main.py b/backend/main.py index 360feca..762e38d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -183,3 +183,7 @@ from api.folders import router as folders_router # noqa: E402 from api.folders import document_move_router as document_move_router # noqa: E402 app.include_router(folders_router) app.include_router(document_move_router) + +# Phase 4: shares router (SHARE-01..05) +from api.shares import router as shares_router # noqa: E402 +app.include_router(shares_router)