feat: expose immutable postbox file references

This commit is contained in:
2026-08-07 14:53:38 +02:00
parent cb37079465
commit 1e511e4c15
7 changed files with 417 additions and 3 deletions
+137 -1
View File
@@ -9,14 +9,18 @@ from sqlalchemy import or_
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.files import FileAccessProvider from govoplan_core.core.files import FileAccessProvider
from govoplan_core.core.files import ( from govoplan_core.core.files import (
PostboxFileReferenceRef,
PostboxFileReferenceRequest,
PostboxFileReferenceProvider,
ManagedArtifactRef, ManagedArtifactRef,
ManagedArtifactStore, ManagedArtifactStore,
ManagedArtifactWriteRequest, ManagedArtifactWriteRequest,
) )
from govoplan_core.core.modules import ModuleContext from govoplan_core.core.modules import ModuleContext
from govoplan_core.security.module_permissions import scopes_grant_compatible 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.runtime import configure_runtime
from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.campaign_attachments import ( from govoplan_files.backend.storage.campaign_attachments import (
annotate_built_messages_with_managed_files, annotate_built_messages_with_managed_files,
managed_match_payloads, 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 ( from govoplan_files.backend.storage.files import (
create_file_asset, create_file_asset,
current_version_and_blob, current_version_and_blob,
get_asset_for_user,
sync_file_asset_from_source, sync_file_asset_from_source,
) )
from govoplan_files.backend.storage.paths import normalize_folder from govoplan_files.backend.storage.paths import normalize_folder
@@ -146,6 +151,137 @@ def artifact_store_capability(context: ModuleContext) -> FilesArtifactStore:
return 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): class FilesAccessService(FileAccessProvider):
def explain_resource_provenance( def explain_resource_provenance(
self, self,
+52 -1
View File
@@ -13,6 +13,7 @@ from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
from govoplan_core.core.files import ( from govoplan_core.core.files import (
CAPABILITY_FILES_ACCESS, CAPABILITY_FILES_ACCESS,
CAPABILITY_FILES_ARTIFACT_STORE, CAPABILITY_FILES_ARTIFACT_STORE,
CAPABILITY_FILES_POSTBOX_REFERENCES,
) )
from govoplan_core.core.module_guards import ( from govoplan_core.core.module_guards import (
drop_table_retirement_provider, drop_table_retirement_provider,
@@ -424,11 +425,12 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PERMISSION_EVALUATOR,
), ),
optional_dependencies=("campaigns", "encryption", "records", "search"), optional_dependencies=("campaigns", "encryption", "postbox", "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_FILES_POSTBOX_REFERENCES, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_FILES, version="1.0.0"), ModuleInterfaceProvider(name=CAPABILITY_RECORD_SOURCE_FILES, version="1.0.0"),
ModuleInterfaceProvider(name=CAPABILITY_FORM_EVIDENCE_FILES, version="1.0.0"), ModuleInterfaceProvider(name=CAPABILITY_FORM_EVIDENCE_FILES, version="1.0.0"),
), ),
@@ -580,6 +582,51 @@ manifest = ModuleManifest(
), ),
), ),
documentation=( 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( DocumentationTopic(
id="files.configuration-package.managed-storage", id="files.configuration-package.managed-storage",
title="Validate deployment-managed file storage", title="Validate deployment-managed file storage",
@@ -1478,6 +1525,10 @@ manifest = ModuleManifest(
"govoplan_files.backend.capabilities", "govoplan_files.backend.capabilities",
fromlist=["artifact_store_capability"], fromlist=["artifact_store_capability"],
).artifact_store_capability(context), ).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__( "files.campaign_attachments": lambda context: __import__(
"govoplan_files.backend.capabilities", fromlist=["campaign_capability"] "govoplan_files.backend.capabilities", fromlist=["campaign_capability"]
).campaign_capability(context), ).campaign_capability(context),
@@ -16,6 +16,7 @@ from govoplan_files.backend.storage.common import FileStorageError
from govoplan_files.backend.storage.files import ( from govoplan_files.backend.storage.files import (
get_asset_for_user, get_asset_for_user,
read_asset_bytes, read_asset_bytes,
read_asset_version_bytes,
soft_delete_assets, 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) @router.delete("/{file_id}", response_model=BulkDeleteResponse)
def delete_file( def delete_file(
file_id: str, file_id: str,
@@ -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 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( def share_file(
session: Session, session: Session,
*, *,
+1
View File
@@ -18,6 +18,7 @@ STATIC_TOPIC_IDS = {
"files.reference.snapshot-provenance-and-capabilities", "files.reference.snapshot-provenance-and-capabilities",
"files.records.exact-version-source", "files.records.exact-version-source",
"files.forms-runtime.managed-evidence", "files.forms-runtime.managed-evidence",
"files.postbox.exact-version-references",
"files.assurance.process-and-release-readiness", "files.assurance.process-and-release-readiness",
} }
RUNTIME_TOPIC_IDS = { RUNTIME_TOPIC_IDS = {
+160
View File
@@ -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()
+11 -1
View File
@@ -55,7 +55,7 @@ class FilesRouterContractTests(unittest.TestCase):
actual = self._operation_keys(router) actual = self._operation_keys(router)
self.assertEqual(expected, actual) self.assertEqual(expected, actual)
self.assertEqual(53, len(actual)) self.assertEqual(54, len(actual))
self.assertFalse( self.assertFalse(
[operation for operation, count in Counter(actual).items() if count > 1] [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((("DELETE",), "/files/{file_id}/shares/{share_id}"), routes)
self.assertIn((("GET",), "/files/{file_id}/share-target-options"), 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: def test_file_listing_exposes_structured_property_filters(self) -> None:
route = next( route = next(
route route