feat(phase-4): Sharing API (SHARE-01..05) — grant by handle, received folder, IDOR-safe revoke
- 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)
This commit is contained in:
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user