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:
curo1305
2026-05-25 18:43:49 +02:00
parent c6feb5faf2
commit 964128e143
2 changed files with 269 additions and 0 deletions
+265
View File
@@ -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()