Refactor backend and frontend cleanup paths
This commit is contained in:
@@ -11,13 +11,9 @@ from deps.auth import get_current_admin
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from api.admin.shared import _user_to_dict
|
||||
|
||||
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
class QuotaUpdate(BaseModel):
|
||||
limit_bytes: int
|
||||
|
||||
@@ -28,9 +24,6 @@ class QuotaUpdate(BaseModel):
|
||||
raise ValueError("limit_bytes must be greater than 0")
|
||||
return v
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/users/{user_id}/quota")
|
||||
async def get_user_quota(
|
||||
user_id: uuid.UUID,
|
||||
|
||||
+16
-45
@@ -21,12 +21,8 @@ from api.admin.shared import _user_to_dict
|
||||
|
||||
router = APIRouter() # NO prefix — parent __init__.py carries /api/admin (D-04)
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
_DEFAULT_QUOTA_BYTES = 104857600
|
||||
|
||||
_DEFAULT_QUOTA_BYTES = 104857600 # 100 MB free-tier default (D-06)
|
||||
|
||||
|
||||
# ── Request models ────────────────────────────────────────────────────────────
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
handle: str
|
||||
@@ -51,20 +47,21 @@ class UserAiConfigUpdate(BaseModel):
|
||||
|
||||
|
||||
class SystemTopicCreate(BaseModel):
|
||||
"""Request model for admin system topic creation (D-09)."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
color: str = "#6366f1"
|
||||
|
||||
|
||||
class UserDeleteConfirm(BaseModel):
|
||||
"""Admin password confirmation required before hard-deleting a user (ADMIN-02, T-05-11-01)."""
|
||||
|
||||
admin_password: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
async def _get_user_or_404(session: AsyncSession, user_id: uuid.UUID) -> User:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
@@ -111,7 +108,7 @@ async def create_user(
|
||||
role=body.role,
|
||||
is_active=True,
|
||||
totp_enabled=False,
|
||||
password_must_change=True, # ADMIN-01: force password change on first login
|
||||
password_must_change=True,
|
||||
)
|
||||
session.add(new_user)
|
||||
|
||||
@@ -121,8 +118,7 @@ async def create_user(
|
||||
used_bytes=0,
|
||||
)
|
||||
session.add(quota)
|
||||
await session.flush() # persist User + Quota before audit_log FK references them
|
||||
# D-13: admin user created event
|
||||
await session.flush()
|
||||
_ip_addr = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -151,11 +147,8 @@ async def update_user_status(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
# Guard: cannot deactivate the only remaining active admin (T-02-29)
|
||||
if not body.is_active and user.role == "admin":
|
||||
count_result = await session.execute(
|
||||
select(func.count(User.id)).where(
|
||||
@@ -174,9 +167,7 @@ async def update_user_status(
|
||||
user.is_active = body.is_active
|
||||
|
||||
if not body.is_active:
|
||||
# Revoke all refresh tokens on deactivation
|
||||
await revoke_all_refresh_tokens(session, user.id)
|
||||
# Revoke any pre-deactivation access tokens still within their TTL (T-7.2-01)
|
||||
await request.app.state.redis.set(
|
||||
f"user_nbf:{user.id}",
|
||||
int(time.time()),
|
||||
@@ -185,7 +176,6 @@ async def update_user_status(
|
||||
|
||||
session.add(user)
|
||||
|
||||
# D-13: user deactivated/activated event
|
||||
_event = "admin.user_deactivated" if not body.is_active else "admin.user_activated"
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -211,9 +201,7 @@ async def initiate_password_reset(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
from services.auth import create_password_reset_token # noqa: PLC0415
|
||||
from config import settings as _settings # noqa: PLC0415
|
||||
@@ -236,16 +224,13 @@ async def update_ai_config(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
_ip_addr = get_client_ip(request)
|
||||
user.ai_provider = body.ai_provider
|
||||
user.ai_model = body.ai_model
|
||||
session.add(user)
|
||||
|
||||
# D-13: AI provider assigned event
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.ai_provider_assigned",
|
||||
@@ -273,19 +258,14 @@ async def delete_user(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> None:
|
||||
# T-05-11-01: Verify admin password before performing any destructive action.
|
||||
# Fail fast — no DB reads for the target user until the admin is confirmed.
|
||||
if not verify_password(body.admin_password, _admin.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid admin password",
|
||||
)
|
||||
|
||||
user = await session.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
user = await _get_user_or_404(session, user_id)
|
||||
|
||||
# T-04-07-04: Cannot delete admin accounts
|
||||
if user.role == "admin":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
@@ -294,15 +274,11 @@ async def delete_user(
|
||||
|
||||
_ip_addr = get_client_ip(request)
|
||||
|
||||
# SEC-09 (cloud): purge cloud-stored documents and credentials BEFORE DB delete.
|
||||
# Must run before MinIO cleanup so that credentials are still available to build
|
||||
# the cloud backend instances for delete_object calls.
|
||||
cloud_conns_result = await session.execute(
|
||||
select(CloudConnection).where(CloudConnection.user_id == user_id)
|
||||
)
|
||||
cloud_conns = cloud_conns_result.scalars().all()
|
||||
for conn in cloud_conns:
|
||||
# Delete cloud objects stored in this provider for this user
|
||||
cloud_docs_result = await session.execute(
|
||||
select(Document).where(
|
||||
Document.user_id == user_id,
|
||||
@@ -314,12 +290,10 @@ async def delete_user(
|
||||
backend = await get_storage_backend_for_document(doc, user, session)
|
||||
await backend.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # Best-effort cloud object cleanup; deletion proceeds regardless
|
||||
# Purge the credentials row (FK cascade would also remove it, but explicit
|
||||
# deletion here guarantees credentials_enc is gone before commit — SEC-09)
|
||||
pass
|
||||
await session.delete(conn)
|
||||
if cloud_conns:
|
||||
await session.flush() # Flush connection deletes before user delete
|
||||
await session.flush()
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="cloud.credentials_purged",
|
||||
@@ -330,7 +304,6 @@ async def delete_user(
|
||||
metadata_={"providers": [c.provider for c in cloud_conns]},
|
||||
)
|
||||
|
||||
# SEC-09 (minio): collect all user documents and delete MinIO objects BEFORE DB delete
|
||||
docs_result = await session.execute(
|
||||
select(Document).where(Document.user_id == user_id)
|
||||
)
|
||||
@@ -341,9 +314,8 @@ async def delete_user(
|
||||
try:
|
||||
await storage.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # Best-effort MinIO cleanup; DB deletion proceeds regardless
|
||||
pass
|
||||
|
||||
# D-13: audit log BEFORE deleting the user row (user FK still valid at flush time)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="admin.user_deleted",
|
||||
@@ -354,7 +326,6 @@ async def delete_user(
|
||||
)
|
||||
await session.flush()
|
||||
|
||||
# Delete user record (CASCADE removes quota, documents, refresh_tokens, etc.)
|
||||
await session.delete(user)
|
||||
await session.commit()
|
||||
|
||||
|
||||
+120
-218
@@ -1,23 +1,4 @@
|
||||
"""
|
||||
Admin audit log API endpoints for DocuVault.
|
||||
|
||||
All handlers require get_current_admin (ADMIN-06, SEC-07) — regular users
|
||||
receive 403 Forbidden.
|
||||
|
||||
Implements:
|
||||
GET /api/admin/audit-log — paginated, filtered audit log viewer
|
||||
GET /api/admin/audit-log/export — CSV streaming export with same filters
|
||||
GET /api/admin/audit-log/daily-exports — list available Celery daily export files
|
||||
GET /api/admin/audit-log/daily-exports/{date} — stream a specific daily export CSV
|
||||
|
||||
Security invariants:
|
||||
- All endpoints use Depends(get_current_admin) — verified by grep
|
||||
- _audit_to_dict() is a pure whitelist: no filename, extracted_text,
|
||||
password_hash, or credentials_enc can appear in responses (ADMIN-06, D-15)
|
||||
- CSV export uses the same _audit_to_dict_with_handles() helper as the JSON viewer
|
||||
- Date path parameter validated against YYYY-MM-DD regex before MinIO key
|
||||
construction — prevents path traversal (T-06.2-04-01, Pitfall 6)
|
||||
"""
|
||||
"""Admin audit log API endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
@@ -44,17 +25,22 @@ from storage.minio_backend import MinIOBackend
|
||||
router = APIRouter(prefix="/api/admin", tags=["audit"])
|
||||
|
||||
_VALID_EVENT_PREFIXES = frozenset({"auth", "document", "folder", "share", "admin", "cloud"})
|
||||
_CSV_FIELDS = [
|
||||
"id",
|
||||
"event_type",
|
||||
"user_id",
|
||||
"actor_id",
|
||||
"user_handle",
|
||||
"actor_handle",
|
||||
"user_email",
|
||||
"resource_id",
|
||||
"ip_address",
|
||||
"metadata_",
|
||||
"created_at",
|
||||
]
|
||||
|
||||
|
||||
# ── Safe response helpers ─────────────────────────────────────────────────────
|
||||
|
||||
def _audit_to_dict(entry: AuditLog) -> dict:
|
||||
"""Safe audit log serializer — never includes filename, extracted_text, or
|
||||
document content (ADMIN-06, D-15).
|
||||
|
||||
Whitelist: id, event_type, user_id, actor_id, resource_id, ip_address,
|
||||
metadata_, created_at. No other keys are possible.
|
||||
"""
|
||||
def _audit_base_fields(entry: AuditLog) -> dict:
|
||||
return {
|
||||
"id": entry.id,
|
||||
"event_type": entry.event_type,
|
||||
@@ -67,38 +53,49 @@ def _audit_to_dict(entry: AuditLog) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _audit_to_dict(entry: AuditLog) -> dict:
|
||||
"""Whitelisted audit serializer shared with the daily export task."""
|
||||
return _audit_base_fields(entry)
|
||||
|
||||
|
||||
def _audit_to_dict_with_handles(
|
||||
entry: AuditLog,
|
||||
user_handle: Optional[str],
|
||||
actor_handle: Optional[str],
|
||||
user_email: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Extended audit log serializer that includes user_handle, actor_handle, and user_email.
|
||||
|
||||
Returns the same fields as _audit_to_dict() plus:
|
||||
- user_handle: str | None (the handle of the user who owns the entry)
|
||||
- actor_handle: str | None (the handle of the actor who performed the event)
|
||||
- user_email: str | None (the email of the user who owns the entry)
|
||||
|
||||
Used by both the JSON viewer and CSV export endpoints (Pitfall 7 — both
|
||||
endpoints must use the enriched function).
|
||||
"""
|
||||
return {
|
||||
"id": entry.id,
|
||||
"event_type": entry.event_type,
|
||||
"user_id": str(entry.user_id) if entry.user_id else None,
|
||||
"actor_id": str(entry.actor_id) if entry.actor_id else None,
|
||||
data = _audit_base_fields(entry)
|
||||
data.update({
|
||||
"user_handle": user_handle or None,
|
||||
"actor_handle": actor_handle or None,
|
||||
"user_email": user_email or None,
|
||||
"resource_id": str(entry.resource_id) if entry.resource_id else None,
|
||||
"ip_address": str(entry.ip_address) if entry.ip_address else None,
|
||||
"metadata_": entry.metadata_,
|
||||
"created_at": entry.created_at.isoformat(),
|
||||
}
|
||||
})
|
||||
return data
|
||||
|
||||
|
||||
# ── Query builder helpers ─────────────────────────────────────────────────────
|
||||
def _validate_event_type(event_type: Optional[str]) -> None:
|
||||
if event_type is not None and event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
|
||||
|
||||
def _apply_audit_filters(
|
||||
query,
|
||||
start: Optional[datetime],
|
||||
end: Optional[datetime],
|
||||
user_uuid: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
):
|
||||
_validate_event_type(event_type)
|
||||
if start is not None:
|
||||
query = query.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
query = query.where(AuditLog.created_at <= end)
|
||||
if user_uuid is not None:
|
||||
query = query.where(AuditLog.user_id == user_uuid)
|
||||
if event_type is not None:
|
||||
query = query.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
return query
|
||||
|
||||
|
||||
def _build_filtered_query(
|
||||
start: Optional[datetime],
|
||||
@@ -106,27 +103,13 @@ def _build_filtered_query(
|
||||
user_id: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
):
|
||||
"""Return a SQLAlchemy Select for AuditLog with the given filters applied.
|
||||
|
||||
Shared by count queries in both the paginated viewer and the CSV export
|
||||
endpoints to ensure consistent filter semantics.
|
||||
|
||||
NOTE: This function selects AuditLog only (no JOIN). It is used for COUNT
|
||||
queries to avoid the subquery ambiguity that arises with multi-column JOINs
|
||||
(Pitfall 4). Data queries use _build_filtered_query_with_handles() instead.
|
||||
"""
|
||||
q = select(AuditLog).order_by(AuditLog.created_at.desc())
|
||||
if start is not None:
|
||||
q = q.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
q = q.where(AuditLog.created_at <= end)
|
||||
if user_id is not None:
|
||||
q = q.where(AuditLog.user_id == user_id)
|
||||
if event_type is not None:
|
||||
if event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
return q
|
||||
return _apply_audit_filters(
|
||||
select(AuditLog).order_by(AuditLog.created_at.desc()),
|
||||
start,
|
||||
end,
|
||||
user_id,
|
||||
event_type,
|
||||
)
|
||||
|
||||
|
||||
def _build_filtered_query_with_handles(
|
||||
@@ -135,15 +118,6 @@ def _build_filtered_query_with_handles(
|
||||
user_uuid: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
):
|
||||
"""Return a multi-column Select that joins User twice for handle enrichment.
|
||||
|
||||
Yields (AuditLog, user_handle: str|None, actor_handle: str|None) tuples.
|
||||
Uses SQLAlchemy aliased() to join User twice without collision:
|
||||
- UserSubject: resolves user_id FK → handle
|
||||
- UserActor: resolves actor_id FK → handle
|
||||
|
||||
outerjoin() ensures entries with NULL user_id or actor_id are still returned.
|
||||
"""
|
||||
UserSubject = aliased(User)
|
||||
UserActor = aliased(User)
|
||||
|
||||
@@ -158,37 +132,68 @@ def _build_filtered_query_with_handles(
|
||||
.outerjoin(UserActor, UserActor.id == AuditLog.actor_id)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
)
|
||||
if start is not None:
|
||||
q = q.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
q = q.where(AuditLog.created_at <= end)
|
||||
if user_uuid is not None:
|
||||
q = q.where(AuditLog.user_id == user_uuid)
|
||||
if event_type is not None:
|
||||
if event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
q = q.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
return q
|
||||
return _apply_audit_filters(q, start, end, user_uuid, event_type)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
# IMPORTANT: daily-export routes are registered BEFORE /audit-log and
|
||||
# /audit-log/export so FastAPI matches the more specific paths first.
|
||||
async def _resolve_user_uuid(session: AsyncSession, user_handle: Optional[str]) -> uuid.UUID | None:
|
||||
if not user_handle:
|
||||
return None
|
||||
|
||||
result = await session.execute(select(User.id).where(User.handle == user_handle))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _count_audit_log(
|
||||
session: AsyncSession,
|
||||
start: Optional[datetime],
|
||||
end: Optional[datetime],
|
||||
user_uuid: Optional[uuid.UUID],
|
||||
event_type: Optional[str],
|
||||
) -> int:
|
||||
count_q = _apply_audit_filters(
|
||||
select(func.count(AuditLog.id)).where(True),
|
||||
start,
|
||||
end,
|
||||
user_uuid,
|
||||
event_type,
|
||||
)
|
||||
result = await session.execute(count_q)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
def _audit_rows_to_dicts(rows) -> list[dict]:
|
||||
return [_audit_to_dict_with_handles(row[0], row[1], row[2], row[3]) for row in rows]
|
||||
|
||||
|
||||
def _csv_response(csv_text: str, filename: str = "audit-export.csv") -> StreamingResponse:
|
||||
return StreamingResponse(
|
||||
iter([csv_text]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"},
|
||||
)
|
||||
|
||||
|
||||
def _empty_csv_response() -> StreamingResponse:
|
||||
output = io.StringIO()
|
||||
csv.DictWriter(output, fieldnames=_CSV_FIELDS).writeheader()
|
||||
return _csv_response(output.getvalue())
|
||||
|
||||
|
||||
def _audit_csv_response(rows) -> StreamingResponse:
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=_CSV_FIELDS)
|
||||
writer.writeheader()
|
||||
for record in _audit_rows_to_dicts(rows):
|
||||
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
|
||||
writer.writerow(record)
|
||||
return _csv_response(output.getvalue())
|
||||
|
||||
|
||||
@router.get("/audit-log/daily-exports")
|
||||
async def list_daily_exports(
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""List available Celery daily audit export files from MinIO (D-15).
|
||||
|
||||
Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] }
|
||||
Items are sorted descending by date.
|
||||
|
||||
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
|
||||
Event loop safety: list_objects() is synchronous; wrapped in asyncio.to_thread
|
||||
to avoid blocking the event loop (T-06.2-04-05).
|
||||
"""
|
||||
"""List available Celery daily audit export files from MinIO."""
|
||||
backend = get_storage_backend()
|
||||
if not isinstance(backend, MinIOBackend):
|
||||
return {"items": []}
|
||||
@@ -215,15 +220,7 @@ async def download_daily_export(
|
||||
date: str,
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
"""Stream a specific Celery daily audit export file from MinIO (D-16).
|
||||
|
||||
The date path parameter is validated against YYYY-MM-DD regex before
|
||||
MinIO key construction to prevent path traversal (T-06.2-04-01, Pitfall 6).
|
||||
|
||||
Returns: StreamingResponse with Content-Type: text/csv.
|
||||
|
||||
Security: requires get_current_admin — regular users receive 403 (T-06.2-04-02).
|
||||
"""
|
||||
"""Stream a specific Celery daily audit export file from MinIO."""
|
||||
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
|
||||
raise HTTPException(status_code=404, detail="Invalid date format")
|
||||
|
||||
@@ -263,56 +260,18 @@ async def list_audit_log(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> dict:
|
||||
"""Return paginated, filtered audit log entries (ADMIN-06).
|
||||
"""Return paginated, filtered audit log entries."""
|
||||
user_uuid = await _resolve_user_uuid(session, user_handle)
|
||||
if user_handle and user_uuid is None:
|
||||
return {"items": [], "total": 0, "page": page, "per_page": per_page}
|
||||
|
||||
Response: { items: [...], total: int, page: int, per_page: int }
|
||||
Each item includes user_handle and actor_handle alongside UUID fields (D-11).
|
||||
Entries never contain filename, extracted_text, or document content (D-15).
|
||||
|
||||
user_handle filter: accepts a plain string handle and resolves to UUID
|
||||
internally. Returns empty results (not 422) for unknown handles (D-12).
|
||||
"""
|
||||
# Handle-to-UUID resolution (D-12, Pattern 4)
|
||||
user_uuid: Optional[uuid.UUID] = None
|
||||
if user_handle:
|
||||
handle_result = await session.execute(
|
||||
select(User.id).where(User.handle == user_handle)
|
||||
)
|
||||
uid = handle_result.scalar_one_or_none()
|
||||
if uid is None:
|
||||
# No user with that handle — return empty results (D-12)
|
||||
return {"items": [], "total": 0, "page": page, "per_page": per_page}
|
||||
user_uuid = uid
|
||||
|
||||
# Count query: use the plain _build_filtered_query (no JOIN) to avoid
|
||||
# COUNT ambiguity on multi-column subqueries (Pitfall 4)
|
||||
count_q = select(func.count(AuditLog.id)).where(True)
|
||||
if start is not None:
|
||||
count_q = count_q.where(AuditLog.created_at >= start)
|
||||
if end is not None:
|
||||
count_q = count_q.where(AuditLog.created_at <= end)
|
||||
if user_uuid is not None:
|
||||
count_q = count_q.where(AuditLog.user_id == user_uuid)
|
||||
if event_type is not None:
|
||||
if event_type not in _VALID_EVENT_PREFIXES:
|
||||
raise HTTPException(status_code=422, detail="Invalid event_type prefix")
|
||||
count_q = count_q.where(AuditLog.event_type.like(f"{event_type}.%"))
|
||||
count_result = await session.execute(count_q)
|
||||
total = count_result.scalar_one()
|
||||
|
||||
# Data query: use enriched JOIN for handle fields
|
||||
total = await _count_audit_log(session, start, end, user_uuid, event_type)
|
||||
data_q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
|
||||
data_q = data_q.limit(per_page).offset((page - 1) * per_page)
|
||||
result = await session.execute(data_q)
|
||||
rows = result.all()
|
||||
|
||||
items = []
|
||||
for row in rows:
|
||||
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
|
||||
items.append(_audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val))
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"items": _audit_rows_to_dicts(result.all()),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
@@ -329,68 +288,11 @@ async def export_audit_log(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(get_current_admin),
|
||||
) -> StreamingResponse:
|
||||
"""Stream a CSV export of filtered audit log entries (ADMIN-06).
|
||||
"""Stream a CSV export of filtered audit log entries."""
|
||||
user_uuid = await _resolve_user_uuid(session, user_handle)
|
||||
if user_handle and user_uuid is None:
|
||||
return _empty_csv_response()
|
||||
|
||||
Uses the same _audit_to_dict_with_handles() whitelist as the JSON viewer —
|
||||
includes user_handle and actor_handle; no filename, extracted_text, or
|
||||
document content appears in the export (D-15, T-04-06-02, Pitfall 7).
|
||||
|
||||
Returns StreamingResponse with Content-Disposition: attachment; filename=audit-export.csv.
|
||||
|
||||
user_handle filter: same handle-to-UUID resolution as the viewer (D-12).
|
||||
"""
|
||||
# Handle-to-UUID resolution (D-12) — same logic as list_audit_log
|
||||
user_uuid: Optional[uuid.UUID] = None
|
||||
if user_handle:
|
||||
handle_result = await session.execute(
|
||||
select(User.id).where(User.handle == user_handle)
|
||||
)
|
||||
uid = handle_result.scalar_one_or_none()
|
||||
if uid is None:
|
||||
# Unknown handle — return empty CSV
|
||||
empty_output = io.StringIO()
|
||||
fields = [
|
||||
"id", "event_type", "user_id", "actor_id", "user_handle", "actor_handle",
|
||||
"user_email", "resource_id", "ip_address", "metadata_", "created_at",
|
||||
]
|
||||
writer = csv.DictWriter(empty_output, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
return StreamingResponse(
|
||||
iter([empty_output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
|
||||
)
|
||||
user_uuid = uid
|
||||
|
||||
# Data query with handle enrichment (Pitfall 7 — export must use enriched function)
|
||||
q = _build_filtered_query_with_handles(start, end, user_uuid, event_type)
|
||||
result = await session.execute(q)
|
||||
rows = result.all()
|
||||
|
||||
fields = [
|
||||
"id",
|
||||
"event_type",
|
||||
"user_id",
|
||||
"actor_id",
|
||||
"user_handle",
|
||||
"actor_handle",
|
||||
"user_email",
|
||||
"resource_id",
|
||||
"ip_address",
|
||||
"metadata_",
|
||||
"created_at",
|
||||
]
|
||||
output = io.StringIO()
|
||||
writer = csv.DictWriter(output, fieldnames=fields)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
entry, user_handle_val, actor_handle_val, user_email_val = row[0], row[1], row[2], row[3]
|
||||
record = _audit_to_dict_with_handles(entry, user_handle_val, actor_handle_val, user_email_val)
|
||||
record["metadata_"] = json.dumps(record["metadata_"]) if record["metadata_"] is not None else ""
|
||||
writer.writerow(record)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=audit-export.csv"},
|
||||
)
|
||||
return _audit_csv_response(result.all())
|
||||
|
||||
+278
-593
File diff suppressed because it is too large
Load Diff
@@ -15,14 +15,12 @@ Security:
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Share, User
|
||||
from db.models import User
|
||||
from api.documents.shared import get_accessible_document
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from services.rate_limiting import account_limiter
|
||||
@@ -31,15 +29,12 @@ from storage.exceptions import CloudConnectionError
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Range header parsing helper ───────────────────────────────────────────────
|
||||
|
||||
def _parse_range(range_header: str, file_size: int) -> tuple:
|
||||
"""Parse a 'bytes=X-Y' Range header and return (start, end).
|
||||
|
||||
Returns (start, end) where both are inclusive byte offsets.
|
||||
Raises HTTP 416 on any invalid or out-of-bounds range.
|
||||
|
||||
T-04-05-03: validates start <= end, start >= 0, end < file_size.
|
||||
"""
|
||||
try:
|
||||
h = range_header.replace("bytes=", "").split("-")
|
||||
@@ -52,8 +47,6 @@ def _parse_range(range_header: str, file_size: int) -> tuple:
|
||||
return start, end
|
||||
|
||||
|
||||
# ── GET /api/documents/{doc_id}/content ──────────────────────────────────────
|
||||
|
||||
@router.get("/{doc_id}/content")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def stream_document_content(
|
||||
@@ -63,26 +56,7 @@ async def stream_document_content(
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Access control: owner OR share recipient (T-04-05-04)
|
||||
if doc.user_id != current_user.id:
|
||||
result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == doc.id,
|
||||
Share.recipient_id == current_user.id,
|
||||
)
|
||||
)
|
||||
share = result.scalar_one_or_none()
|
||||
if share is None:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
doc, _is_recipient = await get_accessible_document(session, doc_id, current_user.id)
|
||||
|
||||
try:
|
||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
||||
|
||||
+39
-153
@@ -1,22 +1,4 @@
|
||||
"""Document CRUD endpoints — list, get, patch, delete, and re-classify.
|
||||
|
||||
Endpoints:
|
||||
GET "" — list documents with sort, folder filter, and FTS (list_documents)
|
||||
GET /{doc_id} — get document metadata (get_document)
|
||||
PATCH /{doc_id} — update filename and/or folder_id (patch_document)
|
||||
DELETE /{doc_id} — delete document, decrement quota atomically (delete_document)
|
||||
POST /{doc_id}/classify — re-queue Celery classification (classify_document, D-08)
|
||||
|
||||
Sub-router carries NO prefix — prefix="/api/documents" lives in __init__.py (D-04).
|
||||
|
||||
Security:
|
||||
T-03-11: ownership assertion on every resource endpoint — cross-user access returns 404.
|
||||
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
|
||||
T-05-09-02: response uses storage.get_metadata() whitelist — no credentials_enc, no password_hash.
|
||||
T-06.2-03-01: cloud documents skip MinIO quota decrement.
|
||||
T-06.2-03-02: cloud delete failure returns {success: false, cloud_delete_failed: true} (HTTP 200).
|
||||
T-07-10: classify endpoint — IDOR returns 404 per ownership assertion.
|
||||
"""
|
||||
"""Document CRUD endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -41,14 +23,31 @@ from services.rate_limiting import account_limiter
|
||||
from storage import get_storage_backend_for_document as _get_storage_backend_for_document
|
||||
from tasks.document_tasks import extract_and_classify
|
||||
|
||||
from api.documents.shared import DocumentPatch, _CLOUD_PROVIDERS
|
||||
from api.documents.shared import DocumentPatch, get_accessible_document, get_owned_document
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── GET /api/documents ────────────────────────────────────────────────────────
|
||||
# Route registered on parent router in __init__.py (FastAPI 0.100+ disallows
|
||||
# include_router when both the include prefix and route path are empty strings).
|
||||
async def _shared_document_ids(session: AsyncSession, user_id: uuid.UUID) -> set[uuid.UUID]:
|
||||
result = await session.execute(select(Share.document_id).where(Share.owner_id == user_id))
|
||||
return {row[0] for row in result.fetchall()}
|
||||
|
||||
|
||||
async def _decorate_shared_flags(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
items: list[dict],
|
||||
) -> list[dict]:
|
||||
shared_ids = await _shared_document_ids(session, user_id)
|
||||
for item in items:
|
||||
try:
|
||||
doc_id = uuid.UUID(item.get("id", ""))
|
||||
except (TypeError, ValueError):
|
||||
item["is_shared"] = False
|
||||
else:
|
||||
item["is_shared"] = doc_id in shared_ids
|
||||
return items
|
||||
|
||||
|
||||
@account_limiter.limit("100/minute")
|
||||
async def list_documents(
|
||||
@@ -63,40 +62,17 @@ async def list_documents(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""List documents with optional sort, folder filter, and full-text search.
|
||||
|
||||
D-16: requires authenticated regular user (get_regular_user rejects admins).
|
||||
Returns only documents belonging to the current user.
|
||||
|
||||
FOLD-05: sort by name|date|size; order asc|desc; folder_id filter;
|
||||
q full-text search via plainto_tsquery (PostgreSQL only — silently skipped
|
||||
on SQLite when function is unavailable). FTS scope is always scoped to
|
||||
current_user.id (T-04-03-02).
|
||||
|
||||
Backward-compat: when sort/order/folder_id/q are not provided, behaviour
|
||||
is identical to the pre-Phase-4 implementation.
|
||||
"""
|
||||
"""List documents with optional sort, folder filter, and full-text search."""
|
||||
request.state.current_user = current_user
|
||||
# If no new params used, fall through to the legacy storage.list_metadata path
|
||||
# to preserve full backward compatibility with topic filtering.
|
||||
if folder_id is None and q is None and sort == "date" and order == "desc":
|
||||
docs = await storage.list_metadata(session, user_id=current_user.id, topic=topic)
|
||||
total = len(docs)
|
||||
start = (page - 1) * per_page
|
||||
# Add is_shared field (Phase 4 addition)
|
||||
shared_result = await session.execute(
|
||||
select(Share.document_id).where(Share.owner_id == current_user.id)
|
||||
items = await _decorate_shared_flags(
|
||||
session,
|
||||
current_user.id,
|
||||
docs[start : start + per_page],
|
||||
)
|
||||
shared_ids = {row[0] for row in shared_result.fetchall()}
|
||||
items = []
|
||||
for d in docs[start : start + per_page]:
|
||||
doc_id_str = d.get("id", "")
|
||||
try:
|
||||
doc_uuid = uuid.UUID(doc_id_str)
|
||||
except (ValueError, AttributeError):
|
||||
doc_uuid = None
|
||||
d["is_shared"] = doc_uuid in shared_ids if doc_uuid else False
|
||||
items.append(d)
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
from db.models import DocumentTopic, Topic # noqa: PLC0415 (avoid circular at module top)
|
||||
@@ -126,8 +102,6 @@ async def list_documents(
|
||||
order_fn = sort_col.asc if order == "asc" else sort_col.desc
|
||||
stmt = stmt.order_by(order_fn())
|
||||
|
||||
# Full-text search — plainto_tsquery on extracted_text (PostgreSQL only)
|
||||
# Falls back to unfiltered if the DB dialect doesn't support @@ (e.g. SQLite in test env)
|
||||
fts_requested = q is not None and len(q) >= 2
|
||||
if fts_requested:
|
||||
fts_stmt = stmt.where(
|
||||
@@ -143,14 +117,11 @@ async def list_documents(
|
||||
result = await session.execute(stmt)
|
||||
docs_orm = result.scalars().all()
|
||||
|
||||
shared_result = await session.execute(
|
||||
select(Share.document_id).where(Share.owner_id == current_user.id)
|
||||
)
|
||||
shared_ids = {row[0] for row in shared_result.fetchall()}
|
||||
|
||||
all_items = []
|
||||
shared_ids = await _shared_document_ids(session, current_user.id)
|
||||
for doc in docs_orm:
|
||||
from services.storage import _doc_to_dict, _load_topic_names # noqa: PLC0415
|
||||
|
||||
topic_names = await _load_topic_names(session, doc.id)
|
||||
d = _doc_to_dict(doc, topic_names)
|
||||
d["is_shared"] = doc.id in shared_ids
|
||||
@@ -166,8 +137,6 @@ async def list_documents(
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/documents/{doc_id} ───────────────────────────────────────────────
|
||||
|
||||
@router.get("/{doc_id}")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def get_document(
|
||||
@@ -176,44 +145,18 @@ async def get_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Return document metadata by ID.
|
||||
|
||||
D-16: requires authenticated regular user. Asserts ownership — cross-user
|
||||
access returns 404 (not 403) to avoid information leakage (T-03-11).
|
||||
"""
|
||||
"""Return document metadata by ID."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
is_recipient = False
|
||||
if doc.user_id != current_user.id:
|
||||
share_result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == uid,
|
||||
Share.recipient_id == current_user.id,
|
||||
)
|
||||
)
|
||||
if share_result.scalar_one_or_none() is None:
|
||||
raise HTTPException(404, "Document not found")
|
||||
is_recipient = True
|
||||
_, is_recipient = await get_accessible_document(session, doc_id, current_user.id)
|
||||
|
||||
meta = await storage.get_metadata(session, doc_id)
|
||||
if meta is None:
|
||||
raise HTTPException(404, "Document not found")
|
||||
# T-04-04-03: recipients get metadata only — extracted_text excluded (consistent with /shares/received)
|
||||
if is_recipient:
|
||||
meta.pop("extracted_text", None)
|
||||
return meta
|
||||
|
||||
|
||||
# ── PATCH /api/documents/{doc_id} ────────────────────────────────────────────
|
||||
|
||||
@router.patch("/{doc_id}")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def patch_document(
|
||||
@@ -223,25 +166,9 @@ async def patch_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Update document metadata (filename and/or folder_id).
|
||||
|
||||
T-05-09-01: get_regular_user dep rejects admins (403) and unauthenticated (401).
|
||||
T-05-09-01: ownership check — non-owner gets 404 to avoid leaking document IDs (D-16).
|
||||
T-05-09-02: response uses storage.get_metadata() which excludes credentials_enc and
|
||||
password_hash via the _doc_to_dict whitelist.
|
||||
|
||||
At least one field must be provided — empty body returns 422.
|
||||
folder_id=null moves the document to the root (no folder).
|
||||
"""
|
||||
"""Update document metadata."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
doc = await get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
if not body.model_fields_set:
|
||||
raise HTTPException(422, "At least one field (filename, folder_id) must be provided")
|
||||
@@ -250,7 +177,6 @@ async def patch_document(
|
||||
doc.filename = body.filename
|
||||
|
||||
if "folder_id" in body.model_fields_set:
|
||||
# folder_id=null → move to root (no folder); folder_id=<uuid> → move to folder
|
||||
if body.folder_id is not None:
|
||||
target = await session.get(Folder, body.folder_id)
|
||||
if target is None or target.user_id != current_user.id:
|
||||
@@ -265,8 +191,6 @@ async def patch_document(
|
||||
return meta
|
||||
|
||||
|
||||
# ── DELETE /api/documents/{doc_id} ───────────────────────────────────────────
|
||||
|
||||
@router.delete("/{doc_id}")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def delete_document(
|
||||
@@ -276,27 +200,9 @@ async def delete_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Delete a document and decrement quota atomically.
|
||||
|
||||
For cloud-stored documents:
|
||||
- Default path: attempt cloud provider delete first; on failure return
|
||||
{success: false, cloud_delete_failed: true} (HTTP 200) so the frontend
|
||||
can offer a "Remove from app" fallback (T-06.2-03-02).
|
||||
- remove_only=true: skip cloud delete, remove DB row only, skip quota decrement.
|
||||
- Cloud docs always use skip_quota=True (never charged MinIO quota, T-06.2-03-01).
|
||||
|
||||
D-16: requires authenticated regular user. Asserts ownership — cross-user
|
||||
delete returns 404 (not 403) to avoid information leakage (T-03-11).
|
||||
"""
|
||||
"""Delete a document and decrement quota when appropriate."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
doc = await get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
is_cloud = doc.storage_backend != "minio"
|
||||
_doc_size = doc.size_bytes
|
||||
@@ -305,7 +211,8 @@ async def delete_document(
|
||||
|
||||
if is_cloud and not remove_only:
|
||||
try:
|
||||
import api.documents as _doc_pkg # late import allows test monkeypatching via api.documents
|
||||
import api.documents as _doc_pkg
|
||||
|
||||
_gsb = _doc_pkg.get_storage_backend_for_document
|
||||
cloud_backend = await _gsb(doc, current_user, session)
|
||||
await cloud_backend.delete_object(doc.object_key)
|
||||
@@ -320,13 +227,10 @@ async def delete_document(
|
||||
},
|
||||
)
|
||||
|
||||
# auto_commit=False defers the commit so the audit log write below happens
|
||||
# in the same transaction — avoids the split-transaction gap (WR-08).
|
||||
ok = await storage.delete_document(session, doc_id, skip_quota=is_cloud, auto_commit=False)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
# D-13: document deleted event — written in the same transaction as the delete (WR-08).
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.deleted",
|
||||
@@ -341,8 +245,6 @@ async def delete_document(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ── POST /api/documents/{doc_id}/classify ────────────────────────────────────
|
||||
|
||||
@router.post("/{doc_id}/classify")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def classify_document(
|
||||
@@ -351,25 +253,9 @@ async def classify_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Re-queue a document for classification via Celery (D-11).
|
||||
|
||||
Sets doc.status='processing', commits, dispatches extract_and_classify.delay(),
|
||||
and returns {'document_id': str, 'status': 'processing'}.
|
||||
|
||||
D-16: requires authenticated regular user. Asserts ownership — cross-user
|
||||
classify returns 404 (not 403) to avoid information leakage (T-03-11).
|
||||
T-07-10: ownership enforced here; IDOR returns 404 per STATE.md policy.
|
||||
Placed in crud.py per D-08: same ownership-check pattern as get/patch/delete.
|
||||
"""
|
||||
"""Re-queue a document for classification via Celery."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(404, "Document not found")
|
||||
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(404, "Document not found")
|
||||
doc = await get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
doc.status = "processing"
|
||||
await session.commit()
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
"""Shared constants and Pydantic request models for the documents API package.
|
||||
|
||||
CODE-08: Single definition of _CLOUD_PROVIDERS, UploadUrlRequest, and DocumentPatch.
|
||||
These are imported by upload.py and crud.py — never duplicated.
|
||||
|
||||
T-05-06-01: _CLOUD_PROVIDERS is an allowlist frozenset; target_backend validated
|
||||
against it (never against user-supplied strings).
|
||||
T-05-09-01: DocumentPatch fields declared explicitly — mass assignment prevented.
|
||||
T-05-09-02: filename_no_path_separators validator preserved verbatim (path traversal
|
||||
defense at the API boundary — D-11 analysis: stays in Pydantic model).
|
||||
"""
|
||||
"""Shared constants, request models, and access helpers for document routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Share
|
||||
|
||||
# Valid cloud backend slugs (T-05-06-01: validated against allowlist, not user-supplied string)
|
||||
_CLOUD_PROVIDERS = frozenset({"google_drive", "onedrive", "nextcloud", "webdav"})
|
||||
|
||||
|
||||
@@ -26,14 +20,6 @@ class UploadUrlRequest(BaseModel):
|
||||
|
||||
|
||||
class DocumentPatch(BaseModel):
|
||||
"""Pydantic model for PATCH /api/documents/{doc_id}.
|
||||
|
||||
Optional fields — model_fields_set distinguishes "not provided" from "set to null".
|
||||
At least one field must be present in model_fields_set (enforced in the handler).
|
||||
|
||||
T-05-09-01: explicit field declaration prevents mass assignment.
|
||||
T-05-09-02: only filename and folder_id are accepted — no other fields can be set.
|
||||
"""
|
||||
filename: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
folder_id: Optional[uuid.UUID] = None
|
||||
|
||||
@@ -43,3 +29,50 @@ class DocumentPatch(BaseModel):
|
||||
if v is not None and ("/" in v or "\\" in v):
|
||||
raise ValueError("filename must not contain path separators")
|
||||
return v
|
||||
|
||||
|
||||
def _document_not_found() -> HTTPException:
|
||||
return HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
|
||||
def parse_document_uuid(doc_id: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise _document_not_found()
|
||||
|
||||
|
||||
async def get_owned_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = parse_document_uuid(doc_id)
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != user_id:
|
||||
raise _document_not_found()
|
||||
return doc
|
||||
|
||||
|
||||
async def get_accessible_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> tuple[Document, bool]:
|
||||
uid = parse_document_uuid(doc_id)
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None:
|
||||
raise _document_not_found()
|
||||
|
||||
if doc.user_id == user_id:
|
||||
return doc, False
|
||||
|
||||
result = await session.execute(
|
||||
select(Share).where(
|
||||
Share.document_id == doc.id,
|
||||
Share.recipient_id == user_id,
|
||||
)
|
||||
)
|
||||
if result.scalar_one_or_none() is None:
|
||||
raise _document_not_found()
|
||||
return doc, True
|
||||
|
||||
+99
-179
@@ -1,32 +1,11 @@
|
||||
"""Document upload endpoints — presigned URL flow and direct cloud upload.
|
||||
|
||||
Endpoints:
|
||||
POST /upload-url — create pending Document row, return presigned PUT URL (D-05 step 1)
|
||||
POST /upload — direct multipart upload supporting cloud backends (D-10, D-14, D-15)
|
||||
POST /{doc_id}/confirm — stat MinIO for authoritative size, enforce quota atomically (D-05 step 3)
|
||||
|
||||
Sub-router carries NO prefix — prefix="/api/documents" lives in __init__.py (D-04).
|
||||
|
||||
Security:
|
||||
T-03-04: object_key computed server-side using str(current_user.id) — never user-supplied.
|
||||
T-03-05: size from backend.stat_object() — never from client.
|
||||
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
|
||||
T-03-11: ownership assertion on confirm — cross-user access returns 404.
|
||||
T-03-15: object_key prefix always the authenticated user's id.
|
||||
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist.
|
||||
T-05-06-02: CloudConnectionError detail never includes provider error detail.
|
||||
"""
|
||||
"""Document upload endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import structlog as _structlog
|
||||
|
||||
_log = _structlog.get_logger(__name__)
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
|
||||
from sqlalchemy import text
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config import settings
|
||||
@@ -36,24 +15,91 @@ from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
from services.audit import write_audit_log
|
||||
from services.rate_limiting import account_limiter
|
||||
from storage import get_storage_backend, get_storage_backend_for_document
|
||||
from storage import get_storage_backend
|
||||
from storage.cloud_backend_factory import build_cloud_backend
|
||||
from storage.cloud_utils import decrypt_credentials
|
||||
from storage.exceptions import CloudConnectionError
|
||||
from tasks.document_tasks import extract_and_classify
|
||||
|
||||
try:
|
||||
from minio.error import S3Error
|
||||
except ImportError:
|
||||
S3Error = Exception # type: ignore[assignment,misc]
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from api.documents.shared import UploadUrlRequest, _CLOUD_PROVIDERS
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── POST /api/documents/upload-url ───────────────────────────────────────────
|
||||
def _new_minio_document(user_id: uuid.UUID, filename: str, content_type: str) -> Document:
|
||||
doc_id = uuid.uuid4()
|
||||
suffix = Path(filename).suffix.lower()
|
||||
return Document(
|
||||
id=doc_id,
|
||||
user_id=user_id,
|
||||
filename=filename,
|
||||
content_type=content_type,
|
||||
size_bytes=0,
|
||||
storage_backend="minio",
|
||||
status="pending",
|
||||
object_key=f"{user_id}/{doc_id}/{uuid.uuid4()}{suffix}",
|
||||
)
|
||||
|
||||
|
||||
async def _create_presigned_upload(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
filename: str,
|
||||
content_type: str,
|
||||
) -> dict:
|
||||
doc = _new_minio_document(user_id, filename, content_type)
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
upload_url = await get_storage_backend().generate_presigned_put_url(
|
||||
doc.object_key, expires_minutes=15
|
||||
)
|
||||
return {"upload_url": upload_url, "document_id": str(doc.id)}
|
||||
|
||||
|
||||
async def _get_active_cloud_connection(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
provider: str,
|
||||
) -> CloudConnection:
|
||||
result = await session.execute(
|
||||
select(CloudConnection).where(
|
||||
CloudConnection.user_id == user_id,
|
||||
CloudConnection.provider == provider,
|
||||
CloudConnection.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
conn = result.scalar_one_or_none()
|
||||
if conn is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active {provider} connection found. Please connect in Settings.",
|
||||
)
|
||||
return conn
|
||||
|
||||
|
||||
def _decrypt_cloud_credentials(conn: CloudConnection, user_id: uuid.UUID) -> dict:
|
||||
return decrypt_credentials(settings.cloud_creds_key.encode(), str(user_id), conn.credentials_enc)
|
||||
|
||||
|
||||
async def _record_upload(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
current_user: User,
|
||||
doc: Document,
|
||||
size_bytes: int,
|
||||
storage_backend: str,
|
||||
) -> None:
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.uploaded",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=doc.id,
|
||||
ip_address=get_client_ip(request) if request else None,
|
||||
metadata_={"size_bytes": size_bytes, "storage_backend": storage_backend},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upload-url")
|
||||
@account_limiter.limit("100/minute")
|
||||
@@ -63,41 +109,15 @@ async def request_upload_url(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Create a pending Document row and return a presigned PUT URL.
|
||||
|
||||
D-05 step 1: FastAPI creates a Document row (status='pending'), generates a
|
||||
15-minute presigned PUT URL, returns {upload_url, document_id}.
|
||||
Quota is NOT reserved at this step — quota enforcement happens at /confirm.
|
||||
|
||||
T-03-04: object_key is computed server-side using str(current_user.id); filename
|
||||
stored in DB only (CLAUDE.md MinIO key schema).
|
||||
T-03-15: object_key prefix is always the authenticated user's id — never user-supplied.
|
||||
"""
|
||||
"""Create a pending Document row and return a presigned PUT URL."""
|
||||
request.state.current_user = current_user
|
||||
doc_id = uuid.uuid4()
|
||||
suffix = Path(body.filename).suffix.lower()
|
||||
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=current_user.id,
|
||||
filename=body.filename,
|
||||
content_type=body.content_type,
|
||||
size_bytes=0,
|
||||
storage_backend="minio",
|
||||
status="pending",
|
||||
object_key=object_key,
|
||||
return await _create_presigned_upload(
|
||||
session,
|
||||
current_user.id,
|
||||
body.filename,
|
||||
body.content_type,
|
||||
)
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
upload_url = await get_storage_backend().generate_presigned_put_url(
|
||||
object_key, expires_minutes=15
|
||||
)
|
||||
return {"upload_url": upload_url, "document_id": str(doc_id)}
|
||||
|
||||
|
||||
# ── POST /api/documents/upload ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/upload")
|
||||
@account_limiter.limit("100/minute")
|
||||
@@ -109,47 +129,15 @@ async def upload_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Direct multipart upload endpoint supporting cloud backends (D-10, D-14, D-15).
|
||||
|
||||
If target_backend == "minio": generates a presigned PUT URL (unchanged MinIO flow).
|
||||
If target_backend in ("google_drive", "onedrive", "nextcloud", "webdav"):
|
||||
1. Reads file bytes from UploadFile
|
||||
2. Loads CloudConnection for current_user.id + target_backend; 404 if not found/not ACTIVE
|
||||
3. Decrypts credentials and instantiates the correct backend class
|
||||
4. Calls cloud_backend.put_object() to upload directly to the provider
|
||||
5. Creates Document with storage_backend=target_backend
|
||||
6. Returns {document_id, storage_backend} — no upload_url (cloud upload is synchronous)
|
||||
|
||||
Cloud uploads do NOT use the atomic quota UPDATE — cloud files are not counted
|
||||
against MinIO quota (D-11: separate backends; cloud storage quota is provider-side).
|
||||
|
||||
Security:
|
||||
T-05-06-01: target_backend validated against _CLOUD_PROVIDERS allowlist → 422 on invalid value
|
||||
T-05-06-02: CloudConnectionError detail message never includes provider error detail
|
||||
"""
|
||||
"""Direct multipart upload endpoint supporting cloud backends."""
|
||||
request.state.current_user = current_user
|
||||
if target_backend == "minio":
|
||||
doc_id = uuid.uuid4()
|
||||
suffix = Path(file.filename or "file").suffix.lower()
|
||||
object_key = f"{current_user.id}/{doc_id}/{uuid.uuid4()}{suffix}"
|
||||
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=current_user.id,
|
||||
filename=file.filename or "upload",
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
size_bytes=0,
|
||||
storage_backend="minio",
|
||||
status="pending",
|
||||
object_key=object_key,
|
||||
return await _create_presigned_upload(
|
||||
session,
|
||||
current_user.id,
|
||||
file.filename or "upload",
|
||||
file.content_type or "application/octet-stream",
|
||||
)
|
||||
session.add(doc)
|
||||
await session.commit()
|
||||
|
||||
upload_url = await get_storage_backend().generate_presigned_put_url(
|
||||
object_key, expires_minutes=15
|
||||
)
|
||||
return {"upload_url": upload_url, "document_id": str(doc_id)}
|
||||
|
||||
if target_backend not in _CLOUD_PROVIDERS:
|
||||
raise HTTPException(
|
||||
@@ -157,23 +145,8 @@ async def upload_document(
|
||||
detail=f"Invalid target_backend '{target_backend}'. Valid values: minio, {', '.join(sorted(_CLOUD_PROVIDERS))}",
|
||||
)
|
||||
|
||||
# Load active CloudConnection for current user + provider (T-05-06-01: user-scoped query)
|
||||
result = await session.execute(
|
||||
select(CloudConnection).where(
|
||||
CloudConnection.user_id == current_user.id,
|
||||
CloudConnection.provider == target_backend,
|
||||
CloudConnection.status == "ACTIVE",
|
||||
)
|
||||
)
|
||||
conn = result.scalar_one_or_none()
|
||||
if conn is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No active {target_backend} connection found. Please connect in Settings.",
|
||||
)
|
||||
|
||||
master_key = settings.cloud_creds_key.encode()
|
||||
credentials = decrypt_credentials(master_key, str(current_user.id), conn.credentials_enc)
|
||||
conn = await _get_active_cloud_connection(session, current_user.id, target_backend)
|
||||
credentials = _decrypt_cloud_credentials(conn, current_user.id)
|
||||
|
||||
file_bytes = await file.read()
|
||||
filename = file.filename or "upload"
|
||||
@@ -182,26 +155,7 @@ async def upload_document(
|
||||
|
||||
doc_id = uuid.uuid4()
|
||||
|
||||
if target_backend == "google_drive":
|
||||
from storage.google_drive_backend import GoogleDriveBackend # lazy import
|
||||
cloud_backend = GoogleDriveBackend(credentials)
|
||||
elif target_backend == "onedrive":
|
||||
from storage.onedrive_backend import OneDriveBackend # lazy import
|
||||
cloud_backend = OneDriveBackend(credentials)
|
||||
elif target_backend == "nextcloud":
|
||||
from storage.nextcloud_backend import NextcloudBackend # lazy import
|
||||
cloud_backend = NextcloudBackend(
|
||||
credentials["server_url"],
|
||||
credentials["username"],
|
||||
credentials["password"],
|
||||
)
|
||||
elif target_backend == "webdav":
|
||||
from storage.webdav_backend import WebDAVBackend # lazy import
|
||||
cloud_backend = WebDAVBackend(
|
||||
credentials["server_url"],
|
||||
credentials["username"],
|
||||
credentials["password"],
|
||||
)
|
||||
cloud_backend = build_cloud_backend(target_backend, credentials)
|
||||
|
||||
try:
|
||||
object_key = await cloud_backend.put_object(
|
||||
@@ -219,9 +173,9 @@ async def upload_document(
|
||||
detail="Cloud connection requires re-authentication. Please reconnect in Settings.",
|
||||
) from exc
|
||||
|
||||
# Bust folder listing cache so the next GET /folders reflects the new file
|
||||
if cloud_folder_path:
|
||||
from services.cloud_cache import invalidate_provider_cache # lazy import
|
||||
|
||||
invalidate_provider_cache(str(current_user.id), target_backend)
|
||||
|
||||
doc = Document(
|
||||
@@ -236,16 +190,7 @@ async def upload_document(
|
||||
)
|
||||
session.add(doc)
|
||||
|
||||
_ip = get_client_ip(request) if request else None
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.uploaded",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=doc.id,
|
||||
ip_address=_ip,
|
||||
metadata_={"size_bytes": len(file_bytes), "storage_backend": target_backend},
|
||||
)
|
||||
await _record_upload(session, request, current_user, doc, len(file_bytes), target_backend)
|
||||
await session.commit()
|
||||
|
||||
extract_and_classify.delay(str(doc.id))
|
||||
@@ -253,8 +198,6 @@ async def upload_document(
|
||||
return {"document_id": str(doc.id), "storage_backend": target_backend}
|
||||
|
||||
|
||||
# ── POST /api/documents/{doc_id}/confirm ─────────────────────────────────────
|
||||
|
||||
@router.post("/{doc_id}/confirm")
|
||||
@account_limiter.limit("100/minute")
|
||||
async def confirm_upload(
|
||||
@@ -263,18 +206,7 @@ async def confirm_upload(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Confirm a presigned PUT upload: stat MinIO for size, enforce quota atomically.
|
||||
|
||||
D-05 step 3: FastAPI reads authoritative file size from MinIO stat_object (never
|
||||
from client), runs atomic quota UPDATE, sets status='uploaded', enqueues Celery task.
|
||||
|
||||
Quota exceeded: HTTP 413 with {"used_bytes": N, "limit_bytes": M, "rejected_bytes": K}
|
||||
Upload not found: HTTP 422 (presigned URL may have expired)
|
||||
|
||||
T-03-05: size always comes from backend.stat_object(doc.object_key) — never client.
|
||||
T-03-06: atomic SQL UPDATE prevents concurrent over-quota uploads (STORE-03 SC2).
|
||||
T-03-11: ownership assertion — cross-user access returns 404 (D-16).
|
||||
"""
|
||||
"""Confirm a presigned PUT upload and enforce quota."""
|
||||
request.state.current_user = current_user
|
||||
try:
|
||||
uid = uuid.UUID(doc_id)
|
||||
@@ -285,7 +217,6 @@ async def confirm_upload(
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
# Get authoritative file size from MinIO (T-03-05 — never trust client-supplied size)
|
||||
try:
|
||||
size = await get_storage_backend().stat_object(doc.object_key)
|
||||
except Exception as exc:
|
||||
@@ -300,7 +231,6 @@ async def confirm_upload(
|
||||
doc.size_bytes = size
|
||||
await session.flush()
|
||||
|
||||
# Atomic quota enforcement — user_id is always set post-migration (Plan 03-03+)
|
||||
result = await session.execute(
|
||||
text(
|
||||
"UPDATE quotas "
|
||||
@@ -323,7 +253,7 @@ async def confirm_upload(
|
||||
try:
|
||||
await get_storage_backend().delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # MinIO cleanup is best-effort; object TTL will eventually expire
|
||||
pass
|
||||
await session.commit()
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
@@ -337,17 +267,7 @@ async def confirm_upload(
|
||||
used_bytes = row.used_bytes
|
||||
|
||||
doc.status = "uploaded"
|
||||
# D-13: document uploaded event — size_bytes + storage_backend only, NO filename, NO extracted_text (T-04-07-02)
|
||||
_ip = get_client_ip(request)
|
||||
await write_audit_log(
|
||||
session,
|
||||
event_type="document.uploaded",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=doc.id,
|
||||
ip_address=_ip,
|
||||
metadata_={"size_bytes": size, "storage_backend": "minio"},
|
||||
)
|
||||
await _record_upload(session, request, current_user, doc, size, "minio")
|
||||
await session.commit()
|
||||
extract_and_classify.delay(str(doc.id))
|
||||
|
||||
|
||||
+90
-199
@@ -1,21 +1,4 @@
|
||||
"""
|
||||
Folder API endpoints for DocuVault — Phase 4, Plan 03.
|
||||
|
||||
Implements FOLD-01 through FOLD-05:
|
||||
POST /api/folders — create folder (FOLD-01)
|
||||
GET /api/folders — list top-level folders (FOLD-02)
|
||||
GET /api/folders/{id} — get folder + breadcrumb (FOLD-02)
|
||||
PATCH /api/folders/{id} — rename folder (FOLD-03)
|
||||
DELETE /api/folders/{id} — delete folder (cascade) (FOLD-03)
|
||||
PATCH /api/documents/{id}/folder — move document to folder (FOLD-04)
|
||||
|
||||
Security invariants (all enforced):
|
||||
T-04-03-01: get_regular_user on all endpoints (admin gets 403)
|
||||
T-04-03-04: All folder IDOR paths return 404 not 403
|
||||
T-04-03-05: PATCH /api/documents/{id}/folder validates both doc and target folder ownership
|
||||
T-04-03-06: IntegrityError (duplicate folder name) → 409 Conflict
|
||||
T-04-03-03: Atomic quota decrement with CASE WHEN pattern (SQLite compat)
|
||||
"""
|
||||
"""Folder and document-folder organization endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
@@ -23,11 +6,11 @@ from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select, text, func
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models import Document, Folder, Quota, Share, User
|
||||
from db.models import Document, Folder, User
|
||||
from deps.auth import get_regular_user
|
||||
from deps.db import get_db
|
||||
from deps.utils import get_client_ip
|
||||
@@ -37,8 +20,6 @@ from storage import get_storage_backend
|
||||
router = APIRouter(prefix="/api/folders", tags=["folders"])
|
||||
|
||||
|
||||
# ── Request / response models ─────────────────────────────────────────────────
|
||||
|
||||
class FolderCreate(BaseModel):
|
||||
name: str
|
||||
parent_id: Optional[str] = None
|
||||
@@ -52,9 +33,6 @@ class DocumentMove(BaseModel):
|
||||
folder_id: Optional[str] = None
|
||||
|
||||
|
||||
|
||||
# ── Helper: folder serialization ──────────────────────────────────────────────
|
||||
|
||||
def _folder_to_dict(folder: Folder) -> dict:
|
||||
return {
|
||||
"id": str(folder.id),
|
||||
@@ -65,8 +43,6 @@ def _folder_to_dict(folder: Folder) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── Helper: document serialization ────────────────────────────────────────────
|
||||
|
||||
def _doc_to_dict(doc: Document) -> dict:
|
||||
return {
|
||||
"id": str(doc.id),
|
||||
@@ -81,7 +57,67 @@ def _doc_to_dict(doc: Document) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# ── POST /api/folders ─────────────────────────────────────────────────────────
|
||||
def _parse_uuid(value: str, not_found_detail: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=not_found_detail)
|
||||
|
||||
|
||||
async def _get_owned_folder(
|
||||
session: AsyncSession,
|
||||
folder_id: str | uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
detail: str = "Folder not found",
|
||||
) -> Folder:
|
||||
uid = folder_id if isinstance(folder_id, uuid.UUID) else _parse_uuid(folder_id, detail)
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
return folder
|
||||
|
||||
|
||||
async def _get_owned_document(
|
||||
session: AsyncSession,
|
||||
doc_id: str,
|
||||
user_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = _parse_uuid(doc_id, "Document not found")
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != user_id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return doc
|
||||
|
||||
|
||||
async def _ensure_unique_folder_name(
|
||||
session: AsyncSession,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
parent_id: Optional[uuid.UUID],
|
||||
exclude_id: Optional[uuid.UUID] = None,
|
||||
) -> None:
|
||||
stmt = select(Folder).where(
|
||||
Folder.user_id == user_id,
|
||||
Folder.name == name,
|
||||
Folder.parent_id == parent_id,
|
||||
)
|
||||
if exclude_id is not None:
|
||||
stmt = stmt.where(Folder.id != exclude_id)
|
||||
|
||||
dup = await session.execute(stmt)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
|
||||
def _duplicate_folder_error() -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
async def create_folder(
|
||||
@@ -90,35 +126,14 @@ async def create_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Create a new folder for the current user.
|
||||
|
||||
FOLD-01: parent_id (if given) must belong to current_user — 404 otherwise.
|
||||
Duplicate name under same parent returns 409 (T-04-03-06).
|
||||
"""
|
||||
parent_uuid: Optional[uuid.UUID] = None
|
||||
if body.parent_id is not None:
|
||||
try:
|
||||
parent_uuid = uuid.UUID(body.parent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await session.get(Folder, parent_uuid)
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent = await _get_owned_folder(
|
||||
session, body.parent_id, current_user.id, "Parent folder not found"
|
||||
)
|
||||
parent_uuid = parent.id
|
||||
|
||||
# Explicit duplicate check — UniqueConstraint won't fire when parent_id IS NULL
|
||||
# because SQL treats NULL as distinct from NULL in unique indexes.
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == parent_uuid,
|
||||
)
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
await _ensure_unique_folder_name(session, current_user.id, body.name, parent_uuid)
|
||||
|
||||
folder = Folder(
|
||||
user_id=current_user.id,
|
||||
@@ -130,10 +145,7 @@ async def create_folder(
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
raise _duplicate_folder_error()
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -148,34 +160,20 @@ async def create_folder(
|
||||
return _folder_to_dict(folder)
|
||||
|
||||
|
||||
# ── GET /api/folders ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("")
|
||||
async def list_folders(
|
||||
parent_id: Optional[str] = None,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""List the current user's folders at a given level.
|
||||
|
||||
FOLD-02: when parent_id is omitted, returns root folders (parent_id IS NULL).
|
||||
When parent_id is supplied, returns that folder's direct children (asserts ownership).
|
||||
Each folder includes has_children so the frontend can hide expand arrows on leaf nodes.
|
||||
"""
|
||||
parent_uuid: Optional[uuid.UUID] = None
|
||||
if parent_id is not None:
|
||||
try:
|
||||
parent_uuid = uuid.UUID(parent_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent_folder = await session.get(Folder, parent_uuid)
|
||||
if parent_folder is None or parent_folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Parent folder not found")
|
||||
parent_folder = await _get_owned_folder(
|
||||
session, parent_id, current_user.id, "Parent folder not found"
|
||||
)
|
||||
parent_uuid = parent_folder.id
|
||||
|
||||
if parent_uuid is None:
|
||||
where_clause = Folder.parent_id.is_(None)
|
||||
else:
|
||||
where_clause = Folder.parent_id == parent_uuid
|
||||
where_clause = Folder.parent_id.is_(None) if parent_uuid is None else Folder.parent_id == parent_uuid
|
||||
|
||||
result = await session.execute(
|
||||
select(Folder)
|
||||
@@ -184,8 +182,6 @@ async def list_folders(
|
||||
)
|
||||
folders = result.scalars().all()
|
||||
|
||||
# One extra query to know which of these folders have sub-folders.
|
||||
# Allows the frontend to hide expand arrows on leaf nodes without extra round-trips.
|
||||
folder_ids = [f.id for f in folders]
|
||||
folders_with_children: set = set()
|
||||
if folder_ids:
|
||||
@@ -205,55 +201,34 @@ async def list_folders(
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/folders/{folder_id} ──────────────────────────────────────────────
|
||||
|
||||
@router.get("/{folder_id}")
|
||||
async def get_folder(
|
||||
folder_id: str,
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Get folder metadata + breadcrumb array from root to this folder.
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
|
||||
FOLD-02 / FOLD-05: breadcrumb is built via iterative parent-walk in Python
|
||||
(not WITH RECURSIVE) so it is compatible with both PostgreSQL and SQLite tests.
|
||||
|
||||
Response: {id, name, parent_id, user_id, created_at, breadcrumb: [{id, name}, ...]}
|
||||
The breadcrumb array is ordered root-first (root is breadcrumb[0]).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
# Build breadcrumb by walking up the parent chain iteratively.
|
||||
# Ownership check on each ancestor ensures no cross-user traversal.
|
||||
crumbs = [{"id": str(folder.id), "name": folder.name}]
|
||||
current = folder
|
||||
visited: set = {current.id}
|
||||
while current.parent_id is not None:
|
||||
if current.parent_id in visited:
|
||||
break # cycle guard (should not occur with proper constraints)
|
||||
break
|
||||
parent = await session.get(Folder, current.parent_id)
|
||||
if parent is None or parent.user_id != current_user.id:
|
||||
break # stop traversal if parent is inaccessible
|
||||
break
|
||||
visited.add(parent.id)
|
||||
crumbs.append({"id": str(parent.id), "name": parent.name})
|
||||
current = parent
|
||||
|
||||
crumbs.reverse() # root-first order
|
||||
crumbs.reverse()
|
||||
|
||||
response = _folder_to_dict(folder)
|
||||
response["breadcrumb"] = crumbs
|
||||
return response
|
||||
|
||||
|
||||
# ── PATCH /api/folders/{folder_id} ───────────────────────────────────────────
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def rename_folder(
|
||||
folder_id: str,
|
||||
@@ -262,46 +237,20 @@ async def rename_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Rename a folder.
|
||||
|
||||
FOLD-03: asserts ownership → 404 if not owner.
|
||||
Duplicate name under same parent returns 409 (T-04-03-06).
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
old_name = folder.name
|
||||
# Explicit duplicate check — same NULL parent_id issue as create_folder.
|
||||
|
||||
if body.name != folder.name:
|
||||
dup = await session.execute(
|
||||
select(Folder).where(
|
||||
Folder.user_id == current_user.id,
|
||||
Folder.name == body.name,
|
||||
Folder.parent_id == folder.parent_id,
|
||||
Folder.id != folder.id,
|
||||
)
|
||||
await _ensure_unique_folder_name(
|
||||
session, current_user.id, body.name, folder.parent_id, folder.id
|
||||
)
|
||||
if dup.scalar_one_or_none() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
|
||||
folder.name = body.name
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="A folder with that name already exists here",
|
||||
)
|
||||
raise _duplicate_folder_error()
|
||||
|
||||
await write_audit_log(
|
||||
session,
|
||||
@@ -316,8 +265,6 @@ async def rename_folder(
|
||||
return _folder_to_dict(folder)
|
||||
|
||||
|
||||
# ── DELETE /api/folders/{folder_id} ──────────────────────────────────────────
|
||||
|
||||
@router.delete("/{folder_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_folder(
|
||||
folder_id: str,
|
||||
@@ -325,30 +272,9 @@ async def delete_folder(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Delete a folder and all of its contents (cascade).
|
||||
|
||||
FOLD-03 + D-03:
|
||||
- Collects all documents in the folder subtree using WITH RECURSIVE CTE
|
||||
(wraps in try/except OperationalError for SQLite test compat; fallback
|
||||
uses direct children only).
|
||||
- Sums size_bytes, performs atomic quota decrement (CASE WHEN pattern for
|
||||
SQLite compat — T-04-03-03).
|
||||
- Deletes MinIO objects best-effort (per-object try/except — PATTERNS.md Pattern 2).
|
||||
- Deletes all document rows and the folder row via ORM.
|
||||
"""
|
||||
try:
|
||||
uid = uuid.UUID(folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await session.get(Folder, uid)
|
||||
if folder is None or folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
|
||||
folder = await _get_owned_folder(session, folder_id, current_user.id)
|
||||
folder_name = folder.name
|
||||
|
||||
# Collect all folder IDs in the subtree via WITH RECURSIVE CTE.
|
||||
# Falls back to direct-children-only on SQLite (OperationalError on recursive CTE).
|
||||
subtree_folder_ids: list[str] = []
|
||||
try:
|
||||
cte_result = await session.execute(
|
||||
@@ -361,17 +287,13 @@ async def delete_folder(
|
||||
" WHERE f.user_id = :uid"
|
||||
") SELECT id FROM subtree"
|
||||
),
|
||||
# Use .hex (no dashes) — SQLite stores UUID as 32-char hex; PostgreSQL accepts both.
|
||||
{"root_id": folder.id.hex, "uid": current_user.id.hex},
|
||||
)
|
||||
subtree_folder_ids = [str(row[0]) for row in cte_result.fetchall()]
|
||||
except OperationalError:
|
||||
# SQLite fallback: only direct children of this folder
|
||||
subtree_folder_ids = [str(folder.id)]
|
||||
|
||||
# Collect all documents in the subtree folder IDs
|
||||
if subtree_folder_ids:
|
||||
# Build UUID list for IN query
|
||||
subtree_uuids = []
|
||||
for fid in subtree_folder_ids:
|
||||
try:
|
||||
@@ -394,7 +316,6 @@ async def delete_folder(
|
||||
|
||||
total_bytes = sum(d.size_bytes for d in docs)
|
||||
|
||||
# Atomic quota decrement (CASE WHEN for SQLite compat — never goes below 0)
|
||||
if total_bytes > 0:
|
||||
await session.execute(
|
||||
text(
|
||||
@@ -406,20 +327,16 @@ async def delete_folder(
|
||||
{"delta": total_bytes, "uid": current_user.id.hex},
|
||||
)
|
||||
|
||||
# Delete MinIO objects best-effort (per-object, never abort on failure)
|
||||
storage_backend = get_storage_backend()
|
||||
for doc in docs:
|
||||
try:
|
||||
await storage_backend.delete_object(doc.object_key)
|
||||
except Exception:
|
||||
pass # best-effort; stale MinIO objects will be garbage-collected
|
||||
pass
|
||||
|
||||
# Delete document rows
|
||||
for doc in docs:
|
||||
await session.delete(doc)
|
||||
|
||||
# Delete the folder (cascade will handle sub-folders in PostgreSQL;
|
||||
# in SQLite test env we already collected and deleted all documents)
|
||||
await session.delete(folder)
|
||||
await session.commit()
|
||||
|
||||
@@ -428,17 +345,12 @@ async def delete_folder(
|
||||
event_type="folder.deleted",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=uid,
|
||||
resource_id=folder.id,
|
||||
ip_address=get_client_ip(request),
|
||||
metadata_={"name": folder_name, "doc_count": len(docs)},
|
||||
)
|
||||
|
||||
|
||||
# ── PATCH /api/documents/{doc_id}/folder ─────────────────────────────────────
|
||||
# This endpoint lives in the folders router (not documents router) because it
|
||||
# is logically a folder organisation operation. The URL prefix /api/documents
|
||||
# is achieved via an explicit path on this APIRouter. FastAPI supports this.
|
||||
|
||||
document_move_router = APIRouter(prefix="/api/documents", tags=["folders"])
|
||||
|
||||
|
||||
@@ -450,33 +362,12 @@ async def move_document(
|
||||
session: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_regular_user),
|
||||
):
|
||||
"""Move a document to a different folder (or to root if folder_id is null).
|
||||
|
||||
FOLD-04:
|
||||
- Asserts document ownership → 404 if not owner.
|
||||
- If folder_id given: asserts target folder ownership → 404 if not owner
|
||||
(T-04-03-05: cross-user folder assignment blocked).
|
||||
- Updates doc.folder_id and commits.
|
||||
- Returns 200 with updated document dict.
|
||||
"""
|
||||
try:
|
||||
doc_uid = uuid.UUID(doc_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
|
||||
doc = await session.get(Document, doc_uid)
|
||||
if doc is None or doc.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
doc = await _get_owned_document(session, doc_id, current_user.id)
|
||||
|
||||
target_folder_uuid: Optional[uuid.UUID] = None
|
||||
if body.folder_id is not None:
|
||||
try:
|
||||
target_folder_uuid = uuid.UUID(body.folder_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
target_folder = await session.get(Folder, target_folder_uuid)
|
||||
if target_folder is None or target_folder.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Folder not found")
|
||||
target_folder = await _get_owned_folder(session, body.folder_id, current_user.id)
|
||||
target_folder_uuid = target_folder.id
|
||||
|
||||
doc.folder_id = target_folder_uuid
|
||||
await session.commit()
|
||||
|
||||
+68
-137
@@ -1,23 +1,7 @@
|
||||
"""
|
||||
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
|
||||
"""
|
||||
"""Document sharing endpoints."""
|
||||
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
|
||||
@@ -33,9 +17,6 @@ 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
|
||||
@@ -60,11 +41,47 @@ class SharePermissionPatch(BaseModel):
|
||||
return v
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
def _parse_uuid(value: str, detail: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(value)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=404, detail=detail)
|
||||
|
||||
|
||||
async def _get_owned_document(
|
||||
session: AsyncSession,
|
||||
document_id: str,
|
||||
owner_id: uuid.UUID,
|
||||
) -> Document:
|
||||
uid = _parse_uuid(document_id, "Document not found")
|
||||
doc = await session.get(Document, uid)
|
||||
if doc is None or doc.user_id != owner_id:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return doc
|
||||
|
||||
# ── POST /api/shares ──────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_owned_share(
|
||||
session: AsyncSession,
|
||||
share_id: str,
|
||||
owner_id: uuid.UUID,
|
||||
) -> Share:
|
||||
sid = _parse_uuid(share_id, "Share not found")
|
||||
share = await session.get(Share, sid)
|
||||
if share is None or share.owner_id != owner_id:
|
||||
raise HTTPException(status_code=404, detail="Share not found")
|
||||
return share
|
||||
|
||||
|
||||
def _share_to_dict(share: Share, recipient: User) -> dict:
|
||||
return {
|
||||
"id": str(share.id),
|
||||
"document_id": str(share.document_id),
|
||||
"owner_id": str(share.owner_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,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||
@@ -74,22 +91,7 @@ async def grant_share(
|
||||
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")
|
||||
doc = await _get_owned_document(session, body.document_id, current_user.id)
|
||||
|
||||
# Recipient lookup by exact handle (D-04)
|
||||
result = await session.execute(
|
||||
@@ -105,7 +107,7 @@ async def grant_share(
|
||||
|
||||
# Create the share row
|
||||
share = Share(
|
||||
document_id=uid,
|
||||
document_id=doc.id,
|
||||
owner_id=current_user.id,
|
||||
recipient_id=recipient.id,
|
||||
permission=body.permission,
|
||||
@@ -126,25 +128,14 @@ async def grant_share(
|
||||
event_type="share.granted",
|
||||
user_id=current_user.id,
|
||||
actor_id=current_user.id,
|
||||
resource_id=uid,
|
||||
resource_id=doc.id,
|
||||
ip_address=get_client_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),
|
||||
"recipient_handle": recipient.handle,
|
||||
"permission": share.permission,
|
||||
"created_at": share.created_at.isoformat() if share.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ── GET /api/shares ───────────────────────────────────────────────────────────
|
||||
return _share_to_dict(share, recipient)
|
||||
|
||||
|
||||
@router.get("")
|
||||
@@ -153,46 +144,28 @@ async def list_shares(
|
||||
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).
|
||||
doc = await _get_owned_document(session, document_id, current_user.id)
|
||||
|
||||
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)
|
||||
.where(Share.document_id == doc.id)
|
||||
)
|
||||
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,
|
||||
}
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"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,
|
||||
}
|
||||
for share, recipient in result.all()
|
||||
]
|
||||
|
||||
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).
|
||||
@@ -203,12 +176,6 @@ 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)
|
||||
@@ -219,26 +186,21 @@ async def list_shared_with_me(
|
||||
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,
|
||||
}
|
||||
)
|
||||
items = [
|
||||
{
|
||||
"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,
|
||||
}
|
||||
for doc, owner in rows
|
||||
]
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
# ── PATCH /api/shares/{share_id} ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.patch("/{share_id}", status_code=200)
|
||||
async def update_share_permission(
|
||||
share_id: str,
|
||||
@@ -247,20 +209,7 @@ async def update_share_permission(
|
||||
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 = await _get_owned_share(session, share_id, current_user.id)
|
||||
share.permission = body.permission
|
||||
|
||||
await write_audit_log(
|
||||
@@ -277,9 +226,6 @@ async def update_share_permission(
|
||||
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,
|
||||
@@ -287,27 +233,12 @@ async def revoke_share(
|
||||
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")
|
||||
|
||||
share = await _get_owned_share(session, share_id, current_user.id)
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user