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.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,
+52 -1
View File
@@ -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),
@@ -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,
@@ -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,
*,