""" 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, field_validator 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 permission: str = "view" @field_validator("permission") @classmethod def validate_permission(cls, v: str) -> str: if v not in {"view", "edit"}: raise ValueError("permission must be 'view' or 'edit'") return v class SharePermissionPatch(BaseModel): permission: str @field_validator("permission") @classmethod def validate_permission(cls, v: str) -> str: if v not in {"view", "edit"}: raise ValueError("permission must be 'view' or 'edit'") return v # ── 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=body.permission, ) 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} # ── PATCH /api/shares/{share_id} ───────────────────────────────────────────── @router.patch("/{share_id}", status_code=200) async def update_share_permission( share_id: str, body: SharePermissionPatch, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_regular_user), ) -> dict: """Update the permission on an existing share (SHARE-03, D-09). T-06.2-02-01 IDOR protection: 404 on owner mismatch — mirrors revoke_share exactly. T-06.2-02-02: SharePermissionPatch validator prevents arbitrary string passthrough. """ try: sid = uuid.UUID(share_id) except ValueError: raise HTTPException(status_code=404, detail="Share not found") share = await session.get(Share, sid) if share is None or share.owner_id != current_user.id: raise HTTPException(status_code=404, detail="Share not found") share.permission = body.permission await session.commit() return {"id": str(share.id), "permission": share.permission} # ── 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()