diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index aa538f2..eaf81a1 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -12,12 +12,12 @@ import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import Select, delete, func, select, update +from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible from src.models.directories import LegalEntity -from src.models.enums import FileStatus +from src.models.enums import FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry @@ -174,7 +174,22 @@ async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None: await session.execute(delete(StoredFile).where(StoredFile.id == file_id)) -async def expire_s3_objects(session: AsyncSession, object_ids: Sequence[uuid.UUID], *, now: datetime.datetime) -> None: - """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22).""" - if object_ids: - await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now)) +async def expire_s3_objects( + session: AsyncSession, + object_ids: Sequence[uuid.UUID], + *, + owned_prefixes: Sequence[tuple[S3BucketRole, str]], + now: datetime.datetime, +) -> None: + """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22) — только + объектам из `object_ids`, чья пара (роль бакета, начало ключа) входит в `owned_prefixes`. Остальные (например, + ссылка задания на объект другого файла) не трогаются и ошибкой не считаются (M-3).""" + if not object_ids or not owned_prefixes: + return + owned = or_( + *( + and_(S3Object.bucket_role == role, S3Object.key.startswith(prefix, autoescape=True)) + for role, prefix in owned_prefixes + ) + ) + await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 3ede2d3..4ed1bcd 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -50,6 +50,7 @@ from src.schemas.common import PageParams from src.schemas.files import FileOut from src.services.documents import file_out from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND +from src.services.result_mapping import RESULTS_PREFIX OBJECT_TYPE = "stored_file" UPLOADS_PREFIX = "uploads" @@ -174,7 +175,8 @@ class PrimaryFilesService: исчезнувший, пока ждали блокировку, — 404. Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий - остаются, им ставится `expires_at = now`. ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает + остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и + `results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает ретеншн бакета. `before_value` аудита — карточка файла.""" visible = await files_repo.get_file(self._session, self._scope, file_id) if visible is None: @@ -197,7 +199,12 @@ class PrimaryFilesService: before = file_out(file, current, documents).model_dump(mode="json") object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)] await files_repo.delete_file_rows(self._session, file) - await files_repo.expire_s3_objects(self._session, object_ids, now=utc_now()) + # M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла + owned_prefixes = [ + (S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/"), + (S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"), + ] + await files_repo.expire_s3_objects(self._session, object_ids, owned_prefixes=owned_prefixes, now=utc_now()) await self._audit.record( action=AuditAction.FILE_DELETE, section=AuditSection.PRIMARY_DOCUMENTS, diff --git a/api/tests/factories.py b/api/tests/factories.py index f8b5007..dfa1cae 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -191,15 +191,17 @@ async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики сессии (`flush`). """ now = _now() + file_id = uuid.uuid4() s3 = S3Object( bucket_role=S3BucketRole.uploads, bucket=FACTORY_BUCKET, - key=s3_key or f"uploads/{uuid.uuid4()}/{original_name}", + key=s3_key or f"uploads/{file_id}/{uuid.uuid4()}/{original_name}", # как `upload_key`: папка файла created_at=now, ) session.add(s3) await session.flush() file = StoredFile( + id=file_id, legal_entity_id=entity.id, file_type=file_type, original_name=original_name, diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 5d39bdb..b45e4bf 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -102,6 +102,28 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa assert event.after_value is None +async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка + задания на объект другого файла (inbox переиспользует `s3_object` при конфликте `(bucket, key)`) его не трогает.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + other_file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR, original_name="other.xls") + job.result_s3_object_id = other_file.s3_object_id # результат «указывает» на загрузку другого файла + await seeded.flush() + file_id, own_id, foreign_id = file.id, file.s3_object_id, other_file.s3_object_id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + own = await seeded.get(S3Object, own_id, populate_existing=True) + foreign = await seeded.get(S3Object, foreign_id, populate_existing=True) + assert own is not None and own.expires_at is not None + assert foreign is not None and foreign.expires_at is None + + async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" entity = await _rostov_entity(seeded)