feat(files): expose governed tabular content
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-21 19:29:54 +02:00
parent ce4eaefbfd
commit a8c3192c3a
7 changed files with 469 additions and 5 deletions
+234
View File
@@ -5,10 +5,20 @@ import binascii
from typing import Any
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.access import AccessDecisionProvenance, PrincipalRef
from govoplan_core.core.files import FileAccessProvider
from govoplan_core.core.files import (
ManagedTabularFile,
ManagedTabularFileAccessError,
ManagedTabularFileContent,
ManagedTabularFileNotFoundError,
ManagedTabularFileProvider,
ManagedTabularFileUnavailableError,
ManagedTabularFileValidationError,
PostboxFileReferenceRef,
PostboxFileReferenceRequest,
PostboxFileReferenceProvider,
@@ -33,6 +43,8 @@ from govoplan_files.backend.storage.files import (
create_file_asset,
current_version_and_blob,
get_asset_for_user,
list_recent_assets_for_user,
read_asset_version_bytes,
sync_file_asset_from_source,
)
from govoplan_files.backend.storage.paths import normalize_folder
@@ -420,6 +432,228 @@ def access_capability(context: ModuleContext) -> FilesAccessService:
return FilesAccessService()
class FilesManagedTabularFileService(ManagedTabularFileProvider):
"""Expose authorized immutable CSV/XLSX versions to optional consumers."""
def list_tabular_files(
self,
session: object,
principal: object,
*,
query: str = "",
limit: int = 100,
) -> tuple[ManagedTabularFile, ...]:
db, api_principal, user_id = _tabular_context(
session,
principal,
required_scope="files:file:read",
)
normalized_query = str(query or "").strip().casefold()
requested_limit = max(1, min(int(limit), 100))
assets = list_recent_assets_for_user(
db,
tenant_id=api_principal.tenant_id,
user_id=user_id,
limit=min(500, requested_limit * 5),
is_admin=has_scope(api_principal, "files:file:admin"),
)
results: list[ManagedTabularFile] = []
for asset in assets:
if normalized_query and normalized_query not in (
f"{asset.filename} {asset.display_path} {asset.description or ''}"
).casefold():
continue
try:
version, blob = current_version_and_blob(db, asset)
except FileStorageError:
continue
if not _is_tabular_file(version.filename_at_upload, version.content_type):
continue
results.append(_managed_tabular_file(asset, version, blob))
if len(results) >= requested_limit:
break
return tuple(results)
def get_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str | None = None,
) -> ManagedTabularFile | None:
db, api_principal, user_id = _tabular_context(
session,
principal,
required_scope="files:file:read",
)
try:
asset = get_asset_for_user(
db,
tenant_id=api_principal.tenant_id,
user_id=user_id,
asset_id=file_asset_id,
is_admin=has_scope(api_principal, "files:file:admin"),
)
except FileStorageError:
return None
if file_version_id:
version = db.get(FileVersion, file_version_id)
if (
version is None
or version.file_asset_id != asset.id
or version.tenant_id != api_principal.tenant_id
):
return None
blob = db.get(FileBlob, version.blob_id)
if blob is None or blob.tenant_id != api_principal.tenant_id:
return None
else:
try:
version, blob = current_version_and_blob(db, asset)
except FileStorageError as exc:
raise ManagedTabularFileUnavailableError(
"Managed file version metadata is unavailable."
) from exc
if not _is_tabular_file(version.filename_at_upload, version.content_type):
return None
return _managed_tabular_file(asset, version, blob)
def read_tabular_file(
self,
session: object,
principal: object,
*,
file_asset_id: str,
file_version_id: str,
max_bytes: int,
) -> ManagedTabularFileContent:
db, api_principal, user_id = _tabular_context(
session,
principal,
required_scope="files:file:download",
)
try:
asset = get_asset_for_user(
db,
tenant_id=api_principal.tenant_id,
user_id=user_id,
asset_id=file_asset_id,
is_admin=has_scope(api_principal, "files:file:admin"),
)
except FileStorageError as exc:
raise ManagedTabularFileNotFoundError(
"Managed tabular file not found."
) from exc
metadata = self.get_tabular_file(
db,
api_principal,
file_asset_id=asset.id,
file_version_id=file_version_id,
)
if metadata is None:
raise ManagedTabularFileNotFoundError(
"Managed tabular file version not found."
)
effective_max = max(1, int(max_bytes))
if metadata.size_bytes > effective_max:
raise ManagedTabularFileValidationError(
f"Managed tabular files are limited to {effective_max:,} bytes for this operation."
)
try:
payload, version, blob = read_asset_version_bytes(
db,
asset,
file_version_id,
)
except FileStorageError as exc:
raise ManagedTabularFileUnavailableError(
"Managed tabular file content is unavailable or failed integrity verification."
) from exc
if len(payload) > effective_max:
raise ManagedTabularFileValidationError(
f"Managed tabular files are limited to {effective_max:,} bytes for this operation."
)
result = _managed_tabular_file(asset, version, blob)
audit_from_principal(
db,
api_principal,
action="files.tabular_content.read",
object_type="file_version",
object_id=version.id,
details={
"file_asset_id": asset.id,
"size_bytes": version.size_bytes,
"checksum_sha256": version.checksum_sha256,
"consumer": "tabular_content",
},
)
return ManagedTabularFileContent(file=result, payload=payload)
def managed_tabular_file_capability(
context: ModuleContext,
) -> FilesManagedTabularFileService:
configure_runtime(registry=context.registry, settings=context.settings)
return FilesManagedTabularFileService()
def _tabular_context(
session: object,
principal: object,
*,
required_scope: str,
) -> tuple[Session, ApiPrincipal, str]:
if not isinstance(session, Session):
raise TypeError("Managed tabular file access requires a SQLAlchemy session.")
if not isinstance(principal, ApiPrincipal):
raise ManagedTabularFileAccessError(
"Managed tabular file access requires a tenant API principal."
)
if not (
has_scope(principal, required_scope)
or has_scope(principal, "files:file:admin")
):
raise ManagedTabularFileAccessError(
f"Managed tabular file access requires {required_scope}."
)
user_id = str(getattr(principal.user, "id", "") or "").strip()
if not principal.tenant_id or not user_id:
raise ManagedTabularFileAccessError(
"Managed tabular file access requires a tenant user principal."
)
return session, principal, user_id
def _managed_tabular_file(
asset: FileAsset,
version: FileVersion,
blob: FileBlob,
) -> ManagedTabularFile:
return ManagedTabularFile(
file_asset_id=asset.id,
file_version_id=version.id,
filename=version.filename_at_upload or asset.filename,
display_path=version.display_path_at_upload or asset.display_path,
content_type=version.content_type or blob.content_type,
size_bytes=version.size_bytes,
sha256=version.checksum_sha256,
updated_at=version.created_at,
current_version=asset.current_version_id == version.id,
)
def _is_tabular_file(filename: str, content_type: str | None) -> bool:
normalized_name = str(filename or "").strip().casefold()
normalized_type = str(content_type or "").split(";", 1)[0].strip().casefold()
return normalized_name.endswith((".csv", ".xlsx")) or normalized_type in {
"text/csv",
"application/csv",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
}
def virtual_folder_resource_id(*, tenant_id: str, owner_type: str, owner_id: str, path: str) -> str:
normalized_path = normalize_folder(path)
encoded_path = base64.urlsafe_b64encode(normalized_path.encode("utf-8")).decode("ascii").rstrip("=")
+55 -1
View File
@@ -14,6 +14,7 @@ from govoplan_core.core.files import (
CAPABILITY_FILES_ACCESS,
CAPABILITY_FILES_ARTIFACT_STORE,
CAPABILITY_FILES_POSTBOX_REFERENCES,
CAPABILITY_FILES_TABULAR_CONTENT,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
@@ -451,7 +452,7 @@ def _dsar_provider(context: ModuleContext) -> object:
manifest = ModuleManifest(
id="files",
name="Files",
version="0.1.18",
version="0.1.19",
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -462,6 +463,7 @@ manifest = ModuleManifest(
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_FILES_TABULAR_CONTENT, 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=FILES_DSAR_CAPABILITY, version="0.1.0"),
@@ -616,6 +618,49 @@ manifest = ModuleManifest(
),
),
documentation=(
DocumentationTopic(
id="files.tabular-content",
title="Use managed CSV and XLSX versions as governed data sources",
summary="Expose exact, authorized managed file versions to Connectors without bypassing Files controls.",
body=(
"Files lists only CSV and XLSX assets visible to the current tenant user. "
"Opening content additionally requires Files download permission and an exact immutable version reference. "
"Size ceilings are checked before storage access; checksum verification, quarantine, encryption envelopes, deletion, ownership, and shares remain authoritative in Files. "
"Connectors receives file metadata and verified bytes through the Core capability and never imports Files models, storage keys, or encryption internals. "
"A newer current version does not silently replace a pinned source version; Connectors reports the version change for explicit review."
),
layer="available",
documentation_types=("admin", "user"),
audience=("administrator", "operator", "power_user"),
related_modules=("connectors", "datasources", "dataflow"),
conditions=(
DocumentationCondition(
required_modules=("files", "connectors"),
required_scopes=("files:file:read", "files:file:download"),
),
),
links=(
DocumentationLink(
label="Files",
href="/files",
kind="runtime",
),
DocumentationLink(
label="Files handbook",
href="govoplan-files/docs/FILES_HANDBOOK.md",
kind="repository",
),
),
metadata={
"kind": "guide",
"consequence_classes": {
"pin_exact_version": "A source keeps its reviewed immutable file version until explicitly refreshed.",
"preserve_file_controls": "Files access, integrity, encryption, retention, and legal-hold controls remain authoritative.",
"fail_closed": "Unavailable, oversized, quarantined, or unauthorized content is not parsed or previewed.",
},
},
order=29,
),
DocumentationTopic(
id="files.postbox.exact-version-references",
title="Resolve exact file versions for Postbox evidence",
@@ -1702,6 +1747,10 @@ manifest = ModuleManifest(
"govoplan_files.backend.capabilities",
fromlist=["postbox_reference_capability"],
).postbox_reference_capability(context),
CAPABILITY_FILES_TABULAR_CONTENT: lambda context: __import__(
"govoplan_files.backend.capabilities",
fromlist=["managed_tabular_file_capability"],
).managed_tabular_file_capability(context),
"files.campaign_attachments": lambda context: __import__(
"govoplan_files.backend.capabilities", fromlist=["campaign_capability"]
).campaign_capability(context),
@@ -1710,6 +1759,11 @@ manifest = ModuleManifest(
FILES_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_FILES_TABULAR_CONTENT: CapabilityDocumentation(
label="Managed tabular file content",
summary="Lists and opens authorized exact CSV/XLSX versions with Files integrity and access controls.",
contract_version="1.0.0",
),
CAPABILITY_RECORD_SOURCE_FILES: CapabilityDocumentation(
label="Files record source",
summary="Resolves currently authorized immutable managed file versions for Records filing.",