diff --git a/docs/FILES_HANDBOOK.md b/docs/FILES_HANDBOOK.md index 71ff7d7..5ae558e 100644 --- a/docs/FILES_HANDBOOK.md +++ b/docs/FILES_HANDBOOK.md @@ -648,11 +648,17 @@ public HTTP API. They must not import Files ORM models or storage helpers. | --- | --- | | `files.access` (`0.1.6`) | Explain resource access provenance for managed files, explicit folders, and virtual folders | | `files.campaign_attachments` (`0.1.6`) | Resolve managed attachment matches, prepare frozen campaign snapshots, annotate built messages, share assets with a campaign, and record/mark exact attachment use | +| `records.source.files` (`1.0.0`) | Recheck current Files access and resolve one exact, integrity-approved managed file version for Records filing | Files requires Core principal resolution and permission evaluation. Campaign is an optional dependency; when installed, Files consumes the optional `campaigns.access` interface to verify campaign existence and access. Missing optional Campaign support fails explicitly rather than bypassing the check. +Records is also optional. When enabled, the source capability returns the +requested `FileVersion` identity, path snapshot, content metadata, SHA-256, +integrity/protection state, and launch link. It rejects mutable aliases, +cross-tenant requests, missing access, and quarantined or failed blobs. Records +stores the filing decision; Files continues to own the version and bytes. ### API families diff --git a/src/govoplan_files/backend/manifest.py b/src/govoplan_files/backend/manifest.py index f324dc4..1772210 100644 --- a/src/govoplan_files/backend/manifest.py +++ b/src/govoplan_files/backend/manifest.py @@ -13,6 +13,7 @@ from govoplan_core.core.files import ( ) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard from govoplan_core.core.modules import ( + CapabilityDocumentation, DocumentationCondition, DocumentationLink, DocumentationTopic, @@ -46,6 +47,10 @@ from govoplan_files.backend.provider_state import ( remote_storage_provider_states, ) from govoplan_files.backend.search_source import create_files_search_source +from govoplan_files.backend.record_source import ( + CAPABILITY_RECORD_SOURCE_FILES, + create_files_record_source, +) register_files_change_tracking() @@ -281,11 +286,12 @@ manifest = ModuleManifest( name="Files", version="0.1.18", required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), - optional_dependencies=("campaigns", "encryption", "search"), + optional_dependencies=("campaigns", "encryption", "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_RECORD_SOURCE_FILES, version="1.0.0"), ), requires_interfaces=( ModuleInterfaceRequirement( @@ -343,6 +349,49 @@ manifest = ModuleManifest( ), ), documentation=( + DocumentationTopic( + id="files.records.exact-version-source", + title="File exact versions into an eAkte", + summary="Let Records preserve an immutable Files version reference after current access and integrity checks.", + body=( + "When Records is enabled, Files exposes exact managed FileVersion identities through the " + "provider-neutral record-source contract. Filing verifies the active tenant, current Files " + "permission and owner/share access, the immutable version identity, and the managed blob " + "integrity gate. Records receives the filename, path, version, digest, media type, size, " + "protection state, and filing launch link; Files continues to own the bytes." + ), + layer="configured", + documentation_types=("admin", "user"), + audience=("file_user", "file_manager", "records_manager", "auditor"), + related_modules=("records",), + order=40, + conditions=( + DocumentationCondition( + required_modules=("files", "records"), + required_scopes=("files:file:read",), + ), + ), + links=( + DocumentationLink(label="Files", href="/files", kind="runtime"), + DocumentationLink(label="Records", href="/records", kind="runtime"), + DocumentationLink(label="Files handbook", href="govoplan-files/docs/FILES_HANDBOOK.md", kind="repository"), + ), + metadata={ + "kind": "workflow", + "help_contexts": ["files.list", "records.action.file"], + "prerequisites": [ + "The exact file version exists and passes the current Files integrity gate.", + "You currently have Files read access and Records filing authority.", + ], + "steps": [ + "Select the exact managed file version that belongs to the institutional record.", + "Choose the destination record and state the access purpose and filing reason.", + "Confirm filing; Files rechecks current access and resolves the exact version.", + "Open the record chronology and verify the version identity and SHA-256 digest.", + ], + "outcome": "Records preserves an exact governed reference while Files remains byte authority.", + }, + ), DocumentationTopic( id="files.search.managed-content", title="Search managed files and folders", @@ -903,6 +952,14 @@ manifest = ModuleManifest( CAPABILITY_FILES_ACCESS: lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["access_capability"]).access_capability(context), CAPABILITY_FILES_ARTIFACT_STORE: lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["artifact_store_capability"]).artifact_store_capability(context), "files.campaign_attachments": lambda context: __import__("govoplan_files.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context), + CAPABILITY_RECORD_SOURCE_FILES: create_files_record_source, + }, + capability_documentation={ + CAPABILITY_RECORD_SOURCE_FILES: CapabilityDocumentation( + label="Files record source", + summary="Resolves currently authorized immutable managed file versions for Records filing.", + contract_version="1.0.0", + ), }, operational_check_providers=( OperationalCheckProviderRegistration( diff --git a/src/govoplan_files/backend/record_source.py b/src/govoplan_files/backend/record_source.py new file mode 100644 index 0000000..56d24c9 --- /dev/null +++ b/src/govoplan_files/backend/record_source.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from collections.abc import Sequence +from urllib.parse import quote + +from sqlalchemy.orm import Session + +from govoplan_core.core.records import ( + RecordContractError, + RecordSourceLocator, + RecordSourceReference, +) +from govoplan_files.backend.db.models import FileBlob, FileVersion +from govoplan_files.backend.storage.common import FileStorageError +from govoplan_files.backend.storage.files import get_asset_for_user + + +CAPABILITY_RECORD_SOURCE_FILES = "records.source.files" + + +class FilesRecordSource: + provider_id = "files" + + def resource_types(self) -> Sequence[str]: + return ("file_version",) + + def resolve( + self, + session: object, + principal: object, + *, + locator: RecordSourceLocator, + purpose: str, + ) -> RecordSourceReference: + if not isinstance(session, Session): + raise RecordContractError( + "Files record references require a database session." + ) + tenant_id = str(getattr(principal, "tenant_id", "") or "").strip() + if not tenant_id or locator.tenant_id != tenant_id: + raise RecordContractError("Files record references cannot cross tenants.") + if locator.source_module != "files" or locator.resource_type != "file_version": + raise RecordContractError("Unsupported Files record source type.") + if not str(purpose or "").strip(): + raise RecordContractError("Files record references require a purpose.") + if not hasattr(principal, "has") or not ( + principal.has("files:file:read") or principal.has("files:file:admin") + ): + raise RecordContractError("Current Files read permission is required.") + user = getattr(principal, "user", None) + user_id = str( + getattr(user, "id", "") or getattr(principal, "membership_id", "") or "" + ).strip() + if not user_id: + raise RecordContractError( + "Files record references require a tenant user principal." + ) + try: + asset = get_asset_for_user( + session, + tenant_id=tenant_id, + user_id=user_id, + asset_id=locator.resource_id, + is_admin=principal.has("files:file:admin"), + ) + except FileStorageError as exc: + raise RecordContractError(str(exc)) from exc + version_query = session.query(FileVersion).filter( + FileVersion.tenant_id == tenant_id, + FileVersion.file_asset_id == asset.id, + ) + revision = locator.source_revision.strip() + version = version_query.filter(FileVersion.id == revision).one_or_none() + if version is None: + raise RecordContractError("The exact file version does not exist.") + blob = session.get(FileBlob, version.blob_id) + if blob is None or blob.tenant_id != tenant_id: + raise RecordContractError( + "The exact file version has no managed content object." + ) + if blob.quarantined_at is not None or blob.integrity_status == "failed": + raise RecordContractError( + "The exact file version failed the current integrity gate." + ) + return RecordSourceReference( + locator=locator, + label=version.filename_at_upload, + authority_mode="external_authoritative", + content_sha256=version.checksum_sha256, + content_type=version.content_type, + size_bytes=version.size_bytes, + recorded_at=version.created_at, + launch_url=( + f"/files?fileId={quote(asset.id, safe='')}&versionId={quote(version.id, safe='')}" + ), + metadata={ + "display_path": version.display_path_at_upload, + "version_number": version.version_number, + "integrity_status": blob.integrity_status, + "protection": blob.protection_discriminator, + }, + ) + + +def create_files_record_source(_context: object) -> FilesRecordSource: + return FilesRecordSource() + + +__all__ = [ + "CAPABILITY_RECORD_SOURCE_FILES", + "FilesRecordSource", + "create_files_record_source", +] diff --git a/tests/test_manifest_documentation.py b/tests/test_manifest_documentation.py index 267b092..a23ef82 100644 --- a/tests/test_manifest_documentation.py +++ b/tests/test_manifest_documentation.py @@ -14,6 +14,7 @@ STATIC_TOPIC_IDS = { "files.reference.shared-storage-profile", "files.reference.generated-artifact-store", "files.reference.snapshot-provenance-and-capabilities", + "files.records.exact-version-source", "files.assurance.process-and-release-readiness", } RUNTIME_TOPIC_IDS = { diff --git a/tests/test_record_source.py b/tests/test_record_source.py new file mode 100644 index 0000000..15a64c2 --- /dev/null +++ b/tests/test_record_source.py @@ -0,0 +1,108 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime +from types import SimpleNamespace +import unittest +from unittest.mock import MagicMock, patch + +from sqlalchemy.orm import Session + +from govoplan_core.core.records import RecordContractError, RecordSourceLocator +from govoplan_files.backend.db.models import FileAsset, FileBlob, FileVersion +from govoplan_files.backend.record_source import FilesRecordSource + + +@dataclass +class Principal: + tenant_id: str = "tenant-1" + membership_id: str = "user-1" + user: object = field(default_factory=lambda: SimpleNamespace(id="user-1")) + + def has(self, scope: str) -> bool: + return scope in {"files:file:read"} + + +class FilesRecordSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.session = MagicMock(spec=Session) + self.asset = FileAsset( + id="asset-1", + tenant_id="tenant-1", + owner_type="user", + owner_user_id="user-1", + current_version_id="version-1", + display_path="Evidence/Decision.pdf", + filename="Decision.pdf", + ) + self.blob = FileBlob( + id="blob-1", + tenant_id="tenant-1", + storage_backend="local", + storage_key="tenant-1/blob-1", + checksum_sha256="a" * 64, + size_bytes=1024, + integrity_status="verified", + ) + self.version = FileVersion( + id="version-1", + tenant_id="tenant-1", + file_asset_id="asset-1", + blob_id="blob-1", + version_number=1, + filename_at_upload="Decision.pdf", + display_path_at_upload="Evidence/Decision.pdf", + content_type="application/pdf", + size_bytes=1024, + checksum_sha256="a" * 64, + ) + self.version.created_at = datetime.now(UTC) + version_query = self.session.query.return_value.filter.return_value + version_query.filter.return_value.one_or_none.return_value = self.version + self.session.get.return_value = self.blob + + def locator(self) -> RecordSourceLocator: + return RecordSourceLocator( + tenant_id="tenant-1", + source_module="files", + resource_type="file_version", + resource_id="asset-1", + source_revision="version-1", + ) + + def test_resolves_exact_authorized_version_and_digest(self) -> None: + with patch( + "govoplan_files.backend.record_source.get_asset_for_user", + return_value=self.asset, + ): + result = FilesRecordSource().resolve( + self.session, + Principal(), + locator=self.locator(), + purpose="document decision basis", + ) + + self.assertEqual("version-1", result.locator.source_revision) + self.assertEqual("a" * 64, result.content_sha256) + self.assertEqual("verified", result.metadata["integrity_status"]) + + def test_quarantined_version_fails_closed(self) -> None: + self.blob.quarantined_at = datetime.now(UTC) + self.session.flush() + with ( + patch( + "govoplan_files.backend.record_source.get_asset_for_user", + return_value=self.asset, + ), + self.assertRaisesRegex(RecordContractError, "integrity gate"), + ): + FilesRecordSource().resolve( + self.session, + Principal(), + locator=self.locator(), + purpose="document decision basis", + ) + + +if __name__ == "__main__": + unittest.main()