feat(files): expose exact versions for records filing
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user