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