diff --git a/src/govoplan_files/backend/capabilities.py b/src/govoplan_files/backend/capabilities.py index cde7cab..09ef899 100644 --- a/src/govoplan_files/backend/capabilities.py +++ b/src/govoplan_files/backend/capabilities.py @@ -9,14 +9,18 @@ from sqlalchemy import or_ from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef from govoplan_core.core.files import FileAccessProvider from govoplan_core.core.files import ( + PostboxFileReferenceRef, + PostboxFileReferenceRequest, + PostboxFileReferenceProvider, ManagedArtifactRef, ManagedArtifactStore, ManagedArtifactWriteRequest, ) from govoplan_core.core.modules import ModuleContext from govoplan_core.security.module_permissions import scopes_grant_compatible -from govoplan_files.backend.db.models import FileAsset, FileFolder, FileShare +from govoplan_files.backend.db.models import FileAsset, FileBlob, FileFolder, FileShare, FileVersion from govoplan_files.backend.runtime import configure_runtime +from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.campaign_attachments import ( annotate_built_messages_with_managed_files, managed_match_payloads, @@ -28,6 +32,7 @@ from govoplan_files.backend.storage.campaign_usage import record_campaign_attach from govoplan_files.backend.storage.files import ( create_file_asset, current_version_and_blob, + get_asset_for_user, sync_file_asset_from_source, ) from govoplan_files.backend.storage.paths import normalize_folder @@ -146,6 +151,137 @@ def artifact_store_capability(context: ModuleContext) -> FilesArtifactStore: return FilesArtifactStore() +class FilesPostboxReferenceService(PostboxFileReferenceProvider): + """Resolve Postbox evidence without turning message access into file access.""" + + def resolve_postbox_references( + self, + session: object, + principal: object, + *, + tenant_id: str, + requests: tuple[PostboxFileReferenceRequest, ...], + ) -> tuple[PostboxFileReferenceRef, ...]: + if not hasattr(session, "get"): + raise TypeError("Postbox file resolution requires a SQLAlchemy session.") + principal_tenant = str(getattr(principal, "tenant_id", "") or "") + can_download = bool( + hasattr(principal, "has") and principal.has("files:file:download") + ) + user = getattr(principal, "user", None) + user_id = str(getattr(user, "id", "") or "") + is_admin = bool( + hasattr(principal, "has") and principal.has("files:file:admin") + ) + results: list[PostboxFileReferenceRef] = [] + for request in requests: + if principal_tenant != tenant_id: + results.append(_unavailable_postbox_reference(request, "tenant_mismatch")) + continue + if not can_download or not user_id: + results.append( + _unavailable_postbox_reference( + request, + "download_permission_missing", + ) + ) + continue + reference_type = request.reference_type.strip().casefold() + version: FileVersion | None = None + if reference_type in {"file", "file_asset", "files:file"}: + results.append( + _unavailable_postbox_reference(request, "exact_version_required") + ) + continue + elif reference_type in {"file_version", "files:file_version"}: + version = session.get(FileVersion, request.reference_id) # type: ignore[attr-defined] + asset = ( + session.get(FileAsset, version.file_asset_id) # type: ignore[attr-defined] + if version is not None + else None + ) + else: + results.append( + _unavailable_postbox_reference( + request, + "unsupported_reference_type", + ) + ) + continue + if ( + asset is None + or version is None + or asset.tenant_id != tenant_id + or version.tenant_id != tenant_id + or asset.deleted_at is not None + ): + results.append(_unavailable_postbox_reference(request, "file_not_found")) + continue + try: + get_asset_for_user( + session, # type: ignore[arg-type] + tenant_id=tenant_id, + user_id=user_id, + asset_id=asset.id, + is_admin=is_admin, + ) + except FileStorageError: + results.append( + _unavailable_postbox_reference(request, "file_access_denied") + ) + continue + blob = session.get(FileBlob, version.blob_id) # type: ignore[attr-defined] + if blob is None or blob.tenant_id != tenant_id: + results.append( + _unavailable_postbox_reference(request, "file_payload_missing") + ) + continue + results.append( + PostboxFileReferenceRef( + reference_type=request.reference_type, + reference_id=request.reference_id, + available=True, + reason_code="available", + file_asset_id=asset.id, + file_version_id=version.id, + filename=version.filename_at_upload or asset.filename, + content_type=version.content_type or blob.content_type, + size_bytes=version.size_bytes, + sha256=version.checksum_sha256, + download_path=( + f"/api/v1/files/{asset.id}/versions/{version.id}/download" + ), + provenance={ + "module": "files", + "postbox_id": request.postbox_id, + "message_id": request.message_id, + "display_path": asset.display_path, + "exact_version": True, + }, + ) + ) + return tuple(results) + + +def _unavailable_postbox_reference( + request: PostboxFileReferenceRequest, + reason_code: str, +) -> PostboxFileReferenceRef: + return PostboxFileReferenceRef( + reference_type=request.reference_type, + reference_id=request.reference_id, + available=False, + reason_code=reason_code, + ) + + +def postbox_reference_capability( + context: ModuleContext, +) -> FilesPostboxReferenceService: + configure_runtime(registry=context.registry, settings=context.settings) + return FilesPostboxReferenceService() + + class FilesAccessService(FileAccessProvider): def explain_resource_provenance( self, diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index 85a87ee..e0ba82d 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -13,6 +13,7 @@ from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER from govoplan_core.core.files import ( CAPABILITY_FILES_ACCESS, CAPABILITY_FILES_ARTIFACT_STORE, + CAPABILITY_FILES_POSTBOX_REFERENCES, ) from govoplan_core.core.module_guards import ( drop_table_retirement_provider, @@ -424,11 +425,12 @@ manifest = ModuleManifest( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR, ), - optional_dependencies=("campaigns", "encryption", "records", "search"), + optional_dependencies=("campaigns", "encryption", "postbox", "records", "search"), provides_interfaces=( ModuleInterfaceProvider(name="files.access", version="0.1.6"), ModuleInterfaceProvider(name="files.campaign_attachments", version="0.1.6"), ModuleInterfaceProvider(name=CAPABILITY_FILES_ARTIFACT_STORE, version="0.1.14"), + ModuleInterfaceProvider(name=CAPABILITY_FILES_POSTBOX_REFERENCES, version="1.0.0"), ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_FILES, version="1.0.0"), ModuleInterfaceProvider(name=CAPABILITY_FORM_EVIDENCE_FILES, version="1.0.0"), ), @@ -580,6 +582,51 @@ manifest = ModuleManifest( ), ), documentation=( + DocumentationTopic( + id="files.postbox.exact-version-references", + title="Resolve exact file versions for Postbox evidence", + summary="Keep Files authorization authoritative when Postbox displays a typed file reference.", + body=( + "Files exposes an optional capability that resolves file-asset and file-version references for an already authorized Postbox message. " + "The capability returns immutable version metadata and a version-specific download route only when the current principal has Files download " + "permission and direct resource access. It does not turn Postbox access into a file share." + ), + layer="configured", + documentation_types=("user", "admin"), + audience=("administrator", "user", "campaign_manager"), + related_modules=("postbox", "audit"), + conditions=( + DocumentationCondition( + required_modules=("files",), + any_scopes=("files:file:download", "files:file:admin"), + ), + ), + links=( + DocumentationLink( + label="Files", + href="/files", + kind="runtime", + ), + DocumentationLink( + label="Files handbook", + href="govoplan-files/docs/FILES_HANDBOOK.md", + kind="repository", + ), + ), + translations={ + "de": { + "title": "Exakte Dateiversionen für Postfachnachweise auflösen", + "summary": "Die Files-Berechtigungsprüfung bleibt maßgeblich, wenn Postbox einen typisierten Dateiverweis anzeigt.", + "body": ( + "Files stellt eine optionale Capability bereit, die Datei- und Dateiversionsverweise einer bereits autorisierten Postfachnachricht auflöst. " + "Sie liefert unveränderliche Versionsmetadaten und einen versionsgenauen Download nur, wenn die aktuelle Person die Files-Downloadberechtigung " + "und direkten Ressourcenzugriff besitzt. Postfachzugriff wird nicht automatisch zu einer Dateifreigabe." + ), + } + }, + metadata={"kind": "reference", "help_contexts": ["files.postbox-reference"]}, + order=30, + ), DocumentationTopic( id="files.configuration-package.managed-storage", title="Validate deployment-managed file storage", @@ -1478,6 +1525,10 @@ manifest = ModuleManifest( "govoplan_files.backend.capabilities", fromlist=["artifact_store_capability"], ).artifact_store_capability(context), + CAPABILITY_FILES_POSTBOX_REFERENCES: lambda context: __import__( + "govoplan_files.backend.capabilities", + fromlist=["postbox_reference_capability"], + ).postbox_reference_capability(context), "files.campaign_attachments": lambda context: __import__( "govoplan_files.backend.capabilities", fromlist=["campaign_capability"] ).campaign_capability(context), diff --git a/src/govoplan_files/backend/routes/assets.py b/src/govoplan_files/backend/routes/assets.py index e7b7060..ed538b5 100644 --- a/src/govoplan_files/backend/routes/assets.py +++ b/src/govoplan_files/backend/routes/assets.py @@ -16,6 +16,7 @@ from govoplan_files.backend.storage.common import FileStorageError from govoplan_files.backend.storage.files import ( get_asset_for_user, read_asset_bytes, + read_asset_version_bytes, soft_delete_assets, ) @@ -85,6 +86,46 @@ def download_file( ) +@router.get("/{file_id}/versions/{version_id}/download") +def download_file_version( + file_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("files:file:download")), +): + try: + asset = get_asset_for_user( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + asset_id=file_id, + is_admin=_is_admin(principal), + ) + data, version, blob = read_asset_version_bytes(session, asset, version_id) + _audit_connector_event( + session, + principal, + action="files.connector.accessed", + asset=asset, + version=version, + blob=blob, + operation="download-version", + commit=True, + ) + except FileStorageError as exc: + raise _http_error(exc, not_found=True) from exc + headers = { + "Content-Disposition": _attachment_disposition( + version.filename_at_upload or asset.filename + ) + } + return StreamingResponse( + BytesIO(data), + media_type=blob.content_type or "application/octet-stream", + headers=headers, + ) + + @router.delete("/{file_id}", response_model=BulkDeleteResponse) def delete_file( file_id: str, diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index 072e1d8..c7ccfa9 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -731,6 +731,21 @@ def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVer return read_verified_blob_bytes(blob, backend=backend), version, blob +def read_asset_version_bytes( + session: Session, + asset: FileAsset, + version_id: str, +) -> tuple[bytes, FileVersion, FileBlob]: + version = session.get(FileVersion, version_id) + if version is None or version.file_asset_id != asset.id: + raise FileStorageError("File version not found") + blob = session.get(FileBlob, version.blob_id) + if blob is None or blob.tenant_id != asset.tenant_id: + raise FileStorageError("File blob not found") + backend = get_storage_backend() + return read_verified_blob_bytes(blob, backend=backend), version, blob + + def share_file( session: Session, *, diff --git a/tests/test_manifest_documentation.py b/tests/test_manifest_documentation.py index b4448bd..f9f35f6 100644 --- a/tests/test_manifest_documentation.py +++ b/tests/test_manifest_documentation.py @@ -18,6 +18,7 @@ STATIC_TOPIC_IDS = { "files.reference.snapshot-provenance-and-capabilities", "files.records.exact-version-source", "files.forms-runtime.managed-evidence", + "files.postbox.exact-version-references", "files.assurance.process-and-release-readiness", } RUNTIME_TOPIC_IDS = { diff --git a/tests/test_postbox_reference.py b/tests/test_postbox_reference.py new file mode 100644 index 0000000..da51325 --- /dev/null +++ b/tests/test_postbox_reference.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from govoplan_core.core.files import PostboxFileReferenceRequest +from govoplan_files.backend.capabilities import FilesPostboxReferenceService +from govoplan_files.backend.db.models import FileAsset, FileBlob, FileVersion +from govoplan_files.backend.storage.common import FileStorageError + + +class _Principal: + def __init__(self, *, tenant_id: str = "tenant-1", scopes: set[str] | None = None): + self.tenant_id = tenant_id + self.user = SimpleNamespace(id="user-1") + self._scopes = {"files:file:download"} if scopes is None else scopes + + def has(self, scope: str) -> bool: + return scope in self._scopes + + +class _Session: + def __init__(self, values: dict[tuple[type[object], str], object]): + self.values = values + + def get(self, model: type[object], resource_id: str): + return self.values.get((model, resource_id)) + + +def _request(reference_type: str = "file_version", reference_id: str = "version-1"): + return PostboxFileReferenceRequest( + postbox_id="postbox-1", + message_id="message-1", + reference_type=reference_type, + reference_id=reference_id, + ) + + +def _objects(): + asset = SimpleNamespace( + id="file-1", + tenant_id="tenant-1", + current_version_id="version-2", + deleted_at=None, + filename="current.txt", + display_path="Evidence/current.txt", + ) + version = SimpleNamespace( + id="version-1", + tenant_id="tenant-1", + file_asset_id="file-1", + blob_id="blob-1", + filename_at_upload="evidence.txt", + content_type="text/plain", + size_bytes=8, + checksum_sha256="a" * 64, + ) + blob = SimpleNamespace( + id="blob-1", + tenant_id="tenant-1", + content_type="text/plain", + ) + return asset, version, blob + + +class FilesPostboxReferenceTests(unittest.TestCase): + def test_resolves_the_referenced_version_instead_of_the_current_version(self) -> None: + asset, version, blob = _objects() + session = _Session( + { + (FileAsset, asset.id): asset, + (FileVersion, version.id): version, + (FileBlob, blob.id): blob, + } + ) + + with patch( + "govoplan_files.backend.capabilities.get_asset_for_user", + return_value=asset, + ): + result = FilesPostboxReferenceService().resolve_postbox_references( + session, + _Principal(), + tenant_id="tenant-1", + requests=(_request(),), + )[0] + + self.assertTrue(result.available) + self.assertEqual("version-1", result.file_version_id) + self.assertEqual( + "/api/v1/files/file-1/versions/version-1/download", + result.download_path, + ) + self.assertTrue(result.provenance["exact_version"]) + + def test_fails_closed_for_tenant_permission_and_file_access_mismatches(self) -> None: + service = FilesPostboxReferenceService() + empty = _Session({}) + + tenant_result = service.resolve_postbox_references( + empty, + _Principal(tenant_id="tenant-2"), + tenant_id="tenant-1", + requests=(_request(),), + )[0] + permission_result = service.resolve_postbox_references( + empty, + _Principal(scopes=set()), + tenant_id="tenant-1", + requests=(_request(),), + )[0] + + asset, version, blob = _objects() + populated = _Session( + { + (FileAsset, asset.id): asset, + (FileVersion, version.id): version, + (FileBlob, blob.id): blob, + } + ) + with patch( + "govoplan_files.backend.capabilities.get_asset_for_user", + side_effect=FileStorageError("denied"), + ): + access_result = service.resolve_postbox_references( + populated, + _Principal(), + tenant_id="tenant-1", + requests=(_request(),), + )[0] + + self.assertEqual("tenant_mismatch", tenant_result.reason_code) + self.assertEqual("download_permission_missing", permission_result.reason_code) + self.assertEqual("file_access_denied", access_result.reason_code) + self.assertFalse(access_result.available) + + def test_asset_reference_does_not_drift_to_a_later_current_version(self) -> None: + asset, version, blob = _objects() + session = _Session( + { + (FileAsset, asset.id): asset, + (FileVersion, version.id): version, + (FileBlob, blob.id): blob, + } + ) + + result = FilesPostboxReferenceService().resolve_postbox_references( + session, + _Principal(), + tenant_id="tenant-1", + requests=(_request("file", asset.id),), + )[0] + + self.assertFalse(result.available) + self.assertEqual("exact_version_required", result.reason_code) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_router_contract.py b/tests/test_router_contract.py index 0f99e72..0b1290f 100644 --- a/tests/test_router_contract.py +++ b/tests/test_router_contract.py @@ -55,7 +55,7 @@ class FilesRouterContractTests(unittest.TestCase): actual = self._operation_keys(router) self.assertEqual(expected, actual) - self.assertEqual(53, len(actual)) + self.assertEqual(54, len(actual)) self.assertFalse( [operation for operation, count in Counter(actual).items() if count > 1] ) @@ -107,6 +107,16 @@ class FilesRouterContractTests(unittest.TestCase): self.assertIn((("DELETE",), "/files/{file_id}/shares/{share_id}"), routes) self.assertIn((("GET",), "/files/{file_id}/share-target-options"), routes) + def test_exact_version_download_route_is_exposed(self) -> None: + routes = { + (tuple(sorted(route.methods or ())), route.path) for route in router.routes + } + + self.assertIn( + (("GET",), "/files/{file_id}/versions/{version_id}/download"), + routes, + ) + def test_file_listing_exposes_structured_property_filters(self) -> None: route = next( route