feat(files): expose governed tabular content
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "webui/src/index.ts",
|
"main": "webui/src/index.ts",
|
||||||
|
|||||||
+2
-2
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "govoplan-files"
|
name = "govoplan-files"
|
||||||
version = "0.1.18"
|
version = "0.1.19"
|
||||||
description = "GovOPlaN files module with backend and WebUI integration."
|
description = "GovOPlaN files module with backend and WebUI integration."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
authors = [{ name = "GovOPlaN" }]
|
authors = [{ name = "GovOPlaN" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"govoplan-core>=0.1.18",
|
"govoplan-core>=0.1.19",
|
||||||
"defusedxml>=0.7,<1",
|
"defusedxml>=0.7,<1",
|
||||||
"pyzipper>=0.3.6,<1",
|
"pyzipper>=0.3.6,<1",
|
||||||
"python-multipart>=0.0.31,<1",
|
"python-multipart>=0.0.31,<1",
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ import binascii
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import or_
|
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.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 (
|
||||||
|
ManagedTabularFile,
|
||||||
|
ManagedTabularFileAccessError,
|
||||||
|
ManagedTabularFileContent,
|
||||||
|
ManagedTabularFileNotFoundError,
|
||||||
|
ManagedTabularFileProvider,
|
||||||
|
ManagedTabularFileUnavailableError,
|
||||||
|
ManagedTabularFileValidationError,
|
||||||
PostboxFileReferenceRef,
|
PostboxFileReferenceRef,
|
||||||
PostboxFileReferenceRequest,
|
PostboxFileReferenceRequest,
|
||||||
PostboxFileReferenceProvider,
|
PostboxFileReferenceProvider,
|
||||||
@@ -33,6 +43,8 @@ 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,
|
get_asset_for_user,
|
||||||
|
list_recent_assets_for_user,
|
||||||
|
read_asset_version_bytes,
|
||||||
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
|
||||||
@@ -420,6 +432,228 @@ def access_capability(context: ModuleContext) -> FilesAccessService:
|
|||||||
return 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:
|
def virtual_folder_resource_id(*, tenant_id: str, owner_type: str, owner_id: str, path: str) -> str:
|
||||||
normalized_path = normalize_folder(path)
|
normalized_path = normalize_folder(path)
|
||||||
encoded_path = base64.urlsafe_b64encode(normalized_path.encode("utf-8")).decode("ascii").rstrip("=")
|
encoded_path = base64.urlsafe_b64encode(normalized_path.encode("utf-8")).decode("ascii").rstrip("=")
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from govoplan_core.core.files import (
|
|||||||
CAPABILITY_FILES_ACCESS,
|
CAPABILITY_FILES_ACCESS,
|
||||||
CAPABILITY_FILES_ARTIFACT_STORE,
|
CAPABILITY_FILES_ARTIFACT_STORE,
|
||||||
CAPABILITY_FILES_POSTBOX_REFERENCES,
|
CAPABILITY_FILES_POSTBOX_REFERENCES,
|
||||||
|
CAPABILITY_FILES_TABULAR_CONTENT,
|
||||||
)
|
)
|
||||||
from govoplan_core.core.module_guards import (
|
from govoplan_core.core.module_guards import (
|
||||||
drop_table_retirement_provider,
|
drop_table_retirement_provider,
|
||||||
@@ -451,7 +452,7 @@ def _dsar_provider(context: ModuleContext) -> object:
|
|||||||
manifest = ModuleManifest(
|
manifest = ModuleManifest(
|
||||||
id="files",
|
id="files",
|
||||||
name="Files",
|
name="Files",
|
||||||
version="0.1.18",
|
version="0.1.19",
|
||||||
required_capabilities=(
|
required_capabilities=(
|
||||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||||
@@ -462,6 +463,7 @@ manifest = ModuleManifest(
|
|||||||
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_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_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"),
|
||||||
ModuleInterfaceProvider(name=FILES_DSAR_CAPABILITY, version="0.1.0"),
|
ModuleInterfaceProvider(name=FILES_DSAR_CAPABILITY, version="0.1.0"),
|
||||||
@@ -616,6 +618,49 @@ manifest = ModuleManifest(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
documentation=(
|
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(
|
DocumentationTopic(
|
||||||
id="files.postbox.exact-version-references",
|
id="files.postbox.exact-version-references",
|
||||||
title="Resolve exact file versions for Postbox evidence",
|
title="Resolve exact file versions for Postbox evidence",
|
||||||
@@ -1702,6 +1747,10 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_files.backend.capabilities",
|
"govoplan_files.backend.capabilities",
|
||||||
fromlist=["postbox_reference_capability"],
|
fromlist=["postbox_reference_capability"],
|
||||||
).postbox_reference_capability(context),
|
).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__(
|
"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),
|
||||||
@@ -1710,6 +1759,11 @@ manifest = ModuleManifest(
|
|||||||
FILES_DSAR_CAPABILITY: _dsar_provider,
|
FILES_DSAR_CAPABILITY: _dsar_provider,
|
||||||
},
|
},
|
||||||
capability_documentation={
|
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(
|
CAPABILITY_RECORD_SOURCE_FILES: CapabilityDocumentation(
|
||||||
label="Files record source",
|
label="Files record source",
|
||||||
summary="Resolves currently authorized immutable managed file versions for Records filing.",
|
summary="Resolves currently authorized immutable managed file versions for Records filing.",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ STATIC_TOPIC_IDS = {
|
|||||||
"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.postbox.exact-version-references",
|
||||||
|
"files.tabular-content",
|
||||||
"files.assurance.process-and-release-readiness",
|
"files.assurance.process-and-release-readiness",
|
||||||
}
|
}
|
||||||
RUNTIME_TOPIC_IDS = {
|
RUNTIME_TOPIC_IDS = {
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from govoplan_core.auth import ApiPrincipal
|
||||||
|
from govoplan_core.core.access import PrincipalRef
|
||||||
|
from govoplan_core.core.files import (
|
||||||
|
ManagedTabularFileAccessError,
|
||||||
|
ManagedTabularFileValidationError,
|
||||||
|
)
|
||||||
|
from govoplan_files.backend.capabilities import FilesManagedTabularFileService
|
||||||
|
from govoplan_files.backend.storage.common import FileStorageError
|
||||||
|
|
||||||
|
|
||||||
|
def principal(
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
*,
|
||||||
|
scopes: tuple[str, ...] = (
|
||||||
|
"files:file:read",
|
||||||
|
"files:file:download",
|
||||||
|
"files:file:admin",
|
||||||
|
),
|
||||||
|
) -> ApiPrincipal:
|
||||||
|
return ApiPrincipal(
|
||||||
|
principal=PrincipalRef(
|
||||||
|
account_id="account-1",
|
||||||
|
membership_id="membership-1",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
scopes=frozenset(scopes),
|
||||||
|
),
|
||||||
|
account=object(),
|
||||||
|
user=SimpleNamespace(id="user-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FilesManagedTabularContentTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||||
|
self.session = sessionmaker(bind=self.engine)()
|
||||||
|
self.blob = SimpleNamespace(
|
||||||
|
id="blob-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
storage_backend="local",
|
||||||
|
storage_key="tenants/tenant-1/files/blob-1",
|
||||||
|
checksum_sha256="a" * 64,
|
||||||
|
size_bytes=8,
|
||||||
|
content_type="text/csv",
|
||||||
|
integrity_status="verified",
|
||||||
|
)
|
||||||
|
self.asset = SimpleNamespace(
|
||||||
|
id="asset-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
owner_type="user",
|
||||||
|
owner_user_id="user-1",
|
||||||
|
current_version_id="version-1",
|
||||||
|
display_path="Imports/source.csv",
|
||||||
|
filename="source.csv",
|
||||||
|
description=None,
|
||||||
|
updated_at=datetime(2026, 8, 21, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
self.version = SimpleNamespace(
|
||||||
|
id="version-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
file_asset_id="asset-1",
|
||||||
|
blob_id="blob-1",
|
||||||
|
version_number=1,
|
||||||
|
filename_at_upload="source.csv",
|
||||||
|
display_path_at_upload="Imports/source.csv",
|
||||||
|
content_type="text/csv",
|
||||||
|
size_bytes=8,
|
||||||
|
checksum_sha256="a" * 64,
|
||||||
|
created_by_user_id="user-1",
|
||||||
|
created_at=datetime(2026, 8, 21, tzinfo=UTC),
|
||||||
|
)
|
||||||
|
self.provider = FilesManagedTabularFileService()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.session.close()
|
||||||
|
self.engine.dispose()
|
||||||
|
|
||||||
|
def test_lists_and_reads_only_exact_authorized_tabular_versions(self) -> None:
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_files.backend.capabilities.list_recent_assets_for_user",
|
||||||
|
return_value=[self.asset],
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_files.backend.capabilities.current_version_and_blob",
|
||||||
|
return_value=(self.version, self.blob),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_files.backend.capabilities.get_asset_for_user",
|
||||||
|
return_value=self.asset,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
self.session,
|
||||||
|
"get",
|
||||||
|
side_effect=lambda model, object_id: {
|
||||||
|
"version-1": self.version,
|
||||||
|
"blob-1": self.blob,
|
||||||
|
}.get(object_id),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_files.backend.capabilities.read_asset_version_bytes",
|
||||||
|
return_value=(b"id\n1\n", self.version, self.blob),
|
||||||
|
) as read_bytes,
|
||||||
|
patch("govoplan_files.backend.capabilities.audit_from_principal"),
|
||||||
|
):
|
||||||
|
listed = self.provider.list_tabular_files(self.session, principal())
|
||||||
|
result = self.provider.read_tabular_file(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
file_asset_id="asset-1",
|
||||||
|
file_version_id="version-1",
|
||||||
|
max_bytes=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(("version-1",), tuple(item.file_version_id for item in listed))
|
||||||
|
self.assertEqual(b"id\n1\n", result.payload)
|
||||||
|
self.assertTrue(result.file.current_version)
|
||||||
|
read_bytes.assert_called_once()
|
||||||
|
|
||||||
|
def test_tenant_scope_permissions_and_pre_read_size_limit_fail_closed(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"govoplan_files.backend.capabilities.get_asset_for_user",
|
||||||
|
side_effect=FileStorageError("File not found"),
|
||||||
|
):
|
||||||
|
self.assertIsNone(
|
||||||
|
self.provider.get_tabular_file(
|
||||||
|
self.session,
|
||||||
|
principal("tenant-2"),
|
||||||
|
file_asset_id="asset-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.assertRaises(ManagedTabularFileAccessError):
|
||||||
|
self.provider.list_tabular_files(
|
||||||
|
self.session,
|
||||||
|
principal(scopes=()),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"govoplan_files.backend.capabilities.get_asset_for_user",
|
||||||
|
return_value=self.asset,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
self.session,
|
||||||
|
"get",
|
||||||
|
side_effect=lambda model, object_id: {
|
||||||
|
"version-1": self.version,
|
||||||
|
"blob-1": self.blob,
|
||||||
|
}.get(object_id),
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"govoplan_files.backend.capabilities.read_asset_version_bytes"
|
||||||
|
) as read_bytes,
|
||||||
|
self.assertRaises(ManagedTabularFileValidationError),
|
||||||
|
):
|
||||||
|
self.provider.read_tabular_file(
|
||||||
|
self.session,
|
||||||
|
principal(),
|
||||||
|
file_asset_id="asset-1",
|
||||||
|
file_version_id="version-1",
|
||||||
|
max_bytes=4,
|
||||||
|
)
|
||||||
|
read_bytes.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@govoplan/files-webui",
|
"name": "@govoplan/files-webui",
|
||||||
"version": "0.1.18",
|
"version": "0.1.19",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user