feat(files): expose exact versions for records filing

This commit is contained in:
2026-08-06 01:43:27 +02:00
parent 3a3360650f
commit 60c3d14f08
5 changed files with 286 additions and 1 deletions
+6
View File
@@ -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.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 | | `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 Files requires Core principal resolution and permission evaluation. Campaign is
an optional dependency; when installed, Files consumes the optional an optional dependency; when installed, Files consumes the optional
`campaigns.access` interface to verify campaign existence and access. Missing `campaigns.access` interface to verify campaign existence and access. Missing
optional Campaign support fails explicitly rather than bypassing the check. 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 ### API families
+58 -1
View File
@@ -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.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition, DocumentationCondition,
DocumentationLink, DocumentationLink,
DocumentationTopic, DocumentationTopic,
@@ -46,6 +47,10 @@ from govoplan_files.backend.provider_state import (
remote_storage_provider_states, remote_storage_provider_states,
) )
from govoplan_files.backend.search_source import create_files_search_source 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() register_files_change_tracking()
@@ -281,11 +286,12 @@ manifest = ModuleManifest(
name="Files", name="Files",
version="0.1.18", version="0.1.18",
required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
optional_dependencies=("campaigns", "encryption", "search"), optional_dependencies=("campaigns", "encryption", "records", "search"),
provides_interfaces=( provides_interfaces=(
ModuleInterfaceProvider(name="files.access", version="0.1.6"), ModuleInterfaceProvider(name="files.access", version="0.1.6"),
ModuleInterfaceProvider(name="files.campaign_attachments", 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_ARTIFACT_STORE, version="0.1.14"),
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_FILES, version="1.0.0"),
), ),
requires_interfaces=( requires_interfaces=(
ModuleInterfaceRequirement( ModuleInterfaceRequirement(
@@ -343,6 +349,49 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( 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( DocumentationTopic(
id="files.search.managed-content", id="files.search.managed-content",
title="Search managed files and folders", 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_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), 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), "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=( operational_check_providers=(
OperationalCheckProviderRegistration( OperationalCheckProviderRegistration(
+113
View File
@@ -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",
]
+1
View File
@@ -14,6 +14,7 @@ STATIC_TOPIC_IDS = {
"files.reference.shared-storage-profile", "files.reference.shared-storage-profile",
"files.reference.generated-artifact-store", "files.reference.generated-artifact-store",
"files.reference.snapshot-provenance-and-capabilities", "files.reference.snapshot-provenance-and-capabilities",
"files.records.exact-version-source",
"files.assurance.process-and-release-readiness", "files.assurance.process-and-release-readiness",
} }
RUNTIME_TOPIC_IDS = { RUNTIME_TOPIC_IDS = {
+108
View File
@@ -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()