fix(security): soft-delete 가드 구조화 — get_live_document 헬퍼 + paper-holder (R7)

조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 5엔드포인트
(get_document_file·image_raw·save_content·preview·content)가 'if not doc' 만 검사 →
삭제 문서 원본/preview/전문/마커이미지가 doc_id(+토큰)만으로 노출·삭제 문서 NAS 재기록.
get_live_document(session, doc_id) 헬퍼(없거나 deleted_at 이면 404)로 통일 — '경로마다
deleted_at 기억' 대신 구조 강제(추가될 서빙 경로 자동 보호). save_content 는 삭제 문서
쓰기 차단까지. find_paper_holder 도 deleted_at IS NULL 필터 추가(dedup.find_canonical 대칭).

검증: py_compile 통과.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyungi
2026-06-16 13:45:33 +09:00
parent 844a5e0204
commit 8a625bfb27
2 changed files with 22 additions and 17 deletions
+20 -16
View File
@@ -69,6 +69,19 @@ def _upload_error(status_code: int, error_code: str, message: str) -> HTTPExcept
)
async def get_live_document(session: AsyncSession, doc_id: int) -> Document:
"""soft-delete(deleted_at) 가드 포함 문서 조회 — 없거나 삭제됐으면 404 (R7).
조회/수정 경로는 deleted_at 을 일관 가드하나 파일/콘텐츠 서빙 엔드포인트가 누락 →
삭제 문서의 원본/preview/전문이 doc_id(+유효 토큰)만으로 노출되던 비대칭. '경로마다
deleted_at 기억'에 의존하지 않게 헬퍼로 구조 강제(추가될 서빙 경로도 자동 보호).
"""
doc = await session.get(Document, doc_id)
if not doc or doc.deleted_at is not None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
return doc
async def _near_dup_scan_bg(doc_id: int) -> None:
"""B-3: post-upload near_duplicate 스캔 (BackgroundTask). 자체 세션, best-effort.
@@ -838,9 +851,7 @@ async def get_document_file(
# 일반 Bearer 헤더 인증 시도
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
doc = await get_live_document(session, doc_id)
# note(메모)는 물리 파일이 없음
if not doc.file_path:
@@ -943,10 +954,8 @@ async def get_document_image_raw(
if not payload or payload.get("type") != "access":
raise HTTPException(status_code=401, detail="유효하지 않은 토큰")
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단)
doc = await session.get(Document, doc_id)
if doc is None:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
# 문서 존재 확인 (image_key 만 있고 doc 가 사라진 케이스 차단 + soft-delete 가드)
doc = await get_live_document(session, doc_id)
img = await session.scalar(
select(DocumentImage).where(
@@ -1357,9 +1366,8 @@ async def save_document_content(
body: dict = None,
):
"""Markdown 원본 파일 저장 + extracted_text 갱신"""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
# soft-delete 문서엔 쓰기 차단 (R7 — 삭제 문서 resurrect / NAS 재기록 방지)
doc = await get_live_document(session, doc_id)
if doc.file_format not in ("md", "txt"):
raise HTTPException(status_code=400, detail="편집 가능한 포맷이 아닙니다 (md, txt만 가능)")
@@ -1399,9 +1407,7 @@ async def get_document_preview(
else:
raise HTTPException(status_code=401, detail="토큰이 필요합니다")
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
doc = await get_live_document(session, doc_id)
preview_path = Path(settings.nas_mount_path) / "PKM" / ".preview" / f"{doc_id}.pdf"
if not preview_path.exists():
@@ -1448,9 +1454,7 @@ async def get_document_content(
session: Annotated[AsyncSession, Depends(get_session)],
):
"""문서 전문 텍스트 반환 (서비스 호출용)."""
doc = await session.get(Document, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
doc = await get_live_document(session, doc_id)
raw_text = doc.extracted_text or ""
content = raw_text[:15000]